Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c865e20a0b | ||
|
|
0fd4021842 | ||
|
|
4db6ff461f | ||
|
|
ce58aebc0e | ||
|
|
80f6365523 | ||
|
|
dabcd783d5 | ||
|
|
3d34185171 |
@@ -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,8 +59,15 @@ 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
|
||||
# An out-of-date translation only warns in checks; publish it with its notice.
|
||||
- name: Refresh translation notices
|
||||
run: python3 scripts/website.py sync
|
||||
- name: Record the published commit
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
|
||||
@@ -222,6 +222,7 @@ export const SCOPES = {
|
||||
website: [
|
||||
"website/**",
|
||||
"scripts/*website*.py",
|
||||
"scripts/test-website-scripts.mjs",
|
||||
".github/workflows/website-check.yml",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
+212
-2
@@ -2,7 +2,10 @@
|
||||
|
||||
import contextlib
|
||||
import importlib.util
|
||||
from html import escape
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import tempfile
|
||||
@@ -137,7 +140,7 @@ class WebsiteMaintenanceChecks(unittest.TestCase):
|
||||
def test_shared_edits_preserve_body_and_catch_drift_before_rewriting(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
for name in ("public", "templates"):
|
||||
for name in ("public", "templates", "locales"):
|
||||
shutil.copytree(ROOT / "website" / name, root / "website" / name)
|
||||
page = root / "website/public/blog/touhou-reconstruction/index.html"
|
||||
original = page.read_text()
|
||||
@@ -159,7 +162,7 @@ class WebsiteMaintenanceChecks(unittest.TestCase):
|
||||
def test_new_page_escapes_metadata_stays_a_draft_and_joins_sitemap_when_ready(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
for name in ("public", "templates"):
|
||||
for name in ("public", "templates", "locales"):
|
||||
shutil.copytree(ROOT / "website" / name, root / "website" / name)
|
||||
site = root / "website/public"
|
||||
page = WEBSITE.new_page(root, "blog/new-example", 'A <test> & "result"', 'Check "quotes" & <markup>.')
|
||||
@@ -242,5 +245,212 @@ class WebsiteMaintenanceChecks(unittest.TestCase):
|
||||
worker.join()
|
||||
|
||||
|
||||
|
||||
def site_copy(directory):
|
||||
"""Copy the authored website inputs without existing translated pages."""
|
||||
root = Path(directory)
|
||||
for name in ("public", "templates", "locales"):
|
||||
shutil.copytree(ROOT / "website" / name, root / "website" / name)
|
||||
for code in WEBSITE.LANGUAGES:
|
||||
if code != "en":
|
||||
shutil.rmtree(root / "website/public" / code, ignore_errors=True)
|
||||
return root
|
||||
|
||||
|
||||
def zh_hant(key):
|
||||
"""Return one zh-Hant UI string, so assertions follow the locale file."""
|
||||
strings = json.loads((ROOT / "website/locales/zh-hant.json").read_text())["strings"]
|
||||
return escape(strings[key])
|
||||
|
||||
|
||||
def quietly(function, *arguments):
|
||||
"""Run a command helper without its progress output."""
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
return function(*arguments)
|
||||
|
||||
|
||||
def mark_reviewed(path):
|
||||
"""Apply the reviewer's change: reviewed status and date, without noindex.
|
||||
|
||||
A reviewed translation has its own search title, so give the test page one.
|
||||
"""
|
||||
source = re.sub(r"<title>(.*?)</title>", r"<title>\1 (zh-Hant)</title>", path.read_text(), count=1)
|
||||
source = source.replace('status="draft"', 'status="reviewed" reviewed="2026-10-11"', 1)
|
||||
path.write_text(source.replace(' <meta name="robots" content="noindex" />\n', "", 1))
|
||||
|
||||
|
||||
def translation_errors(root):
|
||||
"""Return the verifier's translation errors and warnings for a site copy."""
|
||||
return VERIFY.check_translations(root, VERIFY.parse_pages(root / "website/public"))
|
||||
|
||||
|
||||
class WebsiteTranslationChecks(unittest.TestCase):
|
||||
"""Create translations through the contributor command and check what readers get."""
|
||||
|
||||
def test_translate_creates_an_unlinked_noindex_draft(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = site_copy(directory)
|
||||
site = root / "website/public"
|
||||
english = (site / "get-started/index.html").read_text()
|
||||
page = quietly(WEBSITE.new_translation, root, "zh-hant", "get-started")
|
||||
source = page.read_text()
|
||||
self.assertEqual(page, site / "zh-hant/get-started/index.html")
|
||||
self.assertIn('<html lang="zh-Hant">', source)
|
||||
self.assertIn('<meta name="robots" content="noindex" />', source)
|
||||
self.assertEqual(
|
||||
WEBSITE.translation_record(source),
|
||||
{"source-sha256": WEBSITE.source_digest(english), "status": "draft"},
|
||||
)
|
||||
self.assertIn('href="../../assets/styles.css"', source)
|
||||
self.assertIn('<a class="brand" href="../../"', source)
|
||||
self.assertIn(
|
||||
'<a href="../../zh-hant/get-started/" class="nav-start" aria-current="page">'
|
||||
f'{zh_hant("navGetStarted")}</a>',
|
||||
source,
|
||||
)
|
||||
self.assertIn(
|
||||
f'<a href="../../showcase/" hreflang="en">{zh_hant("navShowcases")}'
|
||||
'<span class="nav-badge" lang="en">EN</span></a>',
|
||||
source,
|
||||
)
|
||||
self.assertIn('<li><a href="../../get-started/" hreflang="en" lang="en">English</a></li>', source)
|
||||
self.assertIn('<meta property="og:locale" content="zh_TW" />', source)
|
||||
self.assertNotIn('rel="alternate"', source)
|
||||
self.assertIn(WEBSITE.NOTICE_START + WEBSITE.NOTICE_END, source)
|
||||
self.assertEqual(english, (site / "get-started/index.html").read_text())
|
||||
errors, warnings = translation_errors(root)
|
||||
self.assertEqual(errors, [])
|
||||
self.assertLessEqual({file for file, _ in warnings}, {"website/locales/zh-hant.json"})
|
||||
self.assertEqual(VERIFY.check_site(site)[3], [])
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
PREPARE.prepare_sitemap(site)
|
||||
self.assertNotIn("zh-hant", (site / "sitemap.xml").read_text())
|
||||
for language, route, message in (
|
||||
("zh-hant", "get-started", "already exists"),
|
||||
("en", "faq", "Choose one of"),
|
||||
("ja", "missing", "No English page"),
|
||||
("ja", "zh-hant/get-started", "without a language directory"),
|
||||
("ja", "../outside", "directory route"),
|
||||
):
|
||||
with self.subTest(language=language, route=route):
|
||||
with self.assertRaisesRegex(ValueError, message):
|
||||
quietly(WEBSITE.new_translation, root, language, route)
|
||||
|
||||
def test_reviewed_translations_join_menus_hreflang_and_the_sitemap(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = site_copy(directory)
|
||||
site = root / "website/public"
|
||||
mark_reviewed(quietly(WEBSITE.new_translation, root, "zh-hant", "get-started"))
|
||||
quietly(WEBSITE.sync_pages, root)
|
||||
english = (site / "get-started/index.html").read_text()
|
||||
for tag, url in (
|
||||
("en", "https://rea.tools/get-started/"),
|
||||
("zh-Hant", "https://rea.tools/zh-hant/get-started/"),
|
||||
("x-default", "https://rea.tools/get-started/"),
|
||||
):
|
||||
self.assertIn(f'<link rel="alternate" hreflang="{tag}" href="{url}" />', english)
|
||||
self.assertIn('<meta property="og:locale:alternate" content="zh_TW" />', english)
|
||||
self.assertIn(
|
||||
'<li><a href="../zh-hant/get-started/" hreflang="zh-Hant" lang="zh-Hant">繁體中文</a></li>', english
|
||||
)
|
||||
self.assertNotIn("language-menu", (site / "faq/index.html").read_text())
|
||||
translated = (site / "zh-hant/get-started/index.html").read_text()
|
||||
self.assertIn('<meta property="og:locale:alternate" content="en_US" />', translated)
|
||||
|
||||
# A reviewed home page becomes the menu's fallback for untranslated pages.
|
||||
mark_reviewed(quietly(WEBSITE.new_translation, root, "zh-hant", "/"))
|
||||
quietly(WEBSITE.sync_pages, root)
|
||||
self.assertIn(
|
||||
'<li><a href="../zh-hant/" hreflang="zh-Hant" lang="zh-Hant">繁體中文</a></li>',
|
||||
(site / "faq/index.html").read_text(),
|
||||
)
|
||||
translated = (site / "zh-hant/get-started/index.html").read_text()
|
||||
self.assertIn('<a class="brand" href="../../zh-hant/"', translated)
|
||||
home = (site / "zh-hant/index.html").read_text()
|
||||
self.assertIn('href="get-started/"', home)
|
||||
self.assertIn('href="../showcase/"', home)
|
||||
self.assertIn('href="../assets/styles.css"', home)
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
PREPARE.prepare_sitemap(site)
|
||||
sitemap = (site / "sitemap.xml").read_text()
|
||||
self.assertIn("https://rea.tools/zh-hant/get-started/", sitemap)
|
||||
self.assertIn("https://rea.tools/zh-hant/", sitemap)
|
||||
self.assertEqual(VERIFY.check_site(site)[3], [])
|
||||
self.assertEqual(translation_errors(root)[0], [])
|
||||
|
||||
def test_english_commands_sync_and_text_changes_show_a_notice(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = site_copy(directory)
|
||||
site = root / "website/public"
|
||||
english_path = site / "get-started/index.html"
|
||||
page = quietly(WEBSITE.new_translation, root, "zh-hant", "get-started")
|
||||
english = english_path.read_text()
|
||||
identifier = WEBSITE.synced_code_ids(english)[0]
|
||||
block = WEBSITE.code_block(identifier)
|
||||
|
||||
# A command-only change is copied and keeps the translation current.
|
||||
command = block.search(english).group(2) + " --test-flag"
|
||||
english_path.write_text(block.sub(lambda match: match.group(1) + command + match.group(3), english))
|
||||
with self.assertRaisesRegex(ValueError, "Shared HTML is stale: zh-hant/get-started/index.html"):
|
||||
quietly(WEBSITE.sync_pages, root, True)
|
||||
quietly(WEBSITE.sync_pages, root)
|
||||
self.assertEqual(block.search(page.read_text()).group(2), command)
|
||||
self.assertNotIn('class="translation-notice"', page.read_text())
|
||||
self.assertEqual(translation_errors(root), ([], []))
|
||||
|
||||
# A text change marks the translation out of date.
|
||||
unchanged = english_path.read_text()
|
||||
changed = unchanged.replace(" </main>", " <p>A new English paragraph.</p>\n </main>", 1)
|
||||
english_path.write_text(changed)
|
||||
self.assertEqual(quietly(WEBSITE.sync_pages, root, True), [page])
|
||||
errors, warnings = translation_errors(root)
|
||||
self.assertEqual(errors, [])
|
||||
self.assertTrue(any("has changed since this translation" in message for _, message in warnings))
|
||||
quietly(WEBSITE.sync_pages, root)
|
||||
link = f'<a href="../../get-started/" hreflang="en">{zh_hant("staleNoticeLink")}</a>'
|
||||
self.assertIn(
|
||||
f'<p class="translation-notice" role="note">{zh_hant("staleNotice").replace("{link}", link)}</p>',
|
||||
page.read_text(),
|
||||
)
|
||||
report = quietly(WEBSITE.translation_report, root)
|
||||
self.assertIn(f' after updating the translation, record source-sha256="{WEBSITE.source_digest(changed)}"', report)
|
||||
|
||||
english_path.write_text(unchanged)
|
||||
quietly(WEBSITE.sync_pages, root)
|
||||
self.assertNotIn('class="translation-notice"', page.read_text())
|
||||
|
||||
page.write_text(block.sub(lambda match: match.group(1) + "edited" + match.group(3), page.read_text()))
|
||||
self.assertTrue(any(f"{identifier} must match" in error for error in translation_errors(root)[0]))
|
||||
|
||||
def test_translations_keep_structure_and_cjk_spacing(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = site_copy(directory)
|
||||
page = quietly(WEBSITE.new_translation, root, "zh-hant", "get-started")
|
||||
original = page.read_text()
|
||||
first, second = WEBSITE.copy_targets(original)[:2]
|
||||
|
||||
def added(paragraph):
|
||||
return (" </main>", f" {paragraph}\n </main>")
|
||||
|
||||
cases = (
|
||||
(*added("<p>完成後\n 重新啟動。</p>"), "renders as a gap"),
|
||||
('<main id="main"', '<main id="moved"', "element IDs differ"),
|
||||
(f'data-copy="{first}"', f'data-copy="{second}"', "copy buttons differ"),
|
||||
(' <meta name="robots" content="noindex" />\n', "", "draft translation needs"),
|
||||
('status="draft"', 'status="final"', "status must be draft or reviewed"),
|
||||
)
|
||||
for old, new, message in cases:
|
||||
with self.subTest(message=message):
|
||||
self.assertIn(old, original)
|
||||
page.write_text(original.replace(old, new, 1))
|
||||
self.assertTrue(any(message in error for error in translation_errors(root)[0]))
|
||||
page.write_text(original.replace(*added("<p>重新啟動 REA 用戶端。</p>"), 1))
|
||||
self.assertEqual(translation_errors(root)[0], [])
|
||||
page.write_text(original.replace(*added("<p><code>extra-command</code></p>"), 1))
|
||||
errors, warnings = translation_errors(root)
|
||||
self.assertEqual(errors, [])
|
||||
self.assertTrue(any("inline code differs" in message for _, message in warnings))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+221
-8
@@ -1,6 +1,8 @@
|
||||
"""Check website references, search metadata and the sole production publisher."""
|
||||
|
||||
from collections import Counter
|
||||
from html.parser import HTMLParser
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import struct
|
||||
@@ -10,7 +12,21 @@ from urllib.parse import unquote, urlsplit
|
||||
import xml.etree.ElementTree as ET
|
||||
from zipfile import BadZipFile, ZipFile
|
||||
|
||||
from website import NOTES_FIXTURES, SITE_ORIGIN, authored_markdown, page_url, sync_pages
|
||||
from website import (
|
||||
LANGUAGES,
|
||||
NOTES_FIXTURES,
|
||||
SITE_ORIGIN,
|
||||
SiteState,
|
||||
authored_markdown,
|
||||
code_block,
|
||||
copy_targets,
|
||||
page_language,
|
||||
page_url,
|
||||
source_digest,
|
||||
sync_pages,
|
||||
synced_code_ids,
|
||||
translation_record,
|
||||
)
|
||||
|
||||
LEGACY_ORIGIN = "morluto.github.io"
|
||||
SITE_TEXT_URL = re.compile(
|
||||
@@ -167,8 +183,9 @@ def check_search_metadata(site, pages):
|
||||
title = page.titles[0].strip() if len(page.titles) == 1 else ""
|
||||
if not title:
|
||||
errors.append(f"{label}: needs exactly one nonempty head title")
|
||||
if page.lang != "en" or page.h1_count != 1:
|
||||
errors.append(f"{label}: needs lang=en and exactly one h1")
|
||||
tag = LANGUAGES[page_language(site, path)[0]].tag
|
||||
if page.lang != tag or page.h1_count != 1:
|
||||
errors.append(f"{label}: needs lang={tag} and exactly one h1")
|
||||
if label in NOTES_FIXTURES and not page.noindex():
|
||||
errors.append(
|
||||
f"{label}: example application must have a head noindex directive"
|
||||
@@ -280,15 +297,21 @@ def check_search_metadata(site, pages):
|
||||
return len(indexable), errors
|
||||
|
||||
|
||||
def check_site(site):
|
||||
"""Check assets, HTML fragment destinations and SVG syntax."""
|
||||
def parse_pages(site):
|
||||
"""Parse every HTML page once for reference, metadata and translation checks."""
|
||||
pages = {}
|
||||
errors = []
|
||||
references = 0
|
||||
for path in sorted(site.rglob("*.html")):
|
||||
parser = HtmlReferences()
|
||||
parser.feed(path.read_text(encoding="utf-8"))
|
||||
pages[path] = parser
|
||||
return pages
|
||||
|
||||
|
||||
def check_site(site):
|
||||
"""Check assets, HTML fragment destinations and SVG syntax."""
|
||||
pages = parse_pages(site)
|
||||
errors = []
|
||||
references = 0
|
||||
if site / "index.html" not in pages:
|
||||
errors.append("website/public/index.html is missing")
|
||||
|
||||
@@ -346,6 +369,190 @@ def check_site(site):
|
||||
return len(pages), references, indexable, errors
|
||||
|
||||
|
||||
# Han, kana and CJK punctuation; a space between two of them renders as a gap.
|
||||
CJK_GAP = re.compile(
|
||||
r"([、-〿-ヿ㐀-䶿一-鿿豈-!-])"
|
||||
r"\s+([、-〿-ヿ㐀-䶿一-鿿豈-!-])"
|
||||
)
|
||||
CODE_TAGS = {"code", "kbd", "pre", "script", "style"}
|
||||
|
||||
|
||||
class TranslationText(HTMLParser):
|
||||
"""Collect prose, inline code and message attributes for comparing translations."""
|
||||
|
||||
def __init__(self, synced):
|
||||
super().__init__()
|
||||
self.synced = set(synced)
|
||||
self.stack = []
|
||||
self.prose = []
|
||||
self.inline_code = Counter()
|
||||
self.messages = Counter()
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
attributes = dict(attrs)
|
||||
self.messages.update(name for name in attributes if name.startswith("data-msg-"))
|
||||
if tag in CODE_TAGS:
|
||||
self.stack.append([tag, attributes.get("id"), ""])
|
||||
|
||||
def handle_startendtag(self, tag, attrs):
|
||||
attributes = dict(attrs)
|
||||
self.messages.update(name for name in attributes if name.startswith("data-msg-"))
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if self.stack and self.stack[-1][0] == tag:
|
||||
name, identifier, text = self.stack.pop()
|
||||
if name in ("code", "kbd") and identifier not in self.synced:
|
||||
self.inline_code[" ".join(text.split())] += 1
|
||||
if self.stack:
|
||||
self.stack[-1][2] += text
|
||||
|
||||
def handle_data(self, data):
|
||||
if self.stack:
|
||||
self.stack[-1][2] += data
|
||||
else:
|
||||
self.prose.append(data)
|
||||
|
||||
|
||||
def translation_warning(path, root, message):
|
||||
"""Return a warning tied to a repository file."""
|
||||
return (path.relative_to(root).as_posix(), message)
|
||||
|
||||
|
||||
def check_translations(root, pages):
|
||||
"""Check translation records, copied code, structure parity and CJK spacing.
|
||||
|
||||
A changed English source is a warning: its translation keeps working, shows a
|
||||
notice after sync and is updated later. Errors cover what sync or the
|
||||
translator controls directly.
|
||||
"""
|
||||
site = root / "website/public"
|
||||
errors = []
|
||||
warnings = []
|
||||
try:
|
||||
state = SiteState(root)
|
||||
except ValueError as error:
|
||||
return [str(error)], []
|
||||
for path, page in pages.items():
|
||||
language, english = page_language(site, path)
|
||||
if language == "en":
|
||||
continue
|
||||
label = path.relative_to(site).as_posix()
|
||||
source = path.read_text(encoding="utf-8")
|
||||
record = translation_record(source)
|
||||
english_path = site / english
|
||||
if not english_path.is_file():
|
||||
errors.append(f"{label}: translates {english}, which does not exist")
|
||||
continue
|
||||
english_source = english_path.read_text(encoding="utf-8")
|
||||
if record is None:
|
||||
errors.append(f"{label}: needs one <!-- website:translation source-sha256=... status=... --> record")
|
||||
continue
|
||||
status = record.get("status")
|
||||
if status not in ("draft", "reviewed"):
|
||||
errors.append(f"{label}: translation status must be draft or reviewed")
|
||||
if status == "draft" and not page.noindex():
|
||||
errors.append(f"{label}: a draft translation needs <meta name=\"robots\" content=\"noindex\" />")
|
||||
if status == "reviewed":
|
||||
if page.noindex():
|
||||
errors.append(f"{label}: remove noindex from a reviewed translation")
|
||||
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", record.get("reviewed", "")):
|
||||
errors.append(f"{label}: a reviewed translation needs reviewed=\"YYYY-MM-DD\"")
|
||||
fresh = record.get("source-sha256") == source_digest(english_source)
|
||||
if not fresh:
|
||||
warnings.append(translation_warning(
|
||||
path, root,
|
||||
f"The English page {english} has changed since this translation. Update the translation, "
|
||||
"then set source-sha256 to the new value from python3 scripts/website.py translations.",
|
||||
))
|
||||
|
||||
synced = synced_code_ids(english_source)
|
||||
for identifier in synced:
|
||||
expected = code_block(identifier).search(english_source).group(2)
|
||||
found = code_block(identifier).findall(source)
|
||||
if len(found) != 1 or found[0][1] != expected:
|
||||
errors.append(f"{label}: code block {identifier} must match the English page; run sync")
|
||||
|
||||
english_page = pages[english_path]
|
||||
differences = []
|
||||
if page.ids != english_page.ids:
|
||||
differences.append(
|
||||
f"element IDs differ (missing {sorted(english_page.ids - page.ids)}, extra {sorted(page.ids - english_page.ids)})"
|
||||
)
|
||||
if copy_targets(source) != copy_targets(english_source):
|
||||
differences.append("copy buttons differ from the English page")
|
||||
translated = TranslationText(synced)
|
||||
translated.feed(source)
|
||||
original = TranslationText(synced)
|
||||
original.feed(english_source)
|
||||
if translated.messages != original.messages:
|
||||
differences.append("data-msg-* message attributes differ from the English page")
|
||||
for difference in differences:
|
||||
if fresh:
|
||||
errors.append(f"{label}: {difference}")
|
||||
else:
|
||||
warnings.append(translation_warning(path, root, difference))
|
||||
if fresh and translated.inline_code != original.inline_code:
|
||||
changed = sorted((translated.inline_code - original.inline_code) + (original.inline_code - translated.inline_code))
|
||||
warnings.append(translation_warning(
|
||||
path, root, f"inline code differs from the English page: {', '.join(repr(text) for text in changed[:5])}",
|
||||
))
|
||||
|
||||
if language in ("ja", "zh-hans", "zh-hant"):
|
||||
for text in translated.prose:
|
||||
for match in CJK_GAP.finditer(text):
|
||||
errors.append(
|
||||
f"{label}: whitespace between {match.group(1)!r} and {match.group(2)!r} renders as a gap; "
|
||||
"keep CJK text on one line without spaces"
|
||||
)
|
||||
|
||||
for reference in page.references:
|
||||
url = urlsplit(reference)
|
||||
if url.scheme or url.netloc or not url.path or reference.startswith("/"):
|
||||
continue
|
||||
target = (path.parent / unquote(url.path)).resolve()
|
||||
if target.is_dir():
|
||||
target /= "index.html"
|
||||
if not target.is_relative_to(site.resolve()):
|
||||
continue
|
||||
target_language, target_english = page_language(site.resolve(), target)
|
||||
if (
|
||||
target_language == "en"
|
||||
and target_english != english
|
||||
and state.translations.get((language, target_english)) == "reviewed"
|
||||
):
|
||||
warnings.append(translation_warning(
|
||||
path, root, f"links to the English {target_english}; link to its reviewed translation instead",
|
||||
))
|
||||
|
||||
english_strings = state.locales["en"]["strings"]
|
||||
for code in LANGUAGES:
|
||||
if code == "en" or not any(key[0] == code for key in state.translations):
|
||||
continue
|
||||
locale = state.locales[code]
|
||||
same = sorted(
|
||||
key for key, value in locale["strings"].items()
|
||||
if value == english_strings[key] and key not in locale.get("sameAsEnglish", [])
|
||||
)
|
||||
if same:
|
||||
warnings.append((f"website/locales/{code}.json", f"UI strings still in English: {', '.join(same)}"))
|
||||
return errors, warnings
|
||||
|
||||
|
||||
def report_warnings(warnings):
|
||||
"""Print warnings, as GitHub annotations and a job summary when running in Actions."""
|
||||
for file, message in warnings:
|
||||
print(f"Warning: {file}: {message}")
|
||||
if os.environ.get("GITHUB_ACTIONS") != "true" or not warnings:
|
||||
return
|
||||
for file, message in warnings:
|
||||
print(f"::warning file={file}::{message}")
|
||||
summary = os.environ.get("GITHUB_STEP_SUMMARY")
|
||||
if summary:
|
||||
with open(summary, "a", encoding="utf-8") as output:
|
||||
output.write("### Website warnings\n\n")
|
||||
output.writelines(f"- `{file}`: {message}\n" for file, message in warnings)
|
||||
|
||||
|
||||
def check_publisher(root):
|
||||
"""Detect a second Pages action that could replace the website."""
|
||||
expected = root / ".github/workflows/website-pages.yml"
|
||||
@@ -404,10 +611,16 @@ def main():
|
||||
errors.extend(check_example_archive(root / "website/public"))
|
||||
errors.extend(check_publisher(root))
|
||||
errors.extend(check_repository_links(root))
|
||||
translation_errors, warnings = check_translations(root, parse_pages(root / "website/public"))
|
||||
errors.extend(translation_errors)
|
||||
try:
|
||||
sync_pages(root, check=True)
|
||||
for path in sync_pages(root, check=True):
|
||||
warnings.append(translation_warning(
|
||||
path, root, "the out-of-date notice needs refreshing; run python3 scripts/website.py sync",
|
||||
))
|
||||
except (OSError, ValueError, KeyError) as error:
|
||||
errors.append(str(error))
|
||||
report_warnings(warnings)
|
||||
if errors:
|
||||
print("Website checks failed:\n" + "\n".join(errors), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
+442
-28
@@ -1,26 +1,53 @@
|
||||
"""Maintain shared HTML and run the static website's contributor commands."""
|
||||
|
||||
import argparse
|
||||
from collections import namedtuple
|
||||
from functools import partial
|
||||
from hashlib import sha256
|
||||
from html import escape
|
||||
from html.parser import HTMLParser
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
from pathlib import Path, PurePosixPath
|
||||
import posixpath
|
||||
import re
|
||||
from string import Template
|
||||
import subprocess
|
||||
import sys
|
||||
from textwrap import indent
|
||||
from urllib.parse import quote, urlsplit
|
||||
from urllib.parse import quote, urlsplit, urlunsplit
|
||||
|
||||
|
||||
SITE_ORIGIN = "https://rea.tools"
|
||||
NOTES_FIXTURES = {"examples/notes-electron/index.html", "examples/notes-web/index.html"}
|
||||
# Each label is a key in website/locales/<language>.json.
|
||||
NAVIGATION = (
|
||||
("Showcases", "showcase/"),
|
||||
("Blog", "blog/"),
|
||||
("Get started", "get-started/"),
|
||||
("Guides", "guides/"),
|
||||
("FAQ", "faq/"),
|
||||
("navShowcases", "showcase/"),
|
||||
("navBlog", "blog/"),
|
||||
("navGetStarted", "get-started/"),
|
||||
("navGuides", "guides/"),
|
||||
("navFaq", "faq/"),
|
||||
)
|
||||
Language = namedtuple("Language", "tag name og_locale")
|
||||
# English stays at the root; every other language lives under its directory.
|
||||
# The order is the language menu's order.
|
||||
LANGUAGES = {
|
||||
"en": Language("en", "English", "en_US"),
|
||||
"zh-hans": Language("zh-Hans", "简体中文", "zh_CN"),
|
||||
"zh-hant": Language("zh-Hant", "繁體中文", "zh_TW"),
|
||||
"ja": Language("ja", "日本語", "ja_JP"),
|
||||
"ko": Language("ko", "한국어", "ko_KR"),
|
||||
"de": Language("de", "Deutsch", "de_DE"),
|
||||
"fr": Language("fr", "Français", "fr_FR"),
|
||||
}
|
||||
TRANSLATION_STATUSES = ("draft", "reviewed")
|
||||
TRANSLATION_RECORD = re.compile(r"<!-- website:translation((?:\s+[\w-]+=\"[^\"]*\")*)\s*-->")
|
||||
SHARED_REGION = re.compile(r"<!-- website:([\w-]+):start -->.*?<!-- website:\1:end -->", re.S)
|
||||
NOTICE_START = " <!-- website:translation-notice:start -->\n"
|
||||
NOTICE_END = " <!-- website:translation-notice:end -->"
|
||||
GLOBE_PATH = (
|
||||
"M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18Z M3.6 9h16.8 M3.6 15h16.8 "
|
||||
"M12 3c2.4 2.5 3.6 5.5 3.6 9s-1.2 6.5-3.6 9c-2.4-2.5-3.6-5.5-3.6-9s1.2-6.5 3.6-9Z"
|
||||
)
|
||||
|
||||
|
||||
@@ -75,10 +102,14 @@ class PageMetadata(HTMLParser):
|
||||
self.titles[-1] += data
|
||||
|
||||
|
||||
def file_route(relative):
|
||||
"""Map a site-relative HTML file to its public route."""
|
||||
return relative.removesuffix("index.html") if PurePosixPath(relative).name == "index.html" else relative
|
||||
|
||||
|
||||
def page_route(site, path):
|
||||
"""Map an HTML file to its public route without a separate page registry."""
|
||||
route = path.relative_to(site).as_posix()
|
||||
return route.removesuffix("index.html") if path.name == "index.html" else route
|
||||
return file_route(path.relative_to(site).as_posix())
|
||||
|
||||
|
||||
def page_url(site, path):
|
||||
@@ -86,30 +117,227 @@ def page_url(site, path):
|
||||
return f"{SITE_ORIGIN}/{quote(page_route(site, path), safe='/')}"
|
||||
|
||||
|
||||
def shared_blocks(root, path, source):
|
||||
def page_language(site, path):
|
||||
"""Return a page's language directory and the site-relative file of its English source."""
|
||||
relative = path.relative_to(site).as_posix()
|
||||
first, separator, rest = relative.partition("/")
|
||||
if separator and first in LANGUAGES and first != "en":
|
||||
return first, rest
|
||||
return "en", relative
|
||||
|
||||
|
||||
def load_locales(root):
|
||||
"""Read the UI strings for every language and require the same keys as English."""
|
||||
directory = root / "website/locales"
|
||||
locales = {}
|
||||
for code in LANGUAGES:
|
||||
path = directory / f"{code}.json"
|
||||
try:
|
||||
locale = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as error:
|
||||
raise ValueError(f"website/locales/{code}.json: cannot read UI strings: {error}") from error
|
||||
locales[code] = locale
|
||||
expected = set(locales["en"]["strings"])
|
||||
for code, locale in locales.items():
|
||||
label = f"website/locales/{code}.json"
|
||||
strings = locale.get("strings")
|
||||
if not isinstance(strings, dict) or set(strings) != expected:
|
||||
missing = sorted(expected - set(strings or {}))
|
||||
extra = sorted(set(strings or {}) - expected)
|
||||
raise ValueError(f"{label}: strings must match en.json (missing {missing}, extra {extra})")
|
||||
if any(not isinstance(value, str) or not value.strip() for value in strings.values()):
|
||||
raise ValueError(f"{label}: every UI string must be nonempty text")
|
||||
stars = locale.get("githubStars")
|
||||
if not isinstance(stars, dict) or "other" not in stars or not set(stars) <= {"zero", "one", "two", "few", "many", "other"}:
|
||||
raise ValueError(f"{label}: githubStars needs an 'other' label and CLDR plural categories only")
|
||||
if any("{count}" not in value for value in stars.values()):
|
||||
raise ValueError(f"{label}: every githubStars label needs a {{count}} placeholder")
|
||||
if "{link}" not in strings["staleNotice"]:
|
||||
raise ValueError(f"{label}: staleNotice needs a {{link}} placeholder")
|
||||
same = locale.get("sameAsEnglish", [])
|
||||
if not isinstance(same, list) or not set(same) <= expected:
|
||||
raise ValueError(f"{label}: sameAsEnglish must list UI string keys")
|
||||
return locales
|
||||
|
||||
|
||||
def translation_record(source):
|
||||
"""Return the attributes of a translation's provenance comment, or None."""
|
||||
records = TRANSLATION_RECORD.findall(source)
|
||||
if len(records) != 1:
|
||||
return None
|
||||
return dict(re.findall(r'([\w-]+)="([^"]*)"', records[0]))
|
||||
|
||||
|
||||
def copy_targets(source):
|
||||
"""Return the IDs referenced by copy buttons, in page order."""
|
||||
return list(dict.fromkeys(re.findall(r'\bdata-copy="([^"]+)"', source)))
|
||||
|
||||
|
||||
def code_block(identifier):
|
||||
"""Match a code element by ID, capturing its opening tag, contents and end tag."""
|
||||
return re.compile(rf'(<code\b[^>]*\bid="{re.escape(identifier)}"[^>]*>)(.*?)(</code>)', re.S)
|
||||
|
||||
|
||||
def synced_code_ids(source):
|
||||
"""Return the copy targets that are code blocks; translations take them from English."""
|
||||
return [identifier for identifier in copy_targets(source) if code_block(identifier).search(source)]
|
||||
|
||||
|
||||
def source_digest(source):
|
||||
"""Hash an English page's translatable content.
|
||||
|
||||
Shared regions and copied code blocks are left out, so template edits and
|
||||
command-only changes do not mark translations out of date.
|
||||
"""
|
||||
content = SHARED_REGION.sub("", source)
|
||||
for identifier in synced_code_ids(source):
|
||||
content = code_block(identifier).sub(r"\1\3", content)
|
||||
return sha256(" ".join(content.split()).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def sync_code_blocks(english, source, label):
|
||||
"""Copy every synced code block's contents from the English source, matched by ID."""
|
||||
for identifier in synced_code_ids(english):
|
||||
contents = code_block(identifier).search(english).group(2)
|
||||
pattern = code_block(identifier)
|
||||
if len(pattern.findall(source)) != 1:
|
||||
raise ValueError(f"{label}: needs one <code id=\"{identifier}\"> block copied from the English page")
|
||||
source = pattern.sub(lambda match: match.group(1) + contents + match.group(3), source)
|
||||
return source
|
||||
|
||||
|
||||
class SiteState:
|
||||
"""Read the locales and every translation's status once for a sync or check."""
|
||||
|
||||
def __init__(self, root):
|
||||
self.root = root
|
||||
self.site = root / "website/public"
|
||||
self.locales = load_locales(root)
|
||||
self.translations = {}
|
||||
for path in sorted(self.site.rglob("*.html")):
|
||||
language, english = page_language(self.site, path)
|
||||
if language != "en":
|
||||
record = translation_record(path.read_text(encoding="utf-8")) or {}
|
||||
self.translations[(language, english)] = record.get("status")
|
||||
|
||||
def linkable(self, language, english, from_draft=False):
|
||||
"""Report whether other pages may link to this language's version of a page."""
|
||||
if language == "en":
|
||||
return (self.site / english).is_file()
|
||||
status = self.translations.get((language, english))
|
||||
return status == "reviewed" or (from_draft and status == "draft")
|
||||
|
||||
def alternates(self, english):
|
||||
"""Return the languages whose versions of a page join its hreflang cluster."""
|
||||
return [code for code in LANGUAGES if code == "en" or self.translations.get((code, english)) == "reviewed"]
|
||||
|
||||
|
||||
def language_url(language, english):
|
||||
"""Return the canonical URL of one language's version of a page."""
|
||||
prefix = "" if language == "en" else f"{language}/"
|
||||
return f"{SITE_ORIGIN}/{quote(prefix + file_route(english), safe='/')}"
|
||||
|
||||
|
||||
def shared_blocks(root, path, source, state=None):
|
||||
"""Render shared regions while keeping the page's body and metadata authored."""
|
||||
site = root / "website/public"
|
||||
state = state or SiteState(root)
|
||||
site = state.site
|
||||
metadata = PageMetadata()
|
||||
metadata.feed(source)
|
||||
for name, values in (("title", metadata.titles), ("description", metadata.descriptions)):
|
||||
if len(values) != 1 or not values[0].strip():
|
||||
raise ValueError(f"{path.relative_to(site)}: needs one nonempty head {name}")
|
||||
language, english = page_language(site, path)
|
||||
strings = state.locales[language]["strings"]
|
||||
depth = len(path.relative_to(site).parent.parts)
|
||||
base = "../" * depth or "./"
|
||||
route = page_route(site, path)
|
||||
route = file_route(english)
|
||||
draft = language != "en" and state.translations.get((language, english)) == "draft"
|
||||
|
||||
def href(target_language, target):
|
||||
prefix = "" if target_language == "en" else f"{target_language}/"
|
||||
return base + file_route(prefix + target)
|
||||
|
||||
def localized(target):
|
||||
"""Prefer this language's version of a target page, else the English page."""
|
||||
if language != "en" and state.linkable(language, target, from_draft=draft):
|
||||
return language, href(language, target)
|
||||
return "en", href("en", target)
|
||||
|
||||
links = []
|
||||
for label, destination in NAVIGATION:
|
||||
for key, destination in NAVIGATION:
|
||||
target_language, url = localized(destination + "index.html")
|
||||
attributes = ' class="nav-start"' if destination == "get-started/" else ""
|
||||
if route.startswith(destination):
|
||||
attributes += ' aria-current="page"'
|
||||
links.append(f' <a href="{base}{destination}"{attributes}>{label}</a>')
|
||||
badge = ""
|
||||
if target_language != language:
|
||||
attributes += ' hreflang="en"'
|
||||
badge = '<span class="nav-badge" lang="en">EN</span>'
|
||||
links.append(f' <a href="{url}"{attributes}>{escape(strings[key])}{badge}</a>')
|
||||
|
||||
entries = []
|
||||
for code, details in LANGUAGES.items():
|
||||
if code == language or state.linkable(code, english):
|
||||
url = href(code, english)
|
||||
elif state.linkable(code, "index.html"):
|
||||
url = href(code, "index.html")
|
||||
else:
|
||||
continue
|
||||
current = ' aria-current="true"' if code == language else ""
|
||||
entries.append(
|
||||
f' <li><a href="{url}" hreflang="{details.tag}" lang="{details.tag}"{current}>{details.name}</a></li>'
|
||||
)
|
||||
language_menu = ""
|
||||
if len(entries) > 1:
|
||||
language_menu = "\n".join((
|
||||
"",
|
||||
' <details class="language-menu">',
|
||||
" <summary>",
|
||||
' <svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" focusable="false">',
|
||||
f' <path fill="none" stroke="currentColor" stroke-width="1.6" d="{GLOBE_PATH}" />',
|
||||
" </svg>",
|
||||
f' <span class="sr-only">{escape(strings["language"])}: </span>'
|
||||
f'<span class="language-current">{LANGUAGES[language].name}</span>',
|
||||
" </summary>",
|
||||
' <ul class="language-list">',
|
||||
*entries,
|
||||
" </ul>",
|
||||
" </details>",
|
||||
))
|
||||
|
||||
members = [] if draft else state.alternates(english)
|
||||
hreflang = ""
|
||||
locale_alternates = ""
|
||||
if len(members) > 1:
|
||||
for code in [*members, "x-default"]:
|
||||
tag = LANGUAGES[code].tag if code != "x-default" else code
|
||||
url = language_url(code if code != "x-default" else "en", english)
|
||||
hreflang += f'\n<!-- prettier-ignore -->\n<link rel="alternate" hreflang="{tag}" href="{escape(url, quote=True)}" />'
|
||||
for code in members:
|
||||
if code != language:
|
||||
locale_alternates += (
|
||||
f'\n<!-- prettier-ignore -->\n<meta property="og:locale:alternate" content="{LANGUAGES[code].og_locale}" />'
|
||||
)
|
||||
|
||||
labels = "".join(
|
||||
f'\n data-label-{category}="{escape(label, quote=True)}"'
|
||||
for category, label in state.locales[language]["githubStars"].items()
|
||||
)
|
||||
values = {
|
||||
"base": base,
|
||||
"brand": localized("index.html")[1],
|
||||
"navigation": "\n" + "\n".join(links) + "\n ",
|
||||
"language_menu": language_menu,
|
||||
"canonical": escape(page_url(site, path), quote=True),
|
||||
"hreflang": hreflang,
|
||||
"og_locale": LANGUAGES[language].og_locale,
|
||||
"og_locale_alternates": locale_alternates,
|
||||
"origin": SITE_ORIGIN,
|
||||
"title": escape(metadata.titles[0].strip(), quote=True),
|
||||
"description": escape(metadata.descriptions[0].strip(), quote=True),
|
||||
"github_star_labels": labels,
|
||||
**{f"t_{key}": escape(value, quote=True) for key, value in strings.items()},
|
||||
}
|
||||
templates = root / "website/templates"
|
||||
return {
|
||||
@@ -118,35 +346,89 @@ def shared_blocks(root, path, source):
|
||||
}
|
||||
|
||||
|
||||
def translation_notice(state, path, english_source, record):
|
||||
"""Render the stale-translation notice, or nothing while the source hash matches."""
|
||||
if record is None or record.get("source-sha256") == source_digest(english_source):
|
||||
return ""
|
||||
language, english = page_language(state.site, path)
|
||||
strings = state.locales[language]["strings"]
|
||||
depth = len(path.relative_to(state.site).parent.parts)
|
||||
link = (
|
||||
f'<a href="{"../" * depth}{file_route(english)}" hreflang="en">'
|
||||
f'{escape(strings["staleNoticeLink"])}</a>'
|
||||
)
|
||||
message = escape(strings["staleNotice"]).replace("{link}", link)
|
||||
return f' <!-- prettier-ignore -->\n <p class="translation-notice" role="note">{message}</p>\n'
|
||||
|
||||
|
||||
def replace_region(source, start, end, block, label, name):
|
||||
"""Replace the contents of one marked region."""
|
||||
if source.count(start) != 1 or source.count(end) != 1:
|
||||
raise ValueError(f"{label}: needs one {name} region; use new-page or translate for a new page")
|
||||
before, _, remaining = source.partition(start)
|
||||
_, found, after = remaining.partition(end)
|
||||
if not found:
|
||||
raise ValueError(f"{label}: {name} region is out of order")
|
||||
return before + start + block + end + after
|
||||
|
||||
|
||||
def region_contents(source, start, end):
|
||||
"""Return the current contents of a marked region."""
|
||||
return source.partition(start)[2].partition(end)[0]
|
||||
|
||||
|
||||
def sync_pages(root, check=False):
|
||||
"""Update shared regions, or reject drift without changing any authored file."""
|
||||
site = root / "website/public"
|
||||
"""Update shared regions, or reject drift without changing any authored file.
|
||||
|
||||
Returns the pages that changed. With check=True, structural drift raises, so
|
||||
the returned pages are translations whose only drift is the stale notice;
|
||||
an out-of-date translation is a warning rather than a failure.
|
||||
"""
|
||||
state = SiteState(root)
|
||||
site = state.site
|
||||
changes = []
|
||||
notices = []
|
||||
for path in sorted(site.rglob("*.html")):
|
||||
if path.relative_to(site).as_posix() in NOTES_FIXTURES:
|
||||
continue
|
||||
label = path.relative_to(site).as_posix()
|
||||
original = path.read_text(encoding="utf-8")
|
||||
source = original
|
||||
for name, block in shared_blocks(root, path, source).items():
|
||||
start = f" <!-- website:{name}:start -->\n"
|
||||
end = f" <!-- website:{name}:end -->"
|
||||
if source.count(start) != 1 or source.count(end) != 1:
|
||||
raise ValueError(f"{path.relative_to(site)}: needs one {name} region; use new-page for a new reading page")
|
||||
before, _, remaining = source.partition(start)
|
||||
_, found, after = remaining.partition(end)
|
||||
if not found:
|
||||
raise ValueError(f"{path.relative_to(site)}: {name} region is out of order")
|
||||
source = before + start + block + end + after
|
||||
language, english = page_language(site, path)
|
||||
english_source = None
|
||||
if language != "en":
|
||||
english_path = site / english
|
||||
if not english_path.is_file():
|
||||
raise ValueError(f"{label}: translates {english}, which does not exist")
|
||||
english_source = english_path.read_text(encoding="utf-8")
|
||||
source = sync_code_blocks(english_source, source, label)
|
||||
for name, block in shared_blocks(root, path, source, state).items():
|
||||
source = replace_region(
|
||||
source, f" <!-- website:{name}:start -->\n", f" <!-- website:{name}:end -->", block, label, name
|
||||
)
|
||||
if language != "en":
|
||||
notice = translation_notice(state, path, english_source, translation_record(source))
|
||||
structural = replace_region(
|
||||
source, NOTICE_START, NOTICE_END, region_contents(original, NOTICE_START, NOTICE_END), label,
|
||||
"translation-notice",
|
||||
)
|
||||
source = replace_region(source, NOTICE_START, NOTICE_END, notice, label, "translation-notice")
|
||||
if structural == original and source != original:
|
||||
notices.append((path, source))
|
||||
continue
|
||||
if source != original:
|
||||
changes.append((path, source))
|
||||
if check and changes:
|
||||
names = ", ".join(str(path.relative_to(site)) for path, _ in changes)
|
||||
raise ValueError(f"Shared HTML is stale: {names}. Run python3 scripts/website.py sync and commit the updated pages.")
|
||||
if not check:
|
||||
for path, source in changes:
|
||||
for path, source in changes + notices:
|
||||
path.write_text(source, encoding="utf-8")
|
||||
print(f"{'Checked' if check else 'Synced'} shared HTML ({len(changes)} pages changed).")
|
||||
return [path for path, _ in changes]
|
||||
if check:
|
||||
print(f"Checked shared HTML ({len(notices)} translation notices to refresh).")
|
||||
else:
|
||||
print(f"Synced shared HTML ({len(changes) + len(notices)} pages changed).")
|
||||
return [path for path, _ in (notices if check else changes + notices)]
|
||||
|
||||
|
||||
def new_page(root, route, title, description):
|
||||
@@ -154,6 +436,8 @@ def new_page(root, route, title, description):
|
||||
parts = PurePosixPath(route)
|
||||
if not route or parts.is_absolute() or ".." in parts.parts or "\\" in route or parts.suffix:
|
||||
raise ValueError("Use a relative directory route, such as blog/my-article.")
|
||||
if parts.parts[0] in LANGUAGES:
|
||||
raise ValueError("Create the English page first, then use translate for another language.")
|
||||
site = root / "website/public"
|
||||
path = site.joinpath(*parts.parts, "index.html")
|
||||
if not path.resolve().is_relative_to(site.resolve()):
|
||||
@@ -174,11 +458,131 @@ def new_page(root, route, title, description):
|
||||
return path
|
||||
|
||||
|
||||
def relocate_references(source, english, language, state):
|
||||
"""Rewrite relative references for the translation's directory.
|
||||
|
||||
Pages that already have a version in this language link to it; everything
|
||||
else keeps pointing at the English page or asset.
|
||||
"""
|
||||
english_directory = posixpath.dirname(english)
|
||||
translation_directory = posixpath.join(language, english_directory)
|
||||
|
||||
def rewrite(match):
|
||||
value = match.group(2)
|
||||
url = urlsplit(value)
|
||||
if not value or url.scheme or url.netloc or value.startswith(("#", "/")):
|
||||
return match.group(0)
|
||||
target = posixpath.normpath(posixpath.join(english_directory, url.path))
|
||||
directory = url.path.endswith("/") or target == "."
|
||||
if directory:
|
||||
page = "index.html" if target == "." else f"{target}/index.html"
|
||||
if (language, page) in state.translations:
|
||||
target = posixpath.normpath(posixpath.join(language, target))
|
||||
relative = posixpath.relpath(target, translation_directory or ".")
|
||||
if directory:
|
||||
relative = "./" if relative == "." else relative + "/"
|
||||
return f'{match.group(1)}="{urlunsplit(("", "", relative, url.query, url.fragment))}"'
|
||||
|
||||
return re.sub(r'\b(href|src)="([^"]*)"', rewrite, source)
|
||||
|
||||
|
||||
def new_translation(root, language, route):
|
||||
"""Create a draft translation of an English page, ready to be translated in place."""
|
||||
if language not in LANGUAGES or language == "en":
|
||||
raise ValueError(f"Choose one of: {', '.join(code for code in LANGUAGES if code != 'en')}.")
|
||||
route = route.strip("/")
|
||||
parts = PurePosixPath(route) if route not in ("", ".") else PurePosixPath()
|
||||
if parts.is_absolute() or ".." in parts.parts or "\\" in route or parts.suffix:
|
||||
raise ValueError("Use the English page's directory route, such as get-started, or / for the home page.")
|
||||
if parts.parts and parts.parts[0] in LANGUAGES:
|
||||
raise ValueError("Name the English route, without a language directory.")
|
||||
english = PurePosixPath(*parts.parts, "index.html").as_posix()
|
||||
state = SiteState(root)
|
||||
site = state.site
|
||||
source_path = site / english
|
||||
if not source_path.is_file() or english in NOTES_FIXTURES:
|
||||
raise ValueError(f"No English page to translate at {english}.")
|
||||
path = site / language / english
|
||||
if path.exists():
|
||||
raise ValueError(f"Translation already exists: {path.relative_to(site)}")
|
||||
source = source_path.read_text(encoding="utf-8")
|
||||
if source.count('<html lang="en">') != 1 or source.count(" <!-- website:sharing:start -->\n") != 1:
|
||||
raise ValueError(f"{english}: needs <html lang=\"en\"> and one sharing region")
|
||||
main = re.search(r"^ {4}<main\b[^>]*>\n", source, re.M)
|
||||
if main is None:
|
||||
raise ValueError(f"{english}: needs a <main> element on its own line")
|
||||
source = source.replace('<html lang="en">', f'<html lang="{LANGUAGES[language].tag}">')
|
||||
source = relocate_references(source, english, language, state)
|
||||
record = (
|
||||
f' <!-- website:translation source-sha256="{source_digest(source_path.read_text(encoding="utf-8"))}" '
|
||||
'status="draft" -->\n'
|
||||
)
|
||||
robots = "" if '<meta name="robots"' in source else ' <meta name="robots" content="noindex" />\n'
|
||||
source = source.replace(" <!-- website:sharing:start -->\n", robots + record + " <!-- website:sharing:start -->\n")
|
||||
main = re.search(r"^ {4}<main\b[^>]*>\n", source, re.M)
|
||||
source = source[: main.end()] + NOTICE_START + NOTICE_END + "\n" + source[main.end():]
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(source, encoding="utf-8")
|
||||
sync_pages(root)
|
||||
print(
|
||||
f"Created {path.relative_to(root)} as a noindex draft. Translate its text in place following "
|
||||
"website/translation.md, then run python3 scripts/website.py check."
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def translation_report(root, language=None):
|
||||
"""Describe each translation's review status, freshness and remaining review items."""
|
||||
state = SiteState(root)
|
||||
site = state.site
|
||||
lines = []
|
||||
for (code, english), status in sorted(state.translations.items()):
|
||||
if language and code != language:
|
||||
continue
|
||||
path = site / code / english
|
||||
source = path.read_text(encoding="utf-8")
|
||||
record = translation_record(source) or {}
|
||||
english_path = site / english
|
||||
current = source_digest(english_path.read_text(encoding="utf-8")) if english_path.is_file() else None
|
||||
fresh = record.get("source-sha256") == current
|
||||
reviewed = f" on {record['reviewed']}" if record.get("reviewed") else ""
|
||||
lines.append(f"{code}/{file_route(english) or '(home)'}: {status or 'no record'}{reviewed}, "
|
||||
f"{'up to date' if fresh else 'English source changed'}")
|
||||
if not fresh and current:
|
||||
lines.append(f" after updating the translation, record source-sha256=\"{current}\"")
|
||||
if status != "reviewed":
|
||||
for system, text in re.findall(r'<(?:em|span|strong|kbd)\b[^>]*\bdata-os-ui="([^"]+)"[^>]*>(.*?)</', source, re.S):
|
||||
lines.append(f" confirm on {system}: {' '.join(text.split())}")
|
||||
english_strings = state.locales["en"]["strings"]
|
||||
for code in LANGUAGES:
|
||||
if code == "en" or (language and code != language):
|
||||
continue
|
||||
if not any(key[0] == code for key in state.translations):
|
||||
continue
|
||||
locale = state.locales[code]
|
||||
same = sorted(
|
||||
key for key, value in locale["strings"].items()
|
||||
if value == english_strings[key] and key not in locale.get("sameAsEnglish", [])
|
||||
)
|
||||
if same:
|
||||
lines.append(f"{code}: UI strings still in English: {', '.join(same)}")
|
||||
print("\n".join(lines) or "No translated pages.")
|
||||
return lines
|
||||
|
||||
|
||||
def run_script(root, name, arguments=()):
|
||||
"""Use the active Python environment for the existing preparation and checks."""
|
||||
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):
|
||||
@@ -207,6 +611,11 @@ def main():
|
||||
create.add_argument("route")
|
||||
create.add_argument("--title", required=True)
|
||||
create.add_argument("--description", required=True)
|
||||
translate = commands.add_parser("translate", help="Create a draft translation of an English page")
|
||||
translate.add_argument("language", choices=[code for code in LANGUAGES if code != "en"])
|
||||
translate.add_argument("route", help="The English page's directory route, or / for the home page")
|
||||
report = commands.add_parser("translations", help="List translation status, freshness and review items")
|
||||
report.add_argument("--lang", choices=[code for code in LANGUAGES if code != "en"])
|
||||
serve = commands.add_parser("serve", help="Prepare and preview the static site")
|
||||
serve.add_argument("--port", type=int, default=4173)
|
||||
serve.add_argument("--base-path", choices=("/", "/rea/"), default="/")
|
||||
@@ -215,12 +624,17 @@ def main():
|
||||
try:
|
||||
if args.command == "new-page":
|
||||
new_page(root, args.route, args.title, args.description)
|
||||
elif args.command == "translate":
|
||||
new_translation(root, args.language, args.route)
|
||||
elif args.command == "translations":
|
||||
translation_report(root, args.lang)
|
||||
elif args.command == "sync":
|
||||
sync_pages(root, check=args.check)
|
||||
elif args.command == "check":
|
||||
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
-2
@@ -9,10 +9,14 @@ website content, layout or figures. Use [figures.md](figures.md) and `evidence/`
|
||||
for asset notes and the source of case-study claims.
|
||||
|
||||
- Write English, direct headings and concrete instructions. Each paragraph
|
||||
should explain a new fact or action.
|
||||
should explain a new fact or action. Translated pages follow
|
||||
[translation.md](translation.md): create them with `translate`, keep their
|
||||
structure and copied code blocks in step with English, and refresh a
|
||||
translation's source hash only after updating its text.
|
||||
- Reuse the shared styles, prompts, figures and code-step components. Keep a
|
||||
case study focused on one question, with further examples in native details.
|
||||
- Edit shared header, footer and sharing markup in `templates/`; run
|
||||
- Edit shared header, footer and sharing markup in `templates/` and their text
|
||||
in `locales/`; run
|
||||
`python3 scripts/website.py sync` and commit the rendered regions with their
|
||||
templates. Keep page bodies outside the marked regions. Author each title and
|
||||
description once; derive sharing fields and canonicals from them. Use
|
||||
|
||||
+20
-8
@@ -4,11 +4,16 @@
|
||||
>
|
||||
> — N0zoM1z0
|
||||
|
||||
An English static website with explanatory figures, worked guides and Aegis, DX-Ball, Notion, TH04 and CTF investigations.
|
||||
A static website with explanatory figures, worked guides and Aegis, DX-Ball, Notion, TH04 and CTF investigations.
|
||||
It is written in English; the home page, Get started, the Guides index and the FAQ are
|
||||
also translated, as described in [translation.md](translation.md).
|
||||
The public files are in `website/public/`. The site uses HTML, CSS and small
|
||||
scripts for copying code, following code comparisons and playing the dinosaur
|
||||
speed reconstruction. Small Python commands maintain shared HTML, prepare
|
||||
downloads and check the site. There is no frontend framework, bundler or npm
|
||||
speed reconstruction. The scripts contain no reader-facing text: copy messages
|
||||
and the GitHub star label come from the shared header and footer, and each demo
|
||||
reads its messages from `data-msg-*` attributes in the page, with `{name}`
|
||||
placeholders for inserted values. Small Python commands maintain shared HTML,
|
||||
prepare downloads and check the site. There is no frontend framework, bundler or npm
|
||||
dependency. The checked-in HTML works as ordinary static files.
|
||||
|
||||
[style-guide.md](style-guide.md) explains the writing, page structure, figures,
|
||||
@@ -46,8 +51,10 @@ applies to every page.
|
||||
| ------------------------------ | ---------------------------------------------------------------- | ----------------------------------------------- |
|
||||
| Page text, examples or figures | The page's HTML outside the marked shared regions | `python3 scripts/website.py check` |
|
||||
| Search title or description | The page's `<title>` and first description meta tag | `python3 scripts/website.py sync`, then `check` |
|
||||
| Main navigation | `NAVIGATION` in `scripts/website.py` | `sync`, then `check` |
|
||||
| Main navigation | `NAVIGATION` in `scripts/website.py` and labels in `locales/` | `sync`, then `check` |
|
||||
| Header, social links or footer | `templates/header.html.tmpl` or `templates/footer.html.tmpl` | `sync`, then `check` |
|
||||
| Shared UI text in a language | `locales/<language>.json` | `sync`, then `check` |
|
||||
| A translated page | See [translation.md](translation.md) | `translate`, then `check` |
|
||||
| Shared link previews | `templates/sharing.html.tmpl` or `public/assets/social-card.svg` | `sync`, then `check` |
|
||||
|
||||
Create a reading page with:
|
||||
@@ -86,9 +93,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:
|
||||
|
||||
@@ -459,7 +469,9 @@ See Cloudflare's [GitHub Actions authentication instructions](https://developers
|
||||
The default `both` target checks that both secrets are present before preparing
|
||||
a release. Cloudflare deploys first; GitHub Pages deploys after that step succeeds.
|
||||
|
||||
Preparation adds `deployment.json` with the workflow's commit SHA. Cloudflare
|
||||
After the checks pass, preparation runs `sync` so a translation whose English
|
||||
page changed is published with its out-of-date notice, then adds
|
||||
`deployment.json` with the workflow's commit SHA. Cloudflare
|
||||
receives the extracted Pages artifact through Wrangler's explicit assets path;
|
||||
the production environment disables Wrangler's preview custom build, so deployment
|
||||
uses the prepared artifact directly. The workflow then
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"strings": {
|
||||
"skipLink": "Skip to content",
|
||||
"home": "REA home",
|
||||
"mainNavigation": "Main navigation",
|
||||
"navShowcases": "Showcases",
|
||||
"navBlog": "Blog",
|
||||
"navGetStarted": "Get started",
|
||||
"navGuides": "Guides",
|
||||
"navFaq": "FAQ",
|
||||
"language": "Language",
|
||||
"github": "REA on GitHub",
|
||||
"discord": "Join REA on Discord",
|
||||
"followRea": "Follow REA on X",
|
||||
"source": "Source",
|
||||
"license": "MIT license",
|
||||
"community": "Join the community",
|
||||
"followUs": "Follow us on X",
|
||||
"copied": "Copied",
|
||||
"selected": "Selected",
|
||||
"statusCopied": "Code copied to clipboard.",
|
||||
"statusSelected": "Code selected. Use your keyboard to copy it.",
|
||||
"backToTop": "Back to top",
|
||||
"top": "Top",
|
||||
"imageAlt": "REA — Find out how software works. Reverse engineering with your coding agent.",
|
||||
"staleNotice": "The English page has changed since this translation. {link}",
|
||||
"staleNoticeLink": "Read the English version."
|
||||
},
|
||||
"githubStars": {
|
||||
"one": "REA on GitHub ({count} star)",
|
||||
"other": "REA on GitHub ({count} stars)"
|
||||
},
|
||||
"sameAsEnglish": []
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"strings": {
|
||||
"skipLink": "Skip to content",
|
||||
"home": "REA home",
|
||||
"mainNavigation": "Main navigation",
|
||||
"navShowcases": "Showcases",
|
||||
"navBlog": "Blog",
|
||||
"navGetStarted": "Get started",
|
||||
"navGuides": "Guides",
|
||||
"navFaq": "FAQ",
|
||||
"language": "Language",
|
||||
"github": "REA on GitHub",
|
||||
"discord": "Join REA on Discord",
|
||||
"followRea": "Follow REA on X",
|
||||
"source": "Source",
|
||||
"license": "MIT license",
|
||||
"community": "Join the community",
|
||||
"followUs": "Follow us on X",
|
||||
"copied": "Copied",
|
||||
"selected": "Selected",
|
||||
"statusCopied": "Code copied to clipboard.",
|
||||
"statusSelected": "Code selected. Use your keyboard to copy it.",
|
||||
"backToTop": "Back to top",
|
||||
"top": "Top",
|
||||
"imageAlt": "REA — Find out how software works. Reverse engineering with your coding agent.",
|
||||
"staleNotice": "The English page has changed since this translation. {link}",
|
||||
"staleNoticeLink": "Read the English version."
|
||||
},
|
||||
"githubStars": {
|
||||
"one": "REA on GitHub ({count} star)",
|
||||
"other": "REA on GitHub ({count} stars)"
|
||||
},
|
||||
"sameAsEnglish": []
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"strings": {
|
||||
"skipLink": "Skip to content",
|
||||
"home": "REA home",
|
||||
"mainNavigation": "Main navigation",
|
||||
"navShowcases": "Showcases",
|
||||
"navBlog": "Blog",
|
||||
"navGetStarted": "Get started",
|
||||
"navGuides": "Guides",
|
||||
"navFaq": "FAQ",
|
||||
"language": "Language",
|
||||
"github": "REA on GitHub",
|
||||
"discord": "Join REA on Discord",
|
||||
"followRea": "Follow REA on X",
|
||||
"source": "Source",
|
||||
"license": "MIT license",
|
||||
"community": "Join the community",
|
||||
"followUs": "Follow us on X",
|
||||
"copied": "Copied",
|
||||
"selected": "Selected",
|
||||
"statusCopied": "Code copied to clipboard.",
|
||||
"statusSelected": "Code selected. Use your keyboard to copy it.",
|
||||
"backToTop": "Back to top",
|
||||
"top": "Top",
|
||||
"imageAlt": "REA — Find out how software works. Reverse engineering with your coding agent.",
|
||||
"staleNotice": "The English page has changed since this translation. {link}",
|
||||
"staleNoticeLink": "Read the English version."
|
||||
},
|
||||
"githubStars": {
|
||||
"one": "REA on GitHub ({count} star)",
|
||||
"other": "REA on GitHub ({count} stars)"
|
||||
},
|
||||
"sameAsEnglish": []
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"strings": {
|
||||
"skipLink": "Skip to content",
|
||||
"home": "REA home",
|
||||
"mainNavigation": "Main navigation",
|
||||
"navShowcases": "Showcases",
|
||||
"navBlog": "Blog",
|
||||
"navGetStarted": "Get started",
|
||||
"navGuides": "Guides",
|
||||
"navFaq": "FAQ",
|
||||
"language": "Language",
|
||||
"github": "REA on GitHub",
|
||||
"discord": "Join REA on Discord",
|
||||
"followRea": "Follow REA on X",
|
||||
"source": "Source",
|
||||
"license": "MIT license",
|
||||
"community": "Join the community",
|
||||
"followUs": "Follow us on X",
|
||||
"copied": "Copied",
|
||||
"selected": "Selected",
|
||||
"statusCopied": "Code copied to clipboard.",
|
||||
"statusSelected": "Code selected. Use your keyboard to copy it.",
|
||||
"backToTop": "Back to top",
|
||||
"top": "Top",
|
||||
"imageAlt": "REA — Find out how software works. Reverse engineering with your coding agent.",
|
||||
"staleNotice": "The English page has changed since this translation. {link}",
|
||||
"staleNoticeLink": "Read the English version."
|
||||
},
|
||||
"githubStars": {
|
||||
"other": "REA on GitHub ({count} stars)"
|
||||
},
|
||||
"sameAsEnglish": []
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"strings": {
|
||||
"skipLink": "Skip to content",
|
||||
"home": "REA home",
|
||||
"mainNavigation": "Main navigation",
|
||||
"navShowcases": "Showcases",
|
||||
"navBlog": "Blog",
|
||||
"navGetStarted": "Get started",
|
||||
"navGuides": "Guides",
|
||||
"navFaq": "FAQ",
|
||||
"language": "Language",
|
||||
"github": "REA on GitHub",
|
||||
"discord": "Join REA on Discord",
|
||||
"followRea": "Follow REA on X",
|
||||
"source": "Source",
|
||||
"license": "MIT license",
|
||||
"community": "Join the community",
|
||||
"followUs": "Follow us on X",
|
||||
"copied": "Copied",
|
||||
"selected": "Selected",
|
||||
"statusCopied": "Code copied to clipboard.",
|
||||
"statusSelected": "Code selected. Use your keyboard to copy it.",
|
||||
"backToTop": "Back to top",
|
||||
"top": "Top",
|
||||
"imageAlt": "REA — Find out how software works. Reverse engineering with your coding agent.",
|
||||
"staleNotice": "The English page has changed since this translation. {link}",
|
||||
"staleNoticeLink": "Read the English version."
|
||||
},
|
||||
"githubStars": {
|
||||
"other": "REA on GitHub ({count} stars)"
|
||||
},
|
||||
"sameAsEnglish": []
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"strings": {
|
||||
"skipLink": "Skip to content",
|
||||
"home": "REA home",
|
||||
"mainNavigation": "Main navigation",
|
||||
"navShowcases": "Showcases",
|
||||
"navBlog": "Blog",
|
||||
"navGetStarted": "Get started",
|
||||
"navGuides": "Guides",
|
||||
"navFaq": "FAQ",
|
||||
"language": "Language",
|
||||
"github": "REA on GitHub",
|
||||
"discord": "Join REA on Discord",
|
||||
"followRea": "Follow REA on X",
|
||||
"source": "Source",
|
||||
"license": "MIT license",
|
||||
"community": "Join the community",
|
||||
"followUs": "Follow us on X",
|
||||
"copied": "Copied",
|
||||
"selected": "Selected",
|
||||
"statusCopied": "Code copied to clipboard.",
|
||||
"statusSelected": "Code selected. Use your keyboard to copy it.",
|
||||
"backToTop": "Back to top",
|
||||
"top": "Top",
|
||||
"imageAlt": "REA — Find out how software works. Reverse engineering with your coding agent.",
|
||||
"staleNotice": "The English page has changed since this translation. {link}",
|
||||
"staleNoticeLink": "Read the English version."
|
||||
},
|
||||
"githubStars": {
|
||||
"other": "REA on GitHub ({count} stars)"
|
||||
},
|
||||
"sameAsEnglish": []
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"strings": {
|
||||
"skipLink": "跳至主要內容",
|
||||
"home": "REA 首頁",
|
||||
"mainNavigation": "主要導覽",
|
||||
"navShowcases": "案例",
|
||||
"navBlog": "部落格",
|
||||
"navGetStarted": "快速開始",
|
||||
"navGuides": "指南",
|
||||
"navFaq": "常見問題",
|
||||
"language": "語言",
|
||||
"github": "REA 的 GitHub",
|
||||
"discord": "加入 REA 的 Discord",
|
||||
"followRea": "在 X 上追蹤 REA",
|
||||
"source": "原始碼",
|
||||
"license": "MIT 授權",
|
||||
"community": "加入社群",
|
||||
"followUs": "在 X 上追蹤我們",
|
||||
"copied": "已複製",
|
||||
"selected": "已選取",
|
||||
"statusCopied": "程式碼已複製到剪貼簿。",
|
||||
"statusSelected": "已選取程式碼,請用鍵盤複製。",
|
||||
"backToTop": "回到頂端",
|
||||
"top": "頂端",
|
||||
"imageAlt": "REA:了解軟體如何運作。與你的 AI 代理一起進行逆向工程。",
|
||||
"staleNotice": "英文頁面在這份翻譯之後有更新。{link}",
|
||||
"staleNoticeLink": "閱讀英文版。"
|
||||
},
|
||||
"githubStars": {
|
||||
"other": "REA 的 GitHub({count} 顆星)"
|
||||
},
|
||||
"sameAsEnglish": []
|
||||
}
|
||||
@@ -11,6 +11,15 @@ function initializeDinoDemo(demo) {
|
||||
const auto = demo.querySelector("[data-dino-auto]");
|
||||
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, (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(
|
||||
@@ -66,27 +75,23 @@ function initializeDinoDemo(demo) {
|
||||
context.fillStyle = "#5b6570";
|
||||
context.font = "13px monospace";
|
||||
context.textAlign = "right";
|
||||
context.fillText(
|
||||
`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(
|
||||
crashed ? "Hit a cactus. Try another speed." : "Space or Jump ↑",
|
||||
330,
|
||||
85,
|
||||
);
|
||||
context.fillText(hint, 330, 85);
|
||||
}
|
||||
};
|
||||
|
||||
const stop = (message) => {
|
||||
const stop = (notice) => {
|
||||
running = false;
|
||||
cancelAnimationFrame(frame);
|
||||
start.textContent = crashed ? "Play again" : "Play";
|
||||
if (message) status.textContent = message;
|
||||
show(start, message(crashed ? "msgPlayAgain" : "msgPlay"));
|
||||
show(status, notice);
|
||||
draw();
|
||||
};
|
||||
|
||||
@@ -99,8 +104,8 @@ function initializeDinoDemo(demo) {
|
||||
obstacles = [{ x: 550, height: 38 }];
|
||||
if (auto.checked) speed = SPEED_RULE.start;
|
||||
elapsed = 0;
|
||||
start.textContent = "Play";
|
||||
status.textContent = "Press Play, then jump over the cactus.";
|
||||
show(start, message("msgPlay"));
|
||||
show(status, message("msgReady"));
|
||||
updateSpeedDisplay();
|
||||
draw();
|
||||
};
|
||||
@@ -134,7 +139,7 @@ function initializeDinoDemo(demo) {
|
||||
)
|
||||
) {
|
||||
crashed = true;
|
||||
stop("Hit a cactus. Press Play again, or change the speed and restart.");
|
||||
stop(message("msgCrashed"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -157,8 +162,8 @@ function initializeDinoDemo(demo) {
|
||||
running = true;
|
||||
lastTime = performance.now();
|
||||
elapsed = 0;
|
||||
start.textContent = "Pause";
|
||||
status.textContent = "Running. Space or Jump clears a cactus.";
|
||||
show(start, message("msgPause"));
|
||||
show(status, message("msgRunning"));
|
||||
frame = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
@@ -169,7 +174,7 @@ function initializeDinoDemo(demo) {
|
||||
};
|
||||
|
||||
start.addEventListener("click", () => {
|
||||
if (running) stop("Paused. Press Play to continue.");
|
||||
if (running) stop(message("msgPaused"));
|
||||
else play();
|
||||
});
|
||||
demo.querySelector("[data-dino-jump]").addEventListener("click", jump);
|
||||
@@ -188,25 +193,30 @@ function initializeDinoDemo(demo) {
|
||||
auto.checked = false;
|
||||
speed = Number(slider.value);
|
||||
updateSpeedDisplay();
|
||||
status.textContent = running
|
||||
? `Fixed speed: ${speed.toFixed(1)}. Space or Jump clears a cactus.`
|
||||
: `Fixed speed: ${speed.toFixed(1)}. Press Play to try it.`;
|
||||
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
|
||||
? "Recovered rule: start at 6, then increase toward 13."
|
||||
: `Fixed speed: ${speed.toFixed(1)}.`;
|
||||
show(
|
||||
status,
|
||||
auto.checked
|
||||
? message("msgRule")
|
||||
: message("msgFixed", { speed: speed.toFixed(1) }),
|
||||
);
|
||||
});
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden && running) stop("Paused. Press Play to continue.");
|
||||
if (document.hidden && running) stop(message("msgPaused"));
|
||||
});
|
||||
if ("IntersectionObserver" in window) {
|
||||
new IntersectionObserver((entries) => {
|
||||
if (!entries[0].isIntersecting && running)
|
||||
stop("Paused. Press Play to continue.");
|
||||
if (!entries[0].isIntersecting && running) stop(message("msgPaused"));
|
||||
}).observe(demo);
|
||||
}
|
||||
demo.querySelector(".dino-fallback").setAttribute("hidden", "");
|
||||
@@ -229,7 +239,7 @@ speedCheck?.addEventListener("click", () => {
|
||||
matches =
|
||||
matches && speed === cell.parentElement.children[1].textContent.trim();
|
||||
});
|
||||
document.querySelector("[data-speed-check-status]").textContent = matches
|
||||
? "Replayed the recovered rule. All four values match the controlled original-game check when rounded to three decimals."
|
||||
: "The replay differs from the recorded original-game values. Compare the speed rule and settings above.";
|
||||
const status = document.querySelector("[data-speed-check-status]");
|
||||
const result = matches ? status.dataset.msgMatch : status.dataset.msgMismatch;
|
||||
if (result) status.textContent = result;
|
||||
});
|
||||
|
||||
@@ -5,6 +5,15 @@ function initializePercentDemo() {
|
||||
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";
|
||||
@@ -22,14 +31,14 @@ function initializePercentDemo() {
|
||||
stage === "input" ? current : stage === "percent" ? percent : result,
|
||||
);
|
||||
equals.disabled = stage !== "percent";
|
||||
status.textContent =
|
||||
show(
|
||||
status,
|
||||
stage === "input"
|
||||
? "Press % to see what happens to 10."
|
||||
? message("msgInput")
|
||||
: stage === "percent"
|
||||
? adding
|
||||
? "10% of 200 is 20. Press = to add it."
|
||||
: "For multiplication, 10 becomes 0.1. Press = to multiply."
|
||||
: `200 ${operator} ${percent} = ${result}.`;
|
||||
? message(adding ? "msgPercentAdd" : "msgPercentMultiply")
|
||||
: message("msgResult", { operator, percent, result }),
|
||||
);
|
||||
};
|
||||
|
||||
operations.forEach((button) => {
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
function initializeCopyButtons() {
|
||||
// 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");
|
||||
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;
|
||||
|
||||
const status = document.getElementById("copy-status");
|
||||
const content = target.textContent.trim();
|
||||
const text =
|
||||
target.closest(".terminal-prompt") === null
|
||||
@@ -12,8 +21,7 @@ function initializeCopyButtons() {
|
||||
: content.replace(/\s+/g, " ");
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
button.textContent = "Copied";
|
||||
if (status !== null) status.textContent = "Code copied to clipboard.";
|
||||
report(messages.copied, messages.statusCopied);
|
||||
} catch {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(target);
|
||||
@@ -22,13 +30,11 @@ function initializeCopyButtons() {
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
button.textContent = "Selected";
|
||||
if (status !== null)
|
||||
status.textContent = "Code selected. Use your keyboard to copy it.";
|
||||
report(messages.selected, messages.statusSelected);
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
button.textContent = "Copy";
|
||||
button.textContent = label;
|
||||
}, 2000);
|
||||
});
|
||||
});
|
||||
@@ -128,8 +134,15 @@ async function initializeGitHubStars() {
|
||||
const stars = repository.stargazers_count;
|
||||
if (!Number.isSafeInteger(stars) || stars < 0) return;
|
||||
|
||||
const formatted = new Intl.NumberFormat("en-US").format(stars);
|
||||
const label = `REA on GitHub (${formatted} ${stars === 1 ? "star" : "stars"})`;
|
||||
// The shared header owns one localized label per plural category.
|
||||
const lang = document.documentElement.lang || undefined;
|
||||
const category = new Intl.PluralRules(lang).select(stars);
|
||||
const template =
|
||||
link.dataset[`label${category[0].toUpperCase()}${category.slice(1)}`] ??
|
||||
link.dataset.labelOther;
|
||||
if (!template) return;
|
||||
const formatted = new Intl.NumberFormat(lang).format(stars);
|
||||
const label = template.replace("{count}", formatted);
|
||||
count.textContent = formatted;
|
||||
count.hidden = false;
|
||||
link.setAttribute("aria-label", label);
|
||||
|
||||
@@ -6,8 +6,12 @@
|
||||
--soft: #f4f6f8;
|
||||
--accent: #294f82;
|
||||
--page: #ffffff;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
/* Translated pages extend these stacks with fonts for their script. */
|
||||
--font-latin: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial;
|
||||
--font-code: "SFMono-Regular", Consolas, "Liberation Mono";
|
||||
--font-sans: var(--font-latin), sans-serif;
|
||||
--font-mono: var(--font-code), monospace;
|
||||
font-family: var(--font-sans);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
@@ -44,7 +48,7 @@ button {
|
||||
}
|
||||
code,
|
||||
pre {
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
code {
|
||||
font-size: 0.9em;
|
||||
@@ -57,7 +61,7 @@ kbd {
|
||||
border-radius: 3px;
|
||||
background: var(--soft);
|
||||
color: var(--ink);
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -932,7 +936,7 @@ pre code {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.aegis-calculation .aegis-code {
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
font-family: ui-monospace, var(--font-mono);
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
@@ -1264,7 +1268,7 @@ pre code {
|
||||
margin: 0;
|
||||
padding: 1.4rem;
|
||||
color: #f1f4f8;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.9;
|
||||
}
|
||||
@@ -1878,7 +1882,7 @@ pre code {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.expert-facts dt {
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.expert-facts dd {
|
||||
margin: 0;
|
||||
@@ -2137,7 +2141,7 @@ pre code {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.dino-speed-label output {
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--accent);
|
||||
}
|
||||
@@ -2485,3 +2489,186 @@ pre code {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Translated pages. Latin text keeps the site's fonts, then each language
|
||||
names its own CJK fonts so Han characters take that language's forms. */
|
||||
:lang(en) {
|
||||
--font-sans: var(--font-latin), sans-serif;
|
||||
--font-mono: var(--font-code), monospace;
|
||||
}
|
||||
:lang(ja) {
|
||||
--font-sans:
|
||||
var(--font-latin), "Hiragino Sans", "Hiragino Kaku Gothic ProN",
|
||||
"Yu Gothic UI", Meiryo, "Noto Sans JP", "Noto Sans CJK JP", sans-serif;
|
||||
--font-mono:
|
||||
var(--font-code), "Hiragino Sans", "Yu Gothic UI", "Noto Sans JP",
|
||||
"Noto Sans CJK JP", monospace;
|
||||
}
|
||||
:lang(zh-Hans) {
|
||||
--font-sans:
|
||||
var(--font-latin), "PingFang SC", "Microsoft YaHei", "Noto Sans SC",
|
||||
"Noto Sans CJK SC", sans-serif;
|
||||
--font-mono:
|
||||
var(--font-code), "PingFang SC", "Microsoft YaHei", "Noto Sans SC",
|
||||
"Noto Sans CJK SC", monospace;
|
||||
}
|
||||
:lang(zh-Hant) {
|
||||
--font-sans:
|
||||
var(--font-latin), "PingFang TC", "Microsoft JhengHei", "Noto Sans TC",
|
||||
"Noto Sans CJK TC", sans-serif;
|
||||
--font-mono:
|
||||
var(--font-code), "PingFang TC", "Microsoft JhengHei", "Noto Sans TC",
|
||||
"Noto Sans CJK TC", monospace;
|
||||
}
|
||||
:lang(ko) {
|
||||
--font-sans:
|
||||
var(--font-latin), "Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans KR",
|
||||
"Noto Sans CJK KR", sans-serif;
|
||||
--font-mono:
|
||||
var(--font-code), "Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans KR",
|
||||
"Noto Sans CJK KR", monospace;
|
||||
}
|
||||
/* Resolve the stack again wherever the language changes, such as the
|
||||
language menu's names or an English badge. */
|
||||
[lang] {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
:is(:lang(ja), :lang(ko), :lang(zh-Hans), :lang(zh-Hant))
|
||||
:is(
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
.brand-name,
|
||||
.home-no,
|
||||
.agent-one-prompt,
|
||||
.stats dt,
|
||||
.home-start-grid .home-setup h2
|
||||
) {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
:is(:lang(ja), :lang(ko), :lang(zh-Hans), :lang(zh-Hant)) :is(h1, h2, h3) {
|
||||
line-height: 1.3;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
/* CJK fonts have no italics, so emphasis uses weight instead. */
|
||||
:is(:lang(ja), :lang(ko), :lang(zh-Hans), :lang(zh-Hant)) em {
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
}
|
||||
:lang(ja) {
|
||||
line-break: strict;
|
||||
}
|
||||
:lang(ja) :is(h1, h2, h3) {
|
||||
word-break: auto-phrase;
|
||||
}
|
||||
:lang(ko) {
|
||||
word-break: keep-all;
|
||||
}
|
||||
:lang(de) :is(h1, h2, h3, .lede, .contents a, .button) {
|
||||
hyphens: auto;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.language-menu {
|
||||
position: relative;
|
||||
}
|
||||
.language-menu summary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 44px;
|
||||
padding-inline: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
.language-menu summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.language-menu summary:hover,
|
||||
.language-menu[open] summary {
|
||||
background: var(--soft);
|
||||
color: var(--ink);
|
||||
}
|
||||
.language-menu svg {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.language-list {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
z-index: 6;
|
||||
min-width: 10rem;
|
||||
margin: 0;
|
||||
padding: 0.35rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--page);
|
||||
box-shadow: 0 8px 24px rgb(32 37 43 / 12%);
|
||||
list-style: none;
|
||||
}
|
||||
.language-list li + li {
|
||||
margin-top: 0;
|
||||
}
|
||||
.language-list a {
|
||||
display: block;
|
||||
padding: 0.45rem 0.7rem;
|
||||
border-radius: 6px;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
.language-list a:hover {
|
||||
background: var(--soft);
|
||||
}
|
||||
.language-list a[aria-current="true"] {
|
||||
font-weight: 600;
|
||||
}
|
||||
.nav-badge {
|
||||
margin-left: 0.3em;
|
||||
padding: 0 0.3em;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
vertical-align: 0.15em;
|
||||
}
|
||||
.translation-notice {
|
||||
margin: 1.5rem 0 0;
|
||||
padding: 0.75rem 1rem;
|
||||
border-left: 3px solid var(--accent);
|
||||
background: var(--soft);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
main:not(.container) > .translation-notice {
|
||||
width: min(1080px, calc(100% - 3.5rem));
|
||||
margin-inline: auto;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.language-list {
|
||||
right: auto;
|
||||
left: 0;
|
||||
}
|
||||
.language-current {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
main:not(.container) > .translation-notice {
|
||||
width: calc(100% - 2.5rem);
|
||||
}
|
||||
}
|
||||
/* The language menu adds a fourth header button; make room beside the brand. */
|
||||
@media (max-width: 359px) {
|
||||
.header-social:has(.language-menu) .nav-github-count {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:site_name" content="REA" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:title" content="REA Blog — Thoughts on Reverse Engineering" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:description" content="Thoughts on reverse engineering, drawing on practical experience with REA and AI agents." />
|
||||
@@ -54,9 +56,10 @@
|
||||
<script src="../assets/main.js" defer></script>
|
||||
</head>
|
||||
<body id="top">
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<!-- website:header:start -->
|
||||
<!-- prettier-ignore -->
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<!-- prettier-ignore -->
|
||||
<header class="site-header">
|
||||
<div class="container header-inner">
|
||||
<a class="brand" href="../" aria-label="REA home"
|
||||
@@ -76,6 +79,8 @@
|
||||
href="https://github.com/morluto/rea"
|
||||
aria-label="REA on GitHub"
|
||||
title="REA on GitHub"
|
||||
data-label-one="REA on GitHub ({count} star)"
|
||||
data-label-other="REA on GitHub ({count} stars)"
|
||||
>
|
||||
<svg
|
||||
width="22"
|
||||
@@ -170,7 +175,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<p class="sr-only" id="copy-status" role="status" aria-live="polite"></p>
|
||||
<p
|
||||
class="sr-only"
|
||||
id="copy-status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-copied="Copied"
|
||||
data-selected="Selected"
|
||||
data-status-copied="Code copied to clipboard."
|
||||
data-status-selected="Code selected. Use your keyboard to copy it."
|
||||
></p>
|
||||
<a class="back-to-top" href="#top" aria-label="Back to top">
|
||||
<span aria-hidden="true">↑</span> Top
|
||||
</a>
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:site_name" content="REA" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:title" content="Reverse Engineering’s Industrial Revolution — REA" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:description" content="N0zoM1z0 reflects on manual reverse engineering, Touhou reconstruction with AI agents, and how knowledge compounds across TH08, TH095 and PC-98 TH04." />
|
||||
@@ -54,9 +56,10 @@
|
||||
<script src="../../assets/main.js" defer></script>
|
||||
</head>
|
||||
<body id="top" class="blog-article">
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<!-- website:header:start -->
|
||||
<!-- prettier-ignore -->
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<!-- prettier-ignore -->
|
||||
<header class="site-header">
|
||||
<div class="container header-inner">
|
||||
<a class="brand" href="../../" aria-label="REA home"
|
||||
@@ -76,6 +79,8 @@
|
||||
href="https://github.com/morluto/rea"
|
||||
aria-label="REA on GitHub"
|
||||
title="REA on GitHub"
|
||||
data-label-one="REA on GitHub ({count} star)"
|
||||
data-label-other="REA on GitHub ({count} stars)"
|
||||
>
|
||||
<svg
|
||||
width="22"
|
||||
@@ -1020,7 +1025,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<p class="sr-only" id="copy-status" role="status" aria-live="polite"></p>
|
||||
<p
|
||||
class="sr-only"
|
||||
id="copy-status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-copied="Copied"
|
||||
data-selected="Selected"
|
||||
data-status-copied="Code copied to clipboard."
|
||||
data-status-selected="Code selected. Use your keyboard to copy it."
|
||||
></p>
|
||||
<a class="back-to-top" href="#top" aria-label="Back to top">
|
||||
<span aria-hidden="true">↑</span> Top
|
||||
</a>
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:site_name" content="REA" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:title" content="Chrome Dinosaur Game: Rebuild Its Speed Rule — REA" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:description" content="Read the dinosaur game’s speed rule with REA, rebuild it, then change the speed and play." />
|
||||
@@ -55,9 +57,10 @@
|
||||
<script type="module" src="../../assets/dino-demo.js"></script>
|
||||
</head>
|
||||
<body id="top">
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<!-- website:header:start -->
|
||||
<!-- prettier-ignore -->
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<!-- prettier-ignore -->
|
||||
<header class="site-header">
|
||||
<div class="container header-inner">
|
||||
<a class="brand" href="../../" aria-label="REA home"
|
||||
@@ -77,6 +80,8 @@
|
||||
href="https://github.com/morluto/rea"
|
||||
aria-label="REA on GitHub"
|
||||
title="REA on GitHub"
|
||||
data-label-one="REA on GitHub ({count} star)"
|
||||
data-label-other="REA on GitHub ({count} stars)"
|
||||
>
|
||||
<svg
|
||||
width="22"
|
||||
@@ -166,7 +171,24 @@
|
||||
try a slower or faster game; it switches to a fixed speed. Select
|
||||
the recovered rule to start at 6 and gradually accelerate again.
|
||||
</p>
|
||||
<figure class="dino-demo dino-lab-demo" data-dino-demo>
|
||||
<figure
|
||||
class="dino-demo dino-lab-demo"
|
||||
data-dino-demo
|
||||
data-msg-play="Play"
|
||||
data-msg-pause="Pause"
|
||||
data-msg-play-again="Play again"
|
||||
data-msg-ready="Press Play, then jump over the cactus."
|
||||
data-msg-running="Running. Space or Jump clears a cactus."
|
||||
data-msg-paused="Paused. Press Play to continue."
|
||||
data-msg-crashed="Hit a cactus. Press Play again, or change the speed and restart."
|
||||
data-msg-fixed="Fixed speed: {speed}."
|
||||
data-msg-fixed-running="Fixed speed: {speed}. Space or Jump clears a cactus."
|
||||
data-msg-fixed-stopped="Fixed speed: {speed}. Press Play to try it."
|
||||
data-msg-rule="Recovered rule: start at 6, then increase toward 13."
|
||||
data-msg-distance="DISTANCE {distance}"
|
||||
data-msg-canvas-ready="Space or Jump ↑"
|
||||
data-msg-canvas-crashed="Hit a cactus. Try another speed."
|
||||
>
|
||||
<p class="figure-title">Play the reconstruction</p>
|
||||
<div class="dino-stage">
|
||||
<svg
|
||||
@@ -331,7 +353,13 @@
|
||||
<button class="button" type="button" data-speed-check hidden>
|
||||
Run the speed check
|
||||
</button>
|
||||
<p class="caption" data-speed-check-status role="status">
|
||||
<p
|
||||
class="caption"
|
||||
data-speed-check-status
|
||||
data-msg-match="Replayed the recovered rule. All four values match the controlled original-game check when rounded to three decimals."
|
||||
data-msg-mismatch="The replay differs from the recorded original-game values. Compare the speed rule and settings above."
|
||||
role="status"
|
||||
>
|
||||
Values rounded to three decimals. The branch checks before adding,
|
||||
so the final increment can take speed just above 13.
|
||||
</p>
|
||||
@@ -413,7 +441,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<p class="sr-only" id="copy-status" role="status" aria-live="polite"></p>
|
||||
<p
|
||||
class="sr-only"
|
||||
id="copy-status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-copied="Copied"
|
||||
data-selected="Selected"
|
||||
data-status-copied="Code copied to clipboard."
|
||||
data-status-selected="Code selected. Use your keyboard to copy it."
|
||||
></p>
|
||||
<a class="back-to-top" href="#top" aria-label="Back to top">
|
||||
<span aria-hidden="true">↑</span> Top
|
||||
</a>
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:site_name" content="REA" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:title" content="REA FAQ: Setup, Updates and Troubleshooting" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:description" content="Answers to common REA questions: installation, updates, analysis tools, coding agents, troubleshooting and sharing examples." />
|
||||
@@ -54,9 +56,10 @@
|
||||
<script src="../assets/main.js" defer></script>
|
||||
</head>
|
||||
<body id="top">
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<!-- website:header:start -->
|
||||
<!-- prettier-ignore -->
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<!-- prettier-ignore -->
|
||||
<header class="site-header">
|
||||
<div class="container header-inner">
|
||||
<a class="brand" href="../" aria-label="REA home"
|
||||
@@ -76,6 +79,8 @@
|
||||
href="https://github.com/morluto/rea"
|
||||
aria-label="REA on GitHub"
|
||||
title="REA on GitHub"
|
||||
data-label-one="REA on GitHub ({count} star)"
|
||||
data-label-other="REA on GitHub ({count} stars)"
|
||||
>
|
||||
<svg
|
||||
width="22"
|
||||
@@ -511,7 +516,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<p class="sr-only" id="copy-status" role="status" aria-live="polite"></p>
|
||||
<p
|
||||
class="sr-only"
|
||||
id="copy-status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-copied="Copied"
|
||||
data-selected="Selected"
|
||||
data-status-copied="Code copied to clipboard."
|
||||
data-status-selected="Code selected. Use your keyboard to copy it."
|
||||
></p>
|
||||
<a class="back-to-top" href="#top" aria-label="Back to top">
|
||||
<span aria-hidden="true">↑</span> Top
|
||||
</a>
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:site_name" content="REA" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:title" content="Your First Reverse Engineering Investigation — REA" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:description" content="Try REA on a small Notes app. Set up your agent, ask where exported notes go, and check the CSV formatter with a sample title." />
|
||||
@@ -54,9 +56,10 @@
|
||||
<script src="../assets/main.js" defer></script>
|
||||
</head>
|
||||
<body id="top">
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<!-- website:header:start -->
|
||||
<!-- prettier-ignore -->
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<!-- prettier-ignore -->
|
||||
<header class="site-header">
|
||||
<div class="container header-inner">
|
||||
<a class="brand" href="../" aria-label="REA home"
|
||||
@@ -76,6 +79,8 @@
|
||||
href="https://github.com/morluto/rea"
|
||||
aria-label="REA on GitHub"
|
||||
title="REA on GitHub"
|
||||
data-label-one="REA on GitHub ({count} star)"
|
||||
data-label-other="REA on GitHub ({count} stars)"
|
||||
>
|
||||
<svg
|
||||
width="22"
|
||||
@@ -450,7 +455,16 @@ JS</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<p class="sr-only" id="copy-status" role="status" aria-live="polite"></p>
|
||||
<p
|
||||
class="sr-only"
|
||||
id="copy-status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-copied="Copied"
|
||||
data-selected="Selected"
|
||||
data-status-copied="Code copied to clipboard."
|
||||
data-status-selected="Code selected. Use your keyboard to copy it."
|
||||
></p>
|
||||
<a class="back-to-top" href="#top" aria-label="Back to top">
|
||||
<span aria-hidden="true">↑</span> Top
|
||||
</a>
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:site_name" content="REA" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:title" content="Install REA and Set Up Your Coding Agent" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:description" content="Connect REA to your coding agent or inspect a local JavaScript or Electron app from the terminal. Setup, first questions and native provider guides." />
|
||||
@@ -54,9 +56,10 @@
|
||||
<script src="../assets/main.js" defer></script>
|
||||
</head>
|
||||
<body id="top">
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<!-- website:header:start -->
|
||||
<!-- prettier-ignore -->
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<!-- prettier-ignore -->
|
||||
<header class="site-header">
|
||||
<div class="container header-inner">
|
||||
<a class="brand" href="../" aria-label="REA home"
|
||||
@@ -76,6 +79,8 @@
|
||||
href="https://github.com/morluto/rea"
|
||||
aria-label="REA on GitHub"
|
||||
title="REA on GitHub"
|
||||
data-label-one="REA on GitHub ({count} star)"
|
||||
data-label-other="REA on GitHub ({count} stars)"
|
||||
>
|
||||
<svg
|
||||
width="22"
|
||||
@@ -587,7 +592,7 @@ fnm default 24</code></pre>
|
||||
class="copy-button"
|
||||
type="button"
|
||||
data-copy="ghidra-connect"
|
||||
aria-label="Copy ghidra connection · macos / linux"
|
||||
aria-label="Copy Ghidra connection for macOS and Linux"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
@@ -819,7 +824,16 @@ npx rea-agents@latest setup</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<p class="sr-only" id="copy-status" role="status" aria-live="polite"></p>
|
||||
<p
|
||||
class="sr-only"
|
||||
id="copy-status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-copied="Copied"
|
||||
data-selected="Selected"
|
||||
data-status-copied="Code copied to clipboard."
|
||||
data-status-selected="Code selected. Use your keyboard to copy it."
|
||||
></p>
|
||||
<a class="back-to-top" href="#top" aria-label="Back to top">
|
||||
<span aria-hidden="true">↑</span> Top
|
||||
</a>
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:site_name" content="REA" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:title" content="Browser and Network Analysis — REA" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:description" content="Observe a Notes export with REA: select a browser page, capture a click, inspect the request and follow the export script." />
|
||||
@@ -54,9 +56,10 @@
|
||||
<script src="../../assets/main.js" defer></script>
|
||||
</head>
|
||||
<body id="top">
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<!-- website:header:start -->
|
||||
<!-- prettier-ignore -->
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<!-- prettier-ignore -->
|
||||
<header class="site-header">
|
||||
<div class="container header-inner">
|
||||
<a class="brand" href="../../" aria-label="REA home"
|
||||
@@ -76,6 +79,8 @@
|
||||
href="https://github.com/morluto/rea"
|
||||
aria-label="REA on GitHub"
|
||||
title="REA on GitHub"
|
||||
data-label-one="REA on GitHub ({count} star)"
|
||||
data-label-other="REA on GitHub ({count} stars)"
|
||||
>
|
||||
<svg
|
||||
width="22"
|
||||
@@ -435,7 +440,16 @@ link.click();</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<p class="sr-only" id="copy-status" role="status" aria-live="polite"></p>
|
||||
<p
|
||||
class="sr-only"
|
||||
id="copy-status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-copied="Copied"
|
||||
data-selected="Selected"
|
||||
data-status-copied="Code copied to clipboard."
|
||||
data-status-selected="Code selected. Use your keyboard to copy it."
|
||||
></p>
|
||||
<a class="back-to-top" href="#top" aria-label="Back to top">
|
||||
<span aria-hidden="true">↑</span> Top
|
||||
</a>
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:site_name" content="REA" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:title" content="Reverse Engineering Guides — REA" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:description" content="Learn REA through short worked examples: a native calculation, an Electron CSV export and a browser request." />
|
||||
@@ -54,9 +56,10 @@
|
||||
<script src="../assets/main.js" defer></script>
|
||||
</head>
|
||||
<body id="top">
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<!-- website:header:start -->
|
||||
<!-- prettier-ignore -->
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<!-- prettier-ignore -->
|
||||
<header class="site-header">
|
||||
<div class="container header-inner">
|
||||
<a class="brand" href="../" aria-label="REA home"
|
||||
@@ -76,6 +79,8 @@
|
||||
href="https://github.com/morluto/rea"
|
||||
aria-label="REA on GitHub"
|
||||
title="REA on GitHub"
|
||||
data-label-one="REA on GitHub ({count} star)"
|
||||
data-label-other="REA on GitHub ({count} stars)"
|
||||
>
|
||||
<svg
|
||||
width="22"
|
||||
@@ -222,7 +227,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<p class="sr-only" id="copy-status" role="status" aria-live="polite"></p>
|
||||
<p
|
||||
class="sr-only"
|
||||
id="copy-status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-copied="Copied"
|
||||
data-selected="Selected"
|
||||
data-status-copied="Code copied to clipboard."
|
||||
data-status-selected="Code selected. Use your keyboard to copy it."
|
||||
></p>
|
||||
<a class="back-to-top" href="#top" aria-label="Back to top">
|
||||
<span aria-hidden="true">↑</span> Top
|
||||
</a>
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:site_name" content="REA" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:title" content="JavaScript and Electron Analysis — REA" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:description" content="Trace a Notes CSV export through a renderer API, preload and IPC handler with REA. Includes downloadable example files and a verified analysis result." />
|
||||
@@ -54,9 +56,10 @@
|
||||
<script src="../../assets/main.js" defer></script>
|
||||
</head>
|
||||
<body id="top">
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<!-- website:header:start -->
|
||||
<!-- prettier-ignore -->
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<!-- prettier-ignore -->
|
||||
<header class="site-header">
|
||||
<div class="container header-inner">
|
||||
<a class="brand" href="../../" aria-label="REA home"
|
||||
@@ -76,6 +79,8 @@
|
||||
href="https://github.com/morluto/rea"
|
||||
aria-label="REA on GitHub"
|
||||
title="REA on GitHub"
|
||||
data-label-one="REA on GitHub ({count} star)"
|
||||
data-label-other="REA on GitHub ({count} stars)"
|
||||
>
|
||||
<svg
|
||||
width="22"
|
||||
@@ -435,7 +440,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<p class="sr-only" id="copy-status" role="status" aria-live="polite"></p>
|
||||
<p
|
||||
class="sr-only"
|
||||
id="copy-status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-copied="Copied"
|
||||
data-selected="Selected"
|
||||
data-status-copied="Code copied to clipboard."
|
||||
data-status-selected="Code selected. Use your keyboard to copy it."
|
||||
></p>
|
||||
<a class="back-to-top" href="#top" aria-label="Back to top">
|
||||
<span aria-hidden="true">↑</span> Top
|
||||
</a>
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:site_name" content="REA" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:title" content="Native Binary Analysis — REA" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:description" content="Recover a native calculation with REA: inspect instructions, follow a caller, read constants and check reconstructed C." />
|
||||
@@ -54,9 +56,10 @@
|
||||
<script src="../../assets/main.js" defer></script>
|
||||
</head>
|
||||
<body id="top">
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<!-- website:header:start -->
|
||||
<!-- prettier-ignore -->
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<!-- prettier-ignore -->
|
||||
<header class="site-header">
|
||||
<div class="container header-inner">
|
||||
<a class="brand" href="../../" aria-label="REA home"
|
||||
@@ -76,6 +79,8 @@
|
||||
href="https://github.com/morluto/rea"
|
||||
aria-label="REA on GitHub"
|
||||
title="REA on GitHub"
|
||||
data-label-one="REA on GitHub ({count} star)"
|
||||
data-label-other="REA on GitHub ({count} stars)"
|
||||
>
|
||||
<svg
|
||||
width="22"
|
||||
@@ -449,7 +454,16 @@ return (DxBallInt)pan;</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<p class="sr-only" id="copy-status" role="status" aria-live="polite"></p>
|
||||
<p
|
||||
class="sr-only"
|
||||
id="copy-status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-copied="Copied"
|
||||
data-selected="Selected"
|
||||
data-status-copied="Code copied to clipboard."
|
||||
data-status-selected="Code selected. Use your keyboard to copy it."
|
||||
></p>
|
||||
<a class="back-to-top" href="#top" aria-label="Back to top">
|
||||
<span aria-hidden="true">↑</span> Top
|
||||
</a>
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:site_name" content="REA" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:title" content="REA — Reverse Engineering for Your Coding Agent" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:description" content="Reverse engineer apps, binaries and browser behavior with your coding agent. Follow clear guides and try interactive examples with REA." />
|
||||
@@ -56,9 +58,10 @@
|
||||
<script type="module" src="assets/dino-demo.js"></script>
|
||||
</head>
|
||||
<body class="home-page" id="top">
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<!-- website:header:start -->
|
||||
<!-- prettier-ignore -->
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<!-- prettier-ignore -->
|
||||
<header class="site-header">
|
||||
<div class="container header-inner">
|
||||
<a class="brand" href="./" aria-label="REA home"
|
||||
@@ -78,6 +81,8 @@
|
||||
href="https://github.com/morluto/rea"
|
||||
aria-label="REA on GitHub"
|
||||
title="REA on GitHub"
|
||||
data-label-one="REA on GitHub ({count} star)"
|
||||
data-label-other="REA on GitHub ({count} stars)"
|
||||
>
|
||||
<svg
|
||||
width="22"
|
||||
@@ -512,7 +517,24 @@
|
||||
</div>
|
||||
</dl>
|
||||
<div class="home-example-grid dino-example-grid">
|
||||
<figure class="dino-demo" data-dino-demo>
|
||||
<figure
|
||||
class="dino-demo"
|
||||
data-dino-demo
|
||||
data-msg-play="Play"
|
||||
data-msg-pause="Pause"
|
||||
data-msg-play-again="Play again"
|
||||
data-msg-ready="Press Play, then jump over the cactus."
|
||||
data-msg-running="Running. Space or Jump clears a cactus."
|
||||
data-msg-paused="Paused. Press Play to continue."
|
||||
data-msg-crashed="Hit a cactus. Press Play again, or change the speed and restart."
|
||||
data-msg-fixed="Fixed speed: {speed}."
|
||||
data-msg-fixed-running="Fixed speed: {speed}. Space or Jump clears a cactus."
|
||||
data-msg-fixed-stopped="Fixed speed: {speed}. Press Play to try it."
|
||||
data-msg-rule="Recovered rule: start at 6, then increase toward 13."
|
||||
data-msg-distance="DISTANCE {distance}"
|
||||
data-msg-canvas-ready="Space or Jump ↑"
|
||||
data-msg-canvas-crashed="Hit a cactus. Try another speed."
|
||||
>
|
||||
<p class="figure-title">Play the reconstruction</p>
|
||||
<div class="dino-stage">
|
||||
<svg
|
||||
@@ -746,7 +768,14 @@ SPEED: 6</code></pre>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="home-example-grid">
|
||||
<figure class="percent-figure" data-percent-demo>
|
||||
<figure
|
||||
class="percent-figure"
|
||||
data-percent-demo
|
||||
data-msg-input="Press % to see what happens to 10."
|
||||
data-msg-percent-add="10% of 200 is 20. Press = to add it."
|
||||
data-msg-percent-multiply="For multiplication, 10 becomes 0.1. Press = to multiply."
|
||||
data-msg-result="200 {operator} {percent} = {result}."
|
||||
>
|
||||
<p class="figure-title">Try the calculation</p>
|
||||
<div
|
||||
class="percent-operation"
|
||||
@@ -1115,7 +1144,16 @@ SPEED: 6</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<p class="sr-only" id="copy-status" role="status" aria-live="polite"></p>
|
||||
<p
|
||||
class="sr-only"
|
||||
id="copy-status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-copied="Copied"
|
||||
data-selected="Selected"
|
||||
data-status-copied="Code copied to clipboard."
|
||||
data-status-selected="Code selected. Use your keyboard to copy it."
|
||||
></p>
|
||||
<a class="back-to-top" href="#top" aria-label="Back to top">
|
||||
<span aria-hidden="true">↑</span> Top
|
||||
</a>
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:site_name" content="REA" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:title" content="Aegis APK Reverse Engineering: Login Codes — REA" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:description" content="Inspect the Aegis Android APK with REA, recover its six-digit login-code calculation and try a reconstruction with an adjustable clock." />
|
||||
@@ -55,9 +57,10 @@
|
||||
<script type="module" src="../../assets/aegis-demo.js"></script>
|
||||
</head>
|
||||
<body id="top">
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<!-- website:header:start -->
|
||||
<!-- prettier-ignore -->
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<!-- prettier-ignore -->
|
||||
<header class="site-header">
|
||||
<div class="container header-inner">
|
||||
<a class="brand" href="../../" aria-label="REA home"
|
||||
@@ -77,6 +80,8 @@
|
||||
href="https://github.com/morluto/rea"
|
||||
aria-label="REA on GitHub"
|
||||
title="REA on GitHub"
|
||||
data-label-one="REA on GitHub ({count} star)"
|
||||
data-label-other="REA on GitHub ({count} stars)"
|
||||
>
|
||||
<svg
|
||||
width="22"
|
||||
@@ -747,7 +752,16 @@ npx rea-agents@latest inspect-android-package \
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<p class="sr-only" id="copy-status" role="status" aria-live="polite"></p>
|
||||
<p
|
||||
class="sr-only"
|
||||
id="copy-status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-copied="Copied"
|
||||
data-selected="Selected"
|
||||
data-status-copied="Code copied to clipboard."
|
||||
data-status-selected="Code selected. Use your keyboard to copy it."
|
||||
></p>
|
||||
<a class="back-to-top" href="#top" aria-label="Back to top">
|
||||
<span aria-hidden="true">↑</span> Top
|
||||
</a>
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:site_name" content="REA" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:title" content="CTF Reverse Engineering: DownUnderCTF Flag Checker — REA" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:description" content="Use REA to solve DownUnderCTF's masked squares flag checker. Find the checking functions, extract the masks and compare a recovered flag with the original program." />
|
||||
@@ -54,9 +56,10 @@
|
||||
<script src="../../assets/main.js" defer></script>
|
||||
</head>
|
||||
<body id="top">
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<!-- website:header:start -->
|
||||
<!-- prettier-ignore -->
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<!-- prettier-ignore -->
|
||||
<header class="site-header">
|
||||
<div class="container header-inner">
|
||||
<a class="brand" href="../../" aria-label="REA home"
|
||||
@@ -76,6 +79,8 @@
|
||||
href="https://github.com/morluto/rea"
|
||||
aria-label="REA on GitHub"
|
||||
title="REA on GitHub"
|
||||
data-label-one="REA on GitHub ({count} star)"
|
||||
data-label-other="REA on GitHub ({count} stars)"
|
||||
>
|
||||
<svg
|
||||
width="22"
|
||||
@@ -717,7 +722,16 @@ chmod +x ms_flag_checker
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<p class="sr-only" id="copy-status" role="status" aria-live="polite"></p>
|
||||
<p
|
||||
class="sr-only"
|
||||
id="copy-status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-copied="Copied"
|
||||
data-selected="Selected"
|
||||
data-status-copied="Code copied to clipboard."
|
||||
data-status-selected="Code selected. Use your keyboard to copy it."
|
||||
></p>
|
||||
<a class="back-to-top" href="#top" aria-label="Back to top">
|
||||
<span aria-hidden="true">↑</span> Top
|
||||
</a>
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:site_name" content="REA" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:title" content="DX-Ball Reverse Engineering: Sound Panning — REA" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:description" content="Follow a DX-Ball reconstruction from binary analysis through REA to maintained C, original-x86 tests and matching compiled function bytes." />
|
||||
@@ -54,9 +56,10 @@
|
||||
<script src="../../assets/main.js" defer></script>
|
||||
</head>
|
||||
<body id="top">
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<!-- website:header:start -->
|
||||
<!-- prettier-ignore -->
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<!-- prettier-ignore -->
|
||||
<header class="site-header">
|
||||
<div class="container header-inner">
|
||||
<a class="brand" href="../../" aria-label="REA home"
|
||||
@@ -76,6 +79,8 @@
|
||||
href="https://github.com/morluto/rea"
|
||||
aria-label="REA on GitHub"
|
||||
title="REA on GitHub"
|
||||
data-label-one="REA on GitHub ({count} star)"
|
||||
data-label-other="REA on GitHub ({count} stars)"
|
||||
>
|
||||
<svg
|
||||
width="22"
|
||||
@@ -725,7 +730,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<p class="sr-only" id="copy-status" role="status" aria-live="polite"></p>
|
||||
<p
|
||||
class="sr-only"
|
||||
id="copy-status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-copied="Copied"
|
||||
data-selected="Selected"
|
||||
data-status-copied="Code copied to clipboard."
|
||||
data-status-selected="Code selected. Use your keyboard to copy it."
|
||||
></p>
|
||||
<a class="back-to-top" href="#top" aria-label="Back to top">
|
||||
<span aria-hidden="true">↑</span> Top
|
||||
</a>
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:site_name" content="REA" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:title" content="Reverse Engineering Case Studies — REA" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:description" content="Follow REA investigations through code, figures and demos: an Android authenticator, DX-Ball, Notion, TH04 and a DownUnderCTF flag checker." />
|
||||
@@ -54,9 +56,10 @@
|
||||
<script src="../assets/main.js" defer></script>
|
||||
</head>
|
||||
<body id="top">
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<!-- website:header:start -->
|
||||
<!-- prettier-ignore -->
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<!-- prettier-ignore -->
|
||||
<header class="site-header">
|
||||
<div class="container header-inner">
|
||||
<a class="brand" href="../" aria-label="REA home"
|
||||
@@ -76,6 +79,8 @@
|
||||
href="https://github.com/morluto/rea"
|
||||
aria-label="REA on GitHub"
|
||||
title="REA on GitHub"
|
||||
data-label-one="REA on GitHub ({count} star)"
|
||||
data-label-other="REA on GitHub ({count} stars)"
|
||||
>
|
||||
<svg
|
||||
width="22"
|
||||
@@ -257,7 +262,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<p class="sr-only" id="copy-status" role="status" aria-live="polite"></p>
|
||||
<p
|
||||
class="sr-only"
|
||||
id="copy-status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-copied="Copied"
|
||||
data-selected="Selected"
|
||||
data-status-copied="Code copied to clipboard."
|
||||
data-status-selected="Code selected. Use your keyboard to copy it."
|
||||
></p>
|
||||
<a class="back-to-top" href="#top" aria-label="Back to top">
|
||||
<span aria-hidden="true">↑</span> Top
|
||||
</a>
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:site_name" content="REA" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:title" content="Notion Electron Reverse Engineering: Clipboard — REA" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:description" content="Follow Notion's clipboard API through Electron's preload, IPC wrappers and main process, with short code excerpts and a look at its rich clipboard format." />
|
||||
@@ -54,9 +56,10 @@
|
||||
<script src="../../assets/main.js" defer></script>
|
||||
</head>
|
||||
<body id="top">
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<!-- website:header:start -->
|
||||
<!-- prettier-ignore -->
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<!-- prettier-ignore -->
|
||||
<header class="site-header">
|
||||
<div class="container header-inner">
|
||||
<a class="brand" href="../../" aria-label="REA home"
|
||||
@@ -76,6 +79,8 @@
|
||||
href="https://github.com/morluto/rea"
|
||||
aria-label="REA on GitHub"
|
||||
title="REA on GitHub"
|
||||
data-label-one="REA on GitHub ({count} star)"
|
||||
data-label-other="REA on GitHub ({count} stars)"
|
||||
>
|
||||
<svg
|
||||
width="22"
|
||||
@@ -563,7 +568,16 @@ mimeTypes:
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<p class="sr-only" id="copy-status" role="status" aria-live="polite"></p>
|
||||
<p
|
||||
class="sr-only"
|
||||
id="copy-status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-copied="Copied"
|
||||
data-selected="Selected"
|
||||
data-status-copied="Code copied to clipboard."
|
||||
data-status-selected="Code selected. Use your keyboard to copy it."
|
||||
></p>
|
||||
<a class="back-to-top" href="#top" aria-label="Back to top">
|
||||
<span aria-hidden="true">↑</span> Top
|
||||
</a>
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:site_name" content="REA" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:title" content="TH04 Reverse Engineering: DOS Bullet Patterns — REA" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:description" content="Inspect TH04's original 16-bit DOS code with REA. Follow the assembly and recovered C++ to see how a ring of bullets is spaced and aimed at the player." />
|
||||
@@ -54,9 +56,10 @@
|
||||
<script src="../../assets/main.js" defer></script>
|
||||
</head>
|
||||
<body id="top">
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<!-- website:header:start -->
|
||||
<!-- prettier-ignore -->
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<!-- prettier-ignore -->
|
||||
<header class="site-header">
|
||||
<div class="container header-inner">
|
||||
<a class="brand" href="../../" aria-label="REA home"
|
||||
@@ -76,6 +79,8 @@
|
||||
href="https://github.com/morluto/rea"
|
||||
aria-label="REA on GitHub"
|
||||
title="REA on GitHub"
|
||||
data-label-one="REA on GitHub ({count} star)"
|
||||
data-label-other="REA on GitHub ({count} stars)"
|
||||
>
|
||||
<svg
|
||||
width="22"
|
||||
@@ -740,7 +745,16 @@ no_aim:
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<p class="sr-only" id="copy-status" role="status" aria-live="polite"></p>
|
||||
<p
|
||||
class="sr-only"
|
||||
id="copy-status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-copied="Copied"
|
||||
data-selected="Selected"
|
||||
data-status-copied="Code copied to clipboard."
|
||||
data-status-selected="Code selected. Use your keyboard to copy it."
|
||||
></p>
|
||||
<a class="back-to-top" href="#top" aria-label="Back to top">
|
||||
<span aria-hidden="true">↑</span> Top
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,854 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>安裝 REA 並設定你的 AI 代理</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="將 REA 連接到你的 AI 代理,或在終端機中檢查本機的 JavaScript 或 Electron 應用程式。包括設定步驟、第一個問題和原生分析提供者指南。"
|
||||
/>
|
||||
<meta name="robots" content="noindex" />
|
||||
<!-- website:translation source-sha256="a6c81c517ca7f105e4716ae30f82140a7f397f0195d11e9cc9cebbc35982b022" status="draft" -->
|
||||
<!-- website:sharing:start -->
|
||||
<!-- prettier-ignore -->
|
||||
<link rel="canonical" href="https://rea.tools/zh-hant/get-started/" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:type" content="website" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:site_name" content="REA" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:locale" content="zh_TW" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:title" content="安裝 REA 並設定你的 AI 代理" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:description" content="將 REA 連接到你的 AI 代理,或在終端機中檢查本機的 JavaScript 或 Electron 應用程式。包括設定步驟、第一個問題和原生分析提供者指南。" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:url" content="https://rea.tools/zh-hant/get-started/" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:image" content="https://rea.tools/assets/social-card.png" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:image:type" content="image/png" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:image:height" content="630" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta
|
||||
property="og:image:alt"
|
||||
content="REA:了解軟體如何運作。與你的 AI 代理一起進行逆向工程。"
|
||||
/>
|
||||
<!-- prettier-ignore -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta name="twitter:title" content="安裝 REA 並設定你的 AI 代理" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta name="twitter:description" content="將 REA 連接到你的 AI 代理,或在終端機中檢查本機的 JavaScript 或 Electron 應用程式。包括設定步驟、第一個問題和原生分析提供者指南。" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta name="twitter:image" content="https://rea.tools/assets/social-card.png" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta
|
||||
name="twitter:image:alt"
|
||||
content="REA:了解軟體如何運作。與你的 AI 代理一起進行逆向工程。"
|
||||
/>
|
||||
<!-- website:sharing:end -->
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<link rel="icon" href="../../assets/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="stylesheet" href="../../assets/styles.css" />
|
||||
<script src="../../assets/main.js" defer></script>
|
||||
</head>
|
||||
<body id="top">
|
||||
<!-- website:header:start -->
|
||||
<!-- prettier-ignore -->
|
||||
<a class="skip-link" href="#main">跳至主要內容</a>
|
||||
<!-- prettier-ignore -->
|
||||
<header class="site-header">
|
||||
<div class="container header-inner">
|
||||
<a class="brand" href="../../" aria-label="REA 首頁"
|
||||
><span class="brand-name">REA</span
|
||||
><span class="brand-caption">Reverse Engineer Anything</span></a
|
||||
>
|
||||
<nav class="navigation" aria-label="主要導覽">
|
||||
<a href="../../showcase/" hreflang="en">案例<span class="nav-badge" lang="en">EN</span></a>
|
||||
<a href="../../blog/" hreflang="en">部落格<span class="nav-badge" lang="en">EN</span></a>
|
||||
<a href="../../zh-hant/get-started/" class="nav-start" aria-current="page">快速開始</a>
|
||||
<a href="../../guides/" hreflang="en">指南<span class="nav-badge" lang="en">EN</span></a>
|
||||
<a href="../../faq/" hreflang="en">常見問題<span class="nav-badge" lang="en">EN</span></a>
|
||||
</nav>
|
||||
<div class="header-social">
|
||||
<details class="language-menu">
|
||||
<summary>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path fill="none" stroke="currentColor" stroke-width="1.6" d="M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18Z M3.6 9h16.8 M3.6 15h16.8 M12 3c2.4 2.5 3.6 5.5 3.6 9s-1.2 6.5-3.6 9c-2.4-2.5-3.6-5.5-3.6-9s1.2-6.5 3.6-9Z" />
|
||||
</svg>
|
||||
<span class="sr-only">語言: </span><span class="language-current">繁體中文</span>
|
||||
</summary>
|
||||
<ul class="language-list">
|
||||
<li><a href="../../get-started/" hreflang="en" lang="en">English</a></li>
|
||||
<li><a href="../../zh-hant/get-started/" hreflang="zh-Hant" lang="zh-Hant" aria-current="true">繁體中文</a></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a
|
||||
class="nav-github"
|
||||
href="https://github.com/morluto/rea"
|
||||
aria-label="REA 的 GitHub"
|
||||
title="REA 的 GitHub"
|
||||
data-label-other="REA 的 GitHub({count} 顆星)"
|
||||
>
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82a7.65 7.65 0 0 1 2-.27c.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="nav-github-count" hidden aria-hidden="true"></span>
|
||||
</a>
|
||||
<a
|
||||
class="nav-discord"
|
||||
href="https://discord.gg/GkcryMnJDM"
|
||||
aria-label="加入 REA 的 Discord"
|
||||
title="加入 REA 的 Discord"
|
||||
>
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M20.317 4.3698a19.7913 19.7913 0 0 0-4.8851-1.5152.0741.0741 0 0 0-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 0 0-.0785-.037 19.7363 19.7363 0 0 0-4.8852 1.515.0699.0699 0 0 0-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 0 0 .0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 0 0 .0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 0 0-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 0 1-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 0 1 .0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 0 1 .0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 0 1-.0066.1276 12.2986 12.2986 0 0 1-1.873.8914.0766.0766 0 0 0-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 0 0 .0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 0 0 .0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 0 0-.0312-.0286ZM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189Zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
class="nav-x"
|
||||
href="https://x.com/reatools"
|
||||
aria-label="在 X 上追蹤 REA"
|
||||
title="在 X 上追蹤 REA"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<!-- website:header:end -->
|
||||
<main id="main" class="container">
|
||||
<!-- website:translation-notice:start -->
|
||||
<!-- website:translation-notice:end -->
|
||||
<div class="page-heading">
|
||||
<p class="breadcrumb"><a href="../../">首頁</a> / 快速開始</p>
|
||||
<h1>設定 REA。</h1>
|
||||
<p class="lede">
|
||||
將 REA 連接到你的 AI 代理,或在終端機中檢查本機應用程式。
|
||||
</p>
|
||||
<p class="requirements">
|
||||
你需要 Node.js 22(22.19 以上)、24(24.11 以上)或 26 以上版本。npm
|
||||
會隨 Node.js 一起安裝。
|
||||
</p>
|
||||
<details class="evidence-details" id="install-node">
|
||||
<summary>還沒有 Node.js,或不確定是否已安裝?</summary>
|
||||
<p>開啟終端機並檢查:</p>
|
||||
<div class="code-block">
|
||||
<div class="code-header">
|
||||
<span>終端機</span
|
||||
><button
|
||||
class="copy-button"
|
||||
type="button"
|
||||
data-copy="node-check"
|
||||
aria-label="複製 Node.js 版本檢查命令"
|
||||
>
|
||||
複製
|
||||
</button>
|
||||
</div>
|
||||
<pre><code id="node-check">node -v
|
||||
npm -v</code></pre>
|
||||
</div>
|
||||
<p>
|
||||
如果 <code>node -v</code> 顯示 v22.19+、v24.11+ 或
|
||||
v26+,就可以開始了。如果顯示較舊的版本,或出現「<span
|
||||
data-os-ui="windows"
|
||||
>無法辨識</span
|
||||
>」(英文系統:<span lang="en">not recognized</span>)或「<span
|
||||
lang="en"
|
||||
>command not found</span
|
||||
>」,請安裝 Node.js 24 LTS:
|
||||
</p>
|
||||
<p><strong>Windows (PowerShell)</strong></p>
|
||||
<div class="code-block">
|
||||
<div class="code-header">
|
||||
<span>Windows PowerShell</span
|
||||
><button
|
||||
class="copy-button"
|
||||
type="button"
|
||||
data-copy="node-windows"
|
||||
aria-label="複製 Windows 的 Node.js 安裝命令"
|
||||
>
|
||||
複製
|
||||
</button>
|
||||
</div>
|
||||
<pre><code id="node-windows">winget install OpenJS.NodeJS.LTS</code></pre>
|
||||
</div>
|
||||
<p>
|
||||
如果要求你接受條款,請輸入 Y。關閉 PowerShell 後重新開啟,再執行
|
||||
<code>node -v</code>。
|
||||
</p>
|
||||
<p>
|
||||
如果之後執行 <code>npx</code> 時,PowerShell 顯示<em
|
||||
data-os-ui="windows"
|
||||
>「因為這個系統上已停用指令碼執行」</em
|
||||
>(英文系統:<em lang="en"
|
||||
>"running scripts is disabled on this system"</em
|
||||
>),請執行下列命令一次,然後再試:
|
||||
</p>
|
||||
<div class="code-block">
|
||||
<div class="code-header">
|
||||
<span>Windows PowerShell</span
|
||||
><button
|
||||
class="copy-button"
|
||||
type="button"
|
||||
data-copy="node-windows-policy"
|
||||
aria-label="複製 PowerShell 執行原則命令"
|
||||
>
|
||||
複製
|
||||
</button>
|
||||
</div>
|
||||
<pre><code id="node-windows-policy">Set-ExecutionPolicy -Scope CurrentUser RemoteSigned</code></pre>
|
||||
</div>
|
||||
<p><strong>macOS</strong></p>
|
||||
<p>如果你使用 Homebrew:</p>
|
||||
<div class="code-block">
|
||||
<div class="code-header">
|
||||
<span>macOS · Homebrew</span
|
||||
><button
|
||||
class="copy-button"
|
||||
type="button"
|
||||
data-copy="node-macos-brew"
|
||||
aria-label="複製以 Homebrew 安裝 Node.js 的命令"
|
||||
>
|
||||
複製
|
||||
</button>
|
||||
</div>
|
||||
<pre><code id="node-macos-brew">brew install node</code></pre>
|
||||
</div>
|
||||
<p>否則:</p>
|
||||
<div class="code-block">
|
||||
<div class="code-header">
|
||||
<span>macOS</span
|
||||
><button
|
||||
class="copy-button"
|
||||
type="button"
|
||||
data-copy="node-macos-fnm"
|
||||
aria-label="複製 macOS 的 fnm 安裝命令"
|
||||
>
|
||||
複製
|
||||
</button>
|
||||
</div>
|
||||
<pre><code id="node-macos-fnm">curl -fsSL https://fnm.vercel.app/install | bash</code></pre>
|
||||
</div>
|
||||
<p>
|
||||
關閉<span data-os-ui="macos">終端機</span>後重新開啟,然後執行:
|
||||
</p>
|
||||
<div class="code-block">
|
||||
<div class="code-header">
|
||||
<span>macOS</span
|
||||
><button
|
||||
class="copy-button"
|
||||
type="button"
|
||||
data-copy="node-macos-install"
|
||||
aria-label="複製 macOS 的 Node.js 24 命令"
|
||||
>
|
||||
複製
|
||||
</button>
|
||||
</div>
|
||||
<pre><code id="node-macos-install">fnm install 24
|
||||
fnm default 24</code></pre>
|
||||
</div>
|
||||
<p><strong>Linux</strong></p>
|
||||
<div class="code-block">
|
||||
<div class="code-header">
|
||||
<span>Linux</span
|
||||
><button
|
||||
class="copy-button"
|
||||
type="button"
|
||||
data-copy="node-linux-fnm"
|
||||
aria-label="複製 Linux 的 fnm 安裝命令"
|
||||
>
|
||||
複製
|
||||
</button>
|
||||
</div>
|
||||
<pre><code id="node-linux-fnm">curl -fsSL https://fnm.vercel.app/install | bash</code></pre>
|
||||
</div>
|
||||
<p>
|
||||
安裝程式需要 <code>curl</code> 和 <code>unzip</code>(Debian/Ubuntu
|
||||
上可執行
|
||||
<code>sudo apt install curl unzip</code
|
||||
>)。關閉終端機後重新開啟,然後執行:
|
||||
</p>
|
||||
<div class="code-block">
|
||||
<div class="code-header">
|
||||
<span>Linux</span
|
||||
><button
|
||||
class="copy-button"
|
||||
type="button"
|
||||
data-copy="node-linux-install"
|
||||
aria-label="複製 Linux 的 Node.js 24 命令"
|
||||
>
|
||||
複製
|
||||
</button>
|
||||
</div>
|
||||
<pre><code id="node-linux-install">fnm install 24
|
||||
fnm default 24</code></pre>
|
||||
</div>
|
||||
<p>
|
||||
Linux 發行版本身的 <code>nodejs</code> 套件通常比 22.19
|
||||
舊;如果改用它,請先用 <code>node -v</code> 檢查版本。
|
||||
</p>
|
||||
<p>
|
||||
想用一般的安裝程式?請從
|
||||
<a href="https://nodejs.org/en/download">nodejs.org</a> 下載 LTS
|
||||
版本。
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
<div class="article-layout">
|
||||
<nav class="contents" aria-label="本頁內容">
|
||||
<p>設定選項</p>
|
||||
<ul>
|
||||
<li><a href="#agent">連接你的 AI 代理</a></li>
|
||||
<li><a href="#terminal">使用 CLI</a></li>
|
||||
<li><a href="#native">設定原生分析</a></li>
|
||||
<li><a href="#help">取得協助</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<article class="article" aria-label="REA 快速開始指南">
|
||||
<section class="article-section" id="agent">
|
||||
<h2>連接你的 AI 代理</h2>
|
||||
<p>
|
||||
執行設定流程,將 REA 連接到你的 AI 代理。可選擇的用戶端包括 Claude
|
||||
Code、Codex、Cursor、Gemini CLI、Windsurf、Grok Build 和其他支援的
|
||||
MCP 用戶端。Grok Bot 的連接器要從它的聊天介面新增。
|
||||
</p>
|
||||
<details class="evidence-details">
|
||||
<summary>第一次使用 AI 代理?</summary>
|
||||
<p>
|
||||
AI 代理是在你的電腦上運作的 AI
|
||||
助理,而不是在瀏覽器分頁中。它可以讀取檔案、執行命令,並在做出變更前先詢問你。REA
|
||||
為它提供逆向工程工具。
|
||||
</p>
|
||||
<p>
|
||||
如果你還沒有 AI 代理,請挑選一個,依照它的安裝頁面安裝並登入:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong
|
||||
><a href="https://code.claude.com/docs/en/setup"
|
||||
>Claude Code</a
|
||||
></strong
|
||||
>,由 Anthropic 開發。需要付費的 Claude 方案。
|
||||
</li>
|
||||
<li>
|
||||
<strong
|
||||
><a href="https://github.com/openai/codex">Codex</a></strong
|
||||
>,由 OpenAI 開發。使用你的 ChatGPT 帳號登入。
|
||||
</li>
|
||||
<li>
|
||||
<strong
|
||||
><a href="https://opencode.ai/docs">OpenCode</a></strong
|
||||
>,開放原始碼。可連接你選擇的模型供應商。
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
接著開啟它,把下面的提示詞複製進去。AI 代理會執行 REA
|
||||
的設定流程,並在做任何變更前先讓你看設定計畫。
|
||||
</p>
|
||||
</details>
|
||||
<div class="agent-terminal">
|
||||
<div class="terminal-header">
|
||||
<span class="terminal-title"
|
||||
><span class="terminal-dots" aria-hidden="true"
|
||||
><i></i><i></i><i></i></span
|
||||
>你的 AI 代理</span
|
||||
>
|
||||
<button
|
||||
class="copy-button"
|
||||
type="button"
|
||||
data-copy="agent-install-prompt"
|
||||
aria-label="複製 REA 安裝提示詞"
|
||||
>
|
||||
複製
|
||||
</button>
|
||||
</div>
|
||||
<p class="terminal-prompt">
|
||||
<span class="prompt-symbol" aria-hidden="true">›</span
|
||||
><span id="agent-install-prompt"
|
||||
>使用 npx rea-agents@latest setup 安裝 REA,並將它連接到這個
|
||||
AI 代理。先讓我核准設定計畫,然後驗證安裝結果。</span
|
||||
><span class="terminal-cursor" aria-hidden="true"></span>
|
||||
</p>
|
||||
</div>
|
||||
<details class="evidence-details">
|
||||
<summary>或自行執行設定</summary>
|
||||
<details class="evidence-details">
|
||||
<summary>如何開啟終端機?</summary>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Windows:</strong>開啟<span data-os-ui="windows"
|
||||
>「開始」功能表</span
|
||||
>,輸入 <em>PowerShell</em> 後按 Enter。
|
||||
</li>
|
||||
<li>
|
||||
<strong>macOS:</strong>按 <kbd>⌘</kbd>
|
||||
<kbd>Space</kbd>,輸入<em data-os-ui="macos">終端機</em>或
|
||||
<em>Terminal</em> 後按 Enter。
|
||||
</li>
|
||||
<li>
|
||||
<strong>Linux:</strong>在大多數桌面環境中,按
|
||||
<kbd>Ctrl</kbd> <kbd>Alt</kbd> <kbd>T</kbd>。
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
用 <kbd>Ctrl</kbd> <kbd>V</kbd> 貼上命令(macOS:<kbd>⌘</kbd>
|
||||
<kbd>V</kbd>;Linux 終端機:<kbd>Ctrl</kbd> <kbd>Shift</kbd>
|
||||
<kbd>V</kbd>),然後按 Enter。
|
||||
</p>
|
||||
</details>
|
||||
<div class="code-block">
|
||||
<div class="code-header">
|
||||
<span>終端機</span
|
||||
><button
|
||||
class="copy-button"
|
||||
type="button"
|
||||
data-copy="agent-setup"
|
||||
aria-label="複製 AI 代理設定命令"
|
||||
>
|
||||
複製
|
||||
</button>
|
||||
</div>
|
||||
<pre><code id="agent-setup">npx rea-agents@latest setup</code></pre>
|
||||
</div>
|
||||
</details>
|
||||
<p>
|
||||
選擇你的用戶端並檢查設定計畫。REA 會加入它的 MCP 連線和對應的調查
|
||||
skill,並備份現有的設定。套用變更前,它會先詢問你。
|
||||
</p>
|
||||
<p>設定完成後,請重新啟動你的用戶端。</p>
|
||||
<details class="evidence-details">
|
||||
<summary>如何重新啟動用戶端?</summary>
|
||||
<p>
|
||||
<strong>桌面應用程式</strong>(Claude
|
||||
Desktop、Cursor、Windsurf、VS
|
||||
Code):完全結束應用程式,再重新開啟。只關閉視窗是不夠的。在
|
||||
macOS 上按 <kbd>⌘</kbd> <kbd>Q</kbd>;在 Windows
|
||||
上,如果有系統匣圖示,也要從那裡結束。
|
||||
</p>
|
||||
<p>
|
||||
<strong>終端機 AI 代理</strong>(Claude Code、Codex、Gemini
|
||||
CLI):結束工作階段後重新啟動。
|
||||
</p>
|
||||
</details>
|
||||
<p>
|
||||
給你的 AI 代理一個目標路徑和一個具體的問題。如果是 JavaScript 或
|
||||
Electron 應用程式,可以這樣開始:
|
||||
</p>
|
||||
<blockquote class="prompt">
|
||||
<p>
|
||||
請查看
|
||||
<code>/absolute/path/to/app</code
|
||||
>,找出它如何匯出資料。列出相關的進入點、呼叫和相依性,並附上每項發現的原始碼位置。
|
||||
</p>
|
||||
</blockquote>
|
||||
<p>把路徑換成你本機的目標。</p>
|
||||
<details class="evidence-details">
|
||||
<summary>如何取得資料夾的完整路徑?</summary>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Windows:</strong>在<span data-os-ui="windows"
|
||||
>檔案總管</span
|
||||
>中,以滑鼠右鍵按一下資料夾,然後選擇<em data-os-ui="windows"
|
||||
>複製路徑</em
|
||||
>。在 Windows 10 上,請按住 Shift 再按右鍵,然後選擇<em
|
||||
data-os-ui="windows"
|
||||
>複製為路徑</em
|
||||
>。
|
||||
</li>
|
||||
<li>
|
||||
<strong>macOS:</strong>在 Finder 中,以右鍵按一下資料夾,按住
|
||||
Option 鍵,然後選擇<em data-os-ui="macos"
|
||||
>將「…」拷貝為路徑名稱</em
|
||||
>。
|
||||
</li>
|
||||
<li>
|
||||
<strong>任何系統:</strong
|
||||
>把資料夾拖進終端機視窗,就會出現它的路徑。
|
||||
</li>
|
||||
</ul>
|
||||
<p>在終端機中,如果路徑含有空格,請用引號括起來。</p>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="article-section" id="terminal">
|
||||
<h2>使用 CLI</h2>
|
||||
<p>
|
||||
不執行應用程式,直接分析 JavaScript 或 Electron 應用程式的資料夾或
|
||||
ASAR 封存檔:
|
||||
</p>
|
||||
<div class="code-block">
|
||||
<div class="code-header">
|
||||
<span>靜態應用程式分析</span
|
||||
><button
|
||||
class="copy-button"
|
||||
type="button"
|
||||
data-copy="terminal-analysis"
|
||||
aria-label="複製 JavaScript 分析命令"
|
||||
>
|
||||
複製
|
||||
</button>
|
||||
</div>
|
||||
<pre><code id="terminal-analysis">npx -y rea-agents@latest analyze-javascript-application \
|
||||
/absolute/path/to/app --json</code></pre>
|
||||
</div>
|
||||
<p>
|
||||
把路徑換成你解開後的應用程式目錄或 <code>.asar</code> 檔案。在
|
||||
Windows 上,可以使用 <code>"D:/apps/example"</code> 這類路徑。
|
||||
</p>
|
||||
<details class="evidence-details">
|
||||
<summary>應用程式的 .asar 檔案在哪裡?</summary>
|
||||
<p>
|
||||
Electron 應用程式通常把它放在 <code>resources</code> 資料夾中:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Windows:</strong>
|
||||
|
||||
<code
|
||||
>C:\Users\<you>\AppData\Local\Programs\<App>\resources\app.asar</code
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<strong>macOS:</strong>
|
||||
|
||||
<code
|
||||
>/Applications/<App>.app/Contents/Resources/app.asar</code
|
||||
>
|
||||
(以右鍵按一下應用程式,然後選擇<em data-os-ui="macos"
|
||||
>顯示套件內容</em
|
||||
>)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Linux:</strong>通常在
|
||||
<code>/opt/<App>/resources/app.asar</code>
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
有些應用程式會安裝在其他位置。請在應用程式的資料夾中搜尋
|
||||
<code>app.asar</code>。
|
||||
</p>
|
||||
</details>
|
||||
<p>
|
||||
結果會識別應用程式、顯示模組之間的連結,並提供它們的原始碼位置。REA
|
||||
無法解析的連結會在結果中標示出來。
|
||||
</p>
|
||||
<p>如果想在 shell 中使用固定的 <code>rea</code> 命令:</p>
|
||||
<div class="code-block">
|
||||
<div class="code-header">
|
||||
<span>安裝 CLI</span
|
||||
><button
|
||||
class="copy-button"
|
||||
type="button"
|
||||
data-copy="install-cli"
|
||||
aria-label="複製 CLI 安裝命令"
|
||||
>
|
||||
複製
|
||||
</button>
|
||||
</div>
|
||||
<pre><code id="install-cli">npm install --global rea-agents</code></pre>
|
||||
</div>
|
||||
<p>
|
||||
<a href="../../guides/javascript/"
|
||||
>繼續閱讀 JavaScript 應用程式指南</a
|
||||
>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="article-section" id="native">
|
||||
<h2>設定原生分析</h2>
|
||||
<p>
|
||||
原生二進位檔案需要本機的分析工具。請在下方選擇並設定一個工具,然後把你的執行檔或函式庫交給
|
||||
REA。
|
||||
</p>
|
||||
<details class="evidence-details">
|
||||
<summary>選擇工具:Ghidra、Hopper 或 IDA</summary>
|
||||
<div class="provider-list">
|
||||
<div class="provider">
|
||||
<h3>Ghidra</h3>
|
||||
<div>
|
||||
<p>
|
||||
在 x64 或 arm64 的 Linux 或 macOS 上,使用現有的 Ghidra
|
||||
12.1.x 安裝和 64 位元 JDK 21 或更新版本。你的 Ghidra
|
||||
安裝需要適用於主機架構的原生反編譯器。執行設定前,請先設定路徑:
|
||||
</p>
|
||||
<details class="evidence-details">
|
||||
<summary>連接你的 Ghidra 安裝</summary>
|
||||
<div class="code-block">
|
||||
<div class="code-header">
|
||||
<span>Ghidra 連線 · macOS / Linux</span
|
||||
><button
|
||||
class="copy-button"
|
||||
type="button"
|
||||
data-copy="ghidra-connect"
|
||||
aria-label="複製 macOS 和 Linux 的 Ghidra 連線命令"
|
||||
>
|
||||
複製
|
||||
</button>
|
||||
</div>
|
||||
<pre><code id="ghidra-connect">export GHIDRA_INSTALL_DIR=/absolute/path/to/ghidra_12.1.4_PUBLIC
|
||||
export JAVA_HOME=/absolute/path/to/jdk-21
|
||||
npx -y rea-agents@latest doctor --provider ghidra --json
|
||||
npx rea-agents@latest setup</code></pre>
|
||||
</div>
|
||||
<p>
|
||||
把兩個路徑換成你現有的安裝位置。設定流程會檢查它們,並為你選擇的用戶端記錄有效的設定。在
|
||||
Linux arm64 上,你可能需要另外建置 Ghidra
|
||||
的原生反編譯器。
|
||||
</p>
|
||||
</details>
|
||||
<details class="evidence-details">
|
||||
<summary>在 Windows 上連接 Ghidra</summary>
|
||||
<p>
|
||||
Windows x64 提供實驗性的唯讀支援,可分析本機固定 NTFS
|
||||
磁碟上的原生 x86 和 x64 PE 應用程式及 DLL。請使用現有的
|
||||
Ghidra 12.1.x 安裝和 64 位元 JDK 21 或更新版本:
|
||||
</p>
|
||||
<div class="code-block">
|
||||
<div class="code-header">
|
||||
<span>Ghidra 連線 · Windows PowerShell</span>
|
||||
<button
|
||||
class="copy-button"
|
||||
type="button"
|
||||
data-copy="ghidra-windows-connect"
|
||||
aria-label="複製 Windows PowerShell 的 Ghidra 連線命令"
|
||||
>
|
||||
複製
|
||||
</button>
|
||||
</div>
|
||||
<pre><code id="ghidra-windows-connect">$env:GHIDRA_INSTALL_DIR = "C:/tools/ghidra_12.1.4_PUBLIC"
|
||||
$env:JAVA_HOME = "C:/tools/jdk-21"
|
||||
npx -y rea-agents@latest doctor --provider ghidra --json
|
||||
npx rea-agents@latest setup</code></pre>
|
||||
</div>
|
||||
<p>
|
||||
把兩個路徑換成你的安裝位置,然後檢查設定流程的計畫。
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
<div class="provider">
|
||||
<h3>Hopper</h3>
|
||||
<div>
|
||||
<p>
|
||||
在 macOS 或 Linux 上連接
|
||||
Hopper。執行上面的設定命令;它會偵測現有的安裝,或提出安裝計畫讓你核准。
|
||||
</p>
|
||||
<p>
|
||||
在 macOS 上進行無人看管的分析前,請先完成 Hopper
|
||||
首次啟動時的選擇:使用示範模式,或啟用授權。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="provider">
|
||||
<h3>IDA</h3>
|
||||
<div>
|
||||
<p>
|
||||
已驗證的 IDA 設定使用 Windows,搭配現有的 MCP
|
||||
註冊,連接已開啟的 GUI 或無介面資料庫。Linux 和 macOS
|
||||
上的無介面分析尚未驗證。
|
||||
</p>
|
||||
<details class="evidence-details">
|
||||
<summary>連接現有的 IDA MCP 註冊</summary>
|
||||
<p>
|
||||
把上游伺服器可正常運作的 <code>command</code>、<code
|
||||
>args</code
|
||||
>
|
||||
和選用的 <code>env</code> 保存在 JSON 檔案中。也接受含有
|
||||
<code>ida-pro-mcp</code> 項目的現有
|
||||
<code>mcpServers</code> 物件。執行設定前,請讓 REA
|
||||
指向該檔案:
|
||||
</p>
|
||||
<div class="code-block">
|
||||
<div class="code-header">
|
||||
<span>IDA 連線 · Windows PowerShell</span
|
||||
><button
|
||||
class="copy-button"
|
||||
type="button"
|
||||
data-copy="ida-connect"
|
||||
aria-label="複製 Windows PowerShell 的 IDA 連線命令"
|
||||
>
|
||||
複製
|
||||
</button>
|
||||
</div>
|
||||
<pre><code id="ida-connect">$env:REA_IDA_MCP_CONFIG = "C:/analysis/ida-mcp.json"
|
||||
$env:REA_ANALYSIS_PROVIDER = "ida"
|
||||
npx -y rea-agents@latest doctor --provider ida --json
|
||||
npx rea-agents@latest setup</code></pre>
|
||||
</div>
|
||||
<p>
|
||||
附加模式使用上游的舊版 1.4.0
|
||||
工具,且原始二進位檔案須已在 IDA
|
||||
中開啟。無介面模式使用上游的資料庫監管 API,以及含有
|
||||
<code>"mode": "headless"</code> 的註冊;實際驗證涵蓋 IDA
|
||||
9.3 Windows x64 監管程式。Doctor 會檢查註冊,但不會啟動
|
||||
IDA。
|
||||
</p>
|
||||
<p>
|
||||
請把原始執行檔的路徑交給 REA,並在 CLI 查詢中加上
|
||||
<code>--provider ida</code>。若透過 AI 代理使用,請在
|
||||
REA 註冊的環境變數中加入
|
||||
<code>REA_IDA_MCP_CONFIG</code
|
||||
>,並在開啟二進位檔案時選擇 IDA。
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<p class="small-heading">
|
||||
若已設定好 Ghidra,可用下列命令檢查函式:
|
||||
</p>
|
||||
<div class="code-block">
|
||||
<div class="code-header">
|
||||
<span>函式分析</span
|
||||
><button
|
||||
class="copy-button"
|
||||
type="button"
|
||||
data-copy="native-function"
|
||||
aria-label="複製原生函式分析命令"
|
||||
>
|
||||
複製
|
||||
</button>
|
||||
</div>
|
||||
<pre><code id="native-function">npx -y rea-agents@latest function /absolute/path/to/program main \
|
||||
--provider ghidra --json</code></pre>
|
||||
</div>
|
||||
<p>把 <code>main</code> 換成你要檢查的函式名稱或位址。</p>
|
||||
<details class="evidence-details">
|
||||
<summary>原生分析工作階段如何運作</summary>
|
||||
<p>
|
||||
每個 CLI
|
||||
命令都會匯入並分析二進位檔案,然後關閉它的工作階段。在工作階段開啟期間,你的
|
||||
AI 代理透過 MCP 發出的查詢會重複使用已匯入的二進位檔案。
|
||||
</p>
|
||||
</details>
|
||||
<p>
|
||||
<a href="../../guides/native/">跟著原生分析範例操作</a
|
||||
>,看看函式結果如何引導出還原的計算。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="article-section" id="help">
|
||||
<h2>取得協助</h2>
|
||||
<h3>如果你的 AI 代理看不到 REA</h3>
|
||||
<p>
|
||||
設定完成後,請重新啟動或重新連接用戶端。用
|
||||
<code>doctor</code> 檢查它與 REA 的連線;以 Codex 為例:
|
||||
</p>
|
||||
<div class="code-block">
|
||||
<div class="code-header">
|
||||
<span>檢查單一用戶端</span
|
||||
><button
|
||||
class="copy-button"
|
||||
type="button"
|
||||
data-copy="client-doctor"
|
||||
aria-label="複製用戶端診斷命令"
|
||||
>
|
||||
複製
|
||||
</button>
|
||||
</div>
|
||||
<pre><code id="client-doctor">npx -y rea-agents@latest doctor --client codex --json</code></pre>
|
||||
</div>
|
||||
<h3 class="small-heading">如果提供者出現問題</h3>
|
||||
<p>檢查你正在使用的提供者。以 Ghidra 為例:</p>
|
||||
<div class="code-block">
|
||||
<div class="code-header">
|
||||
<span>檢查單一提供者</span
|
||||
><button
|
||||
class="copy-button"
|
||||
type="button"
|
||||
data-copy="provider-doctor"
|
||||
aria-label="複製提供者診斷命令"
|
||||
>
|
||||
複製
|
||||
</button>
|
||||
</div>
|
||||
<pre><code id="provider-doctor">npx -y rea-agents@latest doctor --provider ghidra --json</code></pre>
|
||||
</div>
|
||||
<p>報告會列出失敗的檢查項目和建議的下一步。</p>
|
||||
<ul class="reading-list">
|
||||
<li>
|
||||
<a href="../../faq/">REA 常見問題</a
|
||||
><span>關於更新、AI 代理和分析的簡短解答。</span>
|
||||
</li>
|
||||
<li>
|
||||
<a href="../../guides/">選擇調查指南</a
|
||||
><span>二進位檔案、Electron 應用程式和瀏覽器的實作範例。</span>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/morluto/rea/issues">GitHub issues</a
|
||||
><span>回報問題時,請附上命令和診斷結果。</span>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://discord.gg/GkcryMnJDM">REA 社群</a
|
||||
><span>問題、調查和進行中的工作。</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
<!-- website:footer:start -->
|
||||
<!-- prettier-ignore -->
|
||||
<footer class="site-footer">
|
||||
<div class="container footer-inner">
|
||||
<p>REA · Reverse Engineer Anything</p>
|
||||
<div class="footer-links">
|
||||
<a href="https://github.com/morluto/rea">原始碼</a
|
||||
><a href="https://github.com/morluto/rea/blob/main/LICENSE">MIT 授權</a
|
||||
><a href="https://discord.gg/GkcryMnJDM">加入社群</a
|
||||
><a href="https://x.com/reatools">在 X 上追蹤我們</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<p
|
||||
class="sr-only"
|
||||
id="copy-status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-copied="已複製"
|
||||
data-selected="已選取"
|
||||
data-status-copied="程式碼已複製到剪貼簿。"
|
||||
data-status-selected="已選取程式碼,請用鍵盤複製。"
|
||||
></p>
|
||||
<a class="back-to-top" href="#top" aria-label="回到頂端">
|
||||
<span aria-hidden="true">↑</span> 頂端
|
||||
</a>
|
||||
<!-- website:footer:end -->
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,14 +3,23 @@
|
||||
<div class="container footer-inner">
|
||||
<p>REA · Reverse Engineer Anything</p>
|
||||
<div class="footer-links">
|
||||
<a href="https://github.com/morluto/rea">Source</a
|
||||
><a href="https://github.com/morluto/rea/blob/main/LICENSE">MIT license</a
|
||||
><a href="https://discord.gg/GkcryMnJDM">Join the community</a
|
||||
><a href="https://x.com/reatools">Follow us on X</a>
|
||||
<a href="https://github.com/morluto/rea">$t_source</a
|
||||
><a href="https://github.com/morluto/rea/blob/main/LICENSE">$t_license</a
|
||||
><a href="https://discord.gg/GkcryMnJDM">$t_community</a
|
||||
><a href="https://x.com/reatools">$t_followUs</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<p class="sr-only" id="copy-status" role="status" aria-live="polite"></p>
|
||||
<a class="back-to-top" href="#top" aria-label="Back to top">
|
||||
<span aria-hidden="true">↑</span> Top
|
||||
<p
|
||||
class="sr-only"
|
||||
id="copy-status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-copied="$t_copied"
|
||||
data-selected="$t_selected"
|
||||
data-status-copied="$t_statusCopied"
|
||||
data-status-selected="$t_statusSelected"
|
||||
></p>
|
||||
<a class="back-to-top" href="#top" aria-label="$t_backToTop">
|
||||
<span aria-hidden="true">↑</span> $t_top
|
||||
</a>
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
<!-- prettier-ignore -->
|
||||
<a class="skip-link" href="#main">$t_skipLink</a>
|
||||
<!-- prettier-ignore -->
|
||||
<header class="site-header">
|
||||
<div class="container header-inner">
|
||||
<a class="brand" href="$base" aria-label="REA home"
|
||||
<a class="brand" href="$brand" aria-label="$t_home"
|
||||
><span class="brand-name">REA</span
|
||||
><span class="brand-caption">Reverse Engineer Anything</span></a
|
||||
>
|
||||
<nav class="navigation" aria-label="Main navigation">$navigation</nav>
|
||||
<div class="header-social">
|
||||
<nav class="navigation" aria-label="$t_mainNavigation">$navigation</nav>
|
||||
<div class="header-social">$language_menu
|
||||
<a
|
||||
class="nav-github"
|
||||
href="https://github.com/morluto/rea"
|
||||
aria-label="REA on GitHub"
|
||||
title="REA on GitHub"
|
||||
aria-label="$t_github"
|
||||
title="$t_github"$github_star_labels
|
||||
>
|
||||
<svg
|
||||
width="22"
|
||||
@@ -30,8 +32,8 @@
|
||||
<a
|
||||
class="nav-discord"
|
||||
href="https://discord.gg/GkcryMnJDM"
|
||||
aria-label="Join REA on Discord"
|
||||
title="Join REA on Discord"
|
||||
aria-label="$t_discord"
|
||||
title="$t_discord"
|
||||
>
|
||||
<svg
|
||||
width="22"
|
||||
@@ -49,8 +51,8 @@
|
||||
<a
|
||||
class="nav-x"
|
||||
href="https://x.com/reatools"
|
||||
aria-label="Follow REA on X"
|
||||
title="Follow REA on X"
|
||||
aria-label="$t_followRea"
|
||||
title="$t_followRea"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
<script src="${base}assets/main.js" defer></script>
|
||||
</head>
|
||||
<body id="top">
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<!-- website:header:start -->
|
||||
<!-- website:header:end -->
|
||||
<main id="main" class="container">
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<!-- prettier-ignore -->
|
||||
<link rel="canonical" href="$canonical" />
|
||||
<link rel="canonical" href="$canonical" />$hreflang
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:type" content="website" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:site_name" content="REA" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:locale" content="$og_locale" />$og_locale_alternates
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:title" content="$title" />
|
||||
<!-- prettier-ignore -->
|
||||
<meta property="og:description" content="$description" />
|
||||
@@ -21,7 +23,7 @@
|
||||
<!-- prettier-ignore -->
|
||||
<meta
|
||||
property="og:image:alt"
|
||||
content="REA — Find out how software works. Reverse engineering with your coding agent."
|
||||
content="$t_imageAlt"
|
||||
/>
|
||||
<!-- prettier-ignore -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
@@ -34,5 +36,5 @@
|
||||
<!-- prettier-ignore -->
|
||||
<meta
|
||||
name="twitter:image:alt"
|
||||
content="REA — Find out how software works. Reverse engineering with your coding agent."
|
||||
content="$t_imageAlt"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
# Translating the website
|
||||
|
||||
The home page, Get started, the Guides index and the FAQ are translated into six
|
||||
languages. Guide pages, the blog, showcases and examples remain in English;
|
||||
translated pages link to their English versions.
|
||||
|
||||
| Directory | `lang` and `hreflang` | `og:locale` | Menu name |
|
||||
| ----------- | --------------------- | ----------- | --------- |
|
||||
| (site root) | `en` | `en_US` | English |
|
||||
| `zh-hans/` | `zh-Hans` | `zh_CN` | 简体中文 |
|
||||
| `zh-hant/` | `zh-Hant` | `zh_TW` | 繁體中文 |
|
||||
| `ja/` | `ja` | `ja_JP` | 日本語 |
|
||||
| `ko/` | `ko` | `ko_KR` | 한국어 |
|
||||
| `de/` | `de` | `de_DE` | Deutsch |
|
||||
| `fr/` | `fr` | `fr_FR` | Français |
|
||||
|
||||
`LANGUAGES` in `scripts/website.py` owns these values. Each language's shared
|
||||
UI text (navigation, header and footer labels, copy messages, the stale-page
|
||||
notice and the sharing-image description) lives in `locales/<language>.json`.
|
||||
The site never redirects by browser language.
|
||||
|
||||
## How a translated page works
|
||||
|
||||
A translation is an ordinary HTML file at the English page's path under its
|
||||
language directory, such as `public/zh-hant/get-started/index.html`. Its head
|
||||
records where it came from:
|
||||
|
||||
```html
|
||||
<!-- website:translation source-sha256="…" status="draft" -->
|
||||
```
|
||||
|
||||
- `source-sha256` identifies the English text the translation was made from.
|
||||
It hashes the English page without its shared regions and copied code
|
||||
blocks, so template and command changes do not count.
|
||||
- `status="draft"` pages carry `noindex`. They are left out of the sitemap,
|
||||
hreflang, the language menu and other pages' navigation; open them by URL.
|
||||
- `status="reviewed" reviewed="YYYY-MM-DD"` pages are indexable and linked.
|
||||
Only a native-speaking reviewer marks a page reviewed.
|
||||
|
||||
`python3 scripts/website.py sync` maintains everything shared:
|
||||
|
||||
- The header, footer and sharing regions use the page's locale. The language
|
||||
menu offers the same page in each reviewed language, or that language's
|
||||
reviewed home page, and leaves out languages with neither.
|
||||
- Navigation points to reviewed pages in the same language. Links to English
|
||||
pages, such as Showcases and Blog, carry a small `EN` badge.
|
||||
- Reviewed versions of a page list each other with `hreflang` alternates and
|
||||
`x-default` pointing to English.
|
||||
- Every code block that a Copy button targets is copied from the English page
|
||||
by ID. Translations never hold their own copy of a command; edit the English
|
||||
page and run `sync`.
|
||||
- When the English text changes, the `website:translation-notice` region at
|
||||
the top of the content says the page may be out of date and links to the
|
||||
English version. Updating the hash removes it.
|
||||
|
||||
## Create and update a translation
|
||||
|
||||
1. Create the draft from the English page:
|
||||
|
||||
```sh
|
||||
python3 scripts/website.py translate zh-hant get-started
|
||||
python3 scripts/website.py translate ja / # the home page
|
||||
```
|
||||
|
||||
The command sets `lang`, adds the record and `noindex`, rewrites relative
|
||||
links for the new directory, links pages that already exist in that
|
||||
language and runs `sync`.
|
||||
|
||||
2. Translate the text in place, following the rules below. Keep the page's
|
||||
structure: element IDs, Copy buttons and `data-msg-*` attributes stay as in
|
||||
English.
|
||||
3. Run `python3 scripts/website.py check` and `npm run format:check`.
|
||||
4. Run `python3 scripts/website.py translations` to see each page's status,
|
||||
whether its English source changed, the OS names still to confirm and any
|
||||
UI strings still in English.
|
||||
|
||||
When the English page changes, `check` warns, and after `sync` the notice
|
||||
appears. Update the translation's text first. Then set `source-sha256` to the
|
||||
value `translations` prints. Never refresh the hash without updating the
|
||||
content: the hash is the record that the translation matches that English text.
|
||||
|
||||
### What the checks enforce
|
||||
|
||||
Failures:
|
||||
|
||||
- A missing or invalid record, a draft without `noindex`, or a reviewed page
|
||||
with `noindex` or without its review date.
|
||||
- A copied code block that differs from English (run `sync`).
|
||||
- While the record matches the English page: different element IDs, Copy
|
||||
buttons or `data-msg-*` attributes.
|
||||
- In Japanese and Chinese, whitespace between two CJK characters. A line break
|
||||
inside a sentence renders as a visible gap, so keep CJK text unwrapped and
|
||||
let the formatter break lines at existing spaces.
|
||||
|
||||
Warnings:
|
||||
|
||||
- The English source changed since the translation, or the notice needs `sync`.
|
||||
- Inline code in prose differs from English.
|
||||
- A link points to an English page that has a reviewed translation.
|
||||
- A locale still has English placeholders. List intentionally identical
|
||||
strings in that locale's `sameAsEnglish`.
|
||||
|
||||
## Translation rules
|
||||
|
||||
- Keep in English: REA, MCP, CLI, Node.js, npm, Ghidra, Hopper, IDA, JDK,
|
||||
product and client names (Claude Code, Codex, OpenCode, Cursor, Gemini CLI,
|
||||
Windsurf, Grok Build, VS Code), and every command, path, code sample, flag,
|
||||
environment variable and file name. Keep the brand line "Reverse Engineer
|
||||
Anything" in English.
|
||||
- Translate the prompts readers paste into their coding agent. Keep any
|
||||
command or path inside a prompt unchanged, and check that the translated
|
||||
prompt still asks for the same actions.
|
||||
- Use the OS menu and button names that the OS shows in that language. Mark
|
||||
each one for review with `data-os-ui="windows"`, `"macos"` or `"linux"`, for
|
||||
example `<em data-os-ui="windows">複製路徑</em>`. Windows 10 and 11 sometimes
|
||||
differ; name both when they do.
|
||||
- When an OS shows an error message in the reader's language, give the
|
||||
translated message and the English original, since many readers use an
|
||||
English system.
|
||||
- Follow the glossary. It comes from the README translations in the
|
||||
repository root, with the zh-Hant changes noted below.
|
||||
- Leave a space between CJK text and Latin words or numbers, as the READMEs
|
||||
do: `使用 REA 的 MCP 服務`. In French, put a non-breaking space (U+00A0)
|
||||
before `:` and inside `« »`, and a narrow non-breaking space (U+202F) before
|
||||
`?`, `!` and `;`.
|
||||
- Keep `<title>` and the meta description natural for search in that
|
||||
language. They must not repeat another page's title.
|
||||
|
||||
### Register
|
||||
|
||||
| Language | Prose | Agent prompts |
|
||||
| -------- | ---------------- | -------------------------- |
|
||||
| zh-Hans | 你 | 你; direct requests |
|
||||
| zh-Hant | 你 | 你; direct requests |
|
||||
| ja | です・ます | 〜してください |
|
||||
| ko | 해요체 (`-세요`) | 반말 (`-해 줘`), as README |
|
||||
| de | du | du |
|
||||
| fr | vous | tu, as in the README |
|
||||
|
||||
### Glossary
|
||||
|
||||
Terms marked † do not appear in the README translations and are proposals for
|
||||
review. zh-Hant uses Taiwan-standard terms; where `README_zh-TW.md` differs,
|
||||
this table wins and the README will be aligned separately.
|
||||
|
||||
| English | zh-Hans | zh-Hant | ja | ko | fr | de |
|
||||
| -------------------------- | ---------------------- | --------------------------- | ---------------------------------------- | ------------------------- | ---------------------------- | -------------------------------- |
|
||||
| reverse engineering | 逆向工程 | 逆向工程 | リバースエンジニアリング | 리버스 엔지니어링 | rétro-ingénierie | Reverse Engineering |
|
||||
| (coding) agent | 智能体 (编程智能体†) | AI 代理 (程式設計 AI 代理†) | エージェント (コーディングエージェント†) | 에이전트 (코딩 에이전트†) | agent (agent de code†) | Agent (Coding-Agent†) |
|
||||
| setup / set up | 设置 / 设置流程 | 設定 / 設定流程 | セットアップ | 설정 / 설정 과정 | configurer; « Setup » | Einrichtung / einrichten |
|
||||
| approve | 批准 | 核准 | 承認 | 승인 | approuver | bestätigen |
|
||||
| restart | 重启 | 重新啟動 | 再起動 | 다시 시작 | redémarrer | neu starten |
|
||||
| MCP server | MCP 服务 | MCP 服務 | MCP サーバー | MCP 서버 | serveur MCP | MCP-Server |
|
||||
| client | 客户端 | 用戶端 | クライアント | 클라이언트 | client | Client† |
|
||||
| skill | skill | skill | スキル | 스킬 | le skill | der Skill |
|
||||
| provider | 提供方 | 提供者 | プロバイダー | 제공자 | fournisseur | Provider |
|
||||
| native analysis | 原生分析 | 原生分析 | ネイティブ分析 | 네이티브 분석 | analyse native | native Analyse |
|
||||
| analysis engine | 分析引擎 | 分析引擎 | 分析エンジン | 분석 엔진 | moteur d'analyse | Analyse-Engine |
|
||||
| binary | 二进制文件 | 二進位檔案 | バイナリ | 바이너리 | binaire | Binärdatei |
|
||||
| executable† | 可执行文件 | 執行檔 | 実行ファイル | 실행 파일 | exécutable | ausführbare Datei |
|
||||
| library† | 库 | 函式庫 | ライブラリ | 라이브러리 | bibliothèque | Bibliothek |
|
||||
| function | 函数 | 函式 | 関数 | 함수 | fonction† | Funktion† |
|
||||
| pseudocode | 伪代码 | 偽程式碼 | 疑似コード | 의사 코드 | pseudo-code | Pseudocode |
|
||||
| assembly | 汇编 | 組合語言 | アセンブリ | 어셈블리 | assembleur | Assemblercode |
|
||||
| decompile | 反编译 | 反編譯 | 逆コンパイル | 디컴파일 | décompiler† | dekompilieren† |
|
||||
| code | 代码 | 程式碼 | コード | 코드 | code | Code |
|
||||
| implement / implementation | 实现 | 實作 | 実装 | 구현 | implémenter / implémentation | implementieren / Implementierung |
|
||||
| via / through | 通过 | 透過 | 経由で | 통해 | via | über |
|
||||
| evidence | 证据 | 證據 | 根拠 | 근거 | preuves | Belege |
|
||||
| Evidence (product record) | Evidence | Evidence | Evidence | Evidence | Evidence | Evidence |
|
||||
| findings | 发现 | 發現 | 調査結果 | 조사 결과 | constatations | Ergebnisse |
|
||||
| target | 目标 | 目標 | 対象 | 대상 | cible | Ziel |
|
||||
| investigation | 调查 | 調查 | 調査 | 조사 | investigation | Untersuchung |
|
||||
| terminal | 终端 | 終端機 | ターミナル | 터미널 | terminal | Terminal |
|
||||
| module / import | 模块 / 导入 | 模組 / 匯入 | モジュール / インポート | 모듈 / 임포트 | modules / imports | Module / Imports |
|
||||
| session | 会话 | 工作階段 | セッション | 세션 | session† | Sitzung† |
|
||||
| configuration | 配置 | 設定 | 設定 | 설정 | configuration | Konfiguration |
|
||||
| backup | 备份 | 備份 | バックアップ | 백업 | sauvegarde | Sicherung / sichern |
|
||||
| update | 更新 | 更新 | 更新 | 업데이트 / 갱신 | mettre à jour | aktualisieren |
|
||||
| headless | 无界面 | 無介面 | ヘッドレス | 헤드리스 | headless† | headless† |
|
||||
| locally | 本机 | 本機 | ローカル | 로컬 | localement | lokal |
|
||||
| bug / issue | 错误 / issue | 錯誤 / issue | バグ / issue | 버그 / issue | bug / issue | Fehler / Issue |
|
||||
| community | 社区 | 社群 | コミュニティ† | 커뮤니티 | communauté† | Community† |
|
||||
| Showcases | 案例 | 案例 | 事例 | 사례 | Études de cas | Fallstudien |
|
||||
| Guides | 指南 | 指南 | ガイド | 가이드 | Guides | Anleitungen |
|
||||
| FAQ | 常见问题 | 常見問題 | よくある質問 | 자주 묻는 질문 | Questions fréquentes | Häufige Fragen |
|
||||
| Get started | 快速开始 | 快速開始 | クイックスタート | 빠른 시작 | Démarrage rapide | Schnellstart |
|
||||
| Blog† | 博客 | 部落格 | ブログ | 블로그 | Blog | Blog |
|
||||
| Copy / Copied / Selected† | 复制 / 已复制 / 已选中 | 複製 / 已複製 / 已選取 | コピー / コピーしました / 選択しました | 복사 / 복사됨 / 선택됨 | Copier / Copié / Sélectionné | Kopieren / Kopiert / Markiert |
|
||||
| Skip to content† | 跳到正文 | 跳至主要內容 | 本文へスキップ | 본문으로 건너뛰기 | Aller au contenu | Zum Inhalt springen |
|
||||
| Back to top† | 返回顶部 | 回到頂端 | ページの先頭へ | 맨 위로 | Retour en haut | Nach oben |
|
||||
|
||||
### OS names to confirm
|
||||
|
||||
These are starting points, not confirmed names. Mark each use with
|
||||
`data-os-ui` and have the reviewer confirm it on that OS in that language.
|
||||
|
||||
| English | zh-Hans | zh-Hant | ja | ko | fr | de |
|
||||
| -------------------------------------------- | ---------------------------- | ---------------------------- | ---------------------- | ---------------------------- | -------------------------------------- | ------------------------- |
|
||||
| File Explorer (Windows) | 文件资源管理器 | 檔案總管 | エクスプローラー | 파일 탐색기 | Explorateur de fichiers | Explorer |
|
||||
| Copy as path (Windows 10 / 11) | 复制为路径 / 复制文件地址 | 複製為路徑 / 複製路徑 | パスのコピー | 경로로 복사 | Copier en tant que chemin d'accès | Als Pfad kopieren |
|
||||
| Start menu (Windows) | 开始菜单 | 開始功能表 | スタートメニュー | 시작 메뉴 | menu Démarrer | Startmenü |
|
||||
| "running scripts is disabled on this system" | translated message + English | translated message + English | translated + English | translated + English | translated + English | translated + English |
|
||||
| Finder (macOS) | 访达 | Finder | Finder | Finder | Finder | Finder |
|
||||
| Copy "…" as Pathname (macOS) | 将"…"拷贝为路径名称 | 將「…」拷貝為路徑名稱 | "…"のパス名をコピー | "…"을(를) 경로 이름으로 복사 | Copier « … » en tant que nom de chemin | „…" als Pfadname kopieren |
|
||||
| Show Package Contents (macOS) | 显示包内容 | 顯示套件內容 | パッケージの内容を表示 | 패키지 내용 보기 | Afficher le contenu du paquet | Paketinhalt zeigen |
|
||||
| Terminal (macOS app) | 终端 | 終端機 | ターミナル | 터미널 | Terminal | Terminal |
|
||||
|
||||
## Review checklist
|
||||
|
||||
A reviewer reads the translation beside the English page and checks:
|
||||
|
||||
- The meaning matches, with nothing added or dropped, and the glossary and
|
||||
register are followed.
|
||||
- Commands, paths, flags and product names are unchanged. Copied code blocks
|
||||
come from English automatically.
|
||||
- Each translated agent prompt, pasted into a real coding agent, leads to the
|
||||
same actions as the English prompt (for the setup prompt: it runs
|
||||
`npx rea-agents@latest setup` and shows the plan before changing anything).
|
||||
- Every `data-os-ui` name matches the OS in that language. `translations`
|
||||
lists them.
|
||||
- The title and description read naturally in search results.
|
||||
- The page works at 320, 375, 768, 1024 and 1440 pixels wide: headings, the
|
||||
header and language menu, Copy buttons and their announced status, opened
|
||||
details, and the back-to-top link.
|
||||
|
||||
To mark the page reviewed, change its record to
|
||||
`status="reviewed" reviewed="YYYY-MM-DD"`, remove its `noindex` line and run
|
||||
`sync`, which adds it to the language menu, hreflang and other pages'
|
||||
navigation. Commit those regenerated pages with it.
|
||||
Reference in New Issue
Block a user