@alepha/react 0.14.1 → 0.14.3
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 +1488 -4
- package/dist/auth/index.browser.js.map +1 -1
- package/dist/auth/index.d.ts +2 -2
- package/dist/auth/index.js +1827 -4
- package/dist/auth/index.js.map +1 -1
- package/dist/core/index.d.ts +58 -937
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +139 -2014
- package/dist/core/index.js.map +1 -1
- package/dist/form/index.d.ts.map +1 -1
- package/dist/form/index.js +6 -1
- package/dist/form/index.js.map +1 -1
- package/dist/head/index.browser.js +3 -1
- package/dist/head/index.browser.js.map +1 -1
- package/dist/head/index.d.ts +552 -8
- package/dist/head/index.d.ts.map +1 -1
- package/dist/head/index.js +17 -2
- package/dist/head/index.js.map +1 -1
- package/dist/{core → router}/index.browser.js +126 -516
- package/dist/router/index.browser.js.map +1 -0
- package/dist/router/index.d.ts +1334 -0
- package/dist/router/index.d.ts.map +1 -0
- package/dist/router/index.js +1939 -0
- package/dist/router/index.js.map +1 -0
- package/package.json +12 -6
- package/src/auth/__tests__/$auth.spec.ts +188 -0
- package/src/auth/index.ts +1 -1
- package/src/auth/services/ReactAuth.ts +1 -1
- package/src/core/__tests__/Router.spec.tsx +169 -0
- package/src/core/components/ClientOnly.tsx +14 -0
- package/src/core/components/ErrorBoundary.tsx +3 -2
- package/src/core/contexts/AlephaContext.ts +3 -0
- package/src/core/contexts/AlephaProvider.tsx +2 -1
- package/src/core/hooks/useAction.browser.spec.tsx +569 -0
- package/src/core/hooks/useAction.ts +11 -0
- package/src/core/index.ts +13 -102
- package/src/form/hooks/useForm.browser.spec.tsx +366 -0
- package/src/form/services/FormModel.ts +5 -0
- package/src/head/__tests__/expandSeo.spec.ts +203 -0
- package/src/head/__tests__/page-head.spec.ts +39 -0
- package/src/head/__tests__/seo-head.spec.ts +121 -0
- package/src/head/hooks/useHead.spec.tsx +288 -0
- package/src/head/index.ts +18 -8
- package/src/head/interfaces/Head.ts +3 -0
- package/src/head/providers/BrowserHeadProvider.browser.spec.ts +271 -0
- package/src/head/providers/HeadProvider.ts +6 -1
- package/src/head/providers/ServerHeadProvider.spec.ts +163 -0
- package/src/head/providers/ServerHeadProvider.ts +20 -0
- 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/{core → router}/components/ErrorViewer.tsx +2 -0
- package/src/router/components/Link.tsx +21 -0
- package/src/{core → router}/components/NestedView.tsx +3 -5
- package/src/router/components/NotFound.tsx +30 -0
- package/src/router/errors/Redirection.ts +28 -0
- package/src/{core → router}/hooks/useActive.ts +6 -2
- package/src/{core → router}/hooks/useQueryParams.ts +2 -2
- package/src/{core → router}/hooks/useRouter.ts +1 -1
- package/src/{core → router}/hooks/useRouterState.ts +1 -1
- package/src/{core → router}/index.browser.ts +14 -12
- package/src/{core/index.shared-router.ts → router/index.shared.ts} +6 -3
- package/src/router/index.ts +125 -0
- package/src/router/primitives/$page.browser.spec.tsx +702 -0
- package/src/router/primitives/$page.spec.tsx +702 -0
- package/src/{core → router}/primitives/$page.ts +1 -1
- package/src/{core → router}/providers/ReactBrowserProvider.ts +3 -13
- package/src/{core → router}/providers/ReactBrowserRendererProvider.ts +3 -0
- package/src/{core → router}/providers/ReactBrowserRouterProvider.ts +3 -0
- package/src/{core → router}/providers/ReactPageProvider.ts +5 -3
- package/src/router/providers/ReactServerProvider.spec.tsx +316 -0
- package/src/{core → router}/providers/ReactServerProvider.ts +12 -30
- package/src/{core → router}/services/ReactPageServerService.ts +3 -0
- package/src/{core → router}/services/ReactPageService.ts +5 -5
- package/src/{core → router}/services/ReactRouter.ts +26 -5
- package/dist/core/index.browser.js.map +0 -1
- package/dist/core/index.native.js +0 -403
- package/dist/core/index.native.js.map +0 -1
- package/src/core/components/Link.tsx +0 -18
- package/src/core/components/NotFound.tsx +0 -27
- package/src/core/errors/Redirection.ts +0 -13
- package/src/core/hooks/useSchema.ts +0 -88
- package/src/core/index.native.ts +0 -21
- package/src/core/index.shared.ts +0 -9
- /package/src/{core → router}/contexts/RouterLayerContext.ts +0 -0
package/src/head/index.ts
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
} from "@alepha/react";
|
|
1
|
+
import { AlephaReact } from "@alepha/react";
|
|
2
|
+
import type {
|
|
3
|
+
PageConfigSchema,
|
|
4
|
+
TPropsDefault,
|
|
5
|
+
TPropsParentDefault,
|
|
6
|
+
} from "@alepha/react/router";
|
|
7
7
|
import { $module } from "alepha";
|
|
8
8
|
import { $head } from "./primitives/$head.ts";
|
|
9
9
|
import type { Head } from "./interfaces/Head.ts";
|
|
10
10
|
import { ServerHeadProvider } from "./providers/ServerHeadProvider.ts";
|
|
11
11
|
import { HeadProvider } from "./providers/HeadProvider.ts";
|
|
12
|
+
import { SeoExpander } from "./helpers/SeoExpander.ts";
|
|
12
13
|
|
|
13
14
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
14
15
|
|
|
@@ -20,7 +21,8 @@ export * from "./providers/ServerHeadProvider.ts";
|
|
|
20
21
|
|
|
21
22
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
// Augment PagePrimitiveOptions in router module
|
|
25
|
+
declare module "@alepha/react/router" {
|
|
24
26
|
interface PagePrimitiveOptions<
|
|
25
27
|
TConfig extends PageConfigSchema = PageConfigSchema,
|
|
26
28
|
TProps extends object = TPropsDefault,
|
|
@@ -28,7 +30,10 @@ declare module "@alepha/react" {
|
|
|
28
30
|
> {
|
|
29
31
|
head?: Head | ((props: TProps, previous?: Head) => Head);
|
|
30
32
|
}
|
|
33
|
+
}
|
|
31
34
|
|
|
35
|
+
// Augment ReactRouterState in router module
|
|
36
|
+
declare module "@alepha/react/router" {
|
|
32
37
|
interface ReactRouterState {
|
|
33
38
|
head: Head;
|
|
34
39
|
}
|
|
@@ -39,11 +44,16 @@ declare module "@alepha/react" {
|
|
|
39
44
|
/**
|
|
40
45
|
* Fill `<head>` server & client side.
|
|
41
46
|
*
|
|
47
|
+
* Generate SEO-friendly meta tags and titles for your React application using AlephaReactHead module.
|
|
48
|
+
*
|
|
49
|
+
* This module provides services and primitives to manage the document head both on the server and client side,
|
|
50
|
+
* ensuring that your application is optimized for search engines and social media sharing.
|
|
51
|
+
*
|
|
42
52
|
* @see {@link ServerHeadProvider}
|
|
43
53
|
* @module alepha.react.head
|
|
44
54
|
*/
|
|
45
55
|
export const AlephaReactHead = $module({
|
|
46
56
|
name: "alepha.react.head",
|
|
47
57
|
primitives: [$head],
|
|
48
|
-
services: [AlephaReact, ServerHeadProvider, HeadProvider],
|
|
58
|
+
services: [AlephaReact, ServerHeadProvider, HeadProvider, SeoExpander],
|
|
49
59
|
});
|
|
@@ -73,7 +73,10 @@ export interface SimpleHead {
|
|
|
73
73
|
bodyAttributes?: Record<string, string>;
|
|
74
74
|
/** Meta tags - supports both name and property attributes */
|
|
75
75
|
meta?: Array<HeadMeta>;
|
|
76
|
+
/** Link tags (e.g., stylesheets, preload, canonical) */
|
|
76
77
|
link?: Array<{ rel: string; href: string }>;
|
|
78
|
+
/** Script tags - any valid script attributes (src, type, async, defer, etc.) */
|
|
79
|
+
script?: Array<Record<string, string | boolean>>;
|
|
77
80
|
}
|
|
78
81
|
|
|
79
82
|
export interface HeadMeta {
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { $page } from "@alepha/react/router";
|
|
2
|
+
import { Alepha } from "alepha";
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { AlephaReactHead } from "../index.browser.ts";
|
|
5
|
+
import type { Head } from "../interfaces/Head.ts";
|
|
6
|
+
import { BrowserHeadProvider } from "./BrowserHeadProvider.ts";
|
|
7
|
+
|
|
8
|
+
describe("BrowserHeadProvider", () => {
|
|
9
|
+
let alepha: Alepha;
|
|
10
|
+
let provider: BrowserHeadProvider;
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
alepha = Alepha.create();
|
|
14
|
+
provider = alepha.inject(BrowserHeadProvider);
|
|
15
|
+
|
|
16
|
+
// Reset document state
|
|
17
|
+
document.title = "";
|
|
18
|
+
document.head.innerHTML = "";
|
|
19
|
+
document.body.removeAttribute("class");
|
|
20
|
+
document.body.removeAttribute("style");
|
|
21
|
+
document.documentElement.removeAttribute("lang");
|
|
22
|
+
document.documentElement.removeAttribute("class");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("getHead", () => {
|
|
26
|
+
it("should return current document head state", () => {
|
|
27
|
+
document.title = "Test Title";
|
|
28
|
+
document.body.setAttribute("class", "test-class");
|
|
29
|
+
document.documentElement.setAttribute("lang", "en");
|
|
30
|
+
|
|
31
|
+
const meta = document.createElement("meta");
|
|
32
|
+
meta.setAttribute("name", "description");
|
|
33
|
+
meta.setAttribute("content", "Test description");
|
|
34
|
+
document.head.appendChild(meta);
|
|
35
|
+
|
|
36
|
+
const head = provider.getHead(document);
|
|
37
|
+
|
|
38
|
+
expect(head.title).toBe("Test Title");
|
|
39
|
+
expect(head.bodyAttributes?.class).toBe("test-class");
|
|
40
|
+
expect(head.htmlAttributes?.lang).toBe("en");
|
|
41
|
+
expect(head.meta).toContainEqual({
|
|
42
|
+
name: "description",
|
|
43
|
+
content: "Test description",
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("should handle empty document state", () => {
|
|
48
|
+
const head = provider.getHead(document);
|
|
49
|
+
|
|
50
|
+
expect(head.title).toBe("");
|
|
51
|
+
expect(head.bodyAttributes).toEqual({});
|
|
52
|
+
expect(head.htmlAttributes).toEqual({});
|
|
53
|
+
expect(head.meta).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("renderHead", () => {
|
|
58
|
+
it("should set document title", () => {
|
|
59
|
+
const head: Head = { title: "New Title" };
|
|
60
|
+
|
|
61
|
+
provider.renderHead(document, head);
|
|
62
|
+
|
|
63
|
+
expect(document.title).toBe("New Title");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("should set body attributes", () => {
|
|
67
|
+
const head: Head = {
|
|
68
|
+
bodyAttributes: {
|
|
69
|
+
class: "new-class",
|
|
70
|
+
style: "background: blue;",
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
provider.renderHead(document, head);
|
|
75
|
+
|
|
76
|
+
expect(document.body.getAttribute("class")).toBe("new-class");
|
|
77
|
+
expect(document.body.getAttribute("style")).toBe("background: blue;");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("should remove body attributes when value is falsy", () => {
|
|
81
|
+
document.body.setAttribute("class", "old-class");
|
|
82
|
+
|
|
83
|
+
const head: Head = {
|
|
84
|
+
bodyAttributes: {
|
|
85
|
+
class: "",
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
provider.renderHead(document, head);
|
|
90
|
+
|
|
91
|
+
expect(document.body.hasAttribute("class")).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("should set html attributes", () => {
|
|
95
|
+
const head: Head = {
|
|
96
|
+
htmlAttributes: {
|
|
97
|
+
lang: "fr",
|
|
98
|
+
dir: "ltr",
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
provider.renderHead(document, head);
|
|
103
|
+
|
|
104
|
+
expect(document.documentElement.getAttribute("lang")).toBe("fr");
|
|
105
|
+
expect(document.documentElement.getAttribute("dir")).toBe("ltr");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("should remove html attributes when value is falsy", () => {
|
|
109
|
+
document.documentElement.setAttribute("lang", "en");
|
|
110
|
+
|
|
111
|
+
const head: Head = {
|
|
112
|
+
htmlAttributes: {
|
|
113
|
+
lang: "",
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
provider.renderHead(document, head);
|
|
118
|
+
|
|
119
|
+
expect(document.documentElement.hasAttribute("lang")).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("should create new meta tags", () => {
|
|
123
|
+
const head: Head = {
|
|
124
|
+
meta: [
|
|
125
|
+
{ name: "description", content: "Test description" },
|
|
126
|
+
{ name: "keywords", content: "test, browser" },
|
|
127
|
+
],
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
provider.renderHead(document, head);
|
|
131
|
+
|
|
132
|
+
const descriptionMeta = document.querySelector(
|
|
133
|
+
'meta[name="description"]',
|
|
134
|
+
);
|
|
135
|
+
const keywordsMeta = document.querySelector('meta[name="keywords"]');
|
|
136
|
+
|
|
137
|
+
expect(descriptionMeta?.getAttribute("content")).toBe("Test description");
|
|
138
|
+
expect(keywordsMeta?.getAttribute("content")).toBe("test, browser");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("should update existing meta tags", () => {
|
|
142
|
+
// Pre-populate with existing meta tag
|
|
143
|
+
const existingMeta = document.createElement("meta");
|
|
144
|
+
existingMeta.setAttribute("name", "description");
|
|
145
|
+
existingMeta.setAttribute("content", "Old description");
|
|
146
|
+
document.head.appendChild(existingMeta);
|
|
147
|
+
|
|
148
|
+
const head: Head = {
|
|
149
|
+
meta: [{ name: "description", content: "New description" }],
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
provider.renderHead(document, head);
|
|
153
|
+
|
|
154
|
+
const descriptionMeta = document.querySelector(
|
|
155
|
+
'meta[name="description"]',
|
|
156
|
+
);
|
|
157
|
+
expect(descriptionMeta?.getAttribute("content")).toBe("New description");
|
|
158
|
+
expect(
|
|
159
|
+
document.querySelectorAll('meta[name="description"]'),
|
|
160
|
+
).toHaveLength(1);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("should handle complete head object", () => {
|
|
164
|
+
const head: Head = {
|
|
165
|
+
title: "Complete Test",
|
|
166
|
+
htmlAttributes: {
|
|
167
|
+
lang: "es",
|
|
168
|
+
class: "theme-dark",
|
|
169
|
+
},
|
|
170
|
+
bodyAttributes: {
|
|
171
|
+
class: "page-test",
|
|
172
|
+
"data-theme": "dark",
|
|
173
|
+
},
|
|
174
|
+
meta: [
|
|
175
|
+
{ name: "description", content: "Complete test page" },
|
|
176
|
+
{ name: "author", content: "Test Author" },
|
|
177
|
+
],
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
provider.renderHead(document, head);
|
|
181
|
+
|
|
182
|
+
expect(document.title).toBe("Complete Test");
|
|
183
|
+
expect(document.documentElement.getAttribute("lang")).toBe("es");
|
|
184
|
+
expect(document.documentElement.getAttribute("class")).toBe("theme-dark");
|
|
185
|
+
expect(document.body.getAttribute("class")).toBe("page-test");
|
|
186
|
+
expect(document.body.getAttribute("data-theme")).toBe("dark");
|
|
187
|
+
|
|
188
|
+
const descriptionMeta = document.querySelector(
|
|
189
|
+
'meta[name="description"]',
|
|
190
|
+
);
|
|
191
|
+
const authorMeta = document.querySelector('meta[name="author"]');
|
|
192
|
+
expect(descriptionMeta?.getAttribute("content")).toBe(
|
|
193
|
+
"Complete test page",
|
|
194
|
+
);
|
|
195
|
+
expect(authorMeta?.getAttribute("content")).toBe("Test Author");
|
|
196
|
+
});
|
|
197
|
+
});
|
|
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
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PageRoute, ReactRouterState } from "@alepha/react";
|
|
1
|
+
import type { PageRoute, ReactRouterState } from "@alepha/react/router";
|
|
2
2
|
import { $inject } from "alepha";
|
|
3
3
|
import { SeoExpander } from "../helpers/SeoExpander.ts";
|
|
4
4
|
import type { Head } from "../interfaces/Head.ts";
|
|
@@ -33,6 +33,7 @@ export class HeadProvider {
|
|
|
33
33
|
...head,
|
|
34
34
|
meta: [...(state.head.meta ?? []), ...meta, ...(head.meta ?? [])],
|
|
35
35
|
link: [...(state.head.link ?? []), ...link, ...(head.link ?? [])],
|
|
36
|
+
script: [...(state.head.script ?? []), ...(head.script ?? [])],
|
|
36
37
|
};
|
|
37
38
|
}
|
|
38
39
|
|
|
@@ -90,5 +91,9 @@ export class HeadProvider {
|
|
|
90
91
|
if (head.link) {
|
|
91
92
|
state.head.link = [...(state.head.link ?? []), ...(head.link ?? [])];
|
|
92
93
|
}
|
|
94
|
+
|
|
95
|
+
if (head.script) {
|
|
96
|
+
state.head.script = [...(state.head.script ?? []), ...(head.script ?? [])];
|
|
97
|
+
}
|
|
93
98
|
}
|
|
94
99
|
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { Alepha } from "alepha";
|
|
2
|
+
import { describe, it } from "vitest";
|
|
3
|
+
import { ServerHeadProvider } from "./ServerHeadProvider.ts";
|
|
4
|
+
|
|
5
|
+
const alepha = Alepha.create();
|
|
6
|
+
const serverHeadProvider = alepha.inject(ServerHeadProvider);
|
|
7
|
+
|
|
8
|
+
describe("ServerHeadProvider", () => {
|
|
9
|
+
it("should render head with custom attributes and meta tags", ({
|
|
10
|
+
expect,
|
|
11
|
+
}) => {
|
|
12
|
+
const template = `
|
|
13
|
+
<!DOCTYPE html>
|
|
14
|
+
<html lang="en">
|
|
15
|
+
<head>
|
|
16
|
+
<meta charset="UTF-8">
|
|
17
|
+
<title>Test</title>
|
|
18
|
+
</head>
|
|
19
|
+
<body>
|
|
20
|
+
</body>
|
|
21
|
+
</html>
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
const head = {
|
|
25
|
+
title: "Test Title",
|
|
26
|
+
htmlAttributes: { lang: "fr", style: "color: red;" },
|
|
27
|
+
bodyAttributes: { class: "test-class" },
|
|
28
|
+
meta: [
|
|
29
|
+
{ name: "description", content: "Test description" },
|
|
30
|
+
{ name: "keywords", content: "test, example" },
|
|
31
|
+
],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const expectedOutput = `
|
|
35
|
+
<!DOCTYPE html>
|
|
36
|
+
<html lang="fr" style="color: red;">
|
|
37
|
+
<head>
|
|
38
|
+
<meta charset="UTF-8">
|
|
39
|
+
<title>Test Title</title>
|
|
40
|
+
<meta name="description" content="Test description">
|
|
41
|
+
<meta name="keywords" content="test, example">
|
|
42
|
+
</head>
|
|
43
|
+
<body class="test-class">
|
|
44
|
+
</body>
|
|
45
|
+
</html>
|
|
46
|
+
`
|
|
47
|
+
.trim()
|
|
48
|
+
.replace(/\s+/g, " ");
|
|
49
|
+
|
|
50
|
+
expect(
|
|
51
|
+
serverHeadProvider.renderHead(template, head).replace(/\s+/g, " "),
|
|
52
|
+
).toBe(expectedOutput);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("should render script tags with src attribute", ({ expect }) => {
|
|
56
|
+
const template = `<!DOCTYPE html><html><head></head><body></body></html>`;
|
|
57
|
+
|
|
58
|
+
const head = {
|
|
59
|
+
script: [{ src: "https://example.com/script.js" }],
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const result = serverHeadProvider.renderHead(template, head);
|
|
63
|
+
expect(result).toContain(
|
|
64
|
+
'<script src="https://example.com/script.js"></script>',
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("should render script tags with async and defer as boolean attributes", ({
|
|
69
|
+
expect,
|
|
70
|
+
}) => {
|
|
71
|
+
const template = `<!DOCTYPE html><html><head></head><body></body></html>`;
|
|
72
|
+
|
|
73
|
+
const head = {
|
|
74
|
+
script: [
|
|
75
|
+
{ src: "https://example.com/async.js", async: true } as Record<
|
|
76
|
+
string,
|
|
77
|
+
string | boolean
|
|
78
|
+
>,
|
|
79
|
+
{ src: "https://example.com/defer.js", defer: true } as Record<
|
|
80
|
+
string,
|
|
81
|
+
string | boolean
|
|
82
|
+
>,
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const result = serverHeadProvider.renderHead(template, head);
|
|
87
|
+
expect(result).toContain(
|
|
88
|
+
'<script src="https://example.com/async.js" async></script>',
|
|
89
|
+
);
|
|
90
|
+
expect(result).toContain(
|
|
91
|
+
'<script src="https://example.com/defer.js" defer></script>',
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("should render script tags with type and crossorigin attributes", ({
|
|
96
|
+
expect,
|
|
97
|
+
}) => {
|
|
98
|
+
const template = `<!DOCTYPE html><html><head></head><body></body></html>`;
|
|
99
|
+
|
|
100
|
+
const head = {
|
|
101
|
+
script: [
|
|
102
|
+
{
|
|
103
|
+
src: "https://example.com/module.js",
|
|
104
|
+
type: "module",
|
|
105
|
+
crossorigin: "anonymous",
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const result = serverHeadProvider.renderHead(template, head);
|
|
111
|
+
expect(result).toContain('type="module"');
|
|
112
|
+
expect(result).toContain('crossorigin="anonymous"');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("should render multiple script tags", ({ expect }) => {
|
|
116
|
+
const template = `<!DOCTYPE html><html><head></head><body></body></html>`;
|
|
117
|
+
|
|
118
|
+
type ScriptAttrs = Record<string, string | boolean>;
|
|
119
|
+
const head = {
|
|
120
|
+
script: [
|
|
121
|
+
{ src: "https://example.com/first.js" } as ScriptAttrs,
|
|
122
|
+
{ src: "https://example.com/second.js", defer: true } as ScriptAttrs,
|
|
123
|
+
{ src: "https://example.com/third.js", type: "module" } as ScriptAttrs,
|
|
124
|
+
],
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const result = serverHeadProvider.renderHead(template, head);
|
|
128
|
+
expect(result).toContain(
|
|
129
|
+
'<script src="https://example.com/first.js"></script>',
|
|
130
|
+
);
|
|
131
|
+
expect(result).toContain(
|
|
132
|
+
'<script src="https://example.com/second.js" defer></script>',
|
|
133
|
+
);
|
|
134
|
+
expect(result).toContain('src="https://example.com/third.js"');
|
|
135
|
+
expect(result).toContain('type="module"');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("should not render script attributes with false value", ({ expect }) => {
|
|
139
|
+
const template = `<!DOCTYPE html><html><head></head><body></body></html>`;
|
|
140
|
+
|
|
141
|
+
const head = {
|
|
142
|
+
script: [{ src: "https://example.com/script.js", defer: false }],
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const result = serverHeadProvider.renderHead(template, head);
|
|
146
|
+
expect(result).toContain(
|
|
147
|
+
'<script src="https://example.com/script.js"></script>',
|
|
148
|
+
);
|
|
149
|
+
expect(result).not.toContain("defer");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("should escape HTML in script attributes", ({ expect }) => {
|
|
153
|
+
const template = `<!DOCTYPE html><html><head></head><body></body></html>`;
|
|
154
|
+
|
|
155
|
+
const head = {
|
|
156
|
+
script: [{ src: 'https://example.com/script.js?a=1&b="2"' }],
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const result = serverHeadProvider.renderHead(template, head);
|
|
160
|
+
expect(result).toContain("&");
|
|
161
|
+
expect(result).toContain(""");
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -68,6 +68,12 @@ export class ServerHeadProvider {
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
if (head.script) {
|
|
72
|
+
for (const script of head.script) {
|
|
73
|
+
headContent += this.renderScriptTag(script);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
71
77
|
// Inject into <head>...</head>
|
|
72
78
|
result = result.replace(
|
|
73
79
|
/<head([^>]*)>(.*?)<\/head>/is,
|
|
@@ -124,4 +130,18 @@ export class ServerHeadProvider {
|
|
|
124
130
|
}
|
|
125
131
|
return "";
|
|
126
132
|
}
|
|
133
|
+
|
|
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`;
|
|
146
|
+
}
|
|
127
147
|
}
|