slidemind 0.1.6 → 0.1.8
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/.claude-plugin/plugin.json +1 -7
- package/CLAUDE.md +21 -15
- package/README.md +21 -10
- package/bin/slidemind.mjs +836 -35
- package/package.json +2 -5
- package/skills/create-slides/SKILL.md +85 -60
- package/skills/search/SKILL.md +27 -27
- package/src/bridge/build-and-push.mjs +166 -55
- package/src/bridge/bundle.mjs +49 -7
- package/src/bridge/capture.mjs +2 -2
- package/src/bridge/client.mjs +374 -44
- package/src/bridge/engine-download.mjs +89 -0
- package/src/{mcp → bridge}/recolor.mjs +10 -9
- package/src/builders/build.mjs +261 -23
- package/src/builders/gate-server.mjs +223 -0
- package/src/builders/lint.mjs +2 -2
- package/src/builders/plan-md.mjs +216 -0
- package/src/builders/plan-overlay.js +747 -0
- package/src/builders/plan.mjs +575 -0
- package/src/builders/resolve-skill.mjs +40 -5
- package/src/builders/review-overlay.js +172 -16
- package/src/builders/review.mjs +139 -80
- package/src/installers/claude.mjs +10 -17
- package/src/installers/codex.mjs +14 -77
- package/src/installers/opencode.mjs +10 -18
- package/src/lib/reused-slides.mjs +2 -4
- package/src/mcp/server.mjs +0 -958
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// Shared low-level primitives for the CLI's blocking review gates (the spatial
|
|
2
|
+
// preview gate in review.mjs and the plan sign-off gate in plan.mjs).
|
|
3
|
+
//
|
|
4
|
+
// These were lifted out of review.mjs so both gates share one implementation of
|
|
5
|
+
// the MIME table, the traversal-guarded static responder, the decision promise +
|
|
6
|
+
// timeout wrapper, and the port + tab-reuse machinery: a deterministic per-deck
|
|
7
|
+
// per-gate port (so a tab left waiting from the previous round reconnects to the
|
|
8
|
+
// same URL), a bind that never fails on a taken port (falls back to an
|
|
9
|
+
// OS-assigned one with a stderr notice), and a heartbeat endpoint each gate
|
|
10
|
+
// serves under its own service marker so a waiting tab only ever reloads into
|
|
11
|
+
// its OWN gate's next round — never into the other gate or a foreign app.
|
|
12
|
+
|
|
13
|
+
import crypto from "node:crypto";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
|
|
17
|
+
// Tab-reuse timing contract, shared by both gates and injected into their
|
|
18
|
+
// overlays as CFG.pollMs / CFG.pollTtlMs:
|
|
19
|
+
// GRACE_MS > POLL_MS so a waiting tab always lands one poll inside the
|
|
20
|
+
// open-browser grace window;
|
|
21
|
+
// POLL_TTL_MS > the gates' 30-min decision timeout so an abandoned tab stops
|
|
22
|
+
// polling only after the server side has certainly given up.
|
|
23
|
+
export const GRACE_MS = 1500;
|
|
24
|
+
export const POLL_MS = 1000;
|
|
25
|
+
export const POLL_TTL_MS = 35 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
const PORT_BASE = 40000;
|
|
28
|
+
const PORT_SPAN = 1000; // preferred ports live in 40000..40999
|
|
29
|
+
|
|
30
|
+
// Extension → content-type table (lifted verbatim from review.mjs).
|
|
31
|
+
export const MIME = {
|
|
32
|
+
".html": "text/html; charset=utf-8",
|
|
33
|
+
".css": "text/css; charset=utf-8",
|
|
34
|
+
".js": "text/javascript; charset=utf-8",
|
|
35
|
+
".json": "application/json; charset=utf-8",
|
|
36
|
+
".svg": "image/svg+xml",
|
|
37
|
+
".png": "image/png",
|
|
38
|
+
".jpg": "image/jpeg",
|
|
39
|
+
".jpeg": "image/jpeg",
|
|
40
|
+
".gif": "image/gif",
|
|
41
|
+
".webp": "image/webp",
|
|
42
|
+
".woff": "font/woff",
|
|
43
|
+
".woff2": "font/woff2",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Traversal-guarded static responder, generalized to MULTIPLE roots. `pathname`
|
|
47
|
+
// is expected already URL-decoded (review.mjs decodes once at the top of its
|
|
48
|
+
// request handler and passes the decoded value in). Each root is tried in order:
|
|
49
|
+
// the pathname is resolved within the root and required to stay inside it (via
|
|
50
|
+
// path.sep so the boundary check is correct on Windows). The first root that
|
|
51
|
+
// holds a matching file serves it (200 + MIME, default application/octet-stream)
|
|
52
|
+
// and returns true. If no root matches — file missing everywhere, or a traversal
|
|
53
|
+
// attempt escaped every root — returns false WITHOUT writing a response, leaving
|
|
54
|
+
// the caller to send its own 404.
|
|
55
|
+
export function serveStaticFile({ roots, pathname, res }) {
|
|
56
|
+
const rel = String(pathname).replace(/^\/+/, "");
|
|
57
|
+
for (const root of roots || []) {
|
|
58
|
+
if (!root) continue;
|
|
59
|
+
const base = path.resolve(root);
|
|
60
|
+
const resolved = path.resolve(base, rel);
|
|
61
|
+
if (resolved !== base && !resolved.startsWith(base + path.sep)) {
|
|
62
|
+
continue; // escaped this root — a later root might still legitimately match
|
|
63
|
+
}
|
|
64
|
+
let data;
|
|
65
|
+
try {
|
|
66
|
+
const st = fs.statSync(resolved);
|
|
67
|
+
if (!st.isFile()) continue;
|
|
68
|
+
data = fs.readFileSync(resolved);
|
|
69
|
+
} catch {
|
|
70
|
+
continue; // missing here — try the next root
|
|
71
|
+
}
|
|
72
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
73
|
+
res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream" });
|
|
74
|
+
res.end(data);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The decision promise + timeout wrapper both gates block on. `resolve(value)`
|
|
81
|
+
// settles the internal promise once (a Promise never re-settles, so repeat calls
|
|
82
|
+
// are no-ops). `waitForDecision({ timeoutMs })` returns the promise directly when
|
|
83
|
+
// no/falsy timeout is given, otherwise races it against a setTimeout that rejects
|
|
84
|
+
// with `timeoutMessage`. The timer is cleared the moment the decision wins so the
|
|
85
|
+
// process can exit instead of being held open by a pending timeout.
|
|
86
|
+
//
|
|
87
|
+
// A timeout rejection carries `code: "GATE_TIMEOUT"` so gates can tell "the user
|
|
88
|
+
// never answered" (an expected outcome with its own DECISION: pending contract)
|
|
89
|
+
// apart from a real failure.
|
|
90
|
+
export function createDecisionGate({
|
|
91
|
+
timeoutMessage = "review timed out — no decision received",
|
|
92
|
+
} = {}) {
|
|
93
|
+
let resolveDecision;
|
|
94
|
+
const decisionPromise = new Promise((res) => {
|
|
95
|
+
resolveDecision = res;
|
|
96
|
+
});
|
|
97
|
+
return {
|
|
98
|
+
resolve(value) {
|
|
99
|
+
resolveDecision(value);
|
|
100
|
+
},
|
|
101
|
+
waitForDecision({ timeoutMs } = {}) {
|
|
102
|
+
if (!timeoutMs) return decisionPromise;
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
const t = setTimeout(() => {
|
|
105
|
+
const err = new Error(timeoutMessage);
|
|
106
|
+
err.code = "GATE_TIMEOUT";
|
|
107
|
+
reject(err);
|
|
108
|
+
}, timeoutMs);
|
|
109
|
+
decisionPromise.then((d) => {
|
|
110
|
+
clearTimeout(t);
|
|
111
|
+
resolve(d);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Deterministic per-deck, per-gate preferred port so a tab left waiting from
|
|
119
|
+
// the previous round can reconnect to the same URL. Stateless — derived purely
|
|
120
|
+
// from the resolved deck path plus the gate name. The gate name is the salt:
|
|
121
|
+
// the preview and plan gates of the SAME deck get independent ports, so their
|
|
122
|
+
// waiting tabs can never sit on each other's URL.
|
|
123
|
+
export function preferredGatePort(deckDir, gateName) {
|
|
124
|
+
const key = `${path.resolve(deckDir)}\u0000${gateName}`;
|
|
125
|
+
const n = crypto.createHash("sha1").update(key).digest().readUInt32BE(0);
|
|
126
|
+
return PORT_BASE + (n % PORT_SPAN);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Bind `server` to `port`, falling back to an OS-assigned port when the
|
|
130
|
+
// preferred one is taken or blocked. An http.Server is reusable after a failed
|
|
131
|
+
// listen, so we retry listen(0) on the SAME instance. onFallback runs once,
|
|
132
|
+
// before the retry, so the caller can warn on stderr. EADDRINUSE: port already
|
|
133
|
+
// bound. EACCES: Windows excluded/reserved port range. Anything else (or a
|
|
134
|
+
// fallback-attempt failure) is a real error and rejects.
|
|
135
|
+
export function listenWithFallback(server, port, host, onFallback) {
|
|
136
|
+
return new Promise((resolve, reject) => {
|
|
137
|
+
const onFirstError = (err) => {
|
|
138
|
+
server.removeListener("listening", onFirstListening);
|
|
139
|
+
if (err && (err.code === "EADDRINUSE" || err.code === "EACCES")) {
|
|
140
|
+
onFallback(port, err);
|
|
141
|
+
const onFallbackError = (err2) => {
|
|
142
|
+
server.removeListener("listening", onFallbackListening);
|
|
143
|
+
reject(err2);
|
|
144
|
+
};
|
|
145
|
+
const onFallbackListening = () => {
|
|
146
|
+
server.removeListener("error", onFallbackError);
|
|
147
|
+
resolve();
|
|
148
|
+
};
|
|
149
|
+
server.once("error", onFallbackError);
|
|
150
|
+
server.once("listening", onFallbackListening);
|
|
151
|
+
server.listen(0, host);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
reject(err);
|
|
155
|
+
};
|
|
156
|
+
const onFirstListening = () => {
|
|
157
|
+
server.removeListener("error", onFirstError);
|
|
158
|
+
resolve();
|
|
159
|
+
};
|
|
160
|
+
server.once("error", onFirstError);
|
|
161
|
+
server.once("listening", onFirstListening);
|
|
162
|
+
server.listen(port, host);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Per-run heartbeat state for one gate server. The gate mounts `respond` under
|
|
167
|
+
// its own heartbeat path; a waiting tab polls it and reloads only when BOTH the
|
|
168
|
+
// service marker matches its own gate (a foreign app or the other gate on this
|
|
169
|
+
// port must never trigger a reload) AND the round nonce differs from the one it
|
|
170
|
+
// was served with (same nonce = this run's own orphan — never self-reload).
|
|
171
|
+
// `waitForHeartbeat(ms)` is the server side of the open-browser grace window.
|
|
172
|
+
export function createHeartbeat({ service }) {
|
|
173
|
+
const round = crypto.randomUUID();
|
|
174
|
+
let seen = false;
|
|
175
|
+
const waiters = [];
|
|
176
|
+
return {
|
|
177
|
+
round,
|
|
178
|
+
respond(res) {
|
|
179
|
+
seen = true;
|
|
180
|
+
while (waiters.length) waiters.shift()();
|
|
181
|
+
res.writeHead(200, { "Content-Type": MIME[".json"], "Cache-Control": "no-store" });
|
|
182
|
+
res.end(JSON.stringify({ ok: true, service, round }));
|
|
183
|
+
},
|
|
184
|
+
waitForHeartbeat(ms) {
|
|
185
|
+
if (seen) return Promise.resolve(true);
|
|
186
|
+
return new Promise((resolve) => {
|
|
187
|
+
const t = setTimeout(() => resolve(false), ms);
|
|
188
|
+
waiters.push(() => {
|
|
189
|
+
clearTimeout(t);
|
|
190
|
+
resolve(true);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Print the gate URL, then decide whether to open a browser. If a tab left
|
|
198
|
+
// waiting from a previous comments round pings the gate's heartbeat within the
|
|
199
|
+
// grace window, reuse it (it reloads itself) instead of opening a duplicate.
|
|
200
|
+
// Returns true if an existing tab was reused. `open` is required — gates pass
|
|
201
|
+
// their browser opener so this module stays dependency-light and tests inject
|
|
202
|
+
// a fake. The message strings are per-gate; defaults speak the preview gate's.
|
|
203
|
+
export async function serveAndOpen(server, {
|
|
204
|
+
open,
|
|
205
|
+
graceMs = GRACE_MS,
|
|
206
|
+
readyLabel = "Preview review",
|
|
207
|
+
reuseNoun = "review tab",
|
|
208
|
+
openedLine = "Opened your browser — leave comments or approve, then return here.",
|
|
209
|
+
waitLine = null,
|
|
210
|
+
} = {}) {
|
|
211
|
+
process.stderr.write(`${readyLabel} ready at ${server.url}\n`); // URL always printed first
|
|
212
|
+
const reused = await server.waitForHeartbeat(graceMs);
|
|
213
|
+
if (reused) {
|
|
214
|
+
process.stderr.write(`Reusing your open ${reuseNoun} — no new browser window.\n`);
|
|
215
|
+
} else {
|
|
216
|
+
open(server.url);
|
|
217
|
+
process.stderr.write(`${openedLine}\n`);
|
|
218
|
+
}
|
|
219
|
+
// Spoken to the AGENT running this command: blocking here is the designed
|
|
220
|
+
// behavior, not a hang — don't kill the call because it sits quiet.
|
|
221
|
+
if (waitLine) process.stderr.write(`${waitLine}\n`);
|
|
222
|
+
return reused;
|
|
223
|
+
}
|
package/src/builders/lint.mjs
CHANGED
|
@@ -76,8 +76,8 @@ function resolveEngine(deckDir) {
|
|
|
76
76
|
export async function lintSlidesOrThrow({ deckDir, slideFiles, layoutSources }) {
|
|
77
77
|
const { enginePath, version, primitives } = resolveEngine(deckDir);
|
|
78
78
|
|
|
79
|
-
// `?v=` busts Node's per-process module cache so a
|
|
80
|
-
//
|
|
79
|
+
// `?v=` busts Node's per-process module cache so a re-import picks up a
|
|
80
|
+
// refreshed engine at the same path after a bundle sync.
|
|
81
81
|
const engine = await import(
|
|
82
82
|
`${pathToFileURL(enginePath).href}?v=${encodeURIComponent(version)}`
|
|
83
83
|
);
|
|
@@ -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, "&")
|
|
18
|
+
.replace(/</g, "<")
|
|
19
|
+
.replace(/>/g, ">");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// For an HTML attribute value: escape as text, then also quotes.
|
|
23
|
+
function escapeAttr(s) {
|
|
24
|
+
return escapeHtml(s).replace(/"/g, """);
|
|
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
|
+
}
|