@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.
- package/CHANGELOG.md +31 -0
- package/LICENSE +202 -0
- package/README.md +88 -0
- package/dist/src/capacity.d.ts +67 -0
- package/dist/src/capacity.js +227 -0
- package/dist/src/check.d.ts +21 -0
- package/dist/src/check.js +192 -0
- package/dist/src/compiled.d.ts +61 -0
- package/dist/src/compiled.js +91 -0
- package/dist/src/cst.d.ts +76 -0
- package/dist/src/cst.js +1 -0
- package/dist/src/diagnostics.d.ts +9 -0
- package/dist/src/diagnostics.js +1 -0
- package/dist/src/edit.d.ts +48 -0
- package/dist/src/edit.js +442 -0
- package/dist/src/index.d.ts +25 -0
- package/dist/src/index.js +24 -0
- package/dist/src/layout/balance.d.ts +34 -0
- package/dist/src/layout/balance.js +327 -0
- package/dist/src/layout/elk.d.ts +64 -0
- package/dist/src/layout/elk.js +267 -0
- package/dist/src/layout/host.d.ts +23 -0
- package/dist/src/layout/host.js +23 -0
- package/dist/src/layout/label.d.ts +49 -0
- package/dist/src/layout/label.js +113 -0
- package/dist/src/layout/measure.d.ts +11 -0
- package/dist/src/layout/measure.js +27 -0
- package/dist/src/layout/ortho.d.ts +54 -0
- package/dist/src/layout/ortho.js +206 -0
- package/dist/src/layout/route.d.ts +57 -0
- package/dist/src/layout/route.js +230 -0
- package/dist/src/lens.d.ts +83 -0
- package/dist/src/lens.js +377 -0
- package/dist/src/lexer.d.ts +7 -0
- package/dist/src/lexer.js +135 -0
- package/dist/src/model.d.ts +63 -0
- package/dist/src/model.js +114 -0
- package/dist/src/parser.d.ts +7 -0
- package/dist/src/parser.js +305 -0
- package/dist/src/render/svg.d.ts +56 -0
- package/dist/src/render/svg.js +289 -0
- package/dist/src/serialize.d.ts +10 -0
- package/dist/src/serialize.js +12 -0
- package/dist/src/tokens.d.ts +26 -0
- package/dist/src/tokens.js +15 -0
- package/dist/src/vocab.d.ts +38 -0
- package/dist/src/vocab.js +58 -0
- 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
+
}
|