@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.
- package/package.json +1 -1
- package/shared/README.md +63 -14
- package/shared/architecture-editor/editor.js +1 -1
- package/shared/architecture-reference.mjs +150 -0
- package/shared/architecture-validation.mjs +309 -0
- package/shared/markdstage-guide.mjs +62 -142
- package/shared/renderer/architecture-contract.mjs +13707 -0
- package/shared/renderer/architecture-diagnostics.mjs +426 -0
- package/shared/renderer/architecture.mjs +180 -199
- package/shared/renderer/renderer.js +676 -57
- package/shared/renderer/slides.css +19 -0
- package/shared/runtime/architecture-editor-server.mjs +2 -0
- package/shared/runtime/architecture-source.mjs +9 -1
- package/shared/runtime/browser.mjs +98 -7
- package/shared/runtime/output.mjs +232 -50
- package/shared/runtime/pptx-package.mjs +404 -67
- package/shared/schema/README.md +60 -5
- package/shared/schema/architecture-contract.mjs +355 -0
- package/shared/scripts/generate-architecture-contract.mjs +41 -0
- package/src/commands/validate.mjs +39 -3
- package/src/runtime.mjs +4 -0
- package/src/skills.mjs +6 -2
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
94
|
-
const SHAPES = new Set(
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
//
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 || "",
|
|
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:
|
|
471
|
-
fontSize:
|
|
472
|
-
opacity:
|
|
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:
|
|
411
|
+
cornerRadius: contractNumber(
|
|
475
412
|
style.cornerRadius,
|
|
476
413
|
`${path}.cornerRadius`,
|
|
477
|
-
|
|
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(
|
|
422
|
+
rejectUnknownKeys(point, new Set(Object.keys(architectureContract.definitions.point.properties)), path);
|
|
487
423
|
return {
|
|
488
|
-
x: origin.x +
|
|
489
|
-
y: origin.y +
|
|
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(
|
|
433
|
+
rejectUnknownKeys(canvas, new Set(Object.keys(architectureContract.definitions.canvas.properties)), "canvas");
|
|
497
434
|
return {
|
|
498
|
-
width:
|
|
499
|
-
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 =
|
|
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:
|
|
531
|
-
columnGap:
|
|
532
|
-
padding:
|
|
533
|
-
columns: Math.trunc(
|
|
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
|
-
|
|
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
|
-
|
|
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 ??
|
|
760
|
+
(placement?.x ?? contractNumber(element.x, `${path}.x`, architectureContract.definitions.coordinate)),
|
|
818
761
|
y:
|
|
819
762
|
origin.y +
|
|
820
|
-
(placement?.y ??
|
|
821
|
-
width: placement?.width ??
|
|
822
|
-
height: placement?.height ??
|
|
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))
|
|
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
|
-
|
|
809
|
+
`must be ${choices}`,
|
|
860
810
|
element.type === undefined
|
|
861
|
-
?
|
|
862
|
-
: `replace ${describeValue(element.type)} with
|
|
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 =
|
|
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`, "",
|
|
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`, "",
|
|
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(
|
|
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
|
-
|
|
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`, "",
|
|
910
|
+
text: textValue(element.text, `${elementPath}.text`, "", fields.text.maxLength),
|
|
963
911
|
icon,
|
|
964
|
-
ariaLabel: textValue(element.ariaLabel, `${elementPath}.ariaLabel`, "",
|
|
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`, "",
|
|
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`, "",
|
|
1020
|
-
ariaLabel: textValue(element.ariaLabel, `${elementPath}.ariaLabel`, "",
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
1279
|
-
|
|
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)) {
|