Replaces <img src="...svg"> 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
.dotdiagrams. The gantt SVG already carrieshtml.dark-modeCSS rules that work because it is inline; the.dotSVGs generated by@hpcc-js/wasm-graphvizdo not. Adding dark-mode classes to.dotsource files is a separate concern. - Change the existing
.dot→.svgregeneration pipeline (dot.mjs). Diagrams are still rendered from.dotsource by WASM Graphviz; this plan only changes how the resulting.svgfiles are embedded in pages. (One.dotsource fix WAS needed:pdf-render-pipeline.dothadmargin=12in itsgraph [...]defaults block, which Graphviz interprets as 12 inches on the root graph --- producing a 2239×2406 pt SVG with 864 pt of dead space. Moved themargin=12to eachsubgraph cluster_*where it means 12 points of intra-cluster padding.) - 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.
<img> tag emission).
SVG images in markdown () render
as <img src="/tB/assets/images/dot/foo.svg">. 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.
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 <script> tag │
└──────────────────────────────────┘
docs/assets/images/gantt.svgis a committed, minimal SVG placeholder (a few bytes; empty<svg>with aviewBox).discoverfinds it;dot.submitor the static-file list includes it.dispatch.executereads its content along with every other.svg.- The plugin inlines the placeholder into BuildInfo's
renderedContent--- identical wrapper markup to every other SVG. The container carriesdata-svg-src="assets/images/gantt.svg". templatePhasewraps it in the full page HTML.writePhasewrites it to_site/(and_site-offline/).- After the build completes,
injectGanttChart()(now ~10 lines) reads the BuildInfo HTML back, finds the container by itsdata-svg-src, replaces the placeholder<svg>…</svg>with the real gantt SVG, and writes the file back. It also writes the real gantt SVG to_site/assets/images/gantt.svgso a direct-URL download works.
The plugin emits this HTML for every inlined SVG:
<div class="svg-inline-wrap">
<div class="svg-controls">
<a href="#" data-action="download-svg"
data-filename="<stem>">Download SVG</a>
<a href="#" data-action="copy-svg">Copy SVG</a>
<a href="#" data-action="download-png"
data-filename="<stem>">Download PNG</a>
<a href="#" data-action="copy-png"
data-filename="<stem>">Copy PNG</a>
</div>
<div class="svg-container" data-svg-src="<srcRel>"
role="img" aria-label="<alt>">
<svg …>…</svg>
</div>
</div><stem> is the filename without extension (e.g. scheduler-dag).
<srcRel> is the source-relative path (e.g.
assets/images/dot/scheduler-dag.svg). <alt> is the markdown
alt text, used for accessibility (the <svg> element itself may
or may not carry a <title>; the aria-label on the container
is the reliable fallback).
A single event-delegation script loaded only on pages where
page.hasSvg is true. Four behaviours, all driven by class /
data-attribute selectors:
| Action | Trigger | Implementation |
|---|---|---|
| Zoom | Click on .svg-container (not on a control) |
Toggle the container to position:fixed fullscreen overlay; match body background colour; Escape to close |
| Download SVG | Click [data-action="download-svg"] |
Serialize sibling <svg> via XMLSerializer, create Blob, trigger download |
| Copy SVG | Click [data-action="copy-svg"] |
navigator.clipboard.writeText(svg.outerHTML) |
| Download / Copy PNG | Click [data-action="download-png"] or [data-action="copy-png"] |
Serialize SVG → Blob → <img> → <canvas> at 2048 px wide → PNG blob → download or clipboard write |
Print media: the script injects
@media print { .svg-controls { display:none } } once on load.
Sizing: the script also injects
.svg-container svg { width: 100%; height: auto } so every
inlined SVG fills the column width and scales proportionally.
Total size: ~80 lines / ~2.5 KB uncompressed. No cost on pages
without SVGs (the <script> tag is not emitted).
builder/
render.mjs +65 / -0 net. svgInlinePlugin function
(~40 lines) + buildSvgWrapper helper
(~25 lines). Registered last in
createMarkdownIt's plugin chain (after
relativeLinksPlugin, which rewrites the
src URL before the image renderer fires).
template.mjs +4 / -0. In renderHead, after the
just-the-docs.js <script> line,
conditionally emit a <script defer> for
svg-inline.js when page.hasSvg is true.
tbdocs.mjs +15 / -55 net. dispatch.execute reads
.svg staticFiles into svgContentsMap
and packs it into the shared SAB.
injectGanttChart collapses from ~60
lines to ~15 (find container by
data-svg-src, replace <svg> content,
write gantt.svg to _site/).
cpu-worker.mjs +3 / -1. renderEnvInit unpacks
svgContentsMap from sharedSAB and passes
it to createMarkdownIt.
sab-broadcast.mjs 0 / 0. No changes; packShared /
unpackShared are generic JSON.
docs/
assets/images/gantt.svg NEW. Committed placeholder (~100 bytes).
Never updated by the build; the real
gantt is injected into _site/ only.
assets/js/svg-inline.js NEW. ~60 lines. Zoom, download, copy
behaviours via event delegation.
Documentation/
BuildInfo.md +1 / -1. Replace <!-- gantt-chart -->
with .
Estimated total churn: ~90 lines added, ~55 removed, plus the two new files (~160 lines combined).
Three batches; each independently committable and verifiable. Batch 2 depends on batch 1; batch 3 depends on batch 2.
| Batch | Substeps | Suggested model | Verifies by |
|---|---|---|---|
| 1 | §5.1 + §5.2 + §5.3 --- the plugin, the data plumbing, and the conditional script tag. Regular .dot SVGs are now inlined. |
Opus | build.bat && check.bat clean; inspect _site/Documentation/Development/Builder/index.html to confirm the three .dot SVGs appear as inline <svg> wrapped in .svg-inline-wrap, not as <img> tags; the <script src="svg-inline.js"> tag is present in that page's <head> and absent in a page with no SVGs (e.g. _site/tB/Core/Dim/index.html). |
| 2 | §5.4 --- the JS file with zoom / download / copy behaviours. | Sonnet | Manual: open the Builder page in a browser; click a diagram to enter fullscreen zoom; press Escape to close; click each of the four control links and verify the download / clipboard result. |
| 3 | §5.5 + §5.6 --- committed placeholder, simplified gantt injection, BuildInfo.md change. | Sonnet | build.bat && check.bat clean; the BuildInfo page shows the gantt chart with the same zoom + controls as the .dot diagrams; the old <!-- gantt-chart --> comment is gone; injectGanttChart is < 20 lines. |
- Opus (batch 1): the core architectural work. The plugin must correctly override the image renderer without breaking non-SVG images, handle the URL-to-srcRel mapping across baseurl configurations, set the page flag at the right time, and the SAB plumbing must survive the structured-clone serialisation.
- Sonnet (batches 2, 3): batch 2 is a self-contained JS file with no interaction with the build pipeline; batch 3 is a mechanical simplification of existing code plus a one-line markdown change.
Location: new function svgInlinePlugin(md, ctx), registered
in createMarkdownIt as the last md.use(...) call (after
flattenAdjacentStrongPlugin).
Mechanism: overrides md.renderer.rules.image.
function svgInlinePlugin(md, ctx) {
const orig = md.renderer.rules.image;
md.renderer.rules.image = (tokens, idx, options, env, self) => {
const token = tokens[idx];
const srcIdx = token.attrIndex("src");
if (srcIdx < 0) return fallback();
const src = token.attrs[srcIdx][1];
if (!src.endsWith(".svg")) return fallback();
// Map the rewritten URL back to srcRel.
// relativeLinksPlugin has already resolved the URL to
// e.g. "/tB/assets/images/dot/scheduler-dag.svg".
const prefix = (ctx.baseurl || "") + "/";
if (!src.startsWith(prefix)) return fallback();
const srcRel = src.slice(prefix.length);
const svgContent = ctx.svgContents?.get(srcRel);
if (!svgContent) return fallback();
// Extract alt text (same as default image renderer).
const alt = self.renderInlineAsText(
token.children, options, env);
const stem = srcRel.split("/").pop().replace(/\.svg$/, "");
if (env?.page) env.page.hasSvg = true;
return buildSvgWrapper(svgContent, alt, stem, srcRel);
function fallback() {
if (orig) return orig(tokens, idx, options, env, self);
// markdown-it default image rule
token.attrs[token.attrIndex("alt")][1] =
self.renderInlineAsText(token.children, options, env);
return self.renderToken(tokens, idx, options);
}
};
}buildSvgWrapper(svgContent, alt, stem, srcRel) returns the
HTML string from §2.3.
The controls use data-action / data-filename attributes; no
inline event handlers.
The function escapes alt and srcRel for attribute context
(double-quote safe).
Read: in dispatch.execute(), after the const shared = {
block. Read every .svg entry in state.staticFiles from disk:
const svgContentsMap = Object.create(null);
for (const f of state.staticFiles) {
if (f.srcRel.endsWith(".svg") && f.srcPath) {
try {
svgContentsMap[f.srcRel] = await fs.readFile(f.srcPath, "utf8");
} catch {}
}
}Add svgContentsMap to the shared object. packShared
serialises it into the SAB alongside staticFilesArr, etc.
dispatch.execute is currently synchronous (no async).
Adding the fs.readFile calls makes it async execute(...).
The scheduler already supports async execute functions; no
framework change needed.
Unpack: in cpu-worker.mjs renderEnvInit():
const { ..., svgContentsMap } = unpackShared(_sharedSAB);
// ...
const svgContents = new Map(Object.entries(svgContentsMap ?? {}));
const markdown = createMarkdownIt({
highlighter, linkTables, baseurl, staticFiles, svgContents,
});Main-thread markdown-it (markdownInit task): this instance is
used only for SEO title rendering and does not render page bodies.
No SVG contents needed; pass svgContents: new Map() (or omit).
In renderHead, after the just-the-docs.js <script> line
(line 130), add:
(page.hasSvg
? ` <script src="${escAttr(relativeUrl(
"/assets/js/svg-inline.js", bu))}" defer></script>\n`
: "")defer keeps the load non-blocking, same as theme-switch.js.
New file at docs/assets/js/svg-inline.js. ~80 lines, no
dependencies, IIFE.
Behaviours:
-
CSS injection. On load, inject a
<style>with@media print { .svg-controls { display:none } }, the base.svg-controls afloat styling, and.svg-container svg { width:100%; height:auto }so every inlined SVG fills the column width. -
Zoom toggle.
document.addEventListener("click", ...), delegated to.svg-container. If the click target is inside.svg-controls, ignore (let the control handler run). Otherwise toggle the container between normal flow andposition:fixed; inset:0; z-index:9999; background:<body bg>; overflow:auto. Setcursor:zoom-in/zoom-out. Track state viadataset.zoomed. Escape keydown closes the zoom (with{capture:true}so it fires before other handlers). -
Control actions.
document.addEventListener("click", ...), delegated to.svg-controls a[data-action].preventDefault. Navigate from the clicked link to the sibling.svg-container > svg.download-svg:new XMLSerializer().serializeToString(svg)→ Blob → object URL → programmatic<a>click.copy-svg:navigator.clipboard.writeText(svg.outerHTML).download-png/copy-png: serialize SVG → Blob →<img>.onload→<canvas>at 2048 px wide (aspect fromviewBox) →canvas.toBlob("image/png")→ download ornavigator.clipboard.write([new ClipboardItem(...)]).
data-filenameon the link provides the download stem (<stem>.svg,<stem>.png).
Committed placeholder (docs/assets/images/gantt.svg):
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 1">
<title>Build task timeline</title>
</svg>Minimal; the real content replaces it in _site/ at the end of
every build.
Simplified injectGanttChart(pages, destRoot, svgContent):
Collapses to ~15 lines. No baseurl parameter --- static files
live directly under the output root (_site/assets/...), not
under the baseurl prefix. For each output tree (destRoot,
destRoot-offline):
- Read the BuildInfo HTML from disk.
- Find the
data-svg-src="assets/images/gantt.svg"container. Extract the range from the first<svgafter that marker to its closing</svg>. - Replace that range with
svgContent. - Write the patched HTML back.
- Write
svgContentto<root>/assets/images/gantt.svgso a direct-URL download of the file works.
No controls, no scripts, no zoom wiring --- the plugin already emitted all of that around the placeholder.
Replace:
<!-- gantt-chart -->With:
The plugin inlines the placeholder SVG and emits the full wrapper. The post-build step fills in the real content.
docs/assets/images/gantt.svg is the committed placeholder. The
plugin inlines it --- the BuildInfo page renders with an empty
gantt area and working controls. After the build completes, the
post-build step fills in the real gantt. On the NEXT page load
(or if the user refreshes), the real gantt is visible.
If an  references a file not in staticFiles
(e.g. a typo), the plugin falls through to the default <img>
renderer. The link checker (check.bat) catches the broken
image reference as it does today.
Absolute URLs (https://...svg), protocol-relative URLs
(//...svg), and fragment-only references (#...) bypass the
plugin entirely (the src.startsWith(prefix) guard rejects them).
Only build-local SVGs with content in the svgContents map are
inlined.
No special handling needed. The watcher already ignores
assets/images/dot/*.svg writes. The gantt placeholder at
docs/assets/images/gantt.svg is never written during the build
(only _site/ is touched). Each serve rebuild reads the
placeholder, inlines it, and the post-build step injects the real
gantt into _site/.
The three .dot SVGs and the gantt placeholder total ~30 KB. The
existing shared SAB carries ~600 KB of serialised data
(linkTablesData + staticFilesArr + sitePathsArr). The SVG
contents add ~5 % overhead --- negligible.
The <img alt="..."> tag is replaced by a <div ... role="img" aria-label="..."> on the container. Screen readers announce the
alt text; the <svg> element's internal <title> (if present)
provides a secondary label.