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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thomas
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,122 @@
1
- # Temporary Holding Version
1
+ # Interactive Markdown (IVM)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Interactive Markdown** is a small browser **editor + renderer** for plain `.md` files that include live **vector graphics** driven by sliders, timers, and a safe expression language.
4
+
5
+ Write Markdown as usual. Add an `ivm` fenced block when you want interactive SVG. The format stays AI-friendly: readable text an agent can generate like normal Markdown.
6
+
7
+ License: **MIT** (see [LICENSE](LICENSE)).
8
+
9
+ ## Features
10
+
11
+ - Split **editor** and **live preview**
12
+ - Open / save `.md`, with a **localStorage** draft
13
+ - IVM widgets: `@slider`, `@timer` (Play/Pause), `@input`, `@toggle`
14
+ - Derived values with `@let` / `@calc`
15
+ - SVG templates with `{{expr}}` bindings
16
+ - Whitelist expression evaluator (no `eval`)
17
+ - SVG sanitizer (strips scripts, event handlers, external hrefs)
18
+
19
+ ## Quick start
20
+
21
+ Requires Node.js 18+.
22
+
23
+ ```bash
24
+ git clone https://github.com/Thomas2032/interactive-markdown.git
25
+ cd interactive-markdown
26
+ npm install
27
+ npm start
28
+ ```
29
+
30
+ Then open the URL printed by the static server (typically `http://localhost:3000`).
31
+
32
+ Equivalent without the npm script:
33
+
34
+ ```bash
35
+ npx serve .
36
+ ```
37
+
38
+ > ES modules need HTTP — opening `index.html` as a `file://` URL will not work.
39
+
40
+ ## IVM dialect (quick reference)
41
+
42
+ Fence name: **`ivm`**.
43
+
44
+ ````markdown
45
+ ```ivm
46
+ @ns optionalSharedName
47
+ @slider amp: 5..60 = 30 step=1 "Amplitude"
48
+ @timer t: 0..100 = 0 speed=20 loop "Phase"
49
+ @input title: "Hello" "Title"
50
+ @toggle show: true "Show"
51
+ @let y = 40 - amp * sin(t * pi / 50)
52
+ @calc a = amp / 2
53
+ @svg
54
+ <svg viewBox="0 0 200 80" xmlns="http://www.w3.org/2000/svg">
55
+ <circle cx="100" cy="{{round(y, 2)}}" r="{{amp}}" fill="#38bdf8"/>
56
+ </svg>
57
+ ```
58
+ ````
59
+
60
+ ### Controls
61
+
62
+ | Line | Meaning |
63
+ |------|---------|
64
+ | `@slider name: min..max = default step=S "Label"` | Range input |
65
+ | `@timer name: min..max = default speed=N loop` | Animated value + Play/Pause (`noloop` to stop at end) |
66
+ | `@input name: "default" "Label"` | Text field |
67
+ | `@toggle name: true\|false "Label"` | Checkbox |
68
+ | `@let name = expression` | Computed value |
69
+ | `@calc name = expression` | Alias of `@let` |
70
+ | `@ns name` | Share variable state across IVM blocks with the same name |
71
+ | `@svg` | Everything after this line is SVG markup |
72
+
73
+ ### Expressions (`{{…}}`)
74
+
75
+ Placeholders use **double braces**: `{{expr}}`.
76
+
77
+ Allowed operators include `+ - * / % ^ **`, comparisons, `&& || !`, and ternary `? :`.
78
+
79
+ Functions include: `abs`, `sqrt`, `pow`, `exp`, `ln`, `log`, `sin`, `cos`, `tan`, `asin`, `acos`, `atan`, `atan2`, `round`, `floor`, `ceil`, `trunc`, `sign`, `min`, `max`, `clamp`, `mod`, `lerp`, `deg`, `rad`.
80
+
81
+ Constants: `pi`, `e`, `tau`.
82
+
83
+ Angles for trig functions are in **radians** (use `deg` / `rad` helpers as needed).
84
+
85
+ ### Fallback
86
+
87
+ In a normal Markdown viewer, an `ivm` fence shows up as a plain code block — still readable, just not interactive.
88
+
89
+ ## Examples
90
+
91
+ Load from the toolbar **Examples…** menu, or open the files directly:
92
+
93
+ - [`examples/amplitude.md`](examples/amplitude.md) — wave amplitude + phase timer
94
+ - [`examples/circular-motion.md`](examples/circular-motion.md) — point on a circle with spin animation
95
+
96
+ ## Project layout
97
+
98
+ ```
99
+ index.html Editor shell
100
+ src/
101
+ app.js UI wiring
102
+ markdown.js MD + IVM pipeline
103
+ parse-ivm.js Fence / control parser
104
+ expr.js Safe expression evaluator
105
+ sanitize-svg.js SVG sanitizer
106
+ render.js Controls + SVG binding
107
+ styles/app.css
108
+ examples/
109
+ test/ Node smoke tests
110
+ ```
111
+
112
+ ## Tests
113
+
114
+ ```bash
115
+ npm test
116
+ ```
117
+
118
+ Smoke tests cover the expression engine, IVM parser, SVG sanitizer, renderer, and Markdown pipeline (`linkedom` is used where a DOM is required).
119
+
120
+ ## License
121
+
122
+ [MIT](LICENSE) © 2026 Thomas / Thomas2032
@@ -0,0 +1,25 @@
1
+ # Wave amplitude
2
+
3
+ Drag **Amplitude** or press **Play** on the phase timer. The path and marker update from `{{expr}}` bindings.
4
+
5
+ ```ivm
6
+ @slider amp: 5..55 = 28 step=1 "Amplitude"
7
+ @timer t: 0..100 = 0 speed=25 loop "Phase"
8
+ @let y = 40 - amp * sin(t * pi / 50)
9
+ @let y2 = 40 - (amp * 0.55) * sin(t * pi / 50 + 1.2)
10
+ @svg
11
+ <svg viewBox="0 0 200 80" width="100%" xmlns="http://www.w3.org/2000/svg">
12
+ <rect x="0" y="0" width="200" height="80" rx="4" fill="#0f172a"/>
13
+ <line x1="10" y1="40" x2="190" y2="40" stroke="#334155" stroke-width="1"/>
14
+ <path d="M 10 40 Q 55 {{round(y,2)}} 100 40 T 190 40"
15
+ fill="none" stroke="#38bdf8" stroke-width="3"/>
16
+ <path d="M 10 40 Q 55 {{round(y2,2)}} 100 40 T 190 40"
17
+ fill="none" stroke="#a78bfa" stroke-width="2" opacity="0.85"/>
18
+ <circle cx="100" cy="{{round(y,2)}}" r="5" fill="#f472b6"/>
19
+ </svg>
20
+ ```
21
+
22
+ ## Tips
23
+
24
+ - `@timer` uses **Play / Pause** in the widget chrome.
25
+ - `@let` values are recomputed whenever a slider or timer changes.
@@ -0,0 +1,25 @@
1
+ # Circular motion
2
+
3
+ Adjust **radius** and **angle**, or animate with the timer. The moving point sits on the circle at angle `a`.
4
+
5
+ ```ivm
6
+ @slider radius: 10..80 = 50 step=1 "Radius"
7
+ @slider angle: 0..360 = 45 step=1 "Angle (°)"
8
+ @timer spin: 0..360 = 0 speed=40 loop "Spin"
9
+ @calc a = angle + spin
10
+ @let x = round(100 + radius * cos(a * pi / 180), 2)
11
+ @let y = round(100 + radius * sin(a * pi / 180), 2)
12
+ @svg
13
+ <svg viewBox="0 0 200 200" width="100%" xmlns="http://www.w3.org/2000/svg">
14
+ <rect x="0" y="0" width="200" height="200" rx="6" fill="#0f172a"/>
15
+ <circle cx="100" cy="100" r="{{radius}}" fill="none" stroke="#475569" stroke-width="2"/>
16
+ <line x1="100" y1="100" x2="{{x}}" y2="{{y}}" stroke="#38bdf8" stroke-width="2"/>
17
+ <circle cx="100" cy="100" r="3" fill="#94a3b8"/>
18
+ <circle cx="{{x}}" cy="{{y}}" r="7" fill="#f472b6"/>
19
+ <text x="8" y="18" fill="#94a3b8" font-size="10" font-family="ui-monospace, monospace">
20
+ a={{round(a,1)}}°
21
+ </text>
22
+ </svg>
23
+ ```
24
+
25
+ Press **Play** on **Spin** to sweep the point around the circle while keeping your base angle offset.
package/index.html ADDED
@@ -0,0 +1,43 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Interactive Markdown</title>
7
+ <link rel="stylesheet" href="styles/app.css" />
8
+ <script src="https://cdn.jsdelivr.net/npm/marked@15.0.7/marked.min.js"></script>
9
+ </head>
10
+ <body>
11
+ <header class="app-header">
12
+ <div class="brand">
13
+ <h1>Interactive Markdown</h1>
14
+ <span>Editor + live IVM preview</span>
15
+ </div>
16
+ <div class="toolbar">
17
+ <button type="button" id="btn-open">Open…</button>
18
+ <button type="button" id="btn-save" class="primary">Save .md</button>
19
+ <button type="button" id="btn-clear">Reset</button>
20
+ <select id="example-select" aria-label="Load example">
21
+ <option value="">Examples…</option>
22
+ <option value="amplitude.md">amplitude.md</option>
23
+ <option value="circular-motion.md">circular-motion.md</option>
24
+ </select>
25
+ <input id="file-input" type="file" accept=".md,text/markdown,text/plain" />
26
+ </div>
27
+ <div id="status">Loading…</div>
28
+ </header>
29
+
30
+ <main class="layout">
31
+ <section class="pane" aria-label="Editor">
32
+ <div class="pane-title">Source (.md)</div>
33
+ <textarea id="editor" spellcheck="false" aria-label="Markdown source"></textarea>
34
+ </section>
35
+ <section class="pane" aria-label="Preview">
36
+ <div class="pane-title">Preview</div>
37
+ <div id="preview"></div>
38
+ </section>
39
+ </main>
40
+
41
+ <script type="module" src="src/app.js"></script>
42
+ </body>
43
+ </html>
package/package.json CHANGED
@@ -1,6 +1,59 @@
1
1
  {
2
2
  "name": "interactive-markdown",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Interactive Markdown (IVM): plain .md with live SVG sliders and timers",
5
+ "main": "./src/index.js",
6
+ "scripts": {
7
+ "start": "serve .",
8
+ "test": "node test/smoke-expr.mjs && node test/smoke-parse-ivm.mjs && node test/smoke-sanitize-svg.mjs && node test/smoke-render.mjs && node test/smoke-markdown.mjs",
9
+ "test:expr": "node test/smoke-expr.mjs",
10
+ "test:parse": "node test/smoke-parse-ivm.mjs",
11
+ "test:sanitize": "node test/smoke-sanitize-svg.mjs",
12
+ "test:render": "node test/smoke-render.mjs",
13
+ "test:markdown": "node test/smoke-markdown.mjs"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/Thomas2032/interactive-markdown.git"
18
+ },
19
+ "keywords": [
20
+ "markdown",
21
+ "svg",
22
+ "interactive",
23
+ "ivm",
24
+ "editor"
25
+ ],
26
+ "author": "Thomas Lo",
27
+ "license": "MIT",
28
+ "bugs": {
29
+ "url": "https://github.com/Thomas2032/interactive-markdown/issues"
30
+ },
31
+ "homepage": "https://github.com/Thomas2032/interactive-markdown#readme",
32
+ "devDependencies": {
33
+ "linkedom": "^0.18.13",
34
+ "serve": "^14.2.6"
35
+ },
36
+ "type": "module",
37
+ "dependencies": {
38
+ "marked": "^18.1.0"
39
+ },
40
+ "exports": {
41
+ ".": "./src/index.js",
42
+ "./expr": "./src/expr.js",
43
+ "./parse-ivm": "./src/parse-ivm.js",
44
+ "./sanitize-svg": "./src/sanitize-svg.js",
45
+ "./render": "./src/render.js",
46
+ "./markdown": "./src/markdown.js"
47
+ },
48
+ "files": [
49
+ "src",
50
+ "examples",
51
+ "styles",
52
+ "index.html",
53
+ "README.md",
54
+ "LICENSE"
55
+ ],
56
+ "engines": {
57
+ "node": ">=18"
58
+ }
59
+ }
package/src/app.js ADDED
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Interactive Markdown — editor + live preview shell.
3
+ */
4
+
5
+ import { renderDocument } from "./markdown.js";
6
+
7
+ const STORAGE_KEY = "interactive-markdown-draft-v1";
8
+
9
+ const editor = document.getElementById("editor");
10
+ const preview = document.getElementById("preview");
11
+ const statusEl = document.getElementById("status");
12
+ const btnOpen = document.getElementById("btn-open");
13
+ const btnSave = document.getElementById("btn-save");
14
+ const btnClear = document.getElementById("btn-clear");
15
+ const fileInput = document.getElementById("file-input");
16
+ const exampleSelect = document.getElementById("example-select");
17
+
18
+ /** @type {{ destroy: () => void } | null} */
19
+ let currentMount = null;
20
+ let debounceTimer = 0;
21
+ let dirty = false;
22
+
23
+ function setStatus(msg) {
24
+ if (statusEl) statusEl.textContent = msg;
25
+ }
26
+
27
+ function renderNow() {
28
+ if (currentMount) {
29
+ currentMount.destroy();
30
+ currentMount = null;
31
+ }
32
+ try {
33
+ currentMount = renderDocument(preview, editor.value);
34
+ setStatus(dirty ? "Edited · preview updated" : "Preview updated");
35
+ } catch (err) {
36
+ preview.innerHTML = `<div class="im-errors">${escapeHtml(err.message || String(err))}</div>`;
37
+ setStatus("Render error");
38
+ console.error(err);
39
+ }
40
+ }
41
+
42
+ function scheduleRender() {
43
+ dirty = true;
44
+ try {
45
+ localStorage.setItem(STORAGE_KEY, editor.value);
46
+ } catch (_) {
47
+ /* ignore quota */
48
+ }
49
+ clearTimeout(debounceTimer);
50
+ debounceTimer = window.setTimeout(renderNow, 180);
51
+ }
52
+
53
+ function escapeHtml(s) {
54
+ return String(s)
55
+ .replace(/&/g, "&amp;")
56
+ .replace(/</g, "&lt;")
57
+ .replace(/>/g, "&gt;")
58
+ .replace(/"/g, "&quot;");
59
+ }
60
+
61
+ function downloadMd(filename, text) {
62
+ const blob = new Blob([text], { type: "text/markdown;charset=utf-8" });
63
+ const url = URL.createObjectURL(blob);
64
+ const a = document.createElement("a");
65
+ a.href = url;
66
+ a.download = filename || "document.md";
67
+ document.body.appendChild(a);
68
+ a.click();
69
+ a.remove();
70
+ URL.revokeObjectURL(url);
71
+ }
72
+
73
+ async function loadExample(name) {
74
+ if (!name) return;
75
+ try {
76
+ const res = await fetch(`examples/${name}`);
77
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
78
+ const text = await res.text();
79
+ editor.value = text;
80
+ dirty = false;
81
+ try {
82
+ localStorage.setItem(STORAGE_KEY, text);
83
+ } catch (_) {}
84
+ renderNow();
85
+ setStatus(`Loaded examples/${name}`);
86
+ } catch (err) {
87
+ setStatus(`Failed to load example: ${err.message}`);
88
+ }
89
+ }
90
+
91
+ const DEFAULT_DOC = `# Interactive Markdown
92
+
93
+ Edit the left pane. The preview updates as you type.
94
+
95
+ Use an \`\`\`ivm\`\`\` fence for sliders, timers, and live SVG.
96
+
97
+ \`\`\`ivm
98
+ @slider amp: 5..60 = 30 step=1 "Amplitude"
99
+ @timer t: 0..100 = 0 speed=20 loop "Phase"
100
+ @let y = 40 - amp * sin(t * pi / 50)
101
+ @svg
102
+ <svg viewBox="0 0 200 80" width="100%" xmlns="http://www.w3.org/2000/svg">
103
+ <rect x="0" y="0" width="200" height="80" fill="#0f172a"/>
104
+ <path d="M 10 40 Q 55 {{round(y,2)}} 100 40 T 190 40"
105
+ fill="none" stroke="#38bdf8" stroke-width="3"/>
106
+ <circle cx="100" cy="{{round(y,2)}}" r="4" fill="#f472b6"/>
107
+ </svg>
108
+ \`\`\`
109
+
110
+ **Quick dialect**
111
+
112
+ - \`@slider name: min..max = default step=S "Label"\`
113
+ - \`@timer name: min..max = default speed=N loop\`
114
+ - \`@let name = expression\` (or \`@calc\`)
115
+ - \`@svg\` then SVG with \`{{expr}}\` placeholders
116
+
117
+ Open a \`.md\` file, or save your draft from the toolbar.
118
+ `;
119
+
120
+ function init() {
121
+ if (!editor || !preview) {
122
+ console.error("Missing #editor or #preview");
123
+ return;
124
+ }
125
+
126
+ let start = DEFAULT_DOC;
127
+ try {
128
+ const saved = localStorage.getItem(STORAGE_KEY);
129
+ if (saved && saved.trim()) start = saved;
130
+ } catch (_) {}
131
+
132
+ editor.value = start;
133
+ editor.addEventListener("input", scheduleRender);
134
+
135
+ btnOpen?.addEventListener("click", () => fileInput?.click());
136
+ fileInput?.addEventListener("change", async () => {
137
+ const file = fileInput.files?.[0];
138
+ if (!file) return;
139
+ const text = await file.text();
140
+ editor.value = text;
141
+ dirty = false;
142
+ try {
143
+ localStorage.setItem(STORAGE_KEY, text);
144
+ } catch (_) {}
145
+ renderNow();
146
+ setStatus(`Opened ${file.name}`);
147
+ fileInput.value = "";
148
+ });
149
+
150
+ btnSave?.addEventListener("click", () => {
151
+ downloadMd("document.md", editor.value);
152
+ dirty = false;
153
+ setStatus("Downloaded document.md");
154
+ });
155
+
156
+ btnClear?.addEventListener("click", () => {
157
+ if (!confirm("Reset editor to the built-in sample and clear the stored draft?")) return;
158
+ editor.value = DEFAULT_DOC;
159
+ try {
160
+ localStorage.removeItem(STORAGE_KEY);
161
+ } catch (_) {}
162
+ dirty = false;
163
+ renderNow();
164
+ setStatus("Reset to default sample");
165
+ });
166
+
167
+ exampleSelect?.addEventListener("change", () => {
168
+ const v = exampleSelect.value;
169
+ if (v) loadExample(v);
170
+ exampleSelect.value = "";
171
+ });
172
+
173
+ window.addEventListener("keydown", (e) => {
174
+ if ((e.metaKey || e.ctrlKey) && e.key === "s") {
175
+ e.preventDefault();
176
+ downloadMd("document.md", editor.value);
177
+ setStatus("Downloaded document.md");
178
+ }
179
+ });
180
+
181
+ renderNow();
182
+ }
183
+
184
+ init();