mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-10 19:49:52 +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:
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user