feat: add deterministic Diagram IR v2 authoring

This commit is contained in:
2026-09-03 10:05:29 +00:00
parent 305006c8cb
commit 50a07b90a9
31 changed files with 2108 additions and 98 deletions
+103
View File
@@ -0,0 +1,103 @@
import assert from "node:assert/strict";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { parseAllPages } from "../../services/drawio-parser/parser.js";
import { run } from "./action.js";
const VALID_SPEC = `version: 1
title: Login flow
direction: horizontal
nodes:
- id: client
label: Client
- id: api
label: API
edges:
- id: login
source: client
target: api
label: HTTPS
`;
test("build converts a YAML Diagram IR into deterministic native Draw.io", () => {
const dir = mkdtempSync(join(tmpdir(), "drawio-build-test-"));
const input = join(dir, "login.yaml");
const output = join(dir, "login.drawio");
try {
writeFileSync(input, VALID_SPEC, "utf8");
const result = run(input, 0, output);
assert.equal(existsSync(output), true);
assert.deepEqual(result.summary, { nodes: 2, edges: 1, pages: 1 });
const firstXml = readFileSync(output, "utf8");
run(input, 0, output);
assert.equal(readFileSync(output, "utf8"), firstXml);
const [page] = parseAllPages(output);
assert.equal(page.pageName, "Login flow");
assert.deepEqual([...page.shapes.keys()].sort(), ["api", "client"]);
assert.equal(page.edges[0].sourceId, "client");
assert.equal(page.edges[0].targetId, "api");
assert.equal(page.shapes.get("client")?.x, 40);
assert.equal(page.shapes.get("api")?.x, 240);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("build expands default page dimensions to fit generated content", () => {
const dir = mkdtempSync(join(tmpdir(), "drawio-build-page-size-test-"));
const input = join(dir, "wide.yaml");
const output = join(dir, "wide.drawio");
try {
const nodes = Array.from({ length: 10 }, (_, index) => ` - { id: node-${index}, label: Node ${index} }`).join("\n");
writeFileSync(input, `version: 1\ndirection: horizontal\nnodes:\n${nodes}\n`, "utf8");
run(input, 0, output);
const [page] = parseAllPages(output);
const right = Math.max(...[...page.shapes.values()].map((shape) => shape.x2));
assert.ok(page.pageWidth >= right + 40);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("build rejects duplicate IDs and unknown edge endpoints", () => {
const dir = mkdtempSync(join(tmpdir(), "drawio-build-invalid-test-"));
const input = join(dir, "invalid.yaml");
const output = join(dir, "invalid.drawio");
try {
writeFileSync(input, `version: 1\nnodes:\n - { id: repeated, label: A }\n - { id: repeated, label: B }\nedges:\n - { id: edge, source: repeated, target: missing }\n`, "utf8");
assert.throws(() => run(input, 0, output), /duplicate node id.*unknown target/si);
assert.equal(existsSync(output), false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("build serializes every v2 page and honors explicit geometry and waypoints", () => {
const dir = mkdtempSync(join(tmpdir(), "drawio-build-v2-test-"));
const input = join(dir, "platform.yaml");
const output = join(dir, "platform.drawio");
try {
writeFileSync(input, `version: 2\ntitle: Platform\ntheme: zinc\nprovenance: { source: test }\nproperties: { audience: engineering }\nextensions: { owner: drawio-main }\npages:\n - id: system\n title: System\n properties: { view: logical }\n layout: { type: manual }\n nodes:\n - id: client\n label: Client\n kind: actor\n properties: { tier: edge }\n provenance: { source: inventory }\n extensions: { critical: true }\n geometry: { x: 40, y: 80, width: 120, height: 60 }\n - id: api\n label: API\n geometry: { x: 300, y: 80, width: 120, height: 60 }\n edges:\n - id: call\n source: client\n target: api\n kind: request\n properties: { protocol: HTTPS }\n waypoints: [{ x: 230, y: 110 }]\n - id: deployment\n title: Deployment\n nodes:\n - { id: server, label: Server }\n edges: []\n`, "utf8");
const result = run(input, 0, output);
assert.deepEqual(result.summary, { nodes: 3, edges: 1, pages: 2 });
const xml = readFileSync(output, "utf8");
assert.match(xml, /data-theme="zinc"/);
assert.match(xml, /data-provenance="\{"source":"test"\}"/);
assert.match(xml, /data-properties="\{"audience":"engineering"\}"/);
assert.match(xml, /data-kind="actor"/);
assert.match(xml, /data-properties="\{"tier":"edge"\}"/);
assert.match(xml, /data-kind="request"/);
assert.match(xml, /data-properties="\{"protocol":"HTTPS"\}"/);
const pages = parseAllPages(output);
assert.deepEqual(pages.map((page) => page.pageName), ["System", "Deployment"]);
assert.equal(pages[0].shapes.get("api")?.x, 300);
assert.deepEqual(pages[0].edges[0].waypoints, [{ x: 230, y: 110 }]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
+26
View File
@@ -0,0 +1,26 @@
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import * as yaml from "js-yaml";
import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js";
import { normalizeDiagramIR, validateDiagramIR } from "../../model/diagram-ir.js";
export function run(filePath: string, _pageIndex = 0, outputPath?: string): Record<string, unknown> {
if (!outputPath) throw new Error("build requires --output <path.drawio>");
const parsed = yaml.load(readFileSync(filePath, "utf8"), { schema: yaml.JSON_SCHEMA });
const ir = validateDiagramIR(parsed);
const normalized = normalizeDiagramIR(ir);
const xml = diagramIRToDrawio(normalized);
const resolvedOutput = resolve(outputPath);
writeFileSync(resolvedOutput, xml, "utf8");
return {
action: "build",
file: filePath,
output: resolvedOutput,
summary: {
nodes: normalized.pages.reduce((total, page) => total + page.nodes.length, 0),
edges: normalized.pages.reduce((total, page) => total + page.edges.length, 0),
pages: normalized.pages.length,
},
};
}
@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { run } from "./action.js";
function withDrawioFile(content: string, callback: (filePath: string) => void): void {
const dir = mkdtempSync(join(tmpdir(), "drawio-connectors-test-"));
const filePath = join(dir, "diagram.drawio");
try {
writeFileSync(filePath, content, "utf8");
callback(filePath);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
const CHILDREN_CONNECTED_INSIDE_CONTAINER = `<mxGraphModel pageWidth="800" pageHeight="600"><root>
<mxCell id="0"/><mxCell id="1" parent="0"/>
<mxCell id="container" value="Domain" style="swimlane;startSize=30;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="400" height="240" as="geometry"/></mxCell>
<mxCell id="a" value="A" vertex="1" parent="container"><mxGeometry x="40" y="60" width="100" height="60" as="geometry"/></mxCell>
<mxCell id="b" value="B" vertex="1" parent="container"><mxGeometry x="220" y="60" width="100" height="60" as="geometry"/></mxCell>
<mxCell id="edge" style="edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="a" target="b"><mxGeometry relative="1" as="geometry"/></mxCell>
</root></mxGraphModel>`;
test("connector validation ignores the shared ancestor container", () => {
withDrawioFile(CHILDREN_CONNECTED_INSIDE_CONTAINER, (filePath) => {
const result = run(filePath);
const issues = result.issues as Array<{ type: string; shapeId?: string }>;
assert.equal(issues.some((issue) => issue.type === "connector_shape_overlap" && issue.shapeId === "container"), false);
});
});
test("connector validation ignores every endpoint ancestor for cross-container edges", () => {
const diagram = `<mxGraphModel pageWidth="900" pageHeight="600"><root>
<mxCell id="0"/><mxCell id="1" parent="0"/>
<mxCell id="container" value="Domain" style="swimlane;startSize=30;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="400" height="240" as="geometry"/></mxCell>
<mxCell id="child" value="Child" vertex="1" parent="container"><mxGeometry x="40" y="60" width="100" height="60" as="geometry"/></mxCell>
<mxCell id="external" value="External" vertex="1" parent="1"><mxGeometry x="520" y="100" width="100" height="60" as="geometry"/></mxCell>
<mxCell id="edge" style="edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="child" target="external"><mxGeometry relative="1" as="geometry"/></mxCell>
</root></mxGraphModel>`;
withDrawioFile(diagram, (filePath) => {
const result = run(filePath);
const issues = result.issues as Array<{ type: string; shapeId?: string }>;
assert.equal(issues.some((issue) => issue.type === "connector_shape_overlap" && issue.shapeId === "container"), false);
});
});
@@ -205,16 +205,36 @@ export function run(filePath: string): Record<string, unknown> {
edgeSegList.push({ edge, segs, pts });
}
function ancestorIds(shapeId: string | null): Set<string> {
const ancestors = new Set<string>();
const visited = new Set<string>();
let currentId = shapeId ?? undefined;
while (currentId && !visited.has(currentId)) {
visited.add(currentId);
const shape = shapes.get(currentId);
if (!shape) break;
currentId = shape.parentId;
if (currentId && shapes.has(currentId)) ancestors.add(currentId);
}
return ancestors;
}
// -------------------------------------------------------------------------
// 1. Connector-shape overlaps
// -------------------------------------------------------------------------
for (const { edge, segs } of edgeSegList) {
const endpointAncestors = new Set([
...ancestorIds(edge.sourceId),
...ancestorIds(edge.targetId),
]);
for (let i = 0; i < segs.length; i++) {
const seg = segs[i];
const isFirst = i === 0;
const isLast = i === segs.length - 1;
for (const [shapeId, shape] of shapes) {
if (endpointAncestors.has(shapeId)) continue;
if (isFirst && shapeId === edge.sourceId) continue;
if (isLast && shapeId === edge.targetId) continue;
@@ -0,0 +1,36 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { run } from "./action.js";
function withDrawioFile(content: string, callback: (filePath: string) => void): void {
const dir = mkdtempSync(join(tmpdir(), "drawio-orphans-test-"));
const filePath = join(dir, "diagram.drawio");
try {
writeFileSync(filePath, content, "utf8");
callback(filePath);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
const CONTAINER_WITH_CONNECTED_CHILDREN = `<mxGraphModel pageWidth="800" pageHeight="600"><root>
<mxCell id="0"/><mxCell id="1" parent="0"/>
<mxCell id="container" value="Domain" style="swimlane;startSize=30;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="400" height="240" as="geometry"/></mxCell>
<mxCell id="a" value="A" vertex="1" parent="container"><mxGeometry x="40" y="60" width="100" height="60" as="geometry"/></mxCell>
<mxCell id="b" value="B" vertex="1" parent="container"><mxGeometry x="220" y="60" width="100" height="60" as="geometry"/></mxCell>
<mxCell id="edge" edge="1" parent="1" source="a" target="b"><mxGeometry relative="1" as="geometry"/></mxCell>
</root></mxGraphModel>`;
test("page-orphans treats a container with connected descendants as connected", () => {
withDrawioFile(CONTAINER_WITH_CONNECTED_CHILDREN, (filePath) => {
const result = run(filePath);
const summary = result.summary as { isolatedShapes: number };
const issues = result.issues as Array<{ type: string; shapeId?: string }>;
assert.equal(summary.isolatedShapes, 0);
assert.equal(issues.some((issue) => issue.type === "isolated_shape" && issue.shapeId === "container"), false);
});
});
+16 -3
View File
@@ -11,11 +11,24 @@ import { parseDiagram } from "../../services/drawio-parser/parser.js";
export function run(filePath: string): Record<string, unknown> {
const { shapes, edges } = parseDiagram(filePath);
// Build set of shape IDs that have at least one edge
// Build set of shape IDs that have at least one edge. Connectivity propagates
// to ancestor containers because a container represents its descendants.
const connectedShapeIds = new Set<string>();
function markConnectedWithAncestors(shapeId: string): void {
const visited = new Set<string>();
let currentId: string | undefined = shapeId;
while (currentId && !visited.has(currentId)) {
visited.add(currentId);
const shape = shapes.get(currentId);
if (!shape) break;
connectedShapeIds.add(currentId);
currentId = shape.parentId;
}
}
for (const e of edges) {
if (e.sourceId && shapes.has(e.sourceId)) connectedShapeIds.add(e.sourceId);
if (e.targetId && shapes.has(e.targetId)) connectedShapeIds.add(e.targetId);
if (e.sourceId && shapes.has(e.sourceId)) markConnectedWithAncestors(e.sourceId);
if (e.targetId && shapes.has(e.targetId)) markConnectedWithAncestors(e.targetId);
}
const issues: Record<string, unknown>[] = [];
@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { run } from "./action.js";
test("quality reports clipping, text overflow risk, and placeholders", () => {
const dir = mkdtempSync(join(tmpdir(), "drawio-quality-test-"));
const file = join(dir, "quality.drawio");
try {
writeFileSync(file, `<mxfile><diagram name="Page"><mxGraphModel pageWidth="300" pageHeight="200"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="long" value="This label is far too long for this tiny shape" style="fontSize=14;fillColor=#ffffff;" vertex="1" parent="1"><mxGeometry x="20" y="20" width="60" height="20" as="geometry"/></mxCell><mxCell id="outside" value="Outside" style="fillColor=#eeeeee;" vertex="1" parent="1"><mxGeometry x="260" y="160" width="80" height="60" as="geometry"/></mxCell><mxCell id="todo" value="TODO" style="fillColor=#dddddd;" vertex="1" parent="1"><mxGeometry x="100" y="100" width="80" height="40" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`, "utf8");
const result = run(file) as { summary: { errors: number; warnings: number }; issues: Array<{ type: string; severity: string }> };
assert.ok(result.summary.errors >= 1);
assert.ok(result.summary.warnings >= 2);
assert.ok(result.issues.some((issue) => issue.type === "page_clipping"));
assert.ok(result.issues.some((issue) => issue.type === "text_overflow_risk"));
assert.ok(result.issues.some((issue) => issue.type === "placeholder_text"));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("quality skips page-bound checks when legacy diagrams omit page dimensions", () => {
const dir = mkdtempSync(join(tmpdir(), "drawio-quality-no-page-test-"));
const file = join(dir, "legacy.drawio");
try {
writeFileSync(file, `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="node" value="Node" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>`, "utf8");
const result = run(file) as { issues: Array<{ type: string }> };
assert.equal(result.issues.some((issue) => issue.type === "page_clipping" || issue.type === "connector_page_clipping"), false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("quality checks connector waypoints and measures density without double-counting nested shapes", () => {
const dir = mkdtempSync(join(tmpdir(), "drawio-quality-routing-test-"));
const file = join(dir, "quality.drawio");
try {
writeFileSync(file, `<mxGraphModel pageWidth="200" pageHeight="200"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="container" value="Container" style="swimlane;" vertex="1" parent="1"><mxGeometry x="20" y="20" width="120" height="120" as="geometry"/></mxCell><mxCell id="child" value="Child" vertex="1" parent="container"><mxGeometry x="0" y="0" width="120" height="120" as="geometry"/></mxCell><mxCell id="target" value="Target" vertex="1" parent="1"><mxGeometry x="150" y="80" width="40" height="40" as="geometry"/></mxCell><mxCell id="edge" edge="1" parent="1" source="child" target="target"><mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="220" y="100"/></Array></mxGeometry></mxCell></root></mxGraphModel>`, "utf8");
const result = run(file) as { issues: Array<{ type: string }> };
assert.ok(result.issues.some((issue) => issue.type === "connector_page_clipping"));
assert.equal(result.issues.some((issue) => issue.type === "page_density"), false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
+153
View File
@@ -0,0 +1,153 @@
import { parseAllPages, type Shape } from "../../services/drawio-parser/parser.js";
type Severity = "error" | "warning";
interface QualityIssue {
type: string;
severity: Severity;
pageIndex: number;
pageName: string;
shapeId?: string;
shapeLabel?: string;
edgeId?: string;
details?: Record<string, unknown>;
}
function styleValue(style: string, key: string): string | undefined {
const match = new RegExp(`(?:^|;)${key}=([^;]*)`, "i").exec(style);
return match?.[1];
}
function plainText(value: string): string {
return value.replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, "").replace(/&nbsp;/gi, " ").trim();
}
function textOverflowRisk(shape: Shape): { estimatedLines: number; charactersPerLine: number } | null {
const text = plainText(shape.label);
if (!text) return null;
const fontSize = Number(styleValue(shape.style, "fontSize") ?? 12);
const horizontalPadding = Number(styleValue(shape.style, "spacingLeft") ?? 8) + Number(styleValue(shape.style, "spacingRight") ?? 8);
const verticalPadding = Number(styleValue(shape.style, "spacingTop") ?? 4) + Number(styleValue(shape.style, "spacingBottom") ?? 4);
const usableWidth = Math.max(1, shape.width - horizontalPadding);
const usableHeight = Math.max(1, shape.height - verticalPadding);
const charactersPerLine = Math.max(1, Math.floor(usableWidth / (fontSize * 0.58)));
const explicitLines = text.split("\n");
const estimatedLines = explicitLines.reduce((total, line) => total + Math.max(1, Math.ceil(line.length / charactersPerLine)), 0);
const requiredHeight = estimatedLines * fontSize * 1.35;
return requiredHeight > usableHeight ? { estimatedLines, charactersPerLine } : null;
}
function unionArea(shapes: Shape[], pageWidth: number, pageHeight: number): number {
const rects = shapes.map((shape) => ({
left: Math.max(0, shape.x),
right: Math.min(pageWidth, shape.x2),
top: Math.max(0, shape.y),
bottom: Math.min(pageHeight, shape.y2),
})).filter((rect) => rect.right > rect.left && rect.bottom > rect.top);
const xs = [...new Set(rects.flatMap((rect) => [rect.left, rect.right]))].sort((a, b) => a - b);
let area = 0;
for (let index = 0; index < xs.length - 1; index += 1) {
const left = xs[index];
const right = xs[index + 1];
const intervals = rects.filter((rect) => rect.left < right && rect.right > left)
.map((rect) => [rect.top, rect.bottom] as const)
.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
let covered = 0;
let start: number | undefined;
let end: number | undefined;
for (const [top, bottom] of intervals) {
if (start === undefined) { start = top; end = bottom; continue; }
if (top > end!) { covered += end! - start; start = top; end = bottom; }
else end = Math.max(end!, bottom);
}
if (start !== undefined) covered += end! - start;
area += (right - left) * covered;
}
return area;
}
export function run(filePath: string): Record<string, unknown> {
const pages = parseAllPages(filePath);
const issues: QualityIssue[] = [];
for (const page of pages) {
const fills = new Set<string>();
for (const shape of page.shapes.values()) {
const fill = styleValue(shape.style, "fillColor");
if (fill && fill !== "none") fills.add(fill.toLowerCase());
if (page.pageWidth > 0 && page.pageHeight > 0 && (shape.x < 0 || shape.y < 0 || shape.x2 > page.pageWidth || shape.y2 > page.pageHeight)) {
issues.push({
type: "page_clipping",
severity: "error",
pageIndex: page.pageIndex,
pageName: page.pageName,
shapeId: shape.id,
shapeLabel: shape.label,
details: { bounds: { x: shape.x, y: shape.y, width: shape.width, height: shape.height }, page: { width: page.pageWidth, height: page.pageHeight } },
});
}
const overflow = textOverflowRisk(shape);
if (overflow) {
issues.push({
type: "text_overflow_risk",
severity: "warning",
pageIndex: page.pageIndex,
pageName: page.pageName,
shapeId: shape.id,
shapeLabel: shape.label,
details: overflow,
});
}
if (/\b(?:todo|tbd|placeholder|lorem ipsum)\b/i.test(plainText(shape.label))) {
issues.push({ type: "placeholder_text", severity: "warning", pageIndex: page.pageIndex, pageName: page.pageName, shapeId: shape.id, shapeLabel: shape.label });
}
const image = styleValue(shape.style, "image");
if (image && /^(?:https?:)?\/\//i.test(image)) {
issues.push({ type: "external_asset", severity: "warning", pageIndex: page.pageIndex, pageName: page.pageName, shapeId: shape.id, shapeLabel: shape.label, details: { image } });
}
}
if (page.pageWidth > 0 && page.pageHeight > 0) for (const edge of page.edges) {
const outside = edge.waypoints.filter((point) => point.x < 0 || point.y < 0 || point.x > page.pageWidth || point.y > page.pageHeight);
if (outside.length > 0) {
issues.push({
type: "connector_page_clipping",
severity: "error",
pageIndex: page.pageIndex,
pageName: page.pageName,
edgeId: edge.id,
details: { outsideWaypoints: outside, page: { width: page.pageWidth, height: page.pageHeight } },
});
}
}
if (fills.size > 3) {
issues.push({ type: "palette_spread", severity: "warning", pageIndex: page.pageIndex, pageName: page.pageName, details: { distinctFillColors: fills.size, recommendedMaximum: 3 } });
}
const pageArea = page.pageWidth * page.pageHeight;
const occupiedArea = unionArea([...page.shapes.values()], page.pageWidth, page.pageHeight);
if (pageArea > 0 && occupiedArea / pageArea > 0.7) {
issues.push({ type: "page_density", severity: "warning", pageIndex: page.pageIndex, pageName: page.pageName, details: { occupiedRatio: Math.round((occupiedArea / pageArea) * 1000) / 1000, maximum: 0.7 } });
}
if (page.shapes.size > 0 && page.edges.length / page.shapes.size > 3) {
issues.push({ type: "edge_density", severity: "warning", pageIndex: page.pageIndex, pageName: page.pageName, details: { edgesPerShape: Math.round((page.edges.length / page.shapes.size) * 100) / 100, maximum: 3 } });
}
}
return {
action: "quality",
file: filePath,
summary: {
pages: pages.length,
shapes: pages.reduce((total, page) => total + page.shapes.size, 0),
edges: pages.reduce((total, page) => total + page.edges.length, 0),
errors: issues.filter((issue) => issue.severity === "error").length,
warnings: issues.filter((issue) => issue.severity === "warning").length,
},
issues,
};
}
@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { run as build } from "../build/action.js";
import { run as validate } from "./action.js";
test("validate accepts Draw.io generated with a standard XML declaration", () => {
const dir = mkdtempSync(join(tmpdir(), "drawio-validate-test-"));
const spec = join(dir, "spec.yaml");
const diagram = join(dir, "diagram.drawio");
try {
writeFileSync(spec, "version: 1\nnodes:\n - { id: node, label: Node }\n", "utf8");
build(spec, 0, diagram);
const result = validate(diagram);
assert.deepEqual(result.summary, { pages: 1, valid: true, invalidPages: 0 });
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
+2 -45
View File
@@ -26,30 +26,9 @@
* - Base64/deflate encoding errors in the <diagram> body
*/
import { readFileSync } from "node:fs";
import { JSDOM } from "jsdom";
import { inflateRaw } from "pako";
import { loadGraphStates } from "../../services/maxgraph-loader/graph-loader.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getAttr(tagStr: string, attr: string): string {
const re = new RegExp(`\\b${attr}\\s*=\\s*(?:"([^"]*?)"|'([^']*?)')`, "i");
const m = re.exec(tagStr);
return m ? (m[1] ?? m[2] ?? "") : "";
}
function decodeDiagramContent(content: string): string {
try {
const decoded = Buffer.from(content.trim(), "base64");
const decompressed = inflateRaw(decoded);
return decodeURIComponent(new TextDecoder("utf-8").decode(decompressed));
} catch {
return content; // already plain XML
}
}
import { parseAllPages } from "../../services/drawio-parser/parser.js";
interface PageResult {
pageIndex: number;
@@ -82,29 +61,7 @@ function checkXmlWellFormed(xml: string): string | null {
// ---------------------------------------------------------------------------
export function run(filePath: string): Record<string, unknown> {
const raw = readFileSync(filePath, "utf-8").trim();
// Extract pages (support both mxfile wrapper and bare mxGraphModel)
const pages: Array<{ name: string; xml: string }> = [];
if (/^<mxfile/i.test(raw)) {
const re = /<diagram([^>]*)>([\s\S]*?)<\/diagram>/gi;
let match: RegExpExecArray | null;
let idx = 0;
while ((match = re.exec(raw)) !== null) {
const name = getAttr(match[1], "name") || `Page ${idx + 1}`;
const xml = decodeDiagramContent(match[2].trim());
pages.push({ name, xml });
idx++;
}
if (pages.length === 0) {
throw new Error("No <diagram> elements found in mxfile");
}
} else if (/^<mxGraphModel/i.test(raw)) {
pages.push({ name: "Page 1", xml: raw });
} else {
throw new Error(`Unrecognised draw.io file format in ${filePath}`);
}
const pages = parseAllPages(filePath).map((page) => ({ name: page.pageName, xml: page.graphModelXml }));
const results: PageResult[] = [];