refactor(website): keep page text when a script message is missing

A page that omits a message attribute (a translation that drops one, or a
page without the shared footer) must not show empty or "undefined" text:

- Copy buttons still copy without #copy-status or its messages, keep their
  own label and leave the status empty.
- The star label is skipped when the header has no label for the count.
- The percent demo, dinosaur game and speed check leave the current text in
  place for a missing message; an unknown {placeholder} stays literal.

scripts/test-website-scripts.mjs runs main.js, home.js and dino-demo.js
against a small document model with node:test. `website.py check` runs it,
so the website CI job and the publisher's preparation job now set up the
repository's Node.js version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 committed 2026-10-11 07:09:16 +00:00
1 parent 3d34185171
commit dabcd783d5
8 files changed
+558 -43

No files matched your search

+4
View File
@@ -30,5 +30,9 @@ jobs:
sudo apt-get update
sudo apt-get install --no-install-recommends -y libcairo2 fonts-dejavu-core
python3 -m pip install -r website/requirements.txt
- name: Set up Node.js for website script tests
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Check shared HTML, links, assets and contributor workflows
run: python3 scripts/website.py check
+4
View File
@@ -59,6 +59,10 @@ jobs:
sudo apt-get update
sudo apt-get install --no-install-recommends -y libcairo2 fonts-dejavu-core
python3 -m pip install -r website/requirements.txt
- name: Set up Node.js for website script tests
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Check shared HTML, links, assets and contributor workflows
run: python3 scripts/website.py check
- name: Record the published commit
+481
View File
@@ -0,0 +1,481 @@
// Run the website's browser scripts against a small document model, so missing
// page-owned messages are checked without a browser.
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { test } from "node:test";
import { setImmediate as nextTurn } from "node:timers/promises";
import { pathToFileURL } from "node:url";
const ASSETS = new URL("../website/public/assets/", import.meta.url);
class Element {
constructor(tag, attributes = {}, children = [], text = "") {
this.tagName = tag.toUpperCase();
this.attributes = new Map(Object.entries(attributes));
this.children = [];
this.parent = null;
this.listeners = new Map();
this.ownText = text;
this.hidden = "hidden" in attributes;
this.disabled = "disabled" in attributes;
this.checked = "checked" in attributes;
this.value = attributes.value ?? "";
for (const child of children) this.append(child);
}
append(child) {
child.parent = this;
this.children.push(child);
}
get textContent() {
return (
this.ownText + this.children.map((child) => child.textContent).join("")
);
}
set textContent(value) {
this.ownText = String(value);
this.children = [];
}
get id() {
return this.getAttribute("id") ?? "";
}
get lang() {
return this.getAttribute("lang") ?? "";
}
get dataset() {
const values = {};
for (const [name, value] of this.attributes) {
if (!name.startsWith("data-")) continue;
const key = name
.slice(5)
.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
values[key] = value;
}
return values;
}
getAttribute(name) {
return this.attributes.has(name) ? this.attributes.get(name) : null;
}
setAttribute(name, value) {
this.attributes.set(name, String(value));
}
removeAttribute(name) {
this.attributes.delete(name);
}
addEventListener(type, listener) {
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
}
dispatch(type, event = {}) {
for (const listener of this.listeners.get(type) ?? []) {
listener({ preventDefault() {}, repeat: false, ...event });
}
}
click() {
this.dispatch("click");
}
focus() {}
descendants() {
return this.children.flatMap((child) => [child, ...child.descendants()]);
}
matches(selector) {
return selector
.split(",")
.some((part) => matchesComplex(this, part.trim()));
}
closest(selector) {
for (let node = this; node !== null; node = node.parent) {
if (node.matches(selector)) return node;
}
return null;
}
querySelectorAll(selector) {
return this.descendants().filter((node) => node.matches(selector));
}
querySelector(selector) {
return this.querySelectorAll(selector)[0] ?? null;
}
getContext() {
return this.context ?? null;
}
}
// Supports the selector forms the website scripts use: tag, #id, .class and
// [attribute], [attribute="value"] or [attribute^="value"], joined by spaces.
function matchesCompound(node, compound) {
const pattern =
/^([a-z0-9]*)((?:#[\w-]+|\.[\w-]+|\[[\w-]+(?:\^?="[^"]*")?\])*)$/i;
const parsed = pattern.exec(compound);
if (parsed === null) throw new Error(`Unsupported selector: ${compound}`);
const [, tag, rest] = parsed;
if (tag && node.tagName !== tag.toUpperCase()) return false;
for (const [, id, className, name, operator, value] of rest.matchAll(
/#([\w-]+)|\.([\w-]+)|\[([\w-]+)(?:(\^?=)"([^"]*)")?\]/g,
)) {
if (id !== undefined) {
if (node.id !== id) return false;
continue;
}
if (className !== undefined) {
const classes = (node.getAttribute("class") ?? "").split(/\s+/);
if (!classes.includes(className)) return false;
continue;
}
const actual = node.getAttribute(name);
if (actual === null) return false;
if (operator === "=" && actual !== value) return false;
if (operator === "^=" && !actual.startsWith(value)) return false;
}
return true;
}
function matchesComplex(node, selector) {
const parts = selector.split(/\s+/);
if (!matchesCompound(node, parts.at(-1))) return false;
let ancestor = node.parent;
for (const part of parts.slice(0, -1).reverse()) {
while (ancestor !== null && !matchesCompound(ancestor, part))
ancestor = ancestor.parent;
if (ancestor === null) return false;
ancestor = ancestor.parent;
}
return true;
}
const h = (tag, attributes, ...children) => {
const text = children.filter((child) => typeof child === "string").join("");
return new Element(
tag,
attributes ?? {},
children.filter((child) => child instanceof Element),
text,
);
};
// Install a fresh document and browser globals, then run one script.
async function run(
script,
body,
{ lang = "en", clipboard = "resolve", stars = 1234 } = {},
) {
const root = h("html", { lang }, h("body", {}, ...body));
const timers = [];
const written = [];
const canvasText = [];
for (const canvas of root.querySelectorAll("canvas")) {
canvas.context = new Proxy(
{ fillText: (text) => canvasText.push(text) },
{ get: (target, name) => target[name] ?? (() => {}), set: () => true },
);
}
const document = {
documentElement: root,
hidden: false,
getElementById: (id) =>
root.descendants().find((node) => node.id === id) ?? null,
querySelector: (selector) => root.querySelector(selector),
querySelectorAll: (selector) => root.querySelectorAll(selector),
createRange: () => ({ selectNodeContents() {} }),
addEventListener() {},
};
const window = {
scrollY: 0,
innerHeight: 900,
location: { hash: "" },
addEventListener() {},
getSelection: () => ({ removeAllRanges() {}, addRange() {} }),
matchMedia: () => ({ matches: false, addEventListener() {} }),
requestAnimationFrame: () => 1,
scrollTo() {},
setTimeout: (callback) => timers.push(callback),
};
Object.assign(globalThis, {
document,
window,
requestAnimationFrame: () => 1,
cancelAnimationFrame() {},
Path2D: class {},
fetch: async () => ({
ok: true,
json: async () => ({ stargazers_count: stars }),
}),
});
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {
clipboard: {
writeText: async (text) => {
if (clipboard === "reject") throw new Error("denied");
written.push(text);
},
},
},
});
await import(
`${pathToFileURL(new URL(script, ASSETS).pathname)}?load=${randomUUID()}`
);
await nextTurn();
return { root, timers, written, canvasText };
}
const copyBlock = () => [
h("button", { "data-copy": "command" }, "Copy"),
h("pre", {}, h("code", { id: "command" }, "npx rea-agents setup")),
];
const copyStatus = (messages = {}) =>
h("p", {
id: "copy-status",
...Object.fromEntries(
Object.entries(messages).map(([key, value]) => [`data-${key}`, value]),
),
});
const englishCopyMessages = {
copied: "Copied",
selected: "Selected",
"status-copied": "Code copied to clipboard.",
"status-selected": "Code selected. Use your keyboard to copy it.",
};
test("copy buttons report and restore localized labels", async () => {
const { root, timers, written } = await run("main.js", [
...copyBlock(),
copyStatus(englishCopyMessages),
]);
const button = root.querySelector("[data-copy]");
button.click();
await nextTurn();
assert.deepEqual(written, ["npx rea-agents setup"]);
assert.equal(button.textContent, "Copied");
assert.equal(
root.querySelector("#copy-status").textContent,
"Code copied to clipboard.",
);
timers.forEach((callback) => callback());
assert.equal(button.textContent, "Copy");
});
test("copy buttons keep their label when messages are missing", async () => {
for (const [body, clipboard] of [
[[...copyBlock(), copyStatus()], "resolve"],
[[...copyBlock(), copyStatus()], "reject"],
[
[...copyBlock(), copyStatus({ copied: "", "status-copied": "" })],
"resolve",
],
[copyBlock(), "resolve"],
]) {
const { root, timers, written } = await run("main.js", body, { clipboard });
const button = root.querySelector("[data-copy]");
button.click();
await nextTurn();
assert.equal(written.length, clipboard === "resolve" ? 1 : 0);
assert.equal(button.textContent, "Copy");
assert.equal(root.querySelector("#copy-status")?.textContent ?? "", "");
timers.forEach((callback) => callback());
assert.equal(button.textContent, "Copy");
}
});
const githubLink = (labels = {}) =>
h(
"a",
{
class: "nav-github",
"aria-label": "REA on GitHub",
title: "REA on GitHub",
...labels,
},
h("span", { class: "nav-github-count", hidden: "" }),
);
test("the star label follows the page language and plural rules", async () => {
const labels = {
"data-label-one": "REA on GitHub ({count} star)",
"data-label-other": "REA on GitHub ({count} stars)",
};
for (const [stars, lang, expected] of [
[1, "en", "REA on GitHub (1 star)"],
[1234, "en", "REA on GitHub (1,234 stars)"],
[1234, "de", "REA on GitHub (1.234 stars)"],
]) {
const { root } = await run("main.js", [githubLink(labels)], {
stars,
lang,
});
await nextTurn();
assert.equal(
root.querySelector(".nav-github").getAttribute("aria-label"),
expected,
);
}
// Languages without a "one" category use the "other" label.
const { root } = await run(
"main.js",
[githubLink({ "data-label-other": "{count}" })],
{ stars: 1, lang: "ja" },
);
await nextTurn();
assert.equal(
root.querySelector(".nav-github").getAttribute("aria-label"),
"1",
);
});
test("the star label stays unchanged without a localized label", async () => {
const { root } = await run("main.js", [githubLink()], { stars: 1 });
await nextTurn();
const link = root.querySelector(".nav-github");
assert.equal(link.getAttribute("aria-label"), "REA on GitHub");
assert.equal(link.getAttribute("title"), "REA on GitHub");
assert.equal(link.querySelector(".nav-github-count").hidden, true);
});
const percentDemo = (messages) =>
h(
"figure",
{ "data-percent-demo": "", ...messages },
h(
"div",
{ class: "percent-operation", hidden: "" },
h(
"button",
{ "data-percent-operation": "add", "aria-pressed": "true" },
"200 + 10%",
),
h(
"button",
{ "data-percent-operation": "multiply", "aria-pressed": "false" },
"200 × 10%",
),
),
h("span", { "data-percent-expression": "" }, "200 +"),
h("strong", { "data-percent-display": "" }, "20"),
h(
"div",
{ class: "percent-controls", hidden: "" },
h("button", { "data-percent-key": "percent" }, "%"),
h("button", { "data-percent-key": "equals", disabled: "" }, "="),
h("button", { "data-percent-key": "reset" }, "Reset"),
),
h(
"p",
{ "data-percent-status": "" },
"10% of 200 = 20. Then 200 + 20 = 220.",
),
);
test("the percent demo uses page messages and keeps text when one is missing", async () => {
const { root } = await run("home.js", [
percentDemo({
"data-msg-input": "Press % to see what happens to 10.",
"data-msg-result": "200 {operator} {percent} = {result}.",
}),
]);
const status = root.querySelector("[data-percent-status]");
const key = (name) =>
root.querySelector(`[data-percent-key="${name}"]`).click();
assert.equal(status.textContent, "Press % to see what happens to 10.");
key("percent");
assert.equal(status.textContent, "Press % to see what happens to 10.");
key("equals");
assert.equal(status.textContent, "200 + 20 = 220.");
const bare = await run("home.js", [percentDemo({})]);
const bareStatus = bare.root.querySelector("[data-percent-status]");
for (const name of ["percent", "equals", "reset"]) {
bare.root.querySelector(`[data-percent-key="${name}"]`).click();
assert.equal(
bareStatus.textContent,
"10% of 200 = 20. Then 200 + 20 = 220.",
);
}
});
const dinoDemo = (messages) =>
h(
"figure",
{ "data-dino-demo": "", ...messages },
h("svg", { class: "dino-fallback" }),
h("canvas", { hidden: "" }),
h(
"div",
{ class: "dino-controls", hidden: "" },
h("button", { "data-dino-start": "" }, "Play"),
h("button", { "data-dino-jump": "" }, "Jump ↑"),
h("button", { "data-dino-reset": "" }, "Restart"),
h("output", { "data-dino-speed": "" }, "6.0"),
h("input", { "data-dino-slider": "", value: "6" }),
h("input", { "data-dino-auto": "", checked: "" }),
),
h(
"p",
{ "data-dino-status": "" },
"Original rule: start at 6, then increase toward 13.",
),
);
test("the dinosaur game never shows a missing message", async () => {
const { root, canvasText } = await run("dino-demo.js", [
dinoDemo({}),
h("button", { "data-speed-check": "", hidden: "" }, "Replay"),
h("p", { "data-speed-check-status": "" }, "Not replayed yet."),
]);
const start = root.querySelector("[data-dino-start]");
const status = root.querySelector("[data-dino-status]");
const slider = root.querySelector("[data-dino-slider]");
start.click();
start.click();
slider.value = "9.5";
slider.dispatch("input");
root.querySelector("[data-dino-reset]").click();
assert.equal(start.textContent, "Play");
assert.equal(
status.textContent,
"Original rule: start at 6, then increase toward 13.",
);
assert.deepEqual(canvasText, []);
root.querySelector("[data-speed-check]").click();
assert.equal(
root.querySelector("[data-speed-check-status]").textContent,
"Not replayed yet.",
);
});
test("the dinosaur game fills placeholders from page messages", async () => {
const { root, canvasText } = await run("dino-demo.js", [
dinoDemo({
"data-msg-pause": "Pause",
"data-msg-fixed-running":
"Fixed speed: {speed}. Space or Jump clears a cactus.",
"data-msg-distance": "DISTANCE {distance}",
}),
]);
const start = root.querySelector("[data-dino-start]");
const slider = root.querySelector("[data-dino-slider]");
start.click();
assert.equal(start.textContent, "Pause");
slider.value = "9.5";
slider.dispatch("input");
assert.equal(
root.querySelector("[data-dino-status]").textContent,
"Fixed speed: 9.5. Space or Jump clears a cactus.",
);
assert.ok(canvasText.includes("DISTANCE 0000"));
});
+9
View File
@@ -179,6 +179,14 @@ def run_script(root, name, arguments=()):
subprocess.run([sys.executable, str(root / "scripts" / name), *arguments], cwd=root, check=True)
def run_browser_script_tests(root):
"""Run the page-script regressions with the repository's Node.js."""
try:
subprocess.run(["node", str(root / "scripts/test-website-scripts.mjs")], cwd=root, check=True)
except FileNotFoundError as error:
raise OSError("The website script tests need Node.js; see website/README.md.") from error
def preview_handler(site, prefix):
"""Serve one static directory at root or a Pages prefix, preserving redirects."""
class Handler(SimpleHTTPRequestHandler):
@@ -221,6 +229,7 @@ def main():
sync_pages(root, check=True)
for name in ("prepare-website.py", "verify-website.py", "test-website.py"):
run_script(root, name)
run_browser_script_tests(root)
elif args.command == "links":
run_script(root, "audit-website-links.py", ["--report", args.report] if args.report else [])
else:
+6 -3
View File
@@ -89,9 +89,12 @@ python3 scripts/website.py check
This checks shared HTML without rewriting it, prepares generated assets, and
checks page links, copied-text targets, repository documentation links, SEO
metadata and the publishing configuration. It also runs the maintenance
regressions. A stale shared region fails with the command needed to fix it.
PR checks and the manual publisher run this same command. The checks work
offline after the Python dependencies are installed.
regressions and `scripts/test-website-scripts.mjs`, which runs the page scripts
against a small document model with Node.js and checks that a missing message
attribute leaves the existing text in place. A stale shared region fails with
the command needed to fix it. PR checks and the manual publisher run this same
command. The checks work offline after the Python dependencies are installed,
using the repository's Node.js version.
For an optional external-link audit, with GitHub CLI available:
+33 -29
View File
@@ -12,8 +12,14 @@ function initializeDinoDemo(demo) {
const output = demo.querySelector("[data-dino-speed]");
const status = demo.querySelector("[data-dino-status]");
// The page owns the localized messages; {name} marks an inserted value.
// A missing message leaves the current text in place.
const message = (name, values = {}) =>
demo.dataset[name].replace(/\{(\w+)\}/g, (_, key) => values[key]);
demo.dataset[name]?.replace(/\{(\w+)\}/g, (match, key) =>
String(values[key] ?? match),
);
const show = (element, text) => {
if (text) element.textContent = text;
};
const ground = 148;
const dinosaurX = 52;
const dinosaur = new Path2D(
@@ -69,29 +75,23 @@ function initializeDinoDemo(demo) {
context.fillStyle = "#5b6570";
context.font = "13px monospace";
context.textAlign = "right";
context.fillText(
message("msgDistance", {
distance: String(Math.floor(distance / 100)).padStart(4, "0"),
}),
580,
28,
);
if (!running) {
const score = message("msgDistance", {
distance: String(Math.floor(distance / 100)).padStart(4, "0"),
});
if (score) context.fillText(score, 580, 28);
const hint = message(crashed ? "msgCanvasCrashed" : "msgCanvasReady");
if (!running && hint) {
context.textAlign = "center";
context.font = "15px system-ui";
context.fillText(
message(crashed ? "msgCanvasCrashed" : "msgCanvasReady"),
330,
85,
);
context.fillText(hint, 330, 85);
}
};
const stop = (notice) => {
running = false;
cancelAnimationFrame(frame);
start.textContent = message(crashed ? "msgPlayAgain" : "msgPlay");
if (notice) status.textContent = notice;
show(start, message(crashed ? "msgPlayAgain" : "msgPlay"));
show(status, notice);
draw();
};
@@ -104,8 +104,8 @@ function initializeDinoDemo(demo) {
obstacles = [{ x: 550, height: 38 }];
if (auto.checked) speed = SPEED_RULE.start;
elapsed = 0;
start.textContent = message("msgPlay");
status.textContent = message("msgReady");
show(start, message("msgPlay"));
show(status, message("msgReady"));
updateSpeedDisplay();
draw();
};
@@ -162,8 +162,8 @@ function initializeDinoDemo(demo) {
running = true;
lastTime = performance.now();
elapsed = 0;
start.textContent = message("msgPause");
status.textContent = message("msgRunning");
show(start, message("msgPause"));
show(status, message("msgRunning"));
frame = requestAnimationFrame(animate);
};
@@ -193,18 +193,23 @@ function initializeDinoDemo(demo) {
auto.checked = false;
speed = Number(slider.value);
updateSpeedDisplay();
status.textContent = message(
running ? "msgFixedRunning" : "msgFixedStopped",
{ speed: speed.toFixed(1) },
show(
status,
message(running ? "msgFixedRunning" : "msgFixedStopped", {
speed: speed.toFixed(1),
}),
);
draw();
});
auto.addEventListener("change", () => {
if (auto.checked) speed = SPEED_RULE.start;
updateSpeedDisplay();
status.textContent = auto.checked
? message("msgRule")
: message("msgFixed", { speed: speed.toFixed(1) });
show(
status,
auto.checked
? message("msgRule")
: message("msgFixed", { speed: speed.toFixed(1) }),
);
});
document.addEventListener("visibilitychange", () => {
if (document.hidden && running) stop(message("msgPaused"));
@@ -235,7 +240,6 @@ speedCheck?.addEventListener("click", () => {
matches && speed === cell.parentElement.children[1].textContent.trim();
});
const status = document.querySelector("[data-speed-check-status]");
status.textContent = matches
? status.dataset.msgMatch
: status.dataset.msgMismatch;
const result = matches ? status.dataset.msgMatch : status.dataset.msgMismatch;
if (result) status.textContent = result;
});
+11 -3
View File
@@ -6,8 +6,14 @@ function initializePercentDemo() {
const status = demo.querySelector("[data-percent-status]");
const equals = demo.querySelector('[data-percent-key="equals"]');
// The page owns the localized messages; {name} marks an inserted value.
// A missing message leaves the current text in place.
const message = (name, values = {}) =>
demo.dataset[name].replace(/\{(\w+)\}/g, (_, key) => values[key]);
demo.dataset[name]?.replace(/\{(\w+)\}/g, (match, key) =>
String(values[key] ?? match),
);
const show = (element, text) => {
if (text) element.textContent = text;
};
const operations = demo.querySelectorAll("[data-percent-operation]");
let operation = "add";
let stage = "input";
@@ -25,12 +31,14 @@ function initializePercentDemo() {
stage === "input" ? current : stage === "percent" ? percent : result,
);
equals.disabled = stage !== "percent";
status.textContent =
show(
status,
stage === "input"
? message("msgInput")
: stage === "percent"
? message(adding ? "msgPercentAdd" : "msgPercentMultiply")
: message("msgResult", { operator, percent, result });
: message("msgResult", { operator, percent, result }),
);
};
operations.forEach((button) => {
+10 -8
View File
@@ -1,11 +1,15 @@
function initializeCopyButtons() {
// The shared footer owns the localized copy messages.
// The shared footer owns the localized copy messages. Without one, a button
// still copies and keeps its own label.
const status = document.getElementById("copy-status");
if (status === null) return;
const messages = status.dataset;
const messages = status?.dataset ?? {};
document.querySelectorAll("[data-copy]").forEach((button) => {
const label = button.textContent;
const report = (buttonText, statusText) => {
if (buttonText) button.textContent = buttonText;
if (status !== null && statusText) status.textContent = statusText;
};
button.addEventListener("click", async () => {
const target = document.getElementById(button.getAttribute("data-copy"));
if (target === null) return;
@@ -17,8 +21,7 @@ function initializeCopyButtons() {
: content.replace(/\s+/g, " ");
try {
await navigator.clipboard.writeText(text);
button.textContent = messages.copied;
status.textContent = messages.statusCopied;
report(messages.copied, messages.statusCopied);
} catch {
const range = document.createRange();
range.selectNodeContents(target);
@@ -27,8 +30,7 @@ function initializeCopyButtons() {
selection.removeAllRanges();
selection.addRange(range);
}
button.textContent = messages.selected;
status.textContent = messages.statusSelected;
report(messages.selected, messages.statusSelected);
}
window.setTimeout(() => {
@@ -138,7 +140,7 @@ async function initializeGitHubStars() {
const template =
link.dataset[`label${category[0].toUpperCase()}${category.slice(1)}`] ??
link.dataset.labelOther;
if (template === undefined) return;
if (!template) return;
const formatted = new Intl.NumberFormat(lang).format(stars);
const label = template.replace("{count}", formatted);
count.textContent = formatted;