From 5ae3e567357299b5680f1d392311b7e36e0ebd49 Mon Sep 17 00:00:00 2001 From: Martin Kavalar Date: Tue, 20 Jun 2023 10:18:29 +0200 Subject: [PATCH 01/23] Don't run existing files through `fs/glob` (#519) Fixes #504. Also improves performance of homepage. --- CHANGELOG.md | 3 +++ src/nextjournal/clerk/builder.clj | 6 ++++-- test/nextjournal/clerk/builder_test.clj | 4 ++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae081ff17..ce76a1a3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,8 +20,11 @@ Changes can be: * πŸ’« Assign `:name` to every viewer in `default-viewers` +* 🐞 Don't run existing files through `fs/glob`, fixes [#504](https://github.com/nextjournal/clerk/issues/504). Also improves performance of homepage. + * 🐞 Show correct non-var return value for deflike form, fixes [#499](https://github.com/nextjournal/clerk/issues/499) + ## 0.14.919 (2023-06-13) * 🚨 Breaking Changes: diff --git a/src/nextjournal/clerk/builder.clj b/src/nextjournal/clerk/builder.clj index 998c83298..7cd5edba5 100644 --- a/src/nextjournal/clerk/builder.clj +++ b/src/nextjournal/clerk/builder.clj @@ -185,7 +185,9 @@ (if error opts (->> resolved-paths - (mapcat (partial fs/glob ".")) + (mapcat (fn [path] (if (fs/exists? path) + [path] + (fs/glob "." path)))) (filter (complement fs/directory?)) (mapv (comp str fs/file)) (hash-map :expanded-paths) @@ -197,7 +199,7 @@ #_(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"]) +#_(do (defn my-paths [] ["notebooks/h*.clj"])Β§ (expand-paths {:paths-fn `my-paths})) #_(expand-paths {:paths ["notebooks/viewers**"]}) diff --git a/test/nextjournal/clerk/builder_test.clj b/test/nextjournal/clerk/builder_test.clj index d952ad6b5..947bc9644 100644 --- a/test/nextjournal/clerk/builder_test.clj +++ b/test/nextjournal/clerk/builder_test.clj @@ -58,6 +58,10 @@ (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 {}))) From 9361e1bd6e666af9b415d80fbcedecb2adb88112 Mon Sep 17 00:00:00 2001 From: Andrea Amantini Date: Tue, 20 Jun 2023 11:52:56 +0200 Subject: [PATCH 02/23] Improve Table of Contents (#512) Overhaul design and fix re-rendering issues. Also added suport for chapter expansion. --------- Co-authored-by: Philippa Markovics Co-authored-by: Martin Kavalar --- CHANGELOG.md | 3 +- notebooks/cherry.clj | 17 +- notebooks/document_linking.clj | 4 + notebooks/how_clerk_works.clj | 2 +- notebooks/meta_toc.clj | 47 +++ notebooks/profile.clj | 8 +- notebooks/tracer.clj | 4 +- package.json | 2 +- src/nextjournal/clerk/analyzer.clj | 2 +- src/nextjournal/clerk/builder.clj | 1 + src/nextjournal/clerk/home.clj | 4 +- src/nextjournal/clerk/render.cljs | 129 +++------ src/nextjournal/clerk/render/navbar.cljs | 352 +++++++++++------------ src/nextjournal/clerk/viewer.cljc | 12 + yarn.lock | 54 +--- 15 files changed, 305 insertions(+), 336 deletions(-) create mode 100644 notebooks/meta_toc.clj diff --git a/CHANGELOG.md b/CHANGELOG.md index ce76a1a3b..1df3701a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,13 +18,14 @@ Changes can be: * 🚨 Rename `:nextjournal.clerk/opts` to `:nextjournal.clerk/render-opts` to clarify this options map is available as the second arg to parametrize the `:render-fn`. Still support the `:nextjournal.clerk/opts` for now. +* πŸ“– Improve Table of Contents design and fixing re-rendering issues. Also added suport for chapter expansion. + * πŸ’« Assign `:name` to every viewer in `default-viewers` * 🐞 Don't run existing files through `fs/glob`, fixes [#504](https://github.com/nextjournal/clerk/issues/504). Also improves performance of homepage. * 🐞 Show correct non-var return value for deflike form, fixes [#499](https://github.com/nextjournal/clerk/issues/499) - ## 0.14.919 (2023-06-13) * 🚨 Breaking Changes: diff --git a/notebooks/cherry.clj b/notebooks/cherry.clj index 7d458bd59..e04c6883d 100644 --- a/notebooks/cherry.clj +++ b/notebooks/cherry.clj @@ -1,6 +1,7 @@ -;; # Compile viewer functions using cherry +;; # πŸ’ Compile viewer functions using cherry (ns cherry - {:nextjournal.clerk/render-evaluator :cherry} + {:nextjournal.clerk/render-evaluator :cherry + :nextjournal.clerk/toc true} (:require [nextjournal.clerk :as clerk] [nextjournal.clerk.viewer :as viewer])) @@ -19,7 +20,7 @@ (pr-str (interleave (cycle [1]) (frequencies [1 2 3 1 2 3])))))]) {:nextjournal.clerk/render-evaluator :sci} nil) -;; Better performance: +;; ## ⏱️ Better performance: (clerk/with-viewer '(fn [value] @@ -54,7 +55,7 @@ :key "id" :fields ["rate"]}}] :projection {:type "albersUsa"} :mark "geoshape" :encoding {:color {:field "rate" :type "quantitative"}}}) -;; ## Input text and compile on the fly with cherry +;; ## πŸ”¨ Input text and compile on the fly with cherry (clerk/with-viewer {;; :evaluator :cherry @@ -80,7 +81,7 @@ {:nextjournal.clerk/render-evaluator :cherry} nil) -;; ## Functions defined with `defn` are part of the global context +;; ## 🌍 Functions defined with `defn` are part of the global context ;; (for now) and can be called in successive expressions @@ -88,7 +89,7 @@ (clerk/eval-cljs-str "(foo 1)") -;; ## Async/await works cherry +;; ## 🚦Async/await works cherry ;; Here we dynamically import a module, await its value and then pull out the ;; default function, which we expose as a global function. Because s-expressions @@ -112,7 +113,7 @@ [nextjournal.clerk.render/render-promise (emoji-picker)]) nil) -;; ## Macros +;; ## 🧩 Macros (clerk/eval-cljs '(defn clicks [] @@ -123,7 +124,7 @@ (clerk/with-viewer '(fn [_] (this-as this [clicks])) nil) -;; ## Evaluator option as form metadata +;; ## πŸ‘» Evaluator option as form metadata ^{::clerk/visibility {:code :hide :result :hide} ::clerk/no-cache true} (clerk/add-viewers! [(assoc viewer/code-block-viewer :transform-fn (viewer/update-val :text))]) diff --git a/notebooks/document_linking.clj b/notebooks/document_linking.clj index 8d7217005..b7a699123 100644 --- a/notebooks/document_linking.clj +++ b/notebooks/document_linking.clj @@ -1,7 +1,9 @@ ;; # πŸ–‡οΈ Document Linking (ns document-linking + {:nextjournal.clerk/toc true} (:require [nextjournal.clerk :as clerk])) +;; ## `clerk/doc-url` helper ;; The helper `clerk/doc-url` allows to reference notebooks by path. We currently support relative paths with respect to the directory which started the Clerk application. An optional trailing hash fragment can appended to the path in order for the page to be scrolled up to the indicated identifier. (clerk/html [:ol @@ -12,6 +14,8 @@ [:li [:a {:href (clerk/doc-url "book.clj")} "The πŸ“•Book"]] [:li [:a {:href (clerk/doc-url "")} "Homepage"]]]) + +;; ## Client Side ;; The same functionality is available in the SCI context when building render functions. (clerk/with-viewer '(fn [_ _] diff --git a/notebooks/how_clerk_works.clj b/notebooks/how_clerk_works.clj index b0cc3fee8..fc457106c 100644 --- a/notebooks/how_clerk_works.clj +++ b/notebooks/how_clerk_works.clj @@ -1,6 +1,6 @@ ;; # How Clerk Works πŸ•΅πŸ»β€β™€οΈ -^{:nextjournal.clerk/toc true} (ns how-clerk-works + {:nextjournal.clerk/toc true} (:require [next.jdbc :as jdbc] [nextjournal.clerk :as clerk] [nextjournal.clerk.parser :as parser] diff --git a/notebooks/meta_toc.clj b/notebooks/meta_toc.clj new file mode 100644 index 000000000..1d9a94052 --- /dev/null +++ b/notebooks/meta_toc.clj @@ -0,0 +1,47 @@ +;; # πŸ“• Meta Table of Contents +(ns meta-toc + {:nextjournal.clerk/toc true} + (:require [nextjournal.clerk :as clerk] + [nextjournal.clerk.parser :as parser] + [nextjournal.markdown.transform :as md.transform] + [nextjournal.clerk.viewer :as v])) + +;; This assembles the table of contents programmatically from a +;; collection of notebooks. + +;; ## Notebooks +(def notebooks + ["notebooks/how_clerk_works.clj" + "notebooks/cherry.clj" + "notebooks/tracer.clj" + "notebooks/document_linking.clj"]) + +(defn md-toc->navbar-items [current-notebook file {:keys [children]}] + (mapv (fn [{:as item :keys [emoji attrs]}] + {:title (md.transform/->text item) + :expanded? (= current-notebook file) + :scroll-to-anchor? false + :emoji emoji + :path (clerk/doc-url file (:id attrs)) + :items (md-toc->navbar-items current-notebook file item)}) children)) + +(defn meta-toc [current-notebook paths] + (into [] + (mapcat (comp (fn [{:keys [toc file]}] (md-toc->navbar-items current-notebook file toc)) + (partial parser/parse-file {:doc? true}))) + paths)) + +(def book-viewer + (update v/notebook-viewer + :transform-fn (fn [original-transform] + (fn [wrapped-value] + (-> wrapped-value + original-transform + (assoc :nextjournal/opts {:expandable? true}) + (assoc-in [:nextjournal/value :toc] + (meta-toc (:file (v/->value wrapped-value)) notebooks))))))) + +#_(clerk/add-viewers! [book-viewer]) + +;; Test actual cross-doc toc +(clerk/add-viewers! [book-viewer]) diff --git a/notebooks/profile.clj b/notebooks/profile.clj index 4432a9074..6fd57f926 100644 --- a/notebooks/profile.clj +++ b/notebooks/profile.clj @@ -17,11 +17,15 @@ -(do (time (analyzer/build-graph analyzed)) :done) +(time + (prof/profile (dotimes [_ 10] + (analyzer/build-graph analyzed)))) -(prof/profile (analyzer/build-graph analyzed)) +(prof/profile + (dotimes [_ 10] + (nextjournal.clerk/show! "notebooks/rule_30.clj"))) (prof/profile (analyzer/build-graph analyzed)) diff --git a/notebooks/tracer.clj b/notebooks/tracer.clj index 0f6f4524f..30b22eae2 100644 --- a/notebooks/tracer.clj +++ b/notebooks/tracer.clj @@ -1,6 +1,8 @@ ;; # πŸ‘©πŸ»β€πŸ’» Show the code -^{:nextjournal.clerk/visibility {:code :hide}} + (ns tracer + {:nextjournal.clerk/visibility {:code :hide} + :nextjournal.clerk/toc true} (:require [nextjournal.clerk :as clerk])) ;; ## Tracer diff --git a/package.json b/package.json index 25d278c04..43e4f1880 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "@nextjournal/lezer-clojure": "1.0.0", "d3-require": "^1.2.4", "emoji-regex": "^10.0.0", - "framer-motion": "^6.2.8", + "framer-motion": "^10.12.16", "katex": "^0.12.0", "lezer-clojure": "1.0.0-rc.2", "markdown-it": "^12.2.0", diff --git a/src/nextjournal/clerk/analyzer.clj b/src/nextjournal/clerk/analyzer.clj index 44fe90c05..c15a6f30a 100644 --- a/src/nextjournal/clerk/analyzer.clj +++ b/src/nextjournal/clerk/analyzer.clj @@ -379,7 +379,7 @@ (or (when-let [{:as cached-analysis :keys [file-sha]} (@!file->analysis-cache file)] (when (= file-sha current-file-sha) cached-analysis)) - (let [analysis (analyze-doc {:file-sha current-file-sha} (parser/parse-file {} file))] + (let [analysis (analyze-doc {:file-sha current-file-sha :graph (dep/graph)} (parser/parse-file {} file))] (swap! !file->analysis-cache assoc file analysis) analysis)))) ([state file] diff --git a/src/nextjournal/clerk/builder.clj b/src/nextjournal/clerk/builder.clj index 7cd5edba5..140a75bc8 100644 --- a/src/nextjournal/clerk/builder.clj +++ b/src/nextjournal/clerk/builder.clj @@ -36,6 +36,7 @@ "fragments" "hiding_clerk_metadata" "js_import" + "meta_toc" "multiviewer" "pagination" "paren_soup" diff --git a/src/nextjournal/clerk/home.clj b/src/nextjournal/clerk/home.clj index 0be748e6f..bd9fb0f9c 100644 --- a/src/nextjournal/clerk/home.clj +++ b/src/nextjournal/clerk/home.clj @@ -170,8 +170,8 @@ [:a.ml-3 {:href "#"} "πŸ™ˆ Controlling Visibility"]]] #_[:div.mt-6 (clerk/with-viewer filter-input-viewer `!filter)] - [:div.flex.mt-6.border-t.font-sans - [:div {:class (str "w-1/2 pt-6 " (when-not (seq @!filter) "pr-6 border-r"))} + [:div.flex.mt-6.border-t.dark:border-slate-700.font-sans + [:div {:class (str "w-1/2 pt-6 " (when-not (seq @!filter) "pr-6 border-r dark:border-slate-700"))} [:h4.text-lg "All Notebooks"] (let [{:keys [query selected-path]} @!filter] (clerk/with-viewer index-viewer {:paths (filter (partial query-fn query) @!notebooks) diff --git a/src/nextjournal/clerk/render.cljs b/src/nextjournal/clerk/render.cljs index 808fe1d29..411779734 100644 --- a/src/nextjournal/clerk/render.cljs +++ b/src/nextjournal/clerk/render.cljs @@ -1,5 +1,5 @@ (ns nextjournal.clerk.render - (:require ["framer-motion" :refer [motion]] + (:require ["framer-motion" :refer [m LazyMotion domAnimation]] ["react" :as react] ["react-dom/client" :as react-client] ["vh-sticky-table-header" :as sticky-table-header] @@ -19,14 +19,12 @@ [nextjournal.clerk.render.navbar :as navbar] [nextjournal.clerk.render.window :as window] [nextjournal.clerk.viewer :as viewer] - [nextjournal.markdown.transform :as md.transform] [reagent.core :as r] [reagent.ratom :as ratom] [sci.core :as sci] [sci.ctx-store] [shadow.cljs.modern :refer [defclass]])) - (r/set-default-compiler! (r/create-compiler {:function-components true})) (declare inspect inspect-presented reagent-viewer html html-viewer) @@ -36,47 +34,31 @@ (defn reagent-atom? [x] (satisfies? ratom/IReactiveAtom x)) -(defn toc-items [items] - (reduce - (fn [acc {:as item :keys [content children attrs emoji]}] - (if content - (let [title (md.transform/->text item)] - (->> {:title title - :emoji emoji - :path (str "#" (:id attrs)) - :items (toc-items children)} - (conj acc) - vec)) - (toc-items (:children item)))) - [] - items)) - -(defn dark-mode-toggle [!state] - (let [{:keys [dark-mode?]} @!state - spring {:type :spring :stiffness 200 :damping 10}] +(defn dark-mode-toggle [!dark-mode?] + (let [spring {:type :spring :stiffness 200 :damping 10}] [:div.relative.dark-mode-toggle [:button.text-slate-400.hover:text-slate-600.dark:hover:text-white.cursor-pointer - {:on-click #(swap! !state assoc :dark-mode? (not dark-mode?))} - (if dark-mode? - [:> (.-svg motion) + {:on-click #(swap! !dark-mode? not)} + (if @!dark-mode? + [:> (.-svg m) {:xmlns "http://www.w3.org/2000/svg" :class "w-5 h-5 md:w-4 md:h-4" :viewBox "0 0 50 50" :key "moon"} - [:> (.-path motion) + [:> (.-path m) {:d "M 43.81 29.354 C 43.688 28.958 43.413 28.626 43.046 28.432 C 42.679 28.238 42.251 28.198 41.854 28.321 C 36.161 29.886 30.067 28.272 25.894 24.096 C 21.722 19.92 20.113 13.824 21.683 8.133 C 21.848 7.582 21.697 6.985 21.29 6.578 C 20.884 6.172 20.287 6.022 19.736 6.187 C 10.659 8.728 4.691 17.389 5.55 26.776 C 6.408 36.163 13.847 43.598 23.235 44.451 C 32.622 45.304 41.28 39.332 43.816 30.253 C 43.902 29.96 43.9 29.647 43.81 29.354 Z" :fill "currentColor" :initial "initial" :animate "animate" :variants {:initial {:scale 0.6 :rotate 90} :animate {:scale 1 :rotate 0 :transition spring}}}]] - [:> (.-svg motion) + [:> (.-svg m) {:key "sun" :class "w-5 h-5 md:w-4 md:h-4" :viewBox "0 0 24 24" :fill "none" :xmlns "http://www.w3.org/2000/svg"} - [:>(.-circle motion) + [:> (.-circle m) {:cx "11.9998" :cy "11.9998" :r "5.75375" @@ -85,7 +67,7 @@ :animate "animate" :variants {:initial {:scale 1.5} :animate {:scale 1 :transition spring}}}] - [:> (.-g motion) + [:> (.-g m) {:initial "initial" :animate "animate" :variants {:initial {:rotate 45} @@ -106,14 +88,13 @@ (.remove class-list "dark"))) (localstorage/set-item! local-storage-dark-mode-key dark-mode?)) -(defn setup-dark-mode! [!state] - (let [{:keys [dark-mode?]} @!state] - (add-watch !state ::dark-mode - (fn [_ _ old {:keys [dark-mode?]}] - (when (not= (:dark-mode? old) dark-mode?) - (set-dark-mode! dark-mode?)))) - (when dark-mode? - (set-dark-mode! dark-mode?)))) +(defn setup-dark-mode! [!dark-mode?] + (add-watch !dark-mode? ::dark-mode-watch + (fn [_ _ old dark-mode?] + (when (not= old dark-mode?) + (set-dark-mode! dark-mode?)))) + (when @!dark-mode? + (set-dark-mode! @!dark-mode?))) (defonce !eval-counter (r/atom 0)) @@ -162,68 +143,40 @@ (.preventDefault e) (clerk-eval (list 'nextjournal.clerk.webserver/navigate! {:nav-path path :skip-history? true})))) -(defn render-notebook [{:as _doc xs :blocks :keys [bundle? doc-css-class sidenotes? toc toc-visibility header footer]} opts] - (r/with-let [local-storage-key "clerk-navbar" - navbar-width 220 - !state (r/atom {:toc (toc-items (:children toc)) - :visibility toc-visibility - :md-toc toc - :dark-mode? (localstorage/get-item local-storage-dark-mode-key) - :theme {:slide-over "bg-slate-100 dark:bg-gray-800 font-sans border-r dark:border-slate-900"} - :width navbar-width - :mobile? (and (exists? js/innerWidth) (< js/innerWidth 640)) - :mobile-width 300 - :local-storage-key local-storage-key - :set-hash? (not bundle?) - :scroll-el (when (exists? js/document) (js/document.querySelector "html")) - :open? (if-some [stored-open? (localstorage/get-item local-storage-key)] - stored-open? - (not= :collapsed toc-visibility))}) +(defn render-notebook [{:as doc xs :blocks :keys [bundle? doc-css-class sidenotes? toc toc-visibility header footer]} + {:as render-opts :keys [!expanded-at expandable-toc?]}] + (r/with-let [!dark-mode? (r/atom (localstorage/get-item local-storage-dark-mode-key)) root-ref-fn (fn [el] (when (and el (exists? js/document)) - (setup-dark-mode! !state) + (setup-dark-mode! !dark-mode?) (when-some [heading (when (and (exists? js/location) (not bundle?)) (try (some-> js/location .-hash not-empty js/decodeURI (subs 1) js/document.getElementById) (catch js/Error _ (js/console.warn (str "Clerk render-notebook, invalid hash: " (.-hash js/location))))))] (js/requestAnimationFrame #(.scrollIntoViewIfNeeded heading)))))] - (let [{:keys [md-toc mobile? open? visibility]} @!state - doc-inset (cond - mobile? 0 - open? navbar-width - :else 0)] - (when-not (= md-toc toc) - (swap! !state assoc :toc (toc-items (:children toc)) :md-toc toc :open? open?)) - (when-not (= visibility toc-visibility) - (swap! !state assoc :visibility toc-visibility :open? (not= :collapsed toc-visibility))) - [:div.flex - {:ref root-ref-fn} - [:div.fixed.top-2.left-2.md:left-auto.md:right-2.z-10 - [dark-mode-toggle !state]] - (when (and toc toc-visibility) - [:<> - [navbar/toggle-button !state - [:<> - [:svg {:xmlns "http://www.w3.org/2000/svg" :fill "none" :viewBox "0 0 24 24" :stroke "currentColor" :width 20 :height 20} - [:path {:stroke-linecap "round" :stroke-linejoin "round" :stroke-width "2" :d "M4 6h16M4 12h16M4 18h16"}]] - [:span.uppercase.tracking-wider.ml-1.font-bold - {:class "text-[12px]"} "ToC"]] - {:class "z-10 fixed right-2 top-2 md:right-auto md:left-3 md:top-[7px] text-slate-400 font-sans text-xs hover:underline cursor-pointer flex items-center bg-white dark:bg-gray-900 py-1 px-3 md:p-0 rounded-full md:rounded-none border md:border-0 border-slate-200 dark:border-gray-500 shadow md:shadow-none dark:text-slate-400 dark:hover:text-white"}] - [navbar/panel !state [navbar/navbar !state]]]) - [:div.flex-auto.w-screen.scroll-container - (into - [:> (.-div motion) + [:> LazyMotion {:features domAnimation} + [:div.flex + {:ref root-ref-fn} + [:div.fixed.top-2.left-2.md:left-auto.md:right-2.z-10 + [dark-mode-toggle !dark-mode?]] + (when (and toc toc-visibility) + [navbar/view toc (assoc render-opts :set-hash? (not bundle?) :toc-visibility toc-visibility)]) + [:div.flex-auto.w-screen.scroll-container + (into + [:> (.-div m) + (merge {:key "notebook-viewer" - :initial (when toc-visibility {:margin-left doc-inset}) - :animate (when toc-visibility {:margin-left doc-inset}) - :transition navbar/spring :class (cond-> (or doc-css-class [:flex :flex-col :items-center :notebook-viewer :flex-auto]) - sidenotes? (conj :sidenotes-layout))}] - ;; TODO: restore react keys via block-id - ;; ^{:key (str processed-block-id "@" @!eval-counter)} - - (inspect-children opts) (concat (when header [header]) xs (when footer [footer])))]]))) + sidenotes? (conj :sidenotes-layout))} + (when (and toc (not (navbar/mobile?))) + (let [inset {:margin-left (if (and toc-visibility (:toc-open? @!expanded-at)) navbar/width 0)}] + {:initial inset + :animate inset + :transition navbar/spring})))] + ;; TODO: restore react keys via block-id + ;; ^{:key (str processed-block-id "@" @!eval-counter)} + (inspect-children render-opts) (concat (when header [header]) xs (when footer [footer])))]]])) (defn opts->query [opts] (->> opts diff --git a/src/nextjournal/clerk/render/navbar.cljs b/src/nextjournal/clerk/render/navbar.cljs index f593c6749..d84346e2d 100644 --- a/src/nextjournal/clerk/render/navbar.cljs +++ b/src/nextjournal/clerk/render/navbar.cljs @@ -1,214 +1,192 @@ (ns nextjournal.clerk.render.navbar - (:require ["framer-motion" :as framer-motion :refer [motion AnimatePresence]] - [nextjournal.clerk.render.localstorage :as localstorage] + (:require ["framer-motion" :as framer-motion :refer [m AnimatePresence]] [applied-science.js-interop :as j] [clojure.string :as str] + [nextjournal.clerk.render.hooks :as hooks] + [nextjournal.clerk.render.localstorage :as localstorage] [reagent.core :as r])) (defn stop-event! [event] (.preventDefault event) (.stopPropagation event)) +(def !scroll-animation (atom false)) + (defn scroll-to-anchor! "Uses framer-motion to animate scrolling to a section. `offset` here is just a visual offset. It looks way nicer to stop just before a section instead of having it glued to the top of the viewport." - [!state anchor] - (let [{:keys [mobile? scroll-animation scroll-el set-hash? visible?]} @!state + [anchor] + (let [scroll-el (js/document.querySelector "html") scroll-top (.-scrollTop scroll-el) offset 40] - (when scroll-animation - (.stop scroll-animation)) - (when scroll-el - (swap! !state assoc - :scroll-animation (.animate framer-motion - scroll-top - (+ scroll-top (.. (js/document.getElementById (subs anchor 1)) getBoundingClientRect -top)) - (j/lit {:onUpdate #(j/assoc! scroll-el :scrollTop (- % offset)) - :onComplete #(when set-hash? (.pushState js/history #js {} "" anchor)) - :type :spring - :duration 0.4 - :bounce 0.15})) - :visible? (if mobile? false visible?))))) + (when-let [anim @!scroll-animation] + (.stop anim)) + (reset! !scroll-animation + (.animate framer-motion + scroll-top + (+ scroll-top (.. (js/document.getElementById (subs anchor 1)) getBoundingClientRect -top)) + (j/lit {:onUpdate #(j/assoc! scroll-el :scrollTop (- % offset)) + :onComplete #(reset! !scroll-animation nil) + :type :spring + :duration 0.4 + :bounce 0.15}))))) -(defn theme-class [theme key] - (-> {:project "py-3" - :toc "py-3" - :heading "mt-1 md:mt-0 text-xs md:text-[12px] uppercase tracking-wider text-slate-500 dark:text-slate-400 font-medium px-3 mb-1 leading-none" - :back "text-xs md:text-[12px] leading-normal text-slate-500 dark:text-slate-400 md:hover:bg-slate-200 md:dark:hover:bg-slate-700 font-normal px-3 py-1" - :expandable "text-base md:text-[14px] leading-normal md:hover:bg-slate-200 md:dark:hover:bg-slate-700 dark:text-white px-3 py-2 md:py-1" - :triangle "text-slate-500 dark:text-slate-400" - :item "text-base md:text-[14px] md:hover:bg-slate-200 md:dark:hover:bg-slate-700 dark:text-white px-3 py-2 md:py-1 leading-normal" - :icon "text-slate-500 dark:text-slate-400" - :slide-over "font-sans bg-white border-r" - :slide-over-unpinned "shadow-xl" - :toggle "text-slate-500 absolute right-2 top-[11px] cursor-pointer z-10"} - (merge theme) - (get key))) +(defn navigate-or-scroll! [event {:as item :keys [path]} {:keys [set-hash?]}] + (let [[path-name search] (.split path "?") + current-path-name (.-pathname js/location) + anchor-only? (str/starts-with? path-name "#") + [_ hash] (some-> search (.split "#"))] + (when (or (and search hash (= path-name current-path-name)) anchor-only?) + (let [anchor (if anchor-only? path-name (str "#" hash))] + (.preventDefault event) + (when set-hash? + (.pushState js/history #js {} "" anchor)) + (scroll-to-anchor! anchor))))) -(defn toc-items [!state items & [options]] - (let [{:keys [theme]} @!state] - (into - [:div] - (map - (fn [{:keys [path title items]}] - [:<> - [:a.flex - {:href path - :class (theme-class theme :item) - :on-click (fn [event] - (stop-event! event) - (scroll-to-anchor! !state path))} - [:div (merge {} options) title]] - (when (seq items) - [:div.ml-3 - [toc-items !state items]])]) - items)))) +(defn render-items [items {:as render-opts :keys [!expanded-at expandable-toc? mobile-toc?]}] + (into + [:div] + (map-indexed + (fn [i {:as item :keys [emoji path title items]}] + (let [label (or title (str/capitalize (last (str/split path #"/")))) + expanded? (get-in @!expanded-at [:toc path])] + [:div.text-base.leading-normal.dark:text-white + {:class "md:text-[14px]"} + (if (seq items) + [:div.flex.relative.hover:bg-slate-200.dark:hover:bg-slate-900.rounded.group.transition + {:class (str "ml-[8px] mr-[4px] gap-[2px] " + (if expandable-toc? "pl-[2px] pr-[6px]" "px-[6px]"))} + (when expandable-toc? + [:div.flex.items-center.justify-center.relative.flex-shrink-0.border.border-transparent.hover:border-indigo-700.hover:bg-indigo-500.dark:hover:bg-indigo-700.hover:shadow.text-slate-600.hover:text-white.dark:text-slate-400.dark:hover:text-white.rounded.cursor-pointer.active:scale-95 + {:class "w-[18px] h-[18px] top-[5px]" + :on-click (fn [event] + (stop-event! event) + (swap! !expanded-at update-in [:toc path] not))} + [:svg.w-3.h-3.transition + {:xmlns "http://www.w3.org/2000/svg" :fill "none" :viewBox "0 0 24 24" :stroke-width "1.5" :stroke "currentColor" + :class (if expanded? "rotate-90" "rotate-0")} + [:path {:stroke-linecap "round" :stroke-linejoin "round" :d "M8.25 4.5l7.5 7.5-7.5 7.5"}]]]) + [:a.py-1.flex.flex-auto.gap-1.group-hover:text-indigo-700.dark:group-hover:text-white.hover:underline.decoration-indigo-300.dark:decoration-slate-400.underline-offset-2 + {:href path + :class (when (and expandable-toc? expanded?) "font-medium") + :on-click (fn [event] + (navigate-or-scroll! event item render-opts) + (when mobile-toc? + (swap! !expanded-at assoc :toc-open? false)))} + (when emoji + [:span.flex-shrink-0 emoji]) + [:span label]] + (when (and expandable-toc? expanded?) + [:span.absolute.bottom-0.border-l.border-slate-300.dark:border-slate-600 + {:class "top-[25px] left-[10px]"}])] + [:a.flex.flex-auto.gap-1.py-1.rounded.hover:bg-slate-200.dark:hover:bg-slate-900.hover:text-indigo-700.dark:hover:text-white.hover:underline.decoration-indigo-300.dark:decoration-slate-400.underline-offset-2.transition + {:class "px-[6px] ml-[8px] mr-[4px]" + :href path + :on-click (fn [event] + (navigate-or-scroll! event item render-opts) + (when mobile-toc? + (swap! !expanded-at assoc :toc-open? false)))} + (when emoji + [:span.flex-shrink-0 emoji]) + [:span label]]) + (when (and (seq items) (or (not expandable-toc?) (and expandable-toc? expanded?))) + [:div.relative + {:class (str (if expandable-toc? "ml-[16px] " "ml-[19px] ") + (when expanded? "mb-2"))} + (when (and expandable-toc? expanded?) + [:span.absolute.top-0.border-l.border-slate-300.dark:border-slate-600 + {:class "left-[2px] bottom-[8px]"}]) + [render-items items render-opts]])])) + items))) -(defn navbar-items [!state items update-at] - (let [{:keys [mobile? theme]} @!state] - (into - [:div] - (map-indexed - (fn [i {:keys [emoji path title expanded? loading? items toc]}] - (let [label (or title (str/capitalize (last (str/split path #"/"))))] - [:<> - (if (seq items) - [:div.flex.cursor-pointer - {:class (theme-class theme :expandable) - :on-click (fn [event] - (stop-event! event) - (swap! !state assoc-in (vec (conj update-at i :expanded?)) (not expanded?)))} - [:div.flex.items-center.justify-center.flex-shrink-0 - {:class "w-[20px] h-[20px] mr-[4px]"} - [:svg.transform.transition - {:viewBox "0 0 100 100" - :class (str (theme-class theme :triangle) " " - "w-[10px] h-[10px] " - (if expanded? "rotate-180" "rotate-90"))} - [:polygon {:points "5.9,88.2 50,11.8 94.1,88.2 " :fill "currentColor"}]]] - [:div label]] - [:a.flex - {:href path - :class (theme-class theme :item) - :on-click (fn [] - (when toc - (swap! !state assoc-in (vec (conj update-at i :loading?)) true) - (js/setTimeout - (fn [] - (swap! !state #(-> (assoc-in % (vec (conj update-at i :loading?)) false) - (assoc :toc toc)))) - 500)) - (when mobile? - (swap! !state assoc :visible? false)))} - [:div.flex.items-center.justify-center.flex-shrink-0 - {:class "w-[20px] h-[20px] mr-[4px]"} - (if loading? - [:svg.animate-spin.h-3.w-3.text-slate-500.dark:text-slate-400 - {:xmlns "http://www.w3.org/2000/svg" :fill "none" :viewBox "0 0 24 24"} - [:circle.opacity-25 {:cx "12" :cy "12" :r "10" :stroke "currentColor" :stroke-width "4"}] - [:path.opacity-75 {:fill "currentColor" :d "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"}]] - (if emoji - [:div emoji] - [:svg.h-4.w-4 - {:xmlns "http://www.w3.org/2000/svg" :fill "none" :viewBox "0 0 24 24" :stroke "currentColor" - :class (theme-class theme :icon)} - [:path {:stroke-linecap "round" :stroke-linejoin "round" :stroke-width "2" :d "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"}]]))] - [:div - (if emoji - (subs label (count emoji)) - label)]]) - (when (and (seq items) expanded?) - [:div.ml-3 - [navbar-items !state items (vec (conj update-at i :items))]])])) - items)))) +(def local-storage-key "clerk-navbar") -(defn navbar [!state] - (let [{:keys [items theme toc]} @!state - items? (seq items)] - [:div.relative.overflow-x-hidden.h-full - (when items? - [:div.absolute.left-0.top-0.w-full.h-full.overflow-y-auto.transform.transition.pb-10 - {:class (str (theme-class theme :project) " " - (if toc "-translate-x-full" "translate-x-0"))} - [:div.px-3.mb-1 - {:class (theme-class theme :heading)} - "Project"] - [navbar-items !state (:items @!state) [:items]]]) - [:div.absolute.left-0.top-0.w-full.h-full.overflow-y-auto.transform - {:class (str (when items? "transition ") - (theme-class theme :toc) " " - (if toc "translate-x-0" "translate-x-full"))} - (if (and (seq items) (seq toc)) - [:div.px-3.py-1.cursor-pointer - {:class (theme-class theme :back) - :on-click #(swap! !state dissoc :toc)} - "← Back to project"] - [:div.px-3.mb-1 - {:class (theme-class theme :heading)} - "TOC"]) - [toc-items !state toc (when (< (count toc) 2) {:class "font-medium"})]]])) +(defn mobile? [] + (and (exists? js/innerWidth) (< js/innerWidth 640))) + +(def spring {:type :spring :duration 0.35 :bounce 0.1}) -(defn toggle-button [!state content & [opts]] - (let [{:keys [mobile? mobile-open? open?]} @!state] +(defn mobile-backdrop [{:keys [!expanded-at]}] + [:> (.-div m) + {:key "mobile-toc-backdrop" + :class "fixed z-10 bg-gray-500 bg-opacity-75 left-0 top-0 bottom-0 right-0" + :initial {:opacity 0} + :animate {:opacity 1} + :exit {:opacity 0} + :on-click #(swap! !expanded-at assoc :toc-open? false) + :transition spring}]) + +(defn close-button [{:keys [!expanded-at mobile-toc?]}] + [:div.toc-toggle.rounded.hover:bg-slate-200.active:bg-slate-300.dark:hover:bg-slate-900.active:dark:bg-slate-950.p-1.text-slate-500.hover:text-slate-600.dark:hover:text-white.absolute.right-2.cursor-pointer.z-10 + {:class "top-[11px] -mt-1 -mr-1" + :on-click #(swap! !expanded-at update :toc-open? not)} + (if mobile-toc? + [:svg.h-5.w-5 {:xmlns "http://www.w3.org/2000/svg" :fill "none" :viewBox "0 0 24 24" :stroke "currentColor" :stroke-width "2"} + [:path {:stroke-linecap "round" :stroke-linejoin "round" :d "M6 18L18 6M6 6l12 12"}]] + [:svg.w-4.w-4 {:xmlns "http://www.w3.org/2000/svg" :fill "none" :viewBox "0 0 24 24" :stroke "currentColor" :stroke-width "2"} + [:path {:stroke-linecap "round" :stroke-linejoin "round" :d "M15 19l-7-7 7-7"}]])]) + +(defn open-button [{:keys [!expanded-at]}] + (r/with-let [ref-fn #(when % + (add-watch !expanded-at ::toc-open-watch + (fn [_ _ old {:keys [toc-open?]}] + (when (not= (:toc-open? old) toc-open?) + (localstorage/set-item! local-storage-key toc-open?)))))] [:div.toc-toggle - (merge {:on-click #(swap! !state assoc - (if mobile? :mobile-open? :open?) (if mobile? (not mobile-open?) (not open?)) - :animation-mode (if mobile? :slide-over :push-in))} opts) - content])) + {:ref ref-fn + :class "z-10 fixed right-2 top-2 md:right-auto md:left-3 md:top-[7px] text-slate-400 font-sans text-xs hover:underline cursor-pointer flex items-center bg-white dark:bg-gray-900 py-1 px-3 md:p-0 rounded-full md:rounded-none border md:border-0 border-slate-200 dark:border-gray-500 shadow md:shadow-none dark:text-slate-400 dark:hover:text-white" + :on-click #(swap! !expanded-at assoc :toc-open? true)} + [:svg {:xmlns "http://www.w3.org/2000/svg" :fill "none" :viewBox "0 0 24 24" :stroke "currentColor" :width 20 :height 20} + [:path {:stroke-linecap "round" :stroke-linejoin "round" :stroke-width "2" :d "M4 6h16M4 12h16M4 18h16"}]] + [:span.uppercase.tracking-wider.ml-1.font-bold + {:class "text-[12px]"} "ToC"]])) -(def spring {:type :spring :duration 0.35 :bounce 0.1}) +(def width 220) +(def mobile-width 300) + +(defn toc-panel [toc {:as render-opts :keys [!expanded-at mobile-toc?]}] + [:> (.-div m) + (let [inset-or-x (if mobile-toc? :x :margin-left) + w (if mobile-toc? mobile-width width)] + {:key "toc-panel" + :style {:width w} + :class (str "fixed h-screen z-10 flex-shrink-0 bg-slate-100 dark:bg-gray-800 font-sans border-r dark:border-slate-900 " + (when mobile-toc? "shadow-xl")) + :initial {inset-or-x (* w -1)} + :animate {inset-or-x 0} + :exit {inset-or-x (* w -1)} + :transition spring}) + [close-button render-opts] + [:div.absolute.left-0.top-0.w-full.h-full.overflow-x-hidden.overflow-y-auto.py-3 + [:div.px-3.mb-1.mt-1.md:mt-0.text-xs.uppercase.tracking-wider.text-slate-500.dark:text-slate-400.font-medium.px-3.mb-1.leading-none + {:class "md:text-[12px]"} + "TOC"] + [render-items toc render-opts]]]) -(defn panel [!state content] - (r/with-let [{:keys [local-storage-key]} @!state - component-key (or local-storage-key (gensym)) - resize #(swap! !state assoc :mobile? (< js/innerWidth 640) :mobile-open? false) +(defn view [toc {:as render-opts :keys [!expanded-at toc-visibility]}] + (hooks/use-effect + (fn [] + (swap! !expanded-at assoc :toc-open? (if-some [stored-open? (localstorage/get-item local-storage-key)] + stored-open? + (not= :collapsed toc-visibility))) + (swap! !expanded-at assoc :toc (into {} + (map (juxt identity some?)) + (keep #(when (and (map? %) (:expanded? %)) (:path %)) (tree-seq coll? not-empty toc))))) + [toc]) + (r/with-let [!mobile-toc? (r/atom (mobile?)) + handle-resize #(reset! !mobile-toc? (mobile?)) ref-fn #(if % - (do - (when local-storage-key - (add-watch !state ::persist - (fn [_ _ old {:keys [open?]}] - (when (not= (:open? old) open?) - (localstorage/set-item! local-storage-key open?))))) - (js/addEventListener "resize" resize) - (resize)) - (js/removeEventListener "resize" resize))] - (let [{:keys [animation-mode hide-toggle? open? mobile-open? mobile? mobile-width theme width]} @!state - w (if mobile? mobile-width width)] - [:div.flex.h-screen.toc-panel - {:ref ref-fn} + (js/addEventListener "resize" handle-resize) + (js/removeEventListener "resize" handle-resize))] + (let [{:keys [toc-open?]} @!expanded-at + mobile-toc? @!mobile-toc?] + [:div {:ref ref-fn} + [open-button render-opts] + (when (and mobile-toc? toc-open?) + [mobile-backdrop render-opts]) [:> AnimatePresence {:initial false} - (when (and mobile? mobile-open?) - [:> (.-div motion) - {:key (str component-key "-backdrop") - :class "fixed z-10 bg-gray-500 bg-opacity-75 left-0 top-0 bottom-0 right-0" - :initial {:opacity 0} - :animate {:opacity 1} - :exit {:opacity 0} - :on-click #(swap! !state assoc :mobile-open? false) - :transition spring}]) - (when (or mobile-open? (and (not mobile?) open?)) - [:> (.-div motion) - {:key (str component-key "-nav") - :style {:width w} - :class (str "h-screen z-10 flex-shrink-0 fixed " - (theme-class theme :slide-over) " " - (when mobile? - (theme-class theme :slide-over-unpinned))) - :initial (if (= animation-mode :slide-over) {:x (* w -1)} {:margin-left (* w -1)}) - :animate (if (= animation-mode :slide-over) {:x 0} {:margin-left 0}) - :exit (if (= animation-mode :slide-over) {:x (* w -1)} {:margin-left (* w -1)}) - :transition spring - :on-animation-start #(swap! !state assoc :animating? true) - :on-animation-complete #(swap! !state assoc :animating? false)} - (when-not hide-toggle? - [toggle-button !state - (if mobile? - [:svg.h-5.w-5 {:xmlns "http://www.w3.org/2000/svg" :fill "none" :viewBox "0 0 24 24" :stroke "currentColor" :stroke-width "2"} - [:path {:stroke-linecap "round" :stroke-linejoin "round" :d "M6 18L18 6M6 6l12 12"}]] - [:svg.w-4.w-4 {:xmlns "http://www.w3.org/2000/svg" :fill "none" :viewBox "0 0 24 24" :stroke "currentColor" :stroke-width "2"} - [:path {:stroke-linecap "round" :stroke-linejoin "round" :d "M15 19l-7-7 7-7"}]]) - {:class (theme-class theme :toggle)}]) - content])]]))) + (when toc-open? + [toc-panel toc (assoc render-opts :mobile-toc? mobile-toc?)])]]))) diff --git a/src/nextjournal/clerk/viewer.cljc b/src/nextjournal/clerk/viewer.cljc index 16aa0f7dd..0bcb960cc 100644 --- a/src/nextjournal/clerk/viewer.cljc +++ b/src/nextjournal/clerk/viewer.cljc @@ -1163,6 +1163,13 @@ {:name `header-viewer :transform-fn (comp mark-presented (update-val header))}) +(defn md-toc->navbar-items [{:keys [children]}] + (mapv (fn [{:as node :keys [emoji attrs]}] + {:title (str/replace (md.transform/->text node) (re-pattern (str "^" emoji "[ ]?")) "") + :emoji emoji + :path (str "#" (:id attrs)) + :items (md-toc->navbar-items node)}) children)) + (comment #?(:clj (nextjournal.clerk/recompute!))) (defn process-blocks [viewers {:as doc :keys [ns]}] @@ -1173,10 +1180,15 @@ (map (comp present (partial ensure-wrapped-with-viewers viewers)))))) (assoc :header (present (with-viewers viewers (with-viewer `header-viewer doc)))) #_(assoc :footer (present (footer doc))) + + (update :toc md-toc->navbar-items) + (update :file str) + (select-keys [:atom-var-name->state :blocks :bundle? :doc-css-class :error + :file :open-graph :ns :title diff --git a/yarn.lock b/yarn.lock index ba7a4fb54..2cdf27776 100644 --- a/yarn.lock +++ b/yarn.lock @@ -642,26 +642,15 @@ fill-range@^7.0.1: dependencies: to-regex-range "^5.0.1" -framer-motion@^6.2.8: - version "6.3.0" - resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-6.3.0.tgz#0e50ef04b4fa070fca7d04bc32fb1d64027b7ea7" - integrity sha512-Nm6l2cemuFeSC1fmq9R32sCQs1eplOuZ3r14/PxRDewpE3NUr+ul5ulGRRzk8K0Aa5p76Tedi3sfCUaTPa5fRg== - dependencies: - framesync "6.0.1" - hey-listen "^1.0.8" - popmotion "11.0.3" - style-value-types "5.0.0" - tslib "^2.1.0" +framer-motion@^10.12.16: + version "10.12.16" + resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-10.12.16.tgz#ccba11d216ac370c6bc65fcd9953a61deb54f071" + integrity sha512-w/SfWEIWJkYSgRHYBmln7EhcNo31ao8Xexol8lGXf1pR/tlnBtf1HcxoUmEiEh6pacB4/geku5ami53AAQWHMQ== + dependencies: + tslib "^2.4.0" optionalDependencies: "@emotion/is-prop-valid" "^0.8.2" -framesync@6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/framesync/-/framesync-6.0.1.tgz#5e32fc01f1c42b39c654c35b16440e07a25d6f20" - integrity sha512-fUY88kXvGiIItgNC7wcTOl0SNRCVXMKSWW2Yzfmn7EKNc+MpCzcz9DhdHcdjbrtN3c6R4H5dTY2jiCpPdysEjA== - dependencies: - tslib "^2.1.0" - fsevents@~2.3.2: version "2.3.2" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" @@ -710,11 +699,6 @@ hash.js@^1.0.0, hash.js@^1.0.3: inherits "^2.0.3" minimalistic-assert "^1.0.1" -hey-listen@^1.0.8: - version "1.0.8" - resolved "https://registry.yarnpkg.com/hey-listen/-/hey-listen-1.0.8.tgz#8e59561ff724908de1aa924ed6ecc84a56a9aa68" - integrity sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q== - hmac-drbg@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" @@ -1025,16 +1009,6 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== -popmotion@11.0.3: - version "11.0.3" - resolved "https://registry.yarnpkg.com/popmotion/-/popmotion-11.0.3.tgz#565c5f6590bbcddab7a33a074bb2ba97e24b0cc9" - integrity sha512-Y55FLdj3UxkR7Vl3s7Qr4e9m0onSnP8W7d/xQLsoJM40vs6UKHFdygs6SWryasTZYqugMjm3BepCF4CWXDiHgA== - dependencies: - framesync "6.0.1" - hey-listen "^1.0.8" - style-value-types "5.0.0" - tslib "^2.1.0" - postcss-js@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/postcss-js/-/postcss-js-4.0.0.tgz#31db79889531b80dc7bc9b0ad283e418dce0ac00" @@ -1336,14 +1310,6 @@ style-mod@^4.0.0: resolved "https://registry.yarnpkg.com/style-mod/-/style-mod-4.0.0.tgz#97e7c2d68b592975f2ca7a63d0dd6fcacfe35a01" integrity sha512-OPhtyEjyyN9x3nhPsu76f52yUGXiZcgvsrFVtvTkyGRQJ0XK+GPc6ov1z+lRpbeabka+MYEQxOYRnt5nF30aMw== -style-value-types@5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/style-value-types/-/style-value-types-5.0.0.tgz#76c35f0e579843d523187989da866729411fc8ad" - integrity sha512-08yq36Ikn4kx4YU6RD7jWEv27v4V+PUsOGa4n/as8Et3CuODMJQ00ENeAVXAeydX4Z2j1XHZF1K2sX4mGl18fA== - dependencies: - hey-listen "^1.0.8" - tslib "^2.1.0" - supports-preserve-symlinks-flag@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" @@ -1395,10 +1361,10 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" -tslib@^2.1.0: - version "2.3.1" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" - integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== +tslib@^2.4.0: + version "2.5.3" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.3.tgz#24944ba2d990940e6e982c4bea147aba80209913" + integrity sha512-mSxlJJwl3BMEQCUNnxXBU9jP4JBktcEGhURcPR6VQVlnP0FdDEsIaz0C35dXNGLyRfrATNofF0F5p2KPxQgB+w== tty-browserify@0.0.0: version "0.0.0" From 1fb82201190429f701bc2f32fcca3b148ce951f0 Mon Sep 17 00:00:00 2001 From: Martin Kavalar Date: Tue, 20 Jun 2023 11:55:38 +0200 Subject: [PATCH 03/23] Mention framer-motion upgrade in Changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1df3701a2..1d16c5f4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ Changes can be: * πŸ“– Improve Table of Contents design and fixing re-rendering issues. Also added suport for chapter expansion. +* πŸ›  Upgrade `framer-motion` dep to `10.12.16`. + * πŸ’« Assign `:name` to every viewer in `default-viewers` * 🐞 Don't run existing files through `fs/glob`, fixes [#504](https://github.com/nextjournal/clerk/issues/504). Also improves performance of homepage. From ffcdb1fd199ae0facc82b2efe10c6911d3b7fd67 Mon Sep 17 00:00:00 2001 From: Martin Kavalar Date: Tue, 20 Jun 2023 12:26:02 +0200 Subject: [PATCH 04/23] Add Tap Inspector to book and homepage --- CHANGELOG.md | 2 ++ book.clj | 16 ++++++++++++++-- notebooks/onwards.md | 2 +- src/nextjournal/clerk/home.clj | 34 ++++++++++++++++++++++++---------- 4 files changed, 41 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d16c5f4a..e314d7d1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ Changes can be: * πŸ“– Improve Table of Contents design and fixing re-rendering issues. Also added suport for chapter expansion. +* πŸ“’ Mention Tap Inspector in Book of Clerk & on Homepage + * πŸ›  Upgrade `framer-motion` dep to `10.12.16`. * πŸ’« Assign `:name` to every viewer in `default-viewers` diff --git a/book.clj b/book.clj index c85173629..1ee18209a 100644 --- a/book.clj +++ b/book.clj @@ -132,8 +132,6 @@ ;; nmap cs :execute ClerkShow() ;; ``` - - ;; ## πŸ” Viewers ;; Clerk comes with a number of useful built-in viewers e.g. for @@ -913,6 +911,20 @@ v/table-viewer ^{::clerk/budget nil ::clerk/auto-expand-results? true} rows +;; ## 🚰 Tap Inspector + +;; Clerk comes with an inspector notebook for Clojure's tap system. Use the following form from your REPL to show it. + +;;```clojure +;;(nextjournal.clerk/show! 'nextjournal.clerk.tap) +;;``` + +;; You can then call `tap>` from anywhere in your codebase and the Tap Inspector will show your value. This supports the full viewer api described above. + +;;```clojure +;;(tap> (clerk/html [:h1 "Hello 🚰 Tap Inspector πŸ‘‹"])) +;;``` + ;; ## 🧱 Static Building ;; Clerk can make a static HTML build from a collection of notebooks. diff --git a/notebooks/onwards.md b/notebooks/onwards.md index d883dc690..d7b8ecc48 100644 --- a/notebooks/onwards.md +++ b/notebooks/onwards.md @@ -107,7 +107,7 @@ Notes about what currently breaks πŸ’₯ and what could be better tomorrow. ## πŸ“– Book Updates - [ ] Explain `render-opts` and `viewer-opts` in book - [ ] Add sync atoms to book -- [ ] Add tap inspector to book +- [x] Add tap inspector to book - [ ] Add customizing clerk: markdown backtick eval example to book - [ ] Add example for cross-document table of contents ## πŸ’‘ Ideas diff --git a/src/nextjournal/clerk/home.clj b/src/nextjournal/clerk/home.clj index bd9fb0f9c..8f4409dda 100644 --- a/src/nextjournal/clerk/home.clj +++ b/src/nextjournal/clerk/home.clj @@ -126,8 +126,8 @@ :placeholder "Type to filter…" :value (:query @!state "") :on-input (fn [e] (swap! !state #(-> % - (assoc :query (.. e -target -value)) - (dissoc :selected-path)))) + (assoc :query (.. e -target -value)) + (dissoc :selected-path)))) :ref !input-el}] [:div.text-slate-400.absolute {:class "left-[10px] top-[11px]"} @@ -139,6 +139,13 @@ {:class "right-[10px] top-[9px]"} "⌘J"]])))) +(defn code-highlight + ([code] (code-highlight {} code)) + ([opts code] + [:span.font-mono.bg-white.bg-amber-100.border.border-amber-300.relative.dark:bg-slate-900.dark:border-slate-600.rounded.font-bold + {:class (str "px-[4px] py-[1px] -top-[1px] mx-[2px] " (:class opts))} + code])) + {::clerk/visibility {:result :show}} ^{::clerk/css-class ["w-full" "m-0"]} @@ -153,23 +160,29 @@ [:div.rounded-lg.border-2.border-amber-100.bg-amber-50.dark:border-slate-600.dark:bg-slate-800.dark:text-slate-100.px-8.py-4.mx-auto.text-center.font-sans.mt-6.md:mt-4 [:div.font-medium "Call " - [:span.font-mono.text-sm.bg-white.bg-amber-100.border.border-amber-300.relative.dark:bg-slate-900.dark:border-slate-600.rounded.font-bold - {:class "px-[4px] py-[1px] -top-[1px] mx-[2px]"} - "nextjournal.clerk/show!"] + (code-highlight {:class "text-sm"} "nextjournal.clerk/show!") " from your REPL to make a notebook appear!"] [:div.mt-2.text-sm "⚑️ This works best when you " [:a {:href "https://book.clerk.vision/#editor-integration"} "set up your editor to use a key binding for this!"]]] - [:div.rounded-lg.border-2.border-indigo-100.bg-indigo-50.dark:border-slate-600.dark:bg-slate-800.dark:text-slate-100.px-8.py-4.mt-6.text-center.font-sans + [:div.rounded-lg.border-2.border-indigo-100.bg-indigo-50.dark:border-slate-600.dark:bg-slate-800.dark:text-slate-100.px-8.py-4.mt-4.text-center.font-sans [:div.font-medium.md:flex.items-center.justify-center - [:span.text-xl.relative {:class "top-[2px] mr-2"} "πŸ“–"] - [:span "New to Clerk? Learn all about it in " [:a {:href "https://book.clerk.vision"} [:span.block.md:inline "The Book of Clerk."]]]] + [:span.text-xl.relative {:class "top-[2px] mr-2"} ""] + [:span "🌱 New to Clerk? Learn all about it in the " [:a {:href "https://book.clerk.vision"} [:span.block.md:inline "πŸ“– Book of Clerk."]]]] #_ [:div.mt-2.text-sm "Here are some handy links:" [:a.ml-3 {:href "#"} "πŸš€ Getting Started"] [:a.ml-3 {:href "#"} "πŸ” Viewers"] [:a.ml-3 {:href "#"} "πŸ™ˆ Controlling Visibility"]]] + [:div.rounded-lg.border-2.border-amber-100.bg-amber-50.dark:border-slate-600.dark:bg-slate-800.dark:text-slate-100.px-8.py-4.mx-auto.text-center.font-sans.mt-6.md:mt-4 + [:div [:span.font-medium "πŸ’‘ Tip:"] " Show the " [:a {:href "/'nextjournal.clerk.tap"} "🚰 Tap Inspector"] " to inspect values using " (code-highlight {:class "text-sm" }"tap>") "."] + [:div.mt-2.text-xs + (code-highlight {:class "text-sm"} "(nextjournal.clerk/show 'nextjournal.clerk.tap)")]] #_[:div.mt-6 - (clerk/with-viewer filter-input-viewer `!filter)] + (clerk/with-viewer filter-input-viewer `!filter)]]) + +^{::clerk/css-class ["w-full" "m-0"]} +(clerk/html + [:div.max-w-prose.px-8.mx-auto.-mt-6 [:div.flex.mt-6.border-t.dark:border-slate-700.font-sans [:div {:class (str "w-1/2 pt-6 " (when-not (seq @!filter) "pr-6 border-r dark:border-slate-700"))} [:h4.text-lg "All Notebooks"] @@ -183,4 +196,5 @@ (cond error [:div {:class "-mx-8"} (clerk/md error)] paths (let [{:keys [query]} @!filter] - (clerk/with-viewer index-viewer {:paths (filter (partial query-fn query) paths)}))))])]]) + (clerk/with-viewer index-viewer {:paths (filter (partial query-fn query) paths)}))))])]] + ) From d8cac004c694a9eb22b5405463232ec52d6ef79f Mon Sep 17 00:00:00 2001 From: Martin Kavalar Date: Tue, 20 Jun 2023 12:53:17 +0200 Subject: [PATCH 05/23] Fix regression regarding useless toc animation on initial load --- src/nextjournal/clerk/render.cljs | 58 ++++++++++++------------ src/nextjournal/clerk/render/navbar.cljs | 25 +++++----- 2 files changed, 42 insertions(+), 41 deletions(-) diff --git a/src/nextjournal/clerk/render.cljs b/src/nextjournal/clerk/render.cljs index 411779734..26e748997 100644 --- a/src/nextjournal/clerk/render.cljs +++ b/src/nextjournal/clerk/render.cljs @@ -1,5 +1,5 @@ (ns nextjournal.clerk.render - (:require ["framer-motion" :refer [m LazyMotion domAnimation]] + (:require ["framer-motion" :refer [motion]] ["react" :as react] ["react-dom/client" :as react-client] ["vh-sticky-table-header" :as sticky-table-header] @@ -40,25 +40,25 @@ [:button.text-slate-400.hover:text-slate-600.dark:hover:text-white.cursor-pointer {:on-click #(swap! !dark-mode? not)} (if @!dark-mode? - [:> (.-svg m) + [:> (.-svg motion) {:xmlns "http://www.w3.org/2000/svg" :class "w-5 h-5 md:w-4 md:h-4" :viewBox "0 0 50 50" :key "moon"} - [:> (.-path m) + [:> (.-path motion) {:d "M 43.81 29.354 C 43.688 28.958 43.413 28.626 43.046 28.432 C 42.679 28.238 42.251 28.198 41.854 28.321 C 36.161 29.886 30.067 28.272 25.894 24.096 C 21.722 19.92 20.113 13.824 21.683 8.133 C 21.848 7.582 21.697 6.985 21.29 6.578 C 20.884 6.172 20.287 6.022 19.736 6.187 C 10.659 8.728 4.691 17.389 5.55 26.776 C 6.408 36.163 13.847 43.598 23.235 44.451 C 32.622 45.304 41.28 39.332 43.816 30.253 C 43.902 29.96 43.9 29.647 43.81 29.354 Z" :fill "currentColor" :initial "initial" :animate "animate" :variants {:initial {:scale 0.6 :rotate 90} :animate {:scale 1 :rotate 0 :transition spring}}}]] - [:> (.-svg m) + [:> (.-svg motion) {:key "sun" :class "w-5 h-5 md:w-4 md:h-4" :viewBox "0 0 24 24" :fill "none" :xmlns "http://www.w3.org/2000/svg"} - [:> (.-circle m) + [:> (.-circle motion) {:cx "11.9998" :cy "11.9998" :r "5.75375" @@ -67,7 +67,7 @@ :animate "animate" :variants {:initial {:scale 1.5} :animate {:scale 1 :transition spring}}}] - [:> (.-g m) + [:> (.-g motion) {:initial "initial" :animate "animate" :variants {:initial {:rotate 45} @@ -154,29 +154,29 @@ (catch js/Error _ (js/console.warn (str "Clerk render-notebook, invalid hash: " (.-hash js/location))))))] - (js/requestAnimationFrame #(.scrollIntoViewIfNeeded heading)))))] - [:> LazyMotion {:features domAnimation} - [:div.flex - {:ref root-ref-fn} - [:div.fixed.top-2.left-2.md:left-auto.md:right-2.z-10 - [dark-mode-toggle !dark-mode?]] - (when (and toc toc-visibility) - [navbar/view toc (assoc render-opts :set-hash? (not bundle?) :toc-visibility toc-visibility)]) - [:div.flex-auto.w-screen.scroll-container - (into - [:> (.-div m) - (merge - {:key "notebook-viewer" - :class (cond-> (or doc-css-class [:flex :flex-col :items-center :notebook-viewer :flex-auto]) - sidenotes? (conj :sidenotes-layout))} - (when (and toc (not (navbar/mobile?))) - (let [inset {:margin-left (if (and toc-visibility (:toc-open? @!expanded-at)) navbar/width 0)}] - {:initial inset - :animate inset - :transition navbar/spring})))] - ;; TODO: restore react keys via block-id - ;; ^{:key (str processed-block-id "@" @!eval-counter)} - (inspect-children render-opts) (concat (when header [header]) xs (when footer [footer])))]]])) + (js/requestAnimationFrame #(.scrollIntoViewIfNeeded heading))))) + _ (swap! !expanded-at merge (navbar/->toc-expanded-at toc toc-visibility))] + [:div.flex + {:ref root-ref-fn} + [:div.fixed.top-2.left-2.md:left-auto.md:right-2.z-10 + [dark-mode-toggle !dark-mode?]] + (when (and toc toc-visibility) + [navbar/view toc (assoc render-opts :set-hash? (not bundle?) :toc-visibility toc-visibility)]) + [:div.flex-auto.w-screen.scroll-container + (into + [:> (.-div motion) + (merge + {:key "notebook-viewer" + :class (cond-> (or doc-css-class [:flex :flex-col :items-center :notebook-viewer :flex-auto]) + sidenotes? (conj :sidenotes-layout))} + (when (and toc (not (navbar/mobile?))) + (let [inset {:margin-left (if (and toc-visibility (:toc-open? @!expanded-at)) navbar/width 0)}] + {:initial inset + :animate inset + :transition navbar/spring})))] + ;; TODO: restore react keys via block-id + ;; ^{:key (str processed-block-id "@" @!eval-counter)} + (inspect-children render-opts) (concat (when header [header]) xs (when footer [footer])))]])) (defn opts->query [opts] (->> opts diff --git a/src/nextjournal/clerk/render/navbar.cljs b/src/nextjournal/clerk/render/navbar.cljs index d84346e2d..183bd789b 100644 --- a/src/nextjournal/clerk/render/navbar.cljs +++ b/src/nextjournal/clerk/render/navbar.cljs @@ -1,5 +1,5 @@ (ns nextjournal.clerk.render.navbar - (:require ["framer-motion" :as framer-motion :refer [m AnimatePresence]] + (:require ["framer-motion" :as framer-motion :refer [motion AnimatePresence]] [applied-science.js-interop :as j] [clojure.string :as str] [nextjournal.clerk.render.hooks :as hooks] @@ -109,7 +109,7 @@ (def spring {:type :spring :duration 0.35 :bounce 0.1}) (defn mobile-backdrop [{:keys [!expanded-at]}] - [:> (.-div m) + [:> (.-div motion) {:key "mobile-toc-backdrop" :class "fixed z-10 bg-gray-500 bg-opacity-75 left-0 top-0 bottom-0 right-0" :initial {:opacity 0} @@ -147,7 +147,7 @@ (def mobile-width 300) (defn toc-panel [toc {:as render-opts :keys [!expanded-at mobile-toc?]}] - [:> (.-div m) + [:> (.-div motion) (let [inset-or-x (if mobile-toc? :x :margin-left) w (if mobile-toc? mobile-width width)] {:key "toc-panel" @@ -165,16 +165,17 @@ "TOC"] [render-items toc render-opts]]]) +(defn ->toc-expanded-at [toc toc-visibility] + {:toc-open? (if-some [stored-open? (localstorage/get-item local-storage-key)] + stored-open? + (not= :collapsed toc-visibility)) + :toc (into {} + (map (juxt identity some?)) + (keep #(when (and (map? %) (:expanded? %)) (:path %)) (tree-seq coll? not-empty toc)))}) + (defn view [toc {:as render-opts :keys [!expanded-at toc-visibility]}] - (hooks/use-effect - (fn [] - (swap! !expanded-at assoc :toc-open? (if-some [stored-open? (localstorage/get-item local-storage-key)] - stored-open? - (not= :collapsed toc-visibility))) - (swap! !expanded-at assoc :toc (into {} - (map (juxt identity some?)) - (keep #(when (and (map? %) (:expanded? %)) (:path %)) (tree-seq coll? not-empty toc))))) - [toc]) + (hooks/use-effect (fn [] (swap! !expanded-at merge (->toc-expanded-at toc toc-visibility))) + [toc toc-visibility]) (r/with-let [!mobile-toc? (r/atom (mobile?)) handle-resize #(reset! !mobile-toc? (mobile?)) ref-fn #(if % From 5b5ee263f61627785806b54011f61e5a35f8af59 Mon Sep 17 00:00:00 2001 From: Martin Kavalar Date: Tue, 20 Jun 2023 14:38:26 +0200 Subject: [PATCH 06/23] Prepare for per-cell progress reporting --- notebooks/exec_status.clj | 24 +++++++++++++++++++++--- src/nextjournal/clerk/render.cljs | 16 ++++++++++------ src/nextjournal/clerk/webserver.clj | 4 ++-- 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/notebooks/exec_status.clj b/notebooks/exec_status.clj index 0860a26ac..3595d8dc3 100644 --- a/notebooks/exec_status.clj +++ b/notebooks/exec_status.clj @@ -1,7 +1,8 @@ ;; # πŸ’ˆ Execution Status (ns exec-status {:nextjournal.clerk/toc true} - (:require [nextjournal.clerk :as clerk])) + (:require [nextjournal.clerk :as clerk] + [nextjournal.clerk.webserver :as webserver])) ;; To see what's going on while waiting for a long-running ;; computation, Clerk will now show an execution status bar on the @@ -25,10 +26,21 @@ {:progress 0.15 :status "Analyzing…"} -{:progress 0.55 :status "Evaluating…"} +{:progress 0.55 :cell-progress 0.34 :status "Evaluating…"} {:progress 0.95 :status "Presenting…"} +(defn set-cell-progress! [progress] + (swap! webserver/!doc (fn [doc] (if-let [status (-> doc meta :status)] + (let [status+progress (assoc status :cell-progress progress)] + (when-let [send-future (-> doc meta ::webserver/!send-status-future)] + (future-cancel send-future)) + (webserver/broadcast-status! status+progress) + (-> doc + (vary-meta dissoc ::!send-status-future) + (vary-meta assoc :status status+progress))) + doc)))) + (defonce !rand (atom 0)) @@ -36,9 +48,15 @@ (Thread/sleep (+ 2000 @!rand)) (def sleepy-cell - (Thread/sleep (+ 2001 @!rand))) + (let [total (+ 2001 @!rand)] + (doseq [i (range total)] + (do + (Thread/sleep 10) + (set-cell-progress! (/ i (float total))))))) (Thread/sleep (+ 2002 @!rand)) (def sleepy-cell-2 (Thread/sleep (+ 2003 @!rand))) + + diff --git a/src/nextjournal/clerk/render.cljs b/src/nextjournal/clerk/render.cljs index 26e748997..5780124b4 100644 --- a/src/nextjournal/clerk/render.cljs +++ b/src/nextjournal/clerk/render.cljs @@ -98,12 +98,16 @@ (defonce !eval-counter (r/atom 0)) -(defn exec-status [{:keys [progress status]}] - [:div.w-full.bg-purple-200.dark:bg-purple-900.rounded.z-20 {:class "h-0.5"} - [:div.bg-purple-600.dark:bg-purple-400 {:class "h-0.5" :style {:width (str (* progress 100) "%")}}] - [:div.absolute.text-purple-600.dark:text-white.text-xs.font-sans.ml-1.bg-white.dark:bg-purple-900.rounded-full.shadow.z-20.font-bold.px-2.border.border-slate-300.dark:border-purple-400 - {:style {:font-size "0.5rem"} :class "left-[35px] md:left-0 mt-[7px] md:mt-1"} - status]]) +(defn exec-status [{:keys [progress cell-progress status]}] + [:<> + [:div.w-full.bg-purple-200.dark:bg-purple-900.rounded.z-20 {:class "h-[2px]"} + [:div.bg-purple-600.dark:bg-purple-400 {:class "h-[2px]" :style {:width (str (* progress 100) "%")}}] + [:div.absolute.text-purple-600.dark:text-white.text-xs.font-sans.ml-1.bg-white.dark:bg-purple-900.rounded-full.shadow.z-20.font-bold.px-2.border.border-slate-300.dark:border-purple-400 + {:style {:font-size "0.5rem"} :class "left-[35px] md:left-0 mt-[7px] md:mt-1"} + status]] + (when cell-progress + [:div.w-full.bg-sky-100.dark:bg-purple-900.rounded.z-20 {:class "h-[2px] mt-[0.5px]"} + [:div.bg-sky-500.dark:bg-purple-400 {:class "h-[2px]" :style {:width (str (* cell-progress 100) "%")}}]])])5 (defn connection-status [status] [:div.absolute.text-red-600.dark:text-white.text-xs.font-sans.ml-1.bg-white.dark:bg-red-800.rounded-full.shadow.z-20.font-bold.px-2.border.border-red-400 diff --git a/src/nextjournal/clerk/webserver.clj b/src/nextjournal/clerk/webserver.clj index b820b41bb..56d41f162 100644 --- a/src/nextjournal/clerk/webserver.clj +++ b/src/nextjournal/clerk/webserver.clj @@ -260,8 +260,8 @@ (defn set-status! [status] (swap! !doc (fn [doc] (-> (or doc (help-doc)) - (vary-meta assoc :status status) - (vary-meta update ::!send-status-future broadcast-status-debounced! status))))) + (vary-meta assoc :status status) + (vary-meta update ::!send-status-future broadcast-status-debounced! status))))) #_(clojure.java.browse/browse-url "http://localhost:7777") From 4f41d6782ee453970e13a03b8257e6134820c33c Mon Sep 17 00:00:00 2001 From: Philippa Markovics Date: Tue, 20 Jun 2023 14:51:45 +0200 Subject: [PATCH 07/23] Fix code cell bottom padding --- resources/stylesheets/viewer.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/stylesheets/viewer.css b/resources/stylesheets/viewer.css index f3385a134..8c8046f27 100644 --- a/resources/stylesheets/viewer.css +++ b/resources/stylesheets/viewer.css @@ -137,7 +137,7 @@ } @media (min-width: 960px){ .notebook-viewer .code-viewer .cm-content { - @apply pb-2 pl-12; + @apply pl-12; } .notebook-viewer .code-listing { width: 48rem !important; From 82cc18f0d07a4c434d808c0be56526660b2be9aa Mon Sep 17 00:00:00 2001 From: Michiel Borkent Date: Tue, 20 Jun 2023 17:28:09 +0200 Subject: [PATCH 08/23] Upgrade `babashka.cli` (#520) With fix for babashka/cli#68. --- deps.edn | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps.edn b/deps.edn index 0e157d86c..676652bf7 100644 --- a/deps.edn +++ b/deps.edn @@ -27,7 +27,7 @@ :aliases {:nextjournal/clerk {:extra-deps {org.clojure/clojure {:mvn/version "1.11.1"} ;; for `:as-alias` support in static build org.slf4j/slf4j-nop {:mvn/version "2.0.7"} - org.babashka/cli {:mvn/version "0.6.50"}} + org.babashka/cli {:mvn/version "0.7.52"}} :extra-paths ["notebooks"] :exec-fn nextjournal.clerk/build! :exec-args {:paths-fn nextjournal.clerk.builder/clerk-docs} From aad939dd06988e096abeca45c80f923728611b72 Mon Sep 17 00:00:00 2001 From: Philippa Markovics Date: Thu, 22 Jun 2023 10:25:32 +0200 Subject: [PATCH 09/23] Rename window to panel & panel improvements (#522) This renames client-side windows to panel and allows opening them programmatically by calling (show-panel :panel-id {:content "Test content"}). You can also provide additional `css-class`, `width`, `height` and `on-close` to the panel now. The last will add a close button to the panel header. --- src/nextjournal/clerk/render.cljs | 20 ++- src/nextjournal/clerk/render/panel.cljs | 191 +++++++++++++++++++++++ src/nextjournal/clerk/render/window.cljs | 177 --------------------- 3 files changed, 207 insertions(+), 181 deletions(-) create mode 100644 src/nextjournal/clerk/render/panel.cljs delete mode 100644 src/nextjournal/clerk/render/window.cljs diff --git a/src/nextjournal/clerk/render.cljs b/src/nextjournal/clerk/render.cljs index 5780124b4..bd4ee84e4 100644 --- a/src/nextjournal/clerk/render.cljs +++ b/src/nextjournal/clerk/render.cljs @@ -17,7 +17,7 @@ [nextjournal.clerk.render.hooks :as hooks] [nextjournal.clerk.render.localstorage :as localstorage] [nextjournal.clerk.render.navbar :as navbar] - [nextjournal.clerk.render.window :as window] + [nextjournal.clerk.render.panel :as panel] [nextjournal.clerk.viewer :as viewer] [reagent.core :as r] [reagent.ratom :as ratom] @@ -550,6 +550,7 @@ (defonce !doc (ratom/atom nil)) (defonce !viewers viewer/!viewers) +(defonce !panels (ratom/atom {})) (defn set-viewers! [scope viewers] #_(js/console.log :set-viewers! {:scope scope :viewers viewers}) @@ -588,8 +589,10 @@ (swap! !state update :desc viewer/merge-presentations more fetch-opts))))} [inspect-presented (:desc @!state)]])) -(defn show-window [& content] - [window/show content]) +(defn show-panel [panel-id panel] + (swap! !panels assoc panel-id panel)) + +#_(show-panel :test {:content [:div "Test"] :width 600 :height 600}) (defn root [] [:<> @@ -601,7 +604,16 @@ [exec-status status])] (when-let [error (get-in @!doc [:nextjournal/value :error])] [:div.fixed.top-0.left-0.w-full.h-full - [inspect-presented error]])]) + [inspect-presented error]]) + (into [:<>] + (map (fn [[id state]] + (js/console.log state) + ^{:key id} + [panel/show + (:content state) + (-> state + (assoc :id id :on-close #(swap! !panels dissoc id)))])) + @!panels)]) (declare mount) diff --git a/src/nextjournal/clerk/render/panel.cljs b/src/nextjournal/clerk/render/panel.cljs new file mode 100644 index 000000000..8305372ef --- /dev/null +++ b/src/nextjournal/clerk/render/panel.cljs @@ -0,0 +1,191 @@ +(ns nextjournal.clerk.render.panel + (:require [applied-science.js-interop :as j] + [nextjournal.clerk.render.hooks :as hooks])) + +(defn resizer [{:keys [on-resize on-resize-start on-resize-end] :or {on-resize-start #() on-resize-end #()}}] + (let [!direction (hooks/use-state nil) + !mouse-down (hooks/use-state false) + handle-mouse-down (fn [dir] + (on-resize-start) + (reset! !direction dir) + (reset! !mouse-down true))] + (hooks/use-effect (fn [] + (let [handle-mouse-move (fn [e] + (when-let [dir @!direction] + (on-resize dir (.-movementX e) (.-movementY e))))] + (when @!mouse-down + (js/addEventListener "mousemove" handle-mouse-move)) + #(js/removeEventListener "mousemove" handle-mouse-move))) + [!mouse-down !direction on-resize]) + (hooks/use-effect (fn [] + (let [handle-mouse-up (fn [] + (on-resize-end) + (reset! !mouse-down false))] + (js/addEventListener "mouseup" handle-mouse-up) + #(js/removeEventListener "mouseup" handle-mouse-up)))) + [:<> + [:div.absolute.z-2.cursor-nwse-resize + {:on-mouse-down #(handle-mouse-down :top-left) + :class "w-[14px] h-[14px] -left-[7px] -top-[7px]"}] + [:div.absolute.z-1.left-0.w-full.cursor-ns-resize + {:on-mouse-down #(handle-mouse-down :top) + :class "h-[4px] -top-[4px]"}] + [:div.absolute.z-2.cursor-nesw-resize + {:on-mouse-down #(handle-mouse-down :top-right) + :class "w-[14px] h-[14px] -right-[7px] -top-[7px]"}] + [:div.absolute.z-1.top-0.h-full.cursor-ew-resize + {:on-mouse-down #(handle-mouse-down :right) + :class "w-[4px] -right-[2px]"}] + [:div.absolute.z-2.cursor-nwse-resize + {:on-mouse-down #(handle-mouse-down :bottom-right) + :class "w-[14px] h-[14px] -right-[7px] -bottom-[7px]"}] + [:div.absolute.z-1.bottom-0.w-full.cursor-ns-resize + {:on-mouse-down #(handle-mouse-down :bottom) + :class "h-[4px] -left-[2px]"}] + [:div.absolute.z-2.cursor-nesw-resize + {:on-mouse-down #(handle-mouse-down :bottom-left) + :class "w-[14px] h-[14px] -left-[7px] -bottom-[7px]"}] + [:div.absolute.z-1.left-0.top-0.h-full.cursor-ew-resize + {:on-mouse-down #(handle-mouse-down :left) + :class "w-[4px]"}]])) + +(defn header [{:keys [id title on-drag on-drag-start on-drag-end on-close] :or {on-drag-start #() on-drag-end #()}}] + (let [!mouse-down (hooks/use-state false) + name (or title id)] + (hooks/use-effect (fn [] + (let [handle-mouse-up (fn [] + (on-drag-end) + (reset! !mouse-down false))] + (js/addEventListener "mouseup" handle-mouse-up) + #(js/addEventListener "mouseup" handle-mouse-up)))) + (hooks/use-effect (fn [] + (let [handle-mouse-move #(on-drag {:x (.-clientX %) :y (.-clientY %) :dx (.-movementX %) :dy (.-movementY %)})] + (when @!mouse-down + (js/addEventListener "mousemove" handle-mouse-move)) + #(js/removeEventListener "mousemove" handle-mouse-move))) + [!mouse-down on-drag]) + [:div.bg-slate-100.hover:bg-slate-200.dark:bg-slate-800.dark:hover:bg-slate-700.cursor-move.w-full.rounded-t-lg.flex-shrink-0.leading-none.flex.items-center.justify-between + {:class (if name "h-[24px] " "h-[14px] ") + :on-mouse-down (fn [event] + (on-drag-start) + (reset! !mouse-down {:start-x (.-screenX event) :start-y (.-screenY event)}))} + (when name + [:span.font-sans.font-medium.text-slate-700 + {:class "text-[12px] ml-[8px] "} + (or title id)]) + (when on-close + [:button.text-slate-600.hover:text-slate-900.hover:bg-slate-300.rounded-tr-lg.flex.items-center.justify-center + {:on-click on-close + :class "w-[24px] h-[24px]"} + [:svg {:xmlns "http://www.w3.org/2000/svg" :fill "none" :viewBox "0 0 24 24" :stroke-width "1.5" :stroke "currentColor" :class "w-3 h-3"} + [:path {:stroke-linecap "round" :stroke-linejoin "round" :d "M6 18L18 6M6 6l12 12"}]]])])) + +(defn resize-top [panel {:keys [top height]} dy] + (j/assoc-in! panel [:style :height] (str (- height dy) "px")) + (j/assoc-in! panel [:style :top] (str (+ top dy) "px"))) + +(defn resize-right [panel {:keys [width]} dx] + (j/assoc-in! panel [:style :width] (str (+ width dx) "px"))) + +(defn resize-bottom [panel {:keys [height]} dy] + (j/assoc-in! panel [:style :height] (str (+ height dy) "px"))) + +(defn resize-left [panel {:keys [left width]} dx] + (j/assoc-in! panel [:style :width] (str (- width dx) "px")) + (j/assoc-in! panel [:style :left] (str (+ left dx) "px"))) + +(defn dock-at-top [panel] + (-> panel + (j/assoc-in! [:style :width] "calc(100vw - 10px)") + (j/assoc-in! [:style :height] "33vh") + (j/assoc-in! [:style :top] "5px") + (j/assoc-in! [:style :left] "5px"))) + +(defn dock-at-right [panel] + (-> panel + (j/assoc-in! [:style :width] "33vw") + (j/assoc-in! [:style :height] "calc(100vh - 10px)") + (j/assoc-in! [:style :top] "5px") + (j/assoc-in! [:style :left] "calc(100vw - 33vw - 5px)"))) + +(defn dock-at-bottom [panel] + (-> panel + (j/assoc-in! [:style :width] "calc(100vw - 10px)") + (j/assoc-in! [:style :height] "33vh") + (j/assoc-in! [:style :top] "calc(100vh - 33vh - 10px)") + (j/assoc-in! [:style :left] "5px"))) + +(defn dock-at-left [panel] + (-> panel + (j/assoc-in! [:style :width] "33vw") + (j/assoc-in! [:style :height] "calc(100vh - 10px)") + (j/assoc-in! [:style :top] "5px") + (j/assoc-in! [:style :left] "5px"))) + +(defn show + ([content] (show content {})) + ([content {:as opts :keys [css-class]}] + (let [!panel-ref (hooks/use-ref nil) + !dragging? (hooks/use-state nil) + !dockable-at (hooks/use-state nil) + !docking-ref (hooks/use-ref nil)] + [:<> + [:div.fixed.border-2.border-dashed.border-indigo-600.border-opacity-70.bg-indigo-600.bg-opacity-30.pointer-events-none.transition-all.rounded-lg + {:class (str "z-[999] " (if-let [side @!dockable-at] + (str "opacity-100 " (case side + :top "left-[5px] top-[5px] right-[5px] h-[33vh]" + :left "left-[5px] top-[5px] bottom-[5px] w-[33vw]" + :bottom "left-[5px] bottom-[5px] right-[5px] h-[33vh]" + :right "right-[5px] top-[5px] bottom-[5px] w-[33vw]")) + "opacity-0 "))}] + [:div.fixed.bg-white.dark:bg-slate-900.shadow-xl.text-slate-800.dark:text-slate-100.rounded-lg.flex.flex-col.hover:ring-2 + {:class (str "z-[1000] " (if @!dragging? "ring-indigo-600 select-none ring-2 " "ring-slate-300 dark:ring-slate-700 ring-1 ")) + :ref !panel-ref + :style {:top 30 :right 30 :width (:width opts 400) :height (:height opts 400)}} + [resizer {:on-resize (fn [dir dx dy] + (when-let [panel @!panel-ref] + (let [rect (j/lookup (.getBoundingClientRect panel))] + (case dir + :top-left (do (resize-top panel rect dy) + (resize-left panel rect dx)) + :top (resize-top panel rect dy) + :top-right (do (resize-top panel rect dy) + (resize-right panel rect dx)) + :right (resize-right panel rect dx) + :bottom-right (do (resize-bottom panel rect dy) + (resize-right panel rect dx)) + :bottom (resize-bottom panel rect dy) + :bottom-left (do (resize-bottom panel rect dy) + (resize-left panel rect dx)) + :left (resize-left panel rect dx))))) + :on-resize-start #(reset! !dragging? true) + :on-resize-end #(reset! !dragging? false)}] + [header (merge {:on-drag (fn [{:keys [x y dx dy]}] + (when-let [panel @!panel-ref] + (let [{:keys [left top width]} (j/lookup (.getBoundingClientRect panel)) + x-edge-offset 20 + y-edge-offset 10 + vw js/innerWidth + vh js/innerHeight] + (reset! !dockable-at (cond + (zero? x) :left + (>= x (- vw 2)) :right + (<= y 0) :top + (>= y (- vh 2)) :bottom + :else nil)) + (reset! !docking-ref @!dockable-at) + (j/assoc-in! panel [:style :left] (str (min (- vw x-edge-offset) (max (+ x-edge-offset (- width)) (+ left dx))) "px")) + (j/assoc-in! panel [:style :top] (str (min (- vh y-edge-offset) (max y-edge-offset (+ top dy))) "px"))))) + :on-drag-start #(reset! !dragging? true) + :on-drag-end (fn [] + (when-let [side @!docking-ref] + (let [panel @!panel-ref] + (case side + :top (dock-at-top panel) + :right (dock-at-right panel) + :bottom (dock-at-bottom panel) + :left (dock-at-left panel)))) + (reset! !dockable-at nil) + (reset! !docking-ref nil))} + opts)] + [:div {:class (str "flex-auto " (or css-class "p-3 overflow-auto"))} content]]]))) diff --git a/src/nextjournal/clerk/render/window.cljs b/src/nextjournal/clerk/render/window.cljs deleted file mode 100644 index 90466904a..000000000 --- a/src/nextjournal/clerk/render/window.cljs +++ /dev/null @@ -1,177 +0,0 @@ -(ns nextjournal.clerk.render.window - (:require [applied-science.js-interop :as j] - [nextjournal.clerk.render.hooks :as hooks])) - -(defn resizer [{:keys [on-resize on-resize-start on-resize-end] :or {on-resize-start #() on-resize-end #()}}] - (let [!direction (hooks/use-state nil) - !mouse-down (hooks/use-state false) - handle-mouse-down (fn [dir] - (on-resize-start) - (reset! !direction dir) - (reset! !mouse-down true))] - (hooks/use-effect (fn [] - (let [handle-mouse-move (fn [e] - (when-let [dir @!direction] - (on-resize dir (.-movementX e) (.-movementY e))))] - (when @!mouse-down - (js/addEventListener "mousemove" handle-mouse-move)) - #(js/removeEventListener "mousemove" handle-mouse-move))) - [!mouse-down !direction on-resize]) - (hooks/use-effect (fn [] - (let [handle-mouse-up (fn [] - (on-resize-end) - (reset! !mouse-down false))] - (js/addEventListener "mouseup" handle-mouse-up) - #(js/removeEventListener "mouseup" handle-mouse-up)))) - [:<> - [:div.absolute.z-2.cursor-nwse-resize - {:on-mouse-down #(handle-mouse-down :top-left) - :class "w-[14px] h-[14px] -left-[7px] -top-[7px]"}] - [:div.absolute.z-1.left-0.w-full.cursor-ns-resize - {:on-mouse-down #(handle-mouse-down :top) - :class "h-[4px] -top-[4px]"}] - [:div.absolute.z-2.cursor-nesw-resize - {:on-mouse-down #(handle-mouse-down :top-right) - :class "w-[14px] h-[14px] -right-[7px] -top-[7px]"}] - [:div.absolute.z-1.top-0.h-full.cursor-ew-resize - {:on-mouse-down #(handle-mouse-down :right) - :class "w-[4px] -right-[2px]"}] - [:div.absolute.z-2.cursor-nwse-resize - {:on-mouse-down #(handle-mouse-down :bottom-right) - :class "w-[14px] h-[14px] -right-[7px] -bottom-[7px]"}] - [:div.absolute.z-1.bottom-0.w-full.cursor-ns-resize - {:on-mouse-down #(handle-mouse-down :bottom) - :class "h-[4px] -left-[2px]"}] - [:div.absolute.z-2.cursor-nesw-resize - {:on-mouse-down #(handle-mouse-down :bottom-left) - :class "w-[14px] h-[14px] -left-[7px] -bottom-[7px]"}] - [:div.absolute.z-1.left-0.top-0.h-full.cursor-ew-resize - {:on-mouse-down #(handle-mouse-down :left) - :class "w-[4px]"}]])) - -(defn header [{:keys [on-drag on-drag-start on-drag-end] :or {on-drag-start #() on-drag-end #()}}] - (let [!mouse-down (hooks/use-state false)] - (hooks/use-effect (fn [] - (let [handle-mouse-up (fn [] - (on-drag-end) - (reset! !mouse-down false))] - (js/addEventListener "mouseup" handle-mouse-up) - #(js/addEventListener "mouseup" handle-mouse-up)))) - (hooks/use-effect (fn [] - (let [handle-mouse-move #(on-drag {:x (.-clientX %) :y (.-clientY %) :dx (.-movementX %) :dy (.-movementY %)})] - (when @!mouse-down - (js/addEventListener "mousemove" handle-mouse-move)) - #(js/removeEventListener "mousemove" handle-mouse-move))) - [!mouse-down on-drag]) - [:div.bg-slate-100.hover:bg-slate-200.dark:bg-slate-800.dark:hover:bg-slate-700.cursor-move.w-full.rounded-t-lg - {:class "h-[14px]" - :on-mouse-down (fn [event] - (on-drag-start) - (reset! !mouse-down {:start-x (.-screenX event) :start-y (.-screenY event)}))}])) - -(defn resize-top [panel {:keys [top height]} dy] - (j/assoc-in! panel [:style :height] (str (- height dy) "px")) - (j/assoc-in! panel [:style :top] (str (+ top dy) "px"))) - -(defn resize-right [panel {:keys [width]} dx] - (j/assoc-in! panel [:style :width] (str (+ width dx) "px"))) - -(defn resize-bottom [panel {:keys [height]} dy] - (j/assoc-in! panel [:style :height] (str (+ height dy) "px"))) - -(defn resize-left [panel {:keys [left width]} dx] - (j/assoc-in! panel [:style :width] (str (- width dx) "px")) - (j/assoc-in! panel [:style :left] (str (+ left dx) "px"))) - -(defn dock-at-top [panel] - (-> panel - (j/assoc-in! [:style :width] "calc(100vw - 10px)") - (j/assoc-in! [:style :height] "33vh") - (j/assoc-in! [:style :top] "5px") - (j/assoc-in! [:style :left] "5px"))) - -(defn dock-at-right [panel] - (-> panel - (j/assoc-in! [:style :width] "33vw") - (j/assoc-in! [:style :height] "calc(100vh - 10px)") - (j/assoc-in! [:style :top] "5px") - (j/assoc-in! [:style :left] "calc(100vw - 33vw - 5px)"))) - -(defn dock-at-bottom [panel] - (-> panel - (j/assoc-in! [:style :width] "calc(100vw - 10px)") - (j/assoc-in! [:style :height] "33vh") - (j/assoc-in! [:style :top] "calc(100vh - 33vh - 10px)") - (j/assoc-in! [:style :left] "5px"))) - -(defn dock-at-left [panel] - (-> panel - (j/assoc-in! [:style :width] "33vw") - (j/assoc-in! [:style :height] "calc(100vh - 10px)") - (j/assoc-in! [:style :top] "5px") - (j/assoc-in! [:style :left] "5px"))) - -(defn show [& content] - (let [!panel-ref (hooks/use-ref nil) - !dragging? (hooks/use-state nil) - !dockable-at (hooks/use-state nil) - !docking-ref (hooks/use-ref nil)] - [:<> - [:div.fixed.border-2.border-dashed.border-indigo-600.border-opacity-70.bg-indigo-600.bg-opacity-30.pointer-events-none.transition-all.rounded-lg - {:class (str "z-[999] " (if-let [side @!dockable-at] - (str "opacity-100 " (case side - :top "left-[5px] top-[5px] right-[5px] h-[33vh]" - :left "left-[5px] top-[5px] bottom-[5px] w-[33vw]" - :bottom "left-[5px] bottom-[5px] right-[5px] h-[33vh]" - :right "right-[5px] top-[5px] bottom-[5px] w-[33vw]")) - "opacity-0 "))}] - [:div.fixed.bg-white.dark:bg-slate-900.shadow-xl.text-slate-800.dark:text-slate-100.rounded-lg.flex.flex-col.hover:ring-2 - {:class (str "z-[1000] " (if @!dragging? "ring-indigo-600 select-none ring-2 " "ring-slate-300 dark:ring-slate-700 ring-1 ")) - :ref !panel-ref - :style {:top 30 :right 30 :width 400 :height 400}} - [resizer {:on-resize (fn [dir dx dy] - (when-let [panel @!panel-ref] - (let [rect (j/lookup (.getBoundingClientRect panel))] - (case dir - :top-left (do (resize-top panel rect dy) - (resize-left panel rect dx)) - :top (resize-top panel rect dy) - :top-right (do (resize-top panel rect dy) - (resize-right panel rect dx)) - :right (resize-right panel rect dx) - :bottom-right (do (resize-bottom panel rect dy) - (resize-right panel rect dx)) - :bottom (resize-bottom panel rect dy) - :bottom-left (do (resize-bottom panel rect dy) - (resize-left panel rect dx)) - :left (resize-left panel rect dx))))) - :on-resize-start #(reset! !dragging? true) - :on-resize-end #(reset! !dragging? false)}] - [header {:on-drag (fn [{:keys [x y dx dy]}] - (when-let [panel @!panel-ref] - (let [{:keys [left top width]} (j/lookup (.getBoundingClientRect panel)) - x-edge-offset 20 - y-edge-offset 10 - vw js/innerWidth - vh js/innerHeight] - (reset! !dockable-at (cond - (zero? x) :left - (>= x (- vw 2)) :right - (<= y 0) :top - (>= y (- vh 2)) :bottom - :else nil)) - (reset! !docking-ref @!dockable-at) - (j/assoc-in! panel [:style :left] (str (min (- vw x-edge-offset) (max (+ x-edge-offset (- width)) (+ left dx))) "px")) - (j/assoc-in! panel [:style :top] (str (min (- vh y-edge-offset) (max y-edge-offset (+ top dy))) "px"))))) - :on-drag-start #(reset! !dragging? true) - :on-drag-end (fn [] - (when-let [side @!docking-ref] - (let [panel @!panel-ref] - (case side - :top (dock-at-top panel) - :right (dock-at-right panel) - :bottom (dock-at-bottom panel) - :left (dock-at-left panel)))) - (reset! !dockable-at nil) - (reset! !docking-ref nil))}] - (into [:div.p-3.flex-auto.overflow-auto] content)]])) From 62b91b7e5a4487472129ea41095de6c62e8834ce Mon Sep 17 00:00:00 2001 From: Philippa Markovics Date: Thu, 22 Jun 2023 10:26:28 +0200 Subject: [PATCH 10/23] Remove log --- src/nextjournal/clerk/render.cljs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/nextjournal/clerk/render.cljs b/src/nextjournal/clerk/render.cljs index bd4ee84e4..d6829e638 100644 --- a/src/nextjournal/clerk/render.cljs +++ b/src/nextjournal/clerk/render.cljs @@ -607,7 +607,6 @@ [inspect-presented error]]) (into [:<>] (map (fn [[id state]] - (js/console.log state) ^{:key id} [panel/show (:content state) From 70af0c7f6cf19bc69c816960c430e6c9e43ea530 Mon Sep 17 00:00:00 2001 From: Martin Kavalar Date: Fri, 23 Jun 2023 12:22:17 +0200 Subject: [PATCH 11/23] Simplify html rendering internals, drop `reagent-viewer` (#523) Removed * `nextjournal.clerk.viewer/reagent-viewer`, * `nextjournal.clerk.render/html-viewer`, * `nextjournal.clerk.render/html`, and * `nextjournal.clerk.render/render-reagent`. From now on, please use * `nextjournal.clerk.viewer/html-viewer`, and * `nextjournal.clerk.viewer/html` instead. Also rename `nextjournal.clerk.render/html-render` to `nextjournal.clerk.render/render-html` and make `nextjournal.clerk.viewer/html` use it when called from a reactive context. --- CHANGELOG.md | 15 +++++++++++++++ notebooks/cards.clj | 27 ++++++++++----------------- notebooks/viewer_api.clj | 4 ++++ src/nextjournal/clerk/render.cljs | 23 +++++------------------ src/nextjournal/clerk/sci_env.cljs | 16 +++++++++++++++- src/nextjournal/clerk/viewer.cljc | 6 +----- 6 files changed, 50 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e314d7d1b..36ca39e88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,21 @@ Changes can be: * 🚨 Rename `:nextjournal.clerk/opts` to `:nextjournal.clerk/render-opts` to clarify this options map is available as the second arg to parametrize the `:render-fn`. Still support the `:nextjournal.clerk/opts` for now. +* 🚨 Simplify html rendering internals + + Removed + + * `nextjournal.clerk.viewer/reagent-viewer`, + * `nextjournal.clerk.render/html-viewer`, + * `nextjournal.clerk.render/html`, and + * `nextjournal.clerk.render/render-reagent`. + + From now on, please use + * `nextjournal.clerk.viewer/html-viewer`, and + * `nextjournal.clerk.viewer/html` instead. + + Also rename `nextjournal.clerk.render/html-render` to `nextjournal.clerk.render/render-html` and make `nextjournal.clerk.viewer/html` use it when called from a reactive context. + * πŸ“– Improve Table of Contents design and fixing re-rendering issues. Also added suport for chapter expansion. * πŸ“’ Mention Tap Inspector in Book of Clerk & on Homepage diff --git a/notebooks/cards.clj b/notebooks/cards.clj index d5ead037f..a49fa949a 100644 --- a/notebooks/cards.clj +++ b/notebooks/cards.clj @@ -1,7 +1,8 @@ ;; # πŸƒ CLJS Cards -^{:nextjournal.clerk/toc true :nextjournal.clerk/visibility {:code :hide}} (ns cards - {:nextjournal.clerk/no-cache true} + {:nextjournal.clerk/toc true + :nextjournal.clerk/no-cache true + :nextjournal.clerk/visibility {:code :hide}} (:require [applied-science.js-interop :as-alias j] [cards-macro :as c] [nextjournal.clerk :as clerk] @@ -126,13 +127,12 @@ (reagent/as-element [:h1 "♻️"])) (c/card - (v/with-viewer `v/reagent-viewer - (fn [] - (reagent/with-let [c (reagent/atom 0)] - [:<> - [:h2 "Count: " @c] - [:button.rounded.bg-blue-500.text-white.py-2.px-4.font-bold.mr-2 {:on-click #(swap! c inc)} "increment"] - [:button.rounded.bg-blue-500.text-white.py-2.px-4.font-bold {:on-click #(swap! c dec)} "decrement"]])))) + (v/with-viewer '(fn [] (reagent/with-let [c (reagent/atom 0)] + [:<> + [:h2 "Count: " @c] + [:button.rounded.bg-blue-500.text-white.py-2.px-4.font-bold.mr-2 {:on-click #(swap! c inc)} "increment"] + [:button.rounded.bg-blue-500.text-white.py-2.px-4.font-bold {:on-click #(swap! c dec)} "decrement"]])) + {})) ;; ## Using `v/with-viewer` (c/card @@ -223,13 +223,6 @@ (update doc :blocks (partial map (fn [{:as b :keys [type text]}] (cond-> b (= :code type) - (assoc :result - {:nextjournal/value - (let [val (eval (read-string text))] - ;; FIXME: this won't be necessary once we unify v/html in SCI env to be the same as in nextjournal.clerk.viewer - ;; v/html is currently html-render for supporting legacy render-fns - (cond->> val - (nextjournal.clerk.render/valid-react-element? val) - (v/with-viewer v/reagent-viewer)))}))))) + (assoc :result {:nextjournal/value (eval (read-string text))}))))) (v/with-viewer v/notebook-viewer {::clerk/width :wide} doc)) ) diff --git a/notebooks/viewer_api.clj b/notebooks/viewer_api.clj index 1b2ddc55b..39cb6c175 100644 --- a/notebooks/viewer_api.clj +++ b/notebooks/viewer_api.clj @@ -71,6 +71,10 @@ (clerk/with-viewer '#(vector :div "Greetings to " [:strong %] "!") "James Clerk Maxwell") +;; Legacy `:render-fn` with `html`: +(clerk/with-viewer '#(nextjournal.clerk.viewer/html (vector :div "Greetings to " [:strong %] "!")) + "James Clerk Maxwell (legacy)") + ^{::clerk/viewer {:render-fn '#(vector :span "The answer is " % ".") :transform-fn (comp inc :nextjournal/value)}} (do 41) diff --git a/src/nextjournal/clerk/render.cljs b/src/nextjournal/clerk/render.cljs index d6829e638..137d8f816 100644 --- a/src/nextjournal/clerk/render.cljs +++ b/src/nextjournal/clerk/render.cljs @@ -27,7 +27,7 @@ (r/set-default-compiler! (r/create-compiler {:function-components true})) -(declare inspect inspect-presented reagent-viewer html html-viewer) +(declare inspect inspect-presented html html-viewer) (def nbsp (gstring/unescapeEntities " ")) @@ -785,23 +785,10 @@ (mount))))) -(defn html-render [markup] - (r/as-element - (if (string? markup) - [:span {:dangerouslySetInnerHTML {:__html markup}}] - markup))) - -(def html-viewer - {:render-fn html-render}) - -(def html - (partial viewer/with-viewer html-viewer)) - -(defn render-reagent [x] - (r/as-element (cond-> x (fn? x) vector))) - -;; TODO: remove -(def reagent-viewer render-reagent) +(defn render-html [markup] + (r/as-element (if (string? markup) + [:span {:dangerouslySetInnerHTML {:__html markup}}] + markup))) (defn render-promise [p opts] (let [!state (hooks/use-state {:pending true})] diff --git a/src/nextjournal/clerk/sci_env.cljs b/src/nextjournal/clerk/sci_env.cljs index 72cfb5426..9b504288e 100644 --- a/src/nextjournal/clerk/sci_env.cljs +++ b/src/nextjournal/clerk/sci_env.cljs @@ -27,6 +27,7 @@ [nextjournal.clojure-mode.extensions.eval-region] [nextjournal.clojure-mode.keymap] [reagent.dom.server :as dom-server] + [reagent.ratom :as ratom] [sci.configs.applied-science.js-interop :as sci.configs.js-interop] [sci.configs.reagent.reagent :as sci.configs.reagent] [sci.core :as sci] @@ -103,9 +104,22 @@ (def ^{:doc "Stub implementation to be replaced during static site generation. Clerk is only serving one page currently."} doc-url (sci/new-var 'doc-url viewer/doc-url)) +(defn ^:private render-html-or-viewer [x] + ;; We've dropped the need to write `nextjournal.clerk.viewer/html` in `:render-fn`s in 0.12, see + ;; https://github.com/nextjournal/clerk/blob/62b91b7e5a4487472129ea41095de6c62e8834ce/CHANGELOG.md#012699-2022-12-02 + + ;; If we don't override `nextjournal.clerk.viewer/html` for the sci + ;; env, we'd produce an infinte loop in the browser. So we're + ;; instead checking if we're inside a reactive context and only + ;; calling `render-html` in that case. Otherwise (i.e. in + ;; `notebooks/cards.clj` we call the normal viewer fn. + (if ratom/*ratom-context* + (render/render-html x) + (viewer/html x))) + (def viewer-namespace (merge (sci/copy-ns nextjournal.clerk.viewer (sci/create-ns 'nextjournal.clerk.viewer)) - {'html render/html-render + {'html render-html-or-viewer 'doc-url doc-url 'url-for render/url-for 'read-string read-string diff --git a/src/nextjournal/clerk/viewer.cljc b/src/nextjournal/clerk/viewer.cljc index 0bcb960cc..37f68df41 100644 --- a/src/nextjournal/clerk/viewer.cljc +++ b/src/nextjournal/clerk/viewer.cljc @@ -911,7 +911,7 @@ (def html-viewer {:name `html-viewer - :render-fn 'identity + :render-fn 'nextjournal.clerk.render/render-html :transform-fn (comp mark-presented transform-html)}) #_(present (with-viewer html-viewer [:div {:nextjournal/value (range 30)} {:nextjournal/value (range 30)}])) @@ -938,9 +938,6 @@ #(update-in % [:nextjournal/render-opts :language] (fn [lang] (or lang "clojure"))) (update-val (fn [v] (if (string? v) v (str/trim (with-out-str (pprint/pprint v)))))))}) -(def reagent-viewer - {:name `reagent-viewer :render-fn 'nextjournal.clerk.render/render-reagent :transform-fn mark-presented}) - (def row-viewer {:name `row-viewer :render-fn '(fn [items opts] (let [item-count (count items)] @@ -1265,7 +1262,6 @@ plotly-viewer vega-lite-viewer markdown-viewer - reagent-viewer row-viewer col-viewer table-viewer From d80187013d7b7b96db3d8b114b8d99f687170668 Mon Sep 17 00:00:00 2001 From: Martin Kavalar Date: Mon, 26 Jun 2023 10:56:49 +0200 Subject: [PATCH 12/23] First cut of Clerk CLJS Editor component (#527) Adds `nextjournal.clerk.render.editor`, a clojure-mode based editor for Clerk documents in the browser. Uses completions based on the sci environment. Parses and evaluates the doc as a Clerk doc in ClojureScript. https://snapshots.nextjournal.com/clerk/build/465f5351161eb28ad631bee197b34d6e0849bd6f/editor.html --------- Co-authored-by: Philippa Markovics --- .github/workflows/main.yml | 4 +- notebooks/editor.clj | 9 + notebooks/rule_30.clj | 3 +- render/deps.edn | 2 +- src/nextjournal/clerk/analyzer.clj | 5 +- src/nextjournal/clerk/builder.clj | 1 + src/nextjournal/clerk/parser.cljc | 3 + src/nextjournal/clerk/render.cljs | 31 +- src/nextjournal/clerk/render/code.cljs | 53 +++- src/nextjournal/clerk/render/editor.cljs | 277 ++++++++++++++++++ src/nextjournal/clerk/render/panel.cljs | 57 ++-- src/nextjournal/clerk/sci_env.cljs | 3 + .../clerk/sci_env/completions.cljs | 138 +++++++++ src/nextjournal/clerk/viewer.cljc | 21 +- 14 files changed, 539 insertions(+), 68 deletions(-) create mode 100644 notebooks/editor.clj create mode 100644 src/nextjournal/clerk/render/editor.cljs create mode 100644 src/nextjournal/clerk/sci_env/completions.cljs diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b835c9eb7..2b59dcd88 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -143,7 +143,9 @@ jobs: uses: google-github-actions/setup-gcloud@v0.3.0 - name: πŸ““ Build Clerk Book - run: clojure -J-Dclojure.main.report=stderr -X:demo:nextjournal/clerk :git/sha '"${{ github.sha }}"' :git/url '"https://github.com/nextjournal/clerk"' :paths '["book.clj" "CHANGELOG.md"]' + run: | + cp notebooks/editor.clj editor.clj + clojure -J-Dclojure.main.report=stderr -X:demo:nextjournal/clerk :git/sha '"${{ github.sha }}"' :git/url '"https://github.com/nextjournal/clerk"' :paths '["book.clj" "CHANGELOG.md" "editor.clj"]' - name: πŸ— Build Clerk Static App with default Notebooks run: clojure -J-Dclojure.main.report=stderr -X:demo:nextjournal/clerk :git/sha '"${{ github.sha }}"' :git/url '"https://github.com/nextjournal/clerk"' :bundle true diff --git a/notebooks/editor.clj b/notebooks/editor.clj new file mode 100644 index 000000000..618833a46 --- /dev/null +++ b/notebooks/editor.clj @@ -0,0 +1,9 @@ +(ns editor + {:nextjournal.clerk/visibility {:code :hide} + :nextjournal.clerk/doc-css-class [:overflow-hidden :p-0]} + (:require [nextjournal.clerk :as clerk])) + +(clerk/with-viewer + {:render-fn 'nextjournal.clerk.render.editor/view + :transform-fn clerk/mark-presented} + (slurp "notebooks/rule_30.clj")) diff --git a/notebooks/rule_30.clj b/notebooks/rule_30.clj index cd4397e31..50087f5d1 100644 --- a/notebooks/rule_30.clj +++ b/notebooks/rule_30.clj @@ -10,7 +10,8 @@ {:pred (every-pred list? (partial every? (some-fn number? vector?))) :render-fn '#(into [:div.flex.flex-col] (nextjournal.clerk.render/inspect-children %2) %1)} {:pred (every-pred vector? (complement map-entry?) (partial every? number?)) - :render-fn '#(into [:div.flex.inline-flex] (nextjournal.clerk.render/inspect-children %2) %1)}]) + :render-fn '#(into [:div.flex.inline-flex] (nextjournal.clerk.render/inspect-children %2) %1)} + {:pred var? :transform-fn (clerk/update-val deref)}]) (clerk/add-viewers! viewers) diff --git a/render/deps.edn b/render/deps.edn index 0e13a6220..2b8f456b5 100644 --- a/render/deps.edn +++ b/render/deps.edn @@ -5,7 +5,7 @@ org.babashka/sci {:mvn/version "0.7.39"} reagent/reagent {:mvn/version "1.2.0"} io.github.babashka/sci.configs {:git/sha "0702ea5a21ad92e6d7cca6d36de84271083ea68f"} - io.github.nextjournal/clojure-mode {:git/sha "ac038ebf6e5da09dd2b8a31609e9ff4a65e36852"} + io.github.nextjournal/clojure-mode {:git/sha "1f55406087814a0dda6806396aa596dbe13ea302"} thheller/shadow-cljs {:mvn/version "2.23.1"} io.github.squint-cljs/cherry {;; :local/root "/Users/borkdude/dev/cherry" :git/sha "ac89d93f136ee8fab91f62949de5b5822ba08b3c"}}} diff --git a/src/nextjournal/clerk/analyzer.clj b/src/nextjournal/clerk/analyzer.clj index c15a6f30a..f1289a217 100644 --- a/src/nextjournal/clerk/analyzer.clj +++ b/src/nextjournal/clerk/analyzer.clj @@ -310,9 +310,6 @@ (throw (ex-info (str "The var `#'" missing-dep "` is being referenced, but Clerk can't find it in the namespace's source code. Did you remove it? This validation can fail when the namespace is mutated programmatically (e.g. using `clojure.core/intern` or side-effecting macros). You can turn off this check by adding `{:nextjournal.clerk/error-on-missing-vars :off}` to the namespace metadata.") {:var-name missing-dep :form form :file file #_#_:defined defined })))))))) -(defn filter-code-blocks-without-form [doc] - (update doc :blocks #(filterv (some-fn :form (complement parser/code?)) %))) - (defn ns-resolver [notebook-ns] (if notebook-ns (into {} (map (juxt key (comp ns-name val))) (ns-aliases notebook-ns)) @@ -363,7 +360,7 @@ (-> doc :blocks count range)) doc? (-> parser/add-block-settings parser/add-open-graph-metadata - filter-code-blocks-without-form)))))) + parser/filter-code-blocks-without-form)))))) #_(let [parsed (nextjournal.clerk.parser/parse-clojure-string "clojure.core/dec")] (build-graph (analyze-doc parsed))) diff --git a/src/nextjournal/clerk/builder.clj b/src/nextjournal/clerk/builder.clj index 140a75bc8..d3e00fff0 100644 --- a/src/nextjournal/clerk/builder.clj +++ b/src/nextjournal/clerk/builder.clj @@ -28,6 +28,7 @@ "controlling_width" "docs" "document_linking" + "editor" "hello" "how_clerk_works" "exec_status" diff --git a/src/nextjournal/clerk/parser.cljc b/src/nextjournal/clerk/parser.cljc index 9dda845de..3607cc4ab 100644 --- a/src/nextjournal/clerk/parser.cljc +++ b/src/nextjournal/clerk/parser.cljc @@ -372,6 +372,9 @@ #_(runnable-code-block? {:type :code :language "clojure" :info "clojure"}) #_(runnable-code-block? {:type :code :language "clojure" :info "clojure {:nextjournal.clerk/code-listing true}"}) +(defn filter-code-blocks-without-form [doc] + (update doc :blocks #(filterv (some-fn :form (complement code?)) %))) + (defn parse-markdown-string [{:as opts :keys [doc?]} s] (let [{:as ctx :keys [content]} (parse-markdown (markdown-context) s)] (loop [{:as state :keys [nodes] ::keys [md-slice]} {:blocks [] ::md-slice [] :nodes content :md-context ctx}] diff --git a/src/nextjournal/clerk/render.cljs b/src/nextjournal/clerk/render.cljs index 137d8f816..4894f8459 100644 --- a/src/nextjournal/clerk/render.cljs +++ b/src/nextjournal/clerk/render.cljs @@ -15,7 +15,6 @@ [nextjournal.clerk.render.code :as code] [nextjournal.clerk.render.context :as view-context] [nextjournal.clerk.render.hooks :as hooks] - [nextjournal.clerk.render.localstorage :as localstorage] [nextjournal.clerk.render.navbar :as navbar] [nextjournal.clerk.render.panel :as panel] [nextjournal.clerk.viewer :as viewer] @@ -34,12 +33,12 @@ (defn reagent-atom? [x] (satisfies? ratom/IReactiveAtom x)) -(defn dark-mode-toggle [!dark-mode?] +(defn dark-mode-toggle [] (let [spring {:type :spring :stiffness 200 :damping 10}] [:div.relative.dark-mode-toggle [:button.text-slate-400.hover:text-slate-600.dark:hover:text-white.cursor-pointer - {:on-click #(swap! !dark-mode? not)} - (if @!dark-mode? + {:on-click #(swap! code/!dark-mode? not)} + (if @code/!dark-mode? [:> (.-svg motion) {:xmlns "http://www.w3.org/2000/svg" :class "w-5 h-5 md:w-4 md:h-4" @@ -79,23 +78,6 @@ [:circle {:cx "20.9101" :cy "6.8555" :r "1.71143" :transform "rotate(-120 20.9101 6.8555)" :fill "currentColor"}] [:circle {:cx "12" :cy "1.71143" :r "1.71143" :fill "currentColor"}]]])]])) -(def local-storage-dark-mode-key "clerk-darkmode") - -(defn set-dark-mode! [dark-mode?] - (let [class-list (.-classList (js/document.querySelector "html"))] - (if dark-mode? - (.add class-list "dark") - (.remove class-list "dark"))) - (localstorage/set-item! local-storage-dark-mode-key dark-mode?)) - -(defn setup-dark-mode! [!dark-mode?] - (add-watch !dark-mode? ::dark-mode-watch - (fn [_ _ old dark-mode?] - (when (not= old dark-mode?) - (set-dark-mode! dark-mode?)))) - (when @!dark-mode? - (set-dark-mode! @!dark-mode?))) - (defonce !eval-counter (r/atom 0)) (defn exec-status [{:keys [progress cell-progress status]}] @@ -149,10 +131,9 @@ (defn render-notebook [{:as doc xs :blocks :keys [bundle? doc-css-class sidenotes? toc toc-visibility header footer]} {:as render-opts :keys [!expanded-at expandable-toc?]}] - (r/with-let [!dark-mode? (r/atom (localstorage/get-item local-storage-dark-mode-key)) - root-ref-fn (fn [el] + (r/with-let [root-ref-fn (fn [el] (when (and el (exists? js/document)) - (setup-dark-mode! !dark-mode?) + (code/setup-dark-mode!) (when-some [heading (when (and (exists? js/location) (not bundle?)) (try (some-> js/location .-hash not-empty js/decodeURI (subs 1) js/document.getElementById) (catch js/Error _ @@ -163,7 +144,7 @@ [:div.flex {:ref root-ref-fn} [:div.fixed.top-2.left-2.md:left-auto.md:right-2.z-10 - [dark-mode-toggle !dark-mode?]] + [dark-mode-toggle]] (when (and toc toc-visibility) [navbar/view toc (assoc render-opts :set-hash? (not bundle?) :toc-visibility toc-visibility)]) [:div.flex-auto.w-screen.scroll-container diff --git a/src/nextjournal/clerk/render/code.cljs b/src/nextjournal/clerk/render/code.cljs index 097e0bfe1..d0757d24e 100644 --- a/src/nextjournal/clerk/render/code.cljs +++ b/src/nextjournal/clerk/render/code.cljs @@ -1,15 +1,37 @@ (ns nextjournal.clerk.render.code (:require ["@codemirror/language" :refer [HighlightStyle syntaxHighlighting LanguageDescription]] - ["@codemirror/state" :refer [EditorState RangeSet RangeSetBuilder Text]] + ["@codemirror/state" :refer [Compartment EditorState RangeSet RangeSetBuilder Text]] ["@codemirror/view" :refer [EditorView Decoration]] ["@lezer/highlight" :refer [tags highlightTree]] ["@nextjournal/lang-clojure" :refer [clojureLanguage]] [applied-science.js-interop :as j] [clojure.string :as str] [nextjournal.clerk.render.hooks :as hooks] + [nextjournal.clerk.render.localstorage :as localstorage] [nextjournal.clojure-mode :as clojure-mode] + [reagent.core :as r] [shadow.esm])) +(def local-storage-dark-mode-key "clerk-darkmode") + +(def !dark-mode? + (r/atom (boolean (localstorage/get-item local-storage-dark-mode-key)))) + +(defn set-dark-mode! [dark-mode?] + (let [class-list (.-classList (js/document.querySelector "html"))] + (if dark-mode? + (.add class-list "dark") + (.remove class-list "dark"))) + (localstorage/set-item! local-storage-dark-mode-key dark-mode?)) + +(defn setup-dark-mode! [] + (add-watch !dark-mode? ::dark-mode-watch + (fn [_ _ old dark-mode?] + (when (not= old dark-mode?) + (set-dark-mode! dark-mode?)))) + (when @!dark-mode? + (set-dark-mode! @!dark-mode?))) + (def highlight-style (.define HighlightStyle (clj->js [{:tag (.-meta tags) :class "cmt-meta"} @@ -131,7 +153,7 @@ [highlight-imported-language {:code code :language language}])]]) ;; editable code viewer -(def theme +(defn get-theme [] (.theme EditorView (j/lit {"&.cm-focused" {:outline "none"} ".cm-line" {:padding "0" @@ -151,7 +173,22 @@ :overflow "hidden"} ".cm-tooltip > ul > li" {:padding "3px 10px 3px 0 !important"} ".cm-tooltip > ul > li:first-child" {:border-top-left-radius "3px" - :border-top-right-radius "3px"}}))) + :border-top-right-radius "3px"} + ".cm-tooltip.cm-tooltip-autocomplete" {:border "0" + :border-radius "6px" + :box-shadow "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)" + "& > ul" {:font-size "12px" + :font-family "'Fira Code', monospace" + :background "rgb(241 245 249)" + :border "1px solid rgb(203 213 225)" + :border-radius "6px"}} + ".cm-tooltip-autocomplete ul li[aria-selected]" {:background "rgb(79 70 229)" + :color "#fff"} + ".cm-tooltip.cm-tooltip-hover" {:background "rgb(241 245 249)" + :border-radius "6px" + :border "1px solid rgb(203 213 225)" + :box-shadow "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)" + :max-width "550px"}}) #js {:dark @!dark-mode?})) (def read-only (.. EditorView -editable (of false))) @@ -161,10 +198,17 @@ (when (.-docChanged tr) (f (.. tr -state sliceDoc))) #js {})))) +(def theme (Compartment.)) + +(defn use-dark-mode [!view] + (hooks/use-effect (fn [] + (add-watch !dark-mode? ::dark-mode #(.dispatch @!view #js {:effects (.reconfigure theme (get-theme))})) + #(remove-watch !dark-mode? ::dark-mode)))) + (def ^:export default-extensions #js [clojure-mode/default-extensions (syntaxHighlighting highlight-style) - theme]) + (.of theme (get-theme))]) (defn make-state [doc extensions] (.create EditorState (j/obj :doc doc :extensions extensions))) @@ -196,4 +240,5 @@ (j/lit {:changes [{:insert @!code-str :from 0 :to (.. state -doc -length)}]})))))) [@!code-str]) + (use-dark-mode !view) [:div {:ref !container-el}]))) diff --git a/src/nextjournal/clerk/render/editor.cljs b/src/nextjournal/clerk/render/editor.cljs new file mode 100644 index 000000000..bfbcdc1d6 --- /dev/null +++ b/src/nextjournal/clerk/render/editor.cljs @@ -0,0 +1,277 @@ +(ns nextjournal.clerk.render.editor + (:require ["@codemirror/autocomplete" :refer [autocompletion]] + ["@codemirror/language" :refer [syntaxTree]] + ["@codemirror/state" :refer [EditorState]] + ["@codemirror/view" :refer [keymap placeholder]] + [applied-science.js-interop :as j] + [clojure.string :as str] + [nextjournal.clerk.parser :as parser] + [nextjournal.clerk.render :as render] + [nextjournal.clerk.render.code :as code] + [nextjournal.clerk.render.hooks :as hooks] + [nextjournal.clerk.render.panel :as panel] + [nextjournal.clerk.sci-env.completions :as sci-completions] + [nextjournal.clerk.viewer :as v] + [nextjournal.clojure-mode.extensions.eval-region :as eval-region] + [nextjournal.clojure-mode.keymap :as clojure-mode.keymap] + [rewrite-clj.node :as n] + [rewrite-clj.parser :as p] + [sci.core :as sci] + [sci.ctx-store] + [shadow.esm])) + +(defn eval-string + ([source] (sci/eval-string* (sci.ctx-store/get-ctx) source)) + ([ctx source] + (when-some [code (not-empty (str/trim source))] + (try {:result (sci/eval-string* ctx code)} + (catch js/Error e + {:error (str (.-message e))}))))) + +(j/defn eval-at-cursor [on-result ^:js {:keys [state]}] + (some->> (eval-region/cursor-node-string state) + (eval-string) + (on-result)) + true) + +(j/defn eval-top-level [on-result ^:js {:keys [state]}] + (some->> (eval-region/top-level-string state) + (eval-string) + (on-result)) + true) + +(j/defn eval-cell [on-result ^:js {:keys [state]}] + (-> (.-doc state) + (str) + (eval-string) + (on-result)) + true) + +(defn autocomplete [^js context] + (let [node-before (.. (syntaxTree (.-state context)) (resolveInner (.-pos context) -1)) + text-before (.. context -state (sliceDoc (.-from node-before) (.-pos context)))] + #js {:from (.-from node-before) + :options (clj->js (map + (fn [{:as option :keys [candidate info]}] + (let [{:keys [arglists arglists-str]} info] + (cond-> {:label candidate :type (if arglists "function" "namespace")} + arglists (assoc :detail arglists-str)))) + (:completions (sci-completions/completions {:ctx (sci.ctx-store/get-ctx) :ns "user" :symbol text-before}))))})) + +(def completion-source + (autocompletion #js {:override #js [autocomplete]})) + +(defn info-at-point [^js view pos] + (let [node-before (.. (syntaxTree (.-state view)) (resolveInner pos -1)) + text-at-point (.. view -state (sliceDoc (.-from node-before) (.-to node-before)))] + (some->> (sci-completions/completions {:ctx (sci.ctx-store/get-ctx) :ns "user" :symbol text-at-point}) + :completions + (filter #(= (:candidate %) text-at-point)) + first + :info))) + +(defn get-block-id [!id->count {:as block :keys [var form type doc]}] + (let [id->count @!id->count + id (if var + var + (let [hash-fn hash] + (symbol (str *ns*) + (case type + :code (str "anon-expr-" (hash-fn form)) + :markdown (str "markdown-" (hash-fn doc))))))] + (swap! !id->count update id (fnil inc 0)) + (if (id->count id) + (symbol (str *ns*) (str (name id) "#" (inc (id->count id)))) + id))) + + +(defn analyze [form] + (cond-> {:form form} + (and (seq? form) + (str/starts-with? (str (first form)) "def")) + (assoc :var (second form)))) + +(defn ns-resolver [notebook-ns] + (into {} (map (juxt key (comp ns-name val))) '{clerk nextjournal.clerk})) + +(defn parse-ns-aliases [ns-form] + (some (fn [x] + (when (and (seq? x) + (= :require (first x))) + (into {} + (keep (fn [require-form] + (when (and (vector? require-form) + (= 3 (count require-form)) + (contains? #{:as :as-alias} (second require-form))) + ((juxt peek first) require-form)))) + (rest x)))) + ns-form)) + +;; TODO: unify with `analyzer/analyze-doc` and move to parser +(defn analyze-doc + ([doc] + (analyze-doc {:doc? true} doc)) + ([{:as state :keys [doc?]} doc] + (binding [*ns* *ns*] + (let [!id->count (atom {})] + (cond-> (reduce (fn [{:as state notebook-ns :ns :keys [ns-aliases]} i] + (let [{:as block :keys [type text]} (get-in doc [:blocks i])] + (if (not= type :code) + (assoc-in state [:blocks i :id] (get-block-id !id->count block)) + (let [node (p/parse-string text) + form (try (n/sexpr node (when ns-aliases {:auto-resolve ns-aliases})) + (catch js/Error e + (throw (ex-info (str "Clerk analysis failed reading block: " + (ex-message e)) + {:block block + :file (:file doc)} + e)))) + analyzed (cond-> (analyze form) + (:file doc) (assoc :file (:file doc))) + block-id (get-block-id !id->count (merge analyzed block)) + analyzed (assoc analyzed :id block-id)] + (cond-> state + (and (not ns-aliases) (parser/ns? form)) (assoc :ns-aliases (parse-ns-aliases form)) + doc? (update-in [:blocks i] merge analyzed) + doc? (assoc-in [:blocks i :text-without-meta] + (parser/text-with-clerk-metadata-removed text (ns-resolver notebook-ns))) + (and doc? (not (contains? state :ns))) (merge (parser/->doc-settings form) {:ns *ns*})))))) + (cond-> state + doc? (merge doc)) + (-> doc :blocks count range)) + doc? (-> parser/add-block-settings + parser/add-open-graph-metadata + parser/filter-code-blocks-without-form)))))) + +(defn eval-blocks [doc] + (update doc :blocks (partial map (fn [{:as cell :keys [type text var form]}] + (cond-> cell + (= :code type) + (assoc :result + {:nextjournal/value (cond->> (eval form) + var (hash-map :nextjournal.clerk/var-from-def))})))))) + +(defn eval-notebook [code] + (->> code + (parser/parse-clojure-string {:doc? true}) + (analyze-doc) + (eval-blocks) + (v/with-viewer v/notebook-viewer))) + +(defonce bar-height 26) + +(defn view [code-string] + (let [!notebook (hooks/use-state nil) + !eval-result (hooks/use-state nil) + !container-el (hooks/use-ref nil) + !info (hooks/use-state nil) + !show-docstring? (hooks/use-state false) + !view (hooks/use-ref nil) + !editor-panel (hooks/use-ref nil) + !notebook-panel (hooks/use-ref nil) + on-result #(reset! !eval-result %) + on-eval #(reset! !notebook (try + (eval-notebook (.. % -state -doc toString)) + (catch js/Error error (v/html [render/error-view error]))))] + (hooks/use-effect + (fn [] + (let [^js view + (reset! !view (code/make-view + (code/make-state code-string + (.concat code/default-extensions + #js [(placeholder "Show code with Option+Return") + (.of keymap clojure-mode.keymap/paredit) + completion-source + (.. EditorState -transactionExtender + (of (fn [^js tr] + (when (.-selection tr) + (reset! !eval-result nil) + (reset! !show-docstring? false) + (reset! !info (some-> (info-at-point @!view (.-to (first (.. tr -selection asSingle -ranges))))))) + #js {}))) + (eval-region/extension {:modifier "Meta"}) + (.of keymap + (j/lit + [{:key "Alt-Enter" + :run on-eval} + {:key "Mod-Enter" + :shift (partial eval-top-level on-result) + :run (partial eval-at-cursor on-result)} + {:key "Mod-i" + :preventDefault true + :run #(swap! !show-docstring? not)} + {:key "Escape" + :run #(reset! !show-docstring? false)}]))])) + @!container-el))] + (on-eval view) + #(.destroy view)))) + (code/use-dark-mode !view) + [:<> + [:style {:type "text/css"} (str ".notebook-viewer { padding-top: 2.5rem; } " + ".notebook-viewer .viewer:first-child { display: none; } " + "#clerk > div > div > .dark-mode-toggle { display: none !important; }")] + [:div.fixed.w-screen.h-screen.flex.flex-col.top-0.left-0 + [:div.flex + [:div.relative + {:ref !editor-panel :style {:width "50vw"}} + [:div.bg-slate-200.border-r.border-slate-300.dark:border-slate-600.px-4.py-3.dark:bg-slate-950.overflow-y-auto.relative + {:style {:height (str "calc(100vh - " (* bar-height 2) "px)")}} + [:div.h-screen {:ref !container-el}]] + [:div.absolute.right-0.top-0.bottom-0.z-1000.group + {:class "w-[9px] -mr-[5px]"} + [:div.absolute.h-full.bg-transparent.group-hover:bg-blue-500.transition.pointer-events-none + {:class "left-[3px] w-[3px]"}] + [panel/resizer {:axis :x :on-resize (fn [_ dx _] + (j/assoc-in! @!editor-panel [:style :width] (str (+ (.-offsetWidth @!editor-panel) dx) "px")) + (j/assoc-in! @!notebook-panel [:style :width] (str (- (.-offsetWidth @!notebook-panel) dx) "px")))}]]] + [:div.bg-white.dark:bg-slate-950.bg-white.flex.flex-col.overflow-y-auto + {:ref !notebook-panel + :style {:width "50vw" :height (str "calc(100vh - " (* bar-height 2) "px)")}} + (when-let [notebook @!notebook] + [:> render/ErrorBoundary {:hash (gensym)} + [render/inspect notebook]])]] + [:div.absolute.left-0.bottom-0.w-screen.font-mono.text-white.border-t.dark:border-slate-600 + [:div.bg-slate-900.dark:bg-slate-800.flex.px-4.font-mono.gap-4.items-center.text-white + {:class "text-[12px]" :style {:height bar-height}} + [:div.flex.gap-1.items-center + "Eval notebook" + [:div.font-inter.text-slate-300 "βŒ₯↩"]] + [:div.flex.gap-1.items-center + "Eval at cursor" + [:div.font-inter.text-slate-300 "βŒ˜β†©"]] + [:div.flex.gap-1.items-center + "Eval top level" + [:div.font-inter.text-slate-300 "β‡§βŒ˜β†©"]] + [:div.flex.gap-1.items-center + "Slurp forward" + [:div.font-inter.text-slate-300 "Ctrlβ†’"]] + [:div.flex.gap-1.items-center + "Barf forward" + [:div.font-inter.text-slate-300 "Ctrl←"]] + [:div.flex.gap-1.items-center + "Splice" + [:div.font-inter.text-slate-300 "βŒ₯S"]] + [:div.flex.gap-1.items-center + "Expand selection" + [:div.font-inter.text-slate-300 "⌘1"]] + [:div.flex.gap-1.items-center + "Contract selection" + [:div.font-inter.text-slate-300 "⌘2"]]] + [:div.w-screen.bg-slate-800.dark:bg-slate-950.px-4.font-mono.items-center.text-white.flex.items-center + {:class "text-[12px] py-[4px]" :style {:min-height bar-height}} + (when-let [{:keys [name arglists-str doc]} @!info] + [:div + [:div.flex.gap-4 + [:div.flex.gap-2 + [:span.font-bold (str name)] + [:span arglists-str]] + (when (and doc (not @!show-docstring?)) + [:div.flex.gap-1.items-center.text-slate-300 + "Show docstring" + [:div.font-inter.text-slate-400.flex-shrink-0 "⌘I"]])] + (when (and doc @!show-docstring?) + [:div.text-slate-300.mt-2.mb-1.leading-relaxed {:class "max-w-[640px]"} doc])])]] + (when-let [result @!eval-result] + [:div.border-t.border-slate-300.dark:border-slate-600.px-4.py-2.flex-shrink-0.absolute.left-0.w-screen.bg-white.dark:bg-slate-950 + {:style {:box-shadow "0 -2px 3px 0 rgb(0 0 0 / 0.025)" :bottom (* bar-height 2)}} + [render/inspect result]])]])) diff --git a/src/nextjournal/clerk/render/panel.cljs b/src/nextjournal/clerk/render/panel.cljs index 8305372ef..dfb1d5f7c 100644 --- a/src/nextjournal/clerk/render/panel.cljs +++ b/src/nextjournal/clerk/render/panel.cljs @@ -2,7 +2,8 @@ (:require [applied-science.js-interop :as j] [nextjournal.clerk.render.hooks :as hooks])) -(defn resizer [{:keys [on-resize on-resize-start on-resize-end] :or {on-resize-start #() on-resize-end #()}}] +(defn resizer [{:keys [axis on-resize on-resize-start on-resize-end] + :or {on-resize-start #() on-resize-end #()}}] (let [!direction (hooks/use-state nil) !mouse-down (hooks/use-state false) handle-mouse-down (fn [dir] @@ -23,31 +24,35 @@ (reset! !mouse-down false))] (js/addEventListener "mouseup" handle-mouse-up) #(js/removeEventListener "mouseup" handle-mouse-up)))) - [:<> - [:div.absolute.z-2.cursor-nwse-resize - {:on-mouse-down #(handle-mouse-down :top-left) - :class "w-[14px] h-[14px] -left-[7px] -top-[7px]"}] - [:div.absolute.z-1.left-0.w-full.cursor-ns-resize - {:on-mouse-down #(handle-mouse-down :top) - :class "h-[4px] -top-[4px]"}] - [:div.absolute.z-2.cursor-nesw-resize - {:on-mouse-down #(handle-mouse-down :top-right) - :class "w-[14px] h-[14px] -right-[7px] -top-[7px]"}] - [:div.absolute.z-1.top-0.h-full.cursor-ew-resize - {:on-mouse-down #(handle-mouse-down :right) - :class "w-[4px] -right-[2px]"}] - [:div.absolute.z-2.cursor-nwse-resize - {:on-mouse-down #(handle-mouse-down :bottom-right) - :class "w-[14px] h-[14px] -right-[7px] -bottom-[7px]"}] - [:div.absolute.z-1.bottom-0.w-full.cursor-ns-resize - {:on-mouse-down #(handle-mouse-down :bottom) - :class "h-[4px] -left-[2px]"}] - [:div.absolute.z-2.cursor-nesw-resize - {:on-mouse-down #(handle-mouse-down :bottom-left) - :class "w-[14px] h-[14px] -left-[7px] -bottom-[7px]"}] - [:div.absolute.z-1.left-0.top-0.h-full.cursor-ew-resize - {:on-mouse-down #(handle-mouse-down :left) - :class "w-[4px]"}]])) + (if axis + [:div.w-full.h-full + {:class (if (= axis :x) "cursor-col-resize" "cursor-row-resize") + :on-mouse-down #(handle-mouse-down (if (= axis :x) :left :up))}] + [:<> + [:div.absolute.z-2.cursor-nwse-resize + {:on-mouse-down #(handle-mouse-down :top-left) + :class "w-[14px] h-[14px] -left-[7px] -top-[7px]"}] + [:div.absolute.z-1.left-0.w-full.cursor-ns-resize + {:on-mouse-down #(handle-mouse-down :top) + :class "h-[4px] -top-[4px]"}] + [:div.absolute.z-2.cursor-nesw-resize + {:on-mouse-down #(handle-mouse-down :top-right) + :class "w-[14px] h-[14px] -right-[7px] -top-[7px]"}] + [:div.absolute.z-1.top-0.h-full.cursor-ew-resize + {:on-mouse-down #(handle-mouse-down :right) + :class "w-[4px] -right-[2px]"}] + [:div.absolute.z-2.cursor-nwse-resize + {:on-mouse-down #(handle-mouse-down :bottom-right) + :class "w-[14px] h-[14px] -right-[7px] -bottom-[7px]"}] + [:div.absolute.z-1.bottom-0.w-full.cursor-ns-resize + {:on-mouse-down #(handle-mouse-down :bottom) + :class "h-[4px] -left-[2px]"}] + [:div.absolute.z-2.cursor-nesw-resize + {:on-mouse-down #(handle-mouse-down :bottom-left) + :class "w-[14px] h-[14px] -left-[7px] -bottom-[7px]"}] + [:div.absolute.z-1.left-0.top-0.h-full.cursor-ew-resize + {:on-mouse-down #(handle-mouse-down :left) + :class "w-[4px]"}]]))) (defn header [{:keys [id title on-drag on-drag-start on-drag-end on-close] :or {on-drag-start #() on-drag-end #()}}] (let [!mouse-down (hooks/use-state false) diff --git a/src/nextjournal/clerk/sci_env.cljs b/src/nextjournal/clerk/sci_env.cljs index 9b504288e..950850fc9 100644 --- a/src/nextjournal/clerk/sci_env.cljs +++ b/src/nextjournal/clerk/sci_env.cljs @@ -19,6 +19,7 @@ [nextjournal.clerk.render :as render] [nextjournal.clerk.render.code] [nextjournal.clerk.render.context :as view-context] + [nextjournal.clerk.render.editor] [nextjournal.clerk.render.hooks] [nextjournal.clerk.render.navbar] [nextjournal.clerk.trim-image] @@ -163,6 +164,7 @@ "react" react} :ns-aliases '{clojure.math cljs.math} :namespaces (merge {'nextjournal.clerk.viewer viewer-namespace + 'nextjournal.clerk viewer-namespace ;; TODO: expose cljs variant of `nextjournal.clerk` with docstrings 'clojure.core {'read-string read-string 'implements? (sci/copy-var implements?* core-ns) 'time (sci/copy-var time core-ns) @@ -172,6 +174,7 @@ 'nextjournal.clerk.parser 'nextjournal.clerk.render 'nextjournal.clerk.render.code + 'nextjournal.clerk.render.editor 'nextjournal.clerk.render.hooks 'nextjournal.clerk.render.navbar diff --git a/src/nextjournal/clerk/sci_env/completions.cljs b/src/nextjournal/clerk/sci_env/completions.cljs new file mode 100644 index 000000000..9c783f15f --- /dev/null +++ b/src/nextjournal/clerk/sci_env/completions.cljs @@ -0,0 +1,138 @@ +(ns nextjournal.clerk.sci-env.completions + (:require [clojure.string :as str] + [goog.object :as gobject] + [sci.core :as sci] + [sci.ctx-store])) + +(defn format [fmt-str x] + (str/replace fmt-str "%s" x)) + +(defn fully-qualified-syms [ctx ns-sym] + (let [syms (sci/eval-string* ctx (format "(keys (ns-map '%s))" ns-sym)) + sym-strs (map #(str "`" %) syms) + sym-expr (str "[" (str/join " " sym-strs) "]") + syms (sci/eval-string* ctx sym-expr) + syms (remove #(str/starts-with? (str %) "nbb.internal") syms)] + syms)) + +(defn- ns-imports->completions [ctx query-ns query] + (let [[_ns-part name-part] (str/split query #"/") + resolved (sci/eval-string* ctx + (pr-str `(let [resolved# (resolve '~query-ns)] + (when-not (var? resolved#) + resolved#))))] + (when resolved + (when-let [[prefix imported] (if name-part + (let [ends-with-dot? (str/ends-with? name-part ".") + fields (str/split name-part #"\.") + fields (if ends-with-dot? + fields + (butlast fields))] + [(str query-ns "/" (when (seq fields) + (let [joined (str/join "." fields)] + (str joined ".")))) + (apply gobject/getValueByKeys resolved + fields)]) + [(str query-ns "/") resolved])] + (let [props (loop [obj imported + props []] + (if obj + (recur (js/Object.getPrototypeOf obj) + (into props (js/Object.getOwnPropertyNames obj))) + props)) + completions (map (fn [k] + [nil (str prefix k)]) props)] + completions))))) + +(defn- match [_alias->ns ns->alias query [sym-ns sym-name qualifier]] + (let [pat (re-pattern query)] + (or (when (and (= :unqualified qualifier) (re-find pat sym-name)) + [sym-ns sym-name]) + (when sym-ns + (or (when (re-find pat (str (get ns->alias (symbol sym-ns)) "/" sym-name)) + [sym-ns (str (get ns->alias (symbol sym-ns)) "/" sym-name)]) + (when (re-find pat (str sym-ns "/" sym-name)) + [sym-ns (str sym-ns "/" sym-name)])))))) + +(defn format-1 [fmt-str x] + (str/replace-first fmt-str "%s" x)) + +(defn info [{:keys [sym ctx] ns-str :ns}] + (if-not sym + {:status ["no-eldoc" "done"] + :err "Message should contain a `sym`"} + (let [code (-> "(when-let [the-var (ns-resolve '%s '%s)] (meta the-var))" + (format-1 ns-str) + (format-1 sym)) + [kind val] (try [::success (sci/eval-string* ctx code)] + (catch :default e + [::error (str e)])) + {:keys [doc file line name arglists]} val] + (if (and name (= kind ::success)) + (cond-> {:ns (some-> val :ns ns-name) + :arglists (pr-str arglists) + :eldoc (mapv #(mapv str %) arglists) + :arglists-str (.join (apply array arglists) "\n") + :status ["done"] + :name name} + doc (assoc :doc doc) + file (assoc :file file) + line (assoc :line line)) + {:status ["done" "no-eldoc"]})))) + +(defn completions [{:keys [ctx] ns-str :ns :as request}] + (try + (let [sci-ns (when ns-str + (sci/find-ns ctx (symbol ns-str)))] + (sci/binding [sci/ns (or sci-ns @sci/ns)] + (if-let [query (or (:symbol request) + (:prefix request))] + (let [has-namespace? (str/includes? query "/") + query-ns (when has-namespace? (some-> (str/split query #"/") + first symbol)) + from-current-ns (fully-qualified-syms ctx (sci/eval-string* ctx "(ns-name *ns*)")) + from-current-ns (map (fn [sym] + [(namespace sym) (name sym) :unqualified]) + from-current-ns) + alias->ns (sci/eval-string* ctx "(let [m (ns-aliases *ns*)] (zipmap (keys m) (map ns-name (vals m))))") + ns->alias (zipmap (vals alias->ns) (keys alias->ns)) + from-aliased-nss (doall (mapcat + (fn [alias] + (let [ns (get alias->ns alias) + syms (sci/eval-string* ctx (format "(keys (ns-publics '%s))" ns))] + (map (fn [sym] + [(str ns) (str sym) :qualified]) + syms))) + (keys alias->ns))) + all-namespaces (->> (sci/eval-string* ctx "(all-ns)") + (map (fn [ns] + [(str ns) nil :qualified]))) + from-imports (when has-namespace? (ns-imports->completions ctx query-ns query)) + fully-qualified-names (when-not from-imports + (when has-namespace? + (let [ns (get alias->ns query-ns query-ns) + syms (sci/eval-string* ctx (format "(and (find-ns '%s) + (keys (ns-publics '%s)))" + ns))] + (map (fn [sym] + [(str ns) (str sym) :qualified]) + syms)))) + svs (concat from-current-ns from-aliased-nss all-namespaces fully-qualified-names) + completions (keep (fn [entry] + (match alias->ns ns->alias query entry)) + svs) + completions (concat completions from-imports) + completions (->> (map (fn [[namespace name]] + (let [candidate (str name) + info (when namespace + (info {:ns (str namespace) :sym candidate :ctx ctx}))] + {:candidate candidate :info info})) + completions) + distinct vec)] + {:completions completions + :status ["done"]}) + {:status ["done"]}))) + (catch :default e + (js/console.error "ERROR" e) + {:completions [] + :status ["done"]}))) diff --git a/src/nextjournal/clerk/viewer.cljc b/src/nextjournal/clerk/viewer.cljc index 37f68df41..2e9de4fb6 100644 --- a/src/nextjournal/clerk/viewer.cljc +++ b/src/nextjournal/clerk/viewer.cljc @@ -1,4 +1,5 @@ (ns nextjournal.clerk.viewer + (:refer-clojure :exclude [var?]) (:require [clojure.string :as str] [clojure.pprint :as pprint] [clojure.datafy :as datafy] @@ -13,6 +14,7 @@ :cljs [[goog.crypt] [goog.crypt.Sha1] [reagent.ratom :as ratom] + [sci.core :as sci] [sci.impl.vars] [sci.lang] [applied-science.js-interop :as j]]) @@ -390,6 +392,10 @@ #_((update-val + 1) {:nextjournal/value 41}) +(defn var? [x] + (or (clojure.core/var? x) + #?(:cljs (instance? sci.lang.Var x)))) + (defn var-from-def? [x] (var? (get-safe x :nextjournal.clerk/var-from-def))) @@ -464,11 +470,14 @@ (defn datafy-scope [scope] (cond - #?@(:clj [(instance? clojure.lang.Namespace scope) (ns-name scope)]) + #?@(:clj [(instance? clojure.lang.Namespace scope) (ns-name scope)] + :cljs [(instance? sci.lang.Namespace scope) (sci/ns-name scope)]) (symbol? scope) scope (#{:default} scope) scope :else (throw (ex-info (str "Unsupported scope `" scope "`. Valid scopes are namespaces, symbol namespace names or `:default`.") {:scope scope})))) +(defn get-*ns* [] + (or *ns* #?(:cljs @sci.core/ns))) (defn get-viewers ([scope] (get-viewers scope nil)) @@ -505,7 +514,7 @@ (set/rename-keys viewer-opts-normalization)) {:as to-present :nextjournal/keys [auto-expand-results?]} (merge (dissoc (->opts wrapped-value) :!budget :nextjournal/budget) opts-from-block - (ensure-wrapped-with-viewers (or viewers (get-viewers *ns*)) value)) + (ensure-wrapped-with-viewers (or viewers (get-viewers (get-*ns*))) value)) presented-result (-> (present to-present) (update :nextjournal/render-opts (fn [{:as opts existing-id :id}] @@ -830,7 +839,7 @@ (def var-viewer {:name `var-viewer - :pred (some-fn var? #?(:cljs #(instance? sci.lang.Var %))) + :pred var? :transform-fn (comp #?(:cljs var->symbol :clj symbol) ->value) :render-fn '(fn [x] [:span.inspected-value [:span.cmt-meta "#'" (str x)]])}) @@ -1335,7 +1344,7 @@ #_(viewer-for default-viewers (with-viewer {:transform-fn identity} [:h1 "Hello Hiccup"])) (defn ensure-wrapped-with-viewers - ([x] (ensure-wrapped-with-viewers (get-viewers *ns*) x)) + ([x] (ensure-wrapped-with-viewers (get-viewers (get-*ns*)) x)) ([viewers x] (-> x ensure-wrapped @@ -1723,13 +1732,13 @@ xs))))))) (defn reset-viewers! - ([viewers] (reset-viewers! *ns* viewers)) + ([viewers] (reset-viewers! (get-*ns*) viewers)) ([scope viewers] (swap! !viewers assoc (datafy-scope scope) viewers) viewers)) (defn add-viewers! [viewers] - (reset-viewers! *ns* (add-viewers (get-default-viewers) viewers))) + (reset-viewers! (get-*ns*) (add-viewers (get-default-viewers) viewers))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; public convenience api From b7207926bacb10e6af17075efd71df3363b74d21 Mon Sep 17 00:00:00 2001 From: Martin Kavalar Date: Mon, 3 Jul 2023 11:06:00 +0200 Subject: [PATCH 13/23] Unify link handling between `build!` and `serve!` (#529) This unifies the link handling between `build!` and `serve!` by no longer using extensions in either mode (was `.clj|md` in `serve!` and `.html` in `build!`). To support this in the unbundled static build, we're now writing directories with `index.html` for each notebook. This makes links in this build no longer accessible without a http server. If you're looking for a self-contained html that works without a webserver, set the `:bundle` option. --- CHANGELOG.md | 6 ++ index.clj | 5 +- notebooks/document_linking.clj | 14 +-- notebooks/links.md | 67 +++++++++++++++ src/nextjournal/clerk/builder.clj | 67 ++++++++------- src/nextjournal/clerk/builder_ui.clj | 8 +- src/nextjournal/clerk/index.clj | 2 +- src/nextjournal/clerk/render.cljs | 108 +++++++++++++----------- src/nextjournal/clerk/viewer.cljc | 4 +- src/nextjournal/clerk/webserver.clj | 27 ++++-- test/nextjournal/clerk/builder_test.clj | 12 +-- 11 files changed, 213 insertions(+), 107 deletions(-) create mode 100644 notebooks/links.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 36ca39e88..b15beda10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,12 @@ Changes can be: * `nextjournal.clerk.viewer/html` instead. Also rename `nextjournal.clerk.render/html-render` to `nextjournal.clerk.render/render-html` and make `nextjournal.clerk.viewer/html` use it when called from a reactive context. + +* 🚨 Unify the link handling between `build!` and `serve!` + + By no longer using extensions in either mode (was `.clj|md` in `serve!` and `.html` in `build!`). + + To support this in the unbundled static build, we're now writing directories with `index.html` for each notebook. This makes links in this build no longer accessible without a http server. If you're looking for a self-contained html that works without a webserver, set the `:bundle` option. * πŸ“– Improve Table of Contents design and fixing re-rendering issues. Also added suport for chapter expansion. diff --git a/index.clj b/index.clj index 613bde5bf..24e5e8ee8 100644 --- a/index.clj +++ b/index.clj @@ -8,5 +8,6 @@ (clerk/html [:div.viewer-markdown [:ul - [:li [:a.underline {:href (clerk/doc-url "notebooks/rule_30.clj")} "Rule 30"]] - [:li [:a.underline {:href (clerk/doc-url "notebooks/markdown.md")} "Markdown"]]]]) + [:li [:a.underline {:href (clerk/doc-url "notebooks/rule_30")} "Rule 30"]] + [:li [:a.underline {:href (clerk/doc-url "notebooks/links")} "Link Design"]] + [:li [:a.underline {:href (clerk/doc-url "notebooks/markdown")} "Markdown"]]]]) diff --git a/notebooks/document_linking.clj b/notebooks/document_linking.clj index b7a699123..e7b1ec842 100644 --- a/notebooks/document_linking.clj +++ b/notebooks/document_linking.clj @@ -7,11 +7,11 @@ ;; The helper `clerk/doc-url` allows to reference notebooks by path. We currently support relative paths with respect to the directory which started the Clerk application. An optional trailing hash fragment can appended to the path in order for the page to be scrolled up to the indicated identifier. (clerk/html [:ol - [:li [:a {:href (clerk/doc-url "notebooks/viewers/html.clj")} "HTML"]] - [:li [:a {:href (clerk/doc-url "notebooks/viewers/image.clj")} "Images"]] + [:li [:a {:href (clerk/doc-url "notebooks/viewers/html")} "HTML"]] + [:li [:a {:href (clerk/doc-url "notebooks/viewers/image")} "Images"]] [:li [:a {:href (clerk/doc-url "notebooks/markdown.md" "appendix")} "Markdown / Appendix"]] - [:li [:a {:href (clerk/doc-url "notebooks/how_clerk_works.clj" "step-3:-analyzer")} "Clerk Analyzer"]] - [:li [:a {:href (clerk/doc-url "book.clj")} "The πŸ“•Book"]] + [:li [:a {:href (clerk/doc-url "notebooks/how_clerk_works" "step-3:-analyzer")} "Clerk Analyzer"]] + [:li [:a {:href (clerk/doc-url "book")} "The πŸ“•Book"]] [:li [:a {:href (clerk/doc-url "")} "Homepage"]]]) @@ -20,6 +20,6 @@ (clerk/with-viewer '(fn [_ _] [:ol - [:li [:a {:href (nextjournal.clerk.viewer/doc-url "notebooks/viewers/html.clj")} "HTML"]] - [:li [:a {:href (nextjournal.clerk.viewer/doc-url "notebooks/markdown.md")} "Markdown"]] - [:li [:a {:href (nextjournal.clerk.viewer/doc-url "notebooks/viewer_api.clj")} "Viewer API / Tables"]]]) nil) + [:li [:a {:href (nextjournal.clerk.viewer/doc-url "notebooks/viewers/html")} "HTML"]] + [:li [:a {:href (nextjournal.clerk.viewer/doc-url "notebooks/markdown")} "Markdown"]] + [:li [:a {:href (nextjournal.clerk.viewer/doc-url "notebooks/viewer_api")} "Viewer API / Tables"]]]) nil) diff --git a/notebooks/links.md b/notebooks/links.md new file mode 100644 index 000000000..5ebac0150 --- /dev/null +++ b/notebooks/links.md @@ -0,0 +1,67 @@ +# Links +```clojure +(ns links + {:nextjournal.clerk/toc true} + (:require [nextjournal.clerk :as clerk])) +``` +## Design + +We have three different modes to consider for links: + +1. Interactive mode `serve!` +2. Static build unbundled `(build! {:bundle false})` +3. Static build bundled `(build! {:bundle true})` + +The behaviour when triggering links we want is: + +1. Interactive mode: trigger a js event `(clerk-eval 'nextjournal.clerk.webserver/navigate! ,,,)`, the doc will in turn be updated via the websocket +2. Static build unbundled: not intercept the link, let the browser perform its normal navigation +3. Static build bundled: trigger a js event to update the doc, update the browser's hash so the doc state is persisted on reload + +We can allow folks to write normal (relative) links. The limitations here being that things like open in new tab would not work and we can't support a routing function. Both these limitations means we probably want to continue encouraging the use of a helper like `clerk/doc-url` going forward. + +We currently don't support navigating to headings / table of contents sections in the bundled build. This could be supported however by introducing a way to encode that in the hash e.g. with `#page:section`. + + +## Examples + + +### JVM-Side + +The helper `clerk/doc-url` allows to reference notebooks by path. We currently support relative paths with respect to the directory which started the Clerk application. An optional trailing hash fragment can appended to the path in order for the page to be scrolled up to the indicated identifier. + + +```clojure +(clerk/html + [:ol + [:li [:a {:href (clerk/doc-url 'nextjournal.clerk.home)} "Home"]] + [:li [:a {:href (clerk/doc-url "notebooks/viewers/html")} "HTML"]] + [:li [:a {:href (clerk/doc-url "notebooks/viewers/image")} "Images"]] + [:li [:a {:href (clerk/doc-url "notebooks/markdown.md" "appendix")} "Markdown / Appendix"]] + [:li [:a {:href (clerk/doc-url "notebooks/how_clerk_works" "step-3:-analyzer")} "Clerk Analyzer"]] + [:li [:a {:href (clerk/doc-url "book")} "The πŸ“•Book"]] + [:li [:a {:href (clerk/doc-url "")} "Homepage"]]]) +``` + +### Render + +The same functionality is available in the SCI context when building render functions. + +```clojure +(clerk/with-viewer + '(fn [_ _] + [:ol + [:li [:a {:href (nextjournal.clerk.viewer/doc-url "notebooks/viewers/html")} "HTML"]] + [:li [:a {:href (nextjournal.clerk.viewer/doc-url "notebooks/markdown")} "Markdown"]] + [:li [:a {:href (nextjournal.clerk.viewer/doc-url "notebooks/viewer_api")} "Viewer API / Tables"]]]) nil) + +``` + + +### Inside Markdown + +Links should work inside markdown as well. + +* [HTML](../notebooks/viewers/html) (relative link) +* [HTML](clerk/doc-url,"notebooks/viewers/html") (doc url, currently not functional) + diff --git a/src/nextjournal/clerk/builder.clj b/src/nextjournal/clerk/builder.clj index d3e00fff0..a1218fa63 100644 --- a/src/nextjournal/clerk/builder.clj +++ b/src/nextjournal/clerk/builder.clj @@ -205,6 +205,9 @@ (expand-paths {:paths-fn `my-paths})) #_(expand-paths {:paths ["notebooks/viewers**"]}) +(def builtin-index + (io/resource "nextjournal/clerk/index.clj")) + (defn process-build-opts [{:as opts :keys [paths index expand-paths?]}] (merge {:out-path default-out-path :bundle? false @@ -223,29 +226,19 @@ (assoc :index (first expanded-paths)) (and (not index) (< 1 (count expanded-paths)) (every? (complement #{"index.clj"}) expanded-paths)) (as-> opts - (let [index (io/resource "nextjournal/clerk/index.clj")] - (-> opts (assoc :index index) (update :expanded-paths conj 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}) #_(process-build-opts {:paths ["notebooks/rule_30.clj" "notebooks/markdown.md"] :expand-paths? true}) -(defn build-path->url [{:as opts :keys [bundle?]} docs] - (into {} - (map (comp (juxt identity #(cond-> (->> % (viewer/map-index opts) strip-index) (not bundle?) ->html-extension)) - str :file)) - docs)) -#_(build-path->url {:bundle? false} [{:file "notebooks/foo.clj"} {:file "index.clj"}]) -#_(build-path->url {:bundle? true} [{:file "notebooks/foo.clj"} {:file "index.clj"}]) - (defn build-static-app-opts [{:as opts :keys [bundle? out-path browse? index]} docs] - (let [path->doc (into {} (map (juxt (comp str :file) :viewer)) docs)] + (let [path->doc (into {} (map (juxt (comp str fs/strip-ext strip-index (partial viewer/map-index opts) :file) :viewer)) docs)] (assoc opts :bundle? bundle? :path->doc path->doc - :paths (vec (keys path->doc)) - :path->url (build-path->url opts docs)))) + :paths (vec (keys path->doc))))) (defn ssr! "Shells out to node to generate server-side-rendered html." @@ -268,28 +261,30 @@ (defn cleanup [build-opts] (select-keys build-opts - [:bundle? :path->doc :path->url :current-path :resource->url :exclude-js? :index :html])) + [:bundle? :path->doc :current-path :resource->url :exclude-js? :index :html])) (defn write-static-app! [opts docs] - (let [{:as opts :keys [bundle? out-path browse? ssr?]} (process-build-opts opts) + (let [{:keys [bundle? out-path browse? ssr?]} opts index-html (str out-path fs/file-separator "index.html") - {:as static-app-opts :keys [path->url path->doc]} (build-static-app-opts (viewer/update-if opts :index str) docs)] - (when-not (contains? (-> path->url vals set) "") - (throw (ex-info "Index must have been processed at this point" {:opts opts :docs docs}))) + {:as static-app-opts :keys [path->doc]} (build-static-app-opts opts docs)] + (when-not (contains? (set (keys path->doc)) "") + (throw (ex-info "Index must have been processed at this point" {:static-app-opts static-app-opts}))) (when-not (fs/exists? (fs/parent index-html)) (fs/create-dirs (fs/parent index-html))) (if bundle? (spit index-html (view/->html (cleanup static-app-opts))) (doseq [[path doc] path->doc] - (let [out-html (str out-path fs/file-separator (->> path (viewer/map-index opts) ->html-extension))] + (let [out-html (fs/file out-path path "index.html")] (fs/create-dirs (fs/parent out-html)) (spit out-html (view/->html (-> static-app-opts (assoc :path->doc (hash-map path doc) :current-path path) (cond-> ssr? ssr!) cleanup)))))) (when browse? - (browse/browse-url (-> index-html fs/absolutize .toString path-to-url-canonicalize))) + (browse/browse-url (if-let [{:keys [port]} (and (= out-path "public/build") @webserver/!server)] + (str "http://localhost:" port "/build/") + (-> index-html fs/absolutize .toString path-to-url-canonicalize)))) {:docs docs :index-html index-html :build-href (if (and @webserver/!server (= out-path default-out-path)) "/build/" index-html)})) @@ -331,13 +326,13 @@ (update opts :resource->url assoc "/css/viewer.css" url)))) (defn doc-url - ([opts doc file path] (doc-url opts doc file path nil)) - ([{:as opts :keys [bundle?]} docs file path fragment] - (let [url (get (build-path->url (viewer/update-if opts :index str) docs) path)] - (if bundle? - (str "#/" url) - (str (viewer/relative-root-prefix-from (viewer/map-index opts file)) - url (when fragment (str "#" fragment))))))) + ([opts file path] (doc-url opts file path nil)) + ([opts file path fragment] + (if (:bundle? opts) + (cond-> (str "#/" path) + fragment (str ":" fragment)) + (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") @@ -395,10 +390,14 @@ (try (binding [*ns* *ns* *build-opts* opts - viewer/doc-url (partial doc-url opts state file)] + 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 (str file)) 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))) + doc)))) (catch Exception e {:error e})))] (report-fn (merge {:stage :built :duration duration :idx idx} @@ -422,8 +421,11 @@ (comment (build-static-app! {:paths clerk-docs :bundle? true}) - (build-static-app! {:paths ["notebooks/index.clj" "notebooks/rule_30.clj" "notebooks/viewer_api.md"] :index "notebooks/index.clj"}) - (build-static-app! {:paths ["index.clj" "notebooks/rule_30.clj" "notebooks/markdown.md"] :bundle? false :browse? false}) + (build-static-app! {:paths ["notebooks/editor.clj"] :browse? true}) + (build-static-app! {:paths ["CHANGELOG.md" "notebooks/editor.clj"] :browse? true}) + (build-static-app! {:paths ["index.clj" "notebooks/links.md" "notebooks/rule_30.clj" "notebooks/markdown.md"] :bundle? true :browse? true}) + (build-static-app! {:paths ["notebooks/links.md" "notebooks/rule_30.clj" "notebooks/markdown.md"] :bundle? true :browse? true}) + (build-static-app! {:paths ["index.clj" "notebooks/rule_30.clj" "notebooks/markdown.md"] :bundle? false :browse? true}) (build-static-app! {:paths ["notebooks/viewers/**"]}) (build-static-app! {:index "notebooks/rule_30.clj" :git/sha "bd85a3de12d34a0622eb5b94d82c9e73b95412d1" :git/url "https://github.com/nextjournal/clerk"}) (reset! config/!resource->url @config/!asset-map) @@ -455,3 +457,4 @@ :bundle? true :git/sha "d60f5417" :git/url "https://github.com/nextjournal/clerk"})) + diff --git a/src/nextjournal/clerk/builder_ui.clj b/src/nextjournal/clerk/builder_ui.clj index 7faa2666e..f511d6ce0 100644 --- a/src/nextjournal/clerk/builder_ui.clj +++ b/src/nextjournal/clerk/builder_ui.clj @@ -22,18 +22,18 @@ (defn checkmark-svg [& [{:keys [size] :or {size 18}}]] [:div.flex.justify-center {:class "w-[24px] h-[24px]"} - [:svg {:xmlns "http://www.w3.org/2000/svg", :fill "none", :viewbox "0 0 24 24", :stroke-width "1.5", :stroke "currentColor", :class "w-6 h-6"} + [:svg {:xmlns "http://www.w3.org/2000/svg", :fill "none", :viewBox "0 0 24 24", :stroke-width "1.5", :stroke "currentColor", :class "w-6 h-6"} [:path {:stroke-linecap "round", :stroke-linejoin "round", :d "M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"}]]]) (defn error-svg [& [{:keys [size] :or {size 18}}]] [:div.flex.justify-center {:class "w-[24px] h-[24px]"} [:svg.text-red-400 - {:xmlns "http://www.w3.org/2000/svg", :viewbox "0 0 20 20", :fill "currentColor" + {:xmlns "http://www.w3.org/2000/svg", :viewBox "0 0 20 20", :fill "currentColor" :style {:width size :height size}} [:path {:fill-rule "evenodd", :d "M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z", :clip-rule "evenodd"}]]]) (def publish-icon-svg - [:svg {:width "18", :height "18", :viewbox "0 0 18 18", :fill "none", :xmlns "http://www.w3.org/2000/svg"} + [:svg {:width "18", :height "18", :viewBox "0 0 18 18", :fill "none", :xmlns "http://www.w3.org/2000/svg"} [:path {:d "M9 17C12.7267 17 15.8583 14.4517 16.7473 11.0026M9 17C5.27327 17 2.14171 14.4517 1.25271 11.0026M9 17C11.2091 17 13 13.4183 13 9C13 4.58172 11.2091 1 9 1M9 17C6.79086 17 5 13.4183 5 9C5 4.58172 6.79086 1 9 1M9 1C11.9913 1 14.5991 2.64172 15.9716 5.07329M9 1C6.00872 1 3.40088 2.64172 2.02838 5.07329M15.9716 5.07329C14.102 6.68924 11.6651 7.66667 9 7.66667C6.33486 7.66667 3.89802 6.68924 2.02838 5.07329M15.9716 5.07329C16.6264 6.23327 17 7.573 17 9C17 9.69154 16.9123 10.3626 16.7473 11.0026M16.7473 11.0026C14.4519 12.2753 11.8106 13 9 13C6.18943 13 3.54811 12.2753 1.25271 11.0026M1.25271 11.0026C1.08775 10.3626 1 9.69154 1 9C1 7.573 1.37362 6.23327 2.02838 5.07329", :stroke "currentColor", :stroke-linecap "round", :stroke-linejoin "round"}]]) ^{:nextjournal.clerk/visibility {:result :show}} @@ -304,7 +304,7 @@ (checkmark-svg) [:div.text-lg.ml-2.mb-0.font-medium "Your notebooks have been built."]] [:a.font-medium.rounded-full.text-sm.px-3.py-1.bg-greenish-20.flex.items-center.border-2.border-greenish.animate-border-pulse.hover:border-white.hover:animate-none - {:href link} + {:href link :data-ignore-anchor-click true} [:div publish-icon-svg] [:span.ml-2 "Open"]]] [:div.flex.items-center.px-4.lg:px-0 diff --git a/src/nextjournal/clerk/index.clj b/src/nextjournal/clerk/index.clj index e4920749f..79a728bae 100644 --- a/src/nextjournal/clerk/index.clj +++ b/src/nextjournal/clerk/index.clj @@ -15,7 +15,7 @@ [:li.border-t.first:border-t-0.dark:border-gray-800.odd:bg-slate-50.dark:odd:bg-white {:class "dark:odd:bg-opacity-[0.03]"} [:a.pl-4.pr-4.py-2.flex.w-full.items-center.justify-between.hover:bg-indigo-50.dark:hover:bg-gray-700 - {:href (clerk/doc-url path)} + {:href (clerk/doc-url (fs/strip-ext path))} [:span.text-sm.md:text-md.monospace.flex-auto.block.truncate path] [:svg.h-4.w-4.flex-shrink-0 {:xmlns "http://www.w3.org/2000/svg" :fill "none" :viewBox "0 0 24 24" :stroke "currentColor"} [:path {:stroke-linecap "round" :stroke-linejoin "round" :stroke-width "2" :d "M9 5l7 7-7 7"}]]]])))}) diff --git a/src/nextjournal/clerk/render.cljs b/src/nextjournal/clerk/render.cljs index 4894f8459..4c65bd53c 100644 --- a/src/nextjournal/clerk/render.cljs +++ b/src/nextjournal/clerk/render.cljs @@ -107,28 +107,6 @@ (declare clerk-eval) -(defn ->URL [^js href] - (js/URL. href)) - -(defn handle-anchor-click [^js e] - (when-some [url (some-> e .-target closest-anchor-parent .-href ->URL)] - (when (= (.-search url) "?clerk/show!") - (.preventDefault e) - (clerk-eval (list 'nextjournal.clerk.webserver/navigate! - (cond-> {:nav-path (subs (js/decodeURI (.-pathname url)) 1)} - (seq (.-hash url)) - (assoc :fragment (subs (.-hash url) 1)))))))) - -(defn history-push-state [{:as opts :keys [path fragment replace?]}] - (when (not= path (some-> js/history .-state .-path)) - (j/call js/history (if replace? :replaceState :pushState) (clj->js opts) "" (str (.. js/document -location -origin) - "/" path (when fragment (str "#" fragment)))))) - -(defn handle-history-popstate [^js e] - (when-let [{:as opts :keys [path]} (js->clj (.-state e) :keywordize-keys true)] - (.preventDefault e) - (clerk-eval (list 'nextjournal.clerk.webserver/navigate! {:nav-path path :skip-history? true})))) - (defn render-notebook [{:as doc xs :blocks :keys [bundle? doc-css-class sidenotes? toc toc-visibility header footer]} {:as render-opts :keys [!expanded-at expandable-toc?]}] (r/with-let [root-ref-fn (fn [el] @@ -714,32 +692,71 @@ (defonce !router (atom nil)) -(defn handle-initial-load [_] - (history-push-state {:path (subs js/location.pathname 1) :replace? true})) +(defn ->URL [^js href] + (js/URL. href)) (defn path-from-url-hash [url] (-> url ->URL .-hash (subs 2))) +(defn static-app? [state] + (contains? state :path->doc)) + +(defn ->doc-url [url] + (let [path (js/decodeURI (.-pathname url)) + doc-path (js/decodeURI (.-pathname (.-location js/document)))] + (if (str/starts-with? path doc-path) + (subs path (count doc-path)) + (subs path 1)))) + +(defn ignore-anchor-click? + [e ^js url] + (let [current-origin (when (exists? js/location) + (.-origin js/location)) + ^js dataset (some-> e .-target closest-anchor-parent .-dataset)] + (or (not= current-origin (.-origin url)) + (.-altKey e) + (some-> dataset .-ignoreAnchorClick some?)))) + +(defn history-push-state [{:as opts :keys [path fragment replace?]}] + (when (not= path (some-> js/history .-state .-path)) + (j/call js/history (if replace? :replaceState :pushState) (clj->js opts) "" (str (.. js/document -location -origin) + "/" path (when fragment (str "#" fragment)))))) + +(defn handle-history-popstate [state ^js e] + (when-let [{:as opts :keys [path]} (js->clj (.-state e) :keywordize-keys true)] + (.preventDefault e) + (clerk-eval (list 'nextjournal.clerk.webserver/navigate! {:nav-path path :skip-history? true})))) + (defn handle-hashchange [{:keys [url->path path->doc]} ^js e] + ;; used for navigation in static bundle build (let [url (some-> e .-event_ .-newURL path-from-url-hash)] - (when-some [doc (get path->doc (get url->path url))] + (when-some [doc (get path->doc url)] (set-state! {:doc doc})))) -(defn listeners [{:as state :keys [mode]}] - (case mode - :path - #{(gevents/listen js/document gevents/EventType.CLICK handle-anchor-click false) - (gevents/listen js/window gevents/EventType.POPSTATE handle-history-popstate false) - (gevents/listen js/window gevents/EventType.LOAD handle-initial-load false)} +(defn handle-anchor-click [{:as state :keys [path->doc url->path]} ^js e] + (when-some [url (some-> e .-target closest-anchor-parent .-href ->URL)] + (when-not (ignore-anchor-click? e url) + (.preventDefault e) + (clerk-eval (list 'nextjournal.clerk.webserver/navigate! + (cond-> {:nav-path (->doc-url url)} + (seq (.-hash url)) + (assoc :fragment (subs (.-hash url) 1)))))))) - :fragment - #{(gevents/listen js/window gevents/EventType.HASHCHANGE (partial handle-hashchange state) false)})) +(defn handle-initial-load [state ^js _e] + (history-push-state {:path (subs js/location.pathname 1) :replace? true})) -(defn setup-router! [{:as state :keys [mode]}] +(defn setup-router! [state] (when (and (exists? js/document) (exists? js/window)) (doseq [listener (:listeners @!router)] (gevents/unlistenByKey listener)) - (reset! !router (assoc state :listeners (listeners state))))) + (reset! !router + (assoc state :listeners + (cond (and (static-app? state) (:bundle? state)) + [(gevents/listen js/window gevents/EventType.HASHCHANGE (partial handle-hashchange state) false)] + (not (static-app? state)) + [(gevents/listen js/document gevents/EventType.CLICK (partial handle-anchor-click state) false) + (gevents/listen js/window gevents/EventType.POPSTATE (partial handle-history-popstate state) false) + (gevents/listen js/window gevents/EventType.LOAD (partial handle-initial-load state) false)]))))) (defn ^:export mount [] @@ -751,19 +768,14 @@ (mount)) (defn ^:export init [{:as state :keys [bundle? path->doc path->url current-path]}] - (let [static-app? (contains? state :path->doc)] ;; TODO: better check - (if static-app? - (let [url->path (set/map-invert path->url)] - (when bundle? (setup-router! (assoc state :mode :fragment :url->path url->path))) - (set-state! {:doc (get path->doc (or current-path - (when (and bundle? (exists? js/document)) - (url->path (path-from-url-hash (.-location js/document)))) - (url->path "")))}) - (mount)) - (do - (setup-router! {:mode :path}) - (set-state! state) - (mount))))) + (setup-router! state) + (set-state! (if (static-app? state) + {:doc (get path->doc (or (if bundle? + (path-from-url-hash (->URL (.-href js/location))) + current-path) + ""))} + state)) + (mount)) (defn render-html [markup] diff --git a/src/nextjournal/clerk/viewer.cljc b/src/nextjournal/clerk/viewer.cljc index 2e9de4fb6..e4d97acb7 100644 --- a/src/nextjournal/clerk/viewer.cljc +++ b/src/nextjournal/clerk/viewer.cljc @@ -1138,7 +1138,7 @@ (defn index-path [{:keys [static-build? index]}] #?(:cljs "" :clj (if static-build? - (if index (str index) "") + "" (if (fs/exists? "index.clj") "index.clj" "'nextjournal.clerk.index")))) (defn header [{:as opts :keys [nav-path static-build?] :git/keys [url sha]}] @@ -1767,7 +1767,7 @@ (defn ^:dynamic doc-url ([path] (doc-url path nil)) ([path fragment] - (str "/" path "?clerk/show!" (when fragment (str "#" fragment))))) + (str "/" path (when fragment (str "#" fragment))))) #_(doc-url "notebooks/rule_30.clj#board") #_(doc-url "notebooks/rule_30.clj") diff --git a/src/nextjournal/clerk/webserver.clj b/src/nextjournal/clerk/webserver.clj index 56d41f162..011fa2093 100644 --- a/src/nextjournal/clerk/webserver.clj +++ b/src/nextjournal/clerk/webserver.clj @@ -181,25 +181,42 @@ (string? file-or-ns) (when (fs/exists? file-or-ns) - (fs/unixify (cond->> 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))) +#_(->nav-path "notebooks/rule_30.clj") #_(->nav-path 'nextjournal.clerk.home) -#_(->nav-path 'nextjournal.clerk.tap) + +(defn find-first-existing-file [files] + (first (filter fs/exists? files))) + +(defn maybe-add-extension [nav-path] + (if (and (string? nav-path) + (or (str/starts-with? nav-path "'") + (fs/exists? nav-path))) + nav-path + (find-first-existing-file (map #(str (fs/file nav-path) "." %) ["md" "clj" "cljc"])))) + +#_(maybe-add-extension "notebooks/rule_30") +#_(maybe-add-extension "notebooks/rule_30.clj") +#_(maybe-add-extension "notebooks/markdown") +#_(maybe-add-extension "'nextjournal.clerk.home") (defn ->file-or-ns [nav-path] - (cond (str/starts-with? nav-path "'") (symbol (subs nav-path 1)) + (cond (str/blank? nav-path) (or (maybe-add-extension "index") + 'nextjournal.clerk.index) + (str/starts-with? nav-path "'") (symbol (subs nav-path 1)) (re-find #"\.(cljc?|md)$" nav-path) nav-path)) (defn show! [opts file-or-ns] ((resolve 'nextjournal.clerk/show!) opts file-or-ns)) (defn navigate! [{:as opts :keys [nav-path]}] - (show! opts (->file-or-ns nav-path))) + (show! opts (->file-or-ns (maybe-add-extension nav-path)))) (defn prefetch-request? [req] (= "prefetch" (-> req :headers (get "purpose")))) @@ -214,7 +231,7 @@ :headers {"Location" (or (:nav-path @!doc) (->nav-path 'nextjournal.clerk.home))}} :else - (if-let [file-or-ns (->file-or-ns nav-path)] + (if-let [file-or-ns (->file-or-ns (maybe-add-extension nav-path))] (do (try (show! {:skip-history? true} file-or-ns) (catch Exception _)) {:status 200 diff --git a/test/nextjournal/clerk/builder_test.clj b/test/nextjournal/clerk/builder_test.clj index 947bc9644..c4e4a4849 100644 --- a/test/nextjournal/clerk/builder_test.clj +++ b/test/nextjournal/clerk/builder_test.clj @@ -103,13 +103,13 @@ (deftest doc-url (testing "link to same dir unbundled" - (is (= "./../notebooks/rule_30.html" ;; NOTE: could also be just "rule_30.html" - (builder/doc-url {:bundle? false} [{:file "notebooks/viewer_api.clj"} {:file "notebooks/rule_30.clj"}] "notebooks/viewer_api.clj" "notebooks/rule_30.clj")))) + (is (= "./../notebooks/rule_30" ;; NOTE: could also be just "rule_30.html" + (builder/doc-url {:bundle? false} "notebooks/viewer_api.clj" "notebooks/rule_30")))) (testing "respects the mapped index" - (is (= "./notebooks/rule_30.html" - (builder/doc-url {:bundle? false} [{:file "index.clj"} {:file "notebooks/rule_30.clj"}] "index.clj" "notebooks/rule_30.clj")))) + (is (= "./notebooks/rule_30" + (builder/doc-url {:bundle? false} "index.clj" "notebooks/rule_30")))) (testing "bundle case" - (is (= "#/notebooks/rule_30.clj" - (builder/doc-url {:bundle? true} [{:file "notebooks/index.clj"} {:file "notebooks/rule_30.clj"}] "index.clj" "notebooks/rule_30.clj"))))) + (is (= "#/notebooks/rule_30" + (builder/doc-url {:bundle? true} "index.clj" "notebooks/rule_30"))))) From 5be6a65633e6afdd1d29af918773f05dfe42f221 Mon Sep 17 00:00:00 2001 From: Andrea Amantini Date: Fri, 21 Jul 2023 12:28:40 +0200 Subject: [PATCH 14/23] Bump slideshow with fixed syntax highlighting --- deps.edn | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps.edn b/deps.edn index 676652bf7..f3de62143 100644 --- a/deps.edn +++ b/deps.edn @@ -71,7 +71,7 @@ sicmutils/sicmutils {:mvn/version "0.20.0"} io.github.mentat-collective/emmy {:git/sha "b98fef51d80d3edcff3100562b929f9980ff34d7" :exclusions [org.babashka/sci]} - io.github.nextjournal/clerk-slideshow {:git/sha "0e6e890fd4f862fa3535290cb7e29591c6e0ec85"}}} + io.github.nextjournal/clerk-slideshow {:git/sha "11a83fea564da04b9d17734f2031a4921d917893"}}} :build {:deps {io.github.nextjournal/clerk {:local/root "."} io.github.nextjournal/cas-client {:git/sha "84ab35c3321c1e51a589fddbeee058aecd055bf8"} From c93e08401579114cc96da615d3b58ba31f3536fd Mon Sep 17 00:00:00 2001 From: Martin Kavalar Date: Mon, 24 Jul 2023 12:09:40 +0200 Subject: [PATCH 15/23] Error handling for missing tailwindcss executable (#530) --- src/nextjournal/clerk/builder.clj | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/nextjournal/clerk/builder.clj b/src/nextjournal/clerk/builder.clj index a1218fa63..10c22118d 100644 --- a/src/nextjournal/clerk/builder.clj +++ b/src/nextjournal/clerk/builder.clj @@ -309,14 +309,16 @@ (str path)) (pr-str viewer))) (let [{:as ret :keys [out err exit]} - (sh "tailwindcss" - "--input" tw-input - "--config" tw-config - ;; FIXME: pass inline - ;;"--content" (str tw-viewer) - ;;"--content" (str tw-folder "/**/*.edn") - "--output" tw-output - "--minify")] + (try (sh "tailwindcss" + "--input" tw-input + "--config" tw-config + ;; FIXME: pass inline + ;;"--content" (str tw-viewer) + ;;"--content" (str tw-folder "/**/*.edn") + "--output" tw-output + "--minify") + (catch java.io.IOException _ + (throw (Exception. "Clerk could not find the `tailwindcss` executable. Please install it using `npm install -D tailwindcss` and try again."))))] (println err) (println out) (when-not (= 0 exit) From 278380e976b931bf01fbd29cd64a4d718c63cdf0 Mon Sep 17 00:00:00 2001 From: Andrea Amantini Date: Wed, 26 Jul 2023 11:24:16 +0200 Subject: [PATCH 16/23] Fix relative URLs in static apps (#536) Fixes relative URLs for unbundled static apps, specifically * CAS image sources returned by `clerk/image` * links produced by `clerk/doc-url` * CSS and JS asset map --- notebooks/document_linking.clj | 2 +- notebooks/viewers/html.clj | 10 +++++----- src/nextjournal/clerk/builder.clj | 1 - src/nextjournal/clerk/view.clj | 2 +- src/nextjournal/clerk/viewer.cljc | 4 +++- test/nextjournal/clerk/builder_test.clj | 17 +++++++++++++++-- 6 files changed, 25 insertions(+), 11 deletions(-) diff --git a/notebooks/document_linking.clj b/notebooks/document_linking.clj index e7b1ec842..47c68cca1 100644 --- a/notebooks/document_linking.clj +++ b/notebooks/document_linking.clj @@ -9,7 +9,7 @@ [:ol [:li [:a {:href (clerk/doc-url "notebooks/viewers/html")} "HTML"]] [:li [:a {:href (clerk/doc-url "notebooks/viewers/image")} "Images"]] - [:li [:a {:href (clerk/doc-url "notebooks/markdown.md" "appendix")} "Markdown / Appendix"]] + [:li [:a {:href (clerk/doc-url "notebooks/markdown" "appendix")} "Markdown / Appendix"]] [:li [:a {:href (clerk/doc-url "notebooks/how_clerk_works" "step-3:-analyzer")} "Clerk Analyzer"]] [:li [:a {:href (clerk/doc-url "book")} "The πŸ“•Book"]] [:li [:a {:href (clerk/doc-url "")} "Homepage"]]]) diff --git a/notebooks/viewers/html.clj b/notebooks/viewers/html.clj index f68b996d6..f4286da76 100644 --- a/notebooks/viewers/html.clj +++ b/notebooks/viewers/html.clj @@ -12,17 +12,17 @@ (clerk/html [:div "Go to " - [:a.text-lg {:href (clerk/doc-url "notebooks/viewers/image.clj")} "images"] + [:a.text-lg {:href (clerk/doc-url "notebooks/viewers/image")} "images"] " notebook."]) (clerk/with-viewer '(fn [_ _] [:div "Go to " - [:a.text-lg {:href (nextjournal.clerk.viewer/doc-url "notebooks/viewers/image.clj")} "images"] + [:a.text-lg {:href (nextjournal.clerk.viewer/doc-url "notebooks/viewers/image")} "images"] " notebook."]) nil) (clerk/html [:ol - [:li [:a {:href (clerk/doc-url "notebooks/document_linking.clj")} "Cross Document Linking"]] - [:li [:a {:href (clerk/doc-url "notebooks/rule_30.clj")} "Rule 30"]] - [:li [:a {:href (clerk/doc-url "notebooks/markdown.md")} "Appendix"]]]) + [:li [:a {:href (clerk/doc-url "notebooks/document_linking")} "Cross Document Linking"]] + [:li [:a {:href (clerk/doc-url "notebooks/rule_30")} "Rule 30"]] + [:li [:a {:href (clerk/doc-url "")} "Back"]]]) diff --git a/src/nextjournal/clerk/builder.clj b/src/nextjournal/clerk/builder.clj index 10c22118d..b3212f9ef 100644 --- a/src/nextjournal/clerk/builder.clj +++ b/src/nextjournal/clerk/builder.clj @@ -459,4 +459,3 @@ :bundle? true :git/sha "d60f5417" :git/url "https://github.com/nextjournal/clerk"})) - diff --git a/src/nextjournal/clerk/view.clj b/src/nextjournal/clerk/view.clj index 6f4ea108e..a7eb4b754 100644 --- a/src/nextjournal/clerk/view.clj +++ b/src/nextjournal/clerk/view.clj @@ -25,7 +25,7 @@ (defn adjust-relative-path [{:as state :keys [current-path]} url] (cond->> url - (and current-path (relative? url)) + (and (not-empty current-path) (relative? url)) (str (v/relative-root-prefix-from (v/map-index state current-path))))) (defn include-viewer-css [state] diff --git a/src/nextjournal/clerk/viewer.cljc b/src/nextjournal/clerk/viewer.cljc index e4d97acb7..f27e1cc48 100644 --- a/src/nextjournal/clerk/viewer.cljc +++ b/src/nextjournal/clerk/viewer.cljc @@ -437,7 +437,9 @@ #?(:clj (defn relative-root-prefix-from [path] - (str "./" (str/join (repeat (get (frequencies (str path)) \/ 0) "../"))))) + (str "./" (when (not= "index.clj" path) + (str/join (repeat (inc (get (frequencies (str path)) \/ 0)) + "../")))))) #?(:clj (defn map-index [{:as _opts :keys [index]} path] diff --git a/test/nextjournal/clerk/builder_test.clj b/test/nextjournal/clerk/builder_test.clj index c4e4a4849..a86633bae 100644 --- a/test/nextjournal/clerk/builder_test.clj +++ b/test/nextjournal/clerk/builder_test.clj @@ -103,12 +103,25 @@ (deftest doc-url (testing "link to same dir unbundled" - (is (= "./../notebooks/rule_30" ;; NOTE: could also be just "rule_30.html" + ;; in the unbundled case the current URL on a given notebook is given by + ;; + ;; fs-path | URL + ;; ---------------------------------------------------- + ;; path/to/notebook.clj | path/to/notebok/[index.html] + (is (= "./../../notebooks/rule_30" ;; NOTE: could also be just "rule_30.html" (builder/doc-url {:bundle? false} "notebooks/viewer_api.clj" "notebooks/rule_30")))) (testing "respects the mapped index" (is (= "./notebooks/rule_30" - (builder/doc-url {:bundle? false} "index.clj" "notebooks/rule_30")))) + (builder/doc-url {:bundle? false} "index.clj" "notebooks/rule_30"))) + + (is (= "./notebooks/rule_30" + (builder/doc-url {:bundle? false :index "notebooks/path/to/notebook.clj"} + "notebooks/path/to/notebook.clj" "notebooks/rule_30"))) + + (is (= "./../../../../notebooks/rule_30" + (builder/doc-url {:bundle? false} + "notebooks/path/to/notebook.clj" "notebooks/rule_30")))) (testing "bundle case" (is (= "#/notebooks/rule_30" From fc6df7186bfea2571fff2dd790d0ad63f8d6295f Mon Sep 17 00:00:00 2001 From: Andrea Amantini Date: Wed, 26 Jul 2023 12:37:30 +0200 Subject: [PATCH 17/23] Fix respecting index.md in static app paths (#538) --- src/nextjournal/clerk/builder.clj | 4 ++-- src/nextjournal/clerk/viewer.cljc | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/nextjournal/clerk/builder.clj b/src/nextjournal/clerk/builder.clj index b3212f9ef..8523b5aa9 100644 --- a/src/nextjournal/clerk/builder.clj +++ b/src/nextjournal/clerk/builder.clj @@ -224,9 +224,9 @@ (dissoc :expand-paths?) (and (not index) (= 1 (count expanded-paths))) (assoc :index (first expanded-paths)) - (and (not index) (< 1 (count expanded-paths)) (every? (complement #{"index.clj"}) 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}) diff --git a/src/nextjournal/clerk/viewer.cljc b/src/nextjournal/clerk/viewer.cljc index f27e1cc48..9b4f48bb3 100644 --- a/src/nextjournal/clerk/viewer.cljc +++ b/src/nextjournal/clerk/viewer.cljc @@ -435,9 +435,12 @@ (Files/write cas-path content (into-array [StandardOpenOption/CREATE]))) cas-url))) +#?(:clj + (def index-path? (partial re-matches #"index.(cljc?|md)"))) + #?(:clj (defn relative-root-prefix-from [path] - (str "./" (when (not= "index.clj" path) + (str "./" (when-not (index-path? path) (str/join (repeat (inc (get (frequencies (str path)) \/ 0)) "../")))))) From 90ec95dadc48a6d53e1156fc7e4fb2b6959f1c9b Mon Sep 17 00:00:00 2001 From: Andrea Amantini Date: Mon, 31 Jul 2023 16:40:18 +0200 Subject: [PATCH 18/23] Prevent directories from shadowing notebook paths --- src/nextjournal/clerk/webserver.clj | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/nextjournal/clerk/webserver.clj b/src/nextjournal/clerk/webserver.clj index 011fa2093..d31b04c7f 100644 --- a/src/nextjournal/clerk/webserver.clj +++ b/src/nextjournal/clerk/webserver.clj @@ -197,7 +197,8 @@ (defn maybe-add-extension [nav-path] (if (and (string? nav-path) (or (str/starts-with? nav-path "'") - (fs/exists? nav-path))) + (and (fs/exists? nav-path) + (not (fs/directory? nav-path))))) nav-path (find-first-existing-file (map #(str (fs/file nav-path) "." %) ["md" "clj" "cljc"])))) From 369cab04144497ae6c4d2c0fc3217ffbf90ab6cb Mon Sep 17 00:00:00 2001 From: Vedang Manerikar Date: Sun, 20 Aug 2023 23:54:58 +0530 Subject: [PATCH 19/23] Fix minor typo on the index page (#546) `show` -> `show!` --- src/nextjournal/clerk/home.clj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nextjournal/clerk/home.clj b/src/nextjournal/clerk/home.clj index 8f4409dda..891a207eb 100644 --- a/src/nextjournal/clerk/home.clj +++ b/src/nextjournal/clerk/home.clj @@ -176,7 +176,7 @@ [:div.rounded-lg.border-2.border-amber-100.bg-amber-50.dark:border-slate-600.dark:bg-slate-800.dark:text-slate-100.px-8.py-4.mx-auto.text-center.font-sans.mt-6.md:mt-4 [:div [:span.font-medium "πŸ’‘ Tip:"] " Show the " [:a {:href "/'nextjournal.clerk.tap"} "🚰 Tap Inspector"] " to inspect values using " (code-highlight {:class "text-sm" }"tap>") "."] [:div.mt-2.text-xs - (code-highlight {:class "text-sm"} "(nextjournal.clerk/show 'nextjournal.clerk.tap)")]] + (code-highlight {:class "text-sm"} "(nextjournal.clerk/show! 'nextjournal.clerk.tap)")]] #_[:div.mt-6 (clerk/with-viewer filter-input-viewer `!filter)]]) From 02dd0fc5f5e62471c2eaaca6a21250a621a56a89 Mon Sep 17 00:00:00 2001 From: Matt Kelly Date: Thu, 24 Aug 2023 05:41:56 -0400 Subject: [PATCH 20/23] Add missing action verb to Book of Clerk (#533) --- book.clj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/book.clj b/book.clj index 1ee18209a..6af1303ac 100644 --- a/book.clj +++ b/book.clj @@ -940,7 +940,7 @@ v/table-viewer ;; Also notably, there is a `:compile-css` option which compiles a css ;; file containing only the used CSS classes from the generated ;; markup. (Otherwise, Clerk is using Tailwind's Play CDN script which -;; can the page flicker, initially.) +;; can make the page flicker, initially.) ;; If set, the `:ssr` option will use React's server-side-rendering to ;; include the generated markup in the build HTML. From 4e61e0e9bb5c90501e8b4d5d735a482cd2a5bb09 Mon Sep 17 00:00:00 2001 From: Andrea Amantini Date: Thu, 24 Aug 2023 16:13:47 +0200 Subject: [PATCH 21/23] Fix preserving expanded-at on clerk/show (#548) We are resetting a result's expansion state when its `:nextjourna/hash` is changing. The hash is computed starting from the presented value using nippy/freeze. It turns out that adding pagination continuation functions (#421) as metadata on the presented result broke nippy serialization and we'd get a fresh hash (a gensym) on each call to show as a fallback. We can fix this by excluding metadata when nippy freezing. --- src/nextjournal/clerk/analyzer.clj | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/nextjournal/clerk/analyzer.clj b/src/nextjournal/clerk/analyzer.clj index f1289a217..687a2f1f9 100644 --- a/src/nextjournal/clerk/analyzer.clj +++ b/src/nextjournal/clerk/analyzer.clj @@ -609,9 +609,10 @@ (let [digest-fn (case hash-type :sha1 sha1-base58 :sha512 sha2-base58)] - (-> value - nippy/fast-freeze - digest-fn)))) + (binding [nippy/*incl-metadata?* false] + (-> value + nippy/fast-freeze + digest-fn))))) #_(valuehash (range 100)) #_(valuehash :sha1 (range 100)) From 0846e106ce8b22202232a529f8c76bb85a95763b Mon Sep 17 00:00:00 2001 From: Martin Kavalar Date: Fri, 25 Aug 2023 16:28:21 +0200 Subject: [PATCH 22/23] Preserve in-memory cache when eval error occurs (#550) Fixes #549 --- src/nextjournal/clerk.clj | 2 +- test/nextjournal/clerk/eval_test.clj | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/nextjournal/clerk.clj b/src/nextjournal/clerk.clj index f56073408..7c506d9c4 100644 --- a/src/nextjournal/clerk.clj +++ b/src/nextjournal/clerk.clj @@ -63,7 +63,7 @@ {: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!))) (catch Exception e - (throw (ex-info (str "`nextjournal.clerk/show!` encountered an eval error with: `" (pr-str file-or-ns) "`") {::doc doc} 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.")) (webserver/update-doc! result)) (catch Exception e diff --git a/test/nextjournal/clerk/eval_test.clj b/test/nextjournal/clerk/eval_test.clj index 913592a7a..a27c7233e 100644 --- a/test/nextjournal/clerk/eval_test.clj +++ b/test/nextjournal/clerk/eval_test.clj @@ -6,7 +6,8 @@ [nextjournal.clerk.eval :as eval] [nextjournal.clerk.parser :as parser] [nextjournal.clerk.view :as view] - [nextjournal.clerk.viewer :as viewer])) + [nextjournal.clerk.viewer :as viewer] + [nextjournal.clerk.webserver :as webserver])) (deftest eval-string (testing "hello 42" @@ -227,3 +228,15 @@ (testing "class is not cachable" (is (not (#'eval/cachable-value? java.lang.String))) (is (not (#'eval/cachable-value? {:foo java.lang.String}))))) + +(deftest show!-test + (testing "in-memory cache is preserved when exception is thrown (#549)" + (let [code "{:f inc :n (rand-int 100000)}" + get-result #(:blob->result @webserver/!doc)] + (clerk/show! (java.io.StringReader. code)) + (let [result-first-run (get-result)] + (try (clerk/show! (java.io.StringReader. (str code " (throw (ex-info \"boom\" {}))"))) + (catch Exception _ nil)) + (clerk/show! (java.io.StringReader. code)) + (is (= result-first-run (get-result))))))) + From f6d9d890e45fd7bffb69e6203743188d933ad74f Mon Sep 17 00:00:00 2001 From: Martin Kavalar Date: Mon, 28 Aug 2023 10:00:00 +0200 Subject: [PATCH 23/23] Add regression test for stable hashes in presentations --- test/nextjournal/clerk/viewer_test.clj | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/nextjournal/clerk/viewer_test.clj b/test/nextjournal/clerk/viewer_test.clj index b527a8c96..ca9cea512 100644 --- a/test/nextjournal/clerk/viewer_test.clj +++ b/test/nextjournal/clerk/viewer_test.clj @@ -279,6 +279,11 @@ :out-path builder/default-out-path} test-doc) #"_data/.+\.png"))))) + (testing "presentations are pure, result hashes are stable" + (let [test-doc (eval/eval-string "(range 100)")] + (is (= (view/doc->viewer {} test-doc) + (view/doc->viewer {} test-doc))))) + (testing "Setting custom options on results via metadata" (is (= :full (-> (eval/eval-string "^{:nextjournal.clerk/width :full} (nextjournal.clerk/html [:div])")