Files
SnapOtter/scripts/i18n/adapters/api-spec.mjs
T
SnapOtterandGitHub 00b651c9f8 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.
2026-07-11 13:01:55 +08:00

148 lines
4.7 KiB
JavaScript

// scripts/i18n/adapters/api-spec.mjs
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import yaml from "js-yaml";
const __dirname = dirname(fileURLToPath(import.meta.url));
// The real spec lives next to the API source. Tests pass a `dir` override.
const DEFAULT_DIR = join(__dirname, "../../../apps/api/src");
const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "head", "options"];
// structuredClone is global in Node 22+, used to deep-copy the parsed spec.
const clone = (value) => structuredClone(value);
/**
* Read and parse the English OpenAPI document.
* @param {string} dir
* @returns {any}
*/
function loadEnglishSpec(dir) {
const raw = readFileSync(join(dir, "openapi.yaml"), "utf8");
return yaml.load(raw);
}
/**
* Walk a parsed spec and yield [id, text] for every translatable prose field,
* in a deterministic order. This is the single source of the id contract used by
* extract, load, and write.
* @param {any} spec
* @returns {Array<[string, string]>}
*/
export function proseFields(spec) {
const out = [];
if (typeof spec?.info?.description === "string") {
out.push(["info.description", spec.info.description]);
}
for (const tag of spec?.tags ?? []) {
if (tag && typeof tag.name === "string" && typeof tag.description === "string") {
out.push([`tags.${tag.name}.description`, tag.description]);
}
}
for (const [path, methods] of Object.entries(spec?.paths ?? {})) {
for (const method of HTTP_METHODS) {
const op = methods?.[method];
if (!op) continue;
if (typeof op.summary === "string") {
out.push([`paths.${path}.${method}.summary`, op.summary]);
}
if (typeof op.description === "string") {
out.push([`paths.${path}.${method}.description`, op.description]);
}
}
}
return out;
}
/**
* Set a prose field on a cloned spec by the same id `proseFields` produced.
* @param {any} spec
* @param {string} id
* @param {string} value
*/
export function setProseField(spec, id, value) {
if (id === "info.description") {
spec.info.description = value;
return;
}
const tagMatch = id.match(/^tags\.(.+)\.description$/);
if (tagMatch) {
const tag = (spec.tags ?? []).find((t) => t?.name === tagMatch[1]);
if (tag) tag.description = value;
return;
}
// paths.<path>.<method>.<field> where <path> may itself contain dots.
const pathMatch = id.match(/^paths\.(.+)\.([a-z]+)\.(summary|description)$/);
if (pathMatch) {
const [, path, method, field] = pathMatch;
const op = spec.paths?.[path]?.[method];
if (op) op[field] = value;
}
}
/**
* Build the api-spec adapter.
* @param {{ dir?: string }} [opts]
*/
export function makeApiSpecAdapter({ dir = DEFAULT_DIR } = {}) {
return {
name: "api",
async extract() {
const spec = loadEnglishSpec(dir);
return proseFields(spec).map(([id, sourceText]) => ({ id, sourceText, kind: "text" }));
},
async write(locale, entries) {
const english = loadEnglishSpec(dir);
const localized = clone(english);
const stamp = {};
// Replace only the prose fields that have a translation; anything missing
// keeps its English text so the document is always complete and valid.
for (const [id] of proseFields(english)) {
const entry = entries.get(id);
if (!entry) continue;
setProseField(localized, id, entry.text);
stamp[id] = {
sourceHash: entry.sourceHash,
provenance: entry.provenance,
outputHash: entry.outputHash,
...(entry.stale ? { stale: true } : {}),
};
}
localized["x-i18n"] = {
locale,
generator: "scripts/i18n/adapters/api-spec.mjs",
entries: stamp,
};
const out = yaml.dump(localized, { lineWidth: -1, noRefs: true });
writeFileSync(join(dir, `openapi.${locale}.yaml`), out, "utf8");
},
async load(locale) {
const file = join(dir, `openapi.${locale}.yaml`);
const result = new Map();
if (!existsSync(file)) return result;
const spec = yaml.load(readFileSync(file, "utf8"));
const stamp = spec?.["x-i18n"]?.entries ?? {};
for (const [id, text] of proseFields(spec)) {
const meta = stamp[id];
if (!meta) continue; // untranslated fallback field, not a stored entry
result.set(id, {
text,
sourceHash: meta.sourceHash,
provenance: meta.provenance === "human" ? "human" : "machine",
outputHash: meta.outputHash,
...(meta.stale ? { stale: true } : {}),
});
}
return result;
},
};
}
export const adapter = makeApiSpecAdapter();