@rhi-zone/rainbow-router 0.0.1

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.
@@ -0,0 +1,11 @@
1
+ import type { StandardSchemaV1 } from '@standard-schema/spec';
2
+ import type { ParamParser } from '../types.ts';
3
+ /**
4
+ * Adapt a Standard Schema validator to a `ParamParser<T>`.
5
+ *
6
+ * Async schemas are not supported at the routing boundary: route matching must
7
+ * be synchronous so the router can produce a `MatchedRoute` in one pass.
8
+ * If `validate` returns a Promise we return `null` (no match) rather than
9
+ * attempting to await it.
10
+ */
11
+ export declare const fromSchema: <T>(schema: StandardSchemaV1<string, T>) => ParamParser<T>;
@@ -0,0 +1,5 @@
1
+ import type { RouterOptions, Router } from './router.ts';
2
+ import type { RouteTree } from './types.ts';
3
+ export declare const createRouter: (tree: RouteTree, options?: Omit<RouterOptions, 'scroll'> & {
4
+ scroll?: RouterOptions['scroll'];
5
+ }) => Router;
@@ -0,0 +1,6 @@
1
+ export type { ParamParser, ScrollHandler, ScrollNav, LoaderCtx, LoaderFn, RouteConfig, RouteTree, MatchedRoute, LoaderState } from './types.ts';
2
+ export { match } from './matcher.ts';
3
+ export type { RouterOptions, Router } from './router.ts';
4
+ export { createRouter } from './router.ts';
5
+ export type { Mountable, MountableFactory } from './mountable.ts';
6
+ export { defineMountable } from './mountable.ts';
package/dist/index.js ADDED
@@ -0,0 +1,94 @@
1
+ import { signal as v, notAsked as b, loading as k, success as L, failure as _ } from "@rhi-zone/rainbow";
2
+ function g(l) {
3
+ return l == null ? {} : typeof l == "object" && !S(l) ? l : { component: l };
4
+ }
5
+ const j = /* @__PURE__ */ new Set(["component", "loader", "params", "scroll"]);
6
+ function S(l) {
7
+ return l !== null && typeof l == "object" && Object.keys(l).some((e) => !j.has(e));
8
+ }
9
+ function E(l) {
10
+ return Object.keys(l).find((e) => e.startsWith("_"));
11
+ }
12
+ function K(l, e) {
13
+ const i = e.split("/").filter(Boolean), f = {}, c = [], s = l[""];
14
+ s !== void 0 && c.push(g(s));
15
+ let r = l;
16
+ for (let h = 0; h < i.length; h++) {
17
+ const w = i[h], p = h === i.length - 1;
18
+ let u = r[w];
19
+ if (u === void 0) {
20
+ const t = E(r);
21
+ if (t === void 0) return null;
22
+ const a = t.slice(1), n = r[t], o = n == null ? void 0 : n.params;
23
+ if (o && a in o) {
24
+ const d = o[a](w);
25
+ if (d === null) return null;
26
+ f[a] = d;
27
+ } else
28
+ f[a] = w;
29
+ u = n;
30
+ }
31
+ if (u == null) return null;
32
+ if (!S(u))
33
+ return p ? { layouts: c, leaf: g(u), params: f, pathname: e } : null;
34
+ if (r = u, !p) {
35
+ const t = r[""];
36
+ t !== void 0 && c.push(g(t));
37
+ }
38
+ }
39
+ const y = r[""];
40
+ return y === void 0 ? null : { layouts: c, leaf: g(y), params: f, pathname: e };
41
+ }
42
+ function R(l, e) {
43
+ const i = v(window.location.pathname), f = i.map((t) => K(l, t)), c = v(b);
44
+ let s = null, r = window.location.pathname;
45
+ const y = f.subscribe((t) => {
46
+ if (s == null || s.abort(), s = null, t === null || t.leaf.loader === void 0) {
47
+ c.set(b);
48
+ return;
49
+ }
50
+ const a = new AbortController();
51
+ s = a, c.set(k), t.leaf.loader({ params: t.params, signal: a.signal }).then(
52
+ (n) => {
53
+ a.signal.aborted || c.set(L(n));
54
+ },
55
+ (n) => {
56
+ a.signal.aborted || c.set(_(n));
57
+ }
58
+ );
59
+ }), h = (t) => {
60
+ var o;
61
+ const a = r, n = window.location.pathname;
62
+ r = n, i.set(n), (o = e == null ? void 0 : e.scroll) == null || o.call(e, {
63
+ type: "pop",
64
+ hash: window.location.hash.slice(1) || null,
65
+ from: a,
66
+ to: n
67
+ });
68
+ };
69
+ return window.addEventListener("popstate", h), {
70
+ current: f,
71
+ loaderState: c,
72
+ navigate: (t) => {
73
+ var m;
74
+ const a = new URL(t, location.href), n = a.pathname, o = a.hash.slice(1) || null, d = r;
75
+ r = n, history.pushState({ _type: "push" }, "", t), (m = e == null ? void 0 : e.scroll) == null || m.call(e, { type: "push", hash: o, from: d, to: n }), i.set(n);
76
+ },
77
+ replace: (t) => {
78
+ var m;
79
+ const a = new URL(t, location.href), n = a.pathname, o = a.hash.slice(1) || null, d = r;
80
+ r = n, history.replaceState({ _type: "replace" }, "", t), (m = e == null ? void 0 : e.scroll) == null || m.call(e, { type: "replace", hash: o, from: d, to: n }), i.set(n);
81
+ },
82
+ back: () => history.back(),
83
+ forward: () => history.forward(),
84
+ destroy: () => {
85
+ y(), window.removeEventListener("popstate", h), s == null || s.abort(), s = null;
86
+ }
87
+ };
88
+ }
89
+ const x = () => (l) => l;
90
+ export {
91
+ R as createRouter,
92
+ x as defineMountable,
93
+ K as match
94
+ };
@@ -0,0 +1 @@
1
+ (function(u,c){typeof exports=="object"&&typeof module<"u"?c(exports,require("@rhi-zone/rainbow")):typeof define=="function"&&define.amd?define(["exports","@rhi-zone/rainbow"],c):(u=typeof globalThis<"u"?globalThis:u||self,c(u.RainbowRouter={},u.rainbow))})(this,(function(u,c){"use strict";function g(l){return l==null?{}:typeof l=="object"&&!S(l)?l:{component:l}}const k=new Set(["component","loader","params","scroll"]);function S(l){return l!==null&&typeof l=="object"&&Object.keys(l).some(e=>!k.has(e))}function R(l){return Object.keys(l).find(e=>e.startsWith("_"))}function j(l,e){const f=e.split("/").filter(Boolean),h={},i=[],r=l[""];r!==void 0&&i.push(g(r));let a=l;for(let m=0;m<f.length;m++){const w=f[m],b=m===f.length-1;let d=a[w];if(d===void 0){const t=R(a);if(t===void 0)return null;const s=t.slice(1),n=a[t],o=n==null?void 0:n.params;if(o&&s in o){const y=o[s](w);if(y===null)return null;h[s]=y}else h[s]=w;d=n}if(d==null)return null;if(!S(d))return b?{layouts:i,leaf:g(d),params:h,pathname:e}:null;if(a=d,!b){const t=a[""];t!==void 0&&i.push(g(t))}}const v=a[""];return v===void 0?null:{layouts:i,leaf:g(v),params:h,pathname:e}}function L(l,e){const f=c.signal(window.location.pathname),h=f.map(t=>j(l,t)),i=c.signal(c.notAsked);let r=null,a=window.location.pathname;const v=h.subscribe(t=>{if(r==null||r.abort(),r=null,t===null||t.leaf.loader===void 0){i.set(c.notAsked);return}const s=new AbortController;r=s,i.set(c.loading),t.leaf.loader({params:t.params,signal:s.signal}).then(n=>{s.signal.aborted||i.set(c.success(n))},n=>{s.signal.aborted||i.set(c.failure(n))})}),m=t=>{var o;const s=a,n=window.location.pathname;a=n,f.set(n),(o=e==null?void 0:e.scroll)==null||o.call(e,{type:"pop",hash:window.location.hash.slice(1)||null,from:s,to:n})};return window.addEventListener("popstate",m),{current:h,loaderState:i,navigate:t=>{var p;const s=new URL(t,location.href),n=s.pathname,o=s.hash.slice(1)||null,y=a;a=n,history.pushState({_type:"push"},"",t),(p=e==null?void 0:e.scroll)==null||p.call(e,{type:"push",hash:o,from:y,to:n}),f.set(n)},replace:t=>{var p;const s=new URL(t,location.href),n=s.pathname,o=s.hash.slice(1)||null,y=a;a=n,history.replaceState({_type:"replace"},"",t),(p=e==null?void 0:e.scroll)==null||p.call(e,{type:"replace",hash:o,from:y,to:n}),f.set(n)},back:()=>history.back(),forward:()=>history.forward(),destroy:()=>{v(),window.removeEventListener("popstate",m),r==null||r.abort(),r=null}}}const _=()=>l=>l;u.createRouter=L,u.defineMountable=_,u.match=j,Object.defineProperty(u,Symbol.toStringTag,{value:"Module"})}));
@@ -0,0 +1,13 @@
1
+ import type { RouteTree, MatchedRoute } from './types.ts';
2
+ /**
3
+ * Match a pathname against a route tree.
4
+ *
5
+ * Algorithm (mirrors the Lua trie router):
6
+ * 1. Split pathname into segments
7
+ * 2. Walk the tree segment-by-segment
8
+ * 3. At each node: try exact key first, then `_name` dynamic key
9
+ * 4. Run ParamParser for dynamic segment — null return = no match → 404
10
+ * 5. Collect `''` handlers at intermediate nodes as layout layers
11
+ * 6. At final segment: `''` key is the leaf handler
12
+ */
13
+ export declare function match(tree: RouteTree, pathname: string): MatchedRoute | null;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,6 @@
1
+ import type { RouteTree } from './types.ts';
2
+ export type Mountable<Context extends Record<string, string>> = RouteTree & {
3
+ readonly '~context': Context;
4
+ };
5
+ export type MountableFactory<Context extends Record<string, string>> = <Tree extends RouteTree>(tree: Tree) => Tree & Mountable<Context>;
6
+ export declare const defineMountable: <Context extends Record<string, string>>() => MountableFactory<Context>;
@@ -0,0 +1,15 @@
1
+ import type { ReadonlySignal, AsyncData } from '@rhi-zone/rainbow';
2
+ import type { RouteTree, MatchedRoute, ScrollHandler } from './types.ts';
3
+ export type RouterOptions = {
4
+ scroll?: ScrollHandler;
5
+ };
6
+ export type Router = {
7
+ readonly current: ReadonlySignal<MatchedRoute | null>;
8
+ readonly loaderState: ReadonlySignal<AsyncData<unknown>>;
9
+ navigate(pathname: string): void;
10
+ replace(pathname: string): void;
11
+ back(): void;
12
+ forward(): void;
13
+ destroy(): void;
14
+ };
15
+ export declare function createRouter(tree: RouteTree, options?: RouterOptions): Router;
@@ -0,0 +1,5 @@
1
+ import type { ScrollHandler } from './types.ts';
2
+ export declare const scrollTop: ScrollHandler;
3
+ export declare const scrollNone: ScrollHandler;
4
+ export declare const scrollToHash: ScrollHandler;
5
+ export declare const scrollRestore: ScrollHandler;
@@ -0,0 +1,44 @@
1
+ import type { AsyncData } from '@rhi-zone/rainbow';
2
+ /** The router's only contract for param validation. null = no match → 404. */
3
+ export type ParamParser<T> = (raw: string) => T | null;
4
+ export type ScrollNav = {
5
+ readonly type: 'push' | 'pop' | 'replace';
6
+ readonly hash: string | null;
7
+ readonly from: string;
8
+ readonly to: string;
9
+ };
10
+ export type ScrollHandler = (nav: ScrollNav) => void;
11
+ export type LoaderCtx<P extends Record<string, unknown> = Record<string, string>> = {
12
+ readonly params: P;
13
+ readonly signal: AbortSignal;
14
+ };
15
+ export type LoaderFn<P extends Record<string, unknown> = Record<string, string>, T = unknown> = (ctx: LoaderCtx<P>) => Promise<T>;
16
+ /**
17
+ * A route node config — metadata attached to a path depth.
18
+ * Component type is `unknown` to stay framework-agnostic;
19
+ * the Lit/React/etc. adapters provide typed wrappers.
20
+ */
21
+ export type RouteConfig = {
22
+ readonly component?: unknown;
23
+ readonly loader?: LoaderFn;
24
+ readonly params?: Record<string, ParamParser<unknown>>;
25
+ readonly scroll?: ScrollHandler;
26
+ };
27
+ /**
28
+ * A route tree node — either a shorthand component, a config, or a subtree.
29
+ * The `''` key at any subtree node is the handler at that exact depth.
30
+ */
31
+ export type RouteTree = {
32
+ readonly [segment: string]: RouteTree | RouteConfig | unknown;
33
+ };
34
+ export type MatchedRoute = {
35
+ /** Layout stack from outermost to innermost, excluding leaf. */
36
+ readonly layouts: RouteConfig[];
37
+ /** The innermost matched config. */
38
+ readonly leaf: RouteConfig;
39
+ /** Validated and parsed params accumulated from all dynamic segments. */
40
+ readonly params: Record<string, unknown>;
41
+ /** The full matched pathname. */
42
+ readonly pathname: string;
43
+ };
44
+ export type LoaderState<T = unknown> = AsyncData<T, unknown>;
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@rhi-zone/rainbow-router",
3
+ "version": "0.0.1",
4
+ "description": "Trie-based SPA router for rainbow",
5
+ "type": "module",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./dist/index.js",
10
+ "types": "./dist/index.d.ts"
11
+ },
12
+ "./scroll": {
13
+ "import": "./dist/scroll.js",
14
+ "types": "./dist/scroll.d.ts"
15
+ },
16
+ "./defaults": {
17
+ "import": "./dist/defaults.js",
18
+ "types": "./dist/defaults.d.ts"
19
+ },
20
+ "./adapters/standard-schema": {
21
+ "import": "./dist/adapters/standard-schema.js",
22
+ "types": "./dist/adapters/standard-schema.d.ts"
23
+ }
24
+ },
25
+ "files": ["dist"],
26
+ "scripts": {
27
+ "dev": "vite build --watch",
28
+ "build": "vite build && tsgo --emitDeclarationOnly",
29
+ "typecheck": "tsgo --noEmit",
30
+ "test": "vitest run"
31
+ },
32
+ "dependencies": {
33
+ "@rhi-zone/rainbow": "0.1.0"
34
+ },
35
+ "devDependencies": {
36
+ "@standard-schema/spec": "^1.0.0",
37
+ "vite": "^6.0.0",
38
+ "vitest": "^2.0.0"
39
+ }
40
+ }