hanzio 2.2.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.
- package/README.md +133 -7
- package/dist/api-wrapper/connection-auth.d.ts +61 -0
- package/dist/cli/fields.d.ts +30 -0
- package/dist/cli/index.d.ts +122 -0
- package/dist/format/index.d.ts +91 -0
- package/dist/format/phone.d.ts +19 -0
- package/dist/http/index.d.ts +14 -0
- package/dist/i18n/catalog.d.ts +47 -0
- package/dist/i18n/check.d.ts +45 -0
- package/dist/i18n/cli.d.ts +2 -0
- package/dist/i18n/content-rules.d.ts +55 -0
- package/dist/i18n/i18next.d.ts +39 -0
- package/dist/i18n/index.d.ts +16 -1
- package/dist/i18n/locales.d.ts +62 -0
- package/dist/i18n/vite.d.ts +16 -0
- package/dist/i18n/vue.d.ts +93 -0
- package/dist/{index-kj1phgrx.js → index-29mmh343.js} +24 -1
- package/dist/{index-zbj9s3s3.js → index-2kz392vw.js} +1 -1
- package/dist/index-5n2e326q.js +409 -0
- package/dist/{index-hy7kbb99.js → index-5tzqfmnj.js} +1 -1
- package/dist/{index-0zvtcbzy.js → index-6f2w90pp.js} +69 -1
- package/dist/index-6ragggbs.js +473 -0
- package/dist/index-mq2qgc20.js +116 -0
- package/dist/index-najbyzpr.js +99 -0
- package/dist/index-ww09bm49.js +250 -0
- package/dist/iso/countries.d.ts +262 -0
- package/dist/iso/currencies.d.ts +165 -0
- package/dist/iso/index.d.ts +4 -0
- package/dist/iso/languages.d.ts +195 -0
- package/dist/iso/phone-language.d.ts +5 -0
- package/dist/promise/index.d.ts +31 -0
- package/dist/redis/client.d.ts +49 -0
- package/dist/redis/concurrency-limit.d.ts +63 -0
- package/dist/redis/index.d.ts +3 -0
- package/dist/redis/lua-script.d.ts +13 -0
- package/dist/secrets/infisical-identity-file.d.ts +12 -0
- package/dist/src/api-wrapper/connection-auth.js +82 -0
- package/dist/src/api-wrapper/index.js +1 -1
- package/dist/src/cli/index.js +10 -0
- package/dist/src/format/index.js +79 -2
- package/dist/src/format/phone.js +21 -0
- package/dist/src/http/index.js +69 -0
- package/dist/src/i18n/check.js +15 -0
- package/dist/src/i18n/cli.js +71 -0
- package/dist/src/i18n/i18next.js +48 -0
- package/dist/src/i18n/index.js +16 -34
- package/dist/src/i18n/vite.js +25 -0
- package/dist/src/i18n/vue.js +51 -0
- package/dist/src/index.js +22 -6
- package/dist/src/iso/index.js +655 -0
- package/dist/src/redis/index.js +577 -0
- package/dist/src/secrets/index.js +78 -35
- package/dist/src/sitemap/index.js +1 -1
- package/dist/src/storage/index.js +8 -117
- package/dist/src/testing/index.js +348 -0
- package/dist/src/zod/index.js +20 -253
- package/dist/string/index.d.ts +21 -0
- package/dist/testing/connection-auth-contract.d.ts +43 -0
- package/dist/testing/helpers.d.ts +26 -0
- package/dist/testing/index.d.ts +5 -0
- package/dist/testing/module-mocker.d.ts +30 -0
- package/dist/testing/prisma.d.ts +30 -0
- package/dist/testing/window.d.ts +18 -0
- package/dist/typedswitch/index.d.ts +55 -34
- package/dist/zod/index.d.ts +10 -0
- package/dist/zod/introspect.d.ts +2 -1
- package/package.json +97 -2
- package/src/api-wrapper/README.md +57 -2
- package/src/api-wrapper/SKILL.md +256 -0
- package/src/api-wrapper/connection-auth.ts +142 -0
- package/src/cli/README.md +220 -0
- package/src/cli/fields.ts +213 -0
- package/src/cli/index.ts +532 -0
- package/src/format/README.md +82 -2
- package/src/format/index.ts +148 -0
- package/src/format/phone.ts +43 -0
- package/src/http/README.md +25 -0
- package/src/http/index.ts +94 -0
- package/src/i18n/README.md +206 -3
- package/src/i18n/catalog.ts +139 -0
- package/src/i18n/check.ts +376 -0
- package/src/i18n/cli.ts +90 -0
- package/src/i18n/content-rules.ts +179 -0
- package/src/i18n/i18next.ts +89 -0
- package/src/i18n/index.ts +34 -0
- package/src/i18n/locales.ts +135 -0
- package/src/i18n/vite.ts +40 -0
- package/src/i18n/vue.ts +82 -0
- package/src/index.ts +1 -1
- package/src/iso/README.md +44 -0
- package/src/iso/countries.ts +272 -0
- package/src/iso/currencies.ts +172 -0
- package/src/iso/index.ts +8 -0
- package/src/iso/languages.ts +208 -0
- package/src/iso/phone-language.ts +46 -0
- package/src/promise/index.ts +100 -0
- package/src/redis/README.md +112 -0
- package/src/redis/client.ts +239 -0
- package/src/redis/concurrency-limit.ts +591 -0
- package/src/redis/index.ts +15 -0
- package/src/redis/lua-script.ts +43 -0
- package/src/secrets/README.md +5 -1
- package/src/secrets/infisical-identity-file.ts +69 -0
- package/src/secrets/infisical.ts +18 -3
- package/src/string/index.ts +59 -0
- package/src/testing/README.md +119 -0
- package/src/testing/connection-auth-contract.ts +303 -0
- package/src/testing/helpers.ts +41 -0
- package/src/testing/index.ts +12 -0
- package/src/testing/module-mocker.ts +50 -0
- package/src/testing/prisma.ts +78 -0
- package/src/testing/window.ts +119 -0
- package/src/typedswitch/README.md +55 -39
- package/src/typedswitch/index.ts +97 -83
- package/src/zod/index.ts +20 -0
- 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
|
+
}
|
package/src/i18n/cli.ts
ADDED
|
@@ -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
|
+
}
|