diff --git a/deps.edn b/deps.edn index 43b0dc2a6..f931a0ace 100644 --- a/deps.edn +++ b/deps.edn @@ -8,7 +8,7 @@ com.nextjournal/beholder {:mvn/version "1.0.0"} - io.github.nextjournal/markdown {:mvn/version "0.2.44"} + io.github.nextjournal/markdown {:mvn/version "0.3.69"} com.taoensso/nippy {:mvn/version "3.1.1"} mvxcvi/multihash {:mvn/version "2.0.3"} diff --git a/notebooks/how_clerk_works.clj b/notebooks/how_clerk_works.clj index 14af0ca6c..d031efb15 100644 --- a/notebooks/how_clerk_works.clj +++ b/notebooks/how_clerk_works.clj @@ -1,5 +1,5 @@ ;; # How Clerk Works 🕵🏻‍♀️ -^{:nextjournal.clerk/toc? true} +^{:nextjournal.clerk/toc true} (ns how-clerk-works (:require [next.jdbc :as jdbc] [nextjournal.clerk :as clerk] diff --git a/notebooks/viewers/custom_markdown.md b/notebooks/viewers/custom_markdown.md new file mode 100644 index 000000000..1d4c0422f --- /dev/null +++ b/notebooks/viewers/custom_markdown.md @@ -0,0 +1,55 @@ +# Customizable Markdown + +Playground for overriding markdown nodes + +```clojure +^{:nextjournal.clerk/visibility #{:hide-ns}} +(ns ^:nextjournal.clerk/no-cache viewers.custom-markdown + (:require [nextjournal.clerk :as clerk] + [nextjournal.clerk.viewer :as v])) +``` + +```clojure +(clerk/set-viewers! [{:name :nextjournal.markdown/text + :transform-fn (v/into-markup [:span {:style {:color "#64748b"}}])} + {:name :nextjournal.markdown/ruler + :transform-fn (constantly + (v/html [:div {:style {:width "100%" :height "80px" :background-position "center" :background-size "cover" + :background-image "url(https://www.maxpixel.net/static/photo/1x/Ornamental-Separator-Decorative-Line-Art-Divider-4715969.png)"}}]))}]) +``` + +## Sections + +with some _more_ text and a ruler. + +--- + +Clerk parses all _consecutive_ `;;`-led _clojure comment lines_ as [Markdown](https://daringfireball.net/projects/markdown). All of markdown syntax should be supported: + +### Lists + +- one **strong** hashtag like #nomarkdownforoldcountry +- two ~~strikethrough~~ + +and bullet lists + +- [x] what +- [ ] the + +### Code + + (assoc {:this "should get"} :clojure "syntax highlighting") + +--- + +### Tables + +Tables with specific alignment. + +| feature | | +|:-------:|:---| +| One |✅ | +| Two |✅ | +| Three |❌ | + +--- diff --git a/notebooks/viewers/in_text_eval.clj b/notebooks/viewers/in_text_eval.clj new file mode 100644 index 000000000..e271f4041 --- /dev/null +++ b/notebooks/viewers/in_text_eval.clj @@ -0,0 +1,35 @@ +;; # 📝 _In-Text_ Evaluation +^{:nextjournal.clerk/visibility #{:hide-ns :hide} :nextjournal.clerk/toc true} +(ns ^{:nextjournal.clerk/no-cache true} viewers.in-text-eval + (:require [nextjournal.clerk :as clerk] + [nextjournal.clerk.viewer :as v] + [nextjournal.markdown.transform :as markdown.transform])) + +;; Being able to override markdown viewers allows us to get in-text evaluation for free: + +^{::clerk/viewer clerk/hide-result} +(defonce num★ (atom 3)) +#_(reset! num★ 3) + +^{::clerk/visibility :show} +(clerk/set-viewers! [{:name :nextjournal.markdown/monospace + :transform-fn (comp eval read-string markdown.transform/->text)} + {:name :nextjournal.markdown/ruler + :transform-fn (constantly + (v/with-viewer :html [:div.text-center (repeat @num★ "★")]))}]) +;; --- +^{::clerk/viewer clerk/hide-result} +(defn slider [var {:keys [min max]}] + (clerk/with-viewer + {:fetch-fn (fn [_ x] x) + :transform-fn (fn [var] {:var-name (symbol var) :value @@var}) + :render-fn `(fn [{:as x :keys [var-name value]}] + (v/html [:input {:type :range + :min ~min :max ~max + :value value + :on-change #(v/clerk-eval `(reset! ~var-name (Integer/parseInt ~(.. % -target -value))))}]))} + var)) + +;; Drag the following slider `(slider #'num★ {:min 1 :max 40})` to control the number of stars (currently **`(deref num★)`**) in our custom horizontal rules. + +;; --- diff --git a/notebooks/viewers/markdown.clj b/notebooks/viewers/markdown.clj index 842ca34d5..686308488 100644 --- a/notebooks/viewers/markdown.clj +++ b/notebooks/viewers/markdown.clj @@ -1,5 +1,5 @@ ;; # Markdown ✍️ (ns markdown (:require [nextjournal.clerk :as clerk])) -(clerk/md "### Text can be\n * **bold**\n * *italic\n * ~~Strikethrough~~\n +(clerk/md "### Text can be\n * **bold**\n * *italic*\n * ~~Strikethrough~~\n It's [Markdown](https://daringfireball.net/projects/markdown/), like you know it.") diff --git a/resources/viewer-js-hash b/resources/viewer-js-hash index 514af5ec4..738ba6003 100644 --- a/resources/viewer-js-hash +++ b/resources/viewer-js-hash @@ -1 +1 @@ -qwFLBeeD9jQbcqC1zFhsiitXFpP \ No newline at end of file +3psx9ELzW9wRaYsqhrcBg9eFzXDj \ No newline at end of file diff --git a/src/nextjournal/clerk.clj b/src/nextjournal/clerk.clj index 2028d1fec..d0d896272 100644 --- a/src/nextjournal/clerk.clj +++ b/src/nextjournal/clerk.clj @@ -361,7 +361,8 @@ (def clerk-docs (into ["notebooks/markdown.md" - "notebooks/onwards.md"] + "notebooks/onwards.md" + "notebooks/viewers/custom_markdown.md"] (map #(str "notebooks/" % ".clj")) ["hello" "how_clerk_works" @@ -378,6 +379,7 @@ "viewers/html" "viewers/image" "viewers/image_layouts" + "viewers/in_text_eval" "viewers/markdown" "viewers/plotly" "viewers/table" diff --git a/src/nextjournal/clerk/hashing.clj b/src/nextjournal/clerk/hashing.clj index 4845fcb97..f233b184e 100644 --- a/src/nextjournal/clerk/hashing.clj +++ b/src/nextjournal/clerk/hashing.clj @@ -198,16 +198,18 @@ (and doc? (n/comment? node)) (-> state (assoc :nodes (drop-while n/comment? nodes)) - (update :blocks conj {:type :markdown :text (apply str (map (comp remove-leading-semicolons n/string) - (take-while n/comment? nodes)))})) + (update :blocks conj {:type :markdown + :doc (-> (apply str (map (comp remove-leading-semicolons n/string) + (take-while n/comment? nodes))) + markdown/parse + (select-keys [:type :content]))})) :else (update state :nodes rest))) (merge (select-keys state [:blocks :visibility]) (when doc? (-> {:content (into [] (comp (filter (comp #{:markdown} :type)) - (map (comp markdown/parse :text)) - (mapcat :content)) + (mapcat (comp :content :doc))) blocks)} markdown.parser/add-title+toc (select-keys #{:title :toc}) diff --git a/src/nextjournal/clerk/sci_viewer.cljs b/src/nextjournal/clerk/sci_viewer.cljs index 431a464dd..8b0281461 100644 --- a/src/nextjournal/clerk/sci_viewer.cljs +++ b/src/nextjournal/clerk/sci_viewer.cljs @@ -80,7 +80,7 @@ (reduce (fn [acc {:as item :keys [content children]}] (if content - (let [title (-> content first :text)] + (let [title (md.transform/->text item)] (->> {:title title :path (str "#" (uri.normalize/normalize-fragment title)) :items (toc-items children)} @@ -1027,9 +1027,10 @@ black")}]))} (html (katex/to-html-string tex-string))) (defn html-viewer [markup] - (if (string? markup) - (html [:div {:dangerouslySetInnerHTML {:__html markup}}]) - (r/as-element markup))) + (r/as-element + (if (string? markup) + [:span {:dangerouslySetInnerHTML {:__html markup}}] + markup))) (defn reagent-viewer [x] (r/as-element (cond-> x (fn? x) vector))) @@ -1039,15 +1040,6 @@ black")}]))} (def plotly-viewer (comp normalize-viewer plotly/viewer)) (def vega-lite-viewer (comp normalize-viewer vega-lite/viewer)) -(defn markdown-viewer - "Accept a markdown string or a structure from parsed markdown." - [data] - (cond - (string? data) - (markdown/viewer data) - (and (map? data) (contains? data :content) (contains? data :type)) - (with-viewer :hiccup (md.transform/->hiccup markdown/default-renderers data)))) - (def expand-icon [:svg {:xmlns "http://www.w3.org/2000/svg" :viewBox "0 0 20 20" :fill "currentColor" :width 12 :height 12} [:path {:fill-rule "evenodd" :d "M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" :clip-rule "evenodd"}]]) @@ -1101,7 +1093,6 @@ black")}]))} 'notebook-viewer notebook 'katex-viewer katex-viewer 'mathjax-viewer mathjax-viewer - 'markdown-viewer markdown-viewer 'code-viewer code-viewer 'foldable-code-viewer foldable-code-viewer 'plotly-viewer plotly-viewer diff --git a/src/nextjournal/clerk/view.clj b/src/nextjournal/clerk/view.clj index 1683c0461..91c614171 100644 --- a/src/nextjournal/clerk/view.clj +++ b/src/nextjournal/clerk/view.clj @@ -152,7 +152,10 @@ (defn describe-block [{:keys [inline-results?] :or {inline-results? false}} {:keys [ns]} {:as cell :keys [type text doc]}] (case type - :markdown [(v/md (or doc text))] + :markdown [(binding [*ns* ns] + (v/describe (cond + text (v/md text) + doc (v/with-md-viewer doc))))] :code (let [{:as cell :keys [result]} (update cell :result apply-viewer-unwrapping-var-from-def) {:keys [code? fold? result?]} (->display cell)] (cond-> [] diff --git a/src/nextjournal/clerk/viewer.cljc b/src/nextjournal/clerk/viewer.cljc index 66497c2c6..22c13a78b 100644 --- a/src/nextjournal/clerk/viewer.cljc +++ b/src/nextjournal/clerk/viewer.cljc @@ -6,7 +6,10 @@ [clojure.walk :as w] #?@(:clj [[clojure.repl :refer [demunge]] [nextjournal.clerk.config :as config]] - :cljs [[reagent.ratom :as ratom]])) + :cljs [[reagent.ratom :as ratom]]) + [nextjournal.markdown :as md] + [nextjournal.markdown.transform :as md.transform] + [lambdaisland.uri.normalize :as uri.normalize]) #?(:clj (:import (java.lang Throwable) (java.awt.image BufferedImage) (javax.imageio ImageIO)))) @@ -153,7 +156,7 @@ (defn inspect-leafs [opts x] (if (wrapped-value? x) - [(->viewer-fn 'v/inspect) (describe x opts)] + [(->viewer-eval 'v/inspect) (describe x opts)] x)) (defn fetch-all [opts xs] @@ -166,6 +169,17 @@ (defn var-from-def? [x] (and (map? x) (get-safe x :nextjournal.clerk/var-from-def))) +(declare with-viewer) + +(defn with-md-viewer [{:as node :keys [type]}] + (with-viewer (keyword "nextjournal.markdown" (name type)) node)) + +(defn into-markup [mkup] + (let [mkup-fn (if (fn? mkup) mkup (constantly mkup))] + (fn [{:as node :keys [text content]}] + (with-viewer :html + (into (mkup-fn node) (cond text [text] content (map with-md-viewer content))))))) + (declare !viewers) ;; keep viewer selection stricly in Clojure @@ -206,10 +220,9 @@ {:name :latex :render-fn (quote v/katex-viewer) :fetch-fn fetch-all} {:name :mathjax :render-fn (quote v/mathjax-viewer) :fetch-fn fetch-all} {:name :html :render-fn (quote v/html) :fetch-fn fetch-all} - {:name :hiccup :render-fn (quote v/html)} ;; TODO: drop once markdown doesn't use it anymore {:name :plotly :render-fn (quote v/plotly-viewer) :fetch-fn fetch-all} {:name :vega-lite :render-fn (quote v/vega-lite-viewer) :fetch-fn fetch-all} - {:name :markdown :render-fn (quote v/markdown-viewer) :fetch-fn fetch-all} + {:name :markdown :transform-fn (comp with-md-viewer md/parse)} {:name :code :render-fn (quote v/code-viewer) :fetch-fn fetch-all :transform-fn #(let [v (value %)] (if (string? v) v (with-out-str (pprint/pprint v))))} {:name :code-folded :render-fn (quote v/foldable-code-viewer) :fetch-fn fetch-all :transform-fn #(let [v (value %)] (if (string? v) v (with-out-str (pprint/pprint v))))} {:name :reagent :render-fn (quote v/reagent-viewer) :fetch-fn fetch-all} @@ -243,8 +256,71 @@ {:pred string? :render-fn (quote v/string-viewer) :fetch-opts {:n 100}} {:pred number? :render-fn '(fn [x] (v/html [:span.tabular-nums (if (js/Number.isNaN x) "NaN" (str x))]))}]) +(def markdown-viewers + [{:name :nextjournal.markdown/doc :transform-fn (into-markup [:div.viewer-markdown])} + + ;; blocks + {:name :nextjournal.markdown/heading + :transform-fn (into-markup + (fn [{:as node :keys [heading-level]}] + [(str "h" heading-level) {:id (uri.normalize/normalize-fragment (md.transform/->text node))}]))} + {:name :nextjournal.markdown/image :transform-fn #(with-viewer :html [:img (:attrs %)])} + {:name :nextjournal.markdown/blockquote :transform-fn (into-markup [:blockquote])} + {:name :nextjournal.markdown/paragraph :transform-fn (into-markup [:p])} + {:name :nextjournal.markdown/ruler :transform-fn (into-markup [:hr])} + {:name :nextjournal.markdown/code + :transform-fn #(with-viewer :html + [:div.viewer-code + (with-viewer :code + (md.transform/->text %))])} + + ;; marks + {:name :nextjournal.markdown/em :transform-fn (into-markup [:em])} + {:name :nextjournal.markdown/strong :transform-fn (into-markup [:strong])} + {:name :nextjournal.markdown/monospace :transform-fn (into-markup [:code])} + {:name :nextjournal.markdown/strikethrough :transform-fn (into-markup [:s])} + {:name :nextjournal.markdown/link :transform-fn (into-markup #(vector :a (:attrs %)))} + {:name :nextjournal.markdown/internal-link :transform-fn (into-markup #(vector :a {:href (str "#" (:text %))}))} + {:name :nextjournal.markdown/hashtag :transform-fn (into-markup #(vector :a {:href (str "#" (:text %))}))} + + ;; inlines + {:name :nextjournal.markdown/text :transform-fn (into-markup [:span])} + {:name :nextjournal.markdown/softbreak :transform-fn (fn [_] (with-viewer :html [:span " "]))} + #?(:clj {:name :nextjournal.markdown/inline :transform-fn (comp eval read-string md.transform/->text)}) + + ;; formulas + {:name :nextjournal.markdown/formula :transform-fn :text :render-fn 'v/katex-viewer} + {:name :nextjournal.markdown/block-formula :transform-fn :text :render-fn 'v/katex-viewer} + + ;; lists + {:name :nextjournal.markdown/bullet-list :transform-fn (into-markup [:ul])} + {:name :nextjournal.markdown/numbered-list :transform-fn (into-markup [:ol])} + {:name :nextjournal.markdown/todo-list :transform-fn (into-markup [:ul.contains-task-list])} + {:name :nextjournal.markdown/list-item :transform-fn (into-markup [:li])} + {:name :nextjournal.markdown/todo-item + :transform-fn (into-markup (fn [{:keys [attrs]}] [:li [:input {:type "checkbox" :default-checked (:checked attrs)}]]))} + + ;; tables + {:name :nextjournal.markdown/table :transform-fn (into-markup [:table])} + {:name :nextjournal.markdown/table-head :transform-fn (into-markup [:thead])} + {:name :nextjournal.markdown/table-body :transform-fn (into-markup [:tbody])} + {:name :nextjournal.markdown/table-row :transform-fn (into-markup [:tr])} + {:name :nextjournal.markdown/table-header + :transform-fn (into-markup #(vector :th {:style (md.transform/table-alignment (:attrs %))}))} + {:name :nextjournal.markdown/table-data + :transform-fn (into-markup #(vector :td {:style (md.transform/table-alignment (:attrs %))}))} + + ;; ToC via [[TOC]] placeholder ignored + {:name :nextjournal.markdown/toc :transform-fn (into-markup [:div.toc])} + + ;; sidenotes + {:name :nextjournal.markdown/sidenote + :transform-fn (into-markup (fn [{:keys [attrs]}] [:span.sidenote [:sup {:style {:margin-right "3px"}} (-> attrs :ref inc)]]))} + {:name :nextjournal.markdown/sidenote-ref + :transform-fn (into-markup [:sup.sidenote-ref])}]) + (defn get-all-viewers [] - {:root default-viewers + {:root (concat default-viewers markdown-viewers) :table default-table-cell-viewers}) (defonce diff --git a/test/nextjournal/clerk/hashing_test.clj b/test/nextjournal/clerk/hashing_test.clj index f9c7e4c80..68fec5803 100644 --- a/test/nextjournal/clerk/hashing_test.clj +++ b/test/nextjournal/clerk/hashing_test.clj @@ -38,20 +38,18 @@ (deftest parse-clojure-string (testing "is returning blocks with types and markdown structure attached" (is (match? (m/equals {:blocks [{:type :code, :text "^:nextjournal.clerk/no-cache ^:nextjournal.clerk/toc (ns example-notebook)", :ns? true} - {:type :markdown, :text " # 📶 Sorting\n"} - {:type :markdown, :text " ## Sorting Sets\n The following set should be sorted upon description\n"} + {:type :markdown, :doc {:type :doc :content [{:type :heading}]}} + {:type :markdown, :doc {:type :doc :content [{:type :heading} + {:type :paragraph}]}} {:type :code, :text "#{3 1 2}"} - {:type :markdown, :text " ## Sorting Maps\n"} + {:type :markdown, :doc {:type :doc :content [{:type :heading}]}} {:type :code, :text "{2 \"bar\" 1 \"foo\"}"}], :visibility #{:show}, :title "📶 Sorting", :toc {:type :toc, :mode true, - :children [{:type :toc, - :content [{:type :text, :text "📶 Sorting"}], - :heading-level 1, - :children [{:type :toc, :content [{:type :text, :text "Sorting Sets"}], :heading-level 2} - {:type :toc, :content [{:type :text, :text "Sorting Maps"}], :heading-level 2}]}]}}) + :children [{:type :toc :children [{:type :toc} + {:type :toc}]}]}}) (h/parse-clojure-string {:doc? true} notebook))))) (deftest no-cache? diff --git a/test/nextjournal/clerk/viewer_test.clj b/test/nextjournal/clerk/viewer_test.clj index ffd49e669..bdcd50639 100644 --- a/test/nextjournal/clerk/viewer_test.clj +++ b/test/nextjournal/clerk/viewer_test.clj @@ -65,9 +65,29 @@ (is (= :full (:nextjournal/width (v/wrapped-with-viewer (v/table {:nextjournal.clerk/width :full} {:a [1] :b [2] :c [3]}))))))) +(defn viewer-eval-inspect? [x] (= x (v/->viewer-eval 'v/inspect))) + (deftest describe (testing "only transform-fn can select viewer" - (is (match? {:nextjournal/value "Hello _markdown_!", :nextjournal/viewer {:name :markdown}} + (is (match? {:nextjournal/viewer {:name :html} + :nextjournal/value [:div.viewer-markdown + [viewer-eval-inspect? + {:nextjournal/viewer {:name :html} + :nextjournal/value [:p + [viewer-eval-inspect? + {:nextjournal/viewer {:name :html} + :nextjournal/value [:span "Hello "]}] + [viewer-eval-inspect? + {:nextjournal/viewer {:name :html} + :nextjournal/value [:em + [viewer-eval-inspect? + {:path [], + :nextjournal/value [:span "markdown"] + :nextjournal/viewer {:name :html}}]]}] + [viewer-eval-inspect? + {:nextjournal/viewer {:name :html} + :nextjournal/value [:span "!"]}]]}]]} + (v/describe (v/with-viewer {:transform-fn (comp v/md :foo)} {:foo "Hello _markdown_!"})))))