@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.
- package/README.md +17 -18
- package/cli.js +8 -3
- package/dist/architecture/html.d.ts +2 -0
- package/dist/architecture/html.js +20 -2
- package/dist/architecture/html.js.map +1 -1
- package/dist/architecture/index.js +16 -6
- package/dist/architecture/index.js.map +1 -1
- package/dist/destinations/index.js +3 -2
- package/dist/destinations/index.js.map +1 -1
- 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.js +1 -1
- package/dist/parser/index.js.map +1 -1
- package/dist/presentation/index.d.ts +3 -0
- package/dist/presentation/index.js +1 -0
- package/dist/presentation/index.js.map +1 -1
- package/dist/units/index.d.ts +13 -0
- package/dist/units/index.js +16 -0
- package/dist/units/index.js.map +1 -0
- package/docs/contracts.md +8 -1
- package/docs/units.md +71 -0
- package/package.json +4 -3
- package/scaffold/assets/hesh-icon.png +0 -0
- package/scaffold/create.js +78 -0
- package/scaffold/template.js +188 -0
- package/src/architecture/html.ts +15 -2
- package/src/architecture/index.ts +13 -5
- package/src/destinations/index.ts +3 -2
- package/src/heshjs/index.ts +2 -0
- package/src/hmvc/index.ts +7 -0
- package/src/parser/index.ts +1 -1
- package/src/presentation/index.ts +3 -1
- package/src/units/index.ts +23 -0
|
@@ -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
|
+
}
|
package/src/architecture/html.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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))
|
|
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
|
|
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; }
|
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
|
@@ -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
|
|
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';
|