interactive-markdown 0.0.0-stage → 0.1.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/expr.js ADDED
@@ -0,0 +1,416 @@
1
+ /**
2
+ * Safe expression evaluator for Interactive Markdown.
3
+ * Whitelist-only recursive-descent parser. NEVER uses eval or Function.
4
+ */
5
+
6
+ const CONSTANTS = Object.freeze({
7
+ pi: Math.PI,
8
+ e: Math.E,
9
+ tau: Math.PI * 2,
10
+ true: true,
11
+ false: false,
12
+ });
13
+
14
+ const FUNCTIONS = Object.freeze({
15
+ abs: Math.abs,
16
+ sqrt: Math.sqrt,
17
+ pow: Math.pow,
18
+ exp: Math.exp,
19
+ ln: Math.log,
20
+ log: (x, base) => (base === undefined ? Math.log10(x) : Math.log(x) / Math.log(base)),
21
+ sin: Math.sin,
22
+ cos: Math.cos,
23
+ tan: Math.tan,
24
+ asin: Math.asin,
25
+ acos: Math.acos,
26
+ atan: Math.atan,
27
+ atan2: Math.atan2,
28
+ round: (x, digits) => {
29
+ if (digits === undefined || digits === null) return Math.round(x);
30
+ const f = 10 ** digits;
31
+ return Math.round(x * f) / f;
32
+ },
33
+ floor: Math.floor,
34
+ ceil: Math.ceil,
35
+ trunc: Math.trunc,
36
+ sign: Math.sign,
37
+ min: Math.min,
38
+ max: Math.max,
39
+ clamp: (x, lo, hi) => Math.min(Math.max(x, lo), hi),
40
+ mod: (a, b) => ((a % b) + b) % b,
41
+ lerp: (a, b, t) => a + (b - a) * t,
42
+ deg: (r) => (r * 180) / Math.PI,
43
+ rad: (d) => (d * Math.PI) / 180,
44
+ });
45
+
46
+ function tokenize(input) {
47
+ const tokens = [];
48
+ let i = 0;
49
+ const s = String(input);
50
+
51
+ while (i < s.length) {
52
+ const c = s[i];
53
+
54
+ if (/\s/.test(c)) {
55
+ i++;
56
+ continue;
57
+ }
58
+
59
+ if (c === "'" || c === '"') {
60
+ const quote = c;
61
+ let j = i + 1;
62
+ let value = "";
63
+ while (j < s.length && s[j] !== quote) {
64
+ if (s[j] === "\\" && j + 1 < s.length) {
65
+ value += s[j + 1];
66
+ j += 2;
67
+ } else {
68
+ value += s[j];
69
+ j++;
70
+ }
71
+ }
72
+ if (j >= s.length) throw new Error(`Unterminated string at ${i}`);
73
+ tokens.push({ type: "string", value });
74
+ i = j + 1;
75
+ continue;
76
+ }
77
+
78
+ if (/[0-9.]/.test(c)) {
79
+ let j = i;
80
+ while (j < s.length && /[0-9.]/.test(s[j])) j++;
81
+ if (j < s.length && (s[j] === "e" || s[j] === "E")) {
82
+ j++;
83
+ if (j < s.length && (s[j] === "+" || s[j] === "-")) j++;
84
+ while (j < s.length && /[0-9]/.test(s[j])) j++;
85
+ }
86
+ const raw = s.slice(i, j);
87
+ const num = Number(raw);
88
+ if (Number.isNaN(num)) throw new Error(`Invalid number: ${raw}`);
89
+ tokens.push({ type: "number", value: num });
90
+ i = j;
91
+ continue;
92
+ }
93
+
94
+ if (/[A-Za-z_]/.test(c)) {
95
+ let j = i + 1;
96
+ while (j < s.length && /[A-Za-z0-9_]/.test(s[j])) j++;
97
+ tokens.push({ type: "ident", value: s.slice(i, j) });
98
+ i = j;
99
+ continue;
100
+ }
101
+
102
+ // Multi-char operators
103
+ const two = s.slice(i, i + 2);
104
+ if (
105
+ two === "**" ||
106
+ two === "&&" ||
107
+ two === "||" ||
108
+ two === "==" ||
109
+ two === "!=" ||
110
+ two === "<=" ||
111
+ two === ">="
112
+ ) {
113
+ tokens.push({ type: "op", value: two });
114
+ i += 2;
115
+ continue;
116
+ }
117
+
118
+ if ("+-*/%^()<>,?:!".includes(c) || c === "<" || c === ">") {
119
+ tokens.push({ type: "op", value: c });
120
+ i++;
121
+ continue;
122
+ }
123
+
124
+ throw new Error(`Unexpected character '${c}' at ${i}`);
125
+ }
126
+
127
+ tokens.push({ type: "eof" });
128
+ return tokens;
129
+ }
130
+
131
+ class Parser {
132
+ constructor(tokens, env) {
133
+ this.tokens = tokens;
134
+ this.pos = 0;
135
+ this.env = env || {};
136
+ }
137
+
138
+ peek() {
139
+ return this.tokens[this.pos];
140
+ }
141
+
142
+ consume() {
143
+ return this.tokens[this.pos++];
144
+ }
145
+
146
+ matchOp(...ops) {
147
+ const t = this.peek();
148
+ if (t.type === "op" && ops.includes(t.value)) {
149
+ this.consume();
150
+ return t.value;
151
+ }
152
+ return null;
153
+ }
154
+
155
+ expectOp(op) {
156
+ const t = this.consume();
157
+ if (t.type !== "op" || t.value !== op) {
158
+ throw new Error(`Expected '${op}', got ${t.type}:${t.value}`);
159
+ }
160
+ }
161
+
162
+ parse() {
163
+ const value = this.parseTernary();
164
+ if (this.peek().type !== "eof") {
165
+ throw new Error(`Unexpected token: ${this.peek().type}:${this.peek().value}`);
166
+ }
167
+ return value;
168
+ }
169
+
170
+ parseTernary() {
171
+ let cond = this.parseOr();
172
+ if (this.matchOp("?")) {
173
+ const a = this.parseTernary();
174
+ this.expectOp(":");
175
+ const b = this.parseTernary();
176
+ return cond ? a : b;
177
+ }
178
+ return cond;
179
+ }
180
+
181
+ parseOr() {
182
+ let left = this.parseAnd();
183
+ while (this.matchOp("||")) {
184
+ const right = this.parseAnd();
185
+ left = left || right;
186
+ }
187
+ return left;
188
+ }
189
+
190
+ parseAnd() {
191
+ let left = this.parseEquality();
192
+ while (this.matchOp("&&")) {
193
+ const right = this.parseEquality();
194
+ left = left && right;
195
+ }
196
+ return left;
197
+ }
198
+
199
+ parseEquality() {
200
+ let left = this.parseComparison();
201
+ for (;;) {
202
+ const op = this.matchOp("==", "!=");
203
+ if (!op) break;
204
+ const right = this.parseComparison();
205
+ left = op === "==" ? left == right : left != right;
206
+ }
207
+ return left;
208
+ }
209
+
210
+ parseComparison() {
211
+ let left = this.parseAdd();
212
+ for (;;) {
213
+ const op = this.matchOp("<", ">", "<=", ">=");
214
+ if (!op) break;
215
+ const right = this.parseAdd();
216
+ if (op === "<") left = left < right;
217
+ else if (op === ">") left = left > right;
218
+ else if (op === "<=") left = left <= right;
219
+ else left = left >= right;
220
+ }
221
+ return left;
222
+ }
223
+
224
+ parseAdd() {
225
+ let left = this.parseMul();
226
+ for (;;) {
227
+ const op = this.matchOp("+", "-");
228
+ if (!op) break;
229
+ const right = this.parseMul();
230
+ left = op === "+" ? left + right : left - right;
231
+ }
232
+ return left;
233
+ }
234
+
235
+ parseMul() {
236
+ let left = this.parseUnary();
237
+ for (;;) {
238
+ const op = this.matchOp("*", "/", "%");
239
+ if (!op) break;
240
+ const right = this.parseUnary();
241
+ if (op === "*") left = left * right;
242
+ else if (op === "/") left = left / right;
243
+ else left = left % right;
244
+ }
245
+ return left;
246
+ }
247
+
248
+ parseUnary() {
249
+ if (this.matchOp("!")) return !this.parseUnary();
250
+ if (this.matchOp("+")) return +this.parseUnary();
251
+ if (this.matchOp("-")) return -this.parseUnary();
252
+ return this.parsePow();
253
+ }
254
+
255
+ parsePow() {
256
+ let base = this.parsePrimary();
257
+ // right-associative: ^ and **
258
+ const op = this.matchOp("^", "**");
259
+ if (op) {
260
+ const exp = this.parseUnary();
261
+ return Math.pow(base, exp);
262
+ }
263
+ return base;
264
+ }
265
+
266
+ parsePrimary() {
267
+ const t = this.peek();
268
+
269
+ if (t.type === "number") {
270
+ this.consume();
271
+ return t.value;
272
+ }
273
+
274
+ if (t.type === "string") {
275
+ this.consume();
276
+ return t.value;
277
+ }
278
+
279
+ if (t.type === "ident") {
280
+ this.consume();
281
+ const name = t.value;
282
+
283
+ // function call
284
+ if (this.peek().type === "op" && this.peek().value === "(") {
285
+ this.consume();
286
+ const args = [];
287
+ if (!(this.peek().type === "op" && this.peek().value === ")")) {
288
+ args.push(this.parseTernary());
289
+ while (this.matchOp(",")) {
290
+ args.push(this.parseTernary());
291
+ }
292
+ }
293
+ this.expectOp(")");
294
+ const fn = FUNCTIONS[name];
295
+ if (!fn) throw new Error(`Unknown function: ${name}`);
296
+ return fn(...args);
297
+ }
298
+
299
+ if (Object.prototype.hasOwnProperty.call(CONSTANTS, name)) {
300
+ return CONSTANTS[name];
301
+ }
302
+ if (Object.prototype.hasOwnProperty.call(this.env, name)) {
303
+ return this.env[name];
304
+ }
305
+ throw new Error(`Unknown identifier: ${name}`);
306
+ }
307
+
308
+ if (t.type === "op" && t.value === "(") {
309
+ this.consume();
310
+ const v = this.parseTernary();
311
+ this.expectOp(")");
312
+ return v;
313
+ }
314
+
315
+ throw new Error(`Unexpected token in primary: ${t.type}:${t.value}`);
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Evaluate a whitelist expression against an environment of variables.
321
+ * @param {string} source
322
+ * @param {Record<string, unknown>} env
323
+ * @returns {unknown}
324
+ */
325
+ export function evaluate(source, env = {}) {
326
+ const tokens = tokenize(source);
327
+ const parser = new Parser(tokens, env);
328
+ return parser.parse();
329
+ }
330
+
331
+ /**
332
+ * Format a value for SVG attribute / text insertion.
333
+ * @param {unknown} value
334
+ * @returns {string}
335
+ */
336
+ export function formatValue(value) {
337
+ if (typeof value === "number") {
338
+ if (!Number.isFinite(value)) return String(value);
339
+ // Trim floating noise while keeping useful precision
340
+ const rounded = Math.round(value * 1e6) / 1e6;
341
+ return String(rounded);
342
+ }
343
+ if (typeof value === "boolean") return value ? "true" : "false";
344
+ if (value == null) return "";
345
+ return String(value);
346
+ }
347
+
348
+ /**
349
+ * Replace {{expr}} placeholders in a string using the evaluator.
350
+ * Double-brace form avoids clashing with lone { } in SVG path data.
351
+ * @param {string} template
352
+ * @param {Record<string, unknown>} env
353
+ * @returns {string}
354
+ */
355
+ export function substitute(template, env = {}) {
356
+ let out = "";
357
+ let i = 0;
358
+ const s = String(template);
359
+
360
+ while (i < s.length) {
361
+ if (s[i] === "{" && s[i + 1] === "{") {
362
+ let depth = 1; // counts {{ ... }} pairs via inner { } for nested exprs
363
+ let j = i + 2;
364
+ let inStr = null;
365
+ while (j < s.length) {
366
+ const c = s[j];
367
+ if (inStr) {
368
+ if (c === "\\" && j + 1 < s.length) {
369
+ j += 2;
370
+ continue;
371
+ }
372
+ if (c === inStr) inStr = null;
373
+ j++;
374
+ continue;
375
+ }
376
+ if (c === "'" || c === '"') {
377
+ inStr = c;
378
+ j++;
379
+ continue;
380
+ }
381
+ if (c === "{" && s[j + 1] === "{") {
382
+ depth++;
383
+ j += 2;
384
+ continue;
385
+ }
386
+ if (c === "}" && s[j + 1] === "}") {
387
+ depth--;
388
+ if (depth === 0) {
389
+ const expr = s.slice(i + 2, j);
390
+ try {
391
+ out += formatValue(evaluate(expr, env));
392
+ } catch (err) {
393
+ out += "{{" + expr + "}}";
394
+ console.warn("[ivm expr]", err.message, "in", expr);
395
+ }
396
+ i = j + 2;
397
+ break;
398
+ }
399
+ j += 2;
400
+ continue;
401
+ }
402
+ j++;
403
+ }
404
+ if (depth !== 0) {
405
+ out += s[i];
406
+ i++;
407
+ }
408
+ continue;
409
+ }
410
+ out += s[i];
411
+ i++;
412
+ }
413
+ return out;
414
+ }
415
+
416
+ export { CONSTANTS, FUNCTIONS };
package/src/index.js ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Interactive Markdown (IVM) — public library entry.
3
+ */
4
+ export { evaluate, substitute } from "./expr.js";
5
+ export { parseIvm, splitIvmFences } from "./parse-ivm.js";
6
+ export { sanitizeSvg } from "./sanitize-svg.js";
7
+ export { mountIvm, resetNamespaces } from "./render.js";
8
+ export { renderDocument } from "./markdown.js";
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Markdown pipeline: render MD text; mount ```ivm fences as live widgets.
3
+ */
4
+
5
+ import { splitIvmFences } from "./parse-ivm.js";
6
+ import { mountIvm, resetNamespaces } from "./render.js";
7
+
8
+ /**
9
+ * Resolve a marked-compatible parser ({ parse(md) => html }).
10
+ * Prefers an explicit override, then globalThis.marked (CDN), then optional inject.
11
+ * @param {{ parse: (md: string) => string } | null | undefined} override
12
+ */
13
+ function resolveMarked(override) {
14
+ if (override && typeof override.parse === "function") return override;
15
+ const g = globalThis.marked;
16
+ if (g && typeof g.parse === "function") return g;
17
+ throw new Error(
18
+ "marked library not loaded — include marked via CDN or pass { marked } to renderDocument"
19
+ );
20
+ }
21
+
22
+ /**
23
+ * Render full Interactive Markdown into a preview element.
24
+ * @param {HTMLElement} previewEl
25
+ * @param {string} source
26
+ * @param {{ marked?: { parse: (md: string) => string } }} [options]
27
+ * @returns {{ destroy: () => void }}
28
+ */
29
+ export function renderDocument(previewEl, source, options = {}) {
30
+ resetNamespaces();
31
+ previewEl.innerHTML = "";
32
+
33
+ const doc = previewEl.ownerDocument || globalThis.document;
34
+ /** @type {Array<{ destroy: () => void }>} */
35
+ const mounts = [];
36
+ const segments = splitIvmFences(source);
37
+ const marked = resolveMarked(options.marked);
38
+
39
+ if (typeof marked.setOptions === "function") {
40
+ marked.setOptions({ gfm: true, breaks: false });
41
+ }
42
+
43
+ for (const seg of segments) {
44
+ if (seg.type === "text") {
45
+ if (!seg.content.trim()) continue;
46
+ const wrap = doc.createElement("div");
47
+ wrap.className = "im-md";
48
+ wrap.innerHTML = marked.parse(seg.content);
49
+ previewEl.appendChild(wrap);
50
+ } else if (seg.type === "ivm") {
51
+ const host = doc.createElement("div");
52
+ host.className = "im-ivm-host";
53
+ previewEl.appendChild(host);
54
+ mounts.push(mountIvm(host, seg.content));
55
+ }
56
+ }
57
+
58
+ return {
59
+ destroy() {
60
+ for (const m of mounts) m.destroy();
61
+ previewEl.innerHTML = "";
62
+ },
63
+ };
64
+ }