@heshammesalam/heshjs 0.0.0-stage → 0.2.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.
@@ -0,0 +1,171 @@
1
+ import { signal, batch, type Disposer } from '../reactivity/index.js';
2
+ import type { AppModule, MountedModule, RouteLocation } from '../hmvc/index.js';
3
+ export type { RouteLocation } from '../hmvc/index.js';
4
+ export interface RouteRecord { path: string; module: AppModule; children?: readonly RouteRecord[] }
5
+ export interface RouterOptions {
6
+ routes: readonly RouteRecord[];
7
+ window?: Window;
8
+ notFound?: AppModule;
9
+ onError?: (error: unknown) => void;
10
+ }
11
+ interface Entry { record: RouteRecord; params: Record<string, string> }
12
+ interface Active extends Entry { instance: MountedModule }
13
+
14
+ function segments(path: string): string[] { return path.split('/').filter(Boolean); }
15
+ function validate(routes: readonly RouteRecord[], ancestors = new Set<string>()): void {
16
+ const siblings = new Set<string>();
17
+ for (const route of routes) {
18
+ if (/[?#]/.test(route.path)) throw new Error(`Route paths cannot contain query/hash: ${route.path}`);
19
+ const parts = segments(route.path);
20
+ const signature = parts.map(p => p.startsWith(':') ? ':' : p).join('/');
21
+ if (siblings.has(signature)) throw new Error(`Duplicate sibling route: ${route.path}`);
22
+ siblings.add(signature);
23
+ const names = new Set(ancestors);
24
+ parts.forEach((p, i) => {
25
+ if (p === '*' && (i !== parts.length - 1 || route.children?.length)) throw new Error('Wildcard must end a leaf route');
26
+ if (p.startsWith(':')) {
27
+ const name = p.slice(1);
28
+ if (!/^[a-zA-Z_][a-zA-Z\d_]*$/.test(name) || names.has(name) || ['__proto__', 'prototype', 'constructor'].includes(name)) throw new Error(`Invalid or duplicate route param: ${name}`);
29
+ names.add(name);
30
+ }
31
+ });
32
+ validate(route.children ?? [], names);
33
+ }
34
+ }
35
+ function find(routes: readonly RouteRecord[], parts: string[], at = 0, params: Record<string, string> = {}): Entry[] | undefined {
36
+ const ordered = [...routes].sort((a, b) => {
37
+ const aa = segments(a.path), bb = segments(b.path);
38
+ for (let i = 0; i < Math.max(aa.length, bb.length); i++) {
39
+ const rank = (p?: string) => p === '*' ? 0 : p?.startsWith(':') ? 1 : p ? 2 : 0.5;
40
+ const diff = rank(bb[i]) - rank(aa[i]); if (diff) return diff;
41
+ }
42
+ return 0;
43
+ });
44
+ for (const record of ordered) {
45
+ const own = { ...params }; let cursor = at, matched = true;
46
+ for (const part of segments(record.path)) {
47
+ if (part === '*') { own['wildcard'] = parts.slice(cursor).join('/'); cursor = parts.length; break; }
48
+ const actual = parts[cursor++];
49
+ if (actual === undefined) { matched = false; break; }
50
+ if (part.startsWith(':')) own[part.slice(1)] = actual;
51
+ else if (part !== actual) { matched = false; break; }
52
+ }
53
+ if (!matched) continue;
54
+ const entry = { record, params: own };
55
+ const children = find(record.children ?? [], parts, cursor, own);
56
+ if (children) return [entry, ...children];
57
+ if (cursor === parts.length) return [entry];
58
+ }
59
+ return undefined;
60
+ }
61
+
62
+ export function createRouter(options: RouterOptions) {
63
+ validate(options.routes);
64
+ const browser = options.window ?? (typeof window !== 'undefined' ? window : undefined);
65
+ if (!browser) throw new Error('createRouter requires a browser Window');
66
+ const report = options.onError ?? ((error: unknown) => console.error(error));
67
+ const location = signal<RouteLocation>(makeLocation(new URL(browser.location.href), {}));
68
+ let root: Node | undefined, active: Active[] = [], fallback: HTMLElement | undefined;
69
+ let transitioning = false;
70
+ let generation = 0;
71
+ const notFoundRecord: RouteRecord | undefined = options.notFound ? { path: '*', module: options.notFound } : undefined;
72
+ function makeLocation(url: URL, params: Record<string, string>): RouteLocation {
73
+ return Object.freeze({ url: new URL(url), pathname: url.pathname, params: Object.freeze({ ...params }), query: new URLSearchParams(url.search), hash: url.hash });
74
+ }
75
+ function render(url: URL): void {
76
+ if (!root) throw new Error('Start the router before navigating');
77
+ if (transitioning) throw new Error('Navigation during module mounting is not supported');
78
+ transitioning = true;
79
+ try {
80
+ let entries: Entry[] | undefined;
81
+ try { entries = find(options.routes, segments(url.pathname).map(decodeURIComponent)); }
82
+ catch (error) { if (!(error instanceof URIError)) throw error; }
83
+ if (!entries && notFoundRecord) entries = [{ record: notFoundRecord, params: {} }];
84
+ entries ??= [];
85
+ let keep = 0;
86
+ while (keep < active.length && keep < entries.length && active[keep]!.record === entries[keep]!.record && JSON.stringify(active[keep]!.params) === JSON.stringify(entries[keep]!.params)) keep++;
87
+ const removed = active.splice(keep);
88
+ const errors: unknown[] = [];
89
+ for (const item of removed.reverse()) { try { item.instance.dispose(); } catch (error) { errors.push(error); } }
90
+ fallback?.remove(); fallback = undefined;
91
+ if (errors.length) throw new AggregateError(errors, 'Route cleanup failed');
92
+ const added: Active[] = [];
93
+ try {
94
+ for (let i = keep; i < entries.length; i++) {
95
+ const entry = entries[i]!;
96
+ const parent = active.at(-1);
97
+ const outlet = parent ? parent.instance.outlet : root;
98
+ if (!outlet) throw new Error(`Parent module ${parent!.record.path} must render Outlet()`);
99
+ const instance = entry.record.module.attach(outlet, makeLocation(url, entry.params), parent?.instance.context, { onError: report });
100
+ const item = { ...entry, instance };
101
+ active.push(item); added.push(item);
102
+ }
103
+ } catch (error) {
104
+ active.splice(keep);
105
+ for (const item of added.reverse()) { try { item.instance.dispose(); } catch (cleanupError) { report(cleanupError); } }
106
+ throw error;
107
+ }
108
+ if (!entries.length) {
109
+ fallback = root.ownerDocument!.createElement('p');
110
+ fallback.setAttribute('role', 'status'); fallback.textContent = 'Page not found'; root.appendChild(fallback);
111
+ }
112
+ batch(() => {
113
+ for (const item of active) item.instance.update(makeLocation(url, item.params));
114
+ location.set(makeLocation(url, entries.at(-1)?.params ?? {}));
115
+ });
116
+ } finally { transitioning = false; }
117
+ }
118
+ function navigate(to: string, navigation: { replace?: boolean } = {}): void {
119
+ if (!root) throw new Error('Start the router before navigating');
120
+ const url = new URL(to, browser!.location.href);
121
+ if (!['http:', 'https:'].includes(url.protocol) || url.origin !== browser!.location.origin) throw new Error('Router navigation requires a same-origin HTTP(S) URL');
122
+ if (url.href === browser!.location.href) return;
123
+ render(url);
124
+ browser!.history[navigation.replace ? 'replaceState' : 'pushState'](null, '', url.href);
125
+ }
126
+ const popstate = () => {
127
+ try { if (location.get().url.href !== browser!.location.href) render(new URL(browser!.location.href)); }
128
+ catch (error) { report(error); }
129
+ };
130
+ const click = (event: MouseEvent) => {
131
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return;
132
+ const target = event.target as Element | null;
133
+ const anchor = target?.closest?.('a[href]');
134
+ if (!anchor || anchor.hasAttribute('download') || anchor.hasAttribute('data-hesh-reload') || (anchor.getAttribute('target') && anchor.getAttribute('target')!.toLowerCase() !== '_self')) return;
135
+ const href = anchor.getAttribute('href')!;
136
+ if (href.startsWith('#')) return;
137
+ let url: URL;
138
+ try { url = new URL(href, browser!.document.baseURI); } catch { return; }
139
+ if (!['http:', 'https:'].includes(url.protocol) || url.origin !== browser!.location.origin) return;
140
+ if (url.pathname === browser!.location.pathname && url.search === browser!.location.search && url.hash) return;
141
+ event.preventDefault();
142
+ try { navigate(url.href); } catch (error) { report(error); }
143
+ };
144
+ function stop(): void {
145
+ if (transitioning) throw new Error('Cannot stop the router during module mounting');
146
+ browser!.document.removeEventListener('click', click);
147
+ browser!.removeEventListener('popstate', popstate);
148
+ browser!.removeEventListener('hashchange', popstate);
149
+ const old = active; active = []; root = undefined;
150
+ generation++;
151
+ fallback?.remove(); fallback = undefined;
152
+ const errors: unknown[] = [];
153
+ for (const item of old.reverse()) { try { item.instance.dispose(); } catch (error) { errors.push(error); } }
154
+ if (errors.length) throw new AggregateError(errors, 'Router cleanup failed');
155
+ }
156
+ return {
157
+ location: { get: location.get }, navigate, stop,
158
+ start(parent: Node): Disposer {
159
+ if (root) throw new Error('Router is already started');
160
+ if (parent.ownerDocument !== browser!.document) throw new Error('Router root must belong to its Window');
161
+ const started = ++generation;
162
+ root = parent;
163
+ try { render(new URL(browser!.location.href)); }
164
+ catch (error) { stop(); throw error; }
165
+ browser!.document.addEventListener('click', click);
166
+ browser!.addEventListener('popstate', popstate);
167
+ browser!.addEventListener('hashchange', popstate);
168
+ return () => { if (generation === started) stop(); };
169
+ }
170
+ };
171
+ }
@@ -0,0 +1,18 @@
1
+ import type { Plugin } from 'vite';
2
+ import { compile } from '../compiler/index.js';
3
+ export function hesh(): Plugin {
4
+ let production = false;
5
+ return {
6
+ name: 'heshjs', enforce: 'pre',
7
+ config() { return { appType: 'spa' }; },
8
+ configResolved(config) { production = config.command === 'build'; },
9
+ transform(source, id) {
10
+ if (!id.endsWith('.hesh')) return;
11
+ const result = compile(source, { filename: id, development: !production });
12
+ return { code: result.code, map: null };
13
+ },
14
+ handleHotUpdate(context) {
15
+ if (context.file.endsWith('.hesh')) { context.server.ws.send({ type: 'full-reload' }); return []; }
16
+ }
17
+ };
18
+ }