mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-07 10:17:47 +08:00
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:
Generated
+1
-2
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+25
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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> = {
|
||||
"&": "&",
|
||||
|
||||
@@ -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[] }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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>"]) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>`
|
||||
|
||||
@@ -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>`
|
||||
|
||||
|
||||
@@ -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>`
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,7 @@
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
Reference in New Issue
Block a user