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
9 changes: 3 additions & 6 deletions scripts/docs-site/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
53 changes: 49 additions & 4 deletions scripts/docs-site/diagram-rendering.test.mjs
Original file line number Diff line number Diff line change
@@ -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<br>state"] --> B[Rendered]';
const hostile = 'flowchart LR\n A["<img src=https://diagram.example/pixel onerror=alert(1)>"] --> 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');
Expand Down Expand Up @@ -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"));
Expand All @@ -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("<!doctype html><title>Previous docs visit</title>");
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);
});
}
8 changes: 8 additions & 0 deletions scripts/docs-site/pele-runtime.mjs
Original file line number Diff line number Diff line change
@@ -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`;
3 changes: 2 additions & 1 deletion scripts/docs-site/site-js.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
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, "");
Expand Down Expand Up @@ -210,7 +211,7 @@
let mermaidReady;let lastMermaidTrigger=null;let mermaidOverlayRender=0;let mermaidZoom;
function resetMermaidBlock(block,source){block.dataset.mermaidReady="";block.classList.remove("is-rendered","is-error");block.innerHTML="<pre><code>"+escapeHtml(source)+"</code></pre>"}
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)})));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed on exact head 85d7661: this is a false positive, not an untrusted-code construction path. pele-runtime.mjs derives the interpolated filename solely as pele- + SHA-256 bytes encoded with digest("hex").slice(0, 12) + .js; package/source text never enters the JavaScript string. site-js.mjs then JSON-encodes the fixed /assets/ prefix and that digest-only filename. Independently checked the producer output against ^pele-[0-9a-f]{12}\.js$ and the real generated import: /assets/pele-bb01f8b243c8.js. There is no attacker-controlled delimiter or executable source to sanitize here. No suppression or security-setting change is needed.

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";
Expand Down
3 changes: 2 additions & 1 deletion scripts/docs-site/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
}
Expand Down
Loading