ssh git@grit.deadkey.org man

Commit 436b267 h

Pat Ambrosio · 26-08-25 23:43

Render diagram fences as diagrams, with a self-hosted renderer

Changes: 6 files +409 -5

Changes (Side-by-Side)

bin/jenny +1 -0

if [ -f "$LAYOUT_DIR/404.sh" ]; then
444 BLOG_HOST="$BLOG_HOST" \ 444 BLOG_HOST="$BLOG_HOST" \
445 BLOG_TITLE="$BLOG_TITLE" \ 445 BLOG_TITLE="$BLOG_TITLE" \
446 NAVS_DIR="${NAVS_DIR:-navs}" \ 446 NAVS_DIR="${NAVS_DIR:-navs}" \
447 DIAGRAM_SCRIPT="${DIAGRAM_SCRIPT:-}" \
447 SED="$SED" AWK="$AWK" DATE="$DATE" GREP="$GREP" \ 448 SED="$SED" AWK="$AWK" DATE="$DATE" GREP="$GREP" \
448 "$LAYOUT_DIR/404.sh" > "$DIST/404.html" 449 "$LAYOUT_DIR/404.sh" > "$DIST/404.html"
449 fi 450 fi

share/jenny/layout/page.sh +78 -0

page_nav() {
61 printf '</nav>\n' 61 printf '</nav>\n'
62 } 62 }
63 63
64 # diagram_script
65 #
66 # Emitted only when the rendered page actually contains a diagram. The
67 # renderer is a few megabytes; making every page pay for it so that the
68 # handful with diagrams work would be the wrong trade, and a page with no
69 # diagram has nothing for it to do.
70 #
71 # The script is served from this site, not a CDN. A diagram is content, and
72 # content that only renders while a third party keeps serving a file is not
73 # self-contained -- the same reason the fonts and the theme are local.
74 # DIAGRAM_SCRIPT points at the vendored copy; unset it and diagrams degrade
75 # to their source text, which stays readable because it is what the author
76 # wrote.
77 diagram_script() {
78 [ -n "${DIAGRAM_SCRIPT:-}" ] || return 0
79 case "${POST_CONTENTS:-}" in
80 *'class="diagram'*) ;;
81 *) return 0 ;;
82 esac
83 cat <<-_DIAGRAM_
84 <script src="${URL_PREFIX}${DIAGRAM_SCRIPT}"></script>
85 <script>
86 (function() {
87 if (typeof mermaid === 'undefined') return;
88 // The page theme is a CSS custom property set, and mermaid needs
89 // real values at init. Reading them from the document keeps one
90 // definition of the palette rather than a second copy here.
91 var cs = getComputedStyle(document.documentElement);
92 var v = function(n, fallback) {
93 var got = cs.getPropertyValue(n);
94 return (got && got.trim()) || fallback;
95 };
96 mermaid.initialize({
97 // The renderer scans for its own class name, and the parser
98 // emits a neutral "diagram" class so the markup does not name
99 // one particular renderer. Point it at what is actually on the
100 // page; startOnLoad is off because this script runs at the end
101 // of the body, after the load event it would wait for.
102 querySelector: '.diagram-mermaid',
103 startOnLoad: false,
104 securityLevel: 'strict',
105 theme: 'base',
106 themeVariables: {
107 background: v('--bg-primary', '#fff'),
108 primaryColor: v('--bg-secondary', '#f4f4f4'),
109 primaryTextColor: v('--text-primary', '#444'),
110 primaryBorderColor: v('--border-medium', '#999'),
111 lineColor: v('--text-muted', '#999'),
112 fontFamily: 'Roboto, sans-serif'
113 }
114 });
115 mermaid.run({ querySelector: '.diagram-mermaid' });
116 })();
117 </script>
118 _DIAGRAM_
119 }
120
64 page_has_nav() { 121 page_has_nav() {
65 [ -n "${PAGE_NAV:-}" ] 122 [ -n "${PAGE_NAV:-}" ]
66 } 123 }
cat << _EOF_
483 .home-spacer { display: none; } 540 .home-spacer { display: none; }
484 } 541 }
485 .contents :first-child { margin-top: 0; } 542 .contents :first-child { margin-top: 0; }
543 /* A diagram is a figure, not a paragraph: it takes the column's width
544 and scales to it rather than rendering at whatever size the renderer
545 picked. Renderers emit a fixed width/height on the svg, so both are
546 overridden -- width alone leaves the original height and distorts
547 it. Before this the diagram sat small against a 60ch column. */
548 .diagram { margin: 1.5em 0; overflow-x: auto; display: block; width: 100%; }
549 /* The renderer writes an inline max-width of the diagram's natural
550 size, which is what keeps a small diagram from being blown up to
551 the column width. That is the right default, so it is left alone;
552 what needed fixing was the container, which shrink-wrapped inside
553 the inline-block body column instead of taking its full width. */
554 .diagram svg { width: 100%; height: auto; display: block; }
555 /* Until the renderer runs -- or if it never does, because the script
556 is absent or blocked -- the element holds the diagram's source. Show
557 it as the code it is rather than as a run-together paragraph, so the
558 page degrades to something readable instead of a wall of text. */
559 .diagram:not(:has(svg)) { white-space: pre; font-family: 'Fira Mono', monospace;
560 font-size: .85em; color: var(--text-muted);
561 background-color: var(--bg-secondary);
562 padding: 1em; border-radius: 3px; }
486 h1, h2, h3, h4, h5, h6 { font-size: 1em; font-weight: normal; text-transform: uppercase; margin: 2em 0 1em; } 563 h1, h2, h3, h4, h5, h6 { font-size: 1em; font-weight: normal; text-transform: uppercase; margin: 2em 0 1em; }
487 ol, ul { padding-left: 1em; } 564 ol, ul { padding-left: 1em; }
488 /* Notes and references are two different lists, so each is labelled. 565 /* Notes and references are two different lists, so each is labelled.
cat << _EOF_
580 </div> 657 </div>
581 </div> 658 </div>
582 </article> 659 </article>
660 $(diagram_script)
583 </body> 661 </body>
584 </html> 662 </html>
585 _EOF_ 663 _EOF_

share/jenny/layout/post.sh +78 -0

1 #!/usr/bin/env bash 1 #!/usr/bin/env bash
2 set -euo pipefail 2 set -euo pipefail
3 3
4 # diagram_script
5 #
6 # Emitted only when the rendered page actually contains a diagram. The
7 # renderer is a few megabytes; making every page pay for it so that the
8 # handful with diagrams work would be the wrong trade, and a page with no
9 # diagram has nothing for it to do.
10 #
11 # The script is served from this site, not a CDN. A diagram is content, and
12 # content that only renders while a third party keeps serving a file is not
13 # self-contained -- the same reason the fonts and the theme are local.
14 # DIAGRAM_SCRIPT points at the vendored copy; unset it and diagrams degrade
15 # to their source text, which stays readable because it is what the author
16 # wrote.
17 diagram_script() {
18 [ -n "${DIAGRAM_SCRIPT:-}" ] || return 0
19 case "${POST_CONTENTS:-}" in
20 *'class="diagram'*) ;;
21 *) return 0 ;;
22 esac
23 cat <<-_DIAGRAM_
24 <script src="${URL_PREFIX}${DIAGRAM_SCRIPT}"></script>
25 <script>
26 (function() {
27 if (typeof mermaid === 'undefined') return;
28 // The page theme is a CSS custom property set, and mermaid needs
29 // real values at init. Reading them from the document keeps one
30 // definition of the palette rather than a second copy here.
31 var cs = getComputedStyle(document.documentElement);
32 var v = function(n, fallback) {
33 var got = cs.getPropertyValue(n);
34 return (got && got.trim()) || fallback;
35 };
36 mermaid.initialize({
37 // The renderer scans for its own class name, and the parser
38 // emits a neutral "diagram" class so the markup does not name
39 // one particular renderer. Point it at what is actually on the
40 // page; startOnLoad is off because this script runs at the end
41 // of the body, after the load event it would wait for.
42 querySelector: '.diagram-mermaid',
43 startOnLoad: false,
44 securityLevel: 'strict',
45 theme: 'base',
46 themeVariables: {
47 background: v('--bg-primary', '#fff'),
48 primaryColor: v('--bg-secondary', '#f4f4f4'),
49 primaryTextColor: v('--text-primary', '#444'),
50 primaryBorderColor: v('--border-medium', '#999'),
51 lineColor: v('--text-muted', '#999'),
52 fontFamily: 'Roboto, sans-serif'
53 }
54 });
55 mermaid.run({ querySelector: '.diagram-mermaid' });
56 })();
57 </script>
58 _DIAGRAM_
59 }
60
4 html_escape() { 61 html_escape() {
5 local s=$1 62 local s=$1
6 # The replacement text must escape &, because bash >= 5.2 expands a bare & 63 # The replacement text must escape &, because bash >= 5.2 expands a bare &
cat << _EOF_
198 .heading .stamp { color: var(--text-muted); } 255 .heading .stamp { color: var(--text-muted); }
199 .contents { display: inline-block; max-width: 60ch; vertical-align: top; width: 100%; } 256 .contents { display: inline-block; max-width: 60ch; vertical-align: top; width: 100%; }
200 .contents :first-child { margin-top: 0; } 257 .contents :first-child { margin-top: 0; }
258 /* A diagram is a figure, not a paragraph: it takes the column's width
259 and scales to it rather than rendering at whatever size the renderer
260 picked. Renderers emit a fixed width/height on the svg, so both are
261 overridden -- width alone leaves the original height and distorts
262 it. Before this the diagram sat small against a 60ch column. */
263 .diagram { margin: 1.5em 0; overflow-x: auto; display: block; width: 100%; }
264 /* The renderer writes an inline max-width of the diagram's natural
265 size, which is what keeps a small diagram from being blown up to
266 the column width. That is the right default, so it is left alone;
267 what needed fixing was the container, which shrink-wrapped inside
268 the inline-block body column instead of taking its full width. */
269 .diagram svg { width: 100%; height: auto; display: block; }
270 /* Until the renderer runs -- or if it never does, because the script
271 is absent or blocked -- the element holds the diagram's source. Show
272 it as the code it is rather than as a run-together paragraph, so the
273 page degrades to something readable instead of a wall of text. */
274 .diagram:not(:has(svg)) { white-space: pre; font-family: 'Fira Mono', monospace;
275 font-size: .85em; color: var(--text-muted);
276 background-color: var(--bg-secondary);
277 padding: 1em; border-radius: 3px; }
201 h1, h2, h3, h4, h5, h6 { font-size: 1em; font-weight: normal; text-transform: uppercase; margin: 2em 0 1em; } 278 h1, h2, h3, h4, h5, h6 { font-size: 1em; font-weight: normal; text-transform: uppercase; margin: 2em 0 1em; }
202 ol, ul { padding-left: 1em; } 279 ol, ul { padding-left: 1em; }
203 /* Notes and references are two different lists, so each is labelled. 280 /* Notes and references are two different lists, so each is labelled.
cat << _EOF_
289 <div class="tags">$(for i in $TAGS; do echo "<a href=\"../tag/$i/\">$(html_escape "$i")</a>"; done;)</div> 366 <div class="tags">$(for i in $TAGS; do echo "<a href=\"../tag/$i/\">$(html_escape "$i")</a>"; done;)</div>
290 </div> 367 </div>
291 </article> 368 </article>
369 $(diagram_script)
292 </body> 370 </body>
293 </html> 371 </html>
294 _EOF_ 372 _EOF_

share/jenny/lib/components.sh +3 -0

render () {
65 BLOG_HOST="$BLOG_HOST" \ 65 BLOG_HOST="$BLOG_HOST" \
66 BLOG_TITLE="$BLOG_TITLE" \ 66 BLOG_TITLE="$BLOG_TITLE" \
67 NAVS_DIR="${NAVS_DIR:-navs}" \ 67 NAVS_DIR="${NAVS_DIR:-navs}" \
68 DIAGRAM_SCRIPT="${DIAGRAM_SCRIPT:-}" \
68 TAGS="$tags" \ 69 TAGS="$tags" \
69 SED="$SED" AWK="$AWK" DATE="$DATE" GREP="$GREP" \ 70 SED="$SED" AWK="$AWK" DATE="$DATE" GREP="$GREP" \
70 "$LAYOUT_DIR/post.sh" > "$destination" 71 "$LAYOUT_DIR/post.sh" > "$destination"
render_index () {
228 BLOG_HOST="$BLOG_HOST" \ 229 BLOG_HOST="$BLOG_HOST" \
229 BLOG_TITLE="$BLOG_TITLE" \ 230 BLOG_TITLE="$BLOG_TITLE" \
230 NAVS_DIR="${NAVS_DIR:-navs}" \ 231 NAVS_DIR="${NAVS_DIR:-navs}" \
232 DIAGRAM_SCRIPT="${DIAGRAM_SCRIPT:-}" \
231 SED="$SED" AWK="$AWK" DATE="$DATE" GREP="$GREP" \ 233 SED="$SED" AWK="$AWK" DATE="$DATE" GREP="$GREP" \
232 "$LAYOUT_DIR/index.sh" > "$destination" 234 "$LAYOUT_DIR/index.sh" > "$destination"
233 } 235 }
render_page () {
439 BLOG_HOST="$BLOG_HOST" \ 441 BLOG_HOST="$BLOG_HOST" \
440 BLOG_TITLE="$BLOG_TITLE" \ 442 BLOG_TITLE="$BLOG_TITLE" \
441 NAVS_DIR="${NAVS_DIR:-navs}" \ 443 NAVS_DIR="${NAVS_DIR:-navs}" \
444 DIAGRAM_SCRIPT="${DIAGRAM_SCRIPT:-}" \
442 IS_PAGE=1 \ 445 IS_PAGE=1 \
443 TAGS="" \ 446 TAGS="" \
444 SED="$SED" AWK="$AWK" DATE="$DATE" GREP="$GREP" \ 447 SED="$SED" AWK="$AWK" DATE="$DATE" GREP="$GREP" \

share/jenny/lib/md2html.awk +25 -5

fence {
611 if(match($0, "^[ \t]*" fence_char "{" fence_len ",}[ \t]*$")){ 611 if(match($0, "^[ \t]*" fence_char "{" fence_len ",}[ \t]*$")){
612 if(fence_first) 612 if(fence_first)
613 # An empty fence: nothing was ever buffered. 613 # An empty fence: nothing was ever buffered.
614 oprint(fence_open "</code></pre>");
614 oprint(fence_open fence_close);
615 else 615 else
616 oprint(fence_held "</code></pre>");
616 oprint(fence_held fence_close);
617 fence = 0; 617 fence = 0;
618 fence_held = ""; 618 fence_held = "";
619 next; 619 next;
fence {
622 # Hold each line until the next one arrives, so the closing tag can be 622 # Hold each line until the next one arrives, so the closing tag can be
623 # joined to the last line rather than sitting on its own. 623 # joined to the last line rather than sitting on its own.
624 if(fence_first){ 624 if(fence_first){
625 fence_held = fence_open escfence($0);
625 fence_held = fence_open (fence_raw ? $0 : escfence($0));
626 fence_first = 0; 626 fence_first = 0;
627 } 627 }
628 else{ 628 else{
629 oprint(fence_held); 629 oprint(fence_held);
630 fence_held = escfence($0);
630 fence_held = (fence_raw ? $0 : escfence($0));
631 } 631 }
632 next; 632 next;
633 } 633 }
fence {
656 fence = 1; 656 fence = 1;
657 fence_first = 1; 657 fence_first = 1;
658 fence_re = "^[ \t]*" fence_char; 658 fence_re = "^[ \t]*" fence_char;
659 if(info != "")
659 # A diagram language is held for a client-side renderer rather than shown
660 # as source. The text goes in verbatim: mermaid reads the element's text
661 # content and parses it itself, so escaping "-->" to "--&gt;" would break
662 # every arrow in the diagram. That makes this the one place a fence's
663 # content is not escaped, which is safe only because the set of languages
664 # treated this way is fixed here -- an author cannot introduce a new one
665 # from a document.
666 #
667 # The language is kept as a class so a theme decides what to do with it.
668 # jenny renders no diagrams itself and loads nothing; a theme that wants
669 # them supplies the renderer.
670 fence_raw = 0;
671 fence_close = "</code></pre>";
672 lang = info;
673 sub(/[ \t].*$/, "", lang);
674 if(lang == "mermaid" || lang == "mermaid-legend"){
675 fence_raw = 1;
676 fence_close = "</div>";
677 fence_open = "<div class=\"diagram diagram-" eschtml(lang) "\">";
678 }
679 else if(info != "")
660 fence_open = "<pre><code class=\"language-" eschtml(info) "\">"; 680 fence_open = "<pre><code class=\"language-" eschtml(info) "\">";
661 else 681 else
662 fence_open = "<pre><code>"; 682 fence_open = "<pre><code>";

test/regressions.sh +224 -0

assert_contains "$(md_render 'A _em <b>x</b> more_ end.')" '<em>em <b>x</b> more
776 it "intraword underscores are still literal" 776 it "intraword underscores are still literal"
777 assert_contains "$(md_render 'Use node_modules and snake_case_name.')" 'node_modules and snake_case_name' 777 assert_contains "$(md_render 'Use node_modules and snake_case_name.')" 'node_modules and snake_case_name'
778 778
779 suite "diagrams"
780
781 # A diagram fence is held for a client-side renderer instead of being shown as
782 # source. jenny renders no diagrams itself and loads nothing by default: a
783 # theme supplies the renderer, and the site self-hosts it.
784
785 it "a mermaid fence becomes a diagram div"
786 assert_contains "$(md_render '```mermaid
787 flowchart LR
788 A --> B
789 ```')" '<div class="diagram diagram-mermaid">'
790
791 it "diagram source is not HTML-escaped"
792 # The renderer reads the element's text content and parses it, so escaping
793 # "-->" to "--&gt;" would break every arrow in the diagram. This is the one
794 # place a fence's content is left raw.
795 dia=$(md_render '```mermaid
796 flowchart LR
797 A["x<br/>y"] -->|"label"| B
798 ```')
799 assert_contains "$dia" '-->'
800 assert_not_contains "$dia" '--&gt;'
801 assert_contains "$dia" '<br/>'
802
803 it "the diagram fence closes with a div, not a code block"
804 assert_contains "$dia" '</div>'
805 assert_not_contains "$dia" '</code></pre>'
806
807 it "the legend variant keeps its own class"
808 assert_contains "$(md_render '```mermaid-legend
809 flowchart LR
810 A --> B
811 ```')" 'diagram-mermaid-legend'
812
813 it "an ordinary code fence is still escaped"
814 # Only the fixed set of diagram languages is raw; everything else escapes as
815 # before, so a document cannot introduce a new raw language.
816 code=$(md_render '```bash
817 echo "a" && b > c
818 ```')
819 assert_contains "$code" '&amp;&amp;'
820 assert_contains "$code" '<pre><code class="language-bash">'
821
822 it "a bare fence is still escaped"
823 assert_contains "$(md_render '```
824 x > y & z
825 ```')" '&gt;'
826
827 it "a language whose name merely starts with mermaid is not raw"
828 notmer=$(md_render '```mermaidish
829 a > b
830 ```')
831 assert_contains "$notmer" '<pre><code'
832 assert_not_contains "$notmer" '<div class="diagram'
833
834 suite "self-hosted diagram renderer"
835
836 dg="$TMPROOT/diagrams"
837 make_blog_min "$dg" 1 10
838 mkdir -p "$dg/pages"
839 printf '# Plain\n\nNo diagram here.\n' > "$dg/pages/plain.md"
840 printf '# Chart\n\n```mermaid\nflowchart LR\n A --> B\n```\n' > "$dg/pages/chart.md"
841
842 it "no renderer is loaded when DIAGRAM_SCRIPT is unset"
843 # jenny ships no renderer and pulls nothing from a CDN. Without the setting a
844 # diagram degrades to its source text, which stays readable.
845 run_jenny_split "$dg"
846 assert_file_not_contains "$dg/.dist/chart.html" '<script src='
847
848 it "the renderer is loaded on a page that has a diagram"
849 printf 'DIAGRAM_SCRIPT="/js/mermaid.min.js"\n' >> "$dg/.blogrc"
850 run_jenny_split "$dg"
851 assert_file_contains "$dg/.dist/chart.html" 'js/mermaid.min.js'
852
853 it "the renderer is not loaded on a page without one"
854 # The bundle is megabytes; every page paying for it so a few work would be the
855 # wrong trade.
856 assert_file_not_contains "$dg/.dist/plain.html" 'js/mermaid.min.js'
857
858 it "the renderer is pointed at the class the parser emits"
859 # The markup uses a neutral "diagram" class rather than naming one renderer,
860 # so the renderer has to be told what to look for. Left at its own default it
861 # found nothing and silently rendered no diagram at all.
862 assert_file_contains "$dg/.dist/chart.html" "querySelector: '.diagram-mermaid'"
863
864 it "the script path is resolved relative to the page"
865 mkdir -p "$dg/pages/deep"
866 printf '# Deep\n\n```mermaid\nflowchart LR\n A --> B\n```\n' > "$dg/pages/deep/d.md"
867 run_jenny_split "$dg"
868 assert_file_contains "$dg/.dist/deep/d.html" '"../js/mermaid.min.js"'
869
870 it "the build is clean"
871 assert_eq "$stderr_output" ""
872
873 suite "diagrams"
874
875 # A diagram fence is held for a client-side renderer instead of being shown as
876 # source. jenny ships no renderer and pulls nothing from a CDN -- these test
877 # the fence rule and the opt-in hook only; the bundle, if a site wants one, is
878 # the site's own file.
879
880 it "a mermaid fence becomes a diagram div"
881 assert_contains "$(md_render '```mermaid
882 flowchart LR
883 A --> B
884 ```')" '<div class="diagram diagram-mermaid">'
885
886 it "diagram source is not HTML-escaped"
887 # The renderer reads the element's text content and parses it, so escaping
888 # "-->" to "--&gt;" would break every arrow in the diagram. This is the one
889 # place a fence's content is left raw.
890 dia=$(md_render '```mermaid
891 flowchart LR
892 A["x<br/>y"] -->|"label"| B
893 ```')
894 assert_contains "$dia" '-->'
895 assert_not_contains "$dia" '--&gt;'
896 assert_contains "$dia" '<br/>'
897
898 it "the diagram fence closes with a div, not a code block"
899 assert_contains "$dia" '</div>'
900 assert_not_contains "$dia" '</code></pre>'
901
902 it "the legend variant keeps its own class"
903 assert_contains "$(md_render '```mermaid-legend
904 flowchart LR
905 A --> B
906 ```')" 'diagram-mermaid-legend'
907
908 it "an ordinary code fence is still escaped"
909 # Only the fixed set of diagram languages is raw; everything else escapes as
910 # before, so a document cannot introduce a new raw language.
911 code=$(md_render '```bash
912 echo "a" && b > c
913 ```')
914 assert_contains "$code" '&amp;&amp;'
915 assert_contains "$code" '<pre><code class="language-bash">'
916
917 it "a bare fence is still escaped"
918 assert_contains "$(md_render '```
919 x > y & z
920 ```')" '&gt;'
921
922 it "a language whose name merely starts with mermaid is not raw"
923 notmer=$(md_render '```mermaidish
924 a > b
925 ```')
926 assert_contains "$notmer" '<pre><code'
927 assert_not_contains "$notmer" '<div class="diagram'
928
929 suite "diagram renderer hook"
930
931 # DIAGRAM_SCRIPT names a script a site serves itself. jenny bundles no
932 # renderer: with the setting unset nothing is emitted, and the path here is a
933 # fixture, not any real file.
934
935 dg="$TMPROOT/diagrams"
936 make_blog_min "$dg" 1 10
937 mkdir -p "$dg/pages"
938 printf '# Plain\n\nNo diagram here.\n' > "$dg/pages/plain.md"
939 printf '# Chart\n\n```mermaid\nflowchart LR\n A --> B\n```\n' > "$dg/pages/chart.md"
940
941 it "nothing is loaded when DIAGRAM_SCRIPT is unset"
942 # The default: a diagram degrades to its source text, which stays readable
943 # because it is what the author wrote.
944 run_jenny_split "$dg"
945 assert_file_not_contains "$dg/.dist/chart.html" '<script src='
946
947 it "the script is emitted on a page that has a diagram"
948 printf 'DIAGRAM_SCRIPT="/js/renderer.js"\n' >> "$dg/.blogrc"
949 run_jenny_split "$dg"
950 assert_file_contains "$dg/.dist/chart.html" 'js/renderer.js'
951
952 it "the script is not emitted on a page without one"
953 # A renderer bundle is typically megabytes; every page paying for it so that a
954 # few work would be the wrong trade.
955 assert_file_not_contains "$dg/.dist/plain.html" 'js/renderer.js'
956
957 it "the renderer is pointed at the class the parser emits"
958 # The markup uses a neutral "diagram" class rather than naming one renderer,
959 # so the renderer has to be told what to look for. Left at its own default it
960 # matched nothing and silently drew no diagram.
961 assert_file_contains "$dg/.dist/chart.html" "querySelector: '.diagram-mermaid'"
962
963 it "the script path is resolved relative to the page"
964 mkdir -p "$dg/pages/deep"
965 printf '# Deep\n\n```mermaid\nflowchart LR\n A --> B\n```\n' > "$dg/pages/deep/d.md"
966 run_jenny_split "$dg"
967 assert_file_contains "$dg/.dist/deep/d.html" '"../js/renderer.js"'
968
969 it "posts get the same treatment as pages"
970 # The fence rule is in the parser, so it applies to every layout. A post that
971 # only got the div -- no renderer, no styling -- would show its diagram as
972 # run-together text.
973 printf '# Charted\n\n```mermaid\nflowchart LR\n A --> B\n```\n' > "$dg/$(date +%Y-%m-%d) charted.md"
974 run_jenny_split "$dg"
975 assert_file_contains "$dg/.dist/post/charted.html" '<div class="diagram diagram-mermaid">'
976 assert_file_contains "$dg/.dist/post/charted.html" 'js/renderer.js'
977
978 it "a post without a diagram loads no renderer"
979 post_plain=$(find "$dg/.dist/post" -name '*.html' ! -name 'charted.html' | head -1)
980 assert_file_not_contains "$post_plain" 'js/renderer.js'
981
982 it "both layouts carry the diagram styling"
983 # Lifted from one layout into the other rather than written twice, so they
984 # cannot drift.
985 assert_file_contains "$dg/.dist/post/charted.html" '.diagram svg { width: 100%'
986 assert_file_contains "$dg/.dist/chart.html" '.diagram svg { width: 100%'
987
988 it "the diagram box takes the body column, not its shrink-wrapped width"
989 # .contents is an inline-block, so a block inside it shrink-wraps to its
990 # content; the diagram sat small against a full-width column until the
991 # container was told to take the width.
992 assert_file_contains "$dg/.dist/chart.html" '.diagram { margin: 1.5em 0; overflow-x: auto; display: block; width: 100%; }'
993
994 it "an unrendered diagram degrades to readable source"
995 # If the script is absent or blocked, the element still holds what the author
996 # wrote. Shown as the code it is rather than as a paragraph with the newlines
997 # collapsed.
998 assert_file_contains "$dg/.dist/chart.html" '.diagram:not(:has(svg)) { white-space: pre;'
999
1000 it "the build is clean"
1001 assert_eq "$stderr_output" ""
1002
779 suite "MMD metadata end to end" 1003 suite "MMD metadata end to end"
780 1004
781 it "a metadata pair survives into the rendered post" 1005 it "a metadata pair survives into the rendered post"

Raw

commit 436b2674123f9ddf2324cb36f37a2b8515d0a6ca
Author: Pat Ambrosio <cp.ambrosio@gmail.com>
Date:   Tue Aug 25 23:43:53 2026 +0800

    Render diagram fences as diagrams, with a self-hosted renderer
    
    A mermaid fence used to render as a code block: the source survived, but
    the diagram did not. Two of mdd's methodology pages carry diagrams that
    are load-bearing for the text around them.
    
    The parser now emits <div class="diagram diagram-mermaid"> and holds the
    content verbatim. This is the one place a fence's text is not escaped,
    because the renderer reads the element's text content and parses it --
    escaping "-->" to "--&gt;" breaks every arrow. That is safe only because
    the set of languages treated this way is fixed in the parser: a document
    cannot introduce a new raw language. Everything else still escapes, and a
    language merely starting with "mermaid" is not raw.
    
    The class is neutral rather than named after one renderer, so a theme
    decides what to do with it. jenny bundles no renderer and loads nothing by
    default: DIAGRAM_SCRIPT names a script the site serves itself, and the
    layout emits it only on pages that actually contain a diagram -- a
    renderer bundle is megabytes, and every page paying for it so a handful
    work would be the wrong trade. Unset, a diagram degrades to its source
    text, styled as the code it is so it stays readable rather than collapsing
    into a paragraph.
    
    Posts get this too. The fence rule lives in the parser, so it applied to
    every layout immediately, but only the page layout had the script hook and
    the styling -- a post with a diagram would have shown run-together text.
    The helper and the CSS are lifted from one layout into the other rather
    than written twice.
    
    Three things measurement corrected along the way. The renderer scans for
    its own class name and found nothing, so it silently drew no diagram at
    all -- it is now pointed at the class the parser emits, and run directly
    rather than via startOnLoad, which waits for an event that has already
    fired by the time a script at the end of the body runs. The container
    shrink-wrapped inside the inline-block body column, leaving the diagram
    small against a full-width column; the container takes the width, while
    the renderer's own inline max-width is left alone because capping a small
    diagram at its natural size is right. And the mermaid theme is read from
    the page's CSS custom properties at init, so the palette has one
    definition rather than a second copy in the layout.
    
    The bundle itself is not vendored in jenny. deadkey serves mermaid 10.9.8
    from its own static/js, recorded with checksums in static/js/mermaid
    .VERSION -- a diagram is content, and content that renders only while a
    third party keeps serving a file is not self-contained.
    
    Tests: the fence rule (raw content, closing tag, the legend variant,
    ordinary and bare fences still escaped, near-miss language names), the
    hook (absent by default, present only where a diagram is, resolved
    relative to the page, pointed at the right class), both layouts carrying
    the styling, and the unrendered fallback. The hook tests use a fixture
    path, never a real bundle. Checked by mutation -- escaping the source,
    emitting the script unconditionally, dropping it from posts -- all caught.
    
    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01WnSAstgnDYdKPwLGv1YZE5

diff --git a/bin/jenny b/bin/jenny
index 6d9be0a..49a4bc8 100755
--- a/bin/jenny
+++ b/bin/jenny
@@ -444,6 +444,7 @@ if [ -f "$LAYOUT_DIR/404.sh" ]; then
   BLOG_HOST="$BLOG_HOST" \
     BLOG_TITLE="$BLOG_TITLE" \
     NAVS_DIR="${NAVS_DIR:-navs}" \
+    DIAGRAM_SCRIPT="${DIAGRAM_SCRIPT:-}" \
     SED="$SED" AWK="$AWK" DATE="$DATE" GREP="$GREP" \
     "$LAYOUT_DIR/404.sh" > "$DIST/404.html"
 fi
diff --git a/share/jenny/layout/page.sh b/share/jenny/layout/page.sh
index df3312d..fb0ebf1 100755
--- a/share/jenny/layout/page.sh
+++ b/share/jenny/layout/page.sh
@@ -61,6 +61,63 @@ page_nav() {
   printf '</nav>\n'
 }
 
+# diagram_script
+#
+# Emitted only when the rendered page actually contains a diagram. The
+# renderer is a few megabytes; making every page pay for it so that the
+# handful with diagrams work would be the wrong trade, and a page with no
+# diagram has nothing for it to do.
+#
+# The script is served from this site, not a CDN. A diagram is content, and
+# content that only renders while a third party keeps serving a file is not
+# self-contained -- the same reason the fonts and the theme are local.
+# DIAGRAM_SCRIPT points at the vendored copy; unset it and diagrams degrade
+# to their source text, which stays readable because it is what the author
+# wrote.
+diagram_script() {
+  [ -n "${DIAGRAM_SCRIPT:-}" ] || return 0
+  case "${POST_CONTENTS:-}" in
+    *'class="diagram'*) ;;
+    *) return 0 ;;
+  esac
+  cat <<-_DIAGRAM_
+	      <script src="${URL_PREFIX}${DIAGRAM_SCRIPT}"></script>
+	      <script>
+	        (function() {
+	          if (typeof mermaid === 'undefined') return;
+	          // The page theme is a CSS custom property set, and mermaid needs
+	          // real values at init. Reading them from the document keeps one
+	          // definition of the palette rather than a second copy here.
+	          var cs = getComputedStyle(document.documentElement);
+	          var v = function(n, fallback) {
+	            var got = cs.getPropertyValue(n);
+	            return (got && got.trim()) || fallback;
+	          };
+	          mermaid.initialize({
+	            // The renderer scans for its own class name, and the parser
+	            // emits a neutral "diagram" class so the markup does not name
+	            // one particular renderer. Point it at what is actually on the
+	            // page; startOnLoad is off because this script runs at the end
+	            // of the body, after the load event it would wait for.
+	            querySelector: '.diagram-mermaid',
+	            startOnLoad: false,
+	            securityLevel: 'strict',
+	            theme: 'base',
+	            themeVariables: {
+	              background:      v('--bg-primary', '#fff'),
+	              primaryColor:    v('--bg-secondary', '#f4f4f4'),
+	              primaryTextColor: v('--text-primary', '#444'),
+	              primaryBorderColor: v('--border-medium', '#999'),
+	              lineColor:       v('--text-muted', '#999'),
+	              fontFamily:      'Roboto, sans-serif'
+	            }
+	          });
+	          mermaid.run({ querySelector: '.diagram-mermaid' });
+	        })();
+	      </script>
+	_DIAGRAM_
+}
+
 page_has_nav() {
   [ -n "${PAGE_NAV:-}" ]
 }
@@ -483,6 +540,26 @@ cat << _EOF_
           .home-spacer { display: none; }
         }
         .contents :first-child { margin-top: 0; }
+        /* A diagram is a figure, not a paragraph: it takes the column's width
+           and scales to it rather than rendering at whatever size the renderer
+           picked. Renderers emit a fixed width/height on the svg, so both are
+           overridden -- width alone leaves the original height and distorts
+           it. Before this the diagram sat small against a 60ch column. */
+        .diagram { margin: 1.5em 0; overflow-x: auto; display: block; width: 100%; }
+        /* The renderer writes an inline max-width of the diagram's natural
+           size, which is what keeps a small diagram from being blown up to
+           the column width. That is the right default, so it is left alone;
+           what needed fixing was the container, which shrink-wrapped inside
+           the inline-block body column instead of taking its full width. */
+        .diagram svg { width: 100%; height: auto; display: block; }
+        /* Until the renderer runs -- or if it never does, because the script
+           is absent or blocked -- the element holds the diagram's source. Show
+           it as the code it is rather than as a run-together paragraph, so the
+           page degrades to something readable instead of a wall of text. */
+        .diagram:not(:has(svg)) { white-space: pre; font-family: 'Fira Mono', monospace;
+                                  font-size: .85em; color: var(--text-muted);
+                                  background-color: var(--bg-secondary);
+                                  padding: 1em; border-radius: 3px; }
         h1, h2, h3, h4, h5, h6 { font-size: 1em; font-weight: normal; text-transform: uppercase; margin: 2em 0 1em; }
         ol, ul { padding-left: 1em; }
         /* Notes and references are two different lists, so each is labelled.
@@ -580,6 +657,7 @@ cat << _EOF_
           </div>
         </div>
       </article>
+$(diagram_script)
     </body>
   </html>
 _EOF_
diff --git a/share/jenny/layout/post.sh b/share/jenny/layout/post.sh
index 8cd075b..364c237 100755
--- a/share/jenny/layout/post.sh
+++ b/share/jenny/layout/post.sh
@@ -1,6 +1,63 @@
 #!/usr/bin/env bash
 set -euo pipefail
 
+# diagram_script
+#
+# Emitted only when the rendered page actually contains a diagram. The
+# renderer is a few megabytes; making every page pay for it so that the
+# handful with diagrams work would be the wrong trade, and a page with no
+# diagram has nothing for it to do.
+#
+# The script is served from this site, not a CDN. A diagram is content, and
+# content that only renders while a third party keeps serving a file is not
+# self-contained -- the same reason the fonts and the theme are local.
+# DIAGRAM_SCRIPT points at the vendored copy; unset it and diagrams degrade
+# to their source text, which stays readable because it is what the author
+# wrote.
+diagram_script() {
+  [ -n "${DIAGRAM_SCRIPT:-}" ] || return 0
+  case "${POST_CONTENTS:-}" in
+    *'class="diagram'*) ;;
+    *) return 0 ;;
+  esac
+  cat <<-_DIAGRAM_
+	      <script src="${URL_PREFIX}${DIAGRAM_SCRIPT}"></script>
+	      <script>
+	        (function() {
+	          if (typeof mermaid === 'undefined') return;
+	          // The page theme is a CSS custom property set, and mermaid needs
+	          // real values at init. Reading them from the document keeps one
+	          // definition of the palette rather than a second copy here.
+	          var cs = getComputedStyle(document.documentElement);
+	          var v = function(n, fallback) {
+	            var got = cs.getPropertyValue(n);
+	            return (got && got.trim()) || fallback;
+	          };
+	          mermaid.initialize({
+	            // The renderer scans for its own class name, and the parser
+	            // emits a neutral "diagram" class so the markup does not name
+	            // one particular renderer. Point it at what is actually on the
+	            // page; startOnLoad is off because this script runs at the end
+	            // of the body, after the load event it would wait for.
+	            querySelector: '.diagram-mermaid',
+	            startOnLoad: false,
+	            securityLevel: 'strict',
+	            theme: 'base',
+	            themeVariables: {
+	              background:      v('--bg-primary', '#fff'),
+	              primaryColor:    v('--bg-secondary', '#f4f4f4'),
+	              primaryTextColor: v('--text-primary', '#444'),
+	              primaryBorderColor: v('--border-medium', '#999'),
+	              lineColor:       v('--text-muted', '#999'),
+	              fontFamily:      'Roboto, sans-serif'
+	            }
+	          });
+	          mermaid.run({ querySelector: '.diagram-mermaid' });
+	        })();
+	      </script>
+	_DIAGRAM_
+}
+
 html_escape() {
   local s=$1
   # The replacement text must escape &, because bash >= 5.2 expands a bare &
@@ -198,6 +255,26 @@ cat << _EOF_
         .heading .stamp { color: var(--text-muted); }
         .contents { display: inline-block; max-width: 60ch; vertical-align: top; width: 100%; }
         .contents :first-child { margin-top: 0; }
+        /* A diagram is a figure, not a paragraph: it takes the column's width
+           and scales to it rather than rendering at whatever size the renderer
+           picked. Renderers emit a fixed width/height on the svg, so both are
+           overridden -- width alone leaves the original height and distorts
+           it. Before this the diagram sat small against a 60ch column. */
+        .diagram { margin: 1.5em 0; overflow-x: auto; display: block; width: 100%; }
+        /* The renderer writes an inline max-width of the diagram's natural
+           size, which is what keeps a small diagram from being blown up to
+           the column width. That is the right default, so it is left alone;
+           what needed fixing was the container, which shrink-wrapped inside
+           the inline-block body column instead of taking its full width. */
+        .diagram svg { width: 100%; height: auto; display: block; }
+        /* Until the renderer runs -- or if it never does, because the script
+           is absent or blocked -- the element holds the diagram's source. Show
+           it as the code it is rather than as a run-together paragraph, so the
+           page degrades to something readable instead of a wall of text. */
+        .diagram:not(:has(svg)) { white-space: pre; font-family: 'Fira Mono', monospace;
+                                  font-size: .85em; color: var(--text-muted);
+                                  background-color: var(--bg-secondary);
+                                  padding: 1em; border-radius: 3px; }
         h1, h2, h3, h4, h5, h6 { font-size: 1em; font-weight: normal; text-transform: uppercase; margin: 2em 0 1em; }
         ol, ul { padding-left: 1em; }
         /* Notes and references are two different lists, so each is labelled.
@@ -289,6 +366,7 @@ cat << _EOF_
         <div class="tags">$(for i in $TAGS; do echo "<a href=\"../tag/$i/\">$(html_escape "$i")</a>"; done;)</div>
         </div>
       </article>
+$(diagram_script)
     </body>
   </html>
 _EOF_
diff --git a/share/jenny/lib/components.sh b/share/jenny/lib/components.sh
index 70e580f..ee1b4c3 100644
--- a/share/jenny/lib/components.sh
+++ b/share/jenny/lib/components.sh
@@ -65,6 +65,7 @@ render () {
     BLOG_HOST="$BLOG_HOST" \
     BLOG_TITLE="$BLOG_TITLE" \
     NAVS_DIR="${NAVS_DIR:-navs}" \
+    DIAGRAM_SCRIPT="${DIAGRAM_SCRIPT:-}" \
     TAGS="$tags" \
     SED="$SED" AWK="$AWK" DATE="$DATE" GREP="$GREP" \
     "$LAYOUT_DIR/post.sh" > "$destination"
@@ -228,6 +229,7 @@ render_index () {
     BLOG_HOST="$BLOG_HOST" \
     BLOG_TITLE="$BLOG_TITLE" \
     NAVS_DIR="${NAVS_DIR:-navs}" \
+    DIAGRAM_SCRIPT="${DIAGRAM_SCRIPT:-}" \
     SED="$SED" AWK="$AWK" DATE="$DATE" GREP="$GREP" \
     "$LAYOUT_DIR/index.sh" > "$destination"
 }
@@ -439,6 +441,7 @@ render_page () {
     BLOG_HOST="$BLOG_HOST" \
     BLOG_TITLE="$BLOG_TITLE" \
     NAVS_DIR="${NAVS_DIR:-navs}" \
+    DIAGRAM_SCRIPT="${DIAGRAM_SCRIPT:-}" \
     IS_PAGE=1 \
     TAGS="" \
     SED="$SED" AWK="$AWK" DATE="$DATE" GREP="$GREP" \
diff --git a/share/jenny/lib/md2html.awk b/share/jenny/lib/md2html.awk
index 5b0a8c1..4c85b16 100755
--- a/share/jenny/lib/md2html.awk
+++ b/share/jenny/lib/md2html.awk
@@ -611,9 +611,9 @@ fence {
 		if(match($0, "^[ \t]*" fence_char "{" fence_len ",}[ \t]*$")){
 			if(fence_first)
 				# An empty fence: nothing was ever buffered.
-				oprint(fence_open "</code></pre>");
+				oprint(fence_open fence_close);
 			else
-				oprint(fence_held "</code></pre>");
+				oprint(fence_held fence_close);
 			fence = 0;
 			fence_held = "";
 			next;
@@ -622,12 +622,12 @@ fence {
 	# Hold each line until the next one arrives, so the closing tag can be
 	# joined to the last line rather than sitting on its own.
 	if(fence_first){
-		fence_held = fence_open escfence($0);
+		fence_held = fence_open (fence_raw ? $0 : escfence($0));
 		fence_first = 0;
 	}
 	else{
 		oprint(fence_held);
-		fence_held = escfence($0);
+		fence_held = (fence_raw ? $0 : escfence($0));
 	}
 	next;
 }
@@ -656,7 +656,27 @@ fence {
 	fence = 1;
 	fence_first = 1;
 	fence_re = "^[ \t]*" fence_char;
-	if(info != "")
+	# A diagram language is held for a client-side renderer rather than shown
+	# as source. The text goes in verbatim: mermaid reads the element's text
+	# content and parses it itself, so escaping "-->" to "--&gt;" would break
+	# every arrow in the diagram. That makes this the one place a fence's
+	# content is not escaped, which is safe only because the set of languages
+	# treated this way is fixed here -- an author cannot introduce a new one
+	# from a document.
+	#
+	# The language is kept as a class so a theme decides what to do with it.
+	# jenny renders no diagrams itself and loads nothing; a theme that wants
+	# them supplies the renderer.
+	fence_raw = 0;
+	fence_close = "</code></pre>";
+	lang = info;
+	sub(/[ \t].*$/, "", lang);
+	if(lang == "mermaid" || lang == "mermaid-legend"){
+		fence_raw = 1;
+		fence_close = "</div>";
+		fence_open = "<div class=\"diagram diagram-" eschtml(lang) "\">";
+	}
+	else if(info != "")
 		fence_open = "<pre><code class=\"language-" eschtml(info) "\">";
 	else
 		fence_open = "<pre><code>";
diff --git a/test/regressions.sh b/test/regressions.sh
index 72f767b..8915343 100755
--- a/test/regressions.sh
+++ b/test/regressions.sh
@@ -776,6 +776,230 @@ assert_contains "$(md_render 'A _em <b>x</b> more_ end.')" '<em>em <b>x</b> more
 it "intraword underscores are still literal"
 assert_contains "$(md_render 'Use node_modules and snake_case_name.')" 'node_modules and snake_case_name'
 
+suite "diagrams"
+
+# A diagram fence is held for a client-side renderer instead of being shown as
+# source. jenny renders no diagrams itself and loads nothing by default: a
+# theme supplies the renderer, and the site self-hosts it.
+
+it "a mermaid fence becomes a diagram div"
+assert_contains "$(md_render '```mermaid
+flowchart LR
+  A --> B
+```')" '<div class="diagram diagram-mermaid">'
+
+it "diagram source is not HTML-escaped"
+# The renderer reads the element's text content and parses it, so escaping
+# "-->" to "--&gt;" would break every arrow in the diagram. This is the one
+# place a fence's content is left raw.
+dia=$(md_render '```mermaid
+flowchart LR
+  A["x<br/>y"] -->|"label"| B
+```')
+assert_contains "$dia" '-->'
+assert_not_contains "$dia" '--&gt;'
+assert_contains "$dia" '<br/>'
+
+it "the diagram fence closes with a div, not a code block"
+assert_contains "$dia" '</div>'
+assert_not_contains "$dia" '</code></pre>'
+
+it "the legend variant keeps its own class"
+assert_contains "$(md_render '```mermaid-legend
+flowchart LR
+  A --> B
+```')" 'diagram-mermaid-legend'
+
+it "an ordinary code fence is still escaped"
+# Only the fixed set of diagram languages is raw; everything else escapes as
+# before, so a document cannot introduce a new raw language.
+code=$(md_render '```bash
+echo "a" && b > c
+```')
+assert_contains "$code" '&amp;&amp;'
+assert_contains "$code" '<pre><code class="language-bash">'
+
+it "a bare fence is still escaped"
+assert_contains "$(md_render '```
+x > y & z
+```')" '&gt;'
+
+it "a language whose name merely starts with mermaid is not raw"
+notmer=$(md_render '```mermaidish
+a > b
+```')
+assert_contains "$notmer" '<pre><code'
+assert_not_contains "$notmer" '<div class="diagram'
+
+suite "self-hosted diagram renderer"
+
+dg="$TMPROOT/diagrams"
+make_blog_min "$dg" 1 10
+mkdir -p "$dg/pages"
+printf '# Plain\n\nNo diagram here.\n' > "$dg/pages/plain.md"
+printf '# Chart\n\n```mermaid\nflowchart LR\n  A --> B\n```\n' > "$dg/pages/chart.md"
+
+it "no renderer is loaded when DIAGRAM_SCRIPT is unset"
+# jenny ships no renderer and pulls nothing from a CDN. Without the setting a
+# diagram degrades to its source text, which stays readable.
+run_jenny_split "$dg"
+assert_file_not_contains "$dg/.dist/chart.html" '<script src='
+
+it "the renderer is loaded on a page that has a diagram"
+printf 'DIAGRAM_SCRIPT="/js/mermaid.min.js"\n' >> "$dg/.blogrc"
+run_jenny_split "$dg"
+assert_file_contains "$dg/.dist/chart.html" 'js/mermaid.min.js'
+
+it "the renderer is not loaded on a page without one"
+# The bundle is megabytes; every page paying for it so a few work would be the
+# wrong trade.
+assert_file_not_contains "$dg/.dist/plain.html" 'js/mermaid.min.js'
+
+it "the renderer is pointed at the class the parser emits"
+# The markup uses a neutral "diagram" class rather than naming one renderer,
+# so the renderer has to be told what to look for. Left at its own default it
+# found nothing and silently rendered no diagram at all.
+assert_file_contains "$dg/.dist/chart.html" "querySelector: '.diagram-mermaid'"
+
+it "the script path is resolved relative to the page"
+mkdir -p "$dg/pages/deep"
+printf '# Deep\n\n```mermaid\nflowchart LR\n  A --> B\n```\n' > "$dg/pages/deep/d.md"
+run_jenny_split "$dg"
+assert_file_contains "$dg/.dist/deep/d.html" '"../js/mermaid.min.js"'
+
+it "the build is clean"
+assert_eq "$stderr_output" ""
+
+suite "diagrams"
+
+# A diagram fence is held for a client-side renderer instead of being shown as
+# source. jenny ships no renderer and pulls nothing from a CDN -- these test
+# the fence rule and the opt-in hook only; the bundle, if a site wants one, is
+# the site's own file.
+
+it "a mermaid fence becomes a diagram div"
+assert_contains "$(md_render '```mermaid
+flowchart LR
+  A --> B
+```')" '<div class="diagram diagram-mermaid">'
+
+it "diagram source is not HTML-escaped"
+# The renderer reads the element's text content and parses it, so escaping
+# "-->" to "--&gt;" would break every arrow in the diagram. This is the one
+# place a fence's content is left raw.
+dia=$(md_render '```mermaid
+flowchart LR
+  A["x<br/>y"] -->|"label"| B
+```')
+assert_contains "$dia" '-->'
+assert_not_contains "$dia" '--&gt;'
+assert_contains "$dia" '<br/>'
+
+it "the diagram fence closes with a div, not a code block"
+assert_contains "$dia" '</div>'
+assert_not_contains "$dia" '</code></pre>'
+
+it "the legend variant keeps its own class"
+assert_contains "$(md_render '```mermaid-legend
+flowchart LR
+  A --> B
+```')" 'diagram-mermaid-legend'
+
+it "an ordinary code fence is still escaped"
+# Only the fixed set of diagram languages is raw; everything else escapes as
+# before, so a document cannot introduce a new raw language.
+code=$(md_render '```bash
+echo "a" && b > c
+```')
+assert_contains "$code" '&amp;&amp;'
+assert_contains "$code" '<pre><code class="language-bash">'
+
+it "a bare fence is still escaped"
+assert_contains "$(md_render '```
+x > y & z
+```')" '&gt;'
+
+it "a language whose name merely starts with mermaid is not raw"
+notmer=$(md_render '```mermaidish
+a > b
+```')
+assert_contains "$notmer" '<pre><code'
+assert_not_contains "$notmer" '<div class="diagram'
+
+suite "diagram renderer hook"
+
+# DIAGRAM_SCRIPT names a script a site serves itself. jenny bundles no
+# renderer: with the setting unset nothing is emitted, and the path here is a
+# fixture, not any real file.
+
+dg="$TMPROOT/diagrams"
+make_blog_min "$dg" 1 10
+mkdir -p "$dg/pages"
+printf '# Plain\n\nNo diagram here.\n' > "$dg/pages/plain.md"
+printf '# Chart\n\n```mermaid\nflowchart LR\n  A --> B\n```\n' > "$dg/pages/chart.md"
+
+it "nothing is loaded when DIAGRAM_SCRIPT is unset"
+# The default: a diagram degrades to its source text, which stays readable
+# because it is what the author wrote.
+run_jenny_split "$dg"
+assert_file_not_contains "$dg/.dist/chart.html" '<script src='
+
+it "the script is emitted on a page that has a diagram"
+printf 'DIAGRAM_SCRIPT="/js/renderer.js"\n' >> "$dg/.blogrc"
+run_jenny_split "$dg"
+assert_file_contains "$dg/.dist/chart.html" 'js/renderer.js'
+
+it "the script is not emitted on a page without one"
+# A renderer bundle is typically megabytes; every page paying for it so that a
+# few work would be the wrong trade.
+assert_file_not_contains "$dg/.dist/plain.html" 'js/renderer.js'
+
+it "the renderer is pointed at the class the parser emits"
+# The markup uses a neutral "diagram" class rather than naming one renderer,
+# so the renderer has to be told what to look for. Left at its own default it
+# matched nothing and silently drew no diagram.
+assert_file_contains "$dg/.dist/chart.html" "querySelector: '.diagram-mermaid'"
+
+it "the script path is resolved relative to the page"
+mkdir -p "$dg/pages/deep"
+printf '# Deep\n\n```mermaid\nflowchart LR\n  A --> B\n```\n' > "$dg/pages/deep/d.md"
+run_jenny_split "$dg"
+assert_file_contains "$dg/.dist/deep/d.html" '"../js/renderer.js"'
+
+it "posts get the same treatment as pages"
+# The fence rule is in the parser, so it applies to every layout. A post that
+# only got the div -- no renderer, no styling -- would show its diagram as
+# run-together text.
+printf '# Charted\n\n```mermaid\nflowchart LR\n  A --> B\n```\n' > "$dg/$(date +%Y-%m-%d) charted.md"
+run_jenny_split "$dg"
+assert_file_contains "$dg/.dist/post/charted.html" '<div class="diagram diagram-mermaid">'
+assert_file_contains "$dg/.dist/post/charted.html" 'js/renderer.js'
+
+it "a post without a diagram loads no renderer"
+post_plain=$(find "$dg/.dist/post" -name '*.html' ! -name 'charted.html' | head -1)
+assert_file_not_contains "$post_plain" 'js/renderer.js'
+
+it "both layouts carry the diagram styling"
+# Lifted from one layout into the other rather than written twice, so they
+# cannot drift.
+assert_file_contains "$dg/.dist/post/charted.html" '.diagram svg { width: 100%'
+assert_file_contains "$dg/.dist/chart.html" '.diagram svg { width: 100%'
+
+it "the diagram box takes the body column, not its shrink-wrapped width"
+# .contents is an inline-block, so a block inside it shrink-wraps to its
+# content; the diagram sat small against a full-width column until the
+# container was told to take the width.
+assert_file_contains "$dg/.dist/chart.html" '.diagram { margin: 1.5em 0; overflow-x: auto; display: block; width: 100%; }'
+
+it "an unrendered diagram degrades to readable source"
+# If the script is absent or blocked, the element still holds what the author
+# wrote. Shown as the code it is rather than as a paragraph with the newlines
+# collapsed.
+assert_file_contains "$dg/.dist/chart.html" '.diagram:not(:has(svg)) { white-space: pre;'
+
+it "the build is clean"
+assert_eq "$stderr_output" ""
+
 suite "MMD metadata end to end"
 
 it "a metadata pair survives into the rendered post"