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,74 @@
/**
* connector-router — computes the actual routed path for draw.io connectors.
*
* Strategy: load the diagram XML into a real maxGraph Graph+GraphView instance
* via graph-loader, which calls GraphView.validate() to compute all cell states
* exactly as draw.io does at render time. Edge absolutePoints are taken directly
* from those states — no synthetic geometry approximation.
*
* This is the only correct approach: draw.io's routing algorithms (especially
* OrthConnector for edgeStyle=orthogonalEdgeStyle) depend on the full graph
* state including parent container bounds, so they cannot be reproduced
* accurately without loading the full model.
*/
import { loadGraphStates } from "../maxgraph-loader/graph-loader.js";
import type { Edge } from "../drawio-parser/parser.js";
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export interface RoutedPoint {
x: number;
y: number;
}
/**
* Build a map of edgeId → routed points for ALL edges in the diagram.
*
* Call this once per diagram page and reuse the result for all edges.
* Using `routeAllEdges` is significantly more efficient than calling
* `routeEdge` in a loop because it runs `GraphView.validate()` only once.
*
* @param graphModelXml - Raw <mxGraphModel> XML for the page
* @returns Map of edge id → array of canvas-absolute routed points
*/
export function routeAllEdges(graphModelXml: string): Map<string, RoutedPoint[]> {
const { edgeRoutes } = loadGraphStates(graphModelXml);
const result = new Map<string, RoutedPoint[]>();
for (const [id, route] of edgeRoutes) {
result.set(id, route.points);
}
return result;
}
/**
* Get routed points for a single edge from a pre-computed route map.
*
* For efficiency, pre-compute the map once using `routeAllEdges` and pass it here.
* Falls back to a straight line between shape centers if the edge is not found.
*
* @param edge - The edge to route
* @param routeMap - Pre-computed map from `routeAllEdges`
* @param shapeMidpoints - Optional fallback: map of shapeId → {midX, midY}
*/
export function getEdgeRoute(
edge: Edge,
routeMap: Map<string, RoutedPoint[]>,
shapeMidpoints?: Map<string, { midX: number; midY: number }>
): RoutedPoint[] {
const pts = routeMap.get(edge.id);
if (pts && pts.length >= 2) return pts;
// Fallback: straight line using shape midpoints
if (shapeMidpoints && edge.sourceId && edge.targetId) {
const src = shapeMidpoints.get(edge.sourceId);
const tgt = shapeMidpoints.get(edge.targetId);
if (src && tgt) {
return [{ x: src.midX, y: src.midY }, { x: tgt.midX, y: tgt.midY }];
}
}
return [];
}
@@ -0,0 +1,79 @@
/**
* Shared helper — build a plain serialisable summary object for a single ParsedPage.
* Used by both diagram-page-summary and diagram-summary actions.
*/
import type { ParsedPage } from "./parser.js";
export interface PageSummaryResult {
pageIndex: number;
pageName: string;
page: { width: number; height: number };
summary: {
shapeCount: number;
edgeCount: number;
contentRight: number;
contentBottom: number;
};
shapes: Array<{
id: string;
label: string;
x: number;
y: number;
width: number;
height: number;
midX: number;
midY: number;
x2: number;
y2: number;
}>;
edges: Array<{
id: string;
label: string;
sourceId: string | null;
targetId: string | null;
waypointCount: number;
waypoints: Array<{ x: number; y: number }>;
}>;
}
export function buildPageSummary(page: ParsedPage): PageSummaryResult {
const shapes = Array.from(page.shapes.values()).map((s) => ({
id: s.id,
label: s.label,
x: s.x,
y: s.y,
width: s.width,
height: s.height,
midX: Math.round(s.midX * 10) / 10,
midY: Math.round(s.midY * 10) / 10,
x2: Math.round(s.x2 * 10) / 10,
y2: Math.round(s.y2 * 10) / 10,
}));
const edges = page.edges.map((e) => ({
id: e.id,
label: e.label,
sourceId: e.sourceId,
targetId: e.targetId,
waypointCount: e.waypoints.length,
waypoints: e.waypoints.map((w) => ({ x: w.x, y: w.y })),
}));
const contentRight = shapes.reduce((max, s) => Math.max(max, s.x2), 0);
const contentBottom = shapes.reduce((max, s) => Math.max(max, s.y2), 0);
return {
pageIndex: page.pageIndex,
pageName: page.pageName,
page: { width: page.pageWidth, height: page.pageHeight },
summary: {
shapeCount: shapes.length,
edgeCount: edges.length,
contentRight: Math.round(contentRight * 10) / 10,
contentBottom: Math.round(contentBottom * 10) / 10,
},
shapes,
edges,
};
}
@@ -0,0 +1,315 @@
/**
* DrawioParser — parse a .drawio file into shapes and edges with absolute coordinates.
*
* Uses @maxgraph/core (the TypeScript successor to mxGraph) with a jsdom DOM polyfill
* so that maxGraph can run in Node.js.
*
* Supports both:
* - bare <mxGraphModel> XML files (single page)
* - <mxfile><diagram>…</diagram></mxfile> wrappers (draw.io desktop format,
* where each <diagram> body is base64+deflate encoded, multi-page supported)
*/
import { readFileSync } from "node:fs";
import { inflateRaw } from "pako";
import { JSDOM } from "jsdom";
import {
GraphDataModel,
ModelXmlSerializer,
type Cell,
type CellStyle,
} from "@maxgraph/core";
// ---------------------------------------------------------------------------
// One-time jsdom DOM polyfill — maxGraph calls addEventListener, DOMParser, etc.
// ---------------------------------------------------------------------------
(function setupDomPolyfill() {
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>");
const w = dom.window as unknown as Record<string, unknown>;
// Assign globals that maxGraph expects in a browser environment
(globalThis as Record<string, unknown>)["window"] = w;
(globalThis as Record<string, unknown>)["document"] = dom.window.document;
(globalThis as Record<string, unknown>)["DOMParser"] = dom.window.DOMParser;
(globalThis as Record<string, unknown>)["XMLSerializer"] = dom.window.XMLSerializer;
(globalThis as Record<string, unknown>)["Element"] = dom.window.Element;
(globalThis as Record<string, unknown>)["HTMLElement"] = dom.window.HTMLElement;
(globalThis as Record<string, unknown>)["Node"] = dom.window.Node;
(globalThis as Record<string, unknown>)["Event"] = dom.window.Event;
// globalThis.navigator is non-configurable in some Node versions — use defineProperty
Object.defineProperty(globalThis, "navigator", {
value: dom.window.navigator,
writable: true,
configurable: true,
});
})();
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
export interface Shape {
id: string;
label: string;
style: string;
x: number;
y: number;
width: number;
height: number;
parentId: string;
/**
* Header bar height for swimlane/container shapes (extracted from startSize= in style).
* 0 for non-swimlane shapes.
* The label of a swimlane lives in the band y..y+startSize.
*/
startSize: number;
// computed
readonly x2: number;
readonly y2: number;
readonly midX: number;
readonly midY: number;
}
export interface Waypoint {
x: number;
y: number;
}
export interface Edge {
id: string;
label: string;
style: string;
sourceId: string | null;
targetId: string | null;
waypoints: Waypoint[];
}
export interface ParsedPage {
pageIndex: number; // 0-based
pageName: string; // value of the name="" attribute on <diagram>, or "Page N"
pageWidth: number;
pageHeight: number;
shapes: Map<string, Shape>;
edges: Edge[];
/** Raw <mxGraphModel> XML string for this page — used by graph-loader for full routing */
graphModelXml: string;
}
/** Single-page result (first page) — kept for backward compatibility. */
export interface ParsedDiagram extends ParsedPage {}
// ---------------------------------------------------------------------------
// Decode <mxfile> base64+deflate diagram content
// ---------------------------------------------------------------------------
function decodeDiagramContent(content: string): string {
try {
const decoded = Buffer.from(content.trim(), "base64");
const decompressed = inflateRaw(decoded);
const text = new TextDecoder("utf-8").decode(decompressed);
return decodeURIComponent(text);
} catch {
// Already plain XML
return content;
}
}
// ---------------------------------------------------------------------------
// Helpers — lightweight regex for attributes / element text
// ---------------------------------------------------------------------------
function getAttrFromTag(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] ?? "") : "";
}
/** Convert a maxGraph CellStyle object back to a semicolon-delimited style string. */
function cellStyleToString(style: CellStyle): string {
const { baseStyleNames = [], ...props } = style;
const parts: string[] = [...baseStyleNames];
for (const [k, v] of Object.entries(props)) {
if (v !== undefined && v !== null) {
parts.push(`${k}=${String(v)}`);
}
}
return parts.join(";");
}
function extractPageDimensions(graphModelXml: string): { pageWidth: number; pageHeight: number } {
const m = /<mxGraphModel([^>]*)>/i.exec(graphModelXml);
if (!m) return { pageWidth: 0, pageHeight: 0 };
const attrs = m[1];
const pw = getAttrFromTag(attrs, "pageWidth");
const ph = getAttrFromTag(attrs, "pageHeight");
return {
pageWidth: pw ? parseFloat(pw) : 0,
pageHeight: ph ? parseFloat(ph) : 0,
};
}
// ---------------------------------------------------------------------------
// Build Shape from a maxGraph Cell (vertex)
// ---------------------------------------------------------------------------
/** Extract startSize from a CellStyle (swimlane header bar height). Default 0. */
function extractStartSize(style: CellStyle): number {
// maxGraph stores startSize as a numeric property on the style object
const raw = (style as Record<string, unknown>)["startSize"];
if (typeof raw === "number" && raw > 0) return raw;
if (typeof raw === "string") {
const n = parseFloat(raw);
if (!isNaN(n) && n > 0) return n;
}
return 0;
}
function cellToShape(cell: Cell): Shape | null {
const id = cell.id;
if (!id || id === "0" || id === "1") return null;
if (!cell.isVertex()) return null;
const geo = cell.getGeometry();
if (!geo) return null;
// getOrigin() walks the parent chain and returns canvas-absolute top-left
const origin = cell.getOrigin();
const x = origin.x;
const y = origin.y;
const width = geo.width ?? 0;
const height = geo.height ?? 0;
const parentId = cell.parent?.id ?? "1";
const startSize = extractStartSize(cell.style);
const shape: Shape = {
id,
label: (cell.value as string) ?? "",
style: cellStyleToString(cell.style),
x,
y,
width,
height,
parentId,
startSize,
get x2() { return this.x + this.width; },
get y2() { return this.y + this.height; },
get midX() { return this.x + this.width / 2; },
get midY() { return this.y + this.height / 2; },
};
return shape;
}
// ---------------------------------------------------------------------------
// Build Edge from a maxGraph Cell (edge)
// ---------------------------------------------------------------------------
function cellToEdge(cell: Cell): Edge | null {
const id = cell.id;
if (!id) return null;
if (!cell.isEdge()) return null;
const geo = cell.getGeometry();
const waypoints: Waypoint[] = [];
if (geo?.points) {
for (const pt of geo.points) {
waypoints.push({ x: pt.x, y: pt.y });
}
}
return {
id,
label: (cell.value as string) ?? "",
style: cellStyleToString(cell.style),
sourceId: cell.source?.id ?? null,
targetId: cell.target?.id ?? null,
waypoints,
};
}
// ---------------------------------------------------------------------------
// Parse a single <mxGraphModel> XML string into a ParsedPage
// ---------------------------------------------------------------------------
function parseGraphModelXml(graphModelXml: string, pageIndex: number, pageName: string): ParsedPage {
const { pageWidth, pageHeight } = extractPageDimensions(graphModelXml);
const model = new GraphDataModel();
new ModelXmlSerializer(model).import(graphModelXml);
const shapes = new Map<string, Shape>();
const edges: Edge[] = [];
for (const cell of Object.values(model.cells ?? {})) {
if (cell.isVertex()) {
const shape = cellToShape(cell);
if (shape) shapes.set(shape.id, shape);
} else if (cell.isEdge()) {
const edge = cellToEdge(cell);
if (edge) edges.push(edge);
}
}
return { pageIndex, pageName, pageWidth, pageHeight, shapes, edges, graphModelXml };
}
// ---------------------------------------------------------------------------
// Extract all <diagram> blocks from an <mxfile> string
// ---------------------------------------------------------------------------
interface DiagramBlock {
name: string;
content: string;
}
function extractDiagramBlocks(mxfileXml: string): DiagramBlock[] {
const blocks: DiagramBlock[] = [];
// Match each <diagram ...>...</diagram> element
const re = /<diagram([^>]*)>([\s\S]*?)<\/diagram>/gi;
let match: RegExpExecArray | null;
while ((match = re.exec(mxfileXml)) !== null) {
const attrs = match[1];
const content = match[2];
const name = getAttrFromTag(attrs, "name") || "";
blocks.push({ name, content: content.trim() });
}
return blocks;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Parse all pages in a .drawio file.
* Returns one ParsedPage per diagram/tab found.
*/
export function parseAllPages(filePath: string): ParsedPage[] {
const raw = readFileSync(filePath, "utf-8").trim();
if (/^<mxfile/i.test(raw)) {
const blocks = extractDiagramBlocks(raw);
if (blocks.length === 0) throw new Error("No <diagram> elements found in mxfile");
return blocks.map((block, i) => {
const graphModelXml = decodeDiagramContent(block.content);
const pageName = block.name || `Page ${i + 1}`;
return parseGraphModelXml(graphModelXml, i, pageName);
});
}
if (/^<mxGraphModel/i.test(raw)) {
return [parseGraphModelXml(raw, 0, "Page 1")];
}
throw new Error(`Unrecognised draw.io file format in ${filePath}`);
}
/**
* Parse the first page of a .drawio file.
* Kept for backward compatibility with connectors-check and page-recommendations.
*/
export function parseDiagram(filePath: string): ParsedDiagram {
return parseAllPages(filePath)[0];
}
@@ -0,0 +1,108 @@
/**
* Shared hierarchy builder — used by page-hierarchy and page-hierarchy-full.
*
* Given a ParsedPage, builds a BFS depth map and a recursive containment tree.
*/
import type { ParsedPage, Shape } from "../drawio-parser/parser.js";
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
export interface HierarchyNode {
id: string;
label: string;
depth: number;
childCount: number;
children: HierarchyNode[];
}
export interface HierarchyResult {
tree: HierarchyNode[];
allNodes: HierarchyNode[];
depthMap: Map<string, number>; // shape id → depth
childrenOf: Map<string, Shape[]>; // parentId → direct children (Shape objects)
maxDepth: number;
totalLevels: number; // maxDepth + 1 — total nesting levels
depthCounts: Record<number, number>; // depth → count of shapes at that depth
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function buildTree(
parentId: string,
childrenMap: Map<string, string[]>,
shapeLabels: Map<string, string>,
depth: number
): HierarchyNode[] {
const ids = childrenMap.get(parentId) ?? [];
return ids.map((id) => {
const children = buildTree(id, childrenMap, shapeLabels, depth + 1);
return {
id,
label: shapeLabels.get(id) ?? "",
depth,
childCount: children.length,
children,
};
});
}
// ---------------------------------------------------------------------------
// Main export
// ---------------------------------------------------------------------------
export function buildHierarchy(page: ParsedPage): HierarchyResult {
const allShapes = Array.from(page.shapes.values());
// Build parent → [children ids] map and label map (for tree)
const childrenMap = new Map<string, string[]>();
const shapeLabels = new Map<string, string>();
for (const s of allShapes) {
shapeLabels.set(s.id, s.label);
const parent = s.parentId ?? "1";
if (!childrenMap.has(parent)) childrenMap.set(parent, []);
childrenMap.get(parent)!.push(s.id);
}
// Build parent → [children Shape] map (for geometry access)
const childrenOf = new Map<string, Shape[]>();
for (const s of allShapes) {
const parent = s.parentId ?? "1";
if (!childrenOf.has(parent)) childrenOf.set(parent, []);
childrenOf.get(parent)!.push(s);
}
// Build recursive tree
const tree = buildTree("1", childrenMap, shapeLabels, 0);
// Flatten tree for stats + depth map
const allNodes: HierarchyNode[] = [];
const depthMap = new Map<string, number>();
const stack = [...tree];
while (stack.length > 0) {
const node = stack.pop()!;
allNodes.push(node);
depthMap.set(node.id, node.depth);
stack.push(...node.children);
}
const maxDepth = allNodes.reduce((m, n) => Math.max(m, n.depth), 0);
const depthCounts: Record<number, number> = {};
for (const n of allNodes) {
depthCounts[n.depth] = (depthCounts[n.depth] ?? 0) + 1;
}
return {
tree,
allNodes,
depthMap,
childrenOf,
maxDepth,
totalLevels: maxDepth + 1,
depthCounts,
};
}
@@ -0,0 +1,72 @@
/**
* dom-polyfill.ts
*
* Sets up (or extends) the global DOM environment required by @maxgraph/core
* when running in Node.js. Safe to import multiple times — idempotent.
*
* The parser (drawio-parser/parser.ts) already sets up a partial DOM polyfill
* for the basic maxGraph model/serializer classes. This module extends it with
* the additional globals needed by Graph+GraphView (rendering pipeline):
* - location
* - SVGElement, MouseEvent, KeyboardEvent, TouchEvent
* - requestAnimationFrame / cancelAnimationFrame
*
* Must be imported before any maxGraph Graph/GraphView usage.
*/
import { JSDOM } from "jsdom";
// Use the same DOM that may already be set up by parser.ts, or create a new one.
// We detect by checking whether global.document already exists.
let win: Record<string, unknown>;
if ((globalThis as Record<string, unknown>)["document"]) {
// Parser already set up a DOM — re-use the existing window
win = (globalThis as Record<string, unknown>)["window"] as Record<string, unknown>;
} else {
const dom = new JSDOM(
'<!DOCTYPE html><html><body></body></html>',
{ pretendToBeVisual: true }
);
win = dom.window as unknown as Record<string, unknown>;
(globalThis as Record<string, unknown>)["document"] = win["document"];
(globalThis as Record<string, unknown>)["window"] = win;
(globalThis as Record<string, unknown>)["DOMParser"] = win["DOMParser"];
(globalThis as Record<string, unknown>)["XMLSerializer"] = win["XMLSerializer"];
(globalThis as Record<string, unknown>)["HTMLElement"] = win["HTMLElement"];
(globalThis as Record<string, unknown>)["Element"] = win["Element"];
(globalThis as Record<string, unknown>)["Node"] = win["Node"];
(globalThis as Record<string, unknown>)["Event"] = win["Event"];
try {
Object.defineProperty(globalThis, "navigator", {
value: win["navigator"],
writable: true,
configurable: true,
});
} catch { /* already defined */ }
}
// Add/overwrite globals needed specifically for Graph+GraphView rendering
const g = globalThis as Record<string, unknown>;
if (!g["SVGElement"]) g["SVGElement"] = win["SVGElement"] ?? class SVGElement {};
if (!g["MouseEvent"]) g["MouseEvent"] = win["MouseEvent"] ?? class MouseEvent {};
if (!g["KeyboardEvent"]) g["KeyboardEvent"] = win["KeyboardEvent"] ?? class KeyboardEvent {};
if (!g["TouchEvent"]) g["TouchEvent"] = win["TouchEvent"] ?? class TouchEvent {};
if (!g["requestAnimationFrame"]) {
g["requestAnimationFrame"] = (fn: () => void) => setTimeout(fn, 0);
}
if (!g["cancelAnimationFrame"]) {
g["cancelAnimationFrame"] = clearTimeout;
}
// location is needed by UrlConverter (used when rendering image shapes)
if (!g["location"]) {
g["location"] = {
protocol: "http:",
host: "localhost",
href: "http://localhost/",
pathname: "/",
};
}
export {};
@@ -0,0 +1,113 @@
/**
* graph-loader.ts
*
* Loads a draw.io diagram XML into a full maxGraph Graph+GraphView instance
* and computes all cell states (including edge absolutePoints) exactly as
* draw.io does at render time.
*
* This is the authoritative source for edge routing — do NOT hand-compute
* orthogonal paths; use the routed points returned here instead.
*
* Usage:
* import { loadGraphStates } from "./graph-loader.js";
* const { edgePoints, vertexBounds } = loadGraphStates(graphModelXml);
*/
// DOM polyfill MUST be imported first so globals are set before maxGraph loads
import "./dom-polyfill.js";
import { JSDOM } from "jsdom";
import { Graph, GraphDataModel, ModelXmlSerializer } from "@maxgraph/core";
import type { Cell, CellState } from "@maxgraph/core";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface Point {
x: number;
y: number;
}
export interface EdgeRoute {
/** Canvas-absolute points for the full routed path (including endpoints) */
points: Point[];
}
export interface VertexBounds {
x: number;
y: number;
width: number;
height: number;
}
export interface GraphStates {
/** Map of edge cell ID → routed absolute points */
edgeRoutes: Map<string, EdgeRoute>;
/** Map of vertex cell ID → absolute bounding box */
vertexBounds: Map<string, VertexBounds>;
}
// ---------------------------------------------------------------------------
// Internal: create a fresh DOM container for each graph instance
// ---------------------------------------------------------------------------
function createContainer(): HTMLElement {
const dom = new JSDOM(
'<!DOCTYPE html><html><body><div id="g" style="width:4000px;height:4000px;"></div></body></html>',
{ pretendToBeVisual: true }
);
return dom.window.document.getElementById("g") as HTMLElement;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Load a single <mxGraphModel> XML string into a maxGraph Graph instance,
* run GraphView.validate() to compute all cell states, and return the
* resulting edge routes and vertex bounds.
*
* @param graphModelXml - Raw <mxGraphModel ...>...</mxGraphModel> XML string
*/
export function loadGraphStates(graphModelXml: string): GraphStates {
const dataModel = new GraphDataModel();
const serializer = new ModelXmlSerializer(dataModel);
serializer.import(graphModelXml);
const container = createContainer();
const graph = new Graph(container, dataModel);
// Compute all cell states (geometry, routing, labels…)
graph.view.validate();
const states = graph.view.getStates() as Map<Cell, CellState>;
const edgeRoutes = new Map<string, EdgeRoute>();
const vertexBounds = new Map<string, VertexBounds>();
for (const [cell, state] of states) {
const id = cell.id;
if (!id || id === "0" || id === "1") continue;
if (cell.isEdge()) {
const pts = state.absolutePoints as Array<Point | null> | null;
if (pts && pts.length >= 2) {
const resolved: Point[] = pts
.filter((p): p is Point => p != null)
.map((p) => ({ x: Math.round(p.x * 10) / 10, y: Math.round(p.y * 10) / 10 }));
edgeRoutes.set(id, { points: resolved });
}
} else if (cell.isVertex()) {
vertexBounds.set(id, {
x: state.x,
y: state.y,
width: state.width,
height: state.height,
});
}
}
return { edgeRoutes, vertexBounds };
}