// Throwaway audit: every t() key used in src must exist in en.json and es.json. import { readFileSync, readdirSync, statSync } from 'node:fs' import { join } from 'node:path' const SRC = new URL('../', import.meta.url).pathname const en = JSON.parse(readFileSync(join(SRC, 'i18n/en.json'), 'utf8')) const es = JSON.parse(readFileSync(join(SRC, 'i18n/es.json'), 'utf8')) // ---- collect source files (skip tests) ---- const files = [] const walk = (dir) => { for (const name of readdirSync(dir)) { const p = join(dir, name) if (statSync(p).isDirectory()) walk(p) else if (/\.tsx?$/.test(name) && !/\.test\.tsx?$/.test(name)) files.push(p) } } walk(SRC) const literal = new Set() const templates = new Set() for (const f of files) { const text = readFileSync(f, 'utf8') for (const m of text.matchAll(/(?:^|[^\w.])t\(\s*'([^']+)'/g)) literal.add(m[1]) for (const m of text.matchAll(/(?:^|[^\w.])t\(\s*"([^"]+)"/g)) literal.add(m[1]) for (const m of text.matchAll(/(?:^|[^\w.])t\(\s*`([^`]+)`/g)) templates.add(m[1]) } // ---- template domains, by key prefix ---- const RARITY = ['common', 'uncommon', 'rare', 'mythic'] const RULES = [ ['seance.connection.', ['connecting', 'open', 'unstable', 'closed']], ['devices.dashboard.connection.', ['connecting', 'open', 'unstable', 'closed']], ['seance.status.', ['attuning', 'summoning', 'gathering']], ['seance.toast.', ['rate_limited', 'veil_crowded']], // other codes fall back to toast.message ['seance.modes.', ['wire', 'evp', 'radio', 'ouija', 'emf']], ['seance.views.', ['board', 'matrix', 'graphs']], ['seance.entity.rarity.', RARITY], ['codex.rarity.', RARITY], ['codex.sort.', ['recent', 'contacted']], ['codex.form.', ['wisp', 'banshee', 'fairy', 'shade']], ['log.counts.', ['question', 'anomaly', 'utterance']], ['landing.stats.', ['entities', 'sessions', 'utterances', 'anomalies']], ['landing.modes.', ['radio', 'evp', 'wire', 'ouija']], ['shop.modules.', ['emfArray', 'geophone', 'spiritMic', 'uvIlluminator']], ['shop.interests.', ['quantumBox', 'emfArray', 'geophone', 'spiritMic', 'uvIlluminator']], ] const expanded = new Set(literal) for (const tpl of templates) { const rule = RULES.find(([prefix]) => tpl.startsWith(prefix)) if (!rule) throw new Error('no domain for template: ' + tpl) const expand = (key) => { const m = key.match(/^(.*?)\$\{[^}]*\}(.*)$/) if (!m) return expanded.add(key) for (const v of rule[1]) expand(m[1] + v + m[2]) } expand(tpl) } // ---- lookup helper (with plural fallback) ---- const get = (obj, key) => key.split('.').reduce((o, k) => (o == null ? o : o[k]), obj) const has = (obj, key) => get(obj, key) !== undefined || get(obj, key + '_other') !== undefined || get(obj, key + '_one') !== undefined const missingEn = [...expanded].filter((k) => !has(en, k)).sort() const missingEs = [...expanded].filter((k) => !has(es, k)).sort() // ---- parity check en <-> es (all leaf paths) ---- const leaves = (obj, prefix = '') => Object.entries(obj).flatMap(([k, v]) => v && typeof v === 'object' && !Array.isArray(v) ? leaves(v, prefix + k + '.') : [prefix + k], ) const enLeaves = new Set(leaves(en)) const esLeaves = new Set(leaves(es)) const enOnly = [...enLeaves].filter((k) => !esLeaves.has(k)) const esOnly = [...esLeaves].filter((k) => !enLeaves.has(k)) console.log('literal keys:', literal.size, '| template keys:', templates.size, '| expanded total:', expanded.size) console.log('templates:', [...templates].sort()) console.log('missing from en.json:', missingEn.length ? missingEn : 'none') console.log('missing from es.json:', missingEs.length ? missingEs : 'none') console.log('en-only leaves:', enOnly.length ? enOnly : 'none') console.log('es-only leaves:', esOnly.length ? esOnly : 'none') if (missingEn.length || missingEs.length || enOnly.length || esOnly.length) process.exit(1) console.log('coverage ok')