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
|
@@ -449,6 +449,95 @@ describe("I18nProvider", () => {
|
|
|
449
449
|
});
|
|
450
450
|
});
|
|
451
451
|
|
|
452
|
+
describe("server-side language autodetect (Accept-Language)", () => {
|
|
453
|
+
/**
|
|
454
|
+
* Injects a real, dictionary-populated `I18nProvider` and exposes the
|
|
455
|
+
* protected resolution used by the `server:onRequest` hook, so the priority
|
|
456
|
+
* (cookie → header → fallback) can be unit-tested against a true registry.
|
|
457
|
+
*/
|
|
458
|
+
const setup = (AppClass: new () => unknown) => {
|
|
459
|
+
const alepha = Alepha.create().with(AlephaReactI18n);
|
|
460
|
+
alepha.inject(AppClass as any);
|
|
461
|
+
const i18n = alepha.inject(I18nProvider);
|
|
462
|
+
return {
|
|
463
|
+
i18n,
|
|
464
|
+
resolve: (cookieLang?: string, headerLang?: string): string =>
|
|
465
|
+
(i18n as any).resolveRequestLang(cookieLang, headerLang),
|
|
466
|
+
};
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
class App {
|
|
470
|
+
en = $dictionary({
|
|
471
|
+
lazy: async () => ({ default: { hello: "Hello" } }),
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
fr = $dictionary({
|
|
475
|
+
lazy: async () => ({ default: { hello: "Bonjour" } }),
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
test("uses the Accept-Language header when no cookie is set", ({
|
|
480
|
+
expect,
|
|
481
|
+
}) => {
|
|
482
|
+
expect(setup(App).resolve(undefined, "fr")).toBe("fr");
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test("lets a manually-selected cookie win over the header", ({
|
|
486
|
+
expect,
|
|
487
|
+
}) => {
|
|
488
|
+
expect(setup(App).resolve("en", "fr")).toBe("en");
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test("falls back when the header language is not registered", ({
|
|
492
|
+
expect,
|
|
493
|
+
}) => {
|
|
494
|
+
expect(setup(App).resolve(undefined, "de")).toBe("en");
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test("falls back when neither cookie nor header is present", ({
|
|
498
|
+
expect,
|
|
499
|
+
}) => {
|
|
500
|
+
expect(setup(App).resolve(undefined, undefined)).toBe("en");
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test("normalizes a region-qualified header to a registered base language", ({
|
|
504
|
+
expect,
|
|
505
|
+
}) => {
|
|
506
|
+
expect(setup(App).resolve(undefined, "fr-FR")).toBe("fr");
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test("prefers an exact region match when one is registered", ({
|
|
510
|
+
expect,
|
|
511
|
+
}) => {
|
|
512
|
+
class RegionApp {
|
|
513
|
+
enUS = $dictionary({
|
|
514
|
+
lang: "en-US",
|
|
515
|
+
lazy: async () => ({ default: { color: "color" } }),
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
fr = $dictionary({
|
|
519
|
+
lazy: async () => ({ default: { color: "couleur" } }),
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
expect(setup(RegionApp).resolve(undefined, "en-US")).toBe("en-US");
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test("ignores the header when autoDetect is disabled", ({ expect }) => {
|
|
527
|
+
const { i18n, resolve } = setup(App);
|
|
528
|
+
i18n.options.autoDetect = false;
|
|
529
|
+
expect(resolve(undefined, "fr")).toBe("en");
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test("still honours a manual cookie when autoDetect is disabled", ({
|
|
533
|
+
expect,
|
|
534
|
+
}) => {
|
|
535
|
+
const { i18n, resolve } = setup(App);
|
|
536
|
+
i18n.options.autoDetect = false;
|
|
537
|
+
expect(resolve("fr", "en")).toBe("fr");
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
|
|
452
541
|
test("should handle partial interpolation args", async ({ expect }) => {
|
|
453
542
|
class App {
|
|
454
543
|
en = $dictionary({
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { Alepha } from "alepha";
|
|
2
|
+
import {
|
|
3
|
+
$page,
|
|
4
|
+
AlephaReactRouter,
|
|
5
|
+
ReactPageProvider,
|
|
6
|
+
RouterLocaleProvider,
|
|
7
|
+
} from "alepha/react/router";
|
|
8
|
+
import { ServerRouterProvider } from "alepha/server";
|
|
9
|
+
import { describe, test } from "vitest";
|
|
10
|
+
import { AlephaReactI18n } from "../index.ts";
|
|
11
|
+
import { $dictionary } from "../primitives/$dictionary.ts";
|
|
12
|
+
import { I18nProvider } from "../providers/I18nProvider.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* End-to-end wiring of locale-prefix routing (`routing: "prefix"`) across the
|
|
16
|
+
* i18n and router modules: configuration, outbound URL prefixing, and the
|
|
17
|
+
* URL-as-source-of-truth language resolution.
|
|
18
|
+
*/
|
|
19
|
+
describe("locale-prefix routing", () => {
|
|
20
|
+
class App {
|
|
21
|
+
en = $dictionary({
|
|
22
|
+
lazy: async () => ({ default: { hi: "Hi" } }),
|
|
23
|
+
});
|
|
24
|
+
fr = $dictionary({
|
|
25
|
+
lazy: async () => ({ default: { hi: "Salut" } }),
|
|
26
|
+
});
|
|
27
|
+
about = $page({
|
|
28
|
+
name: "about",
|
|
29
|
+
path: "/about",
|
|
30
|
+
component: () => "about",
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Everything is injected BEFORE start() — the DI container locks once started.
|
|
35
|
+
const start = async (routing: "none" | "prefix") => {
|
|
36
|
+
const alepha = Alepha.create()
|
|
37
|
+
.with(AlephaReactRouter)
|
|
38
|
+
.with(AlephaReactI18n);
|
|
39
|
+
alepha.inject(App);
|
|
40
|
+
const i18n = alepha.inject(I18nProvider);
|
|
41
|
+
const locale = alepha.inject(RouterLocaleProvider);
|
|
42
|
+
const pages = alepha.inject(ReactPageProvider);
|
|
43
|
+
const server = alepha.inject(ServerRouterProvider);
|
|
44
|
+
i18n.options.routing = routing;
|
|
45
|
+
await alepha.start();
|
|
46
|
+
return { alepha, i18n: i18n as any, locale, pages, server };
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
test("i18n configures the router locale provider from its dictionaries", async ({
|
|
50
|
+
expect,
|
|
51
|
+
}) => {
|
|
52
|
+
const { locale } = await start("prefix");
|
|
53
|
+
expect(locale.enabled).toBe(true);
|
|
54
|
+
expect(locale.defaultLocale).toBe("en");
|
|
55
|
+
expect(locale.locales).toEqual(["en", "fr"]);
|
|
56
|
+
expect(locale.prefixedLocales).toEqual(["fr"]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("generated URLs carry the active locale prefix (default stays bare)", async ({
|
|
60
|
+
expect,
|
|
61
|
+
}) => {
|
|
62
|
+
const { pages, locale } = await start("prefix");
|
|
63
|
+
|
|
64
|
+
// default locale → unprefixed
|
|
65
|
+
expect(pages.pathname("about")).toBe("/about");
|
|
66
|
+
|
|
67
|
+
// active locale fr → prefixed
|
|
68
|
+
locale.current = "fr";
|
|
69
|
+
expect(pages.pathname("about")).toBe("/fr/about");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("the URL locale wins over cookie and Accept-Language", async ({
|
|
73
|
+
expect,
|
|
74
|
+
}) => {
|
|
75
|
+
const { i18n } = await start("prefix");
|
|
76
|
+
|
|
77
|
+
// url prefix beats a conflicting cookie + header
|
|
78
|
+
expect(i18n.resolveRequestLang("en", "en", "fr")).toBe("fr");
|
|
79
|
+
// an unprefixed path resolves to the default locale (no redirect)
|
|
80
|
+
expect(i18n.detectUrlLocale("/about")).toBe("en");
|
|
81
|
+
expect(i18n.detectUrlLocale("/fr/about")).toBe("fr");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("registers prefixed server route variants for non-default locales", async ({
|
|
85
|
+
expect,
|
|
86
|
+
}) => {
|
|
87
|
+
const { server } = await start("prefix");
|
|
88
|
+
const paths = server.getRoutes().map((route) => route.path);
|
|
89
|
+
|
|
90
|
+
expect(paths).toContain("/about");
|
|
91
|
+
expect(paths).toContain("/fr/about");
|
|
92
|
+
// the default locale is never prefixed
|
|
93
|
+
expect(paths).not.toContain("/en/about");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("stays inert when routing is left as 'none'", async ({ expect }) => {
|
|
97
|
+
const { i18n, pages, locale, server } = await start("none");
|
|
98
|
+
|
|
99
|
+
expect(locale.enabled).toBe(false);
|
|
100
|
+
expect(pages.pathname("about")).toBe("/about");
|
|
101
|
+
expect(i18n.detectUrlLocale("/fr/about")).toBeUndefined();
|
|
102
|
+
|
|
103
|
+
const paths = server.getRoutes().map((route) => route.path);
|
|
104
|
+
expect(paths).toContain("/about");
|
|
105
|
+
expect(paths).not.toContain("/fr/about");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { $hook, $inject, Alepha, TypeBoxError, TypeProvider, t } from "alepha";
|
|
2
2
|
import { type DateTime, DateTimeProvider } from "alepha/datetime";
|
|
3
3
|
import { $logger } from "alepha/logger";
|
|
4
|
+
// Locale-prefix routing is optional: the router module (one-directional
|
|
5
|
+
// dependency, `i18n → router`) is only consulted when it is also registered.
|
|
6
|
+
import { RouterLocaleProvider } from "alepha/react/router";
|
|
4
7
|
import { $cookie } from "alepha/server/cookies";
|
|
5
8
|
import type { ServiceDictionary } from "../hooks/useI18n.ts";
|
|
6
9
|
|
|
@@ -26,10 +29,50 @@ export class I18nProvider<
|
|
|
26
29
|
translations: Record<string, string>;
|
|
27
30
|
}> = [];
|
|
28
31
|
|
|
29
|
-
options
|
|
32
|
+
options: {
|
|
33
|
+
fallbackLang: string;
|
|
34
|
+
autoDetect: boolean;
|
|
35
|
+
routing: "none" | "prefix";
|
|
36
|
+
} = {
|
|
30
37
|
fallbackLang: "en",
|
|
38
|
+
/**
|
|
39
|
+
* When true (the default), the UI language for a first-time visitor (one
|
|
40
|
+
* with no `lang` cookie) is detected server-side from the `Accept-Language`
|
|
41
|
+
* header. A manually-selected language (the cookie) always takes
|
|
42
|
+
* precedence, so this never overrides an explicit user choice. Set to false
|
|
43
|
+
* to always start in `fallbackLang` regardless of the browser's preferred
|
|
44
|
+
* language.
|
|
45
|
+
*/
|
|
46
|
+
autoDetect: true,
|
|
47
|
+
/**
|
|
48
|
+
* URL strategy for languages:
|
|
49
|
+
* - `"none"` (default): language lives in a cookie; URLs are not localized.
|
|
50
|
+
* - `"prefix"`: each non-default language gets a path prefix (`/fr/about`),
|
|
51
|
+
* making every language a distinct, crawlable URL for SEO. The default
|
|
52
|
+
* language (`fallbackLang`) stays unprefixed. Requires the router module.
|
|
53
|
+
* The URL becomes the source of truth for language (it wins over the
|
|
54
|
+
* cookie / `Accept-Language`), and there is no automatic redirect.
|
|
55
|
+
*/
|
|
56
|
+
routing: "none",
|
|
31
57
|
};
|
|
32
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Lazily-resolved locale-prefix router integration. Present only when both
|
|
61
|
+
* the router module is registered AND `routing: "prefix"` was configured —
|
|
62
|
+
* otherwise i18n stays fully standalone.
|
|
63
|
+
*/
|
|
64
|
+
protected localeProviderResolved = false;
|
|
65
|
+
protected localeProviderRef?: RouterLocaleProvider;
|
|
66
|
+
protected get localeProvider(): RouterLocaleProvider | undefined {
|
|
67
|
+
if (!this.localeProviderResolved) {
|
|
68
|
+
this.localeProviderResolved = true;
|
|
69
|
+
if (this.alepha.has(RouterLocaleProvider)) {
|
|
70
|
+
this.localeProviderRef = this.alepha.inject(RouterLocaleProvider);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return this.localeProviderRef;
|
|
74
|
+
}
|
|
75
|
+
|
|
33
76
|
public dateFormat: { format: (value: Date) => string } =
|
|
34
77
|
new Intl.DateTimeFormat(this.lang);
|
|
35
78
|
|
|
@@ -50,22 +93,103 @@ export class I18nProvider<
|
|
|
50
93
|
this.refreshLocale();
|
|
51
94
|
}
|
|
52
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Configure locale-prefix routing on the router, before the SSR routes are
|
|
98
|
+
* registered (`priority: "first"` runs ahead of the router's own `configure`
|
|
99
|
+
* hook). No-op unless `routing: "prefix"` and the router module is present.
|
|
100
|
+
*/
|
|
101
|
+
protected readonly onConfigure = $hook({
|
|
102
|
+
on: "configure",
|
|
103
|
+
priority: "first",
|
|
104
|
+
handler: () => {
|
|
105
|
+
const localeProvider = this.localeProvider;
|
|
106
|
+
if (this.options.routing === "prefix" && localeProvider) {
|
|
107
|
+
localeProvider.configure({
|
|
108
|
+
enabled: true,
|
|
109
|
+
defaultLocale: this.fallbackLang,
|
|
110
|
+
locales: this.languages,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
|
|
53
116
|
protected readonly onRender = $hook({
|
|
54
117
|
on: "server:onRequest",
|
|
55
118
|
priority: "last",
|
|
56
119
|
handler: async ({ request }) => {
|
|
57
|
-
this.alepha.store.set(
|
|
120
|
+
this.alepha.store.set(
|
|
121
|
+
"alepha.react.i18n.lang",
|
|
122
|
+
this.resolveRequestLang(
|
|
123
|
+
this.cookie.get(request),
|
|
124
|
+
request.language,
|
|
125
|
+
this.detectUrlLocale(request.url?.pathname),
|
|
126
|
+
),
|
|
127
|
+
);
|
|
58
128
|
},
|
|
59
129
|
});
|
|
60
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Detects the language carried by the request URL when `routing: "prefix"` is
|
|
133
|
+
* active. Returns the locale for any URL (the prefixed one for `/fr/...`, the
|
|
134
|
+
* default for an unprefixed path), or `undefined` when prefix routing is off.
|
|
135
|
+
*/
|
|
136
|
+
protected detectUrlLocale(pathname: string | undefined): string | undefined {
|
|
137
|
+
const localeProvider = this.localeProvider;
|
|
138
|
+
if (localeProvider?.enabled && pathname) {
|
|
139
|
+
return localeProvider.detect(pathname).locale || undefined;
|
|
140
|
+
}
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Resolves the UI language for an incoming server request.
|
|
146
|
+
*
|
|
147
|
+
* Priority:
|
|
148
|
+
* 0. the URL locale prefix (`routing: "prefix"`) — the URL is the source of
|
|
149
|
+
* truth and wins over everything, with no redirect;
|
|
150
|
+
* 1. the `lang` cookie — a language the user manually selected;
|
|
151
|
+
* 2. the `Accept-Language` header (when `autoDetect` is enabled) — but only
|
|
152
|
+
* when the detected language is actually registered, so we never switch to
|
|
153
|
+
* a locale we have no dictionary for. A region-qualified header (`en-US`)
|
|
154
|
+
* matches an exact registration first, then its base language (`en`);
|
|
155
|
+
* 3. `fallbackLang`.
|
|
156
|
+
*/
|
|
157
|
+
protected resolveRequestLang(
|
|
158
|
+
cookieLang: string | undefined,
|
|
159
|
+
headerLang: string | undefined,
|
|
160
|
+
urlLocale?: string,
|
|
161
|
+
): string {
|
|
162
|
+
if (urlLocale) {
|
|
163
|
+
return urlLocale;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (cookieLang) {
|
|
167
|
+
return cookieLang;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (this.options.autoDetect && headerLang) {
|
|
171
|
+
const registered = this.languages;
|
|
172
|
+
for (const candidate of [headerLang, headerLang.split("-")[0]]) {
|
|
173
|
+
if (registered.includes(candidate)) {
|
|
174
|
+
return candidate;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return this.fallbackLang;
|
|
180
|
+
}
|
|
181
|
+
|
|
61
182
|
protected readonly onStart = $hook({
|
|
62
183
|
on: "start",
|
|
63
184
|
handler: async () => {
|
|
64
185
|
if (this.alepha.isBrowser()) {
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
if (
|
|
68
|
-
this.
|
|
186
|
+
// In prefix mode the URL (hydrated into the lang state by the server)
|
|
187
|
+
// is the source of truth, so the cookie must not override it.
|
|
188
|
+
if (!this.localeProvider?.enabled) {
|
|
189
|
+
const cookieLang = this.cookie.get();
|
|
190
|
+
if (cookieLang) {
|
|
191
|
+
this.alepha.store.set("alepha.react.i18n.lang", cookieLang);
|
|
192
|
+
}
|
|
69
193
|
}
|
|
70
194
|
|
|
71
195
|
for (const item of this.registry) {
|
|
@@ -94,26 +218,61 @@ export class I18nProvider<
|
|
|
94
218
|
TypeProvider.setLocale(this.lang);
|
|
95
219
|
}
|
|
96
220
|
|
|
97
|
-
|
|
221
|
+
/**
|
|
222
|
+
* Activates a language: lazily loads its dictionaries (browser), updates the
|
|
223
|
+
* lang state, and refreshes the locale-bound formatters. Does NOT persist a
|
|
224
|
+
* cookie or navigate — that is the caller's concern.
|
|
225
|
+
*/
|
|
226
|
+
protected applyLang = async (lang: string) => {
|
|
98
227
|
if (this.alepha.isBrowser()) {
|
|
99
228
|
for (const item of this.registry) {
|
|
100
|
-
if (lang === item.lang) {
|
|
101
|
-
if (Object.keys(item.translations).length > 0) {
|
|
102
|
-
continue; // already loaded
|
|
103
|
-
}
|
|
229
|
+
if (lang === item.lang && Object.keys(item.translations).length === 0) {
|
|
104
230
|
item.translations = await item.loader();
|
|
105
231
|
}
|
|
106
232
|
}
|
|
107
|
-
this.cookie.set(lang);
|
|
108
233
|
}
|
|
109
234
|
|
|
110
235
|
this.alepha.store.set("alepha.react.i18n.lang", lang);
|
|
111
236
|
this.refreshLocale();
|
|
112
237
|
};
|
|
113
238
|
|
|
239
|
+
public setLang = async (lang: string) => {
|
|
240
|
+
const localeProvider = this.localeProvider;
|
|
241
|
+
if (localeProvider?.enabled) {
|
|
242
|
+
// The URL is the source of truth: switching language means navigating to
|
|
243
|
+
// the same page under the new locale prefix. Dictionaries and formatters
|
|
244
|
+
// are then activated via the resulting `alepha.react.router.locale`
|
|
245
|
+
// change (see the `mutate` hook). No cookie is written in prefix mode.
|
|
246
|
+
const { ReactRouter } = await import("alepha/react/router");
|
|
247
|
+
const router = this.alepha.inject(ReactRouter);
|
|
248
|
+
const canonical = localeProvider.detect(router.pathname).pathname;
|
|
249
|
+
await router.push(localeProvider.withPrefix(canonical, lang));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
await this.applyLang(lang);
|
|
254
|
+
if (this.alepha.isBrowser()) {
|
|
255
|
+
this.cookie.set(lang);
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
114
259
|
protected readonly mutate = $hook({
|
|
115
260
|
on: "state:mutate",
|
|
116
261
|
handler: async ({ key, value }) => {
|
|
262
|
+
// Prefix-mode navigation changed the active locale (set by the router) →
|
|
263
|
+
// activate the matching language (loads dictionaries, refreshes
|
|
264
|
+
// formatters, and re-renders consumers via the lang state).
|
|
265
|
+
if (
|
|
266
|
+
key === "alepha.react.router.locale" &&
|
|
267
|
+
this.localeProvider?.enabled
|
|
268
|
+
) {
|
|
269
|
+
const lang = (value as string) || this.fallbackLang;
|
|
270
|
+
if (lang !== this.lang) {
|
|
271
|
+
await this.applyLang(lang);
|
|
272
|
+
}
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
117
276
|
if (key === "alepha.react.i18n.lang" && this.alepha.isBrowser()) {
|
|
118
277
|
let hasChanged = false;
|
|
119
278
|
for (const item of this.registry) {
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { Alepha } from "alepha";
|
|
2
|
+
import { describe, test } from "vitest";
|
|
3
|
+
import { RouterLocaleProvider } from "../providers/RouterLocaleProvider.ts";
|
|
4
|
+
|
|
5
|
+
describe("RouterLocaleProvider", () => {
|
|
6
|
+
const setup = (config?: Parameters<RouterLocaleProvider["configure"]>[0]) => {
|
|
7
|
+
const alepha = Alepha.create();
|
|
8
|
+
const provider = alepha.inject(RouterLocaleProvider);
|
|
9
|
+
if (config) {
|
|
10
|
+
provider.configure(config);
|
|
11
|
+
}
|
|
12
|
+
return provider;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
describe("when disabled (default)", () => {
|
|
16
|
+
test("detect leaves the pathname untouched", ({ expect }) => {
|
|
17
|
+
const provider = setup();
|
|
18
|
+
expect(provider.detect("/fr/about")).toEqual({
|
|
19
|
+
locale: "",
|
|
20
|
+
pathname: "/fr/about",
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("withPrefix returns the pathname unchanged", ({ expect }) => {
|
|
25
|
+
const provider = setup();
|
|
26
|
+
expect(provider.withPrefix("/about", "fr")).toBe("/about");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("when enabled with default 'en' and locales [en, fr, de]", () => {
|
|
31
|
+
const config = {
|
|
32
|
+
enabled: true,
|
|
33
|
+
defaultLocale: "en",
|
|
34
|
+
locales: ["en", "fr", "de"],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
test("exposes only non-default locales as prefixed", ({ expect }) => {
|
|
38
|
+
const provider = setup(config);
|
|
39
|
+
expect(provider.prefixedLocales).toEqual(["fr", "de"]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("detect strips a known prefixed locale", ({ expect }) => {
|
|
43
|
+
const provider = setup(config);
|
|
44
|
+
expect(provider.detect("/fr/about")).toEqual({
|
|
45
|
+
locale: "fr",
|
|
46
|
+
pathname: "/about",
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("detect treats an unprefixed path as the default locale", ({
|
|
51
|
+
expect,
|
|
52
|
+
}) => {
|
|
53
|
+
const provider = setup(config);
|
|
54
|
+
expect(provider.detect("/about")).toEqual({
|
|
55
|
+
locale: "en",
|
|
56
|
+
pathname: "/about",
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("detect ignores a segment that is the default locale", ({
|
|
61
|
+
expect,
|
|
62
|
+
}) => {
|
|
63
|
+
const provider = setup(config);
|
|
64
|
+
// /en is NOT a prefixed locale (default is unprefixed), so it is a normal path
|
|
65
|
+
expect(provider.detect("/en/about")).toEqual({
|
|
66
|
+
locale: "en",
|
|
67
|
+
pathname: "/en/about",
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("detect normalizes the bare locale root to '/'", ({ expect }) => {
|
|
72
|
+
const provider = setup(config);
|
|
73
|
+
expect(provider.detect("/fr")).toEqual({ locale: "fr", pathname: "/" });
|
|
74
|
+
expect(provider.detect("/fr/")).toEqual({ locale: "fr", pathname: "/" });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("detect strips a deep prefixed path", ({ expect }) => {
|
|
78
|
+
const provider = setup(config);
|
|
79
|
+
expect(provider.detect("/de/c/42/quest")).toEqual({
|
|
80
|
+
locale: "de",
|
|
81
|
+
pathname: "/c/42/quest",
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("withPrefix prepends a non-default locale", ({ expect }) => {
|
|
86
|
+
const provider = setup(config);
|
|
87
|
+
expect(provider.withPrefix("/about", "fr")).toBe("/fr/about");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("withPrefix leaves the default locale unprefixed", ({ expect }) => {
|
|
91
|
+
const provider = setup(config);
|
|
92
|
+
expect(provider.withPrefix("/about", "en")).toBe("/about");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("withPrefix handles the root path", ({ expect }) => {
|
|
96
|
+
const provider = setup(config);
|
|
97
|
+
expect(provider.withPrefix("/", "fr")).toBe("/fr");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("withPrefix ignores an unknown locale", ({ expect }) => {
|
|
101
|
+
const provider = setup(config);
|
|
102
|
+
expect(provider.withPrefix("/about", "zz")).toBe("/about");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("current locale (store-backed)", () => {
|
|
107
|
+
test("defaults to the default locale", ({ expect }) => {
|
|
108
|
+
const provider = setup({
|
|
109
|
+
enabled: true,
|
|
110
|
+
defaultLocale: "en",
|
|
111
|
+
locales: ["en", "fr"],
|
|
112
|
+
});
|
|
113
|
+
expect(provider.current).toBe("en");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("withPrefix uses current() when no locale is passed", ({ expect }) => {
|
|
117
|
+
const provider = setup({
|
|
118
|
+
enabled: true,
|
|
119
|
+
defaultLocale: "en",
|
|
120
|
+
locales: ["en", "fr"],
|
|
121
|
+
});
|
|
122
|
+
provider.current = "fr";
|
|
123
|
+
expect(provider.current).toBe("fr");
|
|
124
|
+
expect(provider.withPrefix("/about")).toBe("/fr/about");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -9,6 +9,7 @@ import { ReactBrowserProvider } from "./providers/ReactBrowserProvider.ts";
|
|
|
9
9
|
import { ReactBrowserRendererProvider } from "./providers/ReactBrowserRendererProvider.ts";
|
|
10
10
|
import { ReactBrowserRouterProvider } from "./providers/ReactBrowserRouterProvider.ts";
|
|
11
11
|
import { ReactPageProvider } from "./providers/ReactPageProvider.ts";
|
|
12
|
+
import { RouterLocaleProvider } from "./providers/RouterLocaleProvider.ts";
|
|
12
13
|
import { ReactPageService } from "./services/ReactPageService.ts";
|
|
13
14
|
import { ReactRouter } from "./services/ReactRouter.ts";
|
|
14
15
|
|
|
@@ -18,6 +19,7 @@ export * from "./index.shared.ts";
|
|
|
18
19
|
export * from "./providers/ReactBrowserProvider.ts";
|
|
19
20
|
export * from "./providers/ReactBrowserRendererProvider.ts";
|
|
20
21
|
export * from "./providers/ReactBrowserRouterProvider.ts";
|
|
22
|
+
export * from "./providers/RouterLocaleProvider.ts";
|
|
21
23
|
|
|
22
24
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
23
25
|
|
|
@@ -30,6 +32,7 @@ export const AlephaReactRouter = $module({
|
|
|
30
32
|
ReactBrowserProvider,
|
|
31
33
|
ReactRouter,
|
|
32
34
|
ReactBrowserRendererProvider,
|
|
35
|
+
RouterLocaleProvider,
|
|
33
36
|
ReactPageService,
|
|
34
37
|
],
|
|
35
38
|
register: (alepha) =>
|
|
@@ -43,5 +46,6 @@ export const AlephaReactRouter = $module({
|
|
|
43
46
|
.with(ReactBrowserProvider)
|
|
44
47
|
.with(ReactBrowserRouterProvider)
|
|
45
48
|
.with(ReactBrowserRendererProvider)
|
|
49
|
+
.with(RouterLocaleProvider)
|
|
46
50
|
.with(ReactRouter),
|
|
47
51
|
});
|
|
@@ -15,5 +15,6 @@ export * from "./hooks/useRouter.ts";
|
|
|
15
15
|
export * from "./hooks/useRouterState.ts";
|
|
16
16
|
export * from "./primitives/$page.ts";
|
|
17
17
|
export * from "./providers/ReactPageProvider.ts";
|
|
18
|
+
export * from "./providers/RootComponentsProvider.ts";
|
|
18
19
|
export * from "./services/ReactPageService.ts";
|
|
19
20
|
export * from "./services/ReactRouter.ts";
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import { ReactPreloadProvider } from "./providers/ReactPreloadProvider.ts";
|
|
16
16
|
import { ReactServerProvider } from "./providers/ReactServerProvider.ts";
|
|
17
17
|
import { ReactServerTemplateProvider } from "./providers/ReactServerTemplateProvider.ts";
|
|
18
|
+
import { RouterLocaleProvider } from "./providers/RouterLocaleProvider.ts";
|
|
18
19
|
import { SSRManifestProvider } from "./providers/SSRManifestProvider.ts";
|
|
19
20
|
import { ReactPageServerService } from "./services/ReactPageServerService.ts";
|
|
20
21
|
import { ReactPageService } from "./services/ReactPageService.ts";
|
|
@@ -28,6 +29,7 @@ export * from "./providers/ReactPageProvider.ts";
|
|
|
28
29
|
export * from "./providers/ReactPreloadProvider.ts";
|
|
29
30
|
export * from "./providers/ReactServerProvider.ts";
|
|
30
31
|
export * from "./providers/ReactServerTemplateProvider.ts";
|
|
32
|
+
export * from "./providers/RouterLocaleProvider.ts";
|
|
31
33
|
export * from "./providers/SSRManifestProvider.ts";
|
|
32
34
|
|
|
33
35
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
@@ -35,6 +37,11 @@ export * from "./providers/SSRManifestProvider.ts";
|
|
|
35
37
|
declare module "alepha" {
|
|
36
38
|
interface State {
|
|
37
39
|
"alepha.react.router.state"?: ReactRouterState;
|
|
40
|
+
/**
|
|
41
|
+
* The active locale path-prefix (e.g. `"fr"`), when `routing: "prefix"` is
|
|
42
|
+
* enabled on the i18n module. Empty/absent for the default locale.
|
|
43
|
+
*/
|
|
44
|
+
"alepha.react.router.locale"?: string;
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
interface Hooks {
|
|
@@ -124,6 +131,7 @@ export const AlephaReactRouter = $module({
|
|
|
124
131
|
ReactRouter,
|
|
125
132
|
ReactServerProvider,
|
|
126
133
|
ReactServerTemplateProvider,
|
|
134
|
+
RouterLocaleProvider,
|
|
127
135
|
SSRManifestProvider,
|
|
128
136
|
ReactPageServerService,
|
|
129
137
|
],
|
|
@@ -143,6 +151,7 @@ export const AlephaReactRouter = $module({
|
|
|
143
151
|
.with(ReactServerTemplateProvider)
|
|
144
152
|
.with(ReactPreloadProvider)
|
|
145
153
|
.with(ReactServerProvider)
|
|
154
|
+
.with(RouterLocaleProvider)
|
|
146
155
|
.with(ReactPageProvider)
|
|
147
156
|
.with(ReactRouter),
|
|
148
157
|
});
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
ReactPageProvider,
|
|
14
14
|
type ReactRouterState,
|
|
15
15
|
} from "./ReactPageProvider.ts";
|
|
16
|
+
import { RouterLocaleProvider } from "./RouterLocaleProvider.ts";
|
|
16
17
|
|
|
17
18
|
export interface BrowserRoute extends Route {
|
|
18
19
|
page: PageRoute;
|
|
@@ -26,6 +27,7 @@ export class ReactBrowserRouterProvider extends RouterProvider<BrowserRoute> {
|
|
|
26
27
|
protected readonly alepha = $inject(Alepha);
|
|
27
28
|
protected readonly pageApi = $inject(ReactPageProvider);
|
|
28
29
|
protected readonly browserHeadProvider = $inject(BrowserHeadProvider);
|
|
30
|
+
protected readonly localeProvider = $inject(RouterLocaleProvider);
|
|
29
31
|
|
|
30
32
|
public add(entry: PageRouteEntry) {
|
|
31
33
|
this.pageApi.add(entry);
|
|
@@ -75,7 +77,19 @@ export class ReactBrowserRouterProvider extends RouterProvider<BrowserRoute> {
|
|
|
75
77
|
});
|
|
76
78
|
|
|
77
79
|
try {
|
|
78
|
-
|
|
80
|
+
// In locale-prefix mode the URL is the source of truth for language:
|
|
81
|
+
// strip a leading `/fr`-style segment and remember it (the i18n module
|
|
82
|
+
// reacts to this to switch dictionaries), then match on the canonical
|
|
83
|
+
// path so the single page tree still resolves. `state.url` keeps the
|
|
84
|
+
// full prefixed URL so generated links carry the prefix forward.
|
|
85
|
+
let matchPathname = pathname;
|
|
86
|
+
if (this.localeProvider.enabled) {
|
|
87
|
+
const detected = this.localeProvider.detect(pathname);
|
|
88
|
+
this.localeProvider.current = detected.locale;
|
|
89
|
+
matchPathname = detected.pathname;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const { route, params } = this.match(matchPathname);
|
|
79
93
|
|
|
80
94
|
const query: Record<string, string> = {};
|
|
81
95
|
if (search) {
|