feat(i18n): add mobile localization release gates
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
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 hardCodedSamples = scanHardCodedUserText();
|
||||
const failures = {
|
||||
missingInEn,
|
||||
missingInTr,
|
||||
interpolationMismatches,
|
||||
authLanguageSelectors,
|
||||
};
|
||||
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,
|
||||
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 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();
|
||||
}
|
||||
Reference in New Issue
Block a user