hanzio 3.0.0 → 3.1.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 (112) hide show
  1. package/README.md +114 -3
  2. package/dist/api-wrapper/connection-auth.d.ts +61 -0
  3. package/dist/cli/fields.d.ts +30 -0
  4. package/dist/cli/index.d.ts +122 -0
  5. package/dist/format/index.d.ts +91 -0
  6. package/dist/format/phone.d.ts +19 -0
  7. package/dist/http/index.d.ts +14 -0
  8. package/dist/i18n/catalog.d.ts +47 -0
  9. package/dist/i18n/check.d.ts +45 -0
  10. package/dist/i18n/cli.d.ts +2 -0
  11. package/dist/i18n/content-rules.d.ts +55 -0
  12. package/dist/i18n/i18next.d.ts +39 -0
  13. package/dist/i18n/index.d.ts +16 -1
  14. package/dist/i18n/locales.d.ts +62 -0
  15. package/dist/i18n/vite.d.ts +16 -0
  16. package/dist/i18n/vue.d.ts +93 -0
  17. package/dist/{index-kj1phgrx.js → index-29mmh343.js} +24 -1
  18. package/dist/{index-zbj9s3s3.js → index-2kz392vw.js} +1 -1
  19. package/dist/index-5n2e326q.js +409 -0
  20. package/dist/{index-hy7kbb99.js → index-5tzqfmnj.js} +1 -1
  21. package/dist/{index-0zvtcbzy.js → index-6f2w90pp.js} +69 -1
  22. package/dist/index-6ragggbs.js +473 -0
  23. package/dist/index-mq2qgc20.js +116 -0
  24. package/dist/index-najbyzpr.js +99 -0
  25. package/dist/index-ww09bm49.js +250 -0
  26. package/dist/iso/countries.d.ts +262 -0
  27. package/dist/iso/currencies.d.ts +165 -0
  28. package/dist/iso/index.d.ts +4 -0
  29. package/dist/iso/languages.d.ts +195 -0
  30. package/dist/iso/phone-language.d.ts +5 -0
  31. package/dist/promise/index.d.ts +31 -0
  32. package/dist/redis/client.d.ts +49 -0
  33. package/dist/redis/concurrency-limit.d.ts +63 -0
  34. package/dist/redis/index.d.ts +3 -0
  35. package/dist/redis/lua-script.d.ts +13 -0
  36. package/dist/secrets/infisical-identity-file.d.ts +12 -0
  37. package/dist/src/api-wrapper/connection-auth.js +82 -0
  38. package/dist/src/api-wrapper/index.js +1 -1
  39. package/dist/src/cli/index.js +10 -0
  40. package/dist/src/format/index.js +79 -2
  41. package/dist/src/format/phone.js +21 -0
  42. package/dist/src/http/index.js +69 -0
  43. package/dist/src/i18n/check.js +15 -0
  44. package/dist/src/i18n/cli.js +71 -0
  45. package/dist/src/i18n/i18next.js +48 -0
  46. package/dist/src/i18n/index.js +16 -34
  47. package/dist/src/i18n/vite.js +25 -0
  48. package/dist/src/i18n/vue.js +51 -0
  49. package/dist/src/index.js +14 -4
  50. package/dist/src/iso/index.js +655 -0
  51. package/dist/src/redis/index.js +577 -0
  52. package/dist/src/secrets/index.js +78 -35
  53. package/dist/src/sitemap/index.js +1 -1
  54. package/dist/src/storage/index.js +8 -117
  55. package/dist/src/testing/index.js +348 -0
  56. package/dist/src/zod/index.js +20 -253
  57. package/dist/string/index.d.ts +21 -0
  58. package/dist/testing/connection-auth-contract.d.ts +43 -0
  59. package/dist/testing/helpers.d.ts +26 -0
  60. package/dist/testing/index.d.ts +5 -0
  61. package/dist/testing/module-mocker.d.ts +30 -0
  62. package/dist/testing/prisma.d.ts +30 -0
  63. package/dist/testing/window.d.ts +18 -0
  64. package/dist/zod/index.d.ts +10 -0
  65. package/dist/zod/introspect.d.ts +2 -1
  66. package/package.json +96 -2
  67. package/src/api-wrapper/README.md +52 -0
  68. package/src/api-wrapper/connection-auth.ts +142 -0
  69. package/src/cli/README.md +220 -0
  70. package/src/cli/fields.ts +213 -0
  71. package/src/cli/index.ts +532 -0
  72. package/src/format/README.md +82 -2
  73. package/src/format/index.ts +148 -0
  74. package/src/format/phone.ts +43 -0
  75. package/src/http/README.md +25 -0
  76. package/src/http/index.ts +94 -0
  77. package/src/i18n/README.md +206 -3
  78. package/src/i18n/catalog.ts +139 -0
  79. package/src/i18n/check.ts +376 -0
  80. package/src/i18n/cli.ts +90 -0
  81. package/src/i18n/content-rules.ts +179 -0
  82. package/src/i18n/i18next.ts +89 -0
  83. package/src/i18n/index.ts +34 -0
  84. package/src/i18n/locales.ts +135 -0
  85. package/src/i18n/vite.ts +40 -0
  86. package/src/i18n/vue.ts +82 -0
  87. package/src/index.ts +1 -1
  88. package/src/iso/README.md +44 -0
  89. package/src/iso/countries.ts +272 -0
  90. package/src/iso/currencies.ts +172 -0
  91. package/src/iso/index.ts +8 -0
  92. package/src/iso/languages.ts +208 -0
  93. package/src/iso/phone-language.ts +46 -0
  94. package/src/promise/index.ts +100 -0
  95. package/src/redis/README.md +112 -0
  96. package/src/redis/client.ts +239 -0
  97. package/src/redis/concurrency-limit.ts +591 -0
  98. package/src/redis/index.ts +15 -0
  99. package/src/redis/lua-script.ts +43 -0
  100. package/src/secrets/README.md +5 -1
  101. package/src/secrets/infisical-identity-file.ts +69 -0
  102. package/src/secrets/infisical.ts +18 -3
  103. package/src/string/index.ts +59 -0
  104. package/src/testing/README.md +119 -0
  105. package/src/testing/connection-auth-contract.ts +303 -0
  106. package/src/testing/helpers.ts +41 -0
  107. package/src/testing/index.ts +12 -0
  108. package/src/testing/module-mocker.ts +50 -0
  109. package/src/testing/prisma.ts +78 -0
  110. package/src/testing/window.ts +119 -0
  111. package/src/zod/index.ts +20 -0
  112. package/src/zod/introspect.ts +1 -1
@@ -0,0 +1,376 @@
1
+ // Catalog checks for `<dir>/<locale>.json` translation catalogs, used by the
2
+ // `hanzio-i18n` bin. The base locale is the source of truth: every locale must
3
+ // have its keys and placeholders, and the generated key maps must match it.
4
+
5
+ import {
6
+ existsSync,
7
+ mkdirSync,
8
+ readdirSync,
9
+ readFileSync,
10
+ statSync,
11
+ writeFileSync
12
+ } from 'node:fs'
13
+ import { dirname, join, relative, sep } from 'node:path'
14
+ import { extractPlaceholders, type Messages } from '.'
15
+ import {
16
+ type CatalogConfig,
17
+ type CatalogIssue,
18
+ flattenCatalog,
19
+ generateCatalogTypes,
20
+ parseMessages
21
+ } from './catalog'
22
+ import { type ContentRulesConfig, createContentRules } from './content-rules'
23
+
24
+ export type { CatalogConfig, CatalogIssue, CatalogRuntime } from './catalog'
25
+ export { generateCatalogTypes, parseMessages } from './catalog'
26
+ export type { ContentRulesConfig } from './content-rules'
27
+
28
+ export type I18nConfig<
29
+ Locale extends string = string,
30
+ Catalog extends string = string
31
+ > = {
32
+ /** The source-of-truth locale; usually `'en'`. */
33
+ baseLocale: NoInfer<Locale>
34
+ /** Every shipped locale, the base included. Each needs a catalog file. */
35
+ locales: readonly Locale[]
36
+ catalogs: readonly (CatalogConfig & { name: Catalog })[]
37
+ /**
38
+ * Groups of catalogs merged into one runtime (`{ ...platform, ...widget }`),
39
+ * so their top-level keys must not overlap.
40
+ */
41
+ disjointNamespaces?: readonly (readonly NoInfer<Catalog>[])[]
42
+ rules?: ContentRulesConfig<NoInfer<Locale>>
43
+ }
44
+
45
+ /**
46
+ * Types an `i18n.config.ts` default export: `baseLocale`, the catalog names
47
+ * in `disjointNamespaces` and the locales in `rules.identical.allowKeys` must
48
+ * be ones the config declares.
49
+ */
50
+ export function defineI18nConfig<
51
+ const Locale extends string,
52
+ const Catalog extends string
53
+ >(config: I18nConfig<Locale, Catalog>): I18nConfig<Locale, Catalog> {
54
+ return config
55
+ }
56
+
57
+ type VueCompiler = typeof import('@intlify/message-compiler')
58
+
59
+ function vueSyntaxIssues(compiler: VueCompiler, key: string, value: string) {
60
+ const issues: string[] = []
61
+ if (compiler.detectHtmlTag(value)) {
62
+ issues.push(
63
+ `"${key}" contains HTML, which vue-i18n rejects with strictMessage: "${value}"`
64
+ )
65
+ }
66
+ const errors: string[] = []
67
+ compiler.baseCompile(value, {
68
+ onError: (error) => errors.push(error.message)
69
+ })
70
+ if (errors.length > 0) {
71
+ issues.push(
72
+ `"${key}" is not valid vue-i18n syntax (${errors.join('; ')}): "${value}" - escape literal { } and @ with {'{'} {'}'} {'@'}`
73
+ )
74
+ }
75
+ return issues
76
+ }
77
+
78
+ function readCatalog(path: string, catalog: string, locale: string) {
79
+ try {
80
+ return parseMessages(
81
+ JSON.parse(readFileSync(path, 'utf8')),
82
+ catalog,
83
+ locale
84
+ )
85
+ } catch (error) {
86
+ return {
87
+ messages: undefined,
88
+ issues: [{ catalog, locale, message: `invalid JSON - ${String(error)}` }]
89
+ }
90
+ }
91
+ }
92
+
93
+ function collectSourceFiles(path: string, files: string[] = []): string[] {
94
+ if (!existsSync(path)) return files
95
+ if (statSync(path).isFile()) {
96
+ if (/\.(tsx?|jsx?|vue|svelte)$/.test(path) && !/\.test\./.test(path)) {
97
+ files.push(path)
98
+ }
99
+ return files
100
+ }
101
+ for (const name of readdirSync(path)) {
102
+ if (name !== 'node_modules') collectSourceFiles(join(path, name), files)
103
+ }
104
+ return files
105
+ }
106
+
107
+ /** Any dotted path, wherever it appears. */
108
+ const KEY_REFERENCE = /[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)+/g
109
+ /** The `prefix.` of a `` `prefix.${expr}` `` key, which keeps a whole family. */
110
+ const KEY_FAMILY_PREFIX = /([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)*\.)\$\{/g
111
+
112
+ /**
113
+ * Keys nothing in `sourceRoots` references. Plain string scanning: any dotted
114
+ * path counts, which over-reports references and so keeps a dead key rather
115
+ * than flagging a live one.
116
+ */
117
+ function findUnusedKeys(
118
+ root: string,
119
+ catalog: CatalogConfig,
120
+ keys: Iterable<string>
121
+ ) {
122
+ const references = new Set<string>()
123
+ const prefixes = new Set(catalog.dynamicKeyPrefixes ?? [])
124
+ const catalogDir = join(root, catalog.dir) + sep
125
+ const files = (catalog.sourceRoots ?? []).flatMap((dir) =>
126
+ collectSourceFiles(join(root, dir))
127
+ )
128
+
129
+ for (const path of files) {
130
+ // The generated key map lives next to the catalogs and names every key.
131
+ if (path.startsWith(catalogDir)) continue
132
+
133
+ const source = readFileSync(path, 'utf8')
134
+ for (const [reference] of source.matchAll(KEY_REFERENCE)) {
135
+ references.add(reference)
136
+ }
137
+ for (const [, prefix] of source.matchAll(KEY_FAMILY_PREFIX)) {
138
+ if (prefix) prefixes.add(prefix)
139
+ }
140
+ }
141
+
142
+ return [...keys].filter(
143
+ (key) =>
144
+ !references.has(key) &&
145
+ ![...prefixes].some((prefix) => key.startsWith(prefix))
146
+ )
147
+ }
148
+
149
+ const placeholderSet = (template: string) =>
150
+ extractPlaceholders(template).sort().join(',')
151
+
152
+ /**
153
+ * Every problem in the configured catalogs, empty when they are valid. Paths
154
+ * in `config` are relative to `root`.
155
+ */
156
+ export async function validateCatalogs(
157
+ config: I18nConfig,
158
+ options: { root: string; checkGeneratedTypes?: boolean }
159
+ ): Promise<CatalogIssue[]> {
160
+ const { root, checkGeneratedTypes = true } = options
161
+ const { baseLocale } = config
162
+ const locales = [...config.locales].sort()
163
+ const rules = createContentRules(config.rules)
164
+ const issues: CatalogIssue[] = []
165
+ const baseByCatalog = new Map<string, Messages>()
166
+
167
+ // An optional peer, loaded only when a catalog needs it.
168
+ let compiler: VueCompiler | undefined
169
+ if (config.catalogs.some((catalog) => catalog.runtime === 'vue-i18n')) {
170
+ try {
171
+ compiler = await import('@intlify/message-compiler')
172
+ } catch {
173
+ issues.push({
174
+ catalog: 'config',
175
+ message:
176
+ 'vue-i18n catalogs need @intlify/message-compiler installed to check message syntax'
177
+ })
178
+ }
179
+ }
180
+
181
+ for (const catalog of config.catalogs) {
182
+ const report = (message: string, locale?: string) =>
183
+ issues.push({ catalog: catalog.name, locale, message })
184
+ const syntax = (locale: string, key: string, value: string) => {
185
+ if (catalog.runtime !== 'vue-i18n' || !compiler) return
186
+ for (const message of vueSyntaxIssues(compiler, key, value)) {
187
+ report(message, locale)
188
+ }
189
+ }
190
+
191
+ const dir = join(root, catalog.dir)
192
+ if (!existsSync(dir)) {
193
+ report(`catalog directory does not exist: ${catalog.dir}`)
194
+ continue
195
+ }
196
+ const files = readdirSync(dir)
197
+ .filter((file) => file.endsWith('.json'))
198
+ .map((file) => file.slice(0, -'.json'.length))
199
+ const missingFiles = locales.filter((locale) => !files.includes(locale))
200
+ const extraFiles = files.filter((locale) => !locales.includes(locale))
201
+ if (missingFiles.length > 0) {
202
+ report(`missing locale file(s): ${missingFiles.join(', ')}`)
203
+ }
204
+ if (extraFiles.length > 0) {
205
+ report(`unregistered locale file(s): ${extraFiles.sort().join(', ')}`)
206
+ }
207
+
208
+ const basePath = join(dir, `${baseLocale}.json`)
209
+ if (!existsSync(basePath)) continue
210
+ const baseResult = readCatalog(basePath, catalog.name, baseLocale)
211
+ issues.push(...baseResult.issues)
212
+ if (!baseResult.messages) continue
213
+ baseByCatalog.set(catalog.name, baseResult.messages)
214
+ const base = flattenCatalog(baseResult.messages)
215
+
216
+ for (const [key, value] of base) {
217
+ const shape = rules.codeShape(value)
218
+ if (shape) {
219
+ report(
220
+ `"${key}" looks like source code, not copy (${shape}): "${value}" - restore the expression at the call site instead of translating it`,
221
+ baseLocale
222
+ )
223
+ }
224
+ const markers = rules.wrongLanguage(key, value)
225
+ if (markers) {
226
+ report(
227
+ `"${key}" is not ${baseLocale} (${markers}): "${value}"`,
228
+ baseLocale
229
+ )
230
+ }
231
+ const banned = rules.bannedWord(key, value, true)
232
+ if (banned) {
233
+ report(
234
+ `"${key}" uses banned vocabulary "${banned}": "${value}"`,
235
+ baseLocale
236
+ )
237
+ }
238
+ syntax(baseLocale, key, value)
239
+ }
240
+
241
+ for (const locale of locales) {
242
+ if (locale === baseLocale) continue
243
+ const path = join(dir, `${locale}.json`)
244
+ if (!existsSync(path)) continue
245
+ const result = readCatalog(path, catalog.name, locale)
246
+ issues.push(...result.issues)
247
+ if (!result.messages) continue
248
+ const translated = flattenCatalog(result.messages)
249
+
250
+ const stale = [...translated.keys()].filter((key) => !base.has(key))
251
+ const missing = [...base.keys()].filter((key) => !translated.has(key))
252
+ if (stale.length > 0) {
253
+ report(
254
+ `stale key(s) not in ${baseLocale}.json: ${stale.join(', ')}`,
255
+ locale
256
+ )
257
+ }
258
+ if (missing.length > 0) {
259
+ report(`missing translation key(s): ${missing.join(', ')}`, locale)
260
+ }
261
+
262
+ for (const [key, value] of translated) {
263
+ // Machine translation rewrites code shapes too, so check every locale.
264
+ const shape = rules.codeShape(value)
265
+ if (shape) {
266
+ report(
267
+ `"${key}" looks like source code, not copy (${shape}): "${value}"`,
268
+ locale
269
+ )
270
+ }
271
+ const banned = rules.bannedWord(key, value, false)
272
+ if (banned) {
273
+ report(
274
+ `"${key}" uses banned vocabulary "${banned}": "${value}"`,
275
+ locale
276
+ )
277
+ }
278
+ const source = base.get(key)
279
+ if (source !== undefined) {
280
+ if (rules.identicalLabel(key, locale, source, value)) {
281
+ report(
282
+ `"${key}" is identical to ${baseLocale}: "${value}" - translate it, or allow it in rules.identical.allowKeys if it is correct as-is`,
283
+ locale
284
+ )
285
+ }
286
+ if (placeholderSet(source) !== placeholderSet(value)) {
287
+ report(
288
+ `placeholder mismatch on "${key}" (${baseLocale}: "${source}" vs ${locale}: "${value}")`,
289
+ locale
290
+ )
291
+ }
292
+ }
293
+ syntax(locale, key, value)
294
+ }
295
+ }
296
+
297
+ if (catalog.sourceRoots?.length) {
298
+ const byNamespace = new Map<string, string[]>()
299
+ for (const key of findUnusedKeys(root, catalog, base.keys())) {
300
+ const namespace = key.split('.')[0] ?? key
301
+ byNamespace.set(namespace, [...(byNamespace.get(namespace) ?? []), key])
302
+ }
303
+ const scanned = catalog.sourceRoots.join(', ')
304
+ for (const [namespace, keys] of [...byNamespace].sort(([a], [b]) =>
305
+ a.localeCompare(b)
306
+ )) {
307
+ report(
308
+ `${keys.length} unused key(s) under "${namespace}" - nothing in ${scanned} references them: ${keys.join(', ')}`
309
+ )
310
+ }
311
+ }
312
+
313
+ if (checkGeneratedTypes && catalog.generatedTypes) {
314
+ const path = join(root, catalog.generatedTypes)
315
+ const expected = generateCatalogTypes(catalog, baseResult.messages)
316
+ const actual = existsSync(path) ? readFileSync(path, 'utf8') : null
317
+ if (actual !== expected) {
318
+ report(
319
+ `generated types are stale; run hanzio-i18n generate (${relative(root, path)})`
320
+ )
321
+ }
322
+ }
323
+ }
324
+
325
+ for (const group of config.disjointNamespaces ?? []) {
326
+ const owners = new Map<string, string>()
327
+ const collisions = new Set<string>()
328
+ for (const name of group) {
329
+ for (const namespace of Object.keys(baseByCatalog.get(name) ?? {})) {
330
+ if (owners.has(namespace)) collisions.add(namespace)
331
+ else owners.set(namespace, name)
332
+ }
333
+ }
334
+ if (collisions.size > 0) {
335
+ issues.push({
336
+ catalog: group.join('/'),
337
+ message: `shared top-level namespace(s): ${[...collisions].join(', ')}`
338
+ })
339
+ }
340
+ }
341
+
342
+ return issues
343
+ }
344
+
345
+ /**
346
+ * Writes each catalog's `generatedTypes` from its base locale. Returns the
347
+ * written paths, or the issues that stopped generation (nothing is written
348
+ * when any base catalog is invalid).
349
+ */
350
+ export function writeCatalogTypes(
351
+ config: I18nConfig,
352
+ options: { root: string }
353
+ ): { written: string[]; issues: CatalogIssue[] } {
354
+ const outputs: { path: string; contents: string }[] = []
355
+ const issues: CatalogIssue[] = []
356
+ for (const catalog of config.catalogs) {
357
+ if (!catalog.generatedTypes) continue
358
+ const parsed = readCatalog(
359
+ join(options.root, catalog.dir, `${config.baseLocale}.json`),
360
+ catalog.name,
361
+ config.baseLocale
362
+ )
363
+ issues.push(...parsed.issues)
364
+ if (!parsed.messages) continue
365
+ outputs.push({
366
+ path: join(options.root, catalog.generatedTypes),
367
+ contents: generateCatalogTypes(catalog, parsed.messages)
368
+ })
369
+ }
370
+ if (issues.length > 0) return { written: [], issues }
371
+ for (const { path, contents } of outputs) {
372
+ mkdirSync(dirname(path), { recursive: true })
373
+ writeFileSync(path, contents)
374
+ }
375
+ return { written: outputs.map(({ path }) => path), issues }
376
+ }
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ // `hanzio-i18n check` validates translation catalogs; `hanzio-i18n generate`
3
+ // writes their key maps. Both read `i18n.config.{ts,mts,js,mjs}` (or
4
+ // `--config <file>`) from the current directory. A `.ts` config needs Bun or
5
+ // a Node version that strips types.
6
+
7
+ import { existsSync } from 'node:fs'
8
+ import { dirname, resolve } from 'node:path'
9
+ import { pathToFileURL } from 'node:url'
10
+ import { z } from 'zod'
11
+ import { CliError, type CommandContext, defineCli, defineCommand } from '../cli'
12
+ import {
13
+ type CatalogIssue,
14
+ type I18nConfig,
15
+ validateCatalogs,
16
+ writeCatalogTypes
17
+ } from './check'
18
+
19
+ const CONFIG_NAMES = [
20
+ 'i18n.config.ts',
21
+ 'i18n.config.mts',
22
+ 'i18n.config.js',
23
+ 'i18n.config.mjs'
24
+ ]
25
+
26
+ type Loaded = { config: I18nConfig; root: string }
27
+
28
+ async function loadConfig(file: string | undefined): Promise<Loaded> {
29
+ const path = file
30
+ ? resolve(file)
31
+ : CONFIG_NAMES.map((name) => resolve(name)).find((path) => existsSync(path))
32
+ if (!path || !existsSync(path))
33
+ throw new CliError(
34
+ `No i18n config found: pass --config or add ${CONFIG_NAMES[0]}.`
35
+ )
36
+ const loaded = (await import(pathToFileURL(path).href)) as {
37
+ default?: I18nConfig
38
+ }
39
+ if (!loaded.default?.catalogs)
40
+ throw new CliError(`${path} must default-export an i18n config`)
41
+ return { config: loaded.default, root: dirname(path) }
42
+ }
43
+
44
+ function failOnIssues(issues: CatalogIssue[], ctx: CommandContext) {
45
+ if (issues.length === 0) return
46
+ for (const issue of issues) {
47
+ const locale = issue.locale ? `/${issue.locale}` : ''
48
+ ctx.stderr.write(`❌ ${issue.catalog}${locale}: ${issue.message}\n`)
49
+ }
50
+ throw new CliError(`${issues.length} catalog problem(s)`)
51
+ }
52
+
53
+ const cli = defineCli({
54
+ name: 'hanzio-i18n',
55
+ globals: z.object({
56
+ config: z
57
+ .string()
58
+ .optional()
59
+ .describe('Config file (default: i18n.config.*)')
60
+ }),
61
+ context: (globals) => loadConfig(globals.config),
62
+ commands: {
63
+ check: defineCommand({
64
+ description: 'Validate the translation catalogs',
65
+ run: async (_input, ctx: CommandContext<Loaded>) => {
66
+ const { config, root } = ctx.context
67
+ failOnIssues(await validateCatalogs(config, { root }), ctx)
68
+ return config.catalogs
69
+ .map(
70
+ (catalog) =>
71
+ `✅ ${catalog.name}: ${config.locales.length} locale(s) valid`
72
+ )
73
+ .join('\n')
74
+ }
75
+ }),
76
+ generate: defineCommand({
77
+ description: "Write each catalog's generatedTypes",
78
+ run: (_input, ctx: CommandContext<Loaded>) => {
79
+ const { config, root } = ctx.context
80
+ const { written, issues } = writeCatalogTypes(config, { root })
81
+ failOnIssues(issues, ctx)
82
+ return (
83
+ written.map((path) => `Generated ${path}`).join('\n') || undefined
84
+ )
85
+ }
86
+ })
87
+ }
88
+ })
89
+
90
+ process.exitCode = await cli.run()
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Content rules for translation VALUES, which key and placeholder parity cannot
3
+ * see: source code stored as copy ("row.name", "carTitle(car)") by an automated
4
+ * extraction pass, copy in the wrong language committed to the base catalog,
5
+ * banned product vocabulary, and copy shipped untranslated.
6
+ */
7
+
8
+ export type ContentRulesConfig<Locale extends string = string> = {
9
+ /**
10
+ * Key prefixes the vocabulary and untranslated rules skip, such as internal
11
+ * admin tooling or a marketing site with its own copy owner.
12
+ */
13
+ exemptKeyPrefixes?: readonly string[]
14
+ /** Values that legitimately look like code, such as `'Solgt.com'`. */
15
+ allowCodeShapes?: readonly string[]
16
+ /**
17
+ * Flags base-catalog values written in another language: two distinct
18
+ * marker words, or one plus a letter from `letters`.
19
+ */
20
+ wrongLanguage?: {
21
+ /** Function words of the other language that are not English words. */
22
+ markers: readonly string[]
23
+ /** Letters only that language uses, e.g. `'æøåÆØÅ'`. */
24
+ letters?: string
25
+ allowKeys?: readonly string[]
26
+ }
27
+ bannedWords?: {
28
+ /** Banned in every catalog, the base included. */
29
+ everywhere?: readonly string[]
30
+ /** Banned in translations only (loanwords readers do not use). */
31
+ translations?: readonly string[]
32
+ allowKeys?: readonly string[]
33
+ }
34
+ /** Translations allowed to equal the base value. */
35
+ identical?: {
36
+ /** Leading words (brands, acronyms) that make a value correct as-is. */
37
+ brands?: readonly string[]
38
+ /** Keys correct as-is, in the listed locales or `'all'`. */
39
+ allowKeys?: Readonly<Record<string, readonly Locale[] | 'all'>>
40
+ }
41
+ }
42
+
43
+ const PLACEHOLDER_MASK = ''
44
+ const EXPRESSION_FRAGMENT = /(?:\?\?|=>|\bas\s+(?:string|number|boolean)\b)/
45
+
46
+ /** Placeholders are content, so `{count} mo.` must not read as a dotted path. */
47
+ const maskPlaceholders = (value: string) =>
48
+ value.replace(/\{[^}]*\}/g, PLACEHOLDER_MASK)
49
+
50
+ const escapeRegExp = (value: string) =>
51
+ value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
52
+
53
+ const wordPattern = (words: readonly string[], flags: string) =>
54
+ new RegExp(`\\b(?:${words.map(escapeRegExp).join('|')})\\w*`, flags)
55
+
56
+ const CODE_SHAPES: { name: string; pattern: RegExp }[] = [
57
+ // carTitle(car), stageName(enrollment.stageId)
58
+ { name: 'call expression', pattern: /^[A-Za-z_$][\w$]*\([^)]*\)$/ },
59
+ // FIELD_LABELS[field.name]
60
+ { name: 'index expression', pattern: /^[A-Za-z_$][\w$]*\[[^\]]*\]$/ },
61
+ // row.name - a lowercase first segment separates this from "Solgt.com"
62
+ {
63
+ name: 'member expression',
64
+ pattern: /^[a-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/
65
+ },
66
+ { name: 'constant reference', pattern: /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/ },
67
+ // updateNodeDescription; plain words ("Cancel") do not match
68
+ { name: 'identifier', pattern: /^[a-z][a-z0-9]*(?:[A-Z][a-zA-Z0-9]*)+$/ },
69
+ // `run.status ?? undefined`, `row.role as string`, arrow bodies
70
+ { name: 'expression fragment', pattern: EXPRESSION_FRAGMENT }
71
+ ]
72
+
73
+ /** Acronyms correct in every language, on top of the configured brands. */
74
+ const DEFAULT_BRANDS = ['API', 'URL', 'SMS', 'CSV', 'PDF', 'ID', 'OK']
75
+
76
+ /** Any letter outside ASCII: the value is not plain English. */
77
+ const NON_ASCII_LETTER = /(?![A-Za-z])\p{L}/u
78
+ /** Paths, selectors, domains, emails and colour codes are not prose. */
79
+ const NON_PROSE = /[/@#=_{}[\]<>]|\.[a-z]{2,}/
80
+
81
+ /** Compiles the configured rules once; each detector returns why, or null. */
82
+ export function createContentRules(config: ContentRulesConfig = {}) {
83
+ const exempt = config.exemptKeyPrefixes ?? []
84
+ const isExempt = (key: string) =>
85
+ exempt.some((prefix) => key.startsWith(prefix))
86
+ const codeAllow = new Set(config.allowCodeShapes ?? [])
87
+
88
+ const wrongLanguage = config.wrongLanguage
89
+ const markerPattern = wrongLanguage?.markers.length
90
+ ? new RegExp(
91
+ `\\b(?:${wrongLanguage.markers.map(escapeRegExp).join('|')})\\b`,
92
+ 'giu'
93
+ )
94
+ : undefined
95
+ const markerLetters = wrongLanguage?.letters
96
+ ? new RegExp(`[${escapeRegExp(wrongLanguage.letters)}]`, 'u')
97
+ : undefined
98
+ const wrongLanguageAllow = new Set(wrongLanguage?.allowKeys ?? [])
99
+
100
+ const everywhere = config.bannedWords?.everywhere ?? []
101
+ const translations = config.bannedWords?.translations ?? []
102
+ const bannedInBase = everywhere.length
103
+ ? wordPattern(everywhere, 'iu')
104
+ : undefined
105
+ const bannedInTranslations =
106
+ everywhere.length || translations.length
107
+ ? wordPattern([...translations, ...everywhere], 'iu')
108
+ : undefined
109
+ const bannedAllow = new Set(config.bannedWords?.allowKeys ?? [])
110
+
111
+ const brands = [...DEFAULT_BRANDS, ...(config.identical?.brands ?? [])]
112
+ const brandOrSymbols = new RegExp(
113
+ `^[\\W\\d]*$|^(?:${brands.map(escapeRegExp).join('|')})\\b`,
114
+ 'i'
115
+ )
116
+ const identicalAllow = config.identical?.allowKeys ?? {}
117
+ const isAllowedIdentical = (key: string, locale: string) => {
118
+ const locales = Object.hasOwn(identicalAllow, key)
119
+ ? identicalAllow[key]
120
+ : undefined
121
+ return locales === 'all' || (locales?.includes(locale) ?? false)
122
+ }
123
+
124
+ return {
125
+ /** The shape name when a value looks like source code. */
126
+ codeShape(value: string): string | null {
127
+ const trimmed = value.trim()
128
+ if (!trimmed || codeAllow.has(trimmed)) return null
129
+ const masked = maskPlaceholders(trimmed)
130
+ // A placeholder means the value is a real template, not code.
131
+ if (masked.includes(PLACEHOLDER_MASK)) return null
132
+ // Whitespace means prose, unless it is an expression fragment.
133
+ if (/\s/.test(masked) && !EXPRESSION_FRAGMENT.test(masked)) return null
134
+ return (
135
+ CODE_SHAPES.find(({ pattern }) => pattern.test(masked))?.name ?? null
136
+ )
137
+ },
138
+
139
+ /** The marker words found when a base value is in the wrong language. */
140
+ wrongLanguage(key: string, value: string): string | null {
141
+ if (!markerPattern || wrongLanguageAllow.has(key)) return null
142
+ const distinct = new Set(
143
+ (value.match(markerPattern) ?? []).map((m) => m.toLowerCase())
144
+ )
145
+ if (distinct.size === 0) return null
146
+ if (distinct.size >= 2 || markerLetters?.test(value)) {
147
+ return [...distinct].sort().join(', ')
148
+ }
149
+ return null
150
+ },
151
+
152
+ /** The offending word when a value uses banned vocabulary. */
153
+ bannedWord(key: string, value: string, isBase: boolean): string | null {
154
+ if (bannedAllow.has(key) || isExempt(key)) return null
155
+ const pattern = isBase ? bannedInBase : bannedInTranslations
156
+ // A placeholder named {pipeline} is a variable, not the word.
157
+ return pattern?.exec(maskPlaceholders(value))?.[0] ?? null
158
+ },
159
+
160
+ /**
161
+ * A translation of two or more words identical to its source ("Document
162
+ * name", a whole English sentence). Single words stay out of scope:
163
+ * "Status" and "Email" are correct in many languages.
164
+ */
165
+ identicalLabel(
166
+ key: string,
167
+ locale: string,
168
+ source: string,
169
+ translated: string
170
+ ): boolean {
171
+ if (source !== translated) return false
172
+ if (isAllowedIdentical(key, locale) || isExempt(key)) return false
173
+ const value = translated.trim()
174
+ if (NON_PROSE.test(value) || brandOrSymbols.test(value)) return false
175
+ if (NON_ASCII_LETTER.test(value)) return false
176
+ return (value.match(/\p{L}{2,}/gu) ?? []).length >= 2
177
+ }
178
+ }
179
+ }