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.
- package/LICENSE +21 -0
- package/README.md +121 -2
- package/examples/amplitude.md +25 -0
- package/examples/circular-motion.md +25 -0
- package/index.html +43 -0
- package/package.json +57 -4
- package/src/app.js +184 -0
- package/src/expr.js +416 -0
- package/src/index.js +8 -0
- package/src/markdown.js +64 -0
- package/src/parse-ivm.js +269 -0
- package/src/render.js +253 -0
- package/src/sanitize-svg.js +196 -0
- package/styles/app.css +336 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sanitize SVG markup before injecting into the DOM.
|
|
3
|
+
* Strips script, foreignObject, event handlers, and external hrefs.
|
|
4
|
+
* Safe shapes (rect, circle, line, path, text, g, defs, animate, …) are kept.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Tags removed entirely (case-insensitive localName). */
|
|
8
|
+
const FORBIDDEN_TAGS = new Set([
|
|
9
|
+
"script",
|
|
10
|
+
"foreignobject",
|
|
11
|
+
"iframe",
|
|
12
|
+
"object",
|
|
13
|
+
"embed",
|
|
14
|
+
"link",
|
|
15
|
+
"meta",
|
|
16
|
+
"base",
|
|
17
|
+
"use", // may reference external resources
|
|
18
|
+
"handler", // legacy
|
|
19
|
+
"style", // avoid CSS injection vectors in v1; presentation attrs remain
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Optional allowlist. If an element is not forbidden and not in this set,
|
|
24
|
+
* it is still kept (unknown SVG elements are generally harmless once scripts/hrefs are gone).
|
|
25
|
+
* Listed for documentation / future strict mode.
|
|
26
|
+
*/
|
|
27
|
+
export const SAFE_SVG_TAGS = Object.freeze([
|
|
28
|
+
"svg",
|
|
29
|
+
"g",
|
|
30
|
+
"defs",
|
|
31
|
+
"symbol",
|
|
32
|
+
"rect",
|
|
33
|
+
"circle",
|
|
34
|
+
"ellipse",
|
|
35
|
+
"line",
|
|
36
|
+
"polyline",
|
|
37
|
+
"polygon",
|
|
38
|
+
"path",
|
|
39
|
+
"text",
|
|
40
|
+
"tspan",
|
|
41
|
+
"textpath",
|
|
42
|
+
"title",
|
|
43
|
+
"desc",
|
|
44
|
+
"animate",
|
|
45
|
+
"animatetransform",
|
|
46
|
+
"animatemotion",
|
|
47
|
+
"set",
|
|
48
|
+
"lineargradient",
|
|
49
|
+
"radialgradient",
|
|
50
|
+
"stop",
|
|
51
|
+
"clippath",
|
|
52
|
+
"mask",
|
|
53
|
+
"pattern",
|
|
54
|
+
"marker",
|
|
55
|
+
"image", // only with safe href (data:image or #)
|
|
56
|
+
"switch",
|
|
57
|
+
"a", // href must be safe (# only in practice)
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {string} href
|
|
62
|
+
* @returns {boolean}
|
|
63
|
+
*/
|
|
64
|
+
export function isSafeHref(href) {
|
|
65
|
+
if (href == null || href === "") return true;
|
|
66
|
+
const v = String(href).trim().toLowerCase();
|
|
67
|
+
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
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getDOMParser() {
|
|
74
|
+
const P = globalThis.DOMParser;
|
|
75
|
+
if (!P) throw new Error("DOMParser is not available");
|
|
76
|
+
return new P();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function serializeElement(el) {
|
|
80
|
+
const S = globalThis.XMLSerializer;
|
|
81
|
+
if (S) return new S().serializeToString(el);
|
|
82
|
+
// linkedom / some environments: Element#toString or outerHTML
|
|
83
|
+
if (typeof el.toString === "function") {
|
|
84
|
+
const s = el.toString();
|
|
85
|
+
if (s && s.startsWith("<")) return s;
|
|
86
|
+
}
|
|
87
|
+
if (typeof el.outerHTML === "string" && el.outerHTML) return el.outerHTML;
|
|
88
|
+
throw new Error("XMLSerializer is not available");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @param {Element} el
|
|
93
|
+
* @param {string} name lowercase attribute name
|
|
94
|
+
*/
|
|
95
|
+
function removeAttr(el, name) {
|
|
96
|
+
if (el.hasAttribute(name)) el.removeAttribute(name);
|
|
97
|
+
// xlink:href variants
|
|
98
|
+
if (name === "href" || name === "xlink:href") {
|
|
99
|
+
try {
|
|
100
|
+
el.removeAttributeNS("http://www.w3.org/1999/xlink", "href");
|
|
101
|
+
} catch (_) {
|
|
102
|
+
/* ignore */
|
|
103
|
+
}
|
|
104
|
+
if (el.hasAttribute("xlink:href")) el.removeAttribute("xlink:href");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Sanitize an SVG string. Returns safe SVG markup (wraps fragments in <svg>).
|
|
110
|
+
* @param {string} svgText
|
|
111
|
+
* @returns {string}
|
|
112
|
+
*/
|
|
113
|
+
export function sanitizeSvg(svgText) {
|
|
114
|
+
const raw = String(svgText || "").trim();
|
|
115
|
+
if (!raw) return "";
|
|
116
|
+
|
|
117
|
+
const parser = getDOMParser();
|
|
118
|
+
const wrapped = /<svg\b/i.test(raw)
|
|
119
|
+
? raw
|
|
120
|
+
: `<svg xmlns="http://www.w3.org/2000/svg">${raw}</svg>`;
|
|
121
|
+
|
|
122
|
+
const doc = parser.parseFromString(wrapped, "image/svg+xml");
|
|
123
|
+
|
|
124
|
+
// Some parsers surface errors as <parsererror>
|
|
125
|
+
const parseError =
|
|
126
|
+
doc.querySelector?.("parsererror") ||
|
|
127
|
+
doc.getElementsByTagName?.("parsererror")?.[0];
|
|
128
|
+
if (parseError) {
|
|
129
|
+
console.warn("[sanitize-svg] parse error");
|
|
130
|
+
return "";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const root = doc.documentElement;
|
|
134
|
+
if (!root || String(root.localName || root.tagName || "").toLowerCase() !== "svg") {
|
|
135
|
+
return "";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
walk(root);
|
|
139
|
+
|
|
140
|
+
if (!root.getAttribute("xmlns")) {
|
|
141
|
+
root.setAttribute("xmlns", "http://www.w3.org/2000/svg");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return serializeElement(root);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* @param {Element} el
|
|
149
|
+
*/
|
|
150
|
+
function walk(el) {
|
|
151
|
+
const tag = String(el.localName || el.tagName || "").toLowerCase().replace(/^.*:/, "");
|
|
152
|
+
|
|
153
|
+
if (FORBIDDEN_TAGS.has(tag)) {
|
|
154
|
+
el.parentNode?.removeChild(el);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const attrs = Array.from(el.attributes || []);
|
|
159
|
+
for (const attr of attrs) {
|
|
160
|
+
const name = String(attr.name || "").toLowerCase();
|
|
161
|
+
const value = attr.value;
|
|
162
|
+
|
|
163
|
+
// on* event handlers
|
|
164
|
+
if (name.startsWith("on")) {
|
|
165
|
+
removeAttr(el, attr.name);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// href / xlink:href
|
|
170
|
+
if (
|
|
171
|
+
name === "href" ||
|
|
172
|
+
name === "xlink:href" ||
|
|
173
|
+
name.endsWith(":href") ||
|
|
174
|
+
(attr.namespaceURI === "http://www.w3.org/1999/xlink" &&
|
|
175
|
+
String(attr.localName || "").toLowerCase() === "href")
|
|
176
|
+
) {
|
|
177
|
+
if (!isSafeHref(value)) {
|
|
178
|
+
removeAttr(el, attr.name);
|
|
179
|
+
}
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// xlink:actuate etc. left alone; block style urls with external schemes
|
|
184
|
+
if (name === "style" && /url\s*\(\s*['"]?\s*(https?:|javascript:|data:text)/i.test(value)) {
|
|
185
|
+
removeAttr(el, attr.name);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const children = Array.from(el.children || el.childNodes || []).filter(
|
|
191
|
+
(n) => n.nodeType === 1
|
|
192
|
+
);
|
|
193
|
+
for (const child of children) {
|
|
194
|
+
walk(/** @type {Element} */ (child));
|
|
195
|
+
}
|
|
196
|
+
}
|
package/styles/app.css
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg: #0b1220;
|
|
3
|
+
--panel: #111827;
|
|
4
|
+
--panel-2: #0f172a;
|
|
5
|
+
--border: #1f2937;
|
|
6
|
+
--text: #e5e7eb;
|
|
7
|
+
--muted: #9ca3af;
|
|
8
|
+
--accent: #38bdf8;
|
|
9
|
+
--accent-2: #a78bfa;
|
|
10
|
+
--danger: #f87171;
|
|
11
|
+
--radius: 10px;
|
|
12
|
+
--font: "Segoe UI", system-ui, -apple-system, sans-serif;
|
|
13
|
+
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
* {
|
|
17
|
+
box-sizing: border-box;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
html,
|
|
21
|
+
body {
|
|
22
|
+
margin: 0;
|
|
23
|
+
height: 100%;
|
|
24
|
+
background: var(--bg);
|
|
25
|
+
color: var(--text);
|
|
26
|
+
font-family: var(--font);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
body {
|
|
30
|
+
display: flex;
|
|
31
|
+
flex-direction: column;
|
|
32
|
+
min-height: 100vh;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.app-header {
|
|
36
|
+
display: flex;
|
|
37
|
+
flex-wrap: wrap;
|
|
38
|
+
align-items: center;
|
|
39
|
+
gap: 0.75rem 1rem;
|
|
40
|
+
padding: 0.75rem 1rem;
|
|
41
|
+
border-bottom: 1px solid var(--border);
|
|
42
|
+
background: var(--panel);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.brand {
|
|
46
|
+
display: flex;
|
|
47
|
+
flex-direction: column;
|
|
48
|
+
min-width: 12rem;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.brand h1 {
|
|
52
|
+
margin: 0;
|
|
53
|
+
font-size: 1.05rem;
|
|
54
|
+
font-weight: 650;
|
|
55
|
+
letter-spacing: 0.01em;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.brand span {
|
|
59
|
+
color: var(--muted);
|
|
60
|
+
font-size: 0.75rem;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.toolbar {
|
|
64
|
+
display: flex;
|
|
65
|
+
flex-wrap: wrap;
|
|
66
|
+
gap: 0.5rem;
|
|
67
|
+
align-items: center;
|
|
68
|
+
flex: 1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.toolbar select,
|
|
72
|
+
.toolbar button {
|
|
73
|
+
font: inherit;
|
|
74
|
+
font-size: 0.85rem;
|
|
75
|
+
border-radius: 8px;
|
|
76
|
+
border: 1px solid var(--border);
|
|
77
|
+
background: var(--panel-2);
|
|
78
|
+
color: var(--text);
|
|
79
|
+
padding: 0.4rem 0.7rem;
|
|
80
|
+
cursor: pointer;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.toolbar button:hover,
|
|
84
|
+
.toolbar select:hover {
|
|
85
|
+
border-color: #374151;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.toolbar button.primary {
|
|
89
|
+
background: linear-gradient(135deg, #0ea5e9, #6366f1);
|
|
90
|
+
border-color: transparent;
|
|
91
|
+
color: white;
|
|
92
|
+
font-weight: 600;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#status {
|
|
96
|
+
color: var(--muted);
|
|
97
|
+
font-size: 0.8rem;
|
|
98
|
+
margin-left: auto;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.layout {
|
|
102
|
+
flex: 1;
|
|
103
|
+
display: grid;
|
|
104
|
+
grid-template-columns: 1fr 1fr;
|
|
105
|
+
min-height: 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
@media (max-width: 900px) {
|
|
109
|
+
.layout {
|
|
110
|
+
grid-template-columns: 1fr;
|
|
111
|
+
grid-template-rows: 45vh 1fr;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.pane {
|
|
116
|
+
display: flex;
|
|
117
|
+
flex-direction: column;
|
|
118
|
+
min-height: 0;
|
|
119
|
+
border-right: 1px solid var(--border);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.pane:last-child {
|
|
123
|
+
border-right: none;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.pane-title {
|
|
127
|
+
padding: 0.5rem 0.85rem;
|
|
128
|
+
font-size: 0.75rem;
|
|
129
|
+
text-transform: uppercase;
|
|
130
|
+
letter-spacing: 0.06em;
|
|
131
|
+
color: var(--muted);
|
|
132
|
+
border-bottom: 1px solid var(--border);
|
|
133
|
+
background: #0c1424;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#editor {
|
|
137
|
+
flex: 1;
|
|
138
|
+
width: 100%;
|
|
139
|
+
resize: none;
|
|
140
|
+
border: 0;
|
|
141
|
+
outline: none;
|
|
142
|
+
padding: 1rem;
|
|
143
|
+
background: var(--panel-2);
|
|
144
|
+
color: var(--text);
|
|
145
|
+
font-family: var(--mono);
|
|
146
|
+
font-size: 0.9rem;
|
|
147
|
+
line-height: 1.45;
|
|
148
|
+
tab-size: 2;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
#preview {
|
|
152
|
+
flex: 1;
|
|
153
|
+
overflow: auto;
|
|
154
|
+
padding: 1rem 1.15rem 2rem;
|
|
155
|
+
background: #0a101c;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* Markdown content */
|
|
159
|
+
.im-md {
|
|
160
|
+
line-height: 1.55;
|
|
161
|
+
max-width: 52rem;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.im-md > *:first-child {
|
|
165
|
+
margin-top: 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.im-md h1,
|
|
169
|
+
.im-md h2,
|
|
170
|
+
.im-md h3 {
|
|
171
|
+
line-height: 1.25;
|
|
172
|
+
margin: 1.2em 0 0.5em;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.im-md h1 {
|
|
176
|
+
font-size: 1.6rem;
|
|
177
|
+
border-bottom: 1px solid var(--border);
|
|
178
|
+
padding-bottom: 0.3rem;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.im-md h2 {
|
|
182
|
+
font-size: 1.25rem;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.im-md p,
|
|
186
|
+
.im-md ul,
|
|
187
|
+
.im-md ol {
|
|
188
|
+
color: #d1d5db;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.im-md code {
|
|
192
|
+
font-family: var(--mono);
|
|
193
|
+
font-size: 0.88em;
|
|
194
|
+
background: #1e293b;
|
|
195
|
+
padding: 0.1em 0.35em;
|
|
196
|
+
border-radius: 4px;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.im-md pre {
|
|
200
|
+
background: #1e293b;
|
|
201
|
+
padding: 0.85rem 1rem;
|
|
202
|
+
border-radius: var(--radius);
|
|
203
|
+
overflow: auto;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.im-md pre code {
|
|
207
|
+
background: transparent;
|
|
208
|
+
padding: 0;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.im-md a {
|
|
212
|
+
color: var(--accent);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.im-md blockquote {
|
|
216
|
+
margin: 0.8em 0;
|
|
217
|
+
padding-left: 0.9rem;
|
|
218
|
+
border-left: 3px solid #334155;
|
|
219
|
+
color: var(--muted);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/* IVM widgets */
|
|
223
|
+
.im-ivm-host {
|
|
224
|
+
margin: 1.1rem 0 1.4rem;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.im-ivm {
|
|
228
|
+
background: #111827;
|
|
229
|
+
border: 1px solid var(--border);
|
|
230
|
+
border-radius: 12px;
|
|
231
|
+
padding: 0.9rem 1rem 1rem;
|
|
232
|
+
max-width: 52rem;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.im-controls {
|
|
236
|
+
display: flex;
|
|
237
|
+
flex-direction: column;
|
|
238
|
+
gap: 0.65rem;
|
|
239
|
+
margin-bottom: 0.85rem;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.im-control {
|
|
243
|
+
display: grid;
|
|
244
|
+
gap: 0.35rem;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
.im-slider-row {
|
|
248
|
+
grid-template-columns: 1fr auto;
|
|
249
|
+
align-items: center;
|
|
250
|
+
column-gap: 0.6rem;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.im-slider-row input[type="range"] {
|
|
254
|
+
grid-column: 1 / -1;
|
|
255
|
+
width: 100%;
|
|
256
|
+
accent-color: var(--accent);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
.im-label {
|
|
260
|
+
display: flex;
|
|
261
|
+
align-items: baseline;
|
|
262
|
+
gap: 0.5rem;
|
|
263
|
+
font-size: 0.9rem;
|
|
264
|
+
color: #cbd5e1;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.im-value {
|
|
268
|
+
font-family: var(--mono);
|
|
269
|
+
font-size: 0.8rem;
|
|
270
|
+
color: var(--accent-2);
|
|
271
|
+
margin-left: auto;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
.im-timer-btn {
|
|
275
|
+
font: inherit;
|
|
276
|
+
font-size: 0.8rem;
|
|
277
|
+
padding: 0.3rem 0.65rem;
|
|
278
|
+
border-radius: 7px;
|
|
279
|
+
border: 1px solid #334155;
|
|
280
|
+
background: #1e293b;
|
|
281
|
+
color: var(--text);
|
|
282
|
+
cursor: pointer;
|
|
283
|
+
white-space: nowrap;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
.im-timer-btn[aria-pressed="true"] {
|
|
287
|
+
background: #164e63;
|
|
288
|
+
border-color: #0891b2;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
.im-input-row input[type="text"] {
|
|
292
|
+
font: inherit;
|
|
293
|
+
padding: 0.4rem 0.55rem;
|
|
294
|
+
border-radius: 7px;
|
|
295
|
+
border: 1px solid var(--border);
|
|
296
|
+
background: #0f172a;
|
|
297
|
+
color: var(--text);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
.im-toggle-label {
|
|
301
|
+
display: inline-flex;
|
|
302
|
+
align-items: center;
|
|
303
|
+
gap: 0.5rem;
|
|
304
|
+
cursor: pointer;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
.im-svg-host {
|
|
308
|
+
background: #020617;
|
|
309
|
+
border-radius: 10px;
|
|
310
|
+
overflow: hidden;
|
|
311
|
+
border: 1px solid #1e293b;
|
|
312
|
+
padding: 0.5rem;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
.im-svg-host svg {
|
|
316
|
+
display: block;
|
|
317
|
+
max-width: 100%;
|
|
318
|
+
height: auto;
|
|
319
|
+
margin: 0 auto;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.im-errors {
|
|
323
|
+
white-space: pre-wrap;
|
|
324
|
+
color: var(--danger);
|
|
325
|
+
background: #450a0a;
|
|
326
|
+
border: 1px solid #7f1d1d;
|
|
327
|
+
border-radius: 8px;
|
|
328
|
+
padding: 0.6rem 0.75rem;
|
|
329
|
+
font-family: var(--mono);
|
|
330
|
+
font-size: 0.8rem;
|
|
331
|
+
margin-bottom: 0.6rem;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
#file-input {
|
|
335
|
+
display: none;
|
|
336
|
+
}
|