@djangocfg/i18n 2.1.585 → 2.1.590
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 +13 -0
- package/package.json +11 -2
- package/src/copy/createCopy.ts +41 -0
- package/src/copy/format.ts +50 -0
- package/src/copy/index.ts +3 -0
- package/src/copy/locales.ts +46 -0
- package/src/copy/testing.ts +79 -0
package/README.md
CHANGED
|
@@ -53,6 +53,19 @@ import { I18nProvider, useT } from '@djangocfg/i18n'
|
|
|
53
53
|
| `@djangocfg/i18n/locales` | Locale data only (server-safe) |
|
|
54
54
|
| `@djangocfg/i18n/utils` | Utilities like `mergeTranslations` (server-safe) |
|
|
55
55
|
|
|
56
|
+
## Self-contained copy
|
|
57
|
+
|
|
58
|
+
Widgets ship their own typed dictionaries and share one locale contract:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { createCopy, type Locale } from '@djangocfg/i18n/copy'
|
|
62
|
+
|
|
63
|
+
const DICTIONARIES: Record<Locale, MyCopy> = { en, ru, /* ... */ 'pt-BR': ptBR }
|
|
64
|
+
export const useMyCopy = createCopy(DICTIONARIES).useCopy
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
See [`@docs/SELF_CONTAINED_COPY.md`](./@docs/SELF_CONTAINED_COPY.md).
|
|
68
|
+
|
|
56
69
|
## CLI
|
|
57
70
|
|
|
58
71
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/i18n",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.590",
|
|
4
4
|
"description": "Lightweight i18n library for @djangocfg packages with built-in translations for English, Russian, and Korean",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"i18n",
|
|
@@ -41,6 +41,14 @@
|
|
|
41
41
|
"import": "./src/locales/index.ts"
|
|
42
42
|
},
|
|
43
43
|
"./locales/*": "./src/locales/*.ts",
|
|
44
|
+
"./copy": {
|
|
45
|
+
"types": "./src/copy/index.ts",
|
|
46
|
+
"import": "./src/copy/index.ts"
|
|
47
|
+
},
|
|
48
|
+
"./copy/testing": {
|
|
49
|
+
"types": "./src/copy/testing.ts",
|
|
50
|
+
"import": "./src/copy/testing.ts"
|
|
51
|
+
},
|
|
44
52
|
"./utils": {
|
|
45
53
|
"types": "./src/utils/index.ts",
|
|
46
54
|
"import": "./src/utils/index.ts"
|
|
@@ -53,6 +61,7 @@
|
|
|
53
61
|
"files": [
|
|
54
62
|
"dist",
|
|
55
63
|
"src",
|
|
64
|
+
"@docs",
|
|
56
65
|
"README.md",
|
|
57
66
|
"LICENSE"
|
|
58
67
|
],
|
|
@@ -75,7 +84,7 @@
|
|
|
75
84
|
"jiti": "^2.7.0"
|
|
76
85
|
},
|
|
77
86
|
"devDependencies": {
|
|
78
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
87
|
+
"@djangocfg/typescript-config": "^2.1.590",
|
|
79
88
|
"@types/node": "^25.9.5",
|
|
80
89
|
"@types/react": "19.2.15",
|
|
81
90
|
"eslint": "^9.39.5",
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
|
|
3
|
+
import { useLocaleOptional } from '../context'
|
|
4
|
+
import { resolveLocale, type Dictionaries, type Locale } from './locales'
|
|
5
|
+
|
|
6
|
+
export interface CopyKit<T> {
|
|
7
|
+
/** Every bundled dictionary, for tests and tooling. */
|
|
8
|
+
readonly dictionaries: Dictionaries<T>
|
|
9
|
+
/** The dictionary for a language tag; English when it is not bundled. */
|
|
10
|
+
resolve(tag: string | null | undefined): T
|
|
11
|
+
/**
|
|
12
|
+
* The dictionary for the active language: the `locale` prop when the host
|
|
13
|
+
* passes one, else the `@djangocfg/i18n` provider, else English. With no
|
|
14
|
+
* provider (a story, a test) it renders without one.
|
|
15
|
+
*/
|
|
16
|
+
useCopy(locale?: string): T
|
|
17
|
+
/** The bundled locale `useCopy` resolved to; feed it to `plural()`. */
|
|
18
|
+
useLocale(locale?: string): Locale
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Wire a package's own dictionaries to the shared locale contract.
|
|
23
|
+
*
|
|
24
|
+
* The package keeps its strings and its `XCopy` schema; this supplies the one
|
|
25
|
+
* resolver and the one hook, so locale handling is identical everywhere.
|
|
26
|
+
*/
|
|
27
|
+
export function createCopy<T>(dictionaries: Dictionaries<T>): CopyKit<T> {
|
|
28
|
+
const resolve = (tag: string | null | undefined): T => dictionaries[resolveLocale(tag)]
|
|
29
|
+
|
|
30
|
+
function useLocale(locale?: string): Locale {
|
|
31
|
+
const fromProvider = useLocaleOptional()
|
|
32
|
+
return resolveLocale(locale ?? fromProvider)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function useCopy(locale?: string): T {
|
|
36
|
+
const resolved = useLocale(locale)
|
|
37
|
+
return useMemo(() => dictionaries[resolved], [resolved])
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { dictionaries, resolve, useCopy, useLocale }
|
|
41
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** The plural categories `Intl.PluralRules` can report; only `other` is required. */
|
|
2
|
+
export interface PluralCopy {
|
|
3
|
+
readonly zero?: string
|
|
4
|
+
readonly one?: string
|
|
5
|
+
readonly two?: string
|
|
6
|
+
readonly few?: string
|
|
7
|
+
readonly many?: string
|
|
8
|
+
readonly other: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type FillValues = Readonly<Record<string, string | number>>
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Fill `{{name}}` placeholders. The placeholder belongs to the dictionary, so a
|
|
15
|
+
* translator can move it; a template literal at the call site could not.
|
|
16
|
+
*/
|
|
17
|
+
export function fill(template: string, values: FillValues): string {
|
|
18
|
+
return template.replace(/\{\{(\w+)\}\}/g, (whole, key: string) => {
|
|
19
|
+
const value = values[key]
|
|
20
|
+
return value === undefined ? whole : String(value)
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Building a PluralRules costs a locale-data lookup and plurals render often.
|
|
25
|
+
const rules = new Map<string, Intl.PluralRules>()
|
|
26
|
+
|
|
27
|
+
function rulesFor(language: string): Intl.PluralRules {
|
|
28
|
+
const cached = rules.get(language)
|
|
29
|
+
if (cached) return cached
|
|
30
|
+
let made: Intl.PluralRules
|
|
31
|
+
try {
|
|
32
|
+
made = new Intl.PluralRules(language)
|
|
33
|
+
} catch {
|
|
34
|
+
// An invalid tag throws; a wrong plural beats a crashed render.
|
|
35
|
+
made = new Intl.PluralRules('en')
|
|
36
|
+
}
|
|
37
|
+
rules.set(language, made)
|
|
38
|
+
return made
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Pick the plural form for `count` and fill `{{count}}`; `other` is the fallback. */
|
|
42
|
+
export function plural(
|
|
43
|
+
copy: PluralCopy,
|
|
44
|
+
count: number,
|
|
45
|
+
language: string,
|
|
46
|
+
values: FillValues = {},
|
|
47
|
+
): string {
|
|
48
|
+
const template = copy[rulesFor(language).select(count)] ?? copy.other
|
|
49
|
+
return fill(template, { ...values, count })
|
|
50
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The locales a self-contained package ships a dictionary for.
|
|
3
|
+
*
|
|
4
|
+
* One list for every widget: a package that forgets a language fails `tsc`
|
|
5
|
+
* (see `Dictionaries`) instead of quietly rendering English for it.
|
|
6
|
+
*/
|
|
7
|
+
export const LOCALES = [
|
|
8
|
+
'en', 'ru', 'ko', 'ja', 'de', 'fr', 'zh', 'it', 'es',
|
|
9
|
+
'nl', 'ar', 'tr', 'pl', 'sv', 'no', 'da', 'pt-BR',
|
|
10
|
+
] as const
|
|
11
|
+
|
|
12
|
+
export type Locale = (typeof LOCALES)[number]
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A complete set of dictionaries. `Record` over the closed `Locale` union, so
|
|
16
|
+
* a missing language is a type error at the registry, not a runtime miss.
|
|
17
|
+
*/
|
|
18
|
+
export type Dictionaries<T> = Readonly<Record<Locale, T>>
|
|
19
|
+
|
|
20
|
+
/** Language tags hosts report that name one of our locales under another code. */
|
|
21
|
+
const ALIASES: Readonly<Record<string, Locale>> = {
|
|
22
|
+
nb: 'no',
|
|
23
|
+
nn: 'no',
|
|
24
|
+
pt: 'pt-BR',
|
|
25
|
+
'pt-pt': 'pt-BR',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const BY_LOWER = new Map<string, Locale>(LOCALES.map((l) => [l.toLowerCase(), l]))
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Map whatever language tag the host reports to a bundled locale.
|
|
32
|
+
*
|
|
33
|
+
* Exact match, then the base language (`zh-CN` → `zh`), then an alias
|
|
34
|
+
* (`nb` → `no`), then English. One resolver for every package, so two widgets
|
|
35
|
+
* on one page never disagree about what `pt` means.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveLocale(tag: string | null | undefined): Locale {
|
|
38
|
+
if (!tag) return 'en'
|
|
39
|
+
const lower = tag.replace('_', '-').toLowerCase()
|
|
40
|
+
const exact = BY_LOWER.get(lower)
|
|
41
|
+
if (exact) return exact
|
|
42
|
+
const alias = ALIASES[lower]
|
|
43
|
+
if (alias) return alias
|
|
44
|
+
const base = lower.split('-')[0] ?? ''
|
|
45
|
+
return BY_LOWER.get(base) ?? ALIASES[base] ?? 'en'
|
|
46
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
import { LOCALES } from './locales'
|
|
4
|
+
|
|
5
|
+
type Tree = { readonly [key: string]: string | Tree }
|
|
6
|
+
|
|
7
|
+
function leaves(tree: Tree, prefix = ''): Map<string, string> {
|
|
8
|
+
const out = new Map<string, string>()
|
|
9
|
+
for (const [key, value] of Object.entries(tree)) {
|
|
10
|
+
const path = prefix ? `${prefix}.${key}` : key
|
|
11
|
+
if (typeof value === 'string') out.set(path, value)
|
|
12
|
+
else for (const [p, v] of leaves(value, path)) out.set(p, v)
|
|
13
|
+
}
|
|
14
|
+
return out
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const placeholders = (s: string): string =>
|
|
18
|
+
[...s.matchAll(/\{{1,2}(\w+)\}{1,2}/g)].map((m) => m[1]).sort().join(',')
|
|
19
|
+
|
|
20
|
+
// A language has the plural forms it has (`ko` has no `one`, `ru` adds `few`
|
|
21
|
+
// and `many`), so only `other` is a fixed key under a `PluralCopy`.
|
|
22
|
+
const OPTIONAL_FORMS = new Set(['zero', 'one', 'two', 'few', 'many'])
|
|
23
|
+
const isOptionalForm = (path: string): boolean => OPTIONAL_FORMS.has(path.split('.').pop() ?? '')
|
|
24
|
+
|
|
25
|
+
const norm = (s: string): string => s.replace('-', '').toLowerCase()
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What `tsc` cannot check about a package's dictionaries; returns problems,
|
|
29
|
+
* empty when clean:
|
|
30
|
+
*
|
|
31
|
+
* - the registry covers every locale in `LOCALES`;
|
|
32
|
+
* - every file in `localesDir` is registered (written, never wired in);
|
|
33
|
+
* - every dictionary has the same keys as English (plural forms other than
|
|
34
|
+
* `other` may differ per language);
|
|
35
|
+
* - every string is non-blank and keeps the `{{placeholders}}` English has.
|
|
36
|
+
*
|
|
37
|
+
* `dictionaries` is typed loosely so any `XCopy` can be passed without a cast.
|
|
38
|
+
*/
|
|
39
|
+
export function checkDictionaries(
|
|
40
|
+
dictionaries: Readonly<Record<string, object>>,
|
|
41
|
+
localesDir: string,
|
|
42
|
+
): string[] {
|
|
43
|
+
const problems: string[] = []
|
|
44
|
+
const registered = Object.keys(dictionaries)
|
|
45
|
+
|
|
46
|
+
for (const locale of LOCALES) {
|
|
47
|
+
if (!registered.includes(locale)) problems.push(`locale not registered: ${locale}`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const onDisk = readdirSync(localesDir)
|
|
51
|
+
.filter((n) => n.endsWith('.ts'))
|
|
52
|
+
.map((n) => n.replace(/\.ts$/, ''))
|
|
53
|
+
if (onDisk.length === 0) problems.push(`no dictionary files in ${localesDir}`)
|
|
54
|
+
for (const file of onDisk) {
|
|
55
|
+
if (!registered.some((k) => norm(k) === norm(file))) {
|
|
56
|
+
problems.push(`file not registered: ${file}`)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const en = dictionaries.en
|
|
61
|
+
if (!en) return [...problems, 'no English dictionary']
|
|
62
|
+
const reference = leaves(en as Tree)
|
|
63
|
+
for (const [locale, dict] of Object.entries(dictionaries)) {
|
|
64
|
+
const own = leaves(dict as Tree)
|
|
65
|
+
for (const [path, text] of reference) {
|
|
66
|
+
const got = own.get(path)
|
|
67
|
+
if (got === undefined) {
|
|
68
|
+
if (!isOptionalForm(path)) problems.push(`${locale}: missing ${path}`)
|
|
69
|
+
} else if (got.trim() === '') problems.push(`${locale}: blank ${path}`)
|
|
70
|
+
else if (placeholders(got) !== placeholders(text)) {
|
|
71
|
+
problems.push(`${locale}: placeholders differ at ${path}`)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
for (const path of own.keys()) {
|
|
75
|
+
if (!reference.has(path) && !isOptionalForm(path)) problems.push(`${locale}: extra ${path}`)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return problems
|
|
79
|
+
}
|