@heshammesalam/heshjs 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -4
- package/cli.js +26 -0
- package/dist/architecture/diagnostics.d.ts +12 -0
- package/dist/architecture/diagnostics.js +8 -0
- package/dist/architecture/diagnostics.js.map +1 -0
- package/dist/architecture/html.d.ts +16 -0
- package/dist/architecture/html.js +132 -0
- package/dist/architecture/html.js.map +1 -0
- package/dist/architecture/index.d.ts +42 -0
- package/dist/architecture/index.js +418 -0
- package/dist/architecture/index.js.map +1 -0
- package/dist/compiler/index.js +2 -0
- package/dist/compiler/index.js.map +1 -1
- package/dist/destinations/index.d.ts +63 -0
- package/dist/destinations/index.js +185 -0
- package/dist/destinations/index.js.map +1 -0
- package/dist/dom/index.d.ts +2 -0
- package/dist/dom/index.js +5 -1
- package/dist/dom/index.js.map +1 -1
- package/dist/domains/index.d.ts +24 -0
- package/dist/domains/index.js +110 -0
- package/dist/domains/index.js.map +1 -0
- package/dist/hmvc/index.d.ts +35 -0
- package/dist/hmvc/index.js +49 -0
- package/dist/hmvc/index.js.map +1 -0
- package/dist/parser/index.d.ts +15 -1
- package/dist/parser/index.js +73 -3
- package/dist/parser/index.js.map +1 -1
- package/dist/presentation/index.d.ts +9 -0
- package/dist/presentation/index.js +38 -0
- package/dist/presentation/index.js.map +1 -0
- package/dist/router/index.d.ts +24 -0
- package/dist/router/index.js +258 -0
- package/dist/router/index.js.map +1 -0
- package/dist/vite/index.d.ts +4 -1
- package/dist/vite/index.js +37 -2
- package/dist/vite/index.js.map +1 -1
- package/docs/contracts.md +60 -0
- package/docs/domain-graph.md +205 -0
- package/docs/grammar.md +4 -0
- package/docs/hmvc.md +134 -0
- package/package.json +10 -3
- package/src/architecture/diagnostics.ts +11 -0
- package/src/architecture/html.ts +88 -0
- package/src/architecture/index.ts +283 -0
- package/src/compiler/index.ts +1 -0
- package/src/destinations/index.ts +146 -0
- package/src/dom/index.ts +6 -1
- package/src/domains/index.ts +97 -0
- package/src/hmvc/index.ts +71 -0
- package/src/parser/index.ts +55 -4
- package/src/presentation/index.ts +31 -0
- package/src/router/index.ts +171 -0
- package/src/vite/index.ts +25 -2
package/docs/grammar.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Milestone 1 language (v0.1)
|
|
2
2
|
|
|
3
|
+
This remains the legacy component grammar. The opt-in architecture descriptor
|
|
4
|
+
extension is specified separately in [contracts.md](contracts.md); it does not
|
|
5
|
+
replace existing .hesh views or add template directives to the brace UI syntax.
|
|
6
|
+
|
|
3
7
|
The grammar is deliberately smaller than the product roadmap. Source is UTF-8.
|
|
4
8
|
Whitespace and JavaScript comments separate tokens. Identifiers follow JavaScript
|
|
5
9
|
identifier rules. Strings and numbers follow JavaScript lexical rules.
|
package/docs/hmvc.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# Building an HMVC single-page application
|
|
2
|
+
|
|
3
|
+
Requires HeshJS 0.2.0. New JavaScript/TypeScript projects use this structure:
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
src/
|
|
7
|
+
main.js # starts the router (.ts for TypeScript)
|
|
8
|
+
app.js # explicit route tree
|
|
9
|
+
modules/
|
|
10
|
+
shell/
|
|
11
|
+
model.js
|
|
12
|
+
controller.js
|
|
13
|
+
View.hesh
|
|
14
|
+
Navigation.js
|
|
15
|
+
module.js
|
|
16
|
+
home/
|
|
17
|
+
model.js
|
|
18
|
+
controller.js
|
|
19
|
+
View.hesh
|
|
20
|
+
module.js
|
|
21
|
+
modules/
|
|
22
|
+
detail/ # independent nested MVC triad
|
|
23
|
+
model.js
|
|
24
|
+
controller.js
|
|
25
|
+
View.hesh
|
|
26
|
+
module.js
|
|
27
|
+
about/ # same MVC structure
|
|
28
|
+
notFound/ # same MVC structure
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
This is an application architecture. Compiler, parser and runtime packages stay
|
|
32
|
+
separate infrastructure packages; they are not model/view/controller folders.
|
|
33
|
+
|
|
34
|
+
## Define a module
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
// model.js
|
|
38
|
+
import { signal } from '@heshammesalam/heshjs';
|
|
39
|
+
export const createModel = () => ({ count: signal(0) });
|
|
40
|
+
|
|
41
|
+
// controller.js
|
|
42
|
+
export const createController = (model, context) => ({
|
|
43
|
+
count: model.count,
|
|
44
|
+
increment: () => model.count.set(model.count.get() + 1),
|
|
45
|
+
id: () => context.route.get().params.id
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```hesh
|
|
50
|
+
// View.hesh
|
|
51
|
+
import { useController, Outlet } from '@heshammesalam/heshjs/hmvc';
|
|
52
|
+
export default view Counter {
|
|
53
|
+
const controller = useController();
|
|
54
|
+
h1("Count: {controller.count.get()}");
|
|
55
|
+
button("+", { controller.increment(); });
|
|
56
|
+
Outlet();
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
// module.js
|
|
62
|
+
import { defineModule } from '@heshammesalam/heshjs/hmvc';
|
|
63
|
+
import { createModel } from './model.js';
|
|
64
|
+
import { createController } from './controller.js';
|
|
65
|
+
import View from './View.hesh';
|
|
66
|
+
export const counterModule = defineModule({ model: createModel, controller: createController, view: View });
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Factories run once per mounted instance. Use `onCleanup` from HeshJS in factories
|
|
70
|
+
to dispose your subscriptions/resources. Do not call factories at import time if
|
|
71
|
+
their state should be instance-local. `useController`, `useModel` and `useModule`
|
|
72
|
+
are synchronous mount-time accessors. Capture the result in your view; events use
|
|
73
|
+
the captured controller. A controller receives `context.parent` for explicit access
|
|
74
|
+
to the parent module's model/controller. No global store or dependency injection
|
|
75
|
+
container is required. Generic `useController<MyController>()` is available in TS
|
|
76
|
+
view/helper modules, while `.hesh` remains JavaScript without full type checking.
|
|
77
|
+
|
|
78
|
+
## Declare the route hierarchy
|
|
79
|
+
|
|
80
|
+
```js
|
|
81
|
+
import { createRouter } from '@heshammesalam/heshjs/router';
|
|
82
|
+
// Import the modules declared by your application.
|
|
83
|
+
export const router = createRouter({ routes: [
|
|
84
|
+
{ path: '/', module: shellModule, children: [
|
|
85
|
+
{ path: '', module: homeModule, children: [
|
|
86
|
+
{ path: 'counter/:id', module: detailModule }
|
|
87
|
+
] },
|
|
88
|
+
{ path: 'about', module: aboutModule },
|
|
89
|
+
{ path: '*', module: notFoundModule }
|
|
90
|
+
] }
|
|
91
|
+
] });
|
|
92
|
+
|
|
93
|
+
const dispose = router.start(document.querySelector('#app'));
|
|
94
|
+
router.navigate('/counter/42?from=home');
|
|
95
|
+
router.navigate('/about', { replace: true });
|
|
96
|
+
// dispose() or router.stop() releases all modules and router listeners.
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Parents with child routes must render one `Outlet()`. Layout state and DOM stay
|
|
100
|
+
mounted when navigating between their children. Leaving a parent resets its state
|
|
101
|
+
on the next visit. Changes to that module's path parameters recreate the module
|
|
102
|
+
and descendants. Query/hash changes update its reactive `context.route.get()`
|
|
103
|
+
without recreating matching instances. A parent's params contain only parameters
|
|
104
|
+
matched up to that parent; `router.location.get()` contains the deepest match.
|
|
105
|
+
|
|
106
|
+
Ordinary same-origin anchor clicks update browser history without document reload.
|
|
107
|
+
Use DOM APIs for anchor attributes for now; declarative attribute syntax is not yet
|
|
108
|
+
part of Hesh grammar. The starter's Navigation component is an example. External,
|
|
109
|
+
download, modifier-key and non-self-target links retain browser behavior. Set
|
|
110
|
+
`data-hesh-reload` on an anchor to opt out. Back/forward and initial deep URLs work.
|
|
111
|
+
There is no base-path option yet; deploy at the origin root or define prefixed routes.
|
|
112
|
+
|
|
113
|
+
Unmatched URLs use `notFound`, a `*` route, or a safe built-in text message.
|
|
114
|
+
`onError` receives navigation/event errors. Programmatic navigation throws errors
|
|
115
|
+
to its caller. Setup must be synchronous. On setup failure new instances are
|
|
116
|
+
disposed, but previously removed children are not restored. Guards, lazy loading,
|
|
117
|
+
async controllers, server authorization and SSR are not implemented.
|
|
118
|
+
|
|
119
|
+
## Serve deep links in production
|
|
120
|
+
|
|
121
|
+
Vite dev/preview use SPA fallback. Production hosts must return index.html for
|
|
122
|
+
application paths. The client router cannot fix an HTTP 404 served before JS loads.
|
|
123
|
+
|
|
124
|
+
For nginx, inside your site's configuration:
|
|
125
|
+
|
|
126
|
+
```nginx
|
|
127
|
+
location / {
|
|
128
|
+
try_files $uri $uri/ /index.html;
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The scaffold includes `public/_redirects` containing `/* /index.html 200` for hosts
|
|
133
|
+
that support that convention. Configure asset/API routes separately on your host.
|
|
134
|
+
This project does not modify your deployment server automatically.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heshammesalam/heshjs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "An experimental independent reactive UI framework with brace-based .hesh components",
|
|
5
5
|
"author": "Hesham",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -14,11 +14,18 @@
|
|
|
14
14
|
"./parser": { "types": "./dist/parser/index.d.ts", "import": "./dist/parser/index.js" },
|
|
15
15
|
"./compiler": { "types": "./dist/compiler/index.d.ts", "import": "./dist/compiler/index.js" },
|
|
16
16
|
"./vite": { "types": "./dist/vite/index.d.ts", "import": "./dist/vite/index.js" },
|
|
17
|
+
"./router": { "types": "./dist/router/index.d.ts", "import": "./dist/router/index.js" },
|
|
18
|
+
"./hmvc": { "types": "./dist/hmvc/index.d.ts", "import": "./dist/hmvc/index.js" },
|
|
19
|
+
"./domains": { "types": "./dist/domains/index.d.ts", "import": "./dist/domains/index.js" },
|
|
20
|
+
"./destinations": { "types": "./dist/destinations/index.d.ts", "import": "./dist/destinations/index.js" },
|
|
21
|
+
"./presentation": { "types": "./dist/presentation/index.d.ts", "import": "./dist/presentation/index.js" },
|
|
22
|
+
"./architecture": { "types": "./dist/architecture/index.d.ts", "import": "./dist/architecture/index.js" },
|
|
17
23
|
"./package.json": "./package.json"
|
|
18
24
|
},
|
|
19
|
-
"files": ["dist", "src", "docs", "README.md"],
|
|
25
|
+
"files": ["dist", "src", "docs", "README.md", "cli.js"],
|
|
26
|
+
"bin": { "hesh": "./cli.js" },
|
|
20
27
|
"engines": { "node": ">=22.12.0" },
|
|
21
|
-
"dependencies": { "typescript": "~5.9.3" },
|
|
28
|
+
"dependencies": { "typescript": "~5.9.3", "parse5": "8.1.0" },
|
|
22
29
|
"peerDependencies": { "vite": "^7.2.2" },
|
|
23
30
|
"peerDependenciesMeta": { "vite": { "optional": true } },
|
|
24
31
|
"publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" }
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface ArchitectureDiagnostic {
|
|
2
|
+
code: string; severity: 'error' | 'warning'; message: string;
|
|
3
|
+
file: string; offset: number; line: number; column: number; suggestion: string;
|
|
4
|
+
}
|
|
5
|
+
export function diagnostic(code: string, message: string, file: string, source: string, offset: number, suggestion: string, severity: 'error' | 'warning' = 'error'): ArchitectureDiagnostic {
|
|
6
|
+
const lines = source.slice(0, offset).split('\n');
|
|
7
|
+
return { code, message, file, offset, line: lines.length, column: lines.at(-1)!.length + 1, suggestion, severity };
|
|
8
|
+
}
|
|
9
|
+
export function formatDiagnostic(item: ArchitectureDiagnostic): string {
|
|
10
|
+
return `${item.file}:${item.line}:${item.column} ${item.code} ${item.severity}: ${item.message}\n ${item.suggestion}`;
|
|
11
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { parseFragment, type DefaultTreeAdapterMap } from 'parse5';
|
|
2
|
+
import { diagnostic, type ArchitectureDiagnostic } from './diagnostics.js';
|
|
3
|
+
export interface HTMLContract {
|
|
4
|
+
filename: string; bindings: string[]; actions: string[];
|
|
5
|
+
destinations: Record<string, Record<string, string>>;
|
|
6
|
+
validateBinding?: (path: string, offset: number, expected?: string, writable?: boolean) => void;
|
|
7
|
+
}
|
|
8
|
+
export interface HTMLResult { code: string; diagnostics: ArchitectureDiagnostic[]; usedBindings: string[]; usedActions: string[]; destinations: string[] }
|
|
9
|
+
export function compileHTML(source: string, contract: HTMLContract): HTMLResult {
|
|
10
|
+
const diagnostics: ArchitectureDiagnostic[] = [], usedBindings = new Set<string>(), usedActions = new Set<string>();
|
|
11
|
+
const report = (code: string, message: string, at: number, suggestion: string, severity: 'error' | 'warning' = 'error') => diagnostics.push(diagnostic(code, message, contract.filename, source, at, suggestion, severity));
|
|
12
|
+
const fragment = parseFragment(source, { sourceCodeLocationInfo: true, onParseError: error => report('HESH-HTML-001', error.code, error.startOffset, 'Correct the HTML syntax.') });
|
|
13
|
+
const lines = ["import * as p from '@heshammesalam/heshjs/presentation';", 'export default function render(parent, state, destinations) {'];
|
|
14
|
+
let serial = 0;
|
|
15
|
+
const linked = new Set<string>();
|
|
16
|
+
const path = (value: string, offset: number, expected?: string, writable = false): string => {
|
|
17
|
+
const pieces = value.trim().split('.');
|
|
18
|
+
if (pieces.some(piece => !/^[$_\p{ID_Start}][$_\u200c\u200d\p{ID_Continue}]*$/u.test(piece) || ['constructor', '__proto__', 'prototype'].includes(piece))) report('HESH-BIND-001', `Invalid binding path ${value}`, offset, 'Use a declared dotted data path, not an expression.');
|
|
19
|
+
if (!contract.bindings.includes(pieces[0]!)) report('HESH-BIND-002', `Missing data binding ${pieces[0]}`, offset, 'Declare the binding and return it from the presentation factory.');
|
|
20
|
+
usedBindings.add(pieces[0]!); contract.validateBinding?.(value.trim(), offset, expected, writable);
|
|
21
|
+
return `p.readBinding(state.bindings, ${JSON.stringify(value.trim())})`;
|
|
22
|
+
};
|
|
23
|
+
const interpolation = (value: string, offset: number): string => {
|
|
24
|
+
const parts: string[] = []; let literal = '', cursor = 0;
|
|
25
|
+
const emit = () => { if (literal) { parts.push(JSON.stringify(literal)); literal = ''; } };
|
|
26
|
+
while (cursor < value.length) {
|
|
27
|
+
if (value[cursor] === '\\' && ['{', '}'].includes(value[cursor + 1] ?? '')) { literal += value[cursor + 1]; cursor += 2; continue; }
|
|
28
|
+
if (value[cursor] !== '{') { literal += value[cursor++]; continue; }
|
|
29
|
+
emit(); const start = ++cursor, end = value.indexOf('}', start);
|
|
30
|
+
if (end < 0) { report('HESH-BIND-001', 'Unterminated interpolation', offset + start - 1, 'Close the binding with }.'); break; }
|
|
31
|
+
parts.push(`String(${path(value.slice(start, end), offset + start)} ?? '')`); cursor = end + 1;
|
|
32
|
+
}
|
|
33
|
+
emit(); return parts.join(' + ') || "''";
|
|
34
|
+
};
|
|
35
|
+
const visit = (node: DefaultTreeAdapterMap['childNode'], parent: string): void => {
|
|
36
|
+
const at = node.sourceCodeLocation?.startOffset ?? 0;
|
|
37
|
+
if (node.nodeName === '#text') { lines.push(`p.text(${parent}, () => ${interpolation((node as DefaultTreeAdapterMap['textNode']).value, at)});`); return; }
|
|
38
|
+
if (!('tagName' in node)) return;
|
|
39
|
+
if (node.namespaceURI !== 'http://www.w3.org/1999/xhtml' || ['script', 'iframe', 'object', 'embed', 'base', 'meta', 'link', 'style', 'template'].includes(node.tagName)) {
|
|
40
|
+
report('HESH-HTML-002', `Unsupported active/foreign template element <${node.tagName}>`, at, 'Use semantic HTML and declared CSS/JS modules.'); return;
|
|
41
|
+
}
|
|
42
|
+
const id = `node${serial++}`;
|
|
43
|
+
lines.push(`const ${id} = p.element(${parent}, ${JSON.stringify(node.tagName)});`);
|
|
44
|
+
const attributes = Object.fromEntries(node.attrs.map(attr => [attr.name, attr.value]));
|
|
45
|
+
const attrAt = (name: string) => node.sourceCodeLocation?.attrs?.[name]?.startOffset ?? at;
|
|
46
|
+
for (const attr of node.attrs) {
|
|
47
|
+
if (attr.name.startsWith('h-')) continue;
|
|
48
|
+
if (/^on/i.test(attr.name) || ['srcdoc', 'innerhtml', 'outerhtml'].includes(attr.name)) { report('HESH-HTML-003', `Unsafe attribute ${attr.name}`, attrAt(attr.name), 'Use h-action and safe DOM bindings.'); continue; }
|
|
49
|
+
const wholeBinding = attr.value.startsWith('{') && attr.value.endsWith('}') && !attr.value.slice(1, -1).includes('{') && !attr.value.slice(1, -1).includes('}');
|
|
50
|
+
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
|
+
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) {
|
|
54
|
+
if (node.childNodes.length) report('HESH-BIND-003', 'h-bind cannot also contain children', at, 'Remove the children or use text interpolation.');
|
|
55
|
+
lines.push(`p.text(${id}, () => ${path(attributes['h-bind'], attrAt('h-bind'))});`);
|
|
56
|
+
} else node.childNodes.forEach(child => visit(child, id));
|
|
57
|
+
if (attributes['h-action'] !== undefined) {
|
|
58
|
+
const name = attributes['h-action']; usedActions.add(name);
|
|
59
|
+
if (!contract.actions.includes(name)) report('HESH-ACTION-001', `Missing action ${name}`, attrAt('h-action'), 'Declare and implement the action.');
|
|
60
|
+
lines.push(`p.bindAction(${id}, state, ${JSON.stringify(name)});`);
|
|
61
|
+
}
|
|
62
|
+
if (attributes['h-value'] !== undefined) {
|
|
63
|
+
const name = attributes['h-value'];
|
|
64
|
+
if (!['input', 'textarea', 'select'].includes(node.tagName) || name.includes('.') || ['checkbox', 'radio', 'file'].includes(attributes.type ?? '') || attributes.multiple !== undefined) report('HESH-BIND-004', 'h-value requires a single-value text control and root writable signal', attrAt('h-value'), 'Use a text input/textarea/single select and a declared signal.');
|
|
65
|
+
path(name, attrAt('h-value'), undefined, true);
|
|
66
|
+
lines.push(`p.bindValue(${id}, state.bindings, ${JSON.stringify(name)});`);
|
|
67
|
+
}
|
|
68
|
+
const destination = attributes['h-destination'];
|
|
69
|
+
const inputs = Object.keys(attributes).filter(name => name.startsWith('h-input-'));
|
|
70
|
+
if (destination !== undefined) {
|
|
71
|
+
linked.add(destination);
|
|
72
|
+
const schema = contract.destinations[destination];
|
|
73
|
+
if (!schema) report('HESH-NAV-001', `Unknown destination ${destination}`, attrAt('h-destination'), 'Declare this destination in the application.');
|
|
74
|
+
if (node.tagName !== 'a' || attributes.href !== undefined) report('HESH-NAV-002', 'h-destination requires an anchor without an explicit href', at, 'Use <a h-destination="Name" ...>.');
|
|
75
|
+
for (const key of Object.keys(schema ?? {})) if (attributes[`h-input-${key.toLowerCase()}`] === undefined) report('HESH-NAV-003', `Missing destination input ${key}`, at, `Add h-input-${key} bound to a declared value.`);
|
|
76
|
+
const entries = inputs.map(attr => {
|
|
77
|
+
const key = Object.keys(schema ?? {}).find(key => key.toLowerCase() === attr.slice(8)) ?? attr.slice(8);
|
|
78
|
+
if (!schema || !Object.hasOwn(schema, key)) report('HESH-NAV-003', `Unknown destination input ${key}`, attrAt(attr), 'Use only inputs declared by the destination.');
|
|
79
|
+
return `${JSON.stringify(key)}: ${path(attributes[attr]!, attrAt(attr), schema?.[key])}`;
|
|
80
|
+
});
|
|
81
|
+
lines.push(`p.attribute(${id}, 'href', () => destinations[${JSON.stringify(destination)}].href({${entries.join(',')}}));`);
|
|
82
|
+
} else if (inputs.length) report('HESH-NAV-003', 'h-input-* requires h-destination', at, 'Declare the destination on this anchor.');
|
|
83
|
+
};
|
|
84
|
+
fragment.childNodes.forEach(node => visit(node, 'parent')); lines.push('}');
|
|
85
|
+
for (const name of contract.bindings) if (!usedBindings.has(name)) report('HESH-BIND-005', `Unused binding ${name}`, 0, 'Remove the declaration if it is not needed by this template.', 'warning');
|
|
86
|
+
for (const name of contract.actions) if (!usedActions.has(name)) report('HESH-ACTION-002', `Unused action ${name}`, 0, 'Remove the declaration if it is not needed by this template.', 'warning');
|
|
87
|
+
return { code: lines.join('\n'), diagnostics, usedBindings: [...usedBindings], usedActions: [...usedActions], destinations: [...linked] };
|
|
88
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import { readFileSync, existsSync, realpathSync, mkdirSync, writeFileSync, statSync } from 'node:fs';
|
|
3
|
+
import { resolve, dirname, relative, extname, join } from 'node:path';
|
|
4
|
+
import { parse, HeshError, type Module, type Contract, type ContractEntry } from '../parser/index.js';
|
|
5
|
+
import { compileHTML } from './html.js';
|
|
6
|
+
import { diagnostic, formatDiagnostic, type ArchitectureDiagnostic } from './diagnostics.js';
|
|
7
|
+
export { compileHTML, formatDiagnostic };
|
|
8
|
+
export type { ArchitectureDiagnostic };
|
|
9
|
+
export interface ProjectOptions { root: string; entry?: string; tsconfig?: string; outDir?: string }
|
|
10
|
+
export interface ProjectResult {
|
|
11
|
+
diagnostics: ArchitectureDiagnostic[]; files: string[]; outputs: Record<string, string>;
|
|
12
|
+
graph: { domains: { name: string; exposes: string[]; consumes: string[] }[]; destinations: { name: string; url: string; input: Record<string, string>; owner: string }[]; links: { from: string; to: string }[] };
|
|
13
|
+
statistics: { parsed: number; reused: number };
|
|
14
|
+
}
|
|
15
|
+
interface Located { node: Contract; file: string; source: string }
|
|
16
|
+
interface Factory { file: string; name: string; result: ts.Type }
|
|
17
|
+
const canonical = (file: string) => {
|
|
18
|
+
const path = resolve(file); const real = existsSync(path) ? realpathSync(path) : path;
|
|
19
|
+
return (ts.sys.useCaseSensitiveFileNames ? real : real.toLowerCase()).replace(/\\/g, '/');
|
|
20
|
+
};
|
|
21
|
+
const inside = (file: string, folder: string) => file === folder || file.startsWith(folder + '/');
|
|
22
|
+
const isFile = (file: string) => existsSync(file) && statSync(file).isFile();
|
|
23
|
+
const modulePath = (from: string, to: string) => { const path = relative(dirname(from), to).replace(/\\/g, '/'); return path.startsWith('.') ? path : './' + path; };
|
|
24
|
+
export function createArchitectureCompiler() {
|
|
25
|
+
const cache = new Map<string, { source: string; ast: Module }>();
|
|
26
|
+
return { clear() { cache.clear(); }, check(options: ProjectOptions): ProjectResult {
|
|
27
|
+
const root = canonical(options.root), entry = canonical(resolve(root, options.entry ?? 'app.hesh'));
|
|
28
|
+
const outDir = resolve(root, options.outDir ?? '.hesh-generated');
|
|
29
|
+
const diagnostics: ArchitectureDiagnostic[] = [], descriptors = new Map<string, Module>(), declarations = new Map<string, Located>();
|
|
30
|
+
const files = new Set<string>(); let parsed = 0, reused = 0;
|
|
31
|
+
const error = (code: string, message: string, item: Located, position = item.node.start, suggestion = 'Correct the declared contract.') => diagnostics.push(diagnostic(code, message, item.file, item.source, position, suggestion));
|
|
32
|
+
const read = (file: string): string => { files.add(file); return readFileSync(file, 'utf8'); };
|
|
33
|
+
const load = (file: string): void => {
|
|
34
|
+
if (descriptors.has(file)) return;
|
|
35
|
+
if (!inside(file, root) || !isFile(file)) { diagnostics.push(diagnostic('HESH-ARCH-001', 'Missing or out-of-project descriptor', file, '', 0, 'Import an existing .hesh file within the application root.')); return; }
|
|
36
|
+
const source = read(file); let ast: Module;
|
|
37
|
+
try {
|
|
38
|
+
const saved = cache.get(file);
|
|
39
|
+
if (saved?.source === source) { ast = saved.ast; reused++; }
|
|
40
|
+
else { ast = parse(source, file); cache.set(file, { source, ast }); parsed++; }
|
|
41
|
+
} catch (failure) {
|
|
42
|
+
diagnostics.push(diagnostic('HESH-SYNTAX-001', failure instanceof Error ? failure.message : String(failure), file, source, failure instanceof HeshError ? failure.offset : 0, 'Fix the Hesh declaration syntax.')); return;
|
|
43
|
+
}
|
|
44
|
+
descriptors.set(file, ast);
|
|
45
|
+
if (ast.views.length) diagnostics.push(diagnostic('HESH-ARCH-001', 'Architecture descriptor files cannot also contain view implementations', file, source, ast.views[0]!.start, 'Keep existing .hesh views in separate component files.'));
|
|
46
|
+
for (const node of ast.contracts ?? []) {
|
|
47
|
+
const item = { node, file, source };
|
|
48
|
+
if (declarations.has(node.name)) error('HESH-ARCH-002', `Duplicate declaration ${node.name}`, item);
|
|
49
|
+
else declarations.set(node.name, item);
|
|
50
|
+
}
|
|
51
|
+
for (const item of ast.imports) {
|
|
52
|
+
const syntax = ts.createSourceFile(file, item.source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
|
|
53
|
+
const statement = syntax.statements[0];
|
|
54
|
+
if (!statement || !ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || statement.importClause || !statement.moduleSpecifier.text.endsWith('.hesh')) {
|
|
55
|
+
diagnostics.push(diagnostic('HESH-ARCH-001', 'Architecture imports must be side-effect imports of .hesh descriptors', file, source, item.start, 'Use import "./domains/inventory/domain.hesh";.')); continue;
|
|
56
|
+
}
|
|
57
|
+
load(canonical(resolve(dirname(file), statement.moduleSpecifier.text)));
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
load(entry);
|
|
61
|
+
const all = [...declarations.values()];
|
|
62
|
+
const applications = all.filter(d => d.node.kind === 'application');
|
|
63
|
+
const app = applications[0];
|
|
64
|
+
if (applications.length !== 1) diagnostics.push(diagnostic('HESH-ARCH-001', 'Exactly one application declaration is required', entry, descriptors.get(entry)?.source ?? '', 0, 'Declare one application listing domains and destinations.'));
|
|
65
|
+
const select = (kind: Contract['kind'], names: ContractEntry[] | undefined) => (names ?? []).flatMap(name => {
|
|
66
|
+
const target = declarations.get(name.name);
|
|
67
|
+
if (!target || target.node.kind !== kind) { if (app) error('HESH-ARCH-003', `Missing ${kind} ${name.name}`, app, name.start); return []; }
|
|
68
|
+
return [target];
|
|
69
|
+
});
|
|
70
|
+
const domains = select('domain', app?.node.blocks.domains), destinations = select('destination', app?.node.blocks.destinations);
|
|
71
|
+
const presentations = all.filter(d => d.node.kind === 'presentation');
|
|
72
|
+
const declaredDomains = all.filter(d => d.node.kind === 'domain');
|
|
73
|
+
const owner = (file: string): Located | undefined => declaredDomains.filter(d => inside(file, canonical(dirname(d.file)))).sort((a, b) => dirname(b.file).length - dirname(a.file).length)[0];
|
|
74
|
+
const field = (item: Located, name: string, required = true) => {
|
|
75
|
+
const value = item.node.fields[name]?.value;
|
|
76
|
+
if (required && !value) error('HESH-ARCH-003', `Missing field ${name} in ${item.node.name}`, item);
|
|
77
|
+
return value ?? '';
|
|
78
|
+
};
|
|
79
|
+
const list = (item: Located, name: string, required = false): ContractEntry[] => {
|
|
80
|
+
if (required && !item.node.blocks[name]) error('HESH-ARCH-003', `Missing block ${name}`, item);
|
|
81
|
+
return item.node.blocks[name] ?? [];
|
|
82
|
+
};
|
|
83
|
+
if (app) { list(app, 'domains', true); list(app, 'destinations', true); }
|
|
84
|
+
const factories = new Map<Located, { file: string; name: string }>();
|
|
85
|
+
for (const item of [...domains, ...presentations]) {
|
|
86
|
+
const specifier = field(item, item.node.kind === 'domain' ? 'implementation' : 'logic');
|
|
87
|
+
const hash = specifier.lastIndexOf('#'), path = specifier.slice(0, hash), name = specifier.slice(hash + 1);
|
|
88
|
+
if (hash < 1 || !/^[$_\p{ID_Start}][$_\u200c\u200d\p{ID_Continue}]*$/u.test(name) || !path.startsWith('.') || !['.js', '.ts', '.mjs'].includes(extname(path)) || path.endsWith('.d.ts')) { error('HESH-ARCH-003', `Invalid implementation reference ${specifier}`, item, item.node.start, 'Use ./module.js#exportName (or .ts/.mjs).'); continue; }
|
|
89
|
+
const file = canonical(resolve(dirname(item.file), path));
|
|
90
|
+
if (!existsSync(file) || !inside(file, root) || owner(file) !== owner(item.file)) { error('HESH-ARCH-004', `Implementation must exist inside its owning domain: ${specifier}`, item); continue; }
|
|
91
|
+
factories.set(item, { file, name }); files.add(file);
|
|
92
|
+
}
|
|
93
|
+
const byDomain = new Map(domains.map(item => [item.node.name, item]));
|
|
94
|
+
for (const item of declaredDomains) if (declaredDomains.some(other => other !== item && dirname(other.file) === dirname(item.file))) error('HESH-ARCH-002', 'Each domain needs its own directory boundary', item);
|
|
95
|
+
const edges = new Map<string, string[]>();
|
|
96
|
+
for (const item of domains) {
|
|
97
|
+
const exposes = list(item, 'exposes', true).map(v => v.name);
|
|
98
|
+
if (!exposes.length) error('HESH-ARCH-003', `Domain ${item.node.name} must expose at least one operation`, item);
|
|
99
|
+
for (const name of exposes) if (exposes.some(other => other !== name && other.startsWith(name + '.'))) error('HESH-ARCH-003', `Overlapping public operation ${name}`, item);
|
|
100
|
+
const dependencies: string[] = [];
|
|
101
|
+
for (const consume of list(item, 'consumes')) {
|
|
102
|
+
const [provider, ...path] = consume.name.split('.'), target = byDomain.get(provider!);
|
|
103
|
+
if (!target || !path.length || !list(target, 'exposes').some(v => v.name === path.join('.'))) error('HESH-ARCH-003', `Unresolved consumed operation ${consume.name}`, item, consume.start, 'Consume an operation explicitly exposed by a selected domain.');
|
|
104
|
+
dependencies.push(provider!);
|
|
105
|
+
}
|
|
106
|
+
for (const policy of list(item, 'access')) if (!exposes.includes(policy.name)) error('HESH-ARCH-003', `Access policy refers to unexposed operation ${policy.name}`, item, policy.start);
|
|
107
|
+
edges.set(item.node.name, dependencies);
|
|
108
|
+
}
|
|
109
|
+
const visited = new Set<string>(), visiting: string[] = [];
|
|
110
|
+
const visit = (name: string) => {
|
|
111
|
+
if (visiting.includes(name)) { const item = byDomain.get(name); if (item) error('HESH-ARCH-005', `Circular dependency: ${[...visiting, name].join(' -> ')}`, item, item.node.start, 'Remove the cycle or extract an explicit shared contract.'); return; }
|
|
112
|
+
if (visited.has(name)) return;
|
|
113
|
+
visiting.push(name); for (const next of edges.get(name) ?? []) visit(next); visiting.pop(); visited.add(name);
|
|
114
|
+
};
|
|
115
|
+
for (const name of byDomain.keys()) visit(name);
|
|
116
|
+
let settings: ts.CompilerOptions = { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Bundler, allowJs: true, checkJs: true, noEmit: true, skipLibCheck: true, allowImportingTsExtensions: true, types: [] };
|
|
117
|
+
if (options.tsconfig) {
|
|
118
|
+
const configFile = resolve(root, options.tsconfig), config = ts.readConfigFile(configFile, ts.sys.readFile);
|
|
119
|
+
if (config.error) diagnostics.push(diagnostic('HESH-ARCH-001', ts.flattenDiagnosticMessageText(config.error.messageText, '\n'), configFile, '', 0, 'Correct the TypeScript project configuration.'));
|
|
120
|
+
else settings = { ...settings, ...ts.parseJsonConfigFileContent(config.config, ts.sys, dirname(configFile)).options, noEmit: true, allowJs: true, checkJs: true };
|
|
121
|
+
}
|
|
122
|
+
const program = ts.createProgram([...factories.values()].map(f => f.file), settings);
|
|
123
|
+
const checker = program.getTypeChecker();
|
|
124
|
+
const resolvedFactories = new Map<Located, Factory>();
|
|
125
|
+
const getMember = (type: ts.Type, path: string[], unwrap = false): ts.Type | undefined => {
|
|
126
|
+
let current = type;
|
|
127
|
+
const unwrapped = (value: ts.Type): ts.Type => {
|
|
128
|
+
const get = checker.getPropertyOfType(value, 'get');
|
|
129
|
+
const callable = get ? checker.getTypeOfSymbolAtLocation(get, get.valueDeclaration ?? program.getSourceFiles()[0]!) : value;
|
|
130
|
+
return checker.getSignaturesOfType(callable, ts.SignatureKind.Call)[0]?.getReturnType() ?? value;
|
|
131
|
+
};
|
|
132
|
+
for (const piece of path) {
|
|
133
|
+
if (unwrap) current = unwrapped(current);
|
|
134
|
+
if (current.flags & ts.TypeFlags.Any) return current;
|
|
135
|
+
const symbol = checker.getPropertyOfType(current, piece); if (!symbol) return;
|
|
136
|
+
current = checker.getTypeOfSymbolAtLocation(symbol, symbol.valueDeclaration ?? program.getSourceFiles()[0]!);
|
|
137
|
+
}
|
|
138
|
+
return unwrap ? unwrapped(current) : current;
|
|
139
|
+
};
|
|
140
|
+
for (const [item, reference] of factories) {
|
|
141
|
+
const sourceFile = program.getSourceFile(reference.file);
|
|
142
|
+
const symbol = sourceFile && checker.getSymbolAtLocation(sourceFile);
|
|
143
|
+
let exported = symbol && checker.getExportsOfModule(symbol).find(v => v.name === reference.name);
|
|
144
|
+
if (exported?.flags && exported.flags & ts.SymbolFlags.Alias) exported = checker.getAliasedSymbol(exported);
|
|
145
|
+
const type = exported && checker.getTypeOfSymbolAtLocation(exported, exported.valueDeclaration ?? sourceFile!);
|
|
146
|
+
const signature = type && checker.getSignaturesOfType(type, ts.SignatureKind.Call)[0];
|
|
147
|
+
if (!signature) { error('HESH-ARCH-003', `Missing callable export ${reference.name} in ${reference.file}`, item); continue; }
|
|
148
|
+
const result = signature.getReturnType();
|
|
149
|
+
if (result.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown) || checker.getPropertyOfType(result, 'then')) { error('HESH-ARCH-003', `Factory ${reference.name} must return a statically inspectable synchronous object`, item); continue; }
|
|
150
|
+
resolvedFactories.set(item, { ...reference, result });
|
|
151
|
+
if (item.node.kind === 'domain') for (const operation of list(item, 'exposes')) {
|
|
152
|
+
const target = getMember(result, operation.name.split('.'));
|
|
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
|
+
}
|
|
155
|
+
else for (const block of ['bindings', 'actions']) {
|
|
156
|
+
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
|
+
const target = getMember(result, [block, binding.name]);
|
|
159
|
+
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
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// Validate actual imports/re-exports, including aliases and files shared by domains.
|
|
164
|
+
const sources = ts.sys.readDirectory(root, ['.ts', '.js', '.mjs'], ['**/node_modules/**', '**/dist/**', '**/.hesh-generated/**'], ['**/*']);
|
|
165
|
+
for (const path of sources) {
|
|
166
|
+
const file = canonical(path); if (inside(file, canonical(outDir))) continue;
|
|
167
|
+
files.add(file); const source = read(file), syntax = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, file.endsWith('.ts') ? ts.ScriptKind.TS : ts.ScriptKind.JS);
|
|
168
|
+
const checkImport = (specifier: ts.Node | undefined): void => {
|
|
169
|
+
if (!specifier) return;
|
|
170
|
+
if (!ts.isStringLiteralLike(specifier)) { diagnostics.push(diagnostic('HESH-ARCH-007', 'Computed module imports cannot be architecture-validated', file, source, specifier.getStart(syntax), 'Use literal imports and explicit injected dependencies.')); return; }
|
|
171
|
+
if (specifier.text.startsWith('node:') || ['fs', 'path', 'child_process', 'worker_threads', 'net', 'tls', 'http', 'https', 'os', 'module'].includes(specifier.text)) {
|
|
172
|
+
diagnostics.push(diagnostic('HESH-SEC-001', `Node/server-only import in client code: ${specifier.text}`, file, source, specifier.getStart(syntax), 'Call server infrastructure through an API rather than bundling it.')); return;
|
|
173
|
+
}
|
|
174
|
+
const target = ts.resolveModuleName(specifier.text, file, settings, ts.sys).resolvedModule?.resolvedFileName;
|
|
175
|
+
const generated = canonical(resolve(dirname(file), specifier.text));
|
|
176
|
+
if (inside(generated, canonical(outDir))) return;
|
|
177
|
+
if (!target) { if (!specifier.text.endsWith('.css') && !specifier.text.endsWith('.hesh')) diagnostics.push(diagnostic('HESH-ARCH-006', `Unresolved module ${specifier.text}`, file, source, specifier.getStart(syntax), 'Fix the path or supply the correct tsconfig alias.')); return; }
|
|
178
|
+
const targetFile = canonical(target); files.add(targetFile);
|
|
179
|
+
if (/(?:^|\/)server(?:\/|$)|\.server\.[cm]?[jt]s$/.test(targetFile)) diagnostics.push(diagnostic('HESH-SEC-001', `Server-only module in client graph: ${specifier.text}`, file, source, specifier.getStart(syntax), 'Move server calls behind a server API; never import server-only code into the browser.'));
|
|
180
|
+
const targetDomain = owner(targetFile), sourceDomain = owner(file);
|
|
181
|
+
if (targetDomain && targetDomain !== sourceDomain) diagnostics.push(diagnostic('HESH-ARCH-004', `Illegal cross-domain dependency. Source domain: ${sourceDomain?.node.name ?? 'shared/application'}. Target domain: ${targetDomain.node.name}. The imported module is private.`, file, source, specifier.getStart(syntax), `Use an explicitly exposed ${targetDomain.node.name} operation through declared dependency injection.`));
|
|
182
|
+
};
|
|
183
|
+
const walk = (node: ts.Node): void => {
|
|
184
|
+
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) checkImport(node.moduleSpecifier);
|
|
185
|
+
else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) checkImport(node.argument.literal);
|
|
186
|
+
else if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || (ts.isIdentifier(node.expression) && node.expression.text === 'require'))) checkImport(node.arguments[0]);
|
|
187
|
+
ts.forEachChild(node, walk);
|
|
188
|
+
};
|
|
189
|
+
walk(syntax);
|
|
190
|
+
}
|
|
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.'));
|
|
192
|
+
const schemas: Record<string, Record<string, string>> = Object.create(null), urls = new Map<string, Located>();
|
|
193
|
+
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
|
+
const presentationFor = new Map<Located, Located>();
|
|
195
|
+
for (const item of destinations) {
|
|
196
|
+
const url = field(item, 'url'), inputs = Object.fromEntries(list(item, 'input', true).map(e => [e.name, e.value ?? '']));
|
|
197
|
+
const segments = url.split('/').filter(Boolean), params = segments.filter(p => p.startsWith(':')).map(p => p.slice(1));
|
|
198
|
+
if (!url.startsWith('/') || url.startsWith('//') || /[?#*%]/.test(url) || new Set(params).size !== params.length || params.some(p => !Object.hasOwn(inputs, p)) || Object.keys(inputs).some(p => !params.includes(p))) error('HESH-NAV-003', `URL/input mismatch for ${item.node.name}`, item, item.node.start, 'Use an absolute URL whose :params exactly match the input block; use plain static segments.');
|
|
199
|
+
for (const input of list(item, 'input')) if (!['string', 'number', 'boolean'].includes(input.value ?? '') || input.name.includes('.')) error('HESH-NAV-003', `Invalid input schema ${input.name}`, item, input.start);
|
|
200
|
+
for (const key of Object.keys(inputs)) if (!/^[A-Za-z_][A-Za-z\d_]*$/.test(key)) error('HESH-NAV-003', `Unsupported URL parameter name ${key}`, item, item.node.start, 'Use ASCII letters/digits/underscore with a letter or underscore first.');
|
|
201
|
+
if (new Set(Object.keys(inputs).map(key => key.toLowerCase())).size !== Object.keys(inputs).length) error('HESH-NAV-003', 'Destination input names must be unique ignoring case for HTML attributes', item);
|
|
202
|
+
const signature = segments.map(p => p.startsWith(':') ? ':' : p).join('/');
|
|
203
|
+
if (urls.has(signature)) error('HESH-NAV-004', `Conflicting destination URL ${url}`, item); else urls.set(signature, item);
|
|
204
|
+
for (const previous of destinations.slice(0, destinations.indexOf(item))) {
|
|
205
|
+
const other = (previous.node.fields.url?.value ?? '').split('/').filter(Boolean);
|
|
206
|
+
if (other.length === segments.length && other.every((part, index) => part === segments[index] || part.startsWith(':') || segments[index]!.startsWith(':'))) error('HESH-NAV-004', `Overlapping destination URLs: ${url} and ${previous.node.fields.url?.value}`, item, item.node.start, 'Use non-overlapping addresses so destination input always resolves to the declared screen.');
|
|
207
|
+
}
|
|
208
|
+
const presentation = declarations.get(field(item, 'presentation'));
|
|
209
|
+
if (!presentation || presentation.node.kind !== 'presentation') error('HESH-NAV-001', `Missing presentation for ${item.node.name}`, item);
|
|
210
|
+
else {
|
|
211
|
+
const own = owner(presentation.file);
|
|
212
|
+
if (!own || !byDomain.has(own.node.name) || owner(item.file) !== own) error('HESH-ARCH-004', 'Destination and presentation must belong to the same selected domain', item);
|
|
213
|
+
else { presentationFor.set(item, presentation); graph.destinations.push({ name: item.node.name, url, input: inputs, owner: own.node.name }); }
|
|
214
|
+
}
|
|
215
|
+
schemas[item.node.name] = inputs;
|
|
216
|
+
}
|
|
217
|
+
const outputs: Record<string, string> = {}, htmlModules = new Map<Located, string>();
|
|
218
|
+
for (const item of new Set(presentationFor.values())) {
|
|
219
|
+
const html = canonical(resolve(dirname(item.file), field(item, 'html'))), stylePath = field(item, 'styles', false);
|
|
220
|
+
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
|
+
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,
|
|
223
|
+
validateBinding(path, offset, expected, writable) {
|
|
224
|
+
if (!factory) return;
|
|
225
|
+
const type = getMember(factory.result, ['bindings', ...path.split('.')], true);
|
|
226
|
+
if (type && type.flags & ts.TypeFlags.Any) diagnostics.push(diagnostic('HESH-TYPE-002', `Binding ${path} has a dynamic any type; its nested shape/input type cannot be proven`, html, source, offset, 'Add JSDoc or a TypeScript type if static checking is required; runtime destination validation still applies.', 'warning'));
|
|
227
|
+
if (!type) diagnostics.push(diagnostic('HESH-BIND-002', `Missing nested data path ${path}`, html, source, offset, 'Return this field in the declared bindings.'));
|
|
228
|
+
if (writable) {
|
|
229
|
+
const raw = getMember(factory.result, ['bindings', path]);
|
|
230
|
+
if (!raw || !checker.getPropertyOfType(raw, 'set')) diagnostics.push(diagnostic('HESH-BIND-004', `Binding ${path} is not writable`, html, source, offset, 'Use a writable signal for h-value.'));
|
|
231
|
+
}
|
|
232
|
+
if (expected && type && !(type.flags & ts.TypeFlags.Any)) {
|
|
233
|
+
const actual = type.flags & ts.TypeFlags.StringLike ? 'string' : type.flags & ts.TypeFlags.NumberLike ? 'number' : type.flags & ts.TypeFlags.BooleanLike ? 'boolean' : 'object/unknown';
|
|
234
|
+
if (actual !== expected) diagnostics.push(diagnostic('HESH-NAV-003', `Destination binding ${path} is ${actual}, expected ${expected}`, html, source, offset, 'Bind a value of the destination input type.'));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
diagnostics.push(...compiled.diagnostics);
|
|
239
|
+
for (const [destination, screen] of presentationFor) if (screen === item) for (const target of compiled.destinations) graph.links.push({ from: destination.node.name, to: target });
|
|
240
|
+
const output = join(outDir, `${item.node.name}.js`); htmlModules.set(item, output);
|
|
241
|
+
let css = '';
|
|
242
|
+
if (stylePath) {
|
|
243
|
+
const style = canonical(resolve(dirname(item.file), stylePath));
|
|
244
|
+
if (!isFile(style) || !style.endsWith('.css') || !inside(style, canonical(dirname(item.file)))) error('HESH-HTML-001', `Missing/private CSS resource ${stylePath}`, item);
|
|
245
|
+
else { files.add(style); css = `import ${JSON.stringify(modulePath(output, style))};\n`; }
|
|
246
|
+
}
|
|
247
|
+
outputs[output] = css + compiled.code;
|
|
248
|
+
}
|
|
249
|
+
const output = join(outDir, 'app.js'), types = join(outDir, 'app.d.ts');
|
|
250
|
+
const js = ["import { createGraphApplication, createDomainGraph } from '@heshammesalam/heshjs/destinations';"];
|
|
251
|
+
const domainValues: string[] = [], pageValues: string[] = [], typeImports: string[] = [], domainTypes: string[] = [];
|
|
252
|
+
let index = 0;
|
|
253
|
+
for (const item of domains) {
|
|
254
|
+
const factory = resolvedFactories.get(item); if (!factory) continue;
|
|
255
|
+
const alias = `factory${index++}`, spec = modulePath(output, factory.file);
|
|
256
|
+
js.push(`import { ${factory.name} as ${alias} } from ${JSON.stringify(spec)};`);
|
|
257
|
+
typeImports.push(`import { ${factory.name} as ${alias} } from ${JSON.stringify(spec)};`);
|
|
258
|
+
const operations = list(item, 'exposes').map(e => e.name);
|
|
259
|
+
domainValues.push(`{name:${JSON.stringify(item.node.name)}, factory:${alias}, exposes:${JSON.stringify(operations)}, consumes:${JSON.stringify(list(item, 'consumes').map(e => e.name))}, access:${JSON.stringify(Object.fromEntries(list(item, 'access').map(e => [e.name, e.value])))}}`);
|
|
260
|
+
const tree: Record<string, unknown> = {};
|
|
261
|
+
for (const operation of operations) { let node = tree; const parts = operation.split('.'); parts.forEach((part, i) => { if (i === parts.length - 1) node[part] = `ReturnType<typeof ${alias}>${parts.map(p => `[${JSON.stringify(p)}]`).join('')}`; else node = (node[part] ??= {}) as Record<string, unknown>; }); }
|
|
262
|
+
const typeTree = (value: Record<string, unknown>): string => '{' + Object.entries(value).map(([key, value]) => `readonly ${JSON.stringify(key)}:${typeof value === 'string' ? value : typeTree(value as Record<string, unknown>)}`).join(';') + '}';
|
|
263
|
+
domainTypes.push(`${JSON.stringify(item.node.name)}:${typeTree(tree)}`);
|
|
264
|
+
}
|
|
265
|
+
for (const item of destinations) {
|
|
266
|
+
const presentation = presentationFor.get(item), factory = presentation && resolvedFactories.get(presentation), html = presentation && htmlModules.get(presentation);
|
|
267
|
+
const data = graph.destinations.find(d => d.name === item.node.name);
|
|
268
|
+
if (!presentation || !factory || !html || !data) continue;
|
|
269
|
+
pageValues.push(`{...${JSON.stringify({ ...data, access: item.node.fields.access?.value })}, load:async()=>{const [logic,template]=await Promise.all([import(${JSON.stringify(modulePath(output, factory.file))}),import(${JSON.stringify(modulePath(output, html))})]);return {create:logic[${JSON.stringify(factory.name)}],view:template.default};}}`);
|
|
270
|
+
}
|
|
271
|
+
js.push(`const domains = [${domainValues.join(',\n')}];`, `const pages = [${pageValues.join(',\n')}];`, 'export const metadata = ' + JSON.stringify(graph) + ';', 'export const createDomains = (options = {}) => createDomainGraph(domains, options);', 'export const createApplication = (options = {}) => createGraphApplication(domains, pages, options);');
|
|
272
|
+
const destinationTypes = graph.destinations.map(d => `${JSON.stringify(d.name)}: Destination<${Object.keys(d.input).length ? '{' + Object.entries(d.input).map(([key, type]) => `${JSON.stringify(key)}:${type}`).join(';') + '}' : 'Record<string, never>'}>`);
|
|
273
|
+
outputs[output] = js.join('\n');
|
|
274
|
+
outputs[types] = ["import type { Destination, GraphApplication, ApplicationOptions, DomainGraph } from '@heshammesalam/heshjs/destinations';", ...typeImports, `export interface DomainAPIs {${domainTypes.join(';')}}`, `export interface Destinations {${destinationTypes.join(';')}}`, 'export declare function createDomains(options?: ApplicationOptions): DomainGraph<DomainAPIs>;', 'export declare function createApplication(options?: ApplicationOptions): GraphApplication<Destinations, DomainAPIs>;', 'export declare const metadata: unknown;'].join('\n');
|
|
275
|
+
if (diagnostics.some(d => d.severity === 'error')) for (const name of Object.keys(outputs)) delete outputs[name];
|
|
276
|
+
return { diagnostics, files: [...files].sort(), outputs, graph, statistics: { parsed, reused } };
|
|
277
|
+
} };
|
|
278
|
+
}
|
|
279
|
+
export function checkProject(options: ProjectOptions): ProjectResult { return createArchitectureCompiler().check(options); }
|
|
280
|
+
export function writeProject(result: ProjectResult): void {
|
|
281
|
+
if (result.diagnostics.some(d => d.severity === 'error')) throw new Error(result.diagnostics.map(formatDiagnostic).join('\n'));
|
|
282
|
+
for (const [file, source] of Object.entries(result.outputs)) { mkdirSync(dirname(file), { recursive: true }); if (!existsSync(file) || readFileSync(file, 'utf8') !== source) writeFileSync(file, source); }
|
|
283
|
+
}
|
package/src/compiler/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ const f = ts.factory;
|
|
|
11
11
|
|
|
12
12
|
export function compile(source: string, options: CompileOptions = {}): Result {
|
|
13
13
|
const ast = parse(source, options.filename);
|
|
14
|
+
if (ast.contracts.length) throw new HeshError('Architecture declarations require the project compiler (hesh check or hesh({ architecture: ... }))', source, ast.contracts[0]!.start, ast.filename);
|
|
14
15
|
const metadata: Binding[] = [];
|
|
15
16
|
const fail = (message: string, offset: number): never => { throw new HeshError(message, source, offset, ast.filename); };
|
|
16
17
|
const names = new Set<string>();
|