@open-mercato/shared 0.7.1-develop.7181.1.702cedc42c → 0.7.1-develop.7182.1.789943f937
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/.turbo/turbo-build.log +1 -1
- package/dist/lib/i18n/config.js.map +2 -2
- package/dist/lib/i18n/context.js +14 -3
- package/dist/lib/i18n/context.js.map +2 -2
- package/dist/lib/i18n/locale-label.js +34 -0
- package/dist/lib/i18n/locale-label.js.map +7 -0
- package/dist/lib/i18n/locale-registry.js +73 -0
- package/dist/lib/i18n/locale-registry.js.map +7 -0
- package/dist/lib/i18n/locale-set.js +54 -0
- package/dist/lib/i18n/locale-set.js.map +7 -0
- package/dist/lib/i18n/locale.js +8 -8
- package/dist/lib/i18n/locale.js.map +2 -2
- package/dist/lib/i18n/server.js +22 -7
- package/dist/lib/i18n/server.js.map +3 -3
- package/dist/lib/testing/renderWithProviders.js +2 -2
- package/dist/lib/testing/renderWithProviders.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/i18n/__tests__/__fixtures__/locale-augmentation-missing-key.ts +18 -0
- package/src/lib/i18n/__tests__/__fixtures__/locale-augmentation-ok.ts +28 -0
- package/src/lib/i18n/__tests__/__fixtures__/locale-unaugmented.ts +5 -0
- package/src/lib/i18n/__tests__/context-supported-locales.test.tsx +134 -0
- package/src/lib/i18n/__tests__/detect-locale-narrowed.test.ts +109 -0
- package/src/lib/i18n/__tests__/dictionary-locale-fallback.test.ts +110 -0
- package/src/lib/i18n/__tests__/locale-augmentation.test.ts +56 -0
- package/src/lib/i18n/__tests__/locale-label.test.ts +141 -0
- package/src/lib/i18n/__tests__/locale-registry.test.ts +267 -0
- package/src/lib/i18n/config.ts +38 -1
- package/src/lib/i18n/config.typecheck.tsx +60 -0
- package/src/lib/i18n/context.tsx +30 -3
- package/src/lib/i18n/locale-label.ts +80 -0
- package/src/lib/i18n/locale-registry.ts +142 -0
- package/src/lib/i18n/locale-set.ts +98 -0
- package/src/lib/i18n/locale.ts +22 -6
- package/src/lib/i18n/server.ts +51 -7
- package/src/lib/testing/renderWithProviders.tsx +4 -2
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Fixture: what a downstream application writes to serve a language the
|
|
2
|
+
// platform does not ship. Compiled by `locale-augmentation.test.ts`, never by
|
|
3
|
+
// the package typecheck (tsconfig excludes `__tests__`), because the
|
|
4
|
+
// augmentation would otherwise widen `Locale` for the whole package.
|
|
5
|
+
import type { Locale } from '../../config'
|
|
6
|
+
|
|
7
|
+
declare module '../../config' {
|
|
8
|
+
interface LocaleRegistry {
|
|
9
|
+
cs: true
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// The new code is now a valid Locale — no patching of node_modules required.
|
|
14
|
+
export const appAddedLocale: Locale = 'cs'
|
|
15
|
+
|
|
16
|
+
// The shipped ones still are.
|
|
17
|
+
export const shippedLocale: Locale = 'pl'
|
|
18
|
+
|
|
19
|
+
// And exhaustiveness is preserved over the app's OWN extended set: this map
|
|
20
|
+
// compiles only because it covers all six.
|
|
21
|
+
export const labels: Record<Locale, string> = {
|
|
22
|
+
en: 'English',
|
|
23
|
+
pl: 'Polski',
|
|
24
|
+
es: 'Español',
|
|
25
|
+
de: 'Deutsch',
|
|
26
|
+
ko: '한국어',
|
|
27
|
+
cs: 'čeština',
|
|
28
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
import { I18nProvider, useLocaleLocked, useSupportedLocales } from '../context'
|
|
4
|
+
import { clearRegisteredLocales, registerLocales } from '../locale-registry'
|
|
5
|
+
import type { Locale } from '../config'
|
|
6
|
+
|
|
7
|
+
function LocaleList() {
|
|
8
|
+
const supported = useSupportedLocales()
|
|
9
|
+
return React.createElement('span', null, supported.join(','))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function LocaleLockedFlag() {
|
|
13
|
+
return React.createElement('span', null, String(useLocaleLocked()))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function render(props: { locale: Locale; dict: {}; supportedLocales?: readonly Locale[] }) {
|
|
17
|
+
return renderToStaticMarkup(
|
|
18
|
+
React.createElement(I18nProvider, props, React.createElement(LocaleList)),
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('useSupportedLocales', () => {
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
clearRegisteredLocales()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('falls back to the shipped set when the provider is given no prop', () => {
|
|
28
|
+
// Every existing test mounts `<I18nProvider locale dict />` with no
|
|
29
|
+
// `supportedLocales`; that must keep working and keep today's list.
|
|
30
|
+
expect(render({ locale: 'en', dict: {} })).toContain('en,pl,es,de,ko')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('reflects locales the app registered at runtime', () => {
|
|
34
|
+
registerLocales(['cs'])
|
|
35
|
+
|
|
36
|
+
expect(render({ locale: 'en', dict: {} })).toContain('en,pl,es,de,ko,cs')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('prefers the server-resolved prop over the process-local registry', () => {
|
|
40
|
+
// This is the whole point of the prop: a client bundle cannot read tenant
|
|
41
|
+
// configuration, so the server narrows the set and hands it down.
|
|
42
|
+
registerLocales(['cs'])
|
|
43
|
+
|
|
44
|
+
const markup = render({
|
|
45
|
+
locale: 'en',
|
|
46
|
+
dict: {},
|
|
47
|
+
supportedLocales: ['en', 'pl'] as readonly Locale[],
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
expect(markup).toContain('en,pl')
|
|
51
|
+
expect(markup).not.toContain('cs')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('carries a locale the platform does not ship down to the client', () => {
|
|
55
|
+
const markup = render({
|
|
56
|
+
locale: 'cs' as Locale,
|
|
57
|
+
dict: {},
|
|
58
|
+
supportedLocales: ['en', 'cs'] as readonly Locale[],
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
expect(markup).toContain('en,cs')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('returns the shipped set outside any provider', () => {
|
|
65
|
+
expect(renderToStaticMarkup(React.createElement(LocaleList))).toContain('en,pl,es,de,ko')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('inherits the served set from an enclosing provider when the prop is omitted', () => {
|
|
69
|
+
// The backend layout mounts a second provider inside the root layout's. An
|
|
70
|
+
// omitted prop there must mean "unchanged", not "reset to the registry",
|
|
71
|
+
// or the whole admin subtree silently loses the tenant narrowing.
|
|
72
|
+
const markup = renderToStaticMarkup(
|
|
73
|
+
React.createElement(
|
|
74
|
+
I18nProvider,
|
|
75
|
+
{ locale: 'en' as Locale, dict: {}, supportedLocales: ['en', 'pl'] as readonly Locale[] },
|
|
76
|
+
React.createElement(
|
|
77
|
+
I18nProvider,
|
|
78
|
+
{ locale: 'en' as Locale, dict: {} },
|
|
79
|
+
React.createElement(LocaleList),
|
|
80
|
+
),
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
expect(markup).toContain('en,pl')
|
|
85
|
+
expect(markup).not.toContain('es')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('lets an inner provider override the inherited set', () => {
|
|
89
|
+
const markup = renderToStaticMarkup(
|
|
90
|
+
React.createElement(
|
|
91
|
+
I18nProvider,
|
|
92
|
+
{ locale: 'en' as Locale, dict: {}, supportedLocales: ['en', 'pl'] as readonly Locale[] },
|
|
93
|
+
React.createElement(
|
|
94
|
+
I18nProvider,
|
|
95
|
+
{ locale: 'en' as Locale, dict: {}, supportedLocales: ['en', 'de'] as readonly Locale[] },
|
|
96
|
+
React.createElement(LocaleList),
|
|
97
|
+
),
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
expect(markup).toContain('en,de')
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe('useLocaleLocked', () => {
|
|
106
|
+
it('inherits the locked flag from an enclosing provider when the prop is omitted', () => {
|
|
107
|
+
const markup = renderToStaticMarkup(
|
|
108
|
+
React.createElement(
|
|
109
|
+
I18nProvider,
|
|
110
|
+
{ locale: 'en' as Locale, dict: {}, localeLocked: true },
|
|
111
|
+
React.createElement(
|
|
112
|
+
I18nProvider,
|
|
113
|
+
{ locale: 'en' as Locale, dict: {} },
|
|
114
|
+
React.createElement(LocaleLockedFlag),
|
|
115
|
+
),
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
expect(markup).toContain('true')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('is false outside any provider and for a provider that sets nothing', () => {
|
|
123
|
+
expect(renderToStaticMarkup(React.createElement(LocaleLockedFlag))).toContain('false')
|
|
124
|
+
expect(
|
|
125
|
+
renderToStaticMarkup(
|
|
126
|
+
React.createElement(
|
|
127
|
+
I18nProvider,
|
|
128
|
+
{ locale: 'en' as Locale, dict: {} },
|
|
129
|
+
React.createElement(LocaleLockedFlag),
|
|
130
|
+
),
|
|
131
|
+
),
|
|
132
|
+
).toContain('false')
|
|
133
|
+
})
|
|
134
|
+
})
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { Locale } from '../config'
|
|
2
|
+
|
|
3
|
+
// `detectLocale` reaches for `next/headers` through a dynamic import, so the
|
|
4
|
+
// mock has to be in place before the module under test is loaded.
|
|
5
|
+
const cookieStore = { value: undefined as string | undefined }
|
|
6
|
+
const headerStore = { acceptLanguage: '' }
|
|
7
|
+
|
|
8
|
+
jest.mock(
|
|
9
|
+
'next/headers',
|
|
10
|
+
() => ({
|
|
11
|
+
cookies: async () => ({
|
|
12
|
+
get: (name: string) =>
|
|
13
|
+
name === 'locale' && cookieStore.value ? { value: cookieStore.value } : undefined,
|
|
14
|
+
}),
|
|
15
|
+
headers: async () => ({
|
|
16
|
+
get: (name: string) =>
|
|
17
|
+
name.toLowerCase() === 'accept-language' ? headerStore.acceptLanguage : null,
|
|
18
|
+
}),
|
|
19
|
+
}),
|
|
20
|
+
{ virtual: true },
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
import { detectLocale } from '../server'
|
|
24
|
+
import { clearRegisteredLocales, registerLocales } from '../locale-registry'
|
|
25
|
+
|
|
26
|
+
const NARROWED: readonly Locale[] = ['pl', 'de']
|
|
27
|
+
|
|
28
|
+
describe('detectLocale with a narrowed supported set', () => {
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
cookieStore.value = undefined
|
|
31
|
+
headerStore.acceptLanguage = ''
|
|
32
|
+
delete process.env.OM_FORCE_LOCALE
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
clearRegisteredLocales()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('honours a cookie that is inside the narrowed set', async () => {
|
|
40
|
+
cookieStore.value = 'de'
|
|
41
|
+
|
|
42
|
+
await expect(detectLocale({ supportedLocales: NARROWED })).resolves.toBe('de')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('ignores a cookie that the tenant has since deselected', async () => {
|
|
46
|
+
cookieStore.value = 'es'
|
|
47
|
+
headerStore.acceptLanguage = 'pl-PL,pl;q=0.9'
|
|
48
|
+
|
|
49
|
+
await expect(detectLocale({ supportedLocales: NARROWED })).resolves.toBe('pl')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('ignores an Accept-Language match outside the narrowed set', async () => {
|
|
53
|
+
headerStore.acceptLanguage = 'es-ES,es;q=0.9'
|
|
54
|
+
|
|
55
|
+
// `es` is a shipped locale, so `resolveLocaleFromAcceptLanguage` matches it;
|
|
56
|
+
// the narrowed set is what rejects it.
|
|
57
|
+
await expect(detectLocale({ supportedLocales: NARROWED })).resolves.not.toBe('es')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('falls through to a lower-ranked header entry that is inside the set', async () => {
|
|
61
|
+
// Header ranks `es` first, but the tenant does not serve it. Matching the
|
|
62
|
+
// header against the process-wide set and re-checking afterwards would
|
|
63
|
+
// discard the whole header on the `es` match and land on the default; the
|
|
64
|
+
// narrowed set has to reach the matcher itself for `de` to win.
|
|
65
|
+
headerStore.acceptLanguage = 'es-ES,es;q=0.9,de;q=0.8'
|
|
66
|
+
|
|
67
|
+
await expect(detectLocale({ supportedLocales: NARROWED })).resolves.toBe('de')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('never returns a locale outside the set it was given', async () => {
|
|
71
|
+
// The regression this guards: the fallback used to be an unconditional
|
|
72
|
+
// `return defaultLocale`, which rendered an English page under a switcher
|
|
73
|
+
// offering only Polish and German.
|
|
74
|
+
headerStore.acceptLanguage = 'en-US,en;q=0.9'
|
|
75
|
+
|
|
76
|
+
const detected = await detectLocale({ supportedLocales: NARROWED })
|
|
77
|
+
|
|
78
|
+
expect(NARROWED).toContain(detected)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('falls back to the default locale when it is in the set', async () => {
|
|
82
|
+
headerStore.acceptLanguage = 'fr-FR,fr;q=0.9'
|
|
83
|
+
|
|
84
|
+
await expect(detectLocale({ supportedLocales: ['en', 'pl'] })).resolves.toBe('en')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('keeps the previous behaviour when no set is passed', async () => {
|
|
88
|
+
headerStore.acceptLanguage = 'es-ES,es;q=0.9'
|
|
89
|
+
|
|
90
|
+
await expect(detectLocale()).resolves.toBe('es')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('still lets OM_FORCE_LOCALE win over the narrowed set', async () => {
|
|
94
|
+
process.env.OM_FORCE_LOCALE = 'ko'
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
await expect(detectLocale({ supportedLocales: NARROWED })).resolves.toBe('ko')
|
|
98
|
+
} finally {
|
|
99
|
+
delete process.env.OM_FORCE_LOCALE
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('detects a locale the app registered but the platform does not ship', async () => {
|
|
104
|
+
registerLocales(['cs'])
|
|
105
|
+
cookieStore.value = 'cs'
|
|
106
|
+
|
|
107
|
+
await expect(detectLocale({ supportedLocales: ['en', 'cs'] as readonly Locale[] })).resolves.toBe('cs')
|
|
108
|
+
})
|
|
109
|
+
})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import {
|
|
2
|
+
loadDictionary,
|
|
3
|
+
registerModules,
|
|
4
|
+
registerAppDictionaryLoader,
|
|
5
|
+
invalidateDictionaryCache,
|
|
6
|
+
} from '../server'
|
|
7
|
+
import { clearRegisteredLocales, registerLocales } from '../locale-registry'
|
|
8
|
+
import type { Locale } from '../config'
|
|
9
|
+
|
|
10
|
+
// Dictionaries keyed by locale, standing in for the app's `i18n/<locale>.json`
|
|
11
|
+
// files. `cs` deliberately has no entry, which is the situation an operator
|
|
12
|
+
// creates by enabling a language nobody has translated yet.
|
|
13
|
+
const APP_DICTIONARIES: Record<string, Record<string, unknown>> = {
|
|
14
|
+
en: { greeting: 'Hello', onlyInEnglish: 'English only' },
|
|
15
|
+
pl: { greeting: 'Cześć' },
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('dictionary fallback for locales the platform does not ship', () => {
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
clearRegisteredLocales()
|
|
21
|
+
registerModules([] as any)
|
|
22
|
+
registerAppDictionaryLoader(async (locale: Locale) => APP_DICTIONARIES[locale] ?? {})
|
|
23
|
+
invalidateDictionaryCache()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
clearRegisteredLocales()
|
|
28
|
+
invalidateDictionaryCache()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('shipped locales keep their exact previous behaviour', () => {
|
|
32
|
+
it('does not layer English underneath another shipped locale', async () => {
|
|
33
|
+
const pl = await loadDictionary('pl')
|
|
34
|
+
|
|
35
|
+
expect(pl).toEqual({ greeting: 'Cześć' })
|
|
36
|
+
// The key that exists only in English must NOT leak into Polish — that
|
|
37
|
+
// would be a behaviour change for locales that ship today.
|
|
38
|
+
expect(pl).not.toHaveProperty('onlyInEnglish')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('leaves the default locale itself untouched', async () => {
|
|
42
|
+
await expect(loadDictionary('en')).resolves.toEqual({
|
|
43
|
+
greeting: 'Hello',
|
|
44
|
+
onlyInEnglish: 'English only',
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('returns an empty dictionary for a shipped locale with no strings', async () => {
|
|
49
|
+
await expect(loadDictionary('de')).resolves.toEqual({})
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('an app-registered locale', () => {
|
|
54
|
+
it('falls back to the default locale instead of rendering raw keys', async () => {
|
|
55
|
+
registerLocales(['cs'])
|
|
56
|
+
|
|
57
|
+
await expect(loadDictionary('cs' as Locale)).resolves.toEqual({
|
|
58
|
+
greeting: 'Hello',
|
|
59
|
+
onlyInEnglish: 'English only',
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('overlays its own translations on top of the default ones', async () => {
|
|
64
|
+
registerLocales(['cs'])
|
|
65
|
+
APP_DICTIONARIES.cs = { greeting: 'Ahoj' }
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
await expect(loadDictionary('cs' as Locale)).resolves.toEqual({
|
|
69
|
+
greeting: 'Ahoj',
|
|
70
|
+
onlyInEnglish: 'English only',
|
|
71
|
+
})
|
|
72
|
+
} finally {
|
|
73
|
+
delete APP_DICTIONARIES.cs
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('lets module translations win over the default-locale base layer', async () => {
|
|
78
|
+
registerLocales(['cs'])
|
|
79
|
+
registerModules([{ translations: { cs: { greeting: 'Ahoj z modulu' } } }] as any)
|
|
80
|
+
|
|
81
|
+
const cs = await loadDictionary('cs' as Locale)
|
|
82
|
+
|
|
83
|
+
expect(cs.greeting).toBe('Ahoj z modulu')
|
|
84
|
+
expect(cs.onlyInEnglish).toBe('English only')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('is still memoized per locale', async () => {
|
|
88
|
+
registerLocales(['cs'])
|
|
89
|
+
|
|
90
|
+
const first = await loadDictionary('cs' as Locale)
|
|
91
|
+
const second = await loadDictionary('cs' as Locale)
|
|
92
|
+
|
|
93
|
+
expect(first).toBe(second)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('does not mutate the default-locale dictionary it copies from', async () => {
|
|
97
|
+
registerLocales(['cs'])
|
|
98
|
+
APP_DICTIONARIES.cs = { greeting: 'Ahoj' }
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
await loadDictionary('cs' as Locale)
|
|
102
|
+
const en = await loadDictionary('en')
|
|
103
|
+
|
|
104
|
+
expect(en.greeting).toBe('Hello')
|
|
105
|
+
} finally {
|
|
106
|
+
delete APP_DICTIONARIES.cs
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import * as path from 'node:path'
|
|
2
|
+
import * as ts from 'typescript'
|
|
3
|
+
|
|
4
|
+
// The extension point is a TYPE-level contract, so the only honest way to test
|
|
5
|
+
// it is to compile a downstream-app fixture and read the diagnostics. Jest runs
|
|
6
|
+
// with `isolatedModules: true` and therefore never type-checks, and the package
|
|
7
|
+
// typecheck cannot cover these fixtures either: a `declare module` augmentation
|
|
8
|
+
// is program-global, so including them would widen `Locale` for every other file
|
|
9
|
+
// in the package (and break `config.typecheck.tsx`). Hence a real, isolated
|
|
10
|
+
// `ts.createProgram` per fixture.
|
|
11
|
+
const FIXTURES_DIR = path.join(__dirname, '__fixtures__')
|
|
12
|
+
|
|
13
|
+
// Mirrors `tsconfig.base.json`; only the options that affect these diagnostics.
|
|
14
|
+
const COMPILER_OPTIONS: ts.CompilerOptions = {
|
|
15
|
+
target: ts.ScriptTarget.ES2022,
|
|
16
|
+
module: ts.ModuleKind.ESNext,
|
|
17
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
18
|
+
strict: true,
|
|
19
|
+
skipLibCheck: true,
|
|
20
|
+
noEmit: true,
|
|
21
|
+
esModuleInterop: true,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function compile(fixture: string): ts.Diagnostic[] {
|
|
25
|
+
const program = ts.createProgram([path.join(FIXTURES_DIR, fixture)], COMPILER_OPTIONS)
|
|
26
|
+
return [...program.getSemanticDiagnostics(), ...program.getSyntacticDiagnostics()]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function messages(diagnostics: ts.Diagnostic[]): string[] {
|
|
30
|
+
return diagnostics.map((d) => ts.flattenDiagnosticMessageText(d.messageText, ' '))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('extending Locale from a downstream application', () => {
|
|
34
|
+
it('lets an app add a locale the platform does not ship, with no patching', () => {
|
|
35
|
+
const diagnostics = compile('locale-augmentation-ok.ts')
|
|
36
|
+
|
|
37
|
+
expect(messages(diagnostics)).toEqual([])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('still rejects an unshipped code when the app has NOT opted in', () => {
|
|
41
|
+
const diagnostics = compile('locale-unaugmented.ts')
|
|
42
|
+
|
|
43
|
+
expect(diagnostics).toHaveLength(1)
|
|
44
|
+
expect(messages(diagnostics)[0]).toContain('"cs"')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('preserves exhaustiveness over the app’s own extended set', () => {
|
|
48
|
+
// An app that widens Locale keeps its drift guard: a Record<Locale, …> that
|
|
49
|
+
// omits the locale it just added must not compile. This is the property that
|
|
50
|
+
// widening `Locale` to `string` would have silently destroyed.
|
|
51
|
+
const diagnostics = compile('locale-augmentation-missing-key.ts')
|
|
52
|
+
|
|
53
|
+
expect(diagnostics).toHaveLength(1)
|
|
54
|
+
expect(messages(diagnostics)[0]).toContain('cs')
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { resolveLocaleLabel } from '../locale-label'
|
|
2
|
+
import type { TranslateFn } from '../context'
|
|
3
|
+
import * as fs from 'node:fs'
|
|
4
|
+
import * as path from 'node:path'
|
|
5
|
+
|
|
6
|
+
// Walks the transitive *value* imports of a module (`import type` excluded, since
|
|
7
|
+
// those vanish at compile time) and returns the module basenames reached. A
|
|
8
|
+
// source scan rather than a `require.cache` diff because Jest's module registry
|
|
9
|
+
// is already warm from this file's own imports by the time any test runs.
|
|
10
|
+
function collectValueImports(entry: string, from = __dirname, seen = new Set<string>()): string[] {
|
|
11
|
+
const resolved = path.resolve(from, `${entry}.ts`)
|
|
12
|
+
const basename = path.basename(resolved, '.ts')
|
|
13
|
+
if (seen.has(basename) || !fs.existsSync(resolved)) return [...seen]
|
|
14
|
+
seen.add(basename)
|
|
15
|
+
|
|
16
|
+
const source = fs.readFileSync(resolved, 'utf8')
|
|
17
|
+
const importPattern = /^import\s+(?!type\s)(?:[^'"]*?\sfrom\s+)?['"](\.[^'"]+)['"]/gm
|
|
18
|
+
for (const match of source.matchAll(importPattern)) {
|
|
19
|
+
collectValueImports(match[1]!, path.dirname(resolved), seen)
|
|
20
|
+
}
|
|
21
|
+
return [...seen]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Mirrors how `LanguageSwitcher` and `PayPage` call it: a real translator that
|
|
25
|
+
// falls back to the inline default when the key is absent from the dictionary.
|
|
26
|
+
function makeTranslator(dict: Record<string, string> = {}): TranslateFn {
|
|
27
|
+
return ((key: string, fallbackOrParams?: unknown) => {
|
|
28
|
+
const fallback = typeof fallbackOrParams === 'string' ? fallbackOrParams : undefined
|
|
29
|
+
return dict[key] ?? fallback ?? key
|
|
30
|
+
}) as TranslateFn
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('resolveLocaleLabel', () => {
|
|
34
|
+
describe('shipped locales without a translator (ProfileDropdown behaviour)', () => {
|
|
35
|
+
// These are the exact strings `ProfileDropdown` rendered from its hardcoded
|
|
36
|
+
// `Record<Locale, string>` before it was replaced — a visual regression guard.
|
|
37
|
+
it.each([
|
|
38
|
+
['en', 'English'],
|
|
39
|
+
['de', 'Deutsch'],
|
|
40
|
+
['es', 'Español'],
|
|
41
|
+
['pl', 'Polski'],
|
|
42
|
+
['ko', '한국어'],
|
|
43
|
+
])('renders %s as the endonym %s', (locale, expected) => {
|
|
44
|
+
expect(resolveLocaleLabel(locale)).toBe(expected)
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('shipped locales with a translator (LanguageSwitcher behaviour)', () => {
|
|
49
|
+
// The exact `t(key, fallback)` pairs the switcher used before the refactor.
|
|
50
|
+
it.each([
|
|
51
|
+
['en', 'common.languages.english', 'English'],
|
|
52
|
+
['pl', 'common.languages.polish', 'Polski'],
|
|
53
|
+
['es', 'common.languages.spanish', 'Español'],
|
|
54
|
+
['de', 'common.languages.german', 'Deutsch'],
|
|
55
|
+
['ko', 'common.languages.korean', '한국어'],
|
|
56
|
+
])('asks for %s via %s and falls back to %s', (locale, key, fallback) => {
|
|
57
|
+
const seen: string[] = []
|
|
58
|
+
const t = ((k: string, f?: unknown) => {
|
|
59
|
+
seen.push(k)
|
|
60
|
+
return typeof f === 'string' ? f : k
|
|
61
|
+
}) as TranslateFn
|
|
62
|
+
|
|
63
|
+
expect(resolveLocaleLabel(locale, t)).toBe(fallback)
|
|
64
|
+
expect(seen).toEqual([key])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('prefers the dictionary value, so a German UI shows localized names', () => {
|
|
68
|
+
const t = makeTranslator({
|
|
69
|
+
'common.languages.polish': 'Polnisch',
|
|
70
|
+
'common.languages.english': 'Englisch',
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
expect(resolveLocaleLabel('pl', t)).toBe('Polnisch')
|
|
74
|
+
expect(resolveLocaleLabel('en', t)).toBe('Englisch')
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('locales the platform does not ship', () => {
|
|
79
|
+
it('falls back to the endonym from Intl', () => {
|
|
80
|
+
expect(resolveLocaleLabel('cs')).toBe('čeština')
|
|
81
|
+
expect(resolveLocaleLabel('fr')).toBe('français')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('uses the endonym even when a translator is supplied', () => {
|
|
85
|
+
// There is no `common.languages.*` key for an app-added locale, so the
|
|
86
|
+
// translator cannot help and must not produce a raw key.
|
|
87
|
+
expect(resolveLocaleLabel('cs', makeTranslator())).toBe('čeština')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('never renders blank or a raw key', () => {
|
|
91
|
+
for (const locale of ['cs', 'fr', 'zh', 'sw', 'zzz']) {
|
|
92
|
+
const label = resolveLocaleLabel(locale)
|
|
93
|
+
expect(label.length).toBeGreaterThan(0)
|
|
94
|
+
expect(label).not.toContain('common.languages')
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('degradation ladder', () => {
|
|
100
|
+
it('falls back to the uppercased code for an unknown code', () => {
|
|
101
|
+
// `zzz` is neither an ISO 639-1 entry nor known to Intl.
|
|
102
|
+
expect(resolveLocaleLabel('zzz')).toBe('ZZZ')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('falls back to the uppercased code when Intl has no data', () => {
|
|
106
|
+
const displayNames = jest
|
|
107
|
+
.spyOn(Intl, 'DisplayNames')
|
|
108
|
+
.mockImplementation((() => ({ of: () => undefined })) as unknown as typeof Intl.DisplayNames)
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
// `za` (Zhuang) is in the ISO 639-1 catalogue, but this module
|
|
112
|
+
// deliberately does not consult it — see the import-graph test below.
|
|
113
|
+
expect(resolveLocaleLabel('za')).toBe('ZA')
|
|
114
|
+
} finally {
|
|
115
|
+
displayNames.mockRestore()
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('survives Intl throwing on a malformed code', () => {
|
|
120
|
+
expect(() => resolveLocaleLabel('!!not a tag!!')).not.toThrow()
|
|
121
|
+
expect(resolveLocaleLabel('!!not a tag!!')).toBe('!!NOT A TAG!!')
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
describe('client bundle weight', () => {
|
|
126
|
+
// Every caller of `resolveLocaleLabel` is a client component, including the
|
|
127
|
+
// public checkout pay page. `iso639.ts` is a 186-entry table with a
|
|
128
|
+
// module-scope `Set` that no bundler can tree-shake, so pulling it in here
|
|
129
|
+
// would ship the whole language catalogue to that route.
|
|
130
|
+
it('does not pull the ISO 639-1 catalogue into its import graph', () => {
|
|
131
|
+
expect(collectValueImports('../locale-label')).not.toContain('iso639')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// Guards the guard: a walker that silently found nothing would pass the
|
|
135
|
+
// assertion above for the wrong reason. `locale-registry` does import the
|
|
136
|
+
// catalogue, transitively proving the traversal reaches real edges.
|
|
137
|
+
it('detects the catalogue where it is genuinely imported', () => {
|
|
138
|
+
expect(collectValueImports('../locale-registry')).toContain('iso639')
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
})
|