@heshammesalam/heshjs 0.0.0-stage → 0.1.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,231 @@
1
+ import ts from 'typescript';
2
+ import { parse, tokenize, HeshError, type Expression, type Element, type Module } from '../parser/index.js';
3
+
4
+ export interface CompileOptions { filename?: string; development?: boolean; runtime?: string; reactivity?: string }
5
+ export interface Binding { view: string; start: number; end: number; dependencies: string[]; kind: string }
6
+ export interface ViewIR { name: string; exported: boolean; default: boolean; instructions: string[] }
7
+ export interface ModuleIR { prelude: string[]; views: ViewIR[]; bindings: Binding[] }
8
+ export interface Result { code: string; css: string; metadata: Binding[]; ast: Module; ir: ModuleIR }
9
+ const printer = ts.createPrinter();
10
+ const f = ts.factory;
11
+
12
+ export function compile(source: string, options: CompileOptions = {}): Result {
13
+ const ast = parse(source, options.filename);
14
+ const metadata: Binding[] = [];
15
+ const fail = (message: string, offset: number): never => { throw new HeshError(message, source, offset, ast.filename); };
16
+ const names = new Set<string>();
17
+ let defaults = 0;
18
+ const imported = new Set<string>();
19
+ const addImport = (name: string, offset: number): void => {
20
+ if (name.startsWith('__h')) fail('Identifiers beginning __h are reserved', offset);
21
+ if (imported.has(name)) fail(`Duplicate import binding ${name}`, offset);
22
+ imported.add(name);
23
+ };
24
+ for (const item of ast.imports) {
25
+ const diagnostics = ts.transpileModule(item.source, { fileName: 'imports.js', reportDiagnostics: true, compilerOptions: { allowJs: true, module: ts.ModuleKind.ESNext } }).diagnostics;
26
+ if (diagnostics?.length) fail(ts.flattenDiagnosticMessageText(diagnostics[0]!.messageText, '\n'), item.start);
27
+ const parsed = ts.createSourceFile('imports.js', item.source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
28
+ const statement = parsed.statements[0];
29
+ if (!statement || !ts.isImportDeclaration(statement)) fail('Expected ESM import', item.start);
30
+ const clause = (statement as ts.ImportDeclaration).importClause;
31
+ if (clause?.name) addImport(clause.name.text, item.start);
32
+ const bindings = clause?.namedBindings;
33
+ if (bindings && ts.isNamedImports(bindings)) bindings.elements.forEach(e => addImport(e.name.text, item.start));
34
+ else if (bindings) addImport(bindings.name.text, item.start);
35
+ }
36
+ for (const view of ast.views) {
37
+ if (!/^[A-Z]/.test(view.name)) fail('View names must begin with an uppercase letter', view.start);
38
+ if (names.has(view.name) || imported.has(view.name)) fail(`Duplicate component ${view.name}`, view.start);
39
+ names.add(view.name);
40
+ if (view.default && ++defaults > 1) fail('Only one default view is allowed', view.start);
41
+ }
42
+ const prelude = [
43
+ `import { signal as __hSignal } from ${JSON.stringify(options.reactivity ?? '@heshammesalam/heshjs/reactivity')};`,
44
+ `import * as __hDOM from ${JSON.stringify(options.runtime ?? '@heshammesalam/heshjs/dom')};`,
45
+ ...ast.imports.map(i => i.source),
46
+ 'const __hUpdate = (s, delta, post) => { const value = s.get(); const old = typeof value === "bigint" ? value : +value; const next = typeof old === "bigint" ? old + BigInt(delta) : old + delta; s.set(next); return post ? old : next; };'
47
+ ];
48
+ const views: ViewIR[] = [];
49
+ for (const view of ast.views) {
50
+ const output: string[] = [];
51
+ const states = new Set<string>(), constants = new Set<string>();
52
+ for (const node of view.body) if (node.kind !== 'element') {
53
+ if (states.has(node.name) || constants.has(node.name) || imported.has(node.name) || names.has(node.name)) fail(`Duplicate binding ${node.name}`, node.start);
54
+ (node.kind === 'state' ? states : constants).add(node.name);
55
+ }
56
+ let serial = 0;
57
+ const available = new Set<string>();
58
+ const transform = (expression: Expression, handler = false, async = false): string => {
59
+ const declarations = [...states, ...constants].map(name => `${constants.has(name) ? 'const' : 'let'} ${name} = undefined;`).join('\n');
60
+ const prefix = `${ast.imports.map(i => i.source).join('\n')}\n${[...names].map(n => `function ${n}() {}`).join('\n')}\nfunction __hContext() {\n${declarations}\n${async ? 'async ' : ''}function __hExpression(${handler ? 'event' : ''}) {\n${handler ? '' : 'return ('}`;
61
+ const text = prefix + expression.source + (handler ? '' : ');') + '\n}\n}';
62
+ const filename = '/__hesh_expression__.js';
63
+ const settings: ts.CompilerOptions = { allowJs: true, checkJs: true, target: ts.ScriptTarget.ESNext, module: ts.ModuleKind.ESNext, noEmit: true };
64
+ const host = ts.createCompilerHost(settings);
65
+ const original = host.getSourceFile.bind(host);
66
+ host.getSourceFile = (name, languageVersion, onError, shouldCreate) => name === filename ? ts.createSourceFile(name, text, languageVersion, true, ts.ScriptKind.JS) : original(name, languageVersion, onError, shouldCreate);
67
+ const program = ts.createProgram([filename], settings, host);
68
+ const file = program.getSourceFile(filename)!;
69
+ const diagnostics = program.getSyntacticDiagnostics(file);
70
+ if (diagnostics.length) {
71
+ const d = diagnostics[0]!;
72
+ fail(ts.flattenDiagnosticMessageText(d.messageText, '\n'), expression.start + Math.max(0, (d.start ?? prefix.length) - prefix.length));
73
+ }
74
+ const checker = program.getTypeChecker();
75
+ const context = file.statements.find(n => ts.isFunctionDeclaration(n) && n.name?.text === '__hContext') as ts.FunctionDeclaration;
76
+ const symbols = new Map<ts.Symbol, string>();
77
+ for (const stmt of context.body!.statements) if (ts.isVariableStatement(stmt)) {
78
+ const name = stmt.declarationList.declarations[0]!.name as ts.Identifier;
79
+ symbols.set(checker.getSymbolAtLocation(name)!, name.text);
80
+ }
81
+ const fn = context.body!.statements.at(-1) as ts.FunctionDeclaration;
82
+ const dependencies = new Set<string>();
83
+ const stateName = (node: ts.Node): string | undefined => {
84
+ if (!ts.isIdentifier(node)) return;
85
+ const symbol = checker.getSymbolAtLocation(node);
86
+ const name = symbol ? symbols.get(symbol) : undefined;
87
+ if (name && !handler && !available.has(name)) fail(`Binding ${name} is used before initialization`, expression.start);
88
+ return name;
89
+ };
90
+ const read = (name: string) => f.createCallExpression(f.createPropertyAccessExpression(f.createIdentifier(name), 'get'), undefined, []);
91
+ const write = (name: string, value: ts.Expression) => f.createCallExpression(f.createPropertyAccessExpression(f.createIdentifier(name), 'set'), undefined, [value]);
92
+ const result = ts.transform(fn.body!, [context => {
93
+ const visit: ts.Visitor = node => {
94
+ if (ts.isIdentifier(node) && node.text.startsWith('__h')) fail('Identifiers beginning __h are reserved', expression.start + Math.max(0, node.getStart(file) - prefix.length));
95
+ if ((ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) && [ts.SyntaxKind.PlusPlusToken, ts.SyntaxKind.MinusMinusToken].includes(node.operator)) {
96
+ const name = stateName(node.operand);
97
+ if (name) {
98
+ dependencies.add(name);
99
+ if (constants.has(name)) fail(`Cannot assign constant ${name}`, expression.start);
100
+ const delta = node.operator === ts.SyntaxKind.PlusPlusToken ? f.createNumericLiteral(1) : f.createPrefixUnaryExpression(ts.SyntaxKind.MinusToken, f.createNumericLiteral(1));
101
+ return f.createCallExpression(f.createIdentifier('__hUpdate'), undefined, [f.createIdentifier(name), delta, node.kind === ts.SyntaxKind.PostfixUnaryExpression ? f.createTrue() : f.createFalse()]);
102
+ }
103
+ }
104
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && node.operatorToken.kind <= ts.SyntaxKind.LastAssignment) {
105
+ if (ts.isArrayLiteralExpression(node.left) || ts.isObjectLiteralExpression(node.left)) {
106
+ const checkTarget = (target: ts.Node): void => {
107
+ const name = stateName(target);
108
+ if (name) fail('Destructuring assignments to component bindings are not supported', expression.start);
109
+ ts.forEachChild(target, checkTarget);
110
+ };
111
+ checkTarget(node.left);
112
+ }
113
+ const name = stateName(node.left);
114
+ if (name) {
115
+ dependencies.add(name);
116
+ if (constants.has(name)) fail(`Cannot assign constant ${name}`, expression.start);
117
+ const right = ts.visitNode(node.right, visit) as ts.Expression;
118
+ if (node.operatorToken.kind === ts.SyntaxKind.EqualsToken) return write(name, right);
119
+ const operators: Partial<Record<ts.SyntaxKind, ts.BinaryOperator>> = {
120
+ [ts.SyntaxKind.PlusEqualsToken]: ts.SyntaxKind.PlusToken, [ts.SyntaxKind.MinusEqualsToken]: ts.SyntaxKind.MinusToken,
121
+ [ts.SyntaxKind.AsteriskEqualsToken]: ts.SyntaxKind.AsteriskToken, [ts.SyntaxKind.SlashEqualsToken]: ts.SyntaxKind.SlashToken,
122
+ [ts.SyntaxKind.PercentEqualsToken]: ts.SyntaxKind.PercentToken, [ts.SyntaxKind.AsteriskAsteriskEqualsToken]: ts.SyntaxKind.AsteriskAsteriskToken,
123
+ [ts.SyntaxKind.AmpersandEqualsToken]: ts.SyntaxKind.AmpersandToken, [ts.SyntaxKind.BarEqualsToken]: ts.SyntaxKind.BarToken,
124
+ [ts.SyntaxKind.CaretEqualsToken]: ts.SyntaxKind.CaretToken, [ts.SyntaxKind.LessThanLessThanEqualsToken]: ts.SyntaxKind.LessThanLessThanToken,
125
+ [ts.SyntaxKind.GreaterThanGreaterThanEqualsToken]: ts.SyntaxKind.GreaterThanGreaterThanToken, [ts.SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken]: ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken
126
+ };
127
+ const op = operators[node.operatorToken.kind];
128
+ if (!op) fail('Logical assignment to state is not supported yet', expression.start);
129
+ return write(name, f.createBinaryExpression(read(name), op!, right));
130
+ }
131
+ }
132
+ if (ts.isShorthandPropertyAssignment(node)) {
133
+ const symbol = checker.getShorthandAssignmentValueSymbol(node);
134
+ const name = symbol && symbols.get(symbol);
135
+ if (name && !handler && !available.has(name)) fail(`Binding ${name} is used before initialization`, expression.start);
136
+ if (name && states.has(name)) { dependencies.add(name); return f.createPropertyAssignment(node.name.text, read(name)); }
137
+ }
138
+ if (ts.isIdentifier(node)) {
139
+ const name = stateName(node);
140
+ if (name && !handler && !available.has(name)) fail(`Binding ${name} is used before initialization`, expression.start);
141
+ if (name && states.has(name)) { dependencies.add(name); return read(name); }
142
+ }
143
+ if ((ts.isForOfStatement(node) || ts.isForInStatement(node)) && stateName(node.initializer)) fail('Use a local loop variable, then assign state in the loop body', expression.start);
144
+ return ts.visitEachChild(node, visit, context);
145
+ };
146
+ return node => ts.visitNode(node, visit) as ts.Block;
147
+ }]);
148
+ // Report unresolved names while leaving module resolution and imported types to Vite/tsc.
149
+ for (const d of program.getSemanticDiagnostics(file)) if ((d.code < 2000 || [2304, 2552, 2588].includes(d.code)) && (d.start ?? 0) >= prefix.length && (d.start ?? 0) < prefix.length + expression.source.length) {
150
+ fail(ts.flattenDiagnosticMessageText(d.messageText, '\n'), expression.start + d.start! - prefix.length);
151
+ }
152
+ const body = result.transformed[0] as ts.Block;
153
+ metadata.push({ view: view.name, start: expression.start, end: expression.end, dependencies: [...dependencies].sort(), kind: handler ? 'event' : 'expression' });
154
+ let rendered: string;
155
+ if (handler) rendered = printer.printNode(ts.EmitHint.Unspecified, body, file);
156
+ else rendered = printer.printNode(ts.EmitHint.Expression, (body.statements[0] as ts.ReturnStatement).expression!, file);
157
+ result.dispose();
158
+ return rendered;
159
+ };
160
+ const value = (expr: Expression): string => {
161
+ if (!expr.source.startsWith('"')) return transform(expr);
162
+ const tokens = tokenize(expr.source);
163
+ if (tokens.length !== 1 || tokens[0]!.kind !== ts.SyntaxKind.StringLiteral) return transform(expr);
164
+ const raw = expr.source.slice(1, -1);
165
+ const parts: string[] = [];
166
+ let literal = '', i = 0;
167
+ const emit = () => { if (literal) { parts.push(JSON.stringify(literal)); literal = ''; } };
168
+ while (i < raw.length) {
169
+ if (raw[i] === '\\') {
170
+ const next = raw[++i];
171
+ const escapes: Record<string, string> = { n: '\n', r: '\r', t: '\t', b: '\b', f: '\f', v: '\v', '0': '\0' };
172
+ if (next === 'u' || next === 'x') fail('Use single quotes for Unicode/hex escapes in literal strings', expr.start + i);
173
+ literal += escapes[next ?? ''] ?? next ?? ''; i++; continue;
174
+ }
175
+ if (raw[i] !== '{') { literal += raw[i++]; continue; }
176
+ emit(); const start = ++i;
177
+ const nested = tokenize(raw.slice(start)); let depth = 1, end = -1;
178
+ for (const token of nested) { if (token.text === '{') depth++; if (token.text === '}' && --depth === 0) { end = start + token.start; break; } }
179
+ if (end < 0) fail('Unterminated text interpolation', expr.start + start);
180
+ parts.push(`String(${transform({ source: raw.slice(start, end), start: expr.start + 1 + start, end: expr.start + 1 + end })} ?? "")`);
181
+ i = end + 1;
182
+ }
183
+ emit(); return parts.length ? parts.join(' + ') : '""';
184
+ };
185
+ const emit = (node: Element, parent: string): void => {
186
+ if (/^[A-Z]/.test(node.name)) {
187
+ if (!names.has(node.name) && !imported.has(node.name)) fail(`Unknown component ${node.name}`, node.start);
188
+ if (node.value || node.handler || node.children.length) fail('Component props, event blocks and slots are not supported in Milestone 1', node.start);
189
+ output.push(`__hDOM.component(${parent}, ${node.name});`); return;
190
+ }
191
+ const id = `__hNode${serial++}`;
192
+ output.push(`const ${id} = __hDOM.element(${parent}, ${JSON.stringify(node.name)});`);
193
+ if (node.value) {
194
+ if (node.name === 'input') {
195
+ if (!states.has(node.value.source.trim()) || !available.has(node.value.source.trim())) fail('input requires an initialized state identifier', node.value.start);
196
+ output.push(`__hDOM.input(${id}, ${node.value.source});`);
197
+ } else if (node.name === 'image' || node.name === 'img') output.push(`__hDOM.attribute(${id}, "src", () => ${value(node.value)});`);
198
+ else output.push(`__hDOM.text(${id}, () => ${value(node.value)});`);
199
+ }
200
+ if (node.handler) output.push(`__hDOM.listen(${id}, "click", ${node.async ? 'async ' : ''}(event) => ${transform(node.handler, true, node.async)});`);
201
+ node.children.forEach(child => emit(child, id));
202
+ };
203
+ for (const node of view.body) {
204
+ if (node.kind === 'element') emit(node, '__hParent');
205
+ else { output.push(`const ${node.name} = ${node.kind === 'state' ? '__hSignal(' : ''}${transform(node.value)}${node.kind === 'state' ? ')' : ''};`); available.add(node.name); }
206
+ }
207
+ views.push({ name: view.name, exported: view.exported, default: view.default, instructions: output });
208
+ }
209
+ const ir: ModuleIR = { prelude, views, bindings: options.development === false ? [] : metadata };
210
+ const output = [...ir.prelude, ...ir.views.flatMap(view => [
211
+ `${view.exported ? 'export ' : ''}${view.default ? 'default ' : ''}function ${view.name}(__hParent) {`,
212
+ ...view.instructions.map(instruction => instruction.split('\n').map(line => ` ${line}`).join('\n')),
213
+ '}'
214
+ ])];
215
+ if (options.development !== false) output.push(`export const __hesh = ${JSON.stringify(metadata)};`);
216
+ return { code: output.join('\n'), css: '', metadata: options.development === false ? [] : metadata, ast, ir };
217
+ }
218
+
219
+ export function createCompiler() {
220
+ const cache = new Map<string, Result>();
221
+ return {
222
+ compile(source: string, options: CompileOptions = {}) {
223
+ const key = JSON.stringify([source, options]);
224
+ let result = cache.get(key);
225
+ if (!result) { result = compile(source, options); cache.set(key, result); }
226
+ return result;
227
+ },
228
+ clear() { cache.clear(); }
229
+ };
230
+ }
231
+
@@ -0,0 +1,67 @@
1
+ import { effect, onCleanup, scope, batch, type Signal, type Disposer } from '../reactivity/index.js';
2
+ export type Component = (parent: Node) => void;
3
+ export interface MountOptions { onError?: (error: unknown) => void }
4
+ let errorHandler: (error: unknown) => void = error => console.error(error);
5
+ const aliases: Record<string, string> = { text: 'span', card: 'div', row: 'div', stack: 'div', grid: 'div', image: 'img', link: 'a' };
6
+
7
+ export function mount(component: Component, parent: Node, options: MountOptions = {}): Disposer {
8
+ const previous = errorHandler;
9
+ errorHandler = options.onError ?? previous;
10
+ try { return scope(() => component(parent)).dispose; }
11
+ finally { errorHandler = previous; }
12
+ }
13
+
14
+ export function element(parent: Node, name: string): HTMLElement {
15
+ const node = parent.ownerDocument!.createElement(aliases[name] ?? name);
16
+ parent.appendChild(node);
17
+ onCleanup(() => node.remove());
18
+ return node;
19
+ }
20
+
21
+ export function text(parent: Node, read: () => unknown): void {
22
+ const node = parent.ownerDocument!.createTextNode('');
23
+ parent.appendChild(node);
24
+ onCleanup(() => node.remove());
25
+ effect(() => { node.data = String(read() ?? ''); });
26
+ }
27
+
28
+ function safeURL(value: string): string {
29
+ // Parse like a browser, including embedded ASCII tabs/newlines.
30
+ const normalized = value.replace(/[\u0000-\u0020]/g, '');
31
+ const scheme = /^([a-z][a-z\d+.-]*):/i.exec(normalized)?.[1]?.toLowerCase();
32
+ if (scheme && !['http', 'https', 'mailto', 'tel'].includes(scheme)) {
33
+ throw new Error(`Unsafe URL scheme: ${scheme}`);
34
+ }
35
+ return value;
36
+ }
37
+
38
+ export function attribute(node: HTMLElement, name: string, read: () => unknown): void {
39
+ if (/^on/i.test(name) || ['innerhtml', 'outerhtml', 'srcdoc'].includes(name.toLowerCase())) throw new Error(`Unsafe attribute: ${name}`);
40
+ effect(() => {
41
+ const value = read();
42
+ if (value == null || value === false) node.removeAttribute(name);
43
+ else {
44
+ const rendered = value === true ? '' : String(value);
45
+ node.setAttribute(name, ['href', 'src', 'action', 'formaction', 'poster', 'xlink:href'].includes(name.toLowerCase()) ? safeURL(rendered) : rendered);
46
+ }
47
+ });
48
+ }
49
+
50
+ export function listen(node: HTMLElement, name: string, handler: (event: Event) => unknown): void {
51
+ const report = errorHandler;
52
+ const listener = (event: Event) => {
53
+ try { Promise.resolve(batch(() => handler(event))).catch(report); }
54
+ catch (error) { report(error); }
55
+ };
56
+ node.addEventListener(name, listener);
57
+ onCleanup(() => node.removeEventListener(name, listener));
58
+ }
59
+
60
+ export function input(node: HTMLInputElement, state: Signal<unknown>): void {
61
+ effect(() => { const value = String(state.get() ?? ''); if (node.value !== value) node.value = value; });
62
+ listen(node, 'input', () => state.set(node.value));
63
+ }
64
+
65
+ export function component(parent: Node, view: Component): void {
66
+ scope(() => view(parent));
67
+ }
@@ -0,0 +1,4 @@
1
+ export { signal, effect, derived, batch, scope, onCleanup, untrack } from '../reactivity/index.js';
2
+ export type { Signal, Disposer } from '../reactivity/index.js';
3
+ export { mount, element, text, attribute, listen, input, component } from '../dom/index.js';
4
+ export type { Component, MountOptions } from '../dom/index.js';
@@ -0,0 +1,149 @@
1
+ import ts from 'typescript';
2
+ export interface Location { start: number; end: number }
3
+ export interface Expression extends Location { source: string }
4
+ export interface State extends Location { kind: 'state' | 'const'; name: string; value: Expression }
5
+ export interface Element extends Location { kind: 'element'; name: string; value?: Expression; handler?: Expression; async: boolean; children: Element[] }
6
+ export interface View extends Location { name: string; exported: boolean; default: boolean; body: (State | Element)[] }
7
+ export interface Module { source: string; filename: string; imports: Expression[]; views: View[] }
8
+ export class HeshError extends Error {
9
+ readonly line: number;
10
+ readonly column: number;
11
+ constructor(message: string, readonly source: string, readonly offset: number, readonly filename = 'component.hesh') {
12
+ const lines = source.slice(0, offset).split('\n');
13
+ const line = lines.length, column = lines.at(-1)!.length + 1;
14
+ super(`${filename}:${line}:${column}: ${message}`);
15
+ this.name = 'HeshError'; this.line = line; this.column = column;
16
+ }
17
+ }
18
+ export interface Token extends Location { text: string; kind: ts.SyntaxKind }
19
+ export function tokenize(source: string, filename = 'component.hesh'): Token[] {
20
+ const tokens: Token[] = [];
21
+ const keywords = new Set('break case catch class const continue debugger default delete do else enum export extends false finally for function if import in instanceof new null return super switch this throw true try typeof var void while with yield let static await async'.split(' '));
22
+ let at = 0;
23
+ const fail = (message: string, offset: number): never => { throw new HeshError(message, source, offset, filename); };
24
+ while (at < source.length) {
25
+ const start = at;
26
+ const ch = source[at]!;
27
+ if (/\s/.test(ch)) { at++; continue; }
28
+ if (source.startsWith('//', at)) { while (at < source.length && source[at] !== '\n') at++; continue; }
29
+ if (source.startsWith('/*', at)) {
30
+ const end = source.indexOf('*/', at + 2);
31
+ if (end < 0) fail('Unterminated comment', at);
32
+ at = end + 2; continue;
33
+ }
34
+ let kind = ts.SyntaxKind.Unknown;
35
+ if (ch === '"' || ch === "'" || ch === '`') {
36
+ const quote = ch;
37
+ at++; let closed = false;
38
+ while (at < source.length) {
39
+ const current = source[at++]!;
40
+ if (current === '\\') { at++; continue; }
41
+ if (quote === '`' && current === '$' && source[at] === '{') fail('Template substitutions belong in imported JS/TS helpers in Milestone 1', at - 1);
42
+ if (current === quote) { closed = true; break; }
43
+ if (quote !== '`' && (current === '\n' || current === '\r')) fail('Unterminated string', start);
44
+ }
45
+ if (!closed) fail('Unterminated string', start);
46
+ kind = quote === '`' ? ts.SyntaxKind.NoSubstitutionTemplateLiteral : ts.SyntaxKind.StringLiteral;
47
+ } else if (/[$_\p{ID_Start}]/u.test(ch)) {
48
+ at++;
49
+ while (at < source.length && /[$\u200c\u200d\p{ID_Continue}]/u.test(source[at]!)) at++;
50
+ kind = keywords.has(source.slice(start, at)) ? ts.SyntaxKind.Unknown : ts.SyntaxKind.Identifier;
51
+ } else if (/\d/.test(ch)) {
52
+ at++;
53
+ while (at < source.length && /[\w.]/.test(source[at]!)) at++;
54
+ kind = ts.SyntaxKind.NumericLiteral;
55
+ } else {
56
+ const operator = ['>>>=', '===', '!==', '**=', '&&=', '||=', '??=', '>>>', '<<=', '>>=', '=>', '==', '!=', '<=', '>=', '++', '--', '+=', '-=', '*=', '/=', '%=', '**', '&&', '||', '??', '?.', '<<', '>>', '&=', '|=', '^=', '...'].find(op => source.startsWith(op, at));
57
+ at += operator?.length ?? 1;
58
+ }
59
+ tokens.push({ kind, text: source.slice(start, at), start, end: at });
60
+ }
61
+ return tokens;
62
+ }
63
+
64
+ export function parse(source: string, filename = 'component.hesh'): Module {
65
+ const tokens = tokenize(source, filename);
66
+ let at = 0;
67
+ const peek = () => tokens[at]?.text;
68
+ const fail = (message: string, offset = tokens[at]?.start ?? source.length): never => { throw new HeshError(message, source, offset, filename); };
69
+ const take = (text?: string): Token => {
70
+ const token = tokens[at];
71
+ if (!token || (text !== undefined && token.text !== text)) fail(`Expected ${text ?? 'token'}, received ${peek() ?? 'end of file'}`);
72
+ at++; return token!;
73
+ };
74
+ const identifier = () => {
75
+ const token = take();
76
+ if (token.kind !== ts.SyntaxKind.Identifier) fail('Expected identifier', token.start);
77
+ if (token.text.startsWith('__h')) fail('Identifiers beginning __h are reserved', token.start);
78
+ return token;
79
+ };
80
+ const expression = (ends: string[]): Expression => {
81
+ const start = tokens[at]?.start ?? source.length;
82
+ const stack: string[] = [];
83
+ const pairs: Record<string, string> = { '(': ')', '[': ']', '{': '}' };
84
+ while (tokens[at]) {
85
+ const token = tokens[at]!;
86
+ if (!stack.length && ends.includes(token.text)) break;
87
+ if (pairs[token.text]) stack.push(pairs[token.text]!);
88
+ else if ([')', ']', '}'].includes(token.text)) {
89
+ if (stack.pop() !== token.text) fail('Unbalanced expression', token.start);
90
+ }
91
+ at++;
92
+ }
93
+ if (stack.length) fail('Unterminated expression', start);
94
+ const end = tokens[at]?.start ?? source.length;
95
+ if (!source.slice(start, end).trim()) fail('Expected expression', start);
96
+ return { source: source.slice(start, end).trimEnd(), start, end };
97
+ };
98
+ const element = (): Element => {
99
+ const name = identifier(); take('(');
100
+ let value: Expression | undefined, handler: Expression | undefined, async = false;
101
+ if (peek() !== ')') value = expression([',', ')']);
102
+ if (peek() === ',') {
103
+ take(',');
104
+ if (peek() === 'async') { take(); async = true; }
105
+ const start = take('{').end;
106
+ let depth = 1;
107
+ while (tokens[at] && depth) {
108
+ if (peek() === '{') depth++;
109
+ if (peek() === '}') depth--;
110
+ if (depth) at++;
111
+ }
112
+ if (!tokens[at]) fail('Unterminated event block', start);
113
+ const end = take('}').start;
114
+ handler = { source: source.slice(start, end), start, end };
115
+ }
116
+ take(')');
117
+ const children: Element[] = [];
118
+ if (peek() === '{') { take(); while (peek() && peek() !== '}') children.push(element()); take('}'); if (peek() === ';') take(); }
119
+ else take(';');
120
+ return { kind: 'element', name: name.text, start: name.start, end: tokens[at - 1]!.end, value, handler, async, children };
121
+ };
122
+ const module: Module = { source, filename, imports: [], views: [] };
123
+ while (tokens[at]) {
124
+ if (peek() === 'import') {
125
+ if (module.views.length) fail('Imports must precede views');
126
+ const start = take().start;
127
+ while (peek() && peek() !== ';') take();
128
+ const end = take(';').end;
129
+ module.imports.push({ source: source.slice(start, end), start, end }); continue;
130
+ }
131
+ let exported = false, isDefault = false;
132
+ if (peek() === 'export') { take(); exported = true; }
133
+ if (peek() === 'default') { take(); isDefault = true; if (!exported) fail('default requires export'); }
134
+ const start = take('view').start;
135
+ const name = identifier().text; take('{');
136
+ const body: (State | Element)[] = [];
137
+ while (peek() && peek() !== '}') {
138
+ if (peek() === 'const' || tokens[at + 1]?.text === '=') {
139
+ const kind = peek() === 'const' ? (take(), 'const' as const) : 'state' as const;
140
+ const name = identifier(); take('='); const value = expression([';']); const end = take(';').end;
141
+ body.push({ kind, name: name.text, value, start: name.start, end });
142
+ } else body.push(element());
143
+ }
144
+ const end = take('}').end;
145
+ module.views.push({ name, body, start, end, exported, default: isDefault });
146
+ }
147
+ if (!module.views.length) fail('Expected at least one view');
148
+ return module;
149
+ }
@@ -0,0 +1,115 @@
1
+ export type Disposer = () => void;
2
+ export interface Signal<T> { get(): T; set(value: T): T }
3
+ interface Observer { run(): void; deps: Set<Set<Observer>>; disposed: boolean; computed: boolean }
4
+ interface Owner { cleanups: Set<Disposer>; disposed: boolean }
5
+ let active: Observer | undefined;
6
+ let owner: Owner | undefined;
7
+ let depth = 0;
8
+ let flushing = false;
9
+ const queue = new Set<Observer>();
10
+
11
+ function flush(): void {
12
+ if (depth || flushing) return;
13
+ flushing = true;
14
+ let steps = 0;
15
+ try {
16
+ while (queue.size) {
17
+ if (++steps > 1000) throw new Error('HeshJS reactive update limit exceeded');
18
+ const next = [...queue].find(o => o.computed) ?? queue.values().next().value!;
19
+ queue.delete(next);
20
+ if (!next.disposed) next.run();
21
+ }
22
+ } catch (error) { queue.clear(); throw error; }
23
+ finally { flushing = false; }
24
+ }
25
+
26
+ export function signal<T>(initial: T): Signal<T> {
27
+ let value = initial;
28
+ const subscribers = new Set<Observer>();
29
+ return {
30
+ get() { if (active) { subscribers.add(active); active.deps.add(subscribers); } return value; },
31
+ set(next) {
32
+ if (!Object.is(value, next)) {
33
+ value = next;
34
+ for (const observer of subscribers) queue.add(observer);
35
+ flush();
36
+ }
37
+ return value;
38
+ }
39
+ };
40
+ }
41
+
42
+ export function onCleanup(fn: Disposer): void {
43
+ if (!owner) throw new Error('onCleanup requires a reactive scope');
44
+ if (owner.disposed) fn(); else owner.cleanups.add(fn);
45
+ }
46
+
47
+ export function scope<T>(fn: () => T): { value: T; dispose: Disposer } {
48
+ const parent = owner;
49
+ const current: Owner = { cleanups: new Set(), disposed: false };
50
+ const dispose = () => {
51
+ if (current.disposed) return;
52
+ current.disposed = true;
53
+ parent?.cleanups.delete(dispose);
54
+ const errors: unknown[] = [];
55
+ for (const cleanup of [...current.cleanups].reverse()) {
56
+ try { cleanup(); } catch (error) { errors.push(error); }
57
+ }
58
+ current.cleanups.clear();
59
+ if (errors.length) throw new AggregateError(errors, 'HeshJS cleanup failed');
60
+ };
61
+ parent?.cleanups.add(dispose);
62
+ owner = current;
63
+ try { return { value: fn(), dispose }; }
64
+ catch (error) { dispose(); throw error; }
65
+ finally { owner = parent; }
66
+ }
67
+
68
+ export function effect(fn: () => void, computed = false): Disposer {
69
+ const owningScope = owner;
70
+ let runScope: { dispose: Disposer } | undefined;
71
+ const observer: Observer = {
72
+ deps: new Set(), disposed: false, computed,
73
+ run() {
74
+ for (const dep of observer.deps) dep.delete(observer);
75
+ observer.deps.clear();
76
+ runScope?.dispose();
77
+ const previous = active;
78
+ const previousOwner = owner;
79
+ active = observer;
80
+ owner = owningScope;
81
+ depth++;
82
+ try { runScope = scope(fn); }
83
+ finally { active = previous; owner = previousOwner; depth--; }
84
+ }
85
+ };
86
+ const dispose = () => {
87
+ if (observer.disposed) return;
88
+ observer.disposed = true;
89
+ owningScope?.cleanups.delete(dispose);
90
+ queue.delete(observer);
91
+ for (const dep of observer.deps) dep.delete(observer);
92
+ observer.deps.clear();
93
+ runScope?.dispose();
94
+ };
95
+ owningScope?.cleanups.add(dispose);
96
+ try { observer.run(); flush(); } catch (error) { dispose(); throw error; }
97
+ return dispose;
98
+ }
99
+
100
+ export function derived<T>(fn: () => T): Pick<Signal<T>, 'get'> {
101
+ const value = signal<T>(undefined as T);
102
+ effect(() => value.set(fn()), true);
103
+ return { get: value.get };
104
+ }
105
+
106
+ export function batch<T>(fn: () => T): T {
107
+ depth++;
108
+ try { return fn(); } finally { depth--; flush(); }
109
+ }
110
+
111
+ export function untrack<T>(fn: () => T): T {
112
+ const previous = active;
113
+ active = undefined;
114
+ try { return fn(); } finally { active = previous; }
115
+ }
@@ -0,0 +1,17 @@
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
+ configResolved(config) { production = config.command === 'build'; },
8
+ transform(source, id) {
9
+ if (!id.endsWith('.hesh')) return;
10
+ const result = compile(source, { filename: id, development: !production });
11
+ return { code: result.code, map: null };
12
+ },
13
+ handleHotUpdate(context) {
14
+ if (context.file.endsWith('.hesh')) { context.server.ws.send({ type: 'full-reload' }); return []; }
15
+ }
16
+ };
17
+ }