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/CHANGELOG.md b/CHANGELOG.md index ae081ff17..b15beda10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,8 +18,37 @@ 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. + +* 🚨 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. + +* πŸ“’ 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` +* 🐞 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) diff --git a/book.clj b/book.clj index c85173629..6af1303ac 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. @@ -928,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. diff --git a/deps.edn b/deps.edn index 0e157d86c..f3de62143 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} @@ -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"} 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/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/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..47c68cca1 100644 --- a/notebooks/document_linking.clj +++ b/notebooks/document_linking.clj @@ -1,21 +1,25 @@ ;; # πŸ–‡οΈ 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 - [: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/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/viewers/html")} "HTML"]] + [:li [:a {:href (clerk/doc-url "notebooks/viewers/image")} "Images"]] + [: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"]]]) + +;; ## Client Side ;; The same functionality is available in the SCI context when building render functions. (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/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/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/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/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/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/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/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/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/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/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/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/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/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/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; 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/src/nextjournal/clerk/analyzer.clj b/src/nextjournal/clerk/analyzer.clj index 44fe90c05..687a2f1f9 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))) @@ -379,7 +376,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] @@ -612,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)) diff --git a/src/nextjournal/clerk/builder.clj b/src/nextjournal/clerk/builder.clj index 998c83298..8523b5aa9 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" @@ -36,6 +37,7 @@ "fragments" "hiding_clerk_metadata" "js_import" + "meta_toc" "multiviewer" "pagination" "paren_soup" @@ -185,7 +187,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,10 +201,13 @@ #_(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**"]}) +(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 @@ -217,31 +224,21 @@ (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 - (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." @@ -264,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)})) @@ -310,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) @@ -327,13 +328,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") @@ -391,10 +392,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} @@ -418,8 +423,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) 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/home.clj b/src/nextjournal/clerk/home.clj index 0be748e6f..891a207eb 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,25 +160,31 @@ [: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)] - [:div.flex.mt-6.border-t.font-sans - [:div {:class (str "w-1/2 pt-6 " (when-not (seq @!filter) "pr-6 border-r"))} + (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"] (let [{:keys [query selected-path]} @!filter] (clerk/with-viewer index-viewer {:paths (filter (partial query-fn query) @!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)}))))])]] + ) 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/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 e4faf9709..095642ec8 100644 --- a/src/nextjournal/clerk/render.cljs +++ b/src/nextjournal/clerk/render.cljs @@ -15,49 +15,30 @@ [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.window :as window] + [nextjournal.clerk.render.panel :as panel] [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) +(declare inspect inspect-presented html html-viewer) (def nbsp (gstring/unescapeEntities " ")) (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 [] + (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? + {: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" @@ -76,7 +57,7 @@ :viewBox "0 0 24 24" :fill "none" :xmlns "http://www.w3.org/2000/svg"} - [:>(.-circle motion) + [:> (.-circle motion) {:cx "11.9998" :cy "11.9998" :r "5.75375" @@ -97,32 +78,18 @@ [: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! [!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?)))) - (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 @@ -140,90 +107,39 @@ (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]} 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))}) - root-ref-fn (fn [el] +(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] (when (and el (exists? js/document)) - (setup-dark-mode! !state) + (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 _ (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) - {: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])))]]))) + (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]] + (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 @@ -580,6 +496,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}) @@ -620,8 +537,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 [] [:<> @@ -633,7 +552,15 @@ [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]] + ^{:key id} + [panel/show + (:content state) + (-> state + (assoc :id id :on-close #(swap! !panels dissoc id)))])) + @!panels)]) (declare mount) @@ -754,32 +681,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 [] @@ -791,38 +757,20 @@ (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))))) - - -(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) + (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] + (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/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/navbar.cljs b/src/nextjournal/clerk/render/navbar.cljs index f593c6749..183bd789b 100644 --- a/src/nextjournal/clerk/render/navbar.cljs +++ b/src/nextjournal/clerk/render/navbar.cljs @@ -1,214 +1,193 @@ (ns nextjournal.clerk.render.navbar (:require ["framer-motion" :as framer-motion :refer [motion AnimatePresence]] - [nextjournal.clerk.render.localstorage :as localstorage] [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 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 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 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))) +(def local-storage-key "clerk-navbar") -(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 mobile? [] + (and (exists? js/innerWidth) (< js/innerWidth 640))) -(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 spring {:type :spring :duration 0.35 :bounce 0.1}) + +(defn mobile-backdrop [{:keys [!expanded-at]}] + [:> (.-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} + :animate {:opacity 1} + :exit {:opacity 0} + :on-click #(swap! !expanded-at assoc :toc-open? false) + :transition spring}]) -(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 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 toggle-button [!state content & [opts]] - (let [{:keys [mobile? mobile-open? open?]} @!state] +(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 motion) + (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 ->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 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 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 % - (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/render/panel.cljs b/src/nextjournal/clerk/render/panel.cljs new file mode 100644 index 000000000..dfb1d5f7c --- /dev/null +++ b/src/nextjournal/clerk/render/panel.cljs @@ -0,0 +1,196 @@ +(ns nextjournal.clerk.render.panel + (:require [applied-science.js-interop :as j] + [nextjournal.clerk.render.hooks :as hooks])) + +(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] + (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)))) + (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) + 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)]])) diff --git a/src/nextjournal/clerk/sci_env.cljs b/src/nextjournal/clerk/sci_env.cljs index 72cfb5426..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] @@ -27,6 +28,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 +105,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 @@ -149,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) @@ -158,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/view.clj b/src/nextjournal/clerk/view.clj index 59172af6f..17465243f 100644 --- a/src/nextjournal/clerk/view.clj +++ b/src/nextjournal/clerk/view.clj @@ -41,7 +41,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 9888f72cf..b9926ac3d 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))) @@ -429,9 +435,14 @@ (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 "./" (str/join (repeat (get (frequencies (str path)) \/ 0) "../"))))) + (str "./" (when-not (index-path? path) + (str/join (repeat (inc (get (frequencies (str path)) \/ 0)) + "../")))))) #?(:clj (defn map-index [{:as _opts :keys [index]} path] @@ -464,11 +475,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 +519,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 +844,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)]])}) @@ -911,7 +925,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 +952,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)] @@ -1132,7 +1143,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]}] @@ -1163,6 +1174,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 +1191,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 @@ -1253,7 +1276,6 @@ plotly-viewer vega-lite-viewer markdown-viewer - reagent-viewer row-viewer col-viewer table-viewer @@ -1327,7 +1349,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 @@ -1714,13 +1736,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 @@ -1749,7 +1771,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 b92961c40..7bf9e06a2 100644 --- a/src/nextjournal/clerk/webserver.clj +++ b/src/nextjournal/clerk/webserver.clj @@ -179,25 +179,43 @@ (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 "'") + (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"])))) + +#_(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")))) @@ -212,7 +230,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 @@ -258,8 +276,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") diff --git a/test/nextjournal/clerk/builder_test.clj b/test/nextjournal/clerk/builder_test.clj index d952ad6b5..a86633bae 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 {}))) @@ -99,13 +103,26 @@ (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")))) + ;; 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.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"))) + + (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.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"))))) diff --git a/test/nextjournal/clerk/eval_test.clj b/test/nextjournal/clerk/eval_test.clj index d705086d0..365625693 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))))))) + diff --git a/test/nextjournal/clerk/viewer_test.clj b/test/nextjournal/clerk/viewer_test.clj index bf4b1e7ed..f0f98ab0d 100644 --- a/test/nextjournal/clerk/viewer_test.clj +++ b/test/nextjournal/clerk/viewer_test.clj @@ -282,6 +282,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])") 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"