Usability wave Workstream C (#5, #7): - manifest.webmanifest with existing 192/512/180 icons, linked in index.html with theme-color aligned to #07070d; no service worker. - GET /api/seances/recent: last 12 of the seeker's own séances with entity, per-kind event counts (one GROUP BY) and up to 3 spirit echoes (one windowed query) — no per-session N+1. - /log Ghost Log page: cards with GhostGlyph, relative in-fiction timestamps, counts and echo lines; LOG link in the séance topbar; en/es i18n parity. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
87 lines
3.8 KiB
JavaScript
87 lines
3.8 KiB
JavaScript
// 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')
|