diff --git a/site/README.md b/site/README.md index e2284b7..f27a0a1 100644 --- a/site/README.md +++ b/site/README.md @@ -20,4 +20,6 @@ Cloudflare Pages project: `threadboard-web`. GitHub source: `vrnrn/Threadboard`, Download and checksum URLs include the package content hash. This keeps cached preview downloads matched to their checksum even when two source builds share a preview version. +Styles, scripts, demo pages, and product images carry a build content hash in their URLs. Returning visitors receive the matching assets after a deployment, including when switching themes or resetting the demo. + The website is independent of Threadboard's fully local runtime. Hosting the page and downloadable installer does not introduce board synchronization or a hosted MCP server. diff --git a/site/scripts/build.mjs b/site/scripts/build.mjs index bfe8797..f7fc83f 100644 --- a/site/scripts/build.mjs +++ b/site/scripts/build.mjs @@ -2,6 +2,7 @@ import { cpSync, mkdirSync, readFileSync, + readdirSync, rmSync, writeFileSync, } from "node:fs"; @@ -170,6 +171,23 @@ writeFileSync( join(output, "404.html"), 'Page not found — Threadboard

404 / PAGE NOT FOUND

Let’s get you back to the board.

This page isn’t here. The product page has the preview and installation guide.

Back to Threadboard
', ); +// Keep returning visitors from combining new HTML with cached scripts or art. +const assetFiles = [ + "index.html", "404.html", "demo/index.html", + "styles.css", "main.js", "demo/app.js", "demo/app.css", "demo/demo.css", + ...readdirSync(join(output, "assets")).sort().map(name => `assets/${name}`), +]; +const assetHash = createHash("sha256"); +for (const file of assetFiles) assetHash.update(file).update(readFileSync(join(output, file))); +const assetVersion = assetHash.digest("hex").slice(0, 12); +for (const file of ["index.html", "404.html", "demo/index.html", "styles.css"]) { + const content = readFileSync(join(output, file), "utf8") + .replace(" `${path}?v=${assetVersion}`); + writeFileSync(join(output, file), content); +} writeFileSync( join(output, "build.json"), JSON.stringify({ diff --git a/site/scripts/check.mjs b/site/scripts/check.mjs index 798b165..33eb59c 100644 --- a/site/scripts/check.mjs +++ b/site/scripts/check.mjs @@ -25,7 +25,8 @@ for (const match of html.matchAll(/\b(?:href|src)="([^"]+)"/g)) { assert.ok(ids.has(reference.slice(1)), `Missing anchor ${reference}`); continue; } - const path = reference === "/" ? "/index.html" : reference; + const pathname = new URL(reference, "https://threadboard.vrnrn.com").pathname; + const path = pathname === "/" ? "/index.html" : pathname; assert.ok( existsSync(resolve(output, `.${path}`)), `Missing asset: ${reference}`, diff --git a/site/src/main.js b/site/src/main.js index ade0529..6bd2afb 100644 --- a/site/src/main.js +++ b/site/src/main.js @@ -1,4 +1,6 @@ const root = document.documentElement; +const assetVersion = root.dataset.assetVersion; +const demoSource = () => `/demo/?theme=${root.dataset.theme}&v=${assetVersion}`; const announcement = document.querySelector("#announcement"); const themeToggle = document.querySelector(".theme-toggle"); const screenshotNames = { @@ -17,10 +19,10 @@ function applyTheme(theme) { theme === "dark" ? "#181818" : "#f7f5f0"; for (const image of document.querySelectorAll("[data-theme-image]")) image.src = imageSource(image.dataset.themeImage); - document.querySelector("[data-open-demo]").href = `/demo/?theme=${theme}`; + document.querySelector("[data-open-demo]").href = demoSource(); } function imageSource(name) { - return `/assets/${name}${root.dataset.theme === "dark" ? "-dark" : ""}.png`; + return `/assets/${name}${root.dataset.theme === "dark" ? "-dark" : ""}.png?v=${assetVersion}`; } applyTheme(root.dataset.theme); themeToggle.addEventListener("click", () => { @@ -164,8 +166,7 @@ imageDialog.addEventListener("close", () => { imageTrigger?.focus(); }); document.querySelector("[data-reset-demo]").addEventListener("click", () => { - document.querySelector("#app-demo").src = - `/demo/?theme=${root.dataset.theme}`; + document.querySelector("#app-demo").src = demoSource(); announcement.textContent = "Demo board reset to its example projects."; }); window.addEventListener("message", (event) => { diff --git a/site/tests/site.spec.ts b/site/tests/site.spec.ts index 4c88297..5204751 100644 --- a/site/tests/site.spec.ts +++ b/site/tests/site.spec.ts @@ -1,6 +1,26 @@ import { test, expect } from "@playwright/test"; import AxeBuilder from "@axe-core/playwright"; import { createHash } from "node:crypto"; +test("returning visitors bypass old unversioned styles and demo assets", async ({ page }) => { + const staleRequests: string[] = []; + await page.route("**/*", async route => { + const url = new URL(route.request().url()); + const stale = ["/styles.css", "/main.js", "/demo/", "/demo/app.js", "/demo/app.css", "/demo/demo.css"]; + if (stale.includes(url.pathname) && !url.searchParams.has("v")) { + staleRequests.push(url.pathname); + await route.fulfill({ status: 410, body: "Old cached asset" }); + } else await route.continue(); + }); + await page.goto("/"); + await expect(page.getByRole("heading", { name: "Task boards. Inside Codex." })).toBeVisible(); + await page.getByRole("button", { name: "Switch to light mode" }).click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "light"); + await page.getByRole("button", { name: "Reset demo board" }).click(); + const app = page.frameLocator("#app-demo"); + await expect(app.getByRole("heading", { name: "Guide users through their first task" })).toBeVisible(); + expect(staleRequests).toEqual([]); +}); + test("dark is the default; theme choice and product images survive navigation", async ({ page, }) => { @@ -49,7 +69,7 @@ test("the example handoff supports pointer and keyboard; screenshot dialog retur await expect(page.getByRole("dialog")).toBeVisible(); await expect(page.locator("#large-image")).toHaveAttribute( "src", - "/assets/task-review-dark.png", + /\/assets\/task-review-dark\.png\?v=[a-f0-9]{12}$/, ); await page.keyboard.press("Escape"); await expect(page.getByRole("dialog")).not.toBeVisible();