@archcode-io/engine 0.2.0-preview.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +202 -0
  3. package/README.md +88 -0
  4. package/dist/src/capacity.d.ts +67 -0
  5. package/dist/src/capacity.js +227 -0
  6. package/dist/src/check.d.ts +21 -0
  7. package/dist/src/check.js +192 -0
  8. package/dist/src/compiled.d.ts +61 -0
  9. package/dist/src/compiled.js +91 -0
  10. package/dist/src/cst.d.ts +76 -0
  11. package/dist/src/cst.js +1 -0
  12. package/dist/src/diagnostics.d.ts +9 -0
  13. package/dist/src/diagnostics.js +1 -0
  14. package/dist/src/edit.d.ts +48 -0
  15. package/dist/src/edit.js +442 -0
  16. package/dist/src/index.d.ts +25 -0
  17. package/dist/src/index.js +24 -0
  18. package/dist/src/layout/balance.d.ts +34 -0
  19. package/dist/src/layout/balance.js +327 -0
  20. package/dist/src/layout/elk.d.ts +64 -0
  21. package/dist/src/layout/elk.js +267 -0
  22. package/dist/src/layout/host.d.ts +23 -0
  23. package/dist/src/layout/host.js +23 -0
  24. package/dist/src/layout/label.d.ts +49 -0
  25. package/dist/src/layout/label.js +113 -0
  26. package/dist/src/layout/measure.d.ts +11 -0
  27. package/dist/src/layout/measure.js +27 -0
  28. package/dist/src/layout/ortho.d.ts +54 -0
  29. package/dist/src/layout/ortho.js +206 -0
  30. package/dist/src/layout/route.d.ts +57 -0
  31. package/dist/src/layout/route.js +230 -0
  32. package/dist/src/lens.d.ts +83 -0
  33. package/dist/src/lens.js +377 -0
  34. package/dist/src/lexer.d.ts +7 -0
  35. package/dist/src/lexer.js +135 -0
  36. package/dist/src/model.d.ts +63 -0
  37. package/dist/src/model.js +114 -0
  38. package/dist/src/parser.d.ts +7 -0
  39. package/dist/src/parser.js +305 -0
  40. package/dist/src/render/svg.d.ts +56 -0
  41. package/dist/src/render/svg.js +289 -0
  42. package/dist/src/serialize.d.ts +10 -0
  43. package/dist/src/serialize.js +12 -0
  44. package/dist/src/tokens.d.ts +26 -0
  45. package/dist/src/tokens.js +15 -0
  46. package/dist/src/vocab.d.ts +38 -0
  47. package/dist/src/vocab.js +58 -0
  48. package/package.json +61 -0
@@ -0,0 +1,56 @@
1
+ import type { Layout, Positioned } from '../layout/elk.js';
2
+ /**
3
+ * Colours are a theme, not a constant: the same drawing on a dark canvas in the
4
+ * Playground and on a white page in a document. `dark` is the Playground's own;
5
+ * `light` is for slides, wikis and print.
6
+ */
7
+ export type ThemeName = 'dark' | 'light';
8
+ export interface Theme {
9
+ bg: string;
10
+ grid: string;
11
+ palette: Record<string, {
12
+ fill: string;
13
+ stroke: string;
14
+ }>;
15
+ fallback: {
16
+ fill: string;
17
+ stroke: string;
18
+ };
19
+ over: string;
20
+ edge: string;
21
+ edgeDerived: string;
22
+ flow: string;
23
+ flowDerived: string;
24
+ labelBg: string;
25
+ text: string;
26
+ textDim: string;
27
+ text2: string;
28
+ meta: string;
29
+ load: string;
30
+ phase: {
31
+ fill: string;
32
+ stroke: string;
33
+ text: string;
34
+ };
35
+ agent: {
36
+ fill: string;
37
+ stroke: string;
38
+ text: string;
39
+ };
40
+ }
41
+ export declare const THEMES: Record<ThemeName, Theme>;
42
+ /** Does the frame's meta line sit beside the title (true) or wrap under it? */
43
+ export declare function metaFits(n: Positioned): boolean;
44
+ /**
45
+ * `hit`: wide invisible strokes for an editor to grab arrows by. `theme`: the
46
+ * colours. `fonts`: CSS (usually `@font-face` rules with data: URIs) written
47
+ * into the picture's own <style>, so a file opened elsewhere keeps its faces.
48
+ * `name`: what the picture is, for the <title> a screen reader or a hover
49
+ * announces (the `title` argument is the caption printed on the picture).
50
+ */
51
+ export declare function toSvg(l: Layout, title: string, opts?: {
52
+ hit?: boolean;
53
+ theme?: ThemeName;
54
+ fonts?: string;
55
+ name?: string;
56
+ }): string;
@@ -0,0 +1,289 @@
1
+ import { textWidth } from '../layout/measure.js';
2
+ import { labelBox, roundedPath } from '../layout/label.js';
3
+ import { footerOf, AGENT_ROW } from '../lens.js';
4
+ const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
5
+ export const THEMES = {
6
+ dark: {
7
+ bg: '#0A0F16', grid: '#24313F',
8
+ palette: {
9
+ actor: { fill: '#171526', stroke: '#6B5FA8' },
10
+ external: { fill: '#141A22', stroke: '#3B4A5C' },
11
+ system: { fill: '#0E1620', stroke: '#2C6F97' },
12
+ service: { fill: '#101922', stroke: '#2C6F97' },
13
+ webapp: { fill: '#101922', stroke: '#2C6F97' },
14
+ app: { fill: '#101922', stroke: '#2C6F97' },
15
+ component: { fill: '#101922', stroke: '#2C6F97' },
16
+ function: { fill: '#101922', stroke: '#2C6F97' },
17
+ job: { fill: '#101922', stroke: '#2C6F97' },
18
+ datastore: { fill: '#0F1B1A', stroke: '#3E8A80' },
19
+ cache: { fill: '#0F1B1A', stroke: '#3E8A80' },
20
+ gateway: { fill: '#1E1A12', stroke: '#8A6A2E' },
21
+ broker: { fill: '#1E1A12', stroke: '#8A6A2E' },
22
+ topic: { fill: '#1E1A12', stroke: '#8A6A2E' },
23
+ // placement — where things run; a cooler, quieter family than the logic
24
+ env: { fill: '#121A16', stroke: '#4E7D5C' },
25
+ segment: { fill: '#121A16', stroke: '#4E7D5C' },
26
+ cluster: { fill: '#131C19', stroke: '#5B8F6C' },
27
+ managed: { fill: '#131C19', stroke: '#5B8F6C' },
28
+ node: { fill: '#141F1B', stroke: '#6AA37C' },
29
+ },
30
+ fallback: { fill: '#101922', stroke: '#3B4A5C' },
31
+ over: '#E0705E',
32
+ edge: '#4A6B85', edgeDerived: '#4FBFB0', flow: '#7FB6DA', flowDerived: '#7FE0D3', labelBg: '#0A0F16',
33
+ text: '#D6DFE9', textDim: '#61717F', text2: '#92A3B4', meta: '#B8C4A8', load: '#7F918F',
34
+ phase: { fill: '#1E1A12', stroke: '#8A6A2E', text: '#E3A44C' },
35
+ agent: { fill: '#141B24', stroke: '#3B4A5C', text: '#9FB0C0' },
36
+ },
37
+ light: {
38
+ bg: '#FFFFFF', grid: '#D9E0E7',
39
+ palette: {
40
+ actor: { fill: '#EFEDF8', stroke: '#7B6FC0' },
41
+ external: { fill: '#F2F4F7', stroke: '#8A98A8' },
42
+ system: { fill: '#E9F2F9', stroke: '#2C6F97' },
43
+ service: { fill: '#ECF4FA', stroke: '#2C6F97' },
44
+ webapp: { fill: '#ECF4FA', stroke: '#2C6F97' },
45
+ app: { fill: '#ECF4FA', stroke: '#2C6F97' },
46
+ component: { fill: '#ECF4FA', stroke: '#2C6F97' },
47
+ function: { fill: '#ECF4FA', stroke: '#2C6F97' },
48
+ job: { fill: '#ECF4FA', stroke: '#2C6F97' },
49
+ datastore: { fill: '#E9F5F3', stroke: '#3E8A80' },
50
+ cache: { fill: '#E9F5F3', stroke: '#3E8A80' },
51
+ gateway: { fill: '#FBF3E3', stroke: '#B58A3A' },
52
+ broker: { fill: '#FBF3E3', stroke: '#B58A3A' },
53
+ topic: { fill: '#FBF3E3', stroke: '#B58A3A' },
54
+ env: { fill: '#F0F6F2', stroke: '#4E7D5C' },
55
+ segment: { fill: '#F0F6F2', stroke: '#4E7D5C' },
56
+ cluster: { fill: '#EBF3ED', stroke: '#5B8F6C' },
57
+ managed: { fill: '#EBF3ED', stroke: '#5B8F6C' },
58
+ node: { fill: '#E7F1EA', stroke: '#6AA37C' },
59
+ },
60
+ fallback: { fill: '#F2F4F7', stroke: '#8A98A8' },
61
+ over: '#C8503F',
62
+ edge: '#5A7A93', edgeDerived: '#2E9A8C', flow: '#2C6F97', flowDerived: '#3E8A80', labelBg: '#FFFFFF',
63
+ text: '#16202B', textDim: '#6A7A88', text2: '#4E5F70', meta: '#5E6E4E', load: '#5F726F',
64
+ phase: { fill: '#FBF3E3', stroke: '#B58A3A', text: '#9A6B1A' },
65
+ agent: { fill: '#F1F4F7', stroke: '#B8C4D0', text: '#4E5F70' },
66
+ },
67
+ };
68
+ function label(n, topY) {
69
+ const cx = n.x + n.w / 2;
70
+ const o = [`<text x="${cx}" y="${topY}" class="nt" text-anchor="middle">${esc(n.label)}</text>`];
71
+ o.push(`<text x="${cx}" y="${topY + 15}" class="nk" text-anchor="middle">[${esc(n.kind)}]</text>`);
72
+ if (n.tech)
73
+ o.push(`<text x="${cx}" y="${topY + 31}" class="nk2" text-anchor="middle">${esc(n.tech)}</text>`);
74
+ if (n.meta)
75
+ o.push(`<text x="${cx}" y="${topY + (n.tech ? 46 : 31)}" class="nm" text-anchor="middle">${esc(n.meta)}</text>`);
76
+ return o.join('');
77
+ }
78
+ /** A person, not a coloured rectangle: a generous head over rounded shoulders. */
79
+ function actorSvg(n, p) {
80
+ const cx = n.x + n.w / 2, headR = 19;
81
+ const headCy = n.y + headR + 2;
82
+ const bodyY = headCy + headR + 5; // a real neck gap, not a dome
83
+ const bodyBottom = n.y + n.h;
84
+ const shoulder = Math.min(34, n.w / 3); // wide, soft shoulders
85
+ const foot = 12; // and rounded feet, not sharp corners
86
+ const body = `M${n.x} ${bodyBottom - foot}` +
87
+ ` L${n.x} ${bodyY + shoulder}` +
88
+ ` Q${n.x} ${bodyY} ${n.x + shoulder} ${bodyY}` +
89
+ ` L${n.x + n.w - shoulder} ${bodyY}` +
90
+ ` Q${n.x + n.w} ${bodyY} ${n.x + n.w} ${bodyY + shoulder}` +
91
+ ` L${n.x + n.w} ${bodyBottom - foot}` +
92
+ ` Q${n.x + n.w} ${bodyBottom} ${n.x + n.w - foot} ${bodyBottom}` +
93
+ ` L${n.x + foot} ${bodyBottom}` +
94
+ ` Q${n.x} ${bodyBottom} ${n.x} ${bodyBottom - foot} Z`;
95
+ return [
96
+ `<circle cx="${cx}" cy="${headCy}" r="${headR}" fill="${p.fill}" stroke="${p.stroke}" stroke-width="1.6"/>`,
97
+ `<path d="${body}" fill="${p.fill}" stroke="${p.stroke}" stroke-width="1.6"/>`,
98
+ label(n, bodyY + (n.tech ? 26 : 30)),
99
+ ].join('');
100
+ }
101
+ /** A cylinder, so a store reads as a store at a glance. */
102
+ function storeSvg(n, p) {
103
+ const ry = 13, cx = n.x + n.w / 2, top = n.y + ry, bot = n.y + n.h - ry;
104
+ const body = `M${n.x} ${top} L${n.x} ${bot} A ${n.w / 2} ${ry} 0 0 0 ${n.x + n.w} ${bot}` +
105
+ ` L${n.x + n.w} ${top} A ${n.w / 2} ${ry} 0 0 0 ${n.x} ${top} Z`;
106
+ return [
107
+ `<path d="${body}" fill="${p.fill}" stroke="${p.stroke}" stroke-width="1.5"/>`,
108
+ `<ellipse cx="${cx}" cy="${top}" rx="${n.w / 2}" ry="${ry}" fill="${p.fill}" stroke="${p.stroke}" stroke-width="1.5"/>`,
109
+ `<ellipse cx="${cx}" cy="${top + 7}" rx="${n.w / 2 - 9}" ry="${ry - 6}" fill="none" stroke="${p.stroke}" stroke-width="0.9" opacity="0.45"/>`,
110
+ label(n, n.y + ry * 2 + (n.tech ? 18 : 24)), // clear of the cap
111
+ ].join('');
112
+ }
113
+ /** Does the frame's meta line sit beside the title (true) or wrap under it? */
114
+ export function metaFits(n) {
115
+ if (!n.meta)
116
+ return true;
117
+ const lw = textWidth(n.label, 13) * 1.08, kw = textWidth(`[${n.kind}]`, 9.5);
118
+ return n.w >= 300 && 16 + lw + 10 + kw + 24 + textWidth(n.meta, 10) * 1.2 + 14 <= n.w;
119
+ }
120
+ function nodeSvg(n, th) {
121
+ return `<g class="node" data-id="${esc(n.id)}" data-kind="${esc(n.kind)}"`
122
+ + `${n.isBoundary ? ' data-boundary="1"' : ''}${n.ref ? ` data-ref="${esc(n.ref)}"` : ''}${n.phase ? ` data-phase="${esc(n.phase)}"` : ''}`
123
+ + `${n.agents?.length ? ` data-footer="${footerOf(n)}"` : ''}${n.isBoundary && !metaFits(n) ? ' data-head="60"' : ''}${n.stage ? ` data-stage="${esc(n.stage)}"` : ''}`
124
+ + ` data-x="${n.x}" data-y="${n.y}" data-w="${n.w}" data-h="${n.h}"><title>${esc([n.label, `[${n.kind}]`, n.tech, n.meta].filter(Boolean).join(' · '))}</title>${nodeShape(n, th)}${phaseTag(n)}</g>`;
125
+ }
126
+ /**
127
+ * Agents installed on a host (`agent antivirus, alloy`) — a row of small chips
128
+ * along the bottom of the frame, wrapping upward when the frame is narrow. They
129
+ * are software on the box that is not part of the model: read at a glance,
130
+ * never connected.
131
+ */
132
+ function agentChips(n) {
133
+ if (!n.agents?.length)
134
+ return '';
135
+ const rows = footerOf(n) / AGENT_ROW;
136
+ const bottom = n.isBoundary ? 6 : 8; // a card's chips sit a little further in than a frame's
137
+ const o = [];
138
+ let x = n.x + 14, row = 0; // rows fill top-down; the last row sits on the frame's bottom edge
139
+ const maxX = n.x + n.w - 14 - (n.load && rows === 1 ? textWidth(n.load, 10) * 1.2 + 16 : 0);
140
+ for (const a of n.agents) {
141
+ const w = textWidth(a, 9) + 12;
142
+ if (x + w > maxX && x > n.x + 14 && row < rows - 1) {
143
+ x = n.x + 14;
144
+ row++;
145
+ }
146
+ const y = n.y + n.h - bottom - (rows - row) * AGENT_ROW + 4;
147
+ o.push(`<g class="ag"><rect x="${x}" y="${y}" width="${w}" height="16" rx="4"/><text x="${x + w / 2}" y="${y + 11.5}" text-anchor="middle">${esc(a)}</text></g>`);
148
+ x += w + 6;
149
+ }
150
+ return o.join('');
151
+ }
152
+ /** A small tag in the corner: this thing belongs to one variant of the architecture. */
153
+ function phaseTag(n) {
154
+ if (!n.phase)
155
+ return '';
156
+ const w = textWidth(n.phase, 9) + 10;
157
+ const x = n.x + n.w - w - 6, y = n.y - 7;
158
+ return `<g class="ph"><rect x="${x}" y="${y}" width="${w}" height="14" rx="7"/>`
159
+ + `<text x="${x + w / 2}" y="${y + 10}" text-anchor="middle">${esc(n.phase)}</text></g>`;
160
+ }
161
+ function nodeShape(n, th) {
162
+ const p = th.palette[n.kind] ?? th.fallback;
163
+ if (n.isBoundary) {
164
+ const stroke = n.over ? th.over : p.stroke;
165
+ const o = [`<rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" rx="10" fill="${p.fill}" stroke="${stroke}" stroke-width="${n.over ? 1.8 : 1.3}" stroke-dasharray="6 4"/>`];
166
+ const lw = textWidth(n.label, 13) * 1.08, kw = textWidth(`[${n.kind}]`, 9.5);
167
+ o.push(`<text x="${n.x + 16}" y="${n.y + 26}" class="bl">${esc(n.label)}</text>`);
168
+ o.push(`<text x="${n.x + 16 + lw + 10}" y="${n.y + 26}" class="bk">[${esc(n.kind)}]</text>`); // bold runs wider
169
+ if (n.meta) {
170
+ // beside the title when there is room, under it when the frame is narrow
171
+ const fits = metaFits(n);
172
+ o.push(fits
173
+ ? `<text x="${n.x + n.w - 14}" y="${n.y + 26}" class="nm" text-anchor="end">${esc(n.meta)}</text>`
174
+ : `<text x="${n.x + 16}" y="${n.y + 41}" class="nm">${esc(n.meta)}</text>`);
175
+ }
176
+ if (n.load)
177
+ o.push(`<text x="${n.x + n.w - 14}" y="${n.y + n.h - 10}" class="${n.over ? 'ld over' : 'ld'}" text-anchor="end">${esc(n.load)}</text>`);
178
+ o.push(agentChips(n));
179
+ return o.join('');
180
+ }
181
+ if (n.kind === 'actor')
182
+ return actorSvg(n, p);
183
+ if (n.kind === 'datastore' || n.kind === 'cache')
184
+ return storeSvg(n, p);
185
+ const dash = n.transit ? ' stroke-dasharray="7 3"' : '';
186
+ return `<rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" rx="8" fill="${p.fill}" stroke="${p.stroke}" stroke-width="1.5"${dash}/>`
187
+ + label(n, n.y + (n.tech ? 26 : 30)) + agentChips(n);
188
+ }
189
+ function edgeSvg(e, hit, th) {
190
+ if (e.points.length < 2)
191
+ return '';
192
+ const d = roundedPath(e.points);
193
+ const stroke = e.derived ? th.edgeDerived : th.edge;
194
+ const dash = e.dashed ? ' stroke-dasharray="5 4"' : '';
195
+ const o = [`<path class="ep" d="${d}" fill="none" stroke="${stroke}" stroke-width="1.4"${dash} marker-end="url(#ar${e.derived ? 'd' : ''})"/>`,
196
+ // A second, invisible-by-default path carries the flow animation, so the
197
+ // dash pattern of the real edge is never disturbed by it.
198
+ `<path class="flow" d="${d}" fill="none" stroke="${e.derived ? th.flowDerived : th.flow}" stroke-width="2.1" stroke-linecap="round"/>`];
199
+ if (e.label) {
200
+ // Centred on the connector by length, so the text sits in the middle of the
201
+ // line the reader is following rather than beside whichever bend came out
202
+ // in the middle of the point list.
203
+ const w = textWidth(e.label, 10) + 12;
204
+ // The layout engine reserved a spot for this label where nothing else sits;
205
+ // use it when there is one. A route drawn by hand (pins, live drag) has no
206
+ // such spot, so the label goes to the middle of the line by length.
207
+ const b = e.labelPos
208
+ ? { rectX: e.labelPos.x - 2, rectY: e.labelPos.y - 1, textX: e.labelPos.x - 2 + w / 2, textY: e.labelPos.y + 10, anchor: 'middle' }
209
+ : labelBox(e.points, w, e.labelAt ?? 0.5, !!e.labelFlip);
210
+ o.push(`<rect class="elbg" x="${b.rectX}" y="${b.rectY}" width="${w}" height="15" rx="3" fill="${th.labelBg}" opacity="0.9"/>`);
211
+ o.push(`<text class="el" x="${b.textX}" y="${b.textY}" text-anchor="${b.anchor}">${esc(e.label)}</text>`);
212
+ }
213
+ // an editor wants a wide, invisible stroke to grab the arrow by
214
+ if (hit)
215
+ o.push(`<path class="hit" d="${d}" fill="none" stroke="transparent" stroke-width="14"/>`);
216
+ return `<g class="edge" data-from="${esc(e.from)}" data-to="${esc(e.to)}" data-verb="${esc(e.verb)}"${e.labelAt !== undefined ? ` data-labelat="${e.labelAt}"` : ''}${e.labelFlip ? ' data-labelflip="1"' : ''}>${o.join('')}</g>`;
217
+ }
218
+ /**
219
+ * `hit`: wide invisible strokes for an editor to grab arrows by. `theme`: the
220
+ * colours. `fonts`: CSS (usually `@font-face` rules with data: URIs) written
221
+ * into the picture's own <style>, so a file opened elsewhere keeps its faces.
222
+ * `name`: what the picture is, for the <title> a screen reader or a hover
223
+ * announces (the `title` argument is the caption printed on the picture).
224
+ */
225
+ export function toSvg(l, title, opts = {}) {
226
+ const th = THEMES[opts.theme ?? 'dark'];
227
+ // The viewBox follows the content, wherever the content happens to be.
228
+ // Rewriting coordinates to start at zero would make every pinned diagram jump
229
+ // by a constant the moment the first card is pinned.
230
+ const pad = 26;
231
+ const xs = l.nodes.flatMap(n => [n.x, n.x + n.w]).concat(l.edges.flatMap(e => e.points.map(p => p.x)));
232
+ const ys = l.nodes.flatMap(n => [n.y, n.y + n.h]).concat(l.edges.flatMap(e => e.points.map(p => p.y)));
233
+ const minX = (xs.length ? Math.min(...xs) : 0) - pad;
234
+ const minY = (ys.length ? Math.min(...ys) : 0) - pad - (title ? 26 : 0);
235
+ const w = Math.max(1, (xs.length ? Math.max(...xs) : 1) + pad - minX);
236
+ const h = Math.max(1, (ys.length ? Math.max(...ys) : 1) + pad - minY);
237
+ // `data-bounds` carries the content extent separately from the viewBox, so a
238
+ // host that drives its own pan and zoom can fit the drawing without letting
239
+ // the browser rescale it behind its back.
240
+ const name = opts.name ?? `ArchCode diagram — ${l.nodes.length} objects, ${l.edges.length} relations`;
241
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${minX} ${minY} ${w} ${h}"`
242
+ + ` width="${Math.ceil(w)}" height="${Math.ceil(h)}" data-bounds="${minX} ${minY} ${w} ${h}" data-theme="${opts.theme ?? 'dark'}" role="img" aria-label="${esc(name)}">
243
+ <title>${esc(name)}</title>
244
+ <defs>
245
+ <marker id="ar" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
246
+ <path d="M0.5 0.8 L7.2 4 L0.5 7.2" fill="none" stroke="${th.edge}" stroke-width="1.3"/></marker>
247
+ <marker id="ard" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
248
+ <path d="M0.5 0.8 L7.2 4 L0.5 7.2" fill="none" stroke="${th.edgeDerived}" stroke-width="1.3"/></marker>
249
+ <pattern id="acgrid" width="24" height="24" patternUnits="userSpaceOnUse">
250
+ <circle cx="1.3" cy="1.3" r="1.3" fill="${th.grid}"/></pattern>
251
+ <style>
252
+ ${opts.fonts ?? ''}
253
+ text{font-family:'IBM Plex Sans',-apple-system,system-ui,sans-serif}
254
+ .nt{font-size:13px;font-weight:600;fill:${th.text}}
255
+ .nk{font-size:9.5px;fill:${th.textDim};font-family:'IBM Plex Mono',ui-monospace,monospace}
256
+ .nk2{font-size:10px;fill:${th.text2}}
257
+ .nm{font-size:10px;fill:${th.meta};font-variant-numeric:tabular-nums}
258
+ .ld{font-size:9.5px;fill:${th.load};font-family:'IBM Plex Mono',ui-monospace,monospace}
259
+ .ld.over{fill:${th.over};font-weight:600}
260
+ .ph rect{fill:${th.phase.fill};stroke:${th.phase.stroke};stroke-width:1}
261
+ .ph text{font-size:9px;fill:${th.phase.text};font-family:'IBM Plex Mono',ui-monospace,monospace}
262
+ .ag rect{fill:${th.agent.fill};stroke:${th.agent.stroke};stroke-width:1}
263
+ .ag text{font-size:9px;fill:${th.agent.text};font-family:'IBM Plex Mono',ui-monospace,monospace}
264
+ .bl{font-size:13px;font-weight:600;fill:${th.text}}
265
+ .bk{font-size:9.5px;fill:${th.textDim};font-family:'IBM Plex Mono',ui-monospace,monospace}
266
+ .el{font-size:10px;fill:${th.text2};font-family:'IBM Plex Mono',ui-monospace,monospace}
267
+ .ttl{font-size:11px;fill:${th.textDim};font-family:'IBM Plex Mono',ui-monospace,monospace;letter-spacing:.12em}
268
+ /* stage (§17.4): a sketch or a proposal is drawn dashed, a deprecated thing dimmed */
269
+ .node[data-stage="sketch"] > rect,.node[data-stage="sketch"] > path,.node[data-stage="sketch"] > circle,.node[data-stage="sketch"] > ellipse,
270
+ .node[data-stage="proposed"] > rect,.node[data-stage="proposed"] > path,.node[data-stage="proposed"] > circle,.node[data-stage="proposed"] > ellipse{stroke-dasharray:4 3}
271
+ .node[data-stage="deprecated"]{opacity:.5}
272
+ .flow{opacity:0;stroke-dasharray:9 999;stroke-dashoffset:0}
273
+ svg.flowing .flow{opacity:.95;animation:acflow 2.4s linear infinite}
274
+ @keyframes acflow{to{stroke-dashoffset:-1008}}
275
+ /* Labels and the animation path must never swallow a click meant for a card. */
276
+ .el,.elbg,.flow,.ttl{pointer-events:none}
277
+ .node{cursor:default}
278
+ svg.draggable .node{cursor:grab}
279
+ svg.draggable .node.dragging{cursor:grabbing}
280
+ </style>
281
+ </defs>
282
+ <g class="viewport">
283
+ <rect class="acgrid" x="-6000" y="-6000" width="14000" height="14000" fill="url(#acgrid)"/>
284
+ ${title ? `<text x="${minX + 14}" y="${minY + 20}" class="ttl">${esc(title.toUpperCase())}</text>` : ''}
285
+ ${l.nodes.filter(n => n.isBoundary).map(n => nodeSvg(n, th)).join('\n')}
286
+ <g class="edges">${l.edges.map(e => edgeSvg(e, !!opts.hit, th)).join('\n')}</g>
287
+ ${l.nodes.filter(n => !n.isBoundary).map(n => nodeSvg(n, th)).join('\n')}
288
+ </g></svg>`;
289
+ }
@@ -0,0 +1,10 @@
1
+ import type { Doc } from './cst.js';
2
+ /**
3
+ * Serialize a document back to text.
4
+ *
5
+ * The document owns the complete token stream, and every byte of the source is
6
+ * inside exactly one token (as trivia or as text), so this is byte-exact for
7
+ * any document that came out of `parse`. This is the round-trip invariant of
8
+ * D-27, and it holds structurally rather than by careful bookkeeping.
9
+ */
10
+ export declare function serialize(doc: Doc): string;
@@ -0,0 +1,12 @@
1
+ import { tokenText } from './tokens.js';
2
+ /**
3
+ * Serialize a document back to text.
4
+ *
5
+ * The document owns the complete token stream, and every byte of the source is
6
+ * inside exactly one token (as trivia or as text), so this is byte-exact for
7
+ * any document that came out of `parse`. This is the round-trip invariant of
8
+ * D-27, and it holds structurally rather than by careful bookkeeping.
9
+ */
10
+ export function serialize(doc) {
11
+ return tokenText(doc.tokens);
12
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Lossless token stream.
3
+ *
4
+ * Every byte of the source belongs to exactly one token: either to its `trivia`
5
+ * (whitespace and comments preceding it) or to its `text`. Therefore
6
+ * tokens.map(t => t.trivia + t.text).join('')
7
+ * reproduces the source byte for byte. This is what makes the round-trip
8
+ * invariant (D-27) structural rather than aspirational.
9
+ */
10
+ export type TokenKind = 'word' | 'string' | 'number' | 'pointer' | 'lbrace' | 'rbrace' | 'comma' | 'newline' | 'eof';
11
+ export interface Token {
12
+ kind: TokenKind;
13
+ /** Exact source text of the token itself. */
14
+ text: string;
15
+ /** Exact whitespace and comments immediately preceding the token. */
16
+ trivia: string;
17
+ /** Byte offset where `trivia` begins. */
18
+ start: number;
19
+ /** Byte offset just past `text`. */
20
+ end: number;
21
+ /** 1-based line of the token text. */
22
+ line: number;
23
+ /** 1-based column of the token text. */
24
+ col: number;
25
+ }
26
+ export declare function tokenText(tokens: readonly Token[]): string;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Lossless token stream.
3
+ *
4
+ * Every byte of the source belongs to exactly one token: either to its `trivia`
5
+ * (whitespace and comments preceding it) or to its `text`. Therefore
6
+ * tokens.map(t => t.trivia + t.text).join('')
7
+ * reproduces the source byte for byte. This is what makes the round-trip
8
+ * invariant (D-27) structural rather than aspirational.
9
+ */
10
+ export function tokenText(tokens) {
11
+ let out = '';
12
+ for (const t of tokens)
13
+ out += t.trivia + t.text;
14
+ return out;
15
+ }
@@ -0,0 +1,38 @@
1
+ /** Vocabulary frozen for grammar v0.1 (spec §4, amended by D-126 and D-136). */
2
+ export declare const OBJECT_KINDS: Set<string>;
3
+ /**
4
+ * Attributes that are complete on their own (`{ transit }`). Inside a one-line
5
+ * body or an inline declaration they never swallow the next word as a value.
6
+ */
7
+ export declare const FLAG_ATTRS: Set<string>;
8
+ export declare const VERBS: Set<string>;
9
+ /** Interface statements (spec §4.3): `exposes http openapi://…`, `stores schema sql://…`. Not relations — nothing on the other end. */
10
+ export declare const IFACE_VERBS: Set<string>;
11
+ /** Canonical form for the two sugar verbs. */
12
+ export declare const VERB_SUGAR: Record<string, string>;
13
+ /** Words that introduce a modifier inside a relation: `over SQL`, `via rabbit`. */
14
+ export declare const RELATION_MODIFIERS: Set<string>;
15
+ /**
16
+ * Attribute keys the language knows (spec §5, §6.2, §6.7). Any other key is
17
+ * still kept verbatim; this list exists so that `capacity calls 20000/day`
18
+ * reads as an attribute even though `calls` is a verb.
19
+ */
20
+ export declare const ATTR_KEYS: Set<string>;
21
+ /**
22
+ * Reserved in v0.2 (D-305/A4): the parser keeps the block byte for byte and
23
+ * says so; nothing inside is read as objects or relations.
24
+ */
25
+ export declare const RESERVED_KINDS: Set<string>;
26
+ /** Blocks whose body is not model statements: kept raw, never lowered. */
27
+ export declare const OPAQUE_KINDS: Set<string>;
28
+ /**
29
+ * Keys that end the value list before them on a shared line (`dc dc1 vlan 3076`).
30
+ * `data` is out: it is a sub-key of `disk` (`disk system 30Gi data 200Gi`).
31
+ */
32
+ export declare const PAIR_KEYS: Set<string>;
33
+ /** Blocks that describe where things run (spec §6). They are frames, never cards. */
34
+ export declare const PLACEMENT_KINDS: Set<string>;
35
+ /** `run checkout replicas 6 cpu 2 mem 4Gi` — a placement statement inside a placement block. */
36
+ export declare const RUN = "run";
37
+ /** What a `run` line may carry inline: its sizing and its phase. */
38
+ export declare const RUN_KEYS: Set<string>;
@@ -0,0 +1,58 @@
1
+ /** Vocabulary frozen for grammar v0.1 (spec §4, amended by D-126 and D-136). */
2
+ export const OBJECT_KINDS = new Set([
3
+ // L1 context
4
+ 'system', 'external', 'actor',
5
+ // L2 container
6
+ 'service', 'webapp', 'app', 'gateway', 'broker', 'datastore', 'cache', 'function', 'job',
7
+ // L3 component
8
+ 'component',
9
+ // channels and contracts — first class since D-126 / D-129
10
+ 'topic', 'contract',
11
+ // model-level blocks
12
+ 'arch', 'env', 'cluster', 'node', 'managed', 'segment', 'decision', 'rule', 'view', 'board', 'profile',
13
+ ]);
14
+ /**
15
+ * Attributes that are complete on their own (`{ transit }`). Inside a one-line
16
+ * body or an inline declaration they never swallow the next word as a value.
17
+ */
18
+ export const FLAG_ATTRS = new Set(['transit']);
19
+ export const VERBS = new Set([
20
+ 'calls', 'publishes', 'subscribes', 'reads', 'writes', 'uses', 'streams', 'depends_on',
21
+ // sugar (spec §4.2)
22
+ 'emits', 'listens',
23
+ ]);
24
+ /** Interface statements (spec §4.3): `exposes http openapi://…`, `stores schema sql://…`. Not relations — nothing on the other end. */
25
+ export const IFACE_VERBS = new Set(['exposes', 'stores']);
26
+ /** Canonical form for the two sugar verbs. */
27
+ export const VERB_SUGAR = { emits: 'publishes', listens: 'subscribes' };
28
+ /** Words that introduce a modifier inside a relation: `over SQL`, `via rabbit`. */
29
+ export const RELATION_MODIFIERS = new Set(['over', 'via', 'spec', 'as', 'port']);
30
+ /**
31
+ * Attribute keys the language knows (spec §5, §6.2, §6.7). Any other key is
32
+ * still kept verbatim; this list exists so that `capacity calls 20000/day`
33
+ * reads as an attribute even though `calls` is a verb.
34
+ */
35
+ export const ATTR_KEYS = new Set([
36
+ 'tech', 'owner', 'tags', 'criticality', 'retention', 'pii', 'stage', 'description', 'repo', 'domain',
37
+ 'capacity', 'availability', 'rto', 'rpo', 'backup', 'phase', 'scope', 'code',
38
+ 'replicas', 'count', 'nodes', 'cpu', 'mem', 'disk', 'gpu', 'host', 'ip', 'os', 'agent', 'region', 'dc', 'vlan',
39
+ 'data', 'manifest', 'role', 'auth', 'ratelimit', 'transit', 'owned_by', 'publisher', 'subscriber', 'via',
40
+ ]);
41
+ /**
42
+ * Reserved in v0.2 (D-305/A4): the parser keeps the block byte for byte and
43
+ * says so; nothing inside is read as objects or relations.
44
+ */
45
+ export const RESERVED_KINDS = new Set(['decision', 'rule', 'board', 'profile']);
46
+ /** Blocks whose body is not model statements: kept raw, never lowered. */
47
+ export const OPAQUE_KINDS = new Set([...RESERVED_KINDS, 'view']);
48
+ /**
49
+ * Keys that end the value list before them on a shared line (`dc dc1 vlan 3076`).
50
+ * `data` is out: it is a sub-key of `disk` (`disk system 30Gi data 200Gi`).
51
+ */
52
+ export const PAIR_KEYS = new Set([...ATTR_KEYS].filter(k => k !== 'data'));
53
+ /** Blocks that describe where things run (spec §6). They are frames, never cards. */
54
+ export const PLACEMENT_KINDS = new Set(['env', 'cluster', 'managed', 'segment', 'node']);
55
+ /** `run checkout replicas 6 cpu 2 mem 4Gi` — a placement statement inside a placement block. */
56
+ export const RUN = 'run';
57
+ /** What a `run` line may carry inline: its sizing and its phase. */
58
+ export const RUN_KEYS = new Set(['replicas', 'count', 'nodes', 'cpu', 'mem', 'disk', 'gpu', 'phase', 'at']);
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@archcode-io/engine",
3
+ "version": "0.2.0-preview.0",
4
+ "description": "ArchCode — an open notation for software architecture. The reference engine: parser with byte-exact round trip, model, semantic checks, lenses, layout and SVG.",
5
+ "license": "Apache-2.0",
6
+ "author": "Baryshev Labs",
7
+ "homepage": "https://archcode.io",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/archcode-io/archcode.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/archcode-io/archcode/issues"
14
+ },
15
+ "keywords": [
16
+ "architecture",
17
+ "c4",
18
+ "diagram",
19
+ "notation",
20
+ "dsl",
21
+ "archcode",
22
+ "elk"
23
+ ],
24
+ "type": "module",
25
+ "main": "dist/src/index.js",
26
+ "types": "dist/src/index.d.ts",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/src/index.d.ts",
30
+ "default": "./dist/src/index.js"
31
+ },
32
+ "./package.json": "./package.json"
33
+ },
34
+ "sideEffects": false,
35
+ "files": [
36
+ "dist/src",
37
+ "README.md",
38
+ "CHANGELOG.md",
39
+ "LICENSE"
40
+ ],
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "scripts": {
45
+ "build": "tsc -p tsconfig.json",
46
+ "test": "npm run build && node --test \"dist/test/**/*.test.js\"",
47
+ "fuzz": "npm run build && node dist/test/fuzz.js",
48
+ "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
49
+ "prepack": "npm run build"
50
+ },
51
+ "devDependencies": {
52
+ "@types/node": "^22.0.0",
53
+ "typescript": "^5.6.0"
54
+ },
55
+ "engines": {
56
+ "node": ">=20"
57
+ },
58
+ "dependencies": {
59
+ "elkjs": "^0.12.0"
60
+ }
61
+ }