@museumwnf/viewer-i18n 3.1.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE.md +44 -44
  2. package/README.md +173 -173
  3. package/dist/exhibition/ar.json +0 -3
  4. package/dist/exhibition/cs.json +0 -3
  5. package/dist/exhibition/de.json +0 -3
  6. package/dist/exhibition/el.json +0 -3
  7. package/dist/exhibition/en.json +0 -3
  8. package/dist/exhibition/es.json +0 -3
  9. package/dist/exhibition/fr.json +0 -3
  10. package/dist/exhibition/index.js +0 -33
  11. package/dist/exhibition/it.json +0 -3
  12. package/dist/exhibition/pt.json +0 -3
  13. package/dist/exhibition/se.json +0 -3
  14. package/dist/exhibition/tr.json +0 -3
  15. package/dist/gallery/ar.json +0 -3
  16. package/dist/gallery/cs.json +0 -3
  17. package/dist/gallery/de.json +0 -3
  18. package/dist/gallery/el.json +0 -3
  19. package/dist/gallery/en.json +0 -3
  20. package/dist/gallery/es.json +0 -3
  21. package/dist/gallery/fr.json +0 -3
  22. package/dist/gallery/index.js +0 -33
  23. package/dist/gallery/it.json +0 -3
  24. package/dist/gallery/pt.json +0 -3
  25. package/dist/gallery/se.json +0 -3
  26. package/dist/gallery/tr.json +0 -3
  27. package/dist/standalone/ar.json +0 -3
  28. package/dist/standalone/cs.json +0 -3
  29. package/dist/standalone/de.json +0 -3
  30. package/dist/standalone/el.json +0 -3
  31. package/dist/standalone/en.json +0 -3
  32. package/dist/standalone/es.json +0 -3
  33. package/dist/standalone/fr.json +0 -3
  34. package/dist/standalone/index.js +0 -33
  35. package/dist/standalone/it.json +0 -3
  36. package/dist/standalone/pt.json +0 -3
  37. package/dist/standalone/se.json +0 -3
  38. package/dist/standalone/tr.json +0 -3
  39. package/namespaces.json +13 -13
  40. package/package.json +38 -38
  41. package/tools/check.mjs +825 -825
package/tools/check.mjs CHANGED
@@ -1,825 +1,825 @@
1
- #!/usr/bin/env node
2
- // The rules of the dictionary, in one place, enforced identically wherever
3
- // texts live: here, in a website's locales/, and at the call sites that read
4
- // them. Nothing beyond what the architecture defines is checked — a rule that
5
- // is not in the epic is not in this file.
6
- //
7
- // Three modes, because there are three kinds of repository:
8
- //
9
- // --dictionary this repo: the namespace catalogues
10
- // --site a website: its own locales/, on its own (no install needed,
11
- // so a translator's pull request is validated in seconds)
12
- // --app a website with its dependencies installed: every key the
13
- // code asks for must resolve against bundle + local file
14
- //
15
- // Messages are written for the person who has to fix them, who is usually a
16
- // translator and not a programmer.
17
-
18
- import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
19
- import { createRequire } from 'node:module'
20
- import { join, relative, resolve } from 'node:path'
21
- import { fileURLToPath, pathToFileURL } from 'node:url'
22
-
23
- // namespace.section.label — camelCase segments, dots only as separators.
24
- export const KEY_RE = /^[a-z][a-zA-Z0-9]*\.[a-z][a-zA-Z0-9]*\.[a-z][a-zA-Z0-9]*$/
25
- // A language file is named after its language: en.json, fr.json, pt-BR.json.
26
- // What a language tag may look like is BCP 47's business and the platform
27
- // already knows it, so nothing here re-describes the grammar: `Intl` parses the
28
- // tag, canonicalises its spelling and rejects what is not one.
29
- //
30
- // The one rule that is ours is the length. An ISO 639 code is two or three
31
- // letters; BCP 47 also allows five to eight, none of which are assigned, so
32
- // without this `common.json` and `index.json` both read as valid languages.
33
- /** The language a file is named after, canonically spelled, or null. */
34
- export function languageOf(name) {
35
- let canonical
36
- try {
37
- ;[canonical] = Intl.getCanonicalLocales(name)
38
- } catch {
39
- return null
40
- }
41
- if (!canonical) return null
42
- return new Intl.Locale(canonical).language.length > 3 ? null : canonical
43
- }
44
- const BASE_LANGUAGE = 'en'
45
-
46
- // There is deliberately no rule here about HTML in a text. viewer-core's
47
- // Markdown pipeline escapes raw HTML rather than rendering it, so a tag a
48
- // translator types appears on the page as the characters they typed and can do
49
- // nothing else. Checking for it here bought no safety and cost accuracy: the
50
- // rule was a regular expression, and it rejected `<https://example.org/>`,
51
- // `<office@museumwnf.net>` and `` `<div>` `` — an autolink, an email autolink
52
- // and a code span, all ordinary Markdown — telling the translator to "write
53
- // formatting in Markdown instead", which is what they had done.
54
- //
55
- // Reading the Markdown grammar properly would have needed a parser, which
56
- // means a dependency, which means npm in every translator's pull request: real
57
- // cost, for a rule whose only remaining job was to prevent literal angle
58
- // brackets on a page. The escape is the guarantee. Keep it that way — if this
59
- // rule ever looks necessary again, check viewer-core's `renderBlock` first.
60
-
61
- const SOURCE_EXTENSIONS = ['.vue', '.js', '.mjs']
62
-
63
- // How a text is asked for is a question about code, so the code is parsed.
64
- // `@vue/compiler-sfc` is the parser the website already builds with — vite
65
- // hands it every one of these files — so this reads them the way the bundler
66
- // does, and needs nothing installed that was not there already. It is resolved
67
- // from the website being checked rather than declared here, which is also what
68
- // keeps `--site` free of it: only this mode ever loads a parser.
69
- //
70
- // It replaced two regular expressions that were wrong in both directions.
71
- // `t(item)` written inside a prose comment was read as a text being asked for,
72
- // and a component's own `const t = (item) => …` could not be told apart from
73
- // the one that looks a text up — both cost real edits during the rollout.
74
- async function loadCompiler(dir) {
75
- // The website's own copy first, so the code is read by the version that
76
- // builds it. Ours second, which is what lets these rules be tested here.
77
- for (const from of [join(dir, 'package.json'), fileURLToPath(import.meta.url)]) {
78
- try {
79
- return await import(pathToFileURL(createRequire(from).resolve('@vue/compiler-sfc')).href)
80
- } catch {
81
- continue
82
- }
83
- }
84
- return null
85
- }
86
-
87
- /** The name a call is made under: `t`, `$t`, `_ctx.$t` — all read as the last part. */
88
- function calleeName(callee) {
89
- if (callee?.type === 'Identifier') return callee.name
90
- if (callee?.type === 'MemberExpression' && callee.property?.type === 'Identifier') {
91
- return callee.property.name
92
- }
93
- return null
94
- }
95
-
96
- const FUNCTIONS = new Set([
97
- 'FunctionDeclaration',
98
- 'FunctionExpression',
99
- 'ArrowFunctionExpression',
100
- 'ObjectMethod',
101
- 'ClassMethod',
102
- ])
103
-
104
- /** Whether a `t` bound by this block is the one that looks a text up — null if it binds none. */
105
- function tBoundIn(block) {
106
- let verdict = null
107
- for (const statement of block.body ?? []) {
108
- if (statement.type === 'FunctionDeclaration' && statement.id?.name === 't') verdict = false
109
- if (statement.type !== 'VariableDeclaration') continue
110
- for (const declarator of statement.declarations) {
111
- const id = declarator.id
112
- const binds =
113
- (id?.type === 'Identifier' && id.name === 't') ||
114
- (id?.type === 'ObjectPattern' &&
115
- id.properties.some((p) => p.value?.name === 't' || p.key?.name === 't'))
116
- if (!binds) continue
117
- verdict =
118
- declarator.init?.type === 'CallExpression' &&
119
- calleeName(declarator.init.callee) === 'useI18n'
120
- }
121
- }
122
- return verdict
123
- }
124
-
125
- /**
126
- * Every node, carrying what `t` means where it stands.
127
- *
128
- * Which is the whole reason this is parsed rather than matched. One real file,
129
- * `water-in-islam/src/composables/useCollection.js`, uses the name both ways:
130
- * `export function facetLabels(t)` receives the text lookup as a parameter,
131
- * while `const t = tr('items', item.id, defaultLang)` further down is a
132
- * translated record. A rule that reads the file as text has to guess, and
133
- * either invents nine references or loses them.
134
- */
135
- function eachNode(node, scope, visit) {
136
- if (Array.isArray(node)) {
137
- for (const item of node) eachNode(item, scope, visit)
138
- return
139
- }
140
- if (!node || typeof node !== 'object' || typeof node.type !== 'string') return
141
-
142
- // A `t` handed to a function is the lookup being passed down; a block that
143
- // declares its own says for itself which it is.
144
- if (FUNCTIONS.has(node.type) && node.params?.some((p) => p.type === 'Identifier' && p.name === 't')) {
145
- scope = true
146
- }
147
- if (node.type === 'Program' || node.type === 'BlockStatement') {
148
- const own = tBoundIn(node)
149
- if (own !== null) scope = own
150
- }
151
-
152
- visit(node, scope)
153
- for (const [key, value] of Object.entries(node)) {
154
- if (key === 'loc' || key.endsWith('Comments')) continue
155
- eachNode(value, scope, visit)
156
- }
157
- }
158
-
159
- /**
160
- * Collect the names asked for in one piece of JavaScript.
161
- *
162
- * `line` offsets a block back onto the file it came from; a template is
163
- * compiled before it is read, so its lines are the compiled ones and it
164
- * reports the file instead.
165
- */
166
- function collectFromScript(compiler, code, where, { line = 0, detectT = true, i18nT = false, found, namespaces = null }) {
167
- let ast
168
- try {
169
- ast = compiler.babelParse(code, { sourceType: 'module', errorRecovery: true })
170
- } catch (error) {
171
- found.unreadable.push(`${where}: ${error.message}`)
172
- return false
173
- }
174
- const at = (node) => (line === null ? where : `${where}:${(node.loc?.start.line ?? 1) + line}`)
175
- const record = (key, node) => {
176
- if (!found.references.has(key)) found.references.set(key, at(node))
177
- }
178
-
179
- // A script says for itself what `t` is at its top level; a compiled template
180
- // has no declarations of its own and inherits the script's answer.
181
- eachNode(ast.program, detectT ? false : i18nT, (node, tIsLookup) => {
182
- if (
183
- node.type === 'ObjectProperty' &&
184
- (node.key?.name === 'keypath' || node.key?.value === 'keypath')
185
- ) {
186
- if (node.value?.type === 'StringLiteral') record(node.value.value, node)
187
- else found.dynamic.push(at(node))
188
- return
189
- }
190
- // A name written in a spec — `title: 'carpets.identity.title'` in a
191
- // dataset.config.js, a label in a catalogue or sheet spec the composed
192
- // views render — is asked for by the platform, not by a call the
193
- // website makes. It reads as one when its first part is a section this
194
- // website receives; any other three-part string is left alone.
195
- if (
196
- namespaces &&
197
- node.type === 'StringLiteral' &&
198
- KEY_RE.test(node.value) &&
199
- namespaces.has(node.value.split('.')[0])
200
- ) {
201
- record(node.value, node)
202
- return
203
- }
204
- if (node.type !== 'CallExpression') return
205
- const name = calleeName(node.callee)
206
- if (name !== 't' && name !== '$t') return
207
- // A bare `t` counts only where `t` is the lookup. `$t` is the global one,
208
- // so it counts wherever it appears.
209
- if (name === 't' && !tIsLookup) return
210
- const [first] = node.arguments
211
- if (first?.type === 'StringLiteral') record(first.value, node)
212
- else found.dynamic.push(at(node))
213
- })
214
-
215
- return tBoundIn(ast.program) === true
216
- }
217
-
218
- // ── helpers ────────────────────────────────────────────────────────────────
219
-
220
- function readJson(file, problems, label) {
221
- let text
222
- try {
223
- text = readFileSync(file, 'utf8')
224
- } catch {
225
- problems.push(`The file **${label}** could not be read.`)
226
- return null
227
- }
228
- try {
229
- const value = JSON.parse(text)
230
- if (value === null || typeof value !== 'object' || Array.isArray(value)) {
231
- problems.push(`The file **${label}** must contain a list of entries between { and }.`)
232
- return null
233
- }
234
- return value
235
- } catch (error) {
236
- problems.push(
237
- `The file **${label}** is not valid JSON, so it cannot be used. This is usually ` +
238
- `a missing or extra comma, quote or brace. Technical detail: ${error.message}`
239
- )
240
- return null
241
- }
242
- }
243
-
244
- function languageFiles(dir) {
245
- return readdirSync(dir)
246
- .filter((name) => name.endsWith('.json'))
247
- .sort()
248
- }
249
-
250
- /**
251
- * Everything that is true of one language file, whatever repository it is in.
252
- * `namespaces` is the set of prefixes this file is allowed to use.
253
- */
254
- function checkEntries(label, data, namespaces, problems) {
255
- const seen = new Map()
256
- for (const [key, value] of Object.entries(data)) {
257
- if (!KEY_RE.test(key)) {
258
- problems.push(
259
- `In **${label}**, \`${key}\` is not a valid entry name. A name is made of three ` +
260
- `parts separated by dots — section.group.name — each starting with a lowercase ` +
261
- `letter, for example \`gallery.sheet.inventoryNumber\`.`
262
- )
263
- continue
264
- }
265
- const namespace = key.slice(0, key.indexOf('.'))
266
- if (!namespaces.includes(namespace)) {
267
- problems.push(
268
- `In **${label}**, \`${key}\` starts with \`${namespace}\`, which this file may not ` +
269
- `use. Allowed here: ${namespaces.map((n) => `\`${n}\``).join(', ')}.`
270
- )
271
- continue
272
- }
273
- const lower = key.toLowerCase()
274
- if (seen.has(lower) && seen.get(lower) !== key) {
275
- problems.push(
276
- `In **${label}**, \`${key}\` and \`${seen.get(lower)}\` differ only in capitalisation. ` +
277
- `Entry names must differ by more than their capitals.`
278
- )
279
- }
280
- seen.set(lower, key)
281
-
282
- if (typeof value !== 'string') {
283
- problems.push(`In **${label}**, the text for \`${key}\` must be written between quotes.`)
284
- continue
285
- }
286
- if (value.trim() === '') {
287
- problems.push(
288
- `In **${label}**, the text for \`${key}\` is empty. Remove the whole line instead — ` +
289
- `an entry with no text hides the English one rather than falling back to it.`
290
- )
291
- }
292
- if (/[{}]/.test(value)) {
293
- problems.push(
294
- `In **${label}**, the text for \`${key}\` contains a curly brace. Texts are complete ` +
295
- `on their own: nothing is inserted into them, so { and } have no meaning here.`
296
- )
297
- }
298
- }
299
- }
300
-
301
- /** A language file may not invent an entry the English file does not have. */
302
- function checkAgainstBase(label, data, base, baseLabel, problems) {
303
- const extra = Object.keys(data).filter((key) => !(key in base))
304
- if (extra.length) {
305
- problems.push(
306
- `The file **${label}** contains entries that do not exist in **${baseLabel}**: ` +
307
- `${extra.map((k) => `\`${k}\``).join(', ')}. Remove them, or check the spelling — ` +
308
- `English is where an entry is created.`
309
- )
310
- }
311
- }
312
-
313
- function coverage(name, data, base) {
314
- const translated = Object.keys(base).filter((key) => key in data).length
315
- const total = Object.keys(base).length
316
- const percent = total === 0 ? 100 : Math.round((translated / total) * 100)
317
- return `${name}: ${translated}/${total} (${percent}%)`
318
- }
319
-
320
- // ── the registry ───────────────────────────────────────────────────────────
321
-
322
- /**
323
- * The dictionary's own namespaces.json when checking the dictionary; the
324
- * installed package's copy otherwise. This file ships with the package, so a
325
- * website never has to carry a second copy of the registry.
326
- */
327
- export function loadRegistry(dir) {
328
- const local = join(dir, 'namespaces.json')
329
- const file = existsSync(local)
330
- ? local
331
- : fileURLToPath(new URL('../namespaces.json', import.meta.url))
332
- return JSON.parse(readFileSync(file, 'utf8'))
333
- }
334
-
335
- /** How a website declares what it is. Read from its package.json. */
336
- export function siteDeclaration(dir, registry, problems) {
337
- const pkgFile = join(dir, 'package.json')
338
- const pkg = existsSync(pkgFile) ? JSON.parse(readFileSync(pkgFile, 'utf8')) : {}
339
- const declared = pkg.viewerI18n
340
- if (!declared?.class || !declared?.namespace) {
341
- problems.push(
342
- 'This website does not say which texts it receives. Add to **package.json**:\n' +
343
- ' "viewerI18n": { "class": "gallery", "namespace": "carpets" }\n' +
344
- ` class is one of: ${Object.keys(registry.bundles).join(', ')}`
345
- )
346
- return null
347
- }
348
- const received = registry.bundles[declared.class]
349
- if (!received) {
350
- problems.push(
351
- `In **package.json**, "viewerI18n.class" is \`${declared.class}\`, which is not a ` +
352
- `kind of website. Use one of: ${Object.keys(registry.bundles).join(', ')}.`
353
- )
354
- return null
355
- }
356
- if (!/^[a-z][a-zA-Z0-9]*$/.test(declared.namespace)) {
357
- problems.push(
358
- `In **package.json**, "viewerI18n.namespace" is \`${declared.namespace}\`. It must be ` +
359
- 'a single word starting with a lowercase letter, for example `carpets`.'
360
- )
361
- return null
362
- }
363
- if (registry.namespaces.includes(declared.namespace)) {
364
- problems.push(
365
- `In **package.json**, "viewerI18n.namespace" is \`${declared.namespace}\`, which is a ` +
366
- 'shared section name. A website needs a name of its own.'
367
- )
368
- return null
369
- }
370
- return { ...declared, received, allowed: [...received, declared.namespace] }
371
- }
372
-
373
- // ── modes ──────────────────────────────────────────────────────────────────
374
-
375
- /**
376
- * The languages a bundle's websites offer, as declared in `namespaces.json`
377
- * under `languages`: every section the bundle contains must exist, complete,
378
- * in each of them. Not declared means not required, which is how the rule
379
- * stays optional for a fixture and mandatory for the real dictionary.
380
- */
381
- function requiredLanguages(registry, bundle) {
382
- const declared = registry.languages?.[bundle]
383
- return Array.isArray(declared) ? declared : []
384
- }
385
-
386
- /** The entries of `base` that `data` lacks — what a translator has to add. */
387
- function missingEntries(data, base) {
388
- return Object.keys(base).filter((key) => !(key in data))
389
- }
390
-
391
- export function checkDictionary(dir) {
392
- const problems = []
393
- const notes = []
394
- const registry = loadRegistry(dir)
395
- const catalogues = {}
396
-
397
- for (const namespace of registry.namespaces) {
398
- const nsDir = join(dir, namespace)
399
- if (!existsSync(nsDir) || !statSync(nsDir).isDirectory()) {
400
- problems.push(`The folder **${namespace}/** is declared in namespaces.json but missing.`)
401
- continue
402
- }
403
- const files = languageFiles(nsDir)
404
- const parsed = {}
405
- for (const file of files) {
406
- const language = file.slice(0, -'.json'.length)
407
- const label = `${namespace}/${file}`
408
- const canonical = languageOf(language)
409
- if (canonical === null) {
410
- problems.push(
411
- `**${label}** is not named after a language. Use the two-letter code, ` +
412
- 'for example `fr.json`.'
413
- )
414
- continue
415
- }
416
- if (canonical !== language) {
417
- problems.push(
418
- `**${label}** should be named \`${canonical}.json\`. A language is spelled one ` +
419
- 'way, so that two files cannot both claim it with only the filesystem ' +
420
- 'deciding which one a reader gets.'
421
- )
422
- continue
423
- }
424
- const data = readJson(join(nsDir, file), problems, label)
425
- if (!data) continue
426
- checkEntries(label, data, [namespace], problems)
427
- parsed[language] = data
428
- }
429
-
430
- const base = parsed[BASE_LANGUAGE]
431
- if (!base) {
432
- problems.push(
433
- `**${namespace}/${BASE_LANGUAGE}.json** is missing. English is where every entry is ` +
434
- 'created; the other languages are compared against it.'
435
- )
436
- continue
437
- }
438
- for (const [language, data] of Object.entries(parsed)) {
439
- if (language === BASE_LANGUAGE) continue
440
- checkAgainstBase(`${namespace}/${language}.json`, data, base, `${namespace}/en.json`, problems)
441
- notes.push(coverage(`${namespace}/${language}`, data, base))
442
- }
443
- notes.push(`${namespace}: ${Object.keys(base).length} entries in English`)
444
- catalogues[namespace] = parsed
445
- }
446
-
447
- for (const bundle of Object.keys(registry.bundles)) {
448
- const unknown = registry.bundles[bundle].filter((n) => !registry.namespaces.includes(n))
449
- if (unknown.length) {
450
- problems.push(
451
- `In **namespaces.json**, the \`${bundle}\` bundle lists unknown sections: ` +
452
- unknown.join(', ')
453
- )
454
- }
455
-
456
- // A website offers the languages its data package declares, and a visitor
457
- // who picks one reads the whole page in it — the records from the package,
458
- // the chrome from here. A section that has no file for that language, or
459
- // an incomplete one, shows that visitor English labels between records in
460
- // their language. So a bundle names the languages its websites offer, and
461
- // every section it contains has to be complete in each of them. The
462
- // shared sections are in every bundle, so they need the union.
463
- for (const language of requiredLanguages(registry, bundle)) {
464
- for (const namespace of registry.bundles[bundle]) {
465
- const parsed = catalogues[namespace]
466
- if (!parsed?.[BASE_LANGUAGE]) continue
467
- const data = parsed[language]
468
- if (!data) {
469
- problems.push(
470
- `**${namespace}/${language}.json** is missing. The \`${bundle}\` websites offer ` +
471
- `\`${language}\`, so every section they receive has to exist in it — copy ` +
472
- `${namespace}/en.json and translate it.`
473
- )
474
- continue
475
- }
476
- const missing = missingEntries(data, parsed[BASE_LANGUAGE])
477
- if (missing.length) {
478
- const shown = missing.slice(0, 8).map((k) => `\`${k}\``).join(', ')
479
- problems.push(
480
- `**${namespace}/${language}.json** is missing ${missing.length} entr` +
481
- `${missing.length === 1 ? 'y' : 'ies'} that ${namespace}/en.json has: ${shown}` +
482
- `${missing.length > 8 ? ', …' : ''}. The \`${bundle}\` websites offer ` +
483
- `\`${language}\`, so this section has to be complete in it.`
484
- )
485
- }
486
- }
487
- }
488
- }
489
-
490
- return { problems, notes }
491
- }
492
-
493
- export function checkSite(dir) {
494
- const problems = []
495
- const notes = []
496
- const registry = loadRegistry(dir)
497
- const site = siteDeclaration(dir, registry, problems)
498
- const localesDir = join(dir, 'locales')
499
-
500
- if (!existsSync(localesDir)) {
501
- problems.push('This website has no **locales/** folder, so it has no texts of its own.')
502
- return { problems, notes }
503
- }
504
- if (!site) return { problems, notes }
505
-
506
- const parsed = {}
507
- for (const file of languageFiles(localesDir)) {
508
- const language = file.slice(0, -'.json'.length)
509
- const label = `locales/${file}`
510
- const canonical = languageOf(language)
511
- if (canonical === null) {
512
- problems.push(
513
- `**${label}** is not named after a language. Use the two-letter code, ` +
514
- 'for example `fr.json`.'
515
- )
516
- continue
517
- }
518
- if (canonical !== language) {
519
- problems.push(
520
- `**${label}** should be named \`${canonical}.json\`. A language is spelled one ` +
521
- 'way, so that two files cannot both claim it with only the filesystem ' +
522
- 'deciding which one a reader gets.'
523
- )
524
- continue
525
- }
526
- const data = readJson(join(localesDir, file), problems, label)
527
- if (!data) continue
528
- checkEntries(label, data, site.allowed, problems)
529
- parsed[language] = data
530
- }
531
-
532
- const base = parsed[BASE_LANGUAGE]
533
- if (!base) {
534
- problems.push(
535
- `**locales/${BASE_LANGUAGE}.json** is missing. English is where every entry of this ` +
536
- 'website is created; the other languages are compared against it.'
537
- )
538
- return { problems, notes }
539
- }
540
- for (const [language, data] of Object.entries(parsed)) {
541
- if (language === BASE_LANGUAGE) continue
542
- checkAgainstBase(`locales/${language}.json`, data, base, 'locales/en.json', problems)
543
- notes.push(coverage(`locales/${language}`, data, base))
544
- }
545
- notes.push(
546
- `receives ${site.received.join(' + ')}; own entries: ${Object.keys(base).length} in English`
547
- )
548
- return { problems, notes }
549
- }
550
-
551
- function sourceFiles(dir) {
552
- const found = []
553
- const walk = (current) => {
554
- for (const entry of readdirSync(current, { withFileTypes: true })) {
555
- if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue
556
- const full = join(current, entry.name)
557
- if (entry.isDirectory()) walk(full)
558
- else if (SOURCE_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) found.push(full)
559
- }
560
- }
561
- if (existsSync(dir)) walk(dir)
562
- return found.sort()
563
- }
564
-
565
- /** Every key the code asks for, and every place it asks with something else. */
566
- export async function scanSources(dir, compiler, { namespaces = null } = {}) {
567
- const found = { references: new Map(), dynamic: [], unreadable: [] }
568
- const parser = compiler ?? (await loadCompiler(dir))
569
- if (!parser) {
570
- found.unreadable.push('no parser')
571
- return found
572
- }
573
-
574
- for (const file of sourceFiles(join(dir, 'src'))) {
575
- const text = readFileSync(file, 'utf8')
576
- const where = relative(dir, file).replaceAll('\\', '/')
577
-
578
- if (!file.endsWith('.vue')) {
579
- collectFromScript(parser, text, where, { found, namespaces })
580
- continue
581
- }
582
-
583
- const { descriptor, errors } = parser.parse(text, { filename: where })
584
- if (errors.length) {
585
- found.unreadable.push(`${where}: ${errors[0].message}`)
586
- continue
587
- }
588
-
589
- // The script first: whether the template's `t` is the i18n one is decided
590
- // by what the script bound it to.
591
- let i18nT = false
592
- for (const block of [descriptor.script, descriptor.scriptSetup]) {
593
- if (!block) continue
594
- const line = (block.loc?.start.line ?? 1) - 1
595
- i18nT = collectFromScript(parser, block.content, where, { line, found, namespaces }) || i18nT
596
- }
597
-
598
- if (!descriptor.template) continue
599
- const compiled = parser.compileTemplate({
600
- source: descriptor.template.content,
601
- filename: where,
602
- id: where,
603
- })
604
- if (compiled.errors.length) {
605
- found.unreadable.push(`${where}: ${compiled.errors[0].message ?? compiled.errors[0]}`)
606
- continue
607
- }
608
- collectFromScript(parser, compiled.code, where, { line: null, detectT: false, i18nT, found, namespaces })
609
- }
610
-
611
- return found
612
- }
613
-
614
- /**
615
- * The languages a website offers: what its data package's `manifest.site.languages`
616
- * declares — the same list viewer-core's `offeredLanguages()` reads — or the
617
- * `--languages` option when the check is asked about a different set. A
618
- * website has one data package, so the first `@museumwnf/*-data` found is it.
619
- */
620
- export function offeredLanguages(dir, override) {
621
- if (override) return { languages: override, from: 'the --languages option' }
622
- const scope = join(dir, 'node_modules', '@museumwnf')
623
- if (!existsSync(scope)) return null
624
- for (const name of readdirSync(scope).sort()) {
625
- if (!name.endsWith('-data')) continue
626
- const manifest = join(scope, name, 'manifest.json')
627
- if (!existsSync(manifest)) continue
628
- let declared
629
- try {
630
- declared = JSON.parse(readFileSync(manifest, 'utf8'))?.site?.languages
631
- } catch {
632
- continue
633
- }
634
- if (!Array.isArray(declared)) continue
635
- const languages = declared
636
- .map((entry) => (typeof entry === 'string' ? entry : entry?.code))
637
- .filter(Boolean)
638
- return { languages, from: `@museumwnf/${name}` }
639
- }
640
- return null
641
- }
642
-
643
- export async function checkApp(dir, { languages: override } = {}) {
644
- const problems = []
645
- const notes = []
646
- const registry = loadRegistry(dir)
647
- const site = siteDeclaration(dir, registry, problems)
648
- if (!site) return { problems, notes }
649
-
650
- const bundleDir = join(dir, 'node_modules', '@museumwnf', 'viewer-i18n', 'dist', site.class)
651
- const bundleFile = join(bundleDir, `${BASE_LANGUAGE}.json`)
652
- if (!existsSync(bundleFile)) {
653
- problems.push(
654
- `The shared texts are not installed, so the entries this website uses cannot be ` +
655
- `checked. Run \`npm install\` first (looked for ${relative(dir, bundleFile)}).`
656
- )
657
- return { problems, notes }
658
- }
659
- const shared = readJson(bundleFile, problems, 'the shared texts')
660
- const localFile = join(dir, 'locales', `${BASE_LANGUAGE}.json`)
661
- const local = existsSync(localFile) ? readJson(localFile, problems, 'locales/en.json') : {}
662
- if (!shared || !local) return { problems, notes }
663
- const effective = { ...shared, ...local }
664
-
665
- // The languages the website offers are the languages a visitor can pick,
666
- // and each of them has to reach the shared texts as well as the records.
667
- // This is checked against what is installed — the bundle as delivered —
668
- // rather than against a promise in the registry.
669
- const offered = offeredLanguages(dir, override)
670
- if (offered) {
671
- const withoutLocal = []
672
- for (const language of offered.languages) {
673
- if (language === BASE_LANGUAGE) continue
674
- const file = join(bundleDir, `${language}.json`)
675
- const translated = existsSync(file) ? readJson(file, problems, `the shared texts in ${language}`) : null
676
- if (!translated) {
677
- problems.push(
678
- `This website offers **${language}** (declared by ${offered.from}), but the shared ` +
679
- `texts have no ${language} at all: a visitor choosing it would read every label, ` +
680
- `button and heading in English. The dictionary's \`${site.class}\` languages ` +
681
- `have to include \`${language}\`.`
682
- )
683
- continue
684
- }
685
- const missing = missingEntries(translated, shared)
686
- if (missing.length) {
687
- problems.push(
688
- `This website offers **${language}** (declared by ${offered.from}), but the shared ` +
689
- `texts in ${language} lack ${missing.length} of their ${Object.keys(shared).length} ` +
690
- `entries, which would show in English. The dictionary has to complete them.`
691
- )
692
- }
693
- if (!existsSync(join(dir, 'locales', `${language}.json`))) withoutLocal.push(language)
694
- }
695
- notes.push(`offers ${offered.languages.join(', ')} (from ${offered.from})`)
696
- if (withoutLocal.length) {
697
- notes.push(
698
- `own entries have no file for ${withoutLocal.join(', ')}: those show in English`
699
- )
700
- }
701
- } else {
702
- notes.push('no data package installed, so the offered languages were not checked')
703
- }
704
-
705
- const { references, dynamic, unreadable } = await scanSources(dir, undefined, {
706
- namespaces: new Set(site.allowed),
707
- })
708
- if (unreadable.includes('no parser')) {
709
- problems.push(
710
- 'The website\'s own Vue compiler could not be found, so its code cannot be read. ' +
711
- 'Run `npm install` first — this check reads the sources with the same parser the ' +
712
- 'website builds with, and takes it from this directory.'
713
- )
714
- return { problems, notes }
715
- }
716
- for (const where of unreadable) {
717
- problems.push(
718
- `The file ${where} could not be parsed, so the texts it asks for cannot be checked.`
719
- )
720
- }
721
- for (const where of dynamic) {
722
- problems.push(
723
- `At ${where}, a text is asked for with something other than a written-out name. ` +
724
- 'Every name must be spelled out where it is used, so that the checks below can ' +
725
- 'see it — map the value to a name explicitly instead.'
726
- )
727
- }
728
- for (const [key, where] of references) {
729
- if (!KEY_RE.test(key)) {
730
- problems.push(
731
- `At ${where}, \`${key}\` is not a valid entry name (three dot-separated parts, ` +
732
- 'each starting with a lowercase letter).'
733
- )
734
- } else if (!(key in effective)) {
735
- problems.push(
736
- `At ${where}, \`${key}\` does not exist. Add it to **locales/en.json** if it belongs ` +
737
- 'to this website, or to the shared dictionary if every website of this kind needs it.'
738
- )
739
- }
740
- }
741
-
742
- const unused = Object.keys(local).filter((key) => !references.has(key))
743
- if (unused.length) {
744
- notes.push(`unused entries in locales/en.json: ${unused.join(', ')}`)
745
- }
746
- notes.push(`${references.size} entries used; ${Object.keys(effective).length} available`)
747
- return { problems, notes }
748
- }
749
-
750
- // ── CLI ────────────────────────────────────────────────────────────────────
751
-
752
- const MODES = { '--dictionary': checkDictionary, '--site': checkSite, '--app': checkApp }
753
-
754
- /** The pull-request comment a translator reads instead of a build log. */
755
- export function report(problems) {
756
- return [
757
- '<!-- locale-validate -->',
758
- 'Thank you for the text update! The automatic check found a few things to fix before ' +
759
- 'it can be merged:',
760
- '',
761
- ...problems.map((problem) => `- ${problem}`),
762
- '',
763
- 'Once you push a fix, the check runs again automatically. No further action is needed ' +
764
- 'after it turns green.',
765
- ].join('\n')
766
- }
767
-
768
- export async function main(argv, { writeReport } = {}) {
769
- const mode = argv.find((arg) => arg in MODES)
770
- if (!mode) {
771
- console.error(
772
- 'Usage: viewer-i18n-check --dictionary|--site|--app [directory] [--languages ar,en,…]'
773
- )
774
- return 2
775
- }
776
- // `--languages` asks --app about a set other than the data package's — a
777
- // website about to offer a language, or a check with no package installed.
778
- const options = {}
779
- const at = argv.indexOf('--languages')
780
- if (at !== -1) {
781
- options.languages = (argv[at + 1] ?? '').split(',').map((l) => l.trim()).filter(Boolean)
782
- argv = argv.filter((_, i) => i !== at && i !== at + 1)
783
- }
784
- const dir = resolve(argv[argv.indexOf(mode) + 1] ?? '.')
785
- const { problems, notes } = await MODES[mode](dir, options)
786
-
787
- for (const note of notes) console.log(` ${note}`)
788
- if (!problems.length) {
789
- console.log('All texts are valid.')
790
- return 0
791
- }
792
- console.error(`\n${problems.length} problem(s) found:`)
793
- for (const problem of problems) console.error(`- ${problem}`)
794
- writeReport?.(report(problems))
795
- return 1
796
- }
797
-
798
- /**
799
- * Whether this file is the program, as opposed to a module someone imported.
800
- *
801
- * Both sides are resolved through their symlinks, which is the whole point: npm
802
- * installs a `bin` on Linux as a symlink in `node_modules/.bin`, so `argv[1]` is
803
- * that link and not this file. Comparing the paths as written made the test
804
- * false in exactly the place it mattered — `npx viewer-i18n-check` in CI ran,
805
- * printed nothing, exited 0, and reported a green blocking check that had
806
- * checked nothing. On Windows npm writes a shim that passes the real path
807
- * instead, which is why it worked everywhere it was tried by hand.
808
- */
809
- function invokedAsProgram() {
810
- if (!process.argv[1]) return false
811
- try {
812
- return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
813
- } catch {
814
- return false
815
- }
816
- }
817
-
818
- if (invokedAsProgram()) {
819
- const { writeFileSync } = await import('node:fs')
820
- process.exit(
821
- await main(process.argv.slice(2), {
822
- writeReport: (body) => writeFileSync('locale-problems.md', body),
823
- })
824
- )
825
- }
1
+ #!/usr/bin/env node
2
+ // The rules of the dictionary, in one place, enforced identically wherever
3
+ // texts live: here, in a website's locales/, and at the call sites that read
4
+ // them. Nothing beyond what the architecture defines is checked — a rule that
5
+ // is not in the epic is not in this file.
6
+ //
7
+ // Three modes, because there are three kinds of repository:
8
+ //
9
+ // --dictionary this repo: the namespace catalogues
10
+ // --site a website: its own locales/, on its own (no install needed,
11
+ // so a translator's pull request is validated in seconds)
12
+ // --app a website with its dependencies installed: every key the
13
+ // code asks for must resolve against bundle + local file
14
+ //
15
+ // Messages are written for the person who has to fix them, who is usually a
16
+ // translator and not a programmer.
17
+
18
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
19
+ import { createRequire } from 'node:module'
20
+ import { join, relative, resolve } from 'node:path'
21
+ import { fileURLToPath, pathToFileURL } from 'node:url'
22
+
23
+ // namespace.section.label — camelCase segments, dots only as separators.
24
+ export const KEY_RE = /^[a-z][a-zA-Z0-9]*\.[a-z][a-zA-Z0-9]*\.[a-z][a-zA-Z0-9]*$/
25
+ // A language file is named after its language: en.json, fr.json, pt-BR.json.
26
+ // What a language tag may look like is BCP 47's business and the platform
27
+ // already knows it, so nothing here re-describes the grammar: `Intl` parses the
28
+ // tag, canonicalises its spelling and rejects what is not one.
29
+ //
30
+ // The one rule that is ours is the length. An ISO 639 code is two or three
31
+ // letters; BCP 47 also allows five to eight, none of which are assigned, so
32
+ // without this `common.json` and `index.json` both read as valid languages.
33
+ /** The language a file is named after, canonically spelled, or null. */
34
+ export function languageOf(name) {
35
+ let canonical
36
+ try {
37
+ ;[canonical] = Intl.getCanonicalLocales(name)
38
+ } catch {
39
+ return null
40
+ }
41
+ if (!canonical) return null
42
+ return new Intl.Locale(canonical).language.length > 3 ? null : canonical
43
+ }
44
+ const BASE_LANGUAGE = 'en'
45
+
46
+ // There is deliberately no rule here about HTML in a text. viewer-core's
47
+ // Markdown pipeline escapes raw HTML rather than rendering it, so a tag a
48
+ // translator types appears on the page as the characters they typed and can do
49
+ // nothing else. Checking for it here bought no safety and cost accuracy: the
50
+ // rule was a regular expression, and it rejected `<https://example.org/>`,
51
+ // `<office@museumwnf.net>` and `` `<div>` `` — an autolink, an email autolink
52
+ // and a code span, all ordinary Markdown — telling the translator to "write
53
+ // formatting in Markdown instead", which is what they had done.
54
+ //
55
+ // Reading the Markdown grammar properly would have needed a parser, which
56
+ // means a dependency, which means npm in every translator's pull request: real
57
+ // cost, for a rule whose only remaining job was to prevent literal angle
58
+ // brackets on a page. The escape is the guarantee. Keep it that way — if this
59
+ // rule ever looks necessary again, check viewer-core's `renderBlock` first.
60
+
61
+ const SOURCE_EXTENSIONS = ['.vue', '.js', '.mjs']
62
+
63
+ // How a text is asked for is a question about code, so the code is parsed.
64
+ // `@vue/compiler-sfc` is the parser the website already builds with — vite
65
+ // hands it every one of these files — so this reads them the way the bundler
66
+ // does, and needs nothing installed that was not there already. It is resolved
67
+ // from the website being checked rather than declared here, which is also what
68
+ // keeps `--site` free of it: only this mode ever loads a parser.
69
+ //
70
+ // It replaced two regular expressions that were wrong in both directions.
71
+ // `t(item)` written inside a prose comment was read as a text being asked for,
72
+ // and a component's own `const t = (item) => …` could not be told apart from
73
+ // the one that looks a text up — both cost real edits during the rollout.
74
+ async function loadCompiler(dir) {
75
+ // The website's own copy first, so the code is read by the version that
76
+ // builds it. Ours second, which is what lets these rules be tested here.
77
+ for (const from of [join(dir, 'package.json'), fileURLToPath(import.meta.url)]) {
78
+ try {
79
+ return await import(pathToFileURL(createRequire(from).resolve('@vue/compiler-sfc')).href)
80
+ } catch {
81
+ continue
82
+ }
83
+ }
84
+ return null
85
+ }
86
+
87
+ /** The name a call is made under: `t`, `$t`, `_ctx.$t` — all read as the last part. */
88
+ function calleeName(callee) {
89
+ if (callee?.type === 'Identifier') return callee.name
90
+ if (callee?.type === 'MemberExpression' && callee.property?.type === 'Identifier') {
91
+ return callee.property.name
92
+ }
93
+ return null
94
+ }
95
+
96
+ const FUNCTIONS = new Set([
97
+ 'FunctionDeclaration',
98
+ 'FunctionExpression',
99
+ 'ArrowFunctionExpression',
100
+ 'ObjectMethod',
101
+ 'ClassMethod',
102
+ ])
103
+
104
+ /** Whether a `t` bound by this block is the one that looks a text up — null if it binds none. */
105
+ function tBoundIn(block) {
106
+ let verdict = null
107
+ for (const statement of block.body ?? []) {
108
+ if (statement.type === 'FunctionDeclaration' && statement.id?.name === 't') verdict = false
109
+ if (statement.type !== 'VariableDeclaration') continue
110
+ for (const declarator of statement.declarations) {
111
+ const id = declarator.id
112
+ const binds =
113
+ (id?.type === 'Identifier' && id.name === 't') ||
114
+ (id?.type === 'ObjectPattern' &&
115
+ id.properties.some((p) => p.value?.name === 't' || p.key?.name === 't'))
116
+ if (!binds) continue
117
+ verdict =
118
+ declarator.init?.type === 'CallExpression' &&
119
+ calleeName(declarator.init.callee) === 'useI18n'
120
+ }
121
+ }
122
+ return verdict
123
+ }
124
+
125
+ /**
126
+ * Every node, carrying what `t` means where it stands.
127
+ *
128
+ * Which is the whole reason this is parsed rather than matched. One real file,
129
+ * `water-in-islam/src/composables/useCollection.js`, uses the name both ways:
130
+ * `export function facetLabels(t)` receives the text lookup as a parameter,
131
+ * while `const t = tr('items', item.id, defaultLang)` further down is a
132
+ * translated record. A rule that reads the file as text has to guess, and
133
+ * either invents nine references or loses them.
134
+ */
135
+ function eachNode(node, scope, visit) {
136
+ if (Array.isArray(node)) {
137
+ for (const item of node) eachNode(item, scope, visit)
138
+ return
139
+ }
140
+ if (!node || typeof node !== 'object' || typeof node.type !== 'string') return
141
+
142
+ // A `t` handed to a function is the lookup being passed down; a block that
143
+ // declares its own says for itself which it is.
144
+ if (FUNCTIONS.has(node.type) && node.params?.some((p) => p.type === 'Identifier' && p.name === 't')) {
145
+ scope = true
146
+ }
147
+ if (node.type === 'Program' || node.type === 'BlockStatement') {
148
+ const own = tBoundIn(node)
149
+ if (own !== null) scope = own
150
+ }
151
+
152
+ visit(node, scope)
153
+ for (const [key, value] of Object.entries(node)) {
154
+ if (key === 'loc' || key.endsWith('Comments')) continue
155
+ eachNode(value, scope, visit)
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Collect the names asked for in one piece of JavaScript.
161
+ *
162
+ * `line` offsets a block back onto the file it came from; a template is
163
+ * compiled before it is read, so its lines are the compiled ones and it
164
+ * reports the file instead.
165
+ */
166
+ function collectFromScript(compiler, code, where, { line = 0, detectT = true, i18nT = false, found, namespaces = null }) {
167
+ let ast
168
+ try {
169
+ ast = compiler.babelParse(code, { sourceType: 'module', errorRecovery: true })
170
+ } catch (error) {
171
+ found.unreadable.push(`${where}: ${error.message}`)
172
+ return false
173
+ }
174
+ const at = (node) => (line === null ? where : `${where}:${(node.loc?.start.line ?? 1) + line}`)
175
+ const record = (key, node) => {
176
+ if (!found.references.has(key)) found.references.set(key, at(node))
177
+ }
178
+
179
+ // A script says for itself what `t` is at its top level; a compiled template
180
+ // has no declarations of its own and inherits the script's answer.
181
+ eachNode(ast.program, detectT ? false : i18nT, (node, tIsLookup) => {
182
+ if (
183
+ node.type === 'ObjectProperty' &&
184
+ (node.key?.name === 'keypath' || node.key?.value === 'keypath')
185
+ ) {
186
+ if (node.value?.type === 'StringLiteral') record(node.value.value, node)
187
+ else found.dynamic.push(at(node))
188
+ return
189
+ }
190
+ // A name written in a spec — `title: 'carpets.identity.title'` in a
191
+ // dataset.config.js, a label in a catalogue or sheet spec the composed
192
+ // views render — is asked for by the platform, not by a call the
193
+ // website makes. It reads as one when its first part is a section this
194
+ // website receives; any other three-part string is left alone.
195
+ if (
196
+ namespaces &&
197
+ node.type === 'StringLiteral' &&
198
+ KEY_RE.test(node.value) &&
199
+ namespaces.has(node.value.split('.')[0])
200
+ ) {
201
+ record(node.value, node)
202
+ return
203
+ }
204
+ if (node.type !== 'CallExpression') return
205
+ const name = calleeName(node.callee)
206
+ if (name !== 't' && name !== '$t') return
207
+ // A bare `t` counts only where `t` is the lookup. `$t` is the global one,
208
+ // so it counts wherever it appears.
209
+ if (name === 't' && !tIsLookup) return
210
+ const [first] = node.arguments
211
+ if (first?.type === 'StringLiteral') record(first.value, node)
212
+ else found.dynamic.push(at(node))
213
+ })
214
+
215
+ return tBoundIn(ast.program) === true
216
+ }
217
+
218
+ // ── helpers ────────────────────────────────────────────────────────────────
219
+
220
+ function readJson(file, problems, label) {
221
+ let text
222
+ try {
223
+ text = readFileSync(file, 'utf8')
224
+ } catch {
225
+ problems.push(`The file **${label}** could not be read.`)
226
+ return null
227
+ }
228
+ try {
229
+ const value = JSON.parse(text)
230
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
231
+ problems.push(`The file **${label}** must contain a list of entries between { and }.`)
232
+ return null
233
+ }
234
+ return value
235
+ } catch (error) {
236
+ problems.push(
237
+ `The file **${label}** is not valid JSON, so it cannot be used. This is usually ` +
238
+ `a missing or extra comma, quote or brace. Technical detail: ${error.message}`
239
+ )
240
+ return null
241
+ }
242
+ }
243
+
244
+ function languageFiles(dir) {
245
+ return readdirSync(dir)
246
+ .filter((name) => name.endsWith('.json'))
247
+ .sort()
248
+ }
249
+
250
+ /**
251
+ * Everything that is true of one language file, whatever repository it is in.
252
+ * `namespaces` is the set of prefixes this file is allowed to use.
253
+ */
254
+ function checkEntries(label, data, namespaces, problems) {
255
+ const seen = new Map()
256
+ for (const [key, value] of Object.entries(data)) {
257
+ if (!KEY_RE.test(key)) {
258
+ problems.push(
259
+ `In **${label}**, \`${key}\` is not a valid entry name. A name is made of three ` +
260
+ `parts separated by dots — section.group.name — each starting with a lowercase ` +
261
+ `letter, for example \`gallery.sheet.inventoryNumber\`.`
262
+ )
263
+ continue
264
+ }
265
+ const namespace = key.slice(0, key.indexOf('.'))
266
+ if (!namespaces.includes(namespace)) {
267
+ problems.push(
268
+ `In **${label}**, \`${key}\` starts with \`${namespace}\`, which this file may not ` +
269
+ `use. Allowed here: ${namespaces.map((n) => `\`${n}\``).join(', ')}.`
270
+ )
271
+ continue
272
+ }
273
+ const lower = key.toLowerCase()
274
+ if (seen.has(lower) && seen.get(lower) !== key) {
275
+ problems.push(
276
+ `In **${label}**, \`${key}\` and \`${seen.get(lower)}\` differ only in capitalisation. ` +
277
+ `Entry names must differ by more than their capitals.`
278
+ )
279
+ }
280
+ seen.set(lower, key)
281
+
282
+ if (typeof value !== 'string') {
283
+ problems.push(`In **${label}**, the text for \`${key}\` must be written between quotes.`)
284
+ continue
285
+ }
286
+ if (value.trim() === '') {
287
+ problems.push(
288
+ `In **${label}**, the text for \`${key}\` is empty. Remove the whole line instead — ` +
289
+ `an entry with no text hides the English one rather than falling back to it.`
290
+ )
291
+ }
292
+ if (/[{}]/.test(value)) {
293
+ problems.push(
294
+ `In **${label}**, the text for \`${key}\` contains a curly brace. Texts are complete ` +
295
+ `on their own: nothing is inserted into them, so { and } have no meaning here.`
296
+ )
297
+ }
298
+ }
299
+ }
300
+
301
+ /** A language file may not invent an entry the English file does not have. */
302
+ function checkAgainstBase(label, data, base, baseLabel, problems) {
303
+ const extra = Object.keys(data).filter((key) => !(key in base))
304
+ if (extra.length) {
305
+ problems.push(
306
+ `The file **${label}** contains entries that do not exist in **${baseLabel}**: ` +
307
+ `${extra.map((k) => `\`${k}\``).join(', ')}. Remove them, or check the spelling — ` +
308
+ `English is where an entry is created.`
309
+ )
310
+ }
311
+ }
312
+
313
+ function coverage(name, data, base) {
314
+ const translated = Object.keys(base).filter((key) => key in data).length
315
+ const total = Object.keys(base).length
316
+ const percent = total === 0 ? 100 : Math.round((translated / total) * 100)
317
+ return `${name}: ${translated}/${total} (${percent}%)`
318
+ }
319
+
320
+ // ── the registry ───────────────────────────────────────────────────────────
321
+
322
+ /**
323
+ * The dictionary's own namespaces.json when checking the dictionary; the
324
+ * installed package's copy otherwise. This file ships with the package, so a
325
+ * website never has to carry a second copy of the registry.
326
+ */
327
+ export function loadRegistry(dir) {
328
+ const local = join(dir, 'namespaces.json')
329
+ const file = existsSync(local)
330
+ ? local
331
+ : fileURLToPath(new URL('../namespaces.json', import.meta.url))
332
+ return JSON.parse(readFileSync(file, 'utf8'))
333
+ }
334
+
335
+ /** How a website declares what it is. Read from its package.json. */
336
+ export function siteDeclaration(dir, registry, problems) {
337
+ const pkgFile = join(dir, 'package.json')
338
+ const pkg = existsSync(pkgFile) ? JSON.parse(readFileSync(pkgFile, 'utf8')) : {}
339
+ const declared = pkg.viewerI18n
340
+ if (!declared?.class || !declared?.namespace) {
341
+ problems.push(
342
+ 'This website does not say which texts it receives. Add to **package.json**:\n' +
343
+ ' "viewerI18n": { "class": "gallery", "namespace": "carpets" }\n' +
344
+ ` class is one of: ${Object.keys(registry.bundles).join(', ')}`
345
+ )
346
+ return null
347
+ }
348
+ const received = registry.bundles[declared.class]
349
+ if (!received) {
350
+ problems.push(
351
+ `In **package.json**, "viewerI18n.class" is \`${declared.class}\`, which is not a ` +
352
+ `kind of website. Use one of: ${Object.keys(registry.bundles).join(', ')}.`
353
+ )
354
+ return null
355
+ }
356
+ if (!/^[a-z][a-zA-Z0-9]*$/.test(declared.namespace)) {
357
+ problems.push(
358
+ `In **package.json**, "viewerI18n.namespace" is \`${declared.namespace}\`. It must be ` +
359
+ 'a single word starting with a lowercase letter, for example `carpets`.'
360
+ )
361
+ return null
362
+ }
363
+ if (registry.namespaces.includes(declared.namespace)) {
364
+ problems.push(
365
+ `In **package.json**, "viewerI18n.namespace" is \`${declared.namespace}\`, which is a ` +
366
+ 'shared section name. A website needs a name of its own.'
367
+ )
368
+ return null
369
+ }
370
+ return { ...declared, received, allowed: [...received, declared.namespace] }
371
+ }
372
+
373
+ // ── modes ──────────────────────────────────────────────────────────────────
374
+
375
+ /**
376
+ * The languages a bundle's websites offer, as declared in `namespaces.json`
377
+ * under `languages`: every section the bundle contains must exist, complete,
378
+ * in each of them. Not declared means not required, which is how the rule
379
+ * stays optional for a fixture and mandatory for the real dictionary.
380
+ */
381
+ function requiredLanguages(registry, bundle) {
382
+ const declared = registry.languages?.[bundle]
383
+ return Array.isArray(declared) ? declared : []
384
+ }
385
+
386
+ /** The entries of `base` that `data` lacks — what a translator has to add. */
387
+ function missingEntries(data, base) {
388
+ return Object.keys(base).filter((key) => !(key in data))
389
+ }
390
+
391
+ export function checkDictionary(dir) {
392
+ const problems = []
393
+ const notes = []
394
+ const registry = loadRegistry(dir)
395
+ const catalogues = {}
396
+
397
+ for (const namespace of registry.namespaces) {
398
+ const nsDir = join(dir, namespace)
399
+ if (!existsSync(nsDir) || !statSync(nsDir).isDirectory()) {
400
+ problems.push(`The folder **${namespace}/** is declared in namespaces.json but missing.`)
401
+ continue
402
+ }
403
+ const files = languageFiles(nsDir)
404
+ const parsed = {}
405
+ for (const file of files) {
406
+ const language = file.slice(0, -'.json'.length)
407
+ const label = `${namespace}/${file}`
408
+ const canonical = languageOf(language)
409
+ if (canonical === null) {
410
+ problems.push(
411
+ `**${label}** is not named after a language. Use the two-letter code, ` +
412
+ 'for example `fr.json`.'
413
+ )
414
+ continue
415
+ }
416
+ if (canonical !== language) {
417
+ problems.push(
418
+ `**${label}** should be named \`${canonical}.json\`. A language is spelled one ` +
419
+ 'way, so that two files cannot both claim it with only the filesystem ' +
420
+ 'deciding which one a reader gets.'
421
+ )
422
+ continue
423
+ }
424
+ const data = readJson(join(nsDir, file), problems, label)
425
+ if (!data) continue
426
+ checkEntries(label, data, [namespace], problems)
427
+ parsed[language] = data
428
+ }
429
+
430
+ const base = parsed[BASE_LANGUAGE]
431
+ if (!base) {
432
+ problems.push(
433
+ `**${namespace}/${BASE_LANGUAGE}.json** is missing. English is where every entry is ` +
434
+ 'created; the other languages are compared against it.'
435
+ )
436
+ continue
437
+ }
438
+ for (const [language, data] of Object.entries(parsed)) {
439
+ if (language === BASE_LANGUAGE) continue
440
+ checkAgainstBase(`${namespace}/${language}.json`, data, base, `${namespace}/en.json`, problems)
441
+ notes.push(coverage(`${namespace}/${language}`, data, base))
442
+ }
443
+ notes.push(`${namespace}: ${Object.keys(base).length} entries in English`)
444
+ catalogues[namespace] = parsed
445
+ }
446
+
447
+ for (const bundle of Object.keys(registry.bundles)) {
448
+ const unknown = registry.bundles[bundle].filter((n) => !registry.namespaces.includes(n))
449
+ if (unknown.length) {
450
+ problems.push(
451
+ `In **namespaces.json**, the \`${bundle}\` bundle lists unknown sections: ` +
452
+ unknown.join(', ')
453
+ )
454
+ }
455
+
456
+ // A website offers the languages its data package declares, and a visitor
457
+ // who picks one reads the whole page in it — the records from the package,
458
+ // the chrome from here. A section that has no file for that language, or
459
+ // an incomplete one, shows that visitor English labels between records in
460
+ // their language. So a bundle names the languages its websites offer, and
461
+ // every section it contains has to be complete in each of them. The
462
+ // shared sections are in every bundle, so they need the union.
463
+ for (const language of requiredLanguages(registry, bundle)) {
464
+ for (const namespace of registry.bundles[bundle]) {
465
+ const parsed = catalogues[namespace]
466
+ if (!parsed?.[BASE_LANGUAGE]) continue
467
+ const data = parsed[language]
468
+ if (!data) {
469
+ problems.push(
470
+ `**${namespace}/${language}.json** is missing. The \`${bundle}\` websites offer ` +
471
+ `\`${language}\`, so every section they receive has to exist in it — copy ` +
472
+ `${namespace}/en.json and translate it.`
473
+ )
474
+ continue
475
+ }
476
+ const missing = missingEntries(data, parsed[BASE_LANGUAGE])
477
+ if (missing.length) {
478
+ const shown = missing.slice(0, 8).map((k) => `\`${k}\``).join(', ')
479
+ problems.push(
480
+ `**${namespace}/${language}.json** is missing ${missing.length} entr` +
481
+ `${missing.length === 1 ? 'y' : 'ies'} that ${namespace}/en.json has: ${shown}` +
482
+ `${missing.length > 8 ? ', …' : ''}. The \`${bundle}\` websites offer ` +
483
+ `\`${language}\`, so this section has to be complete in it.`
484
+ )
485
+ }
486
+ }
487
+ }
488
+ }
489
+
490
+ return { problems, notes }
491
+ }
492
+
493
+ export function checkSite(dir) {
494
+ const problems = []
495
+ const notes = []
496
+ const registry = loadRegistry(dir)
497
+ const site = siteDeclaration(dir, registry, problems)
498
+ const localesDir = join(dir, 'locales')
499
+
500
+ if (!existsSync(localesDir)) {
501
+ problems.push('This website has no **locales/** folder, so it has no texts of its own.')
502
+ return { problems, notes }
503
+ }
504
+ if (!site) return { problems, notes }
505
+
506
+ const parsed = {}
507
+ for (const file of languageFiles(localesDir)) {
508
+ const language = file.slice(0, -'.json'.length)
509
+ const label = `locales/${file}`
510
+ const canonical = languageOf(language)
511
+ if (canonical === null) {
512
+ problems.push(
513
+ `**${label}** is not named after a language. Use the two-letter code, ` +
514
+ 'for example `fr.json`.'
515
+ )
516
+ continue
517
+ }
518
+ if (canonical !== language) {
519
+ problems.push(
520
+ `**${label}** should be named \`${canonical}.json\`. A language is spelled one ` +
521
+ 'way, so that two files cannot both claim it with only the filesystem ' +
522
+ 'deciding which one a reader gets.'
523
+ )
524
+ continue
525
+ }
526
+ const data = readJson(join(localesDir, file), problems, label)
527
+ if (!data) continue
528
+ checkEntries(label, data, site.allowed, problems)
529
+ parsed[language] = data
530
+ }
531
+
532
+ const base = parsed[BASE_LANGUAGE]
533
+ if (!base) {
534
+ problems.push(
535
+ `**locales/${BASE_LANGUAGE}.json** is missing. English is where every entry of this ` +
536
+ 'website is created; the other languages are compared against it.'
537
+ )
538
+ return { problems, notes }
539
+ }
540
+ for (const [language, data] of Object.entries(parsed)) {
541
+ if (language === BASE_LANGUAGE) continue
542
+ checkAgainstBase(`locales/${language}.json`, data, base, 'locales/en.json', problems)
543
+ notes.push(coverage(`locales/${language}`, data, base))
544
+ }
545
+ notes.push(
546
+ `receives ${site.received.join(' + ')}; own entries: ${Object.keys(base).length} in English`
547
+ )
548
+ return { problems, notes }
549
+ }
550
+
551
+ function sourceFiles(dir) {
552
+ const found = []
553
+ const walk = (current) => {
554
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
555
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue
556
+ const full = join(current, entry.name)
557
+ if (entry.isDirectory()) walk(full)
558
+ else if (SOURCE_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) found.push(full)
559
+ }
560
+ }
561
+ if (existsSync(dir)) walk(dir)
562
+ return found.sort()
563
+ }
564
+
565
+ /** Every key the code asks for, and every place it asks with something else. */
566
+ export async function scanSources(dir, compiler, { namespaces = null } = {}) {
567
+ const found = { references: new Map(), dynamic: [], unreadable: [] }
568
+ const parser = compiler ?? (await loadCompiler(dir))
569
+ if (!parser) {
570
+ found.unreadable.push('no parser')
571
+ return found
572
+ }
573
+
574
+ for (const file of sourceFiles(join(dir, 'src'))) {
575
+ const text = readFileSync(file, 'utf8')
576
+ const where = relative(dir, file).replaceAll('\\', '/')
577
+
578
+ if (!file.endsWith('.vue')) {
579
+ collectFromScript(parser, text, where, { found, namespaces })
580
+ continue
581
+ }
582
+
583
+ const { descriptor, errors } = parser.parse(text, { filename: where })
584
+ if (errors.length) {
585
+ found.unreadable.push(`${where}: ${errors[0].message}`)
586
+ continue
587
+ }
588
+
589
+ // The script first: whether the template's `t` is the i18n one is decided
590
+ // by what the script bound it to.
591
+ let i18nT = false
592
+ for (const block of [descriptor.script, descriptor.scriptSetup]) {
593
+ if (!block) continue
594
+ const line = (block.loc?.start.line ?? 1) - 1
595
+ i18nT = collectFromScript(parser, block.content, where, { line, found, namespaces }) || i18nT
596
+ }
597
+
598
+ if (!descriptor.template) continue
599
+ const compiled = parser.compileTemplate({
600
+ source: descriptor.template.content,
601
+ filename: where,
602
+ id: where,
603
+ })
604
+ if (compiled.errors.length) {
605
+ found.unreadable.push(`${where}: ${compiled.errors[0].message ?? compiled.errors[0]}`)
606
+ continue
607
+ }
608
+ collectFromScript(parser, compiled.code, where, { line: null, detectT: false, i18nT, found, namespaces })
609
+ }
610
+
611
+ return found
612
+ }
613
+
614
+ /**
615
+ * The languages a website offers: what its data package's `manifest.site.languages`
616
+ * declares — the same list viewer-core's `offeredLanguages()` reads — or the
617
+ * `--languages` option when the check is asked about a different set. A
618
+ * website has one data package, so the first `@museumwnf/*-data` found is it.
619
+ */
620
+ export function offeredLanguages(dir, override) {
621
+ if (override) return { languages: override, from: 'the --languages option' }
622
+ const scope = join(dir, 'node_modules', '@museumwnf')
623
+ if (!existsSync(scope)) return null
624
+ for (const name of readdirSync(scope).sort()) {
625
+ if (!name.endsWith('-data')) continue
626
+ const manifest = join(scope, name, 'manifest.json')
627
+ if (!existsSync(manifest)) continue
628
+ let declared
629
+ try {
630
+ declared = JSON.parse(readFileSync(manifest, 'utf8'))?.site?.languages
631
+ } catch {
632
+ continue
633
+ }
634
+ if (!Array.isArray(declared)) continue
635
+ const languages = declared
636
+ .map((entry) => (typeof entry === 'string' ? entry : entry?.code))
637
+ .filter(Boolean)
638
+ return { languages, from: `@museumwnf/${name}` }
639
+ }
640
+ return null
641
+ }
642
+
643
+ export async function checkApp(dir, { languages: override } = {}) {
644
+ const problems = []
645
+ const notes = []
646
+ const registry = loadRegistry(dir)
647
+ const site = siteDeclaration(dir, registry, problems)
648
+ if (!site) return { problems, notes }
649
+
650
+ const bundleDir = join(dir, 'node_modules', '@museumwnf', 'viewer-i18n', 'dist', site.class)
651
+ const bundleFile = join(bundleDir, `${BASE_LANGUAGE}.json`)
652
+ if (!existsSync(bundleFile)) {
653
+ problems.push(
654
+ `The shared texts are not installed, so the entries this website uses cannot be ` +
655
+ `checked. Run \`npm install\` first (looked for ${relative(dir, bundleFile)}).`
656
+ )
657
+ return { problems, notes }
658
+ }
659
+ const shared = readJson(bundleFile, problems, 'the shared texts')
660
+ const localFile = join(dir, 'locales', `${BASE_LANGUAGE}.json`)
661
+ const local = existsSync(localFile) ? readJson(localFile, problems, 'locales/en.json') : {}
662
+ if (!shared || !local) return { problems, notes }
663
+ const effective = { ...shared, ...local }
664
+
665
+ // The languages the website offers are the languages a visitor can pick,
666
+ // and each of them has to reach the shared texts as well as the records.
667
+ // This is checked against what is installed — the bundle as delivered —
668
+ // rather than against a promise in the registry.
669
+ const offered = offeredLanguages(dir, override)
670
+ if (offered) {
671
+ const withoutLocal = []
672
+ for (const language of offered.languages) {
673
+ if (language === BASE_LANGUAGE) continue
674
+ const file = join(bundleDir, `${language}.json`)
675
+ const translated = existsSync(file) ? readJson(file, problems, `the shared texts in ${language}`) : null
676
+ if (!translated) {
677
+ problems.push(
678
+ `This website offers **${language}** (declared by ${offered.from}), but the shared ` +
679
+ `texts have no ${language} at all: a visitor choosing it would read every label, ` +
680
+ `button and heading in English. The dictionary's \`${site.class}\` languages ` +
681
+ `have to include \`${language}\`.`
682
+ )
683
+ continue
684
+ }
685
+ const missing = missingEntries(translated, shared)
686
+ if (missing.length) {
687
+ problems.push(
688
+ `This website offers **${language}** (declared by ${offered.from}), but the shared ` +
689
+ `texts in ${language} lack ${missing.length} of their ${Object.keys(shared).length} ` +
690
+ `entries, which would show in English. The dictionary has to complete them.`
691
+ )
692
+ }
693
+ if (!existsSync(join(dir, 'locales', `${language}.json`))) withoutLocal.push(language)
694
+ }
695
+ notes.push(`offers ${offered.languages.join(', ')} (from ${offered.from})`)
696
+ if (withoutLocal.length) {
697
+ notes.push(
698
+ `own entries have no file for ${withoutLocal.join(', ')}: those show in English`
699
+ )
700
+ }
701
+ } else {
702
+ notes.push('no data package installed, so the offered languages were not checked')
703
+ }
704
+
705
+ const { references, dynamic, unreadable } = await scanSources(dir, undefined, {
706
+ namespaces: new Set(site.allowed),
707
+ })
708
+ if (unreadable.includes('no parser')) {
709
+ problems.push(
710
+ 'The website\'s own Vue compiler could not be found, so its code cannot be read. ' +
711
+ 'Run `npm install` first — this check reads the sources with the same parser the ' +
712
+ 'website builds with, and takes it from this directory.'
713
+ )
714
+ return { problems, notes }
715
+ }
716
+ for (const where of unreadable) {
717
+ problems.push(
718
+ `The file ${where} could not be parsed, so the texts it asks for cannot be checked.`
719
+ )
720
+ }
721
+ for (const where of dynamic) {
722
+ problems.push(
723
+ `At ${where}, a text is asked for with something other than a written-out name. ` +
724
+ 'Every name must be spelled out where it is used, so that the checks below can ' +
725
+ 'see it — map the value to a name explicitly instead.'
726
+ )
727
+ }
728
+ for (const [key, where] of references) {
729
+ if (!KEY_RE.test(key)) {
730
+ problems.push(
731
+ `At ${where}, \`${key}\` is not a valid entry name (three dot-separated parts, ` +
732
+ 'each starting with a lowercase letter).'
733
+ )
734
+ } else if (!(key in effective)) {
735
+ problems.push(
736
+ `At ${where}, \`${key}\` does not exist. Add it to **locales/en.json** if it belongs ` +
737
+ 'to this website, or to the shared dictionary if every website of this kind needs it.'
738
+ )
739
+ }
740
+ }
741
+
742
+ const unused = Object.keys(local).filter((key) => !references.has(key))
743
+ if (unused.length) {
744
+ notes.push(`unused entries in locales/en.json: ${unused.join(', ')}`)
745
+ }
746
+ notes.push(`${references.size} entries used; ${Object.keys(effective).length} available`)
747
+ return { problems, notes }
748
+ }
749
+
750
+ // ── CLI ────────────────────────────────────────────────────────────────────
751
+
752
+ const MODES = { '--dictionary': checkDictionary, '--site': checkSite, '--app': checkApp }
753
+
754
+ /** The pull-request comment a translator reads instead of a build log. */
755
+ export function report(problems) {
756
+ return [
757
+ '<!-- locale-validate -->',
758
+ 'Thank you for the text update! The automatic check found a few things to fix before ' +
759
+ 'it can be merged:',
760
+ '',
761
+ ...problems.map((problem) => `- ${problem}`),
762
+ '',
763
+ 'Once you push a fix, the check runs again automatically. No further action is needed ' +
764
+ 'after it turns green.',
765
+ ].join('\n')
766
+ }
767
+
768
+ export async function main(argv, { writeReport } = {}) {
769
+ const mode = argv.find((arg) => arg in MODES)
770
+ if (!mode) {
771
+ console.error(
772
+ 'Usage: viewer-i18n-check --dictionary|--site|--app [directory] [--languages ar,en,…]'
773
+ )
774
+ return 2
775
+ }
776
+ // `--languages` asks --app about a set other than the data package's — a
777
+ // website about to offer a language, or a check with no package installed.
778
+ const options = {}
779
+ const at = argv.indexOf('--languages')
780
+ if (at !== -1) {
781
+ options.languages = (argv[at + 1] ?? '').split(',').map((l) => l.trim()).filter(Boolean)
782
+ argv = argv.filter((_, i) => i !== at && i !== at + 1)
783
+ }
784
+ const dir = resolve(argv[argv.indexOf(mode) + 1] ?? '.')
785
+ const { problems, notes } = await MODES[mode](dir, options)
786
+
787
+ for (const note of notes) console.log(` ${note}`)
788
+ if (!problems.length) {
789
+ console.log('All texts are valid.')
790
+ return 0
791
+ }
792
+ console.error(`\n${problems.length} problem(s) found:`)
793
+ for (const problem of problems) console.error(`- ${problem}`)
794
+ writeReport?.(report(problems))
795
+ return 1
796
+ }
797
+
798
+ /**
799
+ * Whether this file is the program, as opposed to a module someone imported.
800
+ *
801
+ * Both sides are resolved through their symlinks, which is the whole point: npm
802
+ * installs a `bin` on Linux as a symlink in `node_modules/.bin`, so `argv[1]` is
803
+ * that link and not this file. Comparing the paths as written made the test
804
+ * false in exactly the place it mattered — `npx viewer-i18n-check` in CI ran,
805
+ * printed nothing, exited 0, and reported a green blocking check that had
806
+ * checked nothing. On Windows npm writes a shim that passes the real path
807
+ * instead, which is why it worked everywhere it was tried by hand.
808
+ */
809
+ function invokedAsProgram() {
810
+ if (!process.argv[1]) return false
811
+ try {
812
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
813
+ } catch {
814
+ return false
815
+ }
816
+ }
817
+
818
+ if (invokedAsProgram()) {
819
+ const { writeFileSync } = await import('node:fs')
820
+ process.exit(
821
+ await main(process.argv.slice(2), {
822
+ writeReport: (body) => writeFileSync('locale-problems.md', body),
823
+ })
824
+ )
825
+ }