diff --git a/.clj-kondo/etaoin/etaoin/config.edn b/.clj-kondo/etaoin/etaoin/config.edn new file mode 100644 index 0000000..d888f26 --- /dev/null +++ b/.clj-kondo/etaoin/etaoin/config.edn @@ -0,0 +1,32 @@ +{:linters + {:etaoin/with-x-action {:level :error} + :etaoin/binding-sym {:level :error} + :etaoin/opts-map-type {:level :error} + :etaoin/opts-map-pos {:level :error} + :etaoin/empty-opts {:level :warning}} + :hooks + {:analyze-call + {etaoin.api/with-chrome etaoin.api/with-browser + etaoin.api/with-chrome-headless etaoin.api/with-browser + etaoin.api/with-firefox etaoin.api/with-browser + etaoin.api/with-firefox-headless etaoin.api/with-browser + etaoin.api/with-edge etaoin.api/with-browser + etaoin.api/with-edge-headless etaoin.api/with-browser + etaoin.api/with-phantom etaoin.api/with-browser + etaoin.api/with-safari etaoin.api/with-browser + + etaoin.api/with-driver etaoin.api/with-driver + etaoin.api/with-key-down etaoin.api/with-key-down + etaoin.api/with-pointer-btn-down etaoin.api/with-pointer-btn-down + + ;; api2 moves to a more conventional let-ish vector syntax + etaoin.api2/with-chrome etaoin.api2/with-browser + etaoin.api2/with-chrome-headless etaoin.api2/with-browser + etaoin.api2/with-edge etaoin.api2/with-browser + etaoin.api2/with-edge-headless etaoin.api2/with-browser + etaoin.api2/with-firefox etaoin.api2/with-browser + etaoin.api2/with-firefox-headless etaoin.api2/with-browser + etaoin.api2/with-phantom etaoin.api2/with-browser + etaoin.api2/with-safari etaoin.api2/with-browser}} + :lint-as + {etaoin.api/with-pointer-left-btn-down clojure.core/->}} diff --git a/.clj-kondo/etaoin/etaoin/etaoin/api.clj_kondo b/.clj-kondo/etaoin/etaoin/etaoin/api.clj_kondo new file mode 100644 index 0000000..650b8b3 --- /dev/null +++ b/.clj-kondo/etaoin/etaoin/etaoin/api.clj_kondo @@ -0,0 +1,113 @@ +(ns etaoin.api + (:require [clj-kondo.hooks-api :as api] + [etaoin.hooks-util :as h])) + +(defn- nil-node? [n] + (and (api/token-node? n) (nil? (api/sexpr n)))) + +(defn- with-bound-arg [node arg-offset] + (let [macro-args (rest (:children node)) + leading-args (take arg-offset macro-args) + interesting-args (drop arg-offset macro-args) + [opts binding-sym & body] (if (h/symbol-node? (second interesting-args)) + interesting-args + (cons nil interesting-args))] + ;; if the user has specified nil or {} for options we can suggest that is not necessary + (when (and opts + (or (and (api/map-node? opts) (not (seq (:children opts)))) + (nil-node? opts))) + (api/reg-finding! (assoc (meta opts) + :message "Empty or nil driver options can be omitted" + :type :etaoin/empty-opts))) + + (cond + (not (h/symbol-node? binding-sym)) + ;; it makes more sense here to report on the incoming node position instead of what we expect to be the binding-sym + (api/reg-finding! (assoc (meta node) + :message "Expected binding symbol for driver" + :type :etaoin/binding-sym)) + + ;; we don't want to explicitly expect a map because the map might come from + ;; an evalution, but we can do some checks + (and opts ;; we'll assume a list-node is a function call (eval) + (not (nil-node? opts)) ;; nil is actually old-v1 syntax acceptable + (not (api/list-node? opts)) ;; some fn call + (not (h/symbol-node? opts)) ;; from a binding maybe + ;; there are other eval node types... @(something) for example... maybe we'll add them in if folks ask + (not (api/map-node? opts))) + ;; we can report directly on the opts node, because at this point we know we expect + ;; this arg position to be an opts map + (api/reg-finding! (assoc (meta opts) + :message "When specified, opts should be a map" + :type :etaoin/opts-map-type)) + + ;; one last nicety, if the first form in body is a map, the user has accidentally swapped + ;; binding and opt args + (api/map-node? (first body)) + (api/reg-finding! (assoc (meta (first body)) + :message "When specified, opts must appear before binding symbol" + :type :etaoin/opts-map-pos)) + + :else + {:node (api/list-node + (list* + (api/token-node 'let) + ;; simulate the effect, macro is creating a new thing (driver for example) + ;; via binding it. I don't think the bound value matters for the linting process + (api/vector-node [binding-sym (api/map-node [])]) + ;; reference the other args so that they are not linted as unused + (api/vector-node leading-args) + opts ;; might be a binding, so ref it too + body))}))) + +(defn- with-x-down + "This is somewhat of a maybe an odd duck. + I think it is assumed to be used within a threading macro. + And itself employs a threadfirst macro. + So each body form need to have an action (dummy or not) threaded into it." + [node] + (let [macro-args (rest (:children node)) + [input x & body] macro-args + dummy-action (api/map-node [])] + {:node (api/list-node + (apply list* + (api/token-node 'do) + ;; reference x and input just in case they contain something lint-relevant + x input + ;; dump the body, threading a dummy action in as first arg + (map (fn [body-form] + (cond + ;; not certain this is absolutely what we want, but maybe close enough + (h/symbol-node? body-form) (api/list-node (list* body-form dummy-action)) + (api/list-node? body-form) (let [children (:children body-form)] + (assoc body-form :children (apply list* + (first children) + dummy-action + (rest children)))) + :else + (api/reg-finding! (assoc (meta body-form) + :message "expected to be threaded through an action" + :type :etaoin/with-x-action)))) + body)))})) + +(defn with-browser + "Covers etaoin.api/with-chrome and all its variants + [opt? bind & body]" + [{:keys [node]}] + (with-bound-arg node 0)) + +(defn with-driver + "Very similar to with-browser but bound arg is 1 deeper + [type opt? bind & body]" + [{:keys [node]}] + (with-bound-arg node 1)) + +(defn with-key-down + "[input key & body]" + [{:keys [node]}] + (with-x-down node)) + +(defn with-pointer-btn-down + "[input button & body]" + [{:keys [node]}] + (with-x-down node)) diff --git a/.clj-kondo/etaoin/etaoin/etaoin/api2.clj_kondo b/.clj-kondo/etaoin/etaoin/etaoin/api2.clj_kondo new file mode 100644 index 0000000..f5655c3 --- /dev/null +++ b/.clj-kondo/etaoin/etaoin/etaoin/api2.clj_kondo @@ -0,0 +1,27 @@ +(ns etaoin.api2 + (:require [clj-kondo.hooks-api :as api] + [etaoin.hooks-util :as h])) + +(defn with-browser + "Newer variants for api2 + [[bind & [options]] & body]" + [{:keys [node]}] + (let [macro-args (rest (:children node)) + binding-like-vector (first macro-args)] + (if-not (api/vector-node? binding-like-vector) + ;; could use clj-kondo findings, but I think this is good for now + (throw (ex-info "Expected vector for first arg" {})) + (let [binding-sym (-> binding-like-vector :children first)] + (if-not (h/symbol-node? binding-sym) + (throw (ex-info "Expected binding symbol for first arg in vector" {})) + (let [other-args (rest binding-like-vector) + body (rest macro-args)] + {:node (api/list-node + (list* + (api/token-node 'let) + ;; simulate the effect, macro is creating a new thing (driver for example) + ;; via binding it. I don't think the bound value matters for the linting process + (api/vector-node [binding-sym (api/map-node [])]) + ;; reference the other args so that they are not linted as unused + (api/vector-node other-args) + body))})))))) diff --git a/.clj-kondo/etaoin/etaoin/etaoin/hooks_util.clj_kondo b/.clj-kondo/etaoin/etaoin/etaoin/hooks_util.clj_kondo new file mode 100644 index 0000000..cd416a0 --- /dev/null +++ b/.clj-kondo/etaoin/etaoin/etaoin/hooks_util.clj_kondo @@ -0,0 +1,6 @@ +(ns etaoin.hooks-util + (:require [clj-kondo.hooks-api :as api])) + +(defn symbol-node? [node] + (and (api/token-node? node) + (symbol? (api/sexpr node)))) diff --git a/README.adoc b/README.adoc index a943ee9..d5b3c13 100644 --- a/README.adoc +++ b/README.adoc @@ -62,16 +62,16 @@ Test-doc-block versioning scheme is: `major`.`minor`.`patch`-`test-qualifier` // Contributors updated by script, do not edit // AUTO-GENERATED:CONTRIBUTORS-START :imagesdir: ./doc/generated/contributors -[.float-group] +[] -- -image:seancorfield.png[seancorfield,role="left",width=310,link="https://github.com/seancorfield"] -image:MIJOTHY.png[MIJOTHY,role="left",width=310,link="https://github.com/MIJOTHY"] -image:borkdude.png[borkdude,role="left",width=310,link="https://github.com/borkdude"] -image:holyjak.png[holyjak,role="left",width=310,link="https://github.com/holyjak"] -image:PEZ.png[PEZ,role="left",width=310,link="https://github.com/PEZ"] -image:SevereOverfl0w.png[SevereOverfl0w,role="left",width=310,link="https://github.com/SevereOverfl0w"] -image:sogaiu.png[sogaiu,role="left",width=310,link="https://github.com/sogaiu"] -image:uochan.png[uochan,role="left",width=310,link="https://github.com/uochan"] +image:seancorfield.png[seancorfield,width=273,link="https://github.com/seancorfield"] +image:MIJOTHY.png[MIJOTHY,width=273,link="https://github.com/MIJOTHY"] +image:borkdude.png[borkdude,width=273,link="https://github.com/borkdude"] +image:holyjak.png[holyjak,width=273,link="https://github.com/holyjak"] +image:PEZ.png[PEZ,width=273,link="https://github.com/PEZ"] +image:SevereOverfl0w.png[SevereOverfl0w,width=273,link="https://github.com/SevereOverfl0w"] +image:sogaiu.png[sogaiu,width=273,link="https://github.com/sogaiu"] +image:uochan.png[uochan,width=273,link="https://github.com/uochan"] -- // AUTO-GENERATED:CONTRIBUTORS-END @@ -79,9 +79,9 @@ image:uochan.png[uochan,role="left",width=310,link="https://github.com/uochan"] // Maintainers updated by script, do not edit // AUTO-GENERATED:MAINTAINERS-START :imagesdir: ./doc/generated/contributors -[.float-group] +[] -- -image:lread.png[lread,role="left",width=310,link="https://github.com/lread"] +image:lread.png[lread,width=273,link="https://github.com/lread"] -- // AUTO-GENERATED:MAINTAINERS-END diff --git a/bb.edn b/bb.edn index 16cf85a..284b4c3 100644 --- a/bb.edn +++ b/bb.edn @@ -3,7 +3,8 @@ :deps {lread/status-line {:git/url "https://github.com/lread/status-line.git" :sha "cf44c15f30ea3867227fa61ceb823e5e942c707f"} dev.nubank/docopt {:mvn/version "0.6.1-fix7"} - version-clj/version-clj {:mvn/version "2.0.2"}} + version-clj/version-clj {:mvn/version "2.0.2"} + etaoin/etaoin {:mvn/version "1.0.40"}} :tasks {;; setup :requires ([babashka.fs :as fs] diff --git a/doc/generated/contributors/MIJOTHY.png b/doc/generated/contributors/MIJOTHY.png index f0f016e..f95c5c6 100644 Binary files a/doc/generated/contributors/MIJOTHY.png and b/doc/generated/contributors/MIJOTHY.png differ diff --git a/doc/generated/contributors/PEZ.png b/doc/generated/contributors/PEZ.png index cc12853..db06873 100644 Binary files a/doc/generated/contributors/PEZ.png and b/doc/generated/contributors/PEZ.png differ diff --git a/doc/generated/contributors/SevereOverfl0w.png b/doc/generated/contributors/SevereOverfl0w.png index 2cfd3f4..411d436 100644 Binary files a/doc/generated/contributors/SevereOverfl0w.png and b/doc/generated/contributors/SevereOverfl0w.png differ diff --git a/doc/generated/contributors/borkdude.png b/doc/generated/contributors/borkdude.png index b56bdb7..61f50b4 100644 Binary files a/doc/generated/contributors/borkdude.png and b/doc/generated/contributors/borkdude.png differ diff --git a/doc/generated/contributors/holyjak.png b/doc/generated/contributors/holyjak.png index e0524a1..f9498fe 100644 Binary files a/doc/generated/contributors/holyjak.png and b/doc/generated/contributors/holyjak.png differ diff --git a/doc/generated/contributors/lread.png b/doc/generated/contributors/lread.png index c8981b0..f2ddf62 100644 Binary files a/doc/generated/contributors/lread.png and b/doc/generated/contributors/lread.png differ diff --git a/doc/generated/contributors/seancorfield.png b/doc/generated/contributors/seancorfield.png index dd17f1e..0e703a8 100644 Binary files a/doc/generated/contributors/seancorfield.png and b/doc/generated/contributors/seancorfield.png differ diff --git a/doc/generated/contributors/sogaiu.png b/doc/generated/contributors/sogaiu.png index 1de171d..1347455 100644 Binary files a/doc/generated/contributors/sogaiu.png and b/doc/generated/contributors/sogaiu.png differ diff --git a/doc/generated/contributors/uochan.png b/doc/generated/contributors/uochan.png index d41b2b8..e892a6a 100644 Binary files a/doc/generated/contributors/uochan.png and b/doc/generated/contributors/uochan.png differ diff --git a/script/doc_update_readme.clj b/script/doc_update_readme.clj index 4bf4f70..99249b0 100644 --- a/script/doc_update_readme.clj +++ b/script/doc_update_readme.clj @@ -5,38 +5,39 @@ Run manually as needed." (:require [babashka.fs :as fs] [clojure.edn :as edn] - [clojure.java.io :as io] [clojure.string :as string] + [etaoin.api :as etaoin] [helper.main :as main] - [helper.shell :as shell] [hiccup.util :as hu] [hiccup2.core :as h] - [lread.status-line :as status]) - (:import (java.nio.file Files Paths CopyOption StandardCopyOption) - (java.nio.file.attribute FileAttribute))) + [lread.status-line :as status] + [taoensso.timbre :as timbre])) + +;; default log level for bb is debug, change it to info +(alter-var-root #'timbre/*config* #(assoc % :min-level :info)) (def contributions-lookup - {:feedback "๐Ÿ’ฌ feedback" - :code "๐Ÿ’ป code" - :docs "๐Ÿ“ docs" - :inspiration "๐Ÿ’ก inspiration" - :testing "๐Ÿงช testing"}) + {:feedback ["๐Ÿ’ฌ" "feedback"] + :code ["๐Ÿ’ป" "code"] + :docs ["๐Ÿ“" "doc"] + :inspiration ["๐Ÿ’ก" "inspire"] + :testing ["๐Ÿงช" "test"]}) (defn- generate-asciidoc [contributors {:keys [images-dir image-width]}] (str ":imagesdir: " images-dir "\n" - "[.float-group]\n" + "[]\n" "--\n" (apply str (for [{:keys [github-id]} contributors] - (str "image:" github-id ".png[" github-id ",role=\"left\",width=" image-width ",link=\"https://github.com/" github-id "\"]\n"))) + (str "image:" github-id ".png[" github-id ",width=" image-width ",link=\"https://github.com/" github-id "\"]\n"))) "--\n")) (defn- update-readme-text [old-text marker-id new-content] - (let [marker (str "// AUTO-GENERATED:" marker-id ) + (let [marker (str "// AUTO-GENERATED:" marker-id) marker-start (str marker "-START") marker-end (str marker "-END")] - (string/replace old-text - (re-pattern (str "(?s)" marker-start ".*" marker-end)) - (str marker-start "\n" (string/trim new-content) "\n" marker-end)))) + (string/replace-first old-text + (re-pattern (str "(?s)" marker-start ".*" marker-end)) + (str marker-start "\n" (string/trim new-content) "\n" marker-end)))) (defn update-readme-file! [contributors readme-filename image-info] (status/line :head (str "updating " readme-filename)) @@ -50,123 +51,105 @@ (status/line :detail (str readme-filename " text updated"))) (status/line :detail (str readme-filename " text unchanged"))))) -(defn include-css - ;; not in babashka, adpapted from hiccup.page - "Include a list of external stylesheet files." - [& styles] - (for [style styles] - [:link {:type "text/css", :href style, :rel "stylesheet"}])) - -(defn generate-contributor-html [github-id contributions] - (str - (h/html - [:head - (include-css "https://fonts.googleapis.com/css?family=Fira+Code&display=swap") - [:style - (hu/raw-string - "* { - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale;} - body { - font-family: 'Fira Code', monospace; - margin: 0;} - .card { - min-width: 295px; - float: left; - border-radius: 5px; - border: 1px solid #CCCCCC; - padding: 4px; - margin: 0 5px 5px 0; - box-shadow: 4px 4px 3px grey; - background-color: #F4F4F4;} - .avatar { - float: left; - height: 110px; - border-radius: 4px; - padding: 0; - margin-right: 6px; } - .image { margin: 2px;} - .text { - margin-left: 2px; - padding: 0} - .contrib { margin: 0; } - .name { - font-size: 1.20em; - margin: 0 3px 5px 0;}")]] - [:div.card - [:img.avatar {:src (str "https://github.com/" github-id ".png?size=110")}] - [:div.text - [:p.name (str "@" github-id)] - [:div.contribs - (doall - (for [c contributions] - (when-let [c-text (c contributions-lookup)] - [:p.contrib c-text])))]]]))) - -(defn- str->Path [spath] - (Paths/get spath (into-array String []))) - - (defn- temp-Path [prefix] - (Files/createTempDirectory prefix (into-array FileAttribute []))) - -(defn- move-Path [source target] +(defn generate-contributor-html + "Some shenanigins herein. + Needed (?) to calc wrapper div so that when grabbing div would also grab shadowbox." + [github-id contributions {:keys [image-width]}] + (let [card-margin-right 5 + card-padding 7 + card-shadow-h-offset 4 + card-border 1 + card-width (- image-width (+ card-margin-right card-padding (* 2 card-shadow-h-offset) card-border)) + avatar-size 110 + contrib-font-size (if (< (count contributions) 5) "1.2em" "0.77em")] + (str + (h/html + [:head + [:link {:href "https://fonts.googleapis.com", :rel "preconnect"}] + [:link {:href "https://fonts.gstatic.com", :rel "preconnect" :crossorigin "crossorigin"}] + [:link {:type "text/css", :href "https://fonts.googleapis.com/css2?family=Fira+Code&display=swap" :rel "stylesheet"}] + [:style + (hu/raw-string + (str + "* {-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;}\n" + "body {font-family: 'Fira Code', monospace; margin: 0;}\n" + (format ".wrapper {overflow:hidden; min-width: %dpx; max-width: %dpx;}" image-width image-width) "\n" + (format ".card {float: left; border-radius: 5px; + border: %dpx solid #ccc; + padding: %dpx; + margin: 0 5px %dpx 0; + box-shadow: %dpx 4px 3px grey; + background-color: #f4f4f4; + width: %dpx;}" + card-border card-padding card-margin-right card-shadow-h-offset card-width) "\n" + (format ".avatar {float: left; height: %dpx; border-radius: 5px; padding: 0; margin-right: 10px;}" + avatar-size) "\n" + ".image {margin: 2px;}\n" + (format ".contribs {padding-top: 3px; margin-left: 5px; line-height: 1.4; max-height: %dpx; overflow: hidden;}" + avatar-size) "\n" + (format ".contrib {display:inline-block;font-size: %s;white-space: nowrap;margin: 0;margin-right: 0.8em;}\n" + contrib-font-size) + ".symbol {margin-right: 0.3em;}\n" + ".text {}\n" + ".name {font-size: 1.3em; margin: 0; clear:both;}\n"))]] + [:div.wrapper + [:div.card + [:div + [:img.avatar {:src (str "https://github.com/" github-id ".png")}] + [:div.contribs + (doall + (for [c (sort contributions)] + (let [[c-sym c-text] (c contributions-lookup)] + [:span.contrib + [:span.symbol c-sym] + [:span.text c-text]])))] + [:p.name (str "@" github-id)]]]])))) + +(defn- move-path [source target] (when (fs/exists? target) (fs/delete-tree target)) - (.mkdirs (.toFile target)) - (Files/move source target (into-array CopyOption - [(StandardCopyOption/ATOMIC_MOVE) - (StandardCopyOption/REPLACE_EXISTING)]))) + (fs/create-dirs (fs/parent target)) + (fs/move source target {:replace-existing true :atomic-move true})) -(defn- find-chrome [] - (let [mac-chrome "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" - linux-chrome "chrome"] - (cond - (.canExecute (io/file mac-chrome)) mac-chrome - :else linux-chrome))) - -(defn- chrome-info [] - (try - (let [chrome (find-chrome)] - {:exe chrome - :version (-> (shell/command {:out :string} chrome "--version") - :out - string/trim)}) - (catch Exception _e))) - -(defn- generate-image! [target-dir github-id contributions image-opts] - (let [html-file (str target-dir "/temp.html")] +(defn- generate-image! [driver target-dir github-id contributions opts] + (let [html-file (fs/file target-dir (str github-id ".html"))] (try - (spit html-file (generate-contributor-html github-id contributions)) - (shell/command {:out :string :err :string} - (find-chrome) - "--headless" - (str "--screenshot=" target-dir "/" github-id ".png") - (str "--window-size=" (:image-width image-opts) ",125") - "--default-background-color=0" - "--hide-scrollbars" - html-file) + (spit html-file (generate-contributor-html github-id contributions opts)) + (etaoin/go driver (str "file://" html-file)) + ;; send the Chrome-specific request for a transparent background + (etaoin/execute {:driver driver + :method :post + :path [:session (:session driver) "chromium" "send_command_and_get_result"] + :data {:cmd "Emulation.setDefaultBackgroundColorOverride" + :params {:color {:r 0 :g 0 :b 0 :a 0}}}}) + (etaoin/screenshot-element driver + {:tag :div :class :wrapper} + (str target-dir "/" github-id ".png")) (finally - (fs/delete-if-exists html-file))))) + ;; comment this out to leave generated .html around for tweaking/debugging + (when (fs/exists? html-file) + (fs/delete html-file)))))) (defn- generate-contributor-images! [contributors image-opts] (status/line :head "generating contributor images") - (let [work-dir (temp-Path "rewrite-clj-update-readme")] + (let [driver (etaoin/chrome {:headless true}) + work-dir (fs/create-temp-dir {:prefix "cljdoc-update-readme"})] (try (doall (for [contributor-type (keys contributors)] (do (status/line :detail (str contributor-type)) - (doall - (for [{:keys [github-id contributions]} (contributor-type contributors)] - (do - (status/line :detail (str " " github-id " " contributions)) - (generate-image! (str work-dir) github-id contributions image-opts))))))) - (let [target-path (str->Path (:images-dir image-opts))] - (move-Path work-dir target-path)) + (doseq [{:keys [github-id contributions]} (contributor-type contributors)] + (status/line :detail (str " " github-id " " contributions)) + (generate-image! driver (str work-dir) github-id contributions image-opts))))) + (let [target-path (:images-dir image-opts)] + (move-path work-dir target-path)) (catch java.lang.Exception e (when (fs/exists? work-dir) (fs/delete-tree work-dir)) - (throw e))))) + (throw e)) + (finally + (etaoin/quit driver))))) (defn- sort-contributors "Maybe not perfect but the aim for now is to sort by number of contributions then github id. @@ -179,27 +162,27 @@ {} contributors)) -(defn- check-prerequesites [] - (status/line :head "checking prerequesites") - (let [info (chrome-info)] - (if info - (status/line :detail (str "found chrome:" (:exe info) "\n" - "version:" (:version info))) - (status/line :detail "* error: did not find google chrome - need it to generate images.")) - chrome-info)) +(defn- missing-prerequesites [] + (let [need "chromedriver" + found (fs/which need)] + (if found + (do + (status/line :detail "found: %s" found) + nil) + (format "not found: %s" need)))) (defn -main [& args] (when (main/doc-arg-opt args) (let [readme-filename "README.adoc" contributors-source "doc/contributors.edn" - image-opts {:image-width 310 + image-opts {:image-width 273 :images-dir "./doc/generated/contributors"} contributors (->> (slurp contributors-source) edn/read-string sort-contributors)] (status/line :head "updating docs to honor those who contributed") - (when (not (check-prerequesites)) - (status/die 1 "pre-requisites not met")) + (when-let [missing (missing-prerequesites)] + (status/die 1 "Pre-requisites not met\n%s" missing)) (status/line :detail (str "contributors source: " contributors-source)) (generate-contributor-images! contributors image-opts) (update-readme-file! contributors readme-filename image-opts)