@cosmicdrift/kumiko-headless 0.127.0 → 0.128.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-headless",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.128.0",
|
|
4
4
|
"description": "Headless UI logic for Kumiko — Dispatcher contract, Form-Controller, View-Model, Nav-Resolver. Plattform- und React-frei; jeder Renderer (renderer, renderer-web, renderer-native, …) komponiert darauf.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -29,10 +29,14 @@
|
|
|
29
29
|
"./apex": {
|
|
30
30
|
"types": "./src/apex/index.ts",
|
|
31
31
|
"default": "./src/apex/index.ts"
|
|
32
|
+
},
|
|
33
|
+
"./locale-routing": {
|
|
34
|
+
"types": "./src/locale-routing/index.ts",
|
|
35
|
+
"default": "./src/locale-routing/index.ts"
|
|
32
36
|
}
|
|
33
37
|
},
|
|
34
38
|
"dependencies": {
|
|
35
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
39
|
+
"@cosmicdrift/kumiko-framework": "0.128.0",
|
|
36
40
|
"zod": "^4.4.3"
|
|
37
41
|
},
|
|
38
42
|
"publishConfig": {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createLocaleRouter } from "../index";
|
|
3
|
+
|
|
4
|
+
type MoneyHorsePage = "home" | "features" | "rechner" | "budget" | "ltv";
|
|
5
|
+
|
|
6
|
+
const moneyHorseRouter = createLocaleRouter<MoneyHorsePage>({
|
|
7
|
+
defaultLocale: "de",
|
|
8
|
+
prefixedLocales: ["en"],
|
|
9
|
+
routes: {
|
|
10
|
+
home: { de: "/", en: "/en" },
|
|
11
|
+
features: { de: "/funktionen", en: "/en/features" },
|
|
12
|
+
rechner: { de: "/rechner", en: "/en/rechner" },
|
|
13
|
+
budget: { de: "/budget-rechner", en: "/en/budget-rechner" },
|
|
14
|
+
ltv: { de: "/beleihungsauslauf", en: "/en/beleihungsauslauf" },
|
|
15
|
+
},
|
|
16
|
+
localeHints: { en: ["/features"] },
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("createLocaleRouter money-horse config", () => {
|
|
20
|
+
test("detectLang: prefixed, default, legacy hint", () => {
|
|
21
|
+
expect(moneyHorseRouter.detectLang("/")).toBe("de");
|
|
22
|
+
expect(moneyHorseRouter.detectLang("/en")).toBe("en");
|
|
23
|
+
expect(moneyHorseRouter.detectLang("/en/rechner")).toBe("en");
|
|
24
|
+
expect(moneyHorseRouter.detectLang("/rechner")).toBe("de");
|
|
25
|
+
expect(moneyHorseRouter.detectLang("/features")).toBe("en");
|
|
26
|
+
expect(moneyHorseRouter.detectLang("/features/")).toBe("en");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("publicPath returns canonical paths per locale", () => {
|
|
30
|
+
expect(moneyHorseRouter.publicPath("features", "de")).toBe("/funktionen");
|
|
31
|
+
expect(moneyHorseRouter.publicPath("features", "en")).toBe("/en/features");
|
|
32
|
+
expect(moneyHorseRouter.publicPath("rechner", "en")).toBe("/en/rechner");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("resolvePage maps canonical and legacy paths", () => {
|
|
36
|
+
expect(moneyHorseRouter.resolvePage("/funktionen")).toBe("features");
|
|
37
|
+
expect(moneyHorseRouter.resolvePage("/en/features")).toBe("features");
|
|
38
|
+
expect(moneyHorseRouter.resolvePage("/features")).toBe("features");
|
|
39
|
+
expect(moneyHorseRouter.resolvePage("/en/rechner")).toBe("rechner");
|
|
40
|
+
expect(moneyHorseRouter.resolvePage("/login")).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("altLocalePath keeps logical page across locales", () => {
|
|
44
|
+
expect(moneyHorseRouter.altLocalePath("/en")).toBe("/");
|
|
45
|
+
expect(moneyHorseRouter.altLocalePath("/")).toBe("/en");
|
|
46
|
+
expect(moneyHorseRouter.altLocalePath("/en/features")).toBe("/funktionen");
|
|
47
|
+
expect(moneyHorseRouter.altLocalePath("/funktionen")).toBe("/en/features");
|
|
48
|
+
expect(moneyHorseRouter.altLocalePath("/features")).toBe("/funktionen");
|
|
49
|
+
expect(moneyHorseRouter.altLocalePath("/en/rechner")).toBe("/rechner");
|
|
50
|
+
expect(moneyHorseRouter.altLocalePath("/rechner")).toBe("/en/rechner");
|
|
51
|
+
expect(moneyHorseRouter.altLocalePath("/unknown")).toBe("/en");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("sectionAnchor attaches fragment to page path", () => {
|
|
55
|
+
expect(moneyHorseRouter.sectionAnchor("home", "de", "pricing")).toBe("/#pricing");
|
|
56
|
+
expect(moneyHorseRouter.sectionAnchor("home", "en", "pricing")).toBe("/en#pricing");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
type PublicStatusPage = "home" | "developers";
|
|
61
|
+
|
|
62
|
+
const publicStatusRouter = createLocaleRouter<PublicStatusPage>({
|
|
63
|
+
defaultLocale: "de",
|
|
64
|
+
prefixedLocales: ["en"],
|
|
65
|
+
routes: {
|
|
66
|
+
home: { de: "/", en: "/en" },
|
|
67
|
+
developers: { de: "/developers", en: "/en/developers" },
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("createLocaleRouter publicstatus config", () => {
|
|
72
|
+
test("altLocalePath for developers", () => {
|
|
73
|
+
expect(publicStatusRouter.altLocalePath("/en/developers")).toBe("/developers");
|
|
74
|
+
expect(publicStatusRouter.altLocalePath("/developers")).toBe("/en/developers");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("createLocaleRouter inverted default (website-style)", () => {
|
|
79
|
+
const router = createLocaleRouter({
|
|
80
|
+
defaultLocale: "en",
|
|
81
|
+
prefixedLocales: ["de"],
|
|
82
|
+
prefixFor: () => "/de",
|
|
83
|
+
routes: {
|
|
84
|
+
home: { en: "/", de: "/de" },
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("detectLang with non-default prefix locale", () => {
|
|
89
|
+
expect(router.detectLang("/")).toBe("en");
|
|
90
|
+
expect(router.detectLang("/de")).toBe("de");
|
|
91
|
+
expect(router.altLocalePath("/")).toBe("/de");
|
|
92
|
+
expect(router.altLocalePath("/de")).toBe("/");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
export type LocaleRouterConfig<TPage extends string> = {
|
|
2
|
+
/** Canonical default, usually "de". No URL prefix. */
|
|
3
|
+
readonly defaultLocale: string;
|
|
4
|
+
/** Locales that get a URL prefix, e.g. ["en"] → /en/... */
|
|
5
|
+
readonly prefixedLocales: readonly string[];
|
|
6
|
+
/** Prefix segment per locale, default: locale code ("en" → "/en"). */
|
|
7
|
+
readonly prefixFor?: (locale: string) => string;
|
|
8
|
+
/** Logical page → path per locale. Every page must define defaultLocale path. */
|
|
9
|
+
readonly routes: Record<TPage, Record<string, string>>;
|
|
10
|
+
/** Legacy slug-only paths for detectLang/resolvePage, e.g. { en: ["/features"] }. */
|
|
11
|
+
readonly localeHints?: Readonly<Record<string, readonly string[]>>;
|
|
12
|
+
/** Fallback page when altLocalePath cannot resolve pathname. Default: "home". */
|
|
13
|
+
readonly homePage?: TPage;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type LocaleRouter<TPage extends string> = {
|
|
17
|
+
detectLang(pathname: string): string;
|
|
18
|
+
publicPath(page: TPage, locale: string): string;
|
|
19
|
+
resolvePage(pathname: string): TPage | undefined;
|
|
20
|
+
altLocalePath(pathname: string): string;
|
|
21
|
+
sectionAnchor(page: TPage, locale: string, fragment: string): string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function normalizePath(pathname: string): string {
|
|
25
|
+
if (pathname === "" || pathname === "/") return "/";
|
|
26
|
+
return pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function defaultPrefixFor(locale: string): string {
|
|
30
|
+
return `/${locale}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function stripLocalePrefix(
|
|
34
|
+
path: string,
|
|
35
|
+
locale: string,
|
|
36
|
+
prefixFor: (locale: string) => string,
|
|
37
|
+
): string {
|
|
38
|
+
const prefix = prefixFor(locale);
|
|
39
|
+
if (path === prefix) return "/";
|
|
40
|
+
if (path.startsWith(`${prefix}/`)) return path.slice(prefix.length);
|
|
41
|
+
return path;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createLocaleRouter<TPage extends string>(
|
|
45
|
+
config: LocaleRouterConfig<TPage>,
|
|
46
|
+
): LocaleRouter<TPage> {
|
|
47
|
+
const {
|
|
48
|
+
defaultLocale,
|
|
49
|
+
prefixedLocales,
|
|
50
|
+
routes,
|
|
51
|
+
localeHints = {},
|
|
52
|
+
prefixFor = defaultPrefixFor,
|
|
53
|
+
homePage = "home" as TPage,
|
|
54
|
+
} = config;
|
|
55
|
+
|
|
56
|
+
const pathIndex = new Map<string, { page: TPage; locale: string }>();
|
|
57
|
+
|
|
58
|
+
for (const [page, localePaths] of Object.entries(routes) as [TPage, Record<string, string>][]) {
|
|
59
|
+
for (const [locale, routePath] of Object.entries(localePaths)) {
|
|
60
|
+
pathIndex.set(normalizePath(routePath), { page, locale });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for (const [hintLocale, hints] of Object.entries(localeHints)) {
|
|
65
|
+
for (const hint of hints) {
|
|
66
|
+
const normalizedHint = normalizePath(hint);
|
|
67
|
+
if (pathIndex.has(normalizedHint)) continue;
|
|
68
|
+
|
|
69
|
+
for (const [page, localePaths] of Object.entries(routes) as [
|
|
70
|
+
TPage,
|
|
71
|
+
Record<string, string>,
|
|
72
|
+
][]) {
|
|
73
|
+
const canonical = localePaths[hintLocale];
|
|
74
|
+
if (canonical === undefined) continue;
|
|
75
|
+
const canonicalNorm = normalizePath(canonical);
|
|
76
|
+
const hintSlug = stripLocalePrefix(normalizedHint, hintLocale, prefixFor);
|
|
77
|
+
const canonicalSlug = stripLocalePrefix(canonicalNorm, hintLocale, prefixFor);
|
|
78
|
+
if (hintSlug === canonicalSlug) {
|
|
79
|
+
pathIndex.set(normalizedHint, { page, locale: hintLocale });
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function detectLang(pathname: string): string {
|
|
87
|
+
const path = normalizePath(pathname);
|
|
88
|
+
for (const locale of prefixedLocales) {
|
|
89
|
+
const prefix = prefixFor(locale);
|
|
90
|
+
if (path === prefix || path.startsWith(`${prefix}/`)) return locale;
|
|
91
|
+
}
|
|
92
|
+
const resolved = pathIndex.get(path);
|
|
93
|
+
if (resolved !== undefined) return resolved.locale;
|
|
94
|
+
for (const [locale, hints] of Object.entries(localeHints)) {
|
|
95
|
+
if (hints.some((hint) => normalizePath(hint) === path)) return locale;
|
|
96
|
+
}
|
|
97
|
+
return defaultLocale;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function resolvePage(pathname: string): TPage | undefined {
|
|
101
|
+
return pathIndex.get(normalizePath(pathname))?.page;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function publicPath(page: TPage, locale: string): string {
|
|
105
|
+
const routePath = routes[page][locale];
|
|
106
|
+
if (routePath === undefined) {
|
|
107
|
+
throw new Error(`locale-routing: no path for page "${String(page)}" locale "${locale}"`);
|
|
108
|
+
}
|
|
109
|
+
return routePath;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function otherLocale(currentLocale: string): string {
|
|
113
|
+
if (currentLocale === defaultLocale) {
|
|
114
|
+
return prefixedLocales[0] ?? defaultLocale;
|
|
115
|
+
}
|
|
116
|
+
return defaultLocale;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function altLocalePath(pathname: string): string {
|
|
120
|
+
const path = normalizePath(pathname);
|
|
121
|
+
const resolved = pathIndex.get(path);
|
|
122
|
+
const targetLocale = otherLocale(detectLang(pathname));
|
|
123
|
+
if (resolved !== undefined) {
|
|
124
|
+
return publicPath(resolved.page, targetLocale);
|
|
125
|
+
}
|
|
126
|
+
return publicPath(homePage, targetLocale);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function sectionAnchor(page: TPage, locale: string, fragment: string): string {
|
|
130
|
+
return `${publicPath(page, locale)}#${fragment}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { detectLang, publicPath, resolvePage, altLocalePath, sectionAnchor };
|
|
134
|
+
}
|