@alepha/react 0.14.3 → 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 (45) 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 +959 -194
  4. package/dist/auth/index.js.map +1 -1
  5. package/dist/head/index.browser.js +59 -19
  6. package/dist/head/index.browser.js.map +1 -1
  7. package/dist/head/index.d.ts +99 -560
  8. package/dist/head/index.d.ts.map +1 -1
  9. package/dist/head/index.js +91 -87
  10. package/dist/head/index.js.map +1 -1
  11. package/dist/router/index.browser.js +30 -15
  12. package/dist/router/index.browser.js.map +1 -1
  13. package/dist/router/index.d.ts +616 -192
  14. package/dist/router/index.d.ts.map +1 -1
  15. package/dist/router/index.js +960 -195
  16. package/dist/router/index.js.map +1 -1
  17. package/package.json +4 -4
  18. package/src/core/__tests__/Router.spec.tsx +4 -4
  19. package/src/head/{__tests__/expandSeo.spec.ts → helpers/SeoExpander.spec.ts} +1 -1
  20. package/src/head/index.ts +10 -28
  21. package/src/head/providers/BrowserHeadProvider.browser.spec.ts +1 -76
  22. package/src/head/providers/BrowserHeadProvider.ts +25 -19
  23. package/src/head/providers/HeadProvider.ts +76 -10
  24. package/src/head/providers/ServerHeadProvider.ts +22 -138
  25. package/src/router/__tests__/page-head-browser.browser.spec.ts +91 -0
  26. package/src/router/__tests__/page-head.spec.ts +44 -0
  27. package/src/{head → router}/__tests__/seo-head.spec.ts +2 -2
  28. package/src/router/atoms/ssrManifestAtom.ts +60 -0
  29. package/src/router/constants/PAGE_PRELOAD_KEY.ts +6 -0
  30. package/src/router/errors/Redirection.ts +1 -1
  31. package/src/router/index.shared.ts +1 -0
  32. package/src/router/index.ts +16 -2
  33. package/src/router/primitives/$page.browser.spec.tsx +15 -15
  34. package/src/router/primitives/$page.spec.tsx +18 -18
  35. package/src/router/primitives/$page.ts +46 -10
  36. package/src/router/providers/ReactBrowserProvider.ts +14 -29
  37. package/src/router/providers/ReactBrowserRouterProvider.ts +5 -0
  38. package/src/router/providers/ReactPageProvider.ts +11 -4
  39. package/src/router/providers/ReactServerProvider.ts +331 -316
  40. package/src/router/providers/ReactServerTemplateProvider.ts +775 -0
  41. package/src/router/providers/SSRManifestProvider.ts +365 -0
  42. package/src/router/services/ReactPageServerService.ts +5 -3
  43. package/src/router/services/ReactRouter.ts +3 -3
  44. package/src/head/__tests__/page-head.spec.ts +0 -39
  45. package/src/head/providers/ServerHeadProvider.spec.ts +0 -163
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@alepha/react",
3
3
  "description": "React components and hooks for building Alepha applications.",
4
4
  "author": "Nicolas Foures",
5
- "version": "0.14.3",
5
+ "version": "0.14.4",
6
6
  "type": "module",
7
7
  "engines": {
8
8
  "node": ">=22.0.0"
@@ -25,19 +25,19 @@
25
25
  "@testing-library/react": "^16.3.1",
26
26
  "@types/react": "^19",
27
27
  "@types/react-dom": "^19",
28
- "alepha": "0.14.3",
28
+ "alepha": "0.14.4",
29
29
  "jsdom": "^27.4.0",
30
30
  "react": "^19.2.3",
31
31
  "typescript": "^5.9.3",
32
32
  "vitest": "^4.0.16"
33
33
  },
34
34
  "peerDependencies": {
35
- "alepha": "0.14.3",
35
+ "alepha": "0.14.4",
36
36
  "react": "^19"
37
37
  },
38
38
  "scripts": {
39
39
  "lint": "alepha lint",
40
- "check": "alepha typecheck",
40
+ "typecheck": "alepha typecheck",
41
41
  "test": "vitest run",
42
42
  "build": "node scripts/build.ts"
43
43
  },
@@ -74,7 +74,7 @@ test("Router - NestedView", async ({ expect }) => {
74
74
  name: t.text(),
75
75
  }),
76
76
  },
77
- resolve: ({ params }) => params,
77
+ loader: ({ params }) => params,
78
78
  component: (props) => `Hello, ${props.name}!`,
79
79
  },
80
80
  ],
@@ -125,18 +125,18 @@ test("Router - All routes", async ({ expect }) => {
125
125
  {
126
126
  path: ":id",
127
127
  schema: { params: t.object({ id: t.text() }) },
128
- resolve: ({ params }) => {
128
+ loader: ({ params }) => {
129
129
  if (params.id === "boom") throw new Error("boom");
130
130
  return params;
131
131
  },
132
132
  children: [
133
133
  {
134
- resolve: ({ params }) => params,
134
+ loader: ({ params }) => params,
135
135
  component: ({ id }) => `hey ${id}`,
136
136
  },
137
137
  {
138
138
  path: "profile",
139
- resolve: ({ params }) => params,
139
+ loader: ({ params }) => params,
140
140
  component: ({ id }) => `profile of ${id}`,
141
141
  },
142
142
  ],
@@ -1,6 +1,6 @@
1
1
  import { Alepha } from "alepha";
2
2
  import { describe, it } from "vitest";
3
- import { SeoExpander } from "../helpers/SeoExpander.ts";
3
+ import { SeoExpander } from "./SeoExpander.ts";
4
4
 
5
5
  describe("SeoExpander", () => {
6
6
  it("should expand basic SEO configuration", ({ expect }) => {
package/src/head/index.ts CHANGED
@@ -1,15 +1,10 @@
1
1
  import { AlephaReact } from "@alepha/react";
2
- import type {
3
- PageConfigSchema,
4
- TPropsDefault,
5
- TPropsParentDefault,
6
- } from "@alepha/react/router";
7
2
  import { $module } from "alepha";
8
3
  import { $head } from "./primitives/$head.ts";
9
- import type { Head } from "./interfaces/Head.ts";
10
- import { ServerHeadProvider } from "./providers/ServerHeadProvider.ts";
4
+ import { BrowserHeadProvider } from "./providers/BrowserHeadProvider.ts";
11
5
  import { HeadProvider } from "./providers/HeadProvider.ts";
12
6
  import { SeoExpander } from "./helpers/SeoExpander.ts";
7
+ import { ServerHeadProvider } from "./providers/ServerHeadProvider.ts";
13
8
 
14
9
  // ---------------------------------------------------------------------------------------------------------------------
15
10
 
@@ -18,26 +13,7 @@ export * from "./hooks/useHead.ts";
18
13
  export * from "./interfaces/Head.ts";
19
14
  export * from "./helpers/SeoExpander.ts";
20
15
  export * from "./providers/ServerHeadProvider.ts";
21
-
22
- // ---------------------------------------------------------------------------------------------------------------------
23
-
24
- // Augment PagePrimitiveOptions in router module
25
- declare module "@alepha/react/router" {
26
- interface PagePrimitiveOptions<
27
- TConfig extends PageConfigSchema = PageConfigSchema,
28
- TProps extends object = TPropsDefault,
29
- TPropsParent extends object = TPropsParentDefault,
30
- > {
31
- head?: Head | ((props: TProps, previous?: Head) => Head);
32
- }
33
- }
34
-
35
- // Augment ReactRouterState in router module
36
- declare module "@alepha/react/router" {
37
- interface ReactRouterState {
38
- head: Head;
39
- }
40
- }
16
+ export * from "./providers/BrowserHeadProvider.ts";
41
17
 
42
18
  // ---------------------------------------------------------------------------------------------------------------------
43
19
 
@@ -55,5 +31,11 @@ declare module "@alepha/react/router" {
55
31
  export const AlephaReactHead = $module({
56
32
  name: "alepha.react.head",
57
33
  primitives: [$head],
58
- services: [AlephaReact, ServerHeadProvider, HeadProvider, SeoExpander],
34
+ services: [
35
+ AlephaReact,
36
+ BrowserHeadProvider,
37
+ HeadProvider,
38
+ SeoExpander,
39
+ ServerHeadProvider,
40
+ ],
59
41
  });
@@ -1,7 +1,5 @@
1
- import { $page } from "@alepha/react/router";
2
1
  import { Alepha } from "alepha";
3
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
4
- import { AlephaReactHead } from "../index.browser.ts";
2
+ import { beforeEach, describe, expect, it } from "vitest";
5
3
  import type { Head } from "../interfaces/Head.ts";
6
4
  import { BrowserHeadProvider } from "./BrowserHeadProvider.ts";
7
5
 
@@ -195,77 +193,4 @@ describe("BrowserHeadProvider", () => {
195
193
  expect(authorMeta?.getAttribute("content")).toBe("Test Author");
196
194
  });
197
195
  });
198
-
199
- describe("$page integration", () => {
200
- class TestApp {
201
- simplePage = $page({
202
- path: "/",
203
- head: {
204
- title: "Simple Page",
205
- bodyAttributes: { class: "simple-page" },
206
- },
207
- component: () => "Simple content",
208
- });
209
-
210
- complexPage = $page({
211
- path: "/complex",
212
- head: {
213
- title: "Complex Page",
214
- htmlAttributes: {
215
- lang: "en",
216
- "data-theme": "dark",
217
- },
218
- bodyAttributes: {
219
- class: "complex-page",
220
- style: "background: black;",
221
- },
222
- meta: [
223
- { name: "description", content: "Complex test page" },
224
- {
225
- name: "viewport",
226
- content: "width=device-width, initial-scale=1",
227
- },
228
- ],
229
- },
230
- component: () => "Complex content",
231
- });
232
- }
233
-
234
- afterEach(() => {
235
- document.body.querySelector("#root")?.remove();
236
- });
237
-
238
- it("should render simple page head configuration", async () => {
239
- const alepha = Alepha.create().with(AlephaReactHead).with(TestApp);
240
- await alepha.start();
241
-
242
- expect(document.title).toBe("Simple Page");
243
- expect(document.body.getAttribute("class")).toBe("simple-page");
244
- });
245
-
246
- it("should get current head state and match page configuration", async () => {
247
- const alepha = Alepha.create().with(AlephaReactHead);
248
- const app = alepha.inject(TestApp);
249
- await alepha.start();
250
-
251
- // Apply complex page head
252
- const headConfig = app.complexPage.options.head as Head;
253
- provider.renderHead(document, headConfig);
254
-
255
- // Get current head state
256
- const currentHead = provider.getHead(document);
257
-
258
- expect(currentHead.title).toBe(headConfig.title);
259
- expect(currentHead.htmlAttributes?.lang).toBe(
260
- headConfig.htmlAttributes?.lang,
261
- );
262
- expect(currentHead.bodyAttributes?.class).toBe(
263
- headConfig.bodyAttributes?.class,
264
- );
265
- expect(currentHead.meta).toContainEqual({
266
- name: "description",
267
- content: "Complex test page",
268
- });
269
- });
270
- });
271
196
  });
@@ -1,33 +1,39 @@
1
- import { $hook, $inject } from "alepha";
1
+ import { $inject, Alepha } from "alepha";
2
2
  import type { Head, HeadMeta } from "../interfaces/Head.ts";
3
3
  import { HeadProvider } from "./HeadProvider.ts";
4
4
 
5
+ /**
6
+ * Browser-side head provider that manages document head elements.
7
+ *
8
+ * Used by ReactBrowserProvider and ReactBrowserRouterProvider to update
9
+ * document title, meta tags, and other head elements during client-side
10
+ * navigation.
11
+ */
5
12
  export class BrowserHeadProvider {
13
+ protected readonly alepha = $inject(Alepha);
6
14
  protected readonly headProvider = $inject(HeadProvider);
7
15
 
8
16
  protected get document(): Document {
9
17
  return window.document;
10
18
  }
11
19
 
12
- protected readonly onBrowserRender = $hook({
13
- on: "react:browser:render",
14
- handler: async ({ state }) => {
15
- this.headProvider.fillHead(state);
16
- if (state.head) {
17
- this.renderHead(this.document, state.head);
18
- }
19
- },
20
- });
20
+ /**
21
+ * Fill head state from route configurations and render to document.
22
+ * Combines fillHead from HeadProvider with renderHead to the DOM.
23
+ *
24
+ * Only runs in browser environment - no-op on server.
25
+ */
26
+ public fillAndRenderHead(state: { head: Head; layers: Array<any> }): void {
27
+ // Skip on server-side
28
+ if (!this.alepha.isBrowser()) {
29
+ return;
30
+ }
21
31
 
22
- protected readonly onTransitionEnd = $hook({
23
- on: "react:transition:end",
24
- handler: async ({ state }) => {
25
- this.headProvider.fillHead(state);
26
- if (state.head) {
27
- this.renderHead(this.document, state.head);
28
- }
29
- },
30
- });
32
+ this.headProvider.fillHead(state as any);
33
+ if (state.head) {
34
+ this.renderHead(this.document, state.head);
35
+ }
36
+ }
31
37
 
32
38
  public getHead(document: Document): Head {
33
39
  return {
@@ -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,91 @@
1
+ import { AlephaReactHead, BrowserHeadProvider, type Head } from "@alepha/react/head";
2
+ import { Alepha } from "alepha";
3
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
4
+ import { $page } from "../index.browser.ts";
5
+
6
+ describe("$page head integration (browser)", () => {
7
+ let provider: BrowserHeadProvider;
8
+
9
+ class TestApp {
10
+ simplePage = $page({
11
+ path: "/",
12
+ head: {
13
+ title: "Simple Page",
14
+ bodyAttributes: { class: "simple-page" },
15
+ },
16
+ component: () => "Simple content",
17
+ });
18
+
19
+ complexPage = $page({
20
+ path: "/complex",
21
+ head: {
22
+ title: "Complex Page",
23
+ htmlAttributes: {
24
+ lang: "en",
25
+ "data-theme": "dark",
26
+ },
27
+ bodyAttributes: {
28
+ class: "complex-page",
29
+ style: "background: black;",
30
+ },
31
+ meta: [
32
+ { name: "description", content: "Complex test page" },
33
+ {
34
+ name: "viewport",
35
+ content: "width=device-width, initial-scale=1",
36
+ },
37
+ ],
38
+ },
39
+ component: () => "Complex content",
40
+ });
41
+ }
42
+
43
+ beforeEach(() => {
44
+ // Reset document state
45
+ document.title = "";
46
+ document.head.innerHTML = "";
47
+ document.body.removeAttribute("class");
48
+ document.body.removeAttribute("style");
49
+ document.documentElement.removeAttribute("lang");
50
+ document.documentElement.removeAttribute("class");
51
+ document.documentElement.removeAttribute("data-theme");
52
+ });
53
+
54
+ afterEach(() => {
55
+ document.body.querySelector("#root")?.remove();
56
+ });
57
+
58
+ it("should render simple page head configuration", async () => {
59
+ const alepha = Alepha.create().with(AlephaReactHead).with(TestApp);
60
+ await alepha.start();
61
+
62
+ expect(document.title).toBe("Simple Page");
63
+ expect(document.body.getAttribute("class")).toBe("simple-page");
64
+ });
65
+
66
+ it("should get current head state and match page configuration", async () => {
67
+ const alepha = Alepha.create().with(AlephaReactHead);
68
+ provider = alepha.inject(BrowserHeadProvider);
69
+ const app = alepha.inject(TestApp);
70
+ await alepha.start();
71
+
72
+ // Apply complex page head
73
+ const headConfig = app.complexPage.options.head as Head;
74
+ provider.renderHead(document, headConfig);
75
+
76
+ // Get current head state
77
+ const currentHead = provider.getHead(document);
78
+
79
+ expect(currentHead.title).toBe(headConfig.title);
80
+ expect(currentHead.htmlAttributes?.lang).toBe(
81
+ headConfig.htmlAttributes?.lang,
82
+ );
83
+ expect(currentHead.bodyAttributes?.class).toBe(
84
+ headConfig.bodyAttributes?.class,
85
+ );
86
+ expect(currentHead.meta).toContainEqual({
87
+ name: "description",
88
+ content: "Complex test page",
89
+ });
90
+ });
91
+ });