diff --git a/deps.edn b/deps.edn index 2efd7dc4f..5973eda1c 100644 --- a/deps.edn +++ b/deps.edn @@ -73,6 +73,10 @@ :exclusions [org.babashka/sci]} io.github.nextjournal/clerk-slideshow {:git/sha "11a83fea564da04b9d17734f2031a4921d917893"}}} + :nextjournal/garden {:exec-fn nextjournal.clerk/serve! + :exec-args {:index "book.clj"} + :nextjournal.garden/aliases [:demo]} + :build {:deps {io.github.nextjournal/clerk {:local/root "."} io.github.nextjournal/cas-client {:git/sha "22ef8360689cd3938e43a3223023ab1b9711818f"} io.github.clojure/tools.build {:git/tag "v0.6.1" :git/sha "515b334"} diff --git a/garden.edn b/garden.edn new file mode 100644 index 000000000..ef60faf70 --- /dev/null +++ b/garden.edn @@ -0,0 +1 @@ +{:project "book-of-clerk"} diff --git a/notebooks/intern.clj b/notebooks/intern.clj index 79a3ff1cc..98a146b66 100644 --- a/notebooks/intern.clj +++ b/notebooks/intern.clj @@ -33,8 +33,6 @@ c (ns-unmap *ns* 'variable) (ns-unmap (find-ns 'foreign) 'variable) - (reset! nextjournal.clerk.webserver/!doc nextjournal.clerk.webserver/help-doc) - ;; inspect recorded interns (-> @nextjournal.clerk.webserver/!doc :blocks (->> (mapcat (comp :nextjournal/interned :result)))) diff --git a/src/nextjournal/clerk.clj b/src/nextjournal/clerk.clj index 7f6c99096..a5a9c3e0f 100644 --- a/src/nextjournal/clerk.clj +++ b/src/nextjournal/clerk.clj @@ -11,6 +11,7 @@ [nextjournal.clerk.config :as config] [nextjournal.clerk.eval :as eval] [nextjournal.clerk.parser :as parser] + [nextjournal.clerk.paths :as paths] [nextjournal.clerk.viewer :as v] [nextjournal.clerk.webserver :as webserver])) @@ -51,7 +52,10 @@ :else file-or-ns) - doc (try (merge opts + doc (try (merge (webserver/get-build-opts) + opts + (when-let [path (paths/path-in-cwd file-or-ns)] + {:file-path path}) {:nav-path (webserver/->nav-path file-or-ns)} (parser/parse-file {:doc? true} file)) (catch java.io.FileNotFoundException _e @@ -63,7 +67,8 @@ e)))) _ (reset! !last-file file) {:keys [blob->result]} @webserver/!doc - {:keys [result time-ms]} (try (eval/time-ms (eval/+eval-results blob->result (assoc doc :set-status-fn webserver/set-status!))) + {:keys [result time-ms]} (try (eval/time-ms (binding [paths/*build-opts* (webserver/get-build-opts)] + (eval/+eval-results blob->result (assoc doc :set-status-fn webserver/set-status!)))) (catch Exception e (throw (ex-info (str "`nextjournal.clerk/show!` encountered an eval error with: `" (pr-str file-or-ns) "`") {::doc (assoc doc :blob->result blob->result)} e))))] (println (str "Clerk evaluated '" file "' in " time-ms "ms.")) diff --git a/src/nextjournal/clerk/builder.clj b/src/nextjournal/clerk/builder.clj index 8523b5aa9..b203e3e35 100644 --- a/src/nextjournal/clerk/builder.clj +++ b/src/nextjournal/clerk/builder.clj @@ -2,19 +2,18 @@ "Clerk's Static App Builder." (:require [babashka.fs :as fs] [babashka.process :refer [sh]] - [clojure.edn :as edn] [clojure.java.browse :as browse] [clojure.java.io :as io] [clojure.string :as str] [nextjournal.clerk.analyzer :as analyzer] [nextjournal.clerk.builder-ui :as builder-ui] + [nextjournal.clerk.config :as config] [nextjournal.clerk.eval :as eval] [nextjournal.clerk.parser :as parser] + [nextjournal.clerk.paths :as paths] [nextjournal.clerk.view :as view] [nextjournal.clerk.viewer :as viewer] - [nextjournal.clerk.webserver :as webserver] - [nextjournal.clerk.config :as config]) - (:import (java.net URL))) + [nextjournal.clerk.webserver :as webserver])) (def clerk-docs (into ["CHANGELOG.md" @@ -127,84 +126,6 @@ (def default-out-path (str "public" fs/file-separator "build")) -(defn ^:private ensure-not-empty [build-opts {:as opts :keys [error expanded-paths]}] - (if error - opts - (if (empty? expanded-paths) - (merge {:error "nothing to build" :expanded-paths expanded-paths} (select-keys build-opts [:paths :paths-fn :index])) - opts))) - -(defn ^:private maybe-add-index [{:as build-opts :keys [index]} {:as opts :keys [expanded-paths]}] - (if-not (contains? build-opts :index) - opts - (if (and (not (instance? URL index)) - (not (symbol? index)) - (or (not (string? index)) (not (fs/exists? index)))) - {:error "`:index` must be either an instance of java.net.URL or a string and point to an existing file" - :index index} - (cond-> opts - (and index (not (contains? (set expanded-paths) index))) - (update :expanded-paths conj index))))) - -#_(maybe-add-index {:index "book.clj"} {:expanded-paths ["README.md"]}) -#_(maybe-add-index {:index 'book.clj} {:expanded-paths ["README.md"]}) - -(defn resolve-paths [{:as build-opts :keys [paths paths-fn index]}] - (when (and paths paths-fn) - (binding [*out* *err*] - (println "[info] both `:paths` and `:paths-fn` are set, `:paths` will take precendence."))) - (if (not (or paths paths-fn index)) - {:error "must set either `:paths`, `:paths-fn` or `:index`." - :build-opts build-opts} - (cond paths (if (sequential? paths) - {:resolved-paths paths} - {:error "`:paths` must be sequential" :paths paths}) - paths-fn (let [ex-msg "`:path-fn` must be a qualified symbol pointing at an existing var."] - (if-not (qualified-symbol? paths-fn) - {:error ex-msg :paths-fn paths-fn} - (if-some [resolved-var (try (requiring-resolve paths-fn) - (catch Exception _e nil))] - (let [{:as opts :keys [error paths]} - (try {:paths (cond-> @resolved-var (fn? @resolved-var) (apply []))} - (catch Exception e - {:error (str "An error occured invoking `" (pr-str resolved-var) "`: " (ex-message e)) - :paths-fn paths-fn}))] - (if error - opts - (if-not (sequential? paths) - {:error (str "`:paths-fn` must compute to a sequential value.") - :paths-fn paths-fn :resolved-paths paths} - {:resolved-paths paths}))) - {:error ex-msg :paths-fn paths-fn}))) - index {:resolved-paths []}))) - -#_(resolve-paths {:paths ["notebooks/di*.clj"]}) -#_(resolve-paths {:paths-fn 'clojure.core/inc}) -#_(resolve-paths {:paths-fn 'nextjournal.clerk.builder/clerk-docs}) - -(defn expand-paths [build-opts] - (let [{:as opts :keys [error resolved-paths]} (resolve-paths build-opts)] - (if error - opts - (->> resolved-paths - (mapcat (fn [path] (if (fs/exists? path) - [path] - (fs/glob "." path)))) - (filter (complement fs/directory?)) - (mapv (comp str fs/file)) - (hash-map :expanded-paths) - (maybe-add-index build-opts) - (ensure-not-empty build-opts))))) - -#_(expand-paths {:paths ["notebooks/di*.clj"] :index "src/nextjournal/clerk/index.clj"}) -#_(expand-paths {:paths ['notebooks/rule_30.clj]}) -#_(expand-paths {:index "book.clj"}) -#_(expand-paths {:paths-fn `clerk-docs}) -#_(expand-paths {:paths-fn `clerk-docs-2}) -#_(do (defn my-paths [] ["notebooks/h*.clj"])§ - (expand-paths {:paths-fn `my-paths})) -#_(expand-paths {:paths ["notebooks/viewers**"]}) - (def builtin-index (io/resource "nextjournal/clerk/index.clj")) @@ -216,17 +137,15 @@ (let [opts+index (cond-> opts index (assoc :index (str index))) {:as opts' :keys [expanded-paths]} (cond-> opts+index - expand-paths? (merge (expand-paths opts+index)))] + expand-paths? (merge (paths/expand-paths opts+index)))] (-> opts' (update :resource->url #(merge {} %2 %1) @config/!resource->url) (cond-> #_opts' expand-paths? (dissoc :expand-paths?) - (and (not index) (= 1 (count expanded-paths))) - (assoc :index (first expanded-paths)) (and (not index) (< 1 (count expanded-paths)) (every? (complement viewer/index-path?) expanded-paths)) (as-> opts - (-> opts (assoc :index builtin-index) (update :expanded-paths conj builtin-index)))))))) + (-> opts (assoc :index builtin-index) (update :expanded-paths conj builtin-index)))))))) #_(process-build-opts {:index 'book.clj :expand-paths? true}) #_(process-build-opts {:paths ["notebooks/rule_30.clj"] :expand-paths? true}) @@ -336,31 +255,6 @@ (str (viewer/relative-root-prefix-from (viewer/map-index opts file)) path (when fragment (str "#" fragment)))))) -(defn read-opts-from-deps-edn! [] - (if (fs/exists? "deps.edn") - (let [deps-edn (edn/read-string (slurp "deps.edn"))] - (if-some [clerk-alias (get-in deps-edn [:aliases :nextjournal/clerk])] - (get clerk-alias :exec-args - {:error (str "No `:exec-args` found in `:nextjournal/clerk` alias.")}) - {:error (str "No `:nextjournal/clerk` alias found in `deps.edn`.")})) - {:error (str "No `deps.edn` found in project.")})) - -(def ^:dynamic ^:private *build-opts* nil) -(def build-help-link "\n\nLearn how to [set up your static build](https://book.clerk.vision/#static-building).") -(defn index-paths - ([] (index-paths (or *build-opts* (read-opts-from-deps-edn!)))) - ([{:as opts :keys [index error]}] - (if error - (update opts :error str build-help-link) - (let [{:as result :keys [expanded-paths error]} (expand-paths opts)] - (if error - (update result :error str build-help-link) - {:paths (remove #{index "index.clj"} expanded-paths)}))))) - -#_(index-paths) -#_(index-paths {:paths ["CHANGELOG.md"]}) -#_(index-paths {:paths-fn "boom"}) - (defn build-static-app! [{:as opts :keys [bundle?]}] (let [{:as opts :keys [download-cache-fn upload-cache-fn report-fn compile-css? expanded-paths error]} (process-build-opts (assoc opts :expand-paths? true)) @@ -391,11 +285,10 @@ (let [{result :result duration :time-ms} (eval/time-ms (try (binding [*ns* *ns* - *build-opts* opts + paths/*build-opts* opts viewer/doc-url (partial doc-url opts file)] (let [doc (eval/eval-analyzed-doc doc)] (assoc doc :viewer (view/doc->viewer (assoc opts - :static-build? true :nav-path (if (instance? java.net.URL file) (str "'" (:ns doc)) (str file))) diff --git a/src/nextjournal/clerk/git.clj b/src/nextjournal/clerk/git.clj new file mode 100644 index 000000000..78984a6c7 --- /dev/null +++ b/src/nextjournal/clerk/git.clj @@ -0,0 +1,40 @@ +(ns nextjournal.clerk.git + "Clerk's Git integration for backlinks to source code repos." + (:require [babashka.process :as p] + [clojure.string :as str])) + +(defn ^:private shell-out-str + "Shell helper, calls a cmd and returns it output string trimmed." + [cmd] + (str/trim (:out (p/shell {:out :string} cmd)))) + +#_(shell-out-str "git rev-parse HEAD") +#_(shell-out-str "zonk") + +(defn ->github-project [remote-url] + (second (re-find #"^git@github\.com:(.*)\.git$" remote-url))) + +(defn ->https-git-url + "Takes a git `remote-url` and tries to convert it into a https url for + backlinks. Currently only works for github, should be extended for + gitlab, etc." + [remote-url] + (cond + (str/starts-with? remote-url "https://") + (str/replace remote-url #"\.git$" "") + + (->github-project remote-url) + (str "https://github.com/%s" (->github-project remote-url)))) + +#_(->https-git-url "https://github.com/nextjournal/clerk.git") +#_(->https-git-url "git@github.com:nextjournal/clerk.git") + +(defn read-git-attrs [] + (try {:git/sha (shell-out-str "git rev-parse HEAD") + :git/url (some ->https-git-url + (map #(shell-out-str (str "git remote get-url " %)) + (str/split-lines (shell-out-str "git remote"))))} + (catch Exception _ + {}))) + +#_(read-git-attrs) diff --git a/src/nextjournal/clerk/home.clj b/src/nextjournal/clerk/home.clj index 891a207eb..64e84c09d 100644 --- a/src/nextjournal/clerk/home.clj +++ b/src/nextjournal/clerk/home.clj @@ -1,9 +1,9 @@ (ns nextjournal.clerk.home {:nextjournal.clerk/visibility {:code :hide :result :hide}} - (:require [clojure.string :as str] - [babashka.fs :as fs] + (:require [babashka.fs :as fs] + [clojure.string :as str] [nextjournal.clerk :as clerk] - [nextjournal.clerk.builder :as builder] + [nextjournal.clerk.paths :as paths] [nextjournal.clerk.viewer :as v])) (defn glob-notebooks [] @@ -192,7 +192,7 @@ (when-not (seq (:query @!filter)) [:div {:class "w-1/2 pt-6 pl-6"} [:h4.text-lg "Static Build Index"] - (let [{:keys [paths error]} (builder/index-paths)] + (let [{:keys [paths error]} (paths/index-paths)] (cond error [:div {:class "-mx-8"} (clerk/md error)] paths (let [{:keys [query]} @!filter] diff --git a/src/nextjournal/clerk/index.clj b/src/nextjournal/clerk/index.clj index 79a728bae..ed89c99d7 100644 --- a/src/nextjournal/clerk/index.clj +++ b/src/nextjournal/clerk/index.clj @@ -1,12 +1,13 @@ (ns nextjournal.clerk.index - {:nextjournal.clerk/visibility {:code :hide :result :hide}} + {:nextjournal.clerk/visibility {:code :hide :result :hide} + :nextjournal.clerk/no-cache true} (:require [babashka.fs :as fs] [clojure.string :as str] [nextjournal.clerk :as clerk] - [nextjournal.clerk.viewer :as v] - [nextjournal.clerk.builder :as builder])) + [nextjournal.clerk.paths :as paths] + [nextjournal.clerk.viewer :as v])) -(def !paths (delay (builder/index-paths))) +(def !paths (delay (paths/index-paths))) (def index-item-viewer {:pred string? @@ -41,4 +42,4 @@ (clerk/html [:div.text-xs.text-slate-400.font-sans.mb-8.not-prose [:span.block.font-medium "This index page was automatically generated by Clerk."] - "You can customize it by adding a index.clj file to your project’s root directory. See " [:a.text-blue-600.dark:text-blue-300.hover:underline {:href "https://book.clerk.vision/#static-building"} "Static Publishing"] " in the " [:a.text-blue-600.dark:text-blue-300.hover:underline {:href "http://book.clerk.vision"} "Book of Clerk"] "."]) + "You can customize it by adding an index.clj file to your project’s root directory. See " [:a.text-blue-600.dark:text-blue-300.hover:underline {:href "https://book.clerk.vision/#static-building"} "Static Publishing"] " in the " [:a.text-blue-600.dark:text-blue-300.hover:underline {:href "http://book.clerk.vision"} "Book of Clerk"] "."]) diff --git a/src/nextjournal/clerk/paths.clj b/src/nextjournal/clerk/paths.clj new file mode 100644 index 000000000..d877b0a34 --- /dev/null +++ b/src/nextjournal/clerk/paths.clj @@ -0,0 +1,156 @@ +(ns nextjournal.clerk.paths + "Clerk's paths expansion and paths-fn handling." + (:require [babashka.fs :as fs] + [clojure.edn :as edn] + [clojure.string :as str] + [nextjournal.clerk.git :as git]) + (:import [java.net URL])) + +(defn ^:private ensure-not-empty [build-opts {:as opts :keys [error expanded-paths]}] + (if error + opts + (if (empty? expanded-paths) + (merge {:error "nothing to build" :expanded-paths expanded-paths} (select-keys build-opts [:paths :paths-fn :index])) + opts))) + +(defn ^:private maybe-add-index [{:as build-opts :keys [index]} {:as opts :keys [expanded-paths]}] + (if-not (contains? build-opts :index) + opts + (if (and (not (instance? URL index)) + (not (symbol? index)) + (or (not (string? index)) (not (fs/exists? index)))) + {:error "`:index` must be either an instance of java.net.URL or a string and point to an existing file" + :index index} + (cond-> opts + (and index (not (contains? (set expanded-paths) index))) + (update :expanded-paths conj index))))) + +#_(maybe-add-index {:index "book.clj"} {:expanded-paths ["README.md"]}) +#_(maybe-add-index {:index 'book.clj} {:expanded-paths ["README.md"]}) + +(defn resolve-paths [{:as build-opts :keys [paths paths-fn index]}] + (when (and paths paths-fn) + (binding [*out* *err*] + (println "[info] both `:paths` and `:paths-fn` are set, `:paths` will take precendence."))) + (if (not (or paths paths-fn index)) + {:error "must set either `:paths`, `:paths-fn` or `:index`." + :build-opts build-opts} + (cond paths (if (sequential? paths) + {:resolved-paths paths} + {:error "`:paths` must be sequential" :paths paths}) + paths-fn (let [ex-msg "`:path-fn` must be a qualified symbol pointing at an existing var."] + (if-not (qualified-symbol? paths-fn) + {:error ex-msg :paths-fn paths-fn} + (if-some [resolved-var (try (requiring-resolve paths-fn) + (catch Exception _e nil))] + (let [{:as opts :keys [error paths]} + (try {:paths (cond-> @resolved-var (fn? @resolved-var) (apply []))} + (catch Exception e + {:error (str "An error occured invoking `" (pr-str resolved-var) "`: " (ex-message e)) + :paths-fn paths-fn}))] + (if error + opts + (if-not (sequential? paths) + {:error (str "`:paths-fn` must compute to a sequential value.") + :paths-fn paths-fn :resolved-paths paths} + {:resolved-paths paths}))) + {:error ex-msg :paths-fn paths-fn}))) + index {:resolved-paths []}))) + +#_(resolve-paths {:paths ["notebooks/di*.clj"]}) +#_(resolve-paths {:paths-fn 'clojure.core/inc}) +#_(resolve-paths {:paths-fn 'nextjournal.clerk.builder/clerk-docs}) + +(defn set-index-when-single-path [{:as opts :keys [expanded-paths]}] + (cond-> opts + (and (not (contains? opts :index)) + (= 1 (count expanded-paths))) + (assoc :index (first expanded-paths)))) + +#_(set-index-when-single-path {:expanded-paths ["notebooks/rule_30.clj"]}) +#_(set-index-when-single-path {:expanded-paths ["notebooks/rule_30.clj" "book.clj"]}) + +(defn expand-paths [build-opts] + (let [{:as opts :keys [error resolved-paths]} (resolve-paths build-opts)] + (if error + opts + (->> resolved-paths + (mapcat (fn [path] (if (fs/exists? path) + [path] + (fs/glob "." path)))) + (filter (complement fs/directory?)) + (mapv (comp str fs/file)) + (hash-map :expanded-paths) + (maybe-add-index build-opts) + (set-index-when-single-path) + (ensure-not-empty build-opts))))) + +#_(expand-paths {:paths ["notebooks/di*.clj"] :index "src/nextjournal/clerk/index.clj"}) +#_(expand-paths {:paths ['notebooks/rule_30.clj]}) +#_(expand-paths {:index "book.clj"}) +#_(expand-paths {:paths-fn `nextjournal.clerk.builder/clerk-docs}) +#_(expand-paths {:paths-fn `clerk-docs-2}) +#_(do (defn my-paths [] ["notebooks/h*.clj"])§ + (expand-paths {:paths-fn `my-paths})) +#_(expand-paths {:paths ["notebooks/viewers**"]}) + + +(defn read-opts-from-deps-edn! [] + (if (fs/exists? "deps.edn") + (let [deps-edn (edn/read-string (slurp "deps.edn"))] + (if-some [clerk-alias (get-in deps-edn [:aliases :nextjournal/clerk])] + (get clerk-alias :exec-args + {:error (str "No `:exec-args` found in `:nextjournal/clerk` alias.")}) + {:error (str "No `:nextjournal/clerk` alias found in `deps.edn`.")})) + {:error (str "No `deps.edn` found in project.")})) + +(def ^:dynamic *build-opts* nil) + +(def build-help-link "\n\nLearn how to [set up your static build](https://book.clerk.vision/#static-building).") + +(defn index-paths + ([] (index-paths (or *build-opts* (read-opts-from-deps-edn!)))) + ([{:as opts :keys [index error]}] + (if error + (update opts :error str build-help-link) + (let [{:as result :keys [expanded-paths error]} (if (contains? opts :expanded-paths) opts (expand-paths opts))] + (if error + (update result :error str build-help-link) + {:paths (remove #{index "index.clj"} expanded-paths)}))))) + +#_(index-paths) +#_(index-paths {:paths ["CHANGELOG.md"]}) +#_(index-paths {:paths-fn "boom"}) + +(defn process-paths [{:as opts :keys [paths paths-fn index]}] + (merge (if (or paths paths-fn index) + (expand-paths opts) + opts) + (git/read-git-attrs))) + +#_(process-paths {:paths ["notebooks/rule_30.clj"]}) +#_(process-paths {:paths ["notebooks/no_rule_30.clj"]}) +#_(v/route-index? (process-paths @!server)) +#_(route-index (process-paths @!server) "") + + +(defn path-in-cwd + "Turns `file` into a unixified (forward slashed) path if the is in the cwd, + returns `nil` otherwise." + [file] + (when (and (string? file) + (fs/exists? file)) + (let [rel (fs/relativize (fs/cwd) (fs/canonicalize file #{:nofollow-links}))] + (when-not (str/starts-with? (str rel) "..") + (fs/unixify rel))))) + +#_(path-in-cwd "notebooks/rule_30.clj") +#_(path-in-cwd "/tmp/foo.clj") +#_(path-in-cwd "../scratch/rule_30.clj") + +(defn drop-extension [file] + (cond-> file + (fs/extension file) + (str/replace (re-pattern (format ".%s$" (fs/extension file))) ""))) + +#_(drop-extension "notebooks/rule_30.clj") diff --git a/src/nextjournal/clerk/viewer.cljc b/src/nextjournal/clerk/viewer.cljc index f8d388ad7..d673922d3 100644 --- a/src/nextjournal/clerk/viewer.cljc +++ b/src/nextjournal/clerk/viewer.cljc @@ -1134,22 +1134,28 @@ (defn home? [{:keys [nav-path]}] (contains? #{"src/nextjournal/home.clj" "'nextjournal.clerk.home"} nav-path)) -(defn index? [{:as opts :keys [nav-path index]}] - (when nav-path - (or (= "'nextjournal.clerk.index" nav-path) - (= (str index) nav-path) - (re-matches #"(^|.*/)(index\.(clj|cljc|md))$" nav-path)))) +(defn route-index? + "Should the index router be enabled?" + [{:keys [expanded-paths]}] + (boolean (seq expanded-paths))) -(defn index-path [{:keys [static-build? index]}] + +(defn index? [{:as opts :keys [file index ns]}] + (or (= (some-> ns ns-name) 'nextjournal.clerk.index) + (some->> file str (re-matches #"(^|.*/)(index\.(clj|cljc|md))$")) + (and index (= file index)))) + +(defn index-path [{:as opts :keys [index]}] #?(:cljs "" - :clj (if static-build? + :clj (if (route-index? opts) "" (if (fs/exists? "index.clj") "index.clj" "'nextjournal.clerk.index")))) -(defn header [{:as opts :keys [nav-path static-build?] :git/keys [url sha]}] +(defn header [{:as opts :keys [file file-path nav-path static-build? ns] :git/keys [url sha]}] (html [:div.viewer.w-full.max-w-prose.px-8.not-prose.mt-3 [:div.mb-8.text-xs.sans-serif.text-slate-400 - (when (and (not static-build?) (not (home? opts))) + (when (and (not (route-index? opts)) + (not (home? opts))) [:<> [:a.font-medium.border-b.border-dotted.border-slate-300.hover:text-indigo-500.hover:border-indigo-500.dark:border-slate-500.dark:hover:text-white.dark:hover:border-white.transition {:href (doc-url "'nextjournal.clerk.home")} "Home"] @@ -1163,12 +1169,14 @@ (if static-build? "Generated with " "Served from ") [:a.font-medium.border-b.border-dotted.border-slate-300.hover:text-indigo-500.hover:border-indigo-500.dark:border-slate-500.dark:hover:text-white.dark:hover:border-white.transition {:href "https://clerk.vision"} "Clerk"] - " from " - (let [default-index? (str/ends-with? (str nav-path) "src/nextjournal/clerk/index.clj")] - [:a.font-medium.border-b.border-dotted.border-slate-300.hover:text-indigo-500.hover:border-indigo-500.dark:border-slate-500.dark:hover:text-white.dark:hover:border-white.transition - {:href (when (and url sha) (if default-index? (str url "/tree/" sha) (str url "/blob/" sha "/" nav-path)))} - (if (and url default-index?) #?(:clj (subs (.getPath (URL. url)) 1) :cljs url) nav-path) - (when sha [:<> "@" [:span.tabular-nums (subs sha 0 7)]])])]]])) + (let [default-index? (= 'nextjournal.clerk.index (some-> ns ns-name))] + (when (or file-path default-index?) + [:<> + " from " + [:a.font-medium.border-b.border-dotted.border-slate-300.hover:text-indigo-500.hover:border-indigo-500.dark:border-slate-500.dark:hover:text-white.dark:hover:border-white.transition + {:href (when (and url sha) (if default-index? (str url "/tree/" sha) (str url "/blob/" sha "/" file-path)))} + (if (and url default-index?) #?(:clj (subs (.getPath (URL. url)) 1) :cljs url) (or file-path nav-path)) + (when sha [:<> "@" [:span.tabular-nums (subs sha 0 7)]])]]))]]])) (def header-viewer {:name `header-viewer diff --git a/src/nextjournal/clerk/webserver.clj b/src/nextjournal/clerk/webserver.clj index 8c6000b6d..8359291b8 100644 --- a/src/nextjournal/clerk/webserver.clj +++ b/src/nextjournal/clerk/webserver.clj @@ -1,33 +1,24 @@ (ns nextjournal.clerk.webserver (:require [babashka.fs :as fs] [clojure.edn :as edn] + [clojure.java.io :as io] [clojure.pprint :as pprint] [clojure.set :as set] [clojure.string :as str] - [clojure.java.io :as io] [editscript.core :as editscript] [nextjournal.clerk.config :as config] + [nextjournal.clerk.paths :as paths] [nextjournal.clerk.view :as view] [nextjournal.clerk.viewer :as v] [org.httpkit.server :as httpkit]) (:import (java.nio.file Files))) -(defn help-hiccup [] - [:p "Call " [:span.code "nextjournal.clerk/show!"] " from your REPL" - (when-let [watch-paths (seq (:paths @@(resolve 'nextjournal.clerk/!watcher)))] - (into [:<> " or save a file in "] - (interpose " or " (map #(vector :span.code %) watch-paths)))) - " to make your notebook appear…"]) - -(defn help-doc [] - {:blocks [{:type :code - :visibility {:code :hide, :result :show} - :result {:nextjournal/value (v/html (help-hiccup))}}]}) - (defonce !clients (atom #{})) (defonce !doc (atom nil)) (defonce !last-sender-ch (atom nil)) +(defonce !server (atom nil)) + #_(view/doc->viewer @!doc) #_(reset! !doc nil) @@ -182,19 +173,22 @@ (declare present+reset!) +(defn get-build-opts [] + (paths/process-paths @!server)) + (defn ->nav-path [file-or-ns] - (cond (or (symbol? file-or-ns) (instance? clojure.lang.Namespace file-or-ns)) + (cond (or (= 'nextjournal.clerk.index file-or-ns) + (= (:index (get-build-opts)) file-or-ns)) + "" + + (or (symbol? file-or-ns) (instance? clojure.lang.Namespace file-or-ns)) (str "'" file-or-ns) (string? file-or-ns) - (when (fs/exists? file-or-ns) - (fs/unixify (cond->> (fs/strip-ext file-or-ns) - (and (fs/absolute? file-or-ns) - (not (str/starts-with? (fs/relativize (fs/cwd) file-or-ns) ".."))) - (fs/relativize (fs/cwd))))) - - :else (str file-or-ns))) + (paths/drop-extension (or (paths/path-in-cwd file-or-ns) file-or-ns)))) +#_(->nav-path (str (fs/file (fs/cwd) "notebooks/rule_30.clj"))) +#_(->nav-path 'nextjournal.clerk.index) #_(->nav-path "notebooks/rule_30.clj") #_(->nav-path 'nextjournal.clerk.home) @@ -223,13 +217,28 @@ (defn show! [opts file-or-ns] ((resolve 'nextjournal.clerk/show!) opts file-or-ns)) +(defn route-index + "A routing function" + [{:as opts :keys [index expanded-paths]} nav-path] + (if (str/blank? nav-path) + (or index + (get (set expanded-paths) (maybe-add-extension "index")) + "'nextjournal.clerk.index") + nav-path)) + +(defn maybe-route-index [opts path] + (cond->> path + (v/route-index? opts) (route-index opts))) + (defn navigate! [{:as opts :keys [nav-path]}] - (show! opts (->file-or-ns (maybe-add-extension nav-path)))) + (let [route-opts (get-build-opts)] + (show! (merge route-opts opts) (->file-or-ns (maybe-add-extension (maybe-route-index route-opts nav-path)))))) (defn prefetch-request? [req] (= "prefetch" (-> req :headers (get "purpose")))) (defn serve-notebook [{:as req :keys [uri]}] - (let [nav-path (subs uri 1)] + (let [opts (paths/process-paths @!server) + nav-path (maybe-route-index opts (subs uri 1))] (cond (prefetch-request? req) {:status 404} @@ -240,7 +249,9 @@ (->nav-path 'nextjournal.clerk.home))}} :else (if-let [file-or-ns (->file-or-ns (maybe-add-extension nav-path))] - (do (try (show! {:skip-history? true} file-or-ns) + (do (try (show! (merge {:skip-history? true} + (select-keys opts [:expanded-paths :index :git/sha :git/url])) + file-or-ns) (catch Exception _)) {:status 200 :headers {"Content-Type" "text/html" "Cache-Control" "no-store"} @@ -285,7 +296,7 @@ (broadcast-status! status))) (defn set-status! [status] - (swap! !doc (fn [doc] (-> (or doc (help-doc)) + (swap! !doc (fn [doc] (-> (or doc {}) (vary-meta assoc :status status) (vary-meta update ::!send-status-future broadcast-status-debounced! status))))) @@ -295,8 +306,6 @@ ;; * load notebook without results ;; * allow page reload -(defonce !server (atom nil)) - (defn halt! [] (when-let [{:keys [port instance]} @!server] @(httpkit/server-stop! instance) @@ -305,10 +314,10 @@ #_(halt!) -(defn serve! [{:keys [host port] :or {host "localhost" port 7777}}] +(defn serve! [{:as opts :keys [host port] :or {host "localhost" port 7777}}] (halt!) (try - (reset! !server {:host host :port port :instance (httpkit/run-server #'app {:ip host :port port :legacy-return-value? false})}) + (reset! !server (assoc opts :instance (httpkit/run-server #'app {:ip host :port port :legacy-return-value? false}))) (println (format "Clerk webserver started on http://%s:%s ..." host port )) (catch java.net.BindException e (let [msg (format "Clerk webserver could not be started because port %d is not available. Stop what's running on port %d or specify a different port." port port)] @@ -317,4 +326,7 @@ (throw (ex-info msg {:port port} e)))))) #_(serve! {:port 7777}) +#_(serve! {:port 7777 :paths ["notebooks/rule_30.clj"]}) +#_(serve! {:port 7777 :paths ["notebooks/rule_30.clj" "book.clj"]}) +#_(serve! {:port 7777 :paths ["notebooks/rule_30.clj" "notebooks/links.md" "notebooks/markdown.md" "index.clj"]}) #_(serve! {:port 7777 :host "0.0.0.0"}) diff --git a/test/nextjournal/clerk/builder_test.clj b/test/nextjournal/clerk/builder_test.clj index a86633bae..baa6d9c65 100644 --- a/test/nextjournal/clerk/builder_test.clj +++ b/test/nextjournal/clerk/builder_test.clj @@ -30,60 +30,6 @@ (testing "*ns* isn't changed (#506)" (is (= original-*ns* *ns*)))))) -(def test-paths ["boo*.clj"]) -(def test-paths-fn (fn [] ["boo*.clj"])) - -(deftest expand-paths - (testing "expands glob patterns" - (let [{paths :expanded-paths} (builder/expand-paths {:paths ["notebooks/*clj"]})] - (is (> (count paths) 25)) - (is (every? #(str/ends-with? % ".clj") paths)))) - - (testing "supports index" - (is (= {:expanded-paths ["book.clj"]} - (builder/expand-paths {:index "book.clj"})))) - - (testing "supports paths" - (is (= {:expanded-paths ["book.clj"]} - (builder/expand-paths {:paths ["book.clj"]})))) - - (testing "supports paths-fn" - (is (= {:expanded-paths ["book.clj"]} - (builder/expand-paths {:paths-fn `test-paths}))) - (is (= {:expanded-paths ["book.clj"]} - (builder/expand-paths {:paths-fn `test-paths-fn})))) - - (testing "deduplicates index + paths" - (is (= {:expanded-paths [(str (fs/file "notebooks" "rule_30.clj"))]} - (builder/expand-paths {:paths ["notebooks/rule_**.clj"] - :index (str (fs/file "notebooks" "rule_30.clj"))})))) - - (testing "supports absolute paths (#504)" - (is (= {:expanded-paths [(str (fs/file (fs/cwd) "book.clj"))]} - (builder/expand-paths {:paths [(str (fs/file (fs/cwd) "book.clj"))]})))) - - (testing "invalid args" - (is (match? {:error #"must set either"} - (builder/expand-paths {}))) - (is (match? {:error #"must be a qualified symbol pointing at an existing var"} - (builder/expand-paths {:paths-fn :foo}))) - (is (match? {:error #"must be a qualified symbol pointing at an existing var"} - (builder/expand-paths {:paths-fn 'foo}))) - (is (match? {:error #"must be a qualified symbol pointing at an existing var"} - (builder/expand-paths {:paths-fn 'clerk.test.non-existant-name-space/bar}))) - (is (match? {:error #"must be a qualified symbol pointing at an existing var"} - (builder/expand-paths {:paths-fn 'clojure.core/non-existant-var}))) - (is (match? {:error #"must be a qualified symbol pointing at an existing var"} - (builder/expand-paths {:paths-fn "hi"}))) - (is (match? {:error #"nothing to build"} - (builder/expand-paths {:paths []}))) - (is (match? {:error #"An error occured invoking"} - (builder/expand-paths {:paths-fn 'clojure.core/inc}))) - (is (match? {:error #"must compute to a sequential value."} - (builder/expand-paths {:paths-fn 'clojure.core/+}))) - (is (match? {:error "`:index` must be either an instance of java.net.URL or a string and point to an existing file"} - (builder/expand-paths {:index ["book.clj"]}))))) - (deftest build-static-app! (testing "error when paths are empty (issue #339)" (is (thrown-with-msg? ExceptionInfo #"nothing to build" (builder/build-static-app! {:paths []})))) diff --git a/test/nextjournal/clerk/paths_test.clj b/test/nextjournal/clerk/paths_test.clj new file mode 100644 index 000000000..466fa27de --- /dev/null +++ b/test/nextjournal/clerk/paths_test.clj @@ -0,0 +1,60 @@ +(ns nextjournal.clerk.paths-test + (:require [babashka.fs :as fs] + [clojure.string :as str] + [clojure.test :refer [deftest is testing]] + [matcher-combinators.test] + [nextjournal.clerk.paths :as paths])) + +(def test-paths ["boo*.clj"]) +(def test-paths-fn (fn [] ["boo*.clj"])) + +(deftest expand-paths + (testing "expands glob patterns" + (let [{paths :expanded-paths} (paths/expand-paths {:paths ["notebooks/*clj"]})] + (is (> (count paths) 25)) + (is (every? #(str/ends-with? % ".clj") paths)))) + + (testing "supports index" + (is (= ["book.clj"] + (:expanded-paths (paths/expand-paths {:index "book.clj"}))))) + + (testing "supports paths" + (is (= ["book.clj"] + (:expanded-paths (paths/expand-paths {:paths ["book.clj"]}))))) + + (testing "supports paths-fn" + (is (= ["book.clj"] + (:expanded-paths (paths/expand-paths {:paths-fn `test-paths})))) + (is (= ["book.clj"] + (:expanded-paths (paths/expand-paths {:paths-fn `test-paths-fn}))))) + + (testing "deduplicates index + paths" + (is (= [(str (fs/file "notebooks" "rule_30.clj"))] + (:expanded-paths (paths/expand-paths {:paths ["notebooks/rule_**.clj"] + :index (str (fs/file "notebooks" "rule_30.clj"))}))))) + + (testing "supports absolute paths (#504)" + (is (= [(str (fs/file (fs/cwd) "book.clj"))] + (:expanded-paths (paths/expand-paths {:paths [(str (fs/file (fs/cwd) "book.clj"))]}))))) + + (testing "invalid args" + (is (match? {:error #"must set either"} + (paths/expand-paths {}))) + (is (match? {:error #"must be a qualified symbol pointing at an existing var"} + (paths/expand-paths {:paths-fn :foo}))) + (is (match? {:error #"must be a qualified symbol pointing at an existing var"} + (paths/expand-paths {:paths-fn 'foo}))) + (is (match? {:error #"must be a qualified symbol pointing at an existing var"} + (paths/expand-paths {:paths-fn 'clerk.test.non-existant-name-space/bar}))) + (is (match? {:error #"must be a qualified symbol pointing at an existing var"} + (paths/expand-paths {:paths-fn 'clojure.core/non-existant-var}))) + (is (match? {:error #"must be a qualified symbol pointing at an existing var"} + (paths/expand-paths {:paths-fn "hi"}))) + (is (match? {:error #"nothing to build"} + (paths/expand-paths {:paths []}))) + (is (match? {:error #"An error occured invoking"} + (paths/expand-paths {:paths-fn 'clojure.core/inc}))) + (is (match? {:error #"must compute to a sequential value."} + (paths/expand-paths {:paths-fn 'clojure.core/+}))) + (is (match? {:error "`:index` must be either an instance of java.net.URL or a string and point to an existing file"} + (paths/expand-paths {:index ["book.clj"]})))))