@museumwnf/viewer-i18n 3.1.0 → 3.1.1
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.
- package/LICENSE.md +44 -44
- package/README.md +173 -173
- package/namespaces.json +13 -13
- package/package.json +38 -38
- 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
|
+
}
|