@flow-os/router 0.0.47-dev.1772054223
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/README.md +7 -0
- package/config/client/root.tsx +6 -0
- package/config/client/routes/404.tsx +23 -0
- package/config/client/routes/index.tsx +10 -0
- package/package.json +28 -0
- package/src/index.ts +20 -0
- package/src/jsx-runtime/index.ts +78 -0
- package/src/jsx-runtime/types.d.ts +19 -0
- package/src/router/index.ts +2 -0
- package/src/router/matcher.ts +105 -0
- package/src/router/router.ts +184 -0
- package/src/router/types.ts +34 -0
- package/src/router/utils.ts +45 -0
- package/tsconfig.json +18 -0
package/README.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export default function NotFound(path: string) {
|
|
2
|
+
return (
|
|
3
|
+
<div
|
|
4
|
+
role="alert"
|
|
5
|
+
style={{
|
|
6
|
+
minHeight: '100vh',
|
|
7
|
+
display: 'flex',
|
|
8
|
+
flexDirection: 'column',
|
|
9
|
+
alignItems: 'center',
|
|
10
|
+
justifyContent: 'center',
|
|
11
|
+
gap: '0.5rem',
|
|
12
|
+
}}
|
|
13
|
+
>
|
|
14
|
+
<h1 style={{ margin: 0, fontSize: '2rem' }}>404</h1>
|
|
15
|
+
<p style={{ margin: 0, color: 'var(--muted, #666)' }}>
|
|
16
|
+
{path ? `Page not found: ${path}` : 'Page not found'}
|
|
17
|
+
</p>
|
|
18
|
+
<a href="/" style={{ marginTop: '1rem' }}>
|
|
19
|
+
Back to home
|
|
20
|
+
</a>
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@flow-os/router",
|
|
3
|
+
"version": "0.0.47-dev.1772054223",
|
|
4
|
+
"license": "PolyForm-Shield-1.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"main": "./src/index.ts",
|
|
10
|
+
"types": "./src/index.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./src/index.ts",
|
|
14
|
+
"import": "./src/index.ts",
|
|
15
|
+
"default": "./src/index.ts"
|
|
16
|
+
},
|
|
17
|
+
"./jsx-runtime": {
|
|
18
|
+
"types": "./src/jsx-runtime/index.ts",
|
|
19
|
+
"import": "./src/jsx-runtime/index.ts",
|
|
20
|
+
"default": "./src/jsx-runtime/index.ts"
|
|
21
|
+
},
|
|
22
|
+
"./jsx-dev-runtime": {
|
|
23
|
+
"types": "./src/jsx-runtime/index.ts",
|
|
24
|
+
"import": "./src/jsx-runtime/index.ts",
|
|
25
|
+
"default": "./src/jsx-runtime/index.ts"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// ========== Core ==========
|
|
2
|
+
// App = root component (one <App /> in layout). run = call once in entry: run(App, import.meta.glob('./routes/**/*.{ts,tsx}'))
|
|
3
|
+
export { App, run } from './router';
|
|
4
|
+
|
|
5
|
+
// ========== Utilities ==========
|
|
6
|
+
export { go, params } from './router';
|
|
7
|
+
export { base, path, href, query, setQuery } from './router/utils';
|
|
8
|
+
/*
|
|
9
|
+
base() => 'https://localhost:5173'
|
|
10
|
+
|
|
11
|
+
path() => '/posts/123'
|
|
12
|
+
|
|
13
|
+
href() => 'https://localhost:5173/posts/123'
|
|
14
|
+
|
|
15
|
+
params() => ['posts', '123']
|
|
16
|
+
|
|
17
|
+
query() => URLSearchParams { page: '2' }
|
|
18
|
+
|
|
19
|
+
setQuery({ page: '2' }) => 'https://localhost:5173/posts/123?page=2'
|
|
20
|
+
*/
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** Minimal JSX runtime: creates DOM nodes. No React. */
|
|
2
|
+
export const Fragment = Symbol.for('flow.fragment');
|
|
3
|
+
|
|
4
|
+
type Props = Record<string, unknown> & { children?: unknown };
|
|
5
|
+
type JsxType = string | ((props: Props) => Node | null) | typeof Fragment;
|
|
6
|
+
|
|
7
|
+
function normalizeChild(c: unknown): Node | string | null {
|
|
8
|
+
if (c == null) return null;
|
|
9
|
+
if (typeof c === 'string' || typeof c === 'number') return String(c);
|
|
10
|
+
if (c instanceof Node) return c;
|
|
11
|
+
if (Array.isArray(c)) {
|
|
12
|
+
const frag = document.createDocumentFragment();
|
|
13
|
+
for (const x of c) {
|
|
14
|
+
const n = normalizeChild(x);
|
|
15
|
+
if (n !== null) frag.appendChild(typeof n === 'string' ? document.createTextNode(n) : n);
|
|
16
|
+
}
|
|
17
|
+
return frag;
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function appendChildren(el: Node, children: unknown): void {
|
|
23
|
+
const c = normalizeChild(children);
|
|
24
|
+
if (c === null) return;
|
|
25
|
+
if (typeof c === 'string') {
|
|
26
|
+
el.appendChild(document.createTextNode(c));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
el.appendChild(c);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function applyProps(el: HTMLElement, props: Props): void {
|
|
33
|
+
const { children, class: classProp, className: classNameProp, style: styleProp, ...rest } = props;
|
|
34
|
+
if (classProp != null) el.className = Array.isArray(classProp) ? classProp.join(' ') : String(classProp);
|
|
35
|
+
if (classNameProp != null) el.className = String(classNameProp);
|
|
36
|
+
if (styleProp != null && typeof styleProp === 'object') {
|
|
37
|
+
for (const [k, v] of Object.entries(styleProp)) {
|
|
38
|
+
if (v != null) (el.style as Record<string, string>)[k] = String(v);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
for (const [k, v] of Object.entries(rest)) {
|
|
42
|
+
if (v == null) continue;
|
|
43
|
+
if (k === 'href' && typeof v === 'string') (el as HTMLAnchorElement).href = v;
|
|
44
|
+
else if (k.startsWith('on') && typeof v === 'function') (el as unknown as Record<string, unknown>)[k.toLowerCase()] = v;
|
|
45
|
+
else el.setAttribute(k, String(v));
|
|
46
|
+
}
|
|
47
|
+
if (children !== undefined) appendChildren(el, children);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function jsxs(type: JsxType, props: Props, _key?: string | number): Node {
|
|
51
|
+
return jsx(type, props, _key);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function jsx(type: JsxType, props: Props, _key?: string | number): Node {
|
|
55
|
+
if (type === Fragment) {
|
|
56
|
+
const frag = document.createDocumentFragment();
|
|
57
|
+
if (props.children !== undefined) appendChildren(frag, props.children);
|
|
58
|
+
return frag;
|
|
59
|
+
}
|
|
60
|
+
if (typeof type === 'function') {
|
|
61
|
+
const out = type(props);
|
|
62
|
+
return out ?? document.createDocumentFragment();
|
|
63
|
+
}
|
|
64
|
+
const el = document.createElement(type as string);
|
|
65
|
+
applyProps(el, props);
|
|
66
|
+
return el;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function jsxDEV(
|
|
70
|
+
type: JsxType,
|
|
71
|
+
props: Props,
|
|
72
|
+
key: string | number | undefined,
|
|
73
|
+
_isStatic: boolean,
|
|
74
|
+
_source: unknown,
|
|
75
|
+
_self: unknown
|
|
76
|
+
): Node {
|
|
77
|
+
return jsx(type, props, key as string | undefined);
|
|
78
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
declare global {
|
|
2
|
+
namespace JSX {
|
|
3
|
+
type Element = Node;
|
|
4
|
+
type ElementType = keyof IntrinsicElements | ((props: Record<string, unknown>) => Element | null);
|
|
5
|
+
|
|
6
|
+
interface IntrinsicElements {
|
|
7
|
+
[tag: string]: {
|
|
8
|
+
class?: string;
|
|
9
|
+
className?: string;
|
|
10
|
+
style?: Record<string, string | number>;
|
|
11
|
+
href?: string;
|
|
12
|
+
children?: unknown;
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { RouteEntry } from './types';
|
|
2
|
+
|
|
3
|
+
const CATCH_ALL_PREFIX = ':*';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Converts a file-path key (e.g. from import.meta.glob) to a route pattern.
|
|
7
|
+
* - `routes/index.ts` → ''
|
|
8
|
+
* - `routes/about.ts` → 'about'
|
|
9
|
+
* - `routes/posts/[id].ts` → 'posts/:id'
|
|
10
|
+
* - `routes/docs/[...slug].ts` → 'docs/:*slug'
|
|
11
|
+
*/
|
|
12
|
+
export function pathToPattern(key: string): string {
|
|
13
|
+
const afterRoutes = key.replace(/^.*[/\\]routes[/\\]?/i, '').replace(/\.tsx?$/i, '');
|
|
14
|
+
const segments = afterRoutes.split(/[/\\]/).filter(Boolean);
|
|
15
|
+
if (segments.length === 0 || segments[0] === 'index') return '';
|
|
16
|
+
|
|
17
|
+
const patternParts = segments.map((seg) => {
|
|
18
|
+
if (seg.startsWith('[...') && seg.endsWith(']')) {
|
|
19
|
+
const name = seg.slice(4, -1);
|
|
20
|
+
return name ? `${CATCH_ALL_PREFIX}${name}` : `${CATCH_ALL_PREFIX}rest`;
|
|
21
|
+
}
|
|
22
|
+
if (seg.startsWith('[') && seg.endsWith(']')) {
|
|
23
|
+
return ':' + seg.slice(1, -1);
|
|
24
|
+
}
|
|
25
|
+
return seg;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
return patternParts.join('/');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Returns true if the pattern string contains a catch-all segment.
|
|
33
|
+
*/
|
|
34
|
+
export function isCatchAllPattern(pattern: string): boolean {
|
|
35
|
+
return pattern.includes(CATCH_ALL_PREFIX);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Counts static (non-param) segments for route sort order.
|
|
40
|
+
*/
|
|
41
|
+
export function countStaticSegments(pattern: string): number {
|
|
42
|
+
if (!pattern) return 0;
|
|
43
|
+
return pattern.split('/').filter((s) => s && !s.startsWith(':') && !s.startsWith(CATCH_ALL_PREFIX)).length;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Matches a path against a pattern. Supports :param and :*catchAll.
|
|
48
|
+
* - :param matches one segment.
|
|
49
|
+
* - :*name matches the rest of the path (one param with path segments joined by '/').
|
|
50
|
+
* Returns params object or null if no match.
|
|
51
|
+
*/
|
|
52
|
+
export function match(pattern: string, path: string): Record<string, string> | null {
|
|
53
|
+
const patternSegs = pattern ? pattern.split('/').filter(Boolean) : [];
|
|
54
|
+
const pathSegs = path.replace(/^\//, '').split('/').filter(Boolean);
|
|
55
|
+
|
|
56
|
+
const params: Record<string, string> = {};
|
|
57
|
+
let pi = 0;
|
|
58
|
+
|
|
59
|
+
for (let i = 0; i < patternSegs.length; i++) {
|
|
60
|
+
const seg = patternSegs[i]!;
|
|
61
|
+
if (seg.startsWith(CATCH_ALL_PREFIX)) {
|
|
62
|
+
const name = seg.slice(CATCH_ALL_PREFIX.length) || 'rest';
|
|
63
|
+
params[name] = pathSegs.slice(pi).join('/');
|
|
64
|
+
return params;
|
|
65
|
+
}
|
|
66
|
+
if (pi >= pathSegs.length) return null;
|
|
67
|
+
if (seg.startsWith(':')) {
|
|
68
|
+
params[seg.slice(1)] = pathSegs[pi] ?? '';
|
|
69
|
+
pi++;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (seg !== pathSegs[pi]) return null;
|
|
73
|
+
pi++;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return pi === pathSegs.length ? params : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Builds sorted route entries: more specific first (static > dynamic > catch-all),
|
|
81
|
+
* then by segment count descending.
|
|
82
|
+
*/
|
|
83
|
+
export function buildRouteEntries(
|
|
84
|
+
modules: Record<string, () => Promise<import('./types').RouteModule>>
|
|
85
|
+
): RouteEntry[] {
|
|
86
|
+
const entries: RouteEntry[] = Object.entries(modules).map(([key, loader]) => {
|
|
87
|
+
const pattern = pathToPattern(key);
|
|
88
|
+
return {
|
|
89
|
+
pattern,
|
|
90
|
+
loader,
|
|
91
|
+
staticSegments: countStaticSegments(pattern),
|
|
92
|
+
catchAll: isCatchAllPattern(pattern),
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
entries.sort((a, b) => {
|
|
97
|
+
if (a.catchAll !== b.catchAll) return a.catchAll ? 1 : -1;
|
|
98
|
+
if (a.staticSegments !== b.staticSegments) return b.staticSegments - a.staticSegments;
|
|
99
|
+
const lenA = a.pattern.split('/').filter(Boolean).length;
|
|
100
|
+
const lenB = b.pattern.split('/').filter(Boolean).length;
|
|
101
|
+
return lenB - lenA;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return entries;
|
|
105
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { RouteModule, RouterOptions, RunOptions } from './types';
|
|
2
|
+
import { buildRouteEntries, match } from './matcher';
|
|
3
|
+
|
|
4
|
+
let currentRouter: RouterAPI | null = null;
|
|
5
|
+
let currentParams: Record<string, string> = {};
|
|
6
|
+
|
|
7
|
+
export const APP_ID = 'flow-app';
|
|
8
|
+
|
|
9
|
+
/** Root container where the router mounts the current page. Use once in root as <App />. */
|
|
10
|
+
export function App(): HTMLDivElement {
|
|
11
|
+
const el = document.createElement('div');
|
|
12
|
+
el.id = APP_ID;
|
|
13
|
+
el.setAttribute('role', 'main');
|
|
14
|
+
return el;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function setContent(container: HTMLElement, content: Node | string): void {
|
|
18
|
+
container.innerHTML = '';
|
|
19
|
+
if (typeof content === 'string') container.textContent = content;
|
|
20
|
+
else container.appendChild(content);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function defaultNotFound(path: string): Node {
|
|
24
|
+
const wrap = document.createElement('div');
|
|
25
|
+
wrap.setAttribute('role', 'alert');
|
|
26
|
+
const h = document.createElement('h1');
|
|
27
|
+
h.textContent = '404';
|
|
28
|
+
const p = document.createElement('p');
|
|
29
|
+
p.textContent = `Page not found: ${path || '/'}`;
|
|
30
|
+
wrap.append(h, p);
|
|
31
|
+
return wrap;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RouterAPI {
|
|
35
|
+
/** Navigate to a path. Use replace=true to avoid adding a history entry. */
|
|
36
|
+
go(path: string, replace?: boolean): void;
|
|
37
|
+
/** Start listening to popstate and link clicks. Call once after mounting App. */
|
|
38
|
+
start(): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const NOT_FOUND_PATTERN = '404';
|
|
42
|
+
|
|
43
|
+
export function createRouter(
|
|
44
|
+
modules: Record<string, () => Promise<RouteModule>>,
|
|
45
|
+
container: HTMLElement,
|
|
46
|
+
options: RouterOptions = {}
|
|
47
|
+
): RouterAPI {
|
|
48
|
+
const allEntries = buildRouteEntries(modules);
|
|
49
|
+
const routes = allEntries.filter((e) => e.pattern !== NOT_FOUND_PATTERN);
|
|
50
|
+
const notFoundLoader = allEntries.find((e) => e.pattern === NOT_FOUND_PATTERN)?.loader ?? null;
|
|
51
|
+
|
|
52
|
+
const notFound: Node | ((path: string) => Node | Promise<Node>) =
|
|
53
|
+
options.notFound ??
|
|
54
|
+
(notFoundLoader
|
|
55
|
+
? (path) =>
|
|
56
|
+
notFoundLoader().then((mod) => {
|
|
57
|
+
const def = mod.default;
|
|
58
|
+
return typeof def === 'function' ? (def as (p: string) => Node)(path) : (def as unknown as Node);
|
|
59
|
+
})
|
|
60
|
+
: defaultNotFound);
|
|
61
|
+
const fullPageNotFound = Boolean(
|
|
62
|
+
options.appRoot && options.getLayout && (options.notFound != null || notFoundLoader != null)
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
function getContainer(): HTMLElement | null {
|
|
66
|
+
return document.getElementById(APP_ID);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function render(pathname: string): Promise<boolean> {
|
|
70
|
+
const path = pathname === '/' ? '' : pathname.slice(1);
|
|
71
|
+
|
|
72
|
+
for (const { pattern, loader } of routes) {
|
|
73
|
+
const routeParams = match(pattern, path);
|
|
74
|
+
if (routeParams === null) continue;
|
|
75
|
+
|
|
76
|
+
currentParams = routeParams;
|
|
77
|
+
let target = getContainer();
|
|
78
|
+
if (fullPageNotFound && !target) {
|
|
79
|
+
options.appRoot!.innerHTML = '';
|
|
80
|
+
options.appRoot!.appendChild(options.getLayout!());
|
|
81
|
+
target = getContainer();
|
|
82
|
+
}
|
|
83
|
+
if (!target) continue;
|
|
84
|
+
|
|
85
|
+
const fallbackNode =
|
|
86
|
+
options.fallback != null
|
|
87
|
+
? typeof options.fallback === 'function'
|
|
88
|
+
? options.fallback()
|
|
89
|
+
: options.fallback
|
|
90
|
+
: null;
|
|
91
|
+
if (fallbackNode) setContent(target, fallbackNode);
|
|
92
|
+
|
|
93
|
+
const mod = await loader();
|
|
94
|
+
const def = mod.default;
|
|
95
|
+
if (typeof def === 'function') {
|
|
96
|
+
const out = def.length
|
|
97
|
+
? (def as (p: Record<string, string>) => Node)(routeParams)
|
|
98
|
+
: (def as () => Node | string)();
|
|
99
|
+
if (out instanceof Node) setContent(target, out);
|
|
100
|
+
else if (typeof out === 'string') target.innerHTML = out;
|
|
101
|
+
else target.innerHTML = '';
|
|
102
|
+
} else {
|
|
103
|
+
setContent(target, typeof def === 'string' ? def : (def as unknown as Node));
|
|
104
|
+
}
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
currentParams = {};
|
|
109
|
+
const nfRaw = typeof notFound === 'function' ? notFound(pathname) : notFound;
|
|
110
|
+
const nfNode = nfRaw && typeof (nfRaw as Promise<unknown>).then === 'function' ? await (nfRaw as Promise<Node>) : (nfRaw as Node);
|
|
111
|
+
if (fullPageNotFound) {
|
|
112
|
+
options.appRoot!.innerHTML = '';
|
|
113
|
+
options.appRoot!.appendChild(nfNode);
|
|
114
|
+
} else {
|
|
115
|
+
setContent(container, nfNode);
|
|
116
|
+
}
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function go(path: string, replace = false): void {
|
|
121
|
+
const p = path.startsWith('/') ? path : `/${path}`;
|
|
122
|
+
render(p).then((ok) => {
|
|
123
|
+
if (ok) (replace ? history.replaceState : history.pushState).call(history, null, '', p);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function start(): void {
|
|
128
|
+
render(location.pathname || '/');
|
|
129
|
+
window.addEventListener('popstate', () => render(location.pathname || '/'));
|
|
130
|
+
document.addEventListener('click', (e) => {
|
|
131
|
+
const a = (e.target as Element).closest('a');
|
|
132
|
+
if (a?.getAttribute('href')?.startsWith('/') && !a.href.startsWith('//')) {
|
|
133
|
+
e.preventDefault();
|
|
134
|
+
go(a.getAttribute('href')!);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const api: RouterAPI = { go, start };
|
|
140
|
+
currentRouter = api;
|
|
141
|
+
return api;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Navigate to a path. Import and use anywhere; works after run() (or createRouter().start()) has been called.
|
|
146
|
+
* No-op if the router is not started yet.
|
|
147
|
+
*/
|
|
148
|
+
export function go(path: string, replace = false): void {
|
|
149
|
+
currentRouter?.go(path, replace);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Current route params. params() → all, params('id') → one. */
|
|
153
|
+
export function params(): Record<string, string>;
|
|
154
|
+
export function params(name: string): string | undefined;
|
|
155
|
+
export function params(name?: string): Record<string, string> | string | undefined {
|
|
156
|
+
if (name === undefined) return { ...currentParams };
|
|
157
|
+
return currentParams[name] ?? undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Mounts App and starts the router. Use in your virtual entry.
|
|
162
|
+
* Returns the router API so you can call go() (and start()) from outside.
|
|
163
|
+
*/
|
|
164
|
+
export function run(
|
|
165
|
+
AppFactory: () => Node,
|
|
166
|
+
modules: Record<string, () => Promise<RouteModule>>,
|
|
167
|
+
options?: RunOptions
|
|
168
|
+
): RouterAPI {
|
|
169
|
+
const app = document.getElementById('app')!;
|
|
170
|
+
app.appendChild(AppFactory());
|
|
171
|
+
const appContainer = document.getElementById(APP_ID);
|
|
172
|
+
if (!appContainer) {
|
|
173
|
+
throw new Error(`#${APP_ID} not found: use <App /> from @flow-os/router in your root layout.`);
|
|
174
|
+
}
|
|
175
|
+
const routerOptions: RouterOptions = {
|
|
176
|
+
fallback: options?.fallback,
|
|
177
|
+
notFound: options?.notFound,
|
|
178
|
+
appRoot: app,
|
|
179
|
+
getLayout: () => AppFactory(),
|
|
180
|
+
};
|
|
181
|
+
const api = createRouter(modules, appContainer as HTMLElement, routerOptions);
|
|
182
|
+
api.start();
|
|
183
|
+
return api;
|
|
184
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Route module: default can be a component (with optional params), a factory, or raw string/Node. */
|
|
2
|
+
export type RouteModule = {
|
|
3
|
+
default:
|
|
4
|
+
| (() => Node)
|
|
5
|
+
| ((params: Record<string, string>) => Node)
|
|
6
|
+
| (() => string)
|
|
7
|
+
| string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** Options for run(): fallback while loading, custom 404. */
|
|
11
|
+
export type RunOptions = {
|
|
12
|
+
/** Node (or factory) shown while the lazy route is loading. */
|
|
13
|
+
fallback?: Node | (() => Node);
|
|
14
|
+
/** 404 page: if provided, shown full-page (replaces entire #app, no shared layout). */
|
|
15
|
+
notFound?: Node | ((path: string) => Node);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Internal router options (fallback, notFound, full-page 404 wiring). */
|
|
19
|
+
export type RouterOptions = {
|
|
20
|
+
fallback?: Node | (() => Node);
|
|
21
|
+
notFound?: Node | ((path: string) => Node | Promise<Node>);
|
|
22
|
+
appRoot?: HTMLElement;
|
|
23
|
+
getLayout?: () => Node;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Internal: normalized route entry for matching. */
|
|
27
|
+
export type RouteEntry = {
|
|
28
|
+
pattern: string;
|
|
29
|
+
loader: () => Promise<RouteModule>;
|
|
30
|
+
/** Number of static segments (for sort order). */
|
|
31
|
+
staticSegments: number;
|
|
32
|
+
/** True if pattern ends with catch-all. */
|
|
33
|
+
catchAll: boolean;
|
|
34
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Origin: protocol + host (e.g. https://localhost:5173 or https://example.com). */
|
|
2
|
+
export function base(): string {
|
|
3
|
+
return typeof location !== 'undefined' ? location.origin : '';
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** Current pathname (e.g. /posts/123). */
|
|
7
|
+
export function path(p?: string): string {
|
|
8
|
+
const raw = p ?? (typeof location !== 'undefined' ? location.pathname || '/' : '/');
|
|
9
|
+
return raw.startsWith('/') ? raw : `/${raw}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Full path + search + hash. No args = current. */
|
|
13
|
+
export function href(p?: string): string {
|
|
14
|
+
const basePath = path(p);
|
|
15
|
+
if (typeof location === 'undefined') return basePath;
|
|
16
|
+
return basePath + (location.search ?? '') + (location.hash ?? '');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Query (after ?). query() → all as object, query('key') → one value. */
|
|
20
|
+
export function query(): Record<string, string>;
|
|
21
|
+
export function query(name: string): string | null;
|
|
22
|
+
export function query(name?: string): Record<string, string> | string | null {
|
|
23
|
+
const s = typeof location !== 'undefined' ? location.search : '';
|
|
24
|
+
const p = new URLSearchParams(s);
|
|
25
|
+
if (name !== undefined) return p.get(name);
|
|
26
|
+
const out: Record<string, string> = {};
|
|
27
|
+
p.forEach((v, k) => (out[k] = v));
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** New path with query updated. go(setQuery({ page: '2' })) to navigate. */
|
|
32
|
+
export function setQuery(
|
|
33
|
+
obj: Record<string, string | number | boolean | undefined | null>,
|
|
34
|
+
p?: string
|
|
35
|
+
): string {
|
|
36
|
+
const basePath = path(p);
|
|
37
|
+
const current = query();
|
|
38
|
+
const next = { ...current };
|
|
39
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
40
|
+
if (value === undefined || value === null) delete next[key];
|
|
41
|
+
else next[key] = String(value);
|
|
42
|
+
}
|
|
43
|
+
const search = new URLSearchParams(next).toString();
|
|
44
|
+
return search ? `${basePath}?${search}` : basePath;
|
|
45
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"declaration": true,
|
|
10
|
+
"declarationMap": true,
|
|
11
|
+
"esModuleInterop": true,
|
|
12
|
+
"isolatedModules": true,
|
|
13
|
+
"jsx": "react-jsx",
|
|
14
|
+
"jsxImportSource": "@flow-os/router"
|
|
15
|
+
},
|
|
16
|
+
"include": ["src/**/*.ts", "src/**/*.d.ts", "config/**/*.tsx"],
|
|
17
|
+
"exclude": ["node_modules"]
|
|
18
|
+
}
|