@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.
- package/package.json +1 -1
- package/shared/README.md +61 -9
- 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-scene.mjs +312 -0
- package/shared/renderer/architecture.mjs +221 -217
- package/shared/renderer/index.html +9 -1
- package/shared/renderer/mermaid-scene.mjs +1248 -0
- package/shared/renderer/renderer.js +429 -232
- package/shared/renderer/scene-graph.mjs +739 -0
- package/shared/renderer/scene-pptx.mjs +265 -0
- package/shared/renderer/scene-svg.mjs +321 -0
- package/shared/renderer/slides.css +23 -0
- package/shared/renderer/theme.mjs +54 -0
- package/shared/runtime/architecture-editor-server.mjs +5 -0
- package/shared/runtime/architecture-source.mjs +9 -1
- package/shared/runtime/pptx-package.mjs +4 -3
- 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
|
@@ -91,6 +91,60 @@ export function serializeThemeVariables(variables) {
|
|
|
91
91
|
.join("");
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
// Mermaid's "base" theme accepts a `themeVariables` palette instead of one of
|
|
95
|
+
// its built-in named themes (dark/default/neutral/forest). Deriving that
|
|
96
|
+
// palette from the rendered deck's custom properties makes Mermaid diagrams
|
|
97
|
+
// share the slide's background, border, and text colors instead of only
|
|
98
|
+
// approximating the deck theme, and it reuses the same primary/secondary
|
|
99
|
+
// roles as the Architecture DSL (nodes: surface+border+fg, groups:
|
|
100
|
+
// accent-soft+accent-line+accent-strong) so both diagram types match.
|
|
101
|
+
//
|
|
102
|
+
// `secondaryColor`/`tertiaryColor` also back many categorical fills across
|
|
103
|
+
// Mermaid's diagram types (pie slices, git graph nodes, venn/quadrant charts,
|
|
104
|
+
// activation highlights, ...), so they must stay solid and clearly visible
|
|
105
|
+
// against `--bg` rather than reusing the translucent `--accent-soft` wash or
|
|
106
|
+
// `--bg` itself, which rendered those fills nearly invisible. `noteBkgColor`/
|
|
107
|
+
// `noteBorderColor`/`noteTextColor` are hardcoded by Mermaid's base theme
|
|
108
|
+
// (always a pale yellow) unless set explicitly, so sequence-diagram notes
|
|
109
|
+
// need their own override to follow the deck theme too.
|
|
110
|
+
export function mermaidThemeVariables(style) {
|
|
111
|
+
const read = (name) => style.getPropertyValue(name).trim();
|
|
112
|
+
const background = read("--bg");
|
|
113
|
+
const surface = read("--surface");
|
|
114
|
+
const border = read("--border");
|
|
115
|
+
const foreground = read("--fg");
|
|
116
|
+
const muted = read("--muted");
|
|
117
|
+
const accent = read("--accent");
|
|
118
|
+
const accentStrong = read("--accent-strong");
|
|
119
|
+
const accentSoft = read("--accent-soft");
|
|
120
|
+
const accentLine = read("--accent-line");
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
background,
|
|
124
|
+
primaryColor: surface,
|
|
125
|
+
primaryTextColor: foreground,
|
|
126
|
+
primaryBorderColor: border,
|
|
127
|
+
secondaryColor: accentStrong,
|
|
128
|
+
secondaryTextColor: background,
|
|
129
|
+
secondaryBorderColor: accentLine,
|
|
130
|
+
tertiaryColor: muted,
|
|
131
|
+
tertiaryTextColor: background,
|
|
132
|
+
tertiaryBorderColor: border,
|
|
133
|
+
lineColor: accent,
|
|
134
|
+
textColor: foreground,
|
|
135
|
+
mainBkg: surface,
|
|
136
|
+
nodeBorder: border,
|
|
137
|
+
clusterBkg: accentSoft,
|
|
138
|
+
clusterBorder: accentLine,
|
|
139
|
+
titleColor: foreground,
|
|
140
|
+
edgeLabelBackground: background,
|
|
141
|
+
noteBkgColor: accentSoft,
|
|
142
|
+
noteBorderColor: accentLine,
|
|
143
|
+
noteTextColor: accentStrong,
|
|
144
|
+
pie1: accent,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
94
148
|
function assertPlainObject(value, path) {
|
|
95
149
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
96
150
|
throw new Error(`${path} must be an object`);
|
|
@@ -581,6 +581,11 @@ export async function startArchitectureEditorServer({
|
|
|
581
581
|
if (
|
|
582
582
|
[
|
|
583
583
|
"/renderer/architecture.mjs",
|
|
584
|
+
"/renderer/architecture-scene.mjs",
|
|
585
|
+
"/renderer/scene-graph.mjs",
|
|
586
|
+
"/renderer/scene-svg.mjs",
|
|
587
|
+
"/renderer/architecture-contract.mjs",
|
|
588
|
+
"/renderer/architecture-diagnostics.mjs",
|
|
584
589
|
"/renderer/architecture-edit.mjs",
|
|
585
590
|
"/renderer/architecture-document.mjs",
|
|
586
591
|
].includes(route)
|
|
@@ -2,6 +2,7 @@ import { readFile, realpath, stat } from "node:fs/promises";
|
|
|
2
2
|
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
3
|
|
|
4
4
|
import { parseArchitecture } from "../renderer/architecture.mjs";
|
|
5
|
+
import { ArchitectureError } from "../renderer/architecture-diagnostics.mjs";
|
|
5
6
|
import {
|
|
6
7
|
findArchitectureBlocks,
|
|
7
8
|
replaceArchitectureBlock,
|
|
@@ -85,7 +86,11 @@ export async function readArchitectureSourceTarget(workspaceRoot, sourcePath, bl
|
|
|
85
86
|
try {
|
|
86
87
|
parseArchitecture(block.body);
|
|
87
88
|
} catch (error) {
|
|
88
|
-
|
|
89
|
+
if (!(error instanceof ArchitectureError)) throw error;
|
|
90
|
+
throw Object.assign(
|
|
91
|
+
sourceError("invalid_architecture", error.message || "Invalid Architecture DSL."),
|
|
92
|
+
{ diagnostic: error.diagnostic, validation: error.validation },
|
|
93
|
+
);
|
|
89
94
|
}
|
|
90
95
|
return { ...target, markdown, source: block.body };
|
|
91
96
|
}
|
|
@@ -103,10 +108,13 @@ export function saveArchitectureSource({
|
|
|
103
108
|
try {
|
|
104
109
|
parseArchitecture(source);
|
|
105
110
|
} catch (error) {
|
|
111
|
+
if (!(error instanceof ArchitectureError)) throw error;
|
|
106
112
|
return {
|
|
107
113
|
ok: false,
|
|
108
114
|
error: "invalid_architecture",
|
|
109
115
|
message: error?.message || "The diagram is invalid.",
|
|
116
|
+
diagnostic: error.diagnostic,
|
|
117
|
+
validation: error.validation,
|
|
110
118
|
};
|
|
111
119
|
}
|
|
112
120
|
|
|
@@ -707,7 +707,7 @@ function tableXml(element, path, id, relationships) {
|
|
|
707
707
|
return `<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="${id}" name="Table ${id}"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr>${xfrmXml(bounds, "p:xfrm")}<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/table"><a:tbl><a:tblPr firstRow="1" bandRow="1"/><a:tblGrid>${columns}</a:tblGrid>${rows}</a:tbl></a:graphicData></a:graphic></p:graphicFrame>`;
|
|
708
708
|
}
|
|
709
709
|
|
|
710
|
-
function arrowXml(value, path) {
|
|
710
|
+
function arrowXml(value, path, end = "tailEnd") {
|
|
711
711
|
if (value === undefined || value === null || value === false || value === "none") {
|
|
712
712
|
return "";
|
|
713
713
|
}
|
|
@@ -722,7 +722,7 @@ function arrowXml(value, path) {
|
|
|
722
722
|
oval: "oval",
|
|
723
723
|
}[value];
|
|
724
724
|
if (!type) fail(`${path} is not a supported arrow end`);
|
|
725
|
-
return `<a
|
|
725
|
+
return `<a:${end} type="${type}"/>`;
|
|
726
726
|
}
|
|
727
727
|
|
|
728
728
|
function connectorXml(element, path, nextId, relationships) {
|
|
@@ -756,6 +756,7 @@ function connectorXml(element, path, nextId, relationships) {
|
|
|
756
756
|
const y = Math.min(start.y, end.y);
|
|
757
757
|
const flipH = end.x < start.x ? ' flipH="1"' : "";
|
|
758
758
|
const flipV = end.y < start.y ? ' flipV="1"' : "";
|
|
759
|
+
const head = index === 0 ? arrowXml(element.arrowStart, `${path}.arrowStart`, "headEnd") : "";
|
|
759
760
|
const tail =
|
|
760
761
|
index === points.length - 2
|
|
761
762
|
? arrowXml(element.arrowEnd, `${path}.arrowEnd`)
|
|
@@ -763,7 +764,7 @@ function connectorXml(element, path, nextId, relationships) {
|
|
|
763
764
|
shapes.push(
|
|
764
765
|
`<p:sp><p:nvSpPr><p:cNvPr id="${id}" name="Connector ${id}"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm${flipH}${flipV}><a:off x="${emu(x)}" y="${emu(y)}"/><a:ext cx="${emu(Math.abs(end.x - start.x))}" cy="${emu(Math.abs(end.y - start.y))}"/></a:xfrm><a:prstGeom prst="line"><a:avLst/></a:prstGeom><a:ln w="${emu(width)}"><a:solidFill><a:srgbClr val="${color.hex}">${
|
|
765
766
|
alpha < 100000 ? `<a:alpha val="${alpha}"/>` : ""
|
|
766
|
-
}</a:srgbClr></a:solidFill>${dash}${tail}</a:ln></p:spPr></p:sp>`,
|
|
767
|
+
}</a:srgbClr></a:solidFill>${dash}${head}${tail}</a:ln></p:spPr></p:sp>`,
|
|
767
768
|
);
|
|
768
769
|
}
|
|
769
770
|
if (element.label !== undefined) {
|
package/shared/schema/README.md
CHANGED
|
@@ -9,8 +9,8 @@ fences. It supports editor completion and validation as well as automated CI val
|
|
|
9
9
|
| `examples/*.architecture.json` | Working examples with `$schema` |
|
|
10
10
|
|
|
11
11
|
This directory is **intentionally included in the distribution ZIP** so users can
|
|
12
|
-
reference the schema locally.
|
|
13
|
-
|
|
12
|
+
reference the schema locally. Browser rendering uses a generated, dependency-free
|
|
13
|
+
ES-module contract rather than loading JSON Schema or a validation package.
|
|
14
14
|
|
|
15
15
|
## Usage
|
|
16
16
|
|
|
@@ -91,7 +91,7 @@ constraints, so `renderer/architecture.mjs` is authoritative:
|
|
|
91
91
|
| Self-referencing connectors are prohibited | Same as above |
|
|
92
92
|
| Each `id` is unique across the complete tree | Applies to the flattened set of nested elements |
|
|
93
93
|
| 200 elements / 100 connectors / 20,000 text characters | Aggregated **after flattening**, not expressible by `maxItems` on one array |
|
|
94
|
-
|
|
|
94
|
+
| 65,536 source code units (UTF-16) | Existing JavaScript string-length limit before parsing; distinct from UTF-8 guide-response budgets |
|
|
95
95
|
| Layout fit (`children do not fit`) | Calculated dynamically from child sizes and group interior dimensions |
|
|
96
96
|
| Child `width` / `height` maximum under `layout` | Maximum depends on `cellWidth` / `cellHeight` |
|
|
97
97
|
| The `assets/` file referenced by `node.icon` / `image.src` exists | The parser does not access the file system; a missing file renders an empty image region |
|
|
@@ -99,6 +99,43 @@ constraints, so `renderer/architecture.mjs` is authoritative:
|
|
|
99
99
|
`parseArchitecture` can fail even after schema validation. **The parser always
|
|
100
100
|
makes the final determination of whether a diagram can render.**
|
|
101
101
|
|
|
102
|
+
### Shared authoring and diagnostic boundary
|
|
103
|
+
|
|
104
|
+
The bundled JSON Schema is the source of structural vocabulary, not a second
|
|
105
|
+
runtime acceptance policy. A reproducible generation step derives browser-safe
|
|
106
|
+
metadata for permitted fields, element types, scalar constraints, and conditional
|
|
107
|
+
requirements. The compact AI reference and runtime vocabulary consume that same
|
|
108
|
+
metadata. No UI or tool maintains its own permitted-field list.
|
|
109
|
+
|
|
110
|
+
Rendering, editing, saving, CLI validation, and unloaded-input validation share
|
|
111
|
+
the existing normalizer and semantic checks. Successfully normalized v1 input
|
|
112
|
+
remains accepted even where the authoring schema is intentionally stricter.
|
|
113
|
+
Such differences are authoring warnings, not new errors. On rejected input,
|
|
114
|
+
the common diagnostic layer explains independently checkable structural issues
|
|
115
|
+
from the derived contract and uses the same ID/reference checks as rendering.
|
|
116
|
+
It neither repairs the input nor reruns the parser with deleted fields or
|
|
117
|
+
invented defaults.
|
|
118
|
+
|
|
119
|
+
Diagnostics carry stable codes, categories, severity, JSON Pointers, human
|
|
120
|
+
messages, and nonautomatic suggestions. A conflicting replacement value is
|
|
121
|
+
reported rather than overwritten. JSON parsing, structural, semantic, and
|
|
122
|
+
layout stages distinguish passed, failed, and skipped work. Bounded diagnostic
|
|
123
|
+
collection reports truncation explicitly; a skipped stage is not evidence that
|
|
124
|
+
its constraints passed. Legacy exception messages and block-level error arrays
|
|
125
|
+
remain available alongside the detailed report.
|
|
126
|
+
|
|
127
|
+
Unloaded validation is a read-only boundary: it accepts explicit DSL text or
|
|
128
|
+
individual Markdown slide fragments and has no authority to open a canvas,
|
|
129
|
+
change its current page, read or write files, or modify editor drafts.
|
|
130
|
+
API execution success is separate from content validity and completeness.
|
|
131
|
+
Diagnostic budgets are not additional DSL v1 restrictions. Image existence,
|
|
132
|
+
slide clipping, and visual clarity still require separate asset/output review.
|
|
133
|
+
|
|
134
|
+
This preserves the existing schema/runtime responsibility split without a
|
|
135
|
+
browser-side schema dependency or a parser replacement. The tradeoff is a
|
|
136
|
+
generated artifact that must be kept in sync; a drift check makes that
|
|
137
|
+
requirement enforceable.
|
|
138
|
+
|
|
102
139
|
### Invariant: P ⊆ A, except for documented divergences
|
|
103
140
|
|
|
104
141
|
As a rule, documents accepted by the parser (P) must also be accepted by the
|
|
@@ -112,6 +149,8 @@ divergence must make the schema stricter, and every instance is listed below.**
|
|
|
112
149
|
| # | Case | Behavior | Reason |
|
|
113
150
|
| --- | --- | --- | --- |
|
|
114
151
|
| 1 | A child of a group with `layout` has nonnumeric `x` / `y` | Parser accepts; schema rejects | Placement is calculated automatically under `layout`, so the parser silently discards `x` / `y` without validating their values. This likely indicates an authoring mistake that the schema should report. Tightening the parser would reject previously accepted input and would be a breaking change. |
|
|
152
|
+
| 2 | A layout child has numeric `x` / `y` outside the schema range | Parser accepts; schema rejects | Parent-managed placement ignores these values just as it ignores nonnumeric coordinates. Preflight reports a compatibility warning. |
|
|
153
|
+
| 3 | Root `$schema` is not a string | Parser accepts; schema rejects | v1 ignores this metadata without resolving it. Editor completion requires a string; preflight warns without changing runtime acceptance. |
|
|
115
154
|
|
|
116
155
|
Divergences are recorded as `divergence` entries in `test/schema/corpus.mjs`,
|
|
117
156
|
and tests fail when no reason string is present. **CI detects silent divergence.**
|
|
@@ -224,5 +263,21 @@ Because `layered` calculates hierarchy from connector direction, **do not specif
|
|
|
224
263
|
child `x` / `y`**. This matches existing `grid` / `row` / `column` behavior.
|
|
225
264
|
|
|
226
265
|
The validation library (`ajv`) is a **root devDependency**. The extension ships
|
|
227
|
-
as a ZIP and must run without `node_modules`, so do not import
|
|
228
|
-
from
|
|
266
|
+
as a ZIP and must run without `node_modules`, so do not import JSON Schema or
|
|
267
|
+
ajv from the renderer. Regenerate the browser-safe metadata after a schema
|
|
268
|
+
change with:
|
|
269
|
+
|
|
270
|
+
```powershell
|
|
271
|
+
node .github\extensions\markdstage\scripts\generate-architecture-contract.mjs
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
The same command with `--check` fails on drift. The authoring reference, including
|
|
275
|
+
its self-contained example, must remain within 8 KiB UTF-8. Public valid examples
|
|
276
|
+
are checked against both Schema and runtime; intentionally invalid examples
|
|
277
|
+
remain explicitly marked as described above.
|
|
278
|
+
|
|
279
|
+
The generated module has a separate distribution budget: it must stay below
|
|
280
|
+
1,000,000 bytes, the conservative interpretation of the installer's 1 MB
|
|
281
|
+
single-file limit. An automated size check measures the actual generated file.
|
|
282
|
+
This is independent of both the 8 KiB guide-response budget and the existing
|
|
283
|
+
DSL source-length limit.
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
const own = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
|
|
2
|
+
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
3
|
+
const annotations = new Set(["$schema", "$id", "$anchor", "$comment", "title", "description", "examples"]);
|
|
4
|
+
const schemaMaps = new Set(["properties", "patternProperties", "dependentSchemas", "$defs"]);
|
|
5
|
+
const schemaArrays = new Set(["allOf", "anyOf", "oneOf", "prefixItems"]);
|
|
6
|
+
const schemaValues = new Set([
|
|
7
|
+
"items", "contains", "additionalProperties", "unevaluatedProperties", "unevaluatedItems",
|
|
8
|
+
"propertyNames", "not", "if", "then", "else", "contentSchema",
|
|
9
|
+
]);
|
|
10
|
+
const scalarKeywords = new Set([
|
|
11
|
+
...annotations, "type", "enum", "const", "required", "default", "deprecated", "readOnly",
|
|
12
|
+
"writeOnly", "minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf",
|
|
13
|
+
"minLength", "maxLength", "pattern", "format", "minItems", "maxItems", "uniqueItems",
|
|
14
|
+
"minContains", "maxContains", "minProperties", "maxProperties", "dependentRequired",
|
|
15
|
+
"contentEncoding", "contentMediaType",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
function unsupported(path, message) {
|
|
19
|
+
throw new Error(`Unsupported Architecture contract derivation at ${path}: ${message}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function ordered(value) {
|
|
23
|
+
if (Array.isArray(value)) return value.map(ordered);
|
|
24
|
+
if (!object(value)) return value;
|
|
25
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, ordered(value[key])]));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function signature(value) {
|
|
29
|
+
return JSON.stringify(ordered(value));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function assertions(value) {
|
|
33
|
+
if (!object(value)) return value;
|
|
34
|
+
return Object.fromEntries(Object.entries(value)
|
|
35
|
+
.filter(([key]) => !annotations.has(key))
|
|
36
|
+
.map(([key, entry]) => [
|
|
37
|
+
key,
|
|
38
|
+
schemaMaps.has(key)
|
|
39
|
+
? Object.fromEntries(Object.entries(entry).map(([name, child]) => [name, assertions(child)]))
|
|
40
|
+
: schemaArrays.has(key) ? entry.map(assertions)
|
|
41
|
+
: schemaValues.has(key) ? assertions(entry) : entry,
|
|
42
|
+
]));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function conjoin(left, right, path) {
|
|
46
|
+
if (left === true) return right;
|
|
47
|
+
if (right === true) return left;
|
|
48
|
+
if (left === false || right === false) return false;
|
|
49
|
+
for (const [closed, other] of [[left, right], [right, left]]) {
|
|
50
|
+
if (closed.additionalProperties === false &&
|
|
51
|
+
Object.keys(other.properties ?? {}).some((key) => !own(closed.properties ?? {}, key))) {
|
|
52
|
+
unsupported(path, "allOf cannot widen a closed object's permitted properties");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const result = { ...left };
|
|
56
|
+
// Keep complete conditional clauses together; independently merging their if/then/else
|
|
57
|
+
// keywords would change which assertion a branch applies to.
|
|
58
|
+
if (own(left, "if") && own(right, "if")) {
|
|
59
|
+
const condition = Object.fromEntries(["if", "then", "else"]
|
|
60
|
+
.filter((key) => own(right, key)).map((key) => [key, right[key]]));
|
|
61
|
+
right = Object.fromEntries(Object.entries(right)
|
|
62
|
+
.filter(([key]) => !["if", "then", "else"].includes(key)));
|
|
63
|
+
result.allOf = [...(result.allOf ?? []), condition];
|
|
64
|
+
}
|
|
65
|
+
for (const [key, value] of Object.entries(right)) {
|
|
66
|
+
if (!own(result, key) || annotations.has(key)) {
|
|
67
|
+
result[key] = value;
|
|
68
|
+
} else if (key === "properties") {
|
|
69
|
+
result.properties = { ...result.properties };
|
|
70
|
+
for (const [name, property] of Object.entries(value)) {
|
|
71
|
+
result.properties[name] = own(result.properties, name)
|
|
72
|
+
? conjoin(result.properties[name], property, `${path}/properties/${name}`)
|
|
73
|
+
: property;
|
|
74
|
+
}
|
|
75
|
+
} else if (key === "required") {
|
|
76
|
+
result.required = [...new Set([...result.required, ...value])];
|
|
77
|
+
} else if (key === "allOf") {
|
|
78
|
+
result.allOf = [...result.allOf, ...value];
|
|
79
|
+
} else if (key === "enum") {
|
|
80
|
+
result.enum = result.enum.filter((entry) => value.some((other) => signature(entry) === signature(other)));
|
|
81
|
+
if (!result.enum.length) unsupported(path, "allOf has disjoint enums");
|
|
82
|
+
} else if (key === "type") {
|
|
83
|
+
const types = [result.type].flat().filter((entry) => [value].flat().includes(entry));
|
|
84
|
+
if (!types.length) unsupported(path, "allOf has disjoint types");
|
|
85
|
+
result.type = types.length === 1 ? types[0] : types;
|
|
86
|
+
} else if (/^(minimum|exclusiveMinimum|minLength|minItems|minContains|minProperties)$/.test(key)) {
|
|
87
|
+
result[key] = Math.max(result[key], value);
|
|
88
|
+
} else if (/^(maximum|exclusiveMaximum|maxLength|maxItems|maxContains|maxProperties)$/.test(key)) {
|
|
89
|
+
result[key] = Math.min(result[key], value);
|
|
90
|
+
} else if (signature(result[key]) !== signature(value)) {
|
|
91
|
+
unsupported(path, `cannot flatten conflicting ${key} assertions without losing information`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (own(result, "const") && result.enum) {
|
|
95
|
+
if (!result.enum.some((entry) => signature(entry) === signature(result.const))) {
|
|
96
|
+
unsupported(path, "const is excluded by enum");
|
|
97
|
+
}
|
|
98
|
+
delete result.enum;
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function createResolver(schema) {
|
|
104
|
+
function dereference(ref) {
|
|
105
|
+
if (typeof ref !== "string" || !ref.startsWith("#/")) {
|
|
106
|
+
unsupported(String(ref), "only local JSON Pointer $ref values are supported");
|
|
107
|
+
}
|
|
108
|
+
let target = schema;
|
|
109
|
+
for (const part of ref.slice(2).split("/")) {
|
|
110
|
+
const key = part.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
111
|
+
if (!object(target) || !own(target, key)) unsupported(ref, "unresolved $ref");
|
|
112
|
+
target = target[key];
|
|
113
|
+
}
|
|
114
|
+
return target;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function discriminator(input, seen = new Set()) {
|
|
118
|
+
if (!object(input)) return null;
|
|
119
|
+
if (input.$ref) {
|
|
120
|
+
if (seen.has(input.$ref)) unsupported(input.$ref, "recursive discriminator");
|
|
121
|
+
return discriminator(dereference(input.$ref), new Set([...seen, input.$ref]));
|
|
122
|
+
}
|
|
123
|
+
return input.properties?.type?.enum ?? null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function resolve(input, path = "#", stack = []) {
|
|
127
|
+
if (typeof input === "boolean") return input;
|
|
128
|
+
if (!object(input)) unsupported(path, "expected a JSON Schema object or boolean");
|
|
129
|
+
let result = {};
|
|
130
|
+
if (input.$ref) {
|
|
131
|
+
if (stack.includes(input.$ref)) unsupported(path, "recursive $ref outside an element array");
|
|
132
|
+
result = resolve(dereference(input.$ref), input.$ref, [...stack, input.$ref]);
|
|
133
|
+
}
|
|
134
|
+
const local = {};
|
|
135
|
+
for (const [key, value] of Object.entries(input)) {
|
|
136
|
+
if (key === "$ref" || key === "allOf") continue;
|
|
137
|
+
if (schemaMaps.has(key)) {
|
|
138
|
+
local[key] = Object.fromEntries(Object.entries(value)
|
|
139
|
+
.map(([name, child]) => [name, resolve(child, `${path}/${key}/${name}`, stack)]));
|
|
140
|
+
} else if (schemaArrays.has(key)) {
|
|
141
|
+
local[key] = value.map((child, index) => resolve(child, `${path}/${key}/${index}`, stack));
|
|
142
|
+
} else if (schemaValues.has(key)) {
|
|
143
|
+
// Element arrays are the tree boundary. Keep their standard local references rather
|
|
144
|
+
// than unrolling every nesting level (or a future recursive element definition).
|
|
145
|
+
if (key === "items" && object(value) && value.$ref && discriminator(value)) {
|
|
146
|
+
dereference(value.$ref);
|
|
147
|
+
if (Object.keys(value).some((name) => name !== "$ref")) {
|
|
148
|
+
unsupported(`${path}/items`, "element-array $ref siblings need explicit derivation support");
|
|
149
|
+
}
|
|
150
|
+
local[key] = { $ref: value.$ref };
|
|
151
|
+
} else {
|
|
152
|
+
local[key] = resolve(value, `${path}/${key}`, stack);
|
|
153
|
+
}
|
|
154
|
+
} else if (scalarKeywords.has(key)) {
|
|
155
|
+
local[key] = structuredClone(value);
|
|
156
|
+
} else {
|
|
157
|
+
unsupported(`${path}/${key}`, "unrecognized JSON Schema keyword");
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
result = conjoin(result, local, path);
|
|
161
|
+
for (const [index, branch] of (input.allOf ?? []).entries()) {
|
|
162
|
+
const resolved = resolve(branch, `${path}/allOf/${index}`, stack);
|
|
163
|
+
if (object(resolved) && ["if", "anyOf", "oneOf", "not"].some((key) => own(resolved, key))) {
|
|
164
|
+
result = conjoin(result, { allOf: [resolved] }, path);
|
|
165
|
+
} else {
|
|
166
|
+
result = conjoin(result, resolved, path);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
return { resolve, discriminator };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function typeGuard(condition, path) {
|
|
175
|
+
if (!condition?.properties?.type) return null;
|
|
176
|
+
const allowed = new Set(["type", "properties", "required"]);
|
|
177
|
+
if (Object.keys(condition).some((key) => !allowed.has(key))
|
|
178
|
+
|| Object.keys(condition.properties).some((key) => key !== "type")
|
|
179
|
+
|| condition.required?.length !== 1 || condition.required[0] !== "type"
|
|
180
|
+
|| (condition.type !== undefined && condition.type !== "object")) {
|
|
181
|
+
unsupported(path, "element discriminator must test only the required type field");
|
|
182
|
+
}
|
|
183
|
+
const value = condition.properties.type;
|
|
184
|
+
if (Object.keys(value).length !== 1 || (!own(value, "const") && !own(value, "enum"))) {
|
|
185
|
+
unsupported(path, "element discriminator must use const or enum");
|
|
186
|
+
}
|
|
187
|
+
return own(value, "const") ? [value.const] : value.enum;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function elementVariants(table, path) {
|
|
191
|
+
const types = table.properties?.type?.enum;
|
|
192
|
+
if (!Array.isArray(types) || !types.length || types.some((type) => typeof type !== "string")) {
|
|
193
|
+
unsupported(path, "element items must declare a nonempty string type enum");
|
|
194
|
+
}
|
|
195
|
+
const selected = {};
|
|
196
|
+
for (const type of types) {
|
|
197
|
+
let matched = false;
|
|
198
|
+
function select(input) {
|
|
199
|
+
if (!object(input)) unsupported(path, "boolean element branches are not supported");
|
|
200
|
+
if (input.anyOf || input.oneOf || input.not) unsupported(path, "ambiguous element object composition");
|
|
201
|
+
let result = Object.fromEntries(Object.entries(input).filter(([key]) => key !== "allOf"));
|
|
202
|
+
if (input.if) {
|
|
203
|
+
const guard = typeGuard(input.if, path);
|
|
204
|
+
if (guard) {
|
|
205
|
+
result = Object.fromEntries(Object.entries(result)
|
|
206
|
+
.filter(([key]) => !["if", "then", "else"].includes(key)));
|
|
207
|
+
const applies = guard.includes(type);
|
|
208
|
+
matched ||= applies && own(input, "then");
|
|
209
|
+
const branch = applies ? input.then : input.else;
|
|
210
|
+
if (branch !== undefined) result = conjoin(result, select(branch), path);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
for (const branch of input.allOf ?? []) result = conjoin(result, select(branch), path);
|
|
214
|
+
return result;
|
|
215
|
+
}
|
|
216
|
+
selected[type] = select(table);
|
|
217
|
+
if (!matched) unsupported(path, `no conditional element branch for ${JSON.stringify(type)}`);
|
|
218
|
+
if (selected[type].additionalProperties !== false) {
|
|
219
|
+
unsupported(path, `${type} must explicitly bound its permitted properties`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return selected;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function fields(descriptor, path) {
|
|
226
|
+
const result = { ...(descriptor.properties ?? {}) };
|
|
227
|
+
function conditional(input) {
|
|
228
|
+
if (!object(input)) return;
|
|
229
|
+
if (input.anyOf || input.oneOf) unsupported(path, "conditional object unions need explicit field derivation");
|
|
230
|
+
for (const [name, value] of Object.entries(input.properties ?? {})) {
|
|
231
|
+
// A conditional assertion may narrow an existing field (connector.points, for example).
|
|
232
|
+
// The complete condition stays on the element and in definitions; it is not an
|
|
233
|
+
// unconditional restriction on the property descriptor.
|
|
234
|
+
if (!own(result, name)) {
|
|
235
|
+
if (descriptor.additionalProperties === false) {
|
|
236
|
+
unsupported(path, `conditional field ${name} is outside the closed object's permitted properties`);
|
|
237
|
+
}
|
|
238
|
+
result[name] = value;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
for (const branch of input.allOf ?? []) conditional(branch);
|
|
242
|
+
if (input.then) conditional(input.then);
|
|
243
|
+
if (input.else) conditional(input.else);
|
|
244
|
+
}
|
|
245
|
+
conditional(descriptor);
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function elementConditions(descriptor) {
|
|
250
|
+
return Object.fromEntries(["if", "then", "else", "allOf"]
|
|
251
|
+
.filter((key) => own(descriptor, key)).map((key) => [key, descriptor[key]]));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function conditionSignature(conditions, resolver) {
|
|
255
|
+
// Child selectors vary by nesting depth; their exact rules remain in definitions.
|
|
256
|
+
// All other element-level conditions must agree across fixed/flow contexts.
|
|
257
|
+
return JSON.stringify(ordered(assertions(conditions)), (key, value) =>
|
|
258
|
+
key === "items" && resolver.discriminator(value) ? {} : value);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function childTables(descriptor, resolver, path) {
|
|
262
|
+
const result = [];
|
|
263
|
+
function visit(input, mode) {
|
|
264
|
+
if (!object(input)) return;
|
|
265
|
+
for (const property of Object.values(input.properties ?? {})) {
|
|
266
|
+
if (property.items && resolver.discriminator(property.items)) {
|
|
267
|
+
result.push({ items: property.items, mode });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
for (const branch of input.allOf ?? []) visit(branch, mode);
|
|
271
|
+
if (input.if) {
|
|
272
|
+
const before = result.length;
|
|
273
|
+
visit(input.then, "flow");
|
|
274
|
+
visit(input.else, "fixed");
|
|
275
|
+
if (result.length !== before && (
|
|
276
|
+
input.if.type !== "object" || input.if.required?.length !== 1
|
|
277
|
+
|| input.if.required[0] !== "layout"
|
|
278
|
+
|| Object.keys(input.if).some((key) => !["type", "required"].includes(key))
|
|
279
|
+
)) {
|
|
280
|
+
unsupported(path, "child layout context must be selected by presence of the parent's layout");
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
visit(descriptor, "fixed");
|
|
285
|
+
return result;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Derive vocabulary from JSON Schema, not an independent validator. The fixed/flow projection
|
|
290
|
+
* follows the schema's parent-layout branches. Element conditions and resolved definitions
|
|
291
|
+
* retain JSON Schema keywords; depth-specific child selectors remain in definitions.
|
|
292
|
+
* Unsupported projections fail rather than silently publishing partial metadata.
|
|
293
|
+
*/
|
|
294
|
+
export function deriveArchitectureContract(schema) {
|
|
295
|
+
if (!object(schema) || !object(schema.$defs)) unsupported("#", "expected a schema with $defs");
|
|
296
|
+
const resolver = createResolver(schema);
|
|
297
|
+
const { $defs, ...rootSchema } = schema;
|
|
298
|
+
const root = resolver.resolve(rootSchema);
|
|
299
|
+
if (root.type !== "object" || root.additionalProperties !== false || !root.properties?.elements?.items) {
|
|
300
|
+
unsupported("#", "expected a closed root object with elements.items");
|
|
301
|
+
}
|
|
302
|
+
if (root.if || root.allOf || root.anyOf || root.oneOf) {
|
|
303
|
+
unsupported("#", "conditional root fields need explicit derivation support");
|
|
304
|
+
}
|
|
305
|
+
const elements = {};
|
|
306
|
+
const queue = [{ items: root.properties.elements.items, mode: "fixed" }];
|
|
307
|
+
const visited = new Set();
|
|
308
|
+
while (queue.length) {
|
|
309
|
+
const { items, mode } = queue.shift();
|
|
310
|
+
const key = `${mode}:${signature(items)}`;
|
|
311
|
+
if (visited.has(key)) continue;
|
|
312
|
+
visited.add(key);
|
|
313
|
+
const variants = elementVariants(resolver.resolve(items), key);
|
|
314
|
+
for (const [type, descriptor] of Object.entries(variants)) {
|
|
315
|
+
const properties = fields(descriptor, `${key}/${type}`);
|
|
316
|
+
const required = descriptor.required ?? [];
|
|
317
|
+
const conditions = elementConditions(descriptor);
|
|
318
|
+
if (required.some((name) => !own(properties, name))) unsupported(key, `${type} requires an undeclared field`);
|
|
319
|
+
const existing = elements[type] ??= { properties, required: {}, ...conditions };
|
|
320
|
+
if (signature(assertions({ properties: existing.properties })) !== signature(assertions({ properties }))) {
|
|
321
|
+
unsupported(key, `${type} properties vary by parent context; cannot publish one field record`);
|
|
322
|
+
}
|
|
323
|
+
if (conditionSignature(elementConditions(existing), resolver) !== conditionSignature(conditions, resolver)) {
|
|
324
|
+
unsupported(key, `${type} conditional rules vary by parent context beyond child element arrays`);
|
|
325
|
+
}
|
|
326
|
+
if (existing.required[mode] && signature([...existing.required[mode]].sort()) !== signature([...required].sort())) {
|
|
327
|
+
unsupported(key, `${type} requirements vary within ${mode} context`);
|
|
328
|
+
}
|
|
329
|
+
existing.required[mode] = required;
|
|
330
|
+
queue.push(...childTables(descriptor, resolver, `${key}/${type}`));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
for (const [type, element] of Object.entries(elements)) {
|
|
334
|
+
if (!element.required.fixed || !element.required.flow) {
|
|
335
|
+
unsupported("#/properties/elements", `${type} has no reachable fixed and flow variants`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return {
|
|
339
|
+
root: { properties: root.properties, required: root.required ?? [] },
|
|
340
|
+
elements,
|
|
341
|
+
definitions: Object.fromEntries(Object.entries($defs)
|
|
342
|
+
.map(([name, definition]) => [name, resolver.resolve(definition, `#/$defs/${name}`)])),
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function architectureContractModule(schema) {
|
|
347
|
+
// Declaration order is also the runtime's diagnostic field/type order.
|
|
348
|
+
return [
|
|
349
|
+
"// Generated from schema/architecture-v1.schema.json. Do not edit.",
|
|
350
|
+
"// Regenerate: node .github/extensions/markdstage/scripts/generate-architecture-contract.mjs",
|
|
351
|
+
"// Structural metadata only; renderer/architecture.mjs remains the semantic authority.",
|
|
352
|
+
`export const architectureContract = ${JSON.stringify(deriveArchitectureContract(schema), null, 2)};`,
|
|
353
|
+
"",
|
|
354
|
+
].join("\n");
|
|
355
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { architectureContractModule } from "../schema/architecture-contract.mjs";
|
|
5
|
+
|
|
6
|
+
const schemaUrl = new URL("../schema/architecture-v1.schema.json", import.meta.url);
|
|
7
|
+
const outputUrl = new URL("../renderer/architecture-contract.mjs", import.meta.url);
|
|
8
|
+
|
|
9
|
+
export async function generateArchitectureContract({
|
|
10
|
+
check = false,
|
|
11
|
+
source = schemaUrl,
|
|
12
|
+
output = outputUrl,
|
|
13
|
+
} = {}) {
|
|
14
|
+
const expected = architectureContractModule(JSON.parse(await readFile(source, "utf8")));
|
|
15
|
+
let current;
|
|
16
|
+
try {
|
|
17
|
+
current = await readFile(output, "utf8");
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (error.code !== "ENOENT") throw error;
|
|
20
|
+
}
|
|
21
|
+
const changed = expected !== current?.replace(/\r\n/g, "\n");
|
|
22
|
+
if (changed && check) {
|
|
23
|
+
throw new Error("Architecture contract is out of date. Run npm run generate:architecture.");
|
|
24
|
+
}
|
|
25
|
+
if (changed) await writeFile(output, expected, "utf8");
|
|
26
|
+
return { changed, checked: check };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
|
|
30
|
+
try {
|
|
31
|
+
const args = process.argv.slice(2);
|
|
32
|
+
if (args.some((arg) => arg !== "--check") || args.length > 1) {
|
|
33
|
+
throw new Error("Usage: node generate-architecture-contract.mjs [--check]");
|
|
34
|
+
}
|
|
35
|
+
const result = await generateArchitectureContract({ check: args.includes("--check") });
|
|
36
|
+
console.log(`Architecture contract ${result.checked ? "is current" : result.changed ? "generated" : "unchanged"}.`);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
console.error(error.message);
|
|
39
|
+
process.exitCode = 1;
|
|
40
|
+
}
|
|
41
|
+
}
|