qdadm 1.13.0 → 1.19.2
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/package.json +8 -4
- package/src/chain/ActiveStack.ts +79 -98
- package/src/chain/StackHydrator.ts +3 -2
- package/src/chain/index.ts +7 -1
- package/src/components/QdadmRoot.vue +52 -0
- package/src/components/edit/FormActions.vue +9 -6
- package/src/components/edit/LookupPickerDialog.vue +6 -3
- package/src/components/index.ts +6 -0
- package/src/composables/useEntityItemFormPage.ts +1 -0
- package/src/composables/useEntityItemShowPage.ts +1 -0
- package/src/composables/useFieldManager.ts +100 -3
- package/src/composables/useListPage.ts +50 -59
- package/src/composables/useListPage.utils.ts +101 -0
- package/src/composables/useNavigation.ts +26 -3
- package/src/composables/useOptionsLookup.ts +5 -1
- package/src/gen/generateManagers.test.js +27 -0
- package/src/gen/generateManagers.ts +12 -0
- package/src/hooks/HookRegistry.ts +14 -435
- package/src/i18n/I18n.ts +344 -0
- package/src/i18n/IncrementalDomainProvider.ts +153 -0
- package/src/i18n/InlineTranslationProvider.ts +4 -0
- package/src/i18n/LazyTranslationProvider.ts +102 -0
- package/src/i18n/MessagesRegistry.ts +4 -0
- package/src/i18n/Resolver.ts +4 -0
- package/src/i18n/__tests__/I18n.test.ts +169 -0
- package/src/i18n/__tests__/IncrementalDomainProvider.test.ts +146 -0
- package/src/i18n/__tests__/LazyTranslationProvider.test.ts +100 -0
- package/src/i18n/__tests__/Resolver.test.ts +271 -0
- package/src/i18n/defaults/DefaultCoreProvider.ts +28 -0
- package/src/i18n/defaults/core.en.yml +55 -0
- package/src/i18n/defaults/core.fr.yml +55 -0
- package/src/i18n/index.ts +55 -0
- package/src/i18n/loaders/raw-modules.d.ts +15 -0
- package/src/i18n/loaders/yaml.ts +35 -0
- package/src/i18n/strategies.ts +4 -0
- package/src/i18n/types.ts +34 -0
- package/src/i18n/useI18n.ts +34 -0
- package/src/index.ts +37 -0
- package/src/kernel/EventRouter.ts +17 -300
- package/src/kernel/Kernel.i18n.ts +29 -0
- package/src/kernel/Kernel.modules.ts +6 -0
- package/src/kernel/Kernel.registries.ts +10 -2
- package/src/kernel/Kernel.routing.ts +43 -1
- package/src/kernel/Kernel.ts +43 -0
- package/src/kernel/Kernel.types.ts +52 -1
- package/src/kernel/Kernel.vue.ts +121 -15
- package/src/kernel/KernelContext.entities.ts +80 -0
- package/src/kernel/KernelContext.events.ts +57 -0
- package/src/kernel/KernelContext.i18n.ts +37 -0
- package/src/kernel/KernelContext.permissions.ts +38 -0
- package/src/kernel/KernelContext.routing.ts +280 -0
- package/src/kernel/KernelContext.ts +125 -834
- package/src/kernel/KernelContext.types.ts +173 -0
- package/src/kernel/KernelContext.zones.ts +54 -0
- package/src/kernel/SSEBridge.ts +7 -362
- package/src/kernel/SignalBus.ts +24 -148
- package/src/modules/debug/AuthCollector.ts +48 -1
- package/src/modules/debug/Collector.ts +16 -302
- package/src/modules/debug/DebugBridge.ts +10 -171
- package/src/modules/debug/DebugModule.ts +35 -5
- package/src/modules/debug/EntitiesCollector.ts +97 -1
- package/src/modules/debug/ErrorCollector.ts +2 -77
- package/src/modules/debug/I18nCollector.ts +9 -0
- package/src/modules/debug/LocalStorageAdapter.ts +3 -147
- package/src/modules/debug/RouterCollector.ts +101 -1
- package/src/modules/debug/SignalCollector.ts +2 -150
- package/src/modules/debug/ToastCollector.ts +2 -91
- package/src/modules/debug/ZonesCollector.ts +93 -1
- package/src/modules/debug/components/DebugBar.vue +19 -775
- package/src/modules/debug/components/adminPanelsConfig.ts +42 -0
- package/src/modules/debug/components/index.ts +4 -3
- package/src/modules/debug/components/panels/AuthPanel.vue +1 -1
- package/src/modules/debug/components/panels/EntitiesPanel.vue +5 -5
- package/src/modules/debug/components/panels/I18nPanel.vue +738 -0
- package/src/modules/debug/components/panels/RouterPanel.vue +1 -1
- package/src/modules/debug/components/panels/index.ts +10 -4
- package/src/modules/debug/index.ts +15 -0
- package/src/modules/debug/styles.scss +22 -18
- package/src/utils/index.ts +0 -3
- package/src/vite/qdadmDebugPlugin.ts +401 -0
- package/src/vite-env.d.ts +16 -0
- package/src/modules/debug/components/ObjectTree.vue +0 -123
- package/src/modules/debug/components/panels/EntriesPanel.vue +0 -100
- package/src/modules/debug/components/panels/SignalsPanel.vue +0 -188
- package/src/modules/debug/components/panels/ToastsPanel.vue +0 -45
- package/src/utils/debugInjector.ts +0 -306
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { I18n } from '../I18n'
|
|
4
|
+
import type { TranslationProvider } from '../types'
|
|
5
|
+
|
|
6
|
+
describe('I18n — bootstrap and lookup', () => {
|
|
7
|
+
it('ships default core EN bundle', async () => {
|
|
8
|
+
const i18n = new I18n()
|
|
9
|
+
await i18n.bootstrap()
|
|
10
|
+
expect(i18n.t('core.actions.save')).toBe('Save')
|
|
11
|
+
expect(i18n.t('core.errors.required')).toBe('Required')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('disableDefaultCoreBundle skips default core', async () => {
|
|
15
|
+
const i18n = new I18n({ disableDefaultCoreBundle: true })
|
|
16
|
+
await i18n.bootstrap()
|
|
17
|
+
expect(i18n.t('core.actions.save')).toBe('core.actions.save')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('addMessages from kernel options', async () => {
|
|
21
|
+
const i18n = new I18n({
|
|
22
|
+
messages: {
|
|
23
|
+
en: { entities: { books: { fields: { title: 'Title' } } } },
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
await i18n.bootstrap()
|
|
27
|
+
expect(i18n.t('entities.books.fields.title')).toBe('Title')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('addMessages after bootstrap is immediately visible', async () => {
|
|
31
|
+
const i18n = new I18n()
|
|
32
|
+
await i18n.bootstrap()
|
|
33
|
+
i18n.addMessages('en', { entities: { books: { fields: { author: 'Author' } } } })
|
|
34
|
+
expect(i18n.t('entities.books.fields.author')).toBe('Author')
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('I18n — global strategy (default)', () => {
|
|
39
|
+
it('routes entities.*.actions.* to core.actions.*', async () => {
|
|
40
|
+
const i18n = new I18n()
|
|
41
|
+
await i18n.bootstrap()
|
|
42
|
+
expect(i18n.t('entities.books.actions.save')).toBe('Save')
|
|
43
|
+
expect(i18n.t('entities.users.actions.delete')).toBe('Delete')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('routes entities.*.fields.created_at to core.fields.created_at', async () => {
|
|
47
|
+
const i18n = new I18n()
|
|
48
|
+
await i18n.bootstrap()
|
|
49
|
+
expect(i18n.t('entities.books.fields.created_at')).toBe('Created at')
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('I18n — module strategy', () => {
|
|
54
|
+
it('routes entities.<entity>.* to modules.<module>.* with fallback to core', async () => {
|
|
55
|
+
const i18n = new I18n({ keyStrategy: 'module' })
|
|
56
|
+
i18n.registerEntityModule('books', 'BooksModule')
|
|
57
|
+
await i18n.bootstrap()
|
|
58
|
+
i18n.addMessages('en', {
|
|
59
|
+
modules: {
|
|
60
|
+
BooksModule: { actions: { delete: 'Discard' } },
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
// Module override wins
|
|
64
|
+
expect(i18n.t('entities.books.actions.delete')).toBe('Discard')
|
|
65
|
+
// Fallback through module → core
|
|
66
|
+
expect(i18n.t('entities.books.actions.save')).toBe('Save')
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('I18n — locale change', () => {
|
|
71
|
+
it('emits locale:changed signal', async () => {
|
|
72
|
+
const events: Array<{ signal: string; data: unknown }> = []
|
|
73
|
+
const signals = {
|
|
74
|
+
emit: (signal: string, data: unknown) => {
|
|
75
|
+
events.push({ signal, data })
|
|
76
|
+
},
|
|
77
|
+
on: () => () => {},
|
|
78
|
+
}
|
|
79
|
+
const i18n = new I18n({ defaultLocale: 'en' }, { signals })
|
|
80
|
+
await i18n.bootstrap()
|
|
81
|
+
i18n.addMessages('fr', { core: { actions: { save: 'Enregistrer' } } })
|
|
82
|
+
await i18n.changeLocale('fr')
|
|
83
|
+
expect(i18n.locale.value).toBe('fr')
|
|
84
|
+
expect(i18n.t('core.actions.save')).toBe('Enregistrer')
|
|
85
|
+
const emitted = events.find((e) => e.signal === 'locale:changed')
|
|
86
|
+
expect(emitted).toBeDefined()
|
|
87
|
+
expect(emitted?.data).toBe('fr')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('falls back to fallbackLocale for missing keys', async () => {
|
|
91
|
+
// disableDefaultCoreBundle so we can assert pure fallback behavior without
|
|
92
|
+
// the shipped FR bundle filling `core.actions.save` first.
|
|
93
|
+
const i18n = new I18n({
|
|
94
|
+
defaultLocale: 'en',
|
|
95
|
+
fallbackLocale: 'en',
|
|
96
|
+
disableDefaultCoreBundle: true,
|
|
97
|
+
})
|
|
98
|
+
i18n.addMessages('en', { core: { actions: { save: 'Save' } } })
|
|
99
|
+
i18n.addMessages('fr', { entities: { books: { fields: { title: 'Titre' } } } })
|
|
100
|
+
await i18n.bootstrap()
|
|
101
|
+
await i18n.changeLocale('fr')
|
|
102
|
+
expect(i18n.t('entities.books.fields.title')).toBe('Titre')
|
|
103
|
+
// 'core.actions.save' not declared in fr → falls back to en
|
|
104
|
+
expect(i18n.t('core.actions.save')).toBe('Save')
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('I18n — providers', () => {
|
|
109
|
+
it('loads from a custom TranslationProvider during bootstrap', async () => {
|
|
110
|
+
const provider: TranslationProvider = {
|
|
111
|
+
name: 'mock',
|
|
112
|
+
load: (locale) => {
|
|
113
|
+
if (locale === 'en') {
|
|
114
|
+
return { entities: { tasks: { fields: { name: 'Task name' } } } }
|
|
115
|
+
}
|
|
116
|
+
return {}
|
|
117
|
+
},
|
|
118
|
+
availableLocales: () => ['en'],
|
|
119
|
+
}
|
|
120
|
+
const i18n = new I18n({ providers: [provider] })
|
|
121
|
+
await i18n.bootstrap()
|
|
122
|
+
expect(i18n.t('entities.tasks.fields.name')).toBe('Task name')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('async provider load works', async () => {
|
|
126
|
+
const provider: TranslationProvider = {
|
|
127
|
+
name: 'async-mock',
|
|
128
|
+
load: async (locale) => {
|
|
129
|
+
await new Promise((r) => setTimeout(r, 5))
|
|
130
|
+
if (locale === 'en') {
|
|
131
|
+
return { foo: { bar: 'Async value' } }
|
|
132
|
+
}
|
|
133
|
+
return {}
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
const i18n = new I18n({ providers: [provider] })
|
|
137
|
+
await i18n.bootstrap()
|
|
138
|
+
expect(i18n.t('foo.bar')).toBe('Async value')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('availableLocales unions across providers', async () => {
|
|
142
|
+
const a: TranslationProvider = {
|
|
143
|
+
name: 'a',
|
|
144
|
+
load: () => ({}),
|
|
145
|
+
availableLocales: () => ['en', 'fr'],
|
|
146
|
+
}
|
|
147
|
+
const b: TranslationProvider = {
|
|
148
|
+
name: 'b',
|
|
149
|
+
load: () => ({}),
|
|
150
|
+
availableLocales: async () => ['de', 'fr'],
|
|
151
|
+
}
|
|
152
|
+
const i18n = new I18n({ providers: [a, b] })
|
|
153
|
+
const locales = await i18n.availableLocales()
|
|
154
|
+
expect(new Set(locales)).toEqual(new Set(['en', 'fr', 'de']))
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
describe('I18n — dump', () => {
|
|
159
|
+
it('returns the inline bundle (app-level messages only, not framework defaults)', async () => {
|
|
160
|
+
const i18n = new I18n()
|
|
161
|
+
await i18n.bootstrap()
|
|
162
|
+
i18n.addMessages('en', { entities: { books: { fields: { title: 'Title' } } } })
|
|
163
|
+
const dumped = i18n.dump('en')
|
|
164
|
+
expect(dumped.entities?.books?.fields?.title).toBe('Title')
|
|
165
|
+
// Framework defaults (core.*) ship via DefaultCoreProvider, not the inline
|
|
166
|
+
// provider, so they don't show up in dump().
|
|
167
|
+
expect(dumped.core).toBeUndefined()
|
|
168
|
+
})
|
|
169
|
+
})
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { I18n } from '../I18n'
|
|
4
|
+
import { IncrementalDomainProvider, type DomainLoader } from '../IncrementalDomainProvider'
|
|
5
|
+
|
|
6
|
+
function makeSignals() {
|
|
7
|
+
const events: Array<{ signal: string; data: unknown }> = []
|
|
8
|
+
const handlers = new Map<string, Array<(e: { data: unknown }) => void>>()
|
|
9
|
+
return {
|
|
10
|
+
events,
|
|
11
|
+
bus: {
|
|
12
|
+
emit: (signal: string, data: unknown) => {
|
|
13
|
+
events.push({ signal, data })
|
|
14
|
+
for (const h of handlers.get(signal) ?? []) h({ data })
|
|
15
|
+
},
|
|
16
|
+
on: (signal: string, handler: (e: { data: unknown }) => void) => {
|
|
17
|
+
const list = handlers.get(signal) ?? []
|
|
18
|
+
list.push(handler)
|
|
19
|
+
handlers.set(signal, list)
|
|
20
|
+
return () => {}
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('IncrementalDomainProvider — lazy-by-domain', () => {
|
|
27
|
+
it('only loads eagerDomains during bootstrap; lazy domains stay dormant', async () => {
|
|
28
|
+
const coreLoader = vi.fn(async () => ({ actions: { save: 'Save' } }))
|
|
29
|
+
const shopLoader = vi.fn(async () => ({ cart: { title: 'Cart' } }))
|
|
30
|
+
|
|
31
|
+
const provider = new IncrementalDomainProvider({
|
|
32
|
+
domains: { core: coreLoader, shop: shopLoader },
|
|
33
|
+
eagerDomains: ['core'],
|
|
34
|
+
locales: ['en'],
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const i18n = new I18n({ disableDefaultCoreBundle: true, providers: [provider] })
|
|
38
|
+
await i18n.bootstrap()
|
|
39
|
+
|
|
40
|
+
expect(coreLoader).toHaveBeenCalledTimes(1)
|
|
41
|
+
expect(shopLoader).not.toHaveBeenCalled()
|
|
42
|
+
expect(i18n.t('core.actions.save')).toBe('Save')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('t() on an unloaded domain triggers a lazy load + emits i18n:domain-loaded', async () => {
|
|
46
|
+
const shopLoader = vi.fn(async () => ({ cart: { title: 'Cart' } }))
|
|
47
|
+
|
|
48
|
+
const provider = new IncrementalDomainProvider({
|
|
49
|
+
domains: { shop: shopLoader },
|
|
50
|
+
locales: ['en'],
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const sig = makeSignals()
|
|
54
|
+
const i18n = new I18n(
|
|
55
|
+
{ disableDefaultCoreBundle: true, providers: [provider] },
|
|
56
|
+
{ signals: sig.bus }
|
|
57
|
+
)
|
|
58
|
+
await i18n.bootstrap()
|
|
59
|
+
|
|
60
|
+
// First call: shop not loaded yet → returns the raw key, kicks off load
|
|
61
|
+
const first = i18n.t('shop.cart.title')
|
|
62
|
+
expect(first).toBe('shop.cart.title')
|
|
63
|
+
|
|
64
|
+
// Wait one microtask tick for the async load to settle
|
|
65
|
+
await vi.waitFor(() => {
|
|
66
|
+
expect(sig.events.find((e) => e.signal === 'i18n:domain-loaded')).toBeDefined()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
expect(shopLoader).toHaveBeenCalledTimes(1)
|
|
70
|
+
|
|
71
|
+
// Second call: domain is in the registry now
|
|
72
|
+
expect(i18n.t('shop.cart.title')).toBe('Cart')
|
|
73
|
+
|
|
74
|
+
// Signal payload identifies the domain + locale
|
|
75
|
+
const loaded = sig.events.find((e) => e.signal === 'i18n:domain-loaded')
|
|
76
|
+
expect(loaded?.data).toEqual({ locale: 'en', domain: 'shop' })
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('dedupes concurrent lazy loads for the same (locale, domain)', async () => {
|
|
80
|
+
let resolveLoader: ((value: unknown) => void) | undefined
|
|
81
|
+
const shopLoader = vi.fn(
|
|
82
|
+
() =>
|
|
83
|
+
new Promise<{ cart: { title: string } }>((resolve) => {
|
|
84
|
+
resolveLoader = resolve as (v: unknown) => void
|
|
85
|
+
})
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
const provider = new IncrementalDomainProvider({
|
|
89
|
+
domains: { shop: shopLoader as DomainLoader },
|
|
90
|
+
locales: ['en'],
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
const i18n = new I18n({ disableDefaultCoreBundle: true, providers: [provider] })
|
|
94
|
+
await i18n.bootstrap()
|
|
95
|
+
|
|
96
|
+
// 50 concurrent lookups during the inflight window
|
|
97
|
+
for (let i = 0; i < 50; i++) i18n.t('shop.cart.title')
|
|
98
|
+
|
|
99
|
+
expect(shopLoader).toHaveBeenCalledTimes(1)
|
|
100
|
+
resolveLoader?.({ cart: { title: 'Cart' } })
|
|
101
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
102
|
+
|
|
103
|
+
expect(i18n.t('shop.cart.title')).toBe('Cart')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('loadDomain() lets the app pre-warm a domain explicitly', async () => {
|
|
107
|
+
const shopLoader = vi.fn(async () => ({ cart: { title: 'Cart' } }))
|
|
108
|
+
|
|
109
|
+
const provider = new IncrementalDomainProvider({
|
|
110
|
+
domains: { shop: shopLoader },
|
|
111
|
+
locales: ['en'],
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
const i18n = new I18n({ disableDefaultCoreBundle: true, providers: [provider] })
|
|
115
|
+
await i18n.bootstrap()
|
|
116
|
+
|
|
117
|
+
await i18n.loadDomain('shop')
|
|
118
|
+
// After explicit pre-warm, t() resolves immediately — no placeholder
|
|
119
|
+
expect(i18n.t('shop.cart.title')).toBe('Cart')
|
|
120
|
+
expect(shopLoader).toHaveBeenCalledTimes(1)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('miss on a domain known to no provider stays a miss (no infinite trigger)', async () => {
|
|
124
|
+
const shopLoader = vi.fn(async () => ({ cart: { title: 'Cart' } }))
|
|
125
|
+
const provider = new IncrementalDomainProvider({
|
|
126
|
+
domains: { shop: shopLoader },
|
|
127
|
+
locales: ['en'],
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
const sig = makeSignals()
|
|
131
|
+
const i18n = new I18n(
|
|
132
|
+
{ disableDefaultCoreBundle: true, providers: [provider] },
|
|
133
|
+
{ signals: sig.bus }
|
|
134
|
+
)
|
|
135
|
+
await i18n.bootstrap()
|
|
136
|
+
|
|
137
|
+
// `legal.*` is unknown to every provider → no load attempt
|
|
138
|
+
i18n.t('legal.tos.title')
|
|
139
|
+
i18n.t('legal.tos.title')
|
|
140
|
+
i18n.t('legal.tos.title')
|
|
141
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
142
|
+
|
|
143
|
+
expect(shopLoader).not.toHaveBeenCalled()
|
|
144
|
+
expect(sig.events.some((e) => e.signal === 'i18n:domain-loaded')).toBe(false)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { I18n } from '../I18n'
|
|
4
|
+
import { LazyTranslationProvider, type LazyLoader } from '../LazyTranslationProvider'
|
|
5
|
+
|
|
6
|
+
describe('LazyTranslationProvider — domain cascade', () => {
|
|
7
|
+
it('merges partial bundles from multiple domain loaders', async () => {
|
|
8
|
+
const coreLoader: LazyLoader = async (locale) =>
|
|
9
|
+
locale === 'en'
|
|
10
|
+
? { core: { actions: { save: 'Save', cancel: 'Cancel' } } }
|
|
11
|
+
: null
|
|
12
|
+
const shopLoader: LazyLoader = async (locale) =>
|
|
13
|
+
locale === 'en'
|
|
14
|
+
? { shop: { cart: { title: 'Your cart', empty: 'Cart is empty' } } }
|
|
15
|
+
: null
|
|
16
|
+
const legalLoader: LazyLoader = async (locale) =>
|
|
17
|
+
locale === 'en' ? { legal: { tos: { title: 'Terms of Service' } } } : null
|
|
18
|
+
|
|
19
|
+
const provider = new LazyTranslationProvider({
|
|
20
|
+
name: 'composite',
|
|
21
|
+
loaders: [coreLoader, shopLoader, legalLoader],
|
|
22
|
+
locales: ['en'],
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const i18n = new I18n({ disableDefaultCoreBundle: true, providers: [provider] })
|
|
26
|
+
await i18n.bootstrap()
|
|
27
|
+
|
|
28
|
+
expect(i18n.t('core.actions.save')).toBe('Save')
|
|
29
|
+
expect(i18n.t('shop.cart.title')).toBe('Your cart')
|
|
30
|
+
expect(i18n.t('shop.cart.empty')).toBe('Cart is empty')
|
|
31
|
+
expect(i18n.t('legal.tos.title')).toBe('Terms of Service')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('later loaders override earlier ones (last-merge-wins)', async () => {
|
|
35
|
+
const baseLoader: LazyLoader = async () => ({
|
|
36
|
+
core: { actions: { save: 'Base save' } },
|
|
37
|
+
shop: { cart: { title: 'Base cart' } },
|
|
38
|
+
})
|
|
39
|
+
const overrideLoader: LazyLoader = async () => ({
|
|
40
|
+
core: { actions: { save: 'Override save' } },
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const provider = new LazyTranslationProvider({
|
|
44
|
+
loaders: [baseLoader, overrideLoader],
|
|
45
|
+
locales: ['en'],
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const i18n = new I18n({ disableDefaultCoreBundle: true, providers: [provider] })
|
|
49
|
+
await i18n.bootstrap()
|
|
50
|
+
|
|
51
|
+
// overridden by later loader
|
|
52
|
+
expect(i18n.t('core.actions.save')).toBe('Override save')
|
|
53
|
+
// untouched by override → still from base loader
|
|
54
|
+
expect(i18n.t('shop.cart.title')).toBe('Base cart')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('loader returning null is skipped', async () => {
|
|
58
|
+
const presentLoader: LazyLoader = async () => ({
|
|
59
|
+
shop: { cart: { title: 'Cart' } },
|
|
60
|
+
})
|
|
61
|
+
const missingLoader: LazyLoader = async () => null
|
|
62
|
+
|
|
63
|
+
const provider = new LazyTranslationProvider({
|
|
64
|
+
loaders: [missingLoader, presentLoader, missingLoader],
|
|
65
|
+
locales: ['en'],
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const i18n = new I18n({ disableDefaultCoreBundle: true, providers: [provider] })
|
|
69
|
+
await i18n.bootstrap()
|
|
70
|
+
|
|
71
|
+
expect(i18n.t('shop.cart.title')).toBe('Cart')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('availableLocales reflects the configured locales', () => {
|
|
75
|
+
const provider = new LazyTranslationProvider({
|
|
76
|
+
loaders: [async () => ({})],
|
|
77
|
+
locales: ['en', 'fr', 'de'],
|
|
78
|
+
})
|
|
79
|
+
expect(new Set(provider.availableLocales())).toEqual(new Set(['en', 'fr', 'de']))
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('two providers chained: the later one in options.providers wins', async () => {
|
|
83
|
+
const baseProvider = new LazyTranslationProvider({
|
|
84
|
+
loaders: [async () => ({ shop: { cart: { title: 'Default cart' } } })],
|
|
85
|
+
locales: ['en'],
|
|
86
|
+
})
|
|
87
|
+
const overrideProvider = new LazyTranslationProvider({
|
|
88
|
+
loaders: [async () => ({ shop: { cart: { title: 'Override cart' } } })],
|
|
89
|
+
locales: ['en'],
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const i18n = new I18n({
|
|
93
|
+
disableDefaultCoreBundle: true,
|
|
94
|
+
providers: [baseProvider, overrideProvider],
|
|
95
|
+
})
|
|
96
|
+
await i18n.bootstrap()
|
|
97
|
+
|
|
98
|
+
expect(i18n.t('shop.cart.title')).toBe('Override cart')
|
|
99
|
+
})
|
|
100
|
+
})
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { MessagesRegistry } from '../MessagesRegistry'
|
|
4
|
+
import { Resolver, snakeCaseToTitle } from '../Resolver'
|
|
5
|
+
import type { AliasPattern } from '../types'
|
|
6
|
+
|
|
7
|
+
function makeResolver(opts: {
|
|
8
|
+
bundles?: Record<string, Parameters<MessagesRegistry['merge']>[1]>
|
|
9
|
+
globalAliases?: AliasPattern[]
|
|
10
|
+
defaultLocale?: string
|
|
11
|
+
fallbackLocale?: string
|
|
12
|
+
onMissing?: (key: string, locale: string) => void
|
|
13
|
+
}) {
|
|
14
|
+
const reg = new MessagesRegistry()
|
|
15
|
+
for (const [locale, bundle] of Object.entries(opts.bundles ?? {})) {
|
|
16
|
+
reg.merge(locale, bundle)
|
|
17
|
+
}
|
|
18
|
+
const resolver = new Resolver(reg, {
|
|
19
|
+
defaultLocale: opts.defaultLocale ?? 'en',
|
|
20
|
+
fallbackLocale: opts.fallbackLocale ?? 'en',
|
|
21
|
+
globalAliases: opts.globalAliases,
|
|
22
|
+
onMissing: opts.onMissing,
|
|
23
|
+
})
|
|
24
|
+
return { reg, resolver }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('snakeCaseToTitle', () => {
|
|
28
|
+
it('handles snake_case', () => {
|
|
29
|
+
expect(snakeCaseToTitle('first_name')).toBe('First Name')
|
|
30
|
+
})
|
|
31
|
+
it('handles camelCase', () => {
|
|
32
|
+
expect(snakeCaseToTitle('firstName')).toBe('First Name')
|
|
33
|
+
})
|
|
34
|
+
it('handles kebab-case', () => {
|
|
35
|
+
expect(snakeCaseToTitle('first-name')).toBe('First Name')
|
|
36
|
+
})
|
|
37
|
+
it('handles single word', () => {
|
|
38
|
+
expect(snakeCaseToTitle('title')).toBe('Title')
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('Resolver — convention lookup', () => {
|
|
43
|
+
it('returns the matching value from the active locale', () => {
|
|
44
|
+
const { resolver } = makeResolver({
|
|
45
|
+
bundles: { en: { entities: { books: { fields: { title: 'Title' } } } } },
|
|
46
|
+
})
|
|
47
|
+
expect(resolver.translate('entities.books.fields.title', 'en')).toBe('Title')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('falls back to fallbackLocale on miss', () => {
|
|
51
|
+
const { resolver } = makeResolver({
|
|
52
|
+
bundles: { en: { entities: { books: { fields: { title: 'Title' } } } } },
|
|
53
|
+
defaultLocale: 'fr',
|
|
54
|
+
fallbackLocale: 'en',
|
|
55
|
+
})
|
|
56
|
+
// No 'fr' bundle — falls back to 'en'
|
|
57
|
+
expect(resolver.translate('entities.books.fields.title', 'fr')).toBe('Title')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('reads _label from object node', () => {
|
|
61
|
+
const { resolver } = makeResolver({
|
|
62
|
+
bundles: {
|
|
63
|
+
en: {
|
|
64
|
+
entities: {
|
|
65
|
+
books: {
|
|
66
|
+
fields: {
|
|
67
|
+
genre: { _label: 'Genre', options: { fiction: 'Fiction' } },
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
expect(resolver.translate('entities.books.fields.genre', 'en')).toBe('Genre')
|
|
75
|
+
expect(
|
|
76
|
+
resolver.translate('entities.books.fields.genre.options.fiction', 'en')
|
|
77
|
+
).toBe('Fiction')
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('Resolver — interpolation', () => {
|
|
82
|
+
it('replaces {placeholder} params', () => {
|
|
83
|
+
const { resolver } = makeResolver({
|
|
84
|
+
bundles: { en: { core: { errors: { tooShort: 'Too short (min {min})' } } } },
|
|
85
|
+
})
|
|
86
|
+
expect(resolver.translate('core.errors.tooShort', 'en', { min: 3 })).toBe(
|
|
87
|
+
'Too short (min 3)'
|
|
88
|
+
)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('leaves unknown placeholders untouched', () => {
|
|
92
|
+
const { resolver } = makeResolver({
|
|
93
|
+
bundles: { en: { foo: 'Hello {name}' } },
|
|
94
|
+
})
|
|
95
|
+
expect(resolver.translate('foo', 'en')).toBe('Hello {name}')
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('Resolver — value-level @-aliases', () => {
|
|
100
|
+
it('follows a single @-alias', () => {
|
|
101
|
+
const { resolver } = makeResolver({
|
|
102
|
+
bundles: {
|
|
103
|
+
en: {
|
|
104
|
+
entities: { books: { actions: { save: '@core.actions.save' } } },
|
|
105
|
+
core: { actions: { save: 'Save' } },
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
})
|
|
109
|
+
expect(resolver.translate('entities.books.actions.save', 'en')).toBe('Save')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('follows a chain of @-aliases', () => {
|
|
113
|
+
const { resolver } = makeResolver({
|
|
114
|
+
bundles: {
|
|
115
|
+
en: {
|
|
116
|
+
a: '@b',
|
|
117
|
+
b: '@c',
|
|
118
|
+
c: 'Bottom',
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
expect(resolver.translate('a', 'en')).toBe('Bottom')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('detects cycles and falls through', () => {
|
|
126
|
+
const onMissing = vitestFn()
|
|
127
|
+
const { resolver } = makeResolver({
|
|
128
|
+
bundles: { en: { a: '@b', b: '@a' } },
|
|
129
|
+
onMissing,
|
|
130
|
+
})
|
|
131
|
+
// No final string → ends up as miss → returns raw key
|
|
132
|
+
expect(resolver.translate('a', 'en')).toBe('a')
|
|
133
|
+
expect(onMissing.calls.length).toBeGreaterThan(0)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('escapes literal @ via @@ prefix', () => {
|
|
137
|
+
const { resolver } = makeResolver({
|
|
138
|
+
bundles: { en: { ping: '@@home' } },
|
|
139
|
+
})
|
|
140
|
+
expect(resolver.translate('ping', 'en')).toBe('@home')
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('Resolver — pattern aliases', () => {
|
|
145
|
+
const globalAliases: AliasPattern[] = [
|
|
146
|
+
{ pattern: 'entities.*.actions.*', target: 'core.actions.$2' },
|
|
147
|
+
{ pattern: 'entities.*.fields.created_at', target: 'core.fields.created_at' },
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
it('rewrites via wildcard pattern with $2 capture', () => {
|
|
151
|
+
const { resolver } = makeResolver({
|
|
152
|
+
bundles: { en: { core: { actions: { save: 'Save' } } } },
|
|
153
|
+
globalAliases,
|
|
154
|
+
})
|
|
155
|
+
expect(resolver.translate('entities.books.actions.save', 'en')).toBe('Save')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('rewrites a specific exact pattern', () => {
|
|
159
|
+
const { resolver } = makeResolver({
|
|
160
|
+
bundles: { en: { core: { fields: { created_at: 'Created at' } } } },
|
|
161
|
+
globalAliases,
|
|
162
|
+
})
|
|
163
|
+
expect(resolver.translate('entities.books.fields.created_at', 'en')).toBe('Created at')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('longest-match wins when multiple patterns match', () => {
|
|
167
|
+
const aliases: AliasPattern[] = [
|
|
168
|
+
{ pattern: 'entities.*.actions.*', target: 'core.actions.$2' },
|
|
169
|
+
// More specific, should win
|
|
170
|
+
{ pattern: 'entities.books.actions.delete', target: 'books.specialDelete' },
|
|
171
|
+
]
|
|
172
|
+
const { resolver } = makeResolver({
|
|
173
|
+
bundles: {
|
|
174
|
+
en: {
|
|
175
|
+
core: { actions: { delete: 'Delete' } },
|
|
176
|
+
books: { specialDelete: 'Discard' },
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
globalAliases: aliases,
|
|
180
|
+
})
|
|
181
|
+
expect(resolver.translate('entities.books.actions.delete', 'en')).toBe('Discard')
|
|
182
|
+
// Other entities still go through the wildcard
|
|
183
|
+
expect(resolver.translate('entities.users.actions.delete', 'en')).toBe('Delete')
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('does not loop forever when patterns ping-pong', () => {
|
|
187
|
+
const aliases: AliasPattern[] = [
|
|
188
|
+
{ pattern: 'a.*', target: 'b.$1' },
|
|
189
|
+
{ pattern: 'b.*', target: 'a.$1' },
|
|
190
|
+
]
|
|
191
|
+
const { resolver } = makeResolver({
|
|
192
|
+
bundles: { en: {} },
|
|
193
|
+
globalAliases: aliases,
|
|
194
|
+
})
|
|
195
|
+
// Should bail out — no actual value found anywhere
|
|
196
|
+
expect(resolver.translate('a.foo', 'en')).toBe('a.foo')
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
describe('Resolver — final fallbacks', () => {
|
|
201
|
+
it('humanizes field key via snakeCaseToTitle on miss', () => {
|
|
202
|
+
const { resolver } = makeResolver({ bundles: { en: {} } })
|
|
203
|
+
expect(resolver.translate('entities.books.fields.first_name', 'en')).toBe('First Name')
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('does NOT humanize non-field keys', () => {
|
|
207
|
+
const { resolver } = makeResolver({ bundles: { en: {} } })
|
|
208
|
+
expect(resolver.translate('core.actions.save', 'en')).toBe('core.actions.save')
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('emits onMissing on every miss', () => {
|
|
212
|
+
const onMissing = vitestFn<[string, string]>()
|
|
213
|
+
const { resolver } = makeResolver({ bundles: { en: {} }, onMissing })
|
|
214
|
+
resolver.translate('core.actions.save', 'en')
|
|
215
|
+
resolver.translate('entities.books.fields.title', 'en') // miss but humanized
|
|
216
|
+
expect(onMissing.calls.length).toBe(2)
|
|
217
|
+
expect(onMissing.calls[0]).toEqual(['core.actions.save', 'en'])
|
|
218
|
+
expect(onMissing.calls[1]).toEqual(['entities.books.fields.title', 'en'])
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
describe('Resolver — trace', () => {
|
|
223
|
+
it('returns lookup steps', () => {
|
|
224
|
+
const { resolver } = makeResolver({
|
|
225
|
+
bundles: { en: { entities: { books: { fields: { title: 'Title' } } } } },
|
|
226
|
+
})
|
|
227
|
+
const trace = resolver.resolve('entities.books.fields.title', 'en')
|
|
228
|
+
expect(trace.hit).toBe(true)
|
|
229
|
+
expect(trace.result).toBe('Title')
|
|
230
|
+
expect(trace.steps[0]).toEqual({
|
|
231
|
+
kind: 'lookup',
|
|
232
|
+
locale: 'en',
|
|
233
|
+
key: 'entities.books.fields.title',
|
|
234
|
+
found: true,
|
|
235
|
+
})
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('records the alias-pattern step on rewrite', () => {
|
|
239
|
+
const { resolver } = makeResolver({
|
|
240
|
+
bundles: { en: { core: { actions: { save: 'Save' } } } },
|
|
241
|
+
globalAliases: [{ pattern: 'entities.*.actions.*', target: 'core.actions.$2' }],
|
|
242
|
+
})
|
|
243
|
+
const trace = resolver.resolve('entities.books.actions.save', 'en')
|
|
244
|
+
expect(trace.hit).toBe(true)
|
|
245
|
+
const aliasStep = trace.steps.find((s) => s.kind === 'alias-pattern')
|
|
246
|
+
expect(aliasStep).toBeDefined()
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('records snake-case-fallback for missing field', () => {
|
|
250
|
+
const { resolver } = makeResolver({ bundles: { en: {} } })
|
|
251
|
+
const trace = resolver.resolve('entities.books.fields.first_name', 'en')
|
|
252
|
+
expect(trace.hit).toBe(false)
|
|
253
|
+
expect(trace.result).toBe('First Name')
|
|
254
|
+
const last = trace.steps[trace.steps.length - 1]
|
|
255
|
+
expect(last?.kind).toBe('snake-case-fallback')
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
// ----------------------------------------------------------------------------
|
|
260
|
+
// Tiny mock helper (avoids import-order quirks with vi.fn() in some setups)
|
|
261
|
+
// ----------------------------------------------------------------------------
|
|
262
|
+
function vitestFn<A extends unknown[] = unknown[]>(): ((...args: A) => void) & {
|
|
263
|
+
calls: A[]
|
|
264
|
+
} {
|
|
265
|
+
const calls: A[] = []
|
|
266
|
+
const fn = ((...args: A) => {
|
|
267
|
+
calls.push(args)
|
|
268
|
+
}) as ((...args: A) => void) & { calls: A[] }
|
|
269
|
+
fn.calls = calls
|
|
270
|
+
return fn
|
|
271
|
+
}
|