@miragon/wardley-dsl 0.5.1 → 0.5.3

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/dist/index.cjs CHANGED
@@ -14,64 +14,165 @@ function parseCoords(line) {
14
14
  if (Number.isNaN(a) || Number.isNaN(b)) return null;
15
15
  return { a, b };
16
16
  }
17
- function stripCoords(line) {
18
- return line.replace(COORDS_RE, " ");
19
- }
20
- function splitAtCoords(line) {
17
+ function splitAtTuple(line) {
21
18
  const m = COORDS_RE.exec(line);
22
19
  if (!m) return null;
23
20
  const a = Number(m[1]);
24
21
  const b = Number(m[2]);
25
- if (Number.isNaN(a) || Number.isNaN(b)) return null;
26
22
  return {
27
- name: line.slice(0, m.index).trim(),
28
- coords: { a, b },
29
- suffix: line.slice(m.index + m[0].length)
23
+ before: line.slice(0, m.index).trim(),
24
+ tuple: m[0],
25
+ coords: Number.isNaN(a) || Number.isNaN(b) ? null : { a, b },
26
+ after: line.slice(m.index + m[0].length)
30
27
  };
31
28
  }
29
+ function splitAtCoords(line) {
30
+ const split = splitAtTuple(line);
31
+ if (!split?.coords) return null;
32
+ return { name: split.before, coords: split.coords, suffix: split.after };
33
+ }
32
34
  var COORDS4_RE = /\[\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\]/;
33
- function parseCoords4(line) {
35
+ function splitAtCoords4(line) {
34
36
  const m = COORDS4_RE.exec(line);
35
37
  if (!m) return null;
36
38
  const vals = [m[1], m[2], m[3], m[4]].map(Number);
37
39
  if (vals.some(Number.isNaN)) return null;
38
- return { a: vals[0], b: vals[1], c: vals[2], d: vals[3] };
40
+ return {
41
+ before: line.slice(0, m.index).trim(),
42
+ coords: { a: vals[0], b: vals[1], c: vals[2], d: vals[3] },
43
+ after: line.slice(m.index + m[0].length)
44
+ };
45
+ }
46
+ var TUPLE_LIST_RE = /\[\s{0,8}(\[[^[\]]*\](?:\s{0,8},\s{0,8}\[[^[\]]*\])*)\s{0,8}\]/;
47
+ var LIST_ENTRY_RE = /\[[^[\]]*\]/g;
48
+ var WHOLE_COORDS_RE = /^\[\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\]$/;
49
+ function readTupleList(line, list) {
50
+ const tuples = [];
51
+ const unreadable = [];
52
+ for (const [entry] of list[1].matchAll(LIST_ENTRY_RE)) {
53
+ const coords = WHOLE_COORDS_RE.exec(entry);
54
+ const a = Number(coords?.[1]);
55
+ const b = Number(coords?.[2]);
56
+ if (coords && !Number.isNaN(a) && !Number.isNaN(b)) tuples.push({ a, b });
57
+ else unreadable.push(entry);
58
+ }
59
+ return {
60
+ tuples,
61
+ unreadable,
62
+ before: line.slice(0, list.index).trim(),
63
+ after: line.slice(list.index + list[0].length)
64
+ };
39
65
  }
40
- var MULTI_COORDS_RE = /\[\s{0,8}(\[[^[\]]*\](?:\s{0,8},\s{0,8}\[[^[\]]*\])+)\s{0,8}\]/;
41
66
  function parseMultiCoords(line) {
42
- const m = MULTI_COORDS_RE.exec(line);
67
+ const list = TUPLE_LIST_RE.exec(line);
68
+ return list ? readTupleList(line, list) : null;
69
+ }
70
+ var FIRST_CHILD_TUPLE_RE = /\[\s*([-\d.]+)\s*(?:,\s*([-\d.]+)\s*)?\]/;
71
+ function splitAtFirstChildTuple(line) {
72
+ const m = FIRST_CHILD_TUPLE_RE.exec(line);
43
73
  if (!m) return null;
44
- const tuples = [];
45
- const inner = /\[\s{0,8}([-\d.]+)\s{0,8},\s{0,8}([-\d.]+)\s{0,8}\]/g;
46
- let t;
47
- while (t = inner.exec(m[1])) {
48
- const a = Number(t[1]);
49
- const b = Number(t[2]);
50
- if (!Number.isNaN(a) && !Number.isNaN(b)) tuples.push({ a, b });
51
- }
52
- if (!tuples.length) return null;
53
- return { tuples, rest: line.replace(MULTI_COORDS_RE, " ") };
54
- }
55
- function splitLineComment(line) {
56
- let inQuote = false;
74
+ return {
75
+ before: line.slice(0, m.index).trim(),
76
+ values: m[2] === void 0 ? [m[1]] : [m[1], m[2]],
77
+ after: line.slice(m.index + m[0].length)
78
+ };
79
+ }
80
+ function parsePositions(line) {
81
+ const single = COORDS_RE.exec(line);
82
+ const list = TUPLE_LIST_RE.exec(line);
83
+ if (list && (!single || list.index < single.index)) return readTupleList(line, list);
84
+ if (!single) return null;
85
+ const a = Number(single[1]);
86
+ const b = Number(single[2]);
87
+ const readable = !Number.isNaN(a) && !Number.isNaN(b);
88
+ return {
89
+ tuples: readable ? [{ a, b }] : [],
90
+ unreadable: readable ? [] : [single[0]],
91
+ before: line.slice(0, single.index).trim(),
92
+ after: line.slice(single.index + single[0].length)
93
+ };
94
+ }
95
+ function flowValueEnd(line, at) {
96
+ return line[at] === "'" && line[at - 1] === "+" ? line.indexOf("'", at + 1) : -1;
97
+ }
98
+ function rewriteOutsideFlowValues(text, rewrite) {
99
+ let rewritten = "";
100
+ let partStart = 0;
101
+ for (let i = 0; i < text.length; i++) {
102
+ const valueEnd = flowValueEnd(text, i);
103
+ if (valueEnd < 0) continue;
104
+ rewritten += rewrite(text.slice(partStart, i)) + text.slice(i, valueEnd + 1);
105
+ partStart = valueEnd + 1;
106
+ i = valueEnd;
107
+ }
108
+ return rewritten + rewrite(text.slice(partStart));
109
+ }
110
+ function indexOfLineComment(line) {
57
111
  for (let i = 0; i < line.length - 1; i++) {
58
- const ch = line[i];
59
- if (ch === "'") inQuote = !inQuote;
60
- else if (!inQuote && ch === "/" && line[i + 1] === "/" && line[i - 1] !== ":") {
61
- return { code: line.slice(0, i), comment: line.slice(i) };
62
- }
112
+ const valueEnd = flowValueEnd(line, i);
113
+ if (valueEnd > 0) i = valueEnd;
114
+ else if (line[i] === "/" && line[i + 1] === "/" && line[i - 1] !== ":") return i;
63
115
  }
64
- return { code: line, comment: null };
116
+ return -1;
65
117
  }
66
- function indexOfOutsideQuotes(line, needle) {
67
- let inQuote = false;
68
- for (let i = 0; i + needle.length <= line.length; i++) {
69
- const ch = line[i];
70
- if (ch === "'") {
71
- inQuote = !inQuote;
72
- continue;
118
+ function readUrlDefinition(after) {
119
+ const open = after.indexOf("[");
120
+ const close = after.lastIndexOf("]");
121
+ const name = open > 0 ? after.slice(0, open).trim() : "";
122
+ const address = open > 0 && close > open ? after.slice(open + 1, close).trim() : "";
123
+ if (!name || !address || after.slice(close + 1).trim() !== "") return null;
124
+ return { name, address };
125
+ }
126
+ function isUrlDefinition(line) {
127
+ return keywordOf(line) === "url" && readUrlDefinition(line.trim().slice("url".length)) !== null;
128
+ }
129
+ var CommentStripper = class {
130
+ inBlockComment = false;
131
+ get insideBlockComment() {
132
+ return this.inBlockComment;
133
+ }
134
+ strip(raw) {
135
+ const comments = [];
136
+ let working = raw;
137
+ if (this.inBlockComment) {
138
+ const close = working.indexOf("*/");
139
+ if (close < 0) return { code: "", comments: [raw] };
140
+ comments.push(working.slice(0, close + 2));
141
+ working = working.slice(close + 2);
142
+ this.inBlockComment = false;
143
+ }
144
+ const code = isUrlDefinition(working) ? working : this.stripComments(working, comments);
145
+ return { code, comments };
146
+ }
147
+ /** Left to right, so a `/*` inside a `//` comment opens no block comment. */
148
+ stripComments(line, comments) {
149
+ let code = "";
150
+ let rest = line;
151
+ for (; ; ) {
152
+ const lineComment = indexOfLineComment(rest);
153
+ const open = indexOutsideFlowValues(rest, "/*");
154
+ if (lineComment >= 0 && (open < 0 || lineComment < open)) {
155
+ comments.push(rest.slice(lineComment));
156
+ return code + rest.slice(0, lineComment);
157
+ }
158
+ if (open < 0) return code + rest;
159
+ const close = rest.indexOf("*/", open + 2);
160
+ if (close < 0) {
161
+ comments.push(rest.slice(open));
162
+ this.inBlockComment = true;
163
+ return code + rest.slice(0, open);
164
+ }
165
+ comments.push(rest.slice(open, close + 2));
166
+ code += `${rest.slice(0, open)} `;
167
+ rest = rest.slice(close + 2);
73
168
  }
74
- if (!inQuote && line.startsWith(needle, i)) return i;
169
+ }
170
+ };
171
+ function indexOutsideFlowValues(line, needle) {
172
+ for (let i = 0; i + needle.length <= line.length; i++) {
173
+ const valueEnd = flowValueEnd(line, i);
174
+ if (valueEnd > 0) i = valueEnd;
175
+ else if (line.startsWith(needle, i)) return i;
75
176
  }
76
177
  return -1;
77
178
  }
@@ -96,36 +197,168 @@ function parseDecorators(line) {
96
197
  });
97
198
  return { decorators: dec, rest };
98
199
  }
99
- var COLOR_RE = /\(\s*color\s+(#[0-9a-fA-F]{3,8}|[a-zA-Z][\w-]*)\s*\)/i;
100
- function parseColor(line) {
101
- const m = COLOR_RE.exec(line);
102
- if (!m || !m[1]) return { rest: line };
103
- return { color: m[1], rest: line.replace(COLOR_RE, " ") };
200
+ var COLOR_SOURCE = String.raw`\(\s*color\s+(#[0-9a-fA-F]{3,8}|[a-zA-Z][\w-]*)\s*\)`;
201
+ var HEIGHT_SOURCE = String.raw`\(\s{0,8}y\s{1,8}([^\s()]+)\s{0,8}\)`;
202
+ var LABEL_OFFSET_SOURCE = String.raw`label\s*\[\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\]`;
203
+ var COLOR_TOKEN_RE = new RegExp(COLOR_SOURCE, "iy");
204
+ var HEIGHT_TOKEN_RE = new RegExp(HEIGHT_SOURCE, "y");
205
+ var LABEL_TOKEN_RE = new RegExp(LABEL_OFFSET_SOURCE, "iy");
206
+ var URL_TOKEN_RE = /url\s{0,8}\(([^()]*)\)/iy;
207
+ var GROUP_TOKEN_RE = /\(([^()]*)\)/y;
208
+ var INERTIA_TOKEN_RE = /inertia\b/iy;
209
+ var WORD_TOKEN_RE = /\(?[^\s(]*/y;
210
+ function matchAt(pattern, text, at) {
211
+ pattern.lastIndex = at;
212
+ return pattern.exec(text);
213
+ }
214
+ function groupItems(content, contentAt) {
215
+ const items = [];
216
+ let itemStart = 0;
217
+ for (const raw of content.split(",")) {
218
+ const leading = raw.length - raw.trimStart().length;
219
+ const text = raw.trim();
220
+ if (text) items.push({ at: contentAt + itemStart + leading, text });
221
+ itemStart += raw.length + 1;
222
+ }
223
+ return items;
224
+ }
225
+ function readSuffixToken(text, at) {
226
+ let m;
227
+ if (m = matchAt(COLOR_TOKEN_RE, text, at)) {
228
+ return { at, text: m[0], kind: "color", color: m[1] };
229
+ }
230
+ if (m = matchAt(HEIGHT_TOKEN_RE, text, at)) {
231
+ return { at, text: m[0], kind: "height", value: m[1] };
232
+ }
233
+ if (m = matchAt(LABEL_TOKEN_RE, text, at)) {
234
+ return { at, text: m[0], kind: "label", dx: m[1], dy: m[2] };
235
+ }
236
+ if (m = matchAt(URL_TOKEN_RE, text, at)) {
237
+ return { at, text: m[0], kind: "url", ref: m[1].trim() };
238
+ }
239
+ if (m = matchAt(GROUP_TOKEN_RE, text, at)) {
240
+ return { at, text: m[0], kind: "group", items: groupItems(m[1], at + 1) };
241
+ }
242
+ if (m = matchAt(INERTIA_TOKEN_RE, text, at)) return { at, text: m[0], kind: "inertia" };
243
+ return { at, text: matchAt(WORD_TOKEN_RE, text, at)[0], kind: "word" };
104
244
  }
105
- var LABEL_OFFSET_RE = /\blabel\s*\[\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\]/i;
245
+ function scanSuffix(text) {
246
+ const tokens = [];
247
+ let at = 0;
248
+ while (at < text.length) {
249
+ if (/\s/.test(text[at])) {
250
+ at++;
251
+ continue;
252
+ }
253
+ const token = readSuffixToken(text, at);
254
+ tokens.push(token);
255
+ at += token.text.length;
256
+ }
257
+ return tokens;
258
+ }
259
+ var WHOLE_COLOR_GROUP_RE = new RegExp(`^${COLOR_SOURCE}$`, "i");
260
+ var WHOLE_HEIGHT_GROUP_RE = new RegExp(`^${HEIGHT_SOURCE}$`);
261
+ function splitTrailingGroups(text, kinds) {
262
+ const groups = [];
263
+ let end = text.trimEnd().length;
264
+ while (text[end - 1] === ")") {
265
+ const open = text.lastIndexOf("(", end - 1);
266
+ const group = text.slice(Math.max(open, 0), end);
267
+ const wanted = kinds.color && WHOLE_COLOR_GROUP_RE.test(group) || kinds.height && WHOLE_HEIGHT_GROUP_RE.test(group);
268
+ if (open < 0 || !wanted) break;
269
+ groups.push(group);
270
+ end = open;
271
+ while (end > 0 && /\s/.test(text[end - 1])) end--;
272
+ }
273
+ return { rest: text.slice(0, end), groups: groups.reverse() };
274
+ }
275
+ function splitLeadingColor(text) {
276
+ const start = text.length - text.trimStart().length;
277
+ const m = matchAt(COLOR_TOKEN_RE, text, start);
278
+ if (!m) return { rest: text };
279
+ return { color: m[1], rest: text.slice(start + m[0].length) };
280
+ }
281
+ var MAX_UNCLAMPED_MAGNITUDE = 1e12;
282
+ function isRoundTrippableNumber(value) {
283
+ return Number.isFinite(value) && Math.abs(value) <= MAX_UNCLAMPED_MAGNITUDE;
284
+ }
285
+ function readUnclampedNumber(text) {
286
+ const value = Number(text);
287
+ return isRoundTrippableNumber(value) ? value : null;
288
+ }
289
+ var LABEL_OFFSET_RE = new RegExp(String.raw`\b${LABEL_OFFSET_SOURCE}`, "i");
106
290
  function parseLabelOffset(line) {
107
291
  const m = LABEL_OFFSET_RE.exec(line);
108
292
  if (!m) return { labelOffset: null, rest: line };
109
- const dx = Number(m[1]);
110
- const dy = Number(m[2]);
293
+ const dx = readUnclampedNumber(m[1]);
294
+ const dy = readUnclampedNumber(m[2]);
111
295
  const rest = line.replace(LABEL_OFFSET_RE, " ");
112
- if (Number.isNaN(dx) || Number.isNaN(dy)) return { labelOffset: null, rest };
296
+ if (dx === null || dy === null) return { labelOffset: null, rest, unreadable: m[0] };
113
297
  return { labelOffset: { dx, dy }, rest };
114
298
  }
115
- var URL_REF_RE = /\burl\s{0,8}\(([^()]*)\)/i;
116
- function parseUrlRef(line) {
117
- const m = URL_REF_RE.exec(line);
118
- if (!m) return { urlRef: null, rest: line };
119
- return { urlRef: m[1].trim() || null, rest: line.replace(URL_REF_RE, " ") };
120
- }
299
+ var KEYWORD_RE = /^\s*([A-Za-z][\w-]*)(?![\w\p{L}\p{M}\p{N}-])/u;
121
300
  function keywordOf(line) {
122
- const m = /^\s*([A-Za-z][\w-]*)/.exec(line);
301
+ const m = KEYWORD_RE.exec(line);
123
302
  return m ? m[1].toLowerCase() : "";
124
303
  }
125
304
  function slug(label) {
126
305
  return label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_/, "").replace(/_$/, "") || "x";
127
306
  }
128
307
 
308
+ // src/names.ts
309
+ var NAMED_TYPES = /* @__PURE__ */ new Set([
310
+ "anchor",
311
+ "component",
312
+ "accelerator",
313
+ "submap"
314
+ ]);
315
+ function defaultName(type) {
316
+ switch (type) {
317
+ case "anchor":
318
+ return "User";
319
+ case "accelerator":
320
+ return "Accelerator";
321
+ case "submap":
322
+ return "Submap";
323
+ case "pipeline":
324
+ return "Pipeline";
325
+ default:
326
+ return "Component";
327
+ }
328
+ }
329
+ function roundAsWritten(n) {
330
+ return Math.round(n * 1e3) / 1e3;
331
+ }
332
+ var DIVISION_SLASH = "\u2215";
333
+ var FULLWIDTH_PLUS = "\uFF0B";
334
+ var FULLWIDTH_LEFT_BRACE = "\uFF5B";
335
+ function escapeCommentMarkers(text) {
336
+ return rewriteOutsideFlowValues(
337
+ text,
338
+ (part) => part.replace(/\/\*/g, `${DIVISION_SLASH}*`).replace(/(?<!:)\/\//g, DIVISION_SLASH.repeat(2))
339
+ );
340
+ }
341
+ function writtenText(text) {
342
+ return escapeCommentMarkers(text.replace(/\r?\n/g, " "));
343
+ }
344
+ function writtenNoteText(text) {
345
+ return escapeCommentMarkers(text).replace(/\r?\n/g, "\\n");
346
+ }
347
+ function writtenName(label, elementType) {
348
+ const name = label.replace(/\r?\n/g, " ").replace(/->/g, "\u2192").replace(/\+(?=['<>])/g, FULLWIDTH_PLUS).trim().replace(/^\{/, FULLWIDTH_LEFT_BRACE);
349
+ return escapeCommentMarkers(name) || defaultName(elementType);
350
+ }
351
+ var UniqueNames = class {
352
+ used = /* @__PURE__ */ new Set();
353
+ claim(base) {
354
+ let name = base;
355
+ let suffix = 2;
356
+ while (this.used.has(name)) name = `${base} ${suffix++}`;
357
+ this.used.add(name);
358
+ return name;
359
+ }
360
+ };
361
+
129
362
  // src/parser.ts
130
363
  function splitDependency(core) {
131
364
  const arrow = core.indexOf("->");
@@ -153,7 +386,7 @@ function splitFlow(core) {
153
386
  }
154
387
  return null;
155
388
  }
156
- var NON_LINK_KEYWORDS = /* @__PURE__ */ new Set([
389
+ var LINK_LIKE_STATEMENT_KEYWORDS = /* @__PURE__ */ new Set([
157
390
  "title",
158
391
  "style",
159
392
  "size",
@@ -164,12 +397,111 @@ var NON_LINK_KEYWORDS = /* @__PURE__ */ new Set([
164
397
  "evolve",
165
398
  "line"
166
399
  ]);
400
+ var DECLARATION_KEYWORDS = /* @__PURE__ */ new Set([
401
+ "anchor",
402
+ "component",
403
+ "market",
404
+ "ecosystem",
405
+ "note",
406
+ "pioneers",
407
+ "settlers",
408
+ "townplanners",
409
+ "url",
410
+ "build",
411
+ "buy",
412
+ "outsource",
413
+ "accelerator",
414
+ "deaccelerator",
415
+ "submap"
416
+ ]);
167
417
  var KNOWN_STYLES = /* @__PURE__ */ new Set(["wardley", "handwritten", "colour", "dark"]);
168
418
  function compact(obj) {
169
419
  const out = {};
170
420
  for (const [k, v] of Object.entries(obj)) if (v !== void 0) out[k] = v;
171
421
  return out;
172
422
  }
423
+ function linkCore(line) {
424
+ const semi = line.indexOf(";");
425
+ return semi >= 0 ? line.slice(0, semi) : line;
426
+ }
427
+ function readLink(line, lineNo, text) {
428
+ const semi = line.indexOf(";");
429
+ const core = (semi >= 0 ? line.slice(0, semi) : line).trim();
430
+ const label = semi >= 0 ? line.slice(semi + 1).trim() : "";
431
+ const common = { ...label ? { label } : {}, raw: line, lineNo, text };
432
+ const dependency = splitDependency(core);
433
+ if (dependency) return { ...dependency, kind: "dependency", ...common };
434
+ const flow = splitFlow(core);
435
+ if (!flow) return null;
436
+ return {
437
+ left: flow.left,
438
+ right: flow.right,
439
+ kind: "flow",
440
+ bidirectional: flow.op === "<>",
441
+ reverse: flow.op === "<",
442
+ ...flow.value ? { flowValue: flow.value } : {},
443
+ ...common
444
+ };
445
+ }
446
+ function couldBeStatement(line, keyword, after) {
447
+ return LINK_LIKE_STATEMENT_KEYWORDS.has(keyword) || DECLARATION_KEYWORDS.has(keyword) && parseCoords(line) !== null || keyword === "url" && readUrlDefinition(after) !== null;
448
+ }
449
+ function namesAsWritten(link) {
450
+ const keyword = keywordOf(link.left);
451
+ const nameAfterKeyword = link.left.slice(keyword.length).trim();
452
+ return {
453
+ left: [keyword, nameAfterKeyword].filter(Boolean).join(" "),
454
+ right: link.right.split("->").map((part) => part.trim()).join("->")
455
+ };
456
+ }
457
+ function leftNameIsDeclared(link, names) {
458
+ return names.has(link.left) || names.has(namesAsWritten(link).left);
459
+ }
460
+ function unknownStatementMessage(line) {
461
+ const core = linkCore(line);
462
+ if (/->|\+('[^']*')?(<>|>|<)/.test(core)) return "Link needs a name on both sides";
463
+ const flowValue = /\+'[^']*'/.exec(core);
464
+ if (flowValue) return `Flow ${flowValue[0]} has no direction \u2014 add ">", "<" or "<>"`;
465
+ return `Unknown statement "${line.split(/\s/)[0]}"`;
466
+ }
467
+ function buildEdges(links, idOfName, unresolved, labelOf) {
468
+ let dependencies = 0;
469
+ let flows = 0;
470
+ const edges = [];
471
+ for (const link of links) {
472
+ const fromId = idOfName(link.left) ?? idOfName(link.writtenNames?.left ?? link.left);
473
+ const toId = idOfName(link.right) ?? idOfName(link.writtenNames?.right ?? link.right);
474
+ if (!fromId || !toId) {
475
+ unresolved(link, fromId ? link.right : link.left);
476
+ continue;
477
+ }
478
+ if (link.kind === "dependency") {
479
+ edges.push(
480
+ compact({
481
+ id: `dep_${++dependencies}`,
482
+ edgeType: "dependency",
483
+ from: fromId,
484
+ to: toId,
485
+ label: labelOf(link)
486
+ })
487
+ );
488
+ continue;
489
+ }
490
+ const [from, to] = link.reverse ? [toId, fromId] : [fromId, toId];
491
+ edges.push(
492
+ compact({
493
+ id: `flow_${++flows}`,
494
+ edgeType: "flow",
495
+ from,
496
+ to,
497
+ bidirectional: link.bidirectional ? true : void 0,
498
+ flowValue: link.flowValue,
499
+ label: labelOf(link)
500
+ })
501
+ );
502
+ }
503
+ return edges;
504
+ }
173
505
  var IdAllocator = class {
174
506
  used = /* @__PURE__ */ new Set();
175
507
  alloc(prefix, label) {
@@ -185,612 +517,1007 @@ function parseDSL(text) {
185
517
  return parseDSLWithDiagnostics(text).map;
186
518
  }
187
519
  function parseDSLWithDiagnostics(text) {
188
- const diagnostics = [];
189
- const ids = new IdAllocator();
190
- const nameToId = /* @__PURE__ */ new Map();
191
- const elements = [];
192
- const rawPassthrough = [];
193
- const pendingLinks = [];
194
- const pendingEvolve = [];
195
- const pendingPipeline = [];
196
- const pendingMethod = [];
197
- let config = { title: "Untitled Map" };
198
- let annoCounter = 0;
199
- let inBlockComment = false;
200
- const urlDefs = /* @__PURE__ */ new Map();
201
- const pendingUrlRefs = [];
202
- let lastPipeline = null;
203
- let blockPipeline = null;
204
- const register = (name, id) => {
205
- if (!nameToId.has(name)) nameToId.set(name, id);
206
- };
207
- let lineNo = 0;
208
- let currentLine = "";
209
- const pushLink = (line) => {
210
- const semi = line.indexOf(";");
211
- const core = semi >= 0 ? line.slice(0, semi).trim() : line;
212
- const linkLabel = semi >= 0 ? line.slice(semi + 1).trim() : "";
213
- const dep = splitDependency(core);
214
- const flow = dep ? null : splitFlow(core);
215
- if (dep) {
216
- pendingLinks.push({
217
- left: dep.left,
218
- right: dep.right,
219
- kind: "dependency",
220
- ...linkLabel ? { label: linkLabel } : {},
221
- raw: line,
222
- lineNo
223
- });
520
+ const reading = new MapReader(/* @__PURE__ */ new Set()).read(text);
521
+ const needsDeclaredNames = reading.keywordLinks.some(
522
+ (link) => leftNameIsDeclared(link, reading.declaredNames)
523
+ );
524
+ return needsDeclaredNames ? new MapReader(reading.declaredNames).read(text).result : reading.result;
525
+ }
526
+ var MapReader = class {
527
+ constructor(linkNames) {
528
+ this.linkNames = linkNames;
529
+ }
530
+ linkNames;
531
+ diagnostics = [];
532
+ ids = new IdAllocator();
533
+ namesInUse = new UniqueNames();
534
+ /** The first element declared under a name wins it. */
535
+ idBySourceName = /* @__PURE__ */ new Map();
536
+ idByElementName = /* @__PURE__ */ new Map();
537
+ elements = [];
538
+ passthrough = [];
539
+ pendingLinks = [];
540
+ keywordLinks = [];
541
+ pendingEvolve = [];
542
+ pendingPipeline = [];
543
+ pendingMethod = [];
544
+ config = { title: "Untitled Map" };
545
+ lineThatSetConfig = /* @__PURE__ */ new Map();
546
+ unusedConfigLines = [];
547
+ annotationCounter = 0;
548
+ comments = new CommentStripper();
549
+ urlDefinitions = [];
550
+ pendingUrlRefs = [];
551
+ /** Candidate for a `{` block opening: a block binds only to the directly preceding pipeline line. */
552
+ lastPipeline = null;
553
+ blockPipeline = null;
554
+ blockOpening = { line: 0, text: "" };
555
+ lineNo = 0;
556
+ currentLine = "";
557
+ report = (message) => this.diag(message);
558
+ reportAt = (message, line, text) => this.diag(message, line, text);
559
+ diag(message, atLine = this.lineNo, text = this.currentLine) {
560
+ this.diagnostics.push({ line: atLine, message, text });
561
+ }
562
+ keepLine(statement, lineNo = this.lineNo, source = this.currentLine) {
563
+ this.passthrough.push({ text: statement, isComment: false, lineNo, source });
564
+ }
565
+ keep(statement, message) {
566
+ this.keepLine(statement);
567
+ this.diag(message);
568
+ }
569
+ /** For a line that looks like a known construct but cannot be parsed. */
570
+ failed(line) {
571
+ this.keep(line, "Line could not be interpreted (kept losslessly in rawPassthrough)");
572
+ }
573
+ // A reference binds to the name as written first, so `X 2` stays bound to an explicit "X 2" even
574
+ // when a duplicate "X" was renamed to "X 2"; the serializer then writes that reference by the
575
+ // element's new name, and the next parse binds it the same way.
576
+ idOfName(name) {
577
+ return this.idBySourceName.get(name) ?? this.idByElementName.get(name);
578
+ }
579
+ register(sourceName, elementName, id) {
580
+ if (!this.idBySourceName.has(sourceName)) this.idBySourceName.set(sourceName, id);
581
+ this.idByElementName.set(elementName, id);
582
+ }
583
+ setConfig(keyword, update) {
584
+ const earlier = this.lineThatSetConfig.get(keyword);
585
+ if (earlier !== void 0) {
586
+ this.diag(`Repeated "${keyword}" \u2014 replaces the one on line ${earlier}`);
587
+ }
588
+ this.lineThatSetConfig.set(keyword, this.lineNo);
589
+ this.config = update;
590
+ }
591
+ keepUnusedConfigLine(keyword, statement, reason) {
592
+ this.unusedConfigLines.push({
593
+ keyword,
594
+ reason,
595
+ index: this.passthrough.length,
596
+ line: this.lineNo,
597
+ text: this.currentLine
598
+ });
599
+ this.keepLine(statement);
600
+ }
601
+ /**
602
+ * Links are read before statements, as names may begin with a keyword (`Component -> X`); a link
603
+ * has no `[a, b]` tuple before its `;`. A line that could also be its keyword's statement is a
604
+ * link only when both names are declared.
605
+ */
606
+ readsAsLink(line, kw, after) {
607
+ const link = parseCoords(linkCore(line)) ? null : readLink(line, this.lineNo, this.currentLine);
608
+ if (!link) return false;
609
+ if (!couldBeStatement(line, kw, after)) {
610
+ this.pendingLinks.push(link);
224
611
  return true;
225
612
  }
226
- if (flow) {
227
- pendingLinks.push({
228
- left: flow.left,
229
- right: flow.right,
230
- kind: "flow",
231
- bidirectional: flow.op === "<>",
232
- reverse: flow.op === "<",
233
- ...flow.value ? { flowValue: flow.value } : {},
234
- ...linkLabel ? { label: linkLabel } : {},
235
- raw: line,
236
- lineNo
237
- });
613
+ const declared = this.linkNames;
614
+ const written = namesAsWritten(link);
615
+ const leftDeclared = declared.has(link.left) || declared.has(written.left);
616
+ if (leftDeclared && (declared.has(link.right) || declared.has(written.right))) {
617
+ this.pendingLinks.push({ ...link, writtenNames: written });
238
618
  return true;
239
619
  }
620
+ this.keywordLinks.push(link);
621
+ if (leftDeclared) this.diag(`Link: "${link.right}" not found \u2014 read as a "${kw}" statement`);
240
622
  return false;
241
- };
242
- const diag = (message, atLine = lineNo, text_ = currentLine) => diagnostics.push({ line: atLine, message, text: text_ });
243
- const failed = (l) => {
244
- rawPassthrough.push(l);
245
- diag("Line could not be interpreted (kept losslessly in rawPassthrough)");
246
- };
247
- const clampDiag = (n) => {
248
- if (n < 0 || n > 1) diag(`Coordinate ${n} is outside [0,1] and was clamped`);
623
+ }
624
+ /** Clamps a normalized value to [0,1] — with a diagnostic instead of a later validation crash. */
625
+ clamp(n, report = this.report) {
626
+ if (n < 0 || n > 1) report(`Coordinate ${n} is outside [0,1] and was clamped`);
249
627
  return n < 0 ? 0 : n > 1 ? 1 : n;
250
- };
251
- const pos = (visibility, evolution) => ({
252
- visibility: clampDiag(visibility),
253
- evolution: clampDiag(evolution)
254
- });
255
- const sourceLines = text.split(/\r?\n/);
256
- for (let i = 0; i < sourceLines.length; i++) {
257
- const raw = sourceLines[i];
258
- lineNo = i + 1;
259
- currentLine = raw.trim();
260
- let working = raw;
261
- if (inBlockComment) {
262
- const close = working.indexOf("*/");
263
- if (close < 0) {
264
- rawPassthrough.push(raw);
265
- continue;
266
- }
267
- rawPassthrough.push(working.slice(0, close + 2));
268
- working = working.slice(close + 2);
269
- inBlockComment = false;
628
+ }
629
+ position(visibility, evolution) {
630
+ return { visibility: this.clamp(visibility), evolution: this.clamp(evolution) };
631
+ }
632
+ reportTextBefore(before) {
633
+ if (before) this.diag(`Ignored text before the coordinates: "${before}"`);
634
+ }
635
+ /** A suffix written in front of the coordinates is part of the name or text (OWM). */
636
+ reportMisplacedSuffix(groups, where, fix) {
637
+ if (groups.length) this.diag(`"${groups.join(" ")}" ${where} \u2014 ${fix}`);
638
+ }
639
+ /**
640
+ * A text as the serializer writes it. Its comment markers are protected only by flow values
641
+ * `+'…'` within the text itself; one that other parts of the line protected is escaped now,
642
+ * not on the next parse.
643
+ */
644
+ readText(text, line = this.lineNo, source = this.currentLine) {
645
+ const written = escapeCommentMarkers(text);
646
+ if (written !== text) {
647
+ this.diag(
648
+ `"${text}" read as "${written}" \u2014 "//" or "/*" there would start a comment once saved`,
649
+ line,
650
+ source
651
+ );
270
652
  }
271
- if (keywordOf(working) !== "url") {
272
- let open = indexOfOutsideQuotes(working, "/*");
273
- while (open >= 0) {
274
- const close = working.indexOf("*/", open + 2);
275
- if (close < 0) {
276
- rawPassthrough.push(working.slice(open));
277
- working = working.slice(0, open);
278
- inBlockComment = true;
279
- break;
280
- }
281
- rawPassthrough.push(working.slice(open, close + 2));
282
- working = `${working.slice(0, open)} ${working.slice(close + 2)}`;
283
- open = indexOfOutsideQuotes(working, "/*");
284
- }
285
- const { code, comment } = splitLineComment(working);
286
- if (comment !== null) rawPassthrough.push(comment);
287
- working = code;
653
+ return written;
654
+ }
655
+ /** Names an element the way the serializer writes it, so the next parse yields the same name. */
656
+ nameElement(sourceName, elementType, atLine = this.lineNo, atText = this.currentLine) {
657
+ const base = writtenName(sourceName, elementType);
658
+ const name = this.namesInUse.claim(base);
659
+ if (name !== base) {
660
+ this.diag(
661
+ `Duplicate name "${sourceName}" \u2014 renamed to "${name}"; links bind to the first "${sourceName}"`,
662
+ atLine,
663
+ atText
664
+ );
665
+ } else if (name !== sourceName) {
666
+ this.diag(renamedMessage(sourceName, name), atLine, atText);
288
667
  }
289
- const line = working.trim();
290
- if (!line) continue;
291
- if (blockPipeline) {
292
- if (line === "}") {
293
- blockPipeline = null;
294
- continue;
295
- }
296
- const childKw = keywordOf(line);
297
- if (childKw === "component") {
298
- const child = parseBlockChild(line.slice(childKw.length).trim());
299
- if (child) {
300
- blockPipeline.children.push(child);
301
- continue;
302
- }
668
+ return name;
669
+ }
670
+ openBlock() {
671
+ if (this.lastPipeline) {
672
+ this.blockPipeline = this.lastPipeline;
673
+ this.blockOpening = { line: this.lineNo, text: this.currentLine };
674
+ } else this.diag('Ignored "{" \u2014 a block must directly follow a pipeline line');
675
+ this.lastPipeline = null;
676
+ }
677
+ closeBlock() {
678
+ if (this.blockPipeline) this.blockPipeline = null;
679
+ else this.diag('Ignored "}" \u2014 no pipeline block is open');
680
+ this.lastPipeline = null;
681
+ }
682
+ endUnclosedBlock() {
683
+ if (!this.blockPipeline) return;
684
+ this.diag(
685
+ `Pipeline block of "${this.blockPipeline.name}" is never closed \u2014 add "}"`,
686
+ this.blockOpening.line,
687
+ this.blockOpening.text
688
+ );
689
+ this.blockPipeline = null;
690
+ }
691
+ /** A `url(Name)` reference on the element just pushed. */
692
+ addUrlRef(ref) {
693
+ this.pendingUrlRefs.push({
694
+ index: this.elements.length - 1,
695
+ ref,
696
+ line: this.lineNo,
697
+ text: this.currentLine
698
+ });
699
+ }
700
+ read(text) {
701
+ const sourceLines = text.split(/\r?\n/);
702
+ if (sourceLines.length > 1 && sourceLines[sourceLines.length - 1] === "") sourceLines.pop();
703
+ let blockCommentStart = null;
704
+ for (let i = 0; i < sourceLines.length; i++) {
705
+ const raw = withoutTrailingCarriageReturns(sourceLines[i]);
706
+ this.lineNo = i + 1;
707
+ this.currentLine = raw.trim();
708
+ const stripped = this.comments.strip(raw);
709
+ if (!this.comments.insideBlockComment) blockCommentStart = null;
710
+ else blockCommentStart ??= { line: this.lineNo, text: this.currentLine };
711
+ for (const fragment of stripped.comments) {
712
+ this.passthrough.push({
713
+ text: fragment,
714
+ isComment: true,
715
+ lineNo: this.lineNo,
716
+ source: this.currentLine
717
+ });
303
718
  }
304
- rawPassthrough.push(line);
305
- continue;
719
+ this.readCode(stripped.code.trim());
306
720
  }
307
- if (line.startsWith("{")) {
308
- if (lastPipeline) {
309
- blockPipeline = lastPipeline;
310
- lastPipeline = null;
311
- continue;
312
- }
313
- rawPassthrough.push(line);
314
- continue;
721
+ this.endUnclosedBlock();
722
+ if (blockCommentStart) {
723
+ const { line, text: startText } = blockCommentStart;
724
+ this.diag(
725
+ 'Block comment "/*" is never closed \u2014 the rest of the file is a comment',
726
+ line,
727
+ startText
728
+ );
729
+ }
730
+ return this.finish();
731
+ }
732
+ /**
733
+ * Braces are structural tokens: `{` at the start of a line, and `}` there while a block is open
734
+ * or on its own; `}` also ends a block line. A name starting with `}` stays a name.
735
+ */
736
+ readCode(code) {
737
+ let statement = code;
738
+ for (; ; ) {
739
+ if (statement.startsWith("{")) this.openBlock();
740
+ else if (statement.startsWith("}") && (this.blockPipeline || statement === "}")) {
741
+ this.closeBlock();
742
+ } else break;
743
+ statement = statement.slice(1).trim();
315
744
  }
745
+ const closesBlock = this.blockPipeline !== null && statement.endsWith("}");
746
+ if (closesBlock) statement = statement.slice(0, -1).trim();
747
+ if (statement) this.readStatement(statement);
748
+ if (closesBlock) this.blockPipeline = null;
749
+ }
750
+ readStatement(line) {
316
751
  const kw = keywordOf(line);
317
752
  const after = line.slice(kw.length).trim();
318
- if (kw !== "pipeline") lastPipeline = null;
319
- if (!NON_LINK_KEYWORDS.has(kw) && !parseCoords(line) && pushLink(line)) {
320
- continue;
321
- }
753
+ if (this.blockPipeline && kw === "component" && this.readBlockChild(after)) return;
754
+ this.lastPipeline = null;
755
+ if (this.readsAsLink(line, kw, after)) return;
322
756
  switch (kw) {
323
757
  case "title":
324
- config = { ...config, title: after };
325
- break;
326
- case "anchor": {
327
- const node = parseNode(after);
328
- if (!node) {
329
- failed(line);
330
- break;
331
- }
332
- const id = ids.alloc("anchor", node.name);
333
- const anchor = compact({
334
- id,
335
- elementType: "anchor",
336
- label: node.name,
337
- position: pos(node.coords.a, node.coords.b),
338
- labelOffset: node.labelOffset,
339
- color: node.color
340
- });
341
- elements.push(anchor);
342
- register(node.name, id);
343
- break;
344
- }
758
+ this.setConfig("title", { ...this.config, title: this.readText(after) });
759
+ return;
760
+ case "anchor":
761
+ return this.readAnchor(line, after);
345
762
  case "component":
346
763
  case "market":
347
- case "ecosystem": {
348
- const node = parseNode(after);
349
- if (!node) {
350
- failed(line);
351
- break;
352
- }
353
- const decorators = mergeLegacy(kw, node.decorators);
354
- const id = ids.alloc("cmp", node.name);
355
- const component = compact({
356
- id,
357
- elementType: "component",
358
- label: node.name,
359
- position: pos(node.coords.a, node.coords.b),
360
- labelOffset: node.labelOffset,
361
- decorators: Object.keys(decorators).length ? decorators : void 0,
362
- color: node.color
363
- });
364
- elements.push(component);
365
- if (node.urlRef) pendingUrlRefs.push({ index: elements.length - 1, ref: node.urlRef });
366
- register(node.name, id);
367
- break;
368
- }
369
- case "note": {
370
- const { color, rest } = parseColor(after);
371
- const coords = parseCoords(rest);
372
- const textPart = stripCoords(rest).trim().replace(/\\n/g, "\n");
373
- if (!coords) {
374
- failed(line);
375
- break;
376
- }
377
- const id = ids.alloc("note", textPart || "note");
378
- const note = compact({
379
- id,
380
- elementType: "note",
381
- label: textPart,
382
- position: pos(coords.a, coords.b),
383
- color
384
- });
385
- elements.push(note);
386
- break;
387
- }
388
- case "pipeline": {
389
- let body = after;
390
- let opensBlock = false;
391
- if (body.endsWith("{")) {
392
- opensBlock = true;
393
- body = body.slice(0, -1).trim();
394
- }
395
- const col = parseColor(body);
396
- const yMatch = /\(\s{0,8}y\s{1,8}([\d.]+)\s{0,8}\)/.exec(col.rest);
397
- const bodyRest = yMatch ? col.rest.replace(yMatch[0], " ") : col.rest;
398
- const coords = parseCoords(bodyRest);
399
- const name = stripCoords(bodyRest).trim();
400
- if (!name) {
401
- failed(line);
402
- break;
403
- }
404
- const pending = {
405
- name,
406
- ...coords ? { start: coords.a, end: coords.b } : {},
407
- ...yMatch ? { visibility: clamp01(Number(yMatch[1])) } : {},
408
- ...col.color ? { color: col.color } : {},
409
- raw: line,
410
- children: []
411
- };
412
- pendingPipeline.push(pending);
413
- if (opensBlock) blockPipeline = pending;
414
- else lastPipeline = pending;
415
- break;
416
- }
417
- case "evolve": {
418
- const ev = parseEvolve(after);
419
- if (!ev) {
420
- failed(line);
421
- break;
422
- }
423
- pendingEvolve.push({ ...ev, target: clampDiag(ev.target), raw: line, lineNo });
424
- break;
425
- }
426
- case "style": {
427
- const s = after.toLowerCase();
428
- if (KNOWN_STYLES.has(s)) config = { ...config, style: s };
429
- else failed(line);
430
- break;
431
- }
432
- case "size": {
433
- const coords = parseCoords(after);
434
- if (coords) config = { ...config, size: { width: coords.a, height: coords.b } };
435
- else failed(line);
436
- break;
437
- }
438
- case "evolution": {
439
- const parts = after.split("->").map((s) => s.trim());
440
- if (parts.length === 4) {
441
- config = { ...config, evolutionLabels: [parts[0], parts[1], parts[2], parts[3]] };
442
- } else rawPassthrough.push(raw);
443
- break;
444
- }
445
- case "y-axis": {
446
- const parts = after.split("->").map((s) => s.trim()).filter(Boolean);
447
- if (parts.length) {
448
- config = {
449
- ...config,
450
- yAxisLabel: parts[0],
451
- ...parts.length >= 3 ? { yAxisEndLabels: [parts[1], parts[2]] } : {}
452
- };
453
- } else failed(line);
454
- break;
455
- }
456
- case "annotations": {
457
- const coords = parseCoords(after);
458
- if (coords) {
459
- config = {
460
- ...config,
461
- annotationsBoxPosition: pos(coords.a, coords.b)
462
- };
463
- } else rawPassthrough.push(raw);
464
- break;
465
- }
466
- case "annotation": {
467
- const numMatch = /^\s*(\d+)/.exec(after);
468
- const number = numMatch ? Number(numMatch[1]) : ++annoCounter;
469
- const colA = parseColor(after.replace(/^\s*\d+\s*/, ""));
470
- const afterNum = colA.rest;
471
- const multi = parseMultiCoords(afterNum);
472
- let positions;
473
- let text2;
474
- if (multi) {
475
- positions = multi.tuples.map((t) => pos(t.a, t.b));
476
- text2 = multi.rest.trim();
477
- } else {
478
- const coords = parseCoords(afterNum);
479
- if (!coords) {
480
- rawPassthrough.push(line);
481
- break;
482
- }
483
- positions = [pos(coords.a, coords.b)];
484
- text2 = stripCoords(afterNum).trim();
485
- }
486
- const annotation = {
487
- id: ids.alloc("anno", String(number)),
488
- elementType: "annotation",
489
- label: text2,
490
- position: positions[0],
491
- number,
492
- positions,
493
- text: text2,
494
- ...colA.color ? { color: colA.color } : {}
495
- };
496
- elements.push(annotation);
497
- break;
498
- }
499
- case "line": {
500
- const col = parseColor(after);
501
- const multi = parseMultiCoords(col.rest);
502
- if (!multi || multi.tuples.length < 2) {
503
- failed(line);
504
- break;
505
- }
506
- const flags = multi.rest.toLowerCase();
507
- const strokeStyle = flags.includes("(dashed)") ? "dashed" : flags.includes("(dotted)") ? "dotted" : void 0;
508
- const points = multi.tuples.map((t) => pos(t.a, t.b));
509
- const drawing = {
510
- id: ids.alloc("draw", "line"),
511
- elementType: "drawing",
512
- label: "",
513
- position: points[0],
514
- points,
515
- ...flags.includes("(closed)") ? { closed: true } : {},
516
- ...strokeStyle ? { strokeStyle } : {},
517
- ...col.color ? { color: col.color } : {}
518
- };
519
- elements.push(drawing);
520
- break;
521
- }
764
+ case "ecosystem":
765
+ if (!this.pushComponent(after, kw)) this.failed(line);
766
+ return;
767
+ case "note":
768
+ return this.readNote(line, after);
769
+ case "pipeline":
770
+ return this.readPipeline(line, after);
771
+ case "evolve":
772
+ return this.readEvolve(line, after);
773
+ case "style":
774
+ return this.readStyle(line, after);
775
+ case "size":
776
+ return this.readSize(line, after);
777
+ case "evolution":
778
+ return this.readEvolution(line, after);
779
+ case "y-axis":
780
+ return this.readYAxis(line, after);
781
+ case "annotations":
782
+ return this.readAnnotationsBox(line, after);
783
+ case "annotation":
784
+ return this.readAnnotation(line, after);
785
+ case "line":
786
+ return this.readDrawing(line, after);
522
787
  case "pioneers":
523
788
  case "settlers":
524
- case "townplanners": {
525
- const col = parseColor(after);
526
- const four = parseCoords4(col.rest);
527
- if (!four) {
528
- failed(line);
529
- break;
530
- }
531
- elements.push(
532
- makeAttitude(ids, kw, four.a, four.b, four.c, four.d, col.color)
533
- );
534
- break;
535
- }
536
- case "url": {
537
- const open = after.indexOf("[");
538
- const close = after.lastIndexOf("]");
539
- const name = open > 0 ? after.slice(0, open).trim() : "";
540
- const address = open > 0 && close > open ? after.slice(open + 1, close).trim() : "";
541
- if (!name || !address || after.slice(close + 1).trim() !== "") {
542
- failed(line);
543
- break;
544
- }
545
- urlDefs.set(name, address);
546
- break;
547
- }
789
+ case "townplanners":
790
+ return this.readAttitude(line, after, kw);
791
+ case "url":
792
+ return this.readUrlDefinition(line, after);
548
793
  case "build":
549
794
  case "buy":
550
- case "outsource": {
551
- const node = parseNode(after);
552
- if (node) {
553
- const id = ids.alloc("cmp", node.name);
554
- const component = compact({
555
- id,
556
- elementType: "component",
557
- label: node.name,
558
- position: pos(node.coords.a, node.coords.b),
559
- labelOffset: node.labelOffset,
560
- decorators: { ...node.decorators, method: kw },
561
- color: node.color
562
- });
563
- elements.push(component);
564
- register(node.name, id);
565
- } else if (after.trim()) {
566
- pendingMethod.push({ name: after.trim(), method: kw, raw: line, lineNo });
567
- } else {
568
- rawPassthrough.push(line);
569
- }
570
- break;
571
- }
795
+ case "outsource":
796
+ return this.readMethod(line, after, kw);
572
797
  case "accelerator":
573
- case "deaccelerator": {
574
- const node = parseNode(after);
575
- if (!node) {
576
- failed(line);
577
- break;
578
- }
579
- const id = ids.alloc("accel", node.name);
580
- const accelerator = {
581
- id,
582
- elementType: "accelerator",
583
- direction: kw === "deaccelerator" ? "deaccelerate" : "accelerate",
584
- label: node.name,
585
- position: pos(node.coords.a, node.coords.b),
586
- ...node.color ? { color: node.color } : {}
587
- };
588
- elements.push(accelerator);
589
- register(node.name, id);
590
- break;
591
- }
592
- case "submap": {
593
- const node = parseNode(after);
594
- if (!node) {
595
- failed(line);
596
- break;
597
- }
598
- const id = ids.alloc("submap", node.name);
599
- const submap = {
600
- id,
601
- elementType: "submap",
602
- label: node.name,
603
- position: pos(node.coords.a, node.coords.b),
604
- ...node.color ? { color: node.color } : {}
605
- };
606
- elements.push(submap);
607
- if (node.urlRef) pendingUrlRefs.push({ index: elements.length - 1, ref: node.urlRef });
608
- register(node.name, id);
609
- break;
610
- }
798
+ case "deaccelerator":
799
+ return this.readAccelerator(line, after, kw);
800
+ case "submap":
801
+ return this.readSubmap(line, after);
611
802
  default: {
612
- if (!pushLink(line)) rawPassthrough.push(line);
803
+ const link = readLink(line, this.lineNo, this.currentLine);
804
+ if (link) this.pendingLinks.push(link);
805
+ else this.keep(line, unknownStatementMessage(line));
613
806
  }
614
807
  }
615
808
  }
616
- for (const p of pendingPipeline) {
617
- const refId = nameToId.get(p.name);
618
- const ref = elements.find((e) => e.id === refId);
619
- const visibility = p.visibility ?? (ref ? ref.position.visibility : 0.5);
620
- const id = ids.alloc("pipeline", p.name);
621
- register(p.name, id);
622
- const childIds = [];
623
- const childElements = [];
624
- for (const c of p.children) {
625
- const cid = ids.alloc("cmp", c.name);
626
- childElements.push(
627
- compact({
628
- id: cid,
629
- elementType: "component",
630
- label: c.name,
631
- position: { visibility, evolution: clamp01(c.maturity) },
632
- labelOffset: c.labelOffset,
633
- decorators: Object.keys(c.decorators).length ? c.decorators : void 0,
634
- pipelineId: id,
635
- color: c.color
636
- })
637
- );
638
- register(c.name, cid);
639
- childIds.push(cid);
809
+ readBlockChild(after) {
810
+ const blockPipeline = this.blockPipeline;
811
+ const child = parseBlockChild(after, this.report);
812
+ if (child) {
813
+ blockPipeline.children.push({
814
+ ...child,
815
+ maturity: this.clamp(child.maturity),
816
+ lineNo: this.lineNo,
817
+ text: this.currentLine
818
+ });
819
+ return true;
640
820
  }
641
- let start = p.start ?? (p.children.length ? Math.min(...p.children.map((c) => c.maturity)) : NaN);
642
- let end = p.end ?? (p.children.length ? Math.max(...p.children.map((c) => c.maturity)) : NaN);
643
- if (Number.isNaN(start) || Number.isNaN(end)) {
644
- rawPassthrough.push(p.raw);
645
- continue;
821
+ if (splitAtCoords(after)?.name) {
822
+ this.diag(
823
+ `Component with [visibility, maturity] inside the block of pipeline "${blockPipeline.name}" is not a child \u2014 use [maturity]`
824
+ );
646
825
  }
647
- start = clamp01(start);
648
- end = clamp01(end);
649
- if (end <= start) end = Math.min(1, start + 0.05);
650
- const pipeline = {
826
+ return false;
827
+ }
828
+ readAnchor(line, after) {
829
+ const node = parseNode(after, ANCHOR_SUFFIXES, this.report);
830
+ if (!node) return this.failed(line);
831
+ const label = this.nameElement(node.name, "anchor");
832
+ const id = this.ids.alloc("anchor", label);
833
+ const anchor = compact({
651
834
  id,
652
- elementType: "pipeline",
653
- label: p.name,
654
- position: { visibility, evolution: (start + end) / 2 },
655
- evolutionStart: start,
656
- evolutionEnd: end,
657
- childIds,
658
- ...p.color ? { color: p.color } : {}
835
+ elementType: "anchor",
836
+ label,
837
+ position: this.position(node.coords.a, node.coords.b),
838
+ labelOffset: node.labelOffset,
839
+ color: node.color
840
+ });
841
+ this.elements.push(anchor);
842
+ this.register(node.name, label, id);
843
+ }
844
+ /** `component`/`market`/`ecosystem` and `build`/`buy`/`outsource` lines with coordinates. */
845
+ pushComponent(after, kw) {
846
+ const keywordMethod = METHODS.has(kw) ? kw : void 0;
847
+ const node = parseNode(after, COMPONENT_SUFFIXES, this.report, keywordMethod);
848
+ if (!node) return false;
849
+ const decorators = mergeLegacy(kw, node.decorators);
850
+ const label = this.nameElement(node.name, "component");
851
+ const id = this.ids.alloc("cmp", label);
852
+ const component = compact({
853
+ id,
854
+ elementType: "component",
855
+ label,
856
+ position: this.position(node.coords.a, node.coords.b),
857
+ labelOffset: node.labelOffset,
858
+ decorators: Object.keys(decorators).length ? decorators : void 0,
859
+ color: node.color
860
+ });
861
+ this.elements.push(component);
862
+ if (node.urlRef) this.addUrlRef(node.urlRef);
863
+ this.register(node.name, label, id);
864
+ return true;
865
+ }
866
+ readNote(line, after) {
867
+ const split = splitAtCoords(after);
868
+ if (!split) return this.failed(line);
869
+ const { color } = readSuffix(split.suffix, NOTE_SUFFIXES, this.report);
870
+ this.reportMisplacedSuffix(
871
+ splitTrailingGroups(split.name, { color: true, height: false }).groups,
872
+ "before the coordinates is read as part of the text",
873
+ "write it after them"
874
+ );
875
+ const textPart = this.readText(split.name.replace(/\\n/g, "\n"));
876
+ const note = compact({
877
+ id: this.ids.alloc("note", textPart || "note"),
878
+ elementType: "note",
879
+ label: textPart,
880
+ position: this.position(split.coords.a, split.coords.b),
881
+ color
882
+ });
883
+ this.elements.push(note);
884
+ }
885
+ /** `pipeline X [s, e]`, `pipeline X` (block form, range from children) — optionally with `{` at the end. */
886
+ readPipeline(line, after) {
887
+ this.endUnclosedBlock();
888
+ let body = after;
889
+ let opensBlock = false;
890
+ while (body.endsWith("{")) {
891
+ if (opensBlock) this.diag('Ignored "{" \u2014 a block must directly follow a pipeline line');
892
+ opensBlock = true;
893
+ body = body.slice(0, -1).trim();
894
+ }
895
+ const { name, range, suffixText, misplaced } = splitPipelineLine(body);
896
+ if (!name) return this.failed(line);
897
+ if (range && !range.coords) this.diag(unreadableNumberMessage(range.tuple));
898
+ this.reportMisplacedSuffix(
899
+ misplaced,
900
+ "before the coordinates is read as part of the name",
901
+ "write it after them"
902
+ );
903
+ const notes = [];
904
+ const note = (message) => notes.push(message);
905
+ const suffix = readSuffix(suffixText, PIPELINE_SUFFIXES, note);
906
+ const coords = range?.coords;
907
+ const pending = {
908
+ name,
909
+ lineNo: this.lineNo,
910
+ text: this.currentLine,
911
+ ...coords ? { start: this.clamp(coords.a), end: this.clamp(coords.b) } : {},
912
+ ...suffix.height !== void 0 ? { visibility: roundAsWritten(this.clamp(suffix.height, note)) } : {},
913
+ ...suffix.color ? { color: suffix.color } : {},
914
+ notes,
915
+ raw: line.slice(0, line.length - after.length) + body,
916
+ children: []
659
917
  };
660
- elements.push(pipeline, ...childElements);
661
- }
662
- for (const ev of pendingEvolve) {
663
- const id = nameToId.get(ev.name);
664
- const idx = elements.findIndex((e) => e.id === id);
665
- if (idx < 0 || elements[idx].elementType !== "component") {
666
- rawPassthrough.push(ev.raw);
667
- diag(`evolve: component "${ev.name}" not found`, ev.lineNo, ev.raw);
668
- continue;
918
+ this.pendingPipeline.push(pending);
919
+ if (opensBlock) {
920
+ this.blockPipeline = pending;
921
+ this.blockOpening = { line: this.lineNo, text: this.currentLine };
922
+ } else this.lastPipeline = pending;
923
+ }
924
+ readEvolve(line, after) {
925
+ const notes = [];
926
+ const note = (message) => notes.push(message);
927
+ const evolve = parseEvolve(after, note);
928
+ if (!evolve) return this.failed(line);
929
+ this.pendingEvolve.push({
930
+ ...evolve,
931
+ target: this.clamp(evolve.target, note),
932
+ notes,
933
+ raw: line,
934
+ lineNo: this.lineNo,
935
+ text: this.currentLine
936
+ });
937
+ }
938
+ readStyle(line, after) {
939
+ const style = after.toLowerCase();
940
+ if (KNOWN_STYLES.has(style)) {
941
+ this.setConfig("style", { ...this.config, style });
942
+ } else this.failed(line);
943
+ }
944
+ readSize(line, after) {
945
+ const split = splitAtCoords(after);
946
+ const coords = split?.coords;
947
+ if (!coords || !isRoundTrippableNumber(coords.a) || !isRoundTrippableNumber(coords.b)) {
948
+ return this.failed(line);
669
949
  }
670
- const movement = compact({
671
- targetEvolution: ev.target,
672
- newLabel: ev.newLabel,
673
- method: ev.method,
674
- labelOffset: ev.labelOffset
950
+ this.reportTextBefore(split.name);
951
+ readSuffix(split.suffix, NO_SUFFIXES, this.report);
952
+ this.setConfig("size", { ...this.config, size: { width: coords.a, height: coords.b } });
953
+ }
954
+ readEvolution(line, after) {
955
+ const parts = after.split("->").map((s) => s.trim());
956
+ if (parts.length === 4) {
957
+ const [first, second, third, fourth] = parts.map((part) => this.readText(part));
958
+ const evolutionLabels = [first, second, third, fourth];
959
+ this.setConfig("evolution", { ...this.config, evolutionLabels });
960
+ } else {
961
+ const reason = `Evolution needs exactly four stages, found ${parts.length}`;
962
+ this.keepUnusedConfigLine("evolution", line, reason);
963
+ }
964
+ }
965
+ /** `y-axis Label->BottomLabel->TopLabel` — keeps the end labels losslessly too. */
966
+ readYAxis(line, after) {
967
+ const written = after.split("->").map((s) => s.trim());
968
+ const [label, bottom, top] = written.filter(Boolean).map((part) => this.readText(part));
969
+ if (!label) return this.keepUnusedConfigLine("y-axis", line, "y-axis has no label");
970
+ const { yAxisEndLabels: _replaced, ...otherConfig } = this.config;
971
+ const ends = bottom && top ? [bottom, top] : void 0;
972
+ this.setConfig("y-axis", {
973
+ ...otherConfig,
974
+ yAxisLabel: label,
975
+ ...ends ? { yAxisEndLabels: ends } : {}
675
976
  });
676
- elements[idx] = { ...elements[idx], movement };
677
- }
678
- for (const pm of pendingMethod) {
679
- const id = nameToId.get(pm.name);
680
- const idx = elements.findIndex((e) => e.id === id);
681
- const el = idx >= 0 ? elements[idx] : void 0;
682
- if (!el || el.elementType !== "component") {
683
- rawPassthrough.push(pm.raw);
684
- diag(`${pm.method}: component "${pm.name}" not found`, pm.lineNo, pm.raw);
685
- continue;
977
+ if (written.length !== 1 && !(written.length === 3 && written.every(Boolean))) {
978
+ const read = [label, ...ends ?? []].join("->");
979
+ this.diag(`y-axis takes "Label" or "Label->Bottom->Top" \u2014 read as "${read}"`);
686
980
  }
687
- elements[idx] = { ...el, decorators: { ...el.decorators, method: pm.method } };
688
- }
689
- const usedUrlDefs = /* @__PURE__ */ new Set();
690
- for (const { index, ref } of pendingUrlRefs) {
691
- const fromDef = urlDefs.get(ref);
692
- if (fromDef) usedUrlDefs.add(ref);
693
- const address = fromDef ?? (/^[a-z][\w+.-]*:\/\//i.test(ref) ? ref : void 0);
694
- if (!address) continue;
695
- const el = elements[index];
696
- if (el.elementType === "component") {
697
- elements[index] = { ...el, url: address };
698
- } else if (el.elementType === "submap") {
699
- elements[index] = { ...el, urlRef: address };
981
+ }
982
+ readAnnotationsBox(line, after) {
983
+ const split = splitAtCoords(after);
984
+ if (!split) {
985
+ return this.keep(
986
+ line,
987
+ "Annotations box has no position \u2014 not used; add [visibility, maturity]"
988
+ );
700
989
  }
990
+ this.reportTextBefore(split.name);
991
+ readSuffix(split.suffix, NO_SUFFIXES, this.report);
992
+ const annotationsBoxPosition = this.position(split.coords.a, split.coords.b);
993
+ this.setConfig("annotations", { ...this.config, annotationsBoxPosition });
701
994
  }
702
- for (const [name, address] of urlDefs) {
703
- if (!usedUrlDefs.has(name)) rawPassthrough.push(`url ${name} [${address}]`);
995
+ /** `annotation N <position> [(color …)] <text>`; the position is a tuple or a tuple list. */
996
+ readAnnotation(line, after) {
997
+ const numMatch = /^\s*(\d+)/.exec(after);
998
+ const number = numMatch ? Number(numMatch[1]) : ++this.annotationCounter;
999
+ if (!Number.isSafeInteger(number)) return this.failed(line);
1000
+ const found = parsePositions(after.replace(/^\s*\d+\s*/, ""));
1001
+ if (!found) {
1002
+ return this.keep(line, "Annotation has no position \u2014 not drawn; add [visibility, maturity]");
1003
+ }
1004
+ if (!found.tuples.length) return this.failed(line);
1005
+ for (const unreadable of found.unreadable) this.diag(unreadableNumberMessage(unreadable));
1006
+ this.reportTextBefore(found.before);
1007
+ const { color, rest } = splitLeadingColor(found.after);
1008
+ this.reportMisplacedSuffix(
1009
+ splitTrailingGroups(rest, { color: true, height: false }).groups,
1010
+ "after the annotation text is read as part of the text",
1011
+ "write it directly after the position"
1012
+ );
1013
+ const text = this.readText(rest.trim());
1014
+ const positions = found.tuples.map((t) => this.position(t.a, t.b));
1015
+ const annotation = {
1016
+ id: this.ids.alloc("anno", String(number)),
1017
+ elementType: "annotation",
1018
+ label: text,
1019
+ position: positions[0],
1020
+ number,
1021
+ positions,
1022
+ text,
1023
+ ...color ? { color } : {}
1024
+ };
1025
+ this.elements.push(annotation);
704
1026
  }
705
- let depN = 0;
706
- let flowN = 0;
707
- const edges = [];
708
- for (const link of pendingLinks) {
709
- const fromId = nameToId.get(link.left);
710
- const toId = nameToId.get(link.right);
711
- if (!fromId || !toId) {
712
- rawPassthrough.push(link.raw);
713
- diag(
714
- `Link: ${!fromId ? `"${link.left}"` : `"${link.right}"`} not found`,
715
- link.lineNo,
716
- link.raw
1027
+ /** Project extension (freeform drawing): `line [[v,e], [v,e], …] (closed) (dashed) (color x)`. */
1028
+ readDrawing(line, after) {
1029
+ const list = parseMultiCoords(after);
1030
+ if (!list || list.tuples.length < 2) return this.failed(line);
1031
+ for (const unreadable of list.unreadable) this.diag(unreadableNumberMessage(unreadable));
1032
+ this.reportTextBefore(list.before);
1033
+ const suffix = readSuffix(list.after, DRAWING_SUFFIXES, this.report);
1034
+ const points = list.tuples.map((t) => this.position(t.a, t.b));
1035
+ const drawing = {
1036
+ id: this.ids.alloc("draw", "line"),
1037
+ elementType: "drawing",
1038
+ label: "",
1039
+ position: points[0],
1040
+ points,
1041
+ ...suffix.closed ? { closed: true } : {},
1042
+ ...suffix.strokeStyle ? { strokeStyle: suffix.strokeStyle } : {},
1043
+ ...suffix.color ? { color: suffix.color } : {}
1044
+ };
1045
+ this.elements.push(drawing);
1046
+ }
1047
+ /** Canonical OWM form: `<kind> [vis1, mat1, vis2, mat2]` (two corners, normalized). */
1048
+ readAttitude(line, after, kind) {
1049
+ const split = splitAtCoords4(after);
1050
+ if (!split) return this.failed(line);
1051
+ this.reportTextBefore(split.before);
1052
+ const { color } = readSuffix(split.after, ATTITUDE_SUFFIXES, this.report);
1053
+ const { a, b, c, d } = split.coords;
1054
+ const corners = [this.clamp(a), this.clamp(b), this.clamp(c), this.clamp(d)];
1055
+ this.elements.push(makeAttitude(this.ids, kind, corners, color));
1056
+ }
1057
+ /** OWM: `url Name [https://…]` — definition, referenced via `url(Name)` on elements. */
1058
+ readUrlDefinition(line, after) {
1059
+ const definition = readUrlDefinition(after);
1060
+ if (!definition) return this.failed(line);
1061
+ this.urlDefinitions.push({ ...definition, lineNo: this.lineNo, text: this.currentLine });
1062
+ }
1063
+ /**
1064
+ * Legacy OWM: `buy <Name>` (method on an existing component) or `buy <Name> [vis, mat]` (create a
1065
+ * component with a method).
1066
+ */
1067
+ readMethod(line, after, kw) {
1068
+ if (this.pushComponent(after, kw)) return;
1069
+ if (!after) return this.keep(line, `${kw}: needs a component name \u2014 not used`);
1070
+ this.pendingMethod.push({
1071
+ name: after,
1072
+ method: kw,
1073
+ raw: line,
1074
+ lineNo: this.lineNo,
1075
+ text: this.currentLine
1076
+ });
1077
+ }
1078
+ readAccelerator(line, after, kw) {
1079
+ const node = parseNode(after, ACCELERATOR_SUFFIXES, this.report);
1080
+ if (!node) return this.failed(line);
1081
+ const label = this.nameElement(node.name, "accelerator");
1082
+ const id = this.ids.alloc("accel", label);
1083
+ const accelerator = {
1084
+ id,
1085
+ elementType: "accelerator",
1086
+ direction: kw === "deaccelerator" ? "deaccelerate" : "accelerate",
1087
+ label,
1088
+ position: this.position(node.coords.a, node.coords.b),
1089
+ ...node.color ? { color: node.color } : {}
1090
+ };
1091
+ this.elements.push(accelerator);
1092
+ this.register(node.name, label, id);
1093
+ }
1094
+ readSubmap(line, after) {
1095
+ const node = parseNode(after, SUBMAP_SUFFIXES, this.report);
1096
+ if (!node) return this.failed(line);
1097
+ const label = this.nameElement(node.name, "submap");
1098
+ const id = this.ids.alloc("submap", label);
1099
+ const submap = {
1100
+ id,
1101
+ elementType: "submap",
1102
+ label,
1103
+ position: this.position(node.coords.a, node.coords.b),
1104
+ ...node.color ? { color: node.color } : {}
1105
+ };
1106
+ this.elements.push(submap);
1107
+ if (node.urlRef) this.addUrlRef(node.urlRef);
1108
+ this.register(node.name, label, id);
1109
+ }
1110
+ finish() {
1111
+ const removedFromPassthrough = replacedConfigLines(
1112
+ this.unusedConfigLines,
1113
+ this.config,
1114
+ this.reportAt
1115
+ );
1116
+ this.resolvePipelines();
1117
+ const indexById = new Map(this.elements.map((el, index) => [el.id, index]));
1118
+ this.resolveEvolves(indexById);
1119
+ this.resolveMethods(indexById);
1120
+ const keptDefinitions = resolveUrlReferences(
1121
+ this.elements,
1122
+ this.pendingUrlRefs,
1123
+ this.urlDefinitions,
1124
+ this.reportAt
1125
+ );
1126
+ for (const definition of keptDefinitions) {
1127
+ this.keepLine(
1128
+ `url ${definition.name} [${definition.address}]`,
1129
+ definition.lineNo,
1130
+ definition.text
717
1131
  );
718
- continue;
719
1132
  }
720
- if (link.kind === "dependency") {
721
- edges.push(
722
- compact({
723
- id: `dep_${++depN}`,
724
- edgeType: "dependency",
725
- from: fromId,
726
- to: toId,
727
- label: link.label
728
- })
1133
+ const declaredNames = /* @__PURE__ */ new Set([...this.idBySourceName.keys(), ...this.idByElementName.keys()]);
1134
+ const edges = buildEdges(
1135
+ this.pendingLinks,
1136
+ (name) => this.idOfName(name),
1137
+ (link, missing) => {
1138
+ this.keepLine(link.raw, link.lineNo, link.text);
1139
+ this.diag(`Link: "${missing}" not found`, link.lineNo, link.text);
1140
+ },
1141
+ (link) => link.label && this.readText(link.label, link.lineNo, link.text)
1142
+ );
1143
+ const kept = this.passthrough.filter((_, index) => !removedFromPassthrough.has(index));
1144
+ const rawPassthrough = withoutClosersOfKeptComments(kept, this.reportAt);
1145
+ const map = compact({
1146
+ schemaVersion: wardleySchemaModel.CURRENT_SCHEMA_VERSION,
1147
+ config: this.config,
1148
+ elements: this.elements,
1149
+ edges,
1150
+ rawPassthrough: rawPassthrough.length ? rawPassthrough : void 0
1151
+ });
1152
+ return {
1153
+ result: { map: wardleySchemaModel.validateMap(map), diagnostics: this.diagnostics },
1154
+ declaredNames,
1155
+ keywordLinks: this.keywordLinks
1156
+ };
1157
+ }
1158
+ /**
1159
+ * A pipeline pairs with the component written under its name: it takes that component's height,
1160
+ * as the serializer assumes when it leaves out `(y …)`. Block children are not such components.
1161
+ */
1162
+ resolvePipelines() {
1163
+ const componentByName = /* @__PURE__ */ new Map();
1164
+ for (const el of this.elements) {
1165
+ if (el.elementType === "component") componentByName.set(el.label, el);
1166
+ }
1167
+ for (const p of this.pendingPipeline) {
1168
+ const label = writtenName(p.name, "pipeline");
1169
+ const component = componentByName.get(label);
1170
+ const writtenRange = pipelineRange(p);
1171
+ if (!writtenRange) {
1172
+ this.keepLine(p.raw, p.lineNo, p.text);
1173
+ if (!component) {
1174
+ this.diag(
1175
+ `Pipeline "${p.name}" has no range \u2014 not drawn; add [start, end] or child components`,
1176
+ p.lineNo,
1177
+ p.text
1178
+ );
1179
+ }
1180
+ continue;
1181
+ }
1182
+ for (const message of p.notes) this.diag(message, p.lineNo, p.text);
1183
+ if (label !== p.name) this.diag(renamedMessage(p.name, label), p.lineNo, p.text);
1184
+ const range = widenEmptyRange(writtenRange);
1185
+ const widened = range.start !== writtenRange.start || range.end !== writtenRange.end;
1186
+ if (widened && !writtenRange.fromChildren) {
1187
+ this.diag(
1188
+ `Pipeline "${p.name}" range is empty or reversed \u2014 changed to [${range.start}, ${range.end}]`,
1189
+ p.lineNo,
1190
+ p.text
1191
+ );
1192
+ }
1193
+ const visibility = p.visibility ?? component?.position.visibility ?? 0.5;
1194
+ const id = this.ids.alloc("pipeline", label);
1195
+ if (this.idOfName(p.name) === void 0 && this.idOfName(label) === void 0) {
1196
+ this.register(p.name, label, id);
1197
+ }
1198
+ const childNames = p.children.map(
1199
+ (child) => this.nameElement(child.name, "component", child.lineNo, child.text)
729
1200
  );
730
- } else {
731
- const [f, t] = link.reverse ? [toId, fromId] : [fromId, toId];
732
- edges.push(
733
- compact({
734
- id: `flow_${++flowN}`,
735
- edgeType: "flow",
736
- from: f,
737
- to: t,
738
- bidirectional: link.bidirectional ? true : void 0,
739
- flowValue: link.flowValue,
740
- label: link.label
741
- })
1201
+ const { pipeline, children } = pipelineElements(
1202
+ { ...p, name: label },
1203
+ id,
1204
+ visibility,
1205
+ range,
1206
+ this.ids,
1207
+ childNames
742
1208
  );
1209
+ this.elements.push(pipeline);
1210
+ children.forEach((child, index) => {
1211
+ this.elements.push(child);
1212
+ this.register(p.children[index].name, child.label, child.id);
1213
+ });
1214
+ }
1215
+ }
1216
+ componentIndex(name, indexById) {
1217
+ const index = indexById.get(this.idOfName(name) ?? "");
1218
+ return index !== void 0 && this.elements[index].elementType === "component" ? index : -1;
1219
+ }
1220
+ resolveEvolves(indexById) {
1221
+ const evolveLineOf = /* @__PURE__ */ new Map();
1222
+ for (const ev of this.pendingEvolve) {
1223
+ const index = this.componentIndex(ev.name, indexById);
1224
+ if (index < 0) {
1225
+ this.keepLine(ev.raw, ev.lineNo, ev.text);
1226
+ const misplaced = splitMisplacedEvolveSuffix(ev.name);
1227
+ const hint = misplaced && this.componentIndex(misplaced.name, indexById) >= 0 ? misplacedEvolveSuffixHint(misplaced) : "";
1228
+ this.diag(`evolve: component "${ev.name}" not found${hint}`, ev.lineNo, ev.text);
1229
+ continue;
1230
+ }
1231
+ for (const message of ev.notes) this.diag(message, ev.lineNo, ev.text);
1232
+ const component = this.elements[index];
1233
+ const earlier = evolveLineOf.get(component.id);
1234
+ if (earlier !== void 0) {
1235
+ const message = `Repeated evolve for "${ev.name}" \u2014 replaces the one on line ${earlier}`;
1236
+ this.diag(message, ev.lineNo, ev.text);
1237
+ }
1238
+ evolveLineOf.set(component.id, ev.lineNo);
1239
+ const movement = compact({
1240
+ targetEvolution: ev.target,
1241
+ newLabel: ev.newLabel && this.readText(ev.newLabel, ev.lineNo, ev.text),
1242
+ method: ev.method,
1243
+ labelOffset: ev.labelOffset
1244
+ });
1245
+ this.elements[index] = { ...component, movement };
743
1246
  }
744
1247
  }
745
- const map = compact({
746
- schemaVersion: wardleySchemaModel.CURRENT_SCHEMA_VERSION,
747
- config,
748
- elements,
749
- edges,
750
- rawPassthrough: rawPassthrough.length ? rawPassthrough : void 0
1248
+ /** Legacy standalone methods (`buy <name>`). */
1249
+ resolveMethods(indexById) {
1250
+ for (const pm of this.pendingMethod) {
1251
+ const index = this.componentIndex(pm.name, indexById);
1252
+ if (index < 0) {
1253
+ this.keepLine(pm.raw, pm.lineNo, pm.text);
1254
+ this.diag(`${pm.method}: component "${pm.name}" not found`, pm.lineNo, pm.text);
1255
+ continue;
1256
+ }
1257
+ const el = this.elements[index];
1258
+ const replaced = el.decorators?.method;
1259
+ if (replaced && replaced !== pm.method) {
1260
+ const message = `${pm.method}: "${pm.name}" already has the method "${replaced}" \u2014 replaced`;
1261
+ this.diag(message, pm.lineNo, pm.text);
1262
+ }
1263
+ this.elements[index] = { ...el, decorators: { ...el.decorators, method: pm.method } };
1264
+ }
1265
+ }
1266
+ };
1267
+ function renamedMessage(sourceName, name) {
1268
+ return `Name "${sourceName}" renamed to "${name}" \u2014 a name cannot hold link operators ("->", "+>", "+<", "+'"), comment markers or a leading "{"`;
1269
+ }
1270
+ function withoutTrailingCarriageReturns(line) {
1271
+ let end = line.length;
1272
+ while (end > 0 && line[end - 1] === "\r") end--;
1273
+ return line.slice(0, end);
1274
+ }
1275
+ function withoutClosersOfKeptComments(entries, report) {
1276
+ const nextParse = new CommentStripper();
1277
+ return entries.map((entry) => {
1278
+ let text = entry.text;
1279
+ if (!entry.isComment && nextParse.insideBlockComment && text.includes("*/")) {
1280
+ text = text.replaceAll("*/", " ");
1281
+ report(
1282
+ 'Ignored "*/" \u2014 this line is kept after an unclosed "/*" and would end that comment',
1283
+ entry.lineNo,
1284
+ entry.source
1285
+ );
1286
+ }
1287
+ nextParse.strip(text);
1288
+ return text;
751
1289
  });
752
- return { map: wardleySchemaModel.validateMap(map), diagnostics };
753
1290
  }
754
- function parseNode(after) {
1291
+ function replacedConfigLines(unusedLines, config, report) {
1292
+ const configIsSet = {
1293
+ evolution: config.evolutionLabels !== void 0,
1294
+ "y-axis": config.yAxisLabel !== void 0
1295
+ };
1296
+ const replacedIndexes = /* @__PURE__ */ new Set();
1297
+ for (const unused of unusedLines) {
1298
+ const replaced = configIsSet[unused.keyword];
1299
+ if (replaced) replacedIndexes.add(unused.index);
1300
+ const outcome = replaced ? `removed; another "${unused.keyword}" line sets it` : "not used";
1301
+ report(`${unused.reason} \u2014 ${outcome}`, unused.line, unused.text);
1302
+ }
1303
+ return replacedIndexes;
1304
+ }
1305
+ function resolveUrlReferences(elements, refs, definitions, report) {
1306
+ const definitionByName = /* @__PURE__ */ new Map();
1307
+ const replaced = /* @__PURE__ */ new Set();
1308
+ for (const definition of definitions) {
1309
+ const earlier = definitionByName.get(definition.name);
1310
+ if (earlier) {
1311
+ replaced.add(earlier);
1312
+ report(
1313
+ `Repeated url definition "${definition.name}" \u2014 replaces the one on line ${earlier.lineNo}, which is kept unused`,
1314
+ definition.lineNo,
1315
+ definition.text
1316
+ );
1317
+ }
1318
+ definitionByName.set(definition.name, definition);
1319
+ }
1320
+ const used = /* @__PURE__ */ new Set();
1321
+ for (const { index, ref, line, text } of refs) {
1322
+ const definition = definitionByName.get(ref);
1323
+ if (definition) used.add(definition);
1324
+ const address = definition?.address ?? (/^[a-z][\w+.-]*:\/\//i.test(ref) ? ref : void 0);
1325
+ if (!address) {
1326
+ report(`url(${ref}) has no matching "url ${ref} [address]" line \u2014 ignored`, line, text);
1327
+ continue;
1328
+ }
1329
+ const el = elements[index];
1330
+ if (el.elementType === "component") elements[index] = { ...el, url: address };
1331
+ else if (el.elementType === "submap") elements[index] = { ...el, urlRef: address };
1332
+ }
1333
+ return definitions.filter((definition) => replaced.has(definition) || !used.has(definition));
1334
+ }
1335
+ function parseNode(after, suffixes, report, keywordMethod) {
755
1336
  const split = splitAtCoords(after);
756
1337
  if (!split || !split.name) return null;
757
- const col = parseColor(split.suffix);
758
- const url = parseUrlRef(col.rest);
759
- const lo = parseLabelOffset(url.rest);
760
- const dec = parseDecorators(lo.rest);
1338
+ const suffix = readSuffix(split.suffix, suffixes, report, "after the coordinates", keywordMethod);
761
1339
  return compact({
762
1340
  name: split.name,
763
1341
  coords: split.coords,
764
- decorators: dec.decorators,
765
- labelOffset: lo.labelOffset ?? void 0,
766
- urlRef: url.urlRef ?? void 0,
767
- color: col.color ?? void 0
1342
+ decorators: suffix.decorators,
1343
+ labelOffset: suffix.labelOffset,
1344
+ urlRef: suffix.urlRef,
1345
+ color: suffix.color
768
1346
  });
769
1347
  }
770
- function clamp01(n) {
771
- return n < 0 ? 0 : n > 1 ? 1 : n;
1348
+ var COMPONENT_SUFFIXES = /* @__PURE__ */ new Set(["color", "decorators", "label", "url"]);
1349
+ var PIPELINE_CHILD_SUFFIXES = /* @__PURE__ */ new Set(["color", "decorators", "label"]);
1350
+ var ANCHOR_SUFFIXES = /* @__PURE__ */ new Set(["color", "label"]);
1351
+ var SUBMAP_SUFFIXES = /* @__PURE__ */ new Set(["color", "url"]);
1352
+ var ACCELERATOR_SUFFIXES = /* @__PURE__ */ new Set(["color"]);
1353
+ var NOTE_SUFFIXES = /* @__PURE__ */ new Set(["color"]);
1354
+ var ATTITUDE_SUFFIXES = /* @__PURE__ */ new Set(["color"]);
1355
+ var PIPELINE_SUFFIXES = /* @__PURE__ */ new Set(["color", "height"]);
1356
+ var DRAWING_SUFFIXES = /* @__PURE__ */ new Set(["color", "stroke"]);
1357
+ var EVOLVE_SUFFIXES = /* @__PURE__ */ new Set(["method"]);
1358
+ var NO_SUFFIXES = /* @__PURE__ */ new Set();
1359
+ function readSuffix(text, slots, report, place = "after the coordinates", keywordMethod) {
1360
+ const reading = {};
1361
+ const decorators = keywordMethod ? { method: keywordMethod } : {};
1362
+ const seen = /* @__PURE__ */ new Set();
1363
+ const ignored = [];
1364
+ const readGroupItem = (item) => {
1365
+ if (slots.has("decorators") && (item === "market" || item === "ecosystem" || item === "inertia")) {
1366
+ decorators[item] = true;
1367
+ return true;
1368
+ }
1369
+ if ((slots.has("decorators") || slots.has("method")) && METHODS.has(item)) {
1370
+ decorators.method ??= item;
1371
+ return decorators.method === item;
1372
+ }
1373
+ if (slots.has("stroke") && item === "closed") {
1374
+ reading.closed = true;
1375
+ return true;
1376
+ }
1377
+ if (slots.has("stroke") && (item === "dashed" || item === "dotted")) {
1378
+ reading.strokeStyle ??= item;
1379
+ return reading.strokeStyle === item;
1380
+ }
1381
+ return false;
1382
+ };
1383
+ const firstOf = (token, slot) => {
1384
+ if (!slots.has(slot) || seen.has(token.kind)) return false;
1385
+ seen.add(token.kind);
1386
+ return true;
1387
+ };
1388
+ for (const token of scanSuffix(text)) {
1389
+ switch (token.kind) {
1390
+ case "color":
1391
+ if (firstOf(token, "color")) reading.color = token.color;
1392
+ else ignored.push(token);
1393
+ break;
1394
+ case "height":
1395
+ if (firstOf(token, "height")) {
1396
+ const height = readUnclampedNumber(token.value);
1397
+ if (height === null) report(unreadableNumberMessage(token.text));
1398
+ else reading.height = height;
1399
+ } else ignored.push(token);
1400
+ break;
1401
+ case "label":
1402
+ if (firstOf(token, "label")) {
1403
+ const dx = readUnclampedNumber(token.dx);
1404
+ const dy = readUnclampedNumber(token.dy);
1405
+ if (dx === null || dy === null) report(unreadableNumberMessage(token.text));
1406
+ else reading.labelOffset = { dx, dy };
1407
+ } else ignored.push(token);
1408
+ break;
1409
+ case "url":
1410
+ if (token.ref && firstOf(token, "url")) reading.urlRef = token.ref;
1411
+ else ignored.push(token);
1412
+ break;
1413
+ case "inertia":
1414
+ if (slots.has("decorators")) decorators.inertia = true;
1415
+ else ignored.push(token);
1416
+ break;
1417
+ case "group": {
1418
+ const unfit = token.items.filter((item) => !readGroupItem(item.text.toLowerCase()));
1419
+ if (unfit.length === token.items.length) ignored.push(token);
1420
+ else for (const item of unfit) ignored.push(item);
1421
+ break;
1422
+ }
1423
+ case "word":
1424
+ ignored.push(token);
1425
+ }
1426
+ }
1427
+ if (ignored.length) report(`Ignored text ${place}: ${quotedSpans(text, ignored)}`);
1428
+ return compact({ ...reading, decorators });
772
1429
  }
773
- var SINGLE_COORD_RE = /\[\s*([-\d.]+)\s*\]/;
774
- function parseBlockChild(after) {
775
- const m = SINGLE_COORD_RE.exec(after);
776
- if (!m) return null;
777
- const maturity = Number(m[1]);
1430
+ function quotedSpans(text, spans) {
1431
+ const pieces = [];
1432
+ let start = spans[0].at;
1433
+ let end = start + spans[0].text.length;
1434
+ for (let i = 1; i < spans.length; i++) {
1435
+ const span = spans[i];
1436
+ if (text.slice(end, span.at).trim() !== "") {
1437
+ pieces.push(text.slice(start, end));
1438
+ start = span.at;
1439
+ }
1440
+ end = span.at + span.text.length;
1441
+ }
1442
+ pieces.push(text.slice(start, end));
1443
+ return pieces.map((piece) => `"${piece}"`).join(", ");
1444
+ }
1445
+ function splitPipelineLine(body) {
1446
+ const kinds = { color: true, height: true };
1447
+ const split = splitAtTuple(body);
1448
+ if (split) {
1449
+ return {
1450
+ name: split.before,
1451
+ range: { tuple: split.tuple, coords: split.coords },
1452
+ suffixText: split.after,
1453
+ misplaced: splitTrailingGroups(split.before, kinds).groups
1454
+ };
1455
+ }
1456
+ const { rest, groups } = splitTrailingGroups(body, kinds);
1457
+ return { name: rest, range: null, suffixText: groups.join(" "), misplaced: [] };
1458
+ }
1459
+ function unreadableNumberMessage(text) {
1460
+ return `Unreadable number in "${text}" \u2014 ignored`;
1461
+ }
1462
+ function pipelineRange(p) {
1463
+ if (p.start !== void 0 && p.end !== void 0) {
1464
+ return { start: roundAsWritten(p.start), end: roundAsWritten(p.end) };
1465
+ }
1466
+ const [first, ...others] = p.children;
1467
+ if (!first) return null;
1468
+ let start = first.maturity;
1469
+ let end = first.maturity;
1470
+ for (const child of others) {
1471
+ start = Math.min(start, child.maturity);
1472
+ end = Math.max(end, child.maturity);
1473
+ }
1474
+ return { start: roundAsWritten(start), end: roundAsWritten(end), fromChildren: true };
1475
+ }
1476
+ function widenEmptyRange({ start, end }) {
1477
+ if (end > start) return { start, end };
1478
+ const widenedStart = start >= 1 ? 0.95 : start;
1479
+ return { start: widenedStart, end: roundAsWritten(Math.min(1, widenedStart + 0.05)) };
1480
+ }
1481
+ function pipelineElements(p, id, visibility, range, ids, childNames) {
1482
+ const children = p.children.map(
1483
+ (c, index) => compact({
1484
+ id: ids.alloc("cmp", childNames[index]),
1485
+ elementType: "component",
1486
+ label: childNames[index],
1487
+ position: { visibility, evolution: c.maturity },
1488
+ labelOffset: c.labelOffset,
1489
+ decorators: Object.keys(c.decorators).length ? c.decorators : void 0,
1490
+ pipelineId: id,
1491
+ color: c.color
1492
+ })
1493
+ );
1494
+ const pipeline = {
1495
+ id,
1496
+ elementType: "pipeline",
1497
+ label: p.name,
1498
+ position: { visibility, evolution: (range.start + range.end) / 2 },
1499
+ evolutionStart: range.start,
1500
+ evolutionEnd: range.end,
1501
+ childIds: children.map((child) => child.id),
1502
+ ...p.color ? { color: p.color } : {}
1503
+ };
1504
+ return { pipeline, children };
1505
+ }
1506
+ function parseBlockChild(after, report) {
1507
+ const split = splitAtFirstChildTuple(after);
1508
+ if (!split?.before || split.values.length !== 1) return null;
1509
+ const maturity = Number(split.values[0]);
778
1510
  if (Number.isNaN(maturity)) return null;
779
- const name = after.slice(0, m.index).trim();
780
- if (!name) return null;
781
- const suffix = after.slice(m.index + m[0].length);
782
- const col = parseColor(suffix);
783
- const lo = parseLabelOffset(col.rest);
784
- const dec = parseDecorators(lo.rest);
1511
+ const suffix = readSuffix(split.after, PIPELINE_CHILD_SUFFIXES, report);
785
1512
  return compact({
786
- name,
1513
+ name: split.before,
787
1514
  maturity,
788
- decorators: dec.decorators,
789
- labelOffset: lo.labelOffset ?? void 0,
790
- color: col.color ?? void 0
1515
+ decorators: suffix.decorators,
1516
+ labelOffset: suffix.labelOffset,
1517
+ color: suffix.color
791
1518
  });
792
1519
  }
793
- function makeAttitude(ids, kind, v1, m1, v2, m2, color) {
1520
+ function makeAttitude(ids, kind, [v1, m1, v2, m2], color) {
794
1521
  return {
795
1522
  id: ids.alloc("attitude", kind),
796
1523
  elementType: "attitude",
@@ -798,12 +1525,12 @@ function makeAttitude(ids, kind, v1, m1, v2, m2, color) {
798
1525
  label: "",
799
1526
  ...color ? { color } : {},
800
1527
  position: {
801
- visibility: clamp01(Math.max(v1, v2)),
802
- evolution: clamp01(Math.min(m1, m2))
1528
+ visibility: Math.max(v1, v2),
1529
+ evolution: Math.min(m1, m2)
803
1530
  },
804
1531
  corner2: {
805
- visibility: clamp01(Math.min(v1, v2)),
806
- evolution: clamp01(Math.max(m1, m2))
1532
+ visibility: Math.min(v1, v2),
1533
+ evolution: Math.max(m1, m2)
807
1534
  }
808
1535
  };
809
1536
  }
@@ -811,18 +1538,36 @@ function mergeLegacy(kw, dec) {
811
1538
  const merged = { ...dec };
812
1539
  if (kw === "market") merged["market"] = true;
813
1540
  if (kw === "ecosystem") merged["ecosystem"] = true;
1541
+ if (METHODS.has(kw)) merged["method"] = kw;
814
1542
  return compact(merged);
815
1543
  }
816
- function parseEvolve(after) {
1544
+ var MISPLACED_EVOLVE_SUFFIX_RE = /(?:\((build|buy|outsource)\)|\binertia)$/i;
1545
+ function splitMisplacedEvolveSuffix(name) {
1546
+ const m = MISPLACED_EVOLVE_SUFFIX_RE.exec(name);
1547
+ if (!m) return null;
1548
+ const rest = name.slice(0, m.index).trimEnd();
1549
+ return rest ? { name: rest, suffix: m[0] } : null;
1550
+ }
1551
+ function misplacedEvolveSuffixHint({ name, suffix }) {
1552
+ return suffix.startsWith("(") ? ` \u2014 for component "${name}", write "${suffix}" after the target` : ` \u2014 for component "${name}", write "${suffix}" on its component line`;
1553
+ }
1554
+ var NUMBER_TOKEN_RE = /(^|\s)([-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[-+]?\d+)?)(?=\s|\(|$)/gi;
1555
+ function parseEvolve(after, report) {
817
1556
  const lo = parseLabelOffset(after);
818
- const dec = parseDecorators(lo.rest);
819
- const tokens = dec.rest.trim().split(/\s+/);
820
- if (tokens.length < 2) return null;
821
- const last = tokens[tokens.length - 1];
822
- const target = Number(last);
823
- if (Number.isNaN(target)) return null;
824
- const namePart = tokens.slice(0, -1).join(" ").trim();
825
- if (!namePart) return null;
1557
+ let targetMatch;
1558
+ for (const match of lo.rest.matchAll(NUMBER_TOKEN_RE)) targetMatch = match;
1559
+ if (!targetMatch) return null;
1560
+ const targetAt = targetMatch.index + targetMatch[1].length;
1561
+ const namePart = lo.rest.slice(0, targetAt).trim();
1562
+ const target = Number(targetMatch[2]);
1563
+ if (!namePart || !Number.isFinite(target)) return null;
1564
+ if (lo.unreadable) report(unreadableNumberMessage(lo.unreadable));
1565
+ const tail = readSuffix(
1566
+ lo.rest.slice(targetAt + targetMatch[2].length),
1567
+ EVOLVE_SUFFIXES,
1568
+ report,
1569
+ "after the evolve target"
1570
+ );
826
1571
  const renameIdx = namePart.indexOf("->");
827
1572
  const name = renameIdx >= 0 ? namePart.slice(0, renameIdx).trim() : namePart;
828
1573
  const newLabel = renameIdx >= 0 ? namePart.slice(renameIdx + 2).trim() : void 0;
@@ -830,7 +1575,7 @@ function parseEvolve(after) {
830
1575
  name,
831
1576
  newLabel: newLabel || void 0,
832
1577
  target,
833
- method: dec.decorators.method,
1578
+ method: tail.decorators.method,
834
1579
  labelOffset: lo.labelOffset ?? void 0
835
1580
  });
836
1581
  }
@@ -842,23 +1587,28 @@ function urlOf(el) {
842
1587
  return void 0;
843
1588
  }
844
1589
  function r(n) {
845
- return String(Math.round(n * 1e3) / 1e3);
846
- }
847
- var NAMED_TYPES = /* @__PURE__ */ new Set(["anchor", "component", "accelerator", "submap"]);
848
- function defaultName(type) {
849
- return type === "anchor" ? "User" : type === "accelerator" ? "Accelerator" : type === "submap" ? "Submap" : "Component";
1590
+ return String(roundAsWritten(n));
850
1591
  }
851
1592
  function uniqueNames(map) {
852
- const used = /* @__PURE__ */ new Set();
1593
+ const namesInUse = new UniqueNames();
853
1594
  const byId = /* @__PURE__ */ new Map();
854
1595
  for (const el of map.elements) {
855
- if (!NAMED_TYPES.has(el.elementType)) continue;
856
- const base = el.label.trim().replace(/->/g, "\u2192") || defaultName(el.elementType);
857
- let name = base;
858
- let i = 2;
859
- while (used.has(name)) name = `${base} ${i++}`;
860
- used.add(name);
861
- byId.set(el.id, name);
1596
+ if (NAMED_TYPES.has(el.elementType)) {
1597
+ byId.set(el.id, namesInUse.claim(writtenName(el.label, el.elementType)));
1598
+ } else if (el.elementType === "pipeline") {
1599
+ byId.set(el.id, writtenName(el.label, el.elementType));
1600
+ }
1601
+ }
1602
+ return byId;
1603
+ }
1604
+ function urlDefinitionNames(map, nameOf, keptDefinitionNames) {
1605
+ const namesInUse = new UniqueNames();
1606
+ for (const name of keptDefinitionNames) namesInUse.claim(name);
1607
+ const byId = /* @__PURE__ */ new Map();
1608
+ for (const el of map.elements) {
1609
+ if (!urlOf(el)) continue;
1610
+ const readable = nameOf(el).replace(/[()[\]<>]/g, " ").replace(/\s+/g, " ").trim();
1611
+ byId.set(el.id, namesInUse.claim(`${readable} URL`.trim()));
862
1612
  }
863
1613
  return byId;
864
1614
  }
@@ -879,30 +1629,75 @@ function decoratorSuffix(dec) {
879
1629
  if (dec.inertia) out += " inertia";
880
1630
  return out;
881
1631
  }
882
- function serializeDSL(map) {
883
- const lines = [];
884
- const names = uniqueNames(map);
885
- const nameOf = (el) => names.get(el.id) ?? el.label;
886
- lines.push(`title ${map.config.title}`);
887
- if (map.config.style) lines.push(`style ${map.config.style}`);
888
- if (map.config.size)
889
- lines.push(`size [${r(map.config.size.width)}, ${r(map.config.size.height)}]`);
890
- if (map.config.evolutionLabels) lines.push(`evolution ${map.config.evolutionLabels.join("->")}`);
891
- if (map.config.yAxisLabel) {
892
- const ends = map.config.yAxisEndLabels;
893
- lines.push(`y-axis ${map.config.yAxisLabel}${ends ? `->${ends[0]}->${ends[1]}` : ""}`);
894
- }
895
- if (map.config.annotationsBoxPosition) {
896
- const b = map.config.annotationsBoxPosition;
1632
+ function configLines(config) {
1633
+ const lines = [`title ${writtenText(config.title)}`];
1634
+ if (config.style) lines.push(`style ${config.style}`);
1635
+ if (config.size) lines.push(`size [${r(config.size.width)}, ${r(config.size.height)}]`);
1636
+ if (config.evolutionLabels) {
1637
+ lines.push(`evolution ${config.evolutionLabels.map(writtenText).join("->")}`);
1638
+ }
1639
+ if (config.yAxisLabel) {
1640
+ const ends = config.yAxisEndLabels;
1641
+ const endLabels = ends ? `->${writtenText(ends[0])}->${writtenText(ends[1])}` : "";
1642
+ lines.push(`y-axis ${writtenText(config.yAxisLabel)}${endLabels}`);
1643
+ }
1644
+ if (config.annotationsBoxPosition) {
1645
+ const b = config.annotationsBoxPosition;
897
1646
  lines.push(`annotations [${r(b.visibility)}, ${r(b.evolution)}]`);
898
1647
  }
899
- const urlDefNames = /* @__PURE__ */ new Map();
1648
+ return lines;
1649
+ }
1650
+ function passthroughLines(map) {
1651
+ const emitted = /* @__PURE__ */ new Set();
1652
+ if (map.config.evolutionLabels) emitted.add("evolution");
1653
+ if (map.config.yAxisLabel) emitted.add("y-axis");
1654
+ const comments = new CommentStripper();
1655
+ const lines = [];
1656
+ const urlDefinitionNames2 = [];
1657
+ for (const raw of map.rawPassthrough ?? []) {
1658
+ const code = comments.strip(raw).code;
1659
+ const keyword = keywordOf(code);
1660
+ if (emitted.has(keyword)) continue;
1661
+ lines.push(raw);
1662
+ const definition = keyword === "url" ? readUrlDefinition(code.trim().slice(3)) : null;
1663
+ if (definition) urlDefinitionNames2.push(definition.name);
1664
+ }
1665
+ return { lines, urlDefinitionNames: urlDefinitionNames2 };
1666
+ }
1667
+ function pipelineHeights(map, pipelineIds, nameOf) {
1668
+ const componentVisibilityByName = /* @__PURE__ */ new Map();
900
1669
  for (const el of map.elements) {
901
- const address = urlOf(el);
902
- if (!address) continue;
903
- const defName = `${nameOf(el)} URL`;
904
- urlDefNames.set(el.id, defName);
905
- lines.push(`url ${defName} [${address}]`);
1670
+ if (el.elementType !== "component") continue;
1671
+ const isChild = !!el.pipelineId && pipelineIds.has(el.pipelineId);
1672
+ if (!isChild) componentVisibilityByName.set(nameOf(el), el.position.visibility);
1673
+ }
1674
+ return (el) => {
1675
+ if (el.elementType !== "pipeline") return "";
1676
+ const derived = componentVisibilityByName.get(nameOf(el)) ?? 0.5;
1677
+ const written = r(el.position.visibility);
1678
+ return written !== r(derived) ? ` (y ${written})` : "";
1679
+ };
1680
+ }
1681
+ function edgeLines(map, nameOf) {
1682
+ const namesById = new Map(map.elements.map((el) => [el.id, nameOf(el)]));
1683
+ return map.edges.map((edge) => {
1684
+ const from = namesById.get(edge.from) ?? edge.from;
1685
+ const to = namesById.get(edge.to) ?? edge.to;
1686
+ const annotation = edge.label ? `; ${writtenText(edge.label)}` : "";
1687
+ if (edge.edgeType === "dependency") return `${from} -> ${to}${annotation}`;
1688
+ const op = edge.flowValue ? `+'${edge.flowValue}'${edge.bidirectional ? "<>" : ">"}` : edge.bidirectional ? "+<>" : "+>";
1689
+ return `${from} ${op} ${to}${annotation}`;
1690
+ });
1691
+ }
1692
+ function serializeDSL(map) {
1693
+ const names = uniqueNames(map);
1694
+ const nameOf = (el) => names.get(el.id) ?? writtenText(el.label);
1695
+ const passthrough = passthroughLines(map);
1696
+ const lines = configLines(map.config);
1697
+ const urlDefNames = urlDefinitionNames(map, nameOf, passthrough.urlDefinitionNames);
1698
+ for (const el of map.elements) {
1699
+ const defName = urlDefNames.get(el.id);
1700
+ if (defName) lines.push(`url ${defName} [${urlOf(el)}]`);
906
1701
  }
907
1702
  const urlSuffix = (el) => {
908
1703
  const def = urlDefNames.get(el.id);
@@ -920,61 +1715,28 @@ function serializeDSL(map) {
920
1715
  list.push(el);
921
1716
  childrenByPipeline.set(el.pipelineId, list);
922
1717
  }
923
- const isPipelineChild = (el) => el.elementType === "component" && !!el.pipelineId && pipelineIds.has(el.pipelineId);
1718
+ const pipelineHeight = pipelineHeights(map, pipelineIds, nameOf);
924
1719
  const evolveLines = [];
925
- const componentVisByLabel = /* @__PURE__ */ new Map();
926
- for (const el of map.elements) {
927
- if (el.elementType === "component" && !componentVisByLabel.has(el.label)) {
928
- componentVisByLabel.set(el.label, el.position.visibility);
929
- }
930
- }
931
- const pipelineYSuffix = (el) => {
932
- if (el.elementType !== "pipeline") return "";
933
- const derived = componentVisByLabel.get(el.label) ?? 0.5;
934
- return Math.abs(el.position.visibility - derived) > 5e-4 ? ` (y ${r(el.position.visibility)})` : "";
935
- };
936
1720
  for (const el of map.elements) {
937
1721
  if (el.elementType === "component" && el.movement) {
938
1722
  evolveLines.push(evolveLine(el, nameOf(el)));
939
1723
  }
940
- if (isPipelineChild(el)) continue;
941
- lines.push(elementLine(el, nameOf(el)) + pipelineYSuffix(el) + urlSuffix(el));
942
- if (el.elementType === "pipeline") {
943
- const kids = childrenByPipeline.get(el.id) ?? [];
944
- if (kids.length) {
945
- lines.push("{");
946
- for (const k of kids) {
947
- lines.push(
948
- ` component ${nameOf(k)} [${r(k.position.evolution)}]${decoratorSuffix(k.decorators)}${colorSuffix(k)}${offsetSuffix(k.labelOffset)}`
949
- );
950
- }
951
- lines.push("}");
952
- }
953
- }
954
- }
955
- for (const line of evolveLines) lines.push(line);
956
- const labelsById = new Map(map.elements.map((el) => [el.id, el.label]));
957
- for (const edge of map.edges) {
958
- const from = names.get(edge.from) ?? labelsById.get(edge.from) ?? edge.from;
959
- const to = names.get(edge.to) ?? labelsById.get(edge.to) ?? edge.to;
960
- const annotation = edge.label ? `; ${edge.label}` : "";
961
- if (edge.edgeType === "dependency") {
962
- lines.push(`${from} -> ${to}${annotation}`);
963
- } else {
964
- const op = edge.flowValue ? `+'${edge.flowValue}'${edge.bidirectional ? "<>" : ">"}` : edge.bidirectional ? "+<>" : "+>";
965
- lines.push(`${from} ${op} ${to}${annotation}`);
1724
+ if (el.elementType === "component" && el.pipelineId && pipelineIds.has(el.pipelineId)) {
1725
+ continue;
966
1726
  }
967
- }
968
- if (map.rawPassthrough) {
969
- const emitted = /* @__PURE__ */ new Set();
970
- if (map.config.evolutionLabels) emitted.add("evolution");
971
- if (map.config.yAxisLabel) emitted.add("y-axis");
972
- for (const raw of map.rawPassthrough) {
973
- if (emitted.has(raw.trim().split(/\s+/)[0])) continue;
974
- lines.push(raw);
1727
+ lines.push(elementLine(el, nameOf(el)) + pipelineHeight(el) + urlSuffix(el));
1728
+ const kids = childrenByPipeline.get(el.id) ?? [];
1729
+ if (kids.length) {
1730
+ lines.push("{");
1731
+ for (const k of kids) {
1732
+ lines.push(
1733
+ ` component ${nameOf(k)} [${r(k.position.evolution)}]${decoratorSuffix(k.decorators)}${colorSuffix(k)}${offsetSuffix(k.labelOffset)}`
1734
+ );
1735
+ }
1736
+ lines.push("}");
975
1737
  }
976
1738
  }
977
- return lines.join("\n") + "\n";
1739
+ return [...lines, ...evolveLines, ...edgeLines(map, nameOf), ...passthrough.lines, ""].join("\n");
978
1740
  }
979
1741
  function elementLine(el, name) {
980
1742
  const p = el.position;
@@ -984,14 +1746,14 @@ function elementLine(el, name) {
984
1746
  case "component":
985
1747
  return `component ${name} [${r(p.visibility)}, ${r(p.evolution)}]${decoratorSuffix(el.decorators)}${colorSuffix(el)}${offsetSuffix(el.labelOffset)}`;
986
1748
  case "note":
987
- return `note ${name.replace(/\n/g, "\\n")} [${r(p.visibility)}, ${r(p.evolution)}]${colorSuffix(el)}`;
1749
+ return `note ${writtenNoteText(el.label)} [${r(p.visibility)}, ${r(p.evolution)}]${colorSuffix(el)}`;
988
1750
  case "pipeline":
989
1751
  return `pipeline ${name} [${r(el.evolutionStart)}, ${r(el.evolutionEnd)}]${colorSuffix(el)}`;
990
1752
  case "submap":
991
1753
  return `submap ${name} [${r(p.visibility)}, ${r(p.evolution)}]${colorSuffix(el)}`;
992
1754
  case "annotation": {
993
1755
  const pos = el.positions.length > 1 ? `[${el.positions.map((q) => `[${r(q.visibility)}, ${r(q.evolution)}]`).join(", ")}]` : `[${r(p.visibility)}, ${r(p.evolution)}]`;
994
- return `annotation ${el.number} ${pos}${colorSuffix(el)} ${el.text}`;
1756
+ return `annotation ${el.number} ${pos}${colorSuffix(el)} ${writtenText(el.text)}`;
995
1757
  }
996
1758
  case "accelerator":
997
1759
  return `${el.direction === "deaccelerate" ? "deaccelerator" : "accelerator"} ${name} [${r(p.visibility)}, ${r(p.evolution)}]${colorSuffix(el)}`;
@@ -1007,7 +1769,7 @@ function elementLine(el, name) {
1007
1769
  }
1008
1770
  function evolveLine(el, name) {
1009
1771
  const mv = el.movement;
1010
- const label = mv.newLabel ? `${name}->${mv.newLabel}` : name;
1772
+ const label = mv.newLabel ? `${name}->${writtenText(mv.newLabel)}` : name;
1011
1773
  const method = mv.method ? ` (${mv.method})` : "";
1012
1774
  return `evolve ${label} ${r(mv.targetEvolution)}${method}${offsetSuffix(mv.labelOffset)}`;
1013
1775
  }