From cacb76e7768f23348341c1e0b9113652203f2e2a Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Tue, 2 Jun 2026 23:21:36 +0200 Subject: [PATCH 1/5] Rename the build & deploy workflow to tbdocs-gh-pages. --- .github/workflows/{jekyll-gh-pages.yml => tbdocs-gh-pages.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{jekyll-gh-pages.yml => tbdocs-gh-pages.yml} (100%) diff --git a/.github/workflows/jekyll-gh-pages.yml b/.github/workflows/tbdocs-gh-pages.yml similarity index 100% rename from .github/workflows/jekyll-gh-pages.yml rename to .github/workflows/tbdocs-gh-pages.yml From 2277dc808bc525ed1ecdd098db77e4884c47cc92 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Tue, 2 Jun 2026 23:57:39 +0200 Subject: [PATCH 2/5] Plan phase 13: Inline SVG embedding. --- builder/PLAN-13.md | 486 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 486 insertions(+) create mode 100644 builder/PLAN-13.md diff --git a/builder/PLAN-13.md b/builder/PLAN-13.md new file mode 100644 index 00000000..fe849663 --- /dev/null +++ b/builder/PLAN-13.md @@ -0,0 +1,486 @@ +## PLAN-13: Phase 13 --- Inline SVG embedding with zoom / export controls + +Replaces `` rendering with build-time SVG inlining, +and unifies the gantt-chart injection with the same pipeline. Every +`.svg` image referenced from markdown (including `.dot`-generated +diagrams) is read from disk at render time, embedded directly in the +page HTML, and wrapped in a controls bar (Download SVG, Copy SVG, +Download PNG, Copy PNG) with a click-to-zoom fullscreen overlay. + +The gantt chart on the BuildInfo page flows through the same +markdown-it plugin as every other SVG. Because the gantt's timing +data is not available until after the build completes, the plugin +inlines a committed placeholder SVG; a minimal post-build step +substitutes the real content into the already-written HTML. The +wrapper markup, controls, zoom wiring, and JS are identical --- +the only difference is *when* the SVG content arrives. + +What Phase 13 does NOT do: + +- Add dark-mode colour support for Graphviz `.dot` diagrams. The + gantt SVG already carries `html.dark-mode` CSS rules that work + because it is inline; the `.dot` SVGs generated by + `@hpcc-js/wasm-graphviz` do not. Adding dark-mode classes to + `.dot` source files is a separate concern. +- Change the existing `.dot` → `.svg` regeneration pipeline + (`dot.mjs`). Diagrams are still rendered from `.dot` source by + WASM Graphviz; this plan only changes how the resulting `.svg` + files are embedded in pages. +- Touch the offline or PDF build passes. Inline SVGs are already + part of the page HTML by the time those passes run; no + rewriting is needed. +- Add new npm dependencies. + +Target wall-clock impact: negligible. The three `.dot` SVGs +total ~30 KB; reading them adds < 1 ms to the dispatch task. The +per-page render cost is unchanged (string concatenation vs. +`` tag emission). + +--- + +## 1. Current state + +SVG images in markdown (`![alt](/assets/images/dot/foo.svg)`) render +as ``. The browser makes a +separate HTTP request per SVG. Page CSS cannot reach the SVG +internals (dark-mode styling, link colours, etc.). No zoom or +download controls exist. + +The gantt chart on the BuildInfo page has a separate code path: +`injectGanttChart()` in `tbdocs.mjs` (lines 756--816) runs after +the build completes and patches the written HTML, injecting the +inline SVG, four control links, a PNG-export function, and a +click-to-zoom script. This is ~60 lines of self-contained HTML / +JS generation that duplicates the pattern every future SVG would need. + +--- + +## 2. Architecture + +### 2.1. Data flow: SVG contents through the pipeline + +``` + dot.mjs (seed) dispatch (main thread) + ┌─────────────────┐ ┌──────────────────────────┐ + │ .dot → .svg │──staticFiles──▸ │ reads .svg file contents │ + │ regeneration │ │ into svgContentsMap │ + └─────────────────┘ │ packs into sharedSAB │ + └────────────┬─────────────┘ + docs/assets/images/gantt.svg │ + (committed placeholder) │ + ───── also in staticFiles ────────────────────────┘ + │ + ┌──────────────────────┘ + ▼ + ┌──────────────────────────────────┐ + │ cpu-worker renderEnvInit │ + │ unpackShared → svgContentsMap │ + │ createMarkdownIt({ ..., │ + │ svgContents }) │ + └──────────────┬───────────────────┘ + ▼ + ┌──────────────────────────────────┐ + │ svgInlinePlugin (image renderer) │ + │ • src ends in .svg? │ + │ • content in ctx.svgContents? │ + │ → emit wrapper + inline SVG │ + │ → set page.hasSvg = true │ + └──────────────┬───────────────────┘ + ▼ + ┌──────────────────────────────────┐ + │ templatePhase / renderHead │ + │ page.hasSvg → include │ + │ svg-inline.js \n` + : "") +``` + +`defer` keeps the load non-blocking, same as `theme-switch.js`. + +### 5.4. `svg-inline.js` + +New file at `docs/assets/js/svg-inline.js`. ~60 lines, no +dependencies, IIFE. + +Behaviours: + +1. **Print-hide CSS injection**. On load, inject a `
${downloadLink}${copyLink}${downloadPng}${copyPng}
`; - const zoomScript = [ - `\n` : "") + (bu ? ` \n` : "") + ` \n` + + (page.hasSvg ? ` \n` : "") + ` \n` + headSeoBlock(page, site) + init.faviconLink + diff --git a/docs/Documentation/BuildInfo.md b/docs/Documentation/BuildInfo.md index f1c4d185..982e7243 100644 --- a/docs/Documentation/BuildInfo.md +++ b/docs/Documentation/BuildInfo.md @@ -11,4 +11,4 @@ permalink: /Documentation/Development/BuildInfo Gantt chart of this build's task timeline. - +![Build task timeline](/assets/images/gantt.svg) diff --git a/docs/assets/images/dot/pdf-render-pipeline.dot b/docs/assets/images/dot/pdf-render-pipeline.dot index dc79c706..6af77624 100644 --- a/docs/assets/images/dot/pdf-render-pipeline.dot +++ b/docs/assets/images/dot/pdf-render-pipeline.dot @@ -26,10 +26,10 @@ digraph pdf_render { fontsize=13 style="rounded" labeljust="l" - margin=12 ] subgraph cluster_writePdf { + margin=12 label="tbdocs writePdf (pdf.mjs + book.mjs)" ASM [label=combine chapter HTML>] CSS [label=print.css + tb-highlight.css>] @@ -40,6 +40,7 @@ digraph pdf_render { PDFSRC [label=<_site-pdf/book.html
(~5 MB)
print.css, tb-highlight.css,
images>] subgraph cluster_render { + margin=12 label="render-book.mjs" R1 [label=puppeteer
→ headless Chromium
paged.js
CSS Paged Media layout
→ one .pagedjs_page
per output page>] R2 [label=extract metadata
+ outline tree
page.pdf() → raw PDF buffer>] diff --git a/docs/assets/images/dot/pdf-render-pipeline.svg b/docs/assets/images/dot/pdf-render-pipeline.svg index 270dc8eb..ed9cd551 100644 --- a/docs/assets/images/dot/pdf-render-pipeline.svg +++ b/docs/assets/images/dot/pdf-render-pipeline.svg @@ -4,9 +4,9 @@ - - + + pdf_render cluster_writePdf diff --git a/docs/assets/images/gantt.svg b/docs/assets/images/gantt.svg new file mode 100644 index 00000000..b758af92 --- /dev/null +++ b/docs/assets/images/gantt.svg @@ -0,0 +1,3 @@ + + Build task timeline + diff --git a/docs/assets/js/svg-inline.js b/docs/assets/js/svg-inline.js new file mode 100644 index 00000000..3121e088 --- /dev/null +++ b/docs/assets/js/svg-inline.js @@ -0,0 +1,90 @@ +(function () { + var style = document.createElement("style"); + style.textContent = + "@media print { .svg-controls { display: none } }" + + ".svg-controls { overflow: hidden; font-size: 0.85em }" + + ".svg-controls a { float: right; margin-left: 1em }" + + ".svg-container svg { width: 100%; height: auto }"; + document.head.appendChild(style); + + document.addEventListener("click", function (e) { + var container = e.target.closest(".svg-container"); + if (!container) return; + if (e.target.closest(".svg-controls")) return; + e.preventDefault(); + var svg = container.querySelector("svg"); + if (container.dataset.zoomed) { + container.removeAttribute("style"); + container.style.cursor = "zoom-in"; + if (svg) svg.style.maxWidth = ""; + delete container.dataset.zoomed; + } else { + var bg = getComputedStyle(document.body).backgroundColor; + Object.assign(container.style, { + position: "fixed", top: "0", left: "0", width: "100vw", height: "100vh", + zIndex: "9999", background: bg, padding: "1rem", boxSizing: "border-box", + overflow: "auto", cursor: "zoom-out" + }); + if (svg) svg.style.maxWidth = "100%"; + container.dataset.zoomed = "1"; + container.scrollTop = 0; + } + }); + + document.addEventListener("keydown", function (e) { + if (e.key !== "Escape") return; + var zoomed = document.querySelector(".svg-container[data-zoomed]"); + if (!zoomed) return; + zoomed.removeAttribute("style"); + zoomed.style.cursor = "zoom-in"; + var svg = zoomed.querySelector("svg"); + if (svg) svg.style.maxWidth = ""; + delete zoomed.dataset.zoomed; + }, { capture: true }); + + document.addEventListener("click", function (e) { + var link = e.target.closest(".svg-controls a[data-action]"); + if (!link) return; + e.preventDefault(); + var wrap = link.closest(".svg-inline-wrap"); + var svg = wrap && wrap.querySelector(".svg-container > svg"); + if (!svg) return; + var action = link.dataset.action; + var filename = link.dataset.filename || "diagram"; + + if (action === "download-svg") { + triggerDownload( + new Blob([new XMLSerializer().serializeToString(svg)], + { type: "image/svg+xml;charset=utf-8" }), + filename + ".svg"); + } else if (action === "copy-svg") { + navigator.clipboard.writeText(svg.outerHTML); + } else if (action === "download-png" || action === "copy-png") { + var data = new XMLSerializer().serializeToString(svg); + var blob = new Blob([data], { type: "image/svg+xml;charset=utf-8" }); + var url = URL.createObjectURL(blob); + var img = new Image(); + img.onload = function () { + var vb = svg.viewBox.baseVal; + var w = 2048, h = Math.round(vb.height * (w / vb.width)); + var c = document.createElement("canvas"); + c.width = w; c.height = h; + c.getContext("2d").drawImage(img, 0, 0, w, h); + URL.revokeObjectURL(url); + c.toBlob(function (b) { + if (action === "download-png") triggerDownload(b, filename + ".png"); + else navigator.clipboard.write([new ClipboardItem({ "image/png": b })]); + }, "image/png"); + }; + img.src = url; + } + }); + + function triggerDownload(blob, name) { + var a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = name; + a.click(); + URL.revokeObjectURL(a.href); + } +})(); From 4b5f6d62e1f79318f2147196f755948f67768bea Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Wed, 3 Jun 2026 00:47:42 +0200 Subject: [PATCH 4/5] Sort out styling for inline SVGs. --- docs/_sass/custom/custom.scss | 17 +++++++++++++++++ docs/assets/css/print.css | 7 +++++++ docs/assets/js/svg-inline.js | 8 -------- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/docs/_sass/custom/custom.scss b/docs/_sass/custom/custom.scss index 352e21b4..984debdd 100644 --- a/docs/_sass/custom/custom.scss +++ b/docs/_sass/custom/custom.scss @@ -1,5 +1,22 @@ @use "admonitions"; +// Inline SVG diagrams (Phase 13). Controls are float-right links above +// the diagram; the SVG fills the column width. +.svg-controls { + overflow: hidden; + font-size: 0.85em; + + a { + float: right; + margin-left: 1em; + } +} + +.svg-container svg { + width: 100%; + height: auto; +} + .site-logo { padding-right: 3rem; } diff --git a/docs/assets/css/print.css b/docs/assets/css/print.css index e8d39239..c3fa63b4 100644 --- a/docs/assets/css/print.css +++ b/docs/assets/css/print.css @@ -17,6 +17,13 @@ book.html too. Hide it for the PDF render path. */ button.copy-code { display: none; } +/* SVG inline controls are interactive chrome (zoom, download, copy); + hide them in the PDF. Constrain the SVG to the page width and + scale text down so diagram labels don't dominate the page. */ +.svg-controls { display: none; } +.svg-container svg { max-width: 100%; height: auto; } +.svg-container text { font-size: 75%; } + @page { size: A4; diff --git a/docs/assets/js/svg-inline.js b/docs/assets/js/svg-inline.js index 3121e088..4455c12a 100644 --- a/docs/assets/js/svg-inline.js +++ b/docs/assets/js/svg-inline.js @@ -1,12 +1,4 @@ (function () { - var style = document.createElement("style"); - style.textContent = - "@media print { .svg-controls { display: none } }" + - ".svg-controls { overflow: hidden; font-size: 0.85em }" + - ".svg-controls a { float: right; margin-left: 1em }" + - ".svg-container svg { width: 100%; height: auto }"; - document.head.appendChild(style); - document.addEventListener("click", function (e) { var container = e.target.closest(".svg-container"); if (!container) return; From 5c1bdfd592c49157e08ffa5ea7200f581c2e6e19 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Wed, 3 Jun 2026 01:11:14 +0200 Subject: [PATCH 5/5] Document changes from phase 13. --- docs/Documentation/Builder.md | 37 ++++++++++++++++++++++++--- docs/Documentation/Building.md | 4 ++- docs/Documentation/Pipeline-Stages.md | 5 +++- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/docs/Documentation/Builder.md b/docs/Documentation/Builder.md index 9401dca8..3c28c1eb 100644 --- a/docs/Documentation/Builder.md +++ b/docs/Documentation/Builder.md @@ -87,7 +87,7 @@ Modules grouped by role. Each entry has one line; deep-dive in [Pipeline Stages] | File | Role | |---|---| -| [`render.mjs`](https://github.com/twinbasic/documentation/blob/main/builder/render.mjs) | markdown-it configuration + plugin stack + `renderPhase`. Built once on main and once per worker. | +| [`render.mjs`](https://github.com/twinbasic/documentation/blob/main/builder/render.mjs) | markdown-it configuration + plugin stack (including `svgInlinePlugin` for build-time SVG embedding) + `renderPhase`. Built once on main and once per worker. | | [`highlight.mjs`](https://github.com/twinbasic/documentation/blob/main/builder/highlight.mjs) | Shiki bootstrap + the bundled twinBASIC grammar. Emits the just-the-docs wrapper structure. | | [`highlight-theme.mjs`](https://github.com/twinbasic/documentation/blob/main/builder/highlight-theme.mjs) | Loads `Light.theme` + `Dark.theme`, emits `tb-highlight.css` + scope-to-class lookup. | | [`template.mjs`](https://github.com/twinbasic/documentation/blob/main/builder/template.mjs) | `templatePhase` (per-page layout wrap) + `buildInitConfig` + `renderSidebar`. JS template literals; no template engine. | @@ -333,11 +333,42 @@ Three flags on the pool make the reuse safe: `serve.mjs` writes to `docs/_serve/` --- disjoint from `build.bat`'s `_site/` family. A one-off `build.bat` run during a serve session never touches the tree the live preview is showing. +## SVG inlining + +Markdown `![alt](/assets/images/foo.svg)` references to build-local SVGs are replaced at render time with the SVG content inlined directly in the HTML. The feature removes the browser round-trip for separate SVG files and adds interactive controls (zoom, download, clipboard copy) to every inlined diagram. + +The pipeline: + +1. **`dispatch.execute()`** reads every `.svg` static file into a `svgContentsMap` keyed by `srcRel`. The map is packed into the shared SAB and broadcast to every render worker. +2. **`renderEnvInit`** on each worker unpacks `svgContentsMap` and passes it as `svgContents` to `createMarkdownIt`. +3. **`svgInlinePlugin`** in `render.mjs` overrides the markdown-it image renderer. When the `src` ends in `.svg` and the file's content exists in `ctx.svgContents`, the plugin replaces the `` tag with a wrapper structure containing the raw SVG, four control links (Download SVG, Copy SVG, Download PNG, Copy PNG), and a click-to-zoom container. The plugin also sets `page.hasSvg = true`. +4. **`templatePhase`** conditionally includes `