alepha 0.22.0 → 0.23.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 (111) hide show
  1. package/dist/api/jobs/index.d.ts +20 -20
  2. package/dist/api/jobs/index.d.ts.map +1 -1
  3. package/dist/api/keys/index.d.ts +6 -6
  4. package/dist/api/users/index.d.ts +43 -9
  5. package/dist/api/users/index.d.ts.map +1 -1
  6. package/dist/api/users/index.js +24 -3
  7. package/dist/api/users/index.js.map +1 -1
  8. package/dist/api/verifications/index.d.ts +13 -13
  9. package/dist/cli/core/index.d.ts +46 -40
  10. package/dist/cli/core/index.d.ts.map +1 -1
  11. package/dist/cli/core/index.js +51 -101
  12. package/dist/cli/core/index.js.map +1 -1
  13. package/dist/cli/i18n/index.d.ts +12 -5
  14. package/dist/cli/i18n/index.d.ts.map +1 -1
  15. package/dist/cli/i18n/index.js +45 -11
  16. package/dist/cli/i18n/index.js.map +1 -1
  17. package/dist/cli/platform-lib/index.d.ts +32 -6
  18. package/dist/cli/platform-lib/index.d.ts.map +1 -1
  19. package/dist/cli/platform-lib/index.js +82 -19
  20. package/dist/cli/platform-lib/index.js.map +1 -1
  21. package/dist/command/index.d.ts +1 -1
  22. package/dist/mcp/index.d.ts +9 -0
  23. package/dist/mcp/index.d.ts.map +1 -1
  24. package/dist/mcp/index.js +23 -0
  25. package/dist/mcp/index.js.map +1 -1
  26. package/dist/react/form/index.d.ts +0 -1
  27. package/dist/react/form/index.d.ts.map +1 -1
  28. package/dist/react/form/index.js +16 -15
  29. package/dist/react/form/index.js.map +1 -1
  30. package/dist/react/i18n/index.d.ts +43 -0
  31. package/dist/react/i18n/index.d.ts.map +1 -1
  32. package/dist/react/i18n/index.js +114 -10
  33. package/dist/react/i18n/index.js.map +1 -1
  34. package/dist/react/router/index.browser.js +128 -5
  35. package/dist/react/router/index.browser.js.map +1 -1
  36. package/dist/react/router/index.d.ts +108 -1
  37. package/dist/react/router/index.d.ts.map +1 -1
  38. package/dist/react/router/index.js +184 -6
  39. package/dist/react/router/index.js.map +1 -1
  40. package/dist/react/sitemap/index.browser.js +35 -0
  41. package/dist/react/sitemap/index.browser.js.map +1 -0
  42. package/dist/react/sitemap/index.d.ts +92 -0
  43. package/dist/react/sitemap/index.d.ts.map +1 -0
  44. package/dist/react/sitemap/index.js +131 -0
  45. package/dist/react/sitemap/index.js.map +1 -0
  46. package/dist/server/auth/index.d.ts +105 -1
  47. package/dist/server/auth/index.d.ts.map +1 -1
  48. package/dist/server/auth/index.js +1604 -7
  49. package/dist/server/auth/index.js.map +1 -1
  50. package/dist/server/cookies/index.d.ts +15 -0
  51. package/dist/server/cookies/index.d.ts.map +1 -1
  52. package/dist/server/cookies/index.js +22 -3
  53. package/dist/server/cookies/index.js.map +1 -1
  54. package/dist/server/core/index.d.ts +18 -0
  55. package/dist/server/core/index.d.ts.map +1 -1
  56. package/dist/server/core/index.js +25 -0
  57. package/dist/server/core/index.js.map +1 -1
  58. package/package.json +16 -3
  59. package/src/api/users/controllers/RealmController.ts +1 -0
  60. package/src/api/users/primitives/$realm.ts +26 -0
  61. package/src/api/users/providers/RealmProvider.ts +15 -0
  62. package/src/api/users/schemas/realmConfigSchema.ts +14 -0
  63. package/src/cli/core/atoms/buildOptions.ts +0 -12
  64. package/src/cli/core/commands/build.ts +0 -10
  65. package/src/cli/core/index.ts +0 -3
  66. package/src/cli/core/tasks/BuildCloudflareTask.ts +37 -17
  67. package/src/cli/core/tasks/BuildPrerenderTask.ts +44 -7
  68. package/src/cli/i18n/__tests__/I18nCheckService.spec.ts +48 -0
  69. package/src/cli/i18n/services/I18nCheckService.ts +65 -11
  70. package/src/cli/platform-lib/adapters/CloudflareAdapter.ts +128 -36
  71. package/src/mcp/__tests__/McpServerProvider.spec.ts +71 -0
  72. package/src/mcp/providers/McpServerProvider.ts +55 -0
  73. package/src/react/form/__tests__/FormModel-submit-loading.spec.ts +71 -0
  74. package/src/react/form/__tests__/form-submitting-reactive.browser.spec.tsx +96 -0
  75. package/src/react/form/services/FormModel.ts +57 -39
  76. package/src/react/i18n/__tests__/I18nProvider.spec.ts +89 -0
  77. package/src/react/i18n/__tests__/locale-routing.spec.ts +107 -0
  78. package/src/react/i18n/providers/I18nProvider.ts +171 -12
  79. package/src/react/router/__tests__/RouterLocaleProvider.spec.ts +127 -0
  80. package/src/react/router/index.browser.ts +4 -0
  81. package/src/react/router/index.shared.ts +1 -0
  82. package/src/react/router/index.ts +9 -0
  83. package/src/react/router/providers/ReactBrowserRouterProvider.ts +15 -1
  84. package/src/react/router/providers/ReactPageProvider.ts +12 -1
  85. package/src/react/router/providers/ReactServerProvider.ts +92 -1
  86. package/src/react/router/providers/RootComponentsProvider.ts +13 -0
  87. package/src/react/router/providers/RouterLocaleProvider.ts +125 -0
  88. package/src/react/router/providers/__tests__/RootComponentsProvider.spec.ts +15 -0
  89. package/src/react/router/providers/__tests__/rootComponents.ssr.browser.spec.tsx +67 -0
  90. package/src/react/sitemap/__tests__/$sitemap.spec.ts +131 -0
  91. package/src/react/sitemap/index.browser.ts +21 -0
  92. package/src/react/sitemap/index.ts +25 -0
  93. package/src/react/sitemap/primitives/$sitemap.browser.ts +26 -0
  94. package/src/react/sitemap/primitives/$sitemap.ts +196 -0
  95. package/src/server/auth/__tests__/appleClientSecret.spec.ts +34 -0
  96. package/src/server/auth/__tests__/authFederationClient.spec.ts +40 -0
  97. package/src/server/auth/__tests__/federationAssertion.spec.ts +146 -0
  98. package/src/server/auth/__tests__/federationRedirectReplay.spec.ts +44 -0
  99. package/src/server/auth/helpers/appleClientSecret.ts +24 -0
  100. package/src/server/auth/helpers/federationAssertion.ts +74 -0
  101. package/src/server/auth/helpers/jtiReplayGuard.ts +41 -0
  102. package/src/server/auth/helpers/safeRedirectPath.ts +19 -0
  103. package/src/server/auth/index.ts +4 -0
  104. package/src/server/auth/primitives/$authFederationBroker.ts +273 -0
  105. package/src/server/auth/primitives/$authFederationClient.ts +89 -0
  106. package/src/server/auth/providers/ServerAuthProvider.ts +18 -4
  107. package/src/server/cookies/__tests__/ServerCookiesProvider.spec.ts +70 -0
  108. package/src/server/cookies/providers/ServerCookiesProvider.ts +23 -3
  109. package/src/server/core/interfaces/ServerRequest.ts +8 -0
  110. package/src/server/core/primitives/$route.ts +27 -0
  111. package/src/cli/core/tasks/BuildSitemapTask.ts +0 -130
@@ -27,6 +27,8 @@ import {
27
27
  type PagePrimitive,
28
28
  type PagePrimitiveOptions,
29
29
  } from "../primitives/$page.ts";
30
+ import { RootComponentsProvider } from "./RootComponentsProvider.ts";
31
+ import { RouterLocaleProvider } from "./RouterLocaleProvider.ts";
30
32
 
31
33
  // -------------------------------------------------------------------------------------------------------------------
32
34
 
@@ -65,6 +67,8 @@ export class ReactPageProvider {
65
67
  protected readonly log = $logger();
66
68
  protected readonly options = $state(reactPageOptions);
67
69
  protected readonly alepha = $inject(Alepha);
70
+ protected readonly rootComponentsProvider = $inject(RootComponentsProvider);
71
+ protected readonly localeProvider = $inject(RouterLocaleProvider);
68
72
  protected readonly pages: PageRoute[] = [];
69
73
  protected nextIdCursor = 0;
70
74
 
@@ -213,6 +217,12 @@ export class ReactPageProvider {
213
217
  }
214
218
 
215
219
  url = this.compile(url, options.params ?? {});
220
+ url = url.replace(/\/\/+/g, "/") || "/";
221
+
222
+ // Apply the active locale prefix (e.g. `/about` → `/fr/about`) to the path
223
+ // portion only, before any query string is appended. A no-op unless
224
+ // `routing: "prefix"` is enabled on the i18n module.
225
+ url = this.localeProvider.withPrefix(url);
216
226
 
217
227
  if (options.query) {
218
228
  const query = new URLSearchParams(options.query);
@@ -221,7 +231,7 @@ export class ReactPageProvider {
221
231
  }
222
232
  }
223
233
 
224
- return url.replace(/\/\/+/g, "/") || "/";
234
+ return url;
225
235
  }
226
236
 
227
237
  public url(
@@ -240,6 +250,7 @@ export class ReactPageProvider {
240
250
  AlephaContext.Provider,
241
251
  { value: this.alepha },
242
252
  createElement(NestedView, {}, state.layers[0]?.element),
253
+ ...this.rootComponentsProvider.rootComponents,
243
254
  );
244
255
 
245
256
  if (this.options.strictMode) {
@@ -32,6 +32,7 @@ import {
32
32
  reactPageOptions,
33
33
  } from "./ReactPageProvider.ts";
34
34
  import { ReactServerTemplateProvider } from "./ReactServerTemplateProvider.ts";
35
+ import { RouterLocaleProvider } from "./RouterLocaleProvider.ts";
35
36
  import { SSRManifestProvider } from "./SSRManifestProvider.ts";
36
37
 
37
38
  /**
@@ -66,6 +67,7 @@ export class ReactServerProvider {
66
67
  protected readonly serverStaticProvider = $inject(ServerStaticProvider);
67
68
  protected readonly serverRouterProvider = $inject(ServerRouterProvider);
68
69
  protected readonly ssrManifestProvider = $inject(SSRManifestProvider);
70
+ protected readonly localeProvider = $inject(RouterLocaleProvider);
69
71
 
70
72
  /**
71
73
  * Cached check for ServerLinksProvider - avoids has() lookup per request.
@@ -148,13 +150,39 @@ export class ReactServerProvider {
148
150
  ? new PipelineHandler(rawHandler, serverMiddleware)
149
151
  : rawHandler;
150
152
 
153
+ // Canonical (default-locale) route, served unprefixed.
151
154
  this.serverRouterProvider.createRoute({
152
155
  ...page,
153
156
  schema: undefined, // schema is handled by the page primitive provider
157
+ // A page's `static` is page-shaped (`{ entries }`) and handled by the
158
+ // page prerender pass — it must not leak into the route's boolean
159
+ // `static` snapshot flag.
160
+ static: undefined,
154
161
  method: "GET",
155
162
  path: page.match,
156
163
  handler,
157
164
  });
165
+
166
+ // Locale-prefixed variants (`/fr/about`, …) point at the SAME handler.
167
+ // The handler reads the active locale back out of the request URL, so
168
+ // no extra route param is needed and matching stays native.
169
+ if (this.localeProvider.enabled) {
170
+ for (const locale of this.localeProvider.prefixedLocales) {
171
+ const prefixedPath = this.localeProvider.withPrefix(
172
+ page.match,
173
+ locale,
174
+ );
175
+ this.log.debug(`+ ${prefixedPath} -> ${page.name} (${locale})`);
176
+ this.serverRouterProvider.createRoute({
177
+ ...page,
178
+ schema: undefined,
179
+ static: undefined,
180
+ method: "GET",
181
+ path: prefixedPath,
182
+ handler,
183
+ });
184
+ }
185
+ }
158
186
  }
159
187
  }
160
188
  }
@@ -315,6 +343,15 @@ export class ReactServerProvider {
315
343
 
316
344
  this.log.trace("Rendering page", { name: route.name });
317
345
 
346
+ // Locale-prefix mode: the URL is the source of truth for language.
347
+ // Record the active locale so links built during SSR (`pathname()`)
348
+ // carry the prefix, and so hreflang alternates can be emitted.
349
+ if (this.localeProvider.enabled) {
350
+ this.localeProvider.current = this.localeProvider.detect(
351
+ url.pathname,
352
+ ).locale;
353
+ }
354
+
318
355
  // Initialize router state
319
356
  const state: ReactRouterState = {
320
357
  url,
@@ -386,7 +423,20 @@ export class ReactServerProvider {
386
423
  }
387
424
 
388
425
  // Resolve global head for early streaming (htmlAttributes only)
389
- const globalHead = this.serverHeadProvider.resolveGlobalHead();
426
+ let globalHead = this.serverHeadProvider.resolveGlobalHead();
427
+
428
+ // In locale-prefix mode the language is known from the URL before any
429
+ // rendering, so stamp the correct `<html lang>` onto the early head
430
+ // (per-request copy — never mutate the shared global head).
431
+ if (this.localeProvider.enabled) {
432
+ globalHead = {
433
+ ...globalHead,
434
+ htmlAttributes: {
435
+ ...globalHead?.htmlAttributes,
436
+ lang: this.localeProvider.current,
437
+ },
438
+ };
439
+ }
390
440
 
391
441
  // Create optimized HTML stream with early head
392
442
  const htmlStream = this.templateProvider.createEarlyHtmlStream(
@@ -450,6 +500,44 @@ export class ReactServerProvider {
450
500
  * @param state - The router state
451
501
  * @returns Render result with redirect or React stream
452
502
  */
503
+ /**
504
+ * Inject SEO `hreflang` alternate links for the current route when
505
+ * locale-prefix routing is enabled. Each registered locale gets an absolute
506
+ * alternate URL (the default locale stays unprefixed), plus an `x-default`
507
+ * pointing at the unprefixed URL — this is what lets crawlers index every
508
+ * language. Also sets a best-effort `<html lang>` for the non-streaming /
509
+ * prerender path; the streamed path's `<html lang>` is finalized on the
510
+ * client (the early HTML head is flushed before the route is known).
511
+ *
512
+ * No-op unless `routing: "prefix"` is enabled on the i18n module.
513
+ */
514
+ protected injectLocaleHead(state: ReactRouterState): void {
515
+ if (!this.localeProvider.enabled) {
516
+ return;
517
+ }
518
+
519
+ const { origin, search } = state.url;
520
+ const canonical = this.localeProvider.detect(state.url.pathname).pathname;
521
+
522
+ const links = this.localeProvider.locales.map((locale) => ({
523
+ rel: "alternate",
524
+ hreflang: locale,
525
+ href: `${origin}${this.localeProvider.withPrefix(canonical, locale)}${search}`,
526
+ }));
527
+ links.push({
528
+ rel: "alternate",
529
+ hreflang: "x-default",
530
+ href: `${origin}${canonical}${search}`,
531
+ });
532
+
533
+ state.head ??= {};
534
+ state.head.link = [...(state.head.link ?? []), ...links];
535
+ state.head.htmlAttributes = {
536
+ ...state.head.htmlAttributes,
537
+ lang: this.localeProvider.current,
538
+ };
539
+ }
540
+
453
541
  protected async renderPage(
454
542
  route: PageRoute,
455
543
  state: ReactRouterState,
@@ -471,6 +559,9 @@ export class ReactServerProvider {
471
559
  state.head.link = [...(state.head.link ?? []), ...preloadLinks];
472
560
  }
473
561
 
562
+ // Inject SEO hreflang alternates for locale-prefix routing
563
+ this.injectLocaleHead(state);
564
+
474
565
  // Render React to stream
475
566
 
476
567
  const element = this.pageApi.root(state);
@@ -0,0 +1,13 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ /**
4
+ * Extension point letting any module contribute root-level React nodes that
5
+ * render on every page (siblings of the page view, inside AlephaContext).
6
+ *
7
+ * A module pushes into `rootComponents` from its `register` hook; the array
8
+ * is rendered by `ReactPageProvider.root()`. SSR-safe (same element feeds
9
+ * server render + client hydrate).
10
+ */
11
+ export class RootComponentsProvider {
12
+ public rootComponents: ReactNode[] = [];
13
+ }
@@ -0,0 +1,125 @@
1
+ import { $inject, Alepha } from "alepha";
2
+
3
+ /**
4
+ * Generic locale path-prefix mechanism for the router.
5
+ *
6
+ * This provider knows nothing about i18n — it only deals with URL path
7
+ * segments. It is configured by the i18n module (`I18nProvider`) when
8
+ * `routing: "prefix"` is enabled, which keeps the dependency one-directional
9
+ * (`i18n → router`) and avoids a module cycle.
10
+ *
11
+ * The default locale is served WITHOUT a prefix (`/about` = default,
12
+ * `/fr/about` = French). The active locale is derived from the current
13
+ * request/navigation and stored under `alepha.react.router.locale`, so every
14
+ * URL the router builds (`pathname()`) automatically carries the right prefix.
15
+ */
16
+ export class RouterLocaleProvider {
17
+ protected readonly alepha = $inject(Alepha);
18
+
19
+ /**
20
+ * Whether locale path-prefixing is active. Off by default — opt-in via the
21
+ * i18n module.
22
+ */
23
+ public enabled = false;
24
+
25
+ /**
26
+ * The default locale, served without a path prefix (e.g. `"en"` → `/about`).
27
+ */
28
+ public defaultLocale = "";
29
+
30
+ /**
31
+ * All known locales, including the default one.
32
+ */
33
+ public locales: string[] = [];
34
+
35
+ /**
36
+ * Configure the provider. Called by the i18n module before the SSR routes
37
+ * are registered.
38
+ */
39
+ public configure(options: {
40
+ enabled?: boolean;
41
+ defaultLocale?: string;
42
+ locales?: string[];
43
+ }): void {
44
+ if (options.enabled !== undefined) {
45
+ this.enabled = options.enabled;
46
+ }
47
+ if (options.defaultLocale !== undefined) {
48
+ this.defaultLocale = options.defaultLocale;
49
+ }
50
+ if (options.locales !== undefined) {
51
+ this.locales = options.locales;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Locales that carry a URL prefix — every known locale except the default.
57
+ */
58
+ public get prefixedLocales(): string[] {
59
+ return this.locales.filter((locale) => locale !== this.defaultLocale);
60
+ }
61
+
62
+ /**
63
+ * Splits a leading locale segment off a pathname.
64
+ *
65
+ * - `/fr/about` → `{ locale: "fr", pathname: "/about" }` when `fr` is a
66
+ * prefixed locale.
67
+ * - `/about` → `{ locale: defaultLocale, pathname: "/about" }`.
68
+ *
69
+ * When prefixing is disabled the pathname is returned untouched.
70
+ */
71
+ public detect(pathname: string): { locale: string; pathname: string } {
72
+ if (this.enabled) {
73
+ const first = pathname.split("/")[1];
74
+ if (first && this.prefixedLocales.includes(first)) {
75
+ const rest = pathname.slice(first.length + 1);
76
+ return { locale: first, pathname: this.normalize(rest) };
77
+ }
78
+ }
79
+ return { locale: this.defaultLocale, pathname };
80
+ }
81
+
82
+ /**
83
+ * Prepends the locale prefix to a pathname when needed. The default locale
84
+ * (and any unknown/disabled case) returns the pathname unchanged.
85
+ */
86
+ public withPrefix(pathname: string, locale: string = this.current): string {
87
+ if (
88
+ !this.enabled ||
89
+ !locale ||
90
+ locale === this.defaultLocale ||
91
+ !this.prefixedLocales.includes(locale)
92
+ ) {
93
+ return pathname;
94
+ }
95
+ return `/${locale}${pathname === "/" ? "" : pathname}`;
96
+ }
97
+
98
+ /**
99
+ * The active locale, derived from the current request/navigation. Falls back
100
+ * to the default locale when nothing has been detected.
101
+ */
102
+ public get current(): string {
103
+ return (
104
+ this.alepha.store.get("alepha.react.router.locale") || this.defaultLocale
105
+ );
106
+ }
107
+
108
+ public set current(locale: string) {
109
+ this.alepha.store.set("alepha.react.router.locale", locale);
110
+ }
111
+
112
+ /**
113
+ * Normalizes a stripped pathname so it always starts with a single slash and
114
+ * carries no trailing slash (except the root `/`).
115
+ */
116
+ protected normalize(pathname: string): string {
117
+ if (!pathname || pathname === "/") {
118
+ return "/";
119
+ }
120
+ const withLeading = pathname.startsWith("/") ? pathname : `/${pathname}`;
121
+ return withLeading.length > 1 && withLeading.endsWith("/")
122
+ ? withLeading.slice(0, -1)
123
+ : withLeading;
124
+ }
125
+ }
@@ -0,0 +1,15 @@
1
+ import { Alepha } from "alepha";
2
+ import { describe, expect, it } from "vitest";
3
+ import { RootComponentsProvider } from "../RootComponentsProvider.ts";
4
+
5
+ describe("RootComponentsProvider", () => {
6
+ it("starts empty and accepts pushed nodes", () => {
7
+ const alepha = Alepha.create();
8
+ const provider = alepha.inject(RootComponentsProvider);
9
+ expect(provider.rootComponents).toEqual([]);
10
+ provider.rootComponents.push("x" as any);
11
+ expect(alepha.inject(RootComponentsProvider).rootComponents).toHaveLength(
12
+ 1,
13
+ );
14
+ });
15
+ });
@@ -0,0 +1,67 @@
1
+ import { Alepha } from "alepha";
2
+ import { createElement } from "react";
3
+ import { describe, expect, it } from "vitest";
4
+ import type { ReactRouterState } from "../ReactPageProvider.ts";
5
+ import { ReactPageProvider } from "../ReactPageProvider.ts";
6
+ import { RootComponentsProvider } from "../RootComponentsProvider.ts";
7
+
8
+ /**
9
+ * Walk a React element tree (non-circular, element children only) and collect
10
+ * all "data-testid" values found in any element's props.
11
+ */
12
+ function collectTestIds(node: any, seen = new Set<any>()): string[] {
13
+ if (!node || typeof node !== "object" || seen.has(node)) {
14
+ return [];
15
+ }
16
+ seen.add(node);
17
+
18
+ const ids: string[] = [];
19
+
20
+ if (node.props?.["data-testid"]) {
21
+ ids.push(node.props["data-testid"]);
22
+ }
23
+
24
+ // Children may be a single node or an array
25
+ const children = node.props?.children;
26
+ if (Array.isArray(children)) {
27
+ for (const child of children) {
28
+ ids.push(...collectTestIds(child, seen));
29
+ }
30
+ } else if (children) {
31
+ ids.push(...collectTestIds(children, seen));
32
+ }
33
+
34
+ // Also walk positional children (args 2+ on createElement are spread as props.children
35
+ // but StrictMode wraps with a single child — unwrap one level manually)
36
+ return ids;
37
+ }
38
+
39
+ describe("rootComponents in ReactPageProvider.root", () => {
40
+ it("includes pushed components in the rendered root element tree", () => {
41
+ const alepha = Alepha.create();
42
+ alepha
43
+ .inject(RootComponentsProvider)
44
+ .rootComponents.push(
45
+ createElement("div", { "data-testid": "sigil-marker", key: "m" }),
46
+ );
47
+
48
+ const page = alepha.inject(ReactPageProvider);
49
+
50
+ const state: ReactRouterState = {
51
+ layers: [{ element: null } as any],
52
+ url: new URL("http://localhost/"),
53
+ onError: () => undefined as any,
54
+ params: {},
55
+ query: {},
56
+ meta: {},
57
+ head: {} as any,
58
+ };
59
+
60
+ const element: any = page.root(state);
61
+
62
+ // StrictMode wraps the root — unwrap one level
63
+ const inner = element?.props?.children ?? element;
64
+ const ids = collectTestIds(inner);
65
+ expect(ids).toContain("sigil-marker");
66
+ });
67
+ });
@@ -0,0 +1,131 @@
1
+ import { Alepha, t } from "alepha";
2
+ import { DateTimeProvider } from "alepha/datetime";
3
+ import { $page, AlephaReactRouter } from "alepha/react/router";
4
+ import { ServerRouterProvider } from "alepha/server";
5
+ import { describe, it } from "vitest";
6
+ import { AlephaReactSitemap } from "../index.ts";
7
+ import { $sitemap, type SitemapPrimitive } from "../primitives/$sitemap.ts";
8
+
9
+ describe("$sitemap", () => {
10
+ class App {
11
+ sitemap = $sitemap({ hostname: "https://example.com" });
12
+
13
+ home = $page({
14
+ path: "/",
15
+ static: true,
16
+ component: () => "home",
17
+ });
18
+
19
+ about = $page({
20
+ path: "/about",
21
+ static: true,
22
+ component: () => "about",
23
+ });
24
+
25
+ notFound = $page({
26
+ path: "/*",
27
+ component: () => "404",
28
+ });
29
+
30
+ github404 = $page({
31
+ path: "/404",
32
+ static: true,
33
+ component: () => "404",
34
+ });
35
+
36
+ blog = $page({
37
+ path: "/blog/:slug",
38
+ schema: { params: t.object({ slug: t.text() }) },
39
+ static: { entries: [{ params: { slug: "hello" } }] },
40
+ component: () => "post",
41
+ });
42
+ }
43
+
44
+ const start = async () => {
45
+ const alepha = Alepha.create()
46
+ .with(AlephaReactRouter)
47
+ .with(AlephaReactSitemap);
48
+ alepha.inject(App);
49
+ const router = alepha.inject(ServerRouterProvider);
50
+ await alepha.start();
51
+ return { alepha, router };
52
+ };
53
+
54
+ const sitemapOf = (alepha: Alepha) =>
55
+ alepha.primitives("sitemap")[0] as SitemapPrimitive;
56
+
57
+ const findSitemapRoute = (router: ServerRouterProvider) =>
58
+ router.getRoutes().find((route) => route.path === "/sitemap.xml");
59
+
60
+ it("registers a static GET /sitemap.xml route", async ({ expect }) => {
61
+ const { router } = await start();
62
+ const route = findSitemapRoute(router);
63
+ expect(route).toBeDefined();
64
+ expect(route?.method).toBe("GET");
65
+ expect(route?.static).toBe(true);
66
+ });
67
+
68
+ it("prerenders xml built from the app's pages", async ({ expect }) => {
69
+ const { alepha } = await start();
70
+ const { path, body } = sitemapOf(alepha).prerender();
71
+
72
+ expect(path).toBe("/sitemap.xml");
73
+ expect(body).toContain('<?xml version="1.0" encoding="UTF-8"?>');
74
+ expect(body).toContain("<loc>https://example.com/</loc>");
75
+ expect(body).toContain("<loc>https://example.com/about</loc>");
76
+ });
77
+
78
+ it("serves application/xml at request time", async ({ expect }) => {
79
+ const { router } = await start();
80
+ const route = findSitemapRoute(router)!;
81
+ const headers: Record<string, string> = {};
82
+ const reply = {
83
+ headers,
84
+ setHeader(name: string, value: string) {
85
+ headers[name.toLowerCase()] = value;
86
+ return this;
87
+ },
88
+ };
89
+ const body = await route.handler.run({ reply } as any);
90
+ expect(headers["content-type"]).toBe("application/xml");
91
+ expect(String(body)).toContain("<urlset");
92
+ });
93
+
94
+ it("expands parameterized pages via static.entries", async ({ expect }) => {
95
+ const { alepha } = await start();
96
+ const { body } = sitemapOf(alepha).prerender();
97
+ expect(body).toContain("<loc>https://example.com/blog/hello</loc>");
98
+ });
99
+
100
+ it("excludes wildcard and 404 routes", async ({ expect }) => {
101
+ const { alepha } = await start();
102
+ const { body } = sitemapOf(alepha).prerender();
103
+ expect(body).not.toContain("/*");
104
+ expect(body).not.toContain("/404");
105
+ });
106
+
107
+ it("falls back to PUBLIC_URL, then relative, when no hostname is given", async ({
108
+ expect,
109
+ }) => {
110
+ class RelApp {
111
+ sitemap = $sitemap();
112
+ home = $page({ path: "/", static: true, component: () => "home" });
113
+ }
114
+ const alepha = Alepha.create()
115
+ .with(AlephaReactRouter)
116
+ .with(AlephaReactSitemap);
117
+ alepha.inject(RelApp);
118
+ await alepha.start();
119
+
120
+ const { body } = sitemapOf(alepha).prerender();
121
+ expect(body).toContain("<loc>/</loc>");
122
+ });
123
+
124
+ it("uses DateTimeProvider for lastmod (travel-able)", async ({ expect }) => {
125
+ const { alepha } = await start();
126
+ const dateTime = alepha.inject(DateTimeProvider);
127
+ const expected = dateTime.now().format("YYYY-MM-DD");
128
+ const { body } = sitemapOf(alepha).prerender();
129
+ expect(body).toContain(`<lastmod>${expected}</lastmod>`);
130
+ });
131
+ });
@@ -0,0 +1,21 @@
1
+ import { $module } from "alepha";
2
+ import { $sitemap } from "./primitives/$sitemap.browser.ts";
3
+
4
+ // ---------------------------------------------------------------------------------------------------------------------
5
+
6
+ export * from "./primitives/$sitemap.browser.ts";
7
+
8
+ // ---------------------------------------------------------------------------------------------------------------------
9
+
10
+ /**
11
+ * Sitemap generation for React applications (browser entry).
12
+ *
13
+ * The sitemap route only exists server-side, so the browser build ships a
14
+ * no-op {@link $sitemap}. See the server entry for the real implementation.
15
+ *
16
+ * @module alepha.react.sitemap
17
+ */
18
+ export const AlephaReactSitemap = $module({
19
+ name: "alepha.react.sitemap",
20
+ primitives: [$sitemap],
21
+ });
@@ -0,0 +1,25 @@
1
+ import { $module } from "alepha";
2
+ import { AlephaDateTime } from "alepha/datetime";
3
+ import { AlephaServer } from "alepha/server";
4
+ import { $sitemap } from "./primitives/$sitemap.ts";
5
+
6
+ // ---------------------------------------------------------------------------------------------------------------------
7
+
8
+ export * from "./primitives/$sitemap.ts";
9
+
10
+ // ---------------------------------------------------------------------------------------------------------------------
11
+
12
+ /**
13
+ * Sitemap generation for React applications.
14
+ *
15
+ * Exposes the {@link $sitemap} primitive, which serves a `sitemap.xml` built
16
+ * from the app's `$page` primitives — live at request time and prerendered to a
17
+ * static file at build time.
18
+ *
19
+ * @module alepha.react.sitemap
20
+ */
21
+ export const AlephaReactSitemap = $module({
22
+ name: "alepha.react.sitemap",
23
+ imports: [AlephaServer, AlephaDateTime],
24
+ primitives: [$sitemap],
25
+ });
@@ -0,0 +1,26 @@
1
+ import { createPrimitive, KIND, Primitive } from "alepha";
2
+ import type { SitemapPrimitiveOptions } from "./$sitemap.ts";
3
+
4
+ export type { SitemapPrimitiveOptions } from "./$sitemap.ts";
5
+
6
+ /**
7
+ * Browser variant of {@link $sitemap}.
8
+ *
9
+ * The sitemap is a server-only route — there is nothing to register in the
10
+ * client bundle, so this is a no-op primitive. It exists only to keep
11
+ * `$sitemap()` valid as an isomorphic router field; the real implementation
12
+ * lives in `$sitemap.ts` (server entry).
13
+ */
14
+ export const $sitemap = (
15
+ options: SitemapPrimitiveOptions = {},
16
+ ): SitemapPrimitive => {
17
+ return createPrimitive(SitemapPrimitive, options);
18
+ };
19
+
20
+ export class SitemapPrimitive extends Primitive<SitemapPrimitiveOptions> {
21
+ protected onInit() {
22
+ // no-op in the browser
23
+ }
24
+ }
25
+
26
+ $sitemap[KIND] = SitemapPrimitive;