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
@@ -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()
})