From dabcd783d55c2f4aba46371b29db21fbe30e4ddd Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Sun, 11 Oct 2026 07:08:42 +0000 Subject: [PATCH] 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 --- .github/workflows/website-check.yml | 4 + .github/workflows/website-pages.yml | 4 + scripts/test-website-scripts.mjs | 481 ++++++++++++++++++++++++++++ scripts/website.py | 9 + website/README.md | 9 +- website/public/assets/dino-demo.js | 62 ++-- website/public/assets/home.js | 14 +- website/public/assets/main.js | 18 +- 8 files changed, 558 insertions(+), 43 deletions(-) create mode 100644 scripts/test-website-scripts.mjs diff --git a/.github/workflows/website-check.yml b/.github/workflows/website-check.yml index 8931ba418d..b4bcf9c280 100644 --- a/.github/workflows/website-check.yml +++ b/.github/workflows/website-check.yml @@ -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 diff --git a/.github/workflows/website-pages.yml b/.github/workflows/website-pages.yml index 3d20f7ea1f..22a37281a2 100644 --- a/.github/workflows/website-pages.yml +++ b/.github/workflows/website-pages.yml @@ -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 diff --git a/scripts/test-website-scripts.mjs b/scripts/test-website-scripts.mjs new file mode 100644 index 0000000000..83f5742780 --- /dev/null +++ b/scripts/test-website-scripts.mjs @@ -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")); +}); diff --git a/scripts/website.py b/scripts/website.py index 3619f1b83c..fd636897a0 100644 --- a/scripts/website.py +++ b/scripts/website.py @@ -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: diff --git a/website/README.md b/website/README.md index cc28319b64..1618bb9cf1 100644 --- a/website/README.md +++ b/website/README.md @@ -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: diff --git a/website/public/assets/dino-demo.js b/website/public/assets/dino-demo.js index 4622685c36..f3ec24821b 100644 --- a/website/public/assets/dino-demo.js +++ b/website/public/assets/dino-demo.js @@ -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; }); diff --git a/website/public/assets/home.js b/website/public/assets/home.js index 2dfdd1b98a..86fa430286 100644 --- a/website/public/assets/home.js +++ b/website/public/assets/home.js @@ -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) => { diff --git a/website/public/assets/main.js b/website/public/assets/main.js index a4da7c9542..bf13052563 100644 --- a/website/public/assets/main.js +++ b/website/public/assets/main.js @@ -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;