@quazardous/qdadm 1.19.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +111 -0
- package/package.json +101 -0
- package/src/assets/logo.svg +6 -0
- package/src/auth/SessionAuthAdapter.ts +459 -0
- package/src/auth/index.ts +15 -0
- package/src/chain/ActiveStack.ts +154 -0
- package/src/chain/StackHydrator.ts +292 -0
- package/src/chain/index.ts +25 -0
- package/src/chain/useActiveStack.ts +120 -0
- package/src/chain/useStackHydrator.ts +155 -0
- package/src/components/BoolCell.vue +34 -0
- package/src/components/InfoBanner.vue +73 -0
- package/src/components/QdadmRoot.vue +52 -0
- package/src/components/SeverityTag.vue +86 -0
- package/src/components/dialogs/BulkStatusDialog.vue +60 -0
- package/src/components/dialogs/MultiStepDialog.vue +331 -0
- package/src/components/dialogs/SimpleDialog.vue +122 -0
- package/src/components/dialogs/UnsavedChangesDialog.vue +80 -0
- package/src/components/display/BannerZone.vue +65 -0
- package/src/components/display/CardsGrid.vue +72 -0
- package/src/components/display/CopyableId.vue +84 -0
- package/src/components/display/EmptyState.vue +62 -0
- package/src/components/display/IntensityBar.vue +190 -0
- package/src/components/display/RichCardsGrid.vue +101 -0
- package/src/components/edit/FormActions.vue +78 -0
- package/src/components/edit/FormField.vue +82 -0
- package/src/components/edit/FormInput.vue +158 -0
- package/src/components/edit/FormPage.vue +260 -0
- package/src/components/edit/FormTab.vue +217 -0
- package/src/components/edit/FormTabs.vue +120 -0
- package/src/components/edit/LookupField.vue +406 -0
- package/src/components/edit/LookupPickerDialog.vue +230 -0
- package/src/components/editors/JsonEditorFoldable.vue +470 -0
- package/src/components/editors/JsonStructuredField.vue +264 -0
- package/src/components/editors/JsonViewer.vue +51 -0
- package/src/components/editors/KeyValueEditor.vue +314 -0
- package/src/components/editors/LanguageEditor.vue +259 -0
- package/src/components/editors/PermissionEditor.vue +558 -0
- package/src/components/editors/ScopeEditor.vue +372 -0
- package/src/components/editors/VanillaJsonEditor.vue +183 -0
- package/src/components/index.ts +83 -0
- package/src/components/item/FieldGroups.vue +392 -0
- package/src/components/layout/AppLayout.vue +1032 -0
- package/src/components/layout/BaseLayout.vue +190 -0
- package/src/components/layout/Breadcrumb.vue +55 -0
- package/src/components/layout/DashboardLayout.vue +104 -0
- package/src/components/layout/FormLayout.vue +256 -0
- package/src/components/layout/ListLayout.vue +278 -0
- package/src/components/layout/PageHeader.vue +156 -0
- package/src/components/layout/PageLayout.vue +126 -0
- package/src/components/layout/PageNav.vue +192 -0
- package/src/components/layout/SidebarBox.vue +87 -0
- package/src/components/layout/Zone.vue +182 -0
- package/src/components/layout/defaults/DefaultBreadcrumb.vue +149 -0
- package/src/components/layout/defaults/DefaultFooter.vue +89 -0
- package/src/components/layout/defaults/DefaultFormActions.vue +67 -0
- package/src/components/layout/defaults/DefaultHeader.vue +78 -0
- package/src/components/layout/defaults/DefaultMenu.vue +188 -0
- package/src/components/layout/defaults/DefaultPagination.vue +75 -0
- package/src/components/layout/defaults/DefaultTable.vue +137 -0
- package/src/components/layout/defaults/DefaultUserInfo.vue +111 -0
- package/src/components/layout/defaults/index.ts +16 -0
- package/src/components/lists/ActionButtons.vue +49 -0
- package/src/components/lists/ActionColumn.vue +40 -0
- package/src/components/lists/FilterBar.vue +54 -0
- package/src/components/lists/ListPage.vue +467 -0
- package/src/components/pages/LoginPage.vue +334 -0
- package/src/components/pages/NotFoundPage.vue +98 -0
- package/src/components/show/ShowDisplay.vue +343 -0
- package/src/components/show/ShowField.vue +67 -0
- package/src/components/show/ShowPage.vue +431 -0
- package/src/composables/index.ts +155 -0
- package/src/composables/useApp.ts +67 -0
- package/src/composables/useAuth.ts +98 -0
- package/src/composables/useBareForm.ts +319 -0
- package/src/composables/useBreadcrumb.ts +252 -0
- package/src/composables/useChildPage.ts +113 -0
- package/src/composables/useCurrentEntity.ts +60 -0
- package/src/composables/useDeferred.ts +94 -0
- package/src/composables/useDirtyState.ts +138 -0
- package/src/composables/useEntityItemFormPage.ts +942 -0
- package/src/composables/useEntityItemFormPage.types.ts +451 -0
- package/src/composables/useEntityItemPage.ts +438 -0
- package/src/composables/useEntityItemShowPage.ts +722 -0
- package/src/composables/useFieldManager.ts +692 -0
- package/src/composables/useForm.ts +544 -0
- package/src/composables/useGuardStore.ts +53 -0
- package/src/composables/useHooks.ts +56 -0
- package/src/composables/useInfoBanner.ts +217 -0
- package/src/composables/useJsonSyntax.ts +122 -0
- package/src/composables/useLayoutResolver.ts +329 -0
- package/src/composables/useListPage.ts +1535 -0
- package/src/composables/useListPage.types.ts +437 -0
- package/src/composables/useListPage.utils.ts +101 -0
- package/src/composables/useNavContext.ts +439 -0
- package/src/composables/useNavigation.ts +216 -0
- package/src/composables/useOptionsLookup.ts +323 -0
- package/src/composables/usePageTitle.ts +84 -0
- package/src/composables/useSSEBridge.ts +142 -0
- package/src/composables/useSemanticBreadcrumb.ts +222 -0
- package/src/composables/useSidebarState.ts +24 -0
- package/src/composables/useSignals.ts +52 -0
- package/src/composables/useUnsavedChangesGuard.ts +155 -0
- package/src/composables/useUserImpersonator.ts +317 -0
- package/src/composables/useZoneRegistry.ts +252 -0
- package/src/core/bundles.ts +558 -0
- package/src/core/decorator.ts +420 -0
- package/src/core/extension.ts +470 -0
- package/src/core/index.ts +59 -0
- package/src/deferred/DeferredRegistry.ts +353 -0
- package/src/deferred/index.ts +15 -0
- package/src/editors/index.ts +12 -0
- package/src/entity/EntityManager.cache.ts +530 -0
- package/src/entity/EntityManager.crud.ts +615 -0
- package/src/entity/EntityManager.interface.ts +78 -0
- package/src/entity/EntityManager.query.ts +221 -0
- package/src/entity/EntityManager.relations.ts +151 -0
- package/src/entity/EntityManager.ts +944 -0
- package/src/entity/EntityManager.types.ts +288 -0
- package/src/entity/auth/CompositeAuthAdapter.ts +194 -0
- package/src/entity/auth/EntityAuthAdapter.ts +119 -0
- package/src/entity/auth/PermissiveAdapter.ts +43 -0
- package/src/entity/auth/RoleHierarchy.ts +128 -0
- package/src/entity/auth/SecurityChecker.ts +202 -0
- package/src/entity/auth/factory.ts +203 -0
- package/src/entity/auth/index.ts +47 -0
- package/src/entity/factory.test.js +189 -0
- package/src/entity/factory.ts +146 -0
- package/src/entity/index.ts +49 -0
- package/src/entity/storage/ApiStorage.ts +228 -0
- package/src/entity/storage/IStorage.ts +100 -0
- package/src/entity/storage/LocalStorage.ts +200 -0
- package/src/entity/storage/MemoryStorage.ts +184 -0
- package/src/entity/storage/MockApiStorage.ts +318 -0
- package/src/entity/storage/SdkStorage.ts +377 -0
- package/src/entity/storage/factory.test.js +159 -0
- package/src/entity/storage/factory.ts +209 -0
- package/src/entity/storage/index.ts +75 -0
- package/src/gen/FieldMapper.ts +122 -0
- package/src/gen/StorageProfileFactory.ts +93 -0
- package/src/gen/connectors/BaseConnector.ts +150 -0
- package/src/gen/connectors/ManualConnector.test.js +499 -0
- package/src/gen/connectors/ManualConnector.ts +414 -0
- package/src/gen/connectors/OpenAPIConnector.test.js +836 -0
- package/src/gen/connectors/OpenAPIConnector.ts +693 -0
- package/src/gen/connectors/__fixtures__/sample-openapi.json +311 -0
- package/src/gen/connectors/index.ts +27 -0
- package/src/gen/createManagers.ts +245 -0
- package/src/gen/decorators.ts +134 -0
- package/src/gen/generateManagers.test.js +540 -0
- package/src/gen/generateManagers.ts +398 -0
- package/src/gen/index.ts +66 -0
- package/src/gen/schema.ts +248 -0
- package/src/gen/vite-plugin.ts +114 -0
- package/src/generated/managers/testManager.js +45 -0
- package/src/generated/managers/testManager.ts +46 -0
- package/src/hooks/HookRegistry.ts +16 -0
- package/src/hooks/index.ts +15 -0
- 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 +453 -0
- package/src/kernel/EventRouter.ts +23 -0
- package/src/kernel/Kernel.api.ts +79 -0
- package/src/kernel/Kernel.auth.ts +120 -0
- package/src/kernel/Kernel.i18n.ts +29 -0
- package/src/kernel/Kernel.modules.ts +170 -0
- package/src/kernel/Kernel.registries.ts +237 -0
- package/src/kernel/Kernel.routing.ts +334 -0
- package/src/kernel/Kernel.ts +460 -0
- package/src/kernel/Kernel.types.ts +263 -0
- package/src/kernel/Kernel.vue.ts +304 -0
- 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 +252 -0
- package/src/kernel/KernelContext.types.ts +173 -0
- package/src/kernel/KernelContext.zones.ts +54 -0
- package/src/kernel/Module.ts +169 -0
- package/src/kernel/ModuleLoader.ts +645 -0
- package/src/kernel/SSEBridge.ts +11 -0
- package/src/kernel/SignalBus.ts +48 -0
- package/src/kernel/index.ts +56 -0
- package/src/module/index.ts +33 -0
- package/src/module/moduleRegistry.ts +492 -0
- package/src/modules/debug/AuthCollector.ts +501 -0
- package/src/modules/debug/Collector.ts +20 -0
- package/src/modules/debug/DebugBridge.ts +14 -0
- package/src/modules/debug/DebugModule.ts +220 -0
- package/src/modules/debug/EntitiesCollector.ts +839 -0
- package/src/modules/debug/ErrorCollector.ts +4 -0
- package/src/modules/debug/I18nCollector.ts +9 -0
- package/src/modules/debug/LocalStorageAdapter.ts +7 -0
- package/src/modules/debug/RouterCollector.ts +449 -0
- package/src/modules/debug/SignalCollector.ts +4 -0
- package/src/modules/debug/ToastCollector.ts +4 -0
- package/src/modules/debug/ZonesCollector.ts +447 -0
- package/src/modules/debug/components/DebugBar.vue +31 -0
- package/src/modules/debug/components/adminPanelsConfig.ts +42 -0
- package/src/modules/debug/components/index.ts +9 -0
- package/src/modules/debug/components/panels/AuthPanel.vue +267 -0
- package/src/modules/debug/components/panels/EntitiesPanel.vue +572 -0
- package/src/modules/debug/components/panels/I18nPanel.vue +738 -0
- package/src/modules/debug/components/panels/RouterPanel.vue +440 -0
- package/src/modules/debug/components/panels/ZonesPanel.vue +133 -0
- package/src/modules/debug/components/panels/index.ts +16 -0
- package/src/modules/debug/index.ts +58 -0
- package/src/modules/debug/styles.scss +2671 -0
- package/src/notifications/NotificationBadge.vue +35 -0
- package/src/notifications/NotificationListener.vue +71 -0
- package/src/notifications/NotificationModule.ts +76 -0
- package/src/notifications/NotificationPanel.vue +478 -0
- package/src/notifications/NotificationStore.ts +288 -0
- package/src/notifications/index.ts +42 -0
- package/src/notifications/styles.scss +32 -0
- package/src/orchestrator/Orchestrator.ts +427 -0
- package/src/orchestrator/index.ts +8 -0
- package/src/orchestrator/useOrchestrator.ts +80 -0
- package/src/plugin.ts +251 -0
- package/src/query/FilterQuery.ts +306 -0
- package/src/query/QueryExecutor.ts +341 -0
- package/src/query/index.ts +16 -0
- package/src/security/EntityRolesProvider.ts +385 -0
- package/src/security/PermissionMatcher.ts +152 -0
- package/src/security/PermissionRegistry.ts +273 -0
- package/src/security/PersistableRolesProvider.ts +584 -0
- package/src/security/RolesManager.ts +136 -0
- package/src/security/RolesProvider.ts +167 -0
- package/src/security/RolesProviderStorage.ts +211 -0
- package/src/security/SecurityModule.ts +94 -0
- package/src/security/StaticRolesProvider.ts +117 -0
- package/src/security/UsersManager.ts +163 -0
- package/src/security/index.ts +77 -0
- package/src/security/pages/RoleForm.vue +276 -0
- package/src/security/pages/RoleList.vue +123 -0
- package/src/styles/_alerts.scss +95 -0
- package/src/styles/_buttons.scss +391 -0
- package/src/styles/_cards.scss +241 -0
- package/src/styles/_code.scss +78 -0
- package/src/styles/_dialogs.scss +55 -0
- package/src/styles/_filter-bar.scss +70 -0
- package/src/styles/_forms.scss +434 -0
- package/src/styles/_helpers.scss +153 -0
- package/src/styles/_lists.scss +225 -0
- package/src/styles/_main.scss +356 -0
- package/src/styles/_markdown.scss +88 -0
- package/src/styles/_responsive.scss +255 -0
- package/src/styles/_show-pages.scss +186 -0
- package/src/styles/_sidebar.scss +380 -0
- package/src/styles/_states.scss +115 -0
- package/src/styles/_stats.scss +47 -0
- package/src/styles/_variables.scss +224 -0
- package/src/styles/index.scss +38 -0
- package/src/styles/theme/_components.scss +331 -0
- package/src/styles/theme/_index.scss +10 -0
- package/src/styles/theme/_tokens.scss +147 -0
- package/src/styles/theme/_utilities.scss +175 -0
- package/src/toast/ToastBridgeModule.ts +68 -0
- package/src/toast/ToastListener.vue +55 -0
- package/src/toast/index.ts +26 -0
- package/src/toast/useSignalToast.ts +154 -0
- package/src/types/index.ts +285 -0
- package/src/types/pluralize.d.ts +19 -0
- package/src/types/quarkernel.d.ts +50 -0
- package/src/utils/formatters.ts +174 -0
- package/src/utils/index.ts +35 -0
- package/src/utils/transformers.ts +131 -0
- package/src/vite/qdadmDebugPlugin.ts +401 -0
- package/src/vite-env.d.ts +50 -0
- package/src/zones/ZoneRegistry.ts +934 -0
- package/src/zones/index.ts +29 -0
- package/src/zones/zones.ts +191 -0
|
@@ -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,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,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
|
+
})
|