From 4a1f5abcc1a2e36c75a9e4ba9d1a604c9ed76321 Mon Sep 17 00:00:00 2001 From: burinc Date: Sat, 7 Nov 2026 21:08:36 +1100 Subject: [PATCH] feat: README images reach the site, and GitHub alerts render as callouts A homepage rendered from README.md kept the README's repo-root paths, so pointed at /docs/images/logo.svg while :asset-dirs copies docs/images to /images. Links that start with docs// or ./docs// for a configured entry now point at that copy. Anything else is left as written. A blockquote opening with [!NOTE], [!TIP], [!IMPORTANT], [!WARNING] or [!CAUTION] rendered as a plain quote showing the marker. It now renders as GitHub does, a div.markdown-alert with a title, styled from the existing syntax colours so it follows the light and dark themes. Found on champaca's site, whose README has both. --- README.md | 10 ++++++++ resources/static/css/screen.css | 22 +++++++++++++++++ src/site/generic_home.clj | 28 ++++++++++++++++++---- src/site/markdown.clj | 42 ++++++++++++++++++++++++++++----- test/site/core_test.clj | 24 ++++++++++++++++--- test/site/markdown_test.clj | 28 ++++++++++++++++++++++ 6 files changed, 141 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index f8d6b3c..30a64e4 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,16 @@ Leave `:base-path` as `""` only for a site served at a domain root. into a published site, so a project that could write `["../../.ssh"]` would publish it. +An entry is copied to the site root under its own name, so `docs/images` ends +up at `/images`. When the homepage is rendered from `README.md`, its +`src` and `href` values that start with `docs//` (or `./docs//`) +are rewritten to that copy. The README's images then show on the site and on +GitHub alike, and a path into a directory that isn't listed stays as written. + +Markdown pages get GitHub's alerts too. A blockquote opening with `[!NOTE]`, +`[!TIP]`, `[!IMPORTANT]`, `[!WARNING]` or `[!CAUTION]` renders as a titled +callout. + ## Building ```bash diff --git a/resources/static/css/screen.css b/resources/static/css/screen.css index f45418e..09bea2a 100644 --- a/resources/static/css/screen.css +++ b/resources/static/css/screen.css @@ -573,6 +573,28 @@ nav.toc { color: var(--text-dim); } +/* GitHub alerts (> [!NOTE] and friends, site.markdown/github-alerts). Each + kind borrows a syntax-highlighting token, which already has a light and a + dark value; the fallback is the dark one, for the moment before the head + script sets data-theme. */ +.doc-content .markdown-alert { + --alert-color: var(--code-attr, #79c0ff); + border-left: 3px solid var(--alert-color); + margin: 0 0 1.25rem; + padding: 0.25rem 0 0.25rem 1rem; +} +.doc-content .markdown-alert-tip { --alert-color: var(--code-name, #7ee787); } +.doc-content .markdown-alert-important { --alert-color: var(--code-title, #d2a8ff); } +.doc-content .markdown-alert-warning { --alert-color: var(--code-builtin, #ffa657); } +.doc-content .markdown-alert-caution { --alert-color: var(--code-keyword, #ff7b72); } +.doc-content .markdown-alert > p { margin: 0 0 0.5rem; } +.doc-content .markdown-alert > p:last-child { margin-bottom: 0; } +.doc-content .markdown-alert-title { + color: var(--alert-color); + font-weight: 700; + letter-spacing: 0.04em; +} + .doc-content hr { border: none; border-top: 1px solid var(--border); diff --git a/src/site/generic_home.clj b/src/site/generic_home.clj index 8ec8301..91d2fa7 100644 --- a/src/site/generic_home.clj +++ b/src/site/generic_home.clj @@ -28,6 +28,23 @@ [html] (str/replace html #"href='(?!https?://|/)([^']*\.html[^']*)'" "href='guide/$1'")) +(defn rewrite-readme-asset-links + "README.md's relative links are relative to the repository root, which the + site does not mirror. A directory under docs/ named in :asset-dirs is + copied to the site root under its own name (site.core/copy-project-assets!), + so the docs/images/logo.svg a README shows on GitHub is + /images/logo.svg on the site. Rewrites each src and href that + starts with docs// or ./docs//, in either quote style, for each + configured name. Anything else is left as it was, since a path the build + does not copy has no site URL to point at." + [html asset-names site-base] + (reduce (fn [h n] + (str/replace h (re-pattern (str "(src|href)=([\"'])(?:\\./)?docs/" + (java.util.regex.Pattern/quote n) "/")) + (fn [[_ attr q]] (str attr "=" q site-base "/" n "/")))) + html + asset-names)) + (defn rendered-source "What the homepage's source document rendered to, so a caller can both build the page and index it without rendering the markdown twice. @@ -52,17 +69,20 @@ Only the guide/index.md case gets its links rewritten and then guide/-prefixed. README-sourced content keeps whatever the default - rewrite produced: its links are relative to the repository root, which - is a different context and out of scope here. + rewrite produced, since its links are relative to the repository root, + except that links into a configured asset directory point at the copy the + build makes (rewrite-readme-asset-links). The three-arity form takes an already-computed rendered-source, for a caller that needs the same render for something else." ([project site-ctx] (render project site-ctx (rendered-source project))) - ([_project site-ctx {:keys [source body-html toc-html]}] + ([project site-ctx {:keys [source body-html toc-html]}] (let [content (case source nil (str "

" (:site-title site-ctx) "

No documentation yet.

") :guide-index (prefix-guide-links body-html) - body-html)] + (rewrite-readme-asset-links body-html + (map fs/file-name (:asset-dirs project)) + (:site-base site-ctx)))] (selmer/render-file "generic-home.html" (merge site-ctx {:page "home" diff --git a/src/site/markdown.clj b/src/site/markdown.clj index 62f24d4..8617738 100644 --- a/src/site/markdown.clj +++ b/src/site/markdown.clj @@ -283,6 +283,34 @@ [html] (str/replace html html-or-stray-lt (fn [m] (if (= m "<") "<" m)))) +(def ^:private alert-titles + {"note" "Note" "tip" "Tip" "important" "Important" "warning" "Warning" "caution" "Caution"}) + +(def ^:private alert-blockquote + ;; markdown-clj renders `> [!WARNING]` and the lines after it as ONE + ;; paragraph, so the marker is the start of the blockquote's first

. + #"(?is)

\s*

\s*\[!(note|tip|important|warning|caution)\]\s*(.*?)

") + +(defn github-alerts + "Renders GitHub's alert blockquotes (`> [!NOTE]`, TIP, IMPORTANT, WARNING, + CAUTION) the way GitHub does: a div.markdown-alert.markdown-alert- + with a p.markdown-alert-title naming the kind, instead of a plain + blockquote that shows the literal [!WARNING]. Any other blockquote is left + alone, and so is a marker inside code, which markdown-clj has already + escaped. + + Two differences from GitHub, both from working on rendered HTML: a marker + followed by text on its own line (`> [!TIP] text`) becomes an alert here and + stays a quote there, and a blockquote nested inside an alert ends the alert + at the inner quote's close." + [html] + (str/replace html alert-blockquote + (fn [[_ kind body]] + (let [k (str/lower-case kind)] + (str "
" + "

" (alert-titles k) "

" + "

" body "

"))))) + (defn render-markdown "markdown -> HTML. Does NOT use markdown-clj's own :heading-anchors — its id-generation embeds literal entity text (_ etc.) in id @@ -294,13 +322,15 @@ Runs escape-intraword-underscores then unwrap-hard-wraps first (see their docstrings) so snake_case identifiers don't get misread as emphasis and hard-wrapped source paragraphs/list-items don't trip - markdown-clj's line-oriented parsing." + markdown-clj's line-oriented parsing. GitHub alert blockquotes become + callouts last (github-alerts)." [markdown-text] - (escape-stray-lt - (md/md-to-html-string (-> markdown-text - escape-intraword-underscores - unwrap-hard-wraps) - :code-style (fn [lang] (str "class=\"" lang "\""))))) + (github-alerts + (escape-stray-lt + (md/md-to-html-string (-> markdown-text + escape-intraword-underscores + unwrap-hard-wraps) + :code-style (fn [lang] (str "class=\"" lang "\"")))))) (defn assign-heading-ids "Adds a unique id= to every

/

/

, computed from its OWN diff --git a/test/site/core_test.clj b/test/site/core_test.clj index 7d88147..3758acb 100644 --- a/test/site/core_test.clj +++ b/test/site/core_test.clj @@ -48,17 +48,18 @@ {:out :doc :home :site :index}. opts: :home-template? (ship a project homepage template), :assets? (ship an asset directory), :search (copied into the site map when given), :readme? (ship a README.md and no - guide/index.md, so the README is the homepage source). :index is the + guide/index.md, so the README is the homepage source), :readme-text + (that README's markdown, in place of the default). :index is the parsed search-documents.json, or nil when none was written." ([base] (build-fixture-site! base {})) - ([base {:keys [home-template? assets? diagram? mermaid-override readme? search no-h1?]}] + ([base {:keys [home-template? assets? diagram? mermaid-override readme? readme-text search no-h1?]}] (let [tmp (fs/create-temp-dir {:prefix "jltc-site"}) docs (io/file (str tmp) "docs") guide (io/file docs "guide") templates (io/file docs "templates")] (fs/create-dirs guide) (if readme? - (spit (io/file (str tmp) "README.md") "# Readme Title\n\n## Usage\n\nRun it.\n") + (spit (io/file (str tmp) "README.md") (or readme-text "# Readme Title\n\n## Usage\n\nRun it.\n")) (spit (io/file guide "index.md") (if diagram? "# Intro\n\nHello.\n\n```mermaid\nflowchart LR\n a --> b\n```\n" @@ -184,6 +185,23 @@ (let [{:keys [out]} (build-fixture-site! "/some-lib" {:assets? true})] (is (fs/exists? (io/file out "media" "x.gif"))))) +(deftest a-readme-homepage-points-asset-links-at-the-copied-directory + ;; A README's paths are right on GitHub (docs/media/x.gif from the repo + ;; root), but the build copies docs/media to /media, so the + ;; homepage rendered from the README needs the site's path. + (let [{:keys [home out]} + (build-fixture-site! "/some-lib" + {:readme? true :assets? true + :readme-text (str "# R\n\n

\n\n" + "![a](./docs/media/x.gif) [l](docs/media/x.gif) ![o](docs/other/y.png)\n")})] + (testing "raw HTML and markdown images, with or without ./, and links" + (is (= 2 (count (re-seq #"src=\"/some-lib/media/x\.gif\"" home)))) + (is (str/includes? home "href='/some-lib/media/x.gif'")) + (is (not (str/includes? home "docs/media/")))) + (testing "a path the build does not copy is left as written" + (is (str/includes? home "src=\"docs/other/y.png\""))) + (is (fs/exists? (io/file out "media" "x.gif"))))) + (deftest a-missing-asset-dir-warns-instead-of-failing-the-build ;; A project may generate assets from a pipeline that has not run in ;; this checkout. That should not take the docs down. diff --git a/test/site/markdown_test.clj b/test/site/markdown_test.clj index aed2d11..3901485 100644 --- a/test/site/markdown_test.clj +++ b/test/site/markdown_test.clj @@ -429,3 +429,31 @@ (testing "code spans were already escaped and are not escaped twice" (is (str/includes? (md/render-markdown "use `Vec` here\n") "Vec<T>")))) + +(deftest github-alerts-render-every-kind-as-a-callout + ;; GitHub draws `> [!WARNING]` as a titled callout; a plain blockquote + ;; showed the literal marker. + (are [marker kind title] + (let [html (md/render-markdown (str "> " marker "\n> **Bold** body\n"))] + (and (str/includes? html (str "
")) + (str/includes? html (str "

" title "

")) + (str/includes? html "Bold body") + (not (str/includes? html "[!")) + (not (str/includes? html "blockquote")))) + "[!NOTE]" "note" "Note" + "[!TIP]" "tip" "Tip" + "[!IMPORTANT]" "important" "Important" + "[!WARNING]" "warning" "Warning" + "[!CAUTION]" "caution" "Caution" + "[!note]" "note" "Note")) + +(deftest github-alerts-leave-other-quotes-and-code-alone + (testing "a blockquote without a marker stays a blockquote" + (is (str/includes? (md/render-markdown "> just a quote\n") "
"))) + (testing "an unknown marker stays a blockquote" + (is (str/includes? (md/render-markdown "> [!DANGER]\n> x\n") "[!DANGER]"))) + (testing "a marker inside a fenced code block is code, not an alert" + (let [html (md/render-markdown "```\n> [!NOTE]\n> x\n```\n")] + ;; markdown-clj writes the brackets in code as entities + (is (not (str/includes? html "markdown-alert"))) + (is (str/includes? html "
> [!NOTE]")))))