Files
pleno-vue/scripts/i18n-v2-compile-source.mjs

186 lines
5.7 KiB
JavaScript

import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..");
const sourceDirectory = path.join(projectRoot, "src", "i18n", "source");
const generatedDirectory = path.join(projectRoot, "src", "i18n", "generated");
const activeLocales = ["da", "en", "sv", "de", "no"];
const mode = process.argv.includes("--check") ? "check" : "write";
const linkTokenPattern = /@(?<modifier>\.[\p{L}]+)?:(?:\{'(?<literal>[^']+)'\}|(?<path>[\p{L}\p{N}_.-]+))/gu;
const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, ""));
const stableJson = (value) => `${JSON.stringify(value, null, 2)}\n`;
const writeJson = (filePath, value) => {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, stableJson(value), "utf8");
};
const listJsonFiles = (directory) => {
if (!fs.existsSync(directory)) {
return [];
}
const entries = fs.readdirSync(directory, { withFileTypes: true });
return entries
.flatMap((entry) => {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
return listJsonFiles(entryPath);
}
return entry.isFile() && entry.name.endsWith(".json") ? [entryPath] : [];
})
.sort((left, right) => left.localeCompare(right, "en", { sensitivity: "base" }));
};
const mergeMessages = (left = {}, right = {}) => {
const merged = { ...left };
for (const [key, value] of Object.entries(right ?? {})) {
if (isPlainObject(value) && isPlainObject(merged[key])) {
merged[key] = mergeMessages(merged[key], value);
} else {
merged[key] = value;
}
}
return merged;
};
const readJsonFragments = (directory) => {
let merged = {};
for (const filePath of listJsonFiles(directory)) {
merged = mergeMessages(merged, readJson(filePath));
}
return merged;
};
const mapSourcePathToRuntimePath = (keyPath) => {
if (keyPath.startsWith("terms.glossary.")) {
return `words.generated.${keyPath.slice("terms.glossary.".length)}`;
}
if (keyPath.startsWith("terms.")) {
return `words.${keyPath.slice("terms.".length)}`;
}
if (keyPath.startsWith("phrases.compat.")) {
return `templates.generated.compat.${keyPath.slice("phrases.compat.".length)}`;
}
if (keyPath.startsWith("phrases.")) {
return `templates.${keyPath.slice("phrases.".length)}`;
}
return keyPath;
};
const rewriteLinks = (value) => {
if (typeof value === "string") {
return value.replace(linkTokenPattern, (token, modifier = "", literalTarget, pathTarget) => {
const target = literalTarget ?? pathTarget;
const mappedTarget = mapSourcePathToRuntimePath(target);
const quoteTarget = literalTarget || /[^\p{L}\p{N}_.-]/u.test(mappedTarget);
return quoteTarget ? `@${modifier}:{'${mappedTarget}'}` : `@${modifier}:${mappedTarget}`;
});
}
if (Array.isArray(value)) {
return value.map((entry) => rewriteLinks(entry));
}
if (!isPlainObject(value)) {
return value;
}
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, rewriteLinks(entry)]));
};
const compileWords = (sourceTerms) => {
const { glossary, ...terms } = sourceTerms;
return {
grammar: terms.grammar ?? {},
actions: terms.actions ?? {},
entities: terms.entities ?? {},
statuses: terms.statuses ?? {},
domain: terms.domain ?? {},
services: terms.services ?? {},
units: terms.units ?? {},
replication: terms.replication ?? {},
generated: glossary ?? {},
};
};
const compileTemplates = (sourcePhrases) => {
const { compat, ...phrases } = sourcePhrases;
return {
...phrases,
generated: {
compat: compat ?? {},
},
};
};
const compileLocale = (locale) => {
const localeSourceDirectory = path.join(sourceDirectory, locale);
const sourceTerms = readJsonFragments(path.join(localeSourceDirectory, "terms"));
const sourcePhrases = readJsonFragments(path.join(localeSourceDirectory, "phrases"));
return rewriteLinks({
words: compileWords(sourceTerms),
templates: compileTemplates(sourcePhrases),
});
};
const compileGlobal = () => {
const shared = readJsonFragments(path.join(sourceDirectory, "global", "shared"));
const locales = {};
for (const locale of activeLocales) {
locales[locale] = readJsonFragments(path.join(sourceDirectory, "global", "locales", locale));
}
return rewriteLinks({
shared,
locales,
});
};
const compiledFiles = new Map([
[path.join(generatedDirectory, "global-v2.json"), compileGlobal()],
...activeLocales.map((locale) => [path.join(generatedDirectory, `${locale}-v2.json`), compileLocale(locale)]),
]);
if (mode === "check") {
const mismatches = [];
for (const [filePath, value] of compiledFiles.entries()) {
const expected = stableJson(value);
const actual = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, "") : "";
if (actual !== expected) {
mismatches.push(path.relative(projectRoot, filePath));
}
}
if (mismatches.length > 0) {
console.error("Generated i18n v2 runtime files are out of date:");
for (const mismatch of mismatches) {
console.error(` ${mismatch}`);
}
console.error("Run npm run i18n:v2:compile.");
process.exit(1);
}
console.log("Generated i18n v2 runtime files are up to date.");
} else {
for (const [filePath, value] of compiledFiles.entries()) {
writeJson(filePath, value);
}
console.log(`Compiled i18n v2 runtime files to ${path.relative(projectRoot, generatedDirectory)}.`);
}