diff --git a/scripts/docs-site/build.mjs b/scripts/docs-site/build.mjs index 3cd880d50f..45e026ac0f 100644 --- a/scripts/docs-site/build.mjs +++ b/scripts/docs-site/build.mjs @@ -17,6 +17,7 @@ import { walkDocs } from "./document-files.mjs"; import { siteCss } from "./site-css.mjs"; import { siteJs } from "./site-js.mjs"; import { webVitalsAssetName, webVitalsRuntime } from "./web-vitals-runtime.mjs"; +import { peleAssetName, peleEntry, peleRuntime } from "./pele-runtime.mjs"; import { chromeStringsForLocale } from "./chrome-strings.mjs"; import { createMarkdownRenderer, renderMdxish, copyControlContent } from "./mdx-ish.mjs"; import { createRenderCache } from "./render-cache.mjs"; @@ -873,12 +874,8 @@ function writeStaticAssets() { const glimmDir = path.dirname(glimmEntry); fs.writeFileSync(path.join(assetsDir, glimmAssetName), glimmRuntime, "utf8"); fs.copyFileSync(path.join(glimmDir, "..", "LICENSE"), path.join(assetsDir, "glimm-LICENSE.txt")); - const peleDir = path.join(root, "node_modules", "pele"); - const peleEntry = path.join(peleDir, "dist", "pele.js"); - if (fs.existsSync(peleEntry)) { - fs.copyFileSync(peleEntry, path.join(assetsDir, "pele-1.6.0.js")); - fs.copyFileSync(path.join(peleDir, "LICENSE"), path.join(assetsDir, "pele-LICENSE.txt")); - } + fs.writeFileSync(path.join(assetsDir, peleAssetName), peleRuntime, "utf8"); + fs.copyFileSync(path.join(path.dirname(peleEntry), "..", "LICENSE"), path.join(assetsDir, "pele-LICENSE.txt")); fs.writeFileSync(path.join(outDir, ".nojekyll"), "", "utf8"); fs.writeFileSync(path.join(outDir, "og-card.png"), defaultOgPng); fs.writeFileSync(path.join(outDir, "og-card.svg"), defaultOgSvg); diff --git a/scripts/docs-site/diagram-rendering.test.mjs b/scripts/docs-site/diagram-rendering.test.mjs index 15ce5137a9..24258c3613 100644 --- a/scripts/docs-site/diagram-rendering.test.mjs +++ b/scripts/docs-site/diagram-rendering.test.mjs @@ -1,10 +1,15 @@ import assert from "node:assert/strict"; import fs from "node:fs"; +import http from "node:http"; +import { createHash } from "node:crypto"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { test } from "node:test"; import { chromium, webkit } from "playwright"; import { fixture } from "./test-helpers/redirect-fixture.mjs"; +const runtimeUrl = "/assets/pele-" + createHash("sha256").update(fs.readFileSync(fileURLToPath(import.meta.resolve("pele")))).digest("hex").slice(0, 12) + ".js"; + const source = 'flowchart LR\n accTitle: Public diagram\n accDescr: Ready becomes rendered\n A["Ready
state"] --> B[Rendered]'; const hostile = 'flowchart LR\n A[""] --> B[Safe]\n click B "javascript:alert(1)"'; const markdown = '# Diagrams\n\n' + [source, source, hostile, 'flowchart LR\n Broken -->'].map(text => '\x60\x60\x60mermaid\n' + text + '\n\x60\x60\x60').join('\n\n'); @@ -40,18 +45,18 @@ for (const [name, engine] of [["Chromium", chromium], ["WebKit", webkit]]) { await page.goto("https://docs.example/guide", { waitUntil: "domcontentloaded" }); await fontStarted; // Settle the renderer import without releasing the intentionally cold webfonts. - await page.evaluate(async () => { await import("/assets/pele-1.6.0.js"); await new Promise(requestAnimationFrame); }); + await page.evaluate(async url => { await import(url); await new Promise(requestAnimationFrame); }, runtimeUrl); assert.equal(await page.locator(".oc-mermaid.is-rendered").count(), 0, "diagram layout must wait for the label font"); releaseFonts(); await page.waitForFunction(() => document.querySelectorAll(".oc-mermaid.is-rendered").length === 3); assert.match(await page.locator(".oc-mermaid > svg").first().getAttribute("class"), /pele/); assert.equal(await page.locator(".oc-mermaid > svg text").first().evaluate(text => getComputedStyle(text).fontSize), "16px"); - assert.ok(await page.locator(".oc-mermaid > svg").first().evaluate(async svg => { - const pele = await import("/assets/pele-1.6.0.js"); + assert.ok(await page.locator(".oc-mermaid > svg").first().evaluate(async (svg, url) => { + const pele = await import(url); const fontFamily = getComputedStyle(svg).fontFamily; const loaded = pele.render(svg.parentElement.dataset.mermaid, { fontFamily, fontSize: 16, links: false, images: false }); return svg.viewBox.baseVal.width === loaded.width && svg.viewBox.baseVal.height === loaded.height; - }), "cold-load geometry must match the loaded font"); + }, runtimeUrl), "cold-load geometry must match the loaded font"); const titleIds = await page.locator(".oc-mermaid > svg[aria-labelledby]").evaluateAll(svgs => svgs.map(svg => { const title = document.getElementById(svg.getAttribute("aria-labelledby")); const desc = document.getElementById(svg.getAttribute("aria-describedby")); @@ -72,3 +77,43 @@ for (const [name, engine] of [["Chromium", chromium], ["WebKit", webkit]]) { assert.deepEqual(diagramRequests, []); }); } + +for (const [name, engine] of [["Chromium", chromium], ["WebKit", webkit]]) { + test(name + " loads the installed renderer after an immutable legacy asset was cached", async t => { + const f = fixture(t, [], { "guide.md": "# Diagram\n\n" + "\x60\x60\x60mermaid\nflowchart LR\n A --> B\n\x60\x60\x60" }); + fs.symlinkSync(path.resolve("node_modules"), path.join(f.root, "node_modules"), process.platform === "win32" ? "junction" : "dir"); + const built = f.build(); + assert.equal(built.status, 0, built.stderr); + const site = path.join(f.root, "dist/docs-site"); + const runtime = fs.readFileSync(fileURLToPath(import.meta.resolve("pele")), "utf8"); + const shell = fs.readFileSync(path.join(site, "assets/docs-site.js"), "utf8"); + const runtimeUrl = shell.match(/\/assets\/pele-[\w.]+\.js/)[0]; + const server = http.createServer((req, res) => { + if (req.url === "/warm") return res.setHeader("content-type", "text/html").end("Previous docs visit"); + if (req.url === "/assets/pele-1.6.0.js") { + res.setHeader("content-type", "text/javascript"); + res.setHeader("cache-control", "public, max-age=31536000, immutable"); + return res.end(runtime + "\nexport const docsCachedRenderer = true;\n"); + } + let file = path.join(site, new URL(req.url, "http://localhost").pathname); + if (fs.existsSync(file) && fs.statSync(file).isDirectory()) file = path.join(file, "index.html"); + if (!fs.existsSync(file)) return res.writeHead(404).end(); + res.setHeader("content-type", ({ ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".woff2": "font/woff2", ".svg": "image/svg+xml" })[path.extname(file)] || "application/octet-stream"); + res.end(fs.readFileSync(file)); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + t.after(() => new Promise(resolve => server.close(resolve))); + const browser = await engine.launch({ headless: true }); + t.after(() => browser.close()); + const page = await browser.newPage(); + const base = "http://127.0.0.1:" + server.address().port; + await page.goto(base + "/warm"); + await page.evaluate(() => import("/assets/pele-1.6.0.js")); + await page.goto(base + "/guide"); + await page.locator(".oc-mermaid.is-rendered > svg").waitFor(); + assert.equal(await page.evaluate(url => import(url).then(pele => Boolean(pele.docsCachedRenderer)), runtimeUrl), false, + "a current page must not execute the previous immutable renderer"); + assert.equal(runtimeUrl, "/assets/pele-" + createHash("sha256").update(runtime).digest("hex").slice(0, 12) + ".js"); + assert.equal(fs.readFileSync(path.join(site, runtimeUrl), "utf8"), runtime); + }); +} diff --git a/scripts/docs-site/pele-runtime.mjs b/scripts/docs-site/pele-runtime.mjs new file mode 100644 index 0000000000..98884f5c09 --- /dev/null +++ b/scripts/docs-site/pele-runtime.mjs @@ -0,0 +1,8 @@ +import fs from "node:fs"; +import { createHash } from "node:crypto"; +import { fileURLToPath } from "node:url"; + +export const peleEntry = fileURLToPath(import.meta.resolve("pele")); +export const peleRuntime = fs.readFileSync(peleEntry, "utf8"); +// Runtime assets are immutable: a dependency update must select a new URL. +export const peleAssetName = `pele-${createHash("sha256").update(peleRuntime).digest("hex").slice(0, 12)}.js`; diff --git a/scripts/docs-site/site-js.mjs b/scripts/docs-site/site-js.mjs index fc63db5a7d..2133d26074 100644 --- a/scripts/docs-site/site-js.mjs +++ b/scripts/docs-site/site-js.mjs @@ -7,6 +7,7 @@ import { glimmAssetName } from "./glimm-runtime.mjs"; import { createDocsAnalytics } from "./analytics.mjs"; import { createDocsAnalyticsEvents } from "./analytics-events.mjs"; import { webVitalsAssetName } from "./web-vitals-runtime.mjs"; +import { peleAssetName } from "./pele-runtime.mjs"; import { initDocsAnalyticsConsent } from "./analytics-consent.mjs"; // The emitted runtime also supports the classic-script navigation harness. const heroRuntime = fs.readFileSync(new URL("./hero-art.mjs", import.meta.url), "utf8").replace(/^export /gm, ""); @@ -210,7 +211,7 @@ const mermaidCloseIcon='"} async function renderMermaidSvg(source,renderId){ - const pele=await (mermaidReady??=import(withBase("/assets/pele-1.6.0.js"))); + const pele=await (mermaidReady??=import(withBase(${JSON.stringify("/assets/" + peleAssetName)}))); const styles=getComputedStyle(document.querySelector(".docs-layout")||root); const fontFamily=styles.getPropertyValue("--oc-font-body").trim()||"sans-serif"; const fontFamilyMono=styles.getPropertyValue("--oc-font-mono").trim()||"monospace"; diff --git a/scripts/docs-site/smoke.mjs b/scripts/docs-site/smoke.mjs index d43e581b2d..a3117884a2 100644 --- a/scripts/docs-site/smoke.mjs +++ b/scripts/docs-site/smoke.mjs @@ -7,6 +7,7 @@ import { DomUtils, parseDocument } from "htmlparser2"; import { ignoredDocDirs, localeFlags, localeLabels, navigationLocaleToDir } from "./config.mjs"; import { chromeStrings } from "./chrome-strings.mjs"; +import { peleAssetName } from "./pele-runtime.mjs"; import { homeStrings } from "./home-strings.mjs"; import { editSourceUrlForPage, frontmatterSourcePath, readSourceMetadata } from "./edit-source.mjs"; import { parseFrontmatter } from "../../.openclaw-sync/lib/docs-markdown.mjs"; @@ -422,7 +423,7 @@ if (!/\.doc pre \.tok-comment,\.doc pre \.hljs-comment/.test(siteCss) || !/\.doc pre \.tok-key,\.doc pre \.hljs-attr/.test(siteCss)) { throw new Error("assets: syntax token colors are not theme-variable based"); } -if (!fs.existsSync(path.join(site, "assets/pele-1.6.0.js")) +if (!fs.existsSync(path.join(site, "assets", peleAssetName)) || !fs.existsSync(path.join(site, "assets/pele-LICENSE.txt"))) { throw new Error("assets: Pele runtime and license were not copied"); }