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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user