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>
150 lines
5.1 KiB
JavaScript
150 lines
5.1 KiB
JavaScript
function initializePercentDemo() {
|
||
const demo = document.querySelector("[data-percent-demo]");
|
||
if (demo === null) return;
|
||
const display = demo.querySelector("[data-percent-display]");
|
||
const expression = demo.querySelector("[data-percent-expression]");
|
||
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, (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";
|
||
|
||
const render = () => {
|
||
const adding = operation === "add";
|
||
const previous = 200;
|
||
const current = 10;
|
||
const percent = adding ? (current * previous) / 100 : current / 100;
|
||
const result = adding ? previous + percent : previous * percent;
|
||
const operator = adding ? "+" : "×";
|
||
expression.textContent =
|
||
stage === "result" ? `200 ${operator} ${percent} =` : `200 ${operator}`;
|
||
display.textContent = String(
|
||
stage === "input" ? current : stage === "percent" ? percent : result,
|
||
);
|
||
equals.disabled = stage !== "percent";
|
||
show(
|
||
status,
|
||
stage === "input"
|
||
? message("msgInput")
|
||
: stage === "percent"
|
||
? message(adding ? "msgPercentAdd" : "msgPercentMultiply")
|
||
: message("msgResult", { operator, percent, result }),
|
||
);
|
||
};
|
||
|
||
operations.forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
operation = button.getAttribute("data-percent-operation");
|
||
stage = "input";
|
||
operations.forEach((candidate) => {
|
||
candidate.setAttribute("aria-pressed", String(candidate === button));
|
||
});
|
||
render();
|
||
});
|
||
});
|
||
demo.querySelectorAll("[data-percent-key]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
const key = button.getAttribute("data-percent-key");
|
||
stage =
|
||
key === "reset" ? "input" : key === "percent" ? "percent" : "result";
|
||
render();
|
||
});
|
||
});
|
||
demo.querySelector(".percent-operation").hidden = false;
|
||
demo.querySelector(".percent-controls").hidden = false;
|
||
render();
|
||
}
|
||
|
||
function initializeExpertAssembly() {
|
||
const excerpt = document.querySelector("[data-expert-assembly]");
|
||
if (excerpt && window.matchMedia("(max-width: 640px)").matches) {
|
||
excerpt.open = false;
|
||
}
|
||
}
|
||
|
||
function initializeHomeNavigation() {
|
||
const menu = document.querySelector("[data-home-nav]");
|
||
if (menu === null) return;
|
||
const currentLabel = menu.querySelector("[data-home-nav-current]");
|
||
if (currentLabel === null) return;
|
||
const wideScreen = window.matchMedia("(min-width: 1440px)");
|
||
const entries = Array.from(menu.querySelectorAll('a[href^="#"]'))
|
||
.map((link) => ({
|
||
link,
|
||
target: document.getElementById(link.getAttribute("href").slice(1)),
|
||
}))
|
||
.filter((entry) => entry.target !== null);
|
||
if (entries.length === 0) return;
|
||
|
||
const updatePosition = () => {
|
||
const offset = wideScreen.matches ? 40 : 88;
|
||
let current = entries[0];
|
||
entries.forEach((entry) => {
|
||
if (entry.target.getBoundingClientRect().top <= offset) current = entry;
|
||
});
|
||
if (
|
||
window.scrollY + window.innerHeight >=
|
||
document.documentElement.scrollHeight - 2
|
||
) {
|
||
const selected = entries.find(
|
||
(entry) => entry.link.hash === window.location.hash,
|
||
);
|
||
const selectedTop = selected?.target.getBoundingClientRect().top;
|
||
current =
|
||
selectedTop !== undefined &&
|
||
selectedTop >= 0 &&
|
||
selectedTop < window.innerHeight
|
||
? selected
|
||
: entries[entries.length - 1];
|
||
}
|
||
entries.forEach((entry) => {
|
||
if (entry === current)
|
||
entry.link.setAttribute("aria-current", "location");
|
||
else entry.link.removeAttribute("aria-current");
|
||
});
|
||
currentLabel.textContent = current.link.textContent;
|
||
};
|
||
const updateLayout = () => {
|
||
menu.open = wideScreen.matches;
|
||
updatePosition();
|
||
};
|
||
let scheduled = false;
|
||
const schedulePosition = () => {
|
||
if (scheduled) return;
|
||
scheduled = true;
|
||
window.requestAnimationFrame(() => {
|
||
scheduled = false;
|
||
updatePosition();
|
||
});
|
||
};
|
||
entries.forEach(({ link, target }) => {
|
||
link.addEventListener("click", () => {
|
||
if (!wideScreen.matches) menu.open = false;
|
||
const heading = target.querySelector("h2") ?? target;
|
||
heading.setAttribute("tabindex", "-1");
|
||
window.requestAnimationFrame(() =>
|
||
heading.focus({ preventScroll: true }),
|
||
);
|
||
});
|
||
});
|
||
wideScreen.addEventListener("change", updateLayout);
|
||
window.addEventListener("scroll", schedulePosition, { passive: true });
|
||
window.addEventListener("resize", schedulePosition, { passive: true });
|
||
window.addEventListener("hashchange", schedulePosition);
|
||
window.addEventListener("load", schedulePosition);
|
||
updateLayout();
|
||
}
|
||
|
||
initializeExpertAssembly();
|
||
initializePercentDemo();
|
||
initializeHomeNavigation();
|