Compare commits

...
Author SHA1 Message Date
Kaoru0822andClaude Opus 5.5 c865e20a0b feat(website): translate Get started into Traditional Chinese (draft)
Pilot translation created with `website.py translate zh-hant get-started`
and translated in place. It stays a noindex draft until review, so it is
not linked from other pages, the sitemap or hreflang.

- locales/zh-hant.json: Taiwan-standard UI strings for the header, footer,
  copy messages, star label and out-of-date notice.
- Prose, headings, captions, Copy labels, the agent prompts, title and
  description are translated; commands and paths come from English.
- Ten OS names are marked data-os-ui for confirmation (PowerShell's
  messages, the Start menu, File Explorer and Copy as path on Windows;
  Terminal, the Pathname command and Show Package Contents on macOS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:31:29 +00:00
Kaoru0822andClaude Opus 5.5 0fd4021842 feat(website): keep translation tests independent of page text
The translation tests now drop existing translated pages from their site
copy, read expected UI strings from the locale file, and insert their own
paragraphs and code changes instead of editing particular English
sentences, so ordinary page edits and new translations do not break them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:31:28 +00:00
Kaoru0822andClaude Opus 5.5 4db6ff461f feat(website): fit the language menu on narrow screens
The menu's list opened leftward past the screen edge on phones, and at
320px the fourth header button overlapped the brand. Open the list to the
right below 640px, and hide the GitHub star count below 360px when a page
has a language menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:31:28 +00:00
Kaoru0822andClaude Opus 5.5 ce58aebc0e feat(website): add language infrastructure for translated pages
Prepare translated versions of the home, Get started, Guides and FAQ pages
in zh-Hans, zh-Hant, ja, ko, de and fr. English stays at the root and each
language lives under its own directory. No translated page exists yet, so
English pages only gain an og:locale tag.

- LANGUAGES in website.py owns each language's directory, lang/hreflang tag,
  menu name and og:locale; locales/<language>.json holds the shared UI text
  (non-English locales start as English placeholders).
- sync renders the header, footer and sharing regions in the page's
  language: localized navigation with an EN badge on English-only targets,
  a native-details language menu (same page, else the language's reviewed
  home, else omitted), hreflang alternates with x-default, og:locale
  alternates and the brand link to the language's home.
- Translations record source-sha256 and status (draft or reviewed). Drafts
  are noindex and stay out of the sitemap, menus, hreflang and navigation.
- sync copies every Copy-targeted code block from English by ID, and fills
  the website:translation-notice region when the English text has changed.
  The hash leaves out shared regions and those code blocks.
- The verifier checks records, copied code, ID/Copy/data-msg parity while
  fresh, whitespace between CJK characters in ja/zh, and the page's lang.
  An out-of-date translation, inline-code differences, links to English
  pages with a reviewed translation and English placeholder UI strings
  are warnings, shown as GitHub annotations and in the job summary.
- New commands: `translate <language> <route>` creates a draft and
  `translations` reports status, freshness and OS names to confirm.
- styles.css adds per-language CJK font stacks, CJK heading, emphasis and
  line-breaking rules, German hyphenation, and the menu, badge and notice.
- The publisher runs sync after the checks so notices are current.
- website/translation.md documents the workflow, rules, register, glossary
  (with Korean), OS names to confirm and the review checklist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:23:29 +00:00
Kaoru0822andClaude Opus 5.5 80f6365523 refactor(website): run the website lane for the page-script test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:09:16 +00:00
Kaoru0822andClaude Opus 5.5 dabcd783d5 refactor(website): keep page text when a script message is missing
A page that omits a message attribute (a translation that drops one, or a
page without the shared footer) must not show empty or "undefined" text:

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:09:16 +00:00
Kaoru0822andClaude Opus 5.5 3d34185171 refactor(website): move UI text out of scripts before translation
Prepare the website for localized pages without changing what English
readers see:

- Add --font-sans and --font-mono tokens and use them for every font stack.
- Read copy feedback from the shared #copy-status element, restore each
  Copy button's own label, and build the GitHub star label from header
  data attributes with Intl.PluralRules and Intl.NumberFormat for the
  page language.
- Move the percent demo, dinosaur game and speed-check messages into
  data-msg-* attributes on the home page and dino lab.
- Render the skip link from the shared header template.
- Fix the lowercase Ghidra macOS/Linux copy-button label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:09:16 +00:00
43 changed files with 3342 additions and 162 deletions
+4
View File
@@ -30,5 +30,9 @@ jobs:
sudo apt-get update
sudo apt-get install --no-install-recommends -y libcairo2 fonts-dejavu-core
python3 -m pip install -r website/requirements.txt
- name: Set up Node.js for website script tests
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Check shared HTML, links, assets and contributor workflows
run: python3 scripts/website.py check
+7
View File
@@ -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'
+1
View File
@@ -222,6 +222,7 @@ export const SCOPES = {
website: [
"website/**",
"scripts/*website*.py",
"scripts/test-website-scripts.mjs",
".github/workflows/website-check.yml",
],
};
+481
View File
@@ -0,0 +1,481 @@
// Run the website's browser scripts against a small document model, so missing
// page-owned messages are checked without a browser.
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { test } from "node:test";
import { setImmediate as nextTurn } from "node:timers/promises";
import { pathToFileURL } from "node:url";
const ASSETS = new URL("../website/public/assets/", import.meta.url);
class Element {
constructor(tag, attributes = {}, children = [], text = "") {
this.tagName = tag.toUpperCase();
this.attributes = new Map(Object.entries(attributes));
this.children = [];
this.parent = null;
this.listeners = new Map();
this.ownText = text;
this.hidden = "hidden" in attributes;
this.disabled = "disabled" in attributes;
this.checked = "checked" in attributes;
this.value = attributes.value ?? "";
for (const child of children) this.append(child);
}
append(child) {
child.parent = this;
this.children.push(child);
}
get textContent() {
return (
this.ownText + this.children.map((child) => child.textContent).join("")
);
}
set textContent(value) {
this.ownText = String(value);
this.children = [];
}
get id() {
return this.getAttribute("id") ?? "";
}
get lang() {
return this.getAttribute("lang") ?? "";
}
get dataset() {
const values = {};
for (const [name, value] of this.attributes) {
if (!name.startsWith("data-")) continue;
const key = name
.slice(5)
.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
values[key] = value;
}
return values;
}
getAttribute(name) {
return this.attributes.has(name) ? this.attributes.get(name) : null;
}
setAttribute(name, value) {
this.attributes.set(name, String(value));
}
removeAttribute(name) {
this.attributes.delete(name);
}
addEventListener(type, listener) {
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
}
dispatch(type, event = {}) {
for (const listener of this.listeners.get(type) ?? []) {
listener({ preventDefault() {}, repeat: false, ...event });
}
}
click() {
this.dispatch("click");
}
focus() {}
descendants() {
return this.children.flatMap((child) => [child, ...child.descendants()]);
}
matches(selector) {
return selector
.split(",")
.some((part) => matchesComplex(this, part.trim()));
}
closest(selector) {
for (let node = this; node !== null; node = node.parent) {
if (node.matches(selector)) return node;
}
return null;
}
querySelectorAll(selector) {
return this.descendants().filter((node) => node.matches(selector));
}
querySelector(selector) {
return this.querySelectorAll(selector)[0] ?? null;
}
getContext() {
return this.context ?? null;
}
}
// Supports the selector forms the website scripts use: tag, #id, .class and
// [attribute], [attribute="value"] or [attribute^="value"], joined by spaces.
function matchesCompound(node, compound) {
const pattern =
/^([a-z0-9]*)((?:#[\w-]+|\.[\w-]+|\[[\w-]+(?:\^?="[^"]*")?\])*)$/i;
const parsed = pattern.exec(compound);
if (parsed === null) throw new Error(`Unsupported selector: ${compound}`);
const [, tag, rest] = parsed;
if (tag && node.tagName !== tag.toUpperCase()) return false;
for (const [, id, className, name, operator, value] of rest.matchAll(
/#([\w-]+)|\.([\w-]+)|\[([\w-]+)(?:(\^?=)"([^"]*)")?\]/g,
)) {
if (id !== undefined) {
if (node.id !== id) return false;
continue;
}
if (className !== undefined) {
const classes = (node.getAttribute("class") ?? "").split(/\s+/);
if (!classes.includes(className)) return false;
continue;
}
const actual = node.getAttribute(name);
if (actual === null) return false;
if (operator === "=" && actual !== value) return false;
if (operator === "^=" && !actual.startsWith(value)) return false;
}
return true;
}
function matchesComplex(node, selector) {
const parts = selector.split(/\s+/);
if (!matchesCompound(node, parts.at(-1))) return false;
let ancestor = node.parent;
for (const part of parts.slice(0, -1).reverse()) {
while (ancestor !== null && !matchesCompound(ancestor, part))
ancestor = ancestor.parent;
if (ancestor === null) return false;
ancestor = ancestor.parent;
}
return true;
}
const h = (tag, attributes, ...children) => {
const text = children.filter((child) => typeof child === "string").join("");
return new Element(
tag,
attributes ?? {},
children.filter((child) => child instanceof Element),
text,
);
};
// Install a fresh document and browser globals, then run one script.
async function run(
script,
body,
{ lang = "en", clipboard = "resolve", stars = 1234 } = {},
) {
const root = h("html", { lang }, h("body", {}, ...body));
const timers = [];
const written = [];
const canvasText = [];
for (const canvas of root.querySelectorAll("canvas")) {
canvas.context = new Proxy(
{ fillText: (text) => canvasText.push(text) },
{ get: (target, name) => target[name] ?? (() => {}), set: () => true },
);
}
const document = {
documentElement: root,
hidden: false,
getElementById: (id) =>
root.descendants().find((node) => node.id === id) ?? null,
querySelector: (selector) => root.querySelector(selector),
querySelectorAll: (selector) => root.querySelectorAll(selector),
createRange: () => ({ selectNodeContents() {} }),
addEventListener() {},
};
const window = {
scrollY: 0,
innerHeight: 900,
location: { hash: "" },
addEventListener() {},
getSelection: () => ({ removeAllRanges() {}, addRange() {} }),
matchMedia: () => ({ matches: false, addEventListener() {} }),
requestAnimationFrame: () => 1,
scrollTo() {},
setTimeout: (callback) => timers.push(callback),
};
Object.assign(globalThis, {
document,
window,
requestAnimationFrame: () => 1,
cancelAnimationFrame() {},
Path2D: class {},
fetch: async () => ({
ok: true,
json: async () => ({ stargazers_count: stars }),
}),
});
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {
clipboard: {
writeText: async (text) => {
if (clipboard === "reject") throw new Error("denied");
written.push(text);
},
},
},
});
await import(
`${pathToFileURL(new URL(script, ASSETS).pathname)}?load=${randomUUID()}`
);
await nextTurn();
return { root, timers, written, canvasText };
}
const copyBlock = () => [
h("button", { "data-copy": "command" }, "Copy"),
h("pre", {}, h("code", { id: "command" }, "npx rea-agents setup")),
];
const copyStatus = (messages = {}) =>
h("p", {
id: "copy-status",
...Object.fromEntries(
Object.entries(messages).map(([key, value]) => [`data-${key}`, value]),
),
});
const englishCopyMessages = {
copied: "Copied",
selected: "Selected",
"status-copied": "Code copied to clipboard.",
"status-selected": "Code selected. Use your keyboard to copy it.",
};
test("copy buttons report and restore localized labels", async () => {
const { root, timers, written } = await run("main.js", [
...copyBlock(),
copyStatus(englishCopyMessages),
]);
const button = root.querySelector("[data-copy]");
button.click();
await nextTurn();
assert.deepEqual(written, ["npx rea-agents setup"]);
assert.equal(button.textContent, "Copied");
assert.equal(
root.querySelector("#copy-status").textContent,
"Code copied to clipboard.",
);
timers.forEach((callback) => callback());
assert.equal(button.textContent, "Copy");
});
test("copy buttons keep their label when messages are missing", async () => {
for (const [body, clipboard] of [
[[...copyBlock(), copyStatus()], "resolve"],
[[...copyBlock(), copyStatus()], "reject"],
[
[...copyBlock(), copyStatus({ copied: "", "status-copied": "" })],
"resolve",
],
[copyBlock(), "resolve"],
]) {
const { root, timers, written } = await run("main.js", body, { clipboard });
const button = root.querySelector("[data-copy]");
button.click();
await nextTurn();
assert.equal(written.length, clipboard === "resolve" ? 1 : 0);
assert.equal(button.textContent, "Copy");
assert.equal(root.querySelector("#copy-status")?.textContent ?? "", "");
timers.forEach((callback) => callback());
assert.equal(button.textContent, "Copy");
}
});
const githubLink = (labels = {}) =>
h(
"a",
{
class: "nav-github",
"aria-label": "REA on GitHub",
title: "REA on GitHub",
...labels,
},
h("span", { class: "nav-github-count", hidden: "" }),
);
test("the star label follows the page language and plural rules", async () => {
const labels = {
"data-label-one": "REA on GitHub ({count} star)",
"data-label-other": "REA on GitHub ({count} stars)",
};
for (const [stars, lang, expected] of [
[1, "en", "REA on GitHub (1 star)"],
[1234, "en", "REA on GitHub (1,234 stars)"],
[1234, "de", "REA on GitHub (1.234 stars)"],
]) {
const { root } = await run("main.js", [githubLink(labels)], {
stars,
lang,
});
await nextTurn();
assert.equal(
root.querySelector(".nav-github").getAttribute("aria-label"),
expected,
);
}
// Languages without a "one" category use the "other" label.
const { root } = await run(
"main.js",
[githubLink({ "data-label-other": "{count}" })],
{ stars: 1, lang: "ja" },
);
await nextTurn();
assert.equal(
root.querySelector(".nav-github").getAttribute("aria-label"),
"1",
);
});
test("the star label stays unchanged without a localized label", async () => {
const { root } = await run("main.js", [githubLink()], { stars: 1 });
await nextTurn();
const link = root.querySelector(".nav-github");
assert.equal(link.getAttribute("aria-label"), "REA on GitHub");
assert.equal(link.getAttribute("title"), "REA on GitHub");
assert.equal(link.querySelector(".nav-github-count").hidden, true);
});
const percentDemo = (messages) =>
h(
"figure",
{ "data-percent-demo": "", ...messages },
h(
"div",
{ class: "percent-operation", hidden: "" },
h(
"button",
{ "data-percent-operation": "add", "aria-pressed": "true" },
"200 + 10%",
),
h(
"button",
{ "data-percent-operation": "multiply", "aria-pressed": "false" },
"200 × 10%",
),
),
h("span", { "data-percent-expression": "" }, "200 +"),
h("strong", { "data-percent-display": "" }, "20"),
h(
"div",
{ class: "percent-controls", hidden: "" },
h("button", { "data-percent-key": "percent" }, "%"),
h("button", { "data-percent-key": "equals", disabled: "" }, "="),
h("button", { "data-percent-key": "reset" }, "Reset"),
),
h(
"p",
{ "data-percent-status": "" },
"10% of 200 = 20. Then 200 + 20 = 220.",
),
);
test("the percent demo uses page messages and keeps text when one is missing", async () => {
const { root } = await run("home.js", [
percentDemo({
"data-msg-input": "Press % to see what happens to 10.",
"data-msg-result": "200 {operator} {percent} = {result}.",
}),
]);
const status = root.querySelector("[data-percent-status]");
const key = (name) =>
root.querySelector(`[data-percent-key="${name}"]`).click();
assert.equal(status.textContent, "Press % to see what happens to 10.");
key("percent");
assert.equal(status.textContent, "Press % to see what happens to 10.");
key("equals");
assert.equal(status.textContent, "200 + 20 = 220.");
const bare = await run("home.js", [percentDemo({})]);
const bareStatus = bare.root.querySelector("[data-percent-status]");
for (const name of ["percent", "equals", "reset"]) {
bare.root.querySelector(`[data-percent-key="${name}"]`).click();
assert.equal(
bareStatus.textContent,
"10% of 200 = 20. Then 200 + 20 = 220.",
);
}
});
const dinoDemo = (messages) =>
h(
"figure",
{ "data-dino-demo": "", ...messages },
h("svg", { class: "dino-fallback" }),
h("canvas", { hidden: "" }),
h(
"div",
{ class: "dino-controls", hidden: "" },
h("button", { "data-dino-start": "" }, "Play"),
h("button", { "data-dino-jump": "" }, "Jump ↑"),
h("button", { "data-dino-reset": "" }, "Restart"),
h("output", { "data-dino-speed": "" }, "6.0"),
h("input", { "data-dino-slider": "", value: "6" }),
h("input", { "data-dino-auto": "", checked: "" }),
),
h(
"p",
{ "data-dino-status": "" },
"Original rule: start at 6, then increase toward 13.",
),
);
test("the dinosaur game never shows a missing message", async () => {
const { root, canvasText } = await run("dino-demo.js", [
dinoDemo({}),
h("button", { "data-speed-check": "", hidden: "" }, "Replay"),
h("p", { "data-speed-check-status": "" }, "Not replayed yet."),
]);
const start = root.querySelector("[data-dino-start]");
const status = root.querySelector("[data-dino-status]");
const slider = root.querySelector("[data-dino-slider]");
start.click();
start.click();
slider.value = "9.5";
slider.dispatch("input");
root.querySelector("[data-dino-reset]").click();
assert.equal(start.textContent, "Play");
assert.equal(
status.textContent,
"Original rule: start at 6, then increase toward 13.",
);
assert.deepEqual(canvasText, []);
root.querySelector("[data-speed-check]").click();
assert.equal(
root.querySelector("[data-speed-check-status]").textContent,
"Not replayed yet.",
);
});
test("the dinosaur game fills placeholders from page messages", async () => {
const { root, canvasText } = await run("dino-demo.js", [
dinoDemo({
"data-msg-pause": "Pause",
"data-msg-fixed-running":
"Fixed speed: {speed}. Space or Jump clears a cactus.",
"data-msg-distance": "DISTANCE {distance}",
}),
]);
const start = root.querySelector("[data-dino-start]");
const slider = root.querySelector("[data-dino-slider]");
start.click();
assert.equal(start.textContent, "Pause");
slider.value = "9.5";
slider.dispatch("input");
assert.equal(
root.querySelector("[data-dino-status]").textContent,
"Fixed speed: 9.5. Space or Jump clears a cactus.",
);
assert.ok(canvasText.includes("DISTANCE 0000"));
});
+212 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+34
View File
@@ -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": []
}
+34
View File
@@ -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": []
}
+34
View File
@@ -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": []
}
+33
View File
@@ -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": []
}
+33
View File
@@ -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": []
}
+33
View File
@@ -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": []
}
+33
View File
@@ -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": []
}
+42 -32
View File
@@ -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;
});
+15 -6
View File
@@ -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) => {
+22 -9
View File
@@ -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);
+195 -8
View File
@@ -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 -2
View File
@@ -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>
+41 -4
View File
@@ -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 -2
View File
@@ -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 -2
View File
@@ -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>
+17 -3
View File
@@ -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 -2
View File
@@ -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 -2
View File
@@ -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 -2
View File
@@ -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 -2
View File
@@ -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>
+42 -4
View File
@@ -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 -2
View File
@@ -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 -2
View File
@@ -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&#x27;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 -2
View File
@@ -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 -2
View File
@@ -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 -2
View File
@@ -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&#x27;s clipboard API through Electron&#x27;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 -2
View File
@@ -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&#x27;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\&lt;you&gt;\AppData\Local\Programs\&lt;App&gt;\resources\app.asar</code
>
</li>
<li>
<strong>macOS:</strong>
<code
>/Applications/&lt;App&gt;.app/Contents/Resources/app.asar</code
>
(以右鍵按一下應用程式,然後選擇<em data-os-ui="macos"
>顯示套件內容</em
>)
</li>
<li>
<strong>Linux:</strong>通常在
<code>/opt/&lt;App&gt;/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>
+16 -7
View File
@@ -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>
+11 -9
View File
@@ -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"
-1
View File
@@ -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">
+5 -3
View File
@@ -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"
/>
+230
View File
@@ -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.