@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.
- package/dist/auth/index.browser.js +29 -14
- package/dist/auth/index.browser.js.map +1 -1
- package/dist/auth/index.js +959 -194
- package/dist/auth/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 +91 -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 +960 -195
- package/dist/router/index.js.map +1 -1
- package/package.json +4 -4
- package/src/core/__tests__/Router.spec.tsx +4 -4
- package/src/head/{__tests__/expandSeo.spec.ts → helpers/SeoExpander.spec.ts} +1 -1
- package/src/head/index.ts +10 -28
- package/src/head/providers/BrowserHeadProvider.browser.spec.ts +1 -76
- 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/router/__tests__/page-head-browser.browser.spec.ts +91 -0
- package/src/router/__tests__/page-head.spec.ts +44 -0
- package/src/{head → router}/__tests__/seo-head.spec.ts +2 -2
- 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 +15 -15
- package/src/router/primitives/$page.spec.tsx +18 -18
- 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.ts +331 -316
- 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
- package/src/head/__tests__/page-head.spec.ts +0 -39
- 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.
|
|
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.
|
|
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.
|
|
35
|
+
"alepha": "0.14.4",
|
|
36
36
|
"react": "^19"
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
39
39
|
"lint": "alepha lint",
|
|
40
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
134
|
+
loader: ({ params }) => params,
|
|
135
135
|
component: ({ id }) => `hey ${id}`,
|
|
136
136
|
},
|
|
137
137
|
{
|
|
138
138
|
path: "profile",
|
|
139
|
-
|
|
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 "
|
|
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
|
|
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: [
|
|
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 {
|
|
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 { $
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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,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
|
+
});
|