Files
neta/scripts/i18n/release-gate.mjs
T

171 lines
5.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const localeRoots = {
tr: path.join(process.cwd(), "locales", "tr"),
en: path.join(process.cwd(), "locales", "en"),
};
const authRoutes = [
path.join(process.cwd(), "app", "login"),
path.join(process.cwd(), "app", "register"),
path.join(process.cwd(), "app", "forgot-password"),
path.join(process.cwd(), "app", "reset-password"),
];
export function runReleaseGate() {
const tr = collectLocaleKeys(localeRoots.tr);
const en = collectLocaleKeys(localeRoots.en);
const missingInEn = [...tr.keys()].filter((key) => !en.has(key)).sort();
const missingInTr = [...en.keys()].filter((key) => !tr.has(key)).sort();
const interpolationMismatches = [];
for (const key of [...new Set([...tr.keys(), ...en.keys()])].sort()) {
const trVars = interpolationVariables(tr.get(key) ?? "");
const enVars = interpolationVariables(en.get(key) ?? "");
if (trVars.join(",") !== enVars.join(",")) {
interpolationMismatches.push({ key, tr: trVars, en: enVars });
}
}
const authLanguageSelectors = scanAuthLanguageSelectors();
const missingSettingsProviders = scanSettingsNamespaceProviders();
const hardCodedSamples = scanHardCodedUserText();
const failures = {
missingInEn,
missingInTr,
interpolationMismatches,
authLanguageSelectors,
missingSettingsProviders,
};
const ok = Object.values(failures).every((rows) => rows.length === 0);
const summary = {
ok,
catalog: {
trKeys: tr.size,
enKeys: en.size,
missingInEn: missingInEn.slice(0, 25),
missingInTr: missingInTr.slice(0, 25),
interpolationMismatches: interpolationMismatches.slice(0, 25),
},
authLanguageSelectors,
missingSettingsProviders,
hardCodedSamples,
notes: [
"hardCodedSamples bilgilendirme amaçlıdır; false-positive üretmemesi için gate'i fail ettirmez.",
"Browser smoke, RTL ve payload ölçümleri için docs/self-hosted-redesign/release/i18n-self-host-upgrade.md dosyasındaki kabul adımlarını takip et.",
],
};
console.log(JSON.stringify(summary, null, 2));
if (!ok) process.exitCode = 1;
return summary;
}
function scanSettingsNamespaceProviders() {
const requiredFiles = [
{ file: path.join(process.cwd(), "app", "(dashboard)", "layout.tsx"), label: "admin dashboard shell" },
{ file: path.join(process.cwd(), "app", "portal", "layout.tsx"), label: "portal shell" },
];
return requiredFiles.flatMap(({ file, label }) => {
if (!fs.existsSync(file)) return [{ file: path.relative(process.cwd(), file), label, reason: "missing file" }];
const source = fs.readFileSync(file, "utf8");
if (!source.includes("getClientI18nPayload")) {
return [{ file: path.relative(process.cwd(), file), label, reason: "missing getClientI18nPayload" }];
}
if (!source.includes('"settings"') && !source.includes("'settings'")) {
return [{ file: path.relative(process.cwd(), file), label, reason: "settings namespace is not included" }];
}
return [];
});
}
function collectLocaleKeys(root) {
const files = listFiles(root).filter((file) => file.endsWith(".ts"));
const entries = new Map();
const keyPattern = /"([^"]+)"\s*:\s*"((?:\\"|[^"])*)"/g;
for (const file of files) {
const source = fs.readFileSync(file, "utf8");
let match;
while ((match = keyPattern.exec(source))) {
entries.set(match[1], match[2]);
}
}
return entries;
}
function interpolationVariables(value) {
return [...new Set([...value.matchAll(/\{([a-zA-Z][\w.-]*)(?:[,}])/g)].map((match) => match[1]))].sort();
}
function scanAuthLanguageSelectors() {
const patterns = [
/LocaleSelector/,
/LanguageSelector/,
/neta_locale/,
/setLanguagePreference/,
/updateLanguagePreference/,
];
return authRoutes
.flatMap((route) => (fs.existsSync(route) ? listFiles(route) : []))
.filter((file) => /\.(tsx?|jsx?)$/.test(file))
.flatMap((file) => {
const source = fs.readFileSync(file, "utf8");
return patterns
.filter((pattern) => pattern.test(source))
.map((pattern) => ({ file: path.relative(process.cwd(), file), pattern: String(pattern) }));
});
}
function scanHardCodedUserText() {
const roots = ["app", "components"].map((root) => path.join(process.cwd(), root));
const pattern = /[A-Za-zÇĞİÖŞÜçğıöşü]{2,}\s+[A-Za-zÇĞİÖŞÜçğıöşü]{2,}/;
const allowed = [
"className",
"import ",
"from ",
"aria-hidden",
"export ",
"type ",
"interface ",
"console.",
];
const samples = [];
for (const file of roots.flatMap((root) => (fs.existsSync(root) ? listFiles(root) : []))) {
if (!/\.(tsx?|jsx?)$/.test(file)) continue;
const lines = fs.readFileSync(file, "utf8").split("\n");
for (const [index, line] of lines.entries()) {
if (samples.length >= 50) return samples;
if (allowed.some((token) => line.includes(token))) continue;
if (pattern.test(line) && /[">']/.test(line)) {
samples.push({
file: path.relative(process.cwd(), file),
line: index + 1,
sample: line.trim().slice(0, 180),
});
}
}
}
return samples;
}
function listFiles(root) {
const result = [];
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (entry.name === "node_modules" || entry.name === ".next" || entry.name === ".git") continue;
const fullPath = path.join(root, entry.name);
if (entry.isDirectory()) {
result.push(...listFiles(fullPath));
} else {
result.push(fullPath);
}
}
return result;
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
runReleaseGate();
}