@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.
Files changed (44) hide show
  1. package/LICENSE +373 -0
  2. package/README.md +166 -0
  3. package/dist/admin/en.js +424 -0
  4. package/dist/admin/fr.js +424 -0
  5. package/dist/admin/index.d.ts +44 -0
  6. package/dist/admin/index.js +27 -0
  7. package/dist/formatter.d.ts +40 -0
  8. package/dist/formatter.js +65 -0
  9. package/dist/index.d.ts +33 -0
  10. package/dist/index.js +3 -0
  11. package/dist/merge.d.ts +39 -0
  12. package/dist/merge.js +58 -0
  13. package/dist/react/i18n-context.d.ts +29 -0
  14. package/dist/react/i18n-context.js +3 -0
  15. package/dist/react/i18n-provider.d.ts +31 -0
  16. package/dist/react/i18n-provider.js +38 -0
  17. package/dist/react/index.d.ts +24 -0
  18. package/dist/react/index.js +4 -0
  19. package/dist/react/language-menu.d.ts +15 -0
  20. package/dist/react/language-menu.js +78 -0
  21. package/dist/react/use-translation.d.ts +24 -0
  22. package/dist/react/use-translation.js +16 -0
  23. package/dist/resolve.d.ts +21 -0
  24. package/dist/resolve.js +28 -0
  25. package/dist/types.d.ts +65 -0
  26. package/dist/types.js +1 -0
  27. package/package.json +100 -0
  28. package/src/admin/en.json +423 -0
  29. package/src/admin/fr.json +423 -0
  30. package/src/admin/index.test.node.ts +68 -0
  31. package/src/admin/index.ts +99 -0
  32. package/src/formatter.test.node.ts +163 -0
  33. package/src/formatter.ts +166 -0
  34. package/src/index.ts +47 -0
  35. package/src/merge.test.node.ts +85 -0
  36. package/src/merge.ts +135 -0
  37. package/src/react/i18n-context.ts +45 -0
  38. package/src/react/i18n-provider.tsx +76 -0
  39. package/src/react/index.ts +26 -0
  40. package/src/react/language-menu.tsx +115 -0
  41. package/src/react/use-translation.ts +45 -0
  42. package/src/resolve.test.node.ts +128 -0
  43. package/src/resolve.ts +84 -0
  44. 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
+ })
@@ -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)