@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.
@@ -0,0 +1,426 @@
1
+ import { architectureContract } from "./architecture-contract.mjs";
2
+
3
+ const MAX_DIAGNOSTICS = 100;
4
+ const MAX_DIAGNOSTIC_WORK = 10_000;
5
+ const own = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
6
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
7
+ const escapePointer = (value) => String(value).replace(/~/g, "~0").replace(/\//g, "~1");
8
+
9
+ export function architecturePointer(path) {
10
+ const local = path.replace(/^diagram(?:\.|$)/, "");
11
+ if (!local) return "";
12
+ return `/${local.replace(/\[(\d+)\]/g, ".$1").split(".").map(escapePointer).join("/")}`;
13
+ }
14
+
15
+ export function architectureDiagnostic(path, message, remedy, details = {}) {
16
+ return {
17
+ code: details.code ?? "invalid_value",
18
+ category: details.category ?? "structure",
19
+ severity: details.severity ?? "error",
20
+ pointer: details.pointer ?? architecturePointer(path),
21
+ message: `${path}: ${message}${remedy ? `; ${remedy}` : ""}`,
22
+ suggestions: details.suggestions ?? (remedy
23
+ ? [{ action: "review", message: remedy, automatic: false }]
24
+ : []),
25
+ };
26
+ }
27
+
28
+ export class ArchitectureError extends Error {
29
+ constructor(message, diagnostic) {
30
+ super(message);
31
+ this.name = "ArchitectureError";
32
+ if (diagnostic) this.diagnostic = diagnostic;
33
+ }
34
+ }
35
+
36
+ export function throwArchitectureDiagnostic(diagnostic) {
37
+ throw new ArchitectureError(diagnostic.message, diagnostic);
38
+ }
39
+
40
+ export function unknownArchitectureField(value, key, allowed, path, type) {
41
+ const pointer = `${architecturePointer(path)}/${escapePointer(key)}`;
42
+ const replacement = type === "node" && (key === "label" || key === "subtitle")
43
+ ? "text"
44
+ : type === "group" && (key === "label" || key === "text")
45
+ ? "title"
46
+ : type === "connector" && key === "text"
47
+ ? "label"
48
+ : null;
49
+ const suggestions = [];
50
+ if (replacement && allowed.has(replacement)) {
51
+ const conflict = own(value, replacement);
52
+ const merge = key === "subtitle";
53
+ suggestions.push({
54
+ action: conflict || merge ? "review" : "rename",
55
+ from: pointer,
56
+ to: `${architecturePointer(path)}/${escapePointer(replacement)}`,
57
+ conflictsWithExistingValue: conflict,
58
+ message: [
59
+ ...(merge ? ["A multiline text value is not a separately styled subtitle. Review the intended presentation before combining text."] : []),
60
+ conflict
61
+ ? `${replacement} already exists. Decide how to preserve both values; do not overwrite it.`
62
+ : `${type} uses ${replacement} for its displayed text. Change it only after reviewing the intended value.`,
63
+ ].join(" "),
64
+ automatic: false,
65
+ });
66
+ } else {
67
+ suggestions.push({
68
+ action: "review",
69
+ pointer,
70
+ message: type === "connector" && key === "id"
71
+ ? "Connectors have no id. Review why this identifier was supplied before removing it."
72
+ : "This field is not part of the contract. Review its intended meaning before removing or replacing it.",
73
+ automatic: false,
74
+ });
75
+ }
76
+ return architectureDiagnostic(
77
+ `${path}.${key}`,
78
+ "is not supported",
79
+ `remove it or use one of: ${[...allowed].join(", ")}`,
80
+ { code: "unknown_field", pointer, suggestions },
81
+ );
82
+ }
83
+
84
+ export function claimArchitectureId(ids, id, path, report = throwArchitectureDiagnostic) {
85
+ if (ids.has(id)) {
86
+ report(architectureDiagnostic(
87
+ path,
88
+ `duplicates '${id}'`,
89
+ "give every node, group, and image a unique id across the whole diagram",
90
+ { code: "duplicate_id", category: "semantic" },
91
+ ));
92
+ } else {
93
+ ids.add(id);
94
+ }
95
+ }
96
+
97
+ export function checkArchitectureReferences(elements, report = throwArchitectureDiagnostic, { checkIds = true } = {}) {
98
+ const ids = new Set();
99
+ for (const element of elements) {
100
+ if (element.type === "connector") continue;
101
+ if (checkIds) claimArchitectureId(ids, element.id, `${element.sourcePath}.id`, report);
102
+ else ids.add(element.id);
103
+ }
104
+ let connectors = 0;
105
+ for (const element of elements) {
106
+ if (element.type !== "connector") continue;
107
+ connectors += 1;
108
+ for (const endpoint of ["from", "to"]) {
109
+ if (!ids.has(element[endpoint])) {
110
+ report(architectureDiagnostic(
111
+ `${element.sourcePath}.${endpoint}`,
112
+ `references unknown element '${element[endpoint]}'`,
113
+ `add a node or group with id '${element[endpoint]}', or point the connector at an existing id`,
114
+ { code: "undefined_reference", category: "semantic" },
115
+ ));
116
+ }
117
+ }
118
+ if (element.from === element.to) {
119
+ report(architectureDiagnostic(
120
+ element.sourcePath,
121
+ "self-referencing connectors are not supported",
122
+ "point the connector at a different element",
123
+ { code: "self_reference", category: "semantic" },
124
+ ));
125
+ }
126
+ }
127
+ return connectors;
128
+ }
129
+
130
+ export function diagnosticLimit(value = 50) {
131
+ if (!Number.isInteger(value) || value < 1 || value > MAX_DIAGNOSTICS) {
132
+ throw new TypeError(`maxDiagnostics must be an integer between 1 and ${MAX_DIAGNOSTICS}.`);
133
+ }
134
+ return value;
135
+ }
136
+
137
+ function collector(maxDiagnostics) {
138
+ const diagnostics = [];
139
+ const seen = new Set();
140
+ const truncationReasons = new Set();
141
+ let work = 0;
142
+ let truncated = false;
143
+ return {
144
+ diagnostics,
145
+ truncationReasons,
146
+ get truncated() { return truncated; },
147
+ step() {
148
+ work += 1;
149
+ if (work <= MAX_DIAGNOSTIC_WORK) return true;
150
+ truncated = true;
151
+ truncationReasons.add("maxWork");
152
+ return false;
153
+ },
154
+ stop(reason) {
155
+ truncated = true;
156
+ truncationReasons.add(reason);
157
+ },
158
+ add(diagnostic) {
159
+ // The normalizer's primary diagnostic wins over a less specific structural description.
160
+ const key = `${diagnostic.severity}:${diagnostic.category}:${diagnostic.pointer}`;
161
+ if (seen.has(key)) return;
162
+ seen.add(key);
163
+ if (diagnostics.length >= maxDiagnostics) {
164
+ truncated = true;
165
+ truncationReasons.add("maxDiagnostics");
166
+ return;
167
+ }
168
+ diagnostics.push(diagnostic);
169
+ },
170
+ };
171
+ }
172
+
173
+ function valueKindMatches(value, definition) {
174
+ const type = definition.type ?? (own(definition, "const") ? typeof definition.const : undefined);
175
+ if (Array.isArray(type)) return type.some((candidate) => valueKindMatches(value, { type: candidate }));
176
+ if (type === "null") return value === null;
177
+ if (type === "object") return isObject(value);
178
+ if (type === "array") return Array.isArray(value);
179
+ if (type === "integer") return Number.isInteger(value);
180
+ if (type) return typeof value === type;
181
+ if (definition.enum) return definition.enum.some((item) => typeof item === typeof value);
182
+ return true;
183
+ }
184
+
185
+ function conditionMatches(value, condition) {
186
+ if (typeof condition === "boolean") return condition;
187
+ if (!valueKindMatches(value, condition)) return false;
188
+ if (own(condition, "const") && value !== condition.const) return false;
189
+ if (condition.enum && !condition.enum.includes(value)) return false;
190
+ if (condition.not && conditionMatches(value, condition.not)) return false;
191
+ if (condition.allOf && !condition.allOf.every((item) => conditionMatches(value, item))) return false;
192
+ if (condition.anyOf && !condition.anyOf.some((item) => conditionMatches(value, item))) return false;
193
+ if (condition.required && (!isObject(value) || condition.required.some((key) => !own(value, key)))) return false;
194
+ if (condition.properties && isObject(value)) {
195
+ for (const [key, definition] of Object.entries(condition.properties)) {
196
+ if (own(value, key) && !conditionMatches(value[key], definition)) return false;
197
+ }
198
+ }
199
+ return true;
200
+ }
201
+
202
+ // This scan explains rejected input using the same derived structural vocabulary.
203
+ // It never decides acceptance or supplies repaired/default values to the normalizer.
204
+ function scanStructure(raw, result, { maxElements, maxDepth }) {
205
+ const entries = [];
206
+ let visitedElements = 0;
207
+ const emit = (path, message, remedy, code, details) =>
208
+ result.add(architectureDiagnostic(path, message, remedy, { code, ...details }));
209
+
210
+ function inspect(value, definition, path, { overlay = false, skip = new Set() } = {}) {
211
+ if (!result.step()) return;
212
+ if (!definition || typeof definition !== "object") return;
213
+ if (definition.anyOf) {
214
+ const alternatives = definition.anyOf.filter((item) => valueKindMatches(value, item));
215
+ const chosen = alternatives.find((item) =>
216
+ (!item.enum || item.enum.includes(value)) &&
217
+ (!item.pattern || (typeof value === "string" && new RegExp(item.pattern).test(value))),
218
+ ) ?? alternatives[0];
219
+ if (!chosen) {
220
+ emit(path, "has an unsupported value type", "use a value described by the authoring contract", "invalid_type");
221
+ return;
222
+ }
223
+ inspect(value, chosen, path);
224
+ return;
225
+ }
226
+ if (!valueKindMatches(value, definition)) {
227
+ emit(path, `must be ${definition.type === "object" ? "an object" : definition.type === "array" ? "an array" : `a ${definition.type ?? "supported value"}`}`,
228
+ "use the type shown in architecture-schema", "invalid_type");
229
+ return;
230
+ }
231
+ if (own(definition, "const") && value !== definition.const) {
232
+ emit(path, `must be ${JSON.stringify(definition.const)}`, "use the declared contract value", "invalid_value");
233
+ }
234
+ if (definition.enum && !definition.enum.includes(value)) {
235
+ emit(path, `must be one of: ${definition.enum.join(", ")}`, "replace the value with one of them", "invalid_value");
236
+ }
237
+ if (typeof value === "number") {
238
+ if (!Number.isFinite(value)) {
239
+ emit(path, "must be a finite number", "use a finite number in the documented range", "invalid_type");
240
+ } else if ((definition.minimum !== undefined && value < definition.minimum) ||
241
+ (definition.maximum !== undefined && value > definition.maximum)) {
242
+ emit(path, `must be between ${definition.minimum} and ${definition.maximum}`, "adjust the value into that range", "out_of_range");
243
+ }
244
+ }
245
+ if (typeof value === "string") {
246
+ if (definition.maxLength !== undefined && value.length > definition.maxLength) {
247
+ emit(path, `must be at most ${definition.maxLength} characters`, "shorten the text", "text_limit");
248
+ }
249
+ if (definition.pattern && !new RegExp(definition.pattern).test(value)) {
250
+ emit(path, "does not match the permitted format", "use the format shown in architecture-schema", "invalid_value");
251
+ }
252
+ }
253
+ if (Array.isArray(value)) {
254
+ if (definition.maxItems !== undefined && value.length > definition.maxItems) {
255
+ emit(path, `must contain at most ${definition.maxItems} items`, "reduce the item count", "item_limit");
256
+ }
257
+ if (definition.items && !skip.has("items")) {
258
+ for (let index = 0; index < value.length; index += 1) {
259
+ if (!result.step()) break;
260
+ inspect(value[index], definition.items, `${path}[${index}]`);
261
+ }
262
+ }
263
+ }
264
+ if (isObject(value) && definition.properties) {
265
+ if (!overlay) {
266
+ const allowed = new Set(Object.keys(definition.properties));
267
+ for (const key of Object.keys(value)) {
268
+ if (!result.step()) break;
269
+ if (!allowed.has(key)) result.add(unknownArchitectureField(value, key, allowed, path, value.type));
270
+ }
271
+ }
272
+ for (const key of definition.required ?? []) {
273
+ if (!own(value, key)) emit(`${path}.${key}`, "is required", "supply this field as described in architecture-schema", "missing_required");
274
+ }
275
+ for (const [key, property] of Object.entries(definition.properties)) {
276
+ if (skip.has(key) || !own(value, key)) continue;
277
+ inspect(value[key], property, path === "diagram" ? key : `${path}.${key}`);
278
+ }
279
+ }
280
+ for (const part of definition.allOf ?? []) inspect(value, part, path, { overlay: true, skip });
281
+ if (definition.if) {
282
+ const branch = conditionMatches(value, definition.if) ? definition.then : definition.else;
283
+ if (branch) inspect(value, branch, path, { overlay: true, skip });
284
+ }
285
+ if (definition.not && conditionMatches(value, definition.not)) {
286
+ for (const key of definition.not.required ?? []) {
287
+ emit(`${path}.${key}`, "is not permitted in this context", "remove it or use the matching layout/routing mode", "invalid_condition");
288
+ }
289
+ }
290
+ }
291
+
292
+ function elements(items, path, depth, flow) {
293
+ if (!Array.isArray(items)) {
294
+ emit(path, "must be an array", "use a JSON array such as [ ]", items === undefined ? "missing_required" : "invalid_type");
295
+ return;
296
+ }
297
+ if (depth > maxDepth) {
298
+ result.stop("maxDepth");
299
+ emit(path, `nesting must not exceed ${maxDepth} levels`, "flatten the structure", "nesting_limit");
300
+ return;
301
+ }
302
+ for (let index = 0; index < items.length; index += 1) {
303
+ if (!result.step()) break;
304
+ visitedElements += 1;
305
+ if (visitedElements > maxElements) {
306
+ result.stop("maxElements");
307
+ emit("elements", `must contain at most ${maxElements} items`, "split the diagram across multiple slides", "element_limit");
308
+ break;
309
+ }
310
+ const element = items[index];
311
+ const elementPath = `${path}[${index}]`;
312
+ if (!isObject(element)) {
313
+ emit(elementPath, "must be an object", "use a JSON object such as { }", "invalid_type");
314
+ continue;
315
+ }
316
+ const type = element.type;
317
+ if (typeof type !== "string" || !own(architectureContract.elements, type)) {
318
+ emit(`${elementPath}.type`, `must be one of: ${Object.keys(architectureContract.elements).join(", ")}`,
319
+ "supply a supported element type", type === undefined ? "missing_required" : "invalid_value");
320
+ continue;
321
+ }
322
+ const contract = architectureContract.elements[type];
323
+ const skip = new Set(type === "group" ? ["children"] : []);
324
+ if (flow && type !== "connector") {
325
+ skip.add("x");
326
+ skip.add("y");
327
+ }
328
+ inspect(element, {
329
+ ...contract,
330
+ type: "object",
331
+ required: contract.required[flow ? "flow" : "fixed"],
332
+ }, elementPath, { skip });
333
+ entries.push({
334
+ type,
335
+ id: element.id,
336
+ from: element.from,
337
+ to: element.to,
338
+ sourcePath: elementPath,
339
+ });
340
+ if (type === "group") {
341
+ elements(element.children === undefined ? [] : element.children, `${elementPath}.children`, depth + 1, element.layout !== undefined);
342
+ }
343
+ }
344
+ }
345
+
346
+ if (!isObject(raw)) {
347
+ emit("diagram", "must be an object", 'use a JSON object with an "elements" array', "invalid_type");
348
+ return entries;
349
+ }
350
+ inspect(raw, { ...architectureContract.root, type: "object" }, "diagram", { skip: new Set(["elements", "$schema"]) });
351
+ elements(raw.elements, "elements", 0, false);
352
+ return entries;
353
+ }
354
+
355
+ export function architectureCompatibilityWarnings(raw) {
356
+ let occurrences = 0;
357
+ const relatedPointers = [];
358
+ const record = (path) => {
359
+ occurrences += 1;
360
+ if (relatedPointers.length < 8) relatedPointers.push(architecturePointer(path));
361
+ };
362
+ const coordinate = architectureContract.definitions.coordinate;
363
+ const visit = (items, path, flow) => {
364
+ for (const [index, element] of items.entries()) {
365
+ const elementPath = `${path}[${index}]`;
366
+ if (flow && element.type !== "connector") {
367
+ for (const key of ["x", "y"]) {
368
+ const value = element[key];
369
+ if (own(element, key) && (typeof value !== "number" || !Number.isFinite(value) ||
370
+ value < coordinate.minimum || value > coordinate.maximum)) {
371
+ record(`${elementPath}.${key}`);
372
+ }
373
+ }
374
+ }
375
+ if (element.type === "group") visit(element.children ?? [], `${elementPath}.children`, element.layout !== undefined);
376
+ }
377
+ };
378
+ if (own(raw, "$schema") && typeof raw.$schema !== "string") {
379
+ record("$schema");
380
+ }
381
+ visit(raw.elements, "elements", false);
382
+ return {
383
+ diagnostics: occurrences ? [{
384
+ ...architectureDiagnostic(
385
+ "diagram",
386
+ "contains values ignored by the v1 runtime but rejected by the authoring schema",
387
+ "omit coordinates managed by a parent layout; use a string for $schema or omit it",
388
+ { code: "schema_compatibility", severity: "warning" },
389
+ ),
390
+ occurrences,
391
+ relatedPointers,
392
+ }] : [],
393
+ };
394
+ }
395
+
396
+ export function architectureFailureReport(raw, primary, options) {
397
+ const result = collector(options.maxDiagnostics);
398
+ result.add(primary);
399
+ if (raw !== undefined && primary.category !== "json") {
400
+ const entries = scanStructure(raw, result, options);
401
+ if (!result.truncated && !result.diagnostics.some((item) => item.category === "structure" && item.severity === "error")) {
402
+ checkArchitectureReferences(entries, (diagnostic) => result.add(diagnostic));
403
+ }
404
+ }
405
+ const failed = (category) => result.diagnostics.some((item) => item.category === category && item.severity === "error");
406
+ return {
407
+ valid: false,
408
+ complete: false,
409
+ truncated: result.truncated,
410
+ truncationReasons: [...result.truncationReasons],
411
+ limits: {
412
+ maxDiagnostics: options.maxDiagnostics,
413
+ maxWork: MAX_DIAGNOSTIC_WORK,
414
+ maxElements: options.maxElements,
415
+ maxDepth: options.maxDepth,
416
+ },
417
+ stages: {
418
+ json: primary.category === "json" ? "failed" : "passed",
419
+ structure: failed("structure") ? "failed" : options.stages.structure === "passed"
420
+ ? "passed" : raw === undefined || result.truncated ? "skipped" : "passed",
421
+ semantic: failed("semantic") ? "failed" : options.stages.semantic,
422
+ layout: failed("layout") ? "failed" : options.stages.layout,
423
+ },
424
+ diagnostics: result.diagnostics,
425
+ };
426
+ }