@markdstage/markdstage 3.1.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,27 @@
1
- const SVG_NS = "http://www.w3.org/2000/svg";
1
+ import { architectureContract } from "./architecture-contract.mjs";
2
+ import { architectureSnapshotToScene } from "./architecture-scene.mjs";
3
+ import { sceneToSvg, svgPrimitive } from "./scene-svg.mjs";
4
+ import {
5
+ ArchitectureError,
6
+ architectureCompatibilityWarnings,
7
+ architectureDiagnostic,
8
+ architectureFailureReport,
9
+ checkArchitectureReferences,
10
+ claimArchitectureId,
11
+ diagnosticLimit,
12
+ throwArchitectureDiagnostic,
13
+ unknownArchitectureField,
14
+ } from "./architecture-diagnostics.mjs";
15
+
2
16
  const DEFAULT_CANVAS = Object.freeze({ width: 1600, height: 900 });
3
- const DSL_VERSION = 1;
17
+ const DSL_VERSION = architectureContract.root.properties.version.const;
4
18
  const EMPTY_ARCHITECTURE_SOURCE = '{\n "version": 1,\n "elements": []\n}\n';
5
19
  const MAX_SOURCE_LENGTH = 64 * 1024;
6
- const MAX_ELEMENTS = 200;
20
+ const MAX_ELEMENTS = architectureContract.root.properties.elements.maxItems;
7
21
  const MAX_CONNECTORS = 100;
8
22
  const MAX_TOTAL_TEXT = 20_000;
9
23
  const MAX_DEPTH = 4;
10
- const MAX_POINTS = 12;
24
+ const MAX_POINTS = architectureContract.elements.connector.properties.points.maxItems;
11
25
  const CONNECTOR_ENDPOINT_GAP = 14;
12
26
  const MIN_ORTHOGONAL_ENDPOINT_SPAN = 8;
13
27
  const CONNECTOR_LANE_SPACING = 52;
@@ -90,28 +104,20 @@ const ROUTE_FALLBACK_REMEDIES = Object.freeze({
90
104
  "no-clean-candidate": "every candidate route is blocked by another element",
91
105
  });
92
106
 
93
- const ID_PATTERN = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/;
94
- const SHAPES = new Set([
95
- "rect",
96
- "rounded-rect",
97
- "ellipse",
98
- "diamond",
99
- "triangle",
100
- "hexagon",
101
- "parallelogram",
102
- ]);
103
- const ROUTINGS = new Set(["straight", "orthogonal", "polyline"]);
104
- const PORTS = new Set(["auto", "top", "right", "bottom", "left"]);
105
- const LABEL_LAYERS = new Set(["front", "behind"]);
106
- const LAYOUTS = new Set(["row", "column", "grid", "layered"]);
107
- const LAYOUT_DIRECTIONS = new Set(["down", "right"]);
108
- const IMAGE_FITS = new Set(["contain", "cover", "stretch"]);
107
+ const ID_PATTERN = new RegExp(architectureContract.definitions.identifier.pattern);
108
+ const SHAPES = new Set(architectureContract.elements.node.properties.shape.enum);
109
+ const ROUTINGS = new Set(architectureContract.elements.connector.properties.routing.enum);
110
+ const PORTS = new Set(architectureContract.elements.connector.properties.fromPort.enum);
111
+ const LABEL_LAYERS = new Set(architectureContract.elements.connector.properties.labelLayer.enum);
112
+ const LAYOUTS = new Set(architectureContract.definitions.layoutObject.properties.type.enum);
113
+ const LAYOUT_DIRECTIONS = new Set(architectureContract.definitions.layoutObject.properties.direction.enum);
114
+ const IMAGE_FITS = new Set(architectureContract.elements.image.properties.fit.enum);
109
115
  // Recursion limit while layered layout reads the connection graph. This runs
110
116
  // before MAX_DEPTH validation, so impose an independent cutoff for unvalidated input.
111
117
  const MAX_GRAPH_SCAN_DEPTH = 16;
112
118
  // Fixed number of barycenter sweeps for deterministic output.
113
119
  const LAYERED_ORDERING_SWEEPS = 4;
114
- // Canonical built-in icon catalog; both ICONS and rendering derive from this table.
120
+ // Rendering glyphs for the built-in icon names in the schema-derived contract.
115
121
  //
116
122
  // Naming: lowercase kebab-case (`^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$`) nouns for
117
123
  // general concepts, not product or vendor names. Names are public DSL vocabulary,
@@ -177,7 +183,7 @@ const ICON_SHAPES = Object.freeze({
177
183
  { tag: "path", attributes: { d: "m8.5 12 2.5 2.5 4.5-4.5" } },
178
184
  ],
179
185
  });
180
- const ICONS = new Set(Object.keys(ICON_SHAPES));
186
+ const ICONS = new Set(architectureContract.definitions.iconName.enum);
181
187
  // User-provided icons must be repository files under `assets/`. Rendering uses
182
188
  // <image href="/assets/...">, served through safeJoin by the `/assets/*` route
183
189
  // in extension.mjs or the test harness.
@@ -196,7 +202,7 @@ const ASSET_PATH_PATTERN = new RegExp(
196
202
  `^assets/(?:${ASSET_PATH_SEGMENT}/)*${ASSET_PATH_SEGMENT}\\.(?:${ASSET_EXTENSION_PATTERN})$`,
197
203
  );
198
204
  const ICON_ASSET_PATTERN = ASSET_PATH_PATTERN;
199
- const MAX_ASSET_REFERENCE = 200;
205
+ const MAX_ASSET_REFERENCE = architectureContract.definitions.assetPath.maxLength;
200
206
  const MAX_ICON_REFERENCE = MAX_ASSET_REFERENCE;
201
207
  const THEME_TOKENS = Object.freeze({
202
208
  accent: "var(--accent)",
@@ -222,97 +228,18 @@ function localAssetUrl(documentRef, path) {
222
228
  } catch (_) {}
223
229
  return `/${normalized}`;
224
230
  }
225
- const STYLE_KEYS = new Set([
226
- "fill",
227
- "stroke",
228
- "textColor",
229
- "strokeWidth",
230
- "fontSize",
231
- "opacity",
232
- "dash",
233
- "cornerRadius",
234
- ]);
235
- const LAYOUT_KEYS = new Set([
236
- "type",
237
- "gap",
238
- "rowGap",
239
- "columnGap",
240
- "padding",
241
- "columns",
242
- "direction",
243
- ]);
244
- const ELEMENT_KEYS = Object.freeze({
245
- node: new Set([
246
- "type",
247
- "id",
248
- "shape",
249
- "x",
250
- "y",
251
- "width",
252
- "height",
253
- "text",
254
- "icon",
255
- "ariaLabel",
256
- "z",
257
- "style",
258
- ]),
259
- group: new Set([
260
- "type",
261
- "id",
262
- "x",
263
- "y",
264
- "width",
265
- "height",
266
- "title",
267
- "ariaLabel",
268
- "layout",
269
- "z",
270
- "style",
271
- "children",
272
- ]),
273
- image: new Set([
274
- "type",
275
- "id",
276
- "src",
277
- "fit",
278
- "x",
279
- "y",
280
- "width",
281
- "height",
282
- "ariaLabel",
283
- "z",
284
- "style",
285
- ]),
286
- connector: new Set([
287
- "type",
288
- "from",
289
- "to",
290
- "fromPort",
291
- "toPort",
292
- "label",
293
- "labelLayer",
294
- "ariaLabel",
295
- "routing",
296
- "points",
297
- "arrow",
298
- "lane",
299
- "z",
300
- "style",
301
- ]),
302
- });
231
+ const STYLE_KEYS = new Set(Object.keys(architectureContract.definitions.style.properties));
232
+ const LAYOUT_KEYS = new Set(Object.keys(architectureContract.definitions.layoutObject.properties));
233
+ const ELEMENT_KEYS = Object.freeze(Object.fromEntries(
234
+ Object.entries(architectureContract.elements).map(([type, definition]) =>
235
+ [type, new Set(Object.keys(definition.properties))],
236
+ ),
237
+ ));
303
238
 
304
239
  let renderSequence = 0;
305
240
 
306
- class ArchitectureError extends Error {
307
- constructor(message) {
308
- super(message);
309
- this.name = "ArchitectureError";
310
- }
311
- }
312
-
313
- function fail(path, message, remedy) {
314
- const guidance = remedy ? `; ${remedy}` : "";
315
- throw new ArchitectureError(`${path}: ${message}${guidance}`);
241
+ function fail(path, message, remedy, details) {
242
+ throwArchitectureDiagnostic(architectureDiagnostic(path, message, remedy, details));
316
243
  }
317
244
 
318
245
  function describeValue(value) {
@@ -331,37 +258,42 @@ function isObject(value) {
331
258
  }
332
259
 
333
260
  function expectObject(value, path, remedy = "use a JSON object such as { }") {
334
- if (!isObject(value)) fail(path, "must be an object", remedy);
261
+ if (!isObject(value)) fail(path, "must be an object", remedy, { code: "invalid_type" });
335
262
  return value;
336
263
  }
337
264
 
338
265
  function rejectUnknownKeys(value, allowed, path) {
339
266
  for (const key of Object.keys(value)) {
340
267
  if (!allowed.has(key)) {
341
- fail(
342
- `${path}.${key}`,
343
- "is not supported",
344
- `remove it or use one of: ${[...allowed].join(", ")}`,
345
- );
268
+ throwArchitectureDiagnostic(unknownArchitectureField(value, key, allowed, path, value.type));
346
269
  }
347
270
  }
348
271
  }
349
272
 
350
- function numberIn(value, path, min, max, fallback, remedy) {
273
+ function numberIn(value, path, min, max, fallback, remedy, details) {
351
274
  const candidate = value === undefined ? fallback : value;
352
275
  if (typeof candidate !== "number" || !Number.isFinite(candidate)) {
353
276
  fail(
354
277
  path,
355
278
  "must be a finite number",
356
279
  remedy ?? `replace ${describeValue(candidate)} with a number between ${min} and ${max}`,
280
+ { code: candidate === undefined ? "missing_required" : "invalid_type" },
357
281
  );
358
282
  }
359
283
  if (candidate < min || candidate > max) {
360
- fail(path, `must be between ${min} and ${max}`, remedy ?? "adjust the value into that range");
284
+ const intrinsicExtentError = details?.category === "layout" &&
285
+ (candidate < architectureContract.definitions.extent.minimum ||
286
+ candidate > architectureContract.definitions.extent.maximum);
287
+ fail(path, `must be between ${min} and ${max}`, remedy ?? "adjust the value into that range",
288
+ { code: "out_of_range", ...(intrinsicExtentError ? {} : details) });
361
289
  }
362
290
  return candidate;
363
291
  }
364
292
 
293
+ function contractNumber(value, path, definition, fallback) {
294
+ return numberIn(value, path, definition.minimum, definition.maximum, fallback);
295
+ }
296
+
365
297
  function textValue(value, path, fallback = "", maxLength = 500, remedy) {
366
298
  const candidate = value === undefined ? fallback : value;
367
299
  if (typeof candidate !== "string") {
@@ -369,6 +301,7 @@ function textValue(value, path, fallback = "", maxLength = 500, remedy) {
369
301
  path,
370
302
  "must be a string",
371
303
  remedy ?? `replace ${describeValue(candidate)} with text in double quotes`,
304
+ { code: "invalid_type" },
372
305
  );
373
306
  }
374
307
  if (candidate.length > maxLength) {
@@ -376,6 +309,7 @@ function textValue(value, path, fallback = "", maxLength = 500, remedy) {
376
309
  path,
377
310
  `must be at most ${maxLength} characters`,
378
311
  remedy ?? `shorten it by ${candidate.length - maxLength} characters`,
312
+ { code: "text_limit" },
379
313
  );
380
314
  }
381
315
  return candidate;
@@ -387,6 +321,7 @@ function idValue(value, path) {
387
321
  path,
388
322
  "must start with a letter and contain only letters, numbers, '.', '_' or '-'",
389
323
  `replace ${describeValue(value)} with an id such as 'api-gateway' (64 characters or fewer)`,
324
+ { code: value === undefined ? "missing_required" : "invalid_identifier" },
390
325
  );
391
326
  }
392
327
  return value;
@@ -399,6 +334,7 @@ function enumValue(value, path, values, fallback) {
399
334
  path,
400
335
  `must be one of: ${[...values].join(", ")}`,
401
336
  `replace ${describeValue(candidate)} with one of them`,
337
+ { code: value === undefined && fallback === undefined ? "missing_required" : "invalid_value" },
402
338
  );
403
339
  }
404
340
  return candidate;
@@ -429,6 +365,7 @@ function assetPathValue(value, path) {
429
365
  `replace ${describeValue(candidate)} with a repository asset such as 'assets/images/diagram.png' (${ASSET_EXTENSIONS.map(
430
366
  (extension) => `.${extension}`,
431
367
  ).join(", ")} only; '..', 'data:' URIs and external URLs are rejected)`,
368
+ { code: value === undefined ? "missing_required" : "invalid_value" },
432
369
  );
433
370
  }
434
371
 
@@ -453,9 +390,10 @@ function colorValue(value, path, fallback) {
453
390
  }
454
391
 
455
392
  function normalizeStyle(value, path, defaults) {
393
+ const fields = architectureContract.definitions.style.properties;
456
394
  const style = value === undefined ? {} : expectObject(value, path);
457
395
  rejectUnknownKeys(style, STYLE_KEYS, path);
458
- const dash = textValue(style.dash, `${path}.dash`, defaults.dash || "", 40);
396
+ const dash = textValue(style.dash, `${path}.dash`, defaults.dash || "", fields.dash.maxLength);
459
397
  if (dash && !/^\d+(?:\.\d+)?(?:[ ,]+\d+(?:\.\d+)?)*$/.test(dash)) {
460
398
  fail(
461
399
  `${path}.dash`,
@@ -467,15 +405,14 @@ function normalizeStyle(value, path, defaults) {
467
405
  fill: colorValue(style.fill, `${path}.fill`, defaults.fill),
468
406
  stroke: colorValue(style.stroke, `${path}.stroke`, defaults.stroke),
469
407
  textColor: colorValue(style.textColor, `${path}.textColor`, defaults.textColor),
470
- strokeWidth: numberIn(style.strokeWidth, `${path}.strokeWidth`, 0.5, 20, defaults.strokeWidth),
471
- fontSize: numberIn(style.fontSize, `${path}.fontSize`, 8, 160, defaults.fontSize),
472
- opacity: numberIn(style.opacity, `${path}.opacity`, 0, 1, defaults.opacity ?? 1),
408
+ strokeWidth: contractNumber(style.strokeWidth, `${path}.strokeWidth`, fields.strokeWidth, defaults.strokeWidth),
409
+ fontSize: contractNumber(style.fontSize, `${path}.fontSize`, fields.fontSize, defaults.fontSize),
410
+ opacity: contractNumber(style.opacity, `${path}.opacity`, fields.opacity, defaults.opacity ?? 1),
473
411
  dash,
474
- cornerRadius: numberIn(
412
+ cornerRadius: contractNumber(
475
413
  style.cornerRadius,
476
414
  `${path}.cornerRadius`,
477
- 0,
478
- 200,
415
+ fields.cornerRadius,
479
416
  defaults.cornerRadius ?? 24,
480
417
  ),
481
418
  };
@@ -483,20 +420,21 @@ function normalizeStyle(value, path, defaults) {
483
420
 
484
421
  function normalizePoint(value, path, origin) {
485
422
  const point = expectObject(value, path);
486
- rejectUnknownKeys(point, new Set(["x", "y"]), path);
423
+ rejectUnknownKeys(point, new Set(Object.keys(architectureContract.definitions.point.properties)), path);
487
424
  return {
488
- x: origin.x + numberIn(point.x, `${path}.x`, -4000, 4000),
489
- y: origin.y + numberIn(point.y, `${path}.y`, -4000, 4000),
425
+ x: origin.x + contractNumber(point.x, `${path}.x`, architectureContract.definitions.coordinate),
426
+ y: origin.y + contractNumber(point.y, `${path}.y`, architectureContract.definitions.coordinate),
490
427
  };
491
428
  }
492
429
 
493
430
  function parseCanvas(value) {
494
431
  if (value === undefined) return { ...DEFAULT_CANVAS };
432
+ const fields = architectureContract.definitions.canvas.properties;
495
433
  const canvas = expectObject(value, "canvas");
496
- rejectUnknownKeys(canvas, new Set(["width", "height"]), "canvas");
434
+ rejectUnknownKeys(canvas, new Set(Object.keys(architectureContract.definitions.canvas.properties)), "canvas");
497
435
  return {
498
- width: numberIn(canvas.width, "canvas.width", 320, 4000, DEFAULT_CANVAS.width),
499
- height: numberIn(canvas.height, "canvas.height", 180, 4000, DEFAULT_CANVAS.height),
436
+ width: contractNumber(canvas.width, "canvas.width", fields.width, DEFAULT_CANVAS.width),
437
+ height: contractNumber(canvas.height, "canvas.height", fields.height, DEFAULT_CANVAS.height),
500
438
  };
501
439
  }
502
440
 
@@ -514,23 +452,25 @@ function parseLayout(value, path) {
514
452
  };
515
453
  }
516
454
  const layout = expectObject(value, path);
455
+ const fields = architectureContract.definitions.layoutObject.properties;
517
456
  rejectUnknownKeys(layout, LAYOUT_KEYS, path);
518
- const gap = numberIn(layout.gap, `${path}.gap`, 0, 240, 36);
457
+ const gap = contractNumber(layout.gap, `${path}.gap`, fields.gap, 36);
519
458
  const type = enumValue(layout.type, `${path}.type`, LAYOUTS);
520
459
  if (layout.direction !== undefined && type !== "layered") {
521
460
  fail(
522
461
  `${path}.direction`,
523
462
  "is only valid with layered layout",
524
463
  'set "type": "layered" or remove the direction',
464
+ { code: "invalid_condition" },
525
465
  );
526
466
  }
527
467
  return {
528
468
  type,
529
469
  gap,
530
- rowGap: numberIn(layout.rowGap, `${path}.rowGap`, 0, 240, gap),
531
- columnGap: numberIn(layout.columnGap, `${path}.columnGap`, 0, 240, gap),
532
- padding: numberIn(layout.padding, `${path}.padding`, 0, 400, 54),
533
- columns: Math.trunc(numberIn(layout.columns, `${path}.columns`, 1, 12, 3)),
470
+ rowGap: contractNumber(layout.rowGap, `${path}.rowGap`, fields.rowGap, gap),
471
+ columnGap: contractNumber(layout.columnGap, `${path}.columnGap`, fields.columnGap, gap),
472
+ padding: contractNumber(layout.padding, `${path}.padding`, fields.padding, 54),
473
+ columns: Math.trunc(contractNumber(layout.columns, `${path}.columns`, fields.columns, 3)),
534
474
  direction: enumValue(
535
475
  layout.direction,
536
476
  `${path}.direction`,
@@ -704,6 +644,7 @@ function layoutPlacements(children, group, layout, path, graphEdges = []) {
704
644
  `${path}.layout`,
705
645
  "padding and title leave no space for children",
706
646
  "reduce layout padding or increase the group width and height",
647
+ { code: "layout_fit", category: "layout" },
707
648
  );
708
649
  }
709
650
  const count = flowItems.length;
@@ -740,6 +681,7 @@ function layoutPlacements(children, group, layout, path, graphEdges = []) {
740
681
  `${path}.layout`,
741
682
  "children do not fit",
742
683
  "reduce layout gap/padding, enlarge the group, or move some children out",
684
+ { code: "layout_fit", category: "layout" },
743
685
  );
744
686
  }
745
687
  tracks.forEach((track, trackIndex) => {
@@ -767,18 +709,20 @@ function layoutPlacements(children, group, layout, path, graphEdges = []) {
767
709
  const width = numberIn(
768
710
  child.width,
769
711
  `${path}.children[${index}].width`,
770
- 1,
712
+ architectureContract.definitions.extent.minimum,
771
713
  cellWidth,
772
714
  defaultWidth,
773
715
  "the parent layout limits each cell; reduce the value, enlarge the group, or drop width to use the automatic size",
716
+ { code: "layout_extent", category: "layout" },
774
717
  );
775
718
  const height = numberIn(
776
719
  child.height,
777
720
  `${path}.children[${index}].height`,
778
- 1,
721
+ architectureContract.definitions.extent.minimum,
779
722
  cellHeight,
780
723
  defaultHeight,
781
724
  "the parent layout limits each cell; reduce the value, enlarge the group, or drop height to use the automatic size",
725
+ { code: "layout_extent", category: "layout" },
782
726
  );
783
727
  const cellX = vertical
784
728
  ? inner.x + offset + positionInTrack * (cellWidth + layout.columnGap)
@@ -814,12 +758,12 @@ function normalizeBox(element, origin, path, placement) {
814
758
  return {
815
759
  x:
816
760
  origin.x +
817
- (placement?.x ?? numberIn(element.x, `${path}.x`, -4000, 4000)),
761
+ (placement?.x ?? contractNumber(element.x, `${path}.x`, architectureContract.definitions.coordinate)),
818
762
  y:
819
763
  origin.y +
820
- (placement?.y ?? numberIn(element.y, `${path}.y`, -4000, 4000)),
821
- width: placement?.width ?? numberIn(element.width, `${path}.width`, 1, 4000),
822
- height: placement?.height ?? numberIn(element.height, `${path}.height`, 1, 4000),
764
+ (placement?.y ?? contractNumber(element.y, `${path}.y`, architectureContract.definitions.coordinate)),
765
+ width: placement?.width ?? contractNumber(element.width, `${path}.width`, architectureContract.definitions.extent),
766
+ height: placement?.height ?? contractNumber(element.height, `${path}.height`, architectureContract.definitions.extent),
823
767
  };
824
768
  }
825
769
 
@@ -833,12 +777,16 @@ function flattenElements(
833
777
  placements = new Map(),
834
778
  graphEdges = [],
835
779
  ) {
836
- if (!Array.isArray(rawElements)) fail(path, "must be an array", "use a JSON array such as [ ]");
780
+ if (!Array.isArray(rawElements)) {
781
+ fail(path, "must be an array", "use a JSON array such as [ ]",
782
+ { code: rawElements === undefined ? "missing_required" : "invalid_type" });
783
+ }
837
784
  if (depth > MAX_DEPTH) {
838
785
  fail(
839
786
  path,
840
787
  `nesting must not exceed ${MAX_DEPTH} levels`,
841
788
  "flatten the structure or split the diagram across slides",
789
+ { code: "nesting_limit" },
842
790
  );
843
791
  }
844
792
 
@@ -848,24 +796,29 @@ function flattenElements(
848
796
  "elements",
849
797
  `must contain at most ${MAX_ELEMENTS} items`,
850
798
  "split the diagram across multiple slides",
799
+ { code: "element_limit" },
851
800
  );
852
801
  }
853
802
  const elementPath = `${path}[${localIndex}]`;
854
803
  const element = expectObject(raw, elementPath);
855
804
  const type = textValue(element.type, `${elementPath}.type`, "", 20);
856
805
  if (!Object.prototype.hasOwnProperty.call(ELEMENT_KEYS, type)) {
806
+ const supported = Object.keys(ELEMENT_KEYS);
807
+ const choices = `${supported.slice(0, -1).join(", ")}, or ${supported.at(-1)}`;
857
808
  fail(
858
809
  `${elementPath}.type`,
859
- "must be node, group, image, or connector",
810
+ `must be ${choices}`,
860
811
  element.type === undefined
861
- ? 'add a "type" of node, group, image, or connector'
862
- : `replace ${describeValue(element.type)} with node, group, image, or connector`,
812
+ ? `add a "type" of ${choices}`
813
+ : `replace ${describeValue(element.type)} with ${choices}`,
814
+ { code: element.type === undefined ? "missing_required" : "invalid_value" },
863
815
  );
864
816
  }
865
817
  rejectUnknownKeys(element, ELEMENT_KEYS[type], elementPath);
818
+ const fields = architectureContract.elements[type].properties;
866
819
  const order = output.length;
867
820
  const defaultZ = type === "group" ? -50 : type === "connector" ? -10 : 0;
868
- const z = numberIn(element.z, `${elementPath}.z`, -100, 100, defaultZ);
821
+ const z = contractNumber(element.z, `${elementPath}.z`, fields.z, defaultZ);
869
822
 
870
823
  if (type === "connector") {
871
824
  const routing = enumValue(
@@ -880,6 +833,7 @@ function flattenElements(
880
833
  `${elementPath}.points`,
881
834
  `must be an array with at most ${MAX_POINTS} points`,
882
835
  "remove extra waypoints or split the connector into several connectors",
836
+ { code: Array.isArray(points) ? "item_limit" : "invalid_type" },
883
837
  );
884
838
  }
885
839
  if (routing !== "polyline" && points.length) {
@@ -887,6 +841,7 @@ function flattenElements(
887
841
  `${elementPath}.points`,
888
842
  "is only valid with polyline routing",
889
843
  'set "routing": "polyline" or remove the points',
844
+ { code: "invalid_condition" },
890
845
  );
891
846
  }
892
847
  if (element.arrow !== undefined && typeof element.arrow !== "boolean") {
@@ -894,6 +849,7 @@ function flattenElements(
894
849
  `${elementPath}.arrow`,
895
850
  "must be a boolean",
896
851
  `replace ${describeValue(element.arrow)} with true or false`,
852
+ { code: "invalid_type" },
897
853
  );
898
854
  }
899
855
  output.push({
@@ -902,14 +858,14 @@ function flattenElements(
902
858
  to: idValue(element.to, `${elementPath}.to`),
903
859
  fromPort: enumValue(element.fromPort, `${elementPath}.fromPort`, PORTS, "auto"),
904
860
  toPort: enumValue(element.toPort, `${elementPath}.toPort`, PORTS, "auto"),
905
- label: textValue(element.label, `${elementPath}.label`, "", 200),
861
+ label: textValue(element.label, `${elementPath}.label`, "", fields.label.maxLength),
906
862
  labelLayer: enumValue(
907
863
  element.labelLayer,
908
864
  `${elementPath}.labelLayer`,
909
865
  LABEL_LAYERS,
910
866
  "front",
911
867
  ),
912
- ariaLabel: textValue(element.ariaLabel, `${elementPath}.ariaLabel`, "", 300),
868
+ ariaLabel: textValue(element.ariaLabel, `${elementPath}.ariaLabel`, "", fields.ariaLabel.maxLength),
913
869
  routing,
914
870
  points: points.map((point, index) =>
915
871
  normalizePoint(point, `${elementPath}.points[${index}]`, origin),
@@ -918,7 +874,7 @@ function flattenElements(
918
874
  lane:
919
875
  element.lane === undefined
920
876
  ? null
921
- : Math.trunc(numberIn(element.lane, `${elementPath}.lane`, -12, 12)),
877
+ : Math.trunc(contractNumber(element.lane, `${elementPath}.lane`, fields.lane)),
922
878
  z,
923
879
  order,
924
880
  sourcePath: elementPath,
@@ -936,14 +892,7 @@ function flattenElements(
936
892
  }
937
893
 
938
894
  const id = idValue(element.id, `${elementPath}.id`);
939
- if (ids.has(id)) {
940
- fail(
941
- `${elementPath}.id`,
942
- `duplicates '${id}'`,
943
- "give every node, group, and image a unique id across the whole diagram",
944
- );
945
- }
946
- ids.add(id);
895
+ claimArchitectureId(ids, id, `${elementPath}.id`);
947
896
  const box = normalizeBox(element, origin, elementPath, placements.get(localIndex));
948
897
 
949
898
  if (type === "node") {
@@ -959,9 +908,9 @@ function flattenElements(
959
908
  id,
960
909
  shape,
961
910
  ...box,
962
- text: textValue(element.text, `${elementPath}.text`, "", 500),
911
+ text: textValue(element.text, `${elementPath}.text`, "", fields.text.maxLength),
963
912
  icon,
964
- ariaLabel: textValue(element.ariaLabel, `${elementPath}.ariaLabel`, "", 300),
913
+ ariaLabel: textValue(element.ariaLabel, `${elementPath}.ariaLabel`, "", fields.ariaLabel.maxLength),
965
914
  z,
966
915
  order,
967
916
  sourcePath: elementPath,
@@ -985,7 +934,7 @@ function flattenElements(
985
934
  ...box,
986
935
  src: assetPathValue(element.src, `${elementPath}.src`),
987
936
  fit: enumValue(element.fit, `${elementPath}.fit`, IMAGE_FITS, "contain"),
988
- ariaLabel: textValue(element.ariaLabel, `${elementPath}.ariaLabel`, "", 300),
937
+ ariaLabel: textValue(element.ariaLabel, `${elementPath}.ariaLabel`, "", fields.ariaLabel.maxLength),
989
938
  z,
990
939
  order,
991
940
  sourcePath: elementPath,
@@ -1002,6 +951,11 @@ function flattenElements(
1002
951
  return;
1003
952
  }
1004
953
 
954
+ if (type !== "group") {
955
+ fail(`${elementPath}.type`, "has no runtime implementation",
956
+ "use a supported element or update the runtime together with its contract",
957
+ { code: "unsupported_element" });
958
+ }
1005
959
  const groupStyle = normalizeStyle(element.style, `${elementPath}.style`, {
1006
960
  fill: "accentSoft",
1007
961
  stroke: "accentLine",
@@ -1016,8 +970,8 @@ function flattenElements(
1016
970
  type,
1017
971
  id,
1018
972
  ...box,
1019
- title: textValue(element.title, `${elementPath}.title`, "", 200),
1020
- ariaLabel: textValue(element.ariaLabel, `${elementPath}.ariaLabel`, "", 300),
973
+ title: textValue(element.title, `${elementPath}.title`, "", fields.title.maxLength),
974
+ ariaLabel: textValue(element.ariaLabel, `${elementPath}.ariaLabel`, "", fields.ariaLabel.maxLength),
1021
975
  layout: parseLayout(element.layout, `${elementPath}.layout`),
1022
976
  z,
1023
977
  order,
@@ -1026,6 +980,10 @@ function flattenElements(
1026
980
  };
1027
981
  output.push(group);
1028
982
  const children = element.children === undefined ? [] : element.children;
983
+ if (!Array.isArray(children)) {
984
+ fail(`${elementPath}.children`, "must be an array", "use a JSON array such as [ ]",
985
+ { code: "invalid_type" });
986
+ }
1029
987
  const childPlacements = layoutPlacements(
1030
988
  children,
1031
989
  group,
@@ -1096,6 +1054,7 @@ function assignConnectorLanes(elements) {
1096
1054
  `${connector.sourcePath}.lane`,
1097
1055
  `duplicates explicit lane ${connector.lane}`,
1098
1056
  "give overlapping connectors different lane values, or omit lane to assign them automatically",
1057
+ { code: "duplicate_lane", category: "layout" },
1099
1058
  );
1100
1059
  }
1101
1060
  });
@@ -1135,6 +1094,7 @@ function assignConnectorLanes(elements) {
1135
1094
  record.connector.sourcePath,
1136
1095
  "has no available connector lane",
1137
1096
  "reduce the number of connectors between the same elements, or set explicit lane values",
1097
+ { code: "lane_unavailable", category: "layout" },
1138
1098
  );
1139
1099
  }
1140
1100
  record.connector.lane = selected;
@@ -1208,29 +1168,35 @@ export function normalizeArchitectureSource(source) {
1208
1168
  return typeof source === "string" && source.trim() === "" ? EMPTY_ARCHITECTURE_SOURCE : source;
1209
1169
  }
1210
1170
 
1211
- export function parseArchitecture(source) {
1171
+ function readArchitectureSource(source) {
1212
1172
  if (typeof source !== "string") {
1213
- fail("diagram", "must be JSON text", "pass the fenced block contents as a string");
1173
+ fail("diagram", "must be JSON text", "pass the fenced block contents as a string",
1174
+ { code: "invalid_type", category: "json" });
1214
1175
  }
1215
1176
  if (source.length > MAX_SOURCE_LENGTH) {
1216
1177
  fail(
1217
1178
  "diagram",
1218
1179
  `must be at most ${MAX_SOURCE_LENGTH} characters`,
1219
1180
  "split the diagram across multiple slides",
1181
+ { code: "source_limit", category: "json" },
1220
1182
  );
1221
1183
  }
1222
1184
  const normalizedSource = normalizeArchitectureSource(source);
1223
- let raw;
1224
1185
  try {
1225
- raw = JSON.parse(normalizedSource);
1186
+ return JSON.parse(normalizedSource);
1226
1187
  } catch (error) {
1188
+ if (!(error instanceof SyntaxError)) throw error;
1227
1189
  const detail = error?.message ? ` (${error.message})` : "";
1228
1190
  fail(
1229
1191
  "diagram",
1230
1192
  `contains invalid JSON${detail}`,
1231
1193
  "check for trailing commas, unquoted keys, or missing braces",
1194
+ { code: "invalid_json", category: "json" },
1232
1195
  );
1233
1196
  }
1197
+ }
1198
+
1199
+ function parseArchitectureModel(raw, stages) {
1234
1200
  const root = expectObject(
1235
1201
  raw,
1236
1202
  "diagram",
@@ -1238,7 +1204,7 @@ export function parseArchitecture(source) {
1238
1204
  );
1239
1205
  rejectUnknownKeys(
1240
1206
  root,
1241
- new Set(["$schema", "version", "canvas", "title", "description", "elements"]),
1207
+ new Set(Object.keys(architectureContract.root.properties)),
1242
1208
  "diagram",
1243
1209
  );
1244
1210
  const version = numberIn(
@@ -1255,12 +1221,12 @@ export function parseArchitecture(source) {
1255
1221
  const model = {
1256
1222
  version,
1257
1223
  canvas: parseCanvas(root.canvas),
1258
- title: textValue(root.title, "title", "Architecture diagram", 200),
1224
+ title: textValue(root.title, "title", "Architecture diagram", architectureContract.root.properties.title.maxLength),
1259
1225
  description: textValue(
1260
1226
  root.description,
1261
1227
  "description",
1262
1228
  "Architecture diagram rendered from a constrained JSON DSL.",
1263
- 1000,
1229
+ architectureContract.root.properties.description.maxLength,
1264
1230
  ),
1265
1231
  elements: [],
1266
1232
  };
@@ -1275,40 +1241,14 @@ export function parseArchitecture(source) {
1275
1241
  new Map(),
1276
1242
  collectGraphEdges(root.elements),
1277
1243
  );
1278
- const connectable = new Set(
1279
- model.elements.filter((element) => element.type !== "connector").map((element) => element.id),
1280
- );
1281
- let connectorCount = 0;
1282
- model.elements.forEach((element) => {
1283
- if (element.type !== "connector") return;
1284
- connectorCount += 1;
1285
- if (!connectable.has(element.from)) {
1286
- fail(
1287
- `${element.sourcePath}.from`,
1288
- `references unknown element '${element.from}'`,
1289
- `add a node or group with id '${element.from}', or point the connector at an existing id`,
1290
- );
1291
- }
1292
- if (!connectable.has(element.to)) {
1293
- fail(
1294
- `${element.sourcePath}.to`,
1295
- `references unknown element '${element.to}'`,
1296
- `add a node or group with id '${element.to}', or point the connector at an existing id`,
1297
- );
1298
- }
1299
- if (element.from === element.to) {
1300
- fail(
1301
- element.sourcePath,
1302
- "self-referencing connectors are not supported",
1303
- "point the connector at a different element",
1304
- );
1305
- }
1306
- });
1244
+ stages.structure = "passed";
1245
+ const connectorCount = checkArchitectureReferences(model.elements, undefined, { checkIds: false });
1307
1246
  if (connectorCount > MAX_CONNECTORS) {
1308
1247
  fail(
1309
1248
  "elements",
1310
1249
  `must contain at most ${MAX_CONNECTORS} connectors`,
1311
1250
  "split the diagram across multiple slides",
1251
+ { code: "connector_limit", category: "semantic" },
1312
1252
  );
1313
1253
  }
1314
1254
  if (totalTextLength(model) > MAX_TOTAL_TEXT) {
@@ -1316,21 +1256,60 @@ export function parseArchitecture(source) {
1316
1256
  "diagram",
1317
1257
  `text content must be at most ${MAX_TOTAL_TEXT} characters`,
1318
1258
  "shorten node text, group titles, labels, and descriptions",
1259
+ { code: "total_text_limit", category: "semantic" },
1319
1260
  );
1320
1261
  }
1262
+ stages.semantic = "passed";
1321
1263
  assignConnectorLanes(model.elements);
1264
+ stages.layout = "passed";
1322
1265
  model.elements = model.elements
1323
1266
  .slice()
1324
1267
  .sort((left, right) => left.z - right.z || left.order - right.order);
1325
1268
  return model;
1326
1269
  }
1327
1270
 
1328
- function svgElement(documentRef, tag, attributes = {}) {
1329
- const element = documentRef.createElementNS(SVG_NS, tag);
1330
- for (const [name, value] of Object.entries(attributes)) {
1331
- if (value !== undefined && value !== "") element.setAttribute(name, String(value));
1271
+ export function validateArchitecture(source, { maxDiagnostics } = {}) {
1272
+ const limit = diagnosticLimit(maxDiagnostics);
1273
+ const stages = { json: "skipped", structure: "skipped", semantic: "skipped", layout: "skipped" };
1274
+ let raw;
1275
+ let model;
1276
+ try {
1277
+ raw = readArchitectureSource(source);
1278
+ stages.json = "passed";
1279
+ model = parseArchitectureModel(raw, stages);
1280
+ } catch (error) {
1281
+ if (!(error instanceof ArchitectureError) || !error.diagnostic) throw error;
1282
+ return architectureFailureReport(raw, error.diagnostic, {
1283
+ maxDiagnostics: limit,
1284
+ maxElements: MAX_ELEMENTS,
1285
+ maxDepth: MAX_DEPTH,
1286
+ stages,
1287
+ });
1332
1288
  }
1333
- return element;
1289
+ const warnings = architectureCompatibilityWarnings(raw);
1290
+ return {
1291
+ valid: true,
1292
+ complete: true,
1293
+ truncated: false,
1294
+ truncationReasons: [],
1295
+ stages,
1296
+ diagnostics: warnings.diagnostics,
1297
+ model,
1298
+ };
1299
+ }
1300
+
1301
+ export function parseArchitecture(source) {
1302
+ const validation = validateArchitecture(source);
1303
+ if (validation.valid) return validation.model;
1304
+ const diagnostic = validation.diagnostics[0];
1305
+ const error = new ArchitectureError(diagnostic.message, diagnostic);
1306
+ error.validation = validation;
1307
+ throw error;
1308
+ }
1309
+
1310
+ function svgElement(documentRef, tag, attributes = {}) {
1311
+ // Produce portable visual detail; scene-svg is the only SVG DOM writer.
1312
+ return svgPrimitive(tag, attributes);
1334
1313
  }
1335
1314
 
1336
1315
  function appendSvgTitle(documentRef, parent, text) {
@@ -3914,13 +3893,13 @@ export function architectureSemanticSnapshot(model) {
3914
3893
  * export. Coordinates remain in Architecture canvas units here; renderer.js
3915
3894
  * maps them through the rendered SVG viewBox into slide pixels.
3916
3895
  */
3917
- export function architecturePowerPointSnapshot(model, documentRef = globalThis.document) {
3896
+ export function architecturePowerPointSnapshot(model, documentRef = globalThis.document, routingPlan) {
3918
3897
  const lookup = new Map(
3919
3898
  model.elements
3920
3899
  .filter((element) => element.type !== "connector")
3921
3900
  .map((element) => [element.id, element]),
3922
3901
  );
3923
- const { routes, diagnostics } = planConnectorRoutes(model, lookup);
3902
+ const { routes, diagnostics } = routingPlan || planConnectorRoutes(model, lookup);
3924
3903
  const objects = [];
3925
3904
  const fallbacks = [];
3926
3905
  const icons = [];
@@ -4171,8 +4150,8 @@ export function renderArchitectureDiagram(
4171
4150
  .filter((element) => element.type !== "connector")
4172
4151
  .map((element) => [element.id, element]),
4173
4152
  );
4174
- const { routes: connectorRoutes, diagnostics: routingDiagnostics } =
4175
- planConnectorRoutes(model, lookup);
4153
+ const routingPlan = planConnectorRoutes(model, lookup);
4154
+ const { routes: connectorRoutes, diagnostics: routingDiagnostics } = routingPlan;
4176
4155
  model.elements.forEach((element, index) => {
4177
4156
  if (element.type === "image") {
4178
4157
  const clipPath = svgElement(documentRef, "clipPath", {
@@ -4214,16 +4193,21 @@ export function renderArchitectureDiagram(
4214
4193
  // Table for announcing connector endpoints by visible label; traverse elements only once.
4215
4194
  const endpointNames = endpointDisplayNames(model.elements);
4216
4195
  const frontLabels = [];
4196
+ const visuals = new Map();
4217
4197
 
4218
4198
  model.elements.forEach((element, index) => {
4219
4199
  if (element.type === "group") {
4220
- svg.appendChild(renderGroup(documentRef, element));
4200
+ const primitive = renderGroup(documentRef, element);
4201
+ visuals.set(`group:${element.order}`, primitive);
4202
+ svg.appendChild(primitive);
4221
4203
  } else if (element.type === "node") {
4222
- svg.appendChild(renderNode(documentRef, element));
4204
+ const primitive = renderNode(documentRef, element);
4205
+ visuals.set(`node:${element.order}`, primitive);
4206
+ svg.appendChild(primitive);
4223
4207
  } else if (element.type === "image") {
4224
- svg.appendChild(
4225
- renderImage(documentRef, element, `architecture-image-clip-${renderId}-${index}`),
4226
- );
4208
+ const primitive = renderImage(documentRef, element, `architecture-image-clip-${renderId}-${index}`);
4209
+ visuals.set(`image-picture:${element.order}`, primitive);
4210
+ svg.appendChild(primitive);
4227
4211
  } else {
4228
4212
  const rendered = renderConnector(
4229
4213
  documentRef,
@@ -4233,13 +4217,33 @@ export function renderArchitectureDiagram(
4233
4217
  endpointNames,
4234
4218
  model.canvas,
4235
4219
  );
4220
+ visuals.set(`connector:${element.order}`, rendered.group);
4236
4221
  svg.appendChild(rendered.group);
4237
- if (rendered.frontLabel) frontLabels.push(rendered.frontLabel);
4222
+ if (rendered.frontLabel) {
4223
+ visuals.set(`connector-label:${element.order}`, rendered.frontLabel);
4224
+ frontLabels.push(rendered.frontLabel);
4225
+ }
4238
4226
  }
4239
4227
  });
4240
4228
  frontLabels.forEach((label) => svg.appendChild(label));
4241
- wrapper.appendChild(svg);
4242
- const powerPointSnapshot = architecturePowerPointSnapshot(model, documentRef);
4229
+ const powerPointSnapshot = architecturePowerPointSnapshot(model, documentRef, routingPlan);
4230
+ const { scene } = architectureSnapshotToScene(powerPointSnapshot);
4231
+ const slots = new Map();
4232
+ scene.nodes.forEach((node, index) => {
4233
+ const source = node.meta?.architecture;
4234
+ const visual = visuals.get(`${source?.kind}:${source?.order}`);
4235
+ if (visual) {
4236
+ node.meta.svg = visual;
4237
+ slots.set(visual, index);
4238
+ } else {
4239
+ // Icons and back-layer labels are painted within their owning node/connector
4240
+ // so opacity, accessible names and editor hit targets remain a single subtree.
4241
+ node.meta = { ...node.meta, svgOwner: source?.order ?? index };
4242
+ }
4243
+ });
4244
+ svg.children = svg.children.map((child) => slots.has(child) ? { sceneNode: slots.get(child) } : child);
4245
+ scene.meta = { svgRoot: svg };
4246
+ wrapper.appendChild(sceneToSvg(scene, { document: documentRef, template: svg }));
4243
4247
  Object.defineProperty(wrapper, "__presentationPptxSnapshot", {
4244
4248
  value: powerPointSnapshot,
4245
4249
  enumerable: true,