@alepha/react 0.14.2 → 0.14.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/index.browser.js +29 -14
- package/dist/auth/index.browser.js.map +1 -1
- package/dist/auth/index.js +960 -195
- package/dist/auth/index.js.map +1 -1
- package/dist/core/index.d.ts +4 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +7 -4
- package/dist/core/index.js.map +1 -1
- package/dist/head/index.browser.js +59 -19
- package/dist/head/index.browser.js.map +1 -1
- package/dist/head/index.d.ts +99 -560
- package/dist/head/index.d.ts.map +1 -1
- package/dist/head/index.js +92 -87
- package/dist/head/index.js.map +1 -1
- package/dist/router/index.browser.js +30 -15
- package/dist/router/index.browser.js.map +1 -1
- package/dist/router/index.d.ts +616 -192
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/index.js +961 -196
- package/dist/router/index.js.map +1 -1
- package/package.json +4 -4
- package/src/auth/__tests__/$auth.spec.ts +188 -0
- package/src/core/__tests__/Router.spec.tsx +169 -0
- package/src/core/hooks/useAction.browser.spec.tsx +569 -0
- package/src/core/hooks/useAction.ts +11 -0
- package/src/form/hooks/useForm.browser.spec.tsx +366 -0
- package/src/head/helpers/SeoExpander.spec.ts +203 -0
- package/src/head/hooks/useHead.spec.tsx +288 -0
- package/src/head/index.ts +11 -28
- package/src/head/providers/BrowserHeadProvider.browser.spec.ts +196 -0
- package/src/head/providers/BrowserHeadProvider.ts +25 -19
- package/src/head/providers/HeadProvider.ts +76 -10
- package/src/head/providers/ServerHeadProvider.ts +22 -138
- package/src/i18n/__tests__/integration.spec.tsx +239 -0
- package/src/i18n/components/Localize.spec.tsx +357 -0
- package/src/i18n/hooks/useI18n.browser.spec.tsx +438 -0
- package/src/i18n/providers/I18nProvider.spec.ts +389 -0
- package/src/router/__tests__/page-head-browser.browser.spec.ts +91 -0
- package/src/router/__tests__/page-head.spec.ts +44 -0
- package/src/router/__tests__/seo-head.spec.ts +121 -0
- package/src/router/atoms/ssrManifestAtom.ts +60 -0
- package/src/router/constants/PAGE_PRELOAD_KEY.ts +6 -0
- package/src/router/errors/Redirection.ts +1 -1
- package/src/router/index.shared.ts +1 -0
- package/src/router/index.ts +16 -2
- package/src/router/primitives/$page.browser.spec.tsx +702 -0
- package/src/router/primitives/$page.spec.tsx +702 -0
- package/src/router/primitives/$page.ts +46 -10
- package/src/router/providers/ReactBrowserProvider.ts +14 -29
- package/src/router/providers/ReactBrowserRouterProvider.ts +5 -0
- package/src/router/providers/ReactPageProvider.ts +11 -4
- package/src/router/providers/ReactServerProvider.spec.tsx +316 -0
- package/src/router/providers/ReactServerProvider.ts +331 -315
- package/src/router/providers/ReactServerTemplateProvider.ts +775 -0
- package/src/router/providers/SSRManifestProvider.ts +365 -0
- package/src/router/services/ReactPageServerService.ts +5 -3
- package/src/router/services/ReactRouter.ts +3 -3
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { AlephaContext } from "@alepha/react";
|
|
2
|
+
import type { Head } from "../index.ts";
|
|
3
|
+
import { useHead } from "../index.ts";
|
|
4
|
+
import { render } from "@testing-library/react";
|
|
5
|
+
import { Alepha } from "alepha";
|
|
6
|
+
import { act, type ReactNode } from "react";
|
|
7
|
+
import { beforeEach, describe, it, vi } from "vitest";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @vitest-environment jsdom
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
describe("useHead", () => {
|
|
14
|
+
const renderWithAlepha = (alepha: Alepha, element: ReactNode) => {
|
|
15
|
+
return render(
|
|
16
|
+
<AlephaContext.Provider value={alepha}>{element}</AlephaContext.Provider>,
|
|
17
|
+
);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
// Reset document state before each test
|
|
22
|
+
document.title = "";
|
|
23
|
+
document.head.innerHTML = "";
|
|
24
|
+
document.body.removeAttribute("class");
|
|
25
|
+
document.body.removeAttribute("style");
|
|
26
|
+
document.documentElement.removeAttribute("lang");
|
|
27
|
+
document.documentElement.removeAttribute("class");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("should set initial head options on mount", ({ expect }) => {
|
|
31
|
+
const alepha = Alepha.create();
|
|
32
|
+
const TestComponent = () => {
|
|
33
|
+
useHead({
|
|
34
|
+
title: "Test Title",
|
|
35
|
+
});
|
|
36
|
+
return <div>Test</div>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
renderWithAlepha(alepha, <TestComponent />);
|
|
40
|
+
|
|
41
|
+
expect(document.title).toBe("Test Title");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("should return current head state and setter function", ({ expect }) => {
|
|
45
|
+
const alepha = Alepha.create();
|
|
46
|
+
let headState: Head;
|
|
47
|
+
let setHeadFn: (head?: Head | ((previous?: Head) => Head)) => void;
|
|
48
|
+
|
|
49
|
+
const TestComponent = () => {
|
|
50
|
+
const [head, setHead] = useHead();
|
|
51
|
+
headState = head;
|
|
52
|
+
setHeadFn = setHead;
|
|
53
|
+
return <div>Test</div>;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
renderWithAlepha(alepha, <TestComponent />);
|
|
57
|
+
|
|
58
|
+
expect(headState!).toBeDefined();
|
|
59
|
+
expect(setHeadFn!).toBeDefined();
|
|
60
|
+
expect(typeof setHeadFn!).toBe("function");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("should update document title when setHead is called", ({ expect }) => {
|
|
64
|
+
const alepha = Alepha.create();
|
|
65
|
+
let setHeadFn: (head?: Head | ((previous?: Head) => Head)) => void;
|
|
66
|
+
|
|
67
|
+
const TestComponent = () => {
|
|
68
|
+
const [, setHead] = useHead();
|
|
69
|
+
setHeadFn = setHead;
|
|
70
|
+
return <div>Test</div>;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
renderWithAlepha(alepha, <TestComponent />);
|
|
74
|
+
|
|
75
|
+
act(() => {
|
|
76
|
+
setHeadFn({ title: "Updated Title" });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(document.title).toBe("Updated Title");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("should update meta tags when setHead is called", ({ expect }) => {
|
|
83
|
+
const alepha = Alepha.create();
|
|
84
|
+
let setHeadFn: (head?: Head | ((previous?: Head) => Head)) => void;
|
|
85
|
+
|
|
86
|
+
const TestComponent = () => {
|
|
87
|
+
const [, setHead] = useHead();
|
|
88
|
+
setHeadFn = setHead;
|
|
89
|
+
return <div>Test</div>;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
renderWithAlepha(alepha, <TestComponent />);
|
|
93
|
+
|
|
94
|
+
act(() => {
|
|
95
|
+
setHeadFn({
|
|
96
|
+
meta: [
|
|
97
|
+
{ name: "description", content: "Test Description" },
|
|
98
|
+
{ name: "keywords", content: "test, keywords" },
|
|
99
|
+
],
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const descriptionMeta = document.querySelector('meta[name="description"]');
|
|
104
|
+
const keywordsMeta = document.querySelector('meta[name="keywords"]');
|
|
105
|
+
|
|
106
|
+
expect(descriptionMeta?.getAttribute("content")).toBe("Test Description");
|
|
107
|
+
expect(keywordsMeta?.getAttribute("content")).toBe("test, keywords");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("should update body attributes when setHead is called", ({ expect }) => {
|
|
111
|
+
const alepha = Alepha.create();
|
|
112
|
+
let setHeadFn: (head?: Head | ((previous?: Head) => Head)) => void;
|
|
113
|
+
|
|
114
|
+
const TestComponent = () => {
|
|
115
|
+
const [, setHead] = useHead();
|
|
116
|
+
setHeadFn = setHead;
|
|
117
|
+
return <div>Test</div>;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
renderWithAlepha(alepha, <TestComponent />);
|
|
121
|
+
|
|
122
|
+
act(() => {
|
|
123
|
+
setHeadFn({
|
|
124
|
+
bodyAttributes: {
|
|
125
|
+
class: "dark-theme",
|
|
126
|
+
style: "background: black;",
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(document.body.getAttribute("class")).toBe("dark-theme");
|
|
132
|
+
expect(document.body.getAttribute("style")).toBe("background: black;");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("should update html attributes when setHead is called", ({ expect }) => {
|
|
136
|
+
const alepha = Alepha.create();
|
|
137
|
+
let setHeadFn: (head?: Head | ((previous?: Head) => Head)) => void;
|
|
138
|
+
|
|
139
|
+
const TestComponent = () => {
|
|
140
|
+
const [, setHead] = useHead();
|
|
141
|
+
setHeadFn = setHead;
|
|
142
|
+
return <div>Test</div>;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
renderWithAlepha(alepha, <TestComponent />);
|
|
146
|
+
|
|
147
|
+
act(() => {
|
|
148
|
+
setHeadFn({
|
|
149
|
+
htmlAttributes: {
|
|
150
|
+
lang: "en",
|
|
151
|
+
class: "no-js",
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
expect(document.documentElement.getAttribute("lang")).toBe("en");
|
|
157
|
+
expect(document.documentElement.getAttribute("class")).toBe("no-js");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("should support functional updates", ({ expect }) => {
|
|
161
|
+
const alepha = Alepha.create();
|
|
162
|
+
let setHeadFn: (head?: Head | ((previous?: Head) => Head)) => void;
|
|
163
|
+
|
|
164
|
+
const TestComponent = () => {
|
|
165
|
+
const [, setHead] = useHead({ title: "Initial Title" });
|
|
166
|
+
setHeadFn = setHead;
|
|
167
|
+
return <div>Test</div>;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
renderWithAlepha(alepha, <TestComponent />);
|
|
171
|
+
|
|
172
|
+
expect(document.title).toBe("Initial Title");
|
|
173
|
+
|
|
174
|
+
act(() => {
|
|
175
|
+
setHeadFn((prev) => ({
|
|
176
|
+
...prev,
|
|
177
|
+
title: `${prev?.title} - Updated`,
|
|
178
|
+
}));
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
expect(document.title).toBe("Initial Title - Updated");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("should handle multiple head updates", ({ expect }) => {
|
|
185
|
+
const alepha = Alepha.create();
|
|
186
|
+
let setHeadFn: (head?: Head | ((previous?: Head) => Head)) => void;
|
|
187
|
+
|
|
188
|
+
const TestComponent = () => {
|
|
189
|
+
const [, setHead] = useHead();
|
|
190
|
+
setHeadFn = setHead;
|
|
191
|
+
return <div>Test</div>;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
renderWithAlepha(alepha, <TestComponent />);
|
|
195
|
+
|
|
196
|
+
act(() => {
|
|
197
|
+
setHeadFn({
|
|
198
|
+
title: "First Title",
|
|
199
|
+
meta: [{ name: "description", content: "First Description" }],
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
expect(document.title).toBe("First Title");
|
|
204
|
+
expect(
|
|
205
|
+
document
|
|
206
|
+
.querySelector('meta[name="description"]')
|
|
207
|
+
?.getAttribute("content"),
|
|
208
|
+
).toBe("First Description");
|
|
209
|
+
|
|
210
|
+
act(() => {
|
|
211
|
+
setHeadFn({
|
|
212
|
+
title: "Second Title",
|
|
213
|
+
meta: [{ name: "description", content: "Second Description" }],
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
expect(document.title).toBe("Second Title");
|
|
218
|
+
expect(
|
|
219
|
+
document
|
|
220
|
+
.querySelector('meta[name="description"]')
|
|
221
|
+
?.getAttribute("content"),
|
|
222
|
+
).toBe("Second Description");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("should not crash on server-side (non-browser environment)", ({
|
|
226
|
+
expect,
|
|
227
|
+
}) => {
|
|
228
|
+
const alepha = Alepha.create();
|
|
229
|
+
// Mock isBrowser to return false
|
|
230
|
+
vi.spyOn(alepha, "isBrowser").mockReturnValue(false);
|
|
231
|
+
|
|
232
|
+
let headState: Head;
|
|
233
|
+
let setHeadFn: (head?: Head | ((previous?: Head) => Head)) => void;
|
|
234
|
+
|
|
235
|
+
const TestComponent = () => {
|
|
236
|
+
const [head, setHead] = useHead({ title: "Server Title" });
|
|
237
|
+
headState = head;
|
|
238
|
+
setHeadFn = setHead;
|
|
239
|
+
return <div>Test</div>;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
expect(() => {
|
|
243
|
+
renderWithAlepha(alepha, <TestComponent />);
|
|
244
|
+
}).not.toThrow();
|
|
245
|
+
|
|
246
|
+
expect(headState!).toEqual({});
|
|
247
|
+
|
|
248
|
+
// setHead should not crash on server
|
|
249
|
+
expect(() => {
|
|
250
|
+
setHeadFn({ title: "New Title" });
|
|
251
|
+
}).not.toThrow();
|
|
252
|
+
|
|
253
|
+
// Document title should remain unchanged on server
|
|
254
|
+
expect(document.title).toBe("");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("should get current head state from document", ({ expect }) => {
|
|
258
|
+
const alepha = Alepha.create();
|
|
259
|
+
|
|
260
|
+
// Pre-populate document with some head data
|
|
261
|
+
document.title = "Existing Title";
|
|
262
|
+
document.body.setAttribute("class", "existing-class");
|
|
263
|
+
document.documentElement.setAttribute("lang", "fr");
|
|
264
|
+
|
|
265
|
+
const meta = document.createElement("meta");
|
|
266
|
+
meta.setAttribute("name", "author");
|
|
267
|
+
meta.setAttribute("content", "John Doe");
|
|
268
|
+
document.head.appendChild(meta);
|
|
269
|
+
|
|
270
|
+
let headState: Head;
|
|
271
|
+
|
|
272
|
+
const TestComponent = () => {
|
|
273
|
+
const [head] = useHead();
|
|
274
|
+
headState = head;
|
|
275
|
+
return <div>Test</div>;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
renderWithAlepha(alepha, <TestComponent />);
|
|
279
|
+
|
|
280
|
+
expect(headState!.title).toBe("Existing Title");
|
|
281
|
+
expect(headState!.bodyAttributes?.class).toBe("existing-class");
|
|
282
|
+
expect(headState!.htmlAttributes?.lang).toBe("fr");
|
|
283
|
+
expect(headState!.meta).toContainEqual({
|
|
284
|
+
name: "author",
|
|
285
|
+
content: "John Doe",
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
});
|
package/src/head/index.ts
CHANGED
|
@@ -1,14 +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";
|
|
6
|
+
import { SeoExpander } from "./helpers/SeoExpander.ts";
|
|
7
|
+
import { ServerHeadProvider } from "./providers/ServerHeadProvider.ts";
|
|
12
8
|
|
|
13
9
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
14
10
|
|
|
@@ -17,26 +13,7 @@ export * from "./hooks/useHead.ts";
|
|
|
17
13
|
export * from "./interfaces/Head.ts";
|
|
18
14
|
export * from "./helpers/SeoExpander.ts";
|
|
19
15
|
export * from "./providers/ServerHeadProvider.ts";
|
|
20
|
-
|
|
21
|
-
// ---------------------------------------------------------------------------------------------------------------------
|
|
22
|
-
|
|
23
|
-
// Augment PagePrimitiveOptions in router module
|
|
24
|
-
declare module "@alepha/react/router" {
|
|
25
|
-
interface PagePrimitiveOptions<
|
|
26
|
-
TConfig extends PageConfigSchema = PageConfigSchema,
|
|
27
|
-
TProps extends object = TPropsDefault,
|
|
28
|
-
TPropsParent extends object = TPropsParentDefault,
|
|
29
|
-
> {
|
|
30
|
-
head?: Head | ((props: TProps, previous?: Head) => Head);
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Augment ReactRouterState in router module
|
|
35
|
-
declare module "@alepha/react/router" {
|
|
36
|
-
interface ReactRouterState {
|
|
37
|
-
head: Head;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
16
|
+
export * from "./providers/BrowserHeadProvider.ts";
|
|
40
17
|
|
|
41
18
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
42
19
|
|
|
@@ -54,5 +31,11 @@ declare module "@alepha/react/router" {
|
|
|
54
31
|
export const AlephaReactHead = $module({
|
|
55
32
|
name: "alepha.react.head",
|
|
56
33
|
primitives: [$head],
|
|
57
|
-
services: [
|
|
34
|
+
services: [
|
|
35
|
+
AlephaReact,
|
|
36
|
+
BrowserHeadProvider,
|
|
37
|
+
HeadProvider,
|
|
38
|
+
SeoExpander,
|
|
39
|
+
ServerHeadProvider,
|
|
40
|
+
],
|
|
58
41
|
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { Alepha } from "alepha";
|
|
2
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
3
|
+
import type { Head } from "../interfaces/Head.ts";
|
|
4
|
+
import { BrowserHeadProvider } from "./BrowserHeadProvider.ts";
|
|
5
|
+
|
|
6
|
+
describe("BrowserHeadProvider", () => {
|
|
7
|
+
let alepha: Alepha;
|
|
8
|
+
let provider: BrowserHeadProvider;
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
alepha = Alepha.create();
|
|
12
|
+
provider = alepha.inject(BrowserHeadProvider);
|
|
13
|
+
|
|
14
|
+
// Reset document state
|
|
15
|
+
document.title = "";
|
|
16
|
+
document.head.innerHTML = "";
|
|
17
|
+
document.body.removeAttribute("class");
|
|
18
|
+
document.body.removeAttribute("style");
|
|
19
|
+
document.documentElement.removeAttribute("lang");
|
|
20
|
+
document.documentElement.removeAttribute("class");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("getHead", () => {
|
|
24
|
+
it("should return current document head state", () => {
|
|
25
|
+
document.title = "Test Title";
|
|
26
|
+
document.body.setAttribute("class", "test-class");
|
|
27
|
+
document.documentElement.setAttribute("lang", "en");
|
|
28
|
+
|
|
29
|
+
const meta = document.createElement("meta");
|
|
30
|
+
meta.setAttribute("name", "description");
|
|
31
|
+
meta.setAttribute("content", "Test description");
|
|
32
|
+
document.head.appendChild(meta);
|
|
33
|
+
|
|
34
|
+
const head = provider.getHead(document);
|
|
35
|
+
|
|
36
|
+
expect(head.title).toBe("Test Title");
|
|
37
|
+
expect(head.bodyAttributes?.class).toBe("test-class");
|
|
38
|
+
expect(head.htmlAttributes?.lang).toBe("en");
|
|
39
|
+
expect(head.meta).toContainEqual({
|
|
40
|
+
name: "description",
|
|
41
|
+
content: "Test description",
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("should handle empty document state", () => {
|
|
46
|
+
const head = provider.getHead(document);
|
|
47
|
+
|
|
48
|
+
expect(head.title).toBe("");
|
|
49
|
+
expect(head.bodyAttributes).toEqual({});
|
|
50
|
+
expect(head.htmlAttributes).toEqual({});
|
|
51
|
+
expect(head.meta).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("renderHead", () => {
|
|
56
|
+
it("should set document title", () => {
|
|
57
|
+
const head: Head = { title: "New Title" };
|
|
58
|
+
|
|
59
|
+
provider.renderHead(document, head);
|
|
60
|
+
|
|
61
|
+
expect(document.title).toBe("New Title");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("should set body attributes", () => {
|
|
65
|
+
const head: Head = {
|
|
66
|
+
bodyAttributes: {
|
|
67
|
+
class: "new-class",
|
|
68
|
+
style: "background: blue;",
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
provider.renderHead(document, head);
|
|
73
|
+
|
|
74
|
+
expect(document.body.getAttribute("class")).toBe("new-class");
|
|
75
|
+
expect(document.body.getAttribute("style")).toBe("background: blue;");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("should remove body attributes when value is falsy", () => {
|
|
79
|
+
document.body.setAttribute("class", "old-class");
|
|
80
|
+
|
|
81
|
+
const head: Head = {
|
|
82
|
+
bodyAttributes: {
|
|
83
|
+
class: "",
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
provider.renderHead(document, head);
|
|
88
|
+
|
|
89
|
+
expect(document.body.hasAttribute("class")).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("should set html attributes", () => {
|
|
93
|
+
const head: Head = {
|
|
94
|
+
htmlAttributes: {
|
|
95
|
+
lang: "fr",
|
|
96
|
+
dir: "ltr",
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
provider.renderHead(document, head);
|
|
101
|
+
|
|
102
|
+
expect(document.documentElement.getAttribute("lang")).toBe("fr");
|
|
103
|
+
expect(document.documentElement.getAttribute("dir")).toBe("ltr");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("should remove html attributes when value is falsy", () => {
|
|
107
|
+
document.documentElement.setAttribute("lang", "en");
|
|
108
|
+
|
|
109
|
+
const head: Head = {
|
|
110
|
+
htmlAttributes: {
|
|
111
|
+
lang: "",
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
provider.renderHead(document, head);
|
|
116
|
+
|
|
117
|
+
expect(document.documentElement.hasAttribute("lang")).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("should create new meta tags", () => {
|
|
121
|
+
const head: Head = {
|
|
122
|
+
meta: [
|
|
123
|
+
{ name: "description", content: "Test description" },
|
|
124
|
+
{ name: "keywords", content: "test, browser" },
|
|
125
|
+
],
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
provider.renderHead(document, head);
|
|
129
|
+
|
|
130
|
+
const descriptionMeta = document.querySelector(
|
|
131
|
+
'meta[name="description"]',
|
|
132
|
+
);
|
|
133
|
+
const keywordsMeta = document.querySelector('meta[name="keywords"]');
|
|
134
|
+
|
|
135
|
+
expect(descriptionMeta?.getAttribute("content")).toBe("Test description");
|
|
136
|
+
expect(keywordsMeta?.getAttribute("content")).toBe("test, browser");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("should update existing meta tags", () => {
|
|
140
|
+
// Pre-populate with existing meta tag
|
|
141
|
+
const existingMeta = document.createElement("meta");
|
|
142
|
+
existingMeta.setAttribute("name", "description");
|
|
143
|
+
existingMeta.setAttribute("content", "Old description");
|
|
144
|
+
document.head.appendChild(existingMeta);
|
|
145
|
+
|
|
146
|
+
const head: Head = {
|
|
147
|
+
meta: [{ name: "description", content: "New description" }],
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
provider.renderHead(document, head);
|
|
151
|
+
|
|
152
|
+
const descriptionMeta = document.querySelector(
|
|
153
|
+
'meta[name="description"]',
|
|
154
|
+
);
|
|
155
|
+
expect(descriptionMeta?.getAttribute("content")).toBe("New description");
|
|
156
|
+
expect(
|
|
157
|
+
document.querySelectorAll('meta[name="description"]'),
|
|
158
|
+
).toHaveLength(1);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("should handle complete head object", () => {
|
|
162
|
+
const head: Head = {
|
|
163
|
+
title: "Complete Test",
|
|
164
|
+
htmlAttributes: {
|
|
165
|
+
lang: "es",
|
|
166
|
+
class: "theme-dark",
|
|
167
|
+
},
|
|
168
|
+
bodyAttributes: {
|
|
169
|
+
class: "page-test",
|
|
170
|
+
"data-theme": "dark",
|
|
171
|
+
},
|
|
172
|
+
meta: [
|
|
173
|
+
{ name: "description", content: "Complete test page" },
|
|
174
|
+
{ name: "author", content: "Test Author" },
|
|
175
|
+
],
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
provider.renderHead(document, head);
|
|
179
|
+
|
|
180
|
+
expect(document.title).toBe("Complete Test");
|
|
181
|
+
expect(document.documentElement.getAttribute("lang")).toBe("es");
|
|
182
|
+
expect(document.documentElement.getAttribute("class")).toBe("theme-dark");
|
|
183
|
+
expect(document.body.getAttribute("class")).toBe("page-test");
|
|
184
|
+
expect(document.body.getAttribute("data-theme")).toBe("dark");
|
|
185
|
+
|
|
186
|
+
const descriptionMeta = document.querySelector(
|
|
187
|
+
'meta[name="description"]',
|
|
188
|
+
);
|
|
189
|
+
const authorMeta = document.querySelector('meta[name="author"]');
|
|
190
|
+
expect(descriptionMeta?.getAttribute("content")).toBe(
|
|
191
|
+
"Complete test page",
|
|
192
|
+
);
|
|
193
|
+
expect(authorMeta?.getAttribute("content")).toBe("Test Author");
|
|
194
|
+
});
|
|
195
|
+
});
|
|
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 {
|