@heshammesalam/heshjs 0.3.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.
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ import { mkdir, readdir, writeFile, readFile } from 'node:fs/promises';
3
+ import { resolve, basename, join, dirname } from 'node:path';
4
+ import { createInterface } from 'node:readline';
5
+ import { stdin, stdout } from 'node:process';
6
+ import { applicationFiles } from './template.js';
7
+
8
+ export async function createProject(args = process.argv.slice(2)) {
9
+ if (args.includes('--help') || args.includes('-h')) {
10
+ console.log('Usage: hesh create [directory] [--ts | --js]\nOmit directory/language to choose interactively.');
11
+ return;
12
+ }
13
+ const flags = args.filter(arg => arg.startsWith('--'));
14
+ if (flags.some(flag => !['--ts', '--js'].includes(flag)) || (flags.includes('--ts') && flags.includes('--js'))) throw new Error('Choose --ts or --js. Use --help for usage.');
15
+ const directories = args.filter(arg => !arg.startsWith('--'));
16
+ if (directories.length > 1) throw new Error('Provide at most one project directory. Use --help for usage.');
17
+ let terminal, lines, interrupted = false;
18
+ const ask = async (question, validate) => {
19
+ if (!terminal) {
20
+ terminal = createInterface({ input: stdin, output: stdout, terminal: Boolean(stdin.isTTY && stdout.isTTY) });
21
+ lines = terminal[Symbol.asyncIterator]();
22
+ terminal.on('SIGINT', () => { interrupted = true; terminal.close(); });
23
+ }
24
+ while (true) {
25
+ stdout.write(question);
26
+ const answer = await lines.next();
27
+ if (answer.done) {
28
+ const error = new Error(interrupted ? 'Project creation canceled.' : 'Input ended. Provide a directory and --js or --ts for non-interactive use.');
29
+ error.exitCode = interrupted ? 130 : 1;
30
+ throw error;
31
+ }
32
+ const value = validate(answer.value.trim());
33
+ if (value !== undefined) return value;
34
+ }
35
+ };
36
+ let directory, typed, target;
37
+ try {
38
+ console.log('\nHeshJS — Create your application\n');
39
+ directory = directories[0] ?? await ask('Project folder: ', answer => {
40
+ if (answer && !answer.includes('\0')) return answer;
41
+ console.log('Enter the folder name or path you want to create.');
42
+ });
43
+ target = resolve(directory);
44
+ let existing;
45
+ try { existing = await readdir(target); } catch (error) { if (error.code !== 'ENOENT') throw error; }
46
+ if (existing?.length) throw new Error(`Directory is not empty: ${target}`);
47
+ typed = flags.includes('--ts') ? true : flags.includes('--js') ? false : await ask('Choose language:\n 1. JavaScript\n 2. TypeScript\nLanguage [1]: ', answer => {
48
+ const choice = answer.toLowerCase();
49
+ if (['', '1', 'js', 'javascript'].includes(choice)) return false;
50
+ if (['2', 'ts', 'typescript'].includes(choice)) return true;
51
+ console.log('Enter 1 for JavaScript or 2 for TypeScript.');
52
+ });
53
+ } finally { terminal?.close(); }
54
+
55
+ // Read the bundled asset before creating any project files.
56
+ const icon = await readFile(new URL('./assets/hesh-icon.png', import.meta.url));
57
+ const name = basename(target).toLowerCase().replace(/[^a-z0-9-]/g, '-') || 'hesh-app';
58
+ const files = {
59
+ 'package.json': JSON.stringify({ name, private: true, version: '0.0.0', type: 'module', scripts: { dev: 'vite', check: 'hesh check src --write' + (typed ? ' && tsc --noEmit' : ''), graph: 'hesh graph src', build: 'hesh check src --write && ' + (typed ? 'tsc --noEmit && ' : '') + 'vite build', preview: 'vite preview' }, dependencies: { '@heshammesalam/heshjs': '0.5.0' }, devDependencies: { vite: '^7.2.2', ...(typed ? { typescript: '~5.9.3' } : {}) } }, null, 2) + '\n',
60
+ '.gitignore': 'node_modules/\ndist/\n',
61
+ 'index.html': `<!doctype html>\n<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" type="image/png" href="/brand/hesh-icon.png"><link rel="apple-touch-icon" href="/brand/hesh-icon.png"><title>HeshJS</title></head><body><div id="app"></div><script type="module" src="/src/main.${typed ? 'ts' : 'js'}"></script></body></html>\n`,
62
+ 'vite.config.js': "import { hesh } from '@heshammesalam/heshjs/vite';\nexport default { plugins: [hesh({ architecture: { root: 'src'" + (typed ? ", tsconfig: '../tsconfig.json'" : '') + " } })] };\n",
63
+ 'src/style.css': ':root { font: 17px/1.6 system-ui; background: #f3f6fb; color: #17243a; } #app { max-width: 900px; margin: 5vh auto; padding: 32px; background: white; border-radius: 16px; } .hesh-brand { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; } .hesh-brand img { display: block; width: 80px; height: 80px; object-fit: contain; flex: 0 0 80px; } .hesh-brand h1 { margin: 0; font-size: clamp(1.3rem, 4vw, 2rem); } nav { display: flex; flex-wrap: wrap; gap: 20px; } button,input { font: inherit; margin-right: 12px; padding: 8px 16px; } button { cursor: pointer; } button:focus-visible, a:focus-visible, input:focus-visible { outline: 3px solid #2563eb; } main { padding: 20px 0; } footer { border-top: 1px solid #dbe3ef; padding-top: 16px; } .architecture-note { padding: 12px; background: #f0f4ff; border-radius: 8px; } @media(max-width:600px) { #app { margin: 0; padding: 20px; border-radius: 0; } }\n',
64
+ 'public/brand/hesh-icon.png': icon,
65
+ ...applicationFiles(typed)
66
+ };
67
+ if (typed) {
68
+ files['tsconfig.json'] = JSON.stringify({ compilerOptions: { target: 'ES2022', module: 'ESNext', moduleResolution: 'Bundler', strict: true, noEmit: true, allowImportingTsExtensions: true, skipLibCheck: true, types: ['vite/client'] }, include: ['src'] }, null, 2) + '\n';
69
+ files['src/hesh.d.ts'] = "declare module '*.hesh' {\n import type { Component } from '@heshammesalam/heshjs';\n const component: Component;\n export default component;\n}\n";
70
+ }
71
+ await mkdir(target, { recursive: true });
72
+ for (const [path, content] of Object.entries(files)) {
73
+ await mkdir(dirname(join(target, path)), { recursive: true });
74
+ await writeFile(join(target, path), content, { flag: 'wx' });
75
+ }
76
+ console.log(`Created ${typed ? 'TypeScript' : 'JavaScript'} HeshJS project in ${target}\n\nNext:\n cd ${JSON.stringify(directory)}\n npm install\n npm run dev`);
77
+ }
78
+
@@ -0,0 +1,188 @@
1
+ export function applicationFiles(typed) {
2
+ const ext = typed ? 'ts' : 'js';
3
+ const runtime = '@heshammesalam/heshjs';
4
+ const files = {
5
+ 'src/app.hesh': `import "./hesh-units/identity/unit.hesh";
6
+ import "./hesh-units/workspace/unit.hesh";
7
+ application App {
8
+ domains { Identity; Workspace; }
9
+ destinations { Home; About; CounterDetails; Login; }
10
+ }
11
+ `,
12
+ [`src/main.${ext}`]: `import { createApplication } from './.hesh-generated/app.js';
13
+ import { createShellUnit } from './foundation/shell/unit.${ext}';
14
+ import './style.css';
15
+ const root = document.querySelector('#app');
16
+ if (!root) throw new Error('Missing #app');
17
+ const application = createApplication();
18
+ let dispose = () => application.stop();
19
+ try {
20
+ const url = new URL(window.location.href);
21
+ const shell = createShellUnit(application).attach(root, {
22
+ url, pathname: url.pathname, params: {}, query: url.searchParams, hash: url.hash
23
+ });
24
+ dispose = () => { try { application.stop(); } finally { shell.dispose(); } };
25
+ if (!shell.outlet) throw new Error('Shell must render h-outlet');
26
+ application.start(shell.outlet);
27
+ } catch (error) {
28
+ dispose();
29
+ throw error;
30
+ }
31
+ if (import.meta.hot) import.meta.hot.dispose(dispose);
32
+ `,
33
+ 'src/hesh-units/identity/unit.hesh': `domain Identity {
34
+ implementation: "./actions.${ext}#createDomain";
35
+ owns { Session; }
36
+ exposes { session.current; session.login; }
37
+ }
38
+ presentation LoginScreen {
39
+ html: "./parts/login/screen.html";
40
+ logic: "./parts/login/actions.${ext}#createLogin";
41
+ bindings { name; status; }
42
+ actions { signIn; }
43
+ }
44
+ destination Login { url: "/login"; input {} presentation: LoginScreen; }
45
+ `,
46
+ [`src/hesh-units/identity/state.${ext}`]: `import { signal } from '${runtime}';
47
+ export const createState = () => ({ user: signal('Guest') });
48
+ `,
49
+ [`src/hesh-units/identity/actions.${ext}`]: `import { createState } from './state.${ext}';
50
+ export function createDomain() {
51
+ const state = createState();
52
+ ${typed ? '' : '/** @param {string} name */\n '}const login = (name${typed ? ': string' : ''}) => state.user.set(name);
53
+ return { session: { current: () => state.user.get(), login } };
54
+ }
55
+ `,
56
+ [`src/hesh-units/identity/parts/login/state.${ext}`]: `import { signal } from '${runtime}';
57
+ export const createState = () => ({ name: signal('Hesham'), status: signal('Browser-memory demo session') });
58
+ `,
59
+ [`src/hesh-units/identity/parts/login/actions.${ext}`]: `import { createState } from './state.${ext}';
60
+ ${typed ? "import type { createDomain } from '../../actions.ts';\n" : "/** @param {{ domains: { Identity: ReturnType<typeof import('../../actions.js').createDomain> } }} context */\n"}export function createLogin(context${typed ? ': { domains: { Identity: ReturnType<typeof createDomain> } }' : ''}) {
61
+ const state = createState();
62
+ return { bindings: state, actions: { signIn() {
63
+ context.domains.Identity.session.login(state.name.get());
64
+ state.status.set('Demo session updated. Navigate Home to see the injected domain contract.');
65
+ } } };
66
+ }
67
+ `,
68
+ 'src/hesh-units/identity/parts/login/screen.html': `<section><h2>Identity domain</h2><p>This is an in-memory demo, not real authentication. Protected operations require server authorization.</p><form h-action="signIn"><label for="name">Display name</label><input id="name" required h-value="name"><button type="submit">Update demo session</button></form><p role="status" h-bind="status"></p><a h-destination="Home">Back Home</a></section>\n`,
69
+ 'src/hesh-units/workspace/unit.hesh': `domain Workspace {
70
+ implementation: "./actions.${ext}#createDomain";
71
+ owns { Counter; }
72
+ exposes { counter.current; counter.increment; counter.decrement; viewer; }
73
+ consumes { Identity.session.current; }
74
+ }
75
+ presentation HomeScreen {
76
+ html: "./parts/home/screen.html";
77
+ logic: "./parts/home/actions.${ext}#createHome";
78
+ bindings { welcome; count; hint; id; }
79
+ actions { increment; decrement; }
80
+ }
81
+ presentation AboutScreen {
82
+ html: "./parts/about/screen.html";
83
+ logic: "./parts/about/actions.${ext}#createAbout";
84
+ bindings { description; }
85
+ actions {}
86
+ }
87
+ presentation DetailScreen {
88
+ html: "./parts/counter/parts/detail/screen.html";
89
+ logic: "./parts/counter/parts/detail/actions.${ext}#createDetail";
90
+ bindings { id; source; count; }
91
+ actions {}
92
+ }
93
+ destination Home { url: "/"; input {} presentation: HomeScreen; }
94
+ destination About { url: "/about"; input {} presentation: AboutScreen; }
95
+ destination CounterDetails { url: "/counter/:id"; input { id: string; } presentation: DetailScreen; }
96
+ `,
97
+ [`src/hesh-units/workspace/state.${ext}`]: `import { signal } from '${runtime}';
98
+ export const createState = () => ({ count: signal(0) });
99
+ `,
100
+ [`src/hesh-units/workspace/actions.${ext}`]: `import { createState } from './state.${ext}';
101
+ ${typed ? '' : '/** @param {{ Identity: { session: { current(): string } } }} dependencies */\n'}export function createDomain(dependencies${typed ? ': { Identity: { session: { current(): string } } }' : ''}) {
102
+ const state = createState();
103
+ return {
104
+ counter: { current: () => state.count.get(), increment: () => state.count.set(state.count.get() + 1), decrement: () => state.count.set(state.count.get() - 1) },
105
+ viewer: () => dependencies.Identity.session.current()
106
+ };
107
+ }
108
+ `,
109
+ [`src/hesh-units/workspace/parts/home/state.${ext}`]: `import { signal } from '${runtime}';
110
+ export const createState = () => ({ hint: signal('Domain state survives destination changes; presentation state has its own lifetime.') });
111
+ `,
112
+ [`src/hesh-units/workspace/parts/home/actions.${ext}`]: `import { createState } from './state.${ext}';
113
+ ${typed ? "import type { createDomain } from '../../actions.ts';\n" : "/** @param {{ domains: { Workspace: ReturnType<typeof import('../../actions.js').createDomain> } }} context */\n"}export function createHome(context${typed ? ': { domains: { Workspace: ReturnType<typeof createDomain> } }' : ''}) {
114
+ const state = createState(), workspace = context.domains.Workspace;
115
+ return { bindings: { welcome: () => 'Welcome, ' + workspace.viewer(), count: () => workspace.counter.current(), hint: state.hint, id: '42' },
116
+ actions: { increment: () => workspace.counter.increment(), decrement: () => workspace.counter.decrement() } };
117
+ }
118
+ `,
119
+ 'src/hesh-units/workspace/parts/home/screen.html': `<section><h2>Home · Workspace domain</h2><p h-bind="welcome"></p><p class="architecture-note">Domain Graph contracts + hierarchical Hesh Units: state / actions / screen / parts.</p><p>Domain counter: {count}</p><button h-action="increment">+</button><button h-action="decrement">-</button><p h-bind="hint"></p><a h-destination="CounterDetails" h-input-id="id">Open nested counter module</a></section>\n`,
120
+ [`src/hesh-units/workspace/parts/about/state.${ext}`]: `export const createState = () => ({ description: 'Domains own public contracts and dependencies. Each Hesh Unit keeps state, actions and an HTML screen together. The persistent shell and its badge use hierarchical ownership.' });
121
+ `,
122
+ [`src/hesh-units/workspace/parts/about/actions.${ext}`]: `import { createState } from './state.${ext}';
123
+ export const createAbout = () => ({ bindings: createState(), actions: {} });
124
+ `,
125
+ 'src/hesh-units/workspace/parts/about/screen.html': '<section><h2>Domain Graph + Hesh Units</h2><p h-bind="description"></p><a h-destination="Home">Home</a></section>\n',
126
+ [`src/hesh-units/workspace/parts/counter/parts/detail/state.${ext}`]: `import { signal } from '${runtime}';
127
+ ${typed ? '' : '/** @param {string} id */\n'}export const createState = (id${typed ? ': string' : ''}) => ({ id: signal(id) });
128
+ `,
129
+ [`src/hesh-units/workspace/parts/counter/parts/detail/actions.${ext}`]: `import { createState } from './state.${ext}';
130
+ ${typed ? "import type { createDomain } from '../../../../actions.ts';\nimport type { RouteLocation } from '@heshammesalam/heshjs/router';\n" : "/** @param {{ input: { id: string }, location: import('@heshammesalam/heshjs/router').RouteLocation, domains: { Workspace: ReturnType<typeof import('../../../../actions.js').createDomain> } }} context */\n"}export function createDetail(context${typed ? ': { input: { id: string }; location: RouteLocation; domains: { Workspace: ReturnType<typeof createDomain> } }' : ''}) {
131
+ const state = createState(context.input.id);
132
+ return { bindings: { id: state.id, source: () => context.location.query.get('from') ?? 'direct link', count: () => context.domains.Workspace.counter.current() }, actions: {} };
133
+ }
134
+ `,
135
+ 'src/hesh-units/workspace/parts/counter/parts/detail/screen.html': '<section><h2>Nested counter part</h2><p>Destination ID: {id}</p><p>Source: {source}</p><p>Shared domain counter: {count}</p><a h-destination="Home">Home</a></section>\n',
136
+ 'public/_redirects': '/* /index.html 200\n',
137
+ 'README.md': `# HeshJS Units\n\nOne package includes the runtime, compiler and CLI. Run npm install, npm run dev.\nUse npm run check, npm run graph and npm run build.\n\nHTML is the default: screen.html is the view, state.${ext} owns state, actions.${ext} owns behavior, unit.hesh declares contracts, and parts/ contains nested units.\nsrc/hesh-units contains business units; Domain Graph validates their public dependencies.\nsrc/foundation/shell is a persistent HTML unit with h-outlet and a nested h-part.\nShell and part state survives SPA navigation; page state is owned by the destination.\nThe bundled logo also serves as favicon. Identity is an in-memory demo session.\nProduction hosts must rewrite unknown app routes to index.html.\n`
138
+ };
139
+ files['src/app.hesh'] = 'import "./foundation/shell/unit.hesh";\n' + files['src/app.hesh'];
140
+ Object.assign(files, {
141
+ [`src/foundation/shell/state.${ext}`]: `import { signal } from '${runtime}';
142
+ export const createState = () => ({ clicks: signal(0) });
143
+ `,
144
+ [`src/foundation/shell/actions.${ext}`]: `${typed ? "import type { createState } from './state.ts';\n" : ''}import { createBadgeUnit } from './parts/badge/unit.${ext}';
145
+ ${typed ? '' : '/** @param {ReturnType<typeof import(\'./state.js\').createState>} state\n * @param {{ domains: { Identity: { session: { current(): unknown } } }; destinations: object }} application */\n'}export function createActions(state${typed ? ': ReturnType<typeof createState>' : ''}, application${typed ? ': { domains: { Identity: { session: { current(): unknown } } }; destinations: object }' : ''}) {
146
+ return { bindings: { clicks: state.clicks, viewer: () => String(application.domains.Identity.session.current()), detailId: '42' },
147
+ actions: { increment: () => state.clicks.set(state.clicks.get() + 1) }, parts: { Badge: createBadgeUnit() } };
148
+ }
149
+ `,
150
+ [`src/foundation/shell/unit.${ext}`]: `import { defineUnit } from '${runtime}/units';
151
+ import { createState } from './state.${ext}';
152
+ import { createActions } from './actions.${ext}';
153
+ import screen from '../../.hesh-generated/ShellScreen.js';
154
+ ${typed ? '' : '/** @param {{ domains: { Identity: { session: { current(): unknown } } }; destinations: object }} application */\n'}export function createShellUnit(application${typed ? ': { domains: { Identity: { session: { current(): unknown } } }; destinations: object }' : ''}) {
155
+ return defineUnit({ state: createState, actions: state => createActions(state, application), screen, destinations: application.destinations });
156
+ }
157
+ `,
158
+ 'src/foundation/shell/screen.html': `<header><div class="hesh-brand"><img src="/brand/hesh-icon.png" alt="HeshJS logo" width="80" height="80"><h1>HeshJS · Graph + Units</h1></div><nav aria-label="Main navigation"><a h-destination="Home">Home</a><a h-destination="About">About</a><a h-destination="CounterDetails" h-input-id="detailId">Counter detail</a><a h-destination="Login">Demo identity</a><a href="/missing">Not found</a></nav><p>Demo user: {viewer}</p><p>Shell state: {clicks}</p><button h-action="increment">Keep shell state</button></header><main h-outlet></main><footer h-part="Badge"></footer>\n`,
159
+ 'src/foundation/shell/unit.hesh': `presentation ShellScreen {
160
+ html: "./screen.html";
161
+ logic: "./actions.${ext}#createActions";
162
+ bindings { clicks; viewer; detailId; }
163
+ actions { increment; }
164
+ parts { Badge; }
165
+ }
166
+ presentation BadgeScreen {
167
+ html: "./parts/badge/screen.html";
168
+ logic: "./parts/badge/actions.${ext}#createActions";
169
+ bindings { count; }
170
+ actions { increment; }
171
+ }
172
+ `,
173
+ [`src/foundation/shell/parts/badge/state.${ext}`]: `import { signal } from '${runtime}';
174
+ export const createState = () => ({ count: signal(0) });
175
+ `,
176
+ [`src/foundation/shell/parts/badge/actions.${ext}`]: `${typed ? "import type { createState } from './state.ts';\n" : '/** @param {ReturnType<typeof import(\'./state.js\').createState>} state */\n'}export const createActions = (state${typed ? ': ReturnType<typeof createState>' : ''}) => ({ bindings: { count: state.count }, actions: { increment: () => state.count.set(state.count.get() + 1) } });
177
+ `,
178
+ [`src/foundation/shell/parts/badge/unit.${ext}`]: `import { defineUnit } from '${runtime}/units';
179
+ import { createState } from './state.${ext}';
180
+ import { createActions } from './actions.${ext}';
181
+ import screen from '../../../../.hesh-generated/BadgeScreen.js';
182
+ export const createBadgeUnit = () => defineUnit({ state: createState, actions: createActions, screen });
183
+ `,
184
+ 'src/foundation/shell/parts/badge/screen.html': '<aside><p>Nested Hesh part: {count}</p><button h-action="increment">Increment nested state</button></aside>\n'
185
+ });
186
+ return files;
187
+
188
+ }
@@ -2,6 +2,7 @@ import { parseFragment, type DefaultTreeAdapterMap } from 'parse5';
2
2
  import { diagnostic, type ArchitectureDiagnostic } from './diagnostics.js';
3
3
  export interface HTMLContract {
4
4
  filename: string; bindings: string[]; actions: string[];
5
+ parts?: string[]; allowOutlet?: boolean;
5
6
  destinations: Record<string, Record<string, string>>;
6
7
  validateBinding?: (path: string, offset: number, expected?: string, writable?: boolean) => void;
7
8
  }
@@ -49,8 +50,20 @@ export function compileHTML(source: string, contract: HTMLContract): HTMLResult
49
50
  const wholeBinding = attr.value.startsWith('{') && attr.value.endsWith('}') && !attr.value.slice(1, -1).includes('{') && !attr.value.slice(1, -1).includes('}');
50
51
  lines.push(`p.attribute(${id}, ${JSON.stringify(attr.name)}, () => ${wholeBinding ? path(attr.value.slice(1, -1), attrAt(attr.name) + 1) : interpolation(attr.value, attrAt(attr.name))});`);
51
52
  }
52
- for (const name of Object.keys(attributes)) if (name.startsWith('h-') && !['h-action', 'h-bind', 'h-value', 'h-destination'].includes(name) && !name.startsWith('h-input-')) report('HESH-HTML-004', `Unsupported directive ${name}`, attrAt(name), 'Use supported directives; keyed lists/scoped templates are a later milestone.');
53
- if (attributes['h-bind'] !== undefined) {
53
+ for (const name of Object.keys(attributes)) if (name.startsWith('h-') && !['h-action', 'h-bind', 'h-value', 'h-destination', 'h-part', 'h-outlet'].includes(name) && !name.startsWith('h-input-')) report('HESH-HTML-004', `Unsupported directive ${name}`, attrAt(name), 'Use supported directives; keyed lists/scoped templates are a later milestone.');
54
+ const content = ['h-bind', 'h-part', 'h-outlet'].filter(name => attributes[name] !== undefined);
55
+ if (content.length > 1 || (content.length && node.childNodes.length)) report('HESH-HTML-005', 'Content directives require an empty container and cannot be combined', at, 'Choose one content directive and remove children.');
56
+ if (attributes['h-part'] !== undefined || attributes['h-outlet'] !== undefined) {
57
+ if (['area','base','br','col','embed','hr','img','input','link','meta','param','source','track','wbr'].includes(node.tagName)) report('HESH-HTML-005', 'Parts and outlets require a container element', at, 'Use div, main, section or another container.');
58
+ if (attributes['h-part'] !== undefined) {
59
+ const name = attributes['h-part'];
60
+ if (!contract.parts?.includes(name)) report('HESH-PART-001', `Undeclared Hesh part ${name}`, at, 'Declare parts and return a Hesh Unit from parts.NAME.');
61
+ lines.push(`p.mountPart(${id}, state, ${JSON.stringify(name)});`);
62
+ } else {
63
+ if (!contract.allowOutlet || attributes['h-outlet'] !== '') report('HESH-PART-002', 'h-outlet is only available on standalone shell screens', at, 'Use an empty h-outlet on the persistent shell.');
64
+ lines.push(`p.unitOutlet(${id});`);
65
+ }
66
+ } else if (attributes['h-bind'] !== undefined) {
54
67
  if (node.childNodes.length) report('HESH-BIND-003', 'h-bind cannot also contain children', at, 'Remove the children or use text interpolation.');
55
68
  lines.push(`p.text(${id}, () => ${path(attributes['h-bind'], attrAt('h-bind'))});`);
56
69
  } else node.childNodes.forEach(child => visit(child, id));
@@ -152,10 +152,12 @@ export function createArchitectureCompiler() {
152
152
  const target = getMember(result, operation.name.split('.'));
153
153
  if (!target || !checker.getSignaturesOfType(target, ts.SignatureKind.Call).length) error('HESH-ARCH-003', `Unresolved callable implementation ${item.node.name}.${operation.name}`, item, operation.start, 'Return this operation from the declared implementation factory.');
154
154
  }
155
- else for (const block of ['bindings', 'actions']) {
155
+ else for (const block of ['bindings', 'actions', 'parts']) {
156
+ if (block === 'parts' && !list(item, block).length) continue;
156
157
  if (!getMember(result, [block])) error('HESH-BIND-002', `Presentation factory must return a ${block} object`, item);
157
- for (const binding of list(item, block, true)) {
158
+ for (const binding of list(item, block, block !== 'parts')) {
158
159
  const target = getMember(result, [block, binding.name]);
160
+ if (block === 'parts' && !getMember(result, [block, binding.name, 'attach'])?.getCallSignatures().length) error('HESH-PART-001', `Part ${binding.name} must be a Hesh Unit`, item, binding.start);
159
161
  if (!target || (block === 'actions' && !checker.getSignaturesOfType(target, ts.SignatureKind.Call).length)) error(block === 'actions' ? 'HESH-ACTION-001' : 'HESH-BIND-002', `Missing ${block} implementation ${binding.name}`, item, binding.start, `Return ${block}.${binding.name} from the presentation factory.`);
160
162
  }
161
163
  }
@@ -188,7 +190,12 @@ export function createArchitectureCompiler() {
188
190
  };
189
191
  walk(syntax);
190
192
  }
191
- for (const failure of [...program.getSyntacticDiagnostics(), ...program.getSemanticDiagnostics()]) if (failure.file && inside(canonical(failure.file.fileName), root)) diagnostics.push(diagnostic('HESH-TYPE-001', ts.flattenDiagnosticMessageText(failure.messageText, '\n'), failure.file.fileName, failure.file.text, failure.start ?? 0, 'Correct the implementation type/syntax error.'));
193
+ for (const failure of [...program.getSyntacticDiagnostics(), ...program.getSemanticDiagnostics()]) if (failure.file && inside(canonical(failure.file.fileName), root)) {
194
+ // Declared screen modules are produced by this compilation, including on a fresh checkout.
195
+ const missing = failure.code === 2307 && failure.file.text.slice(failure.start, (failure.start ?? 0) + (failure.length ?? 0)).replace(/^['"]|['"]$/g, '');
196
+ const generatedScreen = missing && presentations.some(item => canonical(resolve(dirname(failure.file!.fileName), missing)) === canonical(join(outDir, `${item.node.name}.js`)));
197
+ if (!generatedScreen) diagnostics.push(diagnostic('HESH-TYPE-001', ts.flattenDiagnosticMessageText(failure.messageText, '\n'), failure.file.fileName, failure.file.text, failure.start ?? 0, 'Correct the implementation type/syntax error.'));
198
+ }
192
199
  const schemas: Record<string, Record<string, string>> = Object.create(null), urls = new Map<string, Located>();
193
200
  const graph: ProjectResult['graph'] = { domains: domains.map(d => ({ name: d.node.name, exposes: list(d, 'exposes').map(e => e.name), consumes: list(d, 'consumes').map(e => e.name) })), destinations: [], links: [] };
194
201
  const presentationFor = new Map<Located, Located>();
@@ -215,11 +222,11 @@ export function createArchitectureCompiler() {
215
222
  schemas[item.node.name] = inputs;
216
223
  }
217
224
  const outputs: Record<string, string> = {}, htmlModules = new Map<Located, string>();
218
- for (const item of new Set(presentationFor.values())) {
225
+ for (const item of presentations) {
219
226
  const html = canonical(resolve(dirname(item.file), field(item, 'html'))), stylePath = field(item, 'styles', false);
220
227
  if (!isFile(html) || !html.endsWith('.html') || !inside(html, canonical(dirname(item.file)))) { error('HESH-HTML-001', `Missing or private presentation HTML ${html}`, item); continue; }
221
228
  const source = read(html), factory = resolvedFactories.get(item);
222
- const compiled = compileHTML(source, { filename: html, bindings: list(item, 'bindings', true).map(e => e.name), actions: list(item, 'actions', true).map(e => e.name), destinations: schemas,
229
+ const compiled = compileHTML(source, { filename: html, bindings: list(item, 'bindings', true).map(e => e.name), actions: list(item, 'actions', true).map(e => e.name), parts: list(item, 'parts').map(e => e.name), allowOutlet: ![...presentationFor.values()].includes(item), destinations: schemas,
223
230
  validateBinding(path, offset, expected, writable) {
224
231
  if (!factory) return;
225
232
  const type = getMember(factory.result, ['bindings', ...path.split('.')], true);
@@ -245,6 +252,7 @@ export function createArchitectureCompiler() {
245
252
  else { files.add(style); css = `import ${JSON.stringify(modulePath(output, style))};\n`; }
246
253
  }
247
254
  outputs[output] = css + compiled.code;
255
+ outputs[output.replace(/\.js$/, '.d.ts')] = "import type { PresentationState } from '@heshammesalam/heshjs/presentation';\nexport default function render(parent: Node, state: PresentationState, destinations?: object): void;\n";
248
256
  }
249
257
  const output = join(outDir, 'app.js'), types = join(outDir, 'app.d.ts');
250
258
  const js = ["import { createGraphApplication, createDomainGraph } from '@heshammesalam/heshjs/destinations';"];
@@ -1,5 +1,5 @@
1
1
  import { createRouter } from '../router/index.js';
2
- import { defineModule, useController, type RouteLocation } from '../hmvc/index.js';
2
+ import { defineModule, useController, useModule, withModuleContext, type RouteLocation } from '../hmvc/index.js';
3
3
  import { createDomainGraph, ClientPolicyDenied, type DomainDefinition, type DomainOptions, type DomainGraph } from '../domains/index.js';
4
4
  import { signal, effect, onCleanup, scope, type Disposer } from '../reactivity/index.js';
5
5
  import { mount, element, text } from '../dom/index.js';
@@ -85,6 +85,7 @@ export function createGraphApplication(definitions: readonly DomainDefinition[],
85
85
  model: context => context.route,
86
86
  controller: route => route,
87
87
  view(parent) {
88
+ const parentContext = useModule();
88
89
  const route = useController<{ get(): RouteLocation }>();
89
90
  effect(() => {
90
91
  const location = route.get();
@@ -106,7 +107,7 @@ export function createGraphApplication(definitions: readonly DomainDefinition[],
106
107
  const owned = scope(() => {
107
108
  const state = resources.create({ input, domains: graph.forDomain(page.owner), location, signal: abort.signal });
108
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');
109
- mount(() => resources.view(parent, state, destinations), parent, { onError: report });
110
+ mount(() => withModuleContext(parentContext, () => resources.view(parent, state, destinations)), parent, { onError: report });
110
111
  });
111
112
  dispose = owned.dispose;
112
113
  if (abort.signal.aborted) { dispose(); return; }
@@ -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>;
@@ -132,7 +132,7 @@ export function parse(source: string, filename = 'component.hesh'): ParsedModule
132
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
133
  const schemas: Record<Contract['kind'], Record<string, string>> = {
134
134
  domain: { implementation: 'string', owns: 'list', exposes: 'list', consumes: 'list', access: 'map' },
135
- presentation: { html: 'string', logic: 'string', styles: 'string', bindings: 'list', actions: 'list' },
135
+ presentation: { html: 'string', logic: 'string', styles: 'string', bindings: 'list', actions: 'list', parts: 'list' },
136
136
  destination: { url: 'string', input: 'map', presentation: 'identifier', access: 'string' },
137
137
  application: { domains: 'list', destinations: 'list' }
138
138
  };
@@ -1,7 +1,9 @@
1
1
  import { effect } from '../reactivity/index.js';
2
2
  import { nativeElement as element, text, attribute, listen } from '../dom/index.js';
3
3
  export { element, text, attribute, listen };
4
- export interface PresentationState { bindings: Record<string, unknown>; actions: Record<string, (event: Event) => unknown> }
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> }
5
7
  export function readBinding(bindings: Record<string, unknown>, path: string): unknown {
6
8
  const unwrap = (value: unknown): unknown => {
7
9
  if (typeof value === 'function') return value();
@@ -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';