@byline/i18n 2.6.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/LICENSE +373 -0
- package/README.md +166 -0
- package/dist/admin/en.js +424 -0
- package/dist/admin/fr.js +424 -0
- package/dist/admin/index.d.ts +44 -0
- package/dist/admin/index.js +27 -0
- package/dist/formatter.d.ts +40 -0
- package/dist/formatter.js +65 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +3 -0
- package/dist/merge.d.ts +39 -0
- package/dist/merge.js +58 -0
- package/dist/react/i18n-context.d.ts +29 -0
- package/dist/react/i18n-context.js +3 -0
- package/dist/react/i18n-provider.d.ts +31 -0
- package/dist/react/i18n-provider.js +38 -0
- package/dist/react/index.d.ts +24 -0
- package/dist/react/index.js +4 -0
- package/dist/react/language-menu.d.ts +15 -0
- package/dist/react/language-menu.js +78 -0
- package/dist/react/use-translation.d.ts +24 -0
- package/dist/react/use-translation.js +16 -0
- package/dist/resolve.d.ts +21 -0
- package/dist/resolve.js +28 -0
- package/dist/types.d.ts +65 -0
- package/dist/types.js +1 -0
- package/package.json +100 -0
- package/src/admin/en.json +423 -0
- package/src/admin/fr.json +423 -0
- package/src/admin/index.test.node.ts +68 -0
- package/src/admin/index.ts +99 -0
- package/src/formatter.test.node.ts +163 -0
- package/src/formatter.ts +166 -0
- package/src/index.ts +47 -0
- package/src/merge.test.node.ts +85 -0
- package/src/merge.ts +135 -0
- package/src/react/i18n-context.ts +45 -0
- package/src/react/i18n-provider.tsx +76 -0
- package/src/react/index.ts +26 -0
- package/src/react/language-menu.tsx +115 -0
- package/src/react/use-translation.ts +45 -0
- package/src/resolve.test.node.ts +128 -0
- package/src/resolve.ts +84 -0
- package/src/types.ts +72 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
10
|
+
|
|
11
|
+
import { createFormatter } from './formatter.js'
|
|
12
|
+
import type { TranslationBundle } from './types.js'
|
|
13
|
+
|
|
14
|
+
const bundle: TranslationBundle = {
|
|
15
|
+
en: {
|
|
16
|
+
'byline-admin': {
|
|
17
|
+
'common.actions.save': 'Save',
|
|
18
|
+
'common.actions.cancel': 'Cancel',
|
|
19
|
+
'list.unread': '{count, plural, one {# unread} other {# unread}}',
|
|
20
|
+
'doc.publishedOn': 'Published on {date, date, medium}',
|
|
21
|
+
'errors.malformed': 'Hello {name', // unclosed brace — ICU parse failure
|
|
22
|
+
},
|
|
23
|
+
'plugin-x': {
|
|
24
|
+
'btn.go': 'Go',
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
fr: {
|
|
28
|
+
'byline-admin': {
|
|
29
|
+
'common.actions.save': 'Enregistrer',
|
|
30
|
+
// intentionally missing 'common.actions.cancel' — exercises default-locale fallback
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('createFormatter — basic lookups', () => {
|
|
36
|
+
it('returns the active-locale translation for a known key', () => {
|
|
37
|
+
const f = createFormatter({ bundle, activeLocale: 'en', defaultLocale: 'en' })
|
|
38
|
+
expect(f.t('byline-admin', 'common.actions.save')).toBe('Save')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('honours the active locale over the default locale', () => {
|
|
42
|
+
const f = createFormatter({ bundle, activeLocale: 'fr', defaultLocale: 'en' })
|
|
43
|
+
expect(f.t('byline-admin', 'common.actions.save')).toBe('Enregistrer')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('looks up across namespaces independently', () => {
|
|
47
|
+
const f = createFormatter({ bundle, activeLocale: 'en', defaultLocale: 'en' })
|
|
48
|
+
expect(f.t('plugin-x', 'btn.go')).toBe('Go')
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('createFormatter — ICU formatting', () => {
|
|
53
|
+
it('formats plurals correctly', () => {
|
|
54
|
+
const f = createFormatter({ bundle, activeLocale: 'en', defaultLocale: 'en' })
|
|
55
|
+
expect(f.t('byline-admin', 'list.unread', { count: 1 })).toBe('1 unread')
|
|
56
|
+
expect(f.t('byline-admin', 'list.unread', { count: 5 })).toBe('5 unread')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('formats dates against the active locale', () => {
|
|
60
|
+
const f = createFormatter({ bundle, activeLocale: 'en', defaultLocale: 'en' })
|
|
61
|
+
const fixedDate = new Date('2026-05-28T00:00:00Z')
|
|
62
|
+
const result = f.t('byline-admin', 'doc.publishedOn', { date: fixedDate })
|
|
63
|
+
// Don't pin the exact phrasing — Intl output varies by Node version /
|
|
64
|
+
// ICU data — but it should contain the year and start with "Published".
|
|
65
|
+
expect(result).toMatch(/^Published on .*2026/)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('createFormatter — fallback chain', () => {
|
|
70
|
+
it('falls back to the default locale when the active locale misses', () => {
|
|
71
|
+
const onMissing = vi.fn()
|
|
72
|
+
const f = createFormatter({
|
|
73
|
+
bundle,
|
|
74
|
+
activeLocale: 'fr',
|
|
75
|
+
defaultLocale: 'en',
|
|
76
|
+
onMissing,
|
|
77
|
+
})
|
|
78
|
+
expect(f.t('byline-admin', 'common.actions.cancel')).toBe('Cancel')
|
|
79
|
+
expect(onMissing).toHaveBeenCalledTimes(1)
|
|
80
|
+
expect(onMissing).toHaveBeenCalledWith({
|
|
81
|
+
activeLocale: 'fr',
|
|
82
|
+
namespace: 'byline-admin',
|
|
83
|
+
key: 'common.actions.cancel',
|
|
84
|
+
fellThroughToKey: false,
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('falls back to the raw key when both locales miss', () => {
|
|
89
|
+
const onMissing = vi.fn()
|
|
90
|
+
const f = createFormatter({
|
|
91
|
+
bundle,
|
|
92
|
+
activeLocale: 'fr',
|
|
93
|
+
defaultLocale: 'en',
|
|
94
|
+
onMissing,
|
|
95
|
+
})
|
|
96
|
+
expect(f.t('byline-admin', 'does.not.exist')).toBe('does.not.exist')
|
|
97
|
+
expect(onMissing).toHaveBeenCalledTimes(1)
|
|
98
|
+
expect(onMissing).toHaveBeenCalledWith({
|
|
99
|
+
activeLocale: 'fr',
|
|
100
|
+
namespace: 'byline-admin',
|
|
101
|
+
key: 'does.not.exist',
|
|
102
|
+
fellThroughToKey: true,
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('reports each missing key only once per formatter instance', () => {
|
|
107
|
+
const onMissing = vi.fn()
|
|
108
|
+
const f = createFormatter({
|
|
109
|
+
bundle,
|
|
110
|
+
activeLocale: 'fr',
|
|
111
|
+
defaultLocale: 'en',
|
|
112
|
+
onMissing,
|
|
113
|
+
})
|
|
114
|
+
// Three calls for the same missing key.
|
|
115
|
+
f.t('byline-admin', 'common.actions.cancel')
|
|
116
|
+
f.t('byline-admin', 'common.actions.cancel')
|
|
117
|
+
f.t('byline-admin', 'common.actions.cancel')
|
|
118
|
+
expect(onMissing).toHaveBeenCalledTimes(1)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('does not fire onMissing when active and default are the same and the key exists', () => {
|
|
122
|
+
const onMissing = vi.fn()
|
|
123
|
+
const f = createFormatter({
|
|
124
|
+
bundle,
|
|
125
|
+
activeLocale: 'en',
|
|
126
|
+
defaultLocale: 'en',
|
|
127
|
+
onMissing,
|
|
128
|
+
})
|
|
129
|
+
f.t('byline-admin', 'common.actions.save')
|
|
130
|
+
expect(onMissing).not.toHaveBeenCalled()
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('createFormatter — malformed messages', () => {
|
|
135
|
+
it('returns the raw key when ICU parsing fails', () => {
|
|
136
|
+
const onMissing = vi.fn()
|
|
137
|
+
const f = createFormatter({
|
|
138
|
+
bundle,
|
|
139
|
+
activeLocale: 'en',
|
|
140
|
+
defaultLocale: 'en',
|
|
141
|
+
onMissing,
|
|
142
|
+
})
|
|
143
|
+
expect(f.t('byline-admin', 'errors.malformed', { name: 'Alice' })).toBe('errors.malformed')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('caches the parse failure so we do not re-parse on repeated calls', () => {
|
|
147
|
+
// No direct way to observe the cache, but the same call repeated should
|
|
148
|
+
// be cheap and produce the same fallback string deterministically.
|
|
149
|
+
const f = createFormatter({ bundle, activeLocale: 'en', defaultLocale: 'en' })
|
|
150
|
+
const first = f.t('byline-admin', 'errors.malformed')
|
|
151
|
+
const second = f.t('byline-admin', 'errors.malformed')
|
|
152
|
+
expect(first).toBe('errors.malformed')
|
|
153
|
+
expect(second).toBe('errors.malformed')
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
describe('createFormatter — exposed metadata', () => {
|
|
158
|
+
it('exposes activeLocale and defaultLocale', () => {
|
|
159
|
+
const f = createFormatter({ bundle, activeLocale: 'fr', defaultLocale: 'en' })
|
|
160
|
+
expect(f.activeLocale).toBe('fr')
|
|
161
|
+
expect(f.defaultLocale).toBe('en')
|
|
162
|
+
})
|
|
163
|
+
})
|
package/src/formatter.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* ICU MessageFormat layer over a `TranslationBundle`. The expensive
|
|
11
|
+
* step in `intl-messageformat` is parsing the message string into its
|
|
12
|
+
* AST; once parsed, formatting against `values` is cheap. We cache the
|
|
13
|
+
* formatter by `(locale, namespace, key)` so a re-render that calls
|
|
14
|
+
* `t('foo')` a hundred times pays the parse cost exactly once.
|
|
15
|
+
*
|
|
16
|
+
* `createFormatter` returns a pure object: no React, no DOM, safe to
|
|
17
|
+
* use from server-side loaders, server functions, and Workers. The
|
|
18
|
+
* React hook in `./react` is a thin context wrapper around this.
|
|
19
|
+
*
|
|
20
|
+
* Fallback chain on lookup:
|
|
21
|
+
* 1. `bundle[activeLocale][namespace][key]`
|
|
22
|
+
* 2. `bundle[defaultLocale][namespace][key]`
|
|
23
|
+
* 3. The raw key — loud-by-default so missing translations are
|
|
24
|
+
* visible in the UI during development.
|
|
25
|
+
*
|
|
26
|
+
* A miss in step 1 (but hit in step 2) invokes `onMissing` once per
|
|
27
|
+
* `(locale, namespace, key)` triple — the formatter dedups internally
|
|
28
|
+
* so repeated renders don't spam the console.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { IntlMessageFormat } from 'intl-messageformat'
|
|
32
|
+
|
|
33
|
+
import type {
|
|
34
|
+
LocaleCode,
|
|
35
|
+
MessageKey,
|
|
36
|
+
Namespace,
|
|
37
|
+
TranslationBundle,
|
|
38
|
+
TranslationValues,
|
|
39
|
+
} from './types.js'
|
|
40
|
+
|
|
41
|
+
export interface FormatterOptions {
|
|
42
|
+
bundle: TranslationBundle
|
|
43
|
+
/** The locale to look up first. */
|
|
44
|
+
activeLocale: LocaleCode
|
|
45
|
+
/** Last-resort lookup before falling back to the raw key. */
|
|
46
|
+
defaultLocale: LocaleCode
|
|
47
|
+
/**
|
|
48
|
+
* Invoked once per `(locale, namespace, key)` triple when step 1
|
|
49
|
+
* misses but step 2 hits. Used by the React provider to emit
|
|
50
|
+
* `console.warn` lines in development. Pass `undefined` to suppress.
|
|
51
|
+
*/
|
|
52
|
+
onMissing?: (event: MissingTranslationEvent) => void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface MissingTranslationEvent {
|
|
56
|
+
activeLocale: LocaleCode
|
|
57
|
+
namespace: Namespace
|
|
58
|
+
key: MessageKey
|
|
59
|
+
/** Whether the default-locale lookup also missed. */
|
|
60
|
+
fellThroughToKey: boolean
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface Formatter {
|
|
64
|
+
/**
|
|
65
|
+
* Format a message for `namespace.key` with `values`. Always returns
|
|
66
|
+
* a string. ICU error paths (malformed message, missing required
|
|
67
|
+
* argument) fall through to the raw key rather than throwing — the
|
|
68
|
+
* admin shell should never crash because of a translation bug.
|
|
69
|
+
*/
|
|
70
|
+
t(namespace: Namespace, key: MessageKey, values?: TranslationValues): string
|
|
71
|
+
readonly activeLocale: LocaleCode
|
|
72
|
+
readonly defaultLocale: LocaleCode
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function createFormatter(options: FormatterOptions): Formatter {
|
|
76
|
+
const { bundle, activeLocale, defaultLocale, onMissing } = options
|
|
77
|
+
|
|
78
|
+
// Cache key shape: `${locale}\0${namespace}\0${key}`. Null bytes
|
|
79
|
+
// separate the components so locales / namespaces / keys that happen
|
|
80
|
+
// to contain `:` or `.` (common in translation tooling) can't collide.
|
|
81
|
+
const formatterCache = new Map<string, IntlMessageFormat | null>()
|
|
82
|
+
const missingReported = new Set<string>()
|
|
83
|
+
|
|
84
|
+
function lookupMessage(
|
|
85
|
+
locale: LocaleCode,
|
|
86
|
+
namespace: Namespace,
|
|
87
|
+
key: MessageKey
|
|
88
|
+
): string | undefined {
|
|
89
|
+
return bundle[locale]?.[namespace]?.[key]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getFormatter(
|
|
93
|
+
locale: LocaleCode,
|
|
94
|
+
namespace: Namespace,
|
|
95
|
+
key: MessageKey
|
|
96
|
+
): IntlMessageFormat | null {
|
|
97
|
+
const cacheKey = `${locale}\0${namespace}\0${key}`
|
|
98
|
+
const cached = formatterCache.get(cacheKey)
|
|
99
|
+
if (cached !== undefined) return cached
|
|
100
|
+
const message = lookupMessage(locale, namespace, key)
|
|
101
|
+
if (message == null) {
|
|
102
|
+
formatterCache.set(cacheKey, null)
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const formatter = new IntlMessageFormat(message, locale)
|
|
107
|
+
formatterCache.set(cacheKey, formatter)
|
|
108
|
+
return formatter
|
|
109
|
+
} catch {
|
|
110
|
+
// Malformed message — cache the null so we don't re-parse on
|
|
111
|
+
// every render. The caller falls through to the next tier.
|
|
112
|
+
formatterCache.set(cacheKey, null)
|
|
113
|
+
return null
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function reportMissing(namespace: Namespace, key: MessageKey, fellThroughToKey: boolean): void {
|
|
118
|
+
if (onMissing == null) return
|
|
119
|
+
const reportKey = `${activeLocale}\0${namespace}\0${key}\0${fellThroughToKey ? '1' : '0'}`
|
|
120
|
+
if (missingReported.has(reportKey)) return
|
|
121
|
+
missingReported.add(reportKey)
|
|
122
|
+
onMissing({ activeLocale, namespace, key, fellThroughToKey })
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
activeLocale,
|
|
127
|
+
defaultLocale,
|
|
128
|
+
t(namespace, key, values) {
|
|
129
|
+
// Tier 1 — active locale.
|
|
130
|
+
const activeFormatter = getFormatter(activeLocale, namespace, key)
|
|
131
|
+
if (activeFormatter != null) {
|
|
132
|
+
return formatSafe(activeFormatter, values, key)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Tier 2 — default locale.
|
|
136
|
+
if (defaultLocale !== activeLocale) {
|
|
137
|
+
const defaultFormatter = getFormatter(defaultLocale, namespace, key)
|
|
138
|
+
if (defaultFormatter != null) {
|
|
139
|
+
reportMissing(namespace, key, false)
|
|
140
|
+
return formatSafe(defaultFormatter, values, key)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Tier 3 — raw key. Loud-by-default.
|
|
145
|
+
reportMissing(namespace, key, true)
|
|
146
|
+
return key
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function formatSafe(
|
|
152
|
+
formatter: IntlMessageFormat,
|
|
153
|
+
values: TranslationValues | undefined,
|
|
154
|
+
fallbackKey: MessageKey
|
|
155
|
+
): string {
|
|
156
|
+
try {
|
|
157
|
+
const out = formatter.format(values as Record<string, unknown> | undefined)
|
|
158
|
+
// `format` can return an array when rich-element interpolation is
|
|
159
|
+
// used. Phase 1 narrows the public API to string values only, so
|
|
160
|
+
// the array path shouldn't fire — but if it does, fall back to the
|
|
161
|
+
// raw key rather than returning `[object Object]`.
|
|
162
|
+
return typeof out === 'string' ? out : fallbackKey
|
|
163
|
+
} catch {
|
|
164
|
+
return fallbackKey
|
|
165
|
+
}
|
|
166
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `@byline/i18n` — admin interface translation primitives.
|
|
11
|
+
*
|
|
12
|
+
* Root entry: pure, React-free, safe to import from server contexts
|
|
13
|
+
* (loaders, server fns, Workers). Carries the type definitions, the
|
|
14
|
+
* `mergeTranslations` registry helper, the ICU formatter, and the
|
|
15
|
+
* locale-resolution cascade.
|
|
16
|
+
*
|
|
17
|
+
* The React surface (`<I18nProvider>`, `useTranslation`,
|
|
18
|
+
* `<LanguageMenu>`) lives at `@byline/i18n/react` — single barrel,
|
|
19
|
+
* single React Context identity, to sidestep the Vite `optimizeDeps`
|
|
20
|
+
* trap that has bitten this codebase before (see `@byline/ui`'s
|
|
21
|
+
* `react.ts` comment).
|
|
22
|
+
*
|
|
23
|
+
* Admin bundles (`adminTranslations(...)`, the English bundle) live
|
|
24
|
+
* at `@byline/i18n/admin`.
|
|
25
|
+
*
|
|
26
|
+
* See `docs/I18N.md` for the architecture.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export { createFormatter } from './formatter.js'
|
|
30
|
+
export { mergeTranslations } from './merge.js'
|
|
31
|
+
export { resolveInterfaceLocale } from './resolve.js'
|
|
32
|
+
export type {
|
|
33
|
+
Formatter,
|
|
34
|
+
FormatterOptions,
|
|
35
|
+
MissingTranslationEvent,
|
|
36
|
+
} from './formatter.js'
|
|
37
|
+
export type { MergeOptions, TranslationCollision } from './merge.js'
|
|
38
|
+
export type { ResolveInterfaceLocaleOptions } from './resolve.js'
|
|
39
|
+
export type {
|
|
40
|
+
LocaleCode,
|
|
41
|
+
LocaleDefinition,
|
|
42
|
+
MessageKey,
|
|
43
|
+
Namespace,
|
|
44
|
+
NamespaceTranslations,
|
|
45
|
+
TranslationBundle,
|
|
46
|
+
TranslationValues,
|
|
47
|
+
} from './types.js'
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
10
|
+
|
|
11
|
+
import { mergeTranslations } from './merge.js'
|
|
12
|
+
import type { TranslationBundle } from './types.js'
|
|
13
|
+
|
|
14
|
+
describe('mergeTranslations', () => {
|
|
15
|
+
it('merges disjoint bundles into a single locale tree', () => {
|
|
16
|
+
const a: TranslationBundle = { en: { ns1: { foo: 'A' } } }
|
|
17
|
+
const b: TranslationBundle = { en: { ns2: { bar: 'B' } } }
|
|
18
|
+
const out = mergeTranslations(a, b)
|
|
19
|
+
expect(out).toEqual({ en: { ns1: { foo: 'A' }, ns2: { bar: 'B' } } })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('later bundles override earlier bundles at the (locale, namespace, key) grain', () => {
|
|
23
|
+
const a: TranslationBundle = { en: { ns: { greeting: 'Hello' } } }
|
|
24
|
+
const b: TranslationBundle = { en: { ns: { greeting: 'Hi there' } } }
|
|
25
|
+
const out = mergeTranslations(a, b)
|
|
26
|
+
expect(out.en?.ns?.greeting).toBe('Hi there')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('does not flag identical-value writes as collisions', () => {
|
|
30
|
+
const onCollision = vi.fn()
|
|
31
|
+
mergeTranslations(
|
|
32
|
+
{ onCollision },
|
|
33
|
+
{ en: { ns: { foo: 'same' } } },
|
|
34
|
+
{ en: { ns: { foo: 'same' } } }
|
|
35
|
+
)
|
|
36
|
+
expect(onCollision).not.toHaveBeenCalled()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('invokes onCollision once per actual override', () => {
|
|
40
|
+
const onCollision = vi.fn()
|
|
41
|
+
mergeTranslations(
|
|
42
|
+
{ onCollision },
|
|
43
|
+
{ en: { ns: { foo: 'A', bar: 'X' } } },
|
|
44
|
+
{ en: { ns: { foo: 'B', baz: 'Y' } } }
|
|
45
|
+
)
|
|
46
|
+
expect(onCollision).toHaveBeenCalledTimes(1)
|
|
47
|
+
expect(onCollision).toHaveBeenCalledWith({
|
|
48
|
+
locale: 'en',
|
|
49
|
+
namespace: 'ns',
|
|
50
|
+
key: 'foo',
|
|
51
|
+
previousValue: 'A',
|
|
52
|
+
nextValue: 'B',
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('is associative — (a, (b, c)) === ((a, b), c)', () => {
|
|
57
|
+
const a: TranslationBundle = { en: { ns: { k1: '1', k2: 'A' } } }
|
|
58
|
+
const b: TranslationBundle = { en: { ns: { k2: 'B', k3: '3' } } }
|
|
59
|
+
const c: TranslationBundle = { en: { ns: { k1: 'X' } } }
|
|
60
|
+
const leftAssoc = mergeTranslations(mergeTranslations(a, b), c)
|
|
61
|
+
const rightAssoc = mergeTranslations(a, mergeTranslations(b, c))
|
|
62
|
+
expect(leftAssoc).toEqual(rightAssoc)
|
|
63
|
+
expect(leftAssoc.en?.ns).toEqual({ k1: 'X', k2: 'B', k3: '3' })
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('accepts undefined inputs and returns identity', () => {
|
|
67
|
+
const a: TranslationBundle = { en: { ns: { foo: 'A' } } }
|
|
68
|
+
expect(mergeTranslations(undefined, a, undefined)).toEqual(a)
|
|
69
|
+
expect(mergeTranslations()).toEqual({})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('produces frozen output at every level', () => {
|
|
73
|
+
const out = mergeTranslations({ en: { ns: { foo: 'A' } } })
|
|
74
|
+
expect(Object.isFrozen(out)).toBe(true)
|
|
75
|
+
expect(Object.isFrozen(out.en)).toBe(true)
|
|
76
|
+
expect(Object.isFrozen(out.en?.ns)).toBe(true)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('distinguishes a MergeOptions argument from a bundle by the onCollision function', () => {
|
|
80
|
+
const onCollision = vi.fn()
|
|
81
|
+
// First arg is MergeOptions (has `onCollision` function); second is a bundle.
|
|
82
|
+
const out = mergeTranslations({ onCollision }, { en: { ns: { foo: 'A' } } })
|
|
83
|
+
expect(out.en?.ns?.foo).toBe('A')
|
|
84
|
+
})
|
|
85
|
+
})
|
package/src/merge.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Merge any number of `TranslationBundle`s into a single immutable
|
|
11
|
+
* registry. Last writer wins at the `(locale, namespace, key)` grain;
|
|
12
|
+
* collisions are reported via the optional `onCollision` callback
|
|
13
|
+
* (called once per replaced key) so callers can wire dev-mode warnings.
|
|
14
|
+
*
|
|
15
|
+
* Associative and deterministic — `merge(a, merge(b, c))` produces the
|
|
16
|
+
* same result as `merge(merge(a, b), c)`. Empty / undefined inputs are
|
|
17
|
+
* accepted and produce identity behaviour, so callers don't need
|
|
18
|
+
* conditional branching when a locale bundle is absent.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { LocaleCode, MessageKey, Namespace, TranslationBundle } from './types.js'
|
|
22
|
+
|
|
23
|
+
export interface MergeOptions {
|
|
24
|
+
/**
|
|
25
|
+
* Invoked once per collision (where a later bundle overrides an
|
|
26
|
+
* earlier bundle's value). Pure side effect — used by callers to
|
|
27
|
+
* emit `console.warn` lines in development. Production hosts can
|
|
28
|
+
* pass `undefined` to suppress.
|
|
29
|
+
*/
|
|
30
|
+
onCollision?: (collision: TranslationCollision) => void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface TranslationCollision {
|
|
34
|
+
locale: LocaleCode
|
|
35
|
+
namespace: Namespace
|
|
36
|
+
key: MessageKey
|
|
37
|
+
/** The value that was already present and is being overwritten. */
|
|
38
|
+
previousValue: string
|
|
39
|
+
/** The value that replaces it. */
|
|
40
|
+
nextValue: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Plain `Record<...>` mirror of `TranslationBundle` used internally
|
|
45
|
+
* while building up the merged registry. The public type is `Readonly`
|
|
46
|
+
* everywhere; we only freeze at the end.
|
|
47
|
+
*/
|
|
48
|
+
type MutableBundle = {
|
|
49
|
+
[locale: LocaleCode]: {
|
|
50
|
+
[namespace: Namespace]: { [key: MessageKey]: string }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function mergeTranslations(
|
|
55
|
+
...bundles: Array<TranslationBundle | undefined>
|
|
56
|
+
): TranslationBundle
|
|
57
|
+
export function mergeTranslations(
|
|
58
|
+
options: MergeOptions,
|
|
59
|
+
...bundles: Array<TranslationBundle | undefined>
|
|
60
|
+
): TranslationBundle
|
|
61
|
+
export function mergeTranslations(
|
|
62
|
+
first: MergeOptions | TranslationBundle | undefined,
|
|
63
|
+
...rest: Array<TranslationBundle | undefined>
|
|
64
|
+
): TranslationBundle {
|
|
65
|
+
let options: MergeOptions = {}
|
|
66
|
+
let bundles: Array<TranslationBundle | undefined>
|
|
67
|
+
if (isMergeOptions(first)) {
|
|
68
|
+
options = first
|
|
69
|
+
bundles = rest
|
|
70
|
+
} else {
|
|
71
|
+
bundles = [first, ...rest]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const out: MutableBundle = {}
|
|
75
|
+
for (const bundle of bundles) {
|
|
76
|
+
if (bundle == null) continue
|
|
77
|
+
for (const locale of Object.keys(bundle)) {
|
|
78
|
+
const localeBundle = bundle[locale]
|
|
79
|
+
if (localeBundle == null) continue
|
|
80
|
+
let targetLocale = out[locale]
|
|
81
|
+
if (targetLocale == null) {
|
|
82
|
+
targetLocale = {}
|
|
83
|
+
out[locale] = targetLocale
|
|
84
|
+
}
|
|
85
|
+
for (const namespace of Object.keys(localeBundle)) {
|
|
86
|
+
const namespaceBundle = localeBundle[namespace]
|
|
87
|
+
if (namespaceBundle == null) continue
|
|
88
|
+
let targetNs = targetLocale[namespace]
|
|
89
|
+
if (targetNs == null) {
|
|
90
|
+
targetNs = {}
|
|
91
|
+
targetLocale[namespace] = targetNs
|
|
92
|
+
}
|
|
93
|
+
for (const key of Object.keys(namespaceBundle)) {
|
|
94
|
+
const nextValue = namespaceBundle[key]
|
|
95
|
+
if (nextValue == null) continue
|
|
96
|
+
const previousValue = targetNs[key]
|
|
97
|
+
if (previousValue !== undefined && previousValue !== nextValue) {
|
|
98
|
+
options.onCollision?.({
|
|
99
|
+
locale,
|
|
100
|
+
namespace,
|
|
101
|
+
key,
|
|
102
|
+
previousValue,
|
|
103
|
+
nextValue,
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
targetNs[key] = nextValue
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return freezeBundle(out)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isMergeOptions(value: unknown): value is MergeOptions {
|
|
116
|
+
// Bundles are locale-keyed at the top level (`{ en: {...}, fr: {...} }`)
|
|
117
|
+
// and their values are objects. MergeOptions has the `onCollision` key —
|
|
118
|
+
// a function — and no nested object-shaped sibling keys. Discriminate by
|
|
119
|
+
// looking for that function specifically; any object that isn't a bundle-
|
|
120
|
+
// shaped record falls through to the bundles path.
|
|
121
|
+
if (value == null || typeof value !== 'object') return false
|
|
122
|
+
const obj = value as Record<string, unknown>
|
|
123
|
+
return typeof obj.onCollision === 'function'
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function freezeBundle(bundle: MutableBundle): TranslationBundle {
|
|
127
|
+
for (const locale of Object.keys(bundle)) {
|
|
128
|
+
const localeBundle = bundle[locale]
|
|
129
|
+
for (const namespace of Object.keys(localeBundle)) {
|
|
130
|
+
Object.freeze(localeBundle[namespace])
|
|
131
|
+
}
|
|
132
|
+
Object.freeze(localeBundle)
|
|
133
|
+
}
|
|
134
|
+
return Object.freeze(bundle) as TranslationBundle
|
|
135
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* React Context module — kept in its own file (rather than inlined in
|
|
11
|
+
* `i18n-provider.tsx`) so both the provider and the hook import the
|
|
12
|
+
* same Context identity by path, and any consumer that imports from
|
|
13
|
+
* `@byline/i18n/react` shares it too. Splitting it across files would
|
|
14
|
+
* be fine on its own; what is NOT fine is splitting it across subpath
|
|
15
|
+
* exports — Vite's `optimizeDeps` can inline a private copy per
|
|
16
|
+
* subpath, breaking provider / consumer identity. The single `/react`
|
|
17
|
+
* subpath collapses both into one module graph.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createContext } from 'react'
|
|
21
|
+
|
|
22
|
+
import type { Formatter } from '../formatter.js'
|
|
23
|
+
import type { LocaleCode, LocaleDefinition, TranslationBundle } from '../types.js'
|
|
24
|
+
|
|
25
|
+
export interface I18nContextValue {
|
|
26
|
+
formatter: Formatter
|
|
27
|
+
bundle: TranslationBundle
|
|
28
|
+
activeLocale: LocaleCode
|
|
29
|
+
defaultLocale: LocaleCode
|
|
30
|
+
/**
|
|
31
|
+
* Permitted locale set with native names. `<LanguageMenu>` renders
|
|
32
|
+
* one row per entry; the resolver in the root package just needs the
|
|
33
|
+
* codes (`localeDefinitions.map(d => d.code)`).
|
|
34
|
+
*/
|
|
35
|
+
localeDefinitions: readonly LocaleDefinition[]
|
|
36
|
+
/**
|
|
37
|
+
* Imperative locale change. Provided by the host adapter — typically
|
|
38
|
+
* calls a server fn that updates the user's stored preference and
|
|
39
|
+
* the cookie, then re-renders the provider with the new locale.
|
|
40
|
+
* When undefined the language switcher renders disabled.
|
|
41
|
+
*/
|
|
42
|
+
setLocale?: (next: LocaleCode) => void | Promise<void>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const I18nContext = createContext<I18nContextValue | null>(null)
|