alabjs 0.1.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/adapters/cloudflare.d.ts +31 -0
- package/dist/adapters/cloudflare.d.ts.map +1 -0
- package/dist/adapters/cloudflare.js +30 -0
- package/dist/adapters/cloudflare.js.map +1 -0
- package/dist/adapters/deno.d.ts +22 -0
- package/dist/adapters/deno.d.ts.map +1 -0
- package/dist/adapters/deno.js +21 -0
- package/dist/adapters/deno.js.map +1 -0
- package/dist/adapters/web.d.ts +47 -0
- package/dist/adapters/web.d.ts.map +1 -0
- package/dist/adapters/web.js +212 -0
- package/dist/adapters/web.js.map +1 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +61 -0
- package/dist/cli.js.map +1 -0
- package/dist/client/hooks.d.ts +119 -0
- package/dist/client/hooks.d.ts.map +1 -0
- package/dist/client/hooks.js +220 -0
- package/dist/client/hooks.js.map +1 -0
- package/dist/client/hooks.test.d.ts +2 -0
- package/dist/client/hooks.test.d.ts.map +1 -0
- package/dist/client/hooks.test.js +45 -0
- package/dist/client/hooks.test.js.map +1 -0
- package/dist/client/index.d.ts +6 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +4 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/offline.d.ts +52 -0
- package/dist/client/offline.d.ts.map +1 -0
- package/dist/client/offline.js +90 -0
- package/dist/client/offline.js.map +1 -0
- package/dist/client/provider.d.ts +12 -0
- package/dist/client/provider.d.ts.map +1 -0
- package/dist/client/provider.js +10 -0
- package/dist/client/provider.js.map +1 -0
- package/dist/commands/build.d.ts +18 -0
- package/dist/commands/build.d.ts.map +1 -0
- package/dist/commands/build.js +173 -0
- package/dist/commands/build.js.map +1 -0
- package/dist/commands/dev.d.ts +8 -0
- package/dist/commands/dev.d.ts.map +1 -0
- package/dist/commands/dev.js +447 -0
- package/dist/commands/dev.js.map +1 -0
- package/dist/commands/info.d.ts +6 -0
- package/dist/commands/info.d.ts.map +1 -0
- package/dist/commands/info.js +92 -0
- package/dist/commands/info.js.map +1 -0
- package/dist/commands/ssg.d.ts +8 -0
- package/dist/commands/ssg.d.ts.map +1 -0
- package/dist/commands/ssg.js +124 -0
- package/dist/commands/ssg.js.map +1 -0
- package/dist/commands/start.d.ts +7 -0
- package/dist/commands/start.d.ts.map +1 -0
- package/dist/commands/start.js +26 -0
- package/dist/commands/start.js.map +1 -0
- package/dist/commands/test.d.ts +24 -0
- package/dist/commands/test.d.ts.map +1 -0
- package/dist/commands/test.js +87 -0
- package/dist/commands/test.js.map +1 -0
- package/dist/components/ErrorBoundary.d.ts +38 -0
- package/dist/components/ErrorBoundary.d.ts.map +1 -0
- package/dist/components/ErrorBoundary.js +46 -0
- package/dist/components/ErrorBoundary.js.map +1 -0
- package/dist/components/Font.d.ts +57 -0
- package/dist/components/Font.d.ts.map +1 -0
- package/dist/components/Font.js +33 -0
- package/dist/components/Font.js.map +1 -0
- package/dist/components/Image.d.ts +74 -0
- package/dist/components/Image.d.ts.map +1 -0
- package/dist/components/Image.js +85 -0
- package/dist/components/Image.js.map +1 -0
- package/dist/components/Link.d.ts +23 -0
- package/dist/components/Link.d.ts.map +1 -0
- package/dist/components/Link.js +48 -0
- package/dist/components/Link.js.map +1 -0
- package/dist/components/Script.d.ts +37 -0
- package/dist/components/Script.d.ts.map +1 -0
- package/dist/components/Script.js +70 -0
- package/dist/components/Script.js.map +1 -0
- package/dist/components/index.d.ts +10 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +6 -0
- package/dist/components/index.js.map +1 -0
- package/dist/i18n/i18n.test.d.ts +2 -0
- package/dist/i18n/i18n.test.d.ts.map +1 -0
- package/dist/i18n/i18n.test.js +132 -0
- package/dist/i18n/i18n.test.js.map +1 -0
- package/dist/i18n/index.d.ts +135 -0
- package/dist/i18n/index.d.ts.map +1 -0
- package/dist/i18n/index.js +189 -0
- package/dist/i18n/index.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/router/code-router.d.ts +204 -0
- package/dist/router/code-router.d.ts.map +1 -0
- package/dist/router/code-router.js +258 -0
- package/dist/router/code-router.js.map +1 -0
- package/dist/router/code-router.test.d.ts +2 -0
- package/dist/router/code-router.test.d.ts.map +1 -0
- package/dist/router/code-router.test.js +128 -0
- package/dist/router/code-router.test.js.map +1 -0
- package/dist/router/index.d.ts +4 -0
- package/dist/router/index.d.ts.map +1 -0
- package/dist/router/index.js +2 -0
- package/dist/router/index.js.map +1 -0
- package/dist/router/manifest.d.ts +12 -0
- package/dist/router/manifest.d.ts.map +1 -0
- package/dist/router/manifest.js +2 -0
- package/dist/router/manifest.js.map +1 -0
- package/dist/server/app.d.ts +13 -0
- package/dist/server/app.d.ts.map +1 -0
- package/dist/server/app.js +407 -0
- package/dist/server/app.js.map +1 -0
- package/dist/server/cache.d.ts +99 -0
- package/dist/server/cache.d.ts.map +1 -0
- package/dist/server/cache.js +161 -0
- package/dist/server/cache.js.map +1 -0
- package/dist/server/cache.test.d.ts +2 -0
- package/dist/server/cache.test.d.ts.map +1 -0
- package/dist/server/cache.test.js +150 -0
- package/dist/server/cache.test.js.map +1 -0
- package/dist/server/csrf.d.ts +28 -0
- package/dist/server/csrf.d.ts.map +1 -0
- package/dist/server/csrf.js +66 -0
- package/dist/server/csrf.js.map +1 -0
- package/dist/server/csrf.test.d.ts +2 -0
- package/dist/server/csrf.test.d.ts.map +1 -0
- package/dist/server/csrf.test.js +154 -0
- package/dist/server/csrf.test.js.map +1 -0
- package/dist/server/image.d.ts +18 -0
- package/dist/server/image.d.ts.map +1 -0
- package/dist/server/image.js +97 -0
- package/dist/server/image.js.map +1 -0
- package/dist/server/index.d.ts +57 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +58 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/middleware.d.ts +53 -0
- package/dist/server/middleware.d.ts.map +1 -0
- package/dist/server/middleware.js +80 -0
- package/dist/server/middleware.js.map +1 -0
- package/dist/server/middleware.test.d.ts +2 -0
- package/dist/server/middleware.test.d.ts.map +1 -0
- package/dist/server/middleware.test.js +125 -0
- package/dist/server/middleware.test.js.map +1 -0
- package/dist/server/revalidate.d.ts +49 -0
- package/dist/server/revalidate.d.ts.map +1 -0
- package/dist/server/revalidate.js +62 -0
- package/dist/server/revalidate.js.map +1 -0
- package/dist/server/revalidate.test.d.ts +2 -0
- package/dist/server/revalidate.test.d.ts.map +1 -0
- package/dist/server/revalidate.test.js +93 -0
- package/dist/server/revalidate.test.js.map +1 -0
- package/dist/server/server-fn.test.d.ts +2 -0
- package/dist/server/server-fn.test.d.ts.map +1 -0
- package/dist/server/server-fn.test.js +105 -0
- package/dist/server/server-fn.test.js.map +1 -0
- package/dist/server/sitemap.d.ts +9 -0
- package/dist/server/sitemap.d.ts.map +1 -0
- package/dist/server/sitemap.js +26 -0
- package/dist/server/sitemap.js.map +1 -0
- package/dist/server/sitemap.test.d.ts +2 -0
- package/dist/server/sitemap.test.d.ts.map +1 -0
- package/dist/server/sitemap.test.js +61 -0
- package/dist/server/sitemap.test.js.map +1 -0
- package/dist/server/sse.d.ts +59 -0
- package/dist/server/sse.d.ts.map +1 -0
- package/dist/server/sse.js +91 -0
- package/dist/server/sse.js.map +1 -0
- package/dist/server/sse.test.d.ts +2 -0
- package/dist/server/sse.test.d.ts.map +1 -0
- package/dist/server/sse.test.js +68 -0
- package/dist/server/sse.test.js.map +1 -0
- package/dist/signals/index.d.ts +101 -0
- package/dist/signals/index.d.ts.map +1 -0
- package/dist/signals/index.js +149 -0
- package/dist/signals/index.js.map +1 -0
- package/dist/signals/signals.test.d.ts +2 -0
- package/dist/signals/signals.test.d.ts.map +1 -0
- package/dist/signals/signals.test.js +146 -0
- package/dist/signals/signals.test.js.map +1 -0
- package/dist/ssr/html.d.ts +27 -0
- package/dist/ssr/html.d.ts.map +1 -0
- package/dist/ssr/html.js +107 -0
- package/dist/ssr/html.js.map +1 -0
- package/dist/ssr/html.test.d.ts +2 -0
- package/dist/ssr/html.test.d.ts.map +1 -0
- package/dist/ssr/html.test.js +178 -0
- package/dist/ssr/html.test.js.map +1 -0
- package/dist/ssr/render.d.ts +46 -0
- package/dist/ssr/render.d.ts.map +1 -0
- package/dist/ssr/render.js +87 -0
- package/dist/ssr/render.js.map +1 -0
- package/dist/ssr/router-dev.d.ts +60 -0
- package/dist/ssr/router-dev.d.ts.map +1 -0
- package/dist/ssr/router-dev.js +205 -0
- package/dist/ssr/router-dev.js.map +1 -0
- package/dist/ssr/router-dev.test.d.ts +2 -0
- package/dist/ssr/router-dev.test.d.ts.map +1 -0
- package/dist/ssr/router-dev.test.js +189 -0
- package/dist/ssr/router-dev.test.js.map +1 -0
- package/dist/test/index.d.ts +93 -0
- package/dist/test/index.d.ts.map +1 -0
- package/dist/test/index.js +146 -0
- package/dist/test/index.js.map +1 -0
- package/dist/types/index.d.ts +117 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/napi.d.ts +15 -0
- package/dist/types/napi.d.ts.map +1 -0
- package/dist/types/napi.js +2 -0
- package/dist/types/napi.js.map +1 -0
- package/package.json +107 -0
- package/src/adapters/cloudflare.ts +30 -0
- package/src/adapters/deno.ts +21 -0
- package/src/adapters/web.ts +259 -0
- package/src/cli.ts +68 -0
- package/src/client/hooks.test.ts +54 -0
- package/src/client/hooks.ts +329 -0
- package/src/client/index.ts +5 -0
- package/src/client/offline-sw.ts +191 -0
- package/src/client/offline.ts +114 -0
- package/src/client/provider.tsx +14 -0
- package/src/commands/build.ts +201 -0
- package/src/commands/dev.ts +509 -0
- package/src/commands/info.ts +111 -0
- package/src/commands/ssg.ts +177 -0
- package/src/commands/start.ts +32 -0
- package/src/commands/test.ts +102 -0
- package/src/components/ErrorBoundary.tsx +73 -0
- package/src/components/Font.tsx +100 -0
- package/src/components/Image.tsx +141 -0
- package/src/components/Link.tsx +64 -0
- package/src/components/Script.tsx +97 -0
- package/src/components/index.ts +9 -0
- package/src/i18n/i18n.test.tsx +169 -0
- package/src/i18n/index.tsx +256 -0
- package/src/index.ts +10 -0
- package/src/router/code-router.test.ts +146 -0
- package/src/router/code-router.tsx +459 -0
- package/src/router/index.ts +18 -0
- package/src/router/manifest.ts +13 -0
- package/src/server/app.ts +466 -0
- package/src/server/cache.test.ts +192 -0
- package/src/server/cache.ts +195 -0
- package/src/server/csrf.test.ts +199 -0
- package/src/server/csrf.ts +80 -0
- package/src/server/image.ts +112 -0
- package/src/server/index.ts +144 -0
- package/src/server/middleware.test.ts +151 -0
- package/src/server/middleware.ts +95 -0
- package/src/server/revalidate.test.ts +106 -0
- package/src/server/revalidate.ts +75 -0
- package/src/server/server-fn.test.ts +127 -0
- package/src/server/sitemap.test.ts +68 -0
- package/src/server/sitemap.ts +30 -0
- package/src/server/sse.test.ts +81 -0
- package/src/server/sse.ts +110 -0
- package/src/signals/index.ts +177 -0
- package/src/signals/signals.test.ts +164 -0
- package/src/ssr/html.test.ts +200 -0
- package/src/ssr/html.ts +140 -0
- package/src/ssr/render.ts +144 -0
- package/src/ssr/router-dev.test.ts +230 -0
- package/src/ssr/router-dev.ts +229 -0
- package/src/test/index.ts +206 -0
- package/src/types/compiler.d.ts +25 -0
- package/src/types/index.ts +147 -0
- package/src/types/napi.ts +20 -0
- package/src/types/plugins.d.ts +3 -0
- package/tsconfig.json +11 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.config.ts +32 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { useEffect, type HTMLAttributes } from "react";
|
|
2
|
+
|
|
3
|
+
export interface ScriptProps extends Omit<HTMLAttributes<HTMLScriptElement>, "src"> {
|
|
4
|
+
/** URL of the external script to load. */
|
|
5
|
+
src: string;
|
|
6
|
+
/**
|
|
7
|
+
* Loading strategy:
|
|
8
|
+
* - `"beforeInteractive"` — injected into `<head>` during SSR; blocks page rendering.
|
|
9
|
+
* Use only for scripts that must run before the page is interactive (e.g., analytics init).
|
|
10
|
+
* - `"afterInteractive"` (default) — loaded after the page becomes interactive via a
|
|
11
|
+
* dynamically appended `<script>` tag. Best for tag managers, chat widgets, etc.
|
|
12
|
+
* - `"lazyOnload"` — deferred until the browser is idle (`requestIdleCallback`).
|
|
13
|
+
* Best for low-priority scripts like A/B testing, heatmaps, social embeds.
|
|
14
|
+
*/
|
|
15
|
+
strategy?: "beforeInteractive" | "afterInteractive" | "lazyOnload";
|
|
16
|
+
/** Called once the script has loaded successfully. */
|
|
17
|
+
onLoad?: () => void;
|
|
18
|
+
/** Called if the script fails to load. */
|
|
19
|
+
onError?: () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Load a third-party script with strategy control.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```tsx
|
|
27
|
+
* // Analytics — load after page is interactive
|
|
28
|
+
* <Script src="https://analytics.example.com/script.js" strategy="afterInteractive" />
|
|
29
|
+
*
|
|
30
|
+
* // Chat widget — load when browser is idle
|
|
31
|
+
* <Script
|
|
32
|
+
* src="https://cdn.example.com/chat.js"
|
|
33
|
+
* strategy="lazyOnload"
|
|
34
|
+
* onLoad={() => console.log("Chat ready")}
|
|
35
|
+
* />
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export function Script({
|
|
39
|
+
src,
|
|
40
|
+
strategy = "afterInteractive",
|
|
41
|
+
onLoad,
|
|
42
|
+
onError,
|
|
43
|
+
...rest
|
|
44
|
+
}: ScriptProps) {
|
|
45
|
+
// `beforeInteractive` is handled at SSR time by rendering a real <script> tag.
|
|
46
|
+
// The component returns null on the client to avoid duplicate injection.
|
|
47
|
+
if (strategy === "beforeInteractive") {
|
|
48
|
+
// On the server this renders into the HTML stream; on the client we skip it
|
|
49
|
+
// because the script is already in the <head> from SSR.
|
|
50
|
+
if (typeof window !== "undefined") return null;
|
|
51
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
52
|
+
return <script src={src} {...(rest as any)} />;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
// Skip during SSR (useEffect only runs in the browser).
|
|
58
|
+
const load = () => {
|
|
59
|
+
if (document.querySelector(`script[src="${CSS.escape(src)}"]`)) {
|
|
60
|
+
// Already loaded by a previous render — fire onLoad immediately.
|
|
61
|
+
onLoad?.();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const el = document.createElement("script");
|
|
66
|
+
el.src = src;
|
|
67
|
+
el.async = true;
|
|
68
|
+
if (onLoad) el.addEventListener("load", onLoad, { once: true });
|
|
69
|
+
if (onError) el.addEventListener("error", onError, { once: true });
|
|
70
|
+
|
|
71
|
+
// Copy through any extra data-* or other HTML attributes.
|
|
72
|
+
for (const [k, v] of Object.entries(rest)) {
|
|
73
|
+
if (typeof v === "string") el.setAttribute(k, v);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
document.head.appendChild(el);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
if (strategy === "lazyOnload") {
|
|
80
|
+
if ("requestIdleCallback" in window) {
|
|
81
|
+
const id = requestIdleCallback(load);
|
|
82
|
+
return () => cancelIdleCallback(id);
|
|
83
|
+
}
|
|
84
|
+
// Fallback for browsers without requestIdleCallback (Safari < 16.4).
|
|
85
|
+
const t = setTimeout(load, 200);
|
|
86
|
+
return () => clearTimeout(t);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// "afterInteractive" — load immediately in useEffect (after hydration).
|
|
90
|
+
load();
|
|
91
|
+
return undefined;
|
|
92
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
93
|
+
}, [src, strategy]);
|
|
94
|
+
|
|
95
|
+
// No DOM output — the <script> is appended imperatively.
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { Image, generateBlurPlaceholder } from "./Image.js";
|
|
2
|
+
export type { ImageProps } from "./Image.js";
|
|
3
|
+
export { Link } from "./Link.js";
|
|
4
|
+
export type { LinkProps } from "./Link.js";
|
|
5
|
+
export { ErrorBoundary } from "./ErrorBoundary.js";
|
|
6
|
+
export { Script } from "./Script.js";
|
|
7
|
+
export type { ScriptProps } from "./Script.js";
|
|
8
|
+
export { Font } from "./Font.js";
|
|
9
|
+
export type { FontProps } from "./Font.js";
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect } from "vitest";
|
|
5
|
+
import { createElement } from "react";
|
|
6
|
+
import { renderToString } from "react-dom/server";
|
|
7
|
+
import {
|
|
8
|
+
createI18nConfig,
|
|
9
|
+
LocaleProvider,
|
|
10
|
+
useLocale,
|
|
11
|
+
} from "./index.js";
|
|
12
|
+
|
|
13
|
+
// ─── createI18nConfig ─────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
describe("createI18nConfig", () => {
|
|
16
|
+
it("creates a config with the given locales and default", () => {
|
|
17
|
+
const i18n = createI18nConfig({ locales: ["en", "fil"], defaultLocale: "en" });
|
|
18
|
+
expect(i18n.locales).toEqual(["en", "fil"]);
|
|
19
|
+
expect(i18n.defaultLocale).toBe("en");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("throws if defaultLocale is not in locales", () => {
|
|
23
|
+
expect(() =>
|
|
24
|
+
createI18nConfig({ locales: ["en"], defaultLocale: "fil" }),
|
|
25
|
+
).toThrow('[alabjs/i18n] defaultLocale "fil" must be in the locales array');
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// ─── detectLocale ─────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
describe("detectLocale", () => {
|
|
32
|
+
const i18n = createI18nConfig({
|
|
33
|
+
locales: ["en", "fil", "es"],
|
|
34
|
+
defaultLocale: "en",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("detects locale from URL prefix", () => {
|
|
38
|
+
const req = new Request("http://localhost/fil/about");
|
|
39
|
+
expect(i18n.detectLocale(req)).toBe("fil");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("detects locale from cookie", () => {
|
|
43
|
+
const req = new Request("http://localhost/about", {
|
|
44
|
+
headers: { cookie: "locale=es" },
|
|
45
|
+
});
|
|
46
|
+
expect(i18n.detectLocale(req)).toBe("es");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("detects locale from Accept-Language header", () => {
|
|
50
|
+
const req = new Request("http://localhost/about", {
|
|
51
|
+
headers: { "accept-language": "fil;q=0.9,en;q=0.8" },
|
|
52
|
+
});
|
|
53
|
+
expect(i18n.detectLocale(req)).toBe("fil");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("falls back to defaultLocale when nothing matches", () => {
|
|
57
|
+
const req = new Request("http://localhost/about", {
|
|
58
|
+
headers: { "accept-language": "ja;q=0.9,zh;q=0.8" },
|
|
59
|
+
});
|
|
60
|
+
expect(i18n.detectLocale(req)).toBe("en");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("URL prefix takes precedence over cookie", () => {
|
|
64
|
+
const req = new Request("http://localhost/es/page", {
|
|
65
|
+
headers: { cookie: "locale=fil" },
|
|
66
|
+
});
|
|
67
|
+
expect(i18n.detectLocale(req)).toBe("es");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("cookie takes precedence over Accept-Language", () => {
|
|
71
|
+
const req = new Request("http://localhost/page", {
|
|
72
|
+
headers: {
|
|
73
|
+
cookie: "locale=fil",
|
|
74
|
+
"accept-language": "es;q=0.9",
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
expect(i18n.detectLocale(req)).toBe("fil");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("ignores unknown cookie locale", () => {
|
|
81
|
+
const req = new Request("http://localhost/page", {
|
|
82
|
+
headers: { cookie: "locale=ja" },
|
|
83
|
+
});
|
|
84
|
+
expect(i18n.detectLocale(req)).toBe("en");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("handles Accept-Language with language subtag matching", () => {
|
|
88
|
+
const req = new Request("http://localhost/page", {
|
|
89
|
+
headers: { "accept-language": "en-US,en;q=0.9" },
|
|
90
|
+
});
|
|
91
|
+
expect(i18n.detectLocale(req)).toBe("en");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ─── hasLocalePrefix ──────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
describe("hasLocalePrefix", () => {
|
|
98
|
+
const i18n = createI18nConfig({
|
|
99
|
+
locales: ["en", "fil"],
|
|
100
|
+
defaultLocale: "en",
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("returns true for paths with locale prefix", () => {
|
|
104
|
+
expect(i18n.hasLocalePrefix("/en/about")).toBe(true);
|
|
105
|
+
expect(i18n.hasLocalePrefix("/fil/page")).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("returns false for paths without locale prefix", () => {
|
|
109
|
+
expect(i18n.hasLocalePrefix("/about")).toBe(false);
|
|
110
|
+
expect(i18n.hasLocalePrefix("/")).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ─── stripLocale ──────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
describe("stripLocale", () => {
|
|
117
|
+
const i18n = createI18nConfig({
|
|
118
|
+
locales: ["en", "fil"],
|
|
119
|
+
defaultLocale: "en",
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("strips locale prefix", () => {
|
|
123
|
+
expect(i18n.stripLocale("/en/about")).toBe("/about");
|
|
124
|
+
expect(i18n.stripLocale("/fil/page")).toBe("/page");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("returns / for locale-only path", () => {
|
|
128
|
+
expect(i18n.stripLocale("/en")).toBe("/");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("returns original path when no locale prefix", () => {
|
|
132
|
+
expect(i18n.stripLocale("/about")).toBe("/about");
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ─── localePath ───────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
describe("localePath", () => {
|
|
139
|
+
const i18n = createI18nConfig({ locales: ["en", "fil"], defaultLocale: "en" });
|
|
140
|
+
|
|
141
|
+
it("prefixes path with locale", () => {
|
|
142
|
+
expect(i18n.localePath("fil", "/about")).toBe("/fil/about");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("handles path without leading slash", () => {
|
|
146
|
+
expect(i18n.localePath("en", "about")).toBe("/en/about");
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ─── LocaleProvider + useLocale ───────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
describe("LocaleProvider + useLocale", () => {
|
|
153
|
+
function LocaleDisplay() {
|
|
154
|
+
const locale = useLocale();
|
|
155
|
+
return createElement("span", null, locale);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
it("provides locale to children via context", () => {
|
|
159
|
+
const html = renderToString(
|
|
160
|
+
createElement(LocaleProvider, { locale: "fil", children: createElement(LocaleDisplay) })
|
|
161
|
+
);
|
|
162
|
+
expect(html).toContain("fil");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("defaults to en when no provider", () => {
|
|
166
|
+
const html = renderToString(createElement(LocaleDisplay));
|
|
167
|
+
expect(html).toContain("en");
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alab i18n — locale routing with zero runtime overhead.
|
|
3
|
+
*
|
|
4
|
+
* Locale is detected once per request (from URL prefix → cookie → Accept-Language)
|
|
5
|
+
* and injected into a React context. Pages are served at `/:locale/path`.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* // i18n.ts (project root)
|
|
10
|
+
* import { createI18nConfig } from "alabjs/i18n";
|
|
11
|
+
*
|
|
12
|
+
* export const i18n = createI18nConfig({
|
|
13
|
+
* locales: ["en", "fil", "es"],
|
|
14
|
+
* defaultLocale: "en",
|
|
15
|
+
* });
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* ```ts
|
|
19
|
+
* // middleware.ts — redirect bare paths to locale prefix
|
|
20
|
+
* import { i18n } from "./i18n.js";
|
|
21
|
+
* import { redirect } from "alabjs/middleware";
|
|
22
|
+
*
|
|
23
|
+
* export async function middleware(req: Request) {
|
|
24
|
+
* const locale = i18n.detectLocale(req);
|
|
25
|
+
* const { pathname } = new URL(req.url);
|
|
26
|
+
*
|
|
27
|
+
* // Already has a locale prefix — pass through
|
|
28
|
+
* if (i18n.hasLocalePrefix(pathname)) return;
|
|
29
|
+
*
|
|
30
|
+
* // Redirect /about → /en/about
|
|
31
|
+
* return redirect(`/${locale}${pathname}`);
|
|
32
|
+
* }
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* ```tsx
|
|
36
|
+
* // app/[locale]/layout.tsx
|
|
37
|
+
* import { LocaleProvider } from "alabjs/i18n";
|
|
38
|
+
*
|
|
39
|
+
* export default function LocaleLayout({ params, children }) {
|
|
40
|
+
* return <LocaleProvider locale={params.locale}>{children}</LocaleProvider>;
|
|
41
|
+
* }
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
46
|
+
|
|
47
|
+
// ─── Config ───────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
export interface I18nConfig {
|
|
50
|
+
/** All supported locale codes, e.g. `["en", "fil", "es"]`. */
|
|
51
|
+
locales: string[];
|
|
52
|
+
/** Locale used when no match is found. Must be in `locales`. */
|
|
53
|
+
defaultLocale: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface I18nInstance extends I18nConfig {
|
|
57
|
+
/**
|
|
58
|
+
* Detect the best locale for an incoming request.
|
|
59
|
+
*
|
|
60
|
+
* Priority order:
|
|
61
|
+
* 1. URL pathname prefix (`/en/`, `/fil/`)
|
|
62
|
+
* 2. `locale` cookie
|
|
63
|
+
* 3. `Accept-Language` header (first matching locale)
|
|
64
|
+
* 4. `defaultLocale`
|
|
65
|
+
*/
|
|
66
|
+
detectLocale(req: Request): string;
|
|
67
|
+
/** Return true if the pathname already starts with a supported locale prefix. */
|
|
68
|
+
hasLocalePrefix(pathname: string): boolean;
|
|
69
|
+
/**
|
|
70
|
+
* Strip the locale prefix from a pathname.
|
|
71
|
+
* `/en/about` → `/about`, `/about` → `/about`
|
|
72
|
+
*/
|
|
73
|
+
stripLocale(pathname: string): string;
|
|
74
|
+
/**
|
|
75
|
+
* Build a locale-prefixed path.
|
|
76
|
+
* `localePath("fil", "/about")` → `/fil/about`
|
|
77
|
+
*/
|
|
78
|
+
localePath(locale: string, path: string): string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Create an i18n configuration instance.
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* ```ts
|
|
86
|
+
* export const i18n = createI18nConfig({ locales: ["en", "fil"], defaultLocale: "en" });
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
export function createI18nConfig(config: I18nConfig): I18nInstance {
|
|
90
|
+
const { locales, defaultLocale } = config;
|
|
91
|
+
|
|
92
|
+
if (!locales.includes(defaultLocale)) {
|
|
93
|
+
throw new Error(`[alabjs/i18n] defaultLocale "${defaultLocale}" must be in the locales array`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const localeSet = new Set(locales);
|
|
97
|
+
|
|
98
|
+
function detectLocale(req: Request): string {
|
|
99
|
+
const url = new URL(req.url);
|
|
100
|
+
|
|
101
|
+
// 1. URL prefix
|
|
102
|
+
const firstSegment = url.pathname.split("/")[1] ?? "";
|
|
103
|
+
if (localeSet.has(firstSegment)) return firstSegment;
|
|
104
|
+
|
|
105
|
+
// 2. Cookie
|
|
106
|
+
const cookieHeader = req.headers.get("cookie") ?? "";
|
|
107
|
+
const localeCookie = parseCookieLocale(cookieHeader, locales);
|
|
108
|
+
if (localeCookie) return localeCookie;
|
|
109
|
+
|
|
110
|
+
// 3. Accept-Language
|
|
111
|
+
const acceptLang = req.headers.get("accept-language") ?? "";
|
|
112
|
+
const detected = parseAcceptLanguage(acceptLang, locales);
|
|
113
|
+
if (detected) return detected;
|
|
114
|
+
|
|
115
|
+
return defaultLocale;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function hasLocalePrefix(pathname: string): boolean {
|
|
119
|
+
const first = pathname.split("/")[1] ?? "";
|
|
120
|
+
return localeSet.has(first);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function stripLocale(pathname: string): string {
|
|
124
|
+
const first = pathname.split("/")[1] ?? "";
|
|
125
|
+
if (localeSet.has(first)) {
|
|
126
|
+
return pathname.slice(first.length + 1) || "/";
|
|
127
|
+
}
|
|
128
|
+
return pathname;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function localePath(locale: string, path: string): string {
|
|
132
|
+
const clean = path.startsWith("/") ? path : `/${path}`;
|
|
133
|
+
return `/${locale}${clean}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { locales, defaultLocale, detectLocale, hasLocalePrefix, stripLocale, localePath };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ─── React context ────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
const LocaleCtx = createContext<string>("en");
|
|
142
|
+
|
|
143
|
+
export interface LocaleProviderProps {
|
|
144
|
+
locale: string;
|
|
145
|
+
children: ReactNode;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Provide the current locale to all child components.
|
|
150
|
+
* Place this in your `app/[locale]/layout.tsx`.
|
|
151
|
+
*
|
|
152
|
+
* @example
|
|
153
|
+
* ```tsx
|
|
154
|
+
* export default function LocaleLayout({ params, children }) {
|
|
155
|
+
* return <LocaleProvider locale={params.locale}>{children}</LocaleProvider>;
|
|
156
|
+
* }
|
|
157
|
+
* ```
|
|
158
|
+
*/
|
|
159
|
+
export function LocaleProvider({ locale, children }: LocaleProviderProps) {
|
|
160
|
+
return <LocaleCtx.Provider value={locale}>{children}</LocaleCtx.Provider>;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Read the current locale from context.
|
|
165
|
+
* Must be used inside a `<LocaleProvider>`.
|
|
166
|
+
*
|
|
167
|
+
* @example
|
|
168
|
+
* ```tsx
|
|
169
|
+
* const locale = useLocale(); // "en" | "fil" | "es"
|
|
170
|
+
* ```
|
|
171
|
+
*/
|
|
172
|
+
export function useLocale(): string {
|
|
173
|
+
return useContext(LocaleCtx);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ─── Locale-aware Link ────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
import type { AnchorHTMLAttributes } from "react";
|
|
179
|
+
|
|
180
|
+
export interface LocaleLinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
|
181
|
+
href: string;
|
|
182
|
+
/**
|
|
183
|
+
* Override the locale for this link. Defaults to the current locale from context.
|
|
184
|
+
* Pass `false` to emit the href with no locale prefix.
|
|
185
|
+
*/
|
|
186
|
+
locale?: string | false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* A `<Link>`-compatible anchor that automatically prefixes the `href` with
|
|
191
|
+
* the current (or specified) locale.
|
|
192
|
+
*
|
|
193
|
+
* @example
|
|
194
|
+
* ```tsx
|
|
195
|
+
* // Current locale is "en"
|
|
196
|
+
* <LocaleLink href="/about">About</LocaleLink>
|
|
197
|
+
* // renders: <a href="/en/about">About</a>
|
|
198
|
+
*
|
|
199
|
+
* // Switch to Filipino
|
|
200
|
+
* <LocaleLink href="/about" locale="fil">Filipino</LocaleLink>
|
|
201
|
+
* // renders: <a href="/fil/about">Filipino</a>
|
|
202
|
+
* ```
|
|
203
|
+
*/
|
|
204
|
+
export function LocaleLink({ href, locale, children, onClick, ...rest }: LocaleLinkProps) {
|
|
205
|
+
const currentLocale = useLocale();
|
|
206
|
+
const targetLocale = locale === false ? null : (locale ?? currentLocale);
|
|
207
|
+
|
|
208
|
+
const resolvedHref = targetLocale ? `/${targetLocale}${href.startsWith("/") ? href : `/${href}`}` : href;
|
|
209
|
+
|
|
210
|
+
const handleClick: React.MouseEventHandler<HTMLAnchorElement> = (e) => {
|
|
211
|
+
onClick?.(e);
|
|
212
|
+
if (e.defaultPrevented) return;
|
|
213
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
214
|
+
e.preventDefault();
|
|
215
|
+
if (typeof window !== "undefined" && "__alabjs_navigate" in window) {
|
|
216
|
+
(window as { __alabjs_navigate: (h: string) => void }).__alabjs_navigate(resolvedHref);
|
|
217
|
+
} else {
|
|
218
|
+
window.location.href = resolvedHref;
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
return <a href={resolvedHref} onClick={handleClick} {...rest}>{children}</a>;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
function parseCookieLocale(cookieHeader: string, locales: string[]): string | null {
|
|
228
|
+
for (const part of cookieHeader.split(";")) {
|
|
229
|
+
const [key, val] = part.trim().split("=");
|
|
230
|
+
if (key?.trim() === "locale" && val && locales.includes(val.trim())) {
|
|
231
|
+
return val.trim();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function parseAcceptLanguage(header: string, locales: string[]): string | null {
|
|
238
|
+
// Parse "en-US,en;q=0.9,fil;q=0.8" → [["en-US", 1], ["en", 0.9], ["fil", 0.8]]
|
|
239
|
+
const entries = header
|
|
240
|
+
.split(",")
|
|
241
|
+
.map((part) => {
|
|
242
|
+
const [lang, q] = part.trim().split(";q=");
|
|
243
|
+
return { lang: lang?.trim() ?? "", q: parseFloat(q ?? "1") };
|
|
244
|
+
})
|
|
245
|
+
.sort((a, b) => b.q - a.q);
|
|
246
|
+
|
|
247
|
+
for (const { lang } of entries) {
|
|
248
|
+
// Exact match first (e.g. "fil")
|
|
249
|
+
if (locales.includes(lang)) return lang;
|
|
250
|
+
// Language-only match (e.g. "en" matches "en-US")
|
|
251
|
+
const base = lang.split("-")[0] ?? "";
|
|
252
|
+
const match = locales.find((l) => l === base || l.startsWith(base + "-"));
|
|
253
|
+
if (match) return match;
|
|
254
|
+
}
|
|
255
|
+
return null;
|
|
256
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { createRoute, createRouter } from "./code-router.js";
|
|
3
|
+
|
|
4
|
+
// ─── createRoute ──────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
describe("createRoute", () => {
|
|
7
|
+
it("creates a route descriptor with regex and param names", () => {
|
|
8
|
+
const route = createRoute({
|
|
9
|
+
path: "/users/$id",
|
|
10
|
+
component: () => null,
|
|
11
|
+
});
|
|
12
|
+
expect(route._regex).toBeInstanceOf(RegExp);
|
|
13
|
+
expect(route._paramNames).toEqual(["id"]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("matches static paths", () => {
|
|
17
|
+
const route = createRoute({
|
|
18
|
+
path: "/about",
|
|
19
|
+
component: () => null,
|
|
20
|
+
});
|
|
21
|
+
expect(route._regex.test("/about")).toBe(true);
|
|
22
|
+
expect(route._regex.test("/about/")).toBe(true);
|
|
23
|
+
expect(route._regex.test("/other")).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("matches dynamic segments", () => {
|
|
27
|
+
const route = createRoute({
|
|
28
|
+
path: "/users/$id",
|
|
29
|
+
component: () => null,
|
|
30
|
+
});
|
|
31
|
+
expect(route._regex.test("/users/42")).toBe(true);
|
|
32
|
+
expect(route._regex.test("/users/abc")).toBe(true);
|
|
33
|
+
expect(route._regex.test("/users")).toBe(false);
|
|
34
|
+
expect(route._regex.test("/users/42/extra")).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("extracts multiple params", () => {
|
|
38
|
+
const route = createRoute({
|
|
39
|
+
path: "/posts/$slug/comments/$commentId",
|
|
40
|
+
component: () => null,
|
|
41
|
+
});
|
|
42
|
+
expect(route._paramNames).toEqual(["slug", "commentId"]);
|
|
43
|
+
const match = route._regex.exec("/posts/hello-world/comments/99");
|
|
44
|
+
expect(match).not.toBe(null);
|
|
45
|
+
expect(match![1]).toBe("hello-world");
|
|
46
|
+
expect(match![2]).toBe("99");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("matches root path", () => {
|
|
50
|
+
const route = createRoute({
|
|
51
|
+
path: "/",
|
|
52
|
+
component: () => null,
|
|
53
|
+
});
|
|
54
|
+
expect(route._regex.test("/")).toBe(true);
|
|
55
|
+
expect(route._regex.test("/other")).toBe(false);
|
|
56
|
+
expect(route._paramNames).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("escapes special regex characters in static segments", () => {
|
|
60
|
+
const route = createRoute({
|
|
61
|
+
path: "/search.html",
|
|
62
|
+
component: () => null,
|
|
63
|
+
});
|
|
64
|
+
expect(route._regex.test("/search.html")).toBe(true);
|
|
65
|
+
expect(route._regex.test("/searchXhtml")).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("preserves config properties", () => {
|
|
69
|
+
const component = () => null;
|
|
70
|
+
const loader = async () => ({ data: true });
|
|
71
|
+
const route = createRoute({
|
|
72
|
+
path: "/test",
|
|
73
|
+
component,
|
|
74
|
+
loader,
|
|
75
|
+
});
|
|
76
|
+
expect(route.component).toBe(component);
|
|
77
|
+
expect(route.loader).toBe(loader);
|
|
78
|
+
expect(route.path).toBe("/test");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ─── createRouter ─────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
describe("createRouter", () => {
|
|
85
|
+
it("returns a router with sorted routes", () => {
|
|
86
|
+
const staticRoute = createRoute({ path: "/users/new", component: () => null });
|
|
87
|
+
const dynamicRoute = createRoute({ path: "/users/$id", component: () => null });
|
|
88
|
+
|
|
89
|
+
const router = createRouter([dynamicRoute, staticRoute]);
|
|
90
|
+
|
|
91
|
+
// Static route (0 params) should come first
|
|
92
|
+
expect(router.routes[0]!._paramNames).toHaveLength(0);
|
|
93
|
+
expect(router.routes[1]!._paramNames).toHaveLength(1);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("handles empty routes array", () => {
|
|
97
|
+
const router = createRouter([]);
|
|
98
|
+
expect(router.routes).toEqual([]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("preserves routes with equal param counts", () => {
|
|
102
|
+
const a = createRoute({ path: "/users/$id", component: () => null });
|
|
103
|
+
const b = createRoute({ path: "/posts/$slug", component: () => null });
|
|
104
|
+
const router = createRouter([a, b]);
|
|
105
|
+
expect(router.routes).toHaveLength(2);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ─── Route matching via regex ─────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
describe("route matching", () => {
|
|
112
|
+
it("dynamic route captures params correctly", () => {
|
|
113
|
+
const route = createRoute({
|
|
114
|
+
path: "/users/$id",
|
|
115
|
+
component: () => null,
|
|
116
|
+
});
|
|
117
|
+
const match = route._regex.exec("/users/42");
|
|
118
|
+
expect(match).not.toBe(null);
|
|
119
|
+
const params: Record<string, string> = {};
|
|
120
|
+
route._paramNames.forEach((name, i) => {
|
|
121
|
+
params[name] = decodeURIComponent(match![i + 1]!);
|
|
122
|
+
});
|
|
123
|
+
expect(params).toEqual({ id: "42" });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("handles encoded URI components in params", () => {
|
|
127
|
+
const route = createRoute({
|
|
128
|
+
path: "/posts/$slug",
|
|
129
|
+
component: () => null,
|
|
130
|
+
});
|
|
131
|
+
const match = route._regex.exec("/posts/hello%20world");
|
|
132
|
+
expect(match).not.toBe(null);
|
|
133
|
+
expect(decodeURIComponent(match![1]!)).toBe("hello world");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("multiple dynamic routes in router — first match wins", () => {
|
|
137
|
+
const staticRoute = createRoute({ path: "/users/admin", component: () => null });
|
|
138
|
+
const dynamicRoute = createRoute({ path: "/users/$id", component: () => null });
|
|
139
|
+
const router = createRouter([dynamicRoute, staticRoute]);
|
|
140
|
+
|
|
141
|
+
// Static should be first in sorted order (0 params)
|
|
142
|
+
const first = router.routes[0]!;
|
|
143
|
+
expect(first._regex.test("/users/admin")).toBe(true);
|
|
144
|
+
expect(first._paramNames).toHaveLength(0);
|
|
145
|
+
});
|
|
146
|
+
});
|