Files
Cibello-app/apps/mobile/scripts/i18n-check.mjs
T
Claude 2d59a3e636 i18n(app-svep) + i18n-vakt: hela mobil-UI:t översatt + CI-vakt mot regress
- Svepte 51 hårdkodade strängar -> t() över konto, sök/bläddra, sparade recept,
  kök, planera, skanna, byt måltid, inköp, minne, cooking, api-fel (+ locLabel/
  kategorier via nycklar). 47 nya nycklar, översatta till alla 12 språk.
- NY: apps/mobile/scripts/i18n-check.mjs (i18n:check) – fäller bygget om ett språk
  saknar en nyckel, en använd nyckel saknas, eller det finns hårdkodad UI-text.
  Wire:ad i CI (.github/workflows/ci.yml) efter typecheck. => 'lägg till språk'
  blir: kör vakten, den listar exakt vad som fattas. Aldrig mer leta för hand.
- Prettier-fixade 5 filer från tidigare leveranser så format:check blir grön.
- submit-recipe fri-text-payload + firebase dev-stubs markerade // i18n-ignore.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-21 01:50:08 +00:00

192 lines
7.8 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.
#!/usr/bin/env node
/**
* i18n-vakt: fäller bygget om översättningarna inte är kompletta eller om det
* finns hårdkodad text i UI:t. Kör: `pnpm --filter @app/mobile i18n:check`.
*
* Poängen (långsiktigt): när ett nytt språk läggs till behöver ingen leta
* skärm för skärm vakten listar exakt vilka nycklar som saknas, och hindrar
* att ny hårdkodad svenska smyger in. Wire:a in den i CI så kan bygget aldrig
* gå live med halvfärdig lokalisering.
*
* FEL (exit 1):
* 1. Ett språk saknar en nyckel som svenska (källan) har → faller till svenska.
* 2. En använd t("nyckel") saknas i svenska.
* 3. Hårdkodad användartext (svenska diakriter, eller råsträng i label/
* placeholder/title/text/Alert/setError som inte går via t()).
* VARNING (exit 0): språk med extra nycklar som svenska saknar (t.ex. pl-plural).
*
* Undanta enskild rad med kommentaren // i18n-ignore
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
const ROOT = process.cwd(); // apps/mobile
const LOCALES_DIR = join(ROOT, "src/locales");
const SRC_DIR = join(ROOT, "src");
const SOURCE_LANG = "sv";
// Filer/mönster som INTE är UI-text: locale-data och enhetsvokabulär per språk.
const SKIP_FILES = [/src\/locales\//, /src\/lib\/i18n\.ts$/, /src\/lib\/units\.ts$/];
// Råsträngar som är legitima (varumärke, format, tekniska värden).
const ALLOW = [
/^[\s\p{Emoji}\p{P}\p{S}·•⏳⭐★☆✍️🍽️🥣🥗🥐🍎🍰🧺🧠🛒👥⚙️❄️🗄️🥕→↔↩︎▸▾⚠︎%–—-]+$/u, // bara symboler/emoji
/^https?:\/\//,
/Cibello/,
];
// Ord som är identiska/universella på alla målspråk ingen översättning behövs.
const ALLOW_EXACT = new Set(["OK", "kcal", "g", "kg", "ml", "dl", "l", "st"]);
/** Ta bort kommentarer men BEHÅLL radnummer (ersätt med blanksteg, ej borttag). */
function stripComments(src) {
const keepNL = (m) => m.replace(/[^\n]/g, " ");
return src
.replace(/\{\/\*[\s\S]*?\*\/\}/g, keepNL) // {/* JSX-kommentar */}
.replace(/\/\*[\s\S]*?\*\//g, keepNL) // /* blockkommentar */
.split("\n")
.map((line) => line.replace(/([^:"'`\\])\/\/.*$/, "$1").replace(/^\s*\/\/.*$/, "")) // // radslut (ej URL)
.join("\n");
}
function walk(dir) {
const out = [];
for (const name of readdirSync(dir)) {
const p = join(dir, name);
const st = statSync(p);
if (st.isDirectory()) out.push(...walk(p));
else if (/\.(tsx|ts)$/.test(p)) out.push(p);
}
return out;
}
function loadLocales() {
const langs = readdirSync(LOCALES_DIR).filter((d) =>
statSync(join(LOCALES_DIR, d)).isDirectory(),
);
const map = {};
for (const lang of langs) {
map[lang] = JSON.parse(readFileSync(join(LOCALES_DIR, lang, "common.json"), "utf8"));
}
return map;
}
const errors = [];
const warnings = [];
// ---- 1. Nyckel-paritet ----------------------------------------------------
const locales = loadLocales();
const svKeys = new Set(Object.keys(locales[SOURCE_LANG]));
for (const [lang, data] of Object.entries(locales)) {
if (lang === SOURCE_LANG) continue;
const keys = new Set(Object.keys(data));
const missing = [...svKeys].filter((k) => !keys.has(k));
const extra = [...keys].filter((k) => !svKeys.has(k));
if (missing.length)
errors.push(
`[${lang}] saknar ${missing.length} nyckel/nycklar som ${SOURCE_LANG} har (faller till svenska):\n ` +
missing.slice(0, 30).join("\n ") +
(missing.length > 30 ? `\n …(+${missing.length - 30})` : ""),
);
if (extra.length)
warnings.push(
`[${lang}] har ${extra.length} extra nyckel/nycklar (oanvända?): ${extra.slice(0, 8).join(", ")}${extra.length > 8 ? "…" : ""}`,
);
}
// ---- 2 & 3. Använda nycklar + hårdkodad text ------------------------------
const files = walk(SRC_DIR).filter((f) => !SKIP_FILES.some((re) => re.test(f)));
const usedStatic = new Set();
let dynamicUses = 0;
const DIACRITIC = /[åäöÅÄÖ]/;
// t("nyckel") / t('nyckel') / t(`nyckel`) statisk nyckel utan ${}
const T_STATIC = /\bt\(\s*["'`]([^"'`$}{]+)["'`]/g;
// t(`...${...}`) dynamisk nyckel, hoppa men räkna
const T_DYNAMIC = /\bt\(\s*`[^`]*\$\{/g;
// användarnära positioner med RÅSTRÄNG (inte {t(...)})
const POS =
/(?:label|placeholder|title|text|header|message)\s*[=:]\s*(["'`])((?:(?!\1).)*[A-Za-zÅÄÖåäö]{2,}(?:(?!\1).)*)\1/g;
const ALERT =
/(?:Alert\.alert|setError|EmptyState\s+text=)\s*\(?\s*(["'`])((?:(?!\1).)*[A-Za-zÅÄÖåäö]{2,}(?:(?!\1).)*)\1/g;
// JSX-text: >Svensk text< (fångar diakriter i textnoder)
const JSXTEXT = />\s*([^<>{}\n]*[åäöÅÄÖ][^<>{}\n]*?)\s*</g;
const allowed = (s) => ALLOW_EXACT.has(s.trim()) || ALLOW.some((re) => re.test(s.trim()));
for (const file of files) {
const rel = relative(ROOT, file);
const raw = readFileSync(file, "utf8");
const rawLines = raw.split("\n");
const src = stripComments(raw);
let m;
while ((m = T_STATIC.exec(src))) usedStatic.add(m[1]);
dynamicUses += (src.match(T_DYNAMIC) || []).length;
const lines = src.split("\n");
lines.forEach((line, i) => {
const trimmed = line.trim();
if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) return;
// i18n-ignore läses på RÅraden (kommentaren strippas ju bort i `line`).
if (/i18n-ignore/.test(rawLines[i] ?? "")) return;
const hits = new Set();
for (const re of [POS, ALERT]) {
re.lastIndex = 0;
let mm;
while ((mm = re.exec(line))) {
const val = mm[2];
if (val.includes("${")) continue; // ren interpolationsdel
if (!allowed(val)) hits.add(val);
}
}
JSXTEXT.lastIndex = 0;
let jm;
while ((jm = JSXTEXT.exec(line))) {
const val = jm[1];
if (val.includes("{") || val.includes("`")) continue;
if (!allowed(val)) hits.add(val);
}
// diakriter i råsträngar OCH mallsträngar (även med ${…} svensk text runt
// interpolationen behöver ändå gå via t() med parametrar).
if (DIACRITIC.test(line)) {
const strs = line.match(/"[^"\n]*[åäöÅÄÖ][^"\n]*"|`[^`\n]*[åäöÅÄÖ][^`\n]*`/g) || [];
for (const s of strs) {
const val = s.slice(1, -1);
const bare = val.replace(/\$\{[^}]*\}/g, ""); // ta bort interpolation, kolla resten
if (DIACRITIC.test(bare) && !allowed(val)) hits.add(val);
}
}
// fristående sträng-/mallliteral på egen rad (multi-rad call-argument, t.ex.
// `Bild ${count} tagen`) en fras (innehåller mellanslag) är nästan alltid UI-text.
const st = trimmed.match(/^(["'`])((?:(?!\1).)*)\1,?$/);
if (st) {
const val = st[2];
const bare = val.replace(/\$\{[^}]*\}/g, "");
if (/\s/.test(bare) && /[A-Za-zÅÄÖåäö]{2,}/.test(bare) && !allowed(val)) hits.add(val);
}
for (const h of hits) errors.push(`[hårdkodad] ${rel}:${i + 1} «${h.slice(0, 60)}»`);
});
}
// använda nycklar måste finnas i svenska (plural: tillåt _one/_other-varianter)
const missingUsed = [...usedStatic].filter(
(k) => !svKeys.has(k) && !svKeys.has(`${k}_other`) && !svKeys.has(`${k}_one`),
);
if (missingUsed.length)
errors.push(
`Använda t()-nycklar som saknas i ${SOURCE_LANG}:\n ` + missingUsed.join("\n "),
);
// ---- Rapport --------------------------------------------------------------
const langCount = Object.keys(locales).length;
console.log(
`i18n-vakt: ${langCount} språk, ${svKeys.size} nycklar, ${usedStatic.size} statiska t()-anrop, ${dynamicUses} dynamiska.`,
);
for (const w of warnings) console.log("VARNING " + w);
if (errors.length) {
console.error(`\n${errors.length} fel:\n`);
for (const e of errors) console.error(" " + e);
console.error("\nÅtgärda ovan (eller markera en rad med // i18n-ignore om den är avsiktlig).");
process.exit(1);
}
console.log(
"✓ i18n komplett: alla språk har alla nycklar, inga saknade nycklar, ingen hårdkodad UI-text.",
);