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.
package/src/index.js CHANGED
@@ -6,3 +6,4 @@ export { parseIvm, splitIvmFences } from "./parse-ivm.js";
6
6
  export { sanitizeSvg } from "./sanitize-svg.js";
7
7
  export { mountIvm, resetNamespaces } from "./render.js";
8
8
  export { renderDocument } from "./markdown.js";
9
+ export { buildPlotSvg, normalizePlot } from "./plot.js";
package/src/markdown.js CHANGED
@@ -23,7 +23,7 @@ function resolveMarked(override) {
23
23
  * Render full Interactive Markdown into a preview element.
24
24
  * @param {HTMLElement} previewEl
25
25
  * @param {string} source
26
- * @param {{ marked?: { parse: (md: string) => string } }} [options]
26
+ * @param {{ marked?: { parse: (md: string) => string }, onGraphChange?: (fenceIndex: number, body: string) => void }} [options]
27
27
  * @returns {{ destroy: () => void }}
28
28
  */
29
29
  export function renderDocument(previewEl, source, options = {}) {
@@ -40,6 +40,7 @@ export function renderDocument(previewEl, source, options = {}) {
40
40
  marked.setOptions({ gfm: true, breaks: false });
41
41
  }
42
42
 
43
+ let ivmIndex = 0;
43
44
  for (const seg of segments) {
44
45
  if (seg.type === "text") {
45
46
  if (!seg.content.trim()) continue;
@@ -48,10 +49,17 @@ export function renderDocument(previewEl, source, options = {}) {
48
49
  wrap.innerHTML = marked.parse(seg.content);
49
50
  previewEl.appendChild(wrap);
50
51
  } else if (seg.type === "ivm") {
52
+ const fenceIndex = ivmIndex++;
51
53
  const host = doc.createElement("div");
52
54
  host.className = "im-ivm-host";
53
55
  previewEl.appendChild(host);
54
- mounts.push(mountIvm(host, seg.content));
56
+ mounts.push(
57
+ mountIvm(host, seg.content, {
58
+ onGraphChange(body) {
59
+ options.onGraphChange?.(fenceIndex, body);
60
+ },
61
+ })
62
+ );
55
63
  }
56
64
  }
57
65
 
package/src/parse-ivm.js CHANGED
@@ -2,16 +2,29 @@
2
2
  * Parse ```ivm fence body into controls + SVG template.
3
3
  * Interactive Markdown (IVM) dialect.
4
4
  *
5
- * Line forms:
5
+ * Line forms (long names are canonical; short aliases also parse):
6
6
  * @ns name
7
- * @slider name: min..max = default step=S "Label"
8
- * @timer name: min..max = default speed=N step=S loop|noloop "Label"
9
- * @input name: "default" "Label"
10
- * @toggle name: true|false "Label"
11
- * @let name = expression
12
- * @calc name = expression (alias of @let)
13
- * @svg
14
- * <svg>...</svg> (remainder of fence; use {{expr}} in attrs/text)
7
+ * @slider | @s name: min..max = default [step=S] [deg|rad] ["Label"]
8
+ * @timer | @t name: min..max = default [speed=N] [step=S] [loop|noloop] [deg|rad] ["Label"]
9
+ * @input | @i name: "default" ["Label"]
10
+ * @toggle | @b name: true|false ["Label"]
11
+ * @let | @l name = expression
12
+ * @calc name = expression (alias of @let; prefer @let in docs/generators)
13
+ * @svg [WxH]
14
+ * …markup… (remainder of fence; use {{expr}} in attrs/text)
15
+ * @plot [WxH] [grid|nogrid]
16
+ * @axis x|y: min..max [step=S] ["Label"]
17
+ * @series [id] [stroke=#hex] [fill=#hex] [fillOpacity=N] [width=N]
18
+ * y = expression (free var `x` + controls/@let)
19
+ * @grid on|off
20
+ * @graph
21
+ * @animate [path=ortho|straight|curve] [quantity=N] [direction=forward|reverse] [speed=seconds]
22
+ * @node id: "Label" x=N y=N [kind=action|input|output|decision]
23
+ * @edge from -> to [from=right|left|top|bottom] [to=left|right|top|bottom]
24
+ *
25
+ * Defaults: step=1, timer loop=true, speed=30. Omitted "Label" → UI shows name.
26
+ * `@svg 200x80` stores width/height so the renderer can wrap non-root markup.
27
+ * `@plot` builds a Cartesian chart (mutually exclusive with `@svg` / `@graph`).
15
28
  */
16
29
 
17
30
  /**
@@ -26,15 +39,121 @@
26
39
  * loop?: boolean,
27
40
  * label?: string,
28
41
  * expression?: string,
42
+ * units?: 'deg'|'rad',
29
43
  * }} Control
44
+ * @typedef {"left"|"right"|"top"|"bottom"} GraphSide
45
+ * @typedef {{ id: string, label: string, x: number, y: number, kind: string }} GraphNode
46
+ * @typedef {{ from: string, to: string, fromSide?: GraphSide, toSide?: GraphSide }} GraphEdge
47
+ * @typedef {{ path: string, quantity: number, direction: string, speed: number }} GraphMotion
48
+ * @typedef {{ nodes: GraphNode[], edges: GraphEdge[], animated?: boolean, motion?: GraphMotion }} GraphModel
49
+ * @typedef {{ min: number, max: number, step?: number, label?: string }} PlotAxis
50
+ * @typedef {{
51
+ * id?: string,
52
+ * expression: string,
53
+ * stroke?: string,
54
+ * fill?: string,
55
+ * fillOpacity?: number,
56
+ * width?: number,
57
+ * }} PlotSeries
58
+ * @typedef {{
59
+ * width: number|null,
60
+ * height: number|null,
61
+ * grid: boolean,
62
+ * x: PlotAxis|null,
63
+ * y: PlotAxis|null,
64
+ * series: PlotSeries[],
65
+ * }} PlotModel
30
66
  * @typedef {{
31
67
  * namespace: string|null,
32
68
  * controls: Control[],
33
69
  * templateSvg: string|null,
70
+ * svgWidth: number|null,
71
+ * svgHeight: number|null,
72
+ * graph: GraphModel|null,
73
+ * plot: PlotModel|null,
34
74
  * errors: string[],
35
75
  * }} IvmBlock
36
76
  */
37
77
 
78
+ const NODE_KINDS = new Set(["action", "input", "output", "decision"]);
79
+
80
+ const KIND_ALIASES = Object.freeze({
81
+ s: "slider",
82
+ slider: "slider",
83
+ t: "timer",
84
+ timer: "timer",
85
+ i: "input",
86
+ input: "input",
87
+ b: "toggle",
88
+ toggle: "toggle",
89
+ l: "let",
90
+ let: "let",
91
+ calc: "let",
92
+ });
93
+
94
+ /**
95
+ * @param {string} line
96
+ * @param {string[]} errors
97
+ * @returns {GraphMotion}
98
+ */
99
+ function readAnimate(line, errors) {
100
+ /** @type {GraphMotion} */
101
+ const motion = { path: "ortho", quantity: 1, direction: "forward", speed: 2.8 };
102
+ const rest = line.replace(/^@animate\b/i, "").trim();
103
+ if (!rest) return motion;
104
+ for (const part of rest.split(/\s+/)) {
105
+ const flag = part.match(/^(path|quantity|direction|speed)=(\S+)$/i);
106
+ if (!flag) {
107
+ errors.push(`@animate: unknown ${part}`);
108
+ continue;
109
+ }
110
+ const key = flag[1].toLowerCase();
111
+ const raw = flag[2];
112
+ const value = raw.toLowerCase();
113
+ if (key === "path") {
114
+ if (value !== "ortho" && value !== "straight" && value !== "curve") {
115
+ errors.push(`@animate: unknown path ${raw}`);
116
+ } else motion.path = value;
117
+ } else if (key === "quantity") {
118
+ const n = Number(value);
119
+ if (!Number.isInteger(n) || n < 1 || n > 12) errors.push("@animate: quantity must be 1..12");
120
+ else motion.quantity = n;
121
+ } else if (key === "direction") {
122
+ if (value !== "forward" && value !== "reverse") errors.push(`@animate: unknown direction ${raw}`);
123
+ else motion.direction = value;
124
+ } else if (key === "speed") {
125
+ const n = Number(raw);
126
+ if (!Number.isFinite(n) || n < 0.2 || n > 30) errors.push("@animate: speed must be seconds from 0.2 to 30");
127
+ else motion.speed = Math.round(n * 10) / 10;
128
+ }
129
+ }
130
+ return motion;
131
+ }
132
+
133
+ /**
134
+ * Read a leading "quoted" or 'quoted' string. Supports backslash escapes.
135
+ * @param {string} s
136
+ * @returns {{ value: string, rest: string } | null}
137
+ */
138
+ function parseQuotedPrefix(s) {
139
+ const str = String(s).trimStart();
140
+ const quote = str[0];
141
+ if (quote !== '"' && quote !== "'") return null;
142
+ let value = "";
143
+ let i = 1;
144
+ while (i < str.length) {
145
+ if (str[i] === "\\" && i + 1 < str.length) {
146
+ value += str[i + 1];
147
+ i += 2;
148
+ continue;
149
+ }
150
+ if (str[i] === quote) return { value, rest: str.slice(i + 1).trim() };
151
+ value += str[i];
152
+ i++;
153
+ }
154
+ return null;
155
+ }
156
+
38
157
  function unquote(s) {
39
158
  if (s == null) return s;
40
159
  const t = String(s).trim();
@@ -60,6 +179,8 @@ function parseFlags(rest) {
60
179
  }
61
180
  if (/\bnoloop\b/i.test(rest)) out.loop = "false";
62
181
  else if (/\bloop\b/i.test(rest) && out.loop === undefined) out.loop = "true";
182
+ if (/\bdeg\b/i.test(rest)) out.units = "deg";
183
+ else if (/\brad\b/i.test(rest)) out.units = "rad";
63
184
  return out;
64
185
  }
65
186
 
@@ -81,6 +202,14 @@ export function parseIvm(body) {
81
202
  const controls = [];
82
203
  let namespace = null;
83
204
  let templateSvg = null;
205
+ /** @type {number|null} */
206
+ let svgWidth = null;
207
+ /** @type {number|null} */
208
+ let svgHeight = null;
209
+ /** @type {GraphModel | null} */
210
+ let graph = null;
211
+ /** @type {PlotModel | null} */
212
+ let plot = null;
84
213
 
85
214
  const lines = String(body).replace(/\r\n/g, "\n").split("\n");
86
215
  let i = 0;
@@ -93,11 +222,304 @@ export function parseIvm(body) {
93
222
  continue;
94
223
  }
95
224
 
96
- // @svg — remainder of fence is SVG markup
225
+ // @plot [WxH] [grid|nogrid]
226
+ if (/^@plot\b/i.test(line)) {
227
+ if (plot) {
228
+ errors.push("Duplicate @plot");
229
+ i++;
230
+ continue;
231
+ }
232
+ if (graph) {
233
+ errors.push("@plot cannot mix with @graph");
234
+ i++;
235
+ continue;
236
+ }
237
+ if (templateSvg) {
238
+ errors.push("@plot cannot mix with @svg");
239
+ i++;
240
+ continue;
241
+ }
242
+ plot = { width: null, height: null, grid: true, x: null, y: null, series: [] };
243
+ const rest = line.replace(/^@plot\b/i, "").trim();
244
+ if (rest) {
245
+ const size = rest.match(/^(\d+(?:\.\d+)?)\s*[xX×]\s*(\d+(?:\.\d+)?)\b/);
246
+ let flags = rest;
247
+ if (size) {
248
+ plot.width = Number(size[1]);
249
+ plot.height = Number(size[2]);
250
+ flags = rest.slice(size[0].length).trim();
251
+ }
252
+ if (/\bnogrid\b/i.test(flags)) plot.grid = false;
253
+ else if (/\bgrid\b/i.test(flags)) plot.grid = true;
254
+ const leftover = flags.replace(/\b(nogrid|grid)\b/gi, "").trim();
255
+ if (leftover) errors.push(`@plot: unknown ${leftover}`);
256
+ }
257
+ i++;
258
+ continue;
259
+ }
260
+
261
+ // @grid on|off
262
+ if (/^@grid\b/i.test(line)) {
263
+ if (!plot) {
264
+ errors.push("@grid is only valid after @plot");
265
+ i++;
266
+ continue;
267
+ }
268
+ const rest = line.replace(/^@grid\b/i, "").trim().toLowerCase();
269
+ if (rest === "off" || rest === "false" || rest === "0" || rest === "nogrid") plot.grid = false;
270
+ else if (rest === "on" || rest === "true" || rest === "1" || rest === "grid" || rest === "") plot.grid = true;
271
+ else errors.push(`@grid: expected on|off, got ${rest}`);
272
+ i++;
273
+ continue;
274
+ }
275
+
276
+ // @axis x|y: min..max [step=S] ["Label"]
277
+ {
278
+ const m = line.match(/^@axis\s+([xy])\s*:\s*(.+)$/i);
279
+ if (m) {
280
+ if (!plot) {
281
+ errors.push(`@axis ${m[1]}: only valid after @plot`);
282
+ i++;
283
+ continue;
284
+ }
285
+ const which = m[1].toLowerCase();
286
+ const [rest, label] = takeLabel(m[2]);
287
+ const rm = rest.match(
288
+ /^(-?[0-9]*\.?[0-9]+)\.\.(-?[0-9]*\.?[0-9]+)\s*(.*)$/
289
+ );
290
+ if (!rm) {
291
+ errors.push(`@axis ${which}: expected min..max [step=S]`);
292
+ i++;
293
+ continue;
294
+ }
295
+ const min = Number(rm[1]);
296
+ const max = Number(rm[2]);
297
+ const flags = parseFlags(rm[3] || "");
298
+ const step = flags.step !== undefined ? Number(flags.step) : undefined;
299
+ /** @type {PlotAxis} */
300
+ const axis = {
301
+ min: Number.isFinite(min) ? min : 0,
302
+ max: Number.isFinite(max) ? max : 1,
303
+ };
304
+ if (step !== undefined && Number.isFinite(step) && step > 0) axis.step = step;
305
+ if (label || flags.label) axis.label = label || flags.label;
306
+ if (which === "x") {
307
+ if (plot.x) errors.push("Duplicate @axis x");
308
+ plot.x = axis;
309
+ } else {
310
+ if (plot.y) errors.push("Duplicate @axis y");
311
+ plot.y = axis;
312
+ }
313
+ i++;
314
+ continue;
315
+ }
316
+ }
317
+
318
+ // @series [id] [flags] [y = expr] — expression may continue on the next indented line
319
+ if (/^@series\b/i.test(line)) {
320
+ if (!plot) {
321
+ errors.push("@series is only valid after @plot");
322
+ i++;
323
+ continue;
324
+ }
325
+ const rest = line.replace(/^@series\b/i, "").trim();
326
+ /** @type {PlotSeries} */
327
+ const series = { expression: "" };
328
+ // Optional leading id (not a key=value and not y=)
329
+ let tail = rest;
330
+ const idMatch = tail.match(/^([A-Za-z_][A-Za-z0-9_]*)\b/);
331
+ if (idMatch && !/^(stroke|fill|fillOpacity|width|y)$/i.test(idMatch[1])) {
332
+ series.id = idMatch[1];
333
+ tail = tail.slice(idMatch[0].length).trim();
334
+ }
335
+ // Inline y = expr?
336
+ const yInline = tail.match(/\by\s*=\s*(.+)$/i);
337
+ if (yInline) {
338
+ series.expression = yInline[1].trim();
339
+ tail = tail.slice(0, yInline.index).trim();
340
+ }
341
+ const flags = parseFlags(tail);
342
+ if (flags.stroke) series.stroke = flags.stroke;
343
+ if (flags.fill) series.fill = flags.fill;
344
+ if (flags.fillOpacity !== undefined) {
345
+ const n = Number(flags.fillOpacity);
346
+ if (Number.isFinite(n)) series.fillOpacity = n;
347
+ }
348
+ if (flags.width !== undefined) {
349
+ const n = Number(flags.width);
350
+ if (Number.isFinite(n)) series.width = n;
351
+ }
352
+ // Continuation: next non-empty line starting with y = or indented
353
+ if (!series.expression) {
354
+ let j = i + 1;
355
+ while (j < lines.length) {
356
+ const next = lines[j];
357
+ if (!next.trim() || next.trim().startsWith("#")) {
358
+ j++;
359
+ continue;
360
+ }
361
+ const cont = next.match(/^\s+y\s*=\s*(.+)$/i) || next.match(/^y\s*=\s*(.+)$/i);
362
+ if (cont) {
363
+ series.expression = cont[1].trim();
364
+ i = j;
365
+ }
366
+ break;
367
+ }
368
+ }
369
+ if (!series.expression) errors.push("@series: expected y = expression");
370
+ else plot.series.push(series);
371
+ i++;
372
+ continue;
373
+ }
374
+
375
+ // @graph — following @node / @edge lines are an editable graph
376
+ if (/^@graph$/i.test(line)) {
377
+ if (plot) errors.push("@graph cannot mix with @plot");
378
+ else if (graph) errors.push("Duplicate @graph");
379
+ else graph = { nodes: [], edges: [] };
380
+ i++;
381
+ continue;
382
+ }
383
+ if (/^@graph\b/i.test(line)) {
384
+ errors.push("@graph takes no arguments");
385
+ i++;
386
+ continue;
387
+ }
388
+
389
+ // @node id: "Label" x=N y=N kind=action
390
+ {
391
+ const m = line.match(/^@node\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([\s\S]+)$/i);
392
+ if (m) {
393
+ const id = m[1];
394
+ if (!graph) {
395
+ errors.push(`@node ${id}: only valid after @graph`);
396
+ i++;
397
+ continue;
398
+ }
399
+ const quoted = parseQuotedPrefix(m[2]);
400
+ if (!quoted) {
401
+ errors.push(`@node ${id}: expected "Label" x=N y=N`);
402
+ i++;
403
+ continue;
404
+ }
405
+ if (graph.nodes.some((n) => n.id === id)) {
406
+ errors.push(`@node ${id}: duplicate id`);
407
+ i++;
408
+ continue;
409
+ }
410
+ const flags = parseFlags(quoted.rest);
411
+ const x = flags.x !== undefined ? Number(flags.x) : 48;
412
+ const y = flags.y !== undefined ? Number(flags.y) : 48;
413
+ const rawKind = flags.kind !== undefined ? String(flags.kind).toLowerCase() : "action";
414
+ if (!NODE_KINDS.has(rawKind)) {
415
+ errors.push(`@node ${id}: unknown kind ${flags.kind}`);
416
+ }
417
+ graph.nodes.push({
418
+ id,
419
+ label: quoted.value,
420
+ x: Number.isFinite(x) ? x : 48,
421
+ y: Number.isFinite(y) ? y : 48,
422
+ kind: NODE_KINDS.has(rawKind) ? rawKind : "action",
423
+ });
424
+ i++;
425
+ continue;
426
+ }
427
+ }
428
+
429
+ // @edge from -> to [from=side] [to=side]
430
+ {
431
+ const m = line.match(
432
+ /^@edge\s+([A-Za-z_][A-Za-z0-9_]*)\s*->\s*([A-Za-z_][A-Za-z0-9_]*)(?:\s+([^\s].*))?$/i
433
+ );
434
+ if (m) {
435
+ const from = m[1];
436
+ const to = m[2];
437
+ if (!graph) {
438
+ errors.push(`@edge ${from} -> ${to}: only valid after @graph`);
439
+ i++;
440
+ continue;
441
+ }
442
+ if (from === to) {
443
+ errors.push(`@edge ${from} -> ${to}: a node cannot connect to itself`);
444
+ i++;
445
+ continue;
446
+ }
447
+ if (!graph.nodes.some((n) => n.id === from) || !graph.nodes.some((n) => n.id === to)) {
448
+ errors.push(`@edge ${from} -> ${to}: unknown node`);
449
+ i++;
450
+ continue;
451
+ }
452
+ if (graph.edges.some((e) => e.from === from && e.to === to)) {
453
+ errors.push(`@edge ${from} -> ${to}: duplicate edge`);
454
+ i++;
455
+ continue;
456
+ }
457
+ /** @type {GraphEdge} */
458
+ const edge = { from, to };
459
+ const tail = (m[3] || "").trim();
460
+ let sideError = false;
461
+ if (tail) {
462
+ for (const part of tail.split(/\s+/)) {
463
+ const flag = part.match(/^(from|to)=(left|right|top|bottom)$/i);
464
+ if (!flag) {
465
+ errors.push(`@edge ${from} -> ${to}: unknown ${part}`);
466
+ sideError = true;
467
+ continue;
468
+ }
469
+ const side = /** @type {GraphSide} */ (flag[2].toLowerCase());
470
+ if (flag[1].toLowerCase() === "from") edge.fromSide = side;
471
+ else edge.toSide = side;
472
+ }
473
+ }
474
+ if (edge.fromSide === "right") delete edge.fromSide;
475
+ if (edge.toSide === "left") delete edge.toSide;
476
+ if (!sideError) graph.edges.push(edge);
477
+ i++;
478
+ continue;
479
+ }
480
+ }
481
+
482
+ if (/^@animate\b/i.test(line)) {
483
+ if (!graph) errors.push("@animate is only valid after @graph");
484
+ else if (graph.animated) errors.push("Duplicate @animate");
485
+ else {
486
+ graph.animated = true;
487
+ graph.motion = readAnimate(line, errors);
488
+ }
489
+ i++;
490
+ continue;
491
+ }
492
+
493
+ if (graph) {
494
+ errors.push(`Not allowed inside @graph: ${line}`);
495
+ i++;
496
+ continue;
497
+ }
498
+
499
+ // @svg [WxH] — remainder of fence is SVG markup
97
500
  if (/^@svg\b/i.test(line)) {
98
- const after = line.replace(/^@svg\b/i, "");
501
+ if (plot) {
502
+ errors.push("@svg cannot mix with @plot");
503
+ i++;
504
+ continue;
505
+ }
506
+ const after = line.replace(/^@svg\b/i, "").trim();
507
+ const size = after.match(/^(\d+(?:\.\d+)?)\s*[xX×]\s*(\d+(?:\.\d+)?)\s*$/);
508
+ if (size) {
509
+ svgWidth = Number(size[1]);
510
+ svgHeight = Number(size[2]);
511
+ } else if (after) {
512
+ // Inline markup on the same line (legacy): treat as template start
513
+ const parts = [after];
514
+ i++;
515
+ while (i < lines.length) {
516
+ parts.push(lines[i]);
517
+ i++;
518
+ }
519
+ templateSvg = parts.join("\n").replace(/^\n+/, "").replace(/\n+$/, "\n");
520
+ break;
521
+ }
99
522
  const parts = [];
100
- if (after.trim()) parts.push(after.trim());
101
523
  i++;
102
524
  while (i < lines.length) {
103
525
  parts.push(lines[i]);
@@ -117,27 +539,28 @@ export function parseIvm(body) {
117
539
  }
118
540
  }
119
541
 
120
- // @let / @calc name = expression
542
+ // @let / @l / @calc name = expression
121
543
  {
122
- const m = line.match(/^@(?:let|calc)\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$/i);
544
+ const m = line.match(/^@(let|l|calc)\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$/i);
123
545
  if (m) {
124
- controls.push({ kind: "let", name: m[1], expression: m[2].trim() });
546
+ controls.push({ kind: "let", name: m[2], expression: m[3].trim() });
125
547
  i++;
126
548
  continue;
127
549
  }
128
550
  }
129
551
 
130
- // @slider name: min..max = default step=S "Label"
552
+ // @slider|@s|@timer|@t name: min..max = default …
131
553
  {
132
- const m = line.match(/^@slider\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+)$/i);
554
+ const m = line.match(/^@(slider|s|timer|t)\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+)$/i);
133
555
  if (m) {
134
- const name = m[1];
135
- const [rest, label] = takeLabel(m[2]);
556
+ const kind = KIND_ALIASES[m[1].toLowerCase()];
557
+ const name = m[2];
558
+ const [rest, label] = takeLabel(m[3]);
136
559
  const rm = rest.match(
137
560
  /^(-?[0-9]*\.?[0-9]+)\.\.(-?[0-9]*\.?[0-9]+)\s*(?:=\s*(-?[0-9]*\.?[0-9]+))?\s*(.*)$/
138
561
  );
139
562
  if (!rm) {
140
- errors.push(`@slider ${name}: expected min..max [= default]`);
563
+ errors.push(`@${kind} ${name}: expected min..max [= default]`);
141
564
  i++;
142
565
  continue;
143
566
  }
@@ -145,77 +568,57 @@ export function parseIvm(body) {
145
568
  const def = rm[3] !== undefined ? Number(rm[3]) : range[0];
146
569
  const flags = parseFlags(rm[4] || "");
147
570
  const step = flags.step !== undefined ? Number(flags.step) : 1;
148
- controls.push({
149
- kind: "slider",
571
+ /** @type {Control} */
572
+ const ctrl = {
573
+ kind,
150
574
  name,
151
575
  range,
152
576
  step: Number.isFinite(step) ? step : 1,
153
577
  default: Number.isFinite(def) ? def : range[0],
154
- label: label || flags.label,
155
- });
578
+ label: label || flags.label || undefined,
579
+ };
580
+ if (flags.units === "deg" || flags.units === "rad") {
581
+ ctrl.units = /** @type {'deg'|'rad'} */ (flags.units);
582
+ }
583
+ if (kind === "timer") {
584
+ const speed = flags.speed !== undefined ? Number(flags.speed) : 30;
585
+ ctrl.speed = Number.isFinite(speed) && speed > 0 ? speed : 30;
586
+ ctrl.loop = flags.loop === undefined ? true : parseBool(flags.loop, true);
587
+ }
588
+ controls.push(ctrl);
156
589
  i++;
157
590
  continue;
158
591
  }
159
592
  }
160
593
 
161
- // @timer name: min..max = default speed=N step=S loop|noloop "Label"
594
+ // @input|@i name: "default" "Label"
162
595
  {
163
- const m = line.match(/^@timer\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+)$/i);
596
+ const m = line.match(/^@(input|i)\s+([A-Za-z_][A-Za-z0-9_]*)\s*[:=]\s*(.+)$/i);
164
597
  if (m) {
165
- const name = m[1];
166
- const [rest, label] = takeLabel(m[2]);
167
- const rm = rest.match(
168
- /^(-?[0-9]*\.?[0-9]+)\.\.(-?[0-9]*\.?[0-9]+)\s*(?:=\s*(-?[0-9]*\.?[0-9]+))?\s*(.*)$/
169
- );
170
- if (!rm) {
171
- errors.push(`@timer ${name}: expected min..max [= default]`);
172
- i++;
173
- continue;
174
- }
175
- const range = /** @type {[number, number]} */ ([Number(rm[1]), Number(rm[2])]);
176
- const def = rm[3] !== undefined ? Number(rm[3]) : range[0];
177
- const flags = parseFlags(rm[4] || "");
178
- const step = flags.step !== undefined ? Number(flags.step) : 1;
179
- const speed = flags.speed !== undefined ? Number(flags.speed) : 30;
180
- const loop = flags.loop === undefined ? true : parseBool(flags.loop, true);
598
+ const name = m[2];
599
+ const [rest, label] = takeLabel(m[3]);
181
600
  controls.push({
182
- kind: "timer",
601
+ kind: "input",
183
602
  name,
184
- range,
185
- step: Number.isFinite(step) ? step : 1,
186
- default: Number.isFinite(def) ? def : range[0],
187
- speed: Number.isFinite(speed) && speed > 0 ? speed : 30,
188
- loop,
189
- label: label || flags.label,
603
+ default: unquote(rest) ?? "",
604
+ label: label || undefined,
190
605
  });
191
606
  i++;
192
607
  continue;
193
608
  }
194
609
  }
195
610
 
196
- // @input name: "default" "Label"
611
+ // @toggle|@b name: true|false "Label"
197
612
  {
198
- const m = line.match(/^@input\s+([A-Za-z_][A-Za-z0-9_]*)\s*[:=]\s*(.+)$/i);
613
+ const m = line.match(/^@(toggle|b)\s+([A-Za-z_][A-Za-z0-9_]*)\s*[:=]\s*(.+)$/i);
199
614
  if (m) {
200
- const name = m[1];
201
- const [rest, label] = takeLabel(m[2]);
202
- controls.push({ kind: "input", name, default: unquote(rest) ?? "", label });
203
- i++;
204
- continue;
205
- }
206
- }
207
-
208
- // @toggle name: true|false "Label"
209
- {
210
- const m = line.match(/^@toggle\s+([A-Za-z_][A-Za-z0-9_]*)\s*[:=]\s*(.+)$/i);
211
- if (m) {
212
- const name = m[1];
213
- const [rest, label] = takeLabel(m[2]);
615
+ const name = m[2];
616
+ const [rest, label] = takeLabel(m[3]);
214
617
  controls.push({
215
618
  kind: "toggle",
216
619
  name,
217
620
  default: parseBool(rest, false),
218
- label,
621
+ label: label || undefined,
219
622
  });
220
623
  i++;
221
624
  continue;
@@ -226,7 +629,25 @@ export function parseIvm(body) {
226
629
  i++;
227
630
  }
228
631
 
229
- return { namespace, controls, templateSvg, errors };
632
+ return { namespace, controls, templateSvg, svgWidth, svgHeight, graph, plot, errors };
633
+ }
634
+
635
+ /**
636
+ * Replace the Nth ```ivm fence (0-based) with a new interior.
637
+ * Indexes are computed from the current source, so earlier edits stay consistent.
638
+ * @param {string} source
639
+ * @param {number} fenceIndex
640
+ * @param {string} body
641
+ * @returns {string}
642
+ */
643
+ export function replaceIvmFence(source, fenceIndex, body) {
644
+ const src = String(source).replace(/\r\n/g, "\n");
645
+ const ivm = splitIvmFences(src).filter((s) => s.type === "ivm");
646
+ const seg = ivm[fenceIndex];
647
+ if (!seg) return src;
648
+ const interior = String(body).replace(/\s*$/, "\n");
649
+ const next = "```ivm\n" + interior + "```";
650
+ return src.slice(0, seg.start) + next + src.slice(seg.end);
230
651
  }
231
652
 
232
653
  /**