Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
499 changes: 499 additions & 0 deletions builder/PLAN-13.md

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions builder/cpu-worker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,14 @@ const handlers = {

const { siteData, initData, linkTablesData, staticFilesArr,
baseurl, buildInfo, sitePathsArr,
skipOffline } = unpackShared(_sharedSAB);
skipOffline, svgContentsMap } = unpackShared(_sharedSAB);

const { initHighlighter } = await import("./highlight.mjs");
const highlighter = await initHighlighter();
const linkTables = reconstructLinkTables(linkTablesData);
const staticFiles = new Set(staticFilesArr);
const markdown = createMarkdownIt({ highlighter, linkTables, baseurl, staticFiles });
const svgContents = new Map(Object.entries(svgContentsMap ?? {}));
const markdown = createMarkdownIt({ highlighter, linkTables, baseurl, staticFiles, svgContents });
const site = { ...siteData, markdown, buildInfo };

let offlineBase = null;
Expand Down
51 changes: 51 additions & 0 deletions builder/render.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ export function createMarkdownIt(ctx) {
md.use(kramdownDashesPlugin);
md.use(kramdownEllipsisPlugin);
md.use(flattenAdjacentStrongPlugin);
md.use(svgInlinePlugin, ctx);

return md;
}
Expand Down Expand Up @@ -1571,6 +1572,56 @@ function normaliseBlockHtml(content) {
// <img ...>. kramdown wraps any such sequence in a single <p>.
const STANDALONE_INLINE_HTML_RE = /^(?:<(br|hr|img)\b[^>]*\/?>\s*)+$/i;

// ---------- SVG inline plugin -----------------------------------------------

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();

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();

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);
token.attrs[token.attrIndex("alt")][1] =
self.renderInlineAsText(token.children, options, env);
return self.renderToken(tokens, idx, options);
}
};
}

function buildSvgWrapper(svgContent, alt, stem, srcRel) {
const esc = escapeHtml;
return `<div class="svg-inline-wrap">` +
`<div class="svg-controls">` +
`<a href="#" data-action="download-svg" data-filename="${esc(stem)}">Download SVG</a>` +
`<a href="#" data-action="copy-svg">Copy SVG</a>` +
`<a href="#" data-action="download-png" data-filename="${esc(stem)}">Download PNG</a>` +
`<a href="#" data-action="copy-png" data-filename="${esc(stem)}">Copy PNG</a>` +
`</div>` +
`<div class="svg-container" data-svg-src="${esc(srcRel)}" role="img" aria-label="${esc(alt)}">` +
svgContent +
`</div>` +
`</div>`;
}

// ---------- helpers ---------------------------------------------------------

const HTML_ESCAPE = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
Expand Down
69 changes: 19 additions & 50 deletions builder/tbdocs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -470,7 +470,7 @@ const TASKS = {
dispatch: {
expected: ["nav", "buildInit", "buildInfo", "dot", "deriveRedirects", "markdownInit"],
runOnMain: true,
execute({ nav: { sidebar }, buildInit: { initData }, buildInfo: { buildInfo }, dot: _dotSignal, markdownInit: _markdownInitSignal, deriveRedirects: { stubs } }, ctx, state) {
async execute({ nav: { sidebar }, buildInit: { initData }, buildInfo: { buildInfo }, dot: _dotSignal, markdownInit: _markdownInitSignal, deriveRedirects: { stubs } }, ctx, state) {
void _dotSignal; // dependency signal only -- static files already appended in dot.submit
void _markdownInitSignal; // dependency signal only -- markdown + linkTablesSerialized + seoSiteTitle/seoLogoUrl already on state.site
const chunks = chunkPages(state.pages, ctx.workerCount);
Expand All @@ -485,6 +485,14 @@ const TASKS = {
const sitePaths = buildSitePathsSync(state.pages, state.staticFiles, excludePatterns, stubs, themeAssetRels);
state.sitePaths = sitePaths;
const skipOffline = ctx.opts.skipOffline ?? (state.site.config.also_build_offline === false);
const svgContentsMap = Object.create(null);
for (const f of state.staticFiles) {
if (f.srcRel.endsWith(".svg")) {
try {
svgContentsMap[f.srcRel] = await fs.readFile(path.join(ctx.srcRoot, f.srcRel), "utf8");
} catch {}
}
}
const shared = {
siteData: {
config: state.site.config,
Expand All @@ -499,6 +507,7 @@ const TASKS = {
sitePathsArr: [...sitePaths],
offlineExcludePatterns: excludePatterns,
skipOffline,
svgContentsMap,
};
const sharedSAB = packShared(shared);
return { chunks, sharedSAB };
Expand Down Expand Up @@ -763,55 +772,15 @@ async function injectGanttChart(pages, destRoot, svgContent) {
let html;
try { html = await fs.readFile(htmlPath, "utf8"); }
catch (e) { if (e.code !== "ENOENT") throw e; continue; }
const dataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgContent)}`;
const ls = `font-size:0.85em;float:right;margin-left:1em`;
const downloadLink = `<a href="${dataUrl}" download="gantt.svg" style="${ls}">Download SVG</a>`;
const copyLink = `<a href="javascript:;" onclick="navigator.clipboard.writeText(document.querySelector('#gantt-chart>svg').outerHTML)" style="${ls}">Copy SVG</a>`;
const downloadPng = `<a href="javascript:;" onclick="_ganttPng(1)" style="${ls}">Download PNG</a>`;
const copyPng = `<a href="javascript:;" onclick="_ganttPng(0)" style="${ls}">Copy PNG</a>`;
const pngFn = [
`<script>function _ganttPng(dl){`,
`var svg=document.querySelector('#gantt-chart>svg');if(!svg)return;`,
`var vb=svg.viewBox.baseVal,w=2048,h=Math.round(vb.height*(w/vb.width));`,
`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 c=document.createElement('canvas');c.width=w;c.height=h;`,
`var ctx=c.getContext('2d');`,
`ctx.drawImage(img,0,0,w,h);URL.revokeObjectURL(url);`,
`c.toBlob(function(b){`,
`if(dl){var a=document.createElement('a');a.href=URL.createObjectURL(b);`,
`a.download='gantt.png';a.click();URL.revokeObjectURL(a.href)}`,
`else{navigator.clipboard.write([new ClipboardItem({'image/png':b})])}`,
`},'image/png')};img.src=url}<` + `/script>`,
].join("");
const header = `${pngFn}<style>@media print{#gantt-controls{display:none}}</style><div id="gantt-controls" style="overflow:hidden">${downloadLink}${copyLink}${downloadPng}${copyPng}</div>`;
const zoomScript = [
`<script>(function(){`,
`var c=document.getElementById('gantt-chart');`,
`if(!c)return;`,
`c.style.cursor='zoom-in';`,
`function toggle(){`,
` var svg=c.querySelector('svg');`,
` if(c.dataset.zoomed){`,
` c.removeAttribute('style');c.style.cursor='zoom-in';`,
` if(svg)svg.style.maxWidth='';`,
` delete c.dataset.zoomed;`,
` }else{`,
` var bg=getComputedStyle(document.body).backgroundColor;`,
` Object.assign(c.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%';`,
` c.dataset.zoomed='1';c.scrollTop=0;`,
` }`,
`}`,
`c.addEventListener('click',toggle);`,
`document.addEventListener('keydown',function(e){if(e.key==='Escape'&&c.dataset.zoomed)toggle();},{capture:true});`,
`})();<` + `/script>`,
].join("");
const patched = html.replace("<!-- gantt-chart -->", `${header}\n<div id="gantt-chart">${svgContent}</div>\n${zoomScript}`);
if (patched !== html) await fs.writeFile(htmlPath, patched, "utf8");
const marker = 'data-svg-src="assets/images/gantt.svg"';
const idx = html.indexOf(marker);
if (idx < 0) continue;
const svgStart = html.indexOf("<svg", idx);
const svgEnd = html.indexOf("</svg>", svgStart);
if (svgStart < 0 || svgEnd < 0) continue;
const patched = html.slice(0, svgStart) + svgContent + html.slice(svgEnd + 6);
await fs.writeFile(htmlPath, patched, "utf8");
await fs.writeFile(path.join(root, "assets", "images", "gantt.svg"), svgContent, "utf8");
}
}

Expand Down
1 change: 1 addition & 0 deletions builder/template.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ function renderHead(page, site, init) {
(init.searchEnabled ? ` <script src="${escAttr(relativeUrl("/assets/js/vendor/lunr.min.js", bu))}"></script>\n` : "") +
(bu ? ` <script>window.jtdBaseurl=${JSON.stringify(bu)};</script>\n` : "") +
` <script src="${escAttr(relativeUrl("/assets/js/just-the-docs.js", bu))}"></script>\n` +
(page.hasSvg ? ` <script src="${escAttr(relativeUrl("/assets/js/svg-inline.js", bu))}" defer></script>\n` : "") +
` <meta name="viewport" content="width=device-width, initial-scale=1">\n` +
headSeoBlock(page, site) +
init.faviconLink +
Expand Down
2 changes: 1 addition & 1 deletion docs/Documentation/BuildInfo.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@ permalink: /Documentation/Development/BuildInfo

Gantt chart of this build's task timeline.

<!-- gantt-chart -->
![Build task timeline](/assets/images/gantt.svg)
37 changes: 34 additions & 3 deletions docs/Documentation/Builder.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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 `<img>` 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 `<script defer src="/assets/js/svg-inline.js">` on pages where `page.hasSvg` is true.

The wrapper HTML emitted by `buildSvgWrapper`:

```html
<div class="svg-inline-wrap">
<div class="svg-controls">
<a href="#" data-action="download-svg" data-filename="...">Download SVG</a>
<a href="#" data-action="copy-svg">Copy SVG</a>
<a href="#" data-action="download-png" data-filename="...">Download PNG</a>
<a href="#" data-action="copy-png" data-filename="...">Copy PNG</a>
</div>
<div class="svg-container" data-svg-src="..." role="img" aria-label="...">
<svg>...</svg>
</div>
</div>
```

`svg-inline.js` (~80 lines, no dependencies) handles four client-side behaviours: click-to-zoom (fullscreen overlay, Escape to close), SVG download (serialises the `<svg>` to XML), SVG clipboard copy, and PNG export (renders the SVG to a 2048 px-wide canvas via `Image` + `toBlob`). The controls are hidden in print CSS.

Only SVGs whose content is present in `svgContents` are inlined; external URLs and missing files fall through to the default `<img>` renderer. The main-thread markdown-it instance (used only for site-level SEO) passes an empty map --- no SVG content needed there.

## Gantt chart and build introspection

Every build emits an inline-SVG Gantt chart of its task timeline. [`gantt.mjs`](https://github.com/twinbasic/documentation/blob/main/builder/gantt.mjs)'s `renderGantt(grouped)` takes the `Map<section, taskTiming[]>` the scheduler accumulates and renders one SVG row per main-thread task plus one row per worker lane. Workers appear as a single row each with multiple coloured rectangles (one per task they ran, in completion order); the colour encodes the originating section. Boot timings (cold start, `warmInit`, `renderEnvInit`) appear as a distinct row group on the first build of a session.

`tbdocs.mjs:injectGanttChart` splices the rendered SVG into the [Build Info](BuildInfo) page just after `writeOffline` completes and just before the build returns. The page itself is a placeholder with a `<!-- gantt-chart -->` comment that the inject pass replaces. Both the online and offline copies of the page get the SVG; the page also carries small JavaScript helpers for zoom, download, and PNG export.
The Gantt chart flows through the same SVG inlining pipeline as other diagrams. The [Build Info](BuildInfo) page contains a standard markdown image reference to a placeholder `gantt.svg`; during the render pass it becomes an inline SVG wrapper with zoom and export controls. After `writeOffline` completes, `tbdocs.mjs:injectGanttChart` locates the wrapper's `data-svg-src` marker in the rendered HTML and swaps the placeholder SVG content for the real Gantt chart. Both the online and offline copies of the page are patched; the on-disk `gantt.svg` file is also updated so the offline mirror's fallback stays current.

When adding a new task to `TASKS`, give it a `ganttSection` key matching one of `Seeds` / `Spine` / `Render` / `Write` so it lands in a coherent group. Tasks without a section fall into a generic "Other" bucket.

Expand Down Expand Up @@ -378,7 +409,7 @@ The site's `/assets/` tree at deploy time is assembled from three sources:

| Source on disk | What lives there | Phase that delivers it |
|---|---|---|
| `docs/assets/` | Project-owned content: the SCSS entry point, project JS (`theme-switch.js`), hand-written stylesheets (`print.css`, `just-the-docs-head-nav.css`), Graphviz/DOT diagrams (`.dot` sources + `.svg` renders), and any content images contributors add. | Discovered by [`discover.mjs`](https://github.com/twinbasic/documentation/blob/main/builder/discover.mjs), copied by `writeAssets`. |
| `docs/assets/` | Project-owned content: the SCSS entry point, project JS (`theme-switch.js`, `svg-inline.js`), hand-written stylesheets (`print.css`, `just-the-docs-head-nav.css`), Graphviz/DOT diagrams (`.dot` sources + `.svg` renders), and any content images contributors add. | Discovered by [`discover.mjs`](https://github.com/twinbasic/documentation/blob/main/builder/discover.mjs), copied by `writeAssets`. |
| `builder/vendor/just-the-docs/` | Vendored from the just-the-docs theme (v0.10.1): `_sass/` (the theme's SCSS sources, fed into the compilation) and `assets/js/just-the-docs.js` + `assets/js/vendor/lunr.min.js` (the chrome runtime, copied verbatim). See [`builder/vendor/just-the-docs/README.md`](https://github.com/twinbasic/documentation/blob/main/builder/vendor/just-the-docs/README.md) for the inventory, re-vendoring procedure, and the in-tree patches applied to `just-the-docs.js`. | `_sass/` consumed by [`scss.mjs`](https://github.com/twinbasic/documentation/blob/main/builder/scss.mjs); `assets/` copied by `writeAssets`. |
| Generated in-process | `just-the-docs-combined.css` (from `scss.mjs`) and `tb-highlight.css` (from `highlight-theme.mjs`). Neither is committed; both are rebuilt every run. | Written by `scss` (combined CSS) and `writeAssets` (highlight CSS). |

Expand Down
4 changes: 3 additions & 1 deletion docs/Documentation/Building.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,9 @@ Diagrams live as `.dot` source files under `docs/assets/images/dot/` and are ref

![Diagram](/assets/images/dot/<name>.svg)

`tbdocs` regenerates each `.svg` from its `.dot` sibling when the SVG is missing or older than its source --- editing a `.dot` by one character regenerates the SVG on the next build. Both files belong in git; the `.dot` is the canonical source, the `.svg` is the build artifact the browser loads.
`tbdocs` regenerates each `.svg` from its `.dot` sibling when the SVG is missing or older than its source --- editing a `.dot` by one character regenerates the SVG on the next build. Both files belong in git; the `.dot` is the canonical source, the `.svg` is the build artifact.

At render time, any markdown image reference to a build-local `.svg` is replaced with the SVG content inlined directly in the HTML. Each inlined SVG gets a click-to-zoom overlay and four control links (Download SVG, Copy SVG, Download PNG, Copy PNG). The controls are hidden in print output. See the [SVG inlining](Builder#svg-inlining) section of the Builder page for the implementation details.

The renderer drives `@hpcc-js/wasm-graphviz` directly: one WASM module load (~50 ms) covers the whole batch, then each diagram is a synchronous `gv.dot(src)` call. No headless browser, no in-tree patches, no Chromium dependency for diagrams. Two failure modes are handled distinctly:

Expand Down
Loading