@diagc/core 0.1.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/LICENSE +709 -0
- package/README.md +27 -0
- package/dist/builder.d.ts +381 -0
- package/dist/builder.js +590 -0
- package/dist/children.d.ts +21 -0
- package/dist/children.js +45 -0
- package/dist/commands.d.ts +219 -0
- package/dist/commands.js +474 -0
- package/dist/compose.d.ts +19 -0
- package/dist/compose.js +246 -0
- package/dist/drawings.d.ts +13 -0
- package/dist/drawings.js +36 -0
- package/dist/eject.d.ts +20 -0
- package/dist/eject.js +260 -0
- package/dist/fishbone.d.ts +66 -0
- package/dist/fishbone.js +95 -0
- package/dist/git.d.ts +65 -0
- package/dist/git.js +159 -0
- package/dist/guards.d.ts +10 -0
- package/dist/guards.js +98 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +45 -0
- package/dist/labels.d.ts +5 -0
- package/dist/labels.js +10 -0
- package/dist/layout-defaults.d.ts +20 -0
- package/dist/layout-defaults.js +20 -0
- package/dist/mutate.d.ts +106 -0
- package/dist/mutate.js +547 -0
- package/dist/second-order.d.ts +39 -0
- package/dist/second-order.js +86 -0
- package/dist/text.d.ts +5 -0
- package/dist/text.js +25 -0
- package/dist/threat-model.d.ts +88 -0
- package/dist/threat-model.js +188 -0
- package/dist/types.d.ts +395 -0
- package/dist/types.js +27 -0
- package/dist/util.d.ts +11 -0
- package/dist/util.js +13 -0
- package/dist/validate.d.ts +19 -0
- package/dist/validate.js +736 -0
- package/dist/view/compile.d.ts +29 -0
- package/dist/view/compile.js +78 -0
- package/dist/view/edges.d.ts +4 -0
- package/dist/view/edges.js +118 -0
- package/dist/view/hierarchy.d.ts +41 -0
- package/dist/view/hierarchy.js +103 -0
- package/dist/view/layers.d.ts +7 -0
- package/dist/view/layers.js +17 -0
- package/dist/view/lod.d.ts +15 -0
- package/dist/view/lod.js +17 -0
- package/dist/view/scope.d.ts +23 -0
- package/dist/view/scope.js +106 -0
- package/dist/view/size.d.ts +8 -0
- package/dist/view/size.js +34 -0
- package/dist/view/tree.d.ts +12 -0
- package/dist/view/tree.js +147 -0
- package/dist/view/types.d.ts +68 -0
- package/dist/view/types.js +1 -0
- package/package.json +38 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { DiagramModel } from './types.js';
|
|
2
|
+
export interface IncludeSource {
|
|
3
|
+
model: DiagramModel;
|
|
4
|
+
/** canonical resolved ref (absolute URL/path) — cycle detection compares these */
|
|
5
|
+
ref: string;
|
|
6
|
+
}
|
|
7
|
+
export type IncludeResolver = (spec: string, fromRef: string) => Promise<IncludeSource>;
|
|
8
|
+
export declare class IncludeError extends Error {
|
|
9
|
+
readonly spec: string;
|
|
10
|
+
constructor(spec: string, message: string);
|
|
11
|
+
}
|
|
12
|
+
export declare const MAX_INCLUDE_DEPTH = 10;
|
|
13
|
+
/** Compile-time include expansion: nodes with `include` become containers for
|
|
14
|
+
* the referenced diagram's content (namespaced), then keyed nodes unify
|
|
15
|
+
* model-wide. Pure — all IO goes through the injected resolver. */
|
|
16
|
+
export declare function composeIncludes(model: DiagramModel, ref: string, resolve: IncludeResolver): Promise<{
|
|
17
|
+
model: DiagramModel;
|
|
18
|
+
warnings: string[];
|
|
19
|
+
}>;
|
package/dist/compose.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { validate } from './validate.js';
|
|
2
|
+
import { resolveContainmentPlane } from './view/compile.js';
|
|
3
|
+
import { errMessage } from './util.js';
|
|
4
|
+
export class IncludeError extends Error {
|
|
5
|
+
spec;
|
|
6
|
+
constructor(spec, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.spec = spec;
|
|
9
|
+
this.name = 'IncludeError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export const MAX_INCLUDE_DEPTH = 10;
|
|
13
|
+
/** Compile-time include expansion: nodes with `include` become containers for
|
|
14
|
+
* the referenced diagram's content (namespaced), then keyed nodes unify
|
|
15
|
+
* model-wide. Pure — all IO goes through the injected resolver. */
|
|
16
|
+
export async function composeIncludes(model, ref, resolve) {
|
|
17
|
+
const warnings = [];
|
|
18
|
+
const rootIds = new Set(model.nodes.map((n) => n.id));
|
|
19
|
+
const expanded = await expand(model, ref, [ref], resolve);
|
|
20
|
+
const unified = unifyKeys(expanded, rootIds, warnings);
|
|
21
|
+
return { model: stripIncludes(unified), warnings };
|
|
22
|
+
}
|
|
23
|
+
/** Composed output must be includable itself: strip `include` from every node so
|
|
24
|
+
* that including a published composed artifact grafts its content once instead of
|
|
25
|
+
* re-expanding it (which would either ENOENT resolving relative specs against the
|
|
26
|
+
* artifact's new location, or double-graft into duplicate ids). Retained `key`
|
|
27
|
+
* fields carry transitive identity onward across further composes. `includePlane`
|
|
28
|
+
* and `includePlanes` are graft-time-only options that travel with `include` and
|
|
29
|
+
* are stripped alongside it. */
|
|
30
|
+
function stripIncludes(model) {
|
|
31
|
+
const has = (n) => n.include !== undefined || n.includePlane !== undefined || n.includePlanes !== undefined;
|
|
32
|
+
if (!model.nodes.some(has))
|
|
33
|
+
return model;
|
|
34
|
+
return {
|
|
35
|
+
...model,
|
|
36
|
+
nodes: model.nodes.map((n) => {
|
|
37
|
+
if (!has(n))
|
|
38
|
+
return n;
|
|
39
|
+
const { include: _i, includePlane: _p, includePlanes: _ps, ...rest } = n;
|
|
40
|
+
return rest;
|
|
41
|
+
}),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
async function expand(model, ref, path, resolve) {
|
|
45
|
+
if (path.length > MAX_INCLUDE_DEPTH) {
|
|
46
|
+
throw new IncludeError(ref, `Include depth exceeds ${MAX_INCLUDE_DEPTH}: ${path.join(' -> ')}`);
|
|
47
|
+
}
|
|
48
|
+
let out = {
|
|
49
|
+
...model,
|
|
50
|
+
nodes: [...model.nodes],
|
|
51
|
+
containment: [...model.containment],
|
|
52
|
+
relations: [...model.relations],
|
|
53
|
+
layers: [...model.layers],
|
|
54
|
+
planes: [...model.planes],
|
|
55
|
+
};
|
|
56
|
+
for (const node of model.nodes) {
|
|
57
|
+
if (node.include === undefined)
|
|
58
|
+
continue;
|
|
59
|
+
let src;
|
|
60
|
+
try {
|
|
61
|
+
src = await resolve(node.include, ref);
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
throw new IncludeError(node.include, `Include '${node.include}' (from ${ref}): ${errMessage(e)}`);
|
|
65
|
+
}
|
|
66
|
+
if (path.includes(src.ref)) {
|
|
67
|
+
throw new IncludeError(node.include, `Include cycle: ${[...path, src.ref].join(' -> ')}`);
|
|
68
|
+
}
|
|
69
|
+
const issues = validate(src.model);
|
|
70
|
+
if (issues.length > 0) {
|
|
71
|
+
throw new IncludeError(node.include, `Include '${node.include}' (from ${ref}) is invalid: ${issues.map((i) => i.message).join('; ')}`);
|
|
72
|
+
}
|
|
73
|
+
const child = await expand(src.model, src.ref, [...path, src.ref], resolve);
|
|
74
|
+
out = graft(out, node, child);
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
/** merge `child`'s content into `host` under `into`, namespaced by its id.
|
|
79
|
+
* Same-id layers unify with the host's; see the note inside. */
|
|
80
|
+
function graft(host, into, child) {
|
|
81
|
+
const p = (id) => `${into.id}/${id}`;
|
|
82
|
+
if (into.includePlane !== undefined && !child.planes.some((pl) => pl.id === into.includePlane)) {
|
|
83
|
+
throw new IncludeError(into.include ?? into.id, `Include '${into.id}': plane '${into.includePlane}' not found in the included diagram`);
|
|
84
|
+
}
|
|
85
|
+
const defaultPlane = resolveContainmentPlane(child, undefined);
|
|
86
|
+
// the structural plane: the named one (resolved through containmentOf) or the default
|
|
87
|
+
const structural = resolveContainmentPlane(child, into.includePlane);
|
|
88
|
+
// Layers: an included layer whose id the host already declares merges into the
|
|
89
|
+
// host's layer (host name/tint win, no duplicate row); every other layer is
|
|
90
|
+
// namespaced like the nodes. Node and relation `layer` refs follow the same map.
|
|
91
|
+
const hostLayerIds = new Set(host.layers.map((l) => l.id));
|
|
92
|
+
const layerId = (id) => (hostLayerIds.has(id) ? id : p(id));
|
|
93
|
+
const nodes = child.nodes.map((n) => ({
|
|
94
|
+
...n,
|
|
95
|
+
id: p(n.id),
|
|
96
|
+
...(n.layer !== undefined ? { layer: layerId(n.layer) } : {}),
|
|
97
|
+
}));
|
|
98
|
+
// only the selected plane's structure comes along, imported untagged (default:
|
|
99
|
+
// the include's default plane, reproducing today's behavior unchanged)
|
|
100
|
+
const containment = child.containment
|
|
101
|
+
.filter((e) => (e.plane ?? defaultPlane) === structural)
|
|
102
|
+
.map((e) => ({ parent: p(e.parent), child: p(e.child) }));
|
|
103
|
+
// included roots (parentless in the selected plane) hang under the include node
|
|
104
|
+
const hasParent = new Set(containment.map((e) => e.child));
|
|
105
|
+
for (const n of nodes) {
|
|
106
|
+
if (!hasParent.has(n.id))
|
|
107
|
+
containment.push({ parent: into.id, child: n.id });
|
|
108
|
+
}
|
|
109
|
+
const layers = child.layers
|
|
110
|
+
.filter((l) => !hostLayerIds.has(l.id))
|
|
111
|
+
.map((l) => ({ ...l, id: p(l.id), name: `${into.name}/${l.name}` }));
|
|
112
|
+
const relations = child.relations.map((r) => ({
|
|
113
|
+
...r,
|
|
114
|
+
id: p(r.id),
|
|
115
|
+
from: p(r.from),
|
|
116
|
+
to: p(r.to),
|
|
117
|
+
...(r.layer !== undefined ? { layer: layerId(r.layer) } : {}),
|
|
118
|
+
}));
|
|
119
|
+
// Carried planes (opt-in): the include's planes come over namespaced with
|
|
120
|
+
// notation intact, so switching to one shows the child's content standalone
|
|
121
|
+
// in its own visual language. The child's default-plane rows land twice —
|
|
122
|
+
// untagged (host structure, unchanged behavior) and tagged for the carried
|
|
123
|
+
// plane — while already-tagged rows land tagged only (they were dropped
|
|
124
|
+
// entirely before this feature).
|
|
125
|
+
const carriedPlanes = [];
|
|
126
|
+
const carriedContainment = [];
|
|
127
|
+
if (into.includePlanes === true) {
|
|
128
|
+
if (child.planes.length > 0) {
|
|
129
|
+
for (const pl of child.planes) {
|
|
130
|
+
carriedPlanes.push({
|
|
131
|
+
...pl,
|
|
132
|
+
id: p(pl.id),
|
|
133
|
+
name: `${into.name}/${pl.name}`,
|
|
134
|
+
...(pl.containmentOf !== undefined ? { containmentOf: p(pl.containmentOf) } : {}),
|
|
135
|
+
...(pl.layers !== undefined ? { layers: pl.layers.map(layerId) } : {}),
|
|
136
|
+
...(pl.hides !== undefined ? { hides: pl.hides.map(p) } : {}),
|
|
137
|
+
...(pl.hidesTree !== undefined ? { hidesTree: pl.hidesTree.map(p) } : {}),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
for (const e of child.containment) {
|
|
141
|
+
const plane = e.plane ?? defaultPlane;
|
|
142
|
+
if (plane === undefined)
|
|
143
|
+
continue;
|
|
144
|
+
carriedContainment.push({ parent: p(e.parent), child: p(e.child), plane: p(plane) });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
else if (child.notation !== undefined) {
|
|
148
|
+
// zero-plane child: synthesize one plane to carry the model-level notation
|
|
149
|
+
carriedPlanes.push({ id: p('main'), name: into.name, notation: child.notation });
|
|
150
|
+
for (const e of child.containment) {
|
|
151
|
+
carriedContainment.push({ parent: p(e.parent), child: p(e.child), plane: p('main') });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// If carried planes are landing on a host that declares none of its own, the
|
|
156
|
+
// FIRST carried plane would take `planes[0]` — the model's DEFAULT view.
|
|
157
|
+
// Untagged containment resolves to `planes[0]` too (buildHierarchy/gitGraph:
|
|
158
|
+
// `defaultPlane = planes[0]?.id`, matched via `(e.plane ?? defaultPlane) ===
|
|
159
|
+
// active`), so an unguarded carry would both silently retarget the default
|
|
160
|
+
// view onto the carried plane's content and leak the host's own untagged
|
|
161
|
+
// rows into it — and corrupt a further include's structural resolution the
|
|
162
|
+
// same way, since ITS `defaultPlane` would then resolve to the carried plane
|
|
163
|
+
// instead of the host's real structure. Synthesizing an explicit host base
|
|
164
|
+
// plane ahead of the carried ones keeps `planes[0]` a plain view of untagged
|
|
165
|
+
// content, exactly like a plane-less host today; carried planes start at
|
|
166
|
+
// index 1+ and see only their own tagged rows. A later sibling include's
|
|
167
|
+
// graft sees `host.planes.length > 0` by then and skips this.
|
|
168
|
+
const basePlane = host.planes.length === 0 && carriedPlanes.length > 0 ? [{ id: 'main', name: host.name }] : [];
|
|
169
|
+
// Everything not listed below is the HOST's — `...host` carries its `legend`,
|
|
170
|
+
// `typeColors` and `layerRules` and the child's are never read. Deliberate:
|
|
171
|
+
// those are presentation, and the diagram being looked at owns the look.
|
|
172
|
+
return {
|
|
173
|
+
...host,
|
|
174
|
+
nodes: [...host.nodes, ...nodes],
|
|
175
|
+
containment: [...host.containment, ...containment, ...carriedContainment],
|
|
176
|
+
relations: [...host.relations, ...relations],
|
|
177
|
+
layers: [...host.layers, ...layers],
|
|
178
|
+
planes: [...host.planes, ...basePlane, ...carriedPlanes],
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/** Global key pass: every keyed node unifies under its key as the composed id;
|
|
182
|
+
* all references are rewritten. Display attributes: an umbrella-authored
|
|
183
|
+
* declaration wins over includes; among includes, first encountered wins. */
|
|
184
|
+
function unifyKeys(model, rootIds, warnings) {
|
|
185
|
+
const byKey = new Map();
|
|
186
|
+
for (const n of model.nodes) {
|
|
187
|
+
if (n.key === undefined)
|
|
188
|
+
continue;
|
|
189
|
+
byKey.set(n.key, [...(byKey.get(n.key) ?? []), n]);
|
|
190
|
+
}
|
|
191
|
+
if (byKey.size === 0)
|
|
192
|
+
return model;
|
|
193
|
+
const rename = new Map(); // old id -> key
|
|
194
|
+
const winners = new Map(); // key -> display-attribute source
|
|
195
|
+
for (const [key, members] of byKey) {
|
|
196
|
+
const occupant = model.nodes.find((n) => n.id === key && n.key === undefined);
|
|
197
|
+
if (occupant !== undefined) {
|
|
198
|
+
throw new IncludeError(key, `Key '${key}' collides with unrelated node id '${occupant.id}'`);
|
|
199
|
+
}
|
|
200
|
+
const winner = members.find((m) => rootIds.has(m.id)) ?? members[0];
|
|
201
|
+
if (winner === undefined)
|
|
202
|
+
continue; // unreachable: members is non-empty
|
|
203
|
+
winners.set(key, winner);
|
|
204
|
+
const shownType = (t) => (t === undefined ? 'no type' : `type '${t}'`);
|
|
205
|
+
for (const m of members) {
|
|
206
|
+
rename.set(m.id, key);
|
|
207
|
+
if (m.type !== winner.type) {
|
|
208
|
+
warnings.push(`Key '${key}': ${shownType(m.type)} (node '${m.id}') differs from ${shownType(winner.type)} (node '${winner.id}') — using ${shownType(winner.type)}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const mapId = (id) => rename.get(id) ?? id;
|
|
213
|
+
const emitted = new Set();
|
|
214
|
+
const nodes = [];
|
|
215
|
+
for (const n of model.nodes) {
|
|
216
|
+
if (n.key === undefined) {
|
|
217
|
+
nodes.push(n);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (emitted.has(n.key))
|
|
221
|
+
continue; // one composed node per key, at first position
|
|
222
|
+
emitted.add(n.key);
|
|
223
|
+
const w = winners.get(n.key);
|
|
224
|
+
if (w !== undefined)
|
|
225
|
+
nodes.push({ ...w, id: n.key });
|
|
226
|
+
}
|
|
227
|
+
const seenEdges = new Set();
|
|
228
|
+
const containment = model.containment
|
|
229
|
+
.map((e) => ({ ...e, parent: mapId(e.parent), child: mapId(e.child) }))
|
|
230
|
+
.filter((e) => {
|
|
231
|
+
if (e.parent === e.child)
|
|
232
|
+
return false; // a keyed container including itself flattens out
|
|
233
|
+
const sig = `${e.parent}>${e.child}@${e.plane ?? ''}`;
|
|
234
|
+
if (seenEdges.has(sig))
|
|
235
|
+
return false;
|
|
236
|
+
seenEdges.add(sig);
|
|
237
|
+
return true;
|
|
238
|
+
});
|
|
239
|
+
const relations = model.relations.map((r) => ({ ...r, from: mapId(r.from), to: mapId(r.to) }));
|
|
240
|
+
const planes = model.planes.map((pl) => ({
|
|
241
|
+
...pl,
|
|
242
|
+
...(pl.hides !== undefined ? { hides: pl.hides.map(mapId) } : {}),
|
|
243
|
+
...(pl.hidesTree !== undefined ? { hidesTree: pl.hidesTree.map(mapId) } : {}),
|
|
244
|
+
}));
|
|
245
|
+
return { ...model, nodes, containment, relations, planes };
|
|
246
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Drawings, Stroke } from './types.js';
|
|
2
|
+
export declare const emptyDrawings: () => Drawings;
|
|
3
|
+
/** `k1`, `k2`, … — first free id in this plane's bucket. A deterministic scan
|
|
4
|
+
* rather than a random id: stroke ids only need to be unique within one file,
|
|
5
|
+
* and a counter keeps the sidecar diff readable and the tests mock-free. */
|
|
6
|
+
export declare function uniqueStrokeId(d: Drawings, key: string): string;
|
|
7
|
+
export declare function addStroke(d: Drawings, key: string, stroke: Stroke): Drawings;
|
|
8
|
+
/** An emptied bucket is dropped, mirroring set-layout-settings hygiene, so
|
|
9
|
+
* "does this plane have drawings?" stays a plain key lookup. */
|
|
10
|
+
export declare function deleteStroke(d: Drawings, key: string, id: string): Drawings;
|
|
11
|
+
/** Mirror hygiene for delete-plane. Returns the input unchanged when the
|
|
12
|
+
* plane had no bucket, so unrelated state stays referentially stable. */
|
|
13
|
+
export declare function pruneDrawingsPlane(d: Drawings, plane: string): Drawings;
|
package/dist/drawings.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { CommandError } from './mutate.js';
|
|
2
|
+
export const emptyDrawings = () => ({ version: 1, planes: {} });
|
|
3
|
+
/** `k1`, `k2`, … — first free id in this plane's bucket. A deterministic scan
|
|
4
|
+
* rather than a random id: stroke ids only need to be unique within one file,
|
|
5
|
+
* and a counter keeps the sidecar diff readable and the tests mock-free. */
|
|
6
|
+
export function uniqueStrokeId(d, key) {
|
|
7
|
+
const taken = new Set((d.planes[key] ?? []).map((s) => s.id));
|
|
8
|
+
for (let i = 1;; i++) {
|
|
9
|
+
if (!taken.has(`k${i}`))
|
|
10
|
+
return `k${i}`;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function addStroke(d, key, stroke) {
|
|
14
|
+
const bucket = d.planes[key] ?? [];
|
|
15
|
+
if (bucket.some((s) => s.id === stroke.id))
|
|
16
|
+
throw new CommandError(`Duplicate stroke id '${stroke.id}'`);
|
|
17
|
+
return { ...d, planes: { ...d.planes, [key]: [...bucket, stroke] } };
|
|
18
|
+
}
|
|
19
|
+
/** An emptied bucket is dropped, mirroring set-layout-settings hygiene, so
|
|
20
|
+
* "does this plane have drawings?" stays a plain key lookup. */
|
|
21
|
+
export function deleteStroke(d, key, id) {
|
|
22
|
+
const bucket = d.planes[key];
|
|
23
|
+
if (bucket === undefined || !bucket.some((s) => s.id === id))
|
|
24
|
+
throw new CommandError(`Unknown stroke '${id}'`);
|
|
25
|
+
const kept = bucket.filter((s) => s.id !== id);
|
|
26
|
+
const { [key]: _drop, ...rest } = d.planes;
|
|
27
|
+
return { ...d, planes: kept.length > 0 ? { ...rest, [key]: kept } : rest };
|
|
28
|
+
}
|
|
29
|
+
/** Mirror hygiene for delete-plane. Returns the input unchanged when the
|
|
30
|
+
* plane had no bucket, so unrelated state stays referentially stable. */
|
|
31
|
+
export function pruneDrawingsPlane(d, plane) {
|
|
32
|
+
if (!(plane in d.planes))
|
|
33
|
+
return d;
|
|
34
|
+
const { [plane]: _drop, ...rest } = d.planes;
|
|
35
|
+
return { ...d, planes: rest };
|
|
36
|
+
}
|
package/dist/eject.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { DiagramModel } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Deterministic node-id → TS identifier assignment, first-come order.
|
|
4
|
+
* Separators camelCase the following segment; an id that cannot start an
|
|
5
|
+
* identifier is prefixed with 'n'; collisions and reserved words take the
|
|
6
|
+
* first free numeric suffix starting at 2.
|
|
7
|
+
*/
|
|
8
|
+
export declare function identifiersFor(ids: readonly string[]): Map<string, string>;
|
|
9
|
+
/**
|
|
10
|
+
* A model value as TS source. Strings single-quoted; objects/arrays inline
|
|
11
|
+
* while their one-line form fits INLINE_LIMIT, multi-line (two-space steps,
|
|
12
|
+
* trailing commas) beyond it. Deterministic: key order is the object's own.
|
|
13
|
+
*/
|
|
14
|
+
export declare function tsLiteral(value: unknown, indent: number): string;
|
|
15
|
+
/**
|
|
16
|
+
* The model as an idiomatic builder module. Sections in a fixed order, each in
|
|
17
|
+
* model-array order — exactly what ModelBuilder.toJSON() reassembles, so the
|
|
18
|
+
* emitted file reproduces the model through the builder.
|
|
19
|
+
*/
|
|
20
|
+
export declare function ejectSource(model: DiagramModel): string;
|
package/dist/eject.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/** JS reserved words plus the two bindings the emitted file itself declares. */
|
|
2
|
+
const RESERVED = new Set([
|
|
3
|
+
'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default',
|
|
4
|
+
'delete', 'do', 'else', 'enum', 'export', 'extends', 'false', 'finally', 'for',
|
|
5
|
+
'function', 'if', 'import', 'in', 'instanceof', 'new', 'null', 'return',
|
|
6
|
+
'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof', 'var', 'void',
|
|
7
|
+
'while', 'with', 'let', 'static', 'yield', 'await',
|
|
8
|
+
'm', 'model',
|
|
9
|
+
]);
|
|
10
|
+
/**
|
|
11
|
+
* Deterministic node-id → TS identifier assignment, first-come order.
|
|
12
|
+
* Separators camelCase the following segment; an id that cannot start an
|
|
13
|
+
* identifier is prefixed with 'n'; collisions and reserved words take the
|
|
14
|
+
* first free numeric suffix starting at 2.
|
|
15
|
+
*/
|
|
16
|
+
export function identifiersFor(ids) {
|
|
17
|
+
const taken = new Set();
|
|
18
|
+
const out = new Map();
|
|
19
|
+
for (const id of ids) {
|
|
20
|
+
const segments = id.split(/[^A-Za-z0-9]+/).filter((s) => s !== '');
|
|
21
|
+
let base = segments
|
|
22
|
+
.map((s, i) => (i === 0 ? s : (s[0] ?? '').toUpperCase() + s.slice(1)))
|
|
23
|
+
.join('');
|
|
24
|
+
if (base === '' || /^[0-9]/.test(base))
|
|
25
|
+
base = `n${base}`;
|
|
26
|
+
let ident = base;
|
|
27
|
+
for (let n = 2; RESERVED.has(ident) || taken.has(ident); n++)
|
|
28
|
+
ident = `${base}${n}`;
|
|
29
|
+
taken.add(ident);
|
|
30
|
+
out.set(id, ident);
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
const IDENT_KEY = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
35
|
+
/** How wide an inline object/array may render before it breaks across lines. */
|
|
36
|
+
const INLINE_LIMIT = 72;
|
|
37
|
+
function quote(s) {
|
|
38
|
+
return `'${s
|
|
39
|
+
.replace(/\\/g, '\\\\')
|
|
40
|
+
.replace(/'/g, "\\'")
|
|
41
|
+
.replace(/\n/g, '\\n')
|
|
42
|
+
.replace(/\r/g, '\\r')}'`;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A model value as TS source. Strings single-quoted; objects/arrays inline
|
|
46
|
+
* while their one-line form fits INLINE_LIMIT, multi-line (two-space steps,
|
|
47
|
+
* trailing commas) beyond it. Deterministic: key order is the object's own.
|
|
48
|
+
*/
|
|
49
|
+
export function tsLiteral(value, indent) {
|
|
50
|
+
if (typeof value === 'string')
|
|
51
|
+
return quote(value);
|
|
52
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
53
|
+
return String(value);
|
|
54
|
+
if (value === null)
|
|
55
|
+
return 'null';
|
|
56
|
+
const pad = ' '.repeat(indent);
|
|
57
|
+
const inner = ' '.repeat(indent + 1);
|
|
58
|
+
if (Array.isArray(value)) {
|
|
59
|
+
const items = value.map((v) => tsLiteral(v, indent + 1));
|
|
60
|
+
const inline = `[${items.join(', ')}]`;
|
|
61
|
+
if (inline.length <= INLINE_LIMIT && !inline.includes('\n'))
|
|
62
|
+
return inline;
|
|
63
|
+
return `[\n${items.map((i) => `${inner}${i},`).join('\n')}\n${pad}]`;
|
|
64
|
+
}
|
|
65
|
+
if (typeof value === 'object' && value !== null) {
|
|
66
|
+
const entries = Object.entries(value).map(([k, v]) => `${IDENT_KEY.test(k) ? k : quote(k)}: ${tsLiteral(v, indent + 1)}`);
|
|
67
|
+
if (entries.length === 0)
|
|
68
|
+
return '{}';
|
|
69
|
+
const inline = `{ ${entries.join(', ')} }`;
|
|
70
|
+
if (inline.length <= INLINE_LIMIT && !inline.includes('\n'))
|
|
71
|
+
return inline;
|
|
72
|
+
return `{\n${entries.map((e) => `${inner}${e},`).join('\n')}\n${pad}}`;
|
|
73
|
+
}
|
|
74
|
+
// null is handled above — it round-trips: pruneUndefined (builder.ts) strips
|
|
75
|
+
// only `undefined`, so a null survives the object spread and deep-equal
|
|
76
|
+
// holds. undefined never round-trips (JSON has no undefined), so it still
|
|
77
|
+
// throws here; a caller that can legitimately produce it (e.g. a missing
|
|
78
|
+
// required field in hand-edited JSON) must guard before calling tsLiteral.
|
|
79
|
+
throw new Error(`ejectSource: cannot emit a ${typeof value} literal`);
|
|
80
|
+
}
|
|
81
|
+
function quoted(s) {
|
|
82
|
+
return tsLiteral(s, 0);
|
|
83
|
+
}
|
|
84
|
+
/** NodeOpts emission order — mirrors the interface declaration in builder.ts. */
|
|
85
|
+
const NODE_OPT_KEYS = [
|
|
86
|
+
'type', 'name', 'icon', 'shape', 'image', 'color', 'textColor', 'technology',
|
|
87
|
+
'link', 'description', 'rich', 'textAlign', 'fontScale', 'metadata', 'key', 'include',
|
|
88
|
+
'includePlane', 'includePlanes', 'plane', 'layer', 'columns', 'threats',
|
|
89
|
+
];
|
|
90
|
+
// Drift guard: a field added to DiagramNode without a matching entry above
|
|
91
|
+
// fails this line at `pnpm typecheck` — a future model field silently
|
|
92
|
+
// dropped by the emitter used to surface only as a runtime verify mismatch
|
|
93
|
+
// (or, pre-I2b, a crash) at eject time. `id` is emitted explicitly, ahead of
|
|
94
|
+
// the opts object, so it is excluded here.
|
|
95
|
+
const _nodeOptCoverage = true;
|
|
96
|
+
void _nodeOptCoverage;
|
|
97
|
+
/** RelateOpts emission order (after the always-first `kind` and conditional `id`)
|
|
98
|
+
* — mirrors the interface declaration in builder.ts. */
|
|
99
|
+
const RELATE_OPT_KEYS = [
|
|
100
|
+
'label', 'labels', 'style', 'description', 'layer', 'polarity', 'delay',
|
|
101
|
+
'fromColumn', 'toColumn', 'threats',
|
|
102
|
+
];
|
|
103
|
+
// Drift guard, same shape as _nodeOptCoverage above. `id` and `kind` are
|
|
104
|
+
// emitted explicitly ahead of the opts object; `from`/`to` are the node refs
|
|
105
|
+
// the call is built from, never opts entries — all four are excluded here.
|
|
106
|
+
const _relateOptCoverage = true;
|
|
107
|
+
void _relateOptCoverage;
|
|
108
|
+
/** plane() opts emission order — mirrors ModelBuilder.plane's opts parameter. */
|
|
109
|
+
const PLANE_OPT_KEYS = [
|
|
110
|
+
'name', 'containmentOf', 'layers', 'baseRelations', 'notation', 'hides', 'hidesTree',
|
|
111
|
+
];
|
|
112
|
+
// Drift guard, same shape as _nodeOptCoverage above. `id` is emitted
|
|
113
|
+
// explicitly, ahead of the opts object.
|
|
114
|
+
const _planeOptCoverage = true;
|
|
115
|
+
void _planeOptCoverage;
|
|
116
|
+
/** layer() opts emission order — mirrors ModelBuilder.layer's opts parameter. */
|
|
117
|
+
const LAYER_OPT_KEYS = ['name', 'tint'];
|
|
118
|
+
// Drift guard, same shape as _nodeOptCoverage above. `id` is emitted
|
|
119
|
+
// explicitly, ahead of the opts object.
|
|
120
|
+
const _layerOptCoverage = true;
|
|
121
|
+
void _layerOptCoverage;
|
|
122
|
+
/** Renders a trailing options object for a call, eliding it entirely when empty. */
|
|
123
|
+
function opts(entries) {
|
|
124
|
+
if (entries.length === 0)
|
|
125
|
+
return '';
|
|
126
|
+
const rendered = entries.map(([k, v]) => `${k}: ${tsLiteral(v, 1)}`);
|
|
127
|
+
const inline = `{ ${rendered.join(', ')} }`;
|
|
128
|
+
if (inline.length <= INLINE_LIMIT && !inline.includes('\n'))
|
|
129
|
+
return `, ${inline}`;
|
|
130
|
+
return `, {\n${rendered.map((r) => ` ${r},`).join('\n')}\n}`;
|
|
131
|
+
}
|
|
132
|
+
function nodeOpts(n) {
|
|
133
|
+
const out = [];
|
|
134
|
+
for (const k of NODE_OPT_KEYS) {
|
|
135
|
+
if (k === 'name') {
|
|
136
|
+
// A validate-clean node can lack `name` (validate() does not require
|
|
137
|
+
// it, even though DiagramNode's TS type does) — a hand-edited JSON,
|
|
138
|
+
// typically. Emit without a name opt rather than pushing `undefined`;
|
|
139
|
+
// the builder then rebuilds `name: id`, and the deep-compare refuses
|
|
140
|
+
// honestly instead of the emitter crashing.
|
|
141
|
+
if (n.name !== undefined && n.name !== n.id)
|
|
142
|
+
out.push(['name', n.name]);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const v = n[k];
|
|
146
|
+
if (v !== undefined)
|
|
147
|
+
out.push([k, v]);
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The model as an idiomatic builder module. Sections in a fixed order, each in
|
|
153
|
+
* model-array order — exactly what ModelBuilder.toJSON() reassembles, so the
|
|
154
|
+
* emitted file reproduces the model through the builder.
|
|
155
|
+
*/
|
|
156
|
+
export function ejectSource(model) {
|
|
157
|
+
const referenced = new Set();
|
|
158
|
+
for (const c of model.containment) {
|
|
159
|
+
referenced.add(c.parent);
|
|
160
|
+
referenced.add(c.child);
|
|
161
|
+
}
|
|
162
|
+
for (const r of model.relations) {
|
|
163
|
+
referenced.add(r.from);
|
|
164
|
+
referenced.add(r.to);
|
|
165
|
+
}
|
|
166
|
+
const idents = identifiersFor(model.nodes.filter((n) => referenced.has(n.id)).map((n) => n.id));
|
|
167
|
+
const sections = [];
|
|
168
|
+
if (model.planes.length > 0)
|
|
169
|
+
sections.push(model.planes.map((p) => {
|
|
170
|
+
const o = [];
|
|
171
|
+
for (const k of PLANE_OPT_KEYS) {
|
|
172
|
+
if (k === 'name') {
|
|
173
|
+
if (p.name !== p.id)
|
|
174
|
+
o.push(['name', p.name]);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const v = p[k];
|
|
178
|
+
if (v !== undefined)
|
|
179
|
+
o.push([k, v]);
|
|
180
|
+
}
|
|
181
|
+
return `m.plane(${quoted(p.id)}${opts(o)});`;
|
|
182
|
+
}));
|
|
183
|
+
if (model.layers.length > 0)
|
|
184
|
+
sections.push(model.layers.map((l) => {
|
|
185
|
+
const o = [];
|
|
186
|
+
for (const k of LAYER_OPT_KEYS) {
|
|
187
|
+
if (k === 'name') {
|
|
188
|
+
if (l.name !== l.id)
|
|
189
|
+
o.push(['name', l.name]);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const v = l[k];
|
|
193
|
+
if (v !== undefined)
|
|
194
|
+
o.push([k, v]);
|
|
195
|
+
}
|
|
196
|
+
return `m.layer(${quoted(l.id)}${opts(o)});`;
|
|
197
|
+
}));
|
|
198
|
+
if (model.nodes.length > 0)
|
|
199
|
+
sections.push(model.nodes.map((n) => {
|
|
200
|
+
const ident = idents.get(n.id);
|
|
201
|
+
const call = `m.node(${quoted(n.id)}${opts(nodeOpts(n))});`;
|
|
202
|
+
return ident !== undefined ? `const ${ident} = ${call}` : call;
|
|
203
|
+
}));
|
|
204
|
+
if (model.containment.length > 0) {
|
|
205
|
+
const groups = [];
|
|
206
|
+
for (const c of model.containment) {
|
|
207
|
+
const last = groups[groups.length - 1];
|
|
208
|
+
if (last !== undefined && last.parent === c.parent && last.plane === c.plane)
|
|
209
|
+
last.children.push(c.child);
|
|
210
|
+
else
|
|
211
|
+
groups.push({ parent: c.parent, plane: c.plane, children: [c.child] });
|
|
212
|
+
}
|
|
213
|
+
sections.push(groups.map((g) => {
|
|
214
|
+
const kids = g.children.map((c) => idents.get(c)).join(', ');
|
|
215
|
+
const plane = g.plane !== undefined ? `, { plane: ${quoted(g.plane)} }` : '';
|
|
216
|
+
return `${idents.get(g.parent)}.contains(${kids}${plane});`;
|
|
217
|
+
}));
|
|
218
|
+
}
|
|
219
|
+
if (model.relations.length > 0) {
|
|
220
|
+
const counters = new Map();
|
|
221
|
+
sections.push(model.relations.map((r) => {
|
|
222
|
+
const pair = `${r.from}->${r.to}`;
|
|
223
|
+
const n = counters.get(pair) ?? 0;
|
|
224
|
+
counters.set(pair, n + 1);
|
|
225
|
+
const o = [['kind', r.kind]];
|
|
226
|
+
if (r.id !== `${pair}#${n}`)
|
|
227
|
+
o.push(['id', r.id]);
|
|
228
|
+
for (const k of RELATE_OPT_KEYS) {
|
|
229
|
+
const v = r[k];
|
|
230
|
+
if (v !== undefined)
|
|
231
|
+
o.push([k, v]);
|
|
232
|
+
}
|
|
233
|
+
// relate() requires opts (kind is mandatory), so opts() never returns ''.
|
|
234
|
+
return `m.relate(${idents.get(r.from)}, ${idents.get(r.to)}${opts(o)});`;
|
|
235
|
+
}));
|
|
236
|
+
}
|
|
237
|
+
const setters = [];
|
|
238
|
+
if (model.typeColors !== undefined)
|
|
239
|
+
setters.push(`m.typeColors(${tsLiteral(model.typeColors, 0)});`);
|
|
240
|
+
if (model.layerRules !== undefined)
|
|
241
|
+
setters.push(`m.layerRules(${tsLiteral(model.layerRules, 0)});`);
|
|
242
|
+
if (model.legend !== undefined)
|
|
243
|
+
setters.push(`m.legend(${tsLiteral(model.legend, 0)});`);
|
|
244
|
+
if (model.notation !== undefined)
|
|
245
|
+
setters.push(`m.notation(${quoted(model.notation)});`);
|
|
246
|
+
if (model.style !== undefined)
|
|
247
|
+
setters.push(`m.style(${quoted(model.style)});`);
|
|
248
|
+
if (setters.length > 0)
|
|
249
|
+
sections.push(setters);
|
|
250
|
+
const header = `const m = model(${quoted(model.id)}${model.name !== model.id ? `, { name: ${tsLiteral(model.name, 0)} }` : ''});`;
|
|
251
|
+
return [
|
|
252
|
+
["import { model } from '@diagc/core';"],
|
|
253
|
+
[header],
|
|
254
|
+
...sections,
|
|
255
|
+
['export default m;'],
|
|
256
|
+
]
|
|
257
|
+
.map((s) => s.join('\n'))
|
|
258
|
+
.join('\n\n')
|
|
259
|
+
.concat('\n');
|
|
260
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { DiagramModel } from './types.js';
|
|
2
|
+
/** The notation id a plane (or the model) declares to be drawn as a fish. */
|
|
3
|
+
export declare const FISHBONE_NOTATION: "fishbone";
|
|
4
|
+
export declare const FB_EFFECT_TYPE: "fb-effect";
|
|
5
|
+
export declare const FB_CATEGORY_TYPE: "fb-category";
|
|
6
|
+
/** A cause and a sub-cause are ONE type: which it is comes from where it hangs
|
|
7
|
+
* (see fishboneTree), so promoting a sub-cause is a relation edit, not a type
|
|
8
|
+
* change, and nothing about a node can disagree with its place on the fish. */
|
|
9
|
+
export declare const FB_CAUSE_TYPE: "fb-cause";
|
|
10
|
+
export declare const FISHBONE_TYPES: readonly string[];
|
|
11
|
+
/** The kind the builder and the studio create — from the cause TO what it
|
|
12
|
+
* explains. The derivation below does NOT filter by it (see fishboneTree). */
|
|
13
|
+
export declare const FB_CAUSE_OF_KIND: "cause-of";
|
|
14
|
+
export declare const isFishboneNode: (n: {
|
|
15
|
+
type?: string;
|
|
16
|
+
}) => boolean;
|
|
17
|
+
export type FishbonePreset = 'Software' | '6M' | '4S';
|
|
18
|
+
/** Category sets an author can start from. Software first: it is what this
|
|
19
|
+
* tool is mostly used for; the classic manufacturing (6M) and service (4S) sets
|
|
20
|
+
* follow. The order of names is the order of bones. */
|
|
21
|
+
export declare const FISHBONE_PRESETS: Record<FishbonePreset, readonly string[]>;
|
|
22
|
+
export declare const FISHBONE_PRESET_NAMES: readonly FishbonePreset[];
|
|
23
|
+
/** node id for a preset category name: 'Infrastructure' → 'infrastructure' */
|
|
24
|
+
export declare const presetId: (name: string) => string;
|
|
25
|
+
export interface FishboneCause {
|
|
26
|
+
id: string;
|
|
27
|
+
/** sub-causes, in relation order */
|
|
28
|
+
subs: string[];
|
|
29
|
+
}
|
|
30
|
+
export interface FishboneCategory {
|
|
31
|
+
id: string;
|
|
32
|
+
/** causes, in relation order */
|
|
33
|
+
causes: FishboneCause[];
|
|
34
|
+
}
|
|
35
|
+
export interface FishboneTree {
|
|
36
|
+
/** the head; absent when the model has none — with several, the first in node order (validation flags the rest) */
|
|
37
|
+
effect?: string;
|
|
38
|
+
/** major bones, in relation order */
|
|
39
|
+
categories: FishboneCategory[];
|
|
40
|
+
/** fishbone nodes NOT on the fish, in node order: no parent, a chain that never
|
|
41
|
+
* reaches the effect (a cycle included), a wrong-typed parent, a fourth level,
|
|
42
|
+
* a second effect. Validation says which; the layout only needs "on or off". */
|
|
43
|
+
unattached: string[];
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The one definition of "hangs on": a fishbone node's parent is the `to` of
|
|
47
|
+
* its FIRST relation (declaration order) whose other end is also a fishbone
|
|
48
|
+
* node — any kind, so restyling an arrow can never drop a cause off its bone.
|
|
49
|
+
* Self-loops are skipped, and a relation whose ends aren't both fishbone
|
|
50
|
+
* nodes doesn't count as a parent pick. Shared by fishboneTree and
|
|
51
|
+
* validateFishbone (validate.ts) so the rule is defined exactly once.
|
|
52
|
+
*/
|
|
53
|
+
export declare function fishboneParents(model: DiagramModel): ReadonlyMap<string, string>;
|
|
54
|
+
/**
|
|
55
|
+
* What hangs where. This is the ONE place that answers it, so the layout, the
|
|
56
|
+
* colour hooks, the studio panel and validation cannot disagree.
|
|
57
|
+
*
|
|
58
|
+
* Reads a node's parent via fishboneParents, then reads the fish top-down from
|
|
59
|
+
* the effect with the types checked at each level: only a category hangs on
|
|
60
|
+
* the effect, only a cause on a category, only a cause on a cause, and a
|
|
61
|
+
* sub-cause has no children.
|
|
62
|
+
*
|
|
63
|
+
* Reads the MODEL's relations, never drawn edges: toggling a layer must not
|
|
64
|
+
* move a box. Never throws.
|
|
65
|
+
*/
|
|
66
|
+
export declare function fishboneTree(model: DiagramModel): FishboneTree;
|