slidemind 0.1.7 → 0.1.9

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,216 @@
1
+ // A tiny, self-contained markdown → HTML renderer for the constrained subset an
2
+ // agent-authored plan.md uses. Deliberately NO CDN and no dependencies: the plan
3
+ // gate ships this exact source into the browser (exports stripped, exposed as
4
+ // window.__SLIDEMIND_RENDER_MD__) so the editor's live preview renders with the
5
+ // SAME renderer as the served page. Every block still carries its on-disk source
6
+ // line/kind/section stamps — cheap, tested metadata for tooling.
7
+ //
8
+ // Supported blocks: ATX headings (#..######), blank-line-separated paragraphs,
9
+ // unordered (-/*) and ordered (1.) lists with each <li> stamped individually,
10
+ // > blockquotes, ``` fenced code, and simple pipe tables. Inline: **bold**,
11
+ // *italic*, `code`. All text is HTML-escaped BEFORE inline formatting is applied
12
+ // (defense in depth — the content is model/user-authored). Anything unrecognized
13
+ // degrades to a paragraph. One level of nesting only (nested lists may flatten).
14
+
15
+ function escapeHtml(s) {
16
+ return String(s)
17
+ .replace(/&/g, "&amp;")
18
+ .replace(/</g, "&lt;")
19
+ .replace(/>/g, "&gt;");
20
+ }
21
+
22
+ // For an HTML attribute value: escape as text, then also quotes.
23
+ function escapeAttr(s) {
24
+ return escapeHtml(s).replace(/"/g, "&quot;");
25
+ }
26
+
27
+ // Escape first, THEN apply inline formatting. Code spans are handled before
28
+ // bold/italic so their contents aren't re-interpreted; bold before italic so a
29
+ // **run** isn't half-consumed by the single-* rule.
30
+ function renderInline(text) {
31
+ let s = escapeHtml(text);
32
+ s = s.replace(/`([^`]+)`/g, (_m, c) => `<code>${c}</code>`);
33
+ s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
34
+ s = s.replace(/\*([^*]+)\*/g, "<em>$1</em>");
35
+ return s;
36
+ }
37
+
38
+ // Plain-text form of a heading (markers stripped) for the breadcrumb attribute.
39
+ function stripInlineMarkers(text) {
40
+ return String(text)
41
+ .replace(/`([^`]+)`/g, "$1")
42
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
43
+ .replace(/\*([^*]+)\*/g, "$1")
44
+ .trim();
45
+ }
46
+
47
+ function isListItem(line) {
48
+ return /^\s*(?:[-*]\s+|\d+[.)]\s+)/.test(line);
49
+ }
50
+
51
+ // A table separator row: pipes and dashes only, optional alignment colons.
52
+ function isTableSeparator(line) {
53
+ const s = line.trim();
54
+ if (!s.includes("-")) return false;
55
+ return /^\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?$/.test(s);
56
+ }
57
+
58
+ function splitTableRow(line) {
59
+ let s = line.trim();
60
+ if (s.startsWith("|")) s = s.slice(1);
61
+ if (s.endsWith("|")) s = s.slice(0, -1);
62
+ return s.split("|").map((c) => c.trim());
63
+ }
64
+
65
+ // The three stamp attributes carried by every block.
66
+ function stamp(absLine, kind, section) {
67
+ return (
68
+ ` data-md-line="${absLine}" data-md-kind="${kind}"` +
69
+ ` data-md-section="${escapeAttr(section)}"`
70
+ );
71
+ }
72
+
73
+ export function renderPlanMarkdown(body, { lineOffset = 0 } = {}) {
74
+ const lines = String(body).split("\n");
75
+ const out = [];
76
+ let section = ""; // nearest preceding heading text (breadcrumb)
77
+ let i = 0;
78
+ // idx is 0-based into `lines`; local line number is idx+1, absolute adds the
79
+ // frontmatter offset.
80
+ const abs = (idx) => lineOffset + idx + 1;
81
+
82
+ while (i < lines.length) {
83
+ const line = lines[i];
84
+
85
+ if (/^\s*$/.test(line)) {
86
+ i++;
87
+ continue;
88
+ }
89
+
90
+ // Fenced code — stamped on the opening fence, contents escaped verbatim.
91
+ if (/^\s*```/.test(line)) {
92
+ const startIdx = i;
93
+ i++;
94
+ const code = [];
95
+ while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) {
96
+ code.push(lines[i]);
97
+ i++;
98
+ }
99
+ if (i < lines.length) i++; // consume the closing fence
100
+ out.push(
101
+ `<pre${stamp(abs(startIdx), "code", section)}><code>` +
102
+ escapeHtml(code.join("\n")) +
103
+ `</code></pre>`,
104
+ );
105
+ continue;
106
+ }
107
+
108
+ // ATX heading.
109
+ const heading = line.match(/^(#{1,6})\s+(.*)$/);
110
+ if (heading) {
111
+ const level = heading[1].length;
112
+ const raw = heading[2].replace(/\s+#+\s*$/, "").trim(); // drop optional closing #
113
+ section = stripInlineMarkers(raw);
114
+ const kind = "h" + level;
115
+ out.push(
116
+ `<${kind}${stamp(abs(i), kind, section)}>` +
117
+ renderInline(raw) +
118
+ `</${kind}>`,
119
+ );
120
+ i++;
121
+ continue;
122
+ }
123
+
124
+ // Pipe table — header row followed by a separator row.
125
+ if (
126
+ line.includes("|") &&
127
+ i + 1 < lines.length &&
128
+ isTableSeparator(lines[i + 1])
129
+ ) {
130
+ const headerIdx = i;
131
+ const header = splitTableRow(line);
132
+ i += 2; // skip header + separator
133
+ const rows = [];
134
+ while (i < lines.length && !/^\s*$/.test(lines[i]) && lines[i].includes("|")) {
135
+ rows.push(splitTableRow(lines[i]));
136
+ i++;
137
+ }
138
+ let html = `<table${stamp(abs(headerIdx), "table", section)}>`;
139
+ html +=
140
+ "<thead><tr>" +
141
+ header.map((c) => `<th>${renderInline(c)}</th>`).join("") +
142
+ "</tr></thead>";
143
+ html +=
144
+ "<tbody>" +
145
+ rows
146
+ .map(
147
+ (r) => "<tr>" + r.map((c) => `<td>${renderInline(c)}</td>`).join("") + "</tr>",
148
+ )
149
+ .join("") +
150
+ "</tbody>";
151
+ html += "</table>";
152
+ out.push(html);
153
+ continue;
154
+ }
155
+
156
+ // Blockquote — consecutive > lines, joined.
157
+ if (/^\s*>/.test(line)) {
158
+ const startIdx = i;
159
+ const quote = [];
160
+ while (i < lines.length && /^\s*>/.test(lines[i])) {
161
+ quote.push(lines[i].replace(/^\s*>\s?/, ""));
162
+ i++;
163
+ }
164
+ out.push(
165
+ `<blockquote${stamp(abs(startIdx), "blockquote", section)}>` +
166
+ renderInline(quote.join(" ")) +
167
+ `</blockquote>`,
168
+ );
169
+ continue;
170
+ }
171
+
172
+ // List — each <li> stamped with its own line; the wrapper is unstamped.
173
+ if (isListItem(line)) {
174
+ const ordered = /^\s*\d+[.)]\s+/.test(line);
175
+ const tag = ordered ? "ol" : "ul";
176
+ const items = [];
177
+ while (i < lines.length && isListItem(lines[i])) {
178
+ const content = lines[i].replace(/^\s*(?:[-*]|\d+[.)])\s+/, "");
179
+ items.push(
180
+ `<li${stamp(abs(i), "li", section)}>` + renderInline(content) + `</li>`,
181
+ );
182
+ i++;
183
+ }
184
+ out.push(`<${tag}>` + items.join("") + `</${tag}>`);
185
+ continue;
186
+ }
187
+
188
+ // Paragraph (and the degrade-to-<p> fallback for anything unrecognized):
189
+ // gather until a blank line or the start of another block.
190
+ const startIdx = i;
191
+ const para = [];
192
+ while (
193
+ i < lines.length &&
194
+ !/^\s*$/.test(lines[i]) &&
195
+ !/^(#{1,6})\s+/.test(lines[i]) &&
196
+ !/^\s*```/.test(lines[i]) &&
197
+ !/^\s*>/.test(lines[i]) &&
198
+ !isListItem(lines[i]) &&
199
+ !(
200
+ lines[i].includes("|") &&
201
+ i + 1 < lines.length &&
202
+ isTableSeparator(lines[i + 1])
203
+ )
204
+ ) {
205
+ para.push(lines[i]);
206
+ i++;
207
+ }
208
+ out.push(
209
+ `<p${stamp(abs(startIdx), "p", section)}>` +
210
+ renderInline(para.join(" ")) +
211
+ `</p>`,
212
+ );
213
+ }
214
+
215
+ return out.join("\n");
216
+ }