@alepha/react 0.14.2 → 0.14.4
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/dist/auth/index.browser.js +29 -14
- package/dist/auth/index.browser.js.map +1 -1
- package/dist/auth/index.js +960 -195
- package/dist/auth/index.js.map +1 -1
- package/dist/core/index.d.ts +4 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +7 -4
- package/dist/core/index.js.map +1 -1
- package/dist/head/index.browser.js +59 -19
- package/dist/head/index.browser.js.map +1 -1
- package/dist/head/index.d.ts +99 -560
- package/dist/head/index.d.ts.map +1 -1
- package/dist/head/index.js +92 -87
- package/dist/head/index.js.map +1 -1
- package/dist/router/index.browser.js +30 -15
- package/dist/router/index.browser.js.map +1 -1
- package/dist/router/index.d.ts +616 -192
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/index.js +961 -196
- package/dist/router/index.js.map +1 -1
- package/package.json +4 -4
- package/src/auth/__tests__/$auth.spec.ts +188 -0
- package/src/core/__tests__/Router.spec.tsx +169 -0
- package/src/core/hooks/useAction.browser.spec.tsx +569 -0
- package/src/core/hooks/useAction.ts +11 -0
- package/src/form/hooks/useForm.browser.spec.tsx +366 -0
- package/src/head/helpers/SeoExpander.spec.ts +203 -0
- package/src/head/hooks/useHead.spec.tsx +288 -0
- package/src/head/index.ts +11 -28
- package/src/head/providers/BrowserHeadProvider.browser.spec.ts +196 -0
- package/src/head/providers/BrowserHeadProvider.ts +25 -19
- package/src/head/providers/HeadProvider.ts +76 -10
- package/src/head/providers/ServerHeadProvider.ts +22 -138
- package/src/i18n/__tests__/integration.spec.tsx +239 -0
- package/src/i18n/components/Localize.spec.tsx +357 -0
- package/src/i18n/hooks/useI18n.browser.spec.tsx +438 -0
- package/src/i18n/providers/I18nProvider.spec.ts +389 -0
- package/src/router/__tests__/page-head-browser.browser.spec.ts +91 -0
- package/src/router/__tests__/page-head.spec.ts +44 -0
- package/src/router/__tests__/seo-head.spec.ts +121 -0
- package/src/router/atoms/ssrManifestAtom.ts +60 -0
- package/src/router/constants/PAGE_PRELOAD_KEY.ts +6 -0
- package/src/router/errors/Redirection.ts +1 -1
- package/src/router/index.shared.ts +1 -0
- package/src/router/index.ts +16 -2
- package/src/router/primitives/$page.browser.spec.tsx +702 -0
- package/src/router/primitives/$page.spec.tsx +702 -0
- package/src/router/primitives/$page.ts +46 -10
- package/src/router/providers/ReactBrowserProvider.ts +14 -29
- package/src/router/providers/ReactBrowserRouterProvider.ts +5 -0
- package/src/router/providers/ReactPageProvider.ts +11 -4
- package/src/router/providers/ReactServerProvider.spec.tsx +316 -0
- package/src/router/providers/ReactServerProvider.ts +331 -315
- package/src/router/providers/ReactServerTemplateProvider.ts +775 -0
- package/src/router/providers/SSRManifestProvider.ts +365 -0
- package/src/router/services/ReactPageServerService.ts +5 -3
- package/src/router/services/ReactRouter.ts +3 -3
|
@@ -1,14 +1,54 @@
|
|
|
1
|
-
import type { PageRoute, ReactRouterState } from "@alepha/react/router";
|
|
2
1
|
import { $inject } from "alepha";
|
|
2
|
+
import { $logger } from "alepha/logger";
|
|
3
3
|
import { SeoExpander } from "../helpers/SeoExpander.ts";
|
|
4
4
|
import type { Head } from "../interfaces/Head.ts";
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Provides methods to fill and merge head information into the application state.
|
|
8
|
+
*
|
|
9
|
+
* Used both on server and client side to manage document head.
|
|
10
|
+
*
|
|
11
|
+
* @see {@link SeoExpander}
|
|
12
|
+
* @see {@link ServerHeadProvider}
|
|
13
|
+
* @see {@link BrowserHeadProvider}
|
|
14
|
+
*/
|
|
6
15
|
export class HeadProvider {
|
|
16
|
+
protected readonly log = $logger();
|
|
7
17
|
protected readonly seoExpander = $inject(SeoExpander);
|
|
8
18
|
|
|
9
19
|
public global?: Array<Head | (() => Head)> = [];
|
|
10
20
|
|
|
11
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Track if we've warned about page-level htmlAttributes to avoid spam.
|
|
23
|
+
*/
|
|
24
|
+
protected warnedAboutHtmlAttributes = false;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve global head configuration (from $head primitives only).
|
|
28
|
+
*
|
|
29
|
+
* This is used to get htmlAttributes early, before page loaders run.
|
|
30
|
+
* Only htmlAttributes from global $head are allowed; page-level htmlAttributes
|
|
31
|
+
* are ignored for early streaming optimization.
|
|
32
|
+
*
|
|
33
|
+
* @returns Merged global head with htmlAttributes
|
|
34
|
+
*/
|
|
35
|
+
public resolveGlobalHead(): Head {
|
|
36
|
+
const head: Head = {};
|
|
37
|
+
|
|
38
|
+
for (const h of this.global ?? []) {
|
|
39
|
+
const resolved = typeof h === "function" ? h() : h;
|
|
40
|
+
if (resolved.htmlAttributes) {
|
|
41
|
+
head.htmlAttributes = {
|
|
42
|
+
...head.htmlAttributes,
|
|
43
|
+
...resolved.htmlAttributes,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return head;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
public fillHead(state: HeadState) {
|
|
12
52
|
state.head = {
|
|
13
53
|
...state.head,
|
|
14
54
|
};
|
|
@@ -25,7 +65,7 @@ export class HeadProvider {
|
|
|
25
65
|
}
|
|
26
66
|
}
|
|
27
67
|
|
|
28
|
-
protected mergeHead(state:
|
|
68
|
+
protected mergeHead(state: HeadState, head: Head): void {
|
|
29
69
|
// Expand SEO fields into meta tags
|
|
30
70
|
const { meta, link } = this.seoExpander.expand(head);
|
|
31
71
|
state.head = {
|
|
@@ -38,8 +78,8 @@ export class HeadProvider {
|
|
|
38
78
|
}
|
|
39
79
|
|
|
40
80
|
protected fillHeadByPage(
|
|
41
|
-
page:
|
|
42
|
-
state:
|
|
81
|
+
page: HeadRoute,
|
|
82
|
+
state: HeadState,
|
|
43
83
|
props: Record<string, any>,
|
|
44
84
|
): void {
|
|
45
85
|
if (!page.head) {
|
|
@@ -70,11 +110,14 @@ export class HeadProvider {
|
|
|
70
110
|
state.head.titleSeparator = head.titleSeparator;
|
|
71
111
|
}
|
|
72
112
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
113
|
+
// htmlAttributes from pages are ignored for early streaming optimization.
|
|
114
|
+
// Only global $head can set htmlAttributes.
|
|
115
|
+
if (head.htmlAttributes && !this.warnedAboutHtmlAttributes) {
|
|
116
|
+
this.warnedAboutHtmlAttributes = true;
|
|
117
|
+
this.log.warn(
|
|
118
|
+
"Page-level htmlAttributes are ignored. Use global $head() for htmlAttributes instead, " +
|
|
119
|
+
"as they are sent before page loaders run for early streaming optimization.",
|
|
120
|
+
);
|
|
78
121
|
}
|
|
79
122
|
|
|
80
123
|
if (head.bodyAttributes) {
|
|
@@ -97,3 +140,26 @@ export class HeadProvider {
|
|
|
97
140
|
}
|
|
98
141
|
}
|
|
99
142
|
}
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Minimal route interface for head processing.
|
|
148
|
+
* Avoids circular dependency with @alepha/react/router.
|
|
149
|
+
*/
|
|
150
|
+
interface HeadRoute {
|
|
151
|
+
head?: Head | ((props: Record<string, any>, previous?: Head) => Head);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Minimal state interface for head processing.
|
|
156
|
+
* Avoids circular dependency with @alepha/react/router.
|
|
157
|
+
*/
|
|
158
|
+
interface HeadState {
|
|
159
|
+
head: Head;
|
|
160
|
+
layers: Array<{
|
|
161
|
+
route?: HeadRoute;
|
|
162
|
+
props?: Record<string, any>;
|
|
163
|
+
error?: Error;
|
|
164
|
+
}>;
|
|
165
|
+
}
|
|
@@ -1,147 +1,31 @@
|
|
|
1
|
-
import { $
|
|
2
|
-
import {
|
|
3
|
-
import type { HeadMeta, SimpleHead } from "../interfaces/Head.ts";
|
|
1
|
+
import { $inject } from "alepha";
|
|
2
|
+
import type { Head, SimpleHead } from "../interfaces/Head.ts";
|
|
4
3
|
import { HeadProvider } from "./HeadProvider.ts";
|
|
5
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Server-side head provider that fills head content from route configurations.
|
|
7
|
+
*
|
|
8
|
+
* Used by ReactServerProvider to collect title, meta tags, and other head
|
|
9
|
+
* elements which are then rendered by ReactServerTemplateProvider.
|
|
10
|
+
*/
|
|
6
11
|
export class ServerHeadProvider {
|
|
7
12
|
protected readonly headProvider = $inject(HeadProvider);
|
|
8
|
-
protected readonly serverTimingProvider = $inject(ServerTimingProvider);
|
|
9
13
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
this.serverTimingProvider.endTiming("renderHead");
|
|
19
|
-
},
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
public renderHead(template: string, head: SimpleHead): string {
|
|
23
|
-
let result = template;
|
|
24
|
-
|
|
25
|
-
// Inject htmlAttributes
|
|
26
|
-
const htmlAttributes = head.htmlAttributes;
|
|
27
|
-
if (htmlAttributes) {
|
|
28
|
-
result = result.replace(
|
|
29
|
-
/<html([^>]*)>/i,
|
|
30
|
-
(_, existingAttrs) =>
|
|
31
|
-
`<html${this.mergeAttributes(existingAttrs, htmlAttributes)}>`,
|
|
32
|
-
);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// Inject bodyAttributes
|
|
36
|
-
const bodyAttributes = head.bodyAttributes;
|
|
37
|
-
if (bodyAttributes) {
|
|
38
|
-
result = result.replace(
|
|
39
|
-
/<body([^>]*)>/i,
|
|
40
|
-
(_, existingAttrs) =>
|
|
41
|
-
`<body${this.mergeAttributes(existingAttrs, bodyAttributes)}>`,
|
|
42
|
-
);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// Build head content
|
|
46
|
-
let headContent = "";
|
|
47
|
-
const title = head.title;
|
|
48
|
-
if (title) {
|
|
49
|
-
if (template.includes("<title>")) {
|
|
50
|
-
result = result.replace(
|
|
51
|
-
/<title>(.*?)<\/title>/i,
|
|
52
|
-
() => `<title>${this.escapeHtml(title)}</title>`,
|
|
53
|
-
);
|
|
54
|
-
} else {
|
|
55
|
-
headContent += `<title>${this.escapeHtml(title)}</title>\n`;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
if (head.meta) {
|
|
60
|
-
for (const meta of head.meta) {
|
|
61
|
-
headContent += this.renderMetaTag(meta);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
if (head.link) {
|
|
66
|
-
for (const link of head.link) {
|
|
67
|
-
headContent += `<link rel="${this.escapeHtml(link.rel)}" href="${this.escapeHtml(link.href)}">\n`;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
if (head.script) {
|
|
72
|
-
for (const script of head.script) {
|
|
73
|
-
headContent += this.renderScriptTag(script);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Inject into <head>...</head>
|
|
78
|
-
result = result.replace(
|
|
79
|
-
/<head([^>]*)>(.*?)<\/head>/is,
|
|
80
|
-
(_, existingAttrs, existingHead) =>
|
|
81
|
-
`<head${existingAttrs}>${existingHead}${headContent}</head>`,
|
|
82
|
-
);
|
|
83
|
-
|
|
84
|
-
return result.trim();
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
protected mergeAttributes(
|
|
88
|
-
existing: string,
|
|
89
|
-
attrs: Record<string, string>,
|
|
90
|
-
): string {
|
|
91
|
-
const existingAttrs = this.parseAttributes(existing);
|
|
92
|
-
const merged = { ...existingAttrs, ...attrs };
|
|
93
|
-
return Object.entries(merged)
|
|
94
|
-
.map(([k, v]) => ` ${k}="${this.escapeHtml(v)}"`)
|
|
95
|
-
.join("");
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
protected parseAttributes(attrStr: string): Record<string, string> {
|
|
99
|
-
attrStr = attrStr.replaceAll("'", '"');
|
|
100
|
-
|
|
101
|
-
const attrs: Record<string, string> = {};
|
|
102
|
-
const attrRegex = /([^\s=]+)(?:="([^"]*)")?/g;
|
|
103
|
-
let match: RegExpExecArray | null = attrRegex.exec(attrStr);
|
|
104
|
-
|
|
105
|
-
while (match) {
|
|
106
|
-
attrs[match[1]] = match[2] ?? "";
|
|
107
|
-
match = attrRegex.exec(attrStr);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
return attrs;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
protected escapeHtml(str: string): string {
|
|
114
|
-
return str
|
|
115
|
-
.replace(/&/g, "&")
|
|
116
|
-
.replace(/</g, "<")
|
|
117
|
-
.replace(/>/g, ">")
|
|
118
|
-
.replace(/"/g, """)
|
|
119
|
-
.replace(/'/g, "'");
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
protected renderMetaTag(meta: HeadMeta): string {
|
|
123
|
-
// OpenGraph tags use property attribute
|
|
124
|
-
if (meta.property) {
|
|
125
|
-
return `<meta property="${this.escapeHtml(meta.property)}" content="${this.escapeHtml(meta.content)}">\n`;
|
|
126
|
-
}
|
|
127
|
-
// Standard meta tags use name attribute
|
|
128
|
-
if (meta.name) {
|
|
129
|
-
return `<meta name="${this.escapeHtml(meta.name)}" content="${this.escapeHtml(meta.content)}">\n`;
|
|
130
|
-
}
|
|
131
|
-
return "";
|
|
14
|
+
/**
|
|
15
|
+
* Resolve global head configuration (htmlAttributes only).
|
|
16
|
+
*
|
|
17
|
+
* Used for early streaming optimization - htmlAttributes can be sent
|
|
18
|
+
* before page loaders run since they come from global $head only.
|
|
19
|
+
*/
|
|
20
|
+
public resolveGlobalHead(): Head {
|
|
21
|
+
return this.headProvider.resolveGlobalHead();
|
|
132
22
|
}
|
|
133
23
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
return key;
|
|
141
|
-
}
|
|
142
|
-
return `${key}="${this.escapeHtml(String(value))}"`;
|
|
143
|
-
})
|
|
144
|
-
.join(" ");
|
|
145
|
-
return `<script ${attrs}></script>\n`;
|
|
24
|
+
/**
|
|
25
|
+
* Fill head state from route configurations.
|
|
26
|
+
* Delegates to HeadProvider to merge head data from all route layers.
|
|
27
|
+
*/
|
|
28
|
+
public fillHead(state: { head: SimpleHead; layers: Array<any> }): void {
|
|
29
|
+
this.headProvider.fillHead(state as any);
|
|
146
30
|
}
|
|
147
31
|
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { Alepha } from "alepha";
|
|
2
|
+
import { describe, test } from "vitest";
|
|
3
|
+
import { $dictionary } from "../primitives/$dictionary.ts";
|
|
4
|
+
import { AlephaReactI18n } from "../index.ts";
|
|
5
|
+
import { I18nProvider } from "../providers/I18nProvider.ts";
|
|
6
|
+
|
|
7
|
+
describe("I18n Integration Tests", () => {
|
|
8
|
+
test("should lazy load dictionaries on server start", async ({ expect }) => {
|
|
9
|
+
let enLoaded = false;
|
|
10
|
+
let frLoaded = false;
|
|
11
|
+
|
|
12
|
+
class App {
|
|
13
|
+
en = $dictionary({
|
|
14
|
+
lazy: async () => {
|
|
15
|
+
enLoaded = true;
|
|
16
|
+
return { default: { hello: "Hello" } };
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
fr = $dictionary({
|
|
21
|
+
lazy: async () => {
|
|
22
|
+
frLoaded = true;
|
|
23
|
+
return { default: { hello: "Bonjour" } };
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const alepha = Alepha.create().with(AlephaReactI18n);
|
|
29
|
+
const app = alepha.inject(App);
|
|
30
|
+
|
|
31
|
+
expect(enLoaded).toBe(false);
|
|
32
|
+
expect(frLoaded).toBe(false);
|
|
33
|
+
|
|
34
|
+
await alepha.start();
|
|
35
|
+
|
|
36
|
+
expect(enLoaded).toBe(true);
|
|
37
|
+
expect(frLoaded).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("should share I18nProvider instance across application", async ({
|
|
41
|
+
expect,
|
|
42
|
+
}) => {
|
|
43
|
+
class App {
|
|
44
|
+
en = $dictionary({
|
|
45
|
+
lazy: async () => ({ default: { key: "value" } }),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const alepha = Alepha.create().with(AlephaReactI18n);
|
|
50
|
+
const app = alepha.inject(App);
|
|
51
|
+
|
|
52
|
+
const i18n1 = alepha.inject(I18nProvider);
|
|
53
|
+
const i18n2 = alepha.inject(I18nProvider);
|
|
54
|
+
|
|
55
|
+
expect(i18n1).toBe(i18n2);
|
|
56
|
+
expect(i18n1.registry).toBe(i18n2.registry);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("should support multiple dictionary registrations", async ({
|
|
60
|
+
expect,
|
|
61
|
+
}) => {
|
|
62
|
+
class App {
|
|
63
|
+
en = $dictionary({
|
|
64
|
+
lazy: async () => ({
|
|
65
|
+
default: {
|
|
66
|
+
"app.title": "My Application",
|
|
67
|
+
"app.subtitle": "Built with Alepha",
|
|
68
|
+
"auth.login": "Log In",
|
|
69
|
+
"auth.logout": "Log Out",
|
|
70
|
+
},
|
|
71
|
+
}),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
fr = $dictionary({
|
|
75
|
+
lazy: async () => ({
|
|
76
|
+
default: {
|
|
77
|
+
"app.title": "Mon Application",
|
|
78
|
+
"app.subtitle": "Construit avec Alepha",
|
|
79
|
+
"auth.login": "Se connecter",
|
|
80
|
+
"auth.logout": "Se déconnecter",
|
|
81
|
+
},
|
|
82
|
+
}),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const alepha = Alepha.create().with(AlephaReactI18n);
|
|
87
|
+
const app = alepha.inject(App);
|
|
88
|
+
const i18n = alepha.inject(I18nProvider);
|
|
89
|
+
|
|
90
|
+
await alepha.start();
|
|
91
|
+
|
|
92
|
+
// Test English
|
|
93
|
+
expect(i18n.tr("app.title")).toBe("My Application");
|
|
94
|
+
expect(i18n.tr("app.subtitle")).toBe("Built with Alepha");
|
|
95
|
+
expect(i18n.tr("auth.login")).toBe("Log In");
|
|
96
|
+
expect(i18n.tr("auth.logout")).toBe("Log Out");
|
|
97
|
+
|
|
98
|
+
// Switch to French
|
|
99
|
+
await i18n.setLang("fr");
|
|
100
|
+
|
|
101
|
+
expect(i18n.tr("app.title")).toBe("Mon Application");
|
|
102
|
+
expect(i18n.tr("app.subtitle")).toBe("Construit avec Alepha");
|
|
103
|
+
expect(i18n.tr("auth.login")).toBe("Se connecter");
|
|
104
|
+
expect(i18n.tr("auth.logout")).toBe("Se déconnecter");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("should handle translation with complex interpolation across languages", async ({
|
|
108
|
+
expect,
|
|
109
|
+
}) => {
|
|
110
|
+
class App {
|
|
111
|
+
en = $dictionary({
|
|
112
|
+
lazy: async () => ({
|
|
113
|
+
default: {
|
|
114
|
+
notification: "$1 sent you $2 message(s) about $3",
|
|
115
|
+
},
|
|
116
|
+
}),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
fr = $dictionary({
|
|
120
|
+
lazy: async () => ({
|
|
121
|
+
default: {
|
|
122
|
+
notification: "$1 vous a envoyé $2 message(s) à propos de $3",
|
|
123
|
+
},
|
|
124
|
+
}),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
es = $dictionary({
|
|
128
|
+
lazy: async () => ({
|
|
129
|
+
default: {
|
|
130
|
+
notification: "$1 te envió $2 mensaje(s) sobre $3",
|
|
131
|
+
},
|
|
132
|
+
}),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const alepha = Alepha.create().with(AlephaReactI18n);
|
|
137
|
+
const app = alepha.inject(App);
|
|
138
|
+
const i18n = alepha.inject(I18nProvider);
|
|
139
|
+
|
|
140
|
+
await alepha.start();
|
|
141
|
+
|
|
142
|
+
const args = ["John", "3", "your project"];
|
|
143
|
+
|
|
144
|
+
expect(i18n.tr("notification", { args })).toBe(
|
|
145
|
+
"John sent you 3 message(s) about your project",
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
await i18n.setLang("fr");
|
|
149
|
+
expect(i18n.tr("notification", { args })).toBe(
|
|
150
|
+
"John vous a envoyé 3 message(s) à propos de your project",
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
await i18n.setLang("es");
|
|
154
|
+
expect(i18n.tr("notification", { args })).toBe(
|
|
155
|
+
"John te envió 3 mensaje(s) sobre your project",
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("should properly cleanup on stop", async ({ expect }) => {
|
|
160
|
+
class App {
|
|
161
|
+
en = $dictionary({
|
|
162
|
+
lazy: async () => ({ default: { test: "Test" } }),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const alepha = Alepha.create().with(AlephaReactI18n);
|
|
167
|
+
const app = alepha.inject(App);
|
|
168
|
+
const i18n = alepha.inject(I18nProvider);
|
|
169
|
+
|
|
170
|
+
await alepha.start();
|
|
171
|
+
expect(i18n.registry[0].translations).toEqual({ test: "Test" });
|
|
172
|
+
|
|
173
|
+
await alepha.stop();
|
|
174
|
+
// Registry should still exist after stop
|
|
175
|
+
expect(i18n.registry).toBeDefined();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("should work with empty language fallback chain", async ({ expect }) => {
|
|
179
|
+
class App {
|
|
180
|
+
en = $dictionary({
|
|
181
|
+
lazy: async () => ({ default: { only_en: "Only in English" } }),
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
fr = $dictionary({
|
|
185
|
+
lazy: async () => ({ default: { only_fr: "Seulement en français" } }),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const alepha = Alepha.create().with(AlephaReactI18n);
|
|
190
|
+
const app = alepha.inject(App);
|
|
191
|
+
const i18n = alepha.inject(I18nProvider);
|
|
192
|
+
|
|
193
|
+
await alepha.start();
|
|
194
|
+
|
|
195
|
+
// In English, can't find French-only key
|
|
196
|
+
expect(i18n.tr("only_fr")).toBe("only_fr");
|
|
197
|
+
expect(i18n.tr("only_en")).toBe("Only in English");
|
|
198
|
+
|
|
199
|
+
// In French, falls back to English for English-only key
|
|
200
|
+
await i18n.setLang("fr");
|
|
201
|
+
expect(i18n.tr("only_fr")).toBe("Seulement en français");
|
|
202
|
+
expect(i18n.tr("only_en")).toBe("Only in English");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("should handle rapid language switches", async ({ expect }) => {
|
|
206
|
+
class App {
|
|
207
|
+
en = $dictionary({
|
|
208
|
+
lazy: async () => ({ default: { status: "Active" } }),
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
fr = $dictionary({
|
|
212
|
+
lazy: async () => ({ default: { status: "Actif" } }),
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
de = $dictionary({
|
|
216
|
+
lazy: async () => ({ default: { status: "Aktiv" } }),
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const alepha = Alepha.create().with(AlephaReactI18n);
|
|
221
|
+
const app = alepha.inject(App);
|
|
222
|
+
const i18n = alepha.inject(I18nProvider);
|
|
223
|
+
|
|
224
|
+
await alepha.start();
|
|
225
|
+
|
|
226
|
+
// Rapidly switch languages
|
|
227
|
+
await i18n.setLang("fr");
|
|
228
|
+
expect(i18n.tr("status")).toBe("Actif");
|
|
229
|
+
|
|
230
|
+
await i18n.setLang("de");
|
|
231
|
+
expect(i18n.tr("status")).toBe("Aktiv");
|
|
232
|
+
|
|
233
|
+
await i18n.setLang("en");
|
|
234
|
+
expect(i18n.tr("status")).toBe("Active");
|
|
235
|
+
|
|
236
|
+
await i18n.setLang("fr");
|
|
237
|
+
expect(i18n.tr("status")).toBe("Actif");
|
|
238
|
+
});
|
|
239
|
+
});
|