diff --git a/notebooks/document_linking.clj b/notebooks/document_linking.clj index 47c68cca1..875b09238 100644 --- a/notebooks/document_linking.clj +++ b/notebooks/document_linking.clj @@ -23,3 +23,10 @@ [: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) + +;; ## Links in prose +;; * Link to a namespace `[[rule-30]]` renders as [[rule-30]] +;; * Link to a var `[[how-clerk-works/hashes]]` renders as [[how-clerk-works/hashes]] +;; * Link to a path `[[notebooks/viewers/image.clj]]` renders as [[notebooks/viewers/image.clj]] +;; +;; The href of regular links is processed in a similar fashion: `[Clerk Analyzer](how-clerk-works/hashes)` renders as [Clerk Analyzer](how-clerk-works/hashes). diff --git a/src/nextjournal/clerk.clj b/src/nextjournal/clerk.clj index 7c506d9c4..9e78de0c7 100644 --- a/src/nextjournal/clerk.clj +++ b/src/nextjournal/clerk.clj @@ -1,5 +1,10 @@ (ns nextjournal.clerk - "Clerk's Public API." + "Clerk's Public API. + + Further API: + * [Parsing](nextjournal.clerk.parser) + * [Viewers API](nextjournal.clerk.viewer) + * [Static analysis and caching](nextjournal.clerk.analyzer)" (:require [babashka.fs :as fs] [clojure.java.browse :as browse] [clojure.java.io :as io] @@ -222,13 +227,16 @@ ([viewer-opts x] (v/html viewer-opts x))) (defn md - "Displays `x` with the markdown viewer. + "Displays `x` with the markdown viewer. Accepts strings or a structure as returned by [[nextjournal.markdown/parse]]. Supports an optional first `viewer-opts` map arg with the following optional keys: * `:nextjournal.clerk/width`: set the width to `:full`, `:wide`, `:prose` * `:nextjournal.clerk/viewers`: a seq of viewers to use for presentation of this value and its children - * `:nextjournal.clerk/render-opts`: a map argument that will be passed as a secong arg to the viewers `:render-fn`" + * `:nextjournal.clerk/render-opts`: a map argument that will be passed as a secong arg to the viewers `:render-fn`. + + See also [[nextjournal.clerk.viewer/markdown-viewer]]." + ([x] (v/md x)) ([viewer-opts x] (v/md viewer-opts x))) @@ -553,7 +561,7 @@ #_(with-cache (do (Thread/sleep 4200) 42)) (defmacro defcached - "Like `clojure.core/def` but with Clerk's caching of the value." + "Like `clojure.core/def` but with Clerk's caching of the value. See also [[with-cache]]." [name expr] `(let [result# (-> ~(v/->edn expr) eval/eval-string :blob->result first val :nextjournal/value)] (def ~name result#))) diff --git a/src/nextjournal/clerk/doc.clj b/src/nextjournal/clerk/doc.clj index f18f61ccf..5122aeeaf 100644 --- a/src/nextjournal/clerk/doc.clj +++ b/src/nextjournal/clerk/doc.clj @@ -3,7 +3,31 @@ {:nextjournal.clerk/visibility {:code :hide :result :hide}} (:require [clojure.string :as str] [nextjournal.clerk :as clerk] - [nextjournal.clerk.viewer :as viewer])) + [nextjournal.clerk.viewer :as viewer] + [nextjournal.markdown.transform :as md.transform])) + +(clerk/eval-cljs + '(defn handle-click [{:keys [label var ns]} e] + (.stopPropagation e) + (.preventDefault e) + (when (resolve '!active-ns) + (let [scroll-to-target (fn [] + (if var + (when-some [el (js/document.getElementById (name var))] + (.scrollIntoView el)) + (when ns + (when-some [page (js/document.getElementById "main-column")] + (.scroll page (applied-science.js-interop/obj :top 0))))))] + (when ns + (if (not= @!active-ns (str ns)) + (do (reset! !active-ns (str ns)) + ;; TODO: smarter + (js/setTimeout scroll-to-target 500)) + (scroll-to-target))))))) + +(clerk/eval-cljs + '(defn render-link [{:as info :keys [label]} _] + [:a {:href "#" :on-click (partial handle-click info)} label])) (def render-input '(fn [!query] @@ -100,17 +124,6 @@ #_(ns-tree ns-matches) #_(ns-tree ()) -(defn parent-ns [ns-str] - (when (str/includes? ns-str ".") - (str/join "." (butlast (str/split ns-str #"\."))))) - -(defn prepend-parent [nss] - (when-let [parent (parent-ns (first nss))] - (cons parent nss))) - -(defn path-to-ns [ns-str] - (last (take-while some? (iterate prepend-parent [ns-str])))) - ^{::clerk/visibility {:result :show}} (clerk/html (let [matches (try @@ -161,7 +174,7 @@ (into [:div.text-sm.font-sans.px-5.mt-2] (map render-ns) (ns-tree (str-match-nss @!active-ns)))]])]] - [:div.flex-auto.max-h-screen.overflow-y-auto.px-8.py-5 + [:div#main-column.flex-auto.max-h-screen.overflow-y-auto.px-8.py-5 (let [ns (some-> @!active-ns symbol find-ns)] (cond ns [:<> @@ -195,3 +208,37 @@ #_(deref nextjournal.clerk.webserver/!doc) +(defn resolve-internal-link [link] + (viewer/resolve-internal-link (cond->> link + (and @!active-ns (not= :all @!active-ns) + (not (find-ns (symbol link))) + (not (qualified-symbol? (symbol link)))) + (str @!active-ns "/")))) + +(def get-info + (comp clerk/mark-presented + (fn [wv] + (let [{:as node :keys [type text attrs]} (-> wv :nextjournal/value)] + (when-some [{:as info :keys [ns var]} + (some-> (resolve-internal-link (case type :internal-link text :link (:href attrs))) + (viewer/update-if :ns ns-name) + (viewer/update-if :var symbol))] + (assoc info :label + (str (case type + :internal-link (or var ns) + :link (md.transform/->text node))))))))) + +(def custom-markdown-viewers + [{:name :nextjournal.markdown/internal-link + :render-fn 'nextjournal.clerk.doc/render-link + :transform-fn get-info} + {:name :nextjournal.markdown/link + :render-fn 'nextjournal.clerk.doc/render-link + :transform-fn get-info}]) + +(def markdown-viewer + (update viewer/markdown-viewer :add-viewers viewer/add-viewers custom-markdown-viewers)) + +(viewer/add-viewers! [markdown-viewer]) + +#_(clerk/clear-cache!) diff --git a/src/nextjournal/clerk/parser.cljc b/src/nextjournal/clerk/parser.cljc index 3607cc4ab..f842ce43a 100644 --- a/src/nextjournal/clerk/parser.cljc +++ b/src/nextjournal/clerk/parser.cljc @@ -287,14 +287,17 @@ (defn markdown-context [] (update markdown.parser/empty-doc - :text-tokenizers (partial map markdown.parser/normalize-tokenizer))) - -#_(markdown-context) + :text-tokenizers + (comp (partial mapv markdown.parser/normalize-tokenizer) + (partial cons markdown.parser/internal-link-tokenizer)))) (defn parse-markdown "Like `n.markdown.parser/parse` but allows to reuse the same context in successive calls" - [ctx md] - (markdown.parser/apply-tokens ctx (markdown/tokenize md))) + ([md] (parse-markdown (markdown-context) md)) + ([ctx md] + (markdown.parser/apply-tokens ctx (markdown/tokenize md)))) + +#_(parse-markdown-string {:doc? true} "# Title\nSome [[internal-link]] to be followed.") (defn update-markdown-blocks [{:as state :keys [md-context]} md] (let [{::markdown.parser/keys [path]} md-context @@ -348,9 +351,7 @@ state)))) #_(parse-clojure-string {:doc? true} "'code ;; foo\n;; bar") -#_(parse-clojure-string "'code , ;; foo\n;; bar") #_(parse-clojure-string "'code\n;; foo\n;; bar") -#_(keys (parse-clojure-string {:doc? true} (slurp "notebooks/viewer_api.clj"))) #_(parse-clojure-string {:doc? true} ";; # Hello\n;; ## 👋 Section\n(do 123)\n;; ## 🤚🏽 Section") (defn parse-markdown-cell [{:as state :keys [nodes]} opts] diff --git a/src/nextjournal/clerk/render.cljs b/src/nextjournal/clerk/render.cljs index 4c65bd53c..20f711098 100644 --- a/src/nextjournal/clerk/render.cljs +++ b/src/nextjournal/clerk/render.cljs @@ -865,7 +865,7 @@ [: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"}]]) (defn render-code-block [code-string {:as opts :keys [id]}] - [:div.viewer.code-viewer.w-full.max-w-wide {:data-block-id id} + [:div.viewer.code-viewer.w-full.max-w-wide {:id id} [code/render-code code-string (assoc opts :language "clojure")]]) (defn render-folded-code-block [code-string {:as opts :keys [id]}] diff --git a/src/nextjournal/clerk/viewer.cljc b/src/nextjournal/clerk/viewer.cljc index 9b4f48bb3..5e9874d91 100644 --- a/src/nextjournal/clerk/viewer.cljc +++ b/src/nextjournal/clerk/viewer.cljc @@ -19,7 +19,6 @@ [sci.lang] [applied-science.js-interop :as j]]) [nextjournal.clerk.parser :as parser] - [nextjournal.markdown :as md] [nextjournal.markdown.parser :as md.parser] [nextjournal.markdown.transform :as md.transform]) #?(:clj (:import (com.pngencoder PngEncoder) @@ -31,6 +30,8 @@ (java.nio.file Files StandardOpenOption) (javax.imageio ImageIO)))) +(declare doc-url) + (defrecord ViewerEval [form]) (defrecord ViewerFn [form #?(:cljs f)] @@ -470,7 +471,9 @@ %) presented-result))) -(defn get-default-viewers [] +(defn get-default-viewers + "Returns viewers from the global scope when set, defaults to [[default-viewers]] (see also [[!viewers]])." + [] (:default @!viewers default-viewers)) (defn datafy-scope [scope] @@ -714,6 +717,57 @@ (doto (java.text.SimpleDateFormat. "yyyy-MM-dd'T'HH:mm:ss.SSS-00:00") (.setTimeZone (java.util.TimeZone/getTimeZone "GMT"))))) +#?(:clj (defn resolve-internal-link [link] + (if (fs/exists? link) + {:path link} + (let [sym (symbol link)] + (if (qualified-symbol? sym) + (when-some [var (try (requiring-resolve sym) + (catch Exception _ nil))] + (merge {:var var} (resolve-internal-link (-> var symbol namespace)))) + (when-some [ns (try (require sym) + (find-ns sym) + (catch Exception _ nil))] + (cond-> {:ns ns} + (fs/exists? (analyzer/ns->file sym)) + (assoc :path (analyzer/ns->file sym))))))))) + +#_(resolve-internal-link "notebooks/hello.clj") +#_(resolve-internal-link "nextjournal.clerk.tap") +#_(resolve-internal-link "rule-30/board") + +(defn process-internal-link [link] + #?(:clj + (let [{:keys [path var]} (resolve-internal-link link)] + {:path path + :fragment (when var (str (-> var symbol str) "-code")) + :title (or (when var (-> var symbol str)) + (when path (:title (parser/parse-file {:doc? true} path))) + link)}) + :cljs + {:path link :title link})) + +(defn process-href [^String href] + #?(:cljs href + :clj (if (or (.getScheme (URI. href)) (str/starts-with? href "/")) + href + (let [{:keys [path fragment]} (process-internal-link href)] + (if (or path fragment) (doc-url path fragment) href))))) + +#_(process-href "rule-30") +#_(process-href "#some-id") +#_(process-internal-link "#some-id") + +#_(process-internal-link "notebooks/rule_30.clj") +#_(process-internal-link "viewers.html") +#_(process-internal-link "how-clerk-works/hashes") +#_(process-internal-link "rule-30/first-generation") + +(defn update-if [m k f] (if (k m) (update m k f) m)) +#_(update-if {:n "42"} :n #(Integer/parseInt %)) + +(declare html) + (def markdown-viewers [{:name :nextjournal.markdown/doc :transform-fn (into-markup (fn [{:keys [id]}] [:div.viewer.markdown-viewer.w-full.max-w-prose.px-8 {:data-block-id id}]))} @@ -741,7 +795,12 @@ {: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/link :transform-fn (into-markup #(vector :a (update-if (:attrs %) :href process-href)))} + {:name :nextjournal.markdown/internal-link + :transform-fn (update-val + (fn [{:keys [text]}] + (let [{:keys [path fragment title]} (process-internal-link text)] + (html [:a.internal-link {:href (doc-url path fragment)} title]))))} ;; inlines {:name :nextjournal.markdown/text :transform-fn (into-markup [:<>])} @@ -937,12 +996,13 @@ {:name `vega-lite-viewer :render-fn 'nextjournal.clerk.render/render-vega-lite :transform-fn mark-presented}) (def markdown-viewer + "A clerk viewer for rendering markdown. See also [[nextjournal.clerk/md]]." {:name `markdown-viewer :add-viewers markdown-viewers :transform-fn (fn [wrapped-value] (-> wrapped-value mark-presented - (update :nextjournal/value #(cond->> % (string? %) md/parse)) + (update :nextjournal/value #(cond->> % (string? %) parser/parse-markdown)) (with-md-viewer)))}) (def code-viewer @@ -1122,14 +1182,7 @@ (map (juxt #(list 'quote (symbol %)) #(->> % deref deref (list 'quote)))) (extract-sync-atom-vars doc))))) -(defn update-if [m k f] - (if (k m) - (update m k f) - m)) - -#_(update-if {:n "42"} :n #(Integer/parseInt %)) - -(declare html doc-url) +(declare html) (defn home? [{:keys [nav-path]}] (contains? #{"src/nextjournal/home.clj" "'nextjournal.clerk.home"} nav-path)) @@ -1288,7 +1341,7 @@ hide-result-viewer]) (defonce - ^{:doc "atom containing a map of and per-namespace viewers or `:defaults` overridden viewers."} + ^{:doc "An atom containing a map of per-namespace viewers or `:default` overridden viewers. See also how to [get default viewers](get-default-viewers)."} !viewers (#?(:clj atom :cljs ratom/atom) {}))