@heshammesalam/heshjs 0.1.0 → 0.3.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.
- package/README.md +19 -4
- package/cli.js +26 -0
- package/dist/architecture/diagnostics.d.ts +12 -0
- package/dist/architecture/diagnostics.js +8 -0
- package/dist/architecture/diagnostics.js.map +1 -0
- package/dist/architecture/html.d.ts +16 -0
- package/dist/architecture/html.js +132 -0
- package/dist/architecture/html.js.map +1 -0
- package/dist/architecture/index.d.ts +42 -0
- package/dist/architecture/index.js +418 -0
- package/dist/architecture/index.js.map +1 -0
- package/dist/compiler/index.js +2 -0
- package/dist/compiler/index.js.map +1 -1
- package/dist/destinations/index.d.ts +63 -0
- package/dist/destinations/index.js +185 -0
- package/dist/destinations/index.js.map +1 -0
- package/dist/dom/index.d.ts +2 -0
- package/dist/dom/index.js +5 -1
- package/dist/dom/index.js.map +1 -1
- package/dist/domains/index.d.ts +24 -0
- package/dist/domains/index.js +110 -0
- package/dist/domains/index.js.map +1 -0
- package/dist/hmvc/index.d.ts +35 -0
- package/dist/hmvc/index.js +49 -0
- package/dist/hmvc/index.js.map +1 -0
- package/dist/parser/index.d.ts +15 -1
- package/dist/parser/index.js +73 -3
- package/dist/parser/index.js.map +1 -1
- package/dist/presentation/index.d.ts +9 -0
- package/dist/presentation/index.js +38 -0
- package/dist/presentation/index.js.map +1 -0
- package/dist/router/index.d.ts +24 -0
- package/dist/router/index.js +258 -0
- package/dist/router/index.js.map +1 -0
- package/dist/vite/index.d.ts +4 -1
- package/dist/vite/index.js +37 -2
- package/dist/vite/index.js.map +1 -1
- package/docs/contracts.md +60 -0
- package/docs/domain-graph.md +205 -0
- package/docs/grammar.md +4 -0
- package/docs/hmvc.md +134 -0
- package/package.json +10 -3
- package/src/architecture/diagnostics.ts +11 -0
- package/src/architecture/html.ts +88 -0
- package/src/architecture/index.ts +283 -0
- package/src/compiler/index.ts +1 -0
- package/src/destinations/index.ts +146 -0
- package/src/dom/index.ts +6 -1
- package/src/domains/index.ts +97 -0
- package/src/hmvc/index.ts +71 -0
- package/src/parser/index.ts +55 -4
- package/src/presentation/index.ts +31 -0
- package/src/router/index.ts +171 -0
- package/src/vite/index.ts +25 -2
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { createRouter } from '../router/index.js';
|
|
2
|
+
import { defineModule, useController, type RouteLocation } from '../hmvc/index.js';
|
|
3
|
+
import { createDomainGraph, ClientPolicyDenied, type DomainDefinition, type DomainOptions, type DomainGraph } from '../domains/index.js';
|
|
4
|
+
import { signal, effect, onCleanup, scope, type Disposer } from '../reactivity/index.js';
|
|
5
|
+
import { mount, element, text } from '../dom/index.js';
|
|
6
|
+
import type { PresentationState } from '../presentation/index.js';
|
|
7
|
+
export type InputType = 'string' | 'number' | 'boolean';
|
|
8
|
+
export interface TransitionOptions { query?: Record<string, string>; hash?: string; presentation?: 'page' }
|
|
9
|
+
export interface Destination<I extends object> {
|
|
10
|
+
readonly name: string;
|
|
11
|
+
href(input: I, options?: TransitionOptions): string;
|
|
12
|
+
open(input: I, options?: TransitionOptions): void;
|
|
13
|
+
replace(input: I, options?: TransitionOptions): void;
|
|
14
|
+
/** Prefetch code/resources, not business data or authorization results. */
|
|
15
|
+
prefetch(input: I): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
export interface PresentationContext {
|
|
18
|
+
domains: Record<string, unknown>;
|
|
19
|
+
input: Record<string, string | number | boolean>;
|
|
20
|
+
location: RouteLocation;
|
|
21
|
+
signal: AbortSignal;
|
|
22
|
+
}
|
|
23
|
+
export interface PresentationResources {
|
|
24
|
+
create(context: PresentationContext): PresentationState;
|
|
25
|
+
view(parent: Node, state: PresentationState, destinations: Record<string, Destination<Record<string, unknown>>>): void;
|
|
26
|
+
}
|
|
27
|
+
export interface DestinationDefinition {
|
|
28
|
+
name: string; url: string; input: Record<string, InputType>; owner: string; access?: string;
|
|
29
|
+
load(): Promise<PresentationResources>;
|
|
30
|
+
}
|
|
31
|
+
export interface DestinationEvent { type: 'loading' | 'ready' | 'blocked' | 'error' | 'cancelled' | 'disposed'; destination: string; reason?: string }
|
|
32
|
+
export interface ApplicationOptions extends DomainOptions { window?: Window; onError?: (error: unknown) => void; onDiagnostic?: (event: DestinationEvent) => void }
|
|
33
|
+
export interface GraphApplication<D = Record<string, Destination<Record<string, unknown>>>, G = Record<string, Record<string, unknown>>> {
|
|
34
|
+
destinations: D;
|
|
35
|
+
domains: G;
|
|
36
|
+
status: { get(): { phase: 'idle' | 'loading' | 'ready' | 'error'; destination?: string; error?: unknown } };
|
|
37
|
+
start(parent: Node): Disposer;
|
|
38
|
+
stop(): void;
|
|
39
|
+
}
|
|
40
|
+
export function validateInput(schema: Record<string, InputType>, input: object): Record<string, string | number | boolean> {
|
|
41
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Destination input must be an object');
|
|
42
|
+
const values = input as Record<string, unknown>;
|
|
43
|
+
for (const key of Object.keys(values)) if (!Object.hasOwn(schema, key)) throw new Error(`Unknown destination input ${key}`);
|
|
44
|
+
const result: Record<string, string | number | boolean> = Object.create(null);
|
|
45
|
+
for (const [key, type] of Object.entries(schema)) {
|
|
46
|
+
const value = values[key];
|
|
47
|
+
if (!Object.hasOwn(values, key) || typeof value !== type || (type === 'number' && !Number.isFinite(value)) || (type === 'string' && !(value as string).length)) throw new Error(`Destination input ${key} must be ${type}${type === 'string' ? ' (nonempty)' : ''}`);
|
|
48
|
+
if (type === 'string' && (value === '.' || value === '..')) throw new Error(`Destination input ${key} cannot be a URL dot segment`);
|
|
49
|
+
result[key] = value as string | number | boolean;
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
function decodeInput(definition: DestinationDefinition, params: Readonly<Record<string, string>>): Record<string, string | number | boolean> {
|
|
54
|
+
const values: Record<string, unknown> = Object.create(null);
|
|
55
|
+
for (const [key, type] of Object.entries(definition.input)) {
|
|
56
|
+
const raw = params[key];
|
|
57
|
+
if (raw === undefined) throw new Error(`Missing URL input ${key}`);
|
|
58
|
+
if (type === 'boolean' && raw !== 'true' && raw !== 'false') throw new Error(`URL input ${key} must be true/false`);
|
|
59
|
+
if (type === 'number' && !raw.trim()) throw new Error(`URL input ${key} must be numeric`);
|
|
60
|
+
values[key] = type === 'number' ? Number(raw) : type === 'boolean' ? raw === 'true' : raw;
|
|
61
|
+
}
|
|
62
|
+
return validateInput(definition.input, values);
|
|
63
|
+
}
|
|
64
|
+
export function createGraphApplication(definitions: readonly DomainDefinition[], pages: readonly DestinationDefinition[], options: ApplicationOptions = {}): GraphApplication {
|
|
65
|
+
const graph = createDomainGraph(definitions, options);
|
|
66
|
+
const status = signal<{ phase: 'idle' | 'loading' | 'ready' | 'error'; destination?: string; error?: unknown }>({ phase: 'idle' });
|
|
67
|
+
const report = options.onError ?? (error => console.error(error));
|
|
68
|
+
const emit = (type: DestinationEvent['type'], destination: string, error?: unknown) => {
|
|
69
|
+
if (!options.onDiagnostic) return;
|
|
70
|
+
try { options.onDiagnostic({ type, destination, ...(error === undefined ? {} : { reason: error instanceof Error ? error.message : String(error) }) }); }
|
|
71
|
+
catch (failure) { report(failure); }
|
|
72
|
+
};
|
|
73
|
+
const destinations: Record<string, Destination<Record<string, unknown>>> = Object.create(null);
|
|
74
|
+
const code = new Map<string, Promise<PresentationResources>>();
|
|
75
|
+
let stopped = false;
|
|
76
|
+
const load = (page: DestinationDefinition) => {
|
|
77
|
+
let result = code.get(page.name);
|
|
78
|
+
if (!result) { result = Promise.resolve().then(page.load).catch(error => { code.delete(page.name); throw error; }); code.set(page.name, result); }
|
|
79
|
+
return result;
|
|
80
|
+
};
|
|
81
|
+
const authorize = (page: DestinationDefinition) => {
|
|
82
|
+
if (page.access && options.authorize?.(page.access) !== true) throw new ClientPolicyDenied(page.access, page.name);
|
|
83
|
+
};
|
|
84
|
+
const routes = pages.map(page => ({ path: page.url, module: defineModule({
|
|
85
|
+
model: context => context.route,
|
|
86
|
+
controller: route => route,
|
|
87
|
+
view(parent) {
|
|
88
|
+
const route = useController<{ get(): RouteLocation }>();
|
|
89
|
+
effect(() => {
|
|
90
|
+
const location = route.get();
|
|
91
|
+
const abort = new AbortController(); let dispose: Disposer | undefined, settled = false;
|
|
92
|
+
onCleanup(() => { abort.abort(); dispose?.(); emit(settled ? 'disposed' : 'cancelled', page.name); });
|
|
93
|
+
const message = element(parent, 'p'); message.setAttribute('role', 'status'); text(message, () => 'Loading…');
|
|
94
|
+
status.set({ phase: 'loading', destination: page.name });
|
|
95
|
+
emit('loading', page.name);
|
|
96
|
+
const failed = (error: unknown) => {
|
|
97
|
+
if (abort.signal.aborted) return;
|
|
98
|
+
settled = true;
|
|
99
|
+
message.textContent = 'Unable to open this destination.'; message.setAttribute('role', 'alert');
|
|
100
|
+
status.set({ phase: 'error', destination: page.name, error }); emit(error instanceof ClientPolicyDenied ? 'blocked' : 'error', page.name, error); report(error);
|
|
101
|
+
};
|
|
102
|
+
let input: ReturnType<typeof validateInput>;
|
|
103
|
+
try { authorize(page); input = decodeInput(page, location.params); } catch (error) { failed(error); return; }
|
|
104
|
+
void load(page).then(resources => {
|
|
105
|
+
if (abort.signal.aborted) return;
|
|
106
|
+
const owned = scope(() => {
|
|
107
|
+
const state = resources.create({ input, domains: graph.forDomain(page.owner), location, signal: abort.signal });
|
|
108
|
+
if (state && typeof (state as unknown as { then?: unknown }).then === 'function') throw new Error('Presentation factories must be synchronous; use the provided AbortSignal for async work');
|
|
109
|
+
mount(() => resources.view(parent, state, destinations), parent, { onError: report });
|
|
110
|
+
});
|
|
111
|
+
dispose = owned.dispose;
|
|
112
|
+
if (abort.signal.aborted) { dispose(); return; }
|
|
113
|
+
settled = true; message.remove(); status.set({ phase: 'ready', destination: page.name }); emit('ready', page.name);
|
|
114
|
+
// Page-only focus management; opt-in target or the first heading.
|
|
115
|
+
const target = (parent as Element).querySelector?.('[data-hesh-focus], h1, h2') as HTMLElement | null | undefined;
|
|
116
|
+
if (target) { if (!target.hasAttribute('tabindex')) target.setAttribute('tabindex', '-1'); target.focus({ preventScroll: true }); }
|
|
117
|
+
}).catch(failed);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}) }));
|
|
121
|
+
let router: ReturnType<typeof createRouter>;
|
|
122
|
+
try { router = createRouter({ routes, window: options.window, onError: report }); }
|
|
123
|
+
catch (error) { graph.dispose(); throw error; }
|
|
124
|
+
for (const page of pages) {
|
|
125
|
+
if (destinations[page.name]) { graph.dispose(); throw new Error(`Duplicate destination ${page.name}`); }
|
|
126
|
+
const href = (input: object, transition: TransitionOptions = {}) => {
|
|
127
|
+
if (stopped) throw new Error('Application is disposed');
|
|
128
|
+
if (transition.presentation && transition.presentation !== 'page') throw new Error('Only page presentation is supported');
|
|
129
|
+
const values = validateInput(page.input, input);
|
|
130
|
+
const path = page.url.split('/').map(part => part.startsWith(':') ? encodeURIComponent(String(values[part.slice(1)])) : part).join('/');
|
|
131
|
+
const query = new URLSearchParams(transition.query).toString();
|
|
132
|
+
const hash = transition.hash ? '#' + encodeURIComponent(transition.hash) : '';
|
|
133
|
+
return path + (query ? '?' + query : '') + hash;
|
|
134
|
+
};
|
|
135
|
+
destinations[page.name] = Object.freeze({ name: page.name, href,
|
|
136
|
+
open(input: object, transition?: TransitionOptions) { authorize(page); router.navigate(href(input, transition)); },
|
|
137
|
+
replace(input: object, transition?: TransitionOptions) { authorize(page); router.navigate(href(input, transition), { replace: true }); },
|
|
138
|
+
async prefetch(input: object) { href(input); await load(page); }
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
const stop = () => { if (stopped) return; stopped = true; try { router.stop(); } finally { try { graph.dispose(); } finally { code.clear(); status.set({ phase: 'idle' }); } } };
|
|
142
|
+
return { destinations: Object.freeze(destinations), domains: graph.domains, status: { get: status.get },
|
|
143
|
+
start(parent) { if (stopped) throw new Error('Application is disposed'); try { router.start(parent); return stop; } catch (error) { stop(); throw error; } }, stop };
|
|
144
|
+
}
|
|
145
|
+
export { createDomainGraph };
|
|
146
|
+
export type { DomainDefinition, DomainGraph };
|
package/src/dom/index.ts
CHANGED
|
@@ -12,7 +12,12 @@ export function mount(component: Component, parent: Node, options: MountOptions
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export function element(parent: Node, name: string): HTMLElement {
|
|
15
|
-
|
|
15
|
+
return nativeElement(parent, aliases[name] ?? name);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Creates the exact HTML tag name, without Hesh shorthand aliases. */
|
|
19
|
+
export function nativeElement(parent: Node, name: string): HTMLElement {
|
|
20
|
+
const node = parent.ownerDocument!.createElement(name);
|
|
16
21
|
parent.appendChild(node);
|
|
17
22
|
onCleanup(() => node.remove());
|
|
18
23
|
return node;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { scope, type Disposer } from '../reactivity/index.js';
|
|
2
|
+
export interface DomainDefinition {
|
|
3
|
+
name: string;
|
|
4
|
+
exposes: readonly string[];
|
|
5
|
+
consumes: readonly string[];
|
|
6
|
+
access?: Readonly<Record<string, string>>;
|
|
7
|
+
factory: (dependencies: Record<string, Record<string, unknown>>) => unknown;
|
|
8
|
+
}
|
|
9
|
+
export interface DomainOptions { authorize?: (policy: string) => boolean }
|
|
10
|
+
export class ClientPolicyDenied extends Error {
|
|
11
|
+
readonly code = 'HESH-ACCESS-001';
|
|
12
|
+
constructor(readonly policy: string, readonly contract: string) {
|
|
13
|
+
super(`Client policy denied: ${policy} (${contract}). Server authorization is still required.`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export interface DomainGraph<T = Record<string, Record<string, unknown>>> {
|
|
17
|
+
readonly domains: T;
|
|
18
|
+
forDomain(name: string): Record<string, unknown>;
|
|
19
|
+
dispose: Disposer;
|
|
20
|
+
}
|
|
21
|
+
const unsafe = new Set(['__proto__', 'constructor', 'prototype']);
|
|
22
|
+
export function contractPath(path: string): string[] {
|
|
23
|
+
const parts = path.split('.');
|
|
24
|
+
if (parts.some(p => !p || unsafe.has(p))) throw new Error(`Invalid contract path ${path}`);
|
|
25
|
+
return parts;
|
|
26
|
+
}
|
|
27
|
+
function project(target: Record<string, unknown>, path: string[], value: unknown): void {
|
|
28
|
+
const name = path[0]!;
|
|
29
|
+
if (path.length === 1) { target[name] = value; return; }
|
|
30
|
+
if (target[name] !== undefined && (typeof target[name] !== 'object' || target[name] === null)) throw new Error(`Overlapping public contracts at ${name}`);
|
|
31
|
+
const next = (target[name] ??= Object.create(null)) as Record<string, unknown>;
|
|
32
|
+
project(next, path.slice(1), value);
|
|
33
|
+
}
|
|
34
|
+
function freeze(value: Record<string, unknown>): Record<string, unknown> {
|
|
35
|
+
for (const child of Object.values(value)) if (child && typeof child === 'object') freeze(child as Record<string, unknown>);
|
|
36
|
+
return Object.freeze(value);
|
|
37
|
+
}
|
|
38
|
+
function lookup(value: unknown, parts: string[]): { value: unknown; receiver: unknown } {
|
|
39
|
+
let receiver: unknown;
|
|
40
|
+
for (const part of parts) {
|
|
41
|
+
receiver = value;
|
|
42
|
+
value = value != null && Object.hasOwn(Object(value), part) ? (value as Record<string, unknown>)[part] : undefined;
|
|
43
|
+
}
|
|
44
|
+
return { value, receiver };
|
|
45
|
+
}
|
|
46
|
+
export function createDomainGraph(definitions: readonly DomainDefinition[], options: DomainOptions = {}): DomainGraph {
|
|
47
|
+
const byName = new Map(definitions.map(d => [d.name, d]));
|
|
48
|
+
if (byName.size !== definitions.length) throw new Error('Duplicate domain name');
|
|
49
|
+
const domains: Record<string, Record<string, unknown>> = Object.create(null);
|
|
50
|
+
const contexts: Record<string, Record<string, unknown>> = Object.create(null);
|
|
51
|
+
const building = new Set<string>();
|
|
52
|
+
let disposed = false;
|
|
53
|
+
const owned = scope(() => {
|
|
54
|
+
const build = (name: string): Record<string, unknown> => {
|
|
55
|
+
if (domains[name]) return domains[name]!;
|
|
56
|
+
const definition = byName.get(name);
|
|
57
|
+
if (!definition) throw new Error(`Missing domain ${name}`);
|
|
58
|
+
if (building.has(name)) throw new Error(`Circular domain dependency at ${name}`);
|
|
59
|
+
building.add(name);
|
|
60
|
+
const dependencies: Record<string, Record<string, unknown>> = Object.create(null);
|
|
61
|
+
for (const dependency of definition.consumes) {
|
|
62
|
+
const [provider, ...operation] = contractPath(dependency);
|
|
63
|
+
if (!operation.length) throw new Error(`Dependency must name an operation: ${dependency}`);
|
|
64
|
+
const target = build(provider!);
|
|
65
|
+
const value = lookup(target, operation).value;
|
|
66
|
+
if (typeof value !== 'function') throw new Error(`Missing public operation ${dependency}`);
|
|
67
|
+
project(dependencies[provider!] ??= Object.create(null), operation, value);
|
|
68
|
+
}
|
|
69
|
+
freeze(dependencies);
|
|
70
|
+
const implementation = definition.factory(dependencies);
|
|
71
|
+
if (implementation && typeof (implementation as { then?: unknown }).then === 'function') throw new Error(`Domain ${name} factory must be synchronous`);
|
|
72
|
+
const facade: Record<string, unknown> = Object.create(null);
|
|
73
|
+
for (const operation of definition.exposes) {
|
|
74
|
+
const parts = contractPath(operation);
|
|
75
|
+
const { value, receiver } = lookup(implementation, parts);
|
|
76
|
+
if (typeof value !== 'function') throw new Error(`Unresolved implementation ${name}.${operation}`);
|
|
77
|
+
project(facade, parts, (...args: unknown[]) => {
|
|
78
|
+
if (disposed) throw new Error('Domain graph is disposed');
|
|
79
|
+
const policy = definition.access?.[operation];
|
|
80
|
+
if (policy && options.authorize?.(policy) !== true) throw new ClientPolicyDenied(policy, `${name}.${operation}`);
|
|
81
|
+
return Reflect.apply(value, receiver, args);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
domains[name] = freeze(facade);
|
|
85
|
+
contexts[name] = Object.freeze({ ...dependencies, [name]: domains[name] });
|
|
86
|
+
building.delete(name);
|
|
87
|
+
return domains[name]!;
|
|
88
|
+
};
|
|
89
|
+
for (const definition of definitions) build(definition.name);
|
|
90
|
+
freeze(domains);
|
|
91
|
+
});
|
|
92
|
+
return {
|
|
93
|
+
domains,
|
|
94
|
+
forDomain(name) { if (disposed) throw new Error('Domain graph is disposed'); if (!contexts[name]) throw new Error(`Unknown domain context ${name}`); return contexts[name]!; },
|
|
95
|
+
dispose() { if (disposed) return; disposed = true; owned.dispose(); }
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { mount, element, type Component, type MountOptions } from '../dom/index.js';
|
|
2
|
+
import { signal, type Signal, type Disposer } from '../reactivity/index.js';
|
|
3
|
+
|
|
4
|
+
export interface RouteLocation {
|
|
5
|
+
readonly url: URL;
|
|
6
|
+
readonly pathname: string;
|
|
7
|
+
readonly params: Readonly<Record<string, string>>;
|
|
8
|
+
readonly query: URLSearchParams;
|
|
9
|
+
readonly hash: string;
|
|
10
|
+
}
|
|
11
|
+
export interface ModuleContext<M = unknown, C = unknown> {
|
|
12
|
+
readonly route: Pick<Signal<RouteLocation>, 'get'>;
|
|
13
|
+
readonly parent?: ModuleContext;
|
|
14
|
+
readonly model: M;
|
|
15
|
+
readonly controller: C;
|
|
16
|
+
}
|
|
17
|
+
export interface ModuleDefinition<M, C> {
|
|
18
|
+
model: (context: Pick<ModuleContext, 'route' | 'parent'>) => M;
|
|
19
|
+
controller: (model: M, context: Pick<ModuleContext, 'route' | 'parent'>) => C;
|
|
20
|
+
view: Component;
|
|
21
|
+
}
|
|
22
|
+
export interface MountedModule {
|
|
23
|
+
readonly context: ModuleContext;
|
|
24
|
+
readonly outlet?: Node;
|
|
25
|
+
readonly dispose: Disposer;
|
|
26
|
+
update(location: RouteLocation): void;
|
|
27
|
+
}
|
|
28
|
+
export interface AppModule {
|
|
29
|
+
attach(parent: Node, location: RouteLocation, parentContext?: ModuleContext, options?: MountOptions): MountedModule;
|
|
30
|
+
}
|
|
31
|
+
let current: { context: ModuleContext; outlet?: Node } | undefined;
|
|
32
|
+
|
|
33
|
+
export function useModule<M = unknown, C = unknown>(): ModuleContext<M, C> {
|
|
34
|
+
if (!current) throw new Error('Module context is only available during a module view mount');
|
|
35
|
+
return current.context as ModuleContext<M, C>;
|
|
36
|
+
}
|
|
37
|
+
export function useController<C = unknown>(): C { return useModule<unknown, C>().controller; }
|
|
38
|
+
export function useModel<M = unknown>(): M { return useModule<M>().model; }
|
|
39
|
+
|
|
40
|
+
/** Child route outlet. Import this component in a .hesh layout and call Outlet(). */
|
|
41
|
+
export function Outlet(parent: Node): void {
|
|
42
|
+
if (!current) throw new Error('Outlet requires a module view');
|
|
43
|
+
if (current.outlet) throw new Error('A module may render only one Outlet');
|
|
44
|
+
current.outlet = element(parent, 'div');
|
|
45
|
+
(current.outlet as HTMLElement).setAttribute('data-hesh-outlet', '');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function defineModule<M, C>(definition: ModuleDefinition<M, C>): AppModule {
|
|
49
|
+
return {
|
|
50
|
+
attach(parent, location, parentContext, options) {
|
|
51
|
+
const route = signal(location);
|
|
52
|
+
let state: { context: ModuleContext; outlet?: Node } | undefined;
|
|
53
|
+
const dispose = mount(() => {
|
|
54
|
+
const context = { route: { get: route.get }, parent: parentContext };
|
|
55
|
+
const model = definition.model(context);
|
|
56
|
+
const controller = definition.controller(model, context);
|
|
57
|
+
for (const value of [model, controller]) {
|
|
58
|
+
if (value && typeof (value as { then?: unknown }).then === 'function') throw new Error('HMVC model/controller factories must be synchronous');
|
|
59
|
+
}
|
|
60
|
+
state = { context: { ...context, model, controller } };
|
|
61
|
+
const previous = current;
|
|
62
|
+
current = state;
|
|
63
|
+
try {
|
|
64
|
+
const result: unknown = definition.view(parent);
|
|
65
|
+
if (result && typeof (result as { then?: unknown }).then === 'function') throw new Error('HMVC views must mount synchronously');
|
|
66
|
+
} finally { current = previous; }
|
|
67
|
+
}, parent, options);
|
|
68
|
+
return { context: state!.context, outlet: state!.outlet, dispose, update: next => { route.set(next); } };
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
package/src/parser/index.ts
CHANGED
|
@@ -4,7 +4,15 @@ export interface Expression extends Location { source: string }
|
|
|
4
4
|
export interface State extends Location { kind: 'state' | 'const'; name: string; value: Expression }
|
|
5
5
|
export interface Element extends Location { kind: 'element'; name: string; value?: Expression; handler?: Expression; async: boolean; children: Element[] }
|
|
6
6
|
export interface View extends Location { name: string; exported: boolean; default: boolean; body: (State | Element)[] }
|
|
7
|
-
export interface
|
|
7
|
+
export interface ContractEntry extends Location { name: string; value?: string }
|
|
8
|
+
export interface Contract extends Location {
|
|
9
|
+
kind: 'domain' | 'presentation' | 'destination' | 'application';
|
|
10
|
+
name: string;
|
|
11
|
+
fields: Record<string, ContractEntry>;
|
|
12
|
+
blocks: Record<string, ContractEntry[]>;
|
|
13
|
+
}
|
|
14
|
+
export interface Module { source: string; filename: string; imports: Expression[]; views: View[]; contracts?: Contract[] }
|
|
15
|
+
export interface ParsedModule extends Module { contracts: Contract[] }
|
|
8
16
|
export class HeshError extends Error {
|
|
9
17
|
readonly line: number;
|
|
10
18
|
readonly column: number;
|
|
@@ -61,7 +69,7 @@ export function tokenize(source: string, filename = 'component.hesh'): Token[] {
|
|
|
61
69
|
return tokens;
|
|
62
70
|
}
|
|
63
71
|
|
|
64
|
-
export function parse(source: string, filename = 'component.hesh'):
|
|
72
|
+
export function parse(source: string, filename = 'component.hesh'): ParsedModule {
|
|
65
73
|
const tokens = tokenize(source, filename);
|
|
66
74
|
let at = 0;
|
|
67
75
|
const peek = () => tokens[at]?.text;
|
|
@@ -119,8 +127,51 @@ export function parse(source: string, filename = 'component.hesh'): Module {
|
|
|
119
127
|
else take(';');
|
|
120
128
|
return { kind: 'element', name: name.text, start: name.start, end: tokens[at - 1]!.end, value, handler, async, children };
|
|
121
129
|
};
|
|
122
|
-
const
|
|
130
|
+
const contract = (): Contract => {
|
|
131
|
+
const kind = take(); const name = identifier(); take('{');
|
|
132
|
+
const result: Contract = { kind: kind.text as Contract['kind'], name: name.text, start: kind.start, end: 0, fields: Object.create(null), blocks: Object.create(null) };
|
|
133
|
+
const schemas: Record<Contract['kind'], Record<string, string>> = {
|
|
134
|
+
domain: { implementation: 'string', owns: 'list', exposes: 'list', consumes: 'list', access: 'map' },
|
|
135
|
+
presentation: { html: 'string', logic: 'string', styles: 'string', bindings: 'list', actions: 'list' },
|
|
136
|
+
destination: { url: 'string', input: 'map', presentation: 'identifier', access: 'string' },
|
|
137
|
+
application: { domains: 'list', destinations: 'list' }
|
|
138
|
+
};
|
|
139
|
+
const path = (): ContractEntry => {
|
|
140
|
+
const first = identifier(); let name = first.text;
|
|
141
|
+
while (peek() === '.') { take(); name += '.' + identifier().text; }
|
|
142
|
+
for (const part of name.split('.')) if (['__proto__', 'prototype', 'constructor'].includes(part)) fail('Unsafe contract identifier', first.start);
|
|
143
|
+
return { name, start: first.start, end: tokens[at - 1]!.end };
|
|
144
|
+
};
|
|
145
|
+
const string = () => {
|
|
146
|
+
const token = take();
|
|
147
|
+
if (token.kind !== ts.SyntaxKind.StringLiteral) fail('Expected quoted string', token.start);
|
|
148
|
+
const parsed = ts.createSourceFile('literal.js', token.text, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
|
|
149
|
+
return ((parsed.statements[0] as ts.ExpressionStatement).expression as ts.StringLiteral).text;
|
|
150
|
+
};
|
|
151
|
+
while (peek() && peek() !== '}') {
|
|
152
|
+
const key = take(); const type = Object.hasOwn(schemas[result.kind], key.text) ? schemas[result.kind][key.text] : undefined;
|
|
153
|
+
if (!type) fail(`Unknown ${result.kind} field ${key.text}`, key.start);
|
|
154
|
+
if (result.fields[key.text] || result.blocks[key.text]) fail(`Duplicate field ${key.text}`, key.start);
|
|
155
|
+
if (type === 'string' || type === 'identifier') {
|
|
156
|
+
take(':'); const value = type === 'string' ? string() : identifier().text; const end = take(';').end;
|
|
157
|
+
result.fields[key.text] = { name: key.text, value, start: key.start, end };
|
|
158
|
+
} else {
|
|
159
|
+
take('{'); const entries: ContractEntry[] = []; const seen = new Set<string>();
|
|
160
|
+
while (peek() && peek() !== '}') {
|
|
161
|
+
const entry = path();
|
|
162
|
+
if (seen.has(entry.name)) fail(`Duplicate entry ${entry.name}`, entry.start); seen.add(entry.name);
|
|
163
|
+
if (type === 'map') { take(':'); entry.value = key.text === 'input' ? identifier().text : string(); }
|
|
164
|
+
entry.end = take(';').end; entries.push(entry);
|
|
165
|
+
}
|
|
166
|
+
take('}'); result.blocks[key.text] = entries;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
result.end = take('}').end;
|
|
170
|
+
return result;
|
|
171
|
+
};
|
|
172
|
+
const module: ParsedModule = { source, filename, imports: [], views: [], contracts: [] };
|
|
123
173
|
while (tokens[at]) {
|
|
174
|
+
if (['domain', 'presentation', 'destination', 'application'].includes(peek() ?? '')) { module.contracts.push(contract()); continue; }
|
|
124
175
|
if (peek() === 'import') {
|
|
125
176
|
if (module.views.length) fail('Imports must precede views');
|
|
126
177
|
const start = take().start;
|
|
@@ -144,6 +195,6 @@ export function parse(source: string, filename = 'component.hesh'): Module {
|
|
|
144
195
|
const end = take('}').end;
|
|
145
196
|
module.views.push({ name, body, start, end, exported, default: isDefault });
|
|
146
197
|
}
|
|
147
|
-
if (!module.views.length) fail('Expected at least one view');
|
|
198
|
+
if (!module.views.length && !module.contracts.length) fail('Expected at least one view or architecture declaration');
|
|
148
199
|
return module;
|
|
149
200
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { effect } from '../reactivity/index.js';
|
|
2
|
+
import { nativeElement as element, text, attribute, listen } from '../dom/index.js';
|
|
3
|
+
export { element, text, attribute, listen };
|
|
4
|
+
export interface PresentationState { bindings: Record<string, unknown>; actions: Record<string, (event: Event) => unknown> }
|
|
5
|
+
export function readBinding(bindings: Record<string, unknown>, path: string): unknown {
|
|
6
|
+
const unwrap = (value: unknown): unknown => {
|
|
7
|
+
if (typeof value === 'function') return value();
|
|
8
|
+
if (value && typeof (value as { get?: unknown }).get === 'function') return (value as { get(): unknown }).get();
|
|
9
|
+
return value;
|
|
10
|
+
};
|
|
11
|
+
let value: unknown = bindings;
|
|
12
|
+
for (const part of path.split('.')) {
|
|
13
|
+
if (['__proto__', 'constructor', 'prototype'].includes(part)) throw new Error('Unsafe binding path');
|
|
14
|
+
value = (unwrap(value) as Record<string, unknown> | null)?.[part];
|
|
15
|
+
}
|
|
16
|
+
return unwrap(value);
|
|
17
|
+
}
|
|
18
|
+
export function bindValue(node: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement, bindings: Record<string, unknown>, name: string): void {
|
|
19
|
+
const state = bindings[name] as { get?(): unknown; set?(value: unknown): void };
|
|
20
|
+
if (typeof state?.get !== 'function' || typeof state.set !== 'function') throw new Error(`h-value ${name} requires a writable signal`);
|
|
21
|
+
effect(() => { const value = String(state.get!() ?? ''); if (node.value !== value) node.value = value; });
|
|
22
|
+
listen(node, 'input', () => state.set!(node.value));
|
|
23
|
+
}
|
|
24
|
+
export function bindAction(node: HTMLElement, state: PresentationState, name: string): void {
|
|
25
|
+
const handler = state.actions[name];
|
|
26
|
+
if (typeof handler !== 'function') throw new Error(`Missing presentation action ${name}`);
|
|
27
|
+
listen(node, node.tagName === 'FORM' ? 'submit' : 'click', event => {
|
|
28
|
+
if (node.tagName === 'FORM') event.preventDefault();
|
|
29
|
+
return handler.call(state.actions, event);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -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
|
+
}
|