interactive-markdown 0.1.1 → 0.3.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.
@@ -0,0 +1,771 @@
1
+ /**
2
+ * Document model for the preview block editor.
3
+ * Markdown stays the source of truth: parseDocument reads it, serializeDocument writes it back.
4
+ */
5
+
6
+ import { parseIvm, splitIvmFences } from "./parse-ivm.js";
7
+ import { quoteGraphLabel, serializeGraph } from "./graph-editor.js";
8
+ import { normalizePlot, serializePlotLines } from "./plot.js";
9
+
10
+ /** @typedef {'rect'|'circle'|'ellipse'|'line'|'path'|'text'} ShapeType */
11
+ /** @typedef {{ type: ShapeType, attrs: Record<string, string> }} Shape */
12
+ /** @typedef {{ nodes: {id:string,label:string,x:number,y:number,kind:string}[], edges: {from:string,to:string}[] }} GraphModel */
13
+
14
+ /**
15
+ * @typedef {object} BlockBase
16
+ * @property {string} id
17
+ */
18
+
19
+ let seq = 1;
20
+ function nid() {
21
+ return "b" + seq++;
22
+ }
23
+
24
+ const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
25
+ const SIMPLE_TAGS = new Set(["svg", "g", "rect", "circle", "ellipse", "line", "path", "text"]);
26
+ const SHAPE_TAGS = new Set(["rect", "circle", "ellipse", "line", "path", "text"]);
27
+
28
+ const CONTROL_PREFIX = {
29
+ slider: "s",
30
+ timer: "t",
31
+ input: "in",
32
+ toggle: "c",
33
+ let: "y",
34
+ };
35
+
36
+ /**
37
+ * @param {string} markdown
38
+ * @returns {{ blocks: object[] }}
39
+ */
40
+ export function parseDocument(markdown) {
41
+ const segments = splitIvmFences(markdown);
42
+ /** @type {object[]} */
43
+ const blocks = [];
44
+ for (const seg of segments) {
45
+ if (seg.type === "ivm") blocks.push(blockFromIvm(seg.content));
46
+ else blocks.push(...parseProse(seg.content));
47
+ }
48
+ return { blocks };
49
+ }
50
+
51
+ /**
52
+ * @returns {{ blocks: object[] }}
53
+ */
54
+ export function blankDocument() {
55
+ return { blocks: [] };
56
+ }
57
+
58
+ /**
59
+ * @param {object[]} blocks
60
+ * @returns {string}
61
+ */
62
+ export function serializeDocument(blocks) {
63
+ const parts = [];
64
+ for (const block of blocks || []) {
65
+ const text = serializeBlock(block).replace(/\s+$/, "");
66
+ if (text) parts.push(text);
67
+ }
68
+ if (!parts.length) return "\n";
69
+ return parts.join("\n\n") + "\n";
70
+ }
71
+
72
+ /**
73
+ * Fence body for one figure or graph. Used by the live preview.
74
+ * @param {object} block
75
+ * @returns {string}
76
+ */
77
+ export function serializeFigure(block) {
78
+ if (!block) return "\n";
79
+ if (block.type === "graph") return serializeGraph(block.model || { nodes: [], edges: [] });
80
+ if (block.type === "plot") {
81
+ const rebuilt = [];
82
+ if (block.namespace) rebuilt.push(`@ns ${block.namespace}`);
83
+ const plotLines = serializePlotLines(normalizePlot(block.plot || {}));
84
+ const preamble = [];
85
+ const series = [];
86
+ let sawSeries = false;
87
+ for (const line of plotLines) {
88
+ if (/^@series\b/i.test(line)) sawSeries = true;
89
+ if (sawSeries) series.push(line);
90
+ else preamble.push(line);
91
+ }
92
+ rebuilt.push(...preamble);
93
+ for (const control of block.controls || []) {
94
+ const line = serializeControl(control);
95
+ if (line) rebuilt.push(line);
96
+ }
97
+ rebuilt.push(...series);
98
+ return rebuilt.join("\n").replace(/\s*$/, "\n");
99
+ }
100
+ if (block.type !== "figure") return "\n";
101
+ const lines = [];
102
+ if (block.namespace) lines.push(`@ns ${block.namespace}`);
103
+ for (const control of block.controls || []) {
104
+ const line = serializeControl(control);
105
+ if (line) lines.push(line);
106
+ }
107
+ const svg = svgMarkup(block);
108
+ if (svg) {
109
+ lines.push("@svg");
110
+ lines.push(svg);
111
+ }
112
+ return lines.join("\n").replace(/\s*$/, "\n");
113
+ }
114
+
115
+ /**
116
+ * @param {{ blocks?: object[] }} doc
117
+ * @param {string} feature
118
+ * @param {{ at?: number, figureId?: string, shapeType?: string }} [options]
119
+ * @returns {{ doc: { blocks: object[] }, focusId: string }}
120
+ */
121
+ export function insertFeature(doc, feature, options = {}) {
122
+ const blocks = (doc?.blocks || []).map(cloneBlock);
123
+ const at = options.at;
124
+ /** @type {string} */
125
+ let focusId = "";
126
+
127
+ const place = (block) => {
128
+ const index = at == null ? blocks.length : Math.max(0, Math.min(blocks.length, at));
129
+ blocks.splice(index, 0, block);
130
+ focusId = block.id;
131
+ return block;
132
+ };
133
+
134
+ if (feature === "heading") {
135
+ place({ id: nid(), type: "heading", level: 2, text: "Heading" });
136
+ } else if (feature === "paragraph") {
137
+ place({ id: nid(), type: "paragraph", text: "" });
138
+ } else if (feature === "bullets") {
139
+ place({ id: nid(), type: "bullets", items: ["Item"] });
140
+ } else if (feature === "graph" || feature === "animated") {
141
+ place({
142
+ id: nid(),
143
+ type: "graph",
144
+ model: { nodes: [], edges: [], animated: true },
145
+ });
146
+ } else if (feature === "shape") {
147
+ const shape = defaultShape(options.shapeType || "rect");
148
+ const existing = findFigure(blocks, options.figureId);
149
+ if (existing && existing.shapes && !existing.customSvg) {
150
+ existing.shapes.push(shape);
151
+ focusId = existing.id;
152
+ } else {
153
+ const fig = emptyFigure();
154
+ fig.shapes = [shape];
155
+ place(fig);
156
+ }
157
+ } else if (feature === "plot") {
158
+ place(emptyPlot());
159
+ } else if (feature === "slider" || feature === "timer" || feature === "input" || feature === "toggle" || feature === "formula") {
160
+ const kind = feature === "formula" ? "let" : feature;
161
+ const control = defaultControl(kind, nextControlName(blocks, CONTROL_PREFIX[kind]));
162
+ const existing = findFigure(blocks, options.figureId);
163
+ if (existing) {
164
+ existing.controls.push(control);
165
+ focusId = existing.id;
166
+ } else {
167
+ const fig = emptyFigure();
168
+ fig.controls.push(control);
169
+ place(fig);
170
+ }
171
+ }
172
+
173
+ return { doc: { blocks }, focusId };
174
+ }
175
+
176
+ /**
177
+ * @param {{ blocks?: object[] }} doc
178
+ * @param {string} id
179
+ * @returns {{ blocks: object[] }}
180
+ */
181
+ export function removeBlock(doc, id) {
182
+ return { blocks: (doc?.blocks || []).filter((b) => b.id !== id).map(cloneBlock) };
183
+ }
184
+
185
+ /**
186
+ * @param {{ blocks?: object[] }} doc
187
+ * @param {string} id
188
+ * @param {number} delta
189
+ * @returns {{ blocks: object[] }}
190
+ */
191
+ export function moveBlock(doc, id, delta) {
192
+ const blocks = (doc?.blocks || []).map(cloneBlock);
193
+ const i = blocks.findIndex((b) => b.id === id);
194
+ const j = i + delta;
195
+ if (i < 0 || j < 0 || j >= blocks.length) return { blocks };
196
+ const [item] = blocks.splice(i, 1);
197
+ blocks.splice(j, 0, item);
198
+ return { blocks };
199
+ }
200
+
201
+ /**
202
+ * @param {{ blocks?: object[] }} doc
203
+ * @param {string} id
204
+ * @param {object} patch
205
+ * @returns {{ blocks: object[] }}
206
+ */
207
+ export function updateBlock(doc, id, patch) {
208
+ return {
209
+ blocks: (doc?.blocks || []).map((b) => (b.id === id ? cloneBlock({ ...b, ...patch }) : cloneBlock(b))),
210
+ };
211
+ }
212
+
213
+ /**
214
+ * @param {{ blocks?: object[] }} doc
215
+ * @param {string} blockId
216
+ * @param {number} index
217
+ * @param {object} patch
218
+ * @returns {{ blocks: object[] }}
219
+ */
220
+ export function updateControl(doc, blockId, index, patch) {
221
+ return {
222
+ blocks: (doc?.blocks || []).map((b) => {
223
+ const copy = cloneBlock(b);
224
+ if (copy.id === blockId && (copy.type === "figure" || copy.type === "plot") && copy.controls[index]) {
225
+ copy.controls[index] = cloneControl({ ...copy.controls[index], ...patch });
226
+ }
227
+ return copy;
228
+ }),
229
+ };
230
+ }
231
+
232
+ /**
233
+ * @param {{ blocks?: object[] }} doc
234
+ * @param {string} blockId
235
+ * @param {number} index
236
+ * @returns {{ blocks: object[] }}
237
+ */
238
+ export function removeControl(doc, blockId, index) {
239
+ return {
240
+ blocks: (doc?.blocks || []).map((b) => {
241
+ const copy = cloneBlock(b);
242
+ if (copy.id === blockId && (copy.type === "figure" || copy.type === "plot")) {
243
+ copy.controls = copy.controls.filter((_, i) => i !== index);
244
+ }
245
+ return copy;
246
+ }),
247
+ };
248
+ }
249
+
250
+ /**
251
+ * @param {{ blocks?: object[] }} doc
252
+ * @param {string} blockId
253
+ * @param {number} index
254
+ * @param {Record<string, string>} attrs
255
+ * @returns {{ blocks: object[] }}
256
+ */
257
+ export function updateShape(doc, blockId, index, attrs) {
258
+ return {
259
+ blocks: (doc?.blocks || []).map((b) => {
260
+ const copy = cloneBlock(b);
261
+ if (copy.id === blockId && copy.type === "figure" && copy.shapes && copy.shapes[index]) {
262
+ copy.shapes[index] = {
263
+ type: copy.shapes[index].type,
264
+ attrs: { ...copy.shapes[index].attrs, ...attrs },
265
+ };
266
+ }
267
+ return copy;
268
+ }),
269
+ };
270
+ }
271
+
272
+ /**
273
+ * @param {{ blocks?: object[] }} doc
274
+ * @param {string} blockId
275
+ * @param {number} index
276
+ * @returns {{ blocks: object[] }}
277
+ */
278
+ export function removeShape(doc, blockId, index) {
279
+ return {
280
+ blocks: (doc?.blocks || []).map((b) => {
281
+ const copy = cloneBlock(b);
282
+ if (copy.id === blockId && copy.type === "figure" && copy.shapes) {
283
+ copy.shapes = copy.shapes.filter((_, i) => i !== index);
284
+ }
285
+ return copy;
286
+ }),
287
+ };
288
+ }
289
+
290
+
291
+ /**
292
+ * @param {{ blocks?: object[] }} doc
293
+ * @param {string} blockId
294
+ * @param {"x"|"y"} which
295
+ * @param {object} patch
296
+ * @returns {{ blocks: object[] }}
297
+ */
298
+ export function updatePlotAxis(doc, blockId, which, patch) {
299
+ return {
300
+ blocks: (doc?.blocks || []).map((b) => {
301
+ const copy = cloneBlock(b);
302
+ if (copy.id === blockId && copy.type === "plot" && copy.plot) {
303
+ const axis = { ...(copy.plot[which] || {}), ...patch };
304
+ copy.plot = { ...copy.plot, [which]: axis };
305
+ }
306
+ return copy;
307
+ }),
308
+ };
309
+ }
310
+
311
+ /**
312
+ * @param {{ blocks?: object[] }} doc
313
+ * @param {string} blockId
314
+ * @param {number} index
315
+ * @param {object} patch
316
+ * @returns {{ blocks: object[] }}
317
+ */
318
+ export function updatePlotSeries(doc, blockId, index, patch) {
319
+ return {
320
+ blocks: (doc?.blocks || []).map((b) => {
321
+ const copy = cloneBlock(b);
322
+ if (copy.id === blockId && copy.type === "plot" && copy.plot?.series?.[index]) {
323
+ copy.plot.series[index] = { ...copy.plot.series[index], ...patch };
324
+ }
325
+ return copy;
326
+ }),
327
+ };
328
+ }
329
+
330
+ /**
331
+ * @param {{ blocks?: object[] }} doc
332
+ * @param {string} blockId
333
+ * @param {object} patch
334
+ * @returns {{ blocks: object[] }}
335
+ */
336
+ export function updatePlotMeta(doc, blockId, patch) {
337
+ return {
338
+ blocks: (doc?.blocks || []).map((b) => {
339
+ const copy = cloneBlock(b);
340
+ if (copy.id === blockId && copy.type === "plot" && copy.plot) {
341
+ copy.plot = { ...copy.plot, ...patch };
342
+ }
343
+ return copy;
344
+ }),
345
+ };
346
+ }
347
+
348
+ export { NAME_RE };
349
+
350
+ function emptyFigure() {
351
+ return {
352
+ id: nid(),
353
+ type: "figure",
354
+ namespace: null,
355
+ controls: [],
356
+ viewBox: "0 0 200 120",
357
+ shapes: [],
358
+ customSvg: null,
359
+ };
360
+ }
361
+
362
+ function emptyPlot() {
363
+ return {
364
+ id: nid(),
365
+ type: "plot",
366
+ namespace: null,
367
+ controls: [
368
+ { kind: "slider", name: "mu", range: [-5, 5], step: 0.1, default: 0, label: "μ (mean)" },
369
+ { kind: "slider", name: "sigma", range: [0.5, 4], step: 0.1, default: 1.5, label: "σ (std dev)" },
370
+ ],
371
+ plot: {
372
+ width: 480,
373
+ height: 320,
374
+ grid: true,
375
+ x: { min: -10, max: 10, step: 2, label: "x" },
376
+ y: { min: 0, max: 0.8, step: 0.2, label: "density" },
377
+ series: [
378
+ {
379
+ id: "density",
380
+ expression: "(1/(sigma*sqrt(2*pi))) * exp(-0.5*((x-mu)/sigma)^2)",
381
+ stroke: "#6d28d9",
382
+ fill: "#7c3aed",
383
+ fillOpacity: 0.25,
384
+ },
385
+ ],
386
+ },
387
+ };
388
+ }
389
+
390
+ /**
391
+ * @param {object | null | undefined} plot
392
+ */
393
+ function copyPlotModel(plot) {
394
+ const model = normalizePlot(plot || {});
395
+ return {
396
+ width: model.width,
397
+ height: model.height,
398
+ grid: model.grid,
399
+ samples: model.samples,
400
+ x: { ...model.x },
401
+ y: { ...model.y },
402
+ series: model.series.map((s) => ({ ...s })),
403
+ };
404
+ }
405
+
406
+ /**
407
+ * @param {object[]} blocks
408
+ * @param {string} [id]
409
+ */
410
+ function findFigure(blocks, id) {
411
+ if (!id) return null;
412
+ const block = blocks.find((b) => b.id === id && (b.type === "figure" || b.type === "plot"));
413
+ return block || null;
414
+ }
415
+
416
+ /**
417
+ * @param {object[]} blocks
418
+ * @param {string} prefix
419
+ */
420
+ function nextControlName(blocks, prefix) {
421
+ const used = new Set();
422
+ for (const block of blocks) {
423
+ if (block.type !== "figure" && block.type !== "plot") continue;
424
+ for (const control of block.controls || []) {
425
+ if (control.name) used.add(control.name);
426
+ }
427
+ }
428
+ let i = 1;
429
+ while (used.has(prefix + i)) i++;
430
+ return prefix + i;
431
+ }
432
+
433
+ /**
434
+ * @param {string} kind
435
+ * @param {string} name
436
+ */
437
+ function defaultControl(kind, name) {
438
+ if (kind === "slider") {
439
+ return { kind, name, range: [0, 100], step: 1, default: 50, label: "Slider" };
440
+ }
441
+ if (kind === "timer") {
442
+ return { kind, name, range: [0, 100], step: 1, default: 0, speed: 20, loop: true, label: "Timer" };
443
+ }
444
+ if (kind === "input") return { kind, name, default: "", label: "Text" };
445
+ if (kind === "toggle") return { kind, name, default: false, label: "Toggle" };
446
+ return { kind: "let", name, expression: "0" };
447
+ }
448
+
449
+ /**
450
+ * @param {string} type
451
+ * @returns {Shape}
452
+ */
453
+ function defaultShape(type) {
454
+ if (type === "circle") return { type, attrs: { cx: "40", cy: "40", r: "16", fill: "#38bdf8" } };
455
+ if (type === "ellipse") return { type, attrs: { cx: "40", cy: "40", rx: "24", ry: "14", fill: "#38bdf8" } };
456
+ if (type === "line") {
457
+ return { type, attrs: { x1: "8", y1: "40", x2: "120", y2: "40", stroke: "#38bdf8", "stroke-width": "2" } };
458
+ }
459
+ if (type === "path") {
460
+ return {
461
+ type,
462
+ attrs: { d: "M 8 40 Q 40 8 72 40", fill: "none", stroke: "#38bdf8", "stroke-width": "2" },
463
+ };
464
+ }
465
+ if (type === "text") {
466
+ return { type, attrs: { x: "8", y: "24", fill: "#e5e7eb", "font-size": "14", text: "Label" } };
467
+ }
468
+ return { type: "rect", attrs: { x: "8", y: "8", width: "80", height: "40", fill: "#1e293b" } };
469
+ }
470
+
471
+ /**
472
+ * @param {string} content
473
+ */
474
+ function parseProse(content) {
475
+ const lines = String(content).replace(/\r\n/g, "\n").split("\n");
476
+ /** @type {object[]} */
477
+ const blocks = [];
478
+ let i = 0;
479
+ while (i < lines.length) {
480
+ if (!lines[i].trim()) {
481
+ i++;
482
+ continue;
483
+ }
484
+ if (lines[i].startsWith("```")) {
485
+ const start = i;
486
+ i++;
487
+ while (i < lines.length && !lines[i].startsWith("```")) i++;
488
+ if (i < lines.length) i++;
489
+ blocks.push({ id: nid(), type: "markdown", text: lines.slice(start, i).join("\n") });
490
+ continue;
491
+ }
492
+ const heading = lines[i].match(/^(#{1,3})\s+(.*)$/);
493
+ if (heading) {
494
+ blocks.push({ id: nid(), type: "heading", level: heading[1].length, text: heading[2] });
495
+ i++;
496
+ continue;
497
+ }
498
+ if (/^\s*[-*]\s+/.test(lines[i])) {
499
+ const items = [];
500
+ while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
501
+ items.push(lines[i].replace(/^\s*[-*]\s+/, ""));
502
+ i++;
503
+ }
504
+ blocks.push({ id: nid(), type: "bullets", items });
505
+ continue;
506
+ }
507
+ const start = i;
508
+ i++;
509
+ while (
510
+ i < lines.length &&
511
+ lines[i].trim() &&
512
+ !lines[i].startsWith("```") &&
513
+ !/^(#{1,3})\s+/.test(lines[i]) &&
514
+ !/^\s*[-*]\s+/.test(lines[i])
515
+ ) {
516
+ i++;
517
+ }
518
+ blocks.push({ id: nid(), type: "paragraph", text: lines.slice(start, i).join("\n").trim() });
519
+ }
520
+ return blocks;
521
+ }
522
+
523
+ /**
524
+ * @param {string} body
525
+ */
526
+ function blockFromIvm(body) {
527
+ const parsed = parseIvm(body);
528
+ if (parsed.graph) {
529
+ return {
530
+ id: nid(),
531
+ type: "graph",
532
+ model: copyGraphModel(parsed.graph),
533
+ };
534
+ }
535
+ if (parsed.plot) {
536
+ return {
537
+ id: nid(),
538
+ type: "plot",
539
+ namespace: parsed.namespace,
540
+ controls: parsed.controls.map(cloneControl),
541
+ plot: copyPlotModel(parsed.plot),
542
+ };
543
+ }
544
+ const raw = parsed.templateSvg;
545
+ const template = wrapSlimSvg(raw, parsed.svgWidth, parsed.svgHeight);
546
+ const shapes = template ? tryParseShapes(template) : [];
547
+ const custom = shapes == null;
548
+ const viewBox =
549
+ (template && readViewBox(template)) ||
550
+ (parsed.svgWidth != null && parsed.svgHeight != null
551
+ ? `0 0 ${parsed.svgWidth} ${parsed.svgHeight}`
552
+ : "0 0 200 120");
553
+ return {
554
+ id: nid(),
555
+ type: "figure",
556
+ namespace: parsed.namespace,
557
+ controls: parsed.controls.map(cloneControl),
558
+ viewBox,
559
+ shapes: custom ? null : shapes,
560
+ customSvg: custom ? raw : null,
561
+ };
562
+ }
563
+
564
+ /**
565
+ * If @svg WxH left non-root markup, wrap it so shape parsing sees an <svg> root.
566
+ * @param {string|null} markup
567
+ * @param {number|null} width
568
+ * @param {number|null} height
569
+ */
570
+ function wrapSlimSvg(markup, width, height) {
571
+ if (markup == null) return null;
572
+ const src = String(markup);
573
+ const trimmed = src.trim();
574
+ if (!trimmed) return src;
575
+ if (/^<svg\b/i.test(trimmed)) return src;
576
+ if (width == null || height == null || !Number.isFinite(width) || !Number.isFinite(height)) {
577
+ return src;
578
+ }
579
+ return (
580
+ `<svg viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">\n` +
581
+ src.replace(/^\n+/, "").replace(/\n+$/, "\n") +
582
+ `</svg>`
583
+ );
584
+ }
585
+
586
+ /**
587
+ * @param {string} svg
588
+ * @returns {Shape[] | null}
589
+ */
590
+ function tryParseShapes(svg) {
591
+ const Parser = globalThis.DOMParser;
592
+ if (!Parser) return null;
593
+ let doc;
594
+ try {
595
+ doc = new Parser().parseFromString(svg, "image/svg+xml");
596
+ } catch (_) {
597
+ return null;
598
+ }
599
+ const root = doc.documentElement;
600
+ const rootName = String(root?.localName || root?.tagName || "").toLowerCase();
601
+ if (!root || rootName !== "svg") return null;
602
+ if (typeof doc.querySelector === "function" && doc.querySelector("parsererror")) return null;
603
+ /** @type {Shape[]} */
604
+ const shapes = [];
605
+ const walk = (el) => {
606
+ const tag = String(el.localName || el.tagName || "").toLowerCase();
607
+ if (!SIMPLE_TAGS.has(tag)) return false;
608
+ if (tag === "svg" || tag === "g") {
609
+ if (tag === "g" && el.attributes && el.attributes.length > 0) return false;
610
+ for (const child of el.children || []) {
611
+ if (!walk(child)) return false;
612
+ }
613
+ return true;
614
+ }
615
+ if (!SHAPE_TAGS.has(tag)) return false;
616
+ if (tag === "text" && el.children && el.children.length > 0) return false;
617
+ /** @type {Record<string, string>} */
618
+ const attrs = {};
619
+ if (el.attributes) {
620
+ for (const attr of el.attributes) attrs[attr.name] = attr.value;
621
+ }
622
+ if (tag === "text") attrs.text = String(el.textContent || "").trim();
623
+ shapes.push({ type: /** @type {ShapeType} */ (tag), attrs });
624
+ return true;
625
+ };
626
+ if (!walk(root)) return null;
627
+ return shapes;
628
+ }
629
+
630
+ /**
631
+ * @param {string} svg
632
+ */
633
+ function readViewBox(svg) {
634
+ const match = String(svg).match(/viewBox\s*=\s*"([^"]*)"/i) || String(svg).match(/viewBox\s*=\s*'([^']*)'/i);
635
+ return match ? match[1] : "";
636
+ }
637
+
638
+ /**
639
+ * @param {object} block
640
+ */
641
+ function serializeBlock(block) {
642
+ if (!block) return "";
643
+ if (block.type === "heading") {
644
+ const level = Math.min(3, Math.max(1, Number(block.level) || 1));
645
+ return `${"#".repeat(level)} ${block.text ?? ""}`;
646
+ }
647
+ if (block.type === "paragraph") return String(block.text ?? "");
648
+ if (block.type === "bullets") {
649
+ return (block.items || []).map((item) => `- ${item}`).join("\n");
650
+ }
651
+ if (block.type === "markdown") return String(block.text ?? "");
652
+ if (block.type === "graph" || block.type === "figure" || block.type === "plot") {
653
+ return "```ivm\n" + serializeFigure(block).replace(/\s*$/, "\n") + "```";
654
+ }
655
+ return "";
656
+ }
657
+
658
+ /**
659
+ * @param {object} block
660
+ */
661
+ function svgMarkup(block) {
662
+ if (block.customSvg) return String(block.customSvg).replace(/\s+$/, "");
663
+ if (!block.shapes || !block.shapes.length) return "";
664
+ const viewBox = block.viewBox || "0 0 200 120";
665
+ const body = block.shapes.map(serializeShape).join("\n ");
666
+ return `<svg viewBox="${escapeAttr(viewBox)}" width="100%" xmlns="http://www.w3.org/2000/svg">\n ${body}\n</svg>`;
667
+ }
668
+
669
+ /**
670
+ * @param {Shape} shape
671
+ */
672
+ function serializeShape(shape) {
673
+ const attrs = Object.entries(shape.attrs || {})
674
+ .filter(([key, value]) => key !== "text" && value !== "")
675
+ .map(([key, value]) => `${key}="${escapeAttr(value)}"`)
676
+ .join(" ");
677
+ if (shape.type === "text") {
678
+ return `<text${attrs ? " " + attrs : ""}>${escapeText(shape.attrs?.text ?? "")}</text>`;
679
+ }
680
+ return `<${shape.type}${attrs ? " " + attrs : ""}/>`;
681
+ }
682
+
683
+ /**
684
+ * @param {object} control
685
+ */
686
+ function serializeControl(control) {
687
+ if (!control || !control.name) return "";
688
+ if (control.kind === "let") return `@let ${control.name} = ${control.expression ?? "0"}`;
689
+ if (control.kind === "slider" || control.kind === "timer") {
690
+ const range = control.range || [0, 100];
691
+ let line = `@${control.kind} ${control.name}: ${fmtNum(range[0])}..${fmtNum(range[1])} = ${fmtNum(control.default)}`;
692
+ if (control.kind === "timer") line += ` speed=${fmtNum(control.speed ?? 20)}`;
693
+ line += ` step=${fmtNum(control.step ?? 1)}`;
694
+ if (control.kind === "timer") line += control.loop === false ? " noloop" : " loop";
695
+ if (control.label) line += " " + quoteGraphLabel(control.label);
696
+ return line;
697
+ }
698
+ if (control.kind === "input") {
699
+ let line = `@input ${control.name}: ${quoteGraphLabel(control.default ?? "")}`;
700
+ if (control.label) line += " " + quoteGraphLabel(control.label);
701
+ return line;
702
+ }
703
+ if (control.kind === "toggle") {
704
+ let line = `@toggle ${control.name}: ${control.default ? "true" : "false"}`;
705
+ if (control.label) line += " " + quoteGraphLabel(control.label);
706
+ return line;
707
+ }
708
+ return "";
709
+ }
710
+
711
+ /**
712
+ * @param {{ nodes?: object[], edges?: object[], animated?: boolean, motion?: object } | null | undefined} model
713
+ */
714
+ function copyGraphModel(model) {
715
+ const next = {
716
+ nodes: (model?.nodes || []).map((n) => ({ ...n })),
717
+ edges: (model?.edges || []).map((e) => ({ ...e })),
718
+ };
719
+ if (model?.animated) next.animated = true;
720
+ if (model?.motion) next.motion = { ...model.motion };
721
+ return next;
722
+ }
723
+
724
+ function fmtNum(n) {
725
+ const value = Number(n);
726
+ return Number.isFinite(value) ? String(value) : "0";
727
+ }
728
+
729
+ function escapeAttr(value) {
730
+ return String(value).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
731
+ }
732
+
733
+ function escapeText(value) {
734
+ return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;");
735
+ }
736
+
737
+ /**
738
+ * @param {object} control
739
+ */
740
+ function cloneControl(control) {
741
+ return {
742
+ ...control,
743
+ range: control.range ? [control.range[0], control.range[1]] : control.range,
744
+ };
745
+ }
746
+
747
+ /**
748
+ * @param {object} block
749
+ */
750
+ function cloneBlock(block) {
751
+ if (!block) return block;
752
+ if (block.type === "bullets") return { ...block, items: (block.items || []).slice() };
753
+ if (block.type === "graph") {
754
+ return { ...block, model: copyGraphModel(block.model) };
755
+ }
756
+ if (block.type === "plot") {
757
+ return {
758
+ ...block,
759
+ controls: (block.controls || []).map(cloneControl),
760
+ plot: copyPlotModel(block.plot),
761
+ };
762
+ }
763
+ if (block.type === "figure") {
764
+ return {
765
+ ...block,
766
+ controls: (block.controls || []).map(cloneControl),
767
+ shapes: block.shapes ? block.shapes.map((shape) => ({ type: shape.type, attrs: { ...shape.attrs } })) : null,
768
+ };
769
+ }
770
+ return { ...block };
771
+ }