After an edit that calls translation keys, names the keys the JSON, Laravel PHP, YAML or gettext locale files lack, and the languages that lack them.

The model adds t('checkout.total') to a component, and the key exists in no locale file, or in English only. The page then shows the raw key, or English on the Turkish page, and nobody notices until a user does. This mod tells the model when an edit uses translation keys that one or more locale files lack. The note comes with the Edit's result, so the model adds the keys in the same turn. By default nothing is stopped; in deny mode a commit, a push and a merge stop while a key is missing.
.ts, .tsx, .js, .jsx, .mjs, .cjs, .vue, .svelte, .astro, .php, .py, .rb, .erb, .haml, .slim), it reads the translation calls the edit added: those in new_string that old_string does not have, or every call of a Write.t, $t, i18n.t, __, trans, trans_choice, @lang, _, gettext and ngettext with a quoted first argument, also after this., vm., i18n., $i18n., I18n. and i18n.global.. A variable argument, a template literal and a Rails lazy key (t('.title')) are skipped.node_modules and any file over 2 MB: locales, lang, i18n, translations, locale, config/locales, resources/lang, src/locales, src/i18n, public/locales. The session directory is the one the session started in, read once at its start, because a Bash cd moves the session's own directory. The edited file is named against the git repository the session started in, so a session opened in apps/web names a file of apps/api as apps/api/x.ts. Outside a git repository the file is named against the session directory. That root is read once at the session's start too.| Format | Example path | Keys |
|---|---|---|
| JSON (i18next, vue-i18n, Laravel) | locales/tr.json, locales/tr/checkout.json, lang/tr.json | nested keys as dotted paths; item_one also defines item |
| PHP array (Laravel) | lang/tr/messages.php | messages.key, nested arrays as dotted paths |
| YAML (Rails, Symfony) | config/locales/tr.yml, translations/messages.tr.yaml | dotted paths; a Rails top key (tr:) is left out |
| gettext | locale/tr/LC_MESSAGES/django.po | each msgid |
The language comes from a directory (tr/, en-US/) or from the file name (tr.json, messages.tr.yaml). A key in a namespace file also counts as ns.key and ns:key.
i18n-watch: this edit uses translation keys the locale files lack: checkout.total:42 (missing in tr, de) · checkout.vat:58 (missing in every locale). Add them to each locale file.
At most 10 keys are named, the rest counted. Only the keys this edit added are reported; a key reported before stays in the finding without being said again.
i18n-watch: keys src/Cart.vue uses that the locale files lack: checkout.total:42 (missing in tr, de) · checkout.vat:58 (missing in every locale)
The note and the line are separate channels: the model never reads the line, and you never read the note.
every locale red and a list of some locales yellow. The transcript stays clean. The entry stays until newer ones push it off the pane. With the sidebar closed, or without that mod installed, the transcript line is written as above.The entry is cleared and a green one says which of the two it was:
i18n-watch: every locale now has the keys src/Cart.vue lacked: checkout.total · checkout.vat i18n-watch: index.php no longer uses: Unauthorized Access
With the sidebar closed the same text is one transcript line. The model reads nothing of this: the finding closed by its own work, so a note would only repeat what it just did. A file that is there and cannot be read keeps its finding, because an unread file proves nothing.
i18n-watch: 1 file(s) still use translation keys the locale files lack: index.php (Unauthorized Access:42). Add the keys to every locale file, or take the calls out.
One note per turn, not one per prompt. Without this the finding would be said once, at the edit, and then stand in the pane while the model forgot it. You read nothing new: the pane already carries the same finding.
deny mode the mod also stops git commit, git push and git merge while a file still uses keys the locale files lack; a command with --dry-run, --help or -h is not stopped. A git commit answers for its own files alone: the mod reads the index (git diff --cached --name-only -z) and lets the commit run when it holds none of the open files, with one line to you naming how many still stand. A push and a merge hold no index to read, so every finding stands there. There is no bypass; only you turn the gate off, with /i18n-watch mode note. note mode is the default and stops nothing, but it measures the findings at a git command all the same, so a settled one does not stay in the pane.The locale files are read at the first edit of a turn that needs them, and again after an Edit or Write of a locale file. A project without these directories gets nothing. A locale file that cannot be read or parsed is skipped and logged once per session.
In the live check the model added t('cart.total') to a file of a project with locales/en.json and locales/tr.json, read the note after the Edit, and quoted it word for word.
/i18n-watch on or off, the mode, and the files still missing keys /i18n-watch on | off on by default /i18n-watch mode note note only; the default /i18n-watch mode deny a commit, a push and a merge also stop while a key is missing
claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install i18n-watch@kilimcininkoroglu-mods
Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.
Validated with claude plugin validate on Claude Code 2.1.283:
❯ ./register.ts hooks: session.start, command.run{command=i18n-watch}, turn.start, tool.call{tool=Bash}, turn.complete, prompt.submit, tool.call{tool=Edit}, tool.call{tool=Write} ❯ ./register.ts calls: $.command.register, $.fs.exists (via isDir, usedNow), $.fs.list (via walkLocales), $.fs.read (via loadCatalog, usedNow), $.fs.stat (via isDir), $.process.run (via shownRootOf, stagedPaths), $.session.cwd, $.sidebar.clear (via dropEntry), $.sidebar.set (via toPerson), $.store.get (via readSettings), $.store.set (via runCommand, setMode), $.ui.log (via catalogOf, gate, toPerson)
Reach L2, it runs git to read the index.
apps/web/src/locales is not seen when the session starts at the repository root.included array are not seen.{a: b}) are not followed.t(name), ` t(a.${b}) `) is not checked.tr, pt_BR, zh-Hant); a three-letter code such as fil is not recognised.old_string too) is not checked, and neither is an edit through Bash.deny mode has no bypass. When a finding cannot be fixed, you turn the gate off with /i18n-watch mode note.git commit is not stopped.git commit -a, a -am and a commit with a pathspec after -- are not narrowed to the index, because they commit files the index does not hold yet. Every open finding stands for those.make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test
hooks/register.ts 356 lines1import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
2import { callKeys, isSource, keyLines, newKeys } from './keys.ts'
3import { addFile, denyText, doneLines, doneLog, doneTitle, isCommit, isGuarded, isLocalePath, isNarrowable, LOCALE_DIRS, LOCALE_EXT, logText, modeOf, noteText, openNote, sectionKey, shownPath, sidebarLines, verdict, type Catalog, type Line, type Lines, type Mode } from './locale.ts'
4
5const ENABLED_KEY = 'enabled'
6const MODE_KEY = 'mode'
7
8const USAGE = 'expects nothing (the status), on, off or mode note | deny'
9
10/** Directory levels read under a locale directory, the directory itself being level 1. */
11const MAX_DEPTH = 4
12
13/** Locale files read in all, so a huge tree does not hold an edit. */
14const MAX_FILES = 200
15
16/** A bigger locale file is skipped. */
17const MAX_BYTES = 2_000_000
18
19/**
20 * One reported file: where it is on disk, the keys reported of it, and the line each key was called on.
21 * The keys are a claim, never an answer: every measure reads the file again and drops the keys it no
22 * longer calls, so a key the code deleted cannot hold a finding open.
23 */
24type Open = { path: string; keys: string[]; lines: Lines }
25
26/**
27 * The catalog of the session directory's locale files, read at the first edit that uses a new key and
28 * dropped at each turn and each edit of a locale file; `reported` makes a read error logged once.
29 * `open` holds each reported file's claim, so an edit that adds the keys, and an edit that stops using
30 * them, both close the finding. `root` is the directory the session started in, and locale files are
31 * looked for under it. `shownRoot` is the git repository it lies in (`shownRootOf`), and a path is shown
32 * against it. Both are read once, because a Bash `cd` moves `$.session.cwd()` away. `enabled` and `mode`
33 * are the settings as the store held them at the last read.
34 */
35type State = { enabled: boolean; mode: Mode; catalog?: Catalog; reported: boolean; open: Map<string, Open>; root?: string; shownRoot?: string; owed: boolean }
36
37/**
38 * Reads the on/off setting and the mode from the store, which every window shares, so a change made in
39 * another window applies here at the next hook that acts on it.
40 */
41async function readSettings($: EngineInterface, state: State): Promise<void> {
42 state.enabled = (await $.store.get(ENABLED_KEY)) !== false
43 state.mode = (await $.store.get(MODE_KEY)) === 'deny' ? 'deny' : 'note'
44}
45
46/**
47 * The git repository the session started in, so a file in a sibling directory of a session opened in a
48 * subdirectory still reads short; the session's own directory where git does not answer.
49 */
50async function shownRootOf($: EngineInterface, cwd: string): Promise<string> {
51 try {
52 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd })
53 const root = top.stdout.trim()
54 return top.exitCode === 0 && root !== '' ? root : cwd
55 } catch {
56 // No git here, or the command did not run: paths are shown against the session's directory.
57 return cwd
58 }
59}
60
61/** A locale file and the locale directory it was found under. */
62type Found = { root: string; path: string }
63
64function errorText(err: unknown): string {
65 return err instanceof Error ? err.message : String(err)
66}
67
68async function walkLocales($: EngineInterface, root: string, dir: string, depth: number, out: Found[]): Promise<void> {
69 for (const entry of await $.fs.list(dir)) {
70 if (out.length >= MAX_FILES) return
71 const path = `${dir}/${entry.name}`
72 const isLocale = entry.kind === 'file' && LOCALE_EXT.test(entry.name) && entry.size <= MAX_BYTES
73 if (isLocale) out.push({ root, path })
74 else if (entry.kind === 'dir' && depth < MAX_DEPTH && entry.name !== 'node_modules') await walkLocales($, root, path, depth + 1, out)
75 }
76}
77
78async function isDir($: EngineInterface, path: string): Promise<boolean> {
79 return (await $.fs.exists(path)) && (await $.fs.stat(path)).kind === 'dir'
80}
81
82/** Reads every locale file it finds; a directory or file that fails is named in `errors` and skipped. */
83async function loadCatalog($: EngineInterface, cwd: string): Promise<{ catalog: Catalog; errors: string[] }> {
84 const found: Found[] = []
85 const errors: string[] = []
86 for (const dir of LOCALE_DIRS) {
87 const root = `${cwd}/${dir}`
88 try {
89 if (await isDir($, root)) await walkLocales($, root, root, 1, found)
90 } catch (err) {
91 errors.push(`${dir}: ${errorText(err)}`)
92 }
93 }
94 const catalog: Catalog = new Map()
95 for (const f of found) {
96 try {
97 addFile(catalog, f.path.slice(f.root.length + 1), await $.fs.read(f.path))
98 } catch (err) {
99 errors.push(`${f.path.slice(cwd.length + 1)}: ${errorText(err)}`)
100 }
101 }
102 return { catalog, errors }
103}
104
105/** The directory the session started in, or the current one until `session.start` has recorded it. */
106async function rootOf($: EngineInterface, state: State): Promise<string> {
107 return state.root ?? (await $.session.cwd())
108}
109
110async function catalogOf($: EngineInterface, state: State): Promise<Catalog> {
111 if (state.catalog !== undefined) return state.catalog
112 const { catalog, errors } = await loadCatalog($, await rootOf($, state))
113 if (errors.length > 0 && !state.reported) $.ui.log(`some locale files were not read: ${errors.slice(0, 3).join(' · ')}`)
114 state.reported ||= errors.length > 0
115 state.catalog = catalog
116 return catalog
117}
118
119/**
120 * The finding the person reads: an entry in the shared sidebar's stream while it is open, else the
121 * transcript line, as before. The model's note is another channel and does not change here.
122 */
123async function toPerson($: EngineInterface, key: string, title: string, lines: Line[], line: string): Promise<void> {
124 try {
125 const taken = await $.sidebar.set({ consumer: 'i18n-watch', key: sectionKey(key), title, lines, until: 'stream' })
126 if (taken) return
127 } catch {
128 // The sidebar mod is not installed.
129 }
130 $.ui.log(line)
131}
132
133/** Drops the sidebar entries of one finding, so a key the locales gained leaves no warning behind. */
134async function dropEntry($: EngineInterface, key: string): Promise<void> {
135 try {
136 await $.sidebar.clear({ consumer: 'i18n-watch', key: sectionKey(key) })
137 } catch {
138 // The sidebar mod is not installed.
139 }
140}
141
142/**
143 * The keys a source file calls now, and the line of each, read from disk. `undefined` says the file could
144 * not be measured, and then no key is dropped; a file that is gone answers an empty measure, because the
145 * code it held calls nothing any more.
146 */
147async function usedNow($: EngineInterface, path: string): Promise<{ used: Set<string>; lines: Lines } | undefined> {
148 try {
149 if (!(await $.fs.exists(path))) return { used: new Set(), lines: {} }
150 const text = String(await $.fs.read(path))
151 return { used: callKeys(text), lines: keyLines(text) }
152 } catch {
153 // The file is there and was not read: the finding is left as it stands.
154 return undefined
155 }
156}
157
158/**
159 * Measures one file's claim against the file itself and against the locale files, then writes what still
160 * stands. The finding closes when nothing it named is missing any more, whether the locales gained the
161 * keys or the code stopped calling them, and the person reads one line that says which of the two it was.
162 */
163async function measureFile($: EngineInterface, state: State, file: string, open: Open): Promise<void> {
164 const now = await usedNow($, open.path)
165 const v = verdict(await catalogOf($, state), open.keys, now?.used)
166 const lines = now?.lines ?? open.lines
167 if (v.missing.length > 0) {
168 state.open.set(file, { path: open.path, keys: v.missing.map(m => m.key), lines })
169 return
170 }
171 state.open.delete(file)
172 await dropEntry($, file)
173 await toPerson($, file, doneTitle(v.added, v.gone), doneLines(file, v.added, v.gone), doneLog(file, v.added, v.gone))
174}
175
176/** Measures every finding still open, and reports the ones it closed. */
177async function recheckOpen($: EngineInterface, state: State, skip?: string): Promise<void> {
178 if (!state.enabled || state.open.size === 0) return
179 for (const [file, open] of [...state.open]) {
180 if (file !== skip) await measureFile($, state, file, open)
181 }
182}
183
184/** A locale file changed: the catalog is read again, and every open finding is measured against it. */
185async function afterLocaleEdit($: EngineInterface, state: State, r: ToolCallResult): Promise<ToolCallResult> {
186 await readSettings($, state)
187 state.catalog = undefined
188 await recheckOpen($, state)
189 return r
190}
191
192/** Adds the note to an edit that calls translation keys a locale lacks. */
193async function afterEdit($: EngineInterface, state: State, path: string, before: string, after: string, r: ToolCallResult): Promise<ToolCallResult> {
194 if (r.deny !== undefined || r.isError === true) return r
195 if (isLocalePath(path)) return afterLocaleEdit($, state, r)
196 if (!isSource(path)) return r
197 await readSettings($, state)
198 if (!state.enabled) return r
199 const shown = shownPath(path, state.shownRoot ?? (await rootOf($, state)))
200 // Every other file's finding is measured too, because this edit may have moved a key into one of them.
201 await recheckOpen($, state, shown)
202 const added = newKeys(before, after)
203 const claim = [...new Set([...(state.open.get(shown)?.keys ?? []), ...added])]
204 if (claim.length === 0) return r
205 return noteFor($, state, shown, path, claim, added, r)
206}
207
208/** Writes this file's finding and answers the edit: the note to the model, the line to the person. */
209async function noteFor($: EngineInterface, state: State, shown: string, path: string, claim: string[], added: string[], r: ToolCallResult): Promise<ToolCallResult> {
210 // Measured again here, because a boolean helper does not narrow the result union for the spread below.
211 if (r.deny !== undefined || r.isError === true) return r
212 const held = state.open.get(shown)
213 const now = await usedNow($, path)
214 const v = verdict(await catalogOf($, state), claim, now?.used)
215 const lines = now?.lines ?? {}
216 if (v.missing.length === 0) {
217 if (held !== undefined) await measureFile($, state, shown, { ...held, keys: claim, lines })
218 return r
219 }
220 state.open.set(shown, { path, keys: v.missing.map(m => m.key), lines })
221 // Only the keys this edit added are reported; a key reported before is held open without saying it again.
222 const fresh = v.missing.filter(m => added.includes(m.key))
223 if (fresh.length === 0) return r
224 await toPerson($, shown, 'missing translation keys', sidebarLines(shown, fresh, lines), logText(shown, fresh, lines))
225 return { ...r, context: [...(r.context ?? []), noteText(fresh, lines)] }
226}
227
228/**
229 * The files this commit holds, by absolute path, or undefined when git did not answer. Read before the
230 * command runs, so it is the index as the commit will take it.
231 */
232async function stagedPaths($: EngineInterface, state: State): Promise<Set<string> | undefined> {
233 try {
234 const cwd = await rootOf($, state)
235 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd })
236 const staged = await $.process.run(['git', 'diff', '--cached', '--name-only', '-z'], { cwd })
237 if (top.exitCode !== 0 || staged.exitCode !== 0) return undefined
238 const base = top.stdout.trim()
239 return new Set(staged.stdout.split('\0').filter(Boolean).map(p => `${base}/${p}`))
240 } catch {
241 // No git here, or the command did not run: the findings are not narrowed.
242 return undefined
243 }
244}
245
246/**
247 * The findings this command answers for. A `git commit` answers for its own files alone, so a finding of
248 * a file the commit does not hold lets it run. A `push` or a `merge` holds no index to read, so every
249 * finding stands there.
250 */
251async function scopeOf($: EngineInterface, state: State, command: string): Promise<{ file: string; keys: string[]; lines: Lines }[]> {
252 const all = [...state.open].map(([file, open]) => ({ file, keys: open.keys, lines: open.lines, path: open.path }))
253 if (!isCommit(command) || !isNarrowable(command)) return all
254 const staged = await stagedPaths($, state)
255 return staged === undefined ? all : all.filter(o => staged.has(o.path))
256}
257
258/**
259 * The gate of the `deny` mode: it measures every open finding again, so a key the model added and a key
260 * the code stopped calling both close it and the command runs. A file this command holds that still lacks
261 * a key stops it, and there is no bypass.
262 */
263async function gate($: EngineInterface, state: State, command: string): Promise<string | undefined> {
264 if (state.open.size === 0 || !isGuarded(command)) return undefined
265 await readSettings($, state)
266 if (!state.enabled) return undefined
267 // Measured in both modes, so a finding the code or the locales settled does not stand in the pane.
268 state.catalog = undefined
269 await recheckOpen($, state)
270 if (state.mode !== 'deny' || state.open.size === 0) return undefined
271 const scoped = await scopeOf($, state, command)
272 if (scoped.length === 0) {
273 $.ui.log(`${state.open.size} file(s) still lack translation keys, and this command holds none of them`)
274 return undefined
275 }
276 return denyText(scoped)
277}
278
279async function setMode($: EngineInterface, state: State, word: string): Promise<string> {
280 const mode = modeOf(word)
281 if (mode === undefined) return 'mode expects note or deny'
282 await $.store.set(MODE_KEY, mode)
283 state.mode = mode
284 return mode === 'deny' ? 'mode deny: git commit, push and merge stop while a file lacks translation keys' : 'mode note: the keys are only reported'
285}
286
287function statusText(state: State): string {
288 const open = state.open.size === 0 ? 'no file is open' : `${state.open.size} file(s) still lack keys`
289 return `${state.enabled ? 'on' : 'off'} · mode ${state.mode} · ${open}`
290}
291
292async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
293 const word = args.trim()
294 if (word === 'on' || word === 'off') {
295 await $.store.set(ENABLED_KEY, word === 'on')
296 state.enabled = word === 'on'
297 return word === 'on' ? 'on: each edit is checked for translation keys the locale files lack' : 'off: edits are not checked'
298 }
299 if (word.startsWith('mode')) return setMode($, state, word.slice(4).trim())
300 if (word !== '') return USAGE
301 await readSettings($, state)
302 return statusText(state)
303}
304
305export const register: Register = on => {
306 const state: State = { enabled: true, mode: 'note', reported: false, open: new Map(), owed: false }
307
308 on('session.start', async ($, e, next) => {
309 const r = await next(e)
310 await $.command.register({ name: 'i18n-watch', description: 'Translation keys an edit uses that locale files lack: status, on, off, mode (i18n-watch)', argumentHint: '[on | off | mode note | deny]' })
311 await readSettings($, state)
312 const cwd = await $.session.cwd()
313 state.root = cwd
314 state.shownRoot = await shownRootOf($, cwd)
315 return r
316 })
317
318 // The engine prints the plugin name in front of command text and log lines, so the texts do not repeat it.
319 on('command.run', { command: 'i18n-watch' }, async ($, e) => ({ text: await runCommand($, state, String(e.args ?? '')) }))
320
321 on('turn.start', async (_, e, next) => {
322 state.catalog = undefined
323 return next(e)
324 })
325
326 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
327 const stop = await gate($, state, e.command)
328 return stop === undefined ? next(e) : { deny: stop }
329 })
330
331 /*
332 * The turn's end measures every finding again and owes the model a note for what is left, because a
333 * finding it did not close would otherwise stand in the pane and reach it never again.
334 */
335 on('turn.complete', async ($, e, next) => {
336 const r = await next(e)
337 if (e.agentId !== undefined) return r
338 state.catalog = undefined
339 if (state.open.size > 0) await readSettings($, state)
340 await recheckOpen($, state)
341 state.owed = state.open.size > 0
342 return r
343 })
344
345 // The note goes to the model alone; the person reads the pane, which carries the same finding.
346 on('prompt.submit', async (_, e, next) => {
347 if (!state.owed || state.open.size === 0) return next(e)
348 state.owed = false
349 const note = openNote([...state.open].map(([file, open]) => ({ file, keys: open.keys, lines: open.lines })))
350 return next({ ...e, context: [...(e.context ?? []), note] })
351 })
352
353 on('tool.call', { tool: 'Edit' }, async ($, e, next) => afterEdit($, state, e.file_path, e.old_string, e.new_string, await next(e)))
354 on('tool.call', { tool: 'Write' }, async ($, e, next) => afterEdit($, state, e.file_path, '', e.content, await next(e)))
355}
356hooks/keys.ts 53 lines1/** Translation keys named by the calls in a piece of source code. */
2
3/** Files whose calls are read; a locale file, a README or a config file is not. */
4const SOURCE = /\.(ts|tsx|js|jsx|mjs|cjs|vue|svelte|astro|php|py|rb|erb|haml|slim)$/
5
6/**
7 * A translation call with a quoted first argument: `t`, `$t`, `i18n.t`, `__`, `trans`, `trans_choice`,
8 * `@lang`, `_`, `gettext` and `ngettext`, also after `this.`, `vm.`, `i18n.`, `$i18n.`, `I18n.` or `i18n.global.`.
9 * A call on any other object, a variable argument and a template literal do not match.
10 */
11const CALL = /(?:^|[^\w$.@])(?:(?:this|vm|I18n|i18n\.global|i18n|\$i18n)\.)?(?:\$t|t|__|trans_choice|trans|_|gettext|ngettext|@lang)\(\s*(['"])((?:\\.|(?!\1)[^\\\n])+)\1/gm
12
13/** Undoes backslash escapes: `\n` and `\t` become the characters, any other escaped character itself. */
14export function unescape(text: string): string {
15 return text.replace(/\\(.)/g, (_, c: string) => (c === 'n' ? '\n' : c === 't' ? '\t' : c))
16}
17
18export function isSource(path: string): boolean {
19 return SOURCE.test(path)
20}
21
22/** The keys of the calls in `text`; a Rails lazy key (`.title`) is skipped, because its full name depends on the view. */
23export function callKeys(text: string): Set<string> {
24 const keys = new Set<string>()
25 for (const m of text.matchAll(CALL)) {
26 const key = unescape(m[2] ?? '')
27 if (key.trim() !== '' && !key.startsWith('.')) keys.add(key)
28 }
29 return keys
30}
31
32/** The keys `after` calls that `before` does not. */
33export function newKeys(before: string, after: string): string[] {
34 const old = callKeys(before)
35 return [...callKeys(after)].filter(k => !old.has(k))
36}
37
38/** The 1-based line of each key's first call in `text`, so a finding names where the key is. */
39export function keyLines(text: string): Record<string, number> {
40 const out: Record<string, number> = {}
41 let at = 0
42 let line = 1
43 for (const m of text.matchAll(CALL)) {
44 // The key's own end, because the match starts one character before the call and a call can span lines.
45 const end = (m.index ?? 0) + m[0].length
46 for (let i = at; i < end; i += 1) if (text[i] === '\n') line += 1
47 at = end
48 const key = unescape(m[2] ?? '')
49 if (key.trim() !== '' && !key.startsWith('.') && out[key] === undefined) out[key] = line
50 }
51 return out
52}
53hooks/locale.ts 304 lines1/** The keys each language's locale files define, and the keys an edit uses that a language lacks. */
2import { unescape } from './keys.ts'
3
4/** Directories under the session directory that hold locale files. */
5export const LOCALE_DIRS = ['locales', 'lang', 'i18n', 'translations', 'locale', 'config/locales', 'resources/lang', 'src/locales', 'src/i18n', 'public/locales']
6
7export const LOCALE_EXT = /\.(json|php|ya?ml|po)$/
8
9/** A language code: two letters, with an optional region or script (`tr`, `en-US`, `pt_BR`, `zh-Hant`). */
10const LANG = /^[a-z]{2}(?:[_-][A-Za-z]{2,4})?$/
11
12/** Defined keys per language code. */
13export type Catalog = Map<string, Set<string>>
14
15/** A key and the languages that lack it, `all` when no language has it. */
16export type Missing = { key: string; langs: string[] | 'all' }
17
18/** Whether an edit of `path` can change the catalog. */
19export function isLocalePath(path: string): boolean {
20 return LOCALE_EXT.test(path) && LOCALE_DIRS.some(d => path.includes(`/${d}/`))
21}
22
23/**
24 * The language of a locale file and its namespace, from its path relative to the locale directory:
25 * `tr/messages.php` and `tr/LC_MESSAGES/django.po` by directory, `tr.json` and `messages.tr.yaml` by name.
26 */
27export function fileLang(rel: string): { lang: string; ns?: string } | undefined {
28 const parts = rel.split('/')
29 const stem = (parts.pop() ?? '').replace(LOCALE_EXT, '')
30 const dirLang = parts.find(p => LANG.test(p))
31 if (dirLang !== undefined) return { lang: dirLang, ns: stem }
32 const dotted = stem.split('.')
33 const lang = dotted.pop() ?? ''
34 if (!LANG.test(lang)) return undefined
35 return dotted.length > 0 ? { lang, ns: dotted.join('.') } : { lang }
36}
37
38/** i18next plural forms: `item_one` also defines `item`, the key `t('item', { count })` names. */
39const PLURAL = /_(zero|one|two|few|many|other)$/
40
41function flatten(value: unknown, prefix: string, out: string[]): void {
42 if (prefix !== '') out.push(prefix)
43 if (value === null || typeof value !== 'object' || Array.isArray(value)) return
44 for (const [k, v] of Object.entries(value)) flatten(v, prefix === '' ? k : `${prefix}.${k}`, out)
45}
46
47/** Nested JSON keys as dotted paths; a parse error throws. */
48export function jsonKeys(text: string): string[] {
49 const out: string[] = []
50 flatten(JSON.parse(text) as unknown, '', out)
51 return out.flatMap(k => (PLURAL.test(k) ? [k, k.replace(PLURAL, '')] : [k]))
52}
53
54const PHP_KEY = /^\s*(['"])(.+?)\1\s*=>\s*(.*)$/
55const PHP_OPEN = /^(\[|array\()\s*(\/\/.*)?$/
56const PHP_CLOSE = /^\s*[\])]/
57
58/** Keys of a PHP array file, one `'key' => ...` per line; an array that opens at the end of a line nests. */
59export function phpKeys(text: string): string[] {
60 const stack: string[] = []
61 const out: string[] = []
62 for (const line of text.split('\n')) {
63 const m = PHP_KEY.exec(line)
64 if (m === null) {
65 if (PHP_CLOSE.test(line)) stack.pop()
66 continue
67 }
68 const key = m[2] ?? ''
69 out.push([...stack, key].join('.'))
70 if (PHP_OPEN.test((m[3] ?? '').trim())) stack.push(key)
71 }
72 return out
73}
74
75const YAML_KEY = /^( *)(["']?)([^\s#'"-][^:]*?)\2:(?:\s|$)/
76
77/** Keys of a YAML mapping by indent; a Rails file's top key, the language, is also left out. */
78export function yamlKeys(text: string, lang: string): string[] {
79 const stack: { indent: number; key: string }[] = []
80 const out: string[] = []
81 for (const line of text.split('\n')) {
82 const m = YAML_KEY.exec(line)
83 if (m === null) continue
84 const indent = (m[1] ?? '').length
85 while (stack.length > 0 && (stack.at(-1)?.indent ?? 0) >= indent) stack.pop()
86 stack.push({ indent, key: m[3] ?? '' })
87 out.push(stack.map(s => s.key).join('.'))
88 }
89 return out.flatMap(k => (k.startsWith(`${lang}.`) ? [k, k.slice(lang.length + 1)] : [k]))
90}
91
92/** The `msgid` strings of a gettext file, with continuation lines joined. */
93export function poKeys(text: string): string[] {
94 const out: string[] = []
95 let current: string | undefined
96 for (const line of text.split('\n')) {
97 const id = /^msgid\s+"(.*)"\s*$/.exec(line)
98 const more = /^"(.*)"\s*$/.exec(line)
99 if (id !== null) current = unescape(id[1] ?? '')
100 else if (more !== null && current !== undefined) current += unescape(more[1] ?? '')
101 else {
102 if (current) out.push(current)
103 current = undefined
104 }
105 }
106 if (current) out.push(current)
107 return out
108}
109
110function fileKeys(rel: string, text: string, lang: string): string[] {
111 if (rel.endsWith('.json')) return jsonKeys(text)
112 if (rel.endsWith('.php')) return phpKeys(text)
113 if (rel.endsWith('.po')) return poKeys(text)
114 return yamlKeys(text, lang)
115}
116
117/** Adds one locale file; a key of a namespace file is also added as `ns.key` and `ns:key`. A JSON parse error throws. */
118export function addFile(catalog: Catalog, rel: string, text: string): void {
119 const where = fileLang(rel)
120 if (where === undefined) return
121 // Keys first, so a file that does not parse adds no empty language.
122 const keys = fileKeys(rel, text, where.lang)
123 const set = catalog.get(where.lang) ?? new Set<string>()
124 catalog.set(where.lang, set)
125 for (const key of keys) {
126 set.add(key)
127 if (where.ns !== undefined) set.add(`${where.ns}.${key}`).add(`${where.ns}:${key}`)
128 }
129}
130
131/** The keys some language lacks; nothing when the catalog has no language. */
132export function missingKeys(catalog: Catalog, keys: string[]): Missing[] {
133 const langs = [...catalog.keys()].sort()
134 const out: Missing[] = []
135 for (const key of keys) {
136 const lacking = langs.filter(l => catalog.get(l)?.has(key) !== true)
137 if (lacking.length > 0) out.push({ key, langs: lacking.length === langs.length ? 'all' : lacking })
138 }
139 return out
140}
141
142/** At most this many keys are named in the note, the rest counted. */
143const MAX_NAMED = 10
144
145/** Where each key is called, by line, so a finding says which line holds it. */
146export type Lines = Record<string, number>
147
148/** One key as every text names it: the key, and the line it is called on when that was measured. */
149function at(key: string, lines: Lines): string {
150 const line = lines[key]
151 return line === undefined ? key : `${key}:${line}`
152}
153
154function namedKeys(missing: Missing[], lines: Lines): string {
155 const named = missing.slice(0, MAX_NAMED).map(m => `${at(m.key, lines)} (missing in ${m.langs === 'all' ? 'every locale' : m.langs.join(', ')})`)
156 if (missing.length > MAX_NAMED) named.push(`${missing.length - MAX_NAMED} more`)
157 return named.join(' · ')
158}
159
160export function noteText(missing: Missing[], lines: Lines): string {
161 return `i18n-watch: this edit uses translation keys the locale files lack: ${namedKeys(missing, lines)}. Add them to each locale file.`
162}
163
164/**
165 * The transcript line: the file and its keys, without the instruction the model reads. The engine adds
166 * the mod name. The file is named because the person, unlike the model, did not see the edit.
167 */
168export function logText(file: string, missing: Missing[], lines: Lines): string {
169 return `keys ${file} uses that the locale files lack: ${namedKeys(missing, lines)}`
170}
171
172/** How the sidebar colours a line or a part of one. */
173type Tone = 'ok' | 'warn' | 'error' | 'dim'
174export type Part = { text: string; kind?: Tone }
175/** A sidebar line; `parts` colour pieces of it, and `text` holds the whole line for a sidebar that draws no parts. */
176export type Line = { text: string; kind?: Tone; parts?: Part[] }
177
178const part = (text: string, kind: Tone | undefined): Part => (kind === undefined ? { text } : { text, kind })
179
180/** A line made of parts, its `text` their texts joined. */
181const partsLine = (parts: Part[]): Line => ({ text: parts.map(p => p.text).join(''), parts })
182
183/** One missing key's row: the key red, its line faint, every locale red and a partial list of locales yellow. */
184function missingLine(m: Missing, lines: Lines): Line {
185 const line = lines[m.key]
186 const where = line === undefined ? [] : [part(`:${line}`, 'dim')]
187 const langs = m.langs === 'all' ? part('every locale', 'error') : part(m.langs.join(', '), 'warn')
188 return partsLine([part(m.key, 'error'), ...where, part(' (missing in ', 'dim'), langs, part(')', 'dim')])
189}
190
191/** The sidebar lines of a finding: the file, then one line per missing key, as the closing lines read. */
192export function sidebarLines(file: string, missing: Missing[], lines: Lines): Line[] {
193 const rows = missing.slice(0, MAX_NAMED).map(m => missingLine(m, lines))
194 if (missing.length > MAX_NAMED) rows.push({ text: `${missing.length - MAX_NAMED} more`, kind: 'dim' })
195 return [{ text: file, kind: 'error' }, ...rows]
196}
197
198function namedPlain(keys: string[]): string {
199 const named = keys.slice(0, MAX_NAMED)
200 if (keys.length > MAX_NAMED) named.push(`${keys.length - MAX_NAMED} more`)
201 return named.join(' · ')
202}
203
204/**
205 * What a reported file's keys read as now: the ones a locale still lacks, the ones the code stopped
206 * calling, and the ones every locale gained. `used` is the keys the file calls now, or undefined when
207 * the file could not be read, where every key counts as still called.
208 */
209export type Verdict = { missing: Missing[]; gone: string[]; added: string[] }
210
211export function verdict(catalog: Catalog, claim: readonly string[], used: Set<string> | undefined): Verdict {
212 const gone = used === undefined ? [] : claim.filter(k => !used.has(k))
213 const alive = claim.filter(k => !gone.includes(k))
214 const missing = alive.length === 0 ? [] : missingKeys(catalog, alive)
215 const added = alive.filter(k => !missing.some(m => m.key === k))
216 return { missing, gone, added }
217}
218
219/** The title of a closed finding, by what closed it. */
220export function doneTitle(added: readonly string[], gone: readonly string[]): string {
221 if (gone.length === 0) return 'translation keys added'
222 return added.length === 0 ? 'translation keys gone' : 'translation keys resolved'
223}
224
225function doneParts(file: string, added: string[], gone: string[]): string[] {
226 const parts: string[] = []
227 if (added.length > 0) parts.push(`every locale now has the keys ${file} lacked: ${namedPlain(added)}`)
228 if (gone.length > 0) parts.push(`${file} no longer uses: ${namedPlain(gone)}`)
229 return parts
230}
231
232/** The transcript line of a finding that closed: the keys the locales gained, the keys the code dropped. */
233export function doneLog(file: string, added: string[], gone: string[]): string {
234 return doneParts(file, added, gone).join(' · ')
235}
236
237/** The sidebar lines of a closed finding: the file, then each key with what happened to it. */
238export function doneLines(file: string, added: string[], gone: string[]): Line[] {
239 const addedRows = added.slice(0, MAX_NAMED).map((text): Line => ({ text, kind: 'ok' }))
240 const goneRows = gone.slice(0, MAX_NAMED).map(k => partsLine([part(k, 'ok'), part(' (no longer used)', 'dim')]))
241 return [{ text: file, kind: 'ok' }, ...addedRows, ...goneRows]
242}
243
244/** The global flags git takes before the subcommand, so `git -c user.name=x commit` is still a commit. */
245const GIT_FLAG = String.raw`(?:\s+-[cC]\s+\S+|\s+--(?:git-dir|work-tree|namespace)=\S+|\s+--(?:no-pager|no-replace-objects|bare|literal-pathspecs|paginate))`
246
247/** A `git commit`, `git push` or `git merge` the gate stops while a finding is open. */
248const GUARDED = new RegExp(String.raw`(^|[\s;&|(])git(?:${GIT_FLAG})*\s+(commit|push|merge)\b`)
249const ASKING = /\s(--dry-run|--help|-h)(\s|$)/
250
251export function isGuarded(command: string): boolean {
252 return GUARDED.test(command) && !ASKING.test(command)
253}
254
255/** Whether the command is a `git commit`, the one guarded command whose own files can be measured. */
256export function isCommit(command: string): boolean {
257 return GUARDED.exec(command)?.[2] === 'commit'
258}
259
260/**
261 * Whether the index alone says what this commit holds. A `-a` or `-am` commit stages the tracked files
262 * as it runs, and a pathspec after `--` commits paths the index does not hold, so neither is narrowed.
263 */
264export function isNarrowable(command: string): boolean {
265 const words = command.split(/\s+/)
266 return !words.includes('--') && !words.some(w => w === '--all' || /^-[A-Za-z]*a/.test(w))
267}
268
269/** The mode of the mod: a note only, or a note and a gate on git commit, push and merge. */
270export type Mode = 'note' | 'deny'
271
272/** The mode a `/i18n-watch mode <word>` argument names, or undefined when it is not one. */
273export function modeOf(arg: string): Mode | undefined {
274 return arg === 'note' || arg === 'deny' ? arg : undefined
275}
276
277/** The deny text both the model and the person read: which file lacks which keys, and the one way out. */
278export function denyText(open: readonly { file: string; keys: string[]; lines: Lines }[]): string {
279 const named = open.slice(0, MAX_NAMED).map(o => `${o.file} (${namedPlain(o.keys.map(k => at(k, o.lines)))})`)
280 if (open.length > MAX_NAMED) named.push(`${open.length - MAX_NAMED} more`)
281 return `stopped: ${open.length} file(s) use translation keys the locale files lack: ${named.join(' · ')}. Add the keys to every locale file, then run the command again; there is no way around this gate.`
282}
283
284/**
285 * The note the model reads at the next prompt while a finding stands, so a finding it did not close
286 * reaches it again instead of standing in the pane alone. The person reads the pane and needs no line.
287 */
288export function openNote(open: readonly { file: string; keys: string[]; lines: Lines }[]): string {
289 const named = open.slice(0, MAX_NAMED).map(o => `${o.file} (${namedPlain(o.keys.map(k => at(k, o.lines)))})`)
290 if (open.length > MAX_NAMED) named.push(`${open.length - MAX_NAMED} more`)
291 return `i18n-watch: ${open.length} file(s) still use translation keys the locale files lack: ${named.join(' · ')}. Add the keys to every locale file, or take the calls out.`
292}
293
294/** `path` shown relative to the session's directory when it is inside it. */
295export function shownPath(path: string, cwd: string): string {
296 const base = `${cwd.replace(/\/+$/, '')}/`
297 return path.startsWith(base) ? path.slice(base.length) : path
298}
299
300/** A sidebar section key: the subject cut to what the sidebar takes, so one file keeps one section. */
301export function sectionKey(text: string): string {
302 return text.replace(/[^A-Za-z0-9._:-]+/g, '-').slice(0, 64) || 'note'
303}
304