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.
Files changed (86) hide show
  1. package/package.json +8 -4
  2. package/src/chain/ActiveStack.ts +79 -98
  3. package/src/chain/StackHydrator.ts +3 -2
  4. package/src/chain/index.ts +7 -1
  5. package/src/components/QdadmRoot.vue +52 -0
  6. package/src/components/edit/FormActions.vue +9 -6
  7. package/src/components/edit/LookupPickerDialog.vue +6 -3
  8. package/src/components/index.ts +6 -0
  9. package/src/composables/useEntityItemFormPage.ts +1 -0
  10. package/src/composables/useEntityItemShowPage.ts +1 -0
  11. package/src/composables/useFieldManager.ts +100 -3
  12. package/src/composables/useListPage.ts +50 -59
  13. package/src/composables/useListPage.utils.ts +101 -0
  14. package/src/composables/useNavigation.ts +26 -3
  15. package/src/composables/useOptionsLookup.ts +5 -1
  16. package/src/gen/generateManagers.test.js +27 -0
  17. package/src/gen/generateManagers.ts +12 -0
  18. package/src/hooks/HookRegistry.ts +14 -435
  19. package/src/i18n/I18n.ts +344 -0
  20. package/src/i18n/IncrementalDomainProvider.ts +153 -0
  21. package/src/i18n/InlineTranslationProvider.ts +4 -0
  22. package/src/i18n/LazyTranslationProvider.ts +102 -0
  23. package/src/i18n/MessagesRegistry.ts +4 -0
  24. package/src/i18n/Resolver.ts +4 -0
  25. package/src/i18n/__tests__/I18n.test.ts +169 -0
  26. package/src/i18n/__tests__/IncrementalDomainProvider.test.ts +146 -0
  27. package/src/i18n/__tests__/LazyTranslationProvider.test.ts +100 -0
  28. package/src/i18n/__tests__/Resolver.test.ts +271 -0
  29. package/src/i18n/defaults/DefaultCoreProvider.ts +28 -0
  30. package/src/i18n/defaults/core.en.yml +55 -0
  31. package/src/i18n/defaults/core.fr.yml +55 -0
  32. package/src/i18n/index.ts +55 -0
  33. package/src/i18n/loaders/raw-modules.d.ts +15 -0
  34. package/src/i18n/loaders/yaml.ts +35 -0
  35. package/src/i18n/strategies.ts +4 -0
  36. package/src/i18n/types.ts +34 -0
  37. package/src/i18n/useI18n.ts +34 -0
  38. package/src/index.ts +37 -0
  39. package/src/kernel/EventRouter.ts +17 -300
  40. package/src/kernel/Kernel.i18n.ts +29 -0
  41. package/src/kernel/Kernel.modules.ts +6 -0
  42. package/src/kernel/Kernel.registries.ts +10 -2
  43. package/src/kernel/Kernel.routing.ts +43 -1
  44. package/src/kernel/Kernel.ts +43 -0
  45. package/src/kernel/Kernel.types.ts +52 -1
  46. package/src/kernel/Kernel.vue.ts +121 -15
  47. package/src/kernel/KernelContext.entities.ts +80 -0
  48. package/src/kernel/KernelContext.events.ts +57 -0
  49. package/src/kernel/KernelContext.i18n.ts +37 -0
  50. package/src/kernel/KernelContext.permissions.ts +38 -0
  51. package/src/kernel/KernelContext.routing.ts +280 -0
  52. package/src/kernel/KernelContext.ts +125 -834
  53. package/src/kernel/KernelContext.types.ts +173 -0
  54. package/src/kernel/KernelContext.zones.ts +54 -0
  55. package/src/kernel/SSEBridge.ts +7 -362
  56. package/src/kernel/SignalBus.ts +24 -148
  57. package/src/modules/debug/AuthCollector.ts +48 -1
  58. package/src/modules/debug/Collector.ts +16 -302
  59. package/src/modules/debug/DebugBridge.ts +10 -171
  60. package/src/modules/debug/DebugModule.ts +35 -5
  61. package/src/modules/debug/EntitiesCollector.ts +97 -1
  62. package/src/modules/debug/ErrorCollector.ts +2 -77
  63. package/src/modules/debug/I18nCollector.ts +9 -0
  64. package/src/modules/debug/LocalStorageAdapter.ts +3 -147
  65. package/src/modules/debug/RouterCollector.ts +101 -1
  66. package/src/modules/debug/SignalCollector.ts +2 -150
  67. package/src/modules/debug/ToastCollector.ts +2 -91
  68. package/src/modules/debug/ZonesCollector.ts +93 -1
  69. package/src/modules/debug/components/DebugBar.vue +19 -775
  70. package/src/modules/debug/components/adminPanelsConfig.ts +42 -0
  71. package/src/modules/debug/components/index.ts +4 -3
  72. package/src/modules/debug/components/panels/AuthPanel.vue +1 -1
  73. package/src/modules/debug/components/panels/EntitiesPanel.vue +5 -5
  74. package/src/modules/debug/components/panels/I18nPanel.vue +738 -0
  75. package/src/modules/debug/components/panels/RouterPanel.vue +1 -1
  76. package/src/modules/debug/components/panels/index.ts +10 -4
  77. package/src/modules/debug/index.ts +15 -0
  78. package/src/modules/debug/styles.scss +22 -18
  79. package/src/utils/index.ts +0 -3
  80. package/src/vite/qdadmDebugPlugin.ts +401 -0
  81. package/src/vite-env.d.ts +16 -0
  82. package/src/modules/debug/components/ObjectTree.vue +0 -123
  83. package/src/modules/debug/components/panels/EntriesPanel.vue +0 -100
  84. package/src/modules/debug/components/panels/SignalsPanel.vue +0 -188
  85. package/src/modules/debug/components/panels/ToastsPanel.vue +0 -45
  86. package/src/utils/debugInjector.ts +0 -306
@@ -0,0 +1,344 @@
1
+ /**
2
+ * I18n - Top-level orchestrator (exposed as `kernel.i18n`).
3
+ *
4
+ * Owns:
5
+ * - the active locale (reactive ref)
6
+ * - the MessagesRegistry
7
+ * - registered TranslationProviders
8
+ * - the active strategy's pattern aliases
9
+ * - the Resolver
10
+ *
11
+ * Public API:
12
+ * - t(key, params?)
13
+ * - locale (ref)
14
+ * - availableLocales
15
+ * - resolve(key) (debug trace)
16
+ * - dump(locale) (export merged bundle)
17
+ * - changeLocale(locale) (programmatic; the public way is signals.emit('locale:change', loc))
18
+ * - addProvider(p)
19
+ * - addMessages(locale, bundle)
20
+ * - addAliases(patterns)
21
+ * - registerEntityModule(entity, module) (for the 'module' strategy)
22
+ */
23
+
24
+ import { ref, type Ref } from 'vue'
25
+
26
+ import {
27
+ MessagesRegistry,
28
+ Resolver,
29
+ InlineTranslationProvider,
30
+ resolveStrategy,
31
+ } from '@quazardous/qdcore'
32
+ import type {
33
+ AliasPattern,
34
+ MessagesBundle,
35
+ ResolveTrace,
36
+ TranslateParams,
37
+ TranslationProvider,
38
+ } from '@quazardous/qdcore'
39
+ import { createDefaultCoreProvider } from './defaults/DefaultCoreProvider'
40
+ import { isDomainAwareProvider } from './IncrementalDomainProvider'
41
+ import type { I18nOptions } from './types'
42
+
43
+ export interface I18nDeps {
44
+ /** SignalBus instance for emitting locale:changed and i18n:missing. */
45
+ signals?: {
46
+ emit: (signal: string, payload?: unknown) => void | Promise<void>
47
+ on: (signal: string, handler: (e: { data: unknown }) => void) => () => void
48
+ } | null
49
+ }
50
+
51
+ export class I18n {
52
+ readonly inline: InlineTranslationProvider
53
+ readonly locale: Ref<string>
54
+
55
+ private _registry: MessagesRegistry
56
+ private _resolver: Resolver
57
+ private _providers: TranslationProvider[]
58
+ private _strategyName: string | string[]
59
+ private _appAliases: AliasPattern[]
60
+ private _entityToModule: Map<string, string> = new Map()
61
+ private _signals: I18nDeps['signals']
62
+ private _defaultLocale: string
63
+ private _fallbackLocale: string
64
+ private _loadedLocales: Set<string> = new Set()
65
+ // Track which (locale::domain) pairs have already been resolved through a
66
+ // domain-aware provider, so we don't re-trigger an async load on every
67
+ // subsequent miss for the same domain.
68
+ private _loadedDomains: Set<string> = new Set()
69
+ // Concurrent domain loads — share one in-flight promise per (locale, domain)
70
+ // pair across providers and t() callers.
71
+ private _inflightDomains: Map<string, Promise<void>> = new Map()
72
+
73
+ constructor(options: I18nOptions = {}, deps: I18nDeps = {}) {
74
+ this._defaultLocale = options.defaultLocale ?? 'en'
75
+ this._fallbackLocale = options.fallbackLocale ?? this._defaultLocale
76
+ this._strategyName = options.keyStrategy ?? 'global'
77
+ this._appAliases = options.aliases ? [...options.aliases] : []
78
+ this._signals = deps.signals ?? null
79
+
80
+ this._registry = new MessagesRegistry()
81
+ this.inline = new InlineTranslationProvider()
82
+
83
+ // Provider chain: framework defaults (lazy) → inline (ctx.messages) → user.
84
+ // _loadLocale merges providers in order, so later entries override earlier
85
+ // ones. The default core is first so app-level overrides always win.
86
+ const defaultCoreProviders: TranslationProvider[] = options.disableDefaultCoreBundle
87
+ ? []
88
+ : [createDefaultCoreProvider()]
89
+ this._providers = [
90
+ ...defaultCoreProviders,
91
+ this.inline,
92
+ ...(options.providers ?? []),
93
+ ]
94
+
95
+ this.locale = ref(this._defaultLocale)
96
+
97
+ this._resolver = new Resolver(this._registry, {
98
+ defaultLocale: this._defaultLocale,
99
+ fallbackLocale: this._fallbackLocale,
100
+ globalAliases: this._currentStrategyAliases(),
101
+ onMissing: (key, locale) => {
102
+ this._signals?.emit('i18n:missing', { key, locale })
103
+ },
104
+ })
105
+
106
+ // App-level messages from kernel options.
107
+ if (options.messages) {
108
+ for (const [loc, bundle] of Object.entries(options.messages)) {
109
+ this.inline.push(loc, bundle)
110
+ }
111
+ }
112
+
113
+ // Listen for locale:change requests on the bus, if present.
114
+ if (this._signals) {
115
+ this._signals.on('locale:change', (event) => {
116
+ const loc = typeof event.data === 'string' ? event.data : null
117
+ if (loc) void this.changeLocale(loc)
118
+ })
119
+ }
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Public API
124
+ // ---------------------------------------------------------------------------
125
+
126
+ /**
127
+ * Translate a convention-derived key.
128
+ *
129
+ * On a miss whose top-level segment matches a domain known to one of the
130
+ * registered domain-aware providers (e.g. `IncrementalDomainProvider`),
131
+ * triggers a lazy load for that (locale, domain) pair in the background
132
+ * and emits `i18n:domain-loaded` once merged. The current call still
133
+ * returns the raw key — callers that want to react to the late arrival
134
+ * should listen to `i18n:domain-loaded`.
135
+ */
136
+ t(key: string, params?: TranslateParams): string {
137
+ const trace = this._resolver.resolve(key, this.locale.value, params)
138
+ if (!trace.hit) {
139
+ this._maybeEnsureDomain(key, this.locale.value)
140
+ }
141
+ return trace.result
142
+ }
143
+
144
+ /**
145
+ * Explicitly request a domain to be loaded for a locale, ahead of any
146
+ * `t()` miss that would have triggered it. Useful when a screen is about
147
+ * to need a rare domain and the dev wants to avoid the placeholder flash.
148
+ *
149
+ * Resolves once the domain is merged into the registry (or rejects if no
150
+ * domain-aware provider knows it).
151
+ */
152
+ loadDomain(domain: string, locale: string = this.locale.value): Promise<void> {
153
+ return this._ensureDomain(locale, domain)
154
+ }
155
+
156
+ /**
157
+ * Return the resolution trace (for debugging).
158
+ */
159
+ resolve(key: string, params?: TranslateParams): ResolveTrace {
160
+ return this._resolver.resolve(key, this.locale.value, params)
161
+ }
162
+
163
+ /**
164
+ * Union of available locales across registered providers.
165
+ */
166
+ async availableLocales(): Promise<string[]> {
167
+ const set = new Set<string>()
168
+ for (const p of this._providers) {
169
+ if (typeof p.availableLocales === 'function') {
170
+ const locs = await p.availableLocales()
171
+ for (const l of locs) set.add(l)
172
+ }
173
+ }
174
+ return Array.from(set)
175
+ }
176
+
177
+ /**
178
+ * Switch to a new locale. Loads bundles from providers if not yet cached.
179
+ * Public callers should prefer `signals.emit('locale:change', loc)`.
180
+ */
181
+ async changeLocale(locale: string): Promise<void> {
182
+ if (this.locale.value === locale && this._loadedLocales.has(locale)) return
183
+ await this._loadLocale(locale)
184
+ this.locale.value = locale
185
+ this._signals?.emit('locale:changed', locale)
186
+ }
187
+
188
+ /**
189
+ * Initial bootstrap: load default + fallback locales from providers.
190
+ * Called by the kernel during warmup.
191
+ */
192
+ async bootstrap(): Promise<void> {
193
+ const targets = new Set<string>([this._defaultLocale, this._fallbackLocale])
194
+ for (const loc of targets) {
195
+ await this._loadLocale(loc)
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Register an additional provider after construction.
201
+ */
202
+ addProvider(provider: TranslationProvider): void {
203
+ this._providers.push(provider)
204
+ }
205
+
206
+ /**
207
+ * Push messages into the inline provider (used by ctx.messages()).
208
+ * Bundles merge into the registry on next load. If the locale is already
209
+ * loaded, the bundle is also merged immediately for hot updates.
210
+ */
211
+ addMessages(locale: string, bundle: MessagesBundle): void {
212
+ this.inline.push(locale, bundle)
213
+ if (this._loadedLocales.has(locale)) {
214
+ this._registry.merge(locale, bundle)
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Append app/module-level pattern aliases to the active strategy.
220
+ */
221
+ addAliases(patterns: AliasPattern[]): void {
222
+ this._appAliases.push(...patterns)
223
+ this._resolver.setOptions({ globalAliases: this._currentStrategyAliases() })
224
+ }
225
+
226
+ /**
227
+ * Track which module owns which entity. Used to materialize the 'module'
228
+ * key strategy at runtime (entities.<entity>.* → modules.<module>.*).
229
+ */
230
+ registerEntityModule(entity: string, module: string): void {
231
+ this._entityToModule.set(entity, module)
232
+ if (this._strategyIncludes('module')) {
233
+ this._resolver.setOptions({ globalAliases: this._currentStrategyAliases() })
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Export the currently merged bundle for a locale (for translator workflows).
239
+ */
240
+ dump(locale: string): MessagesBundle {
241
+ return this.inline.load(locale)
242
+ }
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // Internals
246
+ // ---------------------------------------------------------------------------
247
+
248
+ private async _loadLocale(locale: string): Promise<void> {
249
+ for (const p of this._providers) {
250
+ const bundle = await p.load(locale)
251
+ if (bundle && Object.keys(bundle).length > 0) {
252
+ this._registry.merge(locale, bundle)
253
+ }
254
+ }
255
+ this._loadedLocales.add(locale)
256
+ }
257
+
258
+ /**
259
+ * Fire-and-forget: invoked from t() on a miss to opportunistically pull in
260
+ * a domain-aware provider's bundle for the missed top-level segment.
261
+ * Errors are swallowed (logged as a missing-domain signal) — the current
262
+ * t() call has already returned.
263
+ */
264
+ private _maybeEnsureDomain(key: string, locale: string): void {
265
+ const dot = key.indexOf('.')
266
+ const domain = dot === -1 ? key : key.slice(0, dot)
267
+ if (!domain) return
268
+ if (this._loadedDomains.has(`${locale}::${domain}`)) return
269
+ if (!this._providers.some((p) => isDomainAwareProvider(p) && p.knowsDomain(domain))) {
270
+ return
271
+ }
272
+ void this._ensureDomain(locale, domain).catch(() => {
273
+ // Already surfaced via i18n:missing on the original miss; silent here.
274
+ })
275
+ }
276
+
277
+ /**
278
+ * Resolves once a domain has been pulled from any domain-aware provider
279
+ * that knows it. Safe to call concurrently — loads are deduped per
280
+ * (locale, domain) pair. Emits `i18n:domain-loaded` after the merge.
281
+ */
282
+ private _ensureDomain(locale: string, domain: string): Promise<void> {
283
+ const cacheKey = `${locale}::${domain}`
284
+ if (this._loadedDomains.has(cacheKey)) return Promise.resolve()
285
+ const existing = this._inflightDomains.get(cacheKey)
286
+ if (existing) return existing
287
+
288
+ const promise = (async () => {
289
+ let merged = false
290
+ for (const p of this._providers) {
291
+ if (!isDomainAwareProvider(p) || !p.knowsDomain(domain)) continue
292
+ const bundle = await p.loadDomain(locale, domain)
293
+ if (bundle && Object.keys(bundle).length > 0) {
294
+ this._registry.merge(locale, bundle)
295
+ merged = true
296
+ }
297
+ }
298
+ this._loadedDomains.add(cacheKey)
299
+ this._inflightDomains.delete(cacheKey)
300
+ if (merged) {
301
+ this._signals?.emit('i18n:domain-loaded', { locale, domain })
302
+ }
303
+ })()
304
+
305
+ this._inflightDomains.set(cacheKey, promise)
306
+ return promise
307
+ }
308
+
309
+ private _currentStrategyAliases(): AliasPattern[] {
310
+ const fromStrategy = resolveStrategy(this._strategyName)
311
+ const fromModuleStrategy = this._strategyIncludes('module')
312
+ ? this._materializeModuleAliases()
313
+ : []
314
+ return [...fromStrategy, ...fromModuleStrategy, ...this._appAliases]
315
+ }
316
+
317
+ private _strategyIncludes(name: string): boolean {
318
+ if (this._strategyName === name) return true
319
+ if (Array.isArray(this._strategyName)) return this._strategyName.includes(name)
320
+ return false
321
+ }
322
+
323
+ /**
324
+ * For each known entity → module mapping, emit two pattern aliases:
325
+ * entities.<entity>.actions.* → modules.<module>.actions.$1
326
+ * entities.<entity>.errors.* → modules.<module>.errors.$1
327
+ * (plus a more specific actions/errors per-module fallback to core.*.)
328
+ */
329
+ private _materializeModuleAliases(): AliasPattern[] {
330
+ const out: AliasPattern[] = []
331
+ for (const [entity, module] of this._entityToModule) {
332
+ out.push(
333
+ { pattern: `entities.${entity}.actions.*`, target: `modules.${module}.actions.$1` },
334
+ { pattern: `entities.${entity}.errors.*`, target: `modules.${module}.errors.$1` }
335
+ )
336
+ }
337
+ // Module-level fallbacks to core.
338
+ out.push(
339
+ { pattern: 'modules.*.actions.*', target: 'core.actions.$2' },
340
+ { pattern: 'modules.*.errors.*', target: 'core.errors.$2' }
341
+ )
342
+ return out
343
+ }
344
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * IncrementalDomainProvider - declares one loader per top-level translation
3
+ * domain (`core`, `shop`, `legal`, …) and only fetches each (locale, domain)
4
+ * pair when it's first needed.
5
+ *
6
+ * On `load(locale)` (bootstrap / locale change) the provider only resolves
7
+ * the domains listed in `eagerDomains` (typically `['core']`). The rest stay
8
+ * dormant until `loadDomain(locale, domain)` is called — which `I18n.t()`
9
+ * triggers automatically on a miss whose top-level segment is a
10
+ * known-but-not-yet-loaded domain. The host app can also pre-warm explicitly
11
+ * via `i18n.loadDomain(domain, locale?)`. Inflight loads are deduped per
12
+ * `(locale, domain)`.
13
+ *
14
+ * Bundles returned by domain loaders are wrapped under their domain key so
15
+ * the registry merges them at the right place:
16
+ * loader('shop')(locale) → { cart: {...} } → merged as { shop: { cart: {...} } }
17
+ *
18
+ * ## When to use this vs `LazyTranslationProvider`
19
+ *
20
+ * Use **`IncrementalDomainProvider`** when at least one of these matters:
21
+ * - **Bundle size**: large app with rarely-used sections (admin, legal,
22
+ * marketing) and you want to defer their cost.
23
+ * - **Per-domain caching**: domains have different freshness needs, or
24
+ * come from different transports.
25
+ * - **Granularity**: you want to know exactly when a domain has landed
26
+ * (`i18n:domain-loaded` signal).
27
+ *
28
+ * Use **`LazyTranslationProvider`** when the full locale bundle is
29
+ * reasonable to fetch up-front and you just want to organise translations
30
+ * across multiple files. See `LazyTranslationProvider.ts`.
31
+ *
32
+ * ## Tradeoff
33
+ *
34
+ * `t()` returns synchronously even for an unloaded domain — on first miss
35
+ * it returns the raw key (which renders as a placeholder for one frame),
36
+ * kicks off the async load, then emits `i18n:domain-loaded` once merged.
37
+ * The host app subscribes to that signal if it wants to react (e.g. force a
38
+ * re-render of components that hit the placeholder). Pre-warming via
39
+ * `i18n.loadDomain(domain)` avoids the placeholder entirely.
40
+ */
41
+
42
+ import type { MessagesBundle, MessagesNode, TranslationProvider } from '@quazardous/qdcore'
43
+
44
+ export type DomainLoader = (locale: string) => Promise<MessagesNode | null>
45
+
46
+ export interface IncrementalDomainProviderOptions {
47
+ /** Provider name (for debugging / introspection). */
48
+ name?: string
49
+ /** Loader per top-level domain. Key is the domain (e.g. `core`, `shop`). */
50
+ domains: Record<string, DomainLoader>
51
+ /**
52
+ * Domains to load eagerly during `load(locale)` (i.e. on bootstrap or
53
+ * locale change). Default: empty — every domain is purely lazy.
54
+ */
55
+ eagerDomains?: string[]
56
+ /** Locales advertised through `availableLocales()`. */
57
+ locales?: string[]
58
+ }
59
+
60
+ export class IncrementalDomainProvider implements TranslationProvider {
61
+ readonly name: string
62
+ private readonly _domains: Record<string, DomainLoader>
63
+ private readonly _eagerDomains: string[]
64
+ private readonly _locales: string[] | undefined
65
+ private readonly _inflight: Map<string, Promise<MessagesBundle | null>> = new Map()
66
+
67
+ constructor(options: IncrementalDomainProviderOptions) {
68
+ this.name = options.name ?? 'incremental-domain'
69
+ this._domains = { ...options.domains }
70
+ this._eagerDomains = options.eagerDomains ? [...options.eagerDomains] : []
71
+ this._locales = options.locales ? [...options.locales] : undefined
72
+ }
73
+
74
+ /**
75
+ * Eagerly load only the domains listed in `eagerDomains`. Other domains
76
+ * are loaded on demand via `loadDomain`.
77
+ */
78
+ async load(locale: string): Promise<MessagesBundle> {
79
+ if (this._eagerDomains.length === 0) return {}
80
+ const merged: MessagesBundle = {}
81
+ await Promise.all(
82
+ this._eagerDomains.map(async (domain) => {
83
+ const partial = await this._loadDomain(locale, domain)
84
+ if (partial) Object.assign(merged, partial)
85
+ })
86
+ )
87
+ return merged
88
+ }
89
+
90
+ /**
91
+ * Returns whether this provider has a loader registered for `domain`.
92
+ * Used by `I18n.t()` to decide whether a miss can be recovered by a lazy
93
+ * load.
94
+ */
95
+ knowsDomain(domain: string): boolean {
96
+ return Object.prototype.hasOwnProperty.call(this._domains, domain)
97
+ }
98
+
99
+ /**
100
+ * Lazy-load a single domain. Concurrent calls for the same (locale, domain)
101
+ * pair share one in-flight promise. Returns the wrapped bundle (i.e.
102
+ * `{ [domain]: <node> }`) or `null` if the domain is unknown / loader
103
+ * returns nothing.
104
+ */
105
+ loadDomain(locale: string, domain: string): Promise<MessagesBundle | null> {
106
+ if (!this.knowsDomain(domain)) return Promise.resolve(null)
107
+ const cacheKey = `${locale}::${domain}`
108
+ const existing = this._inflight.get(cacheKey)
109
+ if (existing) return existing
110
+ const promise = this._loadDomain(locale, domain)
111
+ this._inflight.set(cacheKey, promise)
112
+ // Keep the inflight cache populated until the promise settles so a second
113
+ // concurrent caller dedupes; once settled, drop the entry — successive
114
+ // calls go through the loader again only if the I18n caller has lost
115
+ // track. In practice I18n keeps its own loaded set and won't re-call.
116
+ void promise.finally(() => {
117
+ this._inflight.delete(cacheKey)
118
+ })
119
+ return promise
120
+ }
121
+
122
+ availableLocales(): string[] {
123
+ return this._locales ? [...this._locales] : []
124
+ }
125
+
126
+ private async _loadDomain(
127
+ locale: string,
128
+ domain: string
129
+ ): Promise<MessagesBundle | null> {
130
+ const loader = this._domains[domain]
131
+ if (!loader) return null
132
+ const node = await loader(locale)
133
+ if (node === null || node === undefined) return null
134
+ return { [domain]: node }
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Type guard: detects a provider that supports per-domain lazy loading.
140
+ * Used by I18n.t() to recover from a domain-miss without coupling I18n to
141
+ * the IncrementalDomainProvider class.
142
+ */
143
+ export function isDomainAwareProvider(
144
+ p: TranslationProvider
145
+ ): p is TranslationProvider & {
146
+ loadDomain(locale: string, domain: string): Promise<MessagesBundle | null>
147
+ knowsDomain(domain: string): boolean
148
+ } {
149
+ return (
150
+ typeof (p as { loadDomain?: unknown }).loadDomain === 'function' &&
151
+ typeof (p as { knowsDomain?: unknown }).knowsDomain === 'function'
152
+ )
153
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * InlineTranslationProvider — qdadm re-export from `@quazardous/qdcore/i18n`.
3
+ */
4
+ export { InlineTranslationProvider } from '@quazardous/qdcore'
@@ -0,0 +1,102 @@
1
+ /**
2
+ * LazyTranslationProvider - generic, format-agnostic provider that resolves
3
+ * locale bundles via a cascade of lazy loaders.
4
+ *
5
+ * Each loader is a `(locale) => Promise<MessagesBundle | null>` function. On
6
+ * `load(locale)`, the provider runs **all** loaders in order and deep-merges
7
+ * the partial bundles they return (later loaders override earlier ones —
8
+ * "last-merge-wins" semantics, same as the rest of the i18n stack).
9
+ *
10
+ * This lets consumers split bundles across multiple files — e.g. one loader
11
+ * for `core.*`, one for `entities.*`, one for `nav.*` — each backed by its
12
+ * own source (YAML file, JSON, fetch, etc.) without the provider knowing
13
+ * about any of those formats.
14
+ *
15
+ * ## When to use this vs `IncrementalDomainProvider`
16
+ *
17
+ * Use **`LazyTranslationProvider`** when you want one bundle per locale and
18
+ * don't care about loading domains independently:
19
+ * - All loaders run on `_loadLocale(locale)` (i.e. bootstrap or locale
20
+ * change). No partial state — once `bootstrap()` resolves, every key in
21
+ * every domain is in the registry for that locale.
22
+ * - Best for small/medium apps where the full bundle for a locale is
23
+ * reasonable to fetch up-front.
24
+ * - Domains are still split across files (the "cascade" lets you organise
25
+ * translations by topic), they're just resolved together.
26
+ *
27
+ * Use **`IncrementalDomainProvider`** when domains should be loaded only on
28
+ * demand — `t('shop.cart.title')` for an unloaded `shop` domain triggers an
29
+ * async load in the background and emits `i18n:domain-loaded` once merged.
30
+ * Best for large apps with rarely-used sections (admin, legal, marketing
31
+ * landing pages, …) where shipping every domain on bootstrap would be
32
+ * wasteful. See `IncrementalDomainProvider.ts`.
33
+ */
34
+
35
+ import type { MessagesBundle, MessagesNode, TranslationProvider } from '@quazardous/qdcore'
36
+
37
+ export type LazyLoader = (locale: string) => Promise<MessagesBundle | null>
38
+
39
+ export interface LazyTranslationProviderOptions {
40
+ /** Provider name (for debugging / introspection). */
41
+ name?: string
42
+ /** Cascade of loaders. Earlier loaders are overridden by later ones. */
43
+ loaders: LazyLoader[]
44
+ /**
45
+ * Locales advertised through `availableLocales()`. If omitted, the provider
46
+ * doesn't advertise any (callers should still be able to ask `load(locale)`
47
+ * — loaders that don't have the locale return null and are skipped).
48
+ */
49
+ locales?: string[]
50
+ }
51
+
52
+ export class LazyTranslationProvider implements TranslationProvider {
53
+ readonly name: string
54
+ private readonly _loaders: LazyLoader[]
55
+ private readonly _locales: string[] | undefined
56
+
57
+ constructor(options: LazyTranslationProviderOptions) {
58
+ this.name = options.name ?? 'lazy'
59
+ this._loaders = [...options.loaders]
60
+ this._locales = options.locales ? [...options.locales] : undefined
61
+ }
62
+
63
+ async load(locale: string): Promise<MessagesBundle> {
64
+ let merged: MessagesBundle = {}
65
+ for (const loader of this._loaders) {
66
+ const partial = await loader(locale)
67
+ if (partial && Object.keys(partial).length > 0) {
68
+ merged = deepMerge(merged, partial)
69
+ }
70
+ }
71
+ return merged
72
+ }
73
+
74
+ availableLocales(): string[] {
75
+ return this._locales ? [...this._locales] : []
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Recursive merge of two MessagesBundle (or any plain-object MessagesNode):
81
+ * objects merge recursively, leaf values from `b` override leaf values from
82
+ * `a`. Arrays are replaced wholesale (we don't expect arrays in bundles, but
83
+ * be safe). Returns a new object — does not mutate inputs.
84
+ */
85
+ function deepMerge(a: MessagesBundle, b: MessagesBundle): MessagesBundle
86
+ function deepMerge(a: MessagesNode, b: MessagesNode): MessagesNode
87
+ function deepMerge(a: MessagesNode, b: MessagesNode): MessagesNode {
88
+ if (!isPlainObject(a) || !isPlainObject(b)) return b
89
+ const out: Record<string, MessagesNode> = { ...(a as Record<string, MessagesNode>) }
90
+ for (const [key, bv] of Object.entries(b as Record<string, MessagesNode>)) {
91
+ const av = out[key]
92
+ out[key] =
93
+ isPlainObject(av) && isPlainObject(bv) ? (deepMerge(av, bv) as MessagesNode) : bv
94
+ }
95
+ return out as MessagesNode
96
+ }
97
+
98
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
99
+ if (value === null || typeof value !== 'object') return false
100
+ const proto = Object.getPrototypeOf(value)
101
+ return proto === Object.prototype || proto === null
102
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * MessagesRegistry — qdadm re-export from `@quazardous/qdcore/i18n`.
3
+ */
4
+ export { MessagesRegistry } from '@quazardous/qdcore'
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Resolver — qdadm re-export from `@quazardous/qdcore/i18n`.
3
+ */
4
+ export { Resolver, snakeCaseToTitle } from '@quazardous/qdcore'