feat(diagram): named styles and compact cells for shorter model output (#969)

The model defines reused styles once as <mxStyle name="..." value="..."/> and
refers to them by name, and writes shapes as one self-closing mxCell with
x, y, w, h and edges with source and target. style-classes.ts and
compact-cells.ts expand both back into standard draw.io XML after validation
and add draw.io's html=1 / whiteSpace=wrap defaults; the diagram shown to the
model is folded into the same notation. Prompts, tool descriptions and the MCP
drawing guide teach the notation with shared examples.

Measured on the five start-screen examples against main: gpt-6-luna 35% fewer
output tokens, Claude Opus 5.5 41% fewer. Two five-model review rounds fixed
quote-aware cell matching, attribute escaping, edge/vertex inference, root-id
and layer handling, and several auto-fix gaps.
This commit is contained in:
Dayuan Jiang
2026-10-10 21:58:21 +09:00
committed by GitHub
parent 498ee628f1
commit cd5352ca88
16 changed files with 1720 additions and 123 deletions
+21 -10
View File
@@ -55,12 +55,16 @@ import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
import { getSystemPrompt } from "@/lib/system-prompts" import { getSystemPrompt } from "@/lib/system-prompts"
import { normalizeBaseUrl } from "@/lib/types/model-config" import { normalizeBaseUrl } from "@/lib/types/model-config"
import { getUserIdFromRequest } from "@/lib/user-id" import { getUserIdFromRequest } from "@/lib/user-id"
import { foldCells } from "@/packages/mcp-server/src/compact-cells.ts"
import { hasCells } from "@/packages/mcp-server/src/pages.ts" import { hasCells } from "@/packages/mcp-server/src/pages.ts"
import { import {
getShapeLibrary, getShapeLibrary,
SHAPE_LIBRARY_LIST, SHAPE_LIBRARY_LIST,
} from "@/packages/mcp-server/src/shape-library.ts" } from "@/packages/mcp-server/src/shape-library.ts"
import { SWIMLANE_EXAMPLE } from "@/packages/mcp-server/src/xml-examples.ts" import {
STYLE_CLASS_EXAMPLE,
SWIMLANE_EXAMPLE,
} from "@/packages/mcp-server/src/xml-examples.ts"
// No explicit cap: a reasoning model can spend minutes planning before it emits // No explicit cap: a reasoning model can spend minutes planning before it emits
// the tool call, so take whatever the host allows. Vercel's own default is 300s, // the tool call, so take whatever the host allows. Vercel's own default is 300s,
@@ -520,18 +524,20 @@ ${userInputText}
SINGLE_SYSTEM_PROVIDERS.has(resolvedProvider) || isCustomOpenAIEndpoint SINGLE_SYSTEM_PROVIDERS.has(resolvedProvider) || isCustomOpenAIEndpoint
const selectionContext = formatSelectionContext(body.selectedCells) const selectionContext = formatSelectionContext(body.selectedCells)
// The model reads the diagram in the compact notation it writes
// (compact-cells.ts); the canvas itself keeps the full XML
const xmlContext = `${ const xmlContext = `${
previousXml previousXml
? `Previous diagram XML (before user's last message): ? `Previous diagram XML (before user's last message):
"""xml """xml
${previousXml} ${foldCells(previousXml)}
""" """
` `
: "" : ""
}Current diagram XML (AUTHORITATIVE - the source of truth): }Current diagram XML (AUTHORITATIVE - the source of truth), shown in the same compact notation you write (shapes with x, y, w, h; edges with source and target):
"""xml """xml
${xml || ""} ${foldCells(xml || "")}
""" """
IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on the canvas right now. The user can manually add, delete, or modify shapes directly in draw.io. Always count and describe elements based on the CURRENT XML, not on what you previously generated. If both previous and current XML are shown, compare them to understand what the user changed.${selectionContext ? `\n\n${selectionContext}` : ""}` IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on the canvas right now. The user can manually add, delete, or modify shapes directly in draw.io. Always count and describe elements based on the CURRENT XML, not on what you previously generated. If both previous and current XML are shown, compare them to understand what the user changed.${selectionContext ? `\n\n${selectionContext}` : ""}`
@@ -659,19 +665,24 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
tools: { tools: {
// Client-side tool that will be executed on the client // Client-side tool that will be executed on the client
display_diagram: { display_diagram: {
description: `Display a diagram on draw.io. Pass ONLY the mxCell elements - wrapper tags and root cells are added automatically. description: `Display a diagram on draw.io. Pass ONLY the mxStyle definitions and the mxCell elements - wrapper tags and root cells are added automatically.
VALIDATION RULES (XML will be rejected if violated): VALIDATION RULES (XML will be rejected if violated):
1. Generate ONLY mxCell elements - NO wrapper tags (<mxfile>, <mxGraphModel>, <root>) 1. Generate ONLY mxStyle definitions and mxCell elements - NO wrapper tags (<mxfile>, <mxGraphModel>, <root>)
2. Do NOT include root cells (id="0" or id="1") - they are added automatically 2. Do NOT include root cells (id="0" or id="1") - they are added automatically
3. All mxCell elements must be siblings - never nested 3. All mxCell elements must be siblings - never nested
4. Every mxCell needs a unique id (start from "2") 4. Every mxCell needs a unique id (start from "2")
5. Every mxCell needs a valid parent attribute (use "1" for top-level) 5. parent defaults to "1"; write parent="<container-id>" only for shapes inside a container
6. Escape special chars in values: &lt; &gt; &amp; &quot; 6. Escape special chars in values: &lt; &gt; &amp; &quot;
A shape is one self-closing mxCell with x, y, w and h; an edge is one with source and target (a cell with source or target is always an edge). vertex="1", edge="1", parent="1" and the mxGeometry element are added automatically. Write parent only for a shape inside a container, and an mxGeometry element only for edge waypoints or for a separate label cell placed on an edge: <mxCell id="9" value="yes" style="edgeLabel;" parent="<edge id>" connectable="0"><mxGeometry x="-0.5" relative="1" as="geometry"/></mxCell>. An edge's own text simply goes in its value.
Example (generate ONLY this - no wrapper tags): Example (generate ONLY this - no wrapper tags):
${SWIMLANE_EXAMPLE} ${SWIMLANE_EXAMPLE}
Styles: define a style used by several cells ONCE with <mxStyle name="..." value="..."/> before the cells and use the name in style like a CSS class; overrides after the name win. Name only styles that two or more cells share; names must not be draw.io's own style names (text, ellipse, rhombus, swimlane, label, image, blue, green, red, gray, yellow, orange, purple, pink). A definition applies to the call it is in. html=1 and whiteSpace=wrap are added automatically, never write them. Labels are HTML: use &lt;br&gt; for a line break, never \\n; a literal < or > is written &amp;lt; or &amp;gt;.
${STYLE_CLASS_EXAMPLE}
Notes: Notes:
- For AWS diagrams, use **AWS 2025 icons**. - For AWS diagrams, use **AWS 2025 icons**.
- For animated connectors, add "flowAnimation=1" to edge style. - For animated connectors, add "flowAnimation=1" to edge style.
@@ -690,12 +701,12 @@ Operations:
- add: Add a new cell. Provide cell_id (new unique id) and new_xml. - add: Add a new cell. Provide cell_id (new unique id) and new_xml.
- delete: Remove a cell. Cascade is automatic: children AND edges (source/target) are auto-deleted. Only specify ONE cell_id. - delete: Remove a cell. Cascade is automatic: children AND edges (source/target) are auto-deleted. Only specify ONE cell_id.
For update/add, new_xml must be a complete mxCell element including mxGeometry. For update/add, new_xml is the complete mxCell in the compact form (a shape with x, y, w, h; an edge with source and target), with its complete style (named styles are not available in edit_diagram).
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\" ⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"
Example - Add a rectangle: Example - Add a rectangle:
{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": "<mxCell id=\\"rect-1\\" value=\\"Hello\\" style=\\"rounded=0;\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]} {"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": "<mxCell id=\\"rect-1\\" value=\\"Hello\\" style=\\"rounded=1;\\" x=\\"100\\" y=\\"100\\" w=\\"120\\" h=\\"60\\"/>"}]}
Example - Delete container (children & edges auto-deleted): Example - Delete container (children & edges auto-deleted):
{"operations": [{"operation": "delete", "cell_id": "2"}]}`, {"operations": [{"operation": "delete", "cell_id": "2"}]}`,
@@ -735,7 +746,7 @@ CRITICAL INSTRUCTIONS:
3. Complete the remaining mxCell elements 3. Complete the remaining mxCell elements
4. If still truncated, call append_diagram again with the next fragment 4. If still truncated, call append_diagram again with the next fragment
Example: If previous output ended with '<mxCell id="x" style="rounded=1', continue with ';" vertex="1">...' and complete the remaining elements.`, Example: If previous output ended with '<mxCell id="x" style="rounded=1', continue with ';" x="40" y="40" w="120" h="60"/>' and complete the remaining elements.`,
inputSchema: z.object({ inputSchema: z.object({
xml: z xml: z
.string() .string()
+33 -3
View File
@@ -10,8 +10,18 @@ import {
extractCompleteMxCells, extractCompleteMxCells,
replaceNodes, replaceNodes,
} from "@/lib/utils" } from "@/lib/utils"
import {
defaultLayerOf,
expandCompactCells,
} from "@/packages/mcp-server/src/compact-cells.ts"
import { applyDiagramOperations } from "@/packages/mcp-server/src/diagram-operations.ts" import { applyDiagramOperations } from "@/packages/mcp-server/src/diagram-operations.ts"
import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts" import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
import {
addDefaultStyles,
applyStyleClasses,
edgeIdsOf,
readStyleClasses,
} from "@/packages/mcp-server/src/style-classes.ts"
// Helper to extract complete operations from streaming input // Helper to extract complete operations from streaming input
function getCompleteOperations( function getCompleteOperations(
@@ -71,9 +81,17 @@ export function useDiagramStreaming({
// so far. The tool handler validates and loads the final diagram. // so far. The tool handler validates and loads the final diagram.
const handleDisplayChart = useCallback( const handleDisplayChart = useCallback(
(xml: string) => { (xml: string) => {
const completeCells = extractCompleteMxCells(xml || "") // Named styles come before the cells, so the ones written so
// far are known by the time their cells stream in
const { classes, xml: cellsXml } = readStyleClasses(xml || "")
const completeCells = extractCompleteMxCells(cellsXml)
if (!completeCells) return if (!completeCells) return
const convertedXml = convertToLegalXml(completeCells) const convertedXml = addDefaultStyles(
applyStyleClasses(
expandCompactCells(convertToLegalXml(completeCells)),
classes,
),
)
if (convertedXml === previousXML.current) return if (convertedXml === previousXML.current) return
// Skip this update while the cells written so far don't parse // Skip this update while the cells written so far don't parse
@@ -202,9 +220,21 @@ export function useDiagramStreaming({
if (lastProcessedXmlRef.current.get(opsKey) === ops) continue if (lastProcessedXmlRef.current.get(opsKey) === ops) continue
lastProcessedXmlRef.current.set(opsKey, ops) lastProcessedXmlRef.current.set(opsKey, ops)
try { try {
const edges = edgeIdsOf(originalXml)
const layer = defaultLayerOf(originalXml)
const { result } = applyDiagramOperations( const { result } = applyDiagramOperations(
originalXml, originalXml,
completeOps, completeOps.map((op) =>
op.new_xml
? {
...op,
new_xml: addDefaultStyles(
expandCompactCells(op.new_xml, layer),
edges,
),
}
: op,
),
) )
if (lastProcessedXmlRef.current.get(resultKey) === result) { if (lastProcessedXmlRef.current.get(resultKey) === result) {
continue continue
+7 -2
View File
@@ -8,8 +8,10 @@ import type { LoadMode } from "@/contexts/diagram-context"
import type { ValidationResult } from "@/lib/diagram-validator" import type { ValidationResult } from "@/lib/diagram-validator"
import { formatValidationFeedback } from "@/lib/diagram-validator" import { formatValidationFeedback } from "@/lib/diagram-validator"
import { isMxCellXmlComplete } from "@/lib/utils" import { isMxCellXmlComplete } from "@/lib/utils"
import { foldCells } from "@/packages/mcp-server/src/compact-cells.ts"
import { editDiagram } from "@/packages/mcp-server/src/edit-diagram.ts" import { editDiagram } from "@/packages/mcp-server/src/edit-diagram.ts"
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts" import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
import { hasCells } from "@/packages/mcp-server/src/pages.ts"
const DEBUG = process.env.NODE_ENV === "development" const DEBUG = process.env.NODE_ENV === "development"
@@ -516,7 +518,7 @@ ${finalXml}
Current diagram XML: Current diagram XML:
\`\`\`xml \`\`\`xml
${currentXml} ${foldCells(currentXml)}
\`\`\` \`\`\`
Please check the cell IDs and retry.`, Please check the cell IDs and retry.`,
@@ -545,7 +547,7 @@ Please check the cell IDs and retry.`,
Current diagram XML: Current diagram XML:
\`\`\`xml \`\`\`xml
${currentXml || "No XML available"} ${currentXml ? foldCells(currentXml) : "No XML available"}
\`\`\` \`\`\`
Please check cell IDs and retry, or use display_diagram to regenerate.`, Please check cell IDs and retry, or use display_diagram to regenerate.`,
@@ -575,7 +577,10 @@ Use display_diagram to create the complete diagram, or edit_diagram to change th
// Detect if LLM incorrectly started fresh instead of continuing // Detect if LLM incorrectly started fresh instead of continuing
// LLM should only output bare mxCells now, so wrapper tags indicate error // LLM should only output bare mxCells now, so wrapper tags indicate error
const trimmed = xml.trim() const trimmed = xml.trim()
// Definitions at the start are a restart only once cells were written
const isFreshStart = const isFreshStart =
(trimmed.startsWith("<mxStyle") &&
hasCells(partialXmlRef.current)) ||
trimmed.startsWith("<mxGraphModel") || trimmed.startsWith("<mxGraphModel") ||
trimmed.startsWith("<root") || trimmed.startsWith("<root") ||
trimmed.startsWith("<mxfile") || trimmed.startsWith("<mxfile") ||
+29 -31
View File
@@ -4,6 +4,7 @@
*/ */
import { import {
STYLE_CLASS_EXAMPLE,
SWIMLANE_EXAMPLE, SWIMLANE_EXAMPLE,
TWO_EDGES_EXAMPLE, TWO_EDGES_EXAMPLE,
WAYPOINT_EXAMPLE, WAYPOINT_EXAMPLE,
@@ -100,45 +101,41 @@ Note that:
When using edit_diagram tool: When using edit_diagram tool:
- Use operations: update (modify cell by id), add (new cell), delete (remove cell by id) - Use operations: update (modify cell by id), add (new cell), delete (remove cell by id)
- For update/add: provide cell_id and complete new_xml (full mxCell element including mxGeometry) - For update/add: provide cell_id and the complete new_xml in the same compact form (a shape with x, y, w, h; an edge with source and target)
- For delete: only cell_id is needed - For delete: only cell_id is needed
- Named styles are not available in edit_diagram: write each cell's complete style
- Find the cell_id from "Current diagram XML" in system context - Find the cell_id from "Current diagram XML" in system context
- Example update: {"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]} - Example update: {"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" x=\\"100\\" y=\\"100\\" w=\\"120\\" h=\\"60\\"/>"}]}
- Example delete: {"operations": [{"operation": "delete", "cell_id": "5"}]} - Example delete: {"operations": [{"operation": "delete", "cell_id": "5"}]}
- Example add: {"operations": [{"operation": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"400\\" y=\\"200\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]} - Example add: {"operations": [{"operation": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;\\" x=\\"400\\" y=\\"200\\" w=\\"120\\" h=\\"60\\"/>"}]}
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\" ⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"
## Draw.io XML Structure Reference ## Draw.io XML Structure Reference
**IMPORTANT:** You only generate the mxCell elements. The wrapper structure and root cells (id="0", id="1") are added automatically. **IMPORTANT:** You only generate the named styles and the mxCell elements. The wrapper structure and root cells (id="0", id="1") are added automatically. A named style is written before the cells as <mxStyle name="n" value="...style pairs..."/>; a cell uses it by putting the name among its style tokens (see Styles). A shape is one self-closing mxCell with x, y, w and h; an edge is one with source and target (a cell with source or target is always an edge). vertex="1", edge="1", parent="1" and the mxGeometry element are added automatically. Write parent only for a shape inside a container, and an mxGeometry element only for edge waypoints or for a separate label cell placed on an edge: <mxCell id="9" value="yes" style="edgeLabel;" parent="<edge id>" connectable="0"><mxGeometry x="-0.5" relative="1" as="geometry"/></mxCell>. An edge's own text simply goes in its value.
Example - generate ONLY this: Example - generate ONLY this:
\`\`\`xml \`\`\`xml
<mxCell id="2" value="Label" style="rounded=1;" vertex="1" parent="1"> <mxCell id="2" value="Label" style="rounded=1;" x="100" y="100" w="120" h="60"/>
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
</mxCell>
\`\`\` \`\`\`
CRITICAL RULES: CRITICAL RULES:
1. Generate ONLY mxCell elements - NO wrapper tags (<mxfile>, <mxGraphModel>, <root>) 1. Generate ONLY mxStyle definitions and mxCell elements - NO wrapper tags (<mxfile>, <mxGraphModel>, <root>)
2. Do NOT include root cells (id="0" or id="1") - they are added automatically 2. Do NOT include root cells (id="0" or id="1") - they are added automatically
3. ALL mxCell elements must be siblings - NEVER nest mxCell inside another mxCell 3. ALL mxCell elements must be siblings - NEVER nest mxCell inside another mxCell
4. Use unique sequential IDs starting from "2" 4. Use unique sequential IDs starting from "2"
5. Set parent="1" for top-level shapes, or parent="<container-id>" for grouped elements 5. Write parent="<container-id>" only for shapes inside a container; top-level cells need no parent
Shape (vertex) example: Shape (vertex) example:
\`\`\`xml \`\`\`xml
<mxCell id="2" value="Label" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1"> <mxCell id="2" value="Label" style="rounded=1;" x="100" y="100" w="120" h="60"/>
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
</mxCell>
\`\`\` \`\`\`
Connector (edge) example: Connector (edge) example:
\`\`\`xml \`\`\`xml
<mxCell id="3" style="endArrow=classic;html=1;" edge="1" parent="1" source="2" target="4"> <mxCell id="3" style="edgeStyle=orthogonalEdgeStyle;" source="2" target="4"/>
<mxGeometry relative="1" as="geometry"/> \`\`\`
</mxCell>
### Edge Routing Rules: ### Edge Routing Rules:
When creating edges/connectors, you MUST follow these rules to avoid overlapping lines: When creating edges/connectors, you MUST follow these rules to avoid overlapping lines:
@@ -153,7 +150,7 @@ When creating edges/connectors, you MUST follow these rules to avoid overlapping
**Rule 3: Always specify exitX, exitY, entryX, entryY explicitly** **Rule 3: Always specify exitX, exitY, entryX, entryY explicitly**
- Every edge MUST have these 4 attributes set in the style - Every edge MUST have these 4 attributes set in the style
- Example: style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;" - Example: style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;"
**Rule 4: Route edges AROUND intermediate shapes (obstacle avoidance) - CRITICAL!** **Rule 4: Route edges AROUND intermediate shapes (obstacle avoidance) - CRITICAL!**
- Before creating an edge, identify ALL shapes positioned between source and target - Before creating an edge, identify ALL shapes positioned between source and target
@@ -188,17 +185,18 @@ When creating edges/connectors, you MUST follow these rules to avoid overlapping
3. "Are any connection points at corners (both X and Y are 0 or 1)?" → If yes, use edge centers instead 3. "Are any connection points at corners (both X and Y are 0 or 1)?" → If yes, use edge centers instead
4. "Could I rearrange shapes to reduce edge crossings?" → If yes, revise layout 4. "Could I rearrange shapes to reduce edge crossings?" → If yes, revise layout
\`\`\`
` `
// Style instructions - only included when minimalStyle is false // Style instructions - only included when minimalStyle is false
const STYLE_INSTRUCTIONS = ` const STYLE_INSTRUCTIONS = `
Common styles: ## Styles
- Shapes: rounded=1 (rounded corners), fillColor=#hex, strokeColor=#hex Define each style used by several cells ONCE, as a named style before the cells, and use the name in the cells like a CSS class. A cell's style can combine a shape token, a name and overrides; later pairs win. Name only styles that two or more cells share; a style used by one cell stays inline. Names must not be draw.io's own style names: shapes such as text, ellipse, rhombus, swimlane, label, image, and colors such as blue, green, red, gray, yellow, orange, purple, pink. A definition applies to the call it is in: each display_diagram call defines the names it uses. The app expands the names, so the saved file is standard draw.io XML.
- Edges: endArrow=classic/block/open/none, startArrow=none/classic, curved=1, edgeStyle=orthogonalEdgeStyle \`\`\`xml
- Text: fontSize=14, fontStyle=1 (bold), align=center/left/right ${STYLE_CLASS_EXAMPLE}
\`\`\`
- NEVER write html=1 or whiteSpace=wrap: the app adds html=1 to every cell and whiteSpace=wrap to shapes. Labels are HTML: use &lt;br&gt; for a line break and &lt;b&gt; for bold, never \\n; a literal < or > in a label is written &amp;lt; or &amp;gt;.
- Do NOT repeat what draw.io already uses. For a plain shape: rounded=0, align=center, verticalAlign=middle, fontSize=12, strokeWidth=1, fillColor=#ffffff, strokeColor=#000000, fontColor=#000000. For an edge: endArrow=classic, strokeColor=#000000. Writing one of them is right only when it overrides what a name or the shape sets: an edge is rounded by default, so rounded=0 on an edge is a real setting, and a text cell is left/top aligned by default, so there align=center or verticalAlign=middle are real settings.
- Keys: shapes rounded=1, fillColor=#hex, strokeColor=#hex; edges endArrow=block/open/none, startArrow=classic, curved=1, dashed=1, edgeStyle=orthogonalEdgeStyle; text fontSize=14, fontStyle=1 (bold), align=center/right.
` `
// Minimal style instruction - skip styling and focus on layout (prepended to prompt for emphasis) // Minimal style instruction - skip styling and focus on layout (prepended to prompt for emphasis)
@@ -208,13 +206,13 @@ const MINIMAL_STYLE_INSTRUCTION = `
### No Styling - Plain Black/White Only ### No Styling - Plain Black/White Only
- NO fillColor, NO strokeColor, NO rounded, NO fontSize, NO fontStyle - NO fillColor, NO strokeColor, NO rounded, NO fontSize, NO fontStyle
- NO color attributes (no hex colors like #ff69b4) - NO color attributes (no hex colors like #ff69b4)
- Style: "whiteSpace=wrap;html=1;" for shapes, "html=1;endArrow=classic;" for edges - Shapes: no style, or only the shape (ellipse, rhombus). Edges: edgeStyle=orthogonalEdgeStyle plus the exit/entry points from the Edge Routing Rules, nothing else. html=1 and whiteSpace=wrap are added automatically.
- IGNORE all color/style examples below - IGNORE all color/style examples below
### Container/Group Shapes - MUST be Transparent ### Container/Group Shapes - MUST be Transparent
- For container shapes (boxes that contain other shapes): use "fillColor=none;" to make background transparent - For container shapes (boxes that contain other shapes): use "fillColor=none;" to make background transparent
- This prevents containers from covering child elements - This prevents containers from covering child elements
- Example: style="whiteSpace=wrap;html=1;fillColor=none;" for container rectangles - Example: style="fillColor=none;" for container rectangles
### Focus on Layout Quality ### Focus on Layout Quality
Since we skip styling, STRICTLY follow the "Edge Routing Rules" section below: Since we skip styling, STRICTLY follow the "Edge Routing Rules" section below:
@@ -235,10 +233,10 @@ const EXTENDED_ADDITIONS = `
### display_diagram Details ### display_diagram Details
**VALIDATION RULES** (XML will be rejected if violated): **VALIDATION RULES** (XML will be rejected if violated):
1. Generate ONLY mxCell elements - wrapper tags and root cells are added automatically 1. Generate ONLY mxStyle definitions and mxCell elements - wrapper tags and root cells are added automatically
2. All mxCell elements must be siblings - never nested inside other mxCell elements 2. All mxCell elements must be siblings - never nested inside other mxCell elements
3. Every mxCell needs a unique id attribute (start from "2") 3. Every mxCell needs a unique id attribute (start from "2")
4. Every mxCell needs a valid parent attribute (use "1" for top-level, or container-id for grouped) 4. parent defaults to "1"; write it only for a shape inside a container (the container's id)
5. Edge source/target attributes must reference existing cell IDs 5. Edge source/target attributes must reference existing cell IDs
6. Escape special characters in values: &lt; for <, &gt; for >, &amp; for &, &quot; for " 6. Escape special characters in values: &lt; for <, &gt; for >, &amp; for &, &quot; for "
@@ -257,7 +255,7 @@ ${SWIMLANE_EXAMPLE}
3. Complete the remaining mxCell elements 3. Complete the remaining mxCell elements
4. If still truncated, call append_diagram again with the next fragment 4. If still truncated, call append_diagram again with the next fragment
**Example:** If previous output ended with \`<mxCell id="x" style="rounded=1\`, continue with \`;" vertex="1">...\` and complete the remaining elements. **Example:** If previous output ended with \`<mxCell id="x" style="rounded=1\`, continue with \`;" x="40" y="40" w="120" h="60"/>\` and complete the remaining elements.
### edit_diagram Details ### edit_diagram Details
@@ -283,12 +281,12 @@ edit_diagram uses ID-based operations to modify cells directly by their id attri
Change label: Change label:
\`\`\`json \`\`\`json
{"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]} {"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" x=\\"100\\" y=\\"100\\" w=\\"120\\" h=\\"60\\"/>"}]}
\`\`\` \`\`\`
Add new shape: Add new shape:
\`\`\`json \`\`\`json
{"operations": [{"operation": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;fillColor=#dae8fc;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"400\\" y=\\"200\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]} {"operations": [{"operation": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;fillColor=#dae8fc;\\" x=\\"400\\" y=\\"200\\" w=\\"120\\" h=\\"60\\"/>"}]}
\`\`\` \`\`\`
Delete container (children & edges auto-deleted): Delete container (children & edges auto-deleted):
@@ -312,7 +310,7 @@ ${TWO_EDGES_EXAMPLE}
### Edge with single waypoint (simple detour): ### Edge with single waypoint (simple detour):
\`\`\`xml \`\`\`xml
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=1;entryX=0.5;entryY=0;endArrow=classic;" edge="1" parent="1" source="a" target="b"> <mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="a" target="b">
<mxGeometry relative="1" as="geometry"> <mxGeometry relative="1" as="geometry">
<Array as="points"> <Array as="points">
<mxPoint x="300" y="150"/> <mxPoint x="300" y="150"/>
+13 -3
View File
@@ -2,6 +2,8 @@ import { type ClassValue, clsx } from "clsx"
import * as pako from "pako" import * as pako from "pako"
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge"
import { hasCells } from "@/packages/mcp-server/src/pages.ts" import { hasCells } from "@/packages/mcp-server/src/pages.ts"
import { readStyleClasses } from "@/packages/mcp-server/src/style-classes.ts"
import { repairQuoteBeforeSlash } from "@/packages/mcp-server/src/xml-validation.ts"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
@@ -41,7 +43,12 @@ export function isRealDiagram(xml: string | undefined | null): boolean {
* @returns true if XML appears complete, false if truncated or empty * @returns true if XML appears complete, false if truncated or empty
*/ */
export function isMxCellXmlComplete(xml: string | undefined | null): boolean { export function isMxCellXmlComplete(xml: string | undefined | null): boolean {
const trimmed = xml?.trim() || "" // Named style definitions before the cells are not cells: output cut off
// right after them is incomplete. A compact cell whose last quote is
// missing is complete; prepareNewDiagram repairs it.
const trimmed = repairQuoteBeforeSlash(
readStyleClasses(xml || "").xml,
).trim()
if (!trimmed) return false if (!trimmed) return false
// Find position of last complete mxCell ending (either /> or </mxCell>) // Find position of last complete mxCell ending (either /> or </mxCell>)
@@ -89,7 +96,9 @@ export function extractCompleteMxCells(xml: string | undefined | null): string {
// Match self-closing <mxCell ... /> or <mxCell ...>...</mxCell>, in document order. // Match self-closing <mxCell ... /> or <mxCell ...>...</mxCell>, in document order.
// The lazy [^>]*? tries "/>" first, so a self-closing cell never swallows // The lazy [^>]*? tries "/>" first, so a self-closing cell never swallows
// the following cells up to the next </mxCell>. // the following cells up to the next </mxCell>.
const cellPattern = /<mxCell\b[^>]*?(?:\/>|>[\s\S]*?<\/mxCell>)/g // Quoted values may hold ">", so the tag ends at the first ">" outside them
const cellPattern =
/<mxCell\b(?:[^<>"']|"[^"]*"|'[^']*')*?(?:\/>|>[\s\S]*?<\/mxCell>)/g
return (xml.match(cellPattern) || []).join("\n") return (xml.match(cellPattern) || []).join("\n")
} }
@@ -152,7 +161,8 @@ export function formatXML(xml: string, indent: string = " "): string {
export function convertToLegalXml(xmlString: string): string { export function convertToLegalXml(xmlString: string): string {
// This regex will match either self-closing <mxCell .../> or a block element // This regex will match either self-closing <mxCell .../> or a block element
// <mxCell ...> ... </mxCell>. Unfinished ones are left out because they don't match. // <mxCell ...> ... </mxCell>. Unfinished ones are left out because they don't match.
const regex = /<mxCell\b[^>]*(?:\/>|>([\s\S]*?)<\/mxCell>)/g const regex =
/<mxCell\b(?:[^<>"']|"[^"]*"|'[^']*')*?(?:\/>|>([\s\S]*?)<\/mxCell>)/g
let match: RegExpExecArray | null let match: RegExpExecArray | null
let result = "<root>\n" let result = "<root>\n"
+196
View File
@@ -0,0 +1,196 @@
/**
* Compact cells: the short way the model writes shapes and edges.
*
* A shape is one self-closing mxCell with its position and size as x, y, w
* and h attributes; an edge is one with source and target. The fixed parts
* of draw.io's XML are left out and filled in here:
*
* <mxCell id="2" value="Start" style="step;" x="40" y="40" w="120" h="60"/>
* <mxCell id="5" style="down;" source="2" target="3"/>
*
* becomes
*
* <mxCell id="2" value="Start" style="step;" vertex="1" parent="1">
* <mxGeometry x="40" y="40" width="120" height="60" as="geometry"/>
* </mxCell>
* <mxCell id="5" style="down;" edge="1" parent="1" source="2" target="3">
* <mxGeometry relative="1" as="geometry"/>
* </mxCell>
*
* The long form stays accepted, and anything beyond the plain case (edge
* waypoints, a label on an edge, a parent other than the layer) is written
* the long way, so a cell may mix both: compact attributes with an explicit
* parent, for example. A cell is an edge when it says so or connects a
* source or target, a shape when it says so, has a size or has a geometry.
*
* foldCells is the reverse, for the diagram shown to the model: it writes
* the plain shapes and edges compactly and leaves everything else as it is,
* so the model reads the notation it is asked to write. Folding then
* expanding gives the same cells back.
*/
import { readAttributes, type TagAttribute } from "./xml-attributes.ts"
// A whole mxCell: self-closing, or with its children (cells never nest)
const CELL_BLOCK =
/<mxCell\b((?:[^<>"']|"[^"]*"|'[^']*')*?)\s*(?:\/>|>([\s\S]*?)<\/mxCell>)/g
// The cell's own geometry, as opposed to one inside its custom data
const OWN_GEOMETRY = /<mxGeometry\b(?:[^<>"']|"[^"]*"|'[^']*')*?\bas="geometry"/
// The four compact attributes and the names draw.io uses for them
const SIZE_ATTRS = new Map([
["x", "x"],
["y", "y"],
["w", "width"],
["h", "height"],
["width", "width"],
["height", "height"],
])
const isSize = (name: string) => SIZE_ATTRS.has(name)
// Attributes a cell is written with, in draw.io's usual order
const FIRST = ["id", "value", "style"]
const LAST = ["vertex", "edge", "parent", "source", "target"]
// A value read from a single-quoted attribute may hold a double quote
const attr = (name: string, value: string) =>
` ${name}="${value.replace(/"/g, "&quot;")}"`
const attributeText = (attrs: TagAttribute[]) =>
attrs.map((a) => attr(a.name, a.value)).join("")
/** The id of the page's first layer (a cell whose parent is "0"), or "1" */
export function defaultLayerOf(xml: string): string {
for (const [, attrText] of xml.matchAll(CELL_BLOCK)) {
const attrs = new Map(
readAttributes(attrText).map((a) => [a.name, a.value]),
)
if (attrs.get("parent") === "0" && attrs.get("id")) {
return attrs.get("id") ?? "1"
}
}
return "1"
}
/**
* Turn compact shapes and edges into standard draw.io cells. A cell with no
* parent goes on `layer`, the page's first layer ("1" on a new page).
*/
export function expandCompactCells(xml: string, layer = "1"): string {
return xml.replace(CELL_BLOCK, (block, attrText: string, body?: string) => {
const attrs = readAttributes(attrText)
const byName = new Map(attrs.map((a) => [a.name, a.value]))
const size = attrs.filter((a) => isSize(a.name))
const inner = body ?? ""
const hasGeometry = OWN_GEOMETRY.test(inner)
const isEdge =
byName.get("edge") === "1" ||
(byName.get("vertex") !== "1" &&
(byName.has("source") || byName.has("target")))
const isVertex =
!isEdge &&
(byName.get("vertex") === "1" || size.length > 0 || hasGeometry)
// Root cells and anything else the model wrote in full are left alone
if (!isEdge && !isVertex) return block
const complete =
size.length === 0 &&
byName.has(isEdge ? "edge" : "vertex") &&
byName.has("parent") &&
hasGeometry
if (complete) return block
// The compact attributes become the geometry; when the cell has its
// own geometry already they stay as written, in case they mean
// something else to whoever wrote them
const consumed = !hasGeometry && isVertex
const rest = attrs.filter(
(a) =>
!FIRST.includes(a.name) &&
!LAST.includes(a.name) &&
!(consumed && isSize(a.name)) &&
!(isEdge && isSize(a.name)),
)
// Rebuilt in the usual order: id, value, style, the rest, then the
// flags and the connections
const ordered: [string, string][] = [
...FIRST.flatMap((n): [string, string][] =>
byName.has(n) ? [[n, byName.get(n) ?? ""]] : [],
),
...rest.map((a): [string, string] => [a.name, a.value]),
[isEdge ? "edge" : "vertex", "1"],
["parent", byName.get("parent") ?? layer],
...["source", "target"].flatMap((n): [string, string][] =>
byName.has(n) ? [[n, byName.get(n) ?? ""]] : [],
),
]
const written = ordered.map(([n, v]) => attr(n, v)).join("")
let geometry = ""
if (!hasGeometry) {
if (isEdge) {
geometry = '<mxGeometry relative="1" as="geometry"/>'
} else {
const get = (short: string, long: string, fallback: string) =>
byName.get(short) ?? byName.get(long) ?? fallback
geometry = `<mxGeometry${attr("x", get("x", "x", "0"))}${attr("y", get("y", "y", "0"))}${attr("width", get("w", "width", "120"))}${attr("height", get("h", "height", "60"))} as="geometry"/>`
}
}
return `<mxCell${written}>${geometry}${inner}</mxCell>`
})
}
/** Attributes of a single self-closing mxGeometry, or null for anything else */
function plainGeometry(body: string): Map<string, string> | null {
const trimmed = body.trim()
const m = trimmed.match(
/^<mxGeometry\b((?:[^<>"']|"[^"]*"|'[^']*')*?)\s*\/>$/,
)
if (!m) return null
const attrs = new Map(readAttributes(m[1]).map((a) => [a.name, a.value]))
// Anything odd, such as a wrong "as", stays as written so folding and
// expanding give the same cells back
return attrs.get("as") === "geometry" ? attrs : null
}
/**
* Write plain shapes and edges compactly, for the diagram shown to the
* model. A shape is plain when its only child is an mxGeometry with x, y,
* width and height; an edge when its only child is the relative geometry.
* Both must name their parent, since expanding fills in the layer for a
* missing one. Everything else, including wrapped cells' extra data and
* edge labels, is left as written.
*/
export function foldCells(xml: string, layer = "1"): string {
return xml.replace(CELL_BLOCK, (block, attrText: string, body?: string) => {
if (body === undefined) return block
const attrs = readAttributes(attrText)
const byName = new Map(attrs.map((a) => [a.name, a.value]))
if (attrs.some((a) => isSize(a.name))) return block
if (!byName.has("parent")) return block
const geometry = plainGeometry(body)
if (!geometry) return block
const geometryKeys = [...geometry.keys()]
.filter((k) => k !== "as")
.sort()
const kept = attrs.filter(
(a) =>
a.name !== "vertex" &&
a.name !== "edge" &&
!(a.name === "parent" && a.value === layer),
)
if (byName.get("vertex") === "1" && byName.get("edge") !== "1") {
if (geometryKeys.join() !== "height,width,x,y") return block
const size = `${attr("x", geometry.get("x") ?? "")}${attr("y", geometry.get("y") ?? "")}${attr("w", geometry.get("width") ?? "")}${attr("h", geometry.get("height") ?? "")}`
return `<mxCell${attributeText(kept)}${size}/>`
}
if (byName.get("edge") === "1" && byName.get("vertex") !== "1") {
if (
geometryKeys.join() !== "relative" ||
geometry.get("relative") !== "1"
) {
return block
}
if (!byName.has("source") || !byName.has("target")) return block
return `<mxCell${attributeText(kept)}/>`
}
return block
})
}
+20 -17
View File
@@ -9,6 +9,7 @@
import { import {
indent, indent,
STYLE_CLASS_EXAMPLE,
SWIMLANE_EXAMPLE, SWIMLANE_EXAMPLE,
TWO_EDGES_EXAMPLE, TWO_EDGES_EXAMPLE,
WAYPOINT_EXAMPLE, WAYPOINT_EXAMPLE,
@@ -26,21 +27,17 @@ export const DRAWING_GUIDE = `# Draw.io drawing guide
- The preview page has History (it saves a snapshot before every AI change and can restore any of the last 20 versions) and Download. You can make changes freely; nothing is lost. - The preview page has History (it saves a snapshot before every AI change and can restore any of the last 20 versions) and Download. You can make changes freely; nothing is lost.
## The XML you send ## The XML you send
Single page (create_new_diagram, add_page): send ONLY the mxCell elements. The server adds <mxfile>, <mxGraphModel>, <root> and the root cells id="0" and id="1". Single page (create_new_diagram, add_page): send ONLY the named styles and the mxCell elements. The server adds <mxfile>, <mxGraphModel>, <root> and the root cells id="0" and id="1", expands named styles (see Styles), adds html=1 to every cell and whiteSpace=wrap to shapes, and fills in vertex, edge, parent="1" and the mxGeometry element. A shape is one self-closing mxCell with x, y, w and h; an edge is one with source and target. A cell with source or target is always an edge. Write parent only for a shape inside a container, and an mxGeometry element only for edge waypoints or for a separate label cell placed on an edge: <mxCell id="9" value="yes" style="edgeLabel;" parent="<edge id>" connectable="0"><mxGeometry x="-0.5" relative="1" as="geometry"/></mxCell>. An edge's own text simply goes in its value.
<mxCell id="2" value="Label" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1"> <mxCell id="2" value="Label" style="rounded=1;" x="100" y="100" w="120" h="60"/>
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/> <mxCell id="3" style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.5;entryX=0;entryY=0.5;" source="2" target="4"/>
</mxCell>
<mxCell id="3" style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.5;entryX=0;entryY=0.5;endArrow=classic;html=1;" edge="1" parent="1" source="2" target="4">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
Several pages at once (create_new_diagram only): send a full <mxfile> with one <diagram id="..." name="..."> per page. Every page's <root> must start with <mxCell id="0"/><mxCell id="1" parent="0"/>. Several pages at once (create_new_diagram only): send a full <mxfile> with one <diagram id="..." name="..."> per page. Every page's <root> must start with <mxCell id="0"/><mxCell id="1" parent="0"/>.
Rules (XML that breaks them is rejected): Rules (XML that breaks them is rejected):
1. All mxCell elements are siblings. NEVER nest an mxCell inside another mxCell. 1. All mxCell elements are siblings. NEVER nest an mxCell inside another mxCell.
2. Ids are unique within a page and start from "2" ("0" and "1" are the root cells). 2. Ids are unique within a page and start from "2" ("0" and "1" are the root cells).
3. parent="1" for top-level shapes, parent="<container id>" for shapes inside a container. 3. parent="<container id>" for shapes inside a container; top-level cells need no parent.
4. Edge source and target must reference existing cell ids. 4. Edge source and target must reference existing cell ids.
5. Escape special characters in attribute values: &lt; for <, &gt; for >, &amp; for &, &quot; for ". 5. Escape special characters in attribute values: &lt; for <, &gt; for >, &amp; for &, &quot; for ".
6. NEVER include XML comments (<!-- -->). draw.io strips them. 6. NEVER include XML comments (<!-- -->). draw.io strips them.
@@ -59,7 +56,7 @@ ${indent(SWIMLANE_EXAMPLE)}
## Edge routing rules ## Edge routing rules
Rule 1: Never let two edges share a path. Two edges between the same nodes exit and enter at different points (exitY=0.3 for the first, exitY=0.7 for the second, not both 0.5). Rule 1: Never let two edges share a path. Two edges between the same nodes exit and enter at different points (exitY=0.3 for the first, exitY=0.7 for the second, not both 0.5).
Rule 2: For bidirectional connections (A to B and B to A), use opposite sides: A exits right (exitX=1) into the left of B (entryX=0); B exits left (exitX=0) into the right of A (entryX=1). Rule 2: For bidirectional connections (A to B and B to A), use opposite sides: A exits right (exitX=1) into the left of B (entryX=0); B exits left (exitX=0) into the right of A (entryX=1).
Rule 3: Always set exitX, exitY, entryX and entryY in the edge style, e.g. style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;". Rule 3: Always set exitX, exitY, entryX and entryY in the edge style, e.g. style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;".
Rule 4: Route edges AROUND shapes in the way. Before drawing an edge, find every shape between source and target; if one is in the path, add waypoints. Route diagonal connections along the outside of the diagram, not through the middle. Keep 20-30px clearance from shapes. An edge must never cross another shape's box. Rule 4: Route edges AROUND shapes in the way. Before drawing an edge, find every shape between source and target; if one is in the path, add waypoints. Route diagonal connections along the outside of the diagram, not through the middle. Keep 20-30px clearance from shapes. An edge must never cross another shape's box.
Rule 5: Plan the layout first. Organize shapes into rows or columns following the flow, space them 150-200px apart so edges have room, and prefer one flow direction (left to right or top to bottom). Rule 5: Plan the layout first. Organize shapes into rows or columns following the flow, space them 150-200px apart so edges have room, and prefer one flow direction (left to right or top to bottom).
Rule 6: Use 2-3 waypoints for L-shaped or U-shaped paths. Each change of direction needs a waypoint, and segments should be horizontal or vertical. Rule 6: Use 2-3 waypoints for L-shaped or U-shaped paths. Each change of direction needs a waypoint, and segments should be horizontal or vertical.
@@ -80,27 +77,33 @@ Waypoints go inside <Array as="points"> in the edge geometry. Example: Hotfix (r
${indent(WAYPOINT_EXAMPLE)} ${indent(WAYPOINT_EXAMPLE)}
## Styles ## Styles
- Shapes: rounded=1, fillColor=#hex, strokeColor=#hex, whiteSpace=wrap;html=1; Define each style used by several cells ONCE, as a named style before the cells, and use the name in the cells like a CSS class. A cell's style can combine a shape token, a name and overrides; later pairs win. Name only styles that two or more cells share; a style used by one cell stays inline. Names must not be draw.io's own style names: shapes such as text, ellipse, rhombus, swimlane, label, image, and colors such as blue, green, red, gray, yellow, orange, purple, pink. A definition applies to the call it is in: each create_new_diagram or add_page call defines the names it uses. The server expands the names, so the saved file is standard draw.io XML.
- Edges: endArrow=classic, block, open or none; startArrow=none or classic; curved=1; edgeStyle=orthogonalEdgeStyle
- Text: fontSize=14, fontStyle=1 (bold), align=center, left or right ${indent(STYLE_CLASS_EXAMPLE)}
- NEVER write html=1 or whiteSpace=wrap: the server adds html=1 to every cell and whiteSpace=wrap to shapes. Labels are HTML: use &lt;br&gt; for a line break and &lt;b&gt; for bold, never \\n; a literal < or > in a label is written &amp;lt; or &amp;gt;.
- Do NOT repeat what draw.io already uses. For a plain shape: rounded=0, align=center, verticalAlign=middle, fontSize=12, strokeWidth=1, fillColor=#ffffff, strokeColor=#000000, fontColor=#000000. For an edge: endArrow=classic, strokeColor=#000000. Writing one of them is right only when it overrides what a name or the shape sets: an edge is rounded by default, so rounded=0 on an edge is a real setting, and a text cell is left/top aligned by default, so there align=center or verticalAlign=middle are real settings.
- Shapes: rounded=1, fillColor=#hex, strokeColor=#hex
- Edges: endArrow=block, open or none; startArrow=classic; curved=1; dashed=1; edgeStyle=orthogonalEdgeStyle
- Text: fontSize=14, fontStyle=1 (bold), align=center or right
- Animated connectors: add flowAnimation=1 to the edge style. - Animated connectors: add flowAnimation=1 to the edge style.
## Minimal style ## Minimal style
When the user asks for a minimal, plain, black-and-white or unstyled diagram, use these rules instead of the styles above: When the user asks for a minimal, plain, black-and-white or unstyled diagram, use these rules instead of the styles above:
- No fillColor, strokeColor, rounded, fontSize, fontStyle or hex colors. - No fillColor, strokeColor, rounded, fontSize, fontStyle or hex colors.
- Shapes use style "whiteSpace=wrap;html=1;", edges use "html=1;endArrow=classic;". - Shapes have no style, or only the shape (ellipse, rhombus); edges have edgeStyle=orthogonalEdgeStyle plus the exit and entry points from the routing rules, nothing else.
- Containers that hold other shapes use "whiteSpace=wrap;html=1;fillColor=none;" so they do not cover their children. - Containers that hold other shapes use "fillColor=none;" so they do not cover their children.
- Keep at least 50px between elements, and follow all edge routing rules strictly. - Keep at least 50px between elements, and follow all edge routing rules strictly.
## Editing with edit_diagram ## Editing with edit_diagram
- update replaces a cell: send the complete mxCell including mxGeometry, with the same id as cell_id. - update replaces a cell: send the complete mxCell in the same compact form (x, y, w, h for a shape), with the same id as cell_id. Named styles are not available here: write each cell's complete style.
- add inserts a new cell with a new id. One cell per operation. - add inserts a new cell with a new id. One cell per operation.
- delete removes a cell. Its children and every edge connected to it are deleted too, so give only the container's id. - delete removes a cell. Its children and every edge connected to it are deleted too, so give only the container's id.
- All-or-nothing: if any operation fails, nothing is applied. A rejected call includes the current XML of the page; rebuild your operations on it and retry. - All-or-nothing: if any operation fails, nothing is applied. A rejected call includes the current XML of the page; rebuild your operations on it and retry.
- If the diagram is large, change it with edit_diagram instead of redrawing it. - If the diagram is large, change it with edit_diagram instead of redrawing it.
{"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]} {"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" x=\\"100\\" y=\\"100\\" w=\\"120\\" h=\\"60\\"/>"}]}
{"page_name": "CNN", "operations": [{"operation": "add", "cell_id": "conv-1", "new_xml": "<mxCell id=\\"conv-1\\" value=\\"Conv\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"40\\" y=\\"40\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]} {"page_name": "CNN", "operations": [{"operation": "add", "cell_id": "conv-1", "new_xml": "<mxCell id=\\"conv-1\\" value=\\"Conv\\" x=\\"40\\" y=\\"40\\" w=\\"120\\" h=\\"60\\"/>"}]}
{"page_index": 1, "operations": [{"operation": "delete", "cell_id": "5"}]} {"page_index": 1, "operations": [{"operation": "delete", "cell_id": "5"}]}
Pages: list_pages shows every page's id, name and index. edit_diagram, get_diagram and export_diagram take an optional page_id, page_name or page_index; without one they use the first page. Pages: list_pages shows every page's id, name and index. edit_diagram, get_diagram and export_diagram take an optional page_id, page_name or page_index; without one they use the first page.
+22 -1
View File
@@ -7,11 +7,13 @@
* so the model never builds on a half-applied edit. * so the model never builds on a half-applied edit.
*/ */
import { defaultLayerOf, expandCompactCells } from "./compact-cells.ts"
import { import {
applyDiagramOperations, applyDiagramOperations,
type DiagramOperation, type DiagramOperation,
} from "./diagram-operations.ts" } from "./diagram-operations.ts"
import { type PageSelector, projectPage } from "./pages.ts" import { type PageSelector, projectPage } from "./pages.ts"
import { addDefaultStyles, edgeIdsOf } from "./style-classes.ts"
import { validateAndFixXml, validateMxCellStructure } from "./xml-validation.ts" import { validateAndFixXml, validateMxCellStructure } from "./xml-validation.ts"
export type EditOutcome = export type EditOutcome =
@@ -48,11 +50,22 @@ export function editDiagram(
const fixes: string[] = [] const fixes: string[] = []
const prepared: DiagramOperation[] = [] const prepared: DiagramOperation[] = []
// Edges already on the page, so a label added to one is not wrapped, and
// the layer a compact cell without a parent goes on
const page = targetPageXml(xml, selector)
const edges = edgeIdsOf(xml)
const layer = defaultLayerOf(page)
for (const op of operations) { for (const op of operations) {
if (op.operation === "delete" || !op.new_xml) { if (op.operation === "delete" || !op.new_xml) {
prepared.push(op) prepared.push(op)
continue continue
} }
if (/<mxStyle\b/i.test(op.new_xml)) {
errors.push(
`${op.operation} ${op.cell_id}: named styles (mxStyle) are not available in edit_diagram; write the cell's complete style`,
)
continue
}
// Checked before validation: several cells fail the strict parser // Checked before validation: several cells fail the strict parser
// with a misleading "only one root" syntax error. // with a misleading "only one root" syntax error.
if (countTopLevelElements(op.new_xml) > 1) { if (countTopLevelElements(op.new_xml) > 1) {
@@ -71,7 +84,15 @@ export function editDiagram(
if (check.fixed) { if (check.fixed) {
fixes.push(`${op.cell_id}: ${check.fixes.join(", ")}`) fixes.push(`${op.cell_id}: ${check.fixes.join(", ")}`)
} }
prepared.push({ ...op, new_xml: check.fixed ?? op.new_xml }) // The defaults the model is told not to write (style-classes.ts),
// added after the fixes so a repaired cell gets them too
prepared.push({
...op,
new_xml: addDefaultStyles(
expandCompactCells(check.fixed ?? op.new_xml, layer),
edges,
),
})
} }
if (errors.length > 0) return { ok: false, errors, pageError: false } if (errors.length > 0) return { ok: false, errors, pageError: false }
+34 -11
View File
@@ -23,6 +23,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import open from "open" import open from "open"
import { z } from "zod" import { z } from "zod"
import { expandCompactCells, foldCells } from "./compact-cells.ts"
import type { DiagramOperation } from "./diagram-operations.ts" import type { DiagramOperation } from "./diagram-operations.ts"
import { installDomPolyfill } from "./dom.ts" import { installDomPolyfill } from "./dom.ts"
import { DRAWING_GUIDE } from "./drawing-guide.ts" import { DRAWING_GUIDE } from "./drawing-guide.ts"
@@ -48,7 +49,11 @@ import {
} from "./http-server.ts" } from "./http-server.ts"
import { parseDrawioFileContent } from "./load-diagram.ts" import { parseDrawioFileContent } from "./load-diagram.ts"
import { log } from "./logger.ts" import { log } from "./logger.ts"
import { prepareNewDiagram, reservedIdError } from "./new-diagram.ts" import {
prepareNewDiagram,
reservedIdError,
takeStyleDefinitions,
} from "./new-diagram.ts"
import { import {
addPageToDoc, addPageToDoc,
deletePageFromDoc, deletePageFromDoc,
@@ -66,6 +71,7 @@ import {
} from "./pages.ts" } from "./pages.ts"
import { Autosaver, defaultDataDir, expandHome } from "./persistence.ts" import { Autosaver, defaultDataDir, expandHome } from "./persistence.ts"
import { getShapeLibrary, SHAPE_LIBRARY_LIST } from "./shape-library.ts" import { getShapeLibrary, SHAPE_LIBRARY_LIST } from "./shape-library.ts"
import { addDefaultStyles, applyStyleClasses } from "./style-classes.ts"
import { validateAndFixXml } from "./xml-validation.ts" import { validateAndFixXml } from "./xml-validation.ts"
// DOMParser/XMLSerializer globals for the XML helpers (Node has neither) // DOMParser/XMLSerializer globals for the XML helpers (Node has neither)
@@ -336,11 +342,11 @@ Before using icon shapes (AWS, Azure, GCP, Kubernetes, Cisco...), call get_shape
Accepted xml: Accepted xml:
1) Only the mxCell elements of one page (recommended). The server adds <mxfile>, <mxGraphModel>, <root> and the root cells "0" and "1": 1) Only the mxCell elements of one page (recommended). The server adds <mxfile>, <mxGraphModel>, <root> and the root cells "0" and "1":
<mxCell id="2" value="Shape" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell> <mxCell id="2" value="Shape" style="rounded=1;" x="40" y="40" w="120" h="60"/>
2) A bare <mxGraphModel> with <root> (one page). 2) A bare <mxGraphModel> with <root> (one page).
3) A full <mxfile> with one or more <diagram> pages. Every page's <root> must start with <mxCell id="0"/><mxCell id="1" parent="0"/>. 3) A full <mxfile> with one or more <diagram> pages. Every page's <root> must start with <mxCell id="0"/><mxCell id="1" parent="0"/>.
Rules: cells are siblings (never nested), ids are unique per page and start from "2", parent="1" for top-level shapes, no XML comments, and shapes stay within x 0 to 800 and y 0 to 600.`, Rules: cells are siblings (never nested), ids are unique per page and start from "2", parent only for shapes inside a container, no XML comments, and shapes stay within x 0 to 800 and y 0 to 600. A style used by several cells is defined once with <mxStyle name="..." value="..."/> before the cells and used by name (see the drawing guide); html=1 and whiteSpace=wrap are added automatically.`,
inputSchema: { inputSchema: {
xml: z xml: z
.string() .string()
@@ -589,10 +595,10 @@ registerWriteTool(
"- add: Add a new cell. Provide cell_id (new unique id within the page) and new_xml. One cell per operation.\n" + "- add: Add a new cell. Provide cell_id (new unique id within the page) and new_xml. One cell per operation.\n" +
"- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.\n" + "- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.\n" +
"- delete: Remove a cell by its id. Only cell_id is needed. Its children and connected edges are deleted too, so give only a container's id.\n\n" + "- delete: Remove a cell by its id. Only cell_id is needed. Its children and connected edges are deleted too, so give only a container's id.\n\n" +
"For add/update, new_xml must be a complete mxCell element including mxGeometry. No XML comments. " + "For add/update, new_xml is the complete mxCell in the compact form (a shape with x, y, w, h; an edge with source and target). No XML comments. " +
'Every " inside new_xml must be escaped as \\" in the JSON.\n\n' + 'Every " inside new_xml must be escaped as \\" in the JSON.\n\n' +
"Example - Add a rectangle on the default (first) page:\n" + "Example - Add a rectangle on the default (first) page:\n" +
'{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": "<mxCell id=\\"rect-1\\" value=\\"Hello\\" style=\\"rounded=0;\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]}\n\n' + '{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": "<mxCell id=\\"rect-1\\" value=\\"Hello\\" style=\\"rounded=1;\\" x=\\"100\\" y=\\"100\\" w=\\"120\\" h=\\"60\\"/>"}]}\n\n' +
"Example - Delete a cell on the default page:\n" + "Example - Delete a cell on the default page:\n" +
'{"operations": [{"operation": "delete", "cell_id": "rect-1"}]}', '{"operations": [{"operation": "delete", "cell_id": "rect-1"}]}',
inputSchema: { inputSchema: {
@@ -700,7 +706,7 @@ registerWriteTool(
content: [ content: [
{ {
type: "text", type: "text",
text: `Error: ${reason}\n\nCurrent XML of ${describeSelector(pageSelector)}:\n\n${targetPageXml(currentSession.xml, pageSelector)}\n\n${next}`, text: `Error: ${reason}\n\nCurrent XML of ${describeSelector(pageSelector)}:\n\n${foldCells(targetPageXml(currentSession.xml, pageSelector))}\n\n${next}`,
}, },
], ],
isError: true, isError: true,
@@ -720,7 +726,7 @@ registerWriteTool(
log.warn(`Edit rejected: ${outcome.errors.join("; ")}`) log.warn(`Edit rejected: ${outcome.errors.join("; ")}`)
const text = outcome.pageError const text = outcome.pageError
? `Error: ${outcome.errors[0]}` ? `Error: ${outcome.errors[0]}`
: `Error: No changes were made because ${outcome.errors.length} operation(s) failed:\n${outcome.errors.map((e) => `- ${e}`).join("\n")}\n\nCurrent XML of ${describeSelector(pageSelector)}:\n\n${targetPageXml(currentSession.xml, pageSelector)}\n\nFix the operations against this XML and retry.` : `Error: No changes were made because ${outcome.errors.length} operation(s) failed:\n${outcome.errors.map((e) => `- ${e}`).join("\n")}\n\nCurrent XML of ${describeSelector(pageSelector)}:\n\n${foldCells(targetPageXml(currentSession.xml, pageSelector))}\n\nFix the operations against this XML and retry.`
return { return {
content: [{ type: "text", text }], content: [{ type: "text", text }],
isError: true, isError: true,
@@ -869,7 +875,7 @@ server.registerTool(
content: [ content: [
{ {
type: "text", type: "text",
text: `Current diagram XML:\n\n${session.xml}\n\n${pageList}${staleNote}`, text: `Current diagram XML:\n\n${foldCells(session.xml)}\n\n${pageList}${staleNote}`,
}, },
], ],
} }
@@ -906,7 +912,7 @@ server.registerTool(
content: [ content: [
{ {
type: "text", type: "text",
text: `Page ${projection.index} ("${projection.name}"):\n\n${projection.xml}\n\n${pageList}${staleNote}${otherPagesNote}`, text: `Page ${projection.index} ("${projection.name}"):\n\n${foldCells(projection.xml)}\n\n${pageList}${staleNote}${otherPagesNote}`,
}, },
], ],
} }
@@ -1587,14 +1593,28 @@ registerWriteTool(
// If caller provided XML, validate it before splicing it in so we // If caller provided XML, validate it before splicing it in so we
// never get a half-broken mxfile written to the session. // never get a half-broken mxfile written to the session.
const reserved = xml && reservedIdError(xml) // Named style definitions come out first and are expanded on the
// validated XML, like prepareNewDiagram
const {
classes,
xml: startXml,
error: styleError,
} = takeStyleDefinitions(xml ?? "")
if (styleError) {
return {
content: [{ type: "text", text: `Error: ${styleError}` }],
isError: true,
}
}
const reserved = startXml && reservedIdError(startXml)
if (reserved) { if (reserved) {
return { return {
content: [{ type: "text", text: `Error: ${reserved}` }], content: [{ type: "text", text: `Error: ${reserved}` }],
isError: true, isError: true,
} }
} }
let cleanXml: string | undefined = xml && wrapCellsInModel(xml) let cleanXml: string | undefined =
startXml && wrapCellsInModel(startXml)
if (cleanXml) { if (cleanXml) {
const { valid, error, fixed, fixes } = const { valid, error, fixed, fixes } =
validateAndFixXml(cleanXml) validateAndFixXml(cleanXml)
@@ -1615,6 +1635,9 @@ registerWriteTool(
isError: true, isError: true,
} }
} }
cleanXml = addDefaultStyles(
applyStyleClasses(expandCompactCells(cleanXml), classes),
)
} }
let info let info
+62 -9
View File
@@ -2,8 +2,16 @@
* A whole new diagram written by the model, for the create_new_diagram tool * A whole new diagram written by the model, for the create_new_diagram tool
* and the web app's display_diagram tool. * and the web app's display_diagram tool.
*/ */
import { normalizeToMxfile, wrapCellsInModel } from "./pages.ts" import { expandCompactCells } from "./compact-cells.ts"
import { hasCells, normalizeToMxfile, wrapCellsInModel } from "./pages.ts"
import {
addDefaultStyles,
applyStyleClasses,
readStyleClasses,
type StyleClasses,
} from "./style-classes.ts"
import { readAttributes } from "./xml-attributes.ts" import { readAttributes } from "./xml-attributes.ts"
import { getXmlSyntaxError } from "./xml-syntax.ts"
import { validateAndFixXml } from "./xml-validation.ts" import { validateAndFixXml } from "./xml-validation.ts"
export type NewDiagram = export type NewDiagram =
@@ -29,11 +37,15 @@ export function reservedIdError(input: string): string | null {
) )
const id = attrs.get("id") const id = attrs.get("id")
if (id !== "0" && id !== "1") continue if (id !== "0" && id !== "1") continue
// A wrapper's id is its cell's; an mxCell counts as a shape or edge // A wrapper's id is its cell's; an mxCell counts as a shape or edge,
// also when written compactly (a size, or a connection)
if ( if (
tag !== "mxCell" || tag !== "mxCell" ||
attrs.get("vertex") === "1" || attrs.get("vertex") === "1" ||
attrs.get("edge") === "1" attrs.get("edge") === "1" ||
["x", "y", "w", "h", "width", "height", "source", "target"].some(
(a) => attrs.has(a),
)
) { ) {
return 'Cell ids "0" and "1" are the root cells, which are added automatically. Give shapes and edges ids starting at "2".' return 'Cell ids "0" and "1" are the root cells, which are added automatically. Give shapes and edges ids starting at "2".'
} }
@@ -42,23 +54,64 @@ export function reservedIdError(input: string): string | null {
} }
/** /**
* Bare cells get the wrapper and root cells first, since the strict parser * The named style definitions are taken out first (style-classes.ts). Bare
* rejects several top-level elements. Then the XML is validated and * cells then get the wrapper and root cells, since the strict parser rejects
* auto-fixed while it is still a bare model, where duplicate ids are * several top-level elements. Then the XML is validated and auto-fixed while
* renamed, and finally turned into an <mxfile>. * it is still a bare model, where duplicate ids are renamed. Compact cells
* (compact-cells.ts) are expanded, the names expanded and the default styles
* added on the fixed XML, so repaired cells get them too, and finally it is
* turned into an <mxfile>.
*/ */
/**
* Take the named style definitions out of the model's XML (style-classes.ts).
* Returns the error for the model when a definition never closed or when
* nothing but definitions was sent.
*/
export function takeStyleDefinitions(input: string): {
classes: StyleClasses
xml: string
error: string | null
} {
const { classes, xml } = readStyleClasses(input)
let error: string | null = null
if (/<mxStyle\b/i.test(xml)) {
error =
'A named style definition is not closed. Write it as <mxStyle name="..." value="..."/> before the cells.'
} else if (classes.size > 0 && !hasCells(xml)) {
error =
"Only named style definitions were sent, no cells. Send the mxCell elements after the definitions."
}
return { classes, xml, error }
}
export function prepareNewDiagram( export function prepareNewDiagram(
input: string, input: string,
page: { pageId?: string; pageName?: string } = {}, page: { pageId?: string; pageName?: string } = {},
): NewDiagram { ): NewDiagram {
const reserved = reservedIdError(input) const {
classes,
xml: cells,
error: styleError,
} = takeStyleDefinitions(input)
if (styleError) return { ok: false, error: styleError }
const reserved = reservedIdError(cells)
if (reserved) return { ok: false, error: reserved } if (reserved) return { ok: false, error: reserved }
let xml = wrapCellsInModel(input) let xml = wrapCellsInModel(cells)
const { valid, error, fixed, fixes } = validateAndFixXml(xml) const { valid, error, fixed, fixes } = validateAndFixXml(xml)
if (fixed) xml = fixed if (fixed) xml = fixed
if (!valid) { if (!valid) {
return { ok: false, error: `XML validation failed - ${error}` } return { ok: false, error: `XML validation failed - ${error}` }
} }
xml = addDefaultStyles(applyStyleClasses(expandCompactCells(xml), classes))
// The rewrites copy attribute values as written; a check that they
// produced well-formed XML, in case one of them ever does not
const rewriteError = getXmlSyntaxError(xml)
if (rewriteError) {
return {
ok: false,
error: `XML validation failed after expanding the cells - ${rewriteError}`,
}
}
const normalized = normalizeToMxfile(xml, page) const normalized = normalizeToMxfile(xml, page)
if (!normalized) { if (!normalized) {
return { return {
+232
View File
@@ -0,0 +1,232 @@
/**
* Named styles, written once and used by name like CSS classes.
*
* The model defines a style before the cells and refers to it from any
* number of cells, so a style string is not repeated in every cell:
*
* <mxStyle name="blue" value="fillColor=#dae8fc;strokeColor=#6c8ebf;"/>
* <mxCell id="2" value="A" style="rounded=1;blue;" vertex="1" parent="1">...</mxCell>
* <mxCell id="3" value="B" style="rhombus;blue;fontSize=14;" vertex="1" parent="1">...</mxCell>
*
* draw.io resolves a style token without "=" through its stylesheet, which
* is not saved in the file, so the names are expanded here and the saved
* XML is plain draw.io XML. A name without a definition is left as it is:
* draw.io ignores it, unless its own stylesheet knows the name (text,
* ellipse, label, blue, green, ...), which then keeps its meaning.
*
* html=1 and whiteSpace=wrap, which draw.io itself puts on every new shape,
* are added here too, so the model never has to write them.
*
* The callers validate and auto-fix the XML first and rewrite the styles
* after, so a repaired cell (quotes written as entities, a lowercase tag)
* gets its defaults too, and the rewrite only ever sees proper attributes.
*/
import { readAttributes } from "./xml-attributes.ts"
export type StyleClasses = Map<string, string>
// <mxStyle name="..." value="..."/> with the whitespace after it, also when
// written as <mxStyle ...> or <mxStyle ...></mxStyle>; quoted values may hold ">"
const STYLE_DEFINITION =
/<mxStyle\b((?:[^<>"']|"[^"]*"|'[^']*')*?)\s*\/?>(?:\s*<\/mxStyle>)?\s*/gi
// An mxCell opening tag; the attributes stop before a closing "/" so a
// self-closing cell keeps its "/>"
const CELL_TAG = /<mxCell\b((?:[^<>"']|"[^"]*"|'[^']*')*?)\s*(\/?)>/g
// draw.io reads keys and tokens as written, " html" is not "html", so the
// checks are exact too
/** Whether the style sets the key (as `key=` at the start or after a ";") */
const hasKey = (style: string, key: string) =>
new RegExp(`(^|;)${key}=`).test(style)
/** Whether the style holds this exact token */
const hasToken = (style: string, token: string) =>
style.split(";").includes(token)
/**
* Replace the names among the tokens with their definitions. The tokens are
* kept exactly as written, empty ones included: a leading ";" is draw.io's
* "no default style" marker, and an entity such as &quot; ends in ";" too.
* A name whose definition is empty is dropped, so it does not leave such a
* marker behind.
*/
function applyToStyle(style: string, classes: StyleClasses): string {
const out: string[] = []
for (const token of style.split(";")) {
const definition = token.includes("=")
? undefined
: classes.get(token.trim())
if (definition === undefined) out.push(token)
else if (definition !== "") out.push(definition)
}
return out.join(";")
}
/**
* A definition's value goes into a style attribute unchecked by the XML
* validator (the definitions are taken out before it runs), so the two
* characters that would break the attribute are escaped here, the way the
* validator repairs them in cells.
*/
function escapeForAttribute(value: string): string {
return value
.replace(
/&(?!(?:lt|gt|amp|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);)/g,
"&amp;",
)
.replace(/</g, "&lt;")
}
/**
* The definitions, resolved against each other, and the XML without them.
* A definition may use names defined before or after it; a name using
* itself stays a bare token.
*/
export function readStyleClasses(xml: string): {
classes: StyleClasses
xml: string
} {
const classes: StyleClasses = new Map()
const rest = xml.replace(STYLE_DEFINITION, (_match, attrText: string) => {
const attrs = new Map(
readAttributes(attrText).map((a) => [a.name, a.value]),
)
// "name" may end in the ";" the model is used to; "style" is the
// attribute the model may reach for instead of "value"
const name = attrs.get("name")?.trim().replace(/;+$/, "")
const value = attrs.get("value") ?? attrs.get("style")
if (name && value !== undefined) {
// Without the trailing ";", so a name followed by ";" in a cell
// expands to "...;" and not to "...;;"
classes.set(name, escapeForAttribute(value).replace(/;+$/, ""))
}
return ""
})
// Two passes resolve names used by other definitions, in either order
for (let pass = 0; pass < 2; pass++) {
for (const [name, value] of classes) {
const others = new Map(classes)
others.delete(name)
classes.set(name, applyToStyle(value, others))
}
}
return { classes, xml: rest }
}
/**
* Rewrite the style of every mxCell. The callback returns the new style, or
* undefined to leave the cell alone. A cell without a style attribute gets
* one when the callback returns a style for it.
*/
function rewriteCellStyles(
xml: string,
rewrite: (
style: string | undefined,
attrs: Map<string, string>,
) => string | undefined,
): string {
return xml.replace(CELL_TAG, (tag, attrText: string, selfClose: string) => {
const attributes = readAttributes(attrText)
const attrs = new Map(attributes.map((a) => [a.name, a.value]))
const style = attributes.find((a) => a.name === "style")
const next = rewrite(style?.value, attrs)
if (next === undefined || next === style?.value) return tag
// The value is copied from attributes as written, so only a quote
// from a single-quoted source needs escaping
const written = `style="${next.replace(/"/g, "&quot;")}"`
if (!style) {
return `<mxCell${attrText} ${written}${selfClose}>`
}
// Keep the attribute where it was, with the whitespace before it
const space =
attrText.slice(style.start, style.end).match(/^\s*/)?.[0] ?? " "
return `<mxCell${attrText.slice(0, style.start)}${space}${written}${attrText.slice(style.end)}${selfClose}>`
})
}
/** Expand the names in every cell's style; the overrides after a name still win */
export function applyStyleClasses(xml: string, classes: StyleClasses): string {
if (classes.size === 0) return xml
return rewriteCellStyles(xml, (style) =>
style === undefined ? undefined : applyToStyle(style, classes),
)
}
/** The ids of the edges in the XML, whose child vertices are edge labels */
export function edgeIdsOf(xml: string): Set<string> {
const ids = new Set<string>()
for (const [, attrText] of xml.matchAll(CELL_TAG)) {
const attrs = new Map(
readAttributes(attrText).map((a) => [a.name, a.value]),
)
const id = attrs.get("id")
if (id && attrs.get("edge") === "1") ids.add(id)
}
return ids
}
/**
* Whether the label sits outside the shape, as with library icons (their
* styles position the label below) and images, so wrapping it to the
* shape's width would be wrong.
*/
function hasLabelOutside(style: string): boolean {
return (
hasKey(style, "verticalLabelPosition") ||
hasKey(style, "labelPosition") ||
hasKey(style, "image") ||
hasToken(style, "shape=image")
)
}
/**
* draw.io's own defaults for a new shape: html=1 (so labels may hold <br>
* and <b>) and whiteSpace=wrap. Wrapping is not added to shapes whose label
* sits outside (icons, images), to text that sizes itself (autosize=1), to
* edge labels (their label box has no width of its own) or next to html=0
* (draw.io renders a wrapping label as HTML). Edges get html=1. Root cells
* and cells that already set a key are left alone.
*
* An edit adds cells to a page the XML does not show, so the caller passes
* the ids of the edges already on it (edgeIdsOf), or new edge labels would
* be wrapped like shapes.
*/
export function addDefaultStyles(
xml: string,
knownEdgeIds: Iterable<string> = [],
): string {
const edges = new Set([...knownEdgeIds, ...edgeIdsOf(xml)])
return rewriteCellStyles(xml, (style, attrs) => {
// A cell marked as both is drawn as an edge
const isEdge = attrs.get("edge") === "1"
const isVertex = attrs.get("vertex") === "1" && !isEdge
if (!isVertex && !isEdge) return undefined
const current = style ?? ""
const isEdgeLabel =
hasToken(current, "edgeLabel") ||
attrs.get("connectable") === "0" ||
edges.has(attrs.get("parent") ?? "")
const added: string[] = []
if (
isVertex &&
!hasKey(current, "whiteSpace") &&
!hasLabelOutside(current) &&
!isEdgeLabel &&
!hasToken(current, "autosize=1") &&
!hasToken(current, "html=0")
) {
added.push("whiteSpace=wrap")
}
if (!hasKey(current, "html")) added.push("html=1")
if (added.length === 0) return undefined
const separator = current === "" || current.endsWith(";") ? "" : ";"
return `${current}${separator}${added.join(";")};`
})
}
/** Expand the named styles of a model's XML and add the default styles */
export function expandStyles(xml: string): string {
const { classes, xml: cells } = readStyleClasses(xml)
return addDefaultStyles(applyStyleClasses(cells, classes))
}
+17 -22
View File
@@ -3,30 +3,16 @@
* prompt and tool descriptions, so both teach the model the same thing. * prompt and tool descriptions, so both teach the model the same thing.
*/ */
export const SWIMLANE_EXAMPLE = `<mxCell id="lane1" value="Frontend" style="swimlane;" vertex="1" parent="1"> export const SWIMLANE_EXAMPLE = `<mxCell id="lane1" value="Frontend" style="swimlane;" x="40" y="40" w="200" h="200"/>
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/> <mxCell id="step1" value="Step 1" style="rounded=1;" parent="lane1" x="20" y="60" w="160" h="40"/>
</mxCell> <mxCell id="lane2" value="Backend" style="swimlane;" x="280" y="40" w="200" h="200"/>
<mxCell id="step1" value="Step 1" style="rounded=1;" vertex="1" parent="lane1"> <mxCell id="step2" value="Step 2" style="rounded=1;" parent="lane2" x="20" y="60" w="160" h="40"/>
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/> <mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;" source="step1" target="step2"/>`
</mxCell>
<mxCell id="lane2" value="Backend" style="swimlane;" vertex="1" parent="1">
<mxGeometry x="280" y="40" width="200" height="200" as="geometry"/>
</mxCell>
<mxCell id="step2" value="Step 2" style="rounded=1;" vertex="1" parent="lane2">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell>
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
<mxGeometry relative="1" as="geometry"/>
</mxCell>`
export const TWO_EDGES_EXAMPLE = `<mxCell id="e1" value="A to B" style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;" edge="1" parent="1" source="a" target="b"> export const TWO_EDGES_EXAMPLE = `<mxCell id="e1" value="A to B" style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;" source="a" target="b"/>
<mxGeometry relative="1" as="geometry"/> <mxCell id="e2" value="B to A" style="edgeStyle=orthogonalEdgeStyle;exitX=0;exitY=0.7;entryX=1;entryY=0.7;" source="b" target="a"/>`
</mxCell>
<mxCell id="e2" value="B to A" style="edgeStyle=orthogonalEdgeStyle;exitX=0;exitY=0.7;entryX=1;entryY=0.7;endArrow=classic;" edge="1" parent="1" source="b" target="a">
<mxGeometry relative="1" as="geometry"/>
</mxCell>`
export const WAYPOINT_EXAMPLE = `<mxCell id="hotfix_to_main" style="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=0;entryX=1;entryY=0.5;endArrow=classic;" edge="1" parent="1" source="hotfix" target="main"> export const WAYPOINT_EXAMPLE = `<mxCell id="hotfix_to_main" style="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=0;entryX=1;entryY=0.5;" edge="1" parent="1" source="hotfix" target="main">
<mxGeometry relative="1" as="geometry"> <mxGeometry relative="1" as="geometry">
<Array as="points"> <Array as="points">
<mxPoint x="750" y="80"/> <mxPoint x="750" y="80"/>
@@ -35,6 +21,15 @@ export const WAYPOINT_EXAMPLE = `<mxCell id="hotfix_to_main" style="edgeStyle=or
</mxGeometry> </mxGeometry>
</mxCell>` </mxCell>`
/** Named styles defined once and used by name, like CSS classes */
export const STYLE_CLASS_EXAMPLE = `<mxStyle name="step" value="fillColor=#dae8fc;strokeColor=#6c8ebf;"/>
<mxStyle name="down" value="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=1;entryX=0.5;entryY=0;"/>
<mxCell id="2" value="Start" style="rounded=1;step;" x="40" y="40" w="120" h="60"/>
<mxCell id="3" value="Check" style="rhombus;step;fontStyle=1;" x="40" y="160" w="120" h="80"/>
<mxCell id="4" value="Done" style="rounded=1;step;" x="40" y="300" w="120" h="60"/>
<mxCell id="5" style="down;" source="2" target="3"/>
<mxCell id="6" style="down;" source="3" target="4"/>`
/** Indent every line, for an indented code block in Markdown */ /** Indent every line, for an indented code block in Markdown */
export const indent = (text: string, prefix = " ") => export const indent = (text: string, prefix = " ") =>
text text
+78 -13
View File
@@ -334,22 +334,25 @@ function checkEntityReferences(xml: string): string | null {
/** Check for nested mxCell tags using regex */ /** Check for nested mxCell tags using regex */
function checkNestedMxCells(xml: string): string | null { function checkNestedMxCells(xml: string): string | null {
const cellTagPattern = /<\/?mxCell[^>]*>/g // Quoted values may hold ">", so the tag ends at the first ">" outside them
const cellTagPattern = /<\/?mxCell\b(?:[^<>"']|"[^"]*"|'[^']*')*>/g
const cellStack: number[] = [] const cellStack: number[] = []
let cellMatch let cellMatch
while ((cellMatch = cellTagPattern.exec(xml)) !== null) { while ((cellMatch = cellTagPattern.exec(xml)) !== null) {
const tag = cellMatch[0] const tag = cellMatch[0]
const nested =
"Invalid XML: Found nested mxCell tags. Cells should be siblings, not nested inside other mxCell elements."
const isLabelOrGeometry =
/\sas\s*=\s*["'](valueLabel|geometry)["']/.test(tag)
if (tag.startsWith("</mxCell>")) { if (tag.startsWith("</mxCell>")) {
if (cellStack.length > 0) cellStack.pop() if (cellStack.length > 0) cellStack.pop()
} else if (!tag.endsWith("/>")) { } else if (tag.endsWith("/>")) {
const isLabelOrGeometry = // A self-closing cell inside an open one is nested too (or the
/\sas\s*=\s*["'](valueLabel|geometry)["']/.test(tag) // open one lacks its </mxCell>; the auto-fix sorts that out)
if (!isLabelOrGeometry) { if (cellStack.length > 0 && !isLabelOrGeometry) return nested
cellStack.push(cellMatch.index) } else if (!isLabelOrGeometry) {
if (cellStack.length > 1) { cellStack.push(cellMatch.index)
return "Invalid XML: Found nested mxCell tags. Cells should be siblings, not nested inside other mxCell elements." if (cellStack.length > 1) return nested
}
}
} }
} }
return null return null
@@ -556,6 +559,19 @@ export function validateMxCellStructure(
* @param xml - The XML string to fix * @param xml - The XML string to fix
* @returns Object with fixed XML and list of fixes applied * @returns Object with fixed XML and list of fixes applied
*/ */
/**
* A compact cell's last attribute is a number, and the model sometimes
* drops its closing quote: h="112/>. Only a cell whose earlier attributes
* are all properly quoted is touched, so a label that happens to contain
* such text is left alone.
*/
export function repairQuoteBeforeSlash(xml: string): string {
return xml.replace(
/<mxCell\b((?:\s+[\w:.-]+="[^"]*")*)(\s+(?:x|y|w|h|width|height)="-?\d+(?:\.\d+)?)\s*\/>/g,
'<mxCell$1$2"/>',
)
}
export function autoFixXml(xml: string): { fixed: string; fixes: string[] } { export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
let fixed = xml let fixed = xml
const fixes: string[] = [] const fixes: string[] = []
@@ -657,6 +673,29 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
fixes.push("Fixed malformed attribute quotes") fixes.push("Fixed malformed attribute quotes")
} }
// 6b. Missing closing quote on the last attribute of a compact cell
// (h="112/>), see repairQuoteBeforeSlash
const quoted = repairQuoteBeforeSlash(fixed)
if (quoted !== fixed) {
fixed = quoted
fixes.push("Added a missing closing quote before />")
}
// 6c. A compact cell written without the slash: <mxCell ... h="60">
// followed by the next cell, a definition or the end. Only cells that
// carry a size or a connection, so an open container stays open.
const slashless =
/<mxCell\b((?:\s+[\w:.-]+="[^"]*")*)\s*>(?=\s*(?:<mxCell\b|<mxStyle\b|$))/g
const closed = fixed.replace(slashless, (tag, attrText: string) =>
/\s(?:x|y|w|h|width|height|source|target)="/.test(attrText)
? `<mxCell${attrText}/>`
: tag,
)
if (closed !== fixed) {
fixed = closed
fixes.push("Closed compact cells written without the slash")
}
// 7. Fix malformed closing tags // 7. Fix malformed closing tags
const malformedClosingTag = /<\/([a-zA-Z][a-zA-Z0-9]*)\s*\/>/g const malformedClosingTag = /<\/([a-zA-Z][a-zA-Z0-9]*)\s*\/>/g
if (malformedClosingTag.test(fixed)) { if (malformedClosingTag.test(fixed)) {
@@ -987,16 +1026,39 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
/<mxCell\s/.test(trimmed) && /<mxCell\s/.test(trimmed) &&
!trimmed.endsWith("/>") && !trimmed.endsWith("/>") &&
!trimmed.endsWith("</mxCell>") !trimmed.endsWith("</mxCell>")
// A self-closing (compact) cell is the next sibling too
const isSelfClosingCell =
/<mxCell\s/.test(trimmed) && trimmed.endsWith("/>")
const isCloseCell = trimmed === "</mxCell>" const isCloseCell = trimmed === "</mxCell>"
// The last cell's closing tag may share its line with the wrapper's
const startsWithClose = !isCloseCell && trimmed.startsWith("</mxCell>")
if (isOpenCell) { if (isOpenCell || isSelfClosingCell) {
if (cellDepth > 0) { if (cellDepth > 0) {
const indent = line.match(/^(\s*)/)?.[1] || "" const indent = line.match(/^(\s*)/)?.[1] || ""
newLines.push(indent + "</mxCell>") newLines.push(indent + "</mxCell>")
trueNestedFixed++ trueNestedFixed++
pendingCloseRemoval++ // The open cell's own </mxCell> is removed later, when there
// is one before the next open cell; a cell that simply lacks
// its closing tag has none to remove
const nextOpen = lines2.findIndex(
(l, j) =>
j > i &&
/<mxCell\s/.test(l.trim()) &&
!l.trim().endsWith("/>"),
)
const nextClose = lines2.findIndex(
(l, j) => j > i && l.trim().startsWith("</mxCell>"),
)
if (
isOpenCell ||
(nextClose !== -1 &&
(nextOpen === -1 || nextClose < nextOpen))
) {
pendingCloseRemoval++
}
} }
cellDepth = 1 cellDepth = isOpenCell ? 1 : 0
newLines.push(line) newLines.push(line)
} else if (isCloseCell) { } else if (isCloseCell) {
if (pendingCloseRemoval > 0) { if (pendingCloseRemoval > 0) {
@@ -1005,6 +1067,9 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
cellDepth = Math.max(0, cellDepth - 1) cellDepth = Math.max(0, cellDepth - 1)
newLines.push(line) newLines.push(line)
} }
} else if (startsWithClose && pendingCloseRemoval > 0) {
pendingCloseRemoval--
newLines.push(line.replace("</mxCell>", ""))
} else { } else {
newLines.push(line) newLines.push(line)
} }
+370
View File
@@ -0,0 +1,370 @@
import { describe, expect, it } from "vitest"
import { extractCompleteMxCells, isMxCellXmlComplete } from "@/lib/utils"
import {
expandCompactCells,
foldCells,
} from "@/packages/mcp-server/src/compact-cells.ts"
import { editDiagram } from "@/packages/mcp-server/src/edit-diagram.ts"
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
import {
normalizeToMxfile,
wrapCellsInModel,
} from "@/packages/mcp-server/src/pages.ts"
const shape =
'<mxCell id="2" value="Start" style="rounded=1;" x="40" y="40" w="120" h="60"/>'
const shapeLong =
'<mxCell id="2" value="Start" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell>'
const edge = '<mxCell id="5" style="down;" source="2" target="3"/>'
const edgeLong =
'<mxCell id="5" style="down;" edge="1" parent="1" source="2" target="3"><mxGeometry relative="1" as="geometry"/></mxCell>'
/** Cells compared by what they mean, not by attribute order */
function canonical(xml: string): string {
const doc = new DOMParser().parseFromString(
`<root>${xml}</root>`,
"text/xml",
)
const describe = (el: Element): string => {
const attrs = Array.from(el.attributes)
.map((a) => `${a.name}=${a.value}`)
.sort()
.join(" ")
const children = Array.from(el.children).map(describe).join("")
return `<${el.tagName} ${attrs}>${children}</${el.tagName}>`
}
return Array.from(doc.documentElement.children).map(describe).join("\n")
}
describe("expandCompactCells", () => {
it("gives a compact shape its flags, parent and geometry", () => {
expect(expandCompactCells(shape)).toBe(shapeLong)
})
it("gives a compact edge its flags, parent and relative geometry", () => {
expect(expandCompactCells(edge)).toBe(edgeLong)
})
it("keeps an explicit parent and accepts width/height as names", () => {
const inLane =
'<mxCell id="3" value="Step" style="rounded=1;" parent="lane1" x="20" y="60" width="160" height="40"/>'
expect(expandCompactCells(inLane)).toBe(
'<mxCell id="3" value="Step" style="rounded=1;" vertex="1" parent="lane1"><mxGeometry x="20" y="60" width="160" height="40" as="geometry"/></mxCell>',
)
})
it("fills in a default position and size when some are missing", () => {
expect(expandCompactCells('<mxCell id="2" value="A" w="100"/>')).toBe(
'<mxCell id="2" value="A" vertex="1" parent="1"><mxGeometry x="0" y="0" width="100" height="60" as="geometry"/></mxCell>',
)
})
it("lets an explicit geometry win over compact attributes", () => {
// The stray attributes stay as written; the geometry is the one used
const both =
'<mxCell id="2" x="1" y="1" w="1" h="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell>'
expect(expandCompactCells(both)).toBe(
'<mxCell id="2" x="1" y="1" w="1" h="1" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell>',
)
})
it("adds the relative geometry to a long-form edge that has none", () => {
const noGeometry =
'<mxCell id="5" style="down;" edge="1" parent="1" source="2" target="3"/>'
expect(expandCompactCells(noGeometry)).toBe(edgeLong)
})
it("leaves root cells, long-form cells and edge labels alone", () => {
const roots = '<mxCell id="0"/><mxCell id="1" parent="0"/>'
const label =
'<mxCell id="9" value="yes" style="edgeLabel;" vertex="1" connectable="0" parent="5"><mxGeometry x="-0.5" relative="1" as="geometry"/></mxCell>'
expect(expandCompactCells(roots + shapeLong + edgeLong + label)).toBe(
roots + shapeLong + edgeLong + label,
)
})
it("works on a cell inside a UserObject wrapper", () => {
const wrapped =
'<UserObject id="4" label="Docs" link="https://example.com"><mxCell style="rounded=1;" x="580" y="40" w="120" h="60"/></UserObject>'
expect(expandCompactCells(wrapped)).toBe(
'<UserObject id="4" label="Docs" link="https://example.com"><mxCell style="rounded=1;" vertex="1" parent="1"><mxGeometry x="580" y="40" width="120" height="60" as="geometry"/></mxCell></UserObject>',
)
})
})
describe("foldCells", () => {
it("writes plain shapes and edges compactly", () => {
expect(foldCells(shapeLong)).toBe(shape)
expect(foldCells(edgeLong)).toBe(edge)
})
it("keeps a parent other than 1 and leaves special cells as written", () => {
const inLane =
'<mxCell id="3" value="Step" style="rounded=1;" vertex="1" parent="lane1"><mxGeometry x="20" y="60" width="160" height="40" as="geometry"/></mxCell>'
expect(foldCells(inLane)).toBe(
'<mxCell id="3" value="Step" style="rounded=1;" parent="lane1" x="20" y="60" w="160" h="40"/>',
)
const waypoints =
'<mxCell id="6" edge="1" parent="1" source="2" target="3"><mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="750" y="80"/></Array></mxGeometry></mxCell>'
const label =
'<mxCell id="9" value="yes" style="edgeLabel;" vertex="1" connectable="0" parent="5"><mxGeometry x="-0.5" relative="1" as="geometry"/></mxCell>'
const sourcePoint =
'<mxCell id="7" edge="1" parent="1" target="3"><mxGeometry relative="1" as="geometry"><mxPoint x="10" y="10" as="sourcePoint"/></mxGeometry></mxCell>'
const roots = '<mxCell id="0"/><mxCell id="1" parent="0"/>'
const wrongAs =
'<mxCell id="8" vertex="1" parent="1"><mxGeometry x="1" y="1" width="2" height="2" as="33"/></mxCell>'
for (const xml of [waypoints, label, sourcePoint, roots, wrongAs]) {
expect(foldCells(xml)).toBe(xml)
}
})
it("round-trips through expandCompactCells", () => {
const page = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${shapeLong}${edgeLong}<mxCell id="lane1" value="Lane" style="swimlane;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="200" height="200" as="geometry"/></mxCell><mxCell id="3" value="Step" style="rounded=1;" vertex="1" parent="lane1"><mxGeometry x="20" y="60" width="160" height="40" as="geometry"/></mxCell><mxCell id="6" edge="1" parent="1" source="2" target="3"><mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="750" y="80"/></Array></mxGeometry></mxCell></root></mxGraphModel>`
const folded = foldCells(page)
expect(folded).not.toContain("vertex=")
expect(canonical(expandCompactCells(folded))).toBe(canonical(page))
})
})
describe("compact cells through the pipeline", () => {
const geometryOf = (xml: string, id: string) =>
xml.match(
new RegExp(`<mxCell id="${id}"[^>]*>(<mxGeometry[^>]*/>)`),
)?.[1]
it("prepareNewDiagram expands them and still adds the defaults", () => {
const prepared = prepareNewDiagram(`${shape}\n${edge}`)
expect(prepared.ok).toBe(true)
if (!prepared.ok) return
expect(prepared.xml).toContain(
'style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1"',
)
expect(geometryOf(prepared.xml, "2")).toBe(
'<mxGeometry x="40" y="40" width="120" height="60" as="geometry"/>',
)
expect(prepared.xml).toContain(
'<mxCell id="5" style="down;html=1;" edge="1" parent="1" source="2" target="3"><mxGeometry relative="1" as="geometry"/></mxCell>',
)
})
it("editDiagram accepts a compact cell in new_xml", () => {
const file =
normalizeToMxfile(wrapCellsInModel(shapeLong), {
pageId: "p1",
pageName: "Page-1",
}) ?? ""
const outcome = editDiagram(
file,
[
{
operation: "add",
cell_id: "3",
new_xml:
'<mxCell id="3" value="Next" style="rounded=1;" x="200" y="40" w="120" h="60"/>',
},
{
operation: "update",
cell_id: "2",
new_xml:
'<mxCell id="2" value="Begin" style="rounded=1;" x="40" y="40" w="120" h="60"/>',
},
],
{},
)
expect(outcome.ok).toBe(true)
if (!outcome.ok) return
expect(outcome.xml).toContain('value="Next"')
expect(outcome.xml).toContain('value="Begin"')
expect(geometryOf(outcome.xml, "3")).toBe(
'<mxGeometry x="200" y="40" width="120" height="60" as="geometry"/>',
)
})
})
describe("a compact cell with a slip", () => {
it("repairs a missing closing quote before the slash", () => {
const prepared = prepareNewDiagram(
'<mxCell id="12" value="Coin Flip" style="rounded=1;" x="535" y="276" w="225" h="112/>',
)
expect(prepared.ok).toBe(true)
if (!prepared.ok) return
expect(prepared.xml).toContain(
'<mxGeometry x="535" y="276" width="225" height="112" as="geometry"/>',
)
})
})
describe("review round: cells the model may write", () => {
it("escapes a double quote from a single-quoted value", () => {
const out = expandCompactCells(
`<mxCell id='2' value='Say "hi"' x='1' y='2' w='3' h='4'/>`,
)
expect(out).toContain('value="Say &quot;hi&quot;"')
const prepared = prepareNewDiagram(
`<mxCell id='2' value='a" visible="0' x="1" y="2" w="3" h="4"/>`,
)
expect(prepared.ok).toBe(true)
if (!prepared.ok) return
expect(prepared.xml).toContain('value="a&quot; visible=&quot;0"')
expect(prepared.xml).not.toContain(' visible="0"')
const folded = foldCells(
`<mxCell id="2" value='Say "hi"' vertex="1" parent="1"><mxGeometry x="1" y="2" width="3" height="4" as="geometry"/></mxCell>`,
)
expect(folded).toBe(
'<mxCell id="2" value="Say &quot;hi&quot;" x="1" y="2" w="3" h="4"/>',
)
})
it("accepts labels with > in compact self-closing cells", () => {
const prepared = prepareNewDiagram(
`<mxCell id="2" value="x > 5" style="rounded=1;" x="40" y="40" w="120" h="60"/>
<mxCell id="3" value="A -> B" style="rounded=1;" x="40" y="200" w="120" h="60"/>
<mxCell id="5" source="2" target="3"/>`,
)
expect(prepared.ok).toBe(true)
expect(
isMxCellXmlComplete(
'<mxCell id="2" value="A > B" x="0" y="0" w="120" h="60"/>',
),
).toBe(true)
expect(
extractCompleteMxCells(
'<mxCell id="2" value="A > B" x="0" y="0" w="120" h="60"/>\n<mxCell id="3" value="C" x="0" y="0" w="1',
),
).toBe('<mxCell id="2" value="A > B" x="0" y="0" w="120" h="60"/>')
})
it("infers a vertex for an edge label with a geometry and an edge for a connection with a size", () => {
const label =
'<mxCell id="9" value="yes" style="edgeLabel;" parent="5" connectable="0"><mxGeometry x="-0.5" relative="1" as="geometry"/></mxCell>'
expect(expandCompactCells(label)).toBe(
'<mxCell id="9" value="yes" style="edgeLabel;" connectable="0" vertex="1" parent="5"><mxGeometry x="-0.5" relative="1" as="geometry"/></mxCell>',
)
expect(
expandCompactCells(
'<mxCell id="e" source="2" target="3" x="0" y="0"/>',
),
).toBe(
'<mxCell id="e" edge="1" parent="1" source="2" target="3"><mxGeometry relative="1" as="geometry"/></mxCell>',
)
// A dangling edge has only one end
expect(
expandCompactCells('<mxCell id="e" style="a;" source="2"/>'),
).toBe(
'<mxCell id="e" style="a;" edge="1" parent="1" source="2"><mxGeometry relative="1" as="geometry"/></mxCell>',
)
})
it("does not take a geometry inside custom data for the cell's own", () => {
const out = expandCompactCells(
'<mxCell id="2" x="10" y="20" w="120" h="60"><Object as="payload"><mxGeometry as="backup"/></Object></mxCell>',
)
expect(out).toBe(
'<mxCell id="2" vertex="1" parent="1"><mxGeometry x="10" y="20" width="120" height="60" as="geometry"/><Object as="payload"><mxGeometry as="backup"/></Object></mxCell>',
)
})
it("keeps stray size attributes when the cell has its own geometry", () => {
const xml =
'<mxCell id="2" x="99" vertex="1" parent="1"><mxGeometry x="1" y="2" width="3" height="4" as="geometry"/></mxCell>'
expect(expandCompactCells(xml)).toBe(xml)
expect(foldCells(xml)).toBe(xml)
})
it("ignores prototype names and folds only cells that name their parent", () => {
expect(expandCompactCells('<mxCell id="2" toString="x"/>')).toBe(
'<mxCell id="2" toString="x"/>',
)
const noParent =
'<mxCell id="2" vertex="1"><mxGeometry x="1" y="2" width="3" height="4" as="geometry"/></mxCell>'
expect(foldCells(noParent)).toBe(noParent)
})
it("puts a compact cell on the page's first layer when editing", () => {
const file = `<mxfile><diagram id="p1" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="L1" parent="0"/>${shapeLong.replace('parent="1"', 'parent="L1"')}</root></mxGraphModel></diagram></mxfile>`
const outcome = editDiagram(
file,
[
{
operation: "add",
cell_id: "3",
new_xml:
'<mxCell id="3" value="B" x="1" y="2" w="3" h="4"/>',
},
],
{},
)
expect(outcome.ok).toBe(true)
if (!outcome.ok) return
expect(outcome.xml).toContain(
'<mxCell id="3" value="B" vertex="1" parent="L1"',
)
expect(foldCells(file, "L1")).toContain(
'<mxCell id="2" value="Start" style="rounded=1;" x="40" y="40" w="120" h="60"/>',
)
})
it("refuses a compact shape with a root cell id instead of dropping it", () => {
const prepared = prepareNewDiagram(
'<mxCell id="1" value="A" x="0" y="0" w="10" h="10"/>',
)
expect(prepared.ok).toBe(false)
if (!prepared.ok) expect(prepared.error).toContain("root cells")
})
it("repairs the quote slip only on a cell's last numeric attribute", () => {
expect(
isMxCellXmlComplete('<mxCell id="2" x="1" y="2" w="3" h="4/>'),
).toBe(true)
const prepared = prepareNewDiagram(
'<mxCell id="2" value="5/>" x="1" y="2" w="3" h="4"/>',
)
expect(prepared.ok).toBe(true)
if (!prepared.ok) return
expect(prepared.xml).toContain('value="5/>"')
expect(prepared.xml).toContain(
'<mxGeometry x="1" y="2" width="3" height="4" as="geometry"/>',
)
})
it("closes a compact cell written without the slash", () => {
const prepared = prepareNewDiagram(
'<mxCell id="2" value="A" x="1" y="2" w="3" h="4">\n<mxCell id="3" value="B" x="1" y="2" w="3" h="4"/>',
)
expect(prepared.ok).toBe(true)
if (!prepared.ok) return
expect(prepared.xml).toContain('value="A"')
expect(prepared.xml).toContain('value="B"')
expect(prepared.fixes.join()).toContain("without the slash")
})
})
describe("a long-form cell missing its closing tag next to compact cells", () => {
it("is closed before the compact cell that follows", () => {
const prepared = prepareNewDiagram(
`<mxCell id="2" value="A" x="0" y="0" w="80" h="40"/>
<mxCell id="4" edge="1" parent="1" source="2" target="5">
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="300" y="150"/></Array></mxGeometry>
<mxCell id="5" value="C" x="400" y="0" w="80" h="40"/>`,
)
expect(prepared.ok).toBe(true)
if (!prepared.ok) return
expect(prepared.xml).toContain('<mxPoint x="300" y="150"/>')
expect(prepared.xml).toContain('value="C"')
expect((prepared.xml.match(/<mxCell\b/g) || []).length).toBe(5)
})
it("still flattens a compact cell nested inside an open cell", () => {
const prepared = prepareNewDiagram(
`<mxCell id="2" value="A" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="80" height="40" as="geometry"/>
<mxCell id="3" value="B" x="100" y="0" w="80" h="40"/>
</mxCell>`,
)
expect(prepared.ok).toBe(true)
if (!prepared.ok) return
expect(prepared.xml).toContain('value="A"')
expect(prepared.xml).toContain('value="B"')
expect((prepared.xml.match(/<mxCell\b/g) || []).length).toBe(4)
})
})
+499
View File
@@ -0,0 +1,499 @@
import { describe, expect, it } from "vitest"
import { isMxCellXmlComplete } from "@/lib/utils"
import { editDiagram } from "@/packages/mcp-server/src/edit-diagram.ts"
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
import {
normalizeToMxfile,
wrapCellsInModel,
} from "@/packages/mcp-server/src/pages.ts"
import {
addDefaultStyles,
applyStyleClasses,
expandStyles,
readStyleClasses,
} from "@/packages/mcp-server/src/style-classes.ts"
const geometry =
'<mxGeometry x="0" y="0" width="80" height="40" as="geometry"/>'
const cell = (id: string, style: string, extra = "") =>
`<mxCell id="${id}" value="${id}" style="${style}" vertex="1" parent="1"${extra}>${geometry}</mxCell>`
const edge = (id: string, style: string) =>
`<mxCell id="${id}" style="${style}" edge="1" parent="1" source="2" target="3"><mxGeometry relative="1" as="geometry"/></mxCell>`
const BLUE =
'<mxStyle name="blue" value="fillColor=#dae8fc;strokeColor=#6c8ebf;"/>'
describe("readStyleClasses", () => {
it("reads the definitions and removes them from the XML", () => {
const xml = `${BLUE}\n<mxStyle name="flow" value="edgeStyle=orthogonalEdgeStyle;"></mxStyle>\n${cell("2", "blue;")}`
const { classes, xml: rest } = readStyleClasses(xml)
expect(classes.get("blue")).toBe(
"fillColor=#dae8fc;strokeColor=#6c8ebf",
)
expect(classes.get("flow")).toBe("edgeStyle=orthogonalEdgeStyle")
expect(rest).toBe(cell("2", "blue;"))
})
it("resolves a definition that uses an earlier name", () => {
const xml = `${BLUE}<mxStyle name="pill" value="rounded=1;blue;arcSize=50;"/>`
const { classes } = readStyleClasses(xml)
expect(classes.get("pill")).toBe(
"rounded=1;fillColor=#dae8fc;strokeColor=#6c8ebf;arcSize=50",
)
})
it("leaves a definition that is still streaming in place", () => {
const xml = `${BLUE}<mxStyle name="flow" value="edgeSt`
const { classes, xml: rest } = readStyleClasses(xml)
expect(classes.size).toBe(1)
expect(rest).toBe('<mxStyle name="flow" value="edgeSt')
})
})
describe("applyStyleClasses", () => {
const classes = readStyleClasses(BLUE).classes
it("expands a name in place, so the overrides after it still win", () => {
const out = applyStyleClasses(
cell("2", "rhombus;blue;fontSize=14;"),
classes,
)
expect(out).toBe(
cell(
"2",
"rhombus;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=14;",
),
)
})
it("leaves unknown names, key=value tokens and other attributes alone", () => {
const xml = `${cell("2", "text;fontSize=blue;")}<mxCell id="3" vertex="1" parent="1" value="blue">${geometry}</mxCell>`
expect(applyStyleClasses(xml, classes)).toBe(xml)
})
it("works inside a wrapped model and keeps the attribute order", () => {
const xml = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" style="blue;" value="A" vertex="1" parent="1">${geometry}</mxCell></root></mxGraphModel>`
expect(applyStyleClasses(xml, classes)).toBe(
xml.replace(
'style="blue;"',
'style="fillColor=#dae8fc;strokeColor=#6c8ebf;"',
),
)
})
})
describe("addDefaultStyles", () => {
it("adds whiteSpace=wrap and html=1 to a shape", () => {
expect(addDefaultStyles(cell("2", "rounded=1;"))).toBe(
cell("2", "rounded=1;whiteSpace=wrap;html=1;"),
)
})
it("gives a shape without a style attribute one", () => {
const xml = `<mxCell id="2" value="A" vertex="1" parent="1">${geometry}</mxCell>`
expect(addDefaultStyles(xml)).toBe(
`<mxCell id="2" value="A" vertex="1" parent="1" style="whiteSpace=wrap;html=1;">${geometry}</mxCell>`,
)
})
it("keeps html=0 and an existing whiteSpace", () => {
const xml = cell("2", "html=0;whiteSpace=nowrap;")
expect(addDefaultStyles(xml)).toBe(xml)
})
it("adds only html=1 to icon shapes, images and edge labels", () => {
const icon = cell(
"2",
"shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.ec2;verticalLabelPosition=bottom;",
)
const image = cell("3", "image=data:image/png,abc;")
const label = cell("4", "edgeLabel;align=center;")
const unconnectable = cell("5", "align=center;", ' connectable="0"')
expect(addDefaultStyles(icon)).toBe(
cell(
"2",
"shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.ec2;verticalLabelPosition=bottom;html=1;",
),
)
expect(addDefaultStyles(image)).toBe(
cell("3", "image=data:image/png,abc;html=1;"),
)
expect(addDefaultStyles(label)).toBe(
cell("4", "edgeLabel;align=center;html=1;"),
)
expect(addDefaultStyles(unconnectable)).toBe(
cell("5", "align=center;html=1;", ' connectable="0"'),
)
})
it("adds html=1 to edges and leaves the root cells alone", () => {
const roots = '<mxCell id="0"/><mxCell id="1" parent="0"/>'
expect(addDefaultStyles(roots + edge("4", "endArrow=block;"))).toBe(
roots + edge("4", "endArrow=block;html=1;"),
)
})
})
describe("expandStyles", () => {
it("expands the names, then adds the defaults", () => {
const xml = `${BLUE}${cell("2", "blue;")}`
expect(expandStyles(xml)).toBe(
cell(
"2",
"fillColor=#dae8fc;strokeColor=#6c8ebf;whiteSpace=wrap;html=1;",
),
)
})
})
describe("prepareNewDiagram with named styles", () => {
it("saves plain draw.io XML without the definitions", () => {
const xml = `${BLUE}\n<mxStyle name="flow" value="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=1;entryX=0.5;entryY=0;"/>\n${cell("2", "blue;")}\n${cell("3", "rhombus;blue;")}\n${edge("4", "flow;")}`
const prepared = prepareNewDiagram(xml, { pageId: "p1" })
expect(prepared.ok).toBe(true)
if (!prepared.ok) return
expect(prepared.xml).not.toContain("mxStyle")
expect(prepared.xml).toContain(
'style="fillColor=#dae8fc;strokeColor=#6c8ebf;whiteSpace=wrap;html=1;"',
)
expect(prepared.xml).toContain(
'style="rhombus;fillColor=#dae8fc;strokeColor=#6c8ebf;whiteSpace=wrap;html=1;"',
)
expect(prepared.xml).toContain(
'style="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=1;entryX=0.5;entryY=0;html=1;"',
)
})
})
describe("editDiagram adds the default styles", () => {
it("adds html=1 and whiteSpace=wrap to an added cell", () => {
const file =
normalizeToMxfile(wrapCellsInModel(cell("2", "")), {
pageId: "p1",
pageName: "Page-1",
}) ?? ""
const outcome = editDiagram(
file,
[
{
operation: "add",
cell_id: "3",
new_xml: cell("3", "rounded=1;"),
},
],
{},
)
expect(outcome.ok).toBe(true)
if (!outcome.ok) return
expect(outcome.xml).toContain(
'style="rounded=1;whiteSpace=wrap;html=1;"',
)
})
})
describe("cells written in other shapes", () => {
it("keeps a self-closing cell without a style self-closing", () => {
const edge =
'<mxCell id="4" edge="1" parent="1" source="2" target="3"/>'
expect(addDefaultStyles(edge)).toBe(
'<mxCell id="4" edge="1" parent="1" source="2" target="3" style="html=1;"/>',
)
const shape = '<mxCell id="5" value="A" vertex="1" parent="1" />'
expect(addDefaultStyles(shape)).toBe(
'<mxCell id="5" value="A" vertex="1" parent="1" style="whiteSpace=wrap;html=1;"/>',
)
})
it("expands a name in a cell wrapped in a UserObject", () => {
const xml = `${BLUE}<UserObject id="4" label="Docs" link="https://example.com"><mxCell style="blue;" vertex="1" parent="1">${geometry}</mxCell></UserObject>`
expect(expandStyles(xml)).toBe(
`<UserObject id="4" label="Docs" link="https://example.com"><mxCell style="fillColor=#dae8fc;strokeColor=#6c8ebf;whiteSpace=wrap;html=1;" vertex="1" parent="1">${geometry}</mxCell></UserObject>`,
)
})
it("leaves text that sizes itself unwrapped", () => {
const xml = cell("2", "text;autosize=1;")
expect(addDefaultStyles(xml)).toBe(cell("2", "text;autosize=1;html=1;"))
})
it("reads a definition written without the closing slash", () => {
const { classes, xml } = readStyleClasses(
`<mxStyle name="a" value="x=1">\n${cell("2", "a;")}`,
)
expect(classes.get("a")).toBe("x=1")
expect(xml).toBe(cell("2", "a;"))
})
it("adds the defaults inside a full mxfile", () => {
const file = `<mxfile><diagram id="p" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cell("2", "rounded=1;")}</root></mxGraphModel></diagram></mxfile>`
const prepared = prepareNewDiagram(file)
expect(prepared.ok).toBe(true)
if (!prepared.ok) return
expect(prepared.xml).toContain(
'<mxCell id="0"/><mxCell id="1" parent="0"/>',
)
expect(prepared.xml).toContain(
'style="rounded=1;whiteSpace=wrap;html=1;"',
)
})
})
describe("truncation check with named styles", () => {
it("treats output cut off right after the definitions as incomplete", () => {
expect(isMxCellXmlComplete(BLUE)).toBe(false)
expect(
isMxCellXmlComplete(`${BLUE}<mxStyle name="flow" value="edgeSt`),
).toBe(false)
expect(isMxCellXmlComplete(`${BLUE}${cell("2", "blue;")}`)).toBe(true)
})
})
describe("styles are rewritten as written", () => {
it("keeps entities, a leading semicolon and a missing trailing one", () => {
const quoted = cell(
"2",
"fontFamily=&quot;Times New Roman&quot;;fillColor=red;",
)
expect(addDefaultStyles(quoted)).toBe(
cell(
"2",
"fontFamily=&quot;Times New Roman&quot;;fillColor=red;whiteSpace=wrap;html=1;",
),
)
const noDefaults = cell("3", ";shape=rectangle;strokeColor=#000000")
expect(addDefaultStyles(noDefaults)).toBe(
cell(
"3",
";shape=rectangle;strokeColor=#000000;whiteSpace=wrap;html=1;",
),
)
const classes = readStyleClasses(BLUE).classes
expect(applyStyleClasses(cell("4", "blue"), classes)).toBe(
cell("4", "fillColor=#dae8fc;strokeColor=#6c8ebf"),
)
})
it("does not add wrapping next to html=0, since wrapping turns HTML on", () => {
const xml = cell("2", "html=0;")
expect(addDefaultStyles(xml)).toBe(xml)
})
it("wraps ordinary shape= shapes and leaves library icons alone", () => {
expect(
addDefaultStyles(
cell(
"2",
"shape=parallelogram;perimeter=parallelogramPerimeter;",
),
),
).toBe(
cell(
"2",
"shape=parallelogram;perimeter=parallelogramPerimeter;whiteSpace=wrap;html=1;",
),
)
expect(
addDefaultStyles(
cell(
"3",
"shape=mxgraph.cisco19.router;verticalLabelPosition=bottom;",
),
),
).toBe(
cell(
"3",
"shape=mxgraph.cisco19.router;verticalLabelPosition=bottom;html=1;",
),
)
// Library shapes whose label sits inside wrap like any shape
expect(
addDefaultStyles(cell("5", "shape=mxgraph.flowchart.process;")),
).toBe(
cell(
"5",
"shape=mxgraph.flowchart.process;whiteSpace=wrap;html=1;",
),
)
expect(addDefaultStyles(cell("4", "shape=image;image=a.png;"))).toBe(
cell("4", "shape=image;image=a.png;html=1;"),
)
})
it("escapes a quote that a single-quoted definition brings in", () => {
const xml = `<mxStyle name="a" value='fontFamily="Arial";'/>${cell("2", "a;")}`
expect(expandStyles(xml)).toBe(
cell("2", "fontFamily=&quot;Arial&quot;;whiteSpace=wrap;html=1;"),
)
})
it("resolves references in either order; duplicates and built-in names", () => {
const { classes } = readStyleClasses(
`<mxStyle name="b" value="c;x=1;"/><mxStyle name="c" value="y=2;"/><mxStyle name="c" value="y=3;"/><mxStyle name="loop" value="loop;z=1;"/>`,
)
// A name defined later still resolves; the last definition wins
expect(classes.get("b")).toBe("y=3;x=1")
// A name using itself stays a bare token
expect(classes.get("loop")).toBe("loop;z=1")
// The last definition of a name wins
expect(classes.get("c")).toBe("y=3")
// A definition named like a built-in replaces that built-in's meaning
const shadow = readStyleClasses(
'<mxStyle name="ellipse" value="fillColor=red;"/>',
).classes
expect(applyStyleClasses(cell("2", "ellipse;"), shadow)).toBe(
cell("2", "fillColor=red;"),
)
})
})
describe("the auto-fix runs before the defaults", () => {
it("repairs quotes written as entities in an edit and then adds the defaults", () => {
const file = normalizeToMxfile(wrapCellsInModel(cell("2", "")), {
pageId: "p1",
pageName: "Page-1",
})
const outcome = editDiagram(
file ?? "",
[
{
operation: "add",
cell_id: "3",
new_xml: `<mxCell id="3" value="B" style=&quot;rounded=1;&quot; vertex="1" parent="1">${geometry}</mxCell>`,
},
],
{},
)
expect(outcome.ok).toBe(true)
if (!outcome.ok) return
expect(outcome.xml).toContain(
'style="rounded=1;whiteSpace=wrap;html=1;"',
)
})
it("repairs a new diagram the same way", () => {
const prepared = prepareNewDiagram(
`${BLUE}<mxCell id="2" value="A" style=&quot;blue;&quot; vertex="1" parent="1">${geometry}</mxCell>`,
)
expect(prepared.ok).toBe(true)
if (!prepared.ok) return
expect(prepared.xml).toContain(
'style="fillColor=#dae8fc;strokeColor=#6c8ebf;whiteSpace=wrap;html=1;"',
)
})
})
describe("definitions and cells the model may write imperfectly", () => {
it("escapes & and < in a definition, which the validator never sees", () => {
const xml = `<mxStyle name="f" value="fontFamily=A&B <C;"/>${cell("2", "f;")}`
const prepared = prepareNewDiagram(xml)
expect(prepared.ok).toBe(true)
if (!prepared.ok) return
expect(prepared.xml).toContain(
'style="fontFamily=A&amp;B &lt;C;whiteSpace=wrap;html=1;"',
)
})
it("accepts a lowercase tag, a style attribute and a name ending in ;", () => {
const { classes, xml } = readStyleClasses(
`<mxstyle name="blue;" style="fillColor=red;"/>${cell("2", "blue;")}`,
)
expect(classes.get("blue")).toBe("fillColor=red")
expect(xml).toBe(cell("2", "blue;"))
})
it("reads keys exactly, like draw.io, so a key after a space does not count", () => {
const xml = cell("2", "rounded=1; html=1; whiteSpace=wrap;")
expect(addDefaultStyles(xml)).toBe(
cell(
"2",
"rounded=1; html=1; whiteSpace=wrap;whiteSpace=wrap;html=1;",
),
)
const classes = readStyleClasses(BLUE).classes
expect(
applyStyleClasses(
cell("3", "rounded=1; blue; fontSize=14;"),
classes,
),
).toBe(
cell(
"3",
"rounded=1;fillColor=#dae8fc;strokeColor=#6c8ebf; fontSize=14;",
),
)
})
it("treats a vertex whose parent is an edge as an edge label", () => {
const xml = `${edge("e1", "")}<mxCell id="lbl" value="yes" style="align=center;" vertex="1" parent="e1"><mxGeometry relative="1" as="geometry"/></mxCell>`
expect(addDefaultStyles(xml)).toContain('style="align=center;html=1;"')
})
it("reports a definition that never closed and definitions without cells", () => {
const open = prepareNewDiagram(
`${BLUE}<mxStyle name="flow" value="edgeSt`,
)
expect(open.ok).toBe(false)
if (!open.ok) expect(open.error).toContain("not closed")
const only = prepareNewDiagram(BLUE)
expect(only.ok).toBe(false)
if (!only.ok) expect(only.error).toContain("no cells")
})
})
describe("edge labels and empty definitions", () => {
it("does not wrap a label added to an edge the XML does not show", () => {
const file =
normalizeToMxfile(
wrapCellsInModel(cell("2", "") + cell("3", "") + edge("4", "")),
{
pageId: "p1",
pageName: "Page-1",
},
) ?? ""
const label = `<mxCell id="9" value="yes" style="text;" vertex="1" parent="4"><mxGeometry x="-0.5" relative="1" as="geometry"/></mxCell>`
const outcome = editDiagram(
file,
[{ operation: "add", cell_id: "9", new_xml: label }],
{},
)
expect(outcome.ok).toBe(true)
if (!outcome.ok) return
expect(outcome.xml).toContain('id="9" value="yes" style="text;html=1;"')
// The same through the function the preview uses
expect(addDefaultStyles(label, ["4"])).toContain('style="text;html=1;"')
})
it("treats a cell marked edge and vertex as an edge", () => {
const both = `<mxCell id="5" style="rounded=1;" edge="1" vertex="1" parent="1" source="2" target="3"><mxGeometry relative="1" as="geometry"/></mxCell>`
expect(addDefaultStyles(both)).toContain('style="rounded=1;html=1;"')
})
it("drops a name whose definition is empty instead of leaving a leading ;", () => {
const xml = `<mxStyle name="plain" value=""/>${cell("2", "plain;rounded=1;")}`
expect(expandStyles(xml)).toBe(
cell("2", "rounded=1;whiteSpace=wrap;html=1;"),
)
})
it("tells the model that edit_diagram has no named styles", () => {
const file =
normalizeToMxfile(wrapCellsInModel(cell("2", "")), {
pageId: "p1",
pageName: "Page-1",
}) ?? ""
const outcome = editDiagram(
file,
[
{
operation: "add",
cell_id: "3",
new_xml: `${BLUE}${cell("3", "blue;")}`,
},
],
{},
)
expect(outcome.ok).toBe(false)
if (!outcome.ok)
expect(outcome.errors[0]).toContain("not available in edit_diagram")
})
})
+87 -1
View File
@@ -11,11 +11,15 @@ const box = (id: string) =>
const BEFORE_FIRST_EDIT = page(box("a")) const BEFORE_FIRST_EDIT = page(box("a"))
const AFTER_FIRST_EDIT = page(box("a") + box("b")) const AFTER_FIRST_EDIT = page(box("a") + box("b"))
const mocks = vi.hoisted(() => ({
loadDiagram: vi.fn((_xml: string, ..._rest: unknown[]) => null),
}))
vi.mock("@/contexts/diagram-context", () => ({ vi.mock("@/contexts/diagram-context", () => ({
useDiagram: () => ({ useDiagram: () => ({
chartXML: BEFORE_FIRST_EDIT, chartXML: BEFORE_FIRST_EDIT,
chartXMLRef: { current: AFTER_FIRST_EDIT }, chartXMLRef: { current: AFTER_FIRST_EDIT },
loadDiagram: vi.fn(() => null), loadDiagram: mocks.loadDiagram,
}), }),
})) }))
@@ -59,3 +63,85 @@ describe("the streaming preview of a second edit", () => {
) )
}) })
}) })
describe("the streaming preview of display_diagram", () => {
it("expands the named styles and adds the defaults to the cells so far", () => {
mocks.loadDiagram.mockClear()
// A complete definition and cell, then a cell still being written
const xml = `<mxStyle name="blue" value="fillColor=#dae8fc;strokeColor=#6c8ebf;"/>
<mxCell id="a" value="a" style="rounded=1;blue;" vertex="1" parent="1"><mxGeometry x="0" y="0" width="80" height="40" as="geometry"/></mxCell>
<mxCell id="b" value="b" style="blue;" vertex="1" parent="1"><mxGeometry x="0" y="0" wid`
const messages = [
{
id: "m1",
role: "assistant",
parts: [
{
type: "tool-display_diagram",
toolCallId: "draw-1",
state: "input-streaming",
input: { xml },
},
],
},
] as any
renderHook(() =>
useDiagramStreaming({
messages,
processedToolCallsRef: { current: new Set() },
editDiagramOriginalXmlRef: { current: new Map() },
loadedMessageIdsRef: { current: new Set() },
}),
)
expect(mocks.loadDiagram).toHaveBeenCalledTimes(1)
const loaded = mocks.loadDiagram.mock.calls[0][0]
expect(loaded).toContain(
'style="rounded=1;fillColor=#dae8fc;strokeColor=#6c8ebf;whiteSpace=wrap;html=1;"',
)
expect(loaded).not.toContain("mxStyle")
expect(loaded).not.toContain('id="b"')
})
})
describe("the streaming preview of edit_diagram", () => {
it("adds the default styles to the cell being added, like the final edit", () => {
mocks.loadDiagram.mockClear()
const messages = [
{
id: "m1",
role: "assistant",
parts: [
{
type: "tool-edit_diagram",
toolCallId: "edit-3",
state: "input-streaming",
input: {
operations: [
{
operation: "add",
cell_id: "c",
new_xml: box("c").replace(
'vertex="1"',
'style="rounded=1;" vertex="1"',
),
},
],
},
},
],
},
] as any
renderHook(() =>
useDiagramStreaming({
messages,
processedToolCallsRef: { current: new Set() },
editDiagramOriginalXmlRef: { current: new Map() },
loadedMessageIdsRef: { current: new Set() },
}),
)
expect(mocks.loadDiagram).toHaveBeenCalledTimes(1)
expect(mocks.loadDiagram.mock.calls[0][0]).toContain(
'style="rounded=1;whiteSpace=wrap;html=1;"',
)
})
})