refactor(mcp-server): make the diagram modules usable from the web app

The web app will reuse the MCP server's XML engine instead of its own
copy in lib/utils.ts, so these modules now run in the browser too.

- Relative imports end in .ts, rewritten to .js by tsc
  (rewriteRelativeImportExtensions); Next.js resolves them directly
- Every module uses the global DOMParser/XMLSerializer: native in the
  browser, linkedom in Node via installDomPolyfill. pages.ts parsed with
  linkedom but serialized with the global serializer, which throws in
  the browser
- The saxes syntax check moves to xml-syntax.ts, so the browser does not
  pull in linkedom; it now also rejects undeclared prefixes such as
  xlink:, as the browser does
- Page decompression uses pako and atob instead of node:zlib and Buffer
- hasCells moves to pages.ts, away from the file system code
- The duplicate cell id check counts UserObject/object ids
- wrapCellsInModel drops comments and text before the first cell, which
  the web app accepts today
- validateAndFixXml takes { strict: false } for diagrams with user content
- Web tests run these modules with a browser DOM (jsdom)
- saxes becomes a direct dependency of the web app
This commit is contained in:
dayuan.jiang
2026-10-04 12:25:53 +09:00
parent 289e6bc25f
commit b7c543ca70
29 changed files with 287 additions and 98 deletions
+1 -2
View File
@@ -66,6 +66,7 @@
"react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.6",
"remark-gfm": "^4.0.1",
"saxes": "^6.0.0",
"server-only": "^0.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.0.2",
@@ -21478,7 +21479,6 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
@@ -25261,7 +25261,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
},
"node_modules/y18n": {
+1
View File
@@ -88,6 +88,7 @@
"react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.6",
"remark-gfm": "^4.0.1",
"saxes": "^6.0.0",
"server-only": "^0.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.0.2",
+25
View File
@@ -12,6 +12,7 @@
"@modelcontextprotocol/sdk": "^1.31.0",
"linkedom": "^0.18.0",
"open": "^11.0.0",
"pako": "^2.2.0",
"saxes": "^6.0.0",
"zod": "^4.0.0"
},
@@ -20,6 +21,7 @@
},
"devDependencies": {
"@types/node": "^24.0.0",
"@types/pako": "^2.0.4",
"tsx": "^4.19.0",
"typescript": "^5",
"vitest": "^4.1.8"
@@ -909,6 +911,13 @@
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitest/expect": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
@@ -2601,6 +2610,22 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pako": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
"integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "(MIT AND Zlib)"
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+2
View File
@@ -42,11 +42,13 @@
"@modelcontextprotocol/sdk": "^1.31.0",
"linkedom": "^0.18.0",
"open": "^11.0.0",
"pako": "^2.2.0",
"saxes": "^6.0.0",
"zod": "^4.0.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"@types/pako": "^2.0.4",
"tsx": "^4.19.0",
"typescript": "^5",
"vitest": "^4.1.8"
@@ -7,10 +7,10 @@
* first page is targeted (the "active page by convention" — see pages.ts).
*/
import { getXmlSyntaxError } from "./dom.js"
import { decompressPageContent } from "./load-diagram.js"
import { log } from "./logger.js"
import { findPageElement, hasPageSelector, type PageSelector } from "./pages.js"
import { decompressPageContent } from "./load-diagram.ts"
import { log } from "./logger.ts"
import { findPageElement, hasPageSelector, type PageSelector } from "./pages.ts"
import { getXmlSyntaxError } from "./xml-syntax.ts"
export interface DiagramOperation {
operation: "update" | "add" | "delete"
+5 -21
View File
@@ -1,29 +1,13 @@
/**
* DOM setup for Node.
* DOM setup for Node. The XML helpers use the global DOMParser and
* XMLSerializer, which the browser has and Node gets from here.
*
* linkedom gives us a DOM with querySelector, but it is lenient: it never
* reports syntax errors (no <parsererror>), and its serializer writes raw
* newlines inside attribute values, which the browser reads back as spaces.
* saxes, a strict XML parser, checks well-formedness the way draw.io's
* DOMParser will, and serializeXml writes attribute values safely.
* reports syntax errors (no <parsererror>; xml-syntax.ts checks them), and
* its serializer writes raw newlines inside attribute values, which the
* browser reads back as spaces. serializeXml writes attribute values safely.
*/
import { DOMParser } from "linkedom"
import { SaxesParser } from "saxes"
/**
* Returns the first XML syntax error as "line:column: message", or null if
* the XML is well-formed. Surrounding whitespace is ignored because every
* caller trims before the XML reaches the browser.
*/
export function getXmlSyntaxError(xml: string): string | null {
let error: string | null = null
const parser = new SaxesParser()
parser.on("error", (err) => {
error ??= err.message
})
parser.write(xml.trim()).close()
return error
}
const ESCAPES: Record<string, string> = {
"&": "&amp;",
+3 -3
View File
@@ -10,9 +10,9 @@
import {
applyDiagramOperations,
type DiagramOperation,
} from "./diagram-operations.js"
import { type PageSelector, projectPage } from "./pages.js"
import { validateAndFixXml, validateMxCellStructure } from "./xml-validation.js"
} from "./diagram-operations.ts"
import { type PageSelector, projectPage } from "./pages.ts"
import { validateAndFixXml, validateMxCellStructure } from "./xml-validation.ts"
export type EditOutcome =
| { ok: true; xml: string; applied: number; fixes: string[] }
+1 -1
View File
@@ -17,7 +17,7 @@
* change: the set of pages, each page's name, and each page's cell tree
* (tags + sorted attributes + text). Byte equality is kept as a fast path.
*/
import { isMxGraphModel, normalizeToMxfile, parseMxfile } from "./pages.js"
import { isMxGraphModel, normalizeToMxfile, parseMxfile } from "./pages.ts"
export type EditGateResult =
| { ok: true }
+1 -1
View File
@@ -3,7 +3,7 @@
* Stores {xml, svg} entries in a circular buffer
*/
import { log } from "./logger.js"
import { log } from "./logger.ts"
const MAX_HISTORY = 20
+2 -2
View File
@@ -38,8 +38,8 @@ import {
getHistory,
getHistoryEntry,
updateLastHistorySvg,
} from "./history.js"
import { log } from "./logger.js"
} from "./history.ts"
import { log } from "./logger.ts"
// Configurable draw.io embed URL for private deployments
const DRAWIO_BASE_URL =
+14 -13
View File
@@ -24,12 +24,12 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import open from "open"
import { z } from "zod"
import type { DiagramOperation } from "./diagram-operations.js"
import { installDomPolyfill } from "./dom.js"
import { DRAWING_GUIDE } from "./drawing-guide.js"
import { editDiagram, targetPageXml } from "./edit-diagram.js"
import { checkEditGate } from "./edit-gate.js"
import { addHistory } from "./history.js"
import type { DiagramOperation } from "./diagram-operations.ts"
import { installDomPolyfill } from "./dom.ts"
import { DRAWING_GUIDE } from "./drawing-guide.ts"
import { editDiagram, targetPageXml } from "./edit-diagram.ts"
import { checkEditGate } from "./edit-gate.ts"
import { addHistory } from "./history.ts"
import {
type ExportFormat,
type ExportOptions,
@@ -42,13 +42,14 @@ import {
shutdown,
startHttpServer,
waitForSync,
} from "./http-server.js"
import { parseDrawioFileContent } from "./load-diagram.js"
import { log } from "./logger.js"
} from "./http-server.ts"
import { parseDrawioFileContent } from "./load-diagram.ts"
import { log } from "./logger.ts"
import {
addPageToDoc,
deletePageFromDoc,
findPageElement,
hasCells,
hasPageSelector,
listPagesFromDoc,
normalizeToMxfile,
@@ -58,10 +59,10 @@ import {
renamePageInDoc,
serializeMxfile,
wrapCellsInModel,
} from "./pages.js"
import { Autosaver, defaultDataDir, hasCells } from "./persistence.js"
import { getShapeLibrary, SHAPE_LIBRARY_GROUPS } from "./shape-library.js"
import { validateAndFixXml } from "./xml-validation.js"
} from "./pages.ts"
import { Autosaver, defaultDataDir } from "./persistence.ts"
import { getShapeLibrary, SHAPE_LIBRARY_GROUPS } from "./shape-library.ts"
import { validateAndFixXml } from "./xml-validation.ts"
// DOMParser/XMLSerializer globals for the XML helpers (Node has neither)
installDomPolyfill()
+8 -7
View File
@@ -7,16 +7,15 @@
* diagram's text content. The rest of the server assumes plain XML inside
* every <diagram>, so loading decompresses all pages up front.
*/
import { inflateRawSync } from "node:zlib"
import { DOMParser } from "linkedom"
import { getXmlSyntaxError } from "./dom.js"
import { inflateRaw } from "pako"
import {
isMxFile,
isMxGraphModel,
normalizeToMxfile,
parseMxfile,
serializeMxfile,
} from "./pages.js"
} from "./pages.ts"
import { getXmlSyntaxError } from "./xml-syntax.ts"
export type LoadResult =
| { ok: true; xml: string }
@@ -28,9 +27,11 @@ export type LoadResult =
*/
export function decompressPageContent(compressed: string): string | null {
try {
const inflated = inflateRawSync(
Buffer.from(compressed.trim(), "base64"),
).toString("utf-8")
// atob and pako work in Node and in the browser
const bytes = Uint8Array.from(atob(compressed.trim()), (c) =>
c.charCodeAt(0),
)
const inflated = inflateRaw(bytes, { to: "string" })
try {
return decodeURIComponent(inflated)
} catch {
+2 -1
View File
@@ -14,7 +14,8 @@ export const log = {
console.error(`[MCP-DrawIO] [ERROR] ${msg}`, ...args)
},
debug: (msg: string, ...args: unknown[]) => {
if (process.env.DEBUG === "true") {
// process is missing when the web app runs this code in the browser
if (typeof process !== "undefined" && process.env.DEBUG === "true") {
console.error(`[MCP-DrawIO] [DEBUG] ${msg}`, ...args)
}
},
+19 -7
View File
@@ -17,8 +17,7 @@
* - how to add/rename/delete pages without re-parsing ad-hoc.
*/
import { DOMParser } from "linkedom"
import { getXmlSyntaxError } from "./dom.js"
import { getXmlSyntaxError } from "./xml-syntax.ts"
export interface PageInfo {
id: string
@@ -53,6 +52,10 @@ export function generatePageId(): string {
return `${a}-${b}`
}
/** Any cell besides the root cells "0" and "1" */
export const hasCells = (xml: string) =>
/<(mxCell\b[^>]*\bid="(?![01]")|UserObject\b|object\b)/.test(xml)
/** Cheap regex check — does the XML start with an <mxfile> root? */
export function isMxFile(xml: string): boolean {
return /^\s*(<\?xml[^>]*\?>\s*)?<mxfile[\s>]/i.test(xml)
@@ -87,15 +90,24 @@ const ROOT_CELLS = '<mxCell id="0"/><mxCell id="1" parent="0"/>'
* Turn a list of bare cells (optionally inside <root>) into a one-page
* <mxGraphModel>, adding the "0" and "1" root cells. The model then only
* writes its own cells, as in the web app (wrapWithMxFile in lib/utils.ts).
* Root cells the model wrote anyway are replaced, and trailing closing tags
* some providers append are dropped. <mxfile>, <mxGraphModel> and anything
* else are returned unchanged.
* Root cells the model wrote anyway are replaced, and comments or text
* before the first cell and trailing closing tags some providers append
* are dropped. <mxfile>, <mxGraphModel> and anything else are returned
* unchanged.
*/
export function wrapCellsInModel(xml: string): string {
let content = stripXmlDeclaration(xml.trim())
if (!/^<(mxCell|UserObject|object|root)[\s/>]/.test(content)) return xml
const start = content.search(/<(mxCell|UserObject|object|root)[\s/>]/)
if (start === -1) return xml
// Only comments and plain text may come before the first cell
if (!/^(?:<!--[\s\S]*?-->|[^<])*$/.test(content.slice(0, start))) {
return xml
}
content = content.replace(/<\/?root>/g, "").trim()
content = content
.slice(start)
.replace(/<\/?root>/g, "")
.trim()
// End of the last cell, counting wrapped cells (</UserObject>, </object>)
let end = -1
for (const close of ["/>", "</mxCell>", "</UserObject>", "</object>"]) {
+2 -5
View File
@@ -17,7 +17,8 @@ import {
} from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import { log } from "./logger.js"
import { log } from "./logger.ts"
import { hasCells } from "./pages.ts"
const DELAY_MS = 1000
const MAX_FILES = 50
@@ -29,10 +30,6 @@ export function defaultDataDir(): string | null {
return dir || join(homedir(), ".next-ai-drawio")
}
/** Any cell besides the root cells "0" and "1" */
export const hasCells = (xml: string) =>
/<(mxCell\b[^>]*\bid="(?![01]")|UserObject\b|object\b)/.test(xml)
export class Autosaver {
private pending = new Map<
string,
+21
View File
@@ -0,0 +1,21 @@
/**
* Strict XML syntax check, used in Node and in the web app's browser code.
* saxes reports syntax errors the way draw.io's DOMParser will, including
* an attribute prefix such as xlink: that was never declared.
*/
import { SaxesParser } from "saxes"
/**
* Returns the first XML syntax error as "line:column: message", or null if
* the XML is well-formed. Surrounding whitespace is ignored because every
* caller trims before the XML reaches the browser.
*/
export function getXmlSyntaxError(xml: string): string | null {
let error: string | null = null
const parser = new SaxesParser({ xmlns: true })
parser.on("error", (err) => {
error ??= err.message
})
parser.write(xml.trim()).close()
return error
}
+23 -9
View File
@@ -3,7 +3,7 @@
* Copied from lib/utils.ts to avoid cross-package imports
*/
import { getXmlSyntaxError } from "./dom.js"
import { getXmlSyntaxError } from "./xml-syntax.ts"
// ============================================================================
// Constants
@@ -216,15 +216,25 @@ function checkDuplicateIds(xml: string): string | null {
return `Invalid XML: Found duplicate <diagram> id(s): ${dupDiagrams.slice(0, 3).join(", ")}. Each page must have a unique id.`
}
// 2) Within each page, mxCell ids must be unique.
// 2) Within each page, cell ids must be unique. A cell with
// a link or custom data is a UserObject/object holding the
// id, around an mxCell whose own id does not count.
for (let i = 0; i < diagrams.length; i++) {
const diagram = diagrams[i]
const pageId = diagram.getAttribute("id") || `(index ${i})`
const cells = diagram.querySelectorAll("mxCell")
const cells = diagram.querySelectorAll(
"mxCell, UserObject, object",
)
const cellIds = new Map<string, number>()
cells.forEach((c) => {
const wrapped =
c.tagName === "mxCell" &&
/^(UserObject|object)$/.test(
c.parentElement?.tagName ?? "",
)
const id = c.getAttribute("id")
if (id) cellIds.set(id, (cellIds.get(id) || 0) + 1)
if (id && !wrapped)
cellIds.set(id, (cellIds.get(id) || 0) + 1)
})
const dups = Array.from(cellIds.entries())
.filter(([, c]) => c > 1)
@@ -1040,19 +1050,23 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
// ============================================================================
/**
* Validates XML and attempts to fix if invalid. Runs the strict checks
* (unknown elements, orphan mxPoints): every caller passes XML the model wrote.
* Validates XML and attempts to fix if invalid. By default runs the strict
* checks (unknown elements, orphan mxPoints), meant for XML the model wrote.
* Pass strict: false for a diagram that also holds the user's own content.
* @param xml - The XML string to validate and potentially fix
* @returns Object with validation result, fixed XML if applicable, and fixes applied
*/
export function validateAndFixXml(xml: string): {
export function validateAndFixXml(
xml: string,
{ strict = true }: { strict?: boolean } = {},
): {
valid: boolean
error: string | null
fixed: string | null
fixes: string[]
} {
// First validation attempt
let error = validateMxCellStructure(xml, { strict: true })
let error = validateMxCellStructure(xml, { strict })
if (!error) {
return { valid: true, error: null, fixed: null, fixes: [] }
@@ -1062,7 +1076,7 @@ export function validateAndFixXml(xml: string): {
const { fixed, fixes } = autoFixXml(xml)
// Validate the fixed version
error = validateMxCellStructure(fixed, { strict: true })
error = validateMxCellStructure(fixed, { strict })
if (!error) {
return { valid: true, error: null, fixed, fixes }
@@ -6,13 +6,13 @@
import { deflateRawSync } from "node:zlib"
import { beforeAll, describe, expect, it, vi } from "vitest"
import { installDomPolyfill } from "../src/dom.js"
import { installDomPolyfill } from "../src/dom.ts"
beforeAll(() => {
installDomPolyfill()
})
import { applyDiagramOperations } from "../src/diagram-operations.js"
import { applyDiagramOperations } from "../src/diagram-operations.ts"
const DOC = `<mxfile><diagram id="p" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><UserObject id="a" label="A" link="https://example.com"><mxCell vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></UserObject><mxCell id="b" value="B" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell><object id="e1" label="" tooltip="t"><mxCell edge="1" source="b" target="a" parent="1"><mxGeometry relative="1" as="geometry"/></mxCell></object><mxCell id="child" value="C" vertex="1" parent="a"><mxGeometry as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`
@@ -3,14 +3,14 @@
*/
import { beforeAll, describe, expect, it } from "vitest"
import { installDomPolyfill } from "../src/dom.js"
import { installDomPolyfill } from "../src/dom.ts"
beforeAll(() => {
installDomPolyfill()
})
import { editDiagram, targetPageXml } from "../src/edit-diagram.js"
import { validateMxCellStructure } from "../src/xml-validation.js"
import { editDiagram, targetPageXml } from "../src/edit-diagram.ts"
import { validateMxCellStructure } from "../src/xml-validation.ts"
const cell = (id: string, extra = "") =>
`<mxCell id="${id}" value="${id}" vertex="1" parent="1"${extra}><mxGeometry x="0" y="0" width="80" height="40" as="geometry"/></mxCell>`
+2 -2
View File
@@ -10,13 +10,13 @@
*/
import { beforeAll, describe, expect, it } from "vitest"
import { installDomPolyfill } from "../src/dom.js"
import { installDomPolyfill } from "../src/dom.ts"
beforeAll(() => {
installDomPolyfill()
})
import { checkEditGate, contentFingerprint } from "../src/edit-gate.js"
import { checkEditGate, contentFingerprint } from "../src/edit-gate.ts"
const XML_A = `<mxfile host="app.diagrams.net"><diagram id="p1" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="box1" value="Hello" style="rounded=0;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`
@@ -8,7 +8,7 @@
import http from "node:http"
import { afterAll, beforeAll, describe, expect, it } from "vitest"
import { addHistory, getHistory } from "../src/history.js"
import { addHistory, getHistory } from "../src/history.ts"
import {
getState,
requestExport,
@@ -17,7 +17,7 @@ import {
shutdown,
startHttpServer,
waitForSync,
} from "../src/http-server.js"
} from "../src/http-server.ts"
let port = 0
@@ -10,7 +10,7 @@
import { deflateRawSync } from "node:zlib"
import { DOMParser } from "linkedom"
import { beforeAll, describe, expect, it } from "vitest"
import { installDomPolyfill } from "../src/dom.js"
import { installDomPolyfill } from "../src/dom.ts"
// Install the DOM polyfills exactly as index.ts does at runtime.
beforeAll(() => {
@@ -20,7 +20,7 @@ beforeAll(() => {
import {
decompressPageContent,
parseDrawioFileContent,
} from "../src/load-diagram.js"
} from "../src/load-diagram.ts"
const MODEL_XML = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="box1" value="Hello" style="rounded=0;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>`
+24 -4
View File
@@ -16,7 +16,7 @@
*/
import { beforeAll, describe, expect, it } from "vitest"
import { installDomPolyfill } from "../src/dom.js"
import { installDomPolyfill } from "../src/dom.ts"
// Install the DOM polyfill exactly as index.ts does at runtime — the
// helpers under test rely on it.
@@ -24,7 +24,7 @@ beforeAll(() => {
installDomPolyfill()
})
import { applyDiagramOperations } from "../src/diagram-operations.js"
import { applyDiagramOperations } from "../src/diagram-operations.ts"
import {
addPageToDoc,
deletePageFromDoc,
@@ -39,8 +39,8 @@ import {
projectPage,
renamePageInDoc,
serializeMxfile,
} from "../src/pages.js"
import { validateAndFixXml } from "../src/xml-validation.js"
} from "../src/pages.ts"
import { validateAndFixXml } from "../src/xml-validation.ts"
const BARE_MODEL_ONE_CELL = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="Hello"><mxGeometry x="40" y="40" width="100" height="40" as="geometry"/></mxCell></root></mxGraphModel>`
@@ -296,6 +296,26 @@ describe("xml-validation.ts — multi-page support", () => {
expect(result.error).toMatch(/duplicate cell ID/i)
})
it("counts the id of a UserObject/object, not of the mxCell it wraps", () => {
const page = (cells: string) =>
`<mxfile><diagram id="p1" name="P1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram></mxfile>`
const wrapped = (id: string) =>
`<UserObject id="${id}" link="x"><mxCell id="${id}" vertex="1" parent="1"/></UserObject>`
expect(validateAndFixXml(page(wrapped("u"))).valid).toBe(true)
const clash = validateAndFixXml(
page(`${wrapped("2")}<mxCell id="2" vertex="1" parent="1"/>`),
)
expect(clash.valid).toBe(false)
expect(clash.error).toMatch(/duplicate cell ID/i)
})
it("can skip the strict checks for diagrams with user content", () => {
const custom = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><MyObj id="5" label="x"><mxCell vertex="1" parent="1"/></MyObj></root></mxGraphModel>`
expect(validateAndFixXml(custom).valid).toBe(true) // fixed by removal
const lenient = validateAndFixXml(custom, { strict: false })
expect(lenient).toMatchObject({ valid: true, fixed: null })
})
it("rejects duplicate <diagram> ids across the file", () => {
const bad = `<mxfile host="app.diagrams.net"><diagram id="p1" name="A"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram><diagram id="p1" name="B"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
const result = validateAndFixXml(bad)
@@ -13,7 +13,7 @@ import {
import { tmpdir } from "node:os"
import { join } from "node:path"
import { afterEach, describe, expect, it } from "vitest"
import { Autosaver, defaultDataDir } from "../src/persistence.js"
import { Autosaver, defaultDataDir } from "../src/persistence.ts"
const DIAGRAM = `<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
const BLANK = `<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
+9 -3
View File
@@ -4,14 +4,14 @@
*/
import { beforeAll, describe, expect, it } from "vitest"
import { installDomPolyfill } from "../src/dom.js"
import { installDomPolyfill } from "../src/dom.ts"
beforeAll(() => {
installDomPolyfill()
})
import { wrapCellsInModel } from "../src/pages.js"
import { validateAndFixXml } from "../src/xml-validation.js"
import { wrapCellsInModel } from "../src/pages.ts"
import { validateAndFixXml } from "../src/xml-validation.ts"
const A = `<mxCell id="2" value="A" vertex="1" parent="1"><mxGeometry x="0" y="0" width="80" height="40" as="geometry"/></mxCell>`
const B = `<mxCell id="3" value="B" vertex="1" parent="1"><mxGeometry x="200" y="0" width="80" height="40" as="geometry"/></mxCell>`
@@ -45,6 +45,12 @@ describe("wrapCellsInModel", () => {
)
})
it("drops comments and text before the first cell", () => {
const expected = `<mxGraphModel><root>${ROOTS}${A}</root></mxGraphModel>`
expect(wrapCellsInModel(`Here is the diagram: ${A}`)).toBe(expected)
expect(wrapCellsInModel(`<!-- boxes -->\n${A}`)).toBe(expected)
})
it("leaves <mxGraphModel> and <mxfile> input unchanged", () => {
const model = `<mxGraphModel><root>${ROOTS}${A}</root></mxGraphModel>`
const file = `<mxfile><diagram id="p" name="P">${model}</diagram></mxfile>`
@@ -8,14 +8,15 @@
*/
import { beforeAll, describe, expect, it } from "vitest"
import { getXmlSyntaxError, installDomPolyfill } from "../src/dom.js"
import { installDomPolyfill } from "../src/dom.ts"
import { getXmlSyntaxError } from "../src/xml-syntax.ts"
beforeAll(() => {
installDomPolyfill()
})
import { addPageToDoc, parseMxfile, serializeMxfile } from "../src/pages.js"
import { validateAndFixXml } from "../src/xml-validation.js"
import { addPageToDoc, parseMxfile, serializeMxfile } from "../src/pages.ts"
import { validateAndFixXml } from "../src/xml-validation.ts"
/** Bare model with the root cells plus the given cells. */
const model = (cells: string) =>
@@ -26,6 +27,18 @@ const model = (cells: string) =>
const BROKEN_CELL = `<mxCell id="9" value="R&D" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell>`
describe("getXmlSyntaxError", () => {
it("rejects an attribute prefix that was never declared", () => {
// The browser's DOMParser, and so draw.io, rejects it too
expect(getXmlSyntaxError(`<mxCell id="2" xlink:href="x"/>`)).toMatch(
/prefix/,
)
expect(
getXmlSyntaxError(
`<mxCell xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="x"/>`,
),
).toBeNull()
})
it("accepts well-formed XML", () => {
expect(getXmlSyntaxError(model(""))).toBeNull()
})
+1
View File
@@ -13,6 +13,7 @@
"declarationMap": true,
"sourceMap": true,
"resolveJsonModule": true,
"rewriteRelativeImportExtensions": true,
"types": ["node"]
},
"include": ["src/**/*"],
+90
View File
@@ -0,0 +1,90 @@
/**
* The web app runs the MCP server's diagram modules in the browser, where
* DOMParser and XMLSerializer are the native ones (jsdom here), not the
* linkedom polyfill the MCP tests use.
*/
import { deflateRaw } from "pako"
import { describe, expect, it } from "vitest"
import { applyDiagramOperations } from "@/packages/mcp-server/src/diagram-operations.ts"
import { editDiagram } from "@/packages/mcp-server/src/edit-diagram.ts"
import { decompressPageContent } from "@/packages/mcp-server/src/load-diagram.ts"
import {
hasCells,
normalizeToMxfile,
wrapCellsInModel,
} from "@/packages/mcp-server/src/pages.ts"
import { getXmlSyntaxError } from "@/packages/mcp-server/src/xml-syntax.ts"
import { validateAndFixXml } from "@/packages/mcp-server/src/xml-validation.ts"
const box = (id: string, parent = "1") =>
`<mxCell id="${id}" value="${id}" vertex="1" parent="${parent}"><mxGeometry x="0" y="0" width="80" height="40" as="geometry"/></mxCell>`
const edge = (id: string, source: string, target: string) =>
`<mxCell id="${id}" edge="1" parent="1" source="${source}" target="${target}"><mxGeometry relative="1" as="geometry"/></mxCell>`
const file = (cells: string) =>
normalizeToMxfile(wrapCellsInModel(cells), {
pageId: "p1",
pageName: "Page-1",
}) as string
describe("MCP diagram modules with a browser DOM", () => {
it("wraps bare cells into a valid file", () => {
const xml = file(box("a") + box("b"))
expect(xml).toContain('<diagram id="p1" name="Page-1">')
expect(validateAndFixXml(xml).valid).toBe(true)
expect(hasCells(xml)).toBe(true)
expect(hasCells(file(""))).toBe(false)
})
it("fixes the case of a misspelled tag in model XML", () => {
const result = validateAndFixXml(
file(box("a"))
.replace('<mxCell id="a"', '<mxcell id="a"')
.replace("</mxCell></root>", "</mxcell></root>"),
)
expect(result.valid).toBe(true)
expect(result.fixes.join(" ")).toMatch(/tag case/)
})
it("reports syntax errors with line and column", () => {
expect(getXmlSyntaxError("<mxfile><diagram></mxfile>")).toMatch(
/^1:\d+/,
)
expect(getXmlSyntaxError(file(box("a")))).toBeNull()
})
it("deletes a cell with its edges", () => {
const xml = file(box("a") + box("b") + edge("e", "a", "b"))
const { result, errors } = applyDiagramOperations(xml, [
{ operation: "delete", cell_id: "a" },
])
expect(errors).toEqual([])
expect(result).not.toContain('id="a"')
expect(result).not.toContain('id="e"')
expect(result).toContain('id="b"')
})
it("runs a whole edit and serializes the target page", () => {
const outcome = editDiagram(
file(box("a")),
[{ operation: "add", cell_id: "b", new_xml: box("b") }],
{},
)
expect(outcome.ok).toBe(true)
if (outcome.ok) expect(outcome.xml).toContain('id="b"')
const failed = editDiagram(
file(box("a")),
[{ operation: "add", cell_id: "b", new_xml: box("b") + box("c") }],
{},
)
expect(failed.ok).toBe(false)
})
it("decompresses a draw.io compressed page", () => {
const model = wrapCellsInModel(box("a"))
const deflated = deflateRaw(encodeURIComponent(model))
const base64 = btoa(String.fromCharCode(...deflated))
expect(decompressPageContent(base64)).toBe(model)
expect(decompressPageContent("not compressed")).toBeNull()
})
})
+1
View File
@@ -9,6 +9,7 @@
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",