Restructure to skill-manager conventions

- Move the TypeScript project under scripts/ (src, tsconfig,
  package.json, pnpm lockfile/workspace, canonical .gitignore);
  drop the npm package-lock
- Add scripts/Taskfile.yml aggregator plus .scripts modules
  (loggers, base, cli) with build, run, and validate tasks
- Move the six SKILL-*.md docs into references/ with kebab names
  and extract the connector-routing sections from SKILL.md into
  references/routing-best-practices.md (SKILL.md 666 -> ~310 lines)
- Add license/metadata/compatibility frontmatter, an Available
  scripts section, and update all CLI paths in README and references

skill-manager validate: 13/13 passed, 0 warnings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 15:20:26 +03:00
co-authored by Claude Fable 5
parent 99f1fd07e3
commit 77655d1d97
72 changed files with 1089 additions and 1828 deletions
@@ -0,0 +1,106 @@
/**
* Action: page-connectors-summary
*
* Returns a summary of all connectors (edges) on a page:
* - Total connector count
* - Per-connector: id, label, source, target, waypoint count, type (directed/undirected/floating),
* routed exit point (first routed coordinate), routed entry point (last routed coordinate)
* - Aggregated stats: labelled vs unlabelled, with waypoints vs straight, floating (missing endpoint)
* - Per-shape port summary: for each shape that is a source or target of 2+ connectors,
* lists the actual exit/entry coordinates to help verify single-port rule compliance.
*/
import { parseDiagram } from "../../services/drawio-parser/parser.js";
import { routeAllEdges } from "../../services/connector-router/connector-router.js";
export function run(filePath: string): Record<string, unknown> {
const { edges, shapes, graphModelXml } = parseDiagram(filePath);
// Get routed paths for all edges
const routeMap = routeAllEdges(graphModelXml);
const connectors = edges.map((e) => {
const hasSource = e.sourceId !== null && shapes.has(e.sourceId);
const hasTarget = e.targetId !== null && shapes.has(e.targetId);
let connectorType: string;
if (hasSource && hasTarget) {
connectorType = "directed";
} else if (!hasSource && !hasTarget) {
connectorType = "floating";
} else {
connectorType = "partial"; // one endpoint missing
}
const pts = routeMap.get(e.id) ?? [];
const exitPt = pts.length >= 1 ? { x: Math.round(pts[0].x * 10) / 10, y: Math.round(pts[0].y * 10) / 10 } : null;
const entryPt = pts.length >= 1 ? { x: Math.round(pts[pts.length - 1].x * 10) / 10, y: Math.round(pts[pts.length - 1].y * 10) / 10 } : null;
return {
id: e.id,
label: e.label,
sourceId: e.sourceId,
sourceLabel: e.sourceId ? (shapes.get(e.sourceId)?.label ?? null) : null,
targetId: e.targetId,
targetLabel: e.targetId ? (shapes.get(e.targetId)?.label ?? null) : null,
waypointCount: e.waypoints.length,
type: connectorType,
exitPoint: exitPt,
entryPoint: entryPt,
};
});
// Build per-shape port map — only for shapes with 2+ connectors on a side
const exitsByShape = new Map<string, Array<{ edgeId: string; label: string; pt: { x: number; y: number } }>>();
const entriesByShape = new Map<string, Array<{ edgeId: string; label: string; pt: { x: number; y: number } }>>();
for (const c of connectors) {
if (c.sourceId && c.exitPoint) {
const list = exitsByShape.get(c.sourceId) ?? [];
list.push({ edgeId: c.id, label: c.label, pt: c.exitPoint });
exitsByShape.set(c.sourceId, list);
}
if (c.targetId && c.entryPoint) {
const list = entriesByShape.get(c.targetId) ?? [];
list.push({ edgeId: c.id, label: c.label, pt: c.entryPoint });
entriesByShape.set(c.targetId, list);
}
}
const shapePorts: Record<string, unknown>[] = [];
const allShapeIds = new Set([...exitsByShape.keys(), ...entriesByShape.keys()]);
for (const shapeId of allShapeIds) {
const exits = exitsByShape.get(shapeId) ?? [];
const entries = entriesByShape.get(shapeId) ?? [];
if (exits.length < 2 && entries.length < 2) continue; // only shapes with multiple connectors on a side
shapePorts.push({
shapeId,
shapeLabel: shapes.get(shapeId)?.label ?? shapeId,
exits,
entries,
});
}
const labelled = connectors.filter((c) => c.label.trim() !== "").length;
const withWaypts = connectors.filter((c) => c.waypointCount > 0).length;
const floating = connectors.filter((c) => c.type === "floating").length;
const partial = connectors.filter((c) => c.type === "partial").length;
const directed = connectors.filter((c) => c.type === "directed").length;
return {
action: "page-connectors-summary",
file: filePath,
summary: {
total: edges.length,
directed,
partial,
floating,
labelled,
unlabelled: edges.length - labelled,
withWaypoints: withWaypts,
straight: edges.length - withWaypts,
},
connectors,
shapePorts,
};
}
@@ -0,0 +1,428 @@
/**
* Action: page-connectors-validation
*
* Checks a .drawio file for five categories of layout defects:
* 1. Connector-shape overlaps — a connector segment passes through a shape
* that is not its own source or target.
* 2. Connector crossings — two connector segments intersect at an interior point.
* 3. Single-port violations — multiple outgoing connectors from the same shape
* exit at different coordinates, OR multiple incoming connectors to the same
* shape enter at different coordinates.
* 4. Corner-port violations — a connector's first routed point (exit) or last routed
* point (entry) coincides with a corner of its source/target shape.
* 5. Header-edge violations — a connector segment runs along (touches) the bottom
* edge of a swimlane/container header bar. The bottom edge of a swimlane header
* is the visual line at y + startSize. Connectors must not touch or cross this line
* because it visually merges with the header border, making the diagram hard to read.
*
* NOTE on 'y' in YAML output:
* js-yaml (YAML 1.1 mode) quotes bare `y` keys as `'y'` because `y` is a YAML 1.1
* boolean alias. To avoid this, horizontal segment Y coordinates are stored under
* the key `yCoord` instead of `y`.
*/
import { parseDiagram, Shape, Edge } from "../../services/drawio-parser/parser.js";
import { routeAllEdges, RoutedPoint } from "../../services/connector-router/connector-router.js";
// ---------------------------------------------------------------------------
// Tolerance
// ---------------------------------------------------------------------------
const ENDPOINT_TOL = 1; // px — grace at connector endpoints touching own source/target
const HEADER_EDGE_TOL = 1; // px — how close a segment can be to the header-bottom line
// ---------------------------------------------------------------------------
// Segment types
// NOTE: HSegment uses `yCoord` (not `y`) to avoid YAML 1.1 boolean quoting of `y`
// ---------------------------------------------------------------------------
interface HSegment { type: "H"; x1: number; x2: number; yCoord: number }
interface VSegment { type: "V"; y1: number; y2: number; x: number }
interface DSegment { type: "D"; x1: number; y1: number; x2: number; y2: number }
type Segment = HSegment | VSegment | DSegment;
// ---------------------------------------------------------------------------
// Build segment list from routed points
// ---------------------------------------------------------------------------
const SEG_TOL = 0.5;
function pointsToSegments(pts: RoutedPoint[]): Segment[] {
const segs: Segment[] = [];
for (let i = 0; i < pts.length - 1; i++) {
const { x: x1, y: y1 } = pts[i];
const { x: x2, y: y2 } = pts[i + 1];
if (Math.abs(y1 - y2) <= SEG_TOL) {
segs.push({ type: "H", x1, x2, yCoord: (y1 + y2) / 2 });
} else if (Math.abs(x1 - x2) <= SEG_TOL) {
segs.push({ type: "V", y1, y2, x: (x1 + x2) / 2 });
} else {
segs.push({ type: "D", x1, y1, x2, y2 });
}
}
return segs;
}
// Helper to get x1/x2/y1/y2 from any segment
function segBounds(seg: Segment): { x1: number; y1: number; x2: number; y2: number } {
if (seg.type === "H") return { x1: seg.x1, y1: seg.yCoord, x2: seg.x2, y2: seg.yCoord };
if (seg.type === "V") return { x1: seg.x, y1: seg.y1, x2: seg.x, y2: seg.y2 };
return { x1: seg.x1, y1: seg.y1, x2: seg.x2, y2: seg.y2 };
}
// ---------------------------------------------------------------------------
// Segment vs shape overlap
// ---------------------------------------------------------------------------
function segmentOverlapsShape(seg: Segment, shape: Shape, isEndpointSeg: boolean): boolean {
const { x: sx1, y: sy1, x2: sx2, y2: sy2 } = shape;
const tol = isEndpointSeg ? ENDPOINT_TOL : 0;
if (seg.type === "H") {
const Y = seg.yCoord;
const xA = Math.min(seg.x1, seg.x2);
const xB = Math.max(seg.x1, seg.x2);
return (sy1 + tol) < Y && Y < (sy2 - tol) &&
Math.max(xA, sx1) < Math.min(xB, sx2);
}
if (seg.type === "V") {
const X = seg.x;
const yA = Math.min(seg.y1, seg.y2);
const yB = Math.max(seg.y1, seg.y2);
return (sx1 + tol) < X && X < (sx2 - tol) &&
Math.max(yA, sy1) < Math.min(yB, sy2);
}
return liangBarskyIntersects(
seg.x1, seg.y1, seg.x2, seg.y2,
sx1 + tol, sy1 + tol, sx2 - tol, sy2 - tol
);
}
function liangBarskyIntersects(
ax: number, ay: number, bx: number, by: number,
sx1: number, sy1: number, sx2: number, sy2: number
): boolean {
const dx = bx - ax;
const dy = by - ay;
const p = [-dx, dx, -dy, dy];
const q = [ax - sx1, sx2 - ax, ay - sy1, sy2 - ay];
let t0 = 0, t1 = 1;
for (let i = 0; i < 4; i++) {
if (p[i] === 0) {
if (q[i] < 0) return false;
} else if (p[i] < 0) {
t0 = Math.max(t0, q[i] / p[i]);
} else {
t1 = Math.min(t1, q[i] / p[i]);
}
if (t0 > t1) return false;
}
return true;
}
// ---------------------------------------------------------------------------
// Segment–segment crossing
// ---------------------------------------------------------------------------
function segmentsCross(a: Segment, b: Segment): [number, number] | null {
const { x1, y1, x2, y2 } = segBounds(a);
const { x1: x3, y1: y3, x2: x4, y2: y4 } = segBounds(b);
const denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
if (Math.abs(denom) < 1e-9) return null;
const t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom;
const u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom;
const eps = 0.001;
if (t > eps && t < 1 - eps && u > eps && u < 1 - eps) {
const ix = x1 + t * (x2 - x1);
const iy = y1 + t * (y2 - y1);
return [Math.round(ix * 10) / 10, Math.round(iy * 10) / 10];
}
return null;
}
// ---------------------------------------------------------------------------
// Header-edge overlap: does a segment touch the bottom edge of a swimlane header?
//
// A swimlane with startSize=S has its header in the band [shape.y .. shape.y+S].
// The bottom edge of the header is the line at Y = shape.y + S.
// A connector must NOT run along (be coincident with) this line.
//
// Detection: a horizontal segment at yCoord ≈ (shape.y + shape.startSize) that
// overlaps the horizontal span of the shape is a violation.
// A vertical segment that passes through this line (y range straddles it) is also flagged.
// ---------------------------------------------------------------------------
function segmentTouchesHeaderEdge(seg: Segment, shape: Shape): boolean {
if (shape.startSize <= 0) return false; // not a swimlane
const headerBottomY = shape.y + shape.startSize;
const sx1 = shape.x;
const sx2 = shape.x2;
if (seg.type === "H") {
const Y = seg.yCoord;
// Is this segment running along the header-bottom line?
if (Math.abs(Y - headerBottomY) <= HEADER_EDGE_TOL) {
const xA = Math.min(seg.x1, seg.x2);
const xB = Math.max(seg.x1, seg.x2);
// Does it overlap the horizontal span of the swimlane?
return Math.max(xA, sx1) < Math.min(xB, sx2);
}
}
if (seg.type === "V") {
const X = seg.x;
const yA = Math.min(seg.y1, seg.y2);
const yB = Math.max(seg.y1, seg.y2);
// Does this vertical segment cross the header-bottom line while inside the swimlane's X span?
if (yA < headerBottomY - HEADER_EDGE_TOL && yB > headerBottomY + HEADER_EDGE_TOL) {
return (sx1 + HEADER_EDGE_TOL) < X && X < (sx2 - HEADER_EDGE_TOL);
}
}
return false;
}
// ---------------------------------------------------------------------------
// Main action
// ---------------------------------------------------------------------------
export function run(filePath: string): Record<string, unknown> {
const { shapes, edges, graphModelXml } = parseDiagram(filePath);
const routeMap = routeAllEdges(graphModelXml);
const issues: Record<string, unknown>[] = [];
const edgeSegList: Array<{ edge: Edge; segs: Segment[]; pts: RoutedPoint[] }> = [];
for (const edge of edges) {
const pts = routeMap.get(edge.id) ?? [];
const segs = pointsToSegments(pts);
edgeSegList.push({ edge, segs, pts });
}
// -------------------------------------------------------------------------
// 1. Connector-shape overlaps
// -------------------------------------------------------------------------
for (const { edge, segs } of edgeSegList) {
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 (isFirst && shapeId === edge.sourceId) continue;
if (isLast && shapeId === edge.targetId) continue;
if (shapeId === edge.sourceId || shapeId === edge.targetId) {
if (!segmentOverlapsShape(seg, shape, true)) continue;
}
const isEndpointSeg = isFirst || isLast;
if (segmentOverlapsShape(seg, shape, isEndpointSeg)) {
issues.push({
type: "connector_shape_overlap",
edgeId: edge.id,
edgeLabel: edge.label,
shapeId,
shapeLabel: shape.label,
segment: seg,
});
}
}
}
}
// -------------------------------------------------------------------------
// 2. Connector crossings
// -------------------------------------------------------------------------
const seenPairs = new Set<string>();
for (let i = 0; i < edgeSegList.length; i++) {
const { edge: ea, segs: segsA } = edgeSegList[i];
for (let j = i + 1; j < edgeSegList.length; j++) {
const { edge: eb, segs: segsB } = edgeSegList[j];
const pairKey = ea.id < eb.id ? `${ea.id}|${eb.id}` : `${eb.id}|${ea.id}`;
if (seenPairs.has(pairKey)) continue;
outer:
for (const sa of segsA) {
for (const sb of segsB) {
const pt = segmentsCross(sa, sb);
if (pt) {
seenPairs.add(pairKey);
issues.push({
type: "connector_crossing",
edgeAId: ea.id,
edgeBId: eb.id,
point: { x: pt[0], yCoord: pt[1] },
});
break outer;
}
}
}
}
}
// -------------------------------------------------------------------------
// 3. Single-port violations
// -------------------------------------------------------------------------
const PORT_TOL = 2;
const exitsByShape = new Map<string, Array<{ edgeId: string; pt: RoutedPoint }>>();
for (const { edge, pts } of edgeSegList) {
if (edge.sourceId && pts.length >= 1) {
const list = exitsByShape.get(edge.sourceId) ?? [];
list.push({ edgeId: edge.id, pt: pts[0] });
exitsByShape.set(edge.sourceId, list);
}
}
const entriesByShape = new Map<string, Array<{ edgeId: string; pt: RoutedPoint }>>();
for (const { edge, pts } of edgeSegList) {
if (edge.targetId && pts.length >= 1) {
const list = entriesByShape.get(edge.targetId) ?? [];
list.push({ edgeId: edge.id, pt: pts[pts.length - 1] });
entriesByShape.set(edge.targetId, list);
}
}
function pointsMatch(a: RoutedPoint, b: RoutedPoint): boolean {
return Math.abs(a.x - b.x) <= PORT_TOL && Math.abs(a.y - b.y) <= PORT_TOL;
}
function checkPortViolation(
shapeId: string,
portType: "exit" | "entry",
entries: Array<{ edgeId: string; pt: RoutedPoint }>
) {
if (entries.length < 2) return;
const ref = entries[0].pt;
const offenders = entries.filter((e) => !pointsMatch(e.pt, ref));
if (offenders.length > 0) {
const shape = shapes.get(shapeId);
issues.push({
type: "single_port_violation",
portType,
shapeId,
shapeLabel: shape?.label ?? shapeId,
referencePoint: { x: Math.round(ref.x * 10) / 10, yCoord: Math.round(ref.y * 10) / 10 },
referenceEdgeId: entries[0].edgeId,
violatingEdges: offenders.map((e) => ({
edgeId: e.edgeId,
point: { x: Math.round(e.pt.x * 10) / 10, yCoord: Math.round(e.pt.y * 10) / 10 },
})),
});
}
}
for (const [shapeId, list] of exitsByShape) {
checkPortViolation(shapeId, "exit", list);
}
for (const [shapeId, list] of entriesByShape) {
checkPortViolation(shapeId, "entry", list);
}
// -------------------------------------------------------------------------
// 4. Corner-port violations
// -------------------------------------------------------------------------
const CORNER_TOL = 2;
function isShapeCorner(pt: RoutedPoint, shape: Shape): boolean {
const nearX1 = Math.abs(pt.x - shape.x) <= CORNER_TOL;
const nearX2 = Math.abs(pt.x - shape.x2) <= CORNER_TOL;
const nearY1 = Math.abs(pt.y - shape.y) <= CORNER_TOL;
const nearY2 = Math.abs(pt.y - shape.y2) <= CORNER_TOL;
return (nearX1 || nearX2) && (nearY1 || nearY2);
}
for (const { edge, pts } of edgeSegList) {
if (pts.length === 0) continue;
if (edge.sourceId) {
const shape = shapes.get(edge.sourceId);
if (shape && isShapeCorner(pts[0], shape)) {
issues.push({
type: "corner_port_violation",
portType: "exit",
edgeId: edge.id,
edgeLabel: edge.label,
shapeId: edge.sourceId,
shapeLabel: shape.label,
point: { x: Math.round(pts[0].x * 10) / 10, yCoord: Math.round(pts[0].y * 10) / 10 },
suggestion: "Move exit point to bottom-center, top-center, left-center, or right-center of the source shape",
});
}
}
if (edge.targetId) {
const shape = shapes.get(edge.targetId);
const lastPt = pts[pts.length - 1];
if (shape && isShapeCorner(lastPt, shape)) {
issues.push({
type: "corner_port_violation",
portType: "entry",
edgeId: edge.id,
edgeLabel: edge.label,
shapeId: edge.targetId,
shapeLabel: shape.label,
point: { x: Math.round(lastPt.x * 10) / 10, yCoord: Math.round(lastPt.y * 10) / 10 },
suggestion: "Move entry point to bottom-center, top-center, left-center, or right-center of the target shape",
});
}
}
}
// -------------------------------------------------------------------------
// 5. Header-edge violations
//
// A connector must not run along (be coincident with) the bottom edge of a
// swimlane/container header bar. This line is at shape.y + shape.startSize.
// Running along this line visually merges with the header border.
// -------------------------------------------------------------------------
for (const { edge, segs } of edgeSegList) {
for (const seg of segs) {
for (const [shapeId, shape] of shapes) {
if (shape.startSize <= 0) continue; // skip non-swimlane shapes
// Skip own source/target
if (shapeId === edge.sourceId || shapeId === edge.targetId) continue;
if (segmentTouchesHeaderEdge(seg, shape)) {
issues.push({
type: "connector_header_edge_overlap",
edgeId: edge.id,
edgeLabel: edge.label,
shapeId,
shapeLabel: shape.label,
headerBottomY: shape.y + shape.startSize,
segment: seg,
suggestion: `Reroute connector to avoid running along the header bottom line (y=${shape.y + shape.startSize}) of '${shape.label}'`,
});
}
}
}
}
const overlaps = issues.filter((i) => i["type"] === "connector_shape_overlap").length;
const crossings = issues.filter((i) => i["type"] === "connector_crossing").length;
const singlePortViolations = issues.filter((i) => i["type"] === "single_port_violation").length;
const cornerPortViolations = issues.filter((i) => i["type"] === "corner_port_violation").length;
const headerEdgeViolations = issues.filter((i) => i["type"] === "connector_header_edge_overlap").length;
return {
action: "page-connectors-validation",
file: filePath,
summary: {
connectorShapeOverlaps: overlaps,
connectorCrossings: crossings,
singlePortViolations,
cornerPortViolations,
headerEdgeViolations,
totalIssues: issues.length,
},
issues,
};
}
@@ -0,0 +1,100 @@
/**
* Action: page-hierarchy-full
*
* Extends page-hierarchy with full geometry for every shape at each nesting level.
*
* Reuses the shared hierarchy-builder service (same logic as page-hierarchy).
*
* "Levels" = containment depth levels in the shape hierarchy (diagramming term):
* level 1 (depth 0) = root-level shapes (parentId === "1")
* level 2 (depth 1) = shapes whose parent is at level 1
* level N (depth N-1) = shapes nested N-1 levels deep
*
* Output structure:
* summary:
* levelsTotal — number of distinct nesting levels
* shapesTotal — total shape count
* shapesPerLevel — { levelNumber: count }
* levels:
* - number — 1-based nesting level (1 = root, 2 = children, …)
* shapeCount
* shapes:
* - id, label, x, y, width, height, parentId
*
* IMPORTANT: Parser returns absolute canvas coordinates for all shapes.
*/
import { parseAllPages } from "../../services/drawio-parser/parser.js";
import { buildHierarchy } from "../../services/hierarchy-builder/hierarchy-builder.js";
import type { Shape } from "../../services/drawio-parser/parser.js";
interface ShapeEntry {
id: string;
label: string;
x: number;
y: number;
width: number;
height: number;
parentId: string;
}
interface NestingLevel {
number: number;
shapeCount: number;
shapes: ShapeEntry[];
}
export function run(
filePath: string,
pageIndex: number = 0
): Record<string, unknown> {
const pages = parseAllPages(filePath);
const page = pages[pageIndex];
if (!page) {
return { error: true, message: `Page index ${pageIndex} not found` };
}
// Reuse shared hierarchy builder
const { depthMap, maxDepth, totalLevels } = buildHierarchy(page);
const allShapes = Array.from(page.shapes.values());
// Group shapes by depth
const levelMap = new Map<number, Shape[]>();
for (const s of allShapes) {
const d = depthMap.get(s.id);
if (d === undefined) continue;
if (!levelMap.has(d)) levelMap.set(d, []);
levelMap.get(d)!.push(s);
}
const levels: NestingLevel[] = [];
for (let d = 0; d <= maxDepth; d++) {
const shapes = (levelMap.get(d) ?? []).sort((a, b) => a.y - b.y || a.x - b.x);
levels.push({
number: d + 1,
shapeCount: shapes.length,
shapes: shapes.map((s) => ({
id: s.id,
label: s.label.replace(/\n/g, " / "),
x: s.x,
y: s.y,
width: s.width,
height: s.height,
parentId: s.parentId,
})),
});
}
return {
action: "page-hierarchy-full",
file: filePath,
pageIndex,
summary: {
levelsTotal: totalLevels,
shapesTotal: allShapes.length,
shapesPerLevel: Object.fromEntries(levels.map((l) => [l.number, l.shapeCount])),
},
levels,
};
}
@@ -0,0 +1,36 @@
/**
* Action: page-hierarchy
*
* Reads the parentId of every shape and builds a recursive containment tree.
*
* Depth levels:
* 0 — layers / top-level containers (direct children of the canvas root, cell id "1")
* 1 — subsystems / groups inside a layer
* 2+ — leaf components and nested elements
*
* Summary includes levelsTotal — the number of distinct nesting levels in the diagram.
*/
import { parseAllPages } from "../../services/drawio-parser/parser.js";
import { buildHierarchy } from "../../services/hierarchy-builder/hierarchy-builder.js";
export function run(filePath: string, pageIndex: number = 0): Record<string, unknown> {
const pages = parseAllPages(filePath);
const page = pages[pageIndex];
if (!page) {
return { error: true, message: `Page index ${pageIndex} not found` };
}
const { tree, totalLevels, depthCounts } = buildHierarchy(page);
return {
action: "page-hierarchy",
file: filePath,
summary: {
shapesTotal: page.shapes.size,
levelsTotal: totalLevels,
depthCounts,
},
tree,
};
}
@@ -0,0 +1,77 @@
/**
* Action: page-labels-validation
*
* Validates shape labels on a single page and reports three categories of issues:
* 1. empty_label — shape has no label (or whitespace only)
* 2. duplicate_label — two or more shapes share the same label
* 3. long_label — label exceeds MAX_LABEL_LENGTH characters
*
* Edges are excluded (connectors often have no label by design).
*/
import { parseDiagram } from "../../services/drawio-parser/parser.js";
const MAX_LABEL_LENGTH = 80;
export function run(filePath: string): Record<string, unknown> {
const { shapes } = parseDiagram(filePath);
const issues: Record<string, unknown>[] = [];
// Track label → [ids] for duplicate detection
const labelIndex = new Map<string, string[]>();
for (const [id, shape] of shapes) {
const label = shape.label?.trim() ?? "";
// 1. Empty label
if (label === "") {
issues.push({ type: "empty_label", shapeId: id });
continue; // skip further checks for unlabelled shapes
}
// 2. Long label
if (label.length > MAX_LABEL_LENGTH) {
issues.push({
type: "long_label",
shapeId: id,
label,
length: label.length,
maxAllowed: MAX_LABEL_LENGTH,
});
}
// Accumulate for duplicate check
if (!labelIndex.has(label)) labelIndex.set(label, []);
labelIndex.get(label)!.push(id);
}
// 3. Duplicate labels
for (const [label, ids] of labelIndex) {
if (ids.length > 1) {
issues.push({
type: "duplicate_label",
label,
shapeIds: ids,
count: ids.length,
});
}
}
const empty = issues.filter((i) => i["type"] === "empty_label").length;
const duplicate = issues.filter((i) => i["type"] === "duplicate_label").length;
const long = issues.filter((i) => i["type"] === "long_label").length;
return {
action: "page-labels-validation",
file: filePath,
config: { maxLabelLength: MAX_LABEL_LENGTH },
summary: {
emptyLabels: empty,
duplicateLabels: duplicate,
longLabels: long,
totalIssues: issues.length,
},
issues,
};
}
@@ -0,0 +1,411 @@
/**
* Action: page-negative-space-summary
*
* For each nesting level in the diagram, computes the "negative space" —
* the free horizontal corridors (X ranges) not occupied by any shape.
*
* This is useful for connector routing: a vertical connector can pass through
* a level without crossing shapes only if its X coordinate falls within one of
* the free corridors at that level.
*
* Uses page-hierarchy-full logic (via hierarchy-builder) to enumerate all levels
* and shapes per level, then uses maxGraph-computed absolute bounds (via
* loadGraphStates / vertexBounds) for accurate positions.
*
* TEXT-AWARE NEGATIVE SPACE
* ─────────────────────────
* Shapes report both their bounding-box occupied range AND the estimated text
* region within that box. For swimlane headers (and any shape whose text does
* not fill the full width) the flanking areas inside the bbox are free space.
*
* Text-width estimation formula:
* charWidth = fontSize × 0.6 (avg glyph width for proportional fonts)
* rawWidth = charCount × charWidth
* padding = fontSize × 1.0 (horizontal padding: ~0.5 em each side)
* textWidth = rawWidth + padding
*
* fontStyle flags (draw.io): 1=bold(+10%), 2=italic(+5%), 4=underline(no effect)
*
* Centering:
* textXMin = shapeCenterX − textWidth/2
* textXMax = shapeCenterX + textWidth/2
* (clamped to shape bbox)
*
* Output structure:
* summary:
* levelsTotal — number of distinct nesting levels
* diagramXMin — leftmost X of diagram canvas (from shapes)
* diagramXMax — rightmost X of diagram canvas (from shapes)
* levels:
* - number — 1-based nesting level (1 = root containers, 2 = children, …)
* yMin — top of this level's Y band
* yMax — bottom of this level's Y band
* shapeCount
* shapes:
* - id, label, xMin, xMax, yMin, yMax,
* textXMin, textXMax, textWidth ← estimated text rendering region
* textFlankLeft ← free space left of text inside bbox
* textFlankRight ← free space right of text inside bbox
* rows: — shapes grouped by distinct Y row (shapes at same Y band)
* - rowIndex
* yMin, yMax
* shapeCount
* occupiedRanges: — merged X ranges occupied by shapes in this row
* - { xMin, xMax }
* freeCorridors: — X ranges NOT occupied in this row (negative space)
* - { xMin, xMax, midX, width }
* freeCorridorCount
* textAwareFreeCorridors: — free corridors using text regions instead of full bboxes
* - { xMin, xMax, midX, width }
* occupiedRanges: — merged X ranges across ALL shapes in this level
* - { xMin, xMax }
* freeCorridors: — X ranges NOT occupied by any shape across entire level
* - { xMin, xMax, midX, width }
* freeCorridorCount
*
* IMPORTANT: Absolute canvas coordinates are used throughout (not relative to parent).
*/
import { parseAllPages } from "../../services/drawio-parser/parser.js";
import { loadGraphStates } from "../../services/maxgraph-loader/graph-loader.js";
import { buildHierarchy } from "../../services/hierarchy-builder/hierarchy-builder.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface ShapeEntry {
id: string;
label: string;
xMin: number;
xMax: number;
yMin: number;
yMax: number;
fontSize: number;
fontStyle: number;
textXMin: number;
textXMax: number;
textWidth: number;
textFlankLeft: number;
textFlankRight: number;
}
interface XRange {
xMin: number;
xMax: number;
}
interface FreeCorridor {
xMin: number;
xMax: number;
midX: number;
width: number;
}
interface RowEntry {
rowIndex: number;
yMin: number;
yMax: number;
shapeCount: number;
occupiedRanges: XRange[];
freeCorridors: FreeCorridor[];
freeCorridorCount: number;
textAwareFreeCorridors: FreeCorridor[];
}
interface LevelEntry {
number: number;
yMin: number;
yMax: number;
shapeCount: number;
shapes: ShapeEntry[];
rows: RowEntry[];
occupiedRanges: XRange[];
freeCorridors: FreeCorridor[];
freeCorridorCount: number;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Estimate the rendered text width for a draw.io label.
*
* Formula:
* charWidth = fontSize × 0.6 (avg glyph width for proportional fonts)
* rawWidth = charCount × charWidth
* padding = fontSize × 1.0 (horizontal padding ~0.5em each side)
* textWidth = rawWidth + padding
*
* fontStyle flags: 1=bold (+10%), 2=italic (+5%)
*
* Returns the estimated width in diagram units (px).
*/
function estimateTextWidth(label: string, fontSize: number, fontStyle: number): number {
const text = label.replace(/&#xa;|<br\s*\/?>/gi, " ").replace(/<[^>]+>/g, "");
const lines = text.split(/[\n\/]/).map((l) => l.trim()).filter((l) => l.length > 0);
// Use the longest line for width estimation
const maxLen = Math.max(...lines.map((l) => l.length), 0);
let charWidth = fontSize * 0.6;
if (fontStyle & 1) charWidth *= 1.10; // bold
if (fontStyle & 2) charWidth *= 1.05; // italic
const rawWidth = maxLen * charWidth;
const padding = fontSize * 1.0;
return Math.ceil(rawWidth + padding);
}
/**
* Compute the estimated text X range within a shape bbox (centered alignment).
* Returns { textXMin, textXMax, textWidth } clamped to [xMin, xMax].
*/
function textRegion(
xMin: number,
xMax: number,
label: string,
fontSize: number,
fontStyle: number
): { textXMin: number; textXMax: number; textWidth: number } {
const tw = estimateTextWidth(label, fontSize, fontStyle);
const centerX = (xMin + xMax) / 2;
const textXMin = Math.max(xMin, Math.round(centerX - tw / 2));
const textXMax = Math.min(xMax, Math.round(centerX + tw / 2));
return { textXMin, textXMax, textWidth: textXMax - textXMin };
}
/**
* Extract fontSize and fontStyle from a draw.io style string.
* Defaults: fontSize=11, fontStyle=0
*/
function parseTextStyle(style: string = ""): { fontSize: number; fontStyle: number } {
const fsMatch = style.match(/fontSize=(\d+)/);
const fstMatch = style.match(/fontStyle=(\d+)/);
return {
fontSize: fsMatch ? parseInt(fsMatch[1], 10) : 11,
fontStyle: fstMatch ? parseInt(fstMatch[1], 10) : 0,
};
}
/**
* Merge overlapping or adjacent X ranges into a minimal set of disjoint ranges.
* Input ranges do not need to be sorted.
*/
function mergeRanges(ranges: XRange[]): XRange[] {
if (ranges.length === 0) return [];
const sorted = [...ranges].sort((a, b) => a.xMin - b.xMin);
const merged: XRange[] = [{ ...sorted[0] }];
for (let i = 1; i < sorted.length; i++) {
const last = merged[merged.length - 1];
if (sorted[i].xMin <= last.xMax) {
last.xMax = Math.max(last.xMax, sorted[i].xMax);
} else {
merged.push({ ...sorted[i] });
}
}
return merged;
}
/**
* Group shapes into Y-row clusters: shapes whose Y ranges overlap form one row.
* Returns clusters sorted by yMin.
*/
function groupIntoRows(shapes: ShapeEntry[]): ShapeEntry[][] {
if (shapes.length === 0) return [];
const sorted = [...shapes].sort((a, b) => a.yMin - b.yMin);
const rows: ShapeEntry[][] = [[sorted[0]]];
let rowYMax = sorted[0].yMax;
for (let i = 1; i < sorted.length; i++) {
const s = sorted[i];
if (s.yMin < rowYMax) {
// Overlaps current row
rows[rows.length - 1].push(s);
rowYMax = Math.max(rowYMax, s.yMax);
} else {
// New row
rows.push([s]);
rowYMax = s.yMax;
}
}
return rows;
}
/**
* Compute free corridors between occupied ranges within [xMin, xMax].
* Only corridors wider than minWidth are returned.
*/
function freeCorridors(
occupied: XRange[],
diagramXMin: number,
diagramXMax: number,
minWidth = 5
): FreeCorridor[] {
const merged = mergeRanges(occupied);
const free: FreeCorridor[] = [];
let cursor = diagramXMin;
for (const seg of merged) {
if (seg.xMin > cursor + minWidth) {
const w = seg.xMin - cursor;
free.push({
xMin: cursor,
xMax: seg.xMin,
midX: Math.round((cursor + seg.xMin) / 2),
width: w,
});
}
cursor = Math.max(cursor, seg.xMax);
}
if (cursor < diagramXMax - minWidth) {
const w = diagramXMax - cursor;
free.push({
xMin: cursor,
xMax: diagramXMax,
midX: Math.round((cursor + diagramXMax) / 2),
width: w,
});
}
return free;
}
// ---------------------------------------------------------------------------
// Main action
// ---------------------------------------------------------------------------
export function run(
filePath: string,
pageIndex: number = 0
): Record<string, unknown> {
// --- Parse hierarchy (shapes with parentId / depth) ---
const pages = parseAllPages(filePath);
const page = pages[pageIndex];
if (!page) {
return { error: true, message: `Page index ${pageIndex} not found` };
}
const { depthMap, maxDepth, totalLevels } = buildHierarchy(page);
// --- Get maxGraph-computed absolute bounds for accuracy ---
// page.graphModelXml is the raw <mxGraphModel> XML for this page
const { vertexBounds } = loadGraphStates(page.graphModelXml);
// --- Compute diagram canvas extent from all vertex bounds ---
let diagramXMin = Infinity;
let diagramXMax = -Infinity;
let diagramYMin = Infinity;
let diagramYMax = -Infinity;
for (const b of vertexBounds.values()) {
diagramXMin = Math.min(diagramXMin, b.x);
diagramXMax = Math.max(diagramXMax, b.x + b.width);
diagramYMin = Math.min(diagramYMin, b.y);
diagramYMax = Math.max(diagramYMax, b.y + b.height);
}
// --- Group shapes by depth ---
const levelMap = new Map<number, ShapeEntry[]>();
for (const s of page.shapes.values()) {
const d = depthMap.get(s.id);
if (d === undefined) continue;
// Use maxGraph absolute bounds if available, fall back to parser coords
const b = vertexBounds.get(s.id);
const xMin = b ? b.x : s.x;
const xMax = b ? b.x + b.width : s.x2;
const yMin = b ? b.y : s.y;
const yMax = b ? b.y + b.height : s.y2;
// Text-aware: estimate text region within shape bbox
const { fontSize, fontStyle } = parseTextStyle(s.style ?? "");
const labelClean = s.label.replace(/\n/g, " / ");
const { textXMin, textXMax, textWidth } = textRegion(xMin, xMax, labelClean, fontSize, fontStyle);
if (!levelMap.has(d)) levelMap.set(d, []);
levelMap.get(d)!.push({
id: s.id,
label: labelClean,
xMin,
xMax,
yMin,
yMax,
fontSize,
fontStyle,
textXMin,
textXMax,
textWidth,
textFlankLeft: textXMin - xMin,
textFlankRight: xMax - textXMax,
});
}
// --- Build per-level negative-space output ---
const levels: LevelEntry[] = [];
for (let d = 0; d <= maxDepth; d++) {
const shapes = (levelMap.get(d) ?? []).sort(
(a, b) => a.yMin - b.yMin || a.xMin - b.xMin
);
// Y band for this level = bounding box of all shapes at this depth
const yMin = shapes.length > 0 ? Math.min(...shapes.map((s) => s.yMin)) : 0;
const yMax = shapes.length > 0 ? Math.max(...shapes.map((s) => s.yMax)) : 0;
// Per-row negative space: group shapes into Y-row clusters
const rowClusters = groupIntoRows(shapes);
const rows: RowEntry[] = rowClusters.map((rowShapes, idx) => {
const rowYMin = Math.min(...rowShapes.map((s) => s.yMin));
const rowYMax = Math.max(...rowShapes.map((s) => s.yMax));
const rowOccupied: XRange[] = rowShapes.map((s) => ({ xMin: s.xMin, xMax: s.xMax }));
const rowOccupiedRanges = mergeRanges(rowOccupied);
const rowFree = freeCorridors(rowOccupiedRanges, diagramXMin, diagramXMax);
// Text-aware free corridors: use text region instead of full bbox
const rowTextOccupied: XRange[] = rowShapes.map((s) => ({ xMin: s.textXMin, xMax: s.textXMax }));
const rowTextOccupiedRanges = mergeRanges(rowTextOccupied);
const rowTextFree = freeCorridors(rowTextOccupiedRanges, diagramXMin, diagramXMax);
return {
rowIndex: idx + 1,
yMin: rowYMin,
yMax: rowYMax,
shapeCount: rowShapes.length,
occupiedRanges: rowOccupiedRanges,
freeCorridors: rowFree,
freeCorridorCount: rowFree.length,
textAwareFreeCorridors: rowTextFree,
};
});
// Occupied X ranges across all shapes in this level
const occupied: XRange[] = shapes.map((s) => ({ xMin: s.xMin, xMax: s.xMax }));
const occupiedRanges = mergeRanges(occupied);
const free = freeCorridors(occupiedRanges, diagramXMin, diagramXMax);
levels.push({
number: d + 1,
yMin,
yMax,
shapeCount: shapes.length,
shapes,
rows,
occupiedRanges,
freeCorridors: free,
freeCorridorCount: free.length,
});
}
return {
action: "page-negative-space-summary",
file: filePath,
pageIndex,
summary: {
levelsTotal: totalLevels,
diagramXMin,
diagramXMax,
diagramYMin,
diagramYMax,
},
levels,
};
}
@@ -0,0 +1,67 @@
/**
* Action: page-orphans
*
* Finds two categories of disconnected elements:
* 1. isolated_shape — a shape that has no edges connected to it at all
* 2. dangling_connector — an edge that is missing its source and/or target shape
*/
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
const connectedShapeIds = new Set<string>();
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);
}
const issues: Record<string, unknown>[] = [];
// 1. Isolated shapes
for (const [id, shape] of shapes) {
if (!connectedShapeIds.has(id)) {
issues.push({
type: "isolated_shape",
shapeId: id,
label: shape.label,
x: shape.x,
y: shape.y,
});
}
}
// 2. Dangling connectors
for (const e of edges) {
const missingSource = !e.sourceId || !shapes.has(e.sourceId);
const missingTarget = !e.targetId || !shapes.has(e.targetId);
if (missingSource || missingTarget) {
issues.push({
type: "dangling_connector",
edgeId: e.id,
label: e.label,
missingSource,
missingTarget,
sourceId: e.sourceId,
targetId: e.targetId,
});
}
}
const isolated = issues.filter((i) => i["type"] === "isolated_shape").length;
const dangling = issues.filter((i) => i["type"] === "dangling_connector").length;
return {
action: "page-orphans",
file: filePath,
summary: {
isolatedShapes: isolated,
danglingConnectors: dangling,
totalIssues: issues.length,
},
issues,
};
}
@@ -0,0 +1,98 @@
/**
* Action: page-recommendations
*
* Analyses the bounding box of all shapes and recommends the smallest standard
* landscape page size that accommodates the content with an 80px margin.
*
* Standard landscape sizes: A4 (1169×827), A3 (1654×1169), A2 (2339×1654), A1 (3307×2339)
*/
import { parseDiagram } from "../../services/drawio-parser/parser.js";
const MARGIN = 80;
const STANDARD_SIZES: Array<{ name: string; width: number; height: number }> = [
{ name: "A4", width: 1169, height: 827 },
{ name: "A3", width: 1654, height: 1169 },
{ name: "A2", width: 2339, height: 1654 },
{ name: "A1", width: 3307, height: 2339 },
];
function recommendPage(
requiredW: number,
requiredH: number
): { name: string; width: number; height: number } {
for (const size of STANDARD_SIZES) {
if (size.width >= requiredW && size.height >= requiredH) return size;
}
// Custom — round up to nearest 10
return {
name: "custom",
width: Math.ceil(requiredW / 10) * 10,
height: Math.ceil(requiredH / 10) * 10,
};
}
export function run(filePath: string): Record<string, unknown> {
const { pageWidth, pageHeight, shapes } = parseDiagram(filePath);
let bbox: Record<string, number>;
let requiredW: number;
let requiredH: number;
if (shapes.size === 0) {
bbox = { xMin: 0, yMin: 0, xMax: 0, yMax: 0, width: 0, height: 0 };
requiredW = MARGIN;
requiredH = MARGIN;
} else {
const allShapes = Array.from(shapes.values());
const xMin = Math.min(...allShapes.map((s) => s.x));
const yMin = Math.min(...allShapes.map((s) => s.y));
const xMax = Math.max(...allShapes.map((s) => s.x2));
const yMax = Math.max(...allShapes.map((s) => s.y2));
bbox = {
xMin: Math.round(xMin * 10) / 10,
yMin: Math.round(yMin * 10) / 10,
xMax: Math.round(xMax * 10) / 10,
yMax: Math.round(yMax * 10) / 10,
width: Math.round((xMax - xMin) * 10) / 10,
height: Math.round((yMax - yMin) * 10) / 10,
};
requiredW = xMax + MARGIN;
requiredH = yMax + MARGIN;
}
const recommended = recommendPage(requiredW, requiredH);
const orientation =
pageWidth > pageHeight ? "landscape" :
pageWidth < pageHeight ? "portrait" :
"square";
const xMax = bbox["xMax"] as number;
const yMax = bbox["yMax"] as number;
const contentFits = pageWidth >= xMax && pageHeight >= yMax;
const hasMargin = (pageWidth - xMax) >= MARGIN && (pageHeight - yMax) >= MARGIN;
return {
action: "page-recommendations",
file: filePath,
currentPage: {
width: pageWidth,
height: pageHeight,
orientation,
},
contentBbox: bbox,
required: {
width: Math.round(requiredW * 10) / 10,
height: Math.round(requiredH * 10) / 10,
},
recommendedPage: recommended,
checks: {
contentFitsCurrentPage: contentFits,
hasAdequateMargin: hasMargin,
isLandscape: orientation === "landscape",
},
};
}
@@ -0,0 +1,81 @@
/**
* Action: page-shape-bbox-validation
*
* Detects shapes whose bounding boxes overlap each other.
* Parent-child containment is intentional and is NOT flagged.
*
* Two shapes overlap when their axis-aligned bounding boxes intersect
* with more than TOL pixels of penetration on both axes.
*/
import { parseDiagram, Shape } from "../../services/drawio-parser/parser.js";
const TOL = 2; // px — minimum penetration depth to flag as an overlap
function bboxOverlaps(a: Shape, b: Shape): boolean {
// Axis-aligned overlap with tolerance
return (
a.x + TOL < b.x2 &&
a.x2 - TOL > b.x &&
a.y + TOL < b.y2 &&
a.y2 - TOL > b.y
);
}
export function run(filePath: string): Record<string, unknown> {
const { shapes } = parseDiagram(filePath);
// Build ancestor set for a shape (to skip parent-child pairs)
function getAncestors(id: string): Set<string> {
const ancestors = new Set<string>();
let current = shapes.get(id);
while (current && current.parentId && current.parentId !== "1") {
ancestors.add(current.parentId);
current = shapes.get(current.parentId);
}
return ancestors;
}
const shapeList = Array.from(shapes.values());
const issues: Record<string, unknown>[] = [];
const seenPairs = new Set<string>();
for (let i = 0; i < shapeList.length; i++) {
const a = shapeList[i];
const aAncestors = getAncestors(a.id);
for (let j = i + 1; j < shapeList.length; j++) {
const b = shapeList[j];
// Skip parent-child containment in either direction
if (aAncestors.has(b.id)) continue;
if (b.parentId === a.id || a.parentId === b.id) continue;
const pairKey = a.id < b.id ? `${a.id}|${b.id}` : `${b.id}|${a.id}`;
if (seenPairs.has(pairKey)) continue;
if (bboxOverlaps(a, b)) {
seenPairs.add(pairKey);
issues.push({
type: "bbox_overlap",
shapeAId: a.id,
shapeALabel: a.label,
shapeBId: b.id,
shapeBLabel: b.label,
overlapX: Math.round((Math.min(a.x2, b.x2) - Math.max(a.x, b.x)) * 10) / 10,
overlapY: Math.round((Math.min(a.y2, b.y2) - Math.max(a.y, b.y)) * 10) / 10,
});
}
}
}
return {
action: "page-shape-bbox-validation",
file: filePath,
summary: {
totalShapes: shapes.size,
overlappingPairs: issues.length,
},
issues,
};
}
@@ -0,0 +1,31 @@
/**
* Action: page-summary
*
* Parses a single page (tab) of a .drawio file and returns a YAML inventory
* of all shapes and edges on that page, including bounding boxes, mid-points,
* and waypoint counts.
*
* Use --page <index> (0-based) to select a page. Defaults to page 0.
*/
import { parseAllPages } from "../../services/drawio-parser/parser.js";
import { buildPageSummary } from "../../services/drawio-parser/page-summary.js";
export function run(filePath: string, pageIndex = 0): Record<string, unknown> {
const allPages = parseAllPages(filePath);
if (pageIndex < 0 || pageIndex >= allPages.length) {
throw new Error(
`Page index ${pageIndex} is out of range. File has ${allPages.length} page(s) (0–${allPages.length - 1}).`
);
}
const pageSummary = buildPageSummary(allPages[pageIndex]);
return {
action: "page-summary",
file: filePath,
pageCount: allPages.length,
...pageSummary,
};
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Action: summary
*
* Parses all pages (tabs) in a .drawio file and returns a YAML inventory of
* shapes and edges for every page, including bounding boxes, mid-points, and
* waypoint counts.
*
* Delegates per-page building to the shared buildPageSummary helper,
* which is also used by the diagram-page-summary action.
*/
import { parseAllPages } from "../../services/drawio-parser/parser.js";
import { buildPageSummary } from "../../services/drawio-parser/page-summary.js";
export function run(filePath: string): Record<string, unknown> {
const allPages = parseAllPages(filePath);
const pages = allPages.map((page) => buildPageSummary(page));
return {
action: "summary",
file: filePath,
pageCount: pages.length,
pages,
};
}
+164
View File
@@ -0,0 +1,164 @@
/**
* Action: validate
*
* Validates that a draw.io diagram compiles correctly against the maxGraph library.
*
* For each page in the file this action:
* 1. Checks that the page XML is well-formed (parseable by DOMParser in text/xml mode)
* 2. Loads the page into a full maxGraph Graph instance via loadGraphStates()
* (which calls graph.view.validate() — the same rendering pass draw.io performs)
* 3. Reports the number of vertices and edges successfully resolved
*
* A page is considered INVALID if:
* - The XML contains a parseerror element (malformed XML)
* - loadGraphStates() throws an exception
* - The resulting graph has 0 vertices AND 0 edges (import silently failed)
*
* Exit criteria (summary.valid):
* true — all pages pass all three checks
* false — at least one page fails
*
* Common causes of failure:
* - HTML comments (<!-- -->) inside the mxGraphModel body: strict XML parsers
* reject them when they appear between sibling elements in certain positions
* - Stray/unmatched closing tags (e.g. orphaned </mxGeometry> or </mxCell>)
* - Unescaped special characters in attribute values
* - 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
}
}
interface PageResult {
pageIndex: number;
pageName: string;
valid: boolean;
xmlParseError: string | null;
vertices: number;
edges: number;
error: string | null;
}
// ---------------------------------------------------------------------------
// XML well-formedness check using jsdom DOMParser (strict text/xml mode)
// ---------------------------------------------------------------------------
function checkXmlWellFormed(xml: string): string | null {
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>");
const parser = new dom.window.DOMParser();
const doc = parser.parseFromString(xml, "text/xml");
const err = doc.querySelector("parsererror");
if (err) {
// Return first line of error message
return (err.textContent ?? "unknown parse error").split("\n")[0].trim();
}
return null;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
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 results: PageResult[] = [];
for (let i = 0; i < pages.length; i++) {
const { name, xml } = pages[i];
const result: PageResult = {
pageIndex: i,
pageName: name,
valid: false,
xmlParseError: null,
vertices: 0,
edges: 0,
error: null,
};
// Step 1: XML well-formedness
const parseError = checkXmlWellFormed(xml);
if (parseError) {
result.xmlParseError = parseError;
result.error = `XML parse error: ${parseError}`;
results.push(result);
continue;
}
// Step 2: maxGraph compile (graph.view.validate())
try {
const { edgeRoutes, vertexBounds } = loadGraphStates(xml);
result.vertices = vertexBounds.size;
result.edges = edgeRoutes.size;
// Step 3: sanity check — a non-empty diagram must have at least some cells
if (result.vertices === 0 && result.edges === 0) {
result.error =
"maxGraph loaded 0 vertices and 0 edges — XML may be structurally empty or silently failed to import";
} else {
result.valid = true;
}
} catch (err) {
result.error = `maxGraph load error: ${err instanceof Error ? err.message : String(err)}`;
}
results.push(result);
}
const allValid = results.every((r) => r.valid);
return {
action: "validate",
file: filePath,
summary: {
pages: results.length,
valid: allValid,
invalidPages: results.filter((r) => !r.valid).length,
},
pages: results,
};
}