@heshammesalam/heshjs 0.2.0 → 0.5.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 +17 -12
- package/cli.js +31 -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 +18 -0
- package/dist/architecture/html.js +150 -0
- package/dist/architecture/html.js.map +1 -0
- package/dist/architecture/index.d.ts +42 -0
- package/dist/architecture/index.js +428 -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 +186 -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/heshjs/index.d.ts +2 -0
- package/dist/heshjs/index.js +1 -0
- package/dist/heshjs/index.js.map +1 -1
- package/dist/hmvc/index.d.ts +2 -0
- package/dist/hmvc/index.js +11 -0
- package/dist/hmvc/index.js.map +1 -1
- 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 +12 -0
- package/dist/presentation/index.js +39 -0
- package/dist/presentation/index.js.map +1 -0
- package/dist/units/index.d.ts +13 -0
- package/dist/units/index.js +16 -0
- package/dist/units/index.js.map +1 -0
- package/dist/vite/index.d.ts +4 -1
- package/dist/vite/index.js +36 -2
- package/dist/vite/index.js.map +1 -1
- package/docs/contracts.md +67 -0
- package/docs/domain-graph.md +205 -0
- package/docs/grammar.md +4 -0
- package/docs/units.md +71 -0
- package/package.json +10 -4
- package/scaffold/assets/hesh-icon.png +0 -0
- package/scaffold/create.js +78 -0
- package/scaffold/template.js +188 -0
- package/src/architecture/diagnostics.ts +11 -0
- package/src/architecture/html.ts +101 -0
- package/src/architecture/index.ts +291 -0
- package/src/compiler/index.ts +1 -0
- package/src/destinations/index.ts +147 -0
- package/src/dom/index.ts +6 -1
- package/src/domains/index.ts +97 -0
- package/src/heshjs/index.ts +2 -0
- package/src/hmvc/index.ts +7 -0
- package/src/parser/index.ts +55 -4
- package/src/presentation/index.ts +33 -0
- package/src/units/index.ts +23 -0
- package/src/vite/index.ts +24 -2
package/src/compiler/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ const f = ts.factory;
|
|
|
11
11
|
|
|
12
12
|
export function compile(source: string, options: CompileOptions = {}): Result {
|
|
13
13
|
const ast = parse(source, options.filename);
|
|
14
|
+
if (ast.contracts.length) throw new HeshError('Architecture declarations require the project compiler (hesh check or hesh({ architecture: ... }))', source, ast.contracts[0]!.start, ast.filename);
|
|
14
15
|
const metadata: Binding[] = [];
|
|
15
16
|
const fail = (message: string, offset: number): never => { throw new HeshError(message, source, offset, ast.filename); };
|
|
16
17
|
const names = new Set<string>();
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { createRouter } from '../router/index.js';
|
|
2
|
+
import { defineModule, useController, useModule, withModuleContext, 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 parentContext = useModule();
|
|
89
|
+
const route = useController<{ get(): RouteLocation }>();
|
|
90
|
+
effect(() => {
|
|
91
|
+
const location = route.get();
|
|
92
|
+
const abort = new AbortController(); let dispose: Disposer | undefined, settled = false;
|
|
93
|
+
onCleanup(() => { abort.abort(); dispose?.(); emit(settled ? 'disposed' : 'cancelled', page.name); });
|
|
94
|
+
const message = element(parent, 'p'); message.setAttribute('role', 'status'); text(message, () => 'Loading…');
|
|
95
|
+
status.set({ phase: 'loading', destination: page.name });
|
|
96
|
+
emit('loading', page.name);
|
|
97
|
+
const failed = (error: unknown) => {
|
|
98
|
+
if (abort.signal.aborted) return;
|
|
99
|
+
settled = true;
|
|
100
|
+
message.textContent = 'Unable to open this destination.'; message.setAttribute('role', 'alert');
|
|
101
|
+
status.set({ phase: 'error', destination: page.name, error }); emit(error instanceof ClientPolicyDenied ? 'blocked' : 'error', page.name, error); report(error);
|
|
102
|
+
};
|
|
103
|
+
let input: ReturnType<typeof validateInput>;
|
|
104
|
+
try { authorize(page); input = decodeInput(page, location.params); } catch (error) { failed(error); return; }
|
|
105
|
+
void load(page).then(resources => {
|
|
106
|
+
if (abort.signal.aborted) return;
|
|
107
|
+
const owned = scope(() => {
|
|
108
|
+
const state = resources.create({ input, domains: graph.forDomain(page.owner), location, signal: abort.signal });
|
|
109
|
+
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');
|
|
110
|
+
mount(() => withModuleContext(parentContext, () => resources.view(parent, state, destinations)), parent, { onError: report });
|
|
111
|
+
});
|
|
112
|
+
dispose = owned.dispose;
|
|
113
|
+
if (abort.signal.aborted) { dispose(); return; }
|
|
114
|
+
settled = true; message.remove(); status.set({ phase: 'ready', destination: page.name }); emit('ready', page.name);
|
|
115
|
+
// Page-only focus management; opt-in target or the first heading.
|
|
116
|
+
const target = (parent as Element).querySelector?.('[data-hesh-focus], h1, h2') as HTMLElement | null | undefined;
|
|
117
|
+
if (target) { if (!target.hasAttribute('tabindex')) target.setAttribute('tabindex', '-1'); target.focus({ preventScroll: true }); }
|
|
118
|
+
}).catch(failed);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}) }));
|
|
122
|
+
let router: ReturnType<typeof createRouter>;
|
|
123
|
+
try { router = createRouter({ routes, window: options.window, onError: report }); }
|
|
124
|
+
catch (error) { graph.dispose(); throw error; }
|
|
125
|
+
for (const page of pages) {
|
|
126
|
+
if (destinations[page.name]) { graph.dispose(); throw new Error(`Duplicate destination ${page.name}`); }
|
|
127
|
+
const href = (input: object, transition: TransitionOptions = {}) => {
|
|
128
|
+
if (stopped) throw new Error('Application is disposed');
|
|
129
|
+
if (transition.presentation && transition.presentation !== 'page') throw new Error('Only page presentation is supported');
|
|
130
|
+
const values = validateInput(page.input, input);
|
|
131
|
+
const path = page.url.split('/').map(part => part.startsWith(':') ? encodeURIComponent(String(values[part.slice(1)])) : part).join('/');
|
|
132
|
+
const query = new URLSearchParams(transition.query).toString();
|
|
133
|
+
const hash = transition.hash ? '#' + encodeURIComponent(transition.hash) : '';
|
|
134
|
+
return path + (query ? '?' + query : '') + hash;
|
|
135
|
+
};
|
|
136
|
+
destinations[page.name] = Object.freeze({ name: page.name, href,
|
|
137
|
+
open(input: object, transition?: TransitionOptions) { authorize(page); router.navigate(href(input, transition)); },
|
|
138
|
+
replace(input: object, transition?: TransitionOptions) { authorize(page); router.navigate(href(input, transition), { replace: true }); },
|
|
139
|
+
async prefetch(input: object) { href(input); await load(page); }
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
const stop = () => { if (stopped) return; stopped = true; try { router.stop(); } finally { try { graph.dispose(); } finally { code.clear(); status.set({ phase: 'idle' }); } } };
|
|
143
|
+
return { destinations: Object.freeze(destinations), domains: graph.domains, status: { get: status.get },
|
|
144
|
+
start(parent) { if (stopped) throw new Error('Application is disposed'); try { router.start(parent); return stop; } catch (error) { stop(); throw error; } }, stop };
|
|
145
|
+
}
|
|
146
|
+
export { createDomainGraph };
|
|
147
|
+
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
|
+
}
|
package/src/heshjs/index.ts
CHANGED
|
@@ -2,3 +2,5 @@ export { signal, effect, derived, batch, scope, onCleanup, untrack } from '../re
|
|
|
2
2
|
export type { Signal, Disposer } from '../reactivity/index.js';
|
|
3
3
|
export { mount, element, text, attribute, listen, input, component } from '../dom/index.js';
|
|
4
4
|
export type { Component, MountOptions } from '../dom/index.js';
|
|
5
|
+
export { defineUnit, useUnit, useUnitState, useUnitActions } from '../units/index.js';
|
|
6
|
+
export type { HeshUnit, UnitDefinition } from '../units/index.js';
|
package/src/hmvc/index.ts
CHANGED
|
@@ -30,6 +30,13 @@ export interface AppModule {
|
|
|
30
30
|
}
|
|
31
31
|
let current: { context: ModuleContext; outlet?: Node } | undefined;
|
|
32
32
|
|
|
33
|
+
/** Restore a captured parent context while mounting an asynchronously loaded screen. */
|
|
34
|
+
export function withModuleContext<T>(context: ModuleContext, run: () => T): T {
|
|
35
|
+
const previous = current;
|
|
36
|
+
current = { context };
|
|
37
|
+
try { return run(); } finally { current = previous; }
|
|
38
|
+
}
|
|
39
|
+
|
|
33
40
|
export function useModule<M = unknown, C = unknown>(): ModuleContext<M, C> {
|
|
34
41
|
if (!current) throw new Error('Module context is only available during a module view mount');
|
|
35
42
|
return current.context as ModuleContext<M, C>;
|
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', parts: '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,33 @@
|
|
|
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 { mountPart, unitOutlet } from '../units/index.js';
|
|
5
|
+
import type { HeshUnit } from '../units/index.js';
|
|
6
|
+
export interface PresentationState { bindings: Record<string, unknown>; actions: Record<string, (event: Event) => unknown>; parts?: Record<string, HeshUnit> }
|
|
7
|
+
export function readBinding(bindings: Record<string, unknown>, path: string): unknown {
|
|
8
|
+
const unwrap = (value: unknown): unknown => {
|
|
9
|
+
if (typeof value === 'function') return value();
|
|
10
|
+
if (value && typeof (value as { get?: unknown }).get === 'function') return (value as { get(): unknown }).get();
|
|
11
|
+
return value;
|
|
12
|
+
};
|
|
13
|
+
let value: unknown = bindings;
|
|
14
|
+
for (const part of path.split('.')) {
|
|
15
|
+
if (['__proto__', 'constructor', 'prototype'].includes(part)) throw new Error('Unsafe binding path');
|
|
16
|
+
value = (unwrap(value) as Record<string, unknown> | null)?.[part];
|
|
17
|
+
}
|
|
18
|
+
return unwrap(value);
|
|
19
|
+
}
|
|
20
|
+
export function bindValue(node: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement, bindings: Record<string, unknown>, name: string): void {
|
|
21
|
+
const state = bindings[name] as { get?(): unknown; set?(value: unknown): void };
|
|
22
|
+
if (typeof state?.get !== 'function' || typeof state.set !== 'function') throw new Error(`h-value ${name} requires a writable signal`);
|
|
23
|
+
effect(() => { const value = String(state.get!() ?? ''); if (node.value !== value) node.value = value; });
|
|
24
|
+
listen(node, 'input', () => state.set!(node.value));
|
|
25
|
+
}
|
|
26
|
+
export function bindAction(node: HTMLElement, state: PresentationState, name: string): void {
|
|
27
|
+
const handler = state.actions[name];
|
|
28
|
+
if (typeof handler !== 'function') throw new Error(`Missing presentation action ${name}`);
|
|
29
|
+
listen(node, node.tagName === 'FORM' ? 'submit' : 'click', event => {
|
|
30
|
+
if (node.tagName === 'FORM') event.preventDefault();
|
|
31
|
+
return handler.call(state.actions, event);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { defineModule, useController, useModule, Outlet, type AppModule, type ModuleContext } from '../hmvc/index.js';
|
|
2
|
+
import type { PresentationState } from '../presentation/index.js';
|
|
3
|
+
|
|
4
|
+
export type HeshUnit = AppModule;
|
|
5
|
+
export interface UnitDefinition<S, A extends PresentationState> {
|
|
6
|
+
state(context: Pick<ModuleContext, 'route' | 'parent'>): S;
|
|
7
|
+
actions(state: S, context: Pick<ModuleContext, 'route' | 'parent'>): A;
|
|
8
|
+
screen(parent: Node, state: A, destinations?: object): void;
|
|
9
|
+
destinations?: object;
|
|
10
|
+
}
|
|
11
|
+
/** Hesh naming over the hierarchical, scope-owned module lifecycle. */
|
|
12
|
+
export function defineUnit<S, A extends PresentationState>(definition: UnitDefinition<S, A>): HeshUnit {
|
|
13
|
+
return defineModule({ model: definition.state, controller: definition.actions,
|
|
14
|
+
view(parent) { definition.screen(parent, useController<A>(), definition.destinations); }
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
export function mountPart(parent: Node, state: PresentationState, name: string): void {
|
|
18
|
+
const part = state.parts?.[name];
|
|
19
|
+
if (!part || typeof part.attach !== 'function') throw new Error(`Missing Hesh part ${name}`);
|
|
20
|
+
const context = useModule();
|
|
21
|
+
part.attach(parent, context.route.get(), context);
|
|
22
|
+
}
|
|
23
|
+
export { Outlet as unitOutlet, useModule as useUnit, useModel as useUnitState, useController as useUnitActions } from '../hmvc/index.js';
|
package/src/vite/index.ts
CHANGED
|
@@ -1,17 +1,39 @@
|
|
|
1
1
|
import type { Plugin } from 'vite';
|
|
2
2
|
import { compile } from '../compiler/index.js';
|
|
3
|
-
|
|
3
|
+
import { createArchitectureCompiler, writeProject, formatDiagnostic, type ProjectOptions } from '../architecture/index.js';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
export function hesh(options: { architecture?: ProjectOptions } = {}): Plugin {
|
|
4
6
|
let production = false;
|
|
7
|
+
const architecture = createArchitectureCompiler();
|
|
8
|
+
let project: ProjectOptions | undefined;
|
|
9
|
+
let watched: string[] = [];
|
|
10
|
+
let warn: ((message: string) => void) | undefined;
|
|
11
|
+
const check = () => {
|
|
12
|
+
if (!project) return;
|
|
13
|
+
const result = architecture.check(project);
|
|
14
|
+
if (result.diagnostics.some(item => item.severity === 'error')) throw new Error(result.diagnostics.map(formatDiagnostic).join('\n'));
|
|
15
|
+
for (const item of result.diagnostics) if (item.severity === 'warning') warn?.(formatDiagnostic(item));
|
|
16
|
+
writeProject(result); watched = result.files;
|
|
17
|
+
};
|
|
5
18
|
return {
|
|
6
19
|
name: 'heshjs', enforce: 'pre',
|
|
7
20
|
config() { return { appType: 'spa' }; },
|
|
8
|
-
configResolved(config) {
|
|
21
|
+
configResolved(config) {
|
|
22
|
+
production = config.command === 'build';
|
|
23
|
+
warn = message => config.logger.warn(message);
|
|
24
|
+
if (options.architecture) { project = { ...options.architecture, root: resolve(config.root, options.architecture.root) }; check(); }
|
|
25
|
+
},
|
|
26
|
+
buildStart() { for (const file of watched) this.addWatchFile(file); },
|
|
9
27
|
transform(source, id) {
|
|
28
|
+
if (project && /(?:^|\/)server(?:\/|$)|\.server\.[cm]?[jt]s$/.test(id.replace(/\\/g, '/'))) throw new Error(`HESH-SEC-001 Server-only module cannot enter the browser bundle: ${id}`);
|
|
10
29
|
if (!id.endsWith('.hesh')) return;
|
|
11
30
|
const result = compile(source, { filename: id, development: !production });
|
|
12
31
|
return { code: result.code, map: null };
|
|
13
32
|
},
|
|
14
33
|
handleHotUpdate(context) {
|
|
34
|
+
if (project && (watched.includes(context.file.replace(/\\/g, '/').toLowerCase()) || watched.includes(context.file.replace(/\\/g, '/')))) {
|
|
35
|
+
check(); context.server.ws.send({ type: 'full-reload' }); return [];
|
|
36
|
+
}
|
|
15
37
|
if (context.file.endsWith('.hesh')) { context.server.ws.send({ type: 'full-reload' }); return []; }
|
|
16
38
|
}
|
|
17
39
|
};
|