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/LICENSE +1 -1
- package/README.md +122 -26
- package/examples/agent-loop.md +26 -0
- package/examples/amplitude.md +11 -14
- package/examples/circular-motion.md +10 -14
- package/examples/graph.md +18 -0
- package/examples/normal-density.md +19 -0
- package/index.html +8 -1
- package/package.json +13 -5
- package/src/app.js +85 -8
- package/src/document-editor.js +1062 -0
- package/src/document.js +771 -0
- package/src/expr.js +6 -0
- package/src/graph-editor.js +1117 -0
- package/src/index.js +1 -0
- package/src/markdown.js +10 -2
- package/src/parse-ivm.js +488 -67
- package/src/plot.js +401 -0
- package/src/render.js +116 -6
- package/src/sanitize-svg.js +240 -9
- package/styles/app.css +659 -1
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(
|
|
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
|
|
13
|
-
* @svg
|
|
14
|
-
*
|
|
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
|
-
// @
|
|
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
|
-
|
|
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(/^@(
|
|
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[
|
|
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
|
|
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
|
|
135
|
-
const
|
|
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(
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
// @
|
|
594
|
+
// @input|@i name: "default" "Label"
|
|
162
595
|
{
|
|
163
|
-
const m = line.match(/^@
|
|
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[
|
|
166
|
-
const [rest, label] = takeLabel(m[
|
|
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: "
|
|
601
|
+
kind: "input",
|
|
183
602
|
name,
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
// @
|
|
611
|
+
// @toggle|@b name: true|false "Label"
|
|
197
612
|
{
|
|
198
|
-
const m = line.match(/^@
|
|
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[
|
|
201
|
-
const [rest, label] = takeLabel(m[
|
|
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
|
/**
|