@cosmicdrift/kumiko-headless 0.328.0 → 0.329.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 (108) hide show
  1. package/dist/apex/css.d.ts +3 -0
  2. package/{src/apex/css.ts → dist/apex/css.js} +11 -24
  3. package/dist/apex/index.d.ts +229 -0
  4. package/dist/apex/index.js +360 -0
  5. package/dist/apex/lightbox.d.ts +4 -0
  6. package/{src/apex/lightbox.ts → dist/apex/lightbox.js} +0 -4
  7. package/dist/contracts/asset.d.ts +15 -0
  8. package/dist/contracts/asset.js +17 -0
  9. package/dist/contracts/index.d.ts +3 -0
  10. package/dist/contracts/index.js +1 -0
  11. package/dist/contracts/locale.d.ts +7 -0
  12. package/dist/contracts/locale.js +11 -0
  13. package/dist/contracts/primitives.d.ts +93 -0
  14. package/dist/contracts/primitives.js +21 -0
  15. package/dist/dispatcher/index.d.ts +2 -0
  16. package/dist/dispatcher/index.js +1 -0
  17. package/dist/dispatcher/types.d.ts +86 -0
  18. package/dist/dispatcher/types.js +34 -0
  19. package/dist/form/form-controller.d.ts +2 -0
  20. package/dist/form/form-controller.js +404 -0
  21. package/dist/form/index.d.ts +3 -0
  22. package/dist/form/index.js +2 -0
  23. package/dist/form/types.d.ts +71 -0
  24. package/dist/form/types.js +1 -0
  25. package/dist/form/zod-bridge.d.ts +5 -0
  26. package/{src/form/zod-bridge.ts → dist/form/zod-bridge.js} +52 -58
  27. package/dist/format/escape.d.ts +5 -0
  28. package/{src/format/escape.ts → dist/format/escape.js} +43 -47
  29. package/dist/format/html-template.d.ts +9 -0
  30. package/dist/format/html-template.js +39 -0
  31. package/dist/format/index.d.ts +10 -0
  32. package/dist/format/index.js +192 -0
  33. package/dist/format/money.d.ts +1 -0
  34. package/{src/format/money.ts → dist/format/money.js} +6 -5
  35. package/dist/index.d.ts +14 -0
  36. package/dist/index.js +9 -0
  37. package/dist/locale-routing/index.d.ts +39 -0
  38. package/dist/locale-routing/index.js +143 -0
  39. package/dist/nav/index.d.ts +2 -0
  40. package/dist/nav/index.js +1 -0
  41. package/dist/nav/resolve.d.ts +2 -0
  42. package/dist/nav/resolve.js +58 -0
  43. package/dist/nav/types.d.ts +26 -0
  44. package/dist/nav/types.js +1 -0
  45. package/dist/store/create-store.d.ts +2 -0
  46. package/{src/store/create-store.ts → dist/store/create-store.js} +21 -23
  47. package/dist/store/equality.d.ts +1 -0
  48. package/{src/store/equality.ts → dist/store/equality.js} +19 -17
  49. package/dist/store/index.d.ts +3 -0
  50. package/dist/store/index.js +2 -0
  51. package/dist/store/types.d.ts +7 -0
  52. package/dist/store/types.js +10 -0
  53. package/dist/view-model/edit.d.ts +10 -0
  54. package/dist/view-model/edit.js +392 -0
  55. package/dist/view-model/embedded-list.d.ts +27 -0
  56. package/dist/view-model/embedded-list.js +63 -0
  57. package/dist/view-model/index.d.ts +7 -0
  58. package/dist/view-model/index.js +3 -0
  59. package/dist/view-model/list.d.ts +16 -0
  60. package/dist/view-model/list.js +201 -0
  61. package/dist/view-model/types.d.ts +300 -0
  62. package/dist/view-model/types.js +1 -0
  63. package/package.json +31 -12
  64. package/src/apex/__tests__/lightbox.test.ts +0 -20
  65. package/src/apex/__tests__/page-head.test.ts +0 -63
  66. package/src/apex/__tests__/render.test.ts +0 -401
  67. package/src/apex/index.ts +0 -667
  68. package/src/contracts/__tests__/contracts.test.ts +0 -123
  69. package/src/contracts/__tests__/field-issue-compat.test.ts +0 -19
  70. package/src/contracts/asset.ts +0 -48
  71. package/src/contracts/index.ts +0 -23
  72. package/src/contracts/locale.ts +0 -33
  73. package/src/contracts/primitives.ts +0 -158
  74. package/src/dispatcher/__tests__/contract.test.ts +0 -163
  75. package/src/dispatcher/index.ts +0 -16
  76. package/src/dispatcher/types.ts +0 -217
  77. package/src/form/__tests__/conditional-fields.test.ts +0 -177
  78. package/src/form/__tests__/form-controller.test.ts +0 -231
  79. package/src/form/__tests__/submit.test.ts +0 -575
  80. package/src/form/__tests__/validation.test.ts +0 -274
  81. package/src/form/form-controller.ts +0 -458
  82. package/src/form/index.ts +0 -15
  83. package/src/form/types.ts +0 -297
  84. package/src/format/__tests__/escape.test.ts +0 -79
  85. package/src/format/__tests__/format.test.ts +0 -285
  86. package/src/format/__tests__/html-template.test.ts +0 -47
  87. package/src/format/html-template.ts +0 -49
  88. package/src/format/index.ts +0 -223
  89. package/src/index.ts +0 -118
  90. package/src/locale-routing/__tests__/locale-routing.test.ts +0 -276
  91. package/src/locale-routing/index.ts +0 -218
  92. package/src/nav/__tests__/resolve.test.ts +0 -279
  93. package/src/nav/index.ts +0 -8
  94. package/src/nav/resolve.ts +0 -60
  95. package/src/nav/types.ts +0 -70
  96. package/src/store/__tests__/create-store.test.ts +0 -139
  97. package/src/store/__tests__/equality.test.ts +0 -66
  98. package/src/store/index.ts +0 -3
  99. package/src/store/types.ts +0 -27
  100. package/src/view-model/__tests__/edit.test.ts +0 -1072
  101. package/src/view-model/__tests__/embedded-list.test.ts +0 -96
  102. package/src/view-model/__tests__/field-labels.test.ts +0 -62
  103. package/src/view-model/__tests__/list.test.ts +0 -439
  104. package/src/view-model/edit.ts +0 -645
  105. package/src/view-model/embedded-list.ts +0 -89
  106. package/src/view-model/index.ts +0 -43
  107. package/src/view-model/list.ts +0 -280
  108. package/src/view-model/types.ts +0 -400
@@ -1,276 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import type { LocaleResolver } from "../../contracts";
3
- import { createLocaleRouter, wrapUrlLocaleResolver } from "../index";
4
-
5
- type MoneyHorsePage = "home" | "features" | "rechner" | "budget" | "ltv";
6
-
7
- const moneyHorseRouter = createLocaleRouter<MoneyHorsePage>({
8
- defaultLocale: "de",
9
- prefixedLocales: ["en"],
10
- routes: {
11
- home: { de: "/", en: "/en" },
12
- features: { de: "/funktionen", en: "/en/features" },
13
- rechner: { de: "/rechner", en: "/en/rechner" },
14
- budget: { de: "/budget-rechner", en: "/en/budget-rechner" },
15
- ltv: { de: "/beleihungsauslauf", en: "/en/beleihungsauslauf" },
16
- },
17
- localeHints: { en: ["/features"] },
18
- });
19
-
20
- describe("createLocaleRouter money-horse config", () => {
21
- test("detectLang: prefixed, default, legacy hint", () => {
22
- expect(moneyHorseRouter.detectLang("/")).toBe("de");
23
- expect(moneyHorseRouter.detectLang("/en")).toBe("en");
24
- expect(moneyHorseRouter.detectLang("/en/rechner")).toBe("en");
25
- expect(moneyHorseRouter.detectLang("/rechner")).toBe("de");
26
- expect(moneyHorseRouter.detectLang("/features")).toBe("en");
27
- expect(moneyHorseRouter.detectLang("/features/")).toBe("en");
28
- });
29
-
30
- test("publicPath returns canonical paths per locale", () => {
31
- expect(moneyHorseRouter.publicPath("features", "de")).toBe("/funktionen");
32
- expect(moneyHorseRouter.publicPath("features", "en")).toBe("/en/features");
33
- expect(moneyHorseRouter.publicPath("rechner", "en")).toBe("/en/rechner");
34
- });
35
-
36
- test("resolvePage maps canonical and legacy paths", () => {
37
- expect(moneyHorseRouter.resolvePage("/funktionen")).toBe("features");
38
- expect(moneyHorseRouter.resolvePage("/en/features")).toBe("features");
39
- expect(moneyHorseRouter.resolvePage("/features")).toBe("features");
40
- expect(moneyHorseRouter.resolvePage("/en/rechner")).toBe("rechner");
41
- expect(moneyHorseRouter.resolvePage("/login")).toBeUndefined();
42
- });
43
-
44
- test("altLocalePath keeps logical page across locales", () => {
45
- expect(moneyHorseRouter.altLocalePath("/en")).toBe("/");
46
- expect(moneyHorseRouter.altLocalePath("/")).toBe("/en");
47
- expect(moneyHorseRouter.altLocalePath("/en/features")).toBe("/funktionen");
48
- expect(moneyHorseRouter.altLocalePath("/funktionen")).toBe("/en/features");
49
- expect(moneyHorseRouter.altLocalePath("/features")).toBe("/funktionen");
50
- expect(moneyHorseRouter.altLocalePath("/en/rechner")).toBe("/rechner");
51
- expect(moneyHorseRouter.altLocalePath("/rechner")).toBe("/en/rechner");
52
- expect(moneyHorseRouter.altLocalePath("/unknown")).toBe("/en");
53
- });
54
-
55
- test("sectionAnchor attaches fragment to page path", () => {
56
- expect(moneyHorseRouter.sectionAnchor("home", "de", "pricing")).toBe("/#pricing");
57
- expect(moneyHorseRouter.sectionAnchor("home", "en", "pricing")).toBe("/en#pricing");
58
- });
59
- });
60
-
61
- type PublicStatusPage = "home" | "developers";
62
-
63
- const publicStatusRouter = createLocaleRouter<PublicStatusPage>({
64
- defaultLocale: "de",
65
- prefixedLocales: ["en"],
66
- routes: {
67
- home: { de: "/", en: "/en" },
68
- developers: { de: "/developers", en: "/en/developers" },
69
- },
70
- });
71
-
72
- describe("createLocaleRouter publicstatus config", () => {
73
- test("altLocalePath for developers", () => {
74
- expect(publicStatusRouter.altLocalePath("/en/developers")).toBe("/developers");
75
- expect(publicStatusRouter.altLocalePath("/developers")).toBe("/en/developers");
76
- });
77
- });
78
-
79
- describe("createLocaleRouter inverted default (website-style)", () => {
80
- const router = createLocaleRouter<"home">({
81
- defaultLocale: "en",
82
- prefixedLocales: ["de"],
83
- prefixFor: () => "/de",
84
- routes: {
85
- home: { en: "/", de: "/de" },
86
- },
87
- });
88
-
89
- test("detectLang with non-default prefix locale", () => {
90
- expect(router.detectLang("/")).toBe("en");
91
- expect(router.detectLang("/de")).toBe("de");
92
- expect(router.altLocalePath("/")).toBe("/de");
93
- expect(router.altLocalePath("/de")).toBe("/");
94
- });
95
- });
96
-
97
- type OfflotPage = "home" | "features";
98
-
99
- const offlotRouter = createLocaleRouter<OfflotPage>({
100
- defaultLocale: "es",
101
- prefixedLocales: ["en", "de"],
102
- routes: {
103
- home: { es: "/", en: "/en", de: "/de" },
104
- features: { es: "/funciones", en: "/en/features", de: "/de/funktionen" },
105
- },
106
- });
107
-
108
- describe("createLocaleRouter with a third locale", () => {
109
- test("altLocalePath with explicit targetLocale reaches any locale", () => {
110
- expect(offlotRouter.altLocalePath("/", "de")).toBe("/de");
111
- expect(offlotRouter.altLocalePath("/", "en")).toBe("/en");
112
- expect(offlotRouter.altLocalePath("/en/features", "de")).toBe("/de/funktionen");
113
- expect(offlotRouter.altLocalePath("/de/funktionen", "es")).toBe("/funciones");
114
- });
115
-
116
- test("altLocalePath with explicit targetLocale equal to current locale is identity", () => {
117
- expect(offlotRouter.altLocalePath("/en/features", "en")).toBe("/en/features");
118
- expect(offlotRouter.altLocalePath("/funciones", "es")).toBe("/funciones");
119
- });
120
-
121
- test("altLocalePath falls back to homePage for unknown path with explicit target", () => {
122
- expect(offlotRouter.altLocalePath("/unknown", "de")).toBe("/de");
123
- });
124
-
125
- test("altLocalePath rejects a targetLocale that only resolves via the prototype chain", () => {
126
- expect(() => offlotRouter.altLocalePath("/", "__proto__")).toThrow(/no path for page/);
127
- expect(() => offlotRouter.altLocalePath("/", "constructor")).toThrow(/no path for page/);
128
- expect(() => offlotRouter.altLocalePath("/", "toString")).toThrow(/no path for page/);
129
- });
130
-
131
- test("altLocalePath without targetLocale keeps binary toggle to prefixedLocales[0]", () => {
132
- expect(offlotRouter.altLocalePath("/")).toBe("/en");
133
- expect(offlotRouter.altLocalePath("/en")).toBe("/");
134
- expect(offlotRouter.altLocalePath("/de")).toBe("/");
135
- });
136
- });
137
-
138
- describe("createLocaleRouter homePage validation", () => {
139
- test("throws at construction when homePage has no routes entry", () => {
140
- expect(() =>
141
- createLocaleRouter({
142
- defaultLocale: "de",
143
- prefixedLocales: ["en"],
144
- routes: { features: { de: "/funktionen", en: "/en/features" } },
145
- }),
146
- ).toThrow(/homePage/);
147
- });
148
-
149
- test("publicPath throws instead of crashing when page is missing from routes", () => {
150
- const router = createLocaleRouter<"home" | "features">({
151
- defaultLocale: "de",
152
- prefixedLocales: ["en"],
153
- routes: { home: { de: "/", en: "/en" } } as unknown as Record<
154
- "home" | "features",
155
- Record<string, string>
156
- >,
157
- });
158
- expect(() => router.publicPath("features", "de")).toThrow(/no path for page/);
159
- });
160
- });
161
-
162
- function staticBase(locale: string): LocaleResolver {
163
- let current = locale;
164
- const listeners = new Set<() => void>();
165
- return {
166
- translate: (key) => key,
167
- locale: () => current,
168
- timeZone: () => "UTC",
169
- subscribe: (listener) => {
170
- listeners.add(listener);
171
- return () => {
172
- listeners.delete(listener);
173
- };
174
- },
175
- setLocale: (next) => {
176
- current = next;
177
- for (const listener of listeners) listener();
178
- },
179
- };
180
- }
181
-
182
- describe("wrapUrlLocaleResolver", () => {
183
- test("registered path uses detectLang, not base locale", () => {
184
- const wrapped = wrapUrlLocaleResolver(staticBase("de"), {
185
- resolvePage: (pathname) => moneyHorseRouter.resolvePage(pathname),
186
- detectLang: (pathname) => moneyHorseRouter.detectLang(pathname),
187
- pathname: () => "/en/features",
188
- });
189
- expect(wrapped.locale()).toBe("en");
190
- });
191
-
192
- test("home path uses URL locale even when base differs", () => {
193
- const wrapped = wrapUrlLocaleResolver(staticBase("en"), {
194
- resolvePage: (pathname) => moneyHorseRouter.resolvePage(pathname),
195
- detectLang: (pathname) => moneyHorseRouter.detectLang(pathname),
196
- pathname: () => "/",
197
- });
198
- expect(wrapped.locale()).toBe("de");
199
- });
200
-
201
- test("missing pathname falls back to base locale (no fake /)", () => {
202
- const wrapped = wrapUrlLocaleResolver(staticBase("en"), {
203
- resolvePage: (pathname) => moneyHorseRouter.resolvePage(pathname),
204
- detectLang: (pathname) => moneyHorseRouter.detectLang(pathname),
205
- pathname: () => undefined,
206
- });
207
- expect(wrapped.locale()).toBe("en");
208
- });
209
-
210
- test("subscribe fires on pathname subscription", () => {
211
- let pathListener: (() => void) | undefined;
212
- const wrapped = wrapUrlLocaleResolver(staticBase("de"), {
213
- resolvePage: () => "home",
214
- detectLang: () => "en",
215
- pathname: () => "/en",
216
- subscribePathname: (listener) => {
217
- pathListener = listener;
218
- return () => {
219
- pathListener = undefined;
220
- };
221
- },
222
- });
223
- let hits = 0;
224
- const unsub = wrapped.subscribe(() => {
225
- hits += 1;
226
- });
227
- pathListener?.();
228
- expect(hits).toBe(1);
229
- unsub();
230
- });
231
-
232
- test("unregistered path keeps base locale", () => {
233
- const wrapped = wrapUrlLocaleResolver(staticBase("de"), {
234
- resolvePage: (pathname) => moneyHorseRouter.resolvePage(pathname),
235
- detectLang: (pathname) => moneyHorseRouter.detectLang(pathname),
236
- pathname: () => "/app/credits",
237
- });
238
- expect(wrapped.locale()).toBe("de");
239
- });
240
-
241
- test("setLocale passes through to base", () => {
242
- const base = staticBase("de");
243
- const wrapped = wrapUrlLocaleResolver(base, {
244
- resolvePage: () => undefined,
245
- detectLang: () => "en",
246
- pathname: () => "/app",
247
- });
248
- wrapped.setLocale?.("en");
249
- expect(base.locale()).toBe("en");
250
- expect(wrapped.locale()).toBe("en");
251
- });
252
-
253
- test("URL locale wins over sticky setLocale on a registered path", () => {
254
- const base = staticBase("de");
255
- const wrapped = wrapUrlLocaleResolver(base, {
256
- resolvePage: (pathname) => moneyHorseRouter.resolvePage(pathname),
257
- detectLang: (pathname) => moneyHorseRouter.detectLang(pathname),
258
- pathname: () => "/en/features",
259
- });
260
- wrapped.setLocale?.("de");
261
- expect(base.locale()).toBe("de");
262
- expect(wrapped.locale()).toBe("en");
263
- });
264
-
265
- test("translateFor resolves copy against the URL locale", () => {
266
- const base = staticBase("de");
267
- const wrapped = wrapUrlLocaleResolver(base, {
268
- resolvePage: (pathname) => moneyHorseRouter.resolvePage(pathname),
269
- detectLang: (pathname) => moneyHorseRouter.detectLang(pathname),
270
- pathname: () => "/en/features",
271
- translateFor: (locale, key) => `${locale}:${key}`,
272
- });
273
- expect(wrapped.translate("hello")).toBe("en:hello");
274
- expect(wrapped.locale()).toBe("en");
275
- });
276
- });
@@ -1,218 +0,0 @@
1
- import type { LocaleResolver } from "../contracts";
2
-
3
- export type LocaleRouterConfig<TPage extends string> = {
4
- /** Canonical default, usually "de". No URL prefix. */
5
- readonly defaultLocale: string;
6
- /** Locales that get a URL prefix, e.g. ["en"] → /en/... */
7
- readonly prefixedLocales: readonly string[];
8
- /** Prefix segment per locale, default: locale code ("en" → "/en"). */
9
- readonly prefixFor?: (locale: string) => string;
10
- /** Logical page → path per locale. Every page must define defaultLocale path. */
11
- readonly routes: Record<TPage, Record<string, string>>;
12
- /** Legacy slug-only paths for detectLang/resolvePage, e.g. { en: ["/features"] }. */
13
- readonly localeHints?: Readonly<Record<string, readonly string[]>>;
14
- /** Fallback page when altLocalePath cannot resolve pathname. Default: "home". */
15
- readonly homePage?: TPage;
16
- };
17
-
18
- export type LocaleRouter<TPage extends string> = {
19
- detectLang(pathname: string): string;
20
- publicPath(page: TPage, locale: string): string;
21
- resolvePage(pathname: string): TPage | undefined;
22
- altLocalePath(pathname: string, targetLocale?: string): string;
23
- sectionAnchor(page: TPage, locale: string, fragment: string): string;
24
- };
25
-
26
- function normalizePath(pathname: string): string {
27
- if (pathname === "" || pathname === "/") return "/";
28
- return pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
29
- }
30
-
31
- function defaultPrefixFor(locale: string): string {
32
- return `/${locale}`;
33
- }
34
-
35
- function stripLocalePrefix(
36
- path: string,
37
- locale: string,
38
- prefixFor: (locale: string) => string,
39
- ): string {
40
- const prefix = prefixFor(locale);
41
- if (path === prefix) return "/";
42
- if (path.startsWith(`${prefix}/`)) return path.slice(prefix.length);
43
- return path;
44
- }
45
-
46
- export function createLocaleRouter<TPage extends string>(
47
- config: LocaleRouterConfig<TPage>,
48
- ): LocaleRouter<TPage> {
49
- const {
50
- defaultLocale,
51
- prefixedLocales,
52
- routes,
53
- localeHints = {},
54
- prefixFor = defaultPrefixFor,
55
- homePage = "home" as TPage,
56
- } = config;
57
-
58
- if (routes[homePage] === undefined) {
59
- throw new Error(
60
- `locale-routing: homePage "${String(homePage)}" has no entry in routes — altLocalePath's fallback would throw at request time`,
61
- );
62
- }
63
- const pathIndex = new Map<string, { page: TPage; locale: string }>();
64
-
65
- for (const [page, localePaths] of Object.entries(routes) as [TPage, Record<string, string>][]) {
66
- for (const [locale, routePath] of Object.entries(localePaths)) {
67
- pathIndex.set(normalizePath(routePath), { page, locale });
68
- }
69
- }
70
-
71
- for (const [hintLocale, hints] of Object.entries(localeHints)) {
72
- for (const hint of hints) {
73
- const normalizedHint = normalizePath(hint);
74
- if (pathIndex.has(normalizedHint)) continue;
75
-
76
- for (const [page, localePaths] of Object.entries(routes) as [
77
- TPage,
78
- Record<string, string>,
79
- ][]) {
80
- const canonical = localePaths[hintLocale];
81
- if (canonical === undefined) continue;
82
- const canonicalNorm = normalizePath(canonical);
83
- const hintSlug = stripLocalePrefix(normalizedHint, hintLocale, prefixFor);
84
- const canonicalSlug = stripLocalePrefix(canonicalNorm, hintLocale, prefixFor);
85
- if (hintSlug === canonicalSlug) {
86
- pathIndex.set(normalizedHint, { page, locale: hintLocale });
87
- break;
88
- }
89
- }
90
- }
91
- }
92
-
93
- function detectLang(pathname: string): string {
94
- const path = normalizePath(pathname);
95
- for (const locale of prefixedLocales) {
96
- const prefix = prefixFor(locale);
97
- if (path === prefix || path.startsWith(`${prefix}/`)) return locale;
98
- }
99
- const resolved = pathIndex.get(path);
100
- if (resolved !== undefined) return resolved.locale;
101
- for (const [locale, hints] of Object.entries(localeHints)) {
102
- if (hints.some((hint) => normalizePath(hint) === path)) return locale;
103
- }
104
- return defaultLocale;
105
- }
106
-
107
- function resolvePage(pathname: string): TPage | undefined {
108
- return pathIndex.get(normalizePath(pathname))?.page;
109
- }
110
-
111
- function publicPath(page: TPage, locale: string): string {
112
- const localePaths = routes[page];
113
- const routePath = localePaths?.[locale];
114
- if (
115
- localePaths === undefined ||
116
- !Object.hasOwn(localePaths, locale) ||
117
- routePath === undefined
118
- ) {
119
- throw new Error(`locale-routing: no path for page "${String(page)}" locale "${locale}"`);
120
- }
121
- return routePath;
122
- }
123
-
124
- function otherLocale(currentLocale: string): string {
125
- if (currentLocale === defaultLocale) {
126
- return prefixedLocales[0] ?? defaultLocale;
127
- }
128
- return defaultLocale;
129
- }
130
-
131
- function altLocalePath(pathname: string, targetLocale?: string): string {
132
- const path = normalizePath(pathname);
133
- const resolved = pathIndex.get(path);
134
- const locale = targetLocale ?? otherLocale(detectLang(pathname));
135
- if (resolved !== undefined) {
136
- return publicPath(resolved.page, locale);
137
- }
138
- return publicPath(homePage, locale);
139
- }
140
-
141
- function sectionAnchor(page: TPage, locale: string, fragment: string): string {
142
- return `${publicPath(page, locale)}#${fragment}`;
143
- }
144
-
145
- return { detectLang, publicPath, resolvePage, altLocalePath, sectionAnchor };
146
- }
147
-
148
- export type WrapUrlLocaleResolverOptions = {
149
- resolvePage: (pathname: string) => string | undefined;
150
- detectLang: (pathname: string) => string;
151
- pathname?: () => string | undefined;
152
- /** Optional pathname-change subscription (browser default: popstate). */
153
- subscribePathname?: (listener: () => void) => () => void;
154
- /** When set, translate against the URL-resolved locale instead of base.translate
155
- * (needed for fixed-T / cloneInstance resolvers that are not locale-live). */
156
- translateFor?: (
157
- locale: string,
158
- key: string,
159
- params?: Readonly<Record<string, unknown>>,
160
- ) => string;
161
- };
162
-
163
- function defaultBrowserPathname(): string | undefined {
164
- const loc = (globalThis as { location?: { pathname?: string } }).location;
165
- return typeof loc?.pathname === "string" ? loc.pathname : undefined;
166
- }
167
-
168
- function defaultSubscribePathname(listener: () => void): () => void {
169
- const g = globalThis as {
170
- addEventListener?: typeof addEventListener;
171
- removeEventListener?: typeof removeEventListener;
172
- };
173
- if (typeof g.addEventListener !== "function" || typeof g.removeEventListener !== "function") {
174
- return () => {};
175
- }
176
- g.addEventListener("popstate", listener);
177
- return () => {
178
- g.removeEventListener?.("popstate", listener);
179
- };
180
- }
181
-
182
- /**
183
- * Prefer URL-derived locale when `resolvePage` matches the current path;
184
- * otherwise defer to `base.locale()`. Passes through setLocale and the rest of LocaleResolver.
185
- * Without `translateFor`, `base.translate` must already be locale-live.
186
- */
187
- export function wrapUrlLocaleResolver(
188
- base: LocaleResolver,
189
- options: WrapUrlLocaleResolverOptions,
190
- ): LocaleResolver {
191
- const getPathname = options.pathname ?? defaultBrowserPathname;
192
- const subscribePathname = options.subscribePathname ?? defaultSubscribePathname;
193
- const resolveLocale = (): string => {
194
- const path = getPathname();
195
- if (path === undefined) return base.locale();
196
- if (options.resolvePage(path) != null) {
197
- return options.detectLang(path);
198
- }
199
- return base.locale();
200
- };
201
- return {
202
- translate: (key, params) =>
203
- options.translateFor
204
- ? options.translateFor(resolveLocale(), key, params)
205
- : base.translate(key, params),
206
- timeZone: () => base.timeZone(),
207
- subscribe: (listener) => {
208
- const unsubBase = base.subscribe(listener);
209
- const unsubPath = subscribePathname(listener);
210
- return () => {
211
- unsubBase();
212
- unsubPath();
213
- };
214
- },
215
- setLocale: base.setLocale !== undefined ? (locale) => base.setLocale?.(locale) : undefined,
216
- locale: resolveLocale,
217
- };
218
- }