@alepha/react 0.14.3 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +10 -0
  2. package/dist/auth/index.browser.js +29 -14
  3. package/dist/auth/index.browser.js.map +1 -1
  4. package/dist/auth/index.d.ts +4 -4
  5. package/dist/auth/index.d.ts.map +1 -1
  6. package/dist/auth/index.js +950 -194
  7. package/dist/auth/index.js.map +1 -1
  8. package/dist/core/index.d.ts +118 -118
  9. package/dist/core/index.d.ts.map +1 -1
  10. package/dist/form/index.d.ts +27 -28
  11. package/dist/form/index.d.ts.map +1 -1
  12. package/dist/head/index.browser.js +59 -19
  13. package/dist/head/index.browser.js.map +1 -1
  14. package/dist/head/index.d.ts +105 -576
  15. package/dist/head/index.d.ts.map +1 -1
  16. package/dist/head/index.js +91 -87
  17. package/dist/head/index.js.map +1 -1
  18. package/dist/i18n/index.d.ts +33 -33
  19. package/dist/i18n/index.d.ts.map +1 -1
  20. package/dist/router/index.browser.js +30 -15
  21. package/dist/router/index.browser.js.map +1 -1
  22. package/dist/router/index.d.ts +827 -403
  23. package/dist/router/index.d.ts.map +1 -1
  24. package/dist/router/index.js +951 -195
  25. package/dist/router/index.js.map +1 -1
  26. package/dist/websocket/index.d.ts +38 -39
  27. package/dist/websocket/index.d.ts.map +1 -1
  28. package/package.json +5 -5
  29. package/src/auth/__tests__/$auth.spec.ts +10 -11
  30. package/src/core/__tests__/Router.spec.tsx +4 -4
  31. package/src/head/{__tests__/expandSeo.spec.ts → helpers/SeoExpander.spec.ts} +1 -1
  32. package/src/head/index.ts +10 -28
  33. package/src/head/providers/BrowserHeadProvider.browser.spec.ts +1 -76
  34. package/src/head/providers/BrowserHeadProvider.ts +25 -19
  35. package/src/head/providers/HeadProvider.ts +76 -10
  36. package/src/head/providers/ServerHeadProvider.ts +22 -138
  37. package/src/router/__tests__/page-head-browser.browser.spec.ts +91 -0
  38. package/src/router/__tests__/page-head.spec.ts +44 -0
  39. package/src/{head → router}/__tests__/seo-head.spec.ts +2 -2
  40. package/src/router/atoms/ssrManifestAtom.ts +60 -0
  41. package/src/router/constants/PAGE_PRELOAD_KEY.ts +6 -0
  42. package/src/router/errors/Redirection.ts +1 -1
  43. package/src/router/index.shared.ts +1 -0
  44. package/src/router/index.ts +16 -2
  45. package/src/router/primitives/$page.browser.spec.tsx +15 -15
  46. package/src/router/primitives/$page.spec.tsx +18 -18
  47. package/src/router/primitives/$page.ts +46 -10
  48. package/src/router/providers/ReactBrowserProvider.ts +14 -29
  49. package/src/router/providers/ReactBrowserRouterProvider.ts +5 -0
  50. package/src/router/providers/ReactPageProvider.ts +11 -4
  51. package/src/router/providers/ReactServerProvider.ts +321 -316
  52. package/src/router/providers/ReactServerTemplateProvider.ts +793 -0
  53. package/src/router/providers/SSRManifestProvider.ts +365 -0
  54. package/src/router/services/ReactPageServerService.ts +5 -3
  55. package/src/router/services/ReactRouter.ts +3 -3
  56. package/src/head/__tests__/page-head.spec.ts +0 -39
  57. package/src/head/providers/ServerHeadProvider.spec.ts +0 -163
@@ -2,54 +2,53 @@ import { ChannelPrimitive, TWSObject } from "alepha/websocket";
2
2
  import { Static } from "alepha";
3
3
 
4
4
  //#region ../../src/websocket/hooks/useRoom.d.ts
5
-
6
5
  /**
7
6
  * UseRoom options
8
7
  */
9
8
  interface UseRoomOptions<TClient extends TWSObject, TServer extends TWSObject> {
10
9
  /**
11
- * Room ID to connect to
12
- */
10
+ * Room ID to connect to
11
+ */
13
12
  roomId: string;
14
13
  /**
15
- * Channel primitive defining the schemas
16
- */
14
+ * Channel primitive defining the schemas
15
+ */
17
16
  channel: ChannelPrimitive<TClient, TServer>;
18
17
  /**
19
- * Handler for incoming messages from the server
20
- */
18
+ * Handler for incoming messages from the server
19
+ */
21
20
  handler: (message: Static<TClient>) => void;
22
21
  /**
23
- * Optional WebSocket URL override
24
- * Defaults to auto-detected URL based on window.location
25
- */
22
+ * Optional WebSocket URL override
23
+ * Defaults to auto-detected URL based on window.location
24
+ */
26
25
  url?: string;
27
26
  /**
28
- * Enable automatic reconnection on disconnect
29
- * @default true
30
- */
27
+ * Enable automatic reconnection on disconnect
28
+ * @default true
29
+ */
31
30
  autoReconnect?: boolean;
32
31
  /**
33
- * Reconnection interval in milliseconds
34
- * @default 3000
35
- */
32
+ * Reconnection interval in milliseconds
33
+ * @default 3000
34
+ */
36
35
  reconnectInterval?: number;
37
36
  /**
38
- * Maximum reconnection attempts (-1 for infinite)
39
- * @default 10
40
- */
37
+ * Maximum reconnection attempts (-1 for infinite)
38
+ * @default 10
39
+ */
41
40
  maxReconnectAttempts?: number;
42
41
  /**
43
- * Called when connection is established
44
- */
42
+ * Called when connection is established
43
+ */
45
44
  onConnect?: () => void;
46
45
  /**
47
- * Called when connection is closed
48
- */
46
+ * Called when connection is closed
47
+ */
49
48
  onDisconnect?: () => void;
50
49
  /**
51
- * Called on connection error
52
- */
50
+ * Called on connection error
51
+ */
53
52
  onError?: (error: Error) => void;
54
53
  }
55
54
  /**
@@ -57,32 +56,32 @@ interface UseRoomOptions<TClient extends TWSObject, TServer extends TWSObject> {
57
56
  */
58
57
  interface UseRoomReturn<TServer extends TWSObject> {
59
58
  /**
60
- * Send a message to the server
61
- */
59
+ * Send a message to the server
60
+ */
62
61
  send: (message: Static<TServer>) => Promise<void>;
63
62
  /**
64
- * Whether the connection is established
65
- */
63
+ * Whether the connection is established
64
+ */
66
65
  isConnected: boolean;
67
66
  /**
68
- * Whether the connection is in progress
69
- */
67
+ * Whether the connection is in progress
68
+ */
70
69
  isConnecting: boolean;
71
70
  /**
72
- * Whether there was an error
73
- */
71
+ * Whether there was an error
72
+ */
74
73
  isError: boolean;
75
74
  /**
76
- * The error object if any
77
- */
75
+ * The error object if any
76
+ */
78
77
  error?: Error;
79
78
  /**
80
- * Manually reconnect
81
- */
79
+ * Manually reconnect
80
+ */
82
81
  reconnect: () => void;
83
82
  /**
84
- * Manually disconnect
85
- */
83
+ * Manually disconnect
84
+ */
86
85
  disconnect: () => void;
87
86
  }
88
87
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../../src/websocket/hooks/useRoom.tsx"],"sourcesContent":[],"mappings":";;;;;;;AASA;AACkB,UADD,cACC,CAAA,gBAAA,SAAA,EAAA,gBACA,SADA,CAAA,CAAA;EACA;;;EAUP,MAAA,EAAA,MAAA;EAKiB;;;EAuCH,OAAA,EA5Cd,gBA4Cc,CA5CG,OA4CH,EA5CY,OA4CZ,CAAA;EAMR;;;EAIC,OAAA,EAAA,CAAA,OAAA,EAjDG,MAiDH,CAjDU,OAiDV,CAAA,EAAA,GAAA,IAAA;EAAoB;;;AA4DtC;EAAwC,GAAA,CAAA,EAAA,MAAA;EAA2B;;;;EAGlD,aAAA,CAAA,EAAA,OAAA;EAAd;;;;;;;;;;;;;;;;;;;;;oBAzEiB;;;;;UAMH,8BAA8B;;;;kBAI7B,OAAO,aAAa;;;;;;;;;;;;;;;;UAoB5B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAwCG,0BAA2B,2BAA2B,oBACxD,eAAe,SAAS,8BAEhC,cAAc"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../../src/websocket/hooks/useRoom.tsx"],"mappings":";;;;;AASA;;UAAiB,cAAA,iBACC,SAAA,kBACA,SAAA;EAAA;;;EAAA,MAAA;EAAA;;;EAAA,OAAA,EAUP,gBAAA,CAAiB,OAAA,EAAS,OAAA;EAAA;;;EAAA,OAAA,GAAA,OAAA,EAKhB,MAAA,CAAO,OAAA;EAAA;;;;EAAA,GAAA;EAAA;;;;EAAA,aAAA;EAAA;;;;EAAA,iBAAA;EAAA;;;;EAAA,oBAAA;EAAA;;;EAAA,SAAA;EAAA;;;EAAA,YAAA;EAAA;;;EAAA,OAAA,IAAA,KAAA,EAuCR,KAAA;AAAA;AAAA;;AAMpB;AANoB,UAMH,aAAA,iBAA8B,SAAA;EAAA;;;EAAA,IAAA,GAAA,OAAA,EAI7B,MAAA,CAAO,OAAA,MAAa,OAAA;EAAA;;;EAAA,WAAA;EAAA;;;EAAA,YAAA;EAAA;;;EAAA,OAAA;EAAA;;;EAAA,KAAA,GAoB5B,KAAA;EAAA;;AAwCV;EAxCU,SAAA;EAAA;;AAwCV;EAxCU,UAAA;AAAA;AAAA;;AAwCV;;;;;;;;;;;;;;;;;;;;;;;;;AAxCU,cAwCG,OAAA,mBAA2B,SAAA,kBAA2B,SAAA,EAAA,OAAA,EACxD,cAAA,CAAe,OAAA,EAAS,OAAA,GAAA,IAAA,gBAEhC,aAAA,CAAc,OAAA"}
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.15.0",
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.15.0",
29
29
  "jsdom": "^27.4.0",
30
30
  "react": "^19.2.3",
31
31
  "typescript": "^5.9.3",
32
- "vitest": "^4.0.16"
32
+ "vitest": "^4.0.17"
33
33
  },
34
34
  "peerDependencies": {
35
- "alepha": "0.14.3",
35
+ "alepha": "0.15.0",
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
  },
@@ -1,10 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { Alepha } from "alepha";
3
3
  import { DateTimeProvider } from "alepha/datetime";
4
- import { $realm } from "alepha/security";
5
- import { HttpClient, ServerProvider } from "alepha/server";
4
+ import { $issuer, AlephaSecurity } from "alepha/security";
5
+ import { AlephaServer, HttpClient, ServerProvider } from "alepha/server";
6
6
  import { $client } from "alepha/server/links";
7
- import { AlephaServerSecurity } from "alepha/server/security";
8
7
  import { describe, test } from "vitest";
9
8
  import {
10
9
  $auth,
@@ -25,7 +24,7 @@ describe("$auth", () => {
25
24
  };
26
25
 
27
26
  class App {
28
- realm = $realm({
27
+ issuer = $issuer({
29
28
  secret: "my-secret-key",
30
29
  roles: [
31
30
  {
@@ -36,7 +35,7 @@ describe("$auth", () => {
36
35
  });
37
36
 
38
37
  auth = $auth({
39
- realm: this.realm,
38
+ issuer: this.issuer,
40
39
  credentials: {
41
40
  account: () => user,
42
41
  },
@@ -94,7 +93,7 @@ describe("$auth", () => {
94
93
  );
95
94
 
96
95
  test("should login with credentials", async ({ expect }) => {
97
- const alepha = Alepha.create().with(App).with(AlephaServerSecurity);
96
+ const alepha = Alepha.create().with(AlephaServer).with(AlephaSecurity).with(App);
98
97
  const auth = alepha.inject(ReactAuth);
99
98
  await alepha.start();
100
99
 
@@ -113,7 +112,7 @@ describe("$auth", () => {
113
112
  });
114
113
 
115
114
  test("should get userinfo", async ({ expect }) => {
116
- const alepha = Alepha.create().with(App).with(AlephaServerSecurity);
115
+ const alepha = Alepha.create().with(AlephaServer).with(AlephaSecurity).with(App);
117
116
  await alepha.start();
118
117
 
119
118
  const { data: tokens } = await login(alepha);
@@ -134,7 +133,7 @@ describe("$auth", () => {
134
133
  });
135
134
 
136
135
  test("should reject expired token", async ({ expect }) => {
137
- const alepha = Alepha.create().with(App);
136
+ const alepha = Alepha.create().with(AlephaServer).with(AlephaSecurity).with(App);
138
137
  await alepha.start();
139
138
 
140
139
  const { data: tokens } = await login(alepha);
@@ -150,7 +149,7 @@ describe("$auth", () => {
150
149
  });
151
150
 
152
151
  test("should refresh expired token", async ({ expect }) => {
153
- const alepha = Alepha.create().with(App);
152
+ const alepha = Alepha.create().with(AlephaServer).with(AlephaSecurity).with(App);
154
153
  await alepha.start();
155
154
 
156
155
  const { data: tokens } = await login(alepha);
@@ -174,7 +173,7 @@ describe("$auth", () => {
174
173
  });
175
174
 
176
175
  test("should reject expired refresh token", async ({ expect }) => {
177
- const alepha = Alepha.create().with(App);
176
+ const alepha = Alepha.create().with(AlephaServer).with(AlephaSecurity).with(App);
178
177
  await alepha.start();
179
178
 
180
179
  const { data: tokens } = await login(alepha);
@@ -182,7 +181,7 @@ describe("$auth", () => {
182
181
  await alepha.inject(DateTimeProvider).travel(40, "days");
183
182
 
184
183
  await expect(refresh(alepha, tokens)).rejects.toThrowError(
185
- "Failed to refresh access token using the refresh token (realm)",
184
+ "Failed to refresh access token using the refresh token (issuer)",
186
185
  );
187
186
  });
188
187
  });
@@ -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
+ }