Skip to content

Commit 8a842bb

Browse files
committed
Add mermaid diagram block: live editor render, static SVG publish, zoom viewer
1 parent 13f239b commit 8a842bb

18 files changed

Lines changed: 1758 additions & 129 deletions

astro.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ export default defineConfig({
217217
'@blocknote/react',
218218
'@blocknote/mantine',
219219
'@blocknote/code-block',
220+
'mermaid',
220221
],
221222
},
222223
build: {

docs/authoring/mdx-to-write-source.mjs

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -126,30 +126,55 @@ function frameProps(attrs) {
126126
};
127127
}
128128

129+
function unwrapJsxStyle(svg) {
130+
return svg.replace(
131+
/<style([^>]*)>\{`([\s\S]*?)`\}<\/style>/g,
132+
(_m, attrs, css) => `<style${attrs}>${css.replace(/\\([\\`$])/g, '$1')}</style>`,
133+
);
134+
}
135+
136+
function figureSegment(attrs, inner) {
137+
const caption = frameAttr(attrs, 'caption').replace(/\s+/g, ' ').trim();
138+
const width = Number(attrs.match(/width=\{(\d+)\}/)?.[1] ?? '') || '';
139+
if (/^<svg[\s>]/.test(inner)) {
140+
return { kind: 'figure', caption, svg: unwrapJsxStyle(inner) };
141+
}
142+
const img =
143+
inner.match(/^!\[([^\]]*)\]\(([^)\s]+)\)$/) ?? inner.match(/^<img[^>]*\ssrc="([^"]+)"[^>]*>$/);
144+
if (img) {
145+
const md = inner.startsWith('![');
146+
return {
147+
kind: 'image',
148+
alt: md ? img[1] : (inner.match(/\salt="([^"]*)"/)?.[1] ?? ''),
149+
src: md ? img[2] : img[1],
150+
caption,
151+
width: width || 360,
152+
};
153+
}
154+
return { kind: 'md', text: inner };
155+
}
156+
129157
let rest = body;
130158
const pattern =
131-
/(<Figure caption=(?:"([\s\S]*?)"|\{("(?:[^"\\]|\\.)*")\})>\s*([\s\S]*?)\s*<\/Figure>)|(<Note>\s*([\s\S]*?)\s*<\/Note>)|(```(\w*)\n([\s\S]*?)```)|(<Interactive\b([^>]*)>\s*<([A-Za-z]\w*)\s+client:visible\s*\/>\s*<\/Interactive>)|(<([A-Z]\w*)\s+client:visible\s*\/>)/g;
159+
/(<Figure([^>]*)>\s*([\s\S]*?)\s*<\/Figure>)|(<Note>\s*([\s\S]*?)\s*<\/Note>)|(```(\w*)\n([\s\S]*?)```)|(<Interactive\b([^>]*)>\s*<([A-Za-z]\w*)\s+client:visible\s*\/>\s*<\/Interactive>)|(<([A-Z]\w*)\s+client:visible\s*\/>)/g;
132160

133161
let cursor = 0;
134162
const segments = [];
135163
let m;
136164
while ((m = pattern.exec(rest))) {
137165
if (m.index > cursor) segments.push({ kind: 'md', text: rest.slice(cursor, m.index) });
138166
if (m[1]) {
139-
const caption = m[3] ? JSON.parse(m[3]) : (m[2] ?? '');
140-
segments.push({
141-
kind: 'figure',
142-
caption: caption.replace(/\s+/g, ' ').trim(),
143-
svg: m[4].trim(),
144-
});
145-
} else if (m[5]) segments.push({ kind: 'note', text: m[6].replace(/\s+/g, ' ').trim() });
146-
else if (m[7])
147-
segments.push({ kind: 'code', lang: m[8] || 'text', code: m[9].replace(/\n$/, '') });
148-
else if (m[10]) segments.push({ kind: 'component', name: m[12], frame: frameProps(m[11]) });
149-
else if (m[13])
167+
segments.push(figureSegment(m[2] ?? '', m[3].trim()));
168+
} else if (m[4]) segments.push({ kind: 'note', text: m[5].replace(/\s+/g, ' ').trim() });
169+
else if (m[6]) {
170+
const code = m[8].replace(/\n$/, '');
171+
if ((m[7] || '') === 'mermaid') segments.push({ kind: 'mermaid', source: code });
172+
else segments.push({ kind: 'code', lang: m[7] || 'text', code });
173+
} else if (m[9]) segments.push({ kind: 'component', name: m[11], frame: frameProps(m[10]) });
174+
else if (m[12])
150175
segments.push({
151176
kind: 'component',
152-
name: m[14],
177+
name: m[13],
153178
frame: { frameTitle: '', frameCaption: '', frameSize: 'normal', frameExpand: false },
154179
});
155180
cursor = m.index + m[0].length;
@@ -263,9 +288,20 @@ for (const seg of segments) {
263288
if (seg.kind === 'md') emitMd(seg.text);
264289
else if (seg.kind === 'figure') push('svg', { code: seg.svg, caption: seg.caption });
265290
else if (seg.kind === 'note') push('note', {}, inline(seg.text));
291+
else if (seg.kind === 'image')
292+
push('figure', {
293+
fileName: '',
294+
src: seg.src,
295+
alt: seg.alt,
296+
caption: seg.caption,
297+
width: seg.width,
298+
});
266299
else if (seg.kind === 'code')
267300
push('codeBlock', { language: seg.lang }, [{ type: 'text', text: seg.code, styles: {} }]);
268-
else if (seg.kind === 'component')
301+
else if (seg.kind === 'mermaid') {
302+
console.warn('note: mermaid block emitted without render — open the post in /write once');
303+
push('mermaid', { source: seg.source, svg: '', caption: '' });
304+
} else if (seg.kind === 'component')
269305
push('customComponent', {
270306
componentName: seg.name,
271307
source: componentSource(seg.name),
@@ -320,6 +356,7 @@ const allowed = new Set([
320356
'gallery',
321357
'video',
322358
'svg',
359+
'mermaid',
323360
'customComponent',
324361
]);
325362
for (const b of blocks) if (!allowed.has(b.type)) throw new Error('bad type ' + b.type);

docs/authoring/write-source-format.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,25 @@ JSON can't carry image binaries. Two placeholder strategies:
241241

242242
Agents **can and should** author complete diagrams this way. Rules: use `stroke="currentColor"` / `fill="currentColor"` so the diagram adapts to light/dark theme; set `style="width:100%;max-width:520px;height:auto;color:currentColor"` on the root; keep `viewBox` around 470 wide; include `role="img"` + `aria-label`.
243243

244+
### Mermaid diagram
245+
246+
```json
247+
{
248+
"id": "mermaid-1",
249+
"type": "mermaid",
250+
"props": {
251+
"source": "flowchart LR\n A[Input] --> B[Model] --> C[Output]",
252+
"svg": "",
253+
"caption": "Optional caption below the diagram."
254+
},
255+
"children": []
256+
}
257+
```
258+
259+
`source` is the mermaid diagram text. Leave `svg` empty — the /write editor renders the diagram and fills it in; the published page then ships that static SVG (readers load no mermaid library). A post with an unrendered mermaid block must be opened in the editor once before publishing.
260+
261+
The MDX→JSON converter maps ` ```mermaid ` fenced blocks to mermaid blocks (unrendered). A _published_ mermaid figure converts back as a static `svg` block — the editable mermaid source only survives via the post's `.write-source.json` sidecar, which is the canonical re-edit path.
262+
244263
### Custom React component (interactive embeds)
245264

246265
```json

0 commit comments

Comments
 (0)