mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(i18n): 21-language pipeline, landing/docs/API wiring, landing+API translations
Shared Claude Code translation pipeline (scripts/i18n, no API key) plus Astro/VitePress/Scalar i18n wiring. Landing and API reference translated into all 20 languages; docs i18n wiring + English source anchors. The translated docs markdown (apps/docs/<locale>/**, 3,620 files) follows in a companion PR because it exceeds GitHub's per-PR CI file limit.
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
// scripts/i18n/adapters/landing-seo.mjs
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const DEFAULT_OUT_DIR = join(__dirname, "../../../apps/landing/src/data/i18n");
|
||||
|
||||
// Only the alternatives pages are localized. Tool-detail pages are English-only
|
||||
// (thin-content SEO risk), so their tool-seo prose is never rendered in a locale
|
||||
// and is intentionally not extracted here.
|
||||
const ALT_STRING_FIELDS = ["pageTitle", "h1", "metaDescription", "intro", "breadth"];
|
||||
|
||||
function pushIfString(units, id, value) {
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
units.push({ id, sourceText: value, kind: "text" });
|
||||
}
|
||||
}
|
||||
|
||||
function extractAlternatives(units, alternatives) {
|
||||
for (const alt of alternatives) {
|
||||
const base = `alt:${alt.slug}`;
|
||||
for (const field of ALT_STRING_FIELDS) pushIfString(units, `${base}:${field}`, alt[field]);
|
||||
const rows = Array.isArray(alt.rows) ? alt.rows : [];
|
||||
rows.forEach((row, i) => {
|
||||
pushIfString(units, `${base}:rows.${i}.feature`, row.feature);
|
||||
pushIfString(units, `${base}:rows.${i}.snapotter`, row.snapotter);
|
||||
pushIfString(units, `${base}:rows.${i}.competitor`, row.competitor);
|
||||
});
|
||||
const faqs = Array.isArray(alt.faqs) ? alt.faqs : [];
|
||||
faqs.forEach((faq, i) => {
|
||||
pushIfString(units, `${base}:faqs.${i}.q`, faq.q);
|
||||
pushIfString(units, `${base}:faqs.${i}.a`, faq.a);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function readJson(path) {
|
||||
try {
|
||||
return JSON.parse(await readFile(path, "utf8"));
|
||||
} catch (err) {
|
||||
if (err && err.code === "ENOENT") return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function altFileFor(dir, locale) {
|
||||
return join(dir, `alternatives.${locale}.json`);
|
||||
}
|
||||
|
||||
function toStored(record) {
|
||||
const out = new Map();
|
||||
for (const [id, e] of Object.entries(record ?? {})) {
|
||||
out.set(id, {
|
||||
text: e.text ?? "",
|
||||
sourceHash: e._sourceHash ?? "",
|
||||
provenance: e.provenance ?? "machine",
|
||||
outputHash: e.outputHash ?? "",
|
||||
stale: Boolean(e.stale),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function toRecord(entries) {
|
||||
const record = {};
|
||||
for (const id of [...entries.keys()].sort()) {
|
||||
const e = entries.get(id);
|
||||
record[id] = {
|
||||
text: e.text,
|
||||
_sourceHash: e.sourceHash,
|
||||
provenance: e.provenance,
|
||||
outputHash: e.outputHash,
|
||||
stale: Boolean(e.stale),
|
||||
};
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the landing SEO data adapter (alternatives pages only).
|
||||
* @param {{
|
||||
* dir?: string,
|
||||
* alternatives?: any[],
|
||||
* }} [opts]
|
||||
*/
|
||||
export function makeLandingSeoAdapter(opts = {}) {
|
||||
const dir = opts.dir ?? DEFAULT_OUT_DIR;
|
||||
return {
|
||||
name: "landing-seo",
|
||||
|
||||
async extract() {
|
||||
const alternatives =
|
||||
opts.alternatives ??
|
||||
(await import("../../../apps/landing/src/data/alternatives.ts")).ALTERNATIVES;
|
||||
const units = [];
|
||||
extractAlternatives(units, alternatives);
|
||||
return units;
|
||||
},
|
||||
|
||||
async load(locale) {
|
||||
const alt = await readJson(altFileFor(dir, locale));
|
||||
return toStored(alt);
|
||||
},
|
||||
|
||||
async write(locale, entries) {
|
||||
const altRecord = toRecord(entries);
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(altFileFor(dir, locale), `${JSON.stringify(altRecord, null, 2)}\n`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const adapter = makeLandingSeoAdapter();
|
||||
Reference in New Issue
Block a user