Skip to content

Commit a8e38e5

Browse files
feat: add @microsoft/webui-router client-side routing package
- Navigation API interception for SPA transitions - Self-registering <webui-route> custom element via connectedCallback - Iterative path matching with :param, :param?, *splat (no regex) - Specificity-based route ranking - Opt-in lazy loading via loaders config with promise dedup - SSR-safe: preserves server-rendered DOM on initial navigation - Component inventory bloom filter for template dedup - webui:route:navigated CustomEvent after each navigation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 40433f3 commit a8e38e5

8 files changed

Lines changed: 1224 additions & 7 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,5 @@
1111
},
1212
"author": "Microsoft Edge",
1313
"license": "MIT",
14-
"packageManager": "pnpm@10.30.3"
14+
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268"
1515
}

packages/webui-router/package.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"name": "@microsoft/webui-router",
3+
"version": "0.0.1",
4+
"type": "module",
5+
"description": "Lightweight client-side router for WebUI apps. Intercepts navigation, matches routes locally or via server route-data, and hydrates components.",
6+
"license": "MIT",
7+
"exports": {
8+
".": {
9+
"import": {
10+
"types": "./dist/index.d.ts",
11+
"default": "./dist/index.js"
12+
}
13+
}
14+
},
15+
"files": [
16+
"dist/"
17+
],
18+
"scripts": {
19+
"build": "esbuild src/index.ts --bundle --outfile=dist/index.js --format=esm --platform=browser && tsc --emitDeclarationOnly",
20+
"typecheck": "tsc --noEmit"
21+
},
22+
"devDependencies": {
23+
"esbuild": "catalog:",
24+
"typescript": "catalog:"
25+
}
26+
}

packages/webui-router/src/index.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* @microsoft/webui-router — DOM-native client-side router for WebUI apps.
3+
*
4+
* Routes are `<webui-route>` custom elements (transformed from `<route>` at build time).
5+
* The router uses the Navigation API to intercept navigations and show/hide matching routes.
6+
*
7+
* @example
8+
* ```ts
9+
* import { Router } from '@microsoft/webui-router';
10+
* Router.start();
11+
* Router.navigate('/contacts/42');
12+
* ```
13+
*
14+
* @packageDocumentation
15+
*/
16+
17+
export { Router, WebUIRouter, WebUIRouteElement } from './router.js';
18+
export type { RouterConfig, NavigationEvent } from './types.js';
19+
export { matchPath, parseTemplate, specificity } from './matcher.js';
20+
export type { Segment, PathMatch } from './matcher.js';
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* Iterative path template matching — no regex.
3+
*
4+
* Supports `:param`, `:param?` (optional), and `*splat` segments.
5+
*/
6+
7+
/** A single parsed segment from a path template. */
8+
export type Segment =
9+
| { type: 'literal'; value: string }
10+
| { type: 'param'; name: string }
11+
| { type: 'optional'; name: string }
12+
| { type: 'splat'; name: string };
13+
14+
/** Result of a successful path match. */
15+
export interface PathMatch {
16+
params: Record<string, string>;
17+
}
18+
19+
/** Parse a path template string into typed segments. */
20+
export function parseTemplate(path: string): Segment[] {
21+
return path
22+
.split('/')
23+
.filter(Boolean)
24+
.map((seg): Segment => {
25+
if (seg.startsWith(':')) {
26+
const raw = seg.slice(1);
27+
if (raw.endsWith('?')) {
28+
return { type: 'optional', name: raw.slice(0, -1) };
29+
}
30+
return { type: 'param', name: raw };
31+
}
32+
if (seg.startsWith('*')) {
33+
return { type: 'splat', name: seg.slice(1) || 'rest' };
34+
}
35+
return { type: 'literal', value: seg };
36+
});
37+
}
38+
39+
// Module-level cache: template strings are static, avoid re-parsing.
40+
const templateCache = new Map<string, Segment[]>();
41+
42+
function getCachedSegments(template: string): Segment[] {
43+
let segs = templateCache.get(template);
44+
if (!segs) {
45+
segs = parseTemplate(template);
46+
templateCache.set(template, segs);
47+
}
48+
return segs;
49+
}
50+
51+
/**
52+
* Try to match a request path against a single route template.
53+
*
54+
* Returns bound parameters on success, `null` on failure.
55+
*/
56+
export function matchPath(
57+
template: string,
58+
requestPath: string,
59+
exact: boolean,
60+
): PathMatch | null {
61+
const segs = getCachedSegments(template);
62+
const parts = requestPath.split('/').filter(Boolean);
63+
const params: Record<string, string> = {};
64+
let pi = 0;
65+
66+
for (let si = 0; si < segs.length; si++) {
67+
const seg = segs[si];
68+
switch (seg.type) {
69+
case 'literal':
70+
if (pi >= parts.length || parts[pi] !== seg.value) return null;
71+
pi++;
72+
break;
73+
case 'param':
74+
if (pi >= parts.length) return null;
75+
params[seg.name] = decodeURIComponent(parts[pi++]);
76+
break;
77+
case 'optional':
78+
if (pi < parts.length) {
79+
params[seg.name] = decodeURIComponent(parts[pi++]);
80+
}
81+
break;
82+
case 'splat':
83+
params[seg.name] = parts.slice(pi).map(decodeURIComponent).join('/');
84+
pi = parts.length;
85+
break;
86+
}
87+
}
88+
89+
if (exact && pi < parts.length) return null;
90+
return { params };
91+
}
92+
93+
/**
94+
* Count the number of literal (non-param) segments in a template.
95+
* Used to rank matches by specificity.
96+
*/
97+
export function specificity(template: string): number {
98+
return getCachedSegments(template).filter(s => s.type === 'literal').length;
99+
}

0 commit comments

Comments
 (0)