@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.
- package/README.md +12 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/src/core/app/Application.d.ts +2 -0
- package/dist/src/core/app/Application.d.ts.map +1 -1
- package/dist/src/core/app/Application.js +7 -0
- package/dist/src/core/app/Application.js.map +1 -1
- package/dist/src/core/bootstrap/app.d.ts.map +1 -1
- package/dist/src/core/bootstrap/app.js +8 -2
- package/dist/src/core/bootstrap/app.js.map +1 -1
- package/dist/src/core/bootstrap/bundle.d.ts +7 -3
- package/dist/src/core/bootstrap/bundle.d.ts.map +1 -1
- package/dist/src/core/bootstrap/bundle.js +84 -7
- package/dist/src/core/bootstrap/bundle.js.map +1 -1
- package/dist/src/core/bootstrap/providers/default-providers.d.ts.map +1 -1
- package/dist/src/core/bootstrap/providers/default-providers.js +11 -3
- package/dist/src/core/bootstrap/providers/default-providers.js.map +1 -1
- package/dist/src/core/contracts/BundleInterface.d.ts +29 -0
- package/dist/src/core/contracts/BundleInterface.d.ts.map +1 -1
- package/dist/src/core/contracts/HelperInterface.d.ts +6 -0
- package/dist/src/core/contracts/HelperInterface.d.ts.map +1 -1
- package/dist/src/core/elements/Html.d.ts.map +1 -1
- package/dist/src/core/elements/Html.js +7 -0
- package/dist/src/core/elements/Html.js.map +1 -1
- package/dist/src/core/routers/Router.d.ts +3 -0
- package/dist/src/core/routers/Router.d.ts.map +1 -1
- package/dist/src/core/routers/Router.js +24 -3
- package/dist/src/core/routers/Router.js.map +1 -1
- package/dist/src/core/services/HelperService.d.ts +7 -0
- package/dist/src/core/services/HelperService.d.ts.map +1 -1
- package/dist/src/core/services/HelperService.js +35 -0
- package/dist/src/core/services/HelperService.js.map +1 -1
- package/dist/src/core/services/TranslationService.d.ts +19 -1
- package/dist/src/core/services/TranslationService.d.ts.map +1 -1
- package/dist/src/core/services/TranslationService.js +62 -6
- package/dist/src/core/services/TranslationService.js.map +1 -1
- package/dist/src/core/view/ViewManager.d.ts.map +1 -1
- package/dist/src/core/view/ViewManager.js +4 -0
- package/dist/src/core/view/ViewManager.js.map +1 -1
- package/index.ts +1 -1
- package/package.json +3 -2
- package/src/core/app/Application.ts +6 -0
- package/src/core/bootstrap/app.ts +9 -3
- package/src/core/bootstrap/bundle.ts +83 -7
- package/src/core/bootstrap/providers/default-providers.ts +9 -3
- package/src/core/contracts/BundleInterface.ts +31 -0
- package/src/core/contracts/HelperInterface.ts +7 -1
- package/src/core/elements/Html.ts +7 -0
- package/src/core/routers/Router.ts +22 -3
- package/src/core/services/HelperService.ts +36 -1
- package/src/core/services/TranslationService.ts +62 -4
- 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
|
|
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
|
-
|
|
44
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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)
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|