Files
Cibello-app/apps/mobile/scripts/i18n-check.mjs
T
Claude 18388bb31c feat(i18n): översätt kvarvarande vyer + härda vakten (audit-backlog)
- API: översatt title/displayName/toName läggs nu bredvid Sv-fälten i favoriter,
  receptvarianter, /scaled, skapar-profiler, topplistor, memory-impact,
  substitutions — samt de denormaliserade titlarna (min dag, historik, matlådor,
  matlåde-förslag, veckoplan) via nullable recipeId med svensk fallback.
  10 endpoints, batchade resolvers (ett anrop per endpoint).
- Mobil: konsumerar de nya fälten med ?? Sv-fallback (swap-meal, sparade recept,
  varianter, min dag, logg, matlådor, veckoplan, inköpslista). Hårdkodade
  strängar -> t() (kitchen, scan-review, register, recept protein/Skapat av);
  decimalkomma -> Intl.NumberFormat. Allergen-etiketter -> t() (+5 nya nycklar).
  11 nya nycklar i alla 12 språk.
- i18n-vakt härdad: fångar nu hårdkodad svenska i prop={`...`}-mallliteraler
  (blind fläck förr). Verifierat att den fäller men inte ger falska positiv.
- whySv var redan lokaliserad (buildWhy med språktagg) - orörd.
- typecheck grönt (alla paket), vakt grön (603 nycklar).

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

206 lines
8.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.
#!/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;
// prop={`…`} mall-literal i JSX-UTTRYCK. POS ser inte {…}, så no-diakritisk
// svenska (»Skapat av ${x}«) slank förr igenom. Granska den literala resten.
const PROP_EXPR =
/(?:label|placeholder|title|text|header|message)\s*=\s*\{\s*`((?:[^`\\]|\\.)*)`\s*\}/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];
// Mallsträngar granskas nu på den LITERALA resten (blind fläck förr
// `label={\`Skapat av ${namn}\`}` slank igenom hela). Strippa ${…} och
// se om ord blir kvar; bara emoji/tal/enheter kvar = ok.
const bare = val.replace(/\$\{[^}]*\}/g, " ");
if (!/[A-Za-zÅÄÖåäö]{2,}/.test(bare)) continue;
if (!allowed(bare)) hits.add(val);
}
}
PROP_EXPR.lastIndex = 0;
let pm;
while ((pm = PROP_EXPR.exec(line))) {
const bare = pm[1].replace(/\$\{[^}]*\}/g, " ");
if (/[A-Za-zÅÄÖåäö]{2,}/.test(bare) && !allowed(bare)) hits.add(pm[1]);
}
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.",
);