Commit 436b267 h
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 "-->" 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 "-->" 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" '-->' |
||
| 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" '&&' |
||
| 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 | ```')" '>' |
||
| 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 "-->" 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" '-->' |
||
| 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" '&&' |
||
| 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 | ```')" '>' |
||
| 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 "-->" 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 "-->" 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 "-->" 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" '-->'
+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" '&&'
+assert_contains "$code" '<pre><code class="language-bash">'
+
+it "a bare fence is still escaped"
+assert_contains "$(md_render '```
+x > y & z
+```')" '>'
+
+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 "-->" 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" '-->'
+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" '&&'
+assert_contains "$code" '<pre><code class="language-bash">'
+
+it "a bare fence is still escaped"
+assert_contains "$(md_render '```
+x > y & z
+```')" '>'
+
+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"