interactive-markdown 0.0.0-stage → 0.1.1

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,269 @@
1
+ /**
2
+ * Parse ```ivm fence body into controls + SVG template.
3
+ * Interactive Markdown (IVM) dialect.
4
+ *
5
+ * Line forms:
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)
15
+ */
16
+
17
+ /**
18
+ * @typedef {'slider'|'timer'|'input'|'toggle'|'let'} ControlKind
19
+ * @typedef {{
20
+ * kind: ControlKind,
21
+ * name?: string,
22
+ * range?: [number, number],
23
+ * step?: number,
24
+ * default?: unknown,
25
+ * speed?: number,
26
+ * loop?: boolean,
27
+ * label?: string,
28
+ * expression?: string,
29
+ * }} Control
30
+ * @typedef {{
31
+ * namespace: string|null,
32
+ * controls: Control[],
33
+ * templateSvg: string|null,
34
+ * errors: string[],
35
+ * }} IvmBlock
36
+ */
37
+
38
+ function unquote(s) {
39
+ if (s == null) return s;
40
+ const t = String(s).trim();
41
+ const m = t.match(/^"(.*)"$/) || t.match(/^'(.*)'$/);
42
+ return m ? m[1] : t;
43
+ }
44
+
45
+ /** @returns {[string, string|undefined]} */
46
+ function takeLabel(rest) {
47
+ const m = rest.match(/\s+"([^"]*)"\s*$/) || rest.match(/\s+'([^']*)'\s*$/);
48
+ if (!m) return [rest.trim(), undefined];
49
+ return [rest.slice(0, m.index).trim(), m[1]];
50
+ }
51
+
52
+ /** @param {string} rest */
53
+ function parseFlags(rest) {
54
+ /** @type {Record<string, string>} */
55
+ const out = {};
56
+ const re = /([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+))/g;
57
+ let m;
58
+ while ((m = re.exec(rest))) {
59
+ out[m[1]] = m[2] !== undefined ? m[2] : m[3] !== undefined ? m[3] : m[4];
60
+ }
61
+ if (/\bnoloop\b/i.test(rest)) out.loop = "false";
62
+ else if (/\bloop\b/i.test(rest) && out.loop === undefined) out.loop = "true";
63
+ return out;
64
+ }
65
+
66
+ function parseBool(raw, fallback = false) {
67
+ if (raw === undefined || raw === null || raw === "") return fallback;
68
+ const s = String(raw).trim().toLowerCase();
69
+ if (s === "true" || s === "yes" || s === "1") return true;
70
+ if (s === "false" || s === "no" || s === "0") return false;
71
+ return fallback;
72
+ }
73
+
74
+ /**
75
+ * @param {string} body fence interior (no ```ivm wrapper)
76
+ * @returns {IvmBlock}
77
+ */
78
+ export function parseIvm(body) {
79
+ const errors = [];
80
+ /** @type {Control[]} */
81
+ const controls = [];
82
+ let namespace = null;
83
+ let templateSvg = null;
84
+
85
+ const lines = String(body).replace(/\r\n/g, "\n").split("\n");
86
+ let i = 0;
87
+
88
+ while (i < lines.length) {
89
+ const line = lines[i].trim();
90
+
91
+ if (!line || line.startsWith("#")) {
92
+ i++;
93
+ continue;
94
+ }
95
+
96
+ // @svg — remainder of fence is SVG markup
97
+ if (/^@svg\b/i.test(line)) {
98
+ const after = line.replace(/^@svg\b/i, "");
99
+ const parts = [];
100
+ if (after.trim()) parts.push(after.trim());
101
+ i++;
102
+ while (i < lines.length) {
103
+ parts.push(lines[i]);
104
+ i++;
105
+ }
106
+ templateSvg = parts.join("\n").replace(/^\n+/, "").replace(/\n+$/, "\n");
107
+ break;
108
+ }
109
+
110
+ // @ns NAME
111
+ {
112
+ const m = line.match(/^@ns\s+([A-Za-z_][A-Za-z0-9_]*)$/i);
113
+ if (m) {
114
+ namespace = m[1];
115
+ i++;
116
+ continue;
117
+ }
118
+ }
119
+
120
+ // @let / @calc name = expression
121
+ {
122
+ const m = line.match(/^@(?:let|calc)\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$/i);
123
+ if (m) {
124
+ controls.push({ kind: "let", name: m[1], expression: m[2].trim() });
125
+ i++;
126
+ continue;
127
+ }
128
+ }
129
+
130
+ // @slider name: min..max = default step=S "Label"
131
+ {
132
+ const m = line.match(/^@slider\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+)$/i);
133
+ if (m) {
134
+ const name = m[1];
135
+ const [rest, label] = takeLabel(m[2]);
136
+ const rm = rest.match(
137
+ /^(-?[0-9]*\.?[0-9]+)\.\.(-?[0-9]*\.?[0-9]+)\s*(?:=\s*(-?[0-9]*\.?[0-9]+))?\s*(.*)$/
138
+ );
139
+ if (!rm) {
140
+ errors.push(`@slider ${name}: expected min..max [= default]`);
141
+ i++;
142
+ continue;
143
+ }
144
+ const range = /** @type {[number, number]} */ ([Number(rm[1]), Number(rm[2])]);
145
+ const def = rm[3] !== undefined ? Number(rm[3]) : range[0];
146
+ const flags = parseFlags(rm[4] || "");
147
+ const step = flags.step !== undefined ? Number(flags.step) : 1;
148
+ controls.push({
149
+ kind: "slider",
150
+ name,
151
+ range,
152
+ step: Number.isFinite(step) ? step : 1,
153
+ default: Number.isFinite(def) ? def : range[0],
154
+ label: label || flags.label,
155
+ });
156
+ i++;
157
+ continue;
158
+ }
159
+ }
160
+
161
+ // @timer name: min..max = default speed=N step=S loop|noloop "Label"
162
+ {
163
+ const m = line.match(/^@timer\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+)$/i);
164
+ 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);
181
+ controls.push({
182
+ kind: "timer",
183
+ 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,
190
+ });
191
+ i++;
192
+ continue;
193
+ }
194
+ }
195
+
196
+ // @input name: "default" "Label"
197
+ {
198
+ const m = line.match(/^@input\s+([A-Za-z_][A-Za-z0-9_]*)\s*[:=]\s*(.+)$/i);
199
+ 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]);
214
+ controls.push({
215
+ kind: "toggle",
216
+ name,
217
+ default: parseBool(rest, false),
218
+ label,
219
+ });
220
+ i++;
221
+ continue;
222
+ }
223
+ }
224
+
225
+ errors.push(`Unrecognized line: ${line}`);
226
+ i++;
227
+ }
228
+
229
+ return { namespace, controls, templateSvg, errors };
230
+ }
231
+
232
+ /**
233
+ * Split markdown into text and ```ivm fence segments.
234
+ * @param {string} markdown
235
+ * @returns {{ type: 'text'|'ivm', content: string, start: number, end: number }[]}
236
+ */
237
+ export function splitIvmFences(markdown) {
238
+ const src = String(markdown).replace(/\r\n/g, "\n");
239
+ /** @type {{ type: 'text'|'ivm', content: string, start: number, end: number }[]} */
240
+ const segments = [];
241
+ const re = /^```ivm[ \t]*\n([\s\S]*?)^```[ \t]*$/gm;
242
+ let last = 0;
243
+ let m;
244
+ while ((m = re.exec(src))) {
245
+ if (m.index > last) {
246
+ const content = src.slice(last, m.index);
247
+ if (content.trim().length) {
248
+ segments.push({ type: "text", content, start: last, end: m.index });
249
+ }
250
+ }
251
+ segments.push({
252
+ type: "ivm",
253
+ content: m[1],
254
+ start: m.index,
255
+ end: m.index + m[0].length,
256
+ });
257
+ last = m.index + m[0].length;
258
+ }
259
+ if (last < src.length) {
260
+ const content = src.slice(last);
261
+ if (content.trim().length) {
262
+ segments.push({ type: "text", content, start: last, end: src.length });
263
+ }
264
+ }
265
+ if (segments.length === 0) {
266
+ segments.push({ type: "text", content: src, start: 0, end: src.length });
267
+ }
268
+ return segments;
269
+ }
package/src/render.js ADDED
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Mount IVM controls and bind SVG templates to live state.
3
+ */
4
+
5
+ import { evaluate, substitute } from "./expr.js";
6
+ import { parseIvm } from "./parse-ivm.js";
7
+ import { sanitizeSvg } from "./sanitize-svg.js";
8
+
9
+ /** @type {Map<string, Record<string, unknown>>} */
10
+ const namespaces = new Map();
11
+
12
+ /**
13
+ * @param {string|null} ns
14
+ * @returns {Record<string, unknown>}
15
+ */
16
+ function getNamespaceState(ns) {
17
+ if (!ns) return {};
18
+ if (!namespaces.has(ns)) namespaces.set(ns, {});
19
+ return /** @type {Record<string, unknown>} */ (namespaces.get(ns));
20
+ }
21
+
22
+ /** Clear shared `@ns` state (call before re-rendering a whole document). */
23
+ export function resetNamespaces() {
24
+ namespaces.clear();
25
+ }
26
+
27
+ /**
28
+ * Mount one ```ivm fence body into a container element.
29
+ * @param {HTMLElement} container
30
+ * @param {string} fenceBody
31
+ * @returns {{ destroy: () => void, getState: () => Record<string, unknown> }}
32
+ */
33
+ export function mountIvm(container, fenceBody) {
34
+ const parsed = parseIvm(fenceBody);
35
+ const doc = container.ownerDocument || globalThis.document;
36
+ container.classList.add("im-ivm");
37
+ container.innerHTML = "";
38
+
39
+ if (parsed.errors.length) {
40
+ const err = doc.createElement("div");
41
+ err.className = "im-errors";
42
+ err.textContent = parsed.errors.join("\n");
43
+ container.appendChild(err);
44
+ }
45
+
46
+ const nsState = getNamespaceState(parsed.namespace);
47
+ /** @type {Record<string, unknown>} */
48
+ const local = Object.create(null);
49
+
50
+ for (const c of parsed.controls) {
51
+ if (c.kind === "let" || c.name == null) continue;
52
+ const existing =
53
+ parsed.namespace && Object.prototype.hasOwnProperty.call(nsState, c.name)
54
+ ? nsState[c.name]
55
+ : undefined;
56
+ const val = existing !== undefined ? existing : c.default;
57
+ local[c.name] = val;
58
+ if (parsed.namespace) nsState[c.name] = val;
59
+ }
60
+
61
+ const controlsEl = doc.createElement("div");
62
+ controlsEl.className = "im-controls";
63
+ container.appendChild(controlsEl);
64
+
65
+ const svgHost = doc.createElement("div");
66
+ svgHost.className = "im-svg-host";
67
+ container.appendChild(svgHost);
68
+
69
+ /** @type {Array<() => void>} */
70
+ const cleanups = [];
71
+ /** @type {Map<string, HTMLElement>} */
72
+ const valueLabels = new Map();
73
+
74
+ function buildEnv() {
75
+ /** @type {Record<string, unknown>} */
76
+ const env = Object.create(null);
77
+ if (parsed.namespace) Object.assign(env, nsState);
78
+ Object.assign(env, local);
79
+
80
+ for (const c of parsed.controls) {
81
+ if (c.kind !== "let" || !c.name || !c.expression) continue;
82
+ try {
83
+ const v = evaluate(c.expression, env);
84
+ env[c.name] = v;
85
+ local[c.name] = v;
86
+ if (parsed.namespace) nsState[c.name] = v;
87
+ } catch (err) {
88
+ console.warn("[ivm @let]", c.name, /** @type {Error} */ (err).message);
89
+ env[c.name] = NaN;
90
+ }
91
+ }
92
+ return env;
93
+ }
94
+
95
+ function rebind() {
96
+ const env = buildEnv();
97
+ for (const [name, el] of valueLabels) {
98
+ const v = env[name];
99
+ el.textContent =
100
+ typeof v === "number" ? String(Math.round(v * 1000) / 1000) : String(v ?? "");
101
+ }
102
+ if (!parsed.templateSvg) {
103
+ svgHost.innerHTML = "";
104
+ return;
105
+ }
106
+ const filled = substitute(parsed.templateSvg, env);
107
+ svgHost.innerHTML = sanitizeSvg(filled);
108
+ }
109
+
110
+ function setVar(name, value) {
111
+ local[name] = value;
112
+ if (parsed.namespace) nsState[name] = value;
113
+ rebind();
114
+ }
115
+
116
+ for (const c of parsed.controls) {
117
+ if (c.kind === "let" || c.name == null) continue;
118
+
119
+ if (c.kind === "slider" || c.kind === "timer") {
120
+ const row = doc.createElement("div");
121
+ row.className = "im-control im-slider-row";
122
+
123
+ const label = doc.createElement("label");
124
+ label.className = "im-label";
125
+ const title = doc.createElement("span");
126
+ title.className = "im-label-text";
127
+ title.textContent = c.label || c.name || "";
128
+ label.appendChild(title);
129
+
130
+ const valSpan = doc.createElement("span");
131
+ valSpan.className = "im-value";
132
+ valueLabels.set(c.name, valSpan);
133
+ label.appendChild(valSpan);
134
+
135
+ const input = doc.createElement("input");
136
+ input.type = "range";
137
+ input.min = String(c.range?.[0] ?? 0);
138
+ input.max = String(c.range?.[1] ?? 100);
139
+ input.step = String(c.step ?? 1);
140
+ input.value = String(local[c.name] ?? c.default ?? 0);
141
+ input.dataset.var = c.name;
142
+ input.setAttribute("aria-label", c.label || c.name || "slider");
143
+
144
+ input.addEventListener("input", () => {
145
+ setVar(c.name, Number(input.value));
146
+ });
147
+
148
+ row.appendChild(label);
149
+ row.appendChild(input);
150
+
151
+ if (c.kind === "timer") {
152
+ const btn = doc.createElement("button");
153
+ btn.type = "button";
154
+ btn.className = "im-timer-btn";
155
+ btn.dataset.timer = c.name;
156
+ let playing = false;
157
+ let raf = 0;
158
+ let lastTs = 0;
159
+ const speed = c.speed || 30;
160
+ const step = c.step ?? 1;
161
+ const [lo, hi] = c.range || [0, 100];
162
+ const loop = c.loop !== false;
163
+ const rafFn = globalThis.requestAnimationFrame?.bind(globalThis);
164
+ const cafFn = globalThis.cancelAnimationFrame?.bind(globalThis);
165
+
166
+ const setPlaying = (p) => {
167
+ playing = p;
168
+ btn.textContent = playing ? "Pause" : "Play";
169
+ btn.setAttribute("aria-pressed", playing ? "true" : "false");
170
+ if (playing) {
171
+ lastTs = globalThis.performance?.now?.() ?? Date.now();
172
+ if (rafFn) raf = rafFn(tick);
173
+ } else if (raf && cafFn) {
174
+ cafFn(raf);
175
+ raf = 0;
176
+ }
177
+ };
178
+
179
+ function tick(ts) {
180
+ if (!playing) return;
181
+ const now = ts ?? (globalThis.performance?.now?.() ?? Date.now());
182
+ const dt = (now - lastTs) / 1000;
183
+ lastTs = now;
184
+ let next = Number(local[c.name]) + step * speed * dt;
185
+ if (next > hi) {
186
+ const span = hi - lo || 1;
187
+ next = loop ? lo + ((next - lo) % span) : hi;
188
+ if (!loop) setPlaying(false);
189
+ }
190
+ if (next < lo) next = lo;
191
+ local[c.name] = next;
192
+ if (parsed.namespace) nsState[c.name] = next;
193
+ input.value = String(next);
194
+ rebind();
195
+ if (playing && rafFn) raf = rafFn(tick);
196
+ }
197
+
198
+ btn.textContent = "Play";
199
+ btn.addEventListener("click", () => setPlaying(!playing));
200
+ row.appendChild(btn);
201
+
202
+ cleanups.push(() => {
203
+ playing = false;
204
+ if (raf && cafFn) cafFn(raf);
205
+ });
206
+ }
207
+
208
+ controlsEl.appendChild(row);
209
+ } else if (c.kind === "input") {
210
+ const row = doc.createElement("div");
211
+ row.className = "im-control im-input-row";
212
+ const label = doc.createElement("label");
213
+ label.className = "im-label";
214
+ label.textContent = c.label || c.name || "";
215
+ const input = doc.createElement("input");
216
+ input.type = "text";
217
+ input.value = String(local[c.name] ?? "");
218
+ input.dataset.var = c.name;
219
+ input.addEventListener("input", () => setVar(c.name, input.value));
220
+ row.appendChild(label);
221
+ row.appendChild(input);
222
+ controlsEl.appendChild(row);
223
+ } else if (c.kind === "toggle") {
224
+ const row = doc.createElement("div");
225
+ row.className = "im-control im-toggle-row";
226
+ const label = doc.createElement("label");
227
+ label.className = "im-label im-toggle-label";
228
+ const input = doc.createElement("input");
229
+ input.type = "checkbox";
230
+ input.checked = Boolean(local[c.name]);
231
+ input.dataset.var = c.name;
232
+ input.addEventListener("change", () => setVar(c.name, input.checked));
233
+ const span = doc.createElement("span");
234
+ span.textContent = c.label || c.name || "";
235
+ label.appendChild(input);
236
+ label.appendChild(span);
237
+ row.appendChild(label);
238
+ controlsEl.appendChild(row);
239
+ }
240
+ }
241
+
242
+ rebind();
243
+
244
+ return {
245
+ destroy() {
246
+ for (const fn of cleanups) fn();
247
+ container.innerHTML = "";
248
+ },
249
+ getState() {
250
+ return { ...buildEnv() };
251
+ },
252
+ };
253
+ }