@saolabs/client 1.3.0 → 1.5.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 (40) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/src/core/app/Application.d.ts +2 -0
  4. package/dist/src/core/app/Application.d.ts.map +1 -1
  5. package/dist/src/core/app/Application.js +7 -0
  6. package/dist/src/core/app/Application.js.map +1 -1
  7. package/dist/src/core/bootstrap/app.d.ts.map +1 -1
  8. package/dist/src/core/bootstrap/app.js +8 -2
  9. package/dist/src/core/bootstrap/app.js.map +1 -1
  10. package/dist/src/core/bootstrap/bundle.d.ts +7 -3
  11. package/dist/src/core/bootstrap/bundle.d.ts.map +1 -1
  12. package/dist/src/core/bootstrap/bundle.js +84 -7
  13. package/dist/src/core/bootstrap/bundle.js.map +1 -1
  14. package/dist/src/core/bootstrap/providers/default-providers.d.ts.map +1 -1
  15. package/dist/src/core/bootstrap/providers/default-providers.js +11 -3
  16. package/dist/src/core/bootstrap/providers/default-providers.js.map +1 -1
  17. package/dist/src/core/contracts/BundleInterface.d.ts +29 -0
  18. package/dist/src/core/contracts/BundleInterface.d.ts.map +1 -1
  19. package/dist/src/core/elements/Html.d.ts +1 -0
  20. package/dist/src/core/elements/Html.d.ts.map +1 -1
  21. package/dist/src/core/elements/Html.js +18 -0
  22. package/dist/src/core/elements/Html.js.map +1 -1
  23. package/dist/src/core/services/HelperService.d.ts +2 -0
  24. package/dist/src/core/services/HelperService.d.ts.map +1 -1
  25. package/dist/src/core/services/HelperService.js +13 -1
  26. package/dist/src/core/services/HelperService.js.map +1 -1
  27. package/dist/src/core/services/TranslationService.d.ts +2 -0
  28. package/dist/src/core/services/TranslationService.d.ts.map +1 -1
  29. package/dist/src/core/services/TranslationService.js +5 -0
  30. package/dist/src/core/services/TranslationService.js.map +1 -1
  31. package/index.ts +1 -1
  32. package/package.json +1 -1
  33. package/src/core/app/Application.ts +6 -0
  34. package/src/core/bootstrap/app.ts +9 -3
  35. package/src/core/bootstrap/bundle.ts +83 -7
  36. package/src/core/bootstrap/providers/default-providers.ts +9 -3
  37. package/src/core/contracts/BundleInterface.ts +31 -0
  38. package/src/core/elements/Html.ts +18 -0
  39. package/src/core/services/HelperService.ts +13 -1
  40. package/src/core/services/TranslationService.ts +6 -0
@@ -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)[];
@@ -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;
@@ -750,6 +757,7 @@ export class Html implements HtmlInterface {
750
757
  if (children && children.length > 0) {
751
758
  hydrateElementList(this, children);
752
759
  }
760
+ this.applyOptionSelection();
753
761
  this.initMode = InitModes.CREATE;
754
762
  return this.element;
755
763
  }
@@ -768,11 +776,21 @@ export class Html implements HtmlInterface {
768
776
  }
769
777
  }
770
778
  for (const node of Array.from(this.element.childNodes)) if (!keep.has(node)) node.remove();
779
+ this.applyOptionSelection();
771
780
  if (this.isStarted) for (const child of children as any[]) child.start?.();
772
781
  this.maybeRunEnter();
773
782
  return this.element;
774
783
  }
775
784
 
785
+ private applyOptionSelection(): void {
786
+ const selected = this.config.props?.selected;
787
+ if (this.tagName.toLowerCase() !== 'option' || !selected) return;
788
+ // Insertion into a select may choose an option automatically. Apply the
789
+ // declared selection again after its DOM and text children are mounted.
790
+ (this.element as HTMLOptionElement).selected = Boolean(selected.type === 'binding'
791
+ ? selected.factory?.() : selected.value);
792
+ }
793
+
776
794
  private renderTextContent(): void {
777
795
  this.contentUnsubscribe?.();
778
796
  this.contentUnsubscribe = null;
@@ -78,13 +78,16 @@ export class HelperService implements HelperInterface {
78
78
  }
79
79
  // Page-data refreshes revisions without resetting the chosen locale.
80
80
  this.translations.refresh(config);
81
+ this.syncHeld();
81
82
  }
82
83
  text(key: string, replace: Record<string, any> | string | null = {}, defaultValue: string | null = null, locale?: string): string {
83
84
  if (typeof replace === 'string' && defaultValue === null) defaultValue = replace;
84
85
  return this.translations.text(key, replace && typeof replace === 'object' ? replace : {}, defaultValue, locale);
85
86
  }
86
87
  installLanguage(locale: string, snapshot: import('./TranslationService.js').LanguageSnapshot): boolean {
87
- return this.translations.install(locale, snapshot);
88
+ const installed = this.translations.install(locale, snapshot);
89
+ this.syncHeld();
90
+ return installed;
88
91
  }
89
92
  async loadLanguage(locale: string, load: (locale: string) => Promise<import('./TranslationService.js').LanguageSnapshot>): Promise<boolean> {
90
93
  return this.installLanguage(locale, await load(locale));
@@ -123,6 +126,15 @@ export class HelperService implements HelperInterface {
123
126
  if (typeof document !== 'undefined') document.documentElement.lang = locale;
124
127
  this.App?.Http?.setHeader?.('Accept-Language', locale);
125
128
  this.App?.API?.getHttpService?.()?.setHeader?.('Accept-Language', locale);
129
+ this.syncHeld();
130
+ }
131
+
132
+ /** Tells the server which dictionaries this app already holds, so page data does not carry them again. */
133
+ private syncHeld(): void {
134
+ const held = this.translations.held();
135
+ if (!held) return;
136
+ this.App?.Http?.setHeader?.('X-Saola-Language', held);
137
+ this.App?.API?.getHttpService?.()?.setHeader?.('X-Saola-Language', held);
126
138
  }
127
139
 
128
140
  // ─── Execution ──────────────────────────────────────────────
@@ -53,6 +53,12 @@ export class TranslationService {
53
53
  return this.interpolate(value, replace);
54
54
  }
55
55
 
56
+ /** The catalogs this app holds, as the server reads them back: `<scope>;<locale>=<revision>;…` ('' while none is loaded). */
57
+ held(): string {
58
+ const parts = Object.entries(this.catalogs).map(([locale, snapshot]) => `${locale}=${snapshot.revision}`);
59
+ return this.catalogScope && parts.length ? [this.catalogScope, ...parts].join(';') : '';
60
+ }
61
+
56
62
  install(locale: string, snapshot: LanguageSnapshot): boolean {
57
63
  if (snapshot.scope !== this.catalogScope) throw new Error('Language snapshot scope differs from the active catalog');
58
64
  if (!Number.isSafeInteger(snapshot.revision) || snapshot.revision < 0) throw new Error('Invalid language revision');