@documonster/mcp 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +170 -51
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +28 -0
- package/dist/capabilities.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +10 -2
- package/dist/config.js.map +1 -1
- package/dist/tools/diagram-inspect.d.ts +20 -0
- package/dist/tools/diagram-inspect.d.ts.map +1 -0
- package/dist/tools/diagram-inspect.js +91 -0
- package/dist/tools/diagram-inspect.js.map +1 -0
- package/dist/tools/diagram-markdown.d.ts +45 -0
- package/dist/tools/diagram-markdown.d.ts.map +1 -0
- package/dist/tools/diagram-markdown.js +127 -0
- package/dist/tools/diagram-markdown.js.map +1 -0
- package/dist/tools/diagram-render.d.ts +14 -0
- package/dist/tools/diagram-render.d.ts.map +1 -0
- package/dist/tools/diagram-render.js +117 -0
- package/dist/tools/diagram-render.js.map +1 -0
- package/dist/tools/diagram.d.ts +217 -0
- package/dist/tools/diagram.d.ts.map +1 -0
- package/dist/tools/diagram.js +760 -0
- package/dist/tools/diagram.js.map +1 -0
- package/dist/tools/doc-convert.d.ts.map +1 -1
- package/dist/tools/doc-convert.js +26 -4
- package/dist/tools/doc-convert.js.map +1 -1
- package/dist/tools/doc-read.d.ts.map +1 -1
- package/dist/tools/doc-read.js +32 -4
- package/dist/tools/doc-read.js.map +1 -1
- package/dist/tools/doc-write.d.ts.map +1 -1
- package/dist/tools/doc-write.js +21 -3
- package/dist/tools/doc-write.js.map +1 -1
- package/dist/tools/document.d.ts +1 -1
- package/dist/tools/document.d.ts.map +1 -1
- package/dist/tools/document.js +13 -1
- package/dist/tools/document.js.map +1 -1
- package/dist/tools/help.d.ts +263 -15
- package/dist/tools/help.d.ts.map +1 -1
- package/dist/tools/help.js +263 -15
- package/dist/tools/help.js.map +1 -1
- package/dist/tools/image.d.ts +147 -0
- package/dist/tools/image.d.ts.map +1 -0
- package/dist/tools/image.js +749 -0
- package/dist/tools/image.js.map +1 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +4 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/inspect.d.ts.map +1 -1
- package/dist/tools/inspect.js +98 -6
- package/dist/tools/inspect.js.map +1 -1
- package/dist/tools/pdf-edit.d.ts +13 -6
- package/dist/tools/pdf-edit.d.ts.map +1 -1
- package/dist/tools/pdf-edit.js +93 -8
- package/dist/tools/pdf-edit.js.map +1 -1
- package/dist/tools/sheet-edit.d.ts.map +1 -1
- package/dist/tools/sheet-edit.js +37 -2
- package/dist/tools/sheet-edit.js.map +1 -1
- package/dist/tools/sheet-image.d.ts +58 -0
- package/dist/tools/sheet-image.d.ts.map +1 -0
- package/dist/tools/sheet-image.js +156 -0
- package/dist/tools/sheet-image.js.map +1 -0
- package/dist/tools/sheet-read.d.ts.map +1 -1
- package/dist/tools/sheet-read.js +18 -1
- package/dist/tools/sheet-read.js.map +1 -1
- package/dist/tools/sheet-write.d.ts.map +1 -1
- package/dist/tools/sheet-write.js +41 -4
- package/dist/tools/sheet-write.js.map +1 -1
- package/dist/tools/template.d.ts +19 -0
- package/dist/tools/template.d.ts.map +1 -1
- package/dist/tools/template.js +436 -11
- package/dist/tools/template.js.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mermaid diagram helpers shared by `diagram_render`, `diagram_inspect` and the
|
|
3
|
+
* Markdown fence rendering in `doc_write` / `doc_convert`.
|
|
4
|
+
*
|
|
5
|
+
* Three things live here that no tool should re-derive:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Where the source comes from.** A model supplies a diagram inline, or names
|
|
8
|
+
* a `.mmd` file, or names a Markdown file with several ` ```mermaid ` fences in
|
|
9
|
+
* it. All three resolve to the same thing, and fence positions are needed again
|
|
10
|
+
* later to splice rendered images back into the Markdown.
|
|
11
|
+
* 2. **What "render" means per format.** The diagram is converted to a display
|
|
12
|
+
* list *once*; SVG, PNG and PDF are three readings of that one list. Doing it
|
|
13
|
+
* any other way is how three backends come to disagree about one picture.
|
|
14
|
+
* 3. **What the parser actually saw.** A model cannot look at the output, so the
|
|
15
|
+
* only way it can verify a diagram is a structural read-back. That is
|
|
16
|
+
* {@link describeDiagram}, and it is the reason `diagram_inspect` exists.
|
|
17
|
+
*
|
|
18
|
+
* Theme presets are deliberately *this package's* own, not a claim of parity with
|
|
19
|
+
* Mermaid's named themes: only `default` is token-for-token Mermaid (the library
|
|
20
|
+
* reproduces Mermaid's `base` exactly). `themeOverrides` exposes every colour
|
|
21
|
+
* token, so nothing is reachable through the library that is not reachable here.
|
|
22
|
+
*/
|
|
23
|
+
import { readFile } from "node:fs/promises";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { encodePng } from "documonster/archive";
|
|
26
|
+
import { cssColour, rasterizeToRgba, renderDrawList, toSvg } from "documonster/draw";
|
|
27
|
+
import { MermaidSyntaxError, mermaidToDrawList, parseMermaid } from "documonster/mermaid";
|
|
28
|
+
import { Pdf, createPdfDrawSurface } from "documonster/pdf";
|
|
29
|
+
import { z } from "zod";
|
|
30
|
+
import { toolError } from "../errors.js";
|
|
31
|
+
import { resolveInRoot } from "../sandbox.js";
|
|
32
|
+
import { assertReadableSize } from "./fs-helpers.js";
|
|
33
|
+
import { escapeTableCell } from "./result.js";
|
|
34
|
+
const DIAGRAM_EXTENSIONS = {
|
|
35
|
+
".svg": "svg",
|
|
36
|
+
".png": "png",
|
|
37
|
+
".pdf": "pdf"
|
|
38
|
+
};
|
|
39
|
+
/** Extensions whose whole content is one diagram. */
|
|
40
|
+
const MERMAID_EXTENSIONS = [".mmd", ".mermaid"];
|
|
41
|
+
/** Extensions whose content is Markdown that may contain mermaid fences. */
|
|
42
|
+
const MARKDOWN_EXTENSIONS = [".md", ".markdown"];
|
|
43
|
+
/**
|
|
44
|
+
* Ceiling on a raster diagram's pixel count.
|
|
45
|
+
*
|
|
46
|
+
* The rasteriser has its own limit and throws a plain `Error`; this one exists so
|
|
47
|
+
* the refusal arrives as a `too_large` tool error with a hint, before any work is
|
|
48
|
+
* done, rather than as an `internal` after it.
|
|
49
|
+
*/
|
|
50
|
+
const MAX_RASTER_PIXELS = 40_000_000;
|
|
51
|
+
/** EMU per PDF point — a display list's unit is a point, as the PDF backend proves. */
|
|
52
|
+
export const EMU_PER_POINT = 12700;
|
|
53
|
+
/** Format a diagram output path denotes, or a tool error naming what is possible. */
|
|
54
|
+
export function requireDiagramFormat(filePath, field) {
|
|
55
|
+
const format = DIAGRAM_EXTENSIONS[path.extname(filePath).toLowerCase()];
|
|
56
|
+
if (format === undefined) {
|
|
57
|
+
throw toolError.invalidInput(`cannot tell the diagram format of ${field} from its extension: ${JSON.stringify(filePath)}`, "Use .svg (crisp, editable, smallest), .png (pastes anywhere) or .pdf (one page sized to the diagram).");
|
|
58
|
+
}
|
|
59
|
+
return format;
|
|
60
|
+
}
|
|
61
|
+
export const THEME_PRESETS = {
|
|
62
|
+
default: {},
|
|
63
|
+
dark: {
|
|
64
|
+
background: "#1e222a",
|
|
65
|
+
nodeFill: "#3b4252",
|
|
66
|
+
nodeStroke: "#88c0d0",
|
|
67
|
+
nodeText: "#eceff4",
|
|
68
|
+
edge: "#d8dee9",
|
|
69
|
+
edgeText: "#eceff4",
|
|
70
|
+
edgeLabelBackground: "#434c5e",
|
|
71
|
+
groupFill: "#2e3440",
|
|
72
|
+
groupStroke: "#5e81ac",
|
|
73
|
+
title: "#eceff4",
|
|
74
|
+
paletteText: "#2e3440",
|
|
75
|
+
palette: [
|
|
76
|
+
"#88c0d0",
|
|
77
|
+
"#bf616a",
|
|
78
|
+
"#a3be8c",
|
|
79
|
+
"#ebcb8b",
|
|
80
|
+
"#b48ead",
|
|
81
|
+
"#d08770",
|
|
82
|
+
"#8fbcbb",
|
|
83
|
+
"#81a1c1",
|
|
84
|
+
"#e5e9f0",
|
|
85
|
+
"#5e81ac",
|
|
86
|
+
"#4c566a",
|
|
87
|
+
"#d8dee9"
|
|
88
|
+
]
|
|
89
|
+
},
|
|
90
|
+
neutral: {
|
|
91
|
+
background: "#ffffff",
|
|
92
|
+
nodeFill: "#eeeeee",
|
|
93
|
+
nodeStroke: "#999999",
|
|
94
|
+
nodeText: "#111111",
|
|
95
|
+
edge: "#555555",
|
|
96
|
+
edgeText: "#111111",
|
|
97
|
+
edgeLabelBackground: "#f4f4f4",
|
|
98
|
+
groupFill: "#fafafa",
|
|
99
|
+
groupStroke: "#cccccc",
|
|
100
|
+
title: "#111111",
|
|
101
|
+
paletteText: "#ffffff",
|
|
102
|
+
palette: [
|
|
103
|
+
"#555555",
|
|
104
|
+
"#777777",
|
|
105
|
+
"#999999",
|
|
106
|
+
"#bbbbbb",
|
|
107
|
+
"#444444",
|
|
108
|
+
"#666666",
|
|
109
|
+
"#888888",
|
|
110
|
+
"#aaaaaa",
|
|
111
|
+
"#333333",
|
|
112
|
+
"#5f5f5f",
|
|
113
|
+
"#7f7f7f",
|
|
114
|
+
"#9f9f9f"
|
|
115
|
+
]
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
const THEME_PRESET_NAMES = Object.keys(THEME_PRESETS);
|
|
119
|
+
/** Colour token names a caller may override individually. */
|
|
120
|
+
const themeOverridesSchema = z
|
|
121
|
+
.object({
|
|
122
|
+
background: z.string().optional(),
|
|
123
|
+
nodeFill: z.string().optional(),
|
|
124
|
+
nodeStroke: z.string().optional(),
|
|
125
|
+
nodeText: z.string().optional(),
|
|
126
|
+
edge: z.string().optional(),
|
|
127
|
+
edgeText: z.string().optional(),
|
|
128
|
+
edgeLabelBackground: z.string().optional(),
|
|
129
|
+
groupFill: z.string().optional(),
|
|
130
|
+
groupStroke: z.string().optional(),
|
|
131
|
+
title: z.string().optional(),
|
|
132
|
+
paletteText: z.string().optional(),
|
|
133
|
+
palette: z.array(z.string()).min(1).max(24).optional()
|
|
134
|
+
})
|
|
135
|
+
.describe("Individual colour overrides applied on top of `theme`. CSS colours. `palette` colours the slices/series of pie, xychart, radar, sankey, journey and quadrant diagrams.");
|
|
136
|
+
/**
|
|
137
|
+
* The rendering fields both the render tool and the fence renderer accept.
|
|
138
|
+
*
|
|
139
|
+
* Exported as a raw shape so `diagram_render` can spread it into its own schema
|
|
140
|
+
* without the two drifting apart.
|
|
141
|
+
*/
|
|
142
|
+
export const diagramStyleShape = {
|
|
143
|
+
theme: z
|
|
144
|
+
.enum(THEME_PRESET_NAMES)
|
|
145
|
+
.optional()
|
|
146
|
+
.describe("Colour set. `default` reproduces Mermaid's own base theme; `dark` and `neutral` are this server's, not Mermaid's named themes. Defaults to `default`."),
|
|
147
|
+
themeOverrides: themeOverridesSchema.optional(),
|
|
148
|
+
background: z
|
|
149
|
+
.string()
|
|
150
|
+
.optional()
|
|
151
|
+
.describe('Page background as a CSS colour, or "transparent". Defaults to white — a transparent PNG is unreadable in a dark viewer, which is a failure the model cannot see.'),
|
|
152
|
+
fontSize: z.number().min(6).max(72).optional().describe("Label font size. Defaults to 14."),
|
|
153
|
+
fontFamily: z.string().optional().describe('Label font family. Defaults to "Arial".'),
|
|
154
|
+
rankGap: z
|
|
155
|
+
.number()
|
|
156
|
+
.min(0)
|
|
157
|
+
.max(400)
|
|
158
|
+
.optional()
|
|
159
|
+
.describe("Graph diagrams: gap between ranks, along the flow. Defaults to 56."),
|
|
160
|
+
nodeGap: z
|
|
161
|
+
.number()
|
|
162
|
+
.min(0)
|
|
163
|
+
.max(400)
|
|
164
|
+
.optional()
|
|
165
|
+
.describe("Graph diagrams: gap between siblings within a rank. Defaults to 34."),
|
|
166
|
+
maxLabelWidth: z
|
|
167
|
+
.number()
|
|
168
|
+
.min(20)
|
|
169
|
+
.max(2000)
|
|
170
|
+
.optional()
|
|
171
|
+
.describe("Longest label line before it wraps. Defaults to 220."),
|
|
172
|
+
padding: z
|
|
173
|
+
.number()
|
|
174
|
+
.min(0)
|
|
175
|
+
.max(200)
|
|
176
|
+
.optional()
|
|
177
|
+
.describe("Margin between the drawing and the edge of the image. Defaults to 16.")
|
|
178
|
+
};
|
|
179
|
+
/**
|
|
180
|
+
* Turn tool arguments into library render options.
|
|
181
|
+
*
|
|
182
|
+
* The background is resolved here rather than left to the library because the
|
|
183
|
+
* library's default is `"transparent"` — right for a caller compositing the list
|
|
184
|
+
* onto something else, wrong for a file a human opens.
|
|
185
|
+
*/
|
|
186
|
+
export function toRenderOptions(args) {
|
|
187
|
+
const preset = THEME_PRESETS[args.theme ?? "default"];
|
|
188
|
+
// Every override is validated, not just the background. `cssColour` falls back to
|
|
189
|
+
// black for anything it cannot read, so a misspelled `nodeFill` produced a diagram
|
|
190
|
+
// with black boxes — and since nothing can look at the picture, the caller's only
|
|
191
|
+
// clue was that it silently stopped matching the theme it asked for.
|
|
192
|
+
const overrides = requireColours(args.themeOverrides ?? {});
|
|
193
|
+
const background = requireColour(args.background ?? overrides.background ?? preset.background ?? "#ffffff", "background");
|
|
194
|
+
const theme = { ...preset, ...overrides, background };
|
|
195
|
+
return {
|
|
196
|
+
theme,
|
|
197
|
+
background,
|
|
198
|
+
...(args.fontSize === undefined ? {} : { fontSize: args.fontSize }),
|
|
199
|
+
...(args.fontFamily === undefined ? {} : { fontFamily: args.fontFamily }),
|
|
200
|
+
...(args.rankGap === undefined ? {} : { rankGap: args.rankGap }),
|
|
201
|
+
...(args.nodeGap === undefined ? {} : { nodeGap: args.nodeGap }),
|
|
202
|
+
...(args.maxLabelWidth === undefined ? {} : { maxLabelWidth: args.maxLabelWidth }),
|
|
203
|
+
...(args.padding === undefined ? {} : { padding: args.padding })
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/** Validate every colour in a set of overrides, including the palette. */
|
|
207
|
+
function requireColours(overrides) {
|
|
208
|
+
const checked = {};
|
|
209
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
210
|
+
if (value === undefined) {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
checked[key] =
|
|
214
|
+
key === "palette"
|
|
215
|
+
? value.map((entry, index) => requireColour(entry, `themeOverrides.palette[${index}]`))
|
|
216
|
+
: requireColour(value, `themeOverrides.${key}`);
|
|
217
|
+
}
|
|
218
|
+
return checked;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Reject a colour token the renderer cannot read.
|
|
222
|
+
*
|
|
223
|
+
* `cssColour` falls back to black for anything it fails to parse, so a misspelled
|
|
224
|
+
* `background` would silently produce a black rectangle — an outcome a model has
|
|
225
|
+
* no way to detect, since it never sees the image. Detecting the fallback needs
|
|
226
|
+
* the "was it actually black?" question asked separately, which is what the
|
|
227
|
+
* pattern below is for.
|
|
228
|
+
*/
|
|
229
|
+
function requireColour(token, field) {
|
|
230
|
+
if (token === "transparent") {
|
|
231
|
+
return token;
|
|
232
|
+
}
|
|
233
|
+
const normalised = token.trim().toLowerCase();
|
|
234
|
+
const parsed = cssColour(normalised);
|
|
235
|
+
const isBlack = parsed.r === 0 && parsed.g === 0 && parsed.b === 0 && parsed.a === 1;
|
|
236
|
+
const spelledBlack = normalised === "black" ||
|
|
237
|
+
/^#0{3,8}$/.test(normalised) ||
|
|
238
|
+
/^rgba?\(\s*0\s*,\s*0\s*,\s*0\s*(,\s*1(\.0+)?\s*)?\)$/.test(normalised);
|
|
239
|
+
if (isBlack && !spelledBlack) {
|
|
240
|
+
throw toolError.invalidInput(`${field} is not a colour the renderer can read: ${JSON.stringify(token)}`, 'Use a hex value like "#ffffff", an rgb()/rgba() function, a CSS colour name, or "transparent".');
|
|
241
|
+
}
|
|
242
|
+
return normalised;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Find every mermaid fence in a Markdown document.
|
|
246
|
+
*
|
|
247
|
+
* Scanned line by line rather than with one regular expression because a fence
|
|
248
|
+
* may be opened with backticks or tildes, of any length from three up, and must
|
|
249
|
+
* be closed by at least as many of the same character — a rule a single pattern
|
|
250
|
+
* expresses badly and an unterminated fence at the end of a file breaks outright.
|
|
251
|
+
* An unterminated fence is treated as running to the end of the document, which
|
|
252
|
+
* is what every Markdown renderer does.
|
|
253
|
+
*/
|
|
254
|
+
export function findMermaidFences(markdown) {
|
|
255
|
+
const fences = [];
|
|
256
|
+
const lines = markdown.split("\n");
|
|
257
|
+
// Offset of the start of each line, so a fence can report its own span. Computed
|
|
258
|
+
// from the raw line lengths, so a `\r` retained by the split is counted and the
|
|
259
|
+
// offsets stay valid against the original string.
|
|
260
|
+
const offsets = [];
|
|
261
|
+
let cursor = 0;
|
|
262
|
+
for (const line of lines) {
|
|
263
|
+
offsets.push(cursor);
|
|
264
|
+
cursor += line.length + 1;
|
|
265
|
+
}
|
|
266
|
+
let index = 0;
|
|
267
|
+
while (index < lines.length) {
|
|
268
|
+
const opening = OPENING_FENCE.exec(lines[index] ?? "");
|
|
269
|
+
if (opening === null) {
|
|
270
|
+
index += 1;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
const marker = opening[1] ?? "";
|
|
274
|
+
const info = (opening[2] ?? "").trim().toLowerCase();
|
|
275
|
+
const closer = closingFence(marker);
|
|
276
|
+
let end = index + 1;
|
|
277
|
+
while (end < lines.length && !closer.test(lines[end] ?? "")) {
|
|
278
|
+
end += 1;
|
|
279
|
+
}
|
|
280
|
+
// Any fence at all opens a block, and only its own closer ends one. A
|
|
281
|
+
// ```mermaid inside a ````markdown block is therefore *content* — an example of
|
|
282
|
+
// a diagram, not a diagram. Treating it as real rendered a picture nobody asked
|
|
283
|
+
// for, left the substituted reference sitting inside a code block where nothing
|
|
284
|
+
// consumed it, and still reported the diagram as embedded.
|
|
285
|
+
if (info === "mermaid") {
|
|
286
|
+
const start = offsets[index] ?? 0;
|
|
287
|
+
// An unterminated fence runs to the end of the document, as every Markdown
|
|
288
|
+
// renderer does; a terminated one ends past its closing line.
|
|
289
|
+
const afterClosing = end < lines.length
|
|
290
|
+
? Math.min(markdown.length, (offsets[end] ?? 0) + (lines[end] ?? "").length + 1)
|
|
291
|
+
: markdown.length;
|
|
292
|
+
fences.push({
|
|
293
|
+
ordinal: fences.length + 1,
|
|
294
|
+
line: index + 1,
|
|
295
|
+
// `\r` is stripped from the diagram source: the Mermaid parser matches its
|
|
296
|
+
// keywords against whole lines, and a trailing carriage return makes every
|
|
297
|
+
// one of them fail.
|
|
298
|
+
source: lines
|
|
299
|
+
.slice(index + 1, end)
|
|
300
|
+
.map(line => line.replace(/\r$/, ""))
|
|
301
|
+
.join("\n"),
|
|
302
|
+
start,
|
|
303
|
+
end: afterClosing
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
index = end + 1;
|
|
307
|
+
}
|
|
308
|
+
return fences;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* A fence opener: three or more backticks or tildes, then an optional info string.
|
|
312
|
+
*
|
|
313
|
+
* The trailing `\r?` is what makes a CRLF document work at all. `split("\n")` leaves
|
|
314
|
+
* the carriage return on every line, and a pattern anchored with `$` after
|
|
315
|
+
* `[ \t]*` matched none of them — so a Windows-authored Markdown file reported zero
|
|
316
|
+
* mermaid fences, and `doc_write` silently embedded the diagram as a code block.
|
|
317
|
+
*/
|
|
318
|
+
const OPENING_FENCE = /^[ \t]{0,3}(`{3,}|~{3,})[ \t]*([^\r\n]*?)[ \t]*\r?$/;
|
|
319
|
+
/** The matching closer: at least as many of the same character, and nothing else. */
|
|
320
|
+
function closingFence(marker) {
|
|
321
|
+
return new RegExp(`^[ \\t]{0,3}${marker[0] === "~" ? "~" : "`"}{${marker.length},}[ \\t]*\\r?$`);
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Strip a fence a model wrapped its own answer in.
|
|
325
|
+
*
|
|
326
|
+
* A model asked for "mermaid source" very often produces a fenced block, because
|
|
327
|
+
* that is how it has seen mermaid written everywhere. Passing that through to the
|
|
328
|
+
* parser fails on the first line with "unsupported diagram type '```mermaid'",
|
|
329
|
+
* which is a confusing report of the model's own formatting habit rather than of
|
|
330
|
+
* anything wrong with the diagram.
|
|
331
|
+
*/
|
|
332
|
+
export function unwrapFence(source) {
|
|
333
|
+
const trimmed = source.trim();
|
|
334
|
+
const fences = findMermaidFences(trimmed);
|
|
335
|
+
if (fences.length === 1 && fences[0]?.start === 0 && fences[0].end >= trimmed.length) {
|
|
336
|
+
return fences[0].source;
|
|
337
|
+
}
|
|
338
|
+
// A bare fence with no language tag, which is the other habit.
|
|
339
|
+
const bare = /^(`{3,}|~{3,})[ \t]*\n([\s\S]*?)\n?\1[ \t]*$/.exec(trimmed);
|
|
340
|
+
return bare?.[2] ?? source;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Resolve the diagram text from `source` or `from`.
|
|
344
|
+
*
|
|
345
|
+
* @throws {McpToolError} `invalid_input` when neither or both were given, or when
|
|
346
|
+
* a Markdown file holds no mermaid fence, or `index` names one that is not there.
|
|
347
|
+
*/
|
|
348
|
+
export async function resolveDiagramSource(config, args) {
|
|
349
|
+
const hasSource = typeof args.source === "string" && args.source.trim().length > 0;
|
|
350
|
+
const hasFrom = typeof args.from === "string" && args.from.trim().length > 0;
|
|
351
|
+
if (hasSource === hasFrom) {
|
|
352
|
+
throw toolError.invalidInput(hasSource
|
|
353
|
+
? "pass either `source` or `from`, not both"
|
|
354
|
+
: "no diagram source: pass `source` with the Mermaid text, or `from` with a path", "`source` is Mermaid text you write yourself. `from` reads a .mmd file, or picks a ```mermaid fence out of a .md file.");
|
|
355
|
+
}
|
|
356
|
+
if (hasSource) {
|
|
357
|
+
return { source: unwrapFence(args.source), origin: "inline", fences: [] };
|
|
358
|
+
}
|
|
359
|
+
const display = args.from;
|
|
360
|
+
const resolved = await resolveInRoot(config, display, { mustExist: true });
|
|
361
|
+
await assertReadableSize(config, resolved, display);
|
|
362
|
+
const text = await readFile(resolved, "utf8");
|
|
363
|
+
const extension = path.extname(resolved).toLowerCase();
|
|
364
|
+
if (MERMAID_EXTENSIONS.includes(extension)) {
|
|
365
|
+
return { source: unwrapFence(text), origin: display, fences: [] };
|
|
366
|
+
}
|
|
367
|
+
const fences = findMermaidFences(text);
|
|
368
|
+
if (fences.length === 0) {
|
|
369
|
+
if (MARKDOWN_EXTENSIONS.includes(extension)) {
|
|
370
|
+
throw toolError.invalidInput(`${display} contains no \`\`\`mermaid fence`, "Add one, or pass the diagram directly as `source`.");
|
|
371
|
+
}
|
|
372
|
+
// Not Markdown and not .mmd: treat the whole file as one diagram rather than
|
|
373
|
+
// refusing over an extension, since the parser will say so if it is not.
|
|
374
|
+
return { source: unwrapFence(text), origin: display, fences: [] };
|
|
375
|
+
}
|
|
376
|
+
const ordinal = args.index ?? 1;
|
|
377
|
+
const chosen = fences.find(fence => fence.ordinal === ordinal);
|
|
378
|
+
if (chosen === undefined) {
|
|
379
|
+
throw toolError.invalidInput(`${display} has ${fences.length} mermaid fence(s); there is no fence ${ordinal}`, `Pass index between 1 and ${fences.length}, or call diagram_inspect on the file to list them.`);
|
|
380
|
+
}
|
|
381
|
+
return { source: chosen.source, origin: display, fences, selected: chosen.ordinal };
|
|
382
|
+
}
|
|
383
|
+
/** Parse mermaid text, reporting a syntax error as a model-facing tool error. */
|
|
384
|
+
export function parseDiagram(source) {
|
|
385
|
+
try {
|
|
386
|
+
return parseMermaid(source);
|
|
387
|
+
}
|
|
388
|
+
catch (cause) {
|
|
389
|
+
if (cause instanceof MermaidSyntaxError) {
|
|
390
|
+
throw toolError.invalidInput(`the diagram could not be parsed: ${cause.message}`, DIAGRAM_HINT, {
|
|
391
|
+
cause
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
throw cause;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
/** Build the display list, mapping a syntax error the same way. */
|
|
398
|
+
export function buildDrawList(source, options) {
|
|
399
|
+
try {
|
|
400
|
+
return mermaidToDrawList(source, options);
|
|
401
|
+
}
|
|
402
|
+
catch (cause) {
|
|
403
|
+
if (cause instanceof MermaidSyntaxError) {
|
|
404
|
+
throw toolError.invalidInput(`the diagram could not be drawn: ${cause.message}`, DIAGRAM_HINT, {
|
|
405
|
+
cause
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
throw cause;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
const DIAGRAM_HINT = 'The first non-empty line names the diagram type, e.g. "flowchart TD" or "sequenceDiagram". Call documonster_help({ topic: "diagrams" }) for the list of supported types.';
|
|
412
|
+
/**
|
|
413
|
+
* Render one display list to one format.
|
|
414
|
+
*
|
|
415
|
+
* The list is built once by the caller and read here — which is the whole claim
|
|
416
|
+
* the drawing engine makes, tested from outside the library by this very function.
|
|
417
|
+
*/
|
|
418
|
+
export async function renderDiagram(list, format, size, background) {
|
|
419
|
+
const width = size.width ?? list.width;
|
|
420
|
+
const height = size.height ?? list.height;
|
|
421
|
+
if (format === "svg") {
|
|
422
|
+
const svg = toSvg(list, {
|
|
423
|
+
...(size.width === undefined ? {} : { width }),
|
|
424
|
+
...(size.height === undefined ? {} : { height }),
|
|
425
|
+
...(background === "transparent" ? {} : { background })
|
|
426
|
+
});
|
|
427
|
+
return { bytes: new TextEncoder().encode(svg), width, height };
|
|
428
|
+
}
|
|
429
|
+
if (format === "png") {
|
|
430
|
+
const scale = size.scale ?? 2;
|
|
431
|
+
const pixels = Math.round(width * scale) * Math.round(height * scale);
|
|
432
|
+
if (pixels > MAX_RASTER_PIXELS) {
|
|
433
|
+
throw toolError.tooLarge(`a ${Math.round(width * scale)}x${Math.round(height * scale)} PNG is ${Math.round(pixels / 1e6)}M pixels, over the ${Math.round(MAX_RASTER_PIXELS / 1e6)}M limit`, "Lower `scale`, or render SVG instead — it is resolution-independent and much smaller.");
|
|
434
|
+
}
|
|
435
|
+
const image = rasterizeToRgba(list, {
|
|
436
|
+
scale,
|
|
437
|
+
...(size.width === undefined ? {} : { width }),
|
|
438
|
+
...(size.height === undefined ? {} : { height })
|
|
439
|
+
});
|
|
440
|
+
return {
|
|
441
|
+
// A display list's unit is a point, so `scale` pixels per point is
|
|
442
|
+
// `72 * scale` dots per inch. Word, Excel and print pipelines read this
|
|
443
|
+
// instead of assuming 96, and getting it wrong resizes the picture.
|
|
444
|
+
bytes: encodePng(image.data, image.width, image.height, { dpi: Math.round(72 * scale) }),
|
|
445
|
+
width: image.width,
|
|
446
|
+
height: image.height
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
const builder = new Pdf.Builder();
|
|
450
|
+
const page = builder.addPage({ width, height });
|
|
451
|
+
// Letterbox: fit the list into the page uniformly, so an explicit page size
|
|
452
|
+
// produces a correct picture rather than a stretched one.
|
|
453
|
+
const fit = Math.min(width / list.width, height / list.height);
|
|
454
|
+
const drawnWidth = list.width * fit;
|
|
455
|
+
const drawnHeight = list.height * fit;
|
|
456
|
+
if (background !== "transparent") {
|
|
457
|
+
// The list's own backdrop covers the drawing only; with letterboxing that
|
|
458
|
+
// leaves the margin unpainted, and a PDF's unpainted area is not white.
|
|
459
|
+
page.drawRect({ x: 0, y: 0, width, height, fill: toPdfColour(background) });
|
|
460
|
+
}
|
|
461
|
+
renderDrawList(list, createPdfDrawSurface(page, {
|
|
462
|
+
x: (width - drawnWidth) / 2,
|
|
463
|
+
y: (height - drawnHeight) / 2,
|
|
464
|
+
width: drawnWidth,
|
|
465
|
+
height: drawnHeight
|
|
466
|
+
}, fit));
|
|
467
|
+
return { bytes: await builder.build(), width, height };
|
|
468
|
+
}
|
|
469
|
+
function toPdfColour(token) {
|
|
470
|
+
const colour = cssColour(token);
|
|
471
|
+
return colour.a >= 1
|
|
472
|
+
? { r: colour.r, g: colour.g, b: colour.b }
|
|
473
|
+
: { r: colour.r, g: colour.g, b: colour.b, a: colour.a };
|
|
474
|
+
}
|
|
475
|
+
// ---------------------------------------------------------------------------
|
|
476
|
+
// Structural read-back
|
|
477
|
+
// ---------------------------------------------------------------------------
|
|
478
|
+
/** Human name for each diagram kind, and the Mermaid keyword that selects it. */
|
|
479
|
+
const KIND_NAMES = {
|
|
480
|
+
flowchart: "flowchart",
|
|
481
|
+
state: "stateDiagram",
|
|
482
|
+
class: "classDiagram",
|
|
483
|
+
er: "erDiagram",
|
|
484
|
+
sequence: "sequenceDiagram",
|
|
485
|
+
requirement: "requirementDiagram",
|
|
486
|
+
c4: "C4",
|
|
487
|
+
architecture: "architecture",
|
|
488
|
+
gantt: "gantt",
|
|
489
|
+
timeline: "timeline",
|
|
490
|
+
journey: "journey",
|
|
491
|
+
kanban: "kanban",
|
|
492
|
+
mindmap: "mindmap",
|
|
493
|
+
git: "gitGraph",
|
|
494
|
+
quadrant: "quadrantChart",
|
|
495
|
+
xy: "xychart",
|
|
496
|
+
radar: "radar",
|
|
497
|
+
sankey: "sankey",
|
|
498
|
+
packet: "packet",
|
|
499
|
+
block: "block",
|
|
500
|
+
pie: "pie"
|
|
501
|
+
};
|
|
502
|
+
/** Every diagram type the parser accepts, for help text and error hints. */
|
|
503
|
+
export const SUPPORTED_DIAGRAM_KEYWORDS = Object.values(KIND_NAMES);
|
|
504
|
+
/** How many labels a structural summary lists before it stops. */
|
|
505
|
+
const MAX_LISTED = 40;
|
|
506
|
+
function diagramDetail(diagram) {
|
|
507
|
+
switch (diagram.kind) {
|
|
508
|
+
case "flowchart":
|
|
509
|
+
return {
|
|
510
|
+
counts: `${diagram.nodes.length} node(s), ${diagram.edges.length} edge(s), ${diagram.subgraphs.length} subgraph(s)`,
|
|
511
|
+
extra: [
|
|
512
|
+
`- direction: ${diagram.direction}`,
|
|
513
|
+
...list("nodes", diagram.nodes.map(node => node.text === node.id ? node.id : `${node.id} (${node.text})`)),
|
|
514
|
+
...list("edges", diagram.edges.map(edge => `${edge.from} ${edge.label === undefined ? "->" : `-[${edge.label}]->`} ${edge.to}`))
|
|
515
|
+
]
|
|
516
|
+
};
|
|
517
|
+
case "state":
|
|
518
|
+
return {
|
|
519
|
+
counts: `${diagram.states.length} state(s), ${diagram.transitions.length} transition(s), ${diagram.composites.length} composite(s)`,
|
|
520
|
+
extra: [
|
|
521
|
+
`- direction: ${diagram.direction}`,
|
|
522
|
+
...list("states", diagram.states.map(state => (state.text === state.id ? state.id : state.text))),
|
|
523
|
+
...list("transitions", diagram.transitions.map(transition => `${transition.from} ${transition.label === undefined ? "->" : `-[${transition.label}]->`} ${transition.to}`))
|
|
524
|
+
]
|
|
525
|
+
};
|
|
526
|
+
case "class":
|
|
527
|
+
return {
|
|
528
|
+
counts: `${diagram.classes.length} class(es), ${diagram.links.length} relation(s)`,
|
|
529
|
+
extra: [
|
|
530
|
+
...list("classes", diagram.classes.map(box => `${box.name} (${box.members.length} member(s))`)),
|
|
531
|
+
// Which classes relate, and how: inheritance drawn as composition is a
|
|
532
|
+
// wrong diagram, and the count cannot show it.
|
|
533
|
+
...list("relations", diagram.links.map(link => `${link.from} ${link.relation} ${link.to}`))
|
|
534
|
+
]
|
|
535
|
+
};
|
|
536
|
+
case "er":
|
|
537
|
+
return {
|
|
538
|
+
counts: `${diagram.entities.length} entit(y/ies), ${diagram.relations.length} relation(s)`,
|
|
539
|
+
extra: [
|
|
540
|
+
...list("entities", diagram.entities.map(entity => `${entity.name} (${entity.attributes.length} attribute(s))`)),
|
|
541
|
+
...list("relations", diagram.relations.map(relation => `${relation.from} ${relation.fromCardinality}–${relation.toCardinality} ${relation.to}${relation.label === undefined ? "" : ` (${relation.label})`}`))
|
|
542
|
+
]
|
|
543
|
+
};
|
|
544
|
+
case "sequence":
|
|
545
|
+
return {
|
|
546
|
+
counts: `${diagram.participants.length} participant(s), ${diagram.messages.length} message(s)`,
|
|
547
|
+
extra: [
|
|
548
|
+
`- autonumber: ${diagram.autonumber}`,
|
|
549
|
+
...list("participants", diagram.participants.map(participant => participant.text)),
|
|
550
|
+
// The messages *are* the diagram. A count cannot distinguish two
|
|
551
|
+
// sequences that say opposite things, which defeats a read-back.
|
|
552
|
+
...list("messages", diagram.messages.map(message => `${message.from} → ${message.to}: ${message.text}`))
|
|
553
|
+
]
|
|
554
|
+
};
|
|
555
|
+
case "requirement":
|
|
556
|
+
return {
|
|
557
|
+
counts: `${diagram.requirements.length} requirement(s), ${diagram.elements.length} element(s), ${diagram.links.length} link(s)`,
|
|
558
|
+
extra: [
|
|
559
|
+
...list("requirements", diagram.requirements.map(requirement => requirement.name)),
|
|
560
|
+
...list("links", diagram.links.map(link => `${link.from} ${link.verb} ${link.to}`))
|
|
561
|
+
]
|
|
562
|
+
};
|
|
563
|
+
case "c4":
|
|
564
|
+
return {
|
|
565
|
+
counts: `${diagram.elements.length} element(s), ${diagram.boundaries.length} boundar(y/ies), ${diagram.relations.length} relation(s)`,
|
|
566
|
+
extra: [
|
|
567
|
+
...list("elements", diagram.elements.map(element => element.label)),
|
|
568
|
+
...list("relations", diagram.relations.map(relation => `${relation.from} → ${relation.to}${relation.label === undefined ? "" : `: ${relation.label}`}`))
|
|
569
|
+
]
|
|
570
|
+
};
|
|
571
|
+
case "architecture":
|
|
572
|
+
return {
|
|
573
|
+
counts: `${diagram.nodes.length} node(s), ${diagram.edges.length} edge(s)`,
|
|
574
|
+
extra: [
|
|
575
|
+
...list("nodes", diagram.nodes.map(node => (node.isGroup ? `${node.label} (group)` : node.label))),
|
|
576
|
+
...list("edges", diagram.edges.map(edge => `${edge.from} → ${edge.to}`))
|
|
577
|
+
]
|
|
578
|
+
};
|
|
579
|
+
case "gantt":
|
|
580
|
+
return {
|
|
581
|
+
counts: `${diagram.tasks.length} task(s) in ${diagram.sections.length} section(s)`,
|
|
582
|
+
extra: list("tasks", diagram.tasks.map(task =>
|
|
583
|
+
// Dates, not the epoch milliseconds they are stored as: a bar in the
|
|
584
|
+
// wrong place is not an ugly chart, it is a wrong one, and this is the
|
|
585
|
+
// only way the model can check the date arithmetic it asked for.
|
|
586
|
+
`${task.label} ${isoDay(task.start)}→${isoDay(task.end)}${task.milestone ? " (milestone)" : ""}`))
|
|
587
|
+
};
|
|
588
|
+
case "timeline":
|
|
589
|
+
return {
|
|
590
|
+
counts: `${diagram.periods.length} period(s) in ${diagram.sections.length} section(s)`,
|
|
591
|
+
extra: list("periods", diagram.periods.map(period => `${period.label} (${period.events.length} event(s))`))
|
|
592
|
+
};
|
|
593
|
+
case "journey":
|
|
594
|
+
return {
|
|
595
|
+
counts: `${diagram.tasks.length} task(s) in ${diagram.sections.length} section(s)`,
|
|
596
|
+
extra: list("tasks", diagram.tasks.map(task => `${task.label} — score ${task.score}`))
|
|
597
|
+
};
|
|
598
|
+
case "kanban":
|
|
599
|
+
return {
|
|
600
|
+
counts: `${diagram.columns.length} column(s), ${diagram.columns.reduce((sum, column) => sum + column.cards.length, 0)} card(s)`,
|
|
601
|
+
extra: list("columns", diagram.columns.map(column => `${column.title} (${column.cards.length})`))
|
|
602
|
+
};
|
|
603
|
+
case "mindmap":
|
|
604
|
+
return {
|
|
605
|
+
counts: diagram.root === undefined
|
|
606
|
+
? "**empty**: no root node was recognised"
|
|
607
|
+
: `${countMindNodes(diagram.root)} node(s)`,
|
|
608
|
+
extra: diagram.root === undefined
|
|
609
|
+
? []
|
|
610
|
+
: [
|
|
611
|
+
`- root: ${JSON.stringify(diagram.root.text)}`,
|
|
612
|
+
...list("nodes", mindNodeLabels(diagram.root).slice(1))
|
|
613
|
+
]
|
|
614
|
+
};
|
|
615
|
+
case "git":
|
|
616
|
+
return {
|
|
617
|
+
counts: `${diagram.commits.length} commit(s) on ${diagram.branches.length} branch(es)`,
|
|
618
|
+
extra: [
|
|
619
|
+
...list("branches", diagram.branches),
|
|
620
|
+
...list("commits", diagram.commits.map(commit => `${commit.kind === "merge" ? "merge " : ""}${commit.id}@${commit.branch}${commit.tag === undefined ? "" : ` (${commit.tag})`}`))
|
|
621
|
+
]
|
|
622
|
+
};
|
|
623
|
+
case "quadrant":
|
|
624
|
+
return {
|
|
625
|
+
counts: `${diagram.points.length} point(s)`,
|
|
626
|
+
extra: [
|
|
627
|
+
`- quadrants: ${diagram.quadrants.map(label => JSON.stringify(label)).join(", ")}`,
|
|
628
|
+
...list("points", diagram.points.map(point => `${point.label} (${point.x}, ${point.y})`))
|
|
629
|
+
]
|
|
630
|
+
};
|
|
631
|
+
case "xy":
|
|
632
|
+
return {
|
|
633
|
+
counts: `${diagram.series.length} series over ${diagram.categories.length} categor(y/ies)`,
|
|
634
|
+
extra: [
|
|
635
|
+
`- orientation: ${diagram.horizontal ? "horizontal" : "vertical"}`,
|
|
636
|
+
...list("series", diagram.series.map(series => `${series.type} (${series.values.length} value(s))`))
|
|
637
|
+
]
|
|
638
|
+
};
|
|
639
|
+
case "radar":
|
|
640
|
+
return {
|
|
641
|
+
counts: `${diagram.series.length} series over ${diagram.axes.length} ax(is/es)`,
|
|
642
|
+
extra: list("axes", diagram.axes)
|
|
643
|
+
};
|
|
644
|
+
case "sankey":
|
|
645
|
+
return {
|
|
646
|
+
counts: `${diagram.links.length} link(s)`,
|
|
647
|
+
extra: list("links", diagram.links.map(link => `${link.from} → ${link.to} (${link.value})`))
|
|
648
|
+
};
|
|
649
|
+
case "packet":
|
|
650
|
+
return {
|
|
651
|
+
counts: `${diagram.fields.length} field(s), ${diagram.bitsPerRow} bits per row`,
|
|
652
|
+
extra: list("fields", diagram.fields.map(field => `${field.start}-${field.end}: ${field.label}`))
|
|
653
|
+
};
|
|
654
|
+
case "block":
|
|
655
|
+
return {
|
|
656
|
+
counts: `${diagram.cells.length} cell(s) in ${diagram.columns} column(s), ${diagram.edges.length} edge(s)`,
|
|
657
|
+
extra: [
|
|
658
|
+
...list("cells", diagram.cells.filter(cell => !cell.spacer).map(cell => cell.label)),
|
|
659
|
+
...list("edges", diagram.edges.map(edge => `${edge.from} → ${edge.to}`))
|
|
660
|
+
]
|
|
661
|
+
};
|
|
662
|
+
case "pie":
|
|
663
|
+
return {
|
|
664
|
+
counts: `${diagram.slices.length} slice(s), values ${diagram.showData ? "shown" : "hidden"}`,
|
|
665
|
+
extra: list("slices", diagram.slices.map(slice => `${slice.label}: ${slice.value}`))
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Describe what the parser made of a diagram, in full.
|
|
671
|
+
*
|
|
672
|
+
* This is the tool surface's answer to a hard problem: the model cannot see the
|
|
673
|
+
* picture, so "it rendered" is not evidence the diagram says what was meant. The
|
|
674
|
+
* parser implements a subset of Mermaid, and the way a subset fails is by
|
|
675
|
+
* *silently omitting* what it did not recognise — a mistyped arrow simply produces
|
|
676
|
+
* one fewer edge. Reporting counts and labels is what lets that be caught.
|
|
677
|
+
*/
|
|
678
|
+
export function describeDiagram(diagram) {
|
|
679
|
+
const detail = diagramDetail(diagram);
|
|
680
|
+
return [
|
|
681
|
+
`- type: **${KIND_NAMES[diagram.kind]}** (\`kind: "${diagram.kind}"\`)`,
|
|
682
|
+
...(diagram.title === undefined || diagram.title.length === 0
|
|
683
|
+
? []
|
|
684
|
+
: [`- title: ${JSON.stringify(diagram.title)}`]),
|
|
685
|
+
`- ${detail.counts}`,
|
|
686
|
+
...detail.extra
|
|
687
|
+
];
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* The same facts on one line, for a report that has no room for a list.
|
|
691
|
+
*
|
|
692
|
+
* Used by `pdf_edit`, where a diagram is one entry in a numbered list of
|
|
693
|
+
* operations and the alternative — saying only "drew a diagram" — would leave the
|
|
694
|
+
* model with no way to check what it drew short of another tool call.
|
|
695
|
+
*/
|
|
696
|
+
export function summariseDiagram(diagram) {
|
|
697
|
+
const detail = diagramDetail(diagram);
|
|
698
|
+
return `${KIND_NAMES[diagram.kind]}${diagram.title === undefined || diagram.title.length === 0 ? "" : ` ${JSON.stringify(diagram.title)}`} — ${detail.counts}`;
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* List the mermaid fences in a Markdown document, for a reader that will not
|
|
702
|
+
* render them.
|
|
703
|
+
*
|
|
704
|
+
* `doc_inspect` and `doc_read` both hand back Markdown as text, so a fence
|
|
705
|
+
* arrives at the model as source code. Without this note the model's next move is
|
|
706
|
+
* to copy that source into its own output and pass it back as `source` — spending
|
|
707
|
+
* tokens on data the server already has, which is the one habit this server's
|
|
708
|
+
* whole design tries to break. Naming the index makes `{ from, index }` the
|
|
709
|
+
* obvious call instead.
|
|
710
|
+
*/
|
|
711
|
+
export function describeFences(markdown, options = {}) {
|
|
712
|
+
const fences = findMermaidFences(markdown);
|
|
713
|
+
if (fences.length === 0) {
|
|
714
|
+
return [];
|
|
715
|
+
}
|
|
716
|
+
return [
|
|
717
|
+
`## ${fences.length} mermaid diagram(s)${options.sampled === true ? " in the first 64 KiB" : ""}`,
|
|
718
|
+
"",
|
|
719
|
+
"| index | line | first line |",
|
|
720
|
+
"| --- | --- | --- |",
|
|
721
|
+
...fences.map(fence => `| ${fence.ordinal} | ${fence.line} | ${escapeTableCell(fence.source.split("\n")[0] ?? "", 60)} |`),
|
|
722
|
+
"",
|
|
723
|
+
"Draw one with `diagram_render({ from, index, to })`, or read its structure with",
|
|
724
|
+
"`diagram_inspect({ from, index })`. Do not copy the diagram source into your own",
|
|
725
|
+
"output in order to render it — name the file and the index instead."
|
|
726
|
+
];
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* Render a capped list of what the parser found.
|
|
730
|
+
*
|
|
731
|
+
* Omitted entirely when empty, because the counts line above already carries the
|
|
732
|
+
* quantity. An emphatic "none recognised" beside a diagram that legitimately
|
|
733
|
+
* declares no edges reads as a failure, which is the opposite of the point: the
|
|
734
|
+
* signal for something dropped is a zero in the counts, and duplicating it here in
|
|
735
|
+
* stronger language made a correct block diagram look broken.
|
|
736
|
+
*/
|
|
737
|
+
function list(label, entries) {
|
|
738
|
+
if (entries.length === 0) {
|
|
739
|
+
return [];
|
|
740
|
+
}
|
|
741
|
+
const shown = entries.slice(0, MAX_LISTED);
|
|
742
|
+
const suffix = entries.length > shown.length ? `, … ${entries.length - shown.length} more` : "";
|
|
743
|
+
return [`- ${label}: ${shown.map(entry => `\`${entry}\``).join(", ")}${suffix}`];
|
|
744
|
+
}
|
|
745
|
+
function countMindNodes(node) {
|
|
746
|
+
return (1 +
|
|
747
|
+
node.children.reduce((sum, child) => sum + countMindNodes(child), 0));
|
|
748
|
+
}
|
|
749
|
+
/** Every label in a mind map, depth first, so the tree itself can be checked. */
|
|
750
|
+
function mindNodeLabels(node) {
|
|
751
|
+
return [
|
|
752
|
+
node.text,
|
|
753
|
+
...node.children.flatMap(mindNodeLabels)
|
|
754
|
+
];
|
|
755
|
+
}
|
|
756
|
+
/** A Gantt bound as a plain date. Epoch milliseconds prove nothing to a reader. */
|
|
757
|
+
function isoDay(time) {
|
|
758
|
+
return Number.isFinite(time) ? new Date(time).toISOString().slice(0, 10) : "?";
|
|
759
|
+
}
|
|
760
|
+
//# sourceMappingURL=diagram.js.map
|