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,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
+ }