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
13 changes: 13 additions & 0 deletions CLOUDFLARE.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,19 @@ enabled here: retaining an old output directory without an exact current-file
inventory would also retain obsolete search fragments. Publication order, search
coverage and old-object deletion remain unchanged.

Content-only commits do not stamp their Git SHA into every HTML page. Analytics
`release` is `js-<12 hex>` from the complete emitted JavaScript before its identity
is prepended, including serialized analytics/config and hashed lazy-module paths.
It identifies the executing runtime even when PJAX loads newer page content.
During the first rollout, a tab still running the previous attribute-based
runtime falls back to `local` after PJAX loads HTML without that attribute; this
means unknown provenance, not the new runtime's identity. No reload is forced.
The final `.openclaw-docs-r2-manifest.json` records the full incoming `buildCommit`
(or null outside a GitHub build) and each object's hashes.
A partial publication may retain older objects; the incoming build commit does
not relabel those bytes. The search fallback JSON is also content-stable: it has
no unused generation timestamp. Pagefind still rebuilds as described above.

### Router deployment

1. On a main push that changes `workers/**` or `wrangler.toml`, `r2-pages.yml` deploys the matching Worker after any required R2 upload, provided that snapshot passed admission before the build.
Expand Down
30 changes: 24 additions & 6 deletions scripts/docs-site/analytics-behavior.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,9 @@ async function openSite(t, { privacy, clock = false, diagram = false, showConsen
await page.goto(`${origin}/guide?utm_source=chatgpt&utm_medium=referral&utm_campaign=docs_launch#intro`, { referer: "https://chatgpt.com/c/PRIVATE_REFERRER_VALUE?key=example" });
await page.waitForFunction(() => document.querySelector(".page-feedback")?.dataset.feedbackReady === "true");
const events = name => page.evaluate(name => (window.dataLayer || []).filter(entry => entry[0] === "event" && (!name || entry[1] === name)).map(entry => ({ name: entry[1], ...entry[2] })), name);
return { page, context, events, collected, googleRequests };
const runtimeRelease = /const docsRuntimeRelease="(js-[a-f0-9]{12})";/.exec(fs.readFileSync(path.join(site, "assets/docs-site.js"), "utf8"))?.[1];
assert.ok(runtimeRelease);
return { page, context, events, collected, googleRequests, runtimeRelease };
}

async function setSavedChoice(page, analytics) {
Expand All @@ -82,11 +84,11 @@ async function setSavedChoice(page, analytics) {
}

test("acquisition, actual copy outcomes, public identifiers and feedback launch stay useful and safe", async t => {
const { page, context, events, collected } = await openSite(t);
const { page, context, events, collected, runtimeRelease } = await openSite(t);
const view = (await events("page_view"))[0];
assert.equal(view.page_location, `${origin}/guide?utm_source=chatgpt&utm_medium=referral&utm_campaign=docs_launch`);
assert.equal(view.page_referrer, "https://chatgpt.com/");
assert.equal(view.release, "1234567890ab");
assert.equal(view.release, runtimeRelease);
await page.locator("[data-code-copy]").click();
await page.waitForFunction(() => window.dataLayer.some(entry => entry[1] === "copy_action"));
await page.evaluate(() => { window.__copyFails = true; });
Expand Down Expand Up @@ -262,9 +264,25 @@ test("regrant after an unmeasured return to the same route starts one current vi
await page.waitForFunction(() => window.dataLayer.some(entry => entry[1] === "section_view" && entry[2].section_id === "install"));
await setSavedChoice(page, "denied");
await page.locator('.doc a[href="/"]').click(); await page.waitForURL(`${origin}/`);
await page.goBack(); await page.waitForURL("**/guide**");
assert.equal((await events("page_view")).length, 1);
await setSavedChoice(page, "granted");
await page.locator('.main[data-analytics-path="/"]').waitFor();
// A history URL change precedes the asynchronous PJAX DOM commit. Hold that
// response to exercise the boundary instead of racing it on a fast runner.
let releaseReturn;
const heldReturn = new Promise(resolve => { releaseReturn = resolve; });
let returnRequested;
const requestStarted = new Promise(resolve => { returnRequested = resolve; });
await page.route(`${origin}/guide**`, async route => {
returnRequested(); await heldReturn; await route.fallback();
});
try {
await page.goBack(); await page.waitForURL("**/guide**"); await requestStarted;
assert.equal(await page.locator(".main").getAttribute("data-analytics-path"), "/");
assert.equal((await events("page_view")).length, 1);
await setSavedChoice(page, "granted");
assert.equal((await events("page_view")).length, 1, "an uncommitted URL cannot start a public view");
} finally { releaseReturn(); }
await page.locator('.main[data-analytics-path="/guide"]').waitFor();
await page.waitForFunction(() => window.dataLayer.filter(entry => entry[1] === "page_view").length === 2);
assert.equal((await events("page_view")).length, 2);
await page.evaluate(() => window.scrollTo({ top: 0, behavior: "instant" }));
await page.waitForFunction(() => window.dataLayer.filter(entry => entry[1] === "section_view" && entry[2].section_id === "install").length === 2);
Expand Down
7 changes: 6 additions & 1 deletion scripts/docs-site/analytics-vitals.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ function harness({ delayed = false, withEvents = false } = {}) {
loadVitals: () => { loads++; return delayed ? pending : Promise.resolve(library); },
};
sandbox.window = sandbox;
vm.runInNewContext(`globalThis.analytics=(${createDocsAnalytics.toString()})(loadVitals);`, sandbox);
vm.runInNewContext(`globalThis.analytics=(${createDocsAnalytics.toString()})(loadVitals, "js-0123456789ab");`, sandbox);
if (withEvents) vm.runInNewContext(`globalThis.telemetry=(${createDocsAnalyticsEvents.toString()})(analytics);`, sandbox);
const vitals = () => JSON.parse(JSON.stringify((sandbox.dataLayer || []).filter(entry => entry[0] === "event" && entry[1] === "web_vital").map(entry => entry[2])));
const report = (name, id) => callbacks[name]?.({ name, id, value: name === "CLS" ? 0.04 : 100, rating: "good", navigationType: "navigate", entries: [{ startTime: 1500 }] });
Expand All @@ -35,14 +35,19 @@ test("allowed document vitals keep typed values and their original public docume
await turn();
h.report("LCP", "first"); h.report("LCP", "first");
h.sandbox.location.pathname = "/next"; h.main.dataset.analyticsPath = "/next";
h.main.dataset.analyticsRelease = "a-newer-html-deployment";
h.analytics.pageView();
h.report("INP", "second"); h.report("CLS", "third");
assert.equal(h.vitals().length, 3);
for (const metric of h.vitals()) {
assert.equal(metric.page_location, "https://docs.openclaw.ai/");
assert.equal(metric.release, "js-0123456789ab", "PJAX content cannot relabel the executing runtime");
assert.equal(["lcp_ms", "inp_ms", "cls_score"].filter(key => Object.hasOwn(metric, key)).length, 1);
assert.equal(metric.metric_value, undefined);
}
const pageViews = h.sandbox.dataLayer.filter(entry => entry[0] === "event" && entry[1] === "page_view");
assert.equal(pageViews.length, 2);
assert.ok(pageViews.every(entry => entry[2].release === "js-0123456789ab"));
});

for (const boundary of ["private", "history", "denial"]) test(`${boundary} permanently invalidates document metrics after public recovery`, async () => {
Expand Down
4 changes: 2 additions & 2 deletions scripts/docs-site/analytics.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Serialized into the browser shell. All page identity comes from the renderer.
export function createDocsAnalytics(loadVitals) {
export function createDocsAnalytics(loadVitals, runtimeRelease = "local") {
const origin = "https://docs.openclaw.ai";
const measurementId = "G-3SK7X2YLSJ";
const listeners = [];
Expand All @@ -26,7 +26,7 @@ export function createDocsAnalytics(loadVitals) {
const main = document.querySelector(".main[data-analytics-path]");
const path = main?.dataset.analyticsPath;
if (!path || !main.dataset.analyticsTitle || path !== (location.pathname.replace(/\/+$/, "") || "/")) return null;
return { main, path, title: main.dataset.analyticsTitle, release: main.dataset.analyticsRelease || "local" };
return { main, path, title: main.dataset.analyticsTitle, release: runtimeRelease };
}
function suspend() {
collectionEpoch++;
Expand Down
2 changes: 1 addition & 1 deletion scripts/docs-site/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,7 @@ ${canonicalUrl ? `<meta property="og:url" content="${escapeAttr(canonicalUrl)}">
${siteHeader(page)}
<div class="doc-shell">
${sidebar(page, nav, activeTab)}
<div class="main"${analyticsEligible ? ` data-analytics-path="${escapeAttr(pageRoute(page))}" data-analytics-title="${escapeAttr(title)}" data-analytics-sections="${escapeAttr(JSON.stringify(toc.map(item => item.id)))}" data-analytics-release="${/^[a-f0-9]{40}$/i.test(process.env.GITHUB_SHA ?? "") ? process.env.GITHUB_SHA.slice(0, 12) : "local"}"` : ""}>
<div class="main"${analyticsEligible ? ` data-analytics-path="${escapeAttr(pageRoute(page))}" data-analytics-title="${escapeAttr(title)}" data-analytics-sections="${escapeAttr(JSON.stringify(toc.map(item => item.id)))}"` : ""}>
${homeHeroArt}
<div class="page-intro"></div>
${tocHtml(toc, page.locale)}
Expand Down
81 changes: 81 additions & 0 deletions scripts/docs-site/publication-stability.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
import test from "node:test";
import { fixture, repo, run, write } from "./test-helpers/redirect-fixture.mjs";
import { siteJs } from "./site-js.mjs";

test("runtime identity hashes the executed source, including analytics and lazy dependencies", () => {
const js = siteJs();
const [header, ...lines] = js.split("\n");
const source = lines.join("\n");
const hash = createHash("sha256").update(source).digest("hex").slice(0, 12);
assert.equal(header, `const docsRuntimeRelease="js-${hash}";`);
assert.match(source, /function createDocsAnalytics\(/);
assert.match(source, /function initDocsAnalyticsConsent\(/);
assert.match(source, /web-vitals-[a-f0-9]{12}\.js/);
assert.match(source, /G-3SK7X2YLSJ/);
});

test("commit-only builds preserve artifacts; two body edits change only their pages and search", (t) => {
const f = fixture(t, [], {
"one.md": "# First\n\nOriginal first body.\n",
"two.md": "# Second\n\nOriginal second body.\n",
});
const site = path.join(f.root, "dist/docs-site");
function build(sha) {
const env = { DOCS_SITE_GA4_ENABLED: "1", GITHUB_SHA: sha };
for (const script of ["build.mjs", "search-index.mjs"]) {
const result = run(f.root, script, env);
assert.equal(result.status, 0, result.stderr);
}
const pagefind = spawnSync(path.join(repo, "node_modules/.bin/pagefind"), ["--site", site], {
env: { PATH: process.env.PATH }, encoding: "utf8",
});
assert.equal(pagefind.status, 0, pagefind.stderr);
for (const script of ["pagefind-normalize.mjs", "r2-prepare.mjs"]) {
const result = run(f.root, script, env);
assert.equal(result.status, 0, result.stderr);
}
return JSON.parse(fs.readFileSync(path.join(f.root, "dist/docs-r2-manifest.json"), "utf8"));
}
const first = build("a".repeat(40));
const second = build("b".repeat(40));
assert.equal(first.buildCommit, "a".repeat(40));
assert.equal(second.buildCommit, "b".repeat(40));
assert.deepEqual(second.entries, first.entries, "all HTML, search, assets and metadata are identical");
const home = fs.readFileSync(path.join(site, "index.html"), "utf8");
assert.match(home, /data-analytics-path="\/"/);
assert.doesNotMatch(home, /data-analytics-release/);

const dryUpload = (previous, scope = "all") => {
write(f.root, "dist/previous.json", JSON.stringify(previous));
const result = run(f.root, "r2-upload.mjs", {
R2_UPLOAD_DRY_RUN: "1", R2_UPLOAD_REMOTE_MANIFEST_PATH: "dist/previous.json",
R2_UPLOAD_SCOPE: scope,
});
assert.equal(result.status, 0, result.stderr);
return [...result.stdout.matchAll(/^r2 dry-run put: (\S+)/gm)].map(match => match[1])
.filter(key => key !== ".openclaw-docs-r2-manifest.json");
};
assert.deepEqual(dryUpload(first), [], "commit-only publication updates the catalog alone");
const olderSearch = { ...first, entries: first.entries.map(entry => entry.key === "docs-search.json"
? { ...entry, sha256: "d".repeat(64) } : entry) };
assert.deepEqual(dryUpload(olderSearch, "shell"), []);
const merged = JSON.parse(fs.readFileSync(path.join(f.root, "dist/docs-r2-manifest.shell.merged.json"), "utf8"));
assert.equal(merged.buildCommit, second.buildCommit, "final partial catalog retains incoming build provenance");
assert.equal(merged.entries.find(entry => entry.key === "docs-search.json").sha256, "d".repeat(64),
"retained out-of-scope objects keep their actual older identity");
write(f.root, "docs/one.md", "# First\n\nChanged first body.\n");
write(f.root, "docs/two.md", "# Second\n\nChanged second body.\n");
const third = build("c".repeat(40));
assert.equal(third.buildCommit, "c".repeat(40));
const changed = dryUpload(second);
assert.deepEqual(changed.filter(key => !key.startsWith("pagefind/")), [
"docs-search.json", "one.md", "two.md", "one", "one/index.html", "two", "two/index.html",
]);
assert.ok(changed.some(key => key.startsWith("pagefind/")), "the native search index reflects edited bodies");
assert.equal(fs.readFileSync(path.join(site, "index.html"), "utf8"), home);
});
4 changes: 4 additions & 0 deletions scripts/docs-site/r2-prepare.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ for (const entry of entries) {
const manifest = {
version: 1,
generatedAt: new Date().toISOString(),
// Build provenance belongs to the publication catalog, not every HTML page.
// A partial upload can retain older objects; their individual hashes remain
// authoritative. This is the commit that produced the incoming build.
buildCommit: /^[a-f0-9]{40}$/i.test(process.env.GITHUB_SHA ?? "") ? process.env.GITHUB_SHA : null,
sourceDir: "dist/docs-site",
outputDir: "dist/docs-site",
objectCount: entries.length,
Expand Down
1 change: 0 additions & 1 deletion scripts/docs-site/search-index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ entries.sort((a, b) => a.url.localeCompare(b.url));

const payload = {
version: 1,
generatedAt: new Date().toISOString(),
count: entries.length,
entries,
};
Expand Down
10 changes: 8 additions & 2 deletions scripts/docs-site/site-js.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import fs from "node:fs";
import { createHash } from "node:crypto";
import { initSidebarLevels } from "./sidebar-levels.mjs";
import { mountHomeHero } from "./home-hero.mjs";
import { createHomeGlimm } from "./home-glimm.mjs";
Expand All @@ -13,12 +14,12 @@
import { resolveDocsFragment } from "../../.openclaw-sync/lib/docs-markdown.mjs";

export function siteJs() {
return `
const source = `
${heroRuntime}
${initSidebarLevels.toString()}
${mountHomeHero.toString()}
${createHomeGlimm.toString()}
const docsAnalytics=(${createDocsAnalytics.toString()})(()=>import(withBase(${JSON.stringify("/assets/" + webVitalsAssetName)})));
const docsAnalytics=(${createDocsAnalytics.toString()})(()=>import(withBase(${JSON.stringify("/assets/" + webVitalsAssetName)})),docsRuntimeRelease);
const docsTelemetry=(${createDocsAnalyticsEvents.toString()})(docsAnalytics);
docsAnalytics.pageView();
queueMicrotask(()=>(${initDocsAnalyticsConsent.toString()})(docsAnalytics,()=>{try{if(docsAnalytics.publicPage())currentDocKey=location.pathname+location.search;syncCommunityInvite()}catch{}}));
Expand Down Expand Up @@ -399,4 +400,9 @@

document.addEventListener("click",e=>{const suggestion=e.target.closest("[data-search-suggestion]");if(!suggestion||!input)return;input.value=suggestion.dataset.searchSuggestion||"";input.focus();scheduleSearch(true)});
`;
// Hash the complete emitted runtime before adding its identity, avoiding a
// self-referential hash. Serialized analytics/config and hashed lazy module
// paths participate; a cached runtime keeps its own identity across PJAX.
const release = "js-" + createHash("sha256").update(source).digest("hex").slice(0, 12);
return `const docsRuntimeRelease=${JSON.stringify(release)};\n${source}`;
}
Loading