@saolabs/client 1.2.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +12 -1
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/src/core/app/Application.d.ts +2 -0
  5. package/dist/src/core/app/Application.d.ts.map +1 -1
  6. package/dist/src/core/app/Application.js +7 -0
  7. package/dist/src/core/app/Application.js.map +1 -1
  8. package/dist/src/core/bootstrap/app.d.ts.map +1 -1
  9. package/dist/src/core/bootstrap/app.js +8 -2
  10. package/dist/src/core/bootstrap/app.js.map +1 -1
  11. package/dist/src/core/bootstrap/bundle.d.ts +7 -3
  12. package/dist/src/core/bootstrap/bundle.d.ts.map +1 -1
  13. package/dist/src/core/bootstrap/bundle.js +84 -7
  14. package/dist/src/core/bootstrap/bundle.js.map +1 -1
  15. package/dist/src/core/bootstrap/providers/default-providers.d.ts.map +1 -1
  16. package/dist/src/core/bootstrap/providers/default-providers.js +11 -3
  17. package/dist/src/core/bootstrap/providers/default-providers.js.map +1 -1
  18. package/dist/src/core/contracts/BundleInterface.d.ts +29 -0
  19. package/dist/src/core/contracts/BundleInterface.d.ts.map +1 -1
  20. package/dist/src/core/contracts/HelperInterface.d.ts +6 -0
  21. package/dist/src/core/contracts/HelperInterface.d.ts.map +1 -1
  22. package/dist/src/core/elements/Html.d.ts.map +1 -1
  23. package/dist/src/core/elements/Html.js +7 -0
  24. package/dist/src/core/elements/Html.js.map +1 -1
  25. package/dist/src/core/routers/Router.d.ts +3 -0
  26. package/dist/src/core/routers/Router.d.ts.map +1 -1
  27. package/dist/src/core/routers/Router.js +24 -3
  28. package/dist/src/core/routers/Router.js.map +1 -1
  29. package/dist/src/core/services/HelperService.d.ts +7 -0
  30. package/dist/src/core/services/HelperService.d.ts.map +1 -1
  31. package/dist/src/core/services/HelperService.js +35 -0
  32. package/dist/src/core/services/HelperService.js.map +1 -1
  33. package/dist/src/core/services/TranslationService.d.ts +19 -1
  34. package/dist/src/core/services/TranslationService.d.ts.map +1 -1
  35. package/dist/src/core/services/TranslationService.js +62 -6
  36. package/dist/src/core/services/TranslationService.js.map +1 -1
  37. package/dist/src/core/view/ViewManager.d.ts.map +1 -1
  38. package/dist/src/core/view/ViewManager.js +4 -0
  39. package/dist/src/core/view/ViewManager.js.map +1 -1
  40. package/index.ts +1 -1
  41. package/package.json +3 -2
  42. package/src/core/app/Application.ts +6 -0
  43. package/src/core/bootstrap/app.ts +9 -3
  44. package/src/core/bootstrap/bundle.ts +83 -7
  45. package/src/core/bootstrap/providers/default-providers.ts +9 -3
  46. package/src/core/contracts/BundleInterface.ts +31 -0
  47. package/src/core/contracts/HelperInterface.ts +7 -1
  48. package/src/core/elements/Html.ts +7 -0
  49. package/src/core/routers/Router.ts +22 -3
  50. package/src/core/services/HelperService.ts +36 -1
  51. package/src/core/services/TranslationService.ts +62 -4
  52. package/src/core/view/ViewManager.ts +2 -0
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import type { ApplicationInterface } from "../contracts/ApplicationInterface.js";
12
- import type { MergedBundles, SaolaBundle } from "../contracts/BundleInterface.js";
12
+ import type { MergeOptions, MergedBundles, SaolaBundle } from "../contracts/BundleInterface.js";
13
13
  import logger from "../services/LoggerService.js";
14
14
 
15
15
  /**
@@ -30,21 +30,83 @@ function emptyMerged(): MergedBundles {
30
30
  return { providers: [], services: {}, helpers: {}, views: {}, boots: [], loaded: [], failed: [] };
31
31
  }
32
32
 
33
+ /** `A.B` phủ chính nó và `A.B.x`. */
34
+ function covered(list: string[] | undefined, key: string): boolean {
35
+ return !!list?.some(p => key === p || key.startsWith(p + '.'));
36
+ }
37
+
38
+ /**
39
+ * Lý do bundle bị từ chối ở chế độ strict, hoặc null nếu hợp lệ.
40
+ * `owners` = khoá → tên bundle đã khai trước đó.
41
+ */
42
+ function rejectReason(b: SaolaBundle, name: string, owners: Map<string, string>, o: MergeOptions, kind: string): string | null {
43
+ if (kind === 'theme') {
44
+ if (b.providers?.length) return `theme "${name}" không được khai providers`;
45
+ for (const key of [...Object.keys(b.services ?? {}), ...Object.keys(b.helpers ?? {})]) {
46
+ if (!covered(o.themeNamespaces, key)) return `theme "${name}" khai khoá "${key}" ngoài namespace trình bày cho phép`;
47
+ }
48
+ }
49
+ for (const [group, map] of [['services', b.services], ['helpers', b.helpers], ['views', b.views]] as const) {
50
+ for (const key of Object.keys(map ?? {})) {
51
+ const id = `${group}:${key}`;
52
+ const prev = owners.get(id);
53
+ if (!prev) continue;
54
+ // Override hợp lệ = bundle khai rõ VÀ context cho phép khoá đó.
55
+ if (b.overrides?.includes(key) && covered(o.overridable, key)) continue;
56
+ return `${group} "${key}" trùng giữa "${prev}" và "${name}" (không có overrides hợp lệ)`;
57
+ }
58
+ }
59
+ return null;
60
+ }
61
+
33
62
  /**
34
63
  * Gộp nhiều manifest theo ĐÚNG THỨ TỰ mảng — không theo thứ tự resolve của
35
64
  * mạng. Bundle sau ĐÈ bundle trước ở `views`/`services`/`helpers`; `providers`
36
65
  * thì CỘNG THÊM chứ không thay, để một bundle không gỡ được provider của app.
66
+ *
67
+ * `opts.strict` bật hàng rào của hợp đồng gói context: bundle trùng khoá không
68
+ * có `overrides` hợp lệ, hoặc theme khai khoá ngoài phạm vi, bị BỎ NGUYÊN CẢ
69
+ * BUNDLE và ghi vào `failed` (không làm sập boot). Provider trùng `name` bị bỏ.
37
70
  */
38
- export function mergeBundles(bundles: Array<SaolaBundle | null | undefined>): MergedBundles {
71
+ export function mergeBundles(bundles: Array<SaolaBundle | null | undefined>, opts: MergeOptions = {}): MergedBundles {
39
72
  const out = emptyMerged();
73
+ const owners = new Map<string, string>();
74
+ const providerNames = new Set<string>();
40
75
 
41
76
  for (const bundle of bundles) {
42
77
  if (!bundle || typeof bundle !== 'object') continue;
43
- out.loaded.push(bundle.name ?? '(không tên)');
44
- if (Array.isArray(bundle.providers)) out.providers.push(...bundle.providers);
78
+ const name = bundle.name ?? '(không tên)';
79
+
80
+ if (opts.strict) {
81
+ const kind = opts.kind ?? bundle.kind ?? 'package';
82
+ const why = rejectReason(bundle, name, owners, opts, kind);
83
+ if (why) {
84
+ out.failed.push([name, why]);
85
+ logger.error(`[Bundle] Bỏ bundle: ${why}`);
86
+ continue;
87
+ }
88
+ }
89
+
90
+ out.loaded.push(name);
91
+ if (Array.isArray(bundle.providers)) {
92
+ for (const p of bundle.providers) {
93
+ const pn = p && typeof p === 'object' ? (p as any).name : undefined;
94
+ if (opts.strict && typeof pn === 'string') {
95
+ if (providerNames.has(pn)) {
96
+ out.failed.push([name, `provider "${pn}" trùng tên, bỏ`]);
97
+ continue;
98
+ }
99
+ providerNames.add(pn);
100
+ }
101
+ out.providers.push(p);
102
+ }
103
+ }
45
104
  if (bundle.services) Object.assign(out.services, bundle.services);
46
105
  if (bundle.helpers) Object.assign(out.helpers, bundle.helpers);
47
106
  if (bundle.views) Object.assign(out.views, bundle.views);
107
+ for (const [group, map] of [['services', bundle.services], ['helpers', bundle.helpers], ['views', bundle.views]] as const) {
108
+ for (const key of Object.keys(map ?? {})) owners.set(`${group}:${key}`, name);
109
+ }
48
110
  if (typeof bundle.boot === 'function') out.boots.push(bundle.boot.bind(bundle));
49
111
  }
50
112
 
@@ -60,8 +122,8 @@ export function mergeBundles(bundles: Array<SaolaBundle | null | undefined>): Me
60
122
  * Một URL hỏng KHÔNG làm sập boot: ghi vào `failed` rồi đi tiếp. Theme lỗi thì
61
123
  * site chạy bằng view gốc, còn hơn trắng trang.
62
124
  */
63
- export async function loadBundles(urls: string[] | null | undefined): Promise<MergedBundles> {
64
- if (!Array.isArray(urls) || urls.length === 0) return emptyMerged();
125
+ export async function loadBundles(urls: string[] | null | undefined, opts: MergeOptions = {}): Promise<MergedBundles> {
126
+ if (!Array.isArray(urls) || urls.length === 0) return mergeBundles(opts.initialBundle ? [opts.initialBundle] : [], opts);
65
127
 
66
128
  const results = await Promise.all(urls.map(async (url): Promise<[string, SaolaBundle | null, string | null]> => {
67
129
  try {
@@ -78,7 +140,21 @@ export async function loadBundles(urls: string[] | null | undefined): Promise<Me
78
140
  }
79
141
  }));
80
142
 
81
- const merged = mergeBundles(results.map(([, bundle]) => bundle));
143
+ const classificationErrors: Array<[string, string]> = [];
144
+ const classified = results.map(([url, bundle]) => {
145
+ if (!bundle) return null;
146
+ const kind = opts.bundleKinds?.[url];
147
+ if (opts.strict && opts.bundleKinds && kind !== 'package' && kind !== 'theme') {
148
+ classificationErrors.push([url, 'Missing trusted bundle kind from server']);
149
+ return null;
150
+ }
151
+ return kind ? {...bundle, kind} : bundle;
152
+ });
153
+ const merged = mergeBundles([opts.initialBundle, ...classified], opts);
154
+ for (const failure of classificationErrors) {
155
+ merged.failed.push(failure);
156
+ logger.error(`[Bundle] ${failure[1]}: ${failure[0]}`);
157
+ }
82
158
  for (const [url, , error] of results) {
83
159
  if (error) {
84
160
  merged.failed.push([url, error]);
@@ -26,11 +26,12 @@ const SYSTEM_SERVICE_KEYS = new Set(['Store', 'Storage', 'Event', 'Http', 'View'
26
26
  * services: { Auth: AuthService, Toast: ToastService }
27
27
  * → 2 providers, mỗi cái dependsOn ['core'], register = app.set(name, new Class(app))
28
28
  */
29
- function servicesFromMap(map: Record<string, any>): ServiceProviderInterface[] {
29
+ function servicesFromMap(map: Record<string, any>, strict = false): ServiceProviderInterface[] {
30
30
  const appInstance = app<ApplicationInterface>();
31
31
  return Object.entries(map)
32
32
  .filter(([name]) => {
33
33
  if (SYSTEM_SERVICE_KEYS.has(name)) {
34
+ if (strict) throw new Error(`[Bootstrap] Không được ghi đè service hệ thống "${name}" qua config.services.`);
34
35
  console.warn(`[Bootstrap] Cannot override system service "${name}" via config.services. Ignored.`);
35
36
  return false;
36
37
  }
@@ -44,7 +45,9 @@ function servicesFromMap(map: Record<string, any>): ServiceProviderInterface[] {
44
45
  // không phải class — `new` vô điều kiện sẽ ném "is not a
45
46
  // constructor". Chỉ dựng khi thật sự là class.
46
47
  const isClass = typeof value === 'function' && value.prototype;
47
- appInstance.set(name, isClass ? new value(appInstance) : value);
48
+ // Dạng `{ factory }`: mỗi Application dựng instance riêng.
49
+ const isFactory = !!value && typeof value === 'object' && typeof value.factory === 'function';
50
+ appInstance.set(name, isClass ? new value(appInstance) : isFactory ? value.factory(appInstance) : value);
48
51
  }
49
52
  }));
50
53
  }
@@ -92,6 +95,8 @@ export function buildDefaultProviders(config: Record<string, any> = {}): NamedSe
92
95
  HelperServiceProvider.config = { i18n: config.i18n };
93
96
  ApiServiceProvider.config = config.api || {};
94
97
  const App = app<ApplicationInterface>();
98
+ const strict = !!config.strictBundles;
99
+ App.strictBundles = strict;
95
100
  const defaults: NamedServiceProvider[] = [
96
101
  CoreServiceProvider,
97
102
  ViewServiceProvider,
@@ -102,7 +107,7 @@ export function buildDefaultProviders(config: Record<string, any> = {}): NamedSe
102
107
 
103
108
  // config.services: { Name: Class } → auto-wrap providers
104
109
  const serviceProviders = config.services
105
- ? servicesFromMap(config.services)
110
+ ? servicesFromMap(config.services, strict)
106
111
  : [];
107
112
 
108
113
  // config.helpers: { tên: fn } → một provider phụ thuộc `helper`
@@ -118,6 +123,7 @@ export function buildDefaultProviders(config: Record<string, any> = {}): NamedSe
118
123
  )
119
124
  .filter(p => {
120
125
  if (SYSTEM_PROVIDER_NAMES.has(p.name as any)) {
126
+ if (strict) throw new Error(`[Bootstrap] Không được ghi đè provider hệ thống "${p.name}".`);
121
127
  console.warn(`[Bootstrap] Cannot override system provider "${p.name}". Ignored.`);
122
128
  return false;
123
129
  }
@@ -22,10 +22,41 @@ export interface SaolaBundle {
22
22
  helpers?: Record<string, any>;
23
23
  /** { 'dot.path': factory } → View.setViewRegistry(). Nạp sau ĐÈ nạp trước. */
24
24
  views?: Record<string, ViewFactory>;
25
+ /**
26
+ * Khoá (service/helper/view) bundle này CỐ Ý thay của bundle trước. Chỉ có hiệu
27
+ * lực ở chế độ strict và chỉ khi khoá nằm trong `overridable` của context.
28
+ */
29
+ overrides?: string[];
30
+ /** `'theme'` = lớp trình bày, bị giới hạn khoá (xem MergeOptions.themeNamespaces). */
31
+ kind?: 'theme' | 'package';
25
32
  /** Chạy sau khi mọi bundle đã merge và App đã init. */
26
33
  boot?(app: ApplicationInterface): void;
27
34
  }
28
35
 
36
+ /**
37
+ * Dạng khai service có factory: App gọi `factory(app)` khi đăng ký, nên mỗi
38
+ * Application có instance RIÊNG (không dùng instance tạo ở phạm vi module).
39
+ */
40
+ export interface ServiceFactory<T = any> {
41
+ factory: (app: ApplicationInterface) => T;
42
+ }
43
+
44
+ /** Tuỳ chọn gộp bundle. Mặc định (không truyền) = hành vi cũ: sau đè trước, im lặng. */
45
+ export interface MergeOptions {
46
+ /** Bật kiểm: trùng khoá, provider trùng tên, giới hạn khoá của theme. */
47
+ strict?: boolean;
48
+ /** Khoá được phép `overrides`; mục `A.B` phủ cả `A.B.x`. */
49
+ overridable?: string[];
50
+ /** Với bundle `kind: 'theme'`: tiền tố khoá services/helpers được phép. Thiếu = không cho khoá nào. */
51
+ themeNamespaces?: string[];
52
+ /** Ép mọi bundle trong lượt gộp là loại này (server quyết, bundle không tự nâng quyền được). */
53
+ kind?: 'theme' | 'package';
54
+ /** Trusted server classification by URL; bundle contents cannot promote a theme. */
55
+ bundleKinds?: Record<string, 'theme' | 'package'>;
56
+ /** Already registered source services/providers participate in strict collision checks. */
57
+ initialBundle?: SaolaBundle;
58
+ }
59
+
29
60
  /** Kết quả gộp nhiều bundle, theo đúng thứ tự mảng URL. */
30
61
  export interface MergedBundles {
31
62
  providers: (ServiceProviderInterface | any)[];
@@ -54,6 +54,12 @@ export interface CollectionProxyInterface {
54
54
  export interface HelperInterface {
55
55
  initTranslations(config: import('../services/TranslationService.js').TranslationConfig): void;
56
56
  getLocale(): string;
57
+ getLanguageScope(): string | undefined;
58
+ applyLanguageConfig(config: import('../services/TranslationService.js').TranslationConfig): void;
59
+ text(key: string, replace?: Record<string, any> | string | null, defaultValue?: string | null, locale?: string): string;
60
+ installLanguage(locale: string, snapshot: import('../services/TranslationService.js').LanguageSnapshot): boolean;
61
+ loadLanguage(locale: string, load: (locale: string) => Promise<import('../services/TranslationService.js').LanguageSnapshot>): Promise<boolean>;
62
+ changeLanguage(locale: string, load: (locale: string) => Promise<import('../services/TranslationService.js').LanguageSnapshot>, canLeave?: () => boolean | Promise<boolean>): Promise<boolean>;
57
63
  setLocale(locale: string): void;
58
64
  __(key: string, replace?: Record<string, any>, locale?: string): string;
59
65
  trans(key: string, replace?: Record<string, any>, locale?: string): string;
@@ -261,4 +267,4 @@ export interface HelperInterface {
261
267
  data_get(obj: any, path: string, defaultValue?: any): any;
262
268
  data_set(obj: any, path: string, value: any): void;
263
269
  old(key: string, defaultValue?: any): any;
264
- }
270
+ }
@@ -732,6 +732,13 @@ export class Html implements HtmlInterface {
732
732
  if (this.isSingleElement()) {
733
733
  return this.element;
734
734
  }
735
+ // Editors own their inner DOM, selection and composition state. Reconcile the
736
+ // host's attributes/events normally, but leave an explicitly declared island alone.
737
+ if (this.element.hasAttribute('data-sao-preserve-children')) {
738
+ this.initMode = InitModes.CREATE;
739
+ this.maybeRunEnter();
740
+ return this.element;
741
+ }
735
742
  if (this.config.content) {
736
743
  this.renderTextContent();
737
744
  return this.element;
@@ -176,6 +176,7 @@ export class Router {
176
176
 
177
177
  /** Navigation guards */
178
178
  private _beforeEach: NavigationGuard | null = null;
179
+ private navigationGuards = new Set<NavigationGuard>();
179
180
  /**
180
181
  * NHIỀU hook, không phải một.
181
182
  *
@@ -389,6 +390,12 @@ export class Router {
389
390
  return this;
390
391
  }
391
392
 
393
+ /** Add a disposable guard without replacing the host's beforeEach guard. */
394
+ addNavigationGuard(guard: NavigationGuard): () => void {
395
+ this.navigationGuards.add(guard);
396
+ return () => { this.navigationGuards.delete(guard); };
397
+ }
398
+
392
399
  /** Đăng ký hook chạy sau mỗi lần điều hướng. Trả về hàm HUỶ đăng ký. */
393
400
  afterEach(hook: AfterNavigationHook): () => void {
394
401
  this.afterHooks.add(hook);
@@ -656,6 +663,7 @@ export class Router {
656
663
  this.routeConfigs = {};
657
664
  this.routeCache.clear();
658
665
  this._beforeEach = null;
666
+ this.navigationGuards.clear();
659
667
  this.afterHooks.clear();
660
668
  this.currentRoute = null;
661
669
  this.liveRegion?.remove();
@@ -752,7 +760,18 @@ export class Router {
752
760
  // Before guard — chạy TRƯỚC khi đụng vào history: guard chặn thì URL giữ nguyên
753
761
  if (this._beforeEach) {
754
762
  const allow = await this._beforeEach(route, from, normalizedPath);
755
- if (allow === false) return;
763
+ if (allow === false) {
764
+ if (type === 'pop') this.restoreUrlAfterFailedPop(from);
765
+ return;
766
+ }
767
+ }
768
+ for (const guard of [...this.navigationGuards]) {
769
+ if (!this.navigationGuards.has(guard)) continue;
770
+ if (requestId !== this.navigationSequence) return;
771
+ if (await guard(route, from, normalizedPath) === false) {
772
+ if (type === 'pop') this.restoreUrlAfterFailedPop(from);
773
+ return;
774
+ }
756
775
  }
757
776
  if (requestId !== this.navigationSequence) return;
758
777
 
@@ -835,9 +854,9 @@ export class Router {
835
854
 
836
855
  /** Pop đã đổi address bar; render fail thì đưa URL về chain còn active. */
837
856
  private restoreUrlAfterFailedPop(from: ActiveRoute | null): void {
838
- if (this.mode !== 'history' || !from) return;
857
+ if (!from) return;
839
858
  const target = from.$fragment ? `${from.$uri}#${from.$fragment}` : from.$uri;
840
- window.history.replaceState(window.history.state, '', target);
859
+ window.history.replaceState(window.history.state, '', this.mode === 'hash' ? '#' + target : target);
841
860
  }
842
861
 
843
862
  /**
@@ -39,6 +39,7 @@ import { app } from "../helpers/app.js";
39
39
  export class HelperService implements HelperInterface {
40
40
  public App: any = null;
41
41
  private translations = new TranslationService();
42
+ private languageChangeSequence = 0;
42
43
  private config: Record<string, any> = {};
43
44
 
44
45
  constructor(App?: any) {
@@ -63,11 +64,45 @@ export class HelperService implements HelperInterface {
63
64
  }
64
65
 
65
66
  initTranslations(config: TranslationConfig): void {
67
+ this.languageChangeSequence++;
66
68
  this.translations.init(config);
67
69
  this.syncLocale();
68
70
  }
69
71
 
70
72
  getLocale(): string { return this.translations.locale; }
73
+ getLanguageScope(): string | undefined { return this.translations.getScope(); }
74
+ applyLanguageConfig(config: TranslationConfig): void {
75
+ if (!config.scope || config.scope !== this.getLanguageScope()) {
76
+ this.initTranslations(config);
77
+ return;
78
+ }
79
+ // Page-data refreshes revisions without resetting the chosen locale.
80
+ this.translations.refresh(config);
81
+ }
82
+ text(key: string, replace: Record<string, any> | string | null = {}, defaultValue: string | null = null, locale?: string): string {
83
+ if (typeof replace === 'string' && defaultValue === null) defaultValue = replace;
84
+ return this.translations.text(key, replace && typeof replace === 'object' ? replace : {}, defaultValue, locale);
85
+ }
86
+ installLanguage(locale: string, snapshot: import('./TranslationService.js').LanguageSnapshot): boolean {
87
+ return this.translations.install(locale, snapshot);
88
+ }
89
+ async loadLanguage(locale: string, load: (locale: string) => Promise<import('./TranslationService.js').LanguageSnapshot>): Promise<boolean> {
90
+ return this.installLanguage(locale, await load(locale));
91
+ }
92
+ async changeLanguage(
93
+ locale: string,
94
+ load: (locale: string) => Promise<import('./TranslationService.js').LanguageSnapshot>,
95
+ canLeave: () => boolean | Promise<boolean> = () => true,
96
+ ): Promise<boolean> {
97
+ const sequence = ++this.languageChangeSequence;
98
+ if (!await canLeave() || sequence !== this.languageChangeSequence) return false;
99
+ const snapshot = await load(locale);
100
+ if (sequence !== this.languageChangeSequence || !await canLeave()) return false;
101
+ if (sequence !== this.languageChangeSequence) return false;
102
+ this.installLanguage(locale, snapshot);
103
+ this.setLocale(locale);
104
+ return true;
105
+ }
71
106
  __(key: string, replace: Record<string, any> = {}, locale?: string): string { return this.translations.trans(key, replace, locale); }
72
107
  trans(key: string, replace: Record<string, any> = {}, locale?: string): string { return this.__(key, replace, locale); }
73
108
  lang(key: string, replace: Record<string, any> = {}, locale?: string): string { return this.__(key, replace, locale); }
@@ -1680,4 +1715,4 @@ export class CollectionProxy implements CollectionProxyInterface {
1680
1715
  pipe<T>(cb: (collection: CollectionProxy) => T): T { return cb(this); }
1681
1716
  toJSON(): any[] { return this.toArray(); }
1682
1717
  toString(): string { return JSON.stringify(this.items); }
1683
- }
1718
+ }
@@ -1,23 +1,42 @@
1
1
  export interface TranslationConfig {
2
+ scope?: string;
2
3
  locale?: string;
3
4
  fallbackLocale?: string;
4
5
  messages?: Record<string, { json?: Record<string, string>; groups?: Record<string, any> }>;
6
+ /** Saola DB dictionaries. Kept separate from Laravel json/groups. */
7
+ catalogs?: Record<string, LanguageSnapshot>;
5
8
  }
6
9
 
7
- /** Synchronous lookup of dictionaries already present in the application bundle. */
10
+ export interface LanguageTree { [key: string]: string | LanguageTree }
11
+ export interface LanguageSnapshot { revision: number; messages: LanguageTree; scope?: string }
12
+
13
+ /** Synchronous lookup; snapshots may be supplied at bootstrap or installed at runtime. */
8
14
  export class TranslationService {
9
15
  locale = 'en';
10
16
  fallbackLocale = 'en';
11
17
  private messages: NonNullable<TranslationConfig['messages']> = {};
18
+ private catalogs: Record<string, LanguageSnapshot> = {};
19
+ private catalogScope?: string;
20
+
21
+ getScope(): string | undefined { return this.catalogScope; }
22
+
23
+ refresh(config: TranslationConfig): void {
24
+ if (config.scope !== this.catalogScope) throw new Error('Language snapshot scope differs from the active catalog');
25
+ for (const [locale, snapshot] of Object.entries(config.catalogs || {})) this.install(locale, snapshot);
26
+ if (config.fallbackLocale) this.fallbackLocale = config.fallbackLocale;
27
+ }
12
28
 
13
29
  init(config: TranslationConfig): void {
30
+ this.catalogScope = config.scope;
14
31
  this.locale = config.locale || 'en';
15
32
  this.fallbackLocale = config.fallbackLocale || 'en';
16
33
  this.messages = config.messages || {};
34
+ this.catalogs = {};
35
+ for (const [locale, snapshot] of Object.entries(config.catalogs || {})) this.install(locale, snapshot);
17
36
  }
18
37
 
19
38
  setLocale(locale: string): boolean {
20
- if (!Object.prototype.hasOwnProperty.call(this.messages, locale)) throw new Error(`Locale is not bundled: ${locale}`);
39
+ if (!Object.prototype.hasOwnProperty.call(this.messages, locale) && !Object.prototype.hasOwnProperty.call(this.catalogs, locale)) throw new Error(`Locale is not loaded: ${locale}`);
21
40
  if (locale === this.locale) return false;
22
41
  this.locale = locale;
23
42
  return true;
@@ -28,6 +47,40 @@ export class TranslationService {
28
47
  return this.interpolate(value, replace);
29
48
  }
30
49
 
50
+ text(key: string, replace: Record<string, any> = {}, defaultValue: string | null = null, locale = this.locale): string {
51
+ const value = this.lookupTree(this.catalogs[locale]?.messages, key)
52
+ ?? this.lookupTree(this.catalogs[this.fallbackLocale]?.messages, key) ?? defaultValue ?? key;
53
+ return this.interpolate(value, replace);
54
+ }
55
+
56
+ install(locale: string, snapshot: LanguageSnapshot): boolean {
57
+ if (snapshot.scope !== this.catalogScope) throw new Error('Language snapshot scope differs from the active catalog');
58
+ if (!Number.isSafeInteger(snapshot.revision) || snapshot.revision < 0) throw new Error('Invalid language revision');
59
+ const validate = (value: any, depth = 0): LanguageTree => {
60
+ if (!value || typeof value !== 'object' || Array.isArray(value) || depth > 32) throw new Error('Invalid language tree');
61
+ const copy: LanguageTree = Object.create(null);
62
+ for (const [key, child] of Object.entries(value)) {
63
+ if (!key || key.includes('.') || /\s/u.test(key) || ['__proto__', 'constructor', 'prototype'].includes(key)) throw new Error('Invalid language key');
64
+ copy[key] = typeof child === 'string' ? child : validate(child, depth + 1);
65
+ }
66
+ return copy;
67
+ };
68
+ const messages = validate(snapshot.messages);
69
+ if (this.catalogs[locale] && this.catalogs[locale].revision > snapshot.revision) return false;
70
+ // Replace, rather than deep merge: removed keys must disappear.
71
+ this.catalogs = { ...this.catalogs, [locale]: { revision: snapshot.revision, messages, scope: snapshot.scope } };
72
+ return true;
73
+ }
74
+
75
+ private lookupTree(tree: LanguageTree | undefined, key: string): string | undefined {
76
+ let value: string | LanguageTree | undefined = tree;
77
+ for (const part of key.split('.')) {
78
+ if (!value || typeof value !== 'object' || !Object.prototype.hasOwnProperty.call(value, part)) return undefined;
79
+ value = value[part];
80
+ }
81
+ return typeof value === 'string' ? value : undefined;
82
+ }
83
+
31
84
  choice(key: string, count: number, replace: Record<string, any> = {}, locale = this.locale): string {
32
85
  const local = this.lookup(key, locale);
33
86
  const value = local ?? this.lookup(key, this.fallbackLocale) ?? key;
@@ -77,6 +130,7 @@ export class TranslationService {
77
130
  }
78
131
 
79
132
  private interpolate(value: string, replace: Record<string, any>): string {
133
+ const parameters: Record<string, string> = Object.create(null);
80
134
  const keys = Object.keys(replace).sort((a, b) => b.length - a.length);
81
135
  for (const key of keys) {
82
136
  const text = String(replace[key]);
@@ -84,8 +138,12 @@ export class TranslationService {
84
138
  [key]: text, [key.toUpperCase()]: text.toUpperCase(),
85
139
  [key.charAt(0).toUpperCase() + key.slice(1)]: text.charAt(0).toUpperCase() + text.slice(1),
86
140
  };
87
- for (const [name, content] of Object.entries(variants)) value = value.split(':' + name).join(content);
141
+ // Build substitutions first; inserted text must never be interpolated again.
142
+ Object.assign(parameters, Object.fromEntries(Object.entries(variants).map(([name, content]) => [':' + name, content])));
88
143
  }
89
- return value;
144
+ const names = Object.keys(parameters).sort((a, b) => b.length - a.length);
145
+ if (!names.length) return value;
146
+ const pattern = new RegExp(names.map(name => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'), 'g');
147
+ return value.replace(pattern, match => parameters[match]);
90
148
  }
91
149
  }
@@ -902,6 +902,7 @@ export class ViewManager implements ViewManagerInterface {
902
902
  let asyncData: Record<string, any> = {};
903
903
  try {
904
904
  const response = await Http.get(fetchUrl, this.fetchOptions);
905
+ if (!this.isNavigationCurrent(navigationGeneration)) return this.createRenderPageViewCancelled(view);
905
906
  asyncData = this.extractAsyncData(response);
906
907
  } catch (err) {
907
908
  if (!this.isNavigationCurrent(navigationGeneration)) {
@@ -1607,6 +1608,7 @@ export class ViewManager implements ViewManagerInterface {
1607
1608
  && Object.prototype.hasOwnProperty.call(payload, 'data')
1608
1609
  && (Object.prototype.hasOwnProperty.call(payload, 'viewContext')
1609
1610
  || Object.prototype.hasOwnProperty.call(payload, 'view'))) {
1611
+ if (payload.i18n) this.App?.Helper?.applyLanguageConfig?.(payload.i18n);
1610
1612
  return payload.data ?? {};
1611
1613
  }
1612
1614
  return payload;