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.
- package/dist/api/jobs/index.d.ts +20 -20
- package/dist/api/jobs/index.d.ts.map +1 -1
- package/dist/api/keys/index.d.ts +6 -6
- package/dist/api/users/index.d.ts +43 -9
- package/dist/api/users/index.d.ts.map +1 -1
- package/dist/api/users/index.js +24 -3
- package/dist/api/users/index.js.map +1 -1
- package/dist/api/verifications/index.d.ts +13 -13
- package/dist/cli/core/index.d.ts +46 -40
- package/dist/cli/core/index.d.ts.map +1 -1
- package/dist/cli/core/index.js +51 -101
- package/dist/cli/core/index.js.map +1 -1
- package/dist/cli/i18n/index.d.ts +12 -5
- package/dist/cli/i18n/index.d.ts.map +1 -1
- package/dist/cli/i18n/index.js +45 -11
- package/dist/cli/i18n/index.js.map +1 -1
- package/dist/cli/platform-lib/index.d.ts +32 -6
- package/dist/cli/platform-lib/index.d.ts.map +1 -1
- package/dist/cli/platform-lib/index.js +82 -19
- package/dist/cli/platform-lib/index.js.map +1 -1
- package/dist/command/index.d.ts +1 -1
- package/dist/mcp/index.d.ts +9 -0
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +23 -0
- package/dist/mcp/index.js.map +1 -1
- package/dist/react/form/index.d.ts +0 -1
- package/dist/react/form/index.d.ts.map +1 -1
- package/dist/react/form/index.js +16 -15
- package/dist/react/form/index.js.map +1 -1
- package/dist/react/i18n/index.d.ts +43 -0
- package/dist/react/i18n/index.d.ts.map +1 -1
- package/dist/react/i18n/index.js +114 -10
- package/dist/react/i18n/index.js.map +1 -1
- package/dist/react/router/index.browser.js +128 -5
- package/dist/react/router/index.browser.js.map +1 -1
- package/dist/react/router/index.d.ts +108 -1
- package/dist/react/router/index.d.ts.map +1 -1
- package/dist/react/router/index.js +184 -6
- package/dist/react/router/index.js.map +1 -1
- package/dist/react/sitemap/index.browser.js +35 -0
- package/dist/react/sitemap/index.browser.js.map +1 -0
- package/dist/react/sitemap/index.d.ts +92 -0
- package/dist/react/sitemap/index.d.ts.map +1 -0
- package/dist/react/sitemap/index.js +131 -0
- package/dist/react/sitemap/index.js.map +1 -0
- package/dist/server/auth/index.d.ts +105 -1
- package/dist/server/auth/index.d.ts.map +1 -1
- package/dist/server/auth/index.js +1604 -7
- package/dist/server/auth/index.js.map +1 -1
- package/dist/server/cookies/index.d.ts +15 -0
- package/dist/server/cookies/index.d.ts.map +1 -1
- package/dist/server/cookies/index.js +22 -3
- package/dist/server/cookies/index.js.map +1 -1
- package/dist/server/core/index.d.ts +18 -0
- package/dist/server/core/index.d.ts.map +1 -1
- package/dist/server/core/index.js +25 -0
- package/dist/server/core/index.js.map +1 -1
- package/package.json +16 -3
- package/src/api/users/controllers/RealmController.ts +1 -0
- package/src/api/users/primitives/$realm.ts +26 -0
- package/src/api/users/providers/RealmProvider.ts +15 -0
- package/src/api/users/schemas/realmConfigSchema.ts +14 -0
- package/src/cli/core/atoms/buildOptions.ts +0 -12
- package/src/cli/core/commands/build.ts +0 -10
- package/src/cli/core/index.ts +0 -3
- package/src/cli/core/tasks/BuildCloudflareTask.ts +37 -17
- package/src/cli/core/tasks/BuildPrerenderTask.ts +44 -7
- package/src/cli/i18n/__tests__/I18nCheckService.spec.ts +48 -0
- package/src/cli/i18n/services/I18nCheckService.ts +65 -11
- package/src/cli/platform-lib/adapters/CloudflareAdapter.ts +128 -36
- package/src/mcp/__tests__/McpServerProvider.spec.ts +71 -0
- package/src/mcp/providers/McpServerProvider.ts +55 -0
- package/src/react/form/__tests__/FormModel-submit-loading.spec.ts +71 -0
- package/src/react/form/__tests__/form-submitting-reactive.browser.spec.tsx +96 -0
- package/src/react/form/services/FormModel.ts +57 -39
- package/src/react/i18n/__tests__/I18nProvider.spec.ts +89 -0
- package/src/react/i18n/__tests__/locale-routing.spec.ts +107 -0
- package/src/react/i18n/providers/I18nProvider.ts +171 -12
- package/src/react/router/__tests__/RouterLocaleProvider.spec.ts +127 -0
- package/src/react/router/index.browser.ts +4 -0
- package/src/react/router/index.shared.ts +1 -0
- package/src/react/router/index.ts +9 -0
- package/src/react/router/providers/ReactBrowserRouterProvider.ts +15 -1
- package/src/react/router/providers/ReactPageProvider.ts +12 -1
- package/src/react/router/providers/ReactServerProvider.ts +92 -1
- package/src/react/router/providers/RootComponentsProvider.ts +13 -0
- package/src/react/router/providers/RouterLocaleProvider.ts +125 -0
- package/src/react/router/providers/__tests__/RootComponentsProvider.spec.ts +15 -0
- package/src/react/router/providers/__tests__/rootComponents.ssr.browser.spec.tsx +67 -0
- package/src/react/sitemap/__tests__/$sitemap.spec.ts +131 -0
- package/src/react/sitemap/index.browser.ts +21 -0
- package/src/react/sitemap/index.ts +25 -0
- package/src/react/sitemap/primitives/$sitemap.browser.ts +26 -0
- package/src/react/sitemap/primitives/$sitemap.ts +196 -0
- package/src/server/auth/__tests__/appleClientSecret.spec.ts +34 -0
- package/src/server/auth/__tests__/authFederationClient.spec.ts +40 -0
- package/src/server/auth/__tests__/federationAssertion.spec.ts +146 -0
- package/src/server/auth/__tests__/federationRedirectReplay.spec.ts +44 -0
- package/src/server/auth/helpers/appleClientSecret.ts +24 -0
- package/src/server/auth/helpers/federationAssertion.ts +74 -0
- package/src/server/auth/helpers/jtiReplayGuard.ts +41 -0
- package/src/server/auth/helpers/safeRedirectPath.ts +19 -0
- package/src/server/auth/index.ts +4 -0
- package/src/server/auth/primitives/$authFederationBroker.ts +273 -0
- package/src/server/auth/primitives/$authFederationClient.ts +89 -0
- package/src/server/auth/providers/ServerAuthProvider.ts +18 -4
- package/src/server/cookies/__tests__/ServerCookiesProvider.spec.ts +70 -0
- package/src/server/cookies/providers/ServerCookiesProvider.ts +23 -3
- package/src/server/core/interfaces/ServerRequest.ts +8 -0
- package/src/server/core/primitives/$route.ts +27 -0
- 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
|
|
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
|
-
|
|
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;
|