|
| 1 | +// Diagnostic + CI gate: find t('a.b.c') calls where a.b.c resolves to an |
| 2 | +// object (not a string) in en.json. Such calls return the object — i18next |
| 3 | +// logs "returned an object instead of string" and the fallback string is |
| 4 | +// IGNORED — so the UI shows that error message instead of the intended copy. |
| 5 | +// |
| 6 | +// Fix the offending call sites by either: |
| 7 | +// 1. Calling the leaf key directly: t('a.b.c.title', 'fallback') |
| 8 | +// 2. Adding a sibling string under a.b.c (e.g. .description) and calling |
| 9 | +// t('a.b.c.description', 'fallback') |
| 10 | +// |
| 11 | +// Exits 1 on any violation so it can run in CI / npm run lint. |
| 12 | +import fs from 'node:fs'; |
| 13 | +import path from 'node:path'; |
| 14 | +import { fileURLToPath } from 'node:url'; |
| 15 | + |
| 16 | +const here = path.dirname(fileURLToPath(import.meta.url)); |
| 17 | +const root = path.resolve(here, '..'); |
| 18 | +const en = JSON.parse(fs.readFileSync(path.join(root, 'src/i18n/en.json'), 'utf8')); |
| 19 | + |
| 20 | +function get(obj, key) { |
| 21 | + return key.split('.').reduce((a, p) => (a && typeof a === 'object' ? a[p] : undefined), obj); |
| 22 | +} |
| 23 | + |
| 24 | +function walk(dir, acc) { |
| 25 | + for (const f of fs.readdirSync(dir)) { |
| 26 | + const p = path.join(dir, f); |
| 27 | + const s = fs.statSync(p); |
| 28 | + if (s.isDirectory()) { |
| 29 | + if (f === 'node_modules' || f === 'dist' || f.startsWith('.')) continue; |
| 30 | + walk(p, acc); |
| 31 | + } else if (/\.(tsx?|jsx?)$/.test(f)) { |
| 32 | + acc.push(p); |
| 33 | + } |
| 34 | + } |
| 35 | + return acc; |
| 36 | +} |
| 37 | + |
| 38 | +const files = walk(path.join(root, 'src'), []); |
| 39 | +// Match t('key.path' or t("key.path" — keep keys to dotted ASCII identifiers. |
| 40 | +// Skip test files (they may intentionally exercise fallback behavior). |
| 41 | +const re = /[^a-zA-Z_]t\(\s*['"]([a-zA-Z0-9_.]+)['"]/g; |
| 42 | +const broken = new Map(); |
| 43 | +for (const f of files) { |
| 44 | + if (/[\\/]__tests__[\\/]|\.test\.|\.spec\./.test(f)) continue; |
| 45 | + const txt = fs.readFileSync(f, 'utf8'); |
| 46 | + let m; |
| 47 | + while ((m = re.exec(txt)) !== null) { |
| 48 | + const key = m[1]; |
| 49 | + if (!key.includes('.')) continue; |
| 50 | + const v = get(en, key); |
| 51 | + if (v && typeof v === 'object' && !Array.isArray(v)) { |
| 52 | + const line = txt.slice(0, m.index).split('\n').length; |
| 53 | + if (!broken.has(key)) broken.set(key, []); |
| 54 | + broken.get(key).push(path.relative(root, f) + ':' + line); |
| 55 | + } |
| 56 | + } |
| 57 | +} |
| 58 | + |
| 59 | +if (broken.size === 0) { |
| 60 | + console.log('✅ audit:i18n-shadowed-keys — no violations'); |
| 61 | + process.exit(0); |
| 62 | +} |
| 63 | + |
| 64 | +console.error('❌ audit:i18n-shadowed-keys — found bare t() calls that resolve to an OBJECT in en.json:'); |
| 65 | +console.error(' (i18next will log "returned an object instead of string" and ignore the fallback)'); |
| 66 | +console.error(''); |
| 67 | +for (const [k, locs] of broken) { |
| 68 | + console.error(` ${k}`); |
| 69 | + for (const l of locs) console.error(` at ${l}`); |
| 70 | +} |
| 71 | +console.error(''); |
| 72 | +console.error(`total broken keys: ${broken.size}`); |
| 73 | +console.error(''); |
| 74 | +console.error('Fix: either call a leaf key (e.g. .title / .description) or add a sibling string under the parent.'); |
| 75 | +process.exit(1); |
0 commit comments