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