@llui/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.
- package/LICENSE +21 -0
- package/README.md +13 -0
- package/dist/connect.d.ts +54 -0
- package/dist/connect.d.ts.map +1 -0
- package/dist/connect.js +101 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +220 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Franco Ponticelli
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# @llui/router
|
|
2
|
+
|
|
3
|
+
Router for [LLui](https://github.com/fponticelli/llui).
|
|
4
|
+
|
|
5
|
+
Structured path matching, history/hash mode, `routing.link()` helper, `routing.listener()` for popstate, and `createHandler()` for mergeHandlers integration.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @llui/router
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## License
|
|
12
|
+
|
|
13
|
+
MIT
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Router } from './index';
|
|
2
|
+
export interface RouterEffect {
|
|
3
|
+
type: '__router';
|
|
4
|
+
action: 'push' | 'replace' | 'back' | 'forward' | 'scroll';
|
|
5
|
+
path?: string;
|
|
6
|
+
x?: number;
|
|
7
|
+
y?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface ConnectedRouter<R> {
|
|
10
|
+
/** Effect: push a new route onto history */
|
|
11
|
+
push(route: R): RouterEffect;
|
|
12
|
+
/** Effect: replace current history entry */
|
|
13
|
+
replace(route: R): RouterEffect;
|
|
14
|
+
/** Effect: go back */
|
|
15
|
+
back(): RouterEffect;
|
|
16
|
+
/** Effect: go forward */
|
|
17
|
+
forward(): RouterEffect;
|
|
18
|
+
/** Effect: scroll to position */
|
|
19
|
+
scroll(x: number, y: number): RouterEffect;
|
|
20
|
+
/** Plugin for handleEffects().use() — handles RouterEffect */
|
|
21
|
+
handleEffect: (ctx: {
|
|
22
|
+
effect: {
|
|
23
|
+
type: string;
|
|
24
|
+
};
|
|
25
|
+
send: unknown;
|
|
26
|
+
signal: AbortSignal;
|
|
27
|
+
}) => boolean;
|
|
28
|
+
/**
|
|
29
|
+
* View helper: attach URL change listener via onMount.
|
|
30
|
+
* Returns an empty comment node. Sends { type: 'navigate', route } on URL change.
|
|
31
|
+
*/
|
|
32
|
+
listener<M>(send: (msg: M) => void, msgFactory?: (route: R) => M): Node[];
|
|
33
|
+
/**
|
|
34
|
+
* View helper: render a navigation link.
|
|
35
|
+
* Generates <a> with proper href and click handler that sends navigate message.
|
|
36
|
+
*/
|
|
37
|
+
link<M>(send: (msg: M) => void, route: R, attrs: Record<string, unknown>, children: Node[], msgFactory?: (route: R) => M): HTMLElement;
|
|
38
|
+
/**
|
|
39
|
+
* Create an update handler for mergeHandlers.
|
|
40
|
+
* Returns [newState, Effect[]] for navigate messages, null for others.
|
|
41
|
+
*/
|
|
42
|
+
createHandler<S, M, E>(config: {
|
|
43
|
+
/** Message type to handle (default: 'navigate') */
|
|
44
|
+
message?: string;
|
|
45
|
+
/** Extract route from message */
|
|
46
|
+
getRoute: (msg: M) => R;
|
|
47
|
+
/** Optional guard — can redirect */
|
|
48
|
+
guard?: (route: R, state: S) => R;
|
|
49
|
+
/** Build new state + effects for the route */
|
|
50
|
+
onNavigate: (state: S, route: R) => [S, E[]];
|
|
51
|
+
}): (state: S, msg: M) => [S, E[]] | null;
|
|
52
|
+
}
|
|
53
|
+
export declare function connectRouter<R>(router: Router<R>): ConnectedRouter<R>;
|
|
54
|
+
//# sourceMappingURL=connect.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"connect.d.ts","sourceRoot":"","sources":["../src/connect.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,SAAS,CAAA;AAKrC,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,UAAU,CAAA;IAChB,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,GAAG,QAAQ,CAAA;IAC1D,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,CAAC,CAAC,EAAE,MAAM,CAAA;IACV,CAAC,CAAC,EAAE,MAAM,CAAA;CACX;AAED,MAAM,WAAW,eAAe,CAAC,CAAC;IAChC,4CAA4C;IAC5C,IAAI,CAAC,KAAK,EAAE,CAAC,GAAG,YAAY,CAAA;IAC5B,4CAA4C;IAC5C,OAAO,CAAC,KAAK,EAAE,CAAC,GAAG,YAAY,CAAA;IAC/B,sBAAsB;IACtB,IAAI,IAAI,YAAY,CAAA;IACpB,yBAAyB;IACzB,OAAO,IAAI,YAAY,CAAA;IACvB,iCAAiC;IACjC,MAAM,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,YAAY,CAAA;IAE1C,8DAA8D;IAC9D,YAAY,EAAE,CAAC,GAAG,EAAE;QAAE,MAAM,EAAE;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAC;QAAC,IAAI,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,WAAW,CAAA;KAAE,KAAK,OAAO,CAAA;IAEhG;;;OAGG;IACH,QAAQ,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,GAAG,EAAE,CAAC,KAAK,IAAI,EAAE,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,CAAC,GAAG,IAAI,EAAE,CAAA;IAEzE;;;OAGG;IACH,IAAI,CAAC,CAAC,EACJ,IAAI,EAAE,CAAC,GAAG,EAAE,CAAC,KAAK,IAAI,EACtB,KAAK,EAAE,CAAC,EACR,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,QAAQ,EAAE,IAAI,EAAE,EAChB,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,CAAC,GAC3B,WAAW,CAAA;IAEd;;;OAGG;IACH,aAAa,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,MAAM,EAAE;QAC7B,mDAAmD;QACnD,OAAO,CAAC,EAAE,MAAM,CAAA;QAChB,iCAAiC;QACjC,QAAQ,EAAE,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,CAAA;QACvB,oCAAoC;QACpC,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,KAAK,CAAC,CAAA;QACjC,8CAA8C;QAC9C,UAAU,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAA;KAC7C,GAAG,CAAC,KAAK,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,GAAG,IAAI,CAAA;CAC1C;AAED,wBAAgB,aAAa,CAAC,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,GAAG,eAAe,CAAC,CAAC,CAAC,CAgHtE"}
|
package/dist/connect.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { a, onMount } from '@llui/dom';
|
|
2
|
+
export function connectRouter(router) {
|
|
3
|
+
function applyEffect(effect) {
|
|
4
|
+
switch (effect.action) {
|
|
5
|
+
case 'push':
|
|
6
|
+
if (router.mode === 'hash') {
|
|
7
|
+
location.hash = effect.path;
|
|
8
|
+
}
|
|
9
|
+
else {
|
|
10
|
+
history.pushState(null, '', effect.path);
|
|
11
|
+
}
|
|
12
|
+
break;
|
|
13
|
+
case 'replace':
|
|
14
|
+
if (router.mode === 'hash') {
|
|
15
|
+
location.replace(effect.path);
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
history.replaceState(null, '', effect.path);
|
|
19
|
+
}
|
|
20
|
+
break;
|
|
21
|
+
case 'back':
|
|
22
|
+
history.back();
|
|
23
|
+
break;
|
|
24
|
+
case 'forward':
|
|
25
|
+
history.forward();
|
|
26
|
+
break;
|
|
27
|
+
case 'scroll':
|
|
28
|
+
window.scrollTo(effect.x, effect.y);
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
push(route) {
|
|
34
|
+
return { type: '__router', action: 'push', path: router.href(route) };
|
|
35
|
+
},
|
|
36
|
+
replace(route) {
|
|
37
|
+
return { type: '__router', action: 'replace', path: router.href(route) };
|
|
38
|
+
},
|
|
39
|
+
back() {
|
|
40
|
+
return { type: '__router', action: 'back' };
|
|
41
|
+
},
|
|
42
|
+
forward() {
|
|
43
|
+
return { type: '__router', action: 'forward' };
|
|
44
|
+
},
|
|
45
|
+
scroll(x, y) {
|
|
46
|
+
return { type: '__router', action: 'scroll', x, y };
|
|
47
|
+
},
|
|
48
|
+
handleEffect({ effect }) {
|
|
49
|
+
if (effect.type !== '__router')
|
|
50
|
+
return false;
|
|
51
|
+
applyEffect(effect);
|
|
52
|
+
return true;
|
|
53
|
+
},
|
|
54
|
+
listener(send, msgFactory) {
|
|
55
|
+
const factory = msgFactory ?? ((r) => ({ type: 'navigate', route: r }));
|
|
56
|
+
onMount(() => {
|
|
57
|
+
const event = router.mode === 'hash' ? 'hashchange' : 'popstate';
|
|
58
|
+
const handler = () => {
|
|
59
|
+
const input = router.mode === 'hash' ? location.hash : location.pathname + location.search;
|
|
60
|
+
const route = router.match(input);
|
|
61
|
+
send(factory(route));
|
|
62
|
+
};
|
|
63
|
+
window.addEventListener(event, handler);
|
|
64
|
+
return () => window.removeEventListener(event, handler);
|
|
65
|
+
});
|
|
66
|
+
return [document.createComment('router')];
|
|
67
|
+
},
|
|
68
|
+
link(send, route, attrs, children, msgFactory) {
|
|
69
|
+
const factory = msgFactory ?? ((r) => ({ type: 'navigate', route: r }));
|
|
70
|
+
return a({
|
|
71
|
+
...attrs,
|
|
72
|
+
href: router.href(route),
|
|
73
|
+
onClick: (e) => {
|
|
74
|
+
const me = e;
|
|
75
|
+
if (me.ctrlKey || me.metaKey || me.shiftKey || me.altKey || me.button !== 0)
|
|
76
|
+
return;
|
|
77
|
+
e.preventDefault();
|
|
78
|
+
// Push history — pushState doesn't fire popstate, so no double-nav
|
|
79
|
+
if (router.mode === 'hash') {
|
|
80
|
+
// hashchange will fire the listener, which sends the navigate message
|
|
81
|
+
location.hash = router.href(route);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
history.pushState(null, '', router.href(route));
|
|
85
|
+
send(factory(route));
|
|
86
|
+
},
|
|
87
|
+
}, children);
|
|
88
|
+
},
|
|
89
|
+
createHandler(config) {
|
|
90
|
+
const msgType = config.message ?? 'navigate';
|
|
91
|
+
return (state, msg) => {
|
|
92
|
+
if (msg.type !== msgType)
|
|
93
|
+
return null;
|
|
94
|
+
let route = config.getRoute(msg);
|
|
95
|
+
if (config.guard)
|
|
96
|
+
route = config.guard(route, state);
|
|
97
|
+
return config.onNavigate(state, route);
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
interface ParamSegment {
|
|
2
|
+
__kind: 'param';
|
|
3
|
+
name: string;
|
|
4
|
+
}
|
|
5
|
+
interface RestSegment {
|
|
6
|
+
__kind: 'rest';
|
|
7
|
+
name: string;
|
|
8
|
+
}
|
|
9
|
+
export type Segment = string | ParamSegment | RestSegment;
|
|
10
|
+
/** Named path parameter: matches one segment */
|
|
11
|
+
export declare function param(name: string): ParamSegment;
|
|
12
|
+
/** Rest parameter: matches remaining segments */
|
|
13
|
+
export declare function rest(name: string): RestSegment;
|
|
14
|
+
interface RouteDefOptions {
|
|
15
|
+
query?: string[];
|
|
16
|
+
}
|
|
17
|
+
export interface RouteDef<R> {
|
|
18
|
+
segments: Segment[];
|
|
19
|
+
build: (params: Record<string, string>) => R;
|
|
20
|
+
queryKeys: string[];
|
|
21
|
+
/** Optional manual toPath override */
|
|
22
|
+
toPath?: (route: R) => string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Define a route with structured path segments.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* route(['article', param('slug')], ({ slug }) => ({ page: 'article', slug }))
|
|
29
|
+
* route(['search'], { query: ['q'] }, ({ q }) => ({ page: 'search', q: q ?? '' }))
|
|
30
|
+
*/
|
|
31
|
+
export declare function route<R = any>(segments: Segment[], buildOrOpts: ((params: Record<string, string>) => R) | RouteDefOptions, buildOrToPath?: ((params: Record<string, string>) => R) | {
|
|
32
|
+
toPath: (route: R) => string;
|
|
33
|
+
}): RouteDef<R>;
|
|
34
|
+
export interface RouterConfig<R> {
|
|
35
|
+
mode?: 'hash' | 'history';
|
|
36
|
+
fallback?: R;
|
|
37
|
+
}
|
|
38
|
+
export interface Router<R> {
|
|
39
|
+
/** Match a pathname to a Route. Returns fallback if no match. */
|
|
40
|
+
match(pathname: string): R;
|
|
41
|
+
/** Format a Route back to a pathname (without hash/history prefix). */
|
|
42
|
+
toPath(route: R): string;
|
|
43
|
+
/** Format a Route to a full href (with # prefix in hash mode). */
|
|
44
|
+
href(route: R): string;
|
|
45
|
+
/** The configured mode */
|
|
46
|
+
mode: 'hash' | 'history';
|
|
47
|
+
/** All route definitions (for iteration) */
|
|
48
|
+
routes: ReadonlyArray<RouteDef<R>>;
|
|
49
|
+
/** The fallback route */
|
|
50
|
+
fallback: R;
|
|
51
|
+
}
|
|
52
|
+
export declare function createRouter<R>(defs: RouteDef<any>[], config?: RouterConfig<R>): Router<R>;
|
|
53
|
+
export {};
|
|
54
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,UAAU,YAAY;IACpB,MAAM,EAAE,OAAO,CAAA;IACf,IAAI,EAAE,MAAM,CAAA;CACb;AAED,UAAU,WAAW;IACnB,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,MAAM,CAAA;CACb;AAED,MAAM,MAAM,OAAO,GAAG,MAAM,GAAG,YAAY,GAAG,WAAW,CAAA;AAEzD,gDAAgD;AAChD,wBAAgB,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,CAEhD;AAED,iDAAiD;AACjD,wBAAgB,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,CAE9C;AAID,UAAU,eAAe;IACvB,KAAK,CAAC,EAAE,MAAM,EAAE,CAAA;CACjB;AAED,MAAM,WAAW,QAAQ,CAAC,CAAC;IACzB,QAAQ,EAAE,OAAO,EAAE,CAAA;IACnB,KAAK,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,CAAC,CAAA;IAC5C,SAAS,EAAE,MAAM,EAAE,CAAA;IACnB,sCAAsC;IACtC,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,MAAM,CAAA;CAC9B;AAED;;;;;;GAMG;AAEH,wBAAgB,KAAK,CAAC,CAAC,GAAG,GAAG,EAC3B,QAAQ,EAAE,OAAO,EAAE,EACnB,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,GAAG,eAAe,EACtE,aAAa,CAAC,EAAE,CAAC,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,GAAG;IAAE,MAAM,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,MAAM,CAAA;CAAE,GACzF,QAAQ,CAAC,CAAC,CAAC,CAQb;AAID,MAAM,WAAW,YAAY,CAAC,CAAC;IAC7B,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IACzB,QAAQ,CAAC,EAAE,CAAC,CAAA;CACb;AAED,MAAM,WAAW,MAAM,CAAC,CAAC;IACvB,iEAAiE;IACjE,KAAK,CAAC,QAAQ,EAAE,MAAM,GAAG,CAAC,CAAA;IAC1B,uEAAuE;IACvE,MAAM,CAAC,KAAK,EAAE,CAAC,GAAG,MAAM,CAAA;IACxB,kEAAkE;IAClE,IAAI,CAAC,KAAK,EAAE,CAAC,GAAG,MAAM,CAAA;IACtB,0BAA0B;IAC1B,IAAI,EAAE,MAAM,GAAG,SAAS,CAAA;IACxB,4CAA4C;IAC5C,MAAM,EAAE,aAAa,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAA;IAClC,yBAAyB;IACzB,QAAQ,EAAE,CAAC,CAAA;CACZ;AAED,wBAAgB,YAAY,CAAC,CAAC,EAE5B,IAAI,EAAE,QAAQ,CAAC,GAAG,CAAC,EAAE,EACrB,MAAM,CAAC,EAAE,YAAY,CAAC,CAAC,CAAC,GACvB,MAAM,CAAC,CAAC,CAAC,CAgFX"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// ── Path Segment Types ───────────────────────────────────────────
|
|
2
|
+
/** Named path parameter: matches one segment */
|
|
3
|
+
export function param(name) {
|
|
4
|
+
return { __kind: 'param', name };
|
|
5
|
+
}
|
|
6
|
+
/** Rest parameter: matches remaining segments */
|
|
7
|
+
export function rest(name) {
|
|
8
|
+
return { __kind: 'rest', name };
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Define a route with structured path segments.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* route(['article', param('slug')], ({ slug }) => ({ page: 'article', slug }))
|
|
15
|
+
* route(['search'], { query: ['q'] }, ({ q }) => ({ page: 'search', q: q ?? '' }))
|
|
16
|
+
*/
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
18
|
+
export function route(segments, buildOrOpts, buildOrToPath) {
|
|
19
|
+
if (typeof buildOrOpts === 'function') {
|
|
20
|
+
const tp = buildOrToPath && typeof buildOrToPath === 'object' ? buildOrToPath.toPath : undefined;
|
|
21
|
+
return { segments, build: buildOrOpts, queryKeys: [], toPath: tp };
|
|
22
|
+
}
|
|
23
|
+
const opts = buildOrOpts;
|
|
24
|
+
const build = buildOrToPath;
|
|
25
|
+
return { segments, build, queryKeys: opts.query ?? [] };
|
|
26
|
+
}
|
|
27
|
+
export function createRouter(
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
29
|
+
defs, config) {
|
|
30
|
+
const mode = config?.mode ?? 'hash';
|
|
31
|
+
const fallback = config?.fallback ?? defs[0].build({});
|
|
32
|
+
function matchPathname(pathname) {
|
|
33
|
+
// Separate path from query string
|
|
34
|
+
let queryParams = {};
|
|
35
|
+
const qIdx = pathname.indexOf('?');
|
|
36
|
+
const rawPath = qIdx !== -1 ? pathname.slice(0, qIdx) : pathname;
|
|
37
|
+
if (qIdx !== -1) {
|
|
38
|
+
queryParams = parseQuery(pathname.slice(qIdx + 1));
|
|
39
|
+
}
|
|
40
|
+
const path = rawPath.replace(/^\/+|\/+$/g, '');
|
|
41
|
+
const pathSegments = path === '' ? [] : path.split('/');
|
|
42
|
+
// Try each route definition
|
|
43
|
+
for (const def of defs) {
|
|
44
|
+
const params = matchDef(def, pathSegments);
|
|
45
|
+
if (params !== null) {
|
|
46
|
+
// Merge query params
|
|
47
|
+
for (const key of def.queryKeys) {
|
|
48
|
+
if (queryParams[key] !== undefined)
|
|
49
|
+
params[key] = queryParams[key];
|
|
50
|
+
}
|
|
51
|
+
return def.build(params);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return fallback;
|
|
55
|
+
}
|
|
56
|
+
function formatPath(r) {
|
|
57
|
+
// Try each route definition in reverse order (most specific first)
|
|
58
|
+
for (let i = defs.length - 1; i >= 0; i--) {
|
|
59
|
+
const def = defs[i];
|
|
60
|
+
// If route has a manual toPath, use it
|
|
61
|
+
if (def.toPath)
|
|
62
|
+
return def.toPath(r);
|
|
63
|
+
// Try to extract params from the Route and build the path
|
|
64
|
+
const path = tryFormat(def, r);
|
|
65
|
+
if (path !== null) {
|
|
66
|
+
// Round-trip check: parse the formatted path and verify URL-relevant
|
|
67
|
+
// fields match. Ignore extra fields (like runtime `data`) that aren't
|
|
68
|
+
// part of the URL — they would break the comparison since the route
|
|
69
|
+
// builder produces default values that differ from the actual state.
|
|
70
|
+
const roundTrip = matchPathname(path);
|
|
71
|
+
const urlKeys = getUrlKeys(def);
|
|
72
|
+
if (partialEqual(roundTrip, r, urlKeys))
|
|
73
|
+
return path;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Last resort: try forward order
|
|
77
|
+
for (const def of defs) {
|
|
78
|
+
if (def.toPath)
|
|
79
|
+
return def.toPath(r);
|
|
80
|
+
const path = tryFormat(def, r);
|
|
81
|
+
if (path !== null)
|
|
82
|
+
return path;
|
|
83
|
+
}
|
|
84
|
+
return '/';
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
match(input) {
|
|
88
|
+
// Strip hash prefix, preserve query string
|
|
89
|
+
const pathname = mode === 'hash' ? input.replace(/^#\/?/, '/') : input;
|
|
90
|
+
return matchPathname(pathname);
|
|
91
|
+
},
|
|
92
|
+
toPath: formatPath,
|
|
93
|
+
href(r) {
|
|
94
|
+
const path = formatPath(r);
|
|
95
|
+
return mode === 'hash' ? `#${path}` : path;
|
|
96
|
+
},
|
|
97
|
+
mode,
|
|
98
|
+
routes: defs,
|
|
99
|
+
fallback,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
// ── Matching ─────────────────────────────────────────────────────
|
|
103
|
+
function matchDef(def, pathSegments) {
|
|
104
|
+
const params = {};
|
|
105
|
+
let si = 0;
|
|
106
|
+
for (let di = 0; di < def.segments.length; di++) {
|
|
107
|
+
const seg = def.segments[di];
|
|
108
|
+
if (typeof seg === 'string') {
|
|
109
|
+
if (si >= pathSegments.length || pathSegments[si] !== seg)
|
|
110
|
+
return null;
|
|
111
|
+
si++;
|
|
112
|
+
}
|
|
113
|
+
else if (seg.__kind === 'param') {
|
|
114
|
+
if (si >= pathSegments.length)
|
|
115
|
+
return null;
|
|
116
|
+
params[seg.name] = decodeURIComponent(pathSegments[si]);
|
|
117
|
+
si++;
|
|
118
|
+
}
|
|
119
|
+
else if (seg.__kind === 'rest') {
|
|
120
|
+
params[seg.name] = pathSegments.slice(si).map(decodeURIComponent).join('/');
|
|
121
|
+
si = pathSegments.length;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// All path segments must be consumed
|
|
125
|
+
if (si !== pathSegments.length)
|
|
126
|
+
return null;
|
|
127
|
+
return params;
|
|
128
|
+
}
|
|
129
|
+
function tryFormat(def, r) {
|
|
130
|
+
const routeObj = r;
|
|
131
|
+
const parts = [];
|
|
132
|
+
for (const seg of def.segments) {
|
|
133
|
+
if (typeof seg === 'string') {
|
|
134
|
+
parts.push(seg);
|
|
135
|
+
}
|
|
136
|
+
else if (seg.__kind === 'param') {
|
|
137
|
+
const value = routeObj[seg.name];
|
|
138
|
+
if (value === undefined || value === null)
|
|
139
|
+
return null;
|
|
140
|
+
parts.push(encodeURIComponent(String(value)));
|
|
141
|
+
}
|
|
142
|
+
else if (seg.__kind === 'rest') {
|
|
143
|
+
const value = routeObj[seg.name];
|
|
144
|
+
if (value === undefined || value === null)
|
|
145
|
+
return null;
|
|
146
|
+
parts.push(String(value));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
let path = '/' + parts.join('/');
|
|
150
|
+
// Append query params if defined
|
|
151
|
+
if (def.queryKeys.length > 0) {
|
|
152
|
+
const qParts = [];
|
|
153
|
+
for (const key of def.queryKeys) {
|
|
154
|
+
const value = routeObj[key];
|
|
155
|
+
if (value !== undefined && value !== null && value !== '') {
|
|
156
|
+
qParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (qParts.length > 0)
|
|
160
|
+
path += '?' + qParts.join('&');
|
|
161
|
+
}
|
|
162
|
+
return path;
|
|
163
|
+
}
|
|
164
|
+
// ── Utilities ────────────────────────────────────────────────────
|
|
165
|
+
function parseQuery(qs) {
|
|
166
|
+
const params = {};
|
|
167
|
+
for (const pair of qs.split('&')) {
|
|
168
|
+
const [key, val] = pair.split('=');
|
|
169
|
+
if (key)
|
|
170
|
+
params[decodeURIComponent(key)] = decodeURIComponent(val ?? '');
|
|
171
|
+
}
|
|
172
|
+
return params;
|
|
173
|
+
}
|
|
174
|
+
/** Extract URL-relevant field names from a route definition */
|
|
175
|
+
function getUrlKeys(def) {
|
|
176
|
+
const keys = new Set();
|
|
177
|
+
for (const seg of def.segments) {
|
|
178
|
+
if (typeof seg === 'string')
|
|
179
|
+
continue;
|
|
180
|
+
keys.add(seg.name);
|
|
181
|
+
}
|
|
182
|
+
for (const key of def.queryKeys) {
|
|
183
|
+
keys.add(key);
|
|
184
|
+
}
|
|
185
|
+
// Also include 'page' / 'tab' or any fixed field from the builder
|
|
186
|
+
// by running the builder with empty params and collecting its keys
|
|
187
|
+
const sample = def.build({});
|
|
188
|
+
for (const key of Object.keys(sample)) {
|
|
189
|
+
// Include all keys from the builder EXCEPT those with object/array values
|
|
190
|
+
// (which are likely runtime state like `data`)
|
|
191
|
+
const val = sample[key];
|
|
192
|
+
if (val === null || val === undefined || typeof val !== 'object') {
|
|
193
|
+
keys.add(key);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return keys;
|
|
197
|
+
}
|
|
198
|
+
/** Compare two objects only on the specified keys */
|
|
199
|
+
function partialEqual(a, b, keys) {
|
|
200
|
+
for (const key of keys) {
|
|
201
|
+
if (!deepEqual(a[key], b[key]))
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
function deepEqual(a, b) {
|
|
207
|
+
if (Object.is(a, b))
|
|
208
|
+
return true;
|
|
209
|
+
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null)
|
|
210
|
+
return false;
|
|
211
|
+
const ka = Object.keys(a);
|
|
212
|
+
const kb = Object.keys(b);
|
|
213
|
+
if (ka.length !== kb.length)
|
|
214
|
+
return false;
|
|
215
|
+
for (const key of ka) {
|
|
216
|
+
if (!deepEqual(a[key], b[key]))
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
return true;
|
|
220
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@llui/router",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts"
|
|
11
|
+
},
|
|
12
|
+
"./connect": {
|
|
13
|
+
"import": "./dist/connect.js",
|
|
14
|
+
"types": "./dist/connect.d.ts"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc -p tsconfig.build.json",
|
|
19
|
+
"check": "tsc --noEmit -p tsconfig.check.json",
|
|
20
|
+
"lint": "eslint src",
|
|
21
|
+
"test": "vitest run"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"@llui/dom": "^0.0.1"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@llui/dom": "workspace:*",
|
|
28
|
+
"typescript": "^6.0.0",
|
|
29
|
+
"vitest": "^4.1.2"
|
|
30
|
+
},
|
|
31
|
+
"sideEffects": false,
|
|
32
|
+
"description": "LLui router — structured path matching, history/hash mode, link helper",
|
|
33
|
+
"keywords": [
|
|
34
|
+
"llui",
|
|
35
|
+
"router",
|
|
36
|
+
"routing",
|
|
37
|
+
"history",
|
|
38
|
+
"navigation"
|
|
39
|
+
],
|
|
40
|
+
"author": "Franco Ponticelli <franco.ponticelli@gmail.com>",
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "git+https://github.com/fponticelli/llui.git",
|
|
45
|
+
"directory": "packages/router"
|
|
46
|
+
},
|
|
47
|
+
"bugs": {
|
|
48
|
+
"url": "https://github.com/fponticelli/llui/issues"
|
|
49
|
+
},
|
|
50
|
+
"homepage": "https://github.com/fponticelli/llui/tree/main/packages/router#readme",
|
|
51
|
+
"files": [
|
|
52
|
+
"dist"
|
|
53
|
+
]
|
|
54
|
+
}
|