feat(i18n): full app + docs localization (de/en)
Build & Push Docker Image / build (push) Successful in 2m33s

- translate remaining pages/components (admin, analytics, redundancy,
  trash, compare, browse, watchlist, admin tools tab, category combobox,
  theme toggle, tool preview card, tool-new/edit) to t() calls
- locales: 453 keys each, parity verified
- docs: bilingual handbook + release notes via *.en.md variants,
  language-aware docs.tsx (markdown paths, nav titles, search index),
  LanguageSwitcher in docs header
- generate-docs: emit per-locale handbook/release/search output,
  localized index.json fields (fileEn/titleEn), en-aware snapshots
This commit is contained in:
opencode
2026-08-04 08:35:05 +02:00
parent a65e2eb92c
commit 3de59ccaaf
77 changed files with 3786 additions and 440 deletions
+73 -15
View File
@@ -26,7 +26,12 @@ const releasesDir = resolve(root, "docs/releases");
const targetDir = resolve(root, "artifacts/toolrate/public/docs");
const isReleaseFile = (name) => /^v\d+\.\d+\.\d+\.md$/.test(name);
const isHandbookFile = (name) => /\.md$/.test(name);
const isEnReleaseFile = (name) => /^v\d+\.\d+\.\d+\.en\.md$/.test(name);
const isHandbookFile = (name) => /\.md$/.test(name) && !name.endsWith(".en.md");
const isEnHandbookFile = (name) => /\.en\.md$/.test(name);
const enName = (file) => (file.endsWith(".md") ? `${file.slice(0, -3)}.en.md` : null);
const deName = (file) => (file.endsWith(".en.md") ? `${file.slice(0, -6)}.md` : null);
// ---------------------------------------------------------------------------
// Version helpers
@@ -196,6 +201,15 @@ function slugify(name) {
return name.replace(/\.md$/, "").toLowerCase().replace(/[^a-z0-9-]+/g, "-");
}
async function readFileOptional(path) {
try {
return await readFile(path, "utf8");
} catch (err) {
if (err.code === "ENOENT") return null;
throw err;
}
}
async function readHandbookPages() {
let files;
try {
@@ -208,12 +222,18 @@ async function readHandbookPages() {
for (const file of files) {
const content = await readFile(join(handbookDir, file), "utf8");
const { frontmatter, body } = parseHandbook(content);
const enFile = enName(file);
const enContent = enFile ? await readFileOptional(join(handbookDir, enFile)) : null;
const en = enContent ? parseHandbook(enContent) : null;
pages.push({
slug: slugify(basename(file)),
file,
title: frontmatter.title ?? extractTitle(body) ?? basename(file),
order: typeof frontmatter.order === "number" ? frontmatter.order : 999,
body,
fileEn: en ? enFile : null,
titleEn: en ? en.frontmatter.title ?? extractTitle(en.body) ?? null : null,
bodyEn: en ? en.body : null,
});
}
pages.sort((a, b) => a.order - b.order || a.title.localeCompare(b.title));
@@ -225,11 +245,12 @@ async function writeHandbookTo(dir) {
await mkdir(dir, { recursive: true });
for (const page of pages) {
await writeFile(join(dir, page.file), page.body);
if (page.bodyEn) await writeFile(join(dir, page.fileEn), page.bodyEn);
}
await writeFile(
join(dir, "index.json"),
JSON.stringify(
pages.map(({ slug, file, title, order }) => ({ slug, file, title, order })),
pages.map(({ slug, file, title, order, fileEn, titleEn }) => ({ slug, file, title, order, fileEn, titleEn })),
null,
2,
),
@@ -256,6 +277,7 @@ function stripMarkdown(md) {
async function buildSearchIndex(reference, handbookPages, releaseVersions) {
const entries = [];
const entriesEn = [];
for (const page of handbookPages) {
const file = join(handbookDir, page.file);
@@ -267,44 +289,58 @@ async function buildSearchIndex(reference, handbookPages, releaseVersions) {
kind: "guide",
text: stripMarkdown(body),
});
if (page.bodyEn) {
entriesEn.push({
title: page.titleEn ?? page.title,
href: `/docs/handbook/${page.slug}`,
kind: "guide",
text: stripMarkdown(page.bodyEn),
});
}
}
for (const tag of reference.tags) {
for (const ep of tag.endpoints) {
entries.push({
const entry = {
title: `${ep.method} ${ep.path}`,
href: `/docs/reference/endpoints/${tag.name}#${ep.operationId}`,
kind: "endpoint",
text: `${ep.summary} ${ep.description} ${ep.parameters
.map((p) => `${p.name} ${p.description}`)
.join(" ")}`.trim(),
});
};
entries.push(entry);
entriesEn.push(entry);
}
}
for (const schema of reference.schemas) {
for (const field of schema.fields) {
entries.push({
const entry = {
title: `${schema.name}.${field.name}`,
href: `/docs/reference/schemas/${schema.name}#${field.name}`,
kind: "field",
text: `${field.description} ${field.constraints} ${field.type.value ?? ""}`.trim(),
});
};
entries.push(entry);
entriesEn.push(entry);
}
}
for (const version of releaseVersions) {
const file = join(releasesDir, `${version}.md`);
const content = await readFile(file, "utf8");
entries.push({
const base = {
title: version,
href: `/docs/releases/${version}`,
kind: "release",
text: stripMarkdown(content),
});
};
entries.push({ ...base, text: stripMarkdown(content) });
const enContent = await readFileOptional(join(releasesDir, `${version}.en.md`));
entriesEn.push({ ...base, text: stripMarkdown(enContent ?? content) });
}
return entries;
return { entries, entriesEn };
}
// ---------------------------------------------------------------------------
@@ -348,7 +384,6 @@ async function main() {
throw err;
}
releaseNames.sort(cmp).reverse();
await rm(targetDir, { recursive: true, force: true });
await mkdir(join(targetDir, "handbook"), { recursive: true });
await mkdir(join(targetDir, "releases"), { recursive: true });
@@ -363,7 +398,18 @@ async function main() {
const handbookPages = await buildHandbookIndex();
await writeFile(
join(targetDir, "handbook/index.json"),
JSON.stringify(handbookPages, null, 2),
JSON.stringify(
handbookPages.map(({ slug, file, title, order, fileEn, titleEn }) => ({
slug,
file,
title,
order,
fileEn,
titleEn,
})),
null,
2,
),
);
// 3. Release notes + versioned docs snapshots (last 7 versions)
@@ -372,6 +418,9 @@ async function main() {
const version = parseVersion(name);
const content = await readFile(join(releasesDir, name), "utf8");
await copyFile(join(releasesDir, name), join(targetDir, "releases", name));
const enName = `${version}.en.md`;
const enContent = await readFileOptional(join(releasesDir, enName));
if (enContent) await copyFile(join(releasesDir, enName), join(targetDir, "releases", enName));
const snapDir = join(releasesDir, version);
const hasReference = await stat(join(snapDir, "reference.json"))
.then(() => true)
@@ -399,12 +448,20 @@ async function main() {
join(snapDir, "handbook", page.file),
join(targetDir, "versions", version, "handbook", page.file),
);
if (page.fileEn) {
await copyFile(
join(snapDir, "handbook", page.fileEn),
join(targetDir, "versions", version, "handbook", page.fileEn),
);
}
}
}
versions.push({
version,
file: `releases/${name}`,
fileEn: enContent ? `releases/${enName}` : null,
title: extractTitle(content) ?? version,
titleEn: enContent ? extractTitle(enContent) ?? version : null,
date: extractDate(content) ?? null,
hasReference,
hasHandbook,
@@ -414,12 +471,13 @@ async function main() {
await writeFile(join(targetDir, "index.json"), JSON.stringify(versions, null, 2));
// 4. Search index
const searchIndex = await buildSearchIndex(reference, handbookPages, versions.map((v) => v.version));
await writeFile(join(targetDir, "search.json"), JSON.stringify(searchIndex, null, 2));
const { entries, entriesEn } = await buildSearchIndex(reference, handbookPages, versions.map((v) => v.version));
await writeFile(join(targetDir, "search.json"), JSON.stringify(entries, null, 2));
await writeFile(join(targetDir, "search.en.json"), JSON.stringify(entriesEn, null, 2));
console.log(
`[generate-docs] synced ${versions.length} release(s), ${handbookPages.length} handbook page(s), ` +
`${reference.schemas.length} schema(s), ${searchIndex.length} search entries`,
`${reference.schemas.length} schema(s), ${entries.length} search entries`,
);
}