@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.
Files changed (57) hide show
  1. package/dist/auth/index.browser.js +29 -14
  2. package/dist/auth/index.browser.js.map +1 -1
  3. package/dist/auth/index.js +960 -195
  4. package/dist/auth/index.js.map +1 -1
  5. package/dist/core/index.d.ts +4 -0
  6. package/dist/core/index.d.ts.map +1 -1
  7. package/dist/core/index.js +7 -4
  8. package/dist/core/index.js.map +1 -1
  9. package/dist/head/index.browser.js +59 -19
  10. package/dist/head/index.browser.js.map +1 -1
  11. package/dist/head/index.d.ts +99 -560
  12. package/dist/head/index.d.ts.map +1 -1
  13. package/dist/head/index.js +92 -87
  14. package/dist/head/index.js.map +1 -1
  15. package/dist/router/index.browser.js +30 -15
  16. package/dist/router/index.browser.js.map +1 -1
  17. package/dist/router/index.d.ts +616 -192
  18. package/dist/router/index.d.ts.map +1 -1
  19. package/dist/router/index.js +961 -196
  20. package/dist/router/index.js.map +1 -1
  21. package/package.json +4 -4
  22. package/src/auth/__tests__/$auth.spec.ts +188 -0
  23. package/src/core/__tests__/Router.spec.tsx +169 -0
  24. package/src/core/hooks/useAction.browser.spec.tsx +569 -0
  25. package/src/core/hooks/useAction.ts +11 -0
  26. package/src/form/hooks/useForm.browser.spec.tsx +366 -0
  27. package/src/head/helpers/SeoExpander.spec.ts +203 -0
  28. package/src/head/hooks/useHead.spec.tsx +288 -0
  29. package/src/head/index.ts +11 -28
  30. package/src/head/providers/BrowserHeadProvider.browser.spec.ts +196 -0
  31. package/src/head/providers/BrowserHeadProvider.ts +25 -19
  32. package/src/head/providers/HeadProvider.ts +76 -10
  33. package/src/head/providers/ServerHeadProvider.ts +22 -138
  34. package/src/i18n/__tests__/integration.spec.tsx +239 -0
  35. package/src/i18n/components/Localize.spec.tsx +357 -0
  36. package/src/i18n/hooks/useI18n.browser.spec.tsx +438 -0
  37. package/src/i18n/providers/I18nProvider.spec.ts +389 -0
  38. package/src/router/__tests__/page-head-browser.browser.spec.ts +91 -0
  39. package/src/router/__tests__/page-head.spec.ts +44 -0
  40. package/src/router/__tests__/seo-head.spec.ts +121 -0
  41. package/src/router/atoms/ssrManifestAtom.ts +60 -0
  42. package/src/router/constants/PAGE_PRELOAD_KEY.ts +6 -0
  43. package/src/router/errors/Redirection.ts +1 -1
  44. package/src/router/index.shared.ts +1 -0
  45. package/src/router/index.ts +16 -2
  46. package/src/router/primitives/$page.browser.spec.tsx +702 -0
  47. package/src/router/primitives/$page.spec.tsx +702 -0
  48. package/src/router/primitives/$page.ts +46 -10
  49. package/src/router/providers/ReactBrowserProvider.ts +14 -29
  50. package/src/router/providers/ReactBrowserRouterProvider.ts +5 -0
  51. package/src/router/providers/ReactPageProvider.ts +11 -4
  52. package/src/router/providers/ReactServerProvider.spec.tsx +316 -0
  53. package/src/router/providers/ReactServerProvider.ts +331 -315
  54. package/src/router/providers/ReactServerTemplateProvider.ts +775 -0
  55. package/src/router/providers/SSRManifestProvider.ts +365 -0
  56. package/src/router/services/ReactPageServerService.ts +5 -3
  57. 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
- public fillHead(state: ReactRouterState) {
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: ReactRouterState, head: Head): void {
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: PageRoute,
42
- state: ReactRouterState,
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
- if (head.htmlAttributes) {
74
- state.head.htmlAttributes = {
75
- ...state.head.htmlAttributes,
76
- ...head.htmlAttributes,
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 { $hook, $inject } from "alepha";
2
- import { ServerTimingProvider } from "alepha/server";
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
- protected readonly onServerRenderEnd = $hook({
11
- on: "react:server:render:end",
12
- handler: async (ev) => {
13
- this.serverTimingProvider.beginTiming("renderHead");
14
- this.headProvider.fillHead(ev.state);
15
- if (ev.state.head) {
16
- ev.html = this.renderHead(ev.html, ev.state.head);
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, "&amp;")
116
- .replace(/</g, "&lt;")
117
- .replace(/>/g, "&gt;")
118
- .replace(/"/g, "&quot;")
119
- .replace(/'/g, "&#039;");
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
- protected renderScriptTag(script: Record<string, string | boolean>): string {
135
- const attrs = Object.entries(script)
136
- .filter(([, value]) => value !== false)
137
- .map(([key, value]) => {
138
- // Boolean attributes - render without value if true
139
- if (value === true) {
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
+ });