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:
1 parent
3d34185171
commit
dabcd783d5
8 files changed
+558
-43
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
@@ -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
@@ -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:
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user