interactive-markdown 0.1.1 → 0.2.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 +89 -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/index.html +7 -1
- package/package.json +10 -4
- package/src/app.js +85 -8
- package/src/document-editor.js +956 -0
- package/src/document.js +633 -0
- package/src/expr.js +6 -0
- package/src/graph-editor.js +1117 -0
- package/src/markdown.js +10 -2
- package/src/parse-ivm.js +306 -67
- package/src/render.js +98 -6
- package/src/sanitize-svg.js +240 -9
- package/styles/app.css +647 -1
package/src/sanitize-svg.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Sanitize SVG markup before injecting into the DOM.
|
|
3
3
|
* Strips script, foreignObject, event handlers, and external hrefs.
|
|
4
|
-
*
|
|
4
|
+
* Keeps shapes, SMIL animation, filters, internal <use>, and CSS in <style>
|
|
5
|
+
* (keyframes, dash motion) after a CSS pass that drops imports and remote urls.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
/** Tags removed entirely (case-insensitive localName). */
|
|
@@ -14,11 +15,11 @@ const FORBIDDEN_TAGS = new Set([
|
|
|
14
15
|
"link",
|
|
15
16
|
"meta",
|
|
16
17
|
"base",
|
|
17
|
-
"use", // may reference external resources
|
|
18
18
|
"handler", // legacy
|
|
19
|
-
"style", // avoid CSS injection vectors in v1; presentation attrs remain
|
|
20
19
|
]);
|
|
21
20
|
|
|
21
|
+
const MOTION_TAGS = new Set(["animate", "set", "animatetransform", "animatemotion"]);
|
|
22
|
+
|
|
22
23
|
/**
|
|
23
24
|
* Optional allowlist. If an element is not forbidden and not in this set,
|
|
24
25
|
* it is still kept (unknown SVG elements are generally harmless once scripts/hrefs are gone).
|
|
@@ -44,6 +45,7 @@ export const SAFE_SVG_TAGS = Object.freeze([
|
|
|
44
45
|
"animate",
|
|
45
46
|
"animatetransform",
|
|
46
47
|
"animatemotion",
|
|
48
|
+
"mpath",
|
|
47
49
|
"set",
|
|
48
50
|
"lineargradient",
|
|
49
51
|
"radialgradient",
|
|
@@ -52,9 +54,36 @@ export const SAFE_SVG_TAGS = Object.freeze([
|
|
|
52
54
|
"mask",
|
|
53
55
|
"pattern",
|
|
54
56
|
"marker",
|
|
57
|
+
"filter",
|
|
58
|
+
"fegaussianblur",
|
|
59
|
+
"femerge",
|
|
60
|
+
"femergenode",
|
|
61
|
+
"feoffset",
|
|
62
|
+
"feflood",
|
|
63
|
+
"fecomposite",
|
|
64
|
+
"feblend",
|
|
65
|
+
"fecolormatrix",
|
|
66
|
+
"fedropshadow",
|
|
67
|
+
"fecomponenttransfer",
|
|
68
|
+
"fefuncr",
|
|
69
|
+
"fefuncg",
|
|
70
|
+
"fefuncb",
|
|
71
|
+
"fefunca",
|
|
72
|
+
"femorphology",
|
|
73
|
+
"feturbulence",
|
|
74
|
+
"fedisplacementmap",
|
|
75
|
+
"feimage",
|
|
76
|
+
"fetile",
|
|
77
|
+
"fediffuselighting",
|
|
78
|
+
"fespecularlighting",
|
|
79
|
+
"fedistantlight",
|
|
80
|
+
"fepointlight",
|
|
81
|
+
"fespotlight",
|
|
55
82
|
"image", // only with safe href (data:image or #)
|
|
56
83
|
"switch",
|
|
57
84
|
"a", // href must be safe (# only in practice)
|
|
85
|
+
"style", // sanitized CSS only; renderer scopes it to the figure
|
|
86
|
+
"use", // fragment hrefs only
|
|
58
87
|
]);
|
|
59
88
|
|
|
60
89
|
/**
|
|
@@ -65,11 +94,150 @@ export function isSafeHref(href) {
|
|
|
65
94
|
if (href == null || href === "") return true;
|
|
66
95
|
const v = String(href).trim().toLowerCase();
|
|
67
96
|
if (v.startsWith("#")) return true;
|
|
68
|
-
if (v.startsWith("data:image/")) return true;
|
|
69
|
-
// Block javascript:, http(s):, file:, and any other scheme or relative URL
|
|
97
|
+
if (v.startsWith("data:image/") && !v.startsWith("data:image/svg")) return true;
|
|
98
|
+
// Block javascript:, http(s):, file:, svg data urls, and any other scheme or relative URL
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Drop CSS that can load remote resources or break out of a style element.
|
|
104
|
+
* Keeps @keyframes, animation, and url(#fragment) / raster data images.
|
|
105
|
+
* @param {string} css
|
|
106
|
+
* @returns {string}
|
|
107
|
+
*/
|
|
108
|
+
export function sanitizeCss(css) {
|
|
109
|
+
let s = String(css ?? "");
|
|
110
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
111
|
+
s = stripCssComments(s);
|
|
112
|
+
s = decodeCssEscapes(s);
|
|
113
|
+
}
|
|
114
|
+
s = stripCssComments(s);
|
|
115
|
+
s = s.replace(/<!--/g, "").replace(/-->/g, "");
|
|
116
|
+
// Prevent </style> breakout when the markup is later parsed as HTML.
|
|
117
|
+
s = s.replace(/</g, "");
|
|
118
|
+
s = stripAtBlocks(s, ["font-face", "document"]);
|
|
119
|
+
s = s.replace(/@import\b[^;{]*;?/gi, "");
|
|
120
|
+
s = s.replace(/@(charset|namespace)\b[^;{]*;?/gi, "");
|
|
121
|
+
s = s.replace(/expression\s*\(/gi, "(");
|
|
122
|
+
s = s.replace(/javascript\s*:/gi, "");
|
|
123
|
+
s = s.replace(/-moz-binding\s*:[^;}]*/gi, "");
|
|
124
|
+
s = s.replace(/behavior\s*:[^;}]*/gi, "");
|
|
125
|
+
s = s.replace(/position\s*:\s*fixed/gi, "position:static");
|
|
126
|
+
s = replaceUnsafeUrls(s);
|
|
127
|
+
return s.trim();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function stripCssComments(css) {
|
|
131
|
+
return String(css).replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\*[\s\S]*$/g, "");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Decode CSS hex and single-character escapes so obfuscated url()/javascript: cannot slip through. */
|
|
135
|
+
function decodeCssEscapes(input) {
|
|
136
|
+
let out = "";
|
|
137
|
+
for (let i = 0; i < input.length; i++) {
|
|
138
|
+
if (input[i] !== "\\") {
|
|
139
|
+
out += input[i];
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const hex = /^[0-9a-fA-F]{1,6}/.exec(input.slice(i + 1));
|
|
143
|
+
if (hex) {
|
|
144
|
+
const cp = parseInt(hex[0], 16);
|
|
145
|
+
i += hex[0].length;
|
|
146
|
+
const next = input[i + 1];
|
|
147
|
+
if (next === " " || next === "\n" || next === "\r" || next === "\t" || next === "\f") i += 1;
|
|
148
|
+
if (cp > 0 && cp <= 0x10ffff) out += String.fromCodePoint(cp);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (i + 1 < input.length) {
|
|
152
|
+
out += input[i + 1];
|
|
153
|
+
i += 1;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* @param {string} css
|
|
161
|
+
* @param {string[]} names at-rule names without @
|
|
162
|
+
*/
|
|
163
|
+
function stripAtBlocks(css, names) {
|
|
164
|
+
const re = new RegExp(`@(${names.join("|")})\\b`, "gi");
|
|
165
|
+
let out = "";
|
|
166
|
+
let last = 0;
|
|
167
|
+
let m;
|
|
168
|
+
while ((m = re.exec(css))) {
|
|
169
|
+
out += css.slice(last, m.index);
|
|
170
|
+
let i = re.lastIndex;
|
|
171
|
+
while (i < css.length && css[i] !== "{" && css[i] !== ";") i++;
|
|
172
|
+
if (css[i] === ";") {
|
|
173
|
+
i++;
|
|
174
|
+
} else if (css[i] === "{") {
|
|
175
|
+
let depth = 0;
|
|
176
|
+
for (; i < css.length; i++) {
|
|
177
|
+
if (css[i] === "{") depth++;
|
|
178
|
+
else if (css[i] === "}") {
|
|
179
|
+
depth--;
|
|
180
|
+
if (depth === 0) {
|
|
181
|
+
i++;
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
last = i;
|
|
188
|
+
re.lastIndex = i;
|
|
189
|
+
}
|
|
190
|
+
out += css.slice(last);
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* @param {string} body
|
|
196
|
+
* @returns {boolean}
|
|
197
|
+
*/
|
|
198
|
+
function isSafeCssUrl(body) {
|
|
199
|
+
let v = String(body || "").trim();
|
|
200
|
+
if (
|
|
201
|
+
(v.startsWith('"') && v.endsWith('"') && v.length >= 2) ||
|
|
202
|
+
(v.startsWith("'") && v.endsWith("'") && v.length >= 2)
|
|
203
|
+
) {
|
|
204
|
+
v = v.slice(1, -1).trim();
|
|
205
|
+
}
|
|
206
|
+
if (!v) return false;
|
|
207
|
+
const low = v.toLowerCase();
|
|
208
|
+
if (v.startsWith("#")) return !/[\s"'()]/.test(v);
|
|
209
|
+
if (low.startsWith("data:image/") && !low.startsWith("data:image/svg")) return true;
|
|
70
210
|
return false;
|
|
71
211
|
}
|
|
72
212
|
|
|
213
|
+
function replaceUnsafeUrls(css) {
|
|
214
|
+
let out = "";
|
|
215
|
+
let i = 0;
|
|
216
|
+
const lower = css.toLowerCase();
|
|
217
|
+
while (i < css.length) {
|
|
218
|
+
const idx = lower.indexOf("url(", i);
|
|
219
|
+
if (idx === -1) {
|
|
220
|
+
out += css.slice(i);
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
out += css.slice(i, idx);
|
|
224
|
+
let j = idx + 4;
|
|
225
|
+
let depth = 1;
|
|
226
|
+
while (j < css.length && depth > 0) {
|
|
227
|
+
const ch = css[j];
|
|
228
|
+
if (ch === "(") depth++;
|
|
229
|
+
else if (ch === ")") depth--;
|
|
230
|
+
if (depth > 0) j++;
|
|
231
|
+
else break;
|
|
232
|
+
}
|
|
233
|
+
if (depth !== 0) break;
|
|
234
|
+
const body = css.slice(idx + 4, j);
|
|
235
|
+
out += isSafeCssUrl(body) ? `url(${body.trim()})` : "none";
|
|
236
|
+
i = j + 1;
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
|
|
73
241
|
function getDOMParser() {
|
|
74
242
|
const P = globalThis.DOMParser;
|
|
75
243
|
if (!P) throw new Error("DOMParser is not available");
|
|
@@ -105,6 +273,45 @@ function removeAttr(el, name) {
|
|
|
105
273
|
}
|
|
106
274
|
}
|
|
107
275
|
|
|
276
|
+
function hrefOf(el) {
|
|
277
|
+
return (
|
|
278
|
+
el.getAttribute("href") ||
|
|
279
|
+
el.getAttribute("xlink:href") ||
|
|
280
|
+
""
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* SMIL can animate href or event-handler attributes. Drop those animations.
|
|
286
|
+
* @param {Element} el
|
|
287
|
+
* @param {string} tag
|
|
288
|
+
* @returns {boolean} true if the element was removed
|
|
289
|
+
*/
|
|
290
|
+
function dropDangerousAnimation(el, tag) {
|
|
291
|
+
if (!MOTION_TAGS.has(tag)) return false;
|
|
292
|
+
const attrName = String(el.getAttribute("attributeName") || "")
|
|
293
|
+
.trim()
|
|
294
|
+
.toLowerCase()
|
|
295
|
+
.replace(/^.*:/, "");
|
|
296
|
+
if (
|
|
297
|
+
attrName.startsWith("on") ||
|
|
298
|
+
attrName === "href" ||
|
|
299
|
+
attrName === "style"
|
|
300
|
+
) {
|
|
301
|
+
el.parentNode?.removeChild(el);
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
for (const key of ["from", "to", "by", "values"]) {
|
|
305
|
+
if (!el.hasAttribute(key)) continue;
|
|
306
|
+
const raw = el.getAttribute(key) ?? "";
|
|
307
|
+
if (!/url\s*\(|javascript\s*:|expression\s*\(/i.test(raw)) continue;
|
|
308
|
+
const next = sanitizeCss(raw);
|
|
309
|
+
if (!next) el.removeAttribute(key);
|
|
310
|
+
else el.setAttribute(key, next);
|
|
311
|
+
}
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
|
|
108
315
|
/**
|
|
109
316
|
* Sanitize an SVG string. Returns safe SVG markup (wraps fragments in <svg>).
|
|
110
317
|
* @param {string} svgText
|
|
@@ -155,6 +362,17 @@ function walk(el) {
|
|
|
155
362
|
return;
|
|
156
363
|
}
|
|
157
364
|
|
|
365
|
+
// <use href="sprite.svg#id"> can pull in external documents. Fragments stay.
|
|
366
|
+
if (tag === "use") {
|
|
367
|
+
const href = String(hrefOf(el)).trim();
|
|
368
|
+
if (!href.startsWith("#") || !isSafeHref(href)) {
|
|
369
|
+
el.parentNode?.removeChild(el);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (dropDangerousAnimation(el, tag)) return;
|
|
375
|
+
|
|
158
376
|
const attrs = Array.from(el.attributes || []);
|
|
159
377
|
for (const attr of attrs) {
|
|
160
378
|
const name = String(attr.name || "").toLowerCase();
|
|
@@ -180,13 +398,26 @@ function walk(el) {
|
|
|
180
398
|
continue;
|
|
181
399
|
}
|
|
182
400
|
|
|
183
|
-
//
|
|
184
|
-
if (name === "style"
|
|
185
|
-
|
|
186
|
-
|
|
401
|
+
// Presentation values and style="" — keep url(#id), drop remote loads.
|
|
402
|
+
if (name === "style" || /url\s*\(/i.test(value)) {
|
|
403
|
+
const next = sanitizeCss(value);
|
|
404
|
+
if (!next) removeAttr(el, attr.name);
|
|
405
|
+
else if (next !== value) el.setAttribute(attr.name, next);
|
|
187
406
|
}
|
|
188
407
|
}
|
|
189
408
|
|
|
409
|
+
if (tag === "style") {
|
|
410
|
+
const kids = Array.from(el.children || []).filter((n) => n.nodeType === 1);
|
|
411
|
+
for (const child of kids) child.parentNode?.removeChild(child);
|
|
412
|
+
const css = sanitizeCss(el.textContent || "");
|
|
413
|
+
if (!css) {
|
|
414
|
+
el.parentNode?.removeChild(el);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
el.textContent = css;
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
190
421
|
const children = Array.from(el.children || el.childNodes || []).filter(
|
|
191
422
|
(n) => n.nodeType === 1
|
|
192
423
|
);
|