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.
- package/CLAUDE.md +10 -7
- package/README.md +20 -7
- package/bin/slidemind.mjs +393 -39
- package/package.json +2 -1
- package/skills/create-slides/SKILL.md +68 -16
- package/skills/search/SKILL.md +15 -2
- package/src/bridge/build-and-push.mjs +11 -0
- package/src/bridge/bundle.mjs +15 -4
- package/src/bridge/capture.mjs +20 -2
- package/src/bridge/client.mjs +99 -0
- package/src/builders/build.mjs +83 -11
- package/src/builders/gate-server.mjs +364 -0
- package/src/builders/plan-markdown-edit.mjs +120 -0
- package/src/builders/plan-md.mjs +216 -0
- package/src/builders/plan-overlay.js +1778 -0
- package/src/builders/plan.mjs +696 -0
- package/src/builders/red-thread-backbone.mjs +194 -0
- package/src/builders/resolve-skill.mjs +40 -5
- package/src/builders/review-overlay.js +191 -13
- package/src/builders/review.mjs +196 -68
- package/src/builders/storyboard-markdown.mjs +118 -0
- package/src/builders/storyboard-schema.mjs +180 -0
- package/src/builders/visual-families.mjs +32 -0
- package/src/pptx/app-props.mjs +222 -0
- package/src/pptx/hidden-ole.mjs +125 -0
- package/src/pptx/image-extensions.mjs +190 -0
- package/src/pptx/ooxml.mjs +169 -0
- package/src/pptx/read-local.mjs +206 -0
- package/src/pptx/slide-swap.mjs +429 -0
- package/src/pptx/zip.mjs +30 -0
|
@@ -0,0 +1,364 @@
|
|
|
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 with its own round nonce and
|
|
168
|
+
// visibility state, and the response's service marker + round nonce + `action`
|
|
169
|
+
// tell it what to do (a foreign app or the other gate on this port must never
|
|
170
|
+
// trigger a reload; same nonce = this run's own orphan — never a waiter).
|
|
171
|
+
//
|
|
172
|
+
// The gate must ALWAYS end up visibly open in front of the user, so tab reuse
|
|
173
|
+
// is granted only to a waiting tab that reports itself VISIBLE (`visible: true`,
|
|
174
|
+
// from the overlay's document.visibilityState) inside the grace window. A tab
|
|
175
|
+
// buried in a background window must not silently swallow the round — it keeps
|
|
176
|
+
// polling (`action: "wait"`) until the grace window decides, then either
|
|
177
|
+
// reloads (`"reload"`, a visible tab won reuse — every waiter follows it into
|
|
178
|
+
// the new round) or is told the round opened in a fresh browser tab and it
|
|
179
|
+
// should retire (`"close"`).
|
|
180
|
+
//
|
|
181
|
+
// `waitForHeartbeat(ms)` is the server side of the open-browser grace window:
|
|
182
|
+
// it resolves true the moment a visible waiter claims reuse, false when the
|
|
183
|
+
// grace expires with none. The gate then calls `markFresh()` before opening a
|
|
184
|
+
// new browser tab so late pollers get `action: "close"` instead of reloading
|
|
185
|
+
// into a duplicate.
|
|
186
|
+
//
|
|
187
|
+
// `notePageLoad()` / `waitForPageLoad(ms)` / `pageLoaded()` track whether the
|
|
188
|
+
// gate PAGE was ever actually fetched: the gate's request handler calls
|
|
189
|
+
// `notePageLoad()` whenever it serves the root page, and serveAndOpen waits on
|
|
190
|
+
// it after launching the opener — the only end-to-end proof that a browser
|
|
191
|
+
// really opened the gate (a spawned opener exiting 0 proves nothing on a box
|
|
192
|
+
// with a broken default-browser association). `pageLoaded()` is re-checked at
|
|
193
|
+
// decision-timeout time so the DECISION: pending contract can tell the agent
|
|
194
|
+
// whether the user ever saw the gate at all.
|
|
195
|
+
export function createHeartbeat({ service }) {
|
|
196
|
+
const round = crypto.randomUUID();
|
|
197
|
+
// null = undecided (grace window running), "reuse" = a visible waiting tab
|
|
198
|
+
// reloads into this round, "fresh" = a new browser tab was opened for it.
|
|
199
|
+
let mode = null;
|
|
200
|
+
const waiters = [];
|
|
201
|
+
let pageSeen = false;
|
|
202
|
+
const pageWaiters = [];
|
|
203
|
+
return {
|
|
204
|
+
round,
|
|
205
|
+
notePageLoad() {
|
|
206
|
+
pageSeen = true;
|
|
207
|
+
while (pageWaiters.length) pageWaiters.shift()();
|
|
208
|
+
},
|
|
209
|
+
pageLoaded() {
|
|
210
|
+
return pageSeen;
|
|
211
|
+
},
|
|
212
|
+
waitForPageLoad(ms) {
|
|
213
|
+
if (pageSeen) return Promise.resolve(true);
|
|
214
|
+
if (!(ms > 0)) return Promise.resolve(false);
|
|
215
|
+
return new Promise((resolve) => {
|
|
216
|
+
const t = setTimeout(() => resolve(false), ms);
|
|
217
|
+
pageWaiters.push(() => {
|
|
218
|
+
clearTimeout(t);
|
|
219
|
+
resolve(true);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
},
|
|
223
|
+
respond(res, { visible = false, tabRound = null } = {}) {
|
|
224
|
+
// A poll carrying THIS run's nonce is our own page (or an orphan of this
|
|
225
|
+
// run) — never a previous-round waiter, never a reuse claim.
|
|
226
|
+
const isWaiter = tabRound !== round;
|
|
227
|
+
if (isWaiter && mode === null && visible) {
|
|
228
|
+
mode = "reuse";
|
|
229
|
+
while (waiters.length) waiters.shift()();
|
|
230
|
+
}
|
|
231
|
+
const action = !isWaiter
|
|
232
|
+
? "none"
|
|
233
|
+
: mode === "reuse"
|
|
234
|
+
? "reload"
|
|
235
|
+
: mode === "fresh"
|
|
236
|
+
? "close"
|
|
237
|
+
: "wait";
|
|
238
|
+
res.writeHead(200, { "Content-Type": MIME[".json"], "Cache-Control": "no-store" });
|
|
239
|
+
res.end(JSON.stringify({ ok: true, service, round, action }));
|
|
240
|
+
},
|
|
241
|
+
markFresh() {
|
|
242
|
+
if (mode === null) mode = "fresh";
|
|
243
|
+
},
|
|
244
|
+
waitForHeartbeat(ms) {
|
|
245
|
+
if (mode === "reuse") return Promise.resolve(true);
|
|
246
|
+
if (mode === "fresh" || !(ms > 0)) return Promise.resolve(false);
|
|
247
|
+
return new Promise((resolve) => {
|
|
248
|
+
const t = setTimeout(() => resolve(false), ms);
|
|
249
|
+
waiters.push(() => {
|
|
250
|
+
clearTimeout(t);
|
|
251
|
+
resolve(true);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Browser-status lines for a gate's DECISION: pending output, shared by both
|
|
259
|
+
// gates so the wording is identical. A pending decision has two very different
|
|
260
|
+
// stories: the user saw the gate and hasn't answered (benign — just wait), or
|
|
261
|
+
// no tab ever loaded it (the browser open failed and they never saw anything —
|
|
262
|
+
// the agent must hand them the link). `pageLoaded` comes from the server's
|
|
263
|
+
// page-load tracker re-checked at timeout time, so a tab that opened late still
|
|
264
|
+
// counts. Returns [] when the caller has no status to report (url unknown),
|
|
265
|
+
// keeping older call sites and tests valid.
|
|
266
|
+
export function pendingBrowserLines({ pageLoaded = null, url = null } = {}) {
|
|
267
|
+
if (!url) return [];
|
|
268
|
+
if (pageLoaded) {
|
|
269
|
+
return [
|
|
270
|
+
`Browser status: the gate DID open in the user's browser (a tab loaded ${url})`,
|
|
271
|
+
"— they just haven't decided yet.",
|
|
272
|
+
];
|
|
273
|
+
}
|
|
274
|
+
return [
|
|
275
|
+
"Browser status: NO tab ever loaded the gate — the browser most likely FAILED",
|
|
276
|
+
"to open on the user's machine, so they never saw it. When you tell the user,",
|
|
277
|
+
`give them the gate link to open manually once you re-run: ${url}`,
|
|
278
|
+
"(the re-run binds the same address when its port is free, and prints it again).",
|
|
279
|
+
];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// How long serveAndOpen waits, after launching the opener, for the fresh tab
|
|
283
|
+
// to actually fetch the gate page before reporting the open as unconfirmed.
|
|
284
|
+
// Generous enough for a cold browser start; the gate blocks on the decision
|
|
285
|
+
// anyway, so this delay only defers one status line.
|
|
286
|
+
export const OPEN_CONFIRM_MS = 8000;
|
|
287
|
+
|
|
288
|
+
// When the opener chain FAILED outright, the user will never see the gate on
|
|
289
|
+
// their own — blocking the agent for the full decision timeout helps nobody.
|
|
290
|
+
// The gates instead give a manually-opened tab this long to appear (the URL was
|
|
291
|
+
// printed, the user may click it from the terminal), then return the pending
|
|
292
|
+
// contract early so the agent can hand the user the link.
|
|
293
|
+
export const OPEN_FAILED_GRACE_MS = 90 * 1000;
|
|
294
|
+
|
|
295
|
+
// Print the gate URL, then decide whether to open a browser. If a VISIBLE tab
|
|
296
|
+
// left waiting from a previous comments round pings the gate's heartbeat within
|
|
297
|
+
// the grace window, reuse it (it reloads itself) instead of opening a duplicate.
|
|
298
|
+
// In every other case — first run, no waiting tab, or only hidden/background
|
|
299
|
+
// tabs polling — the browser is ALWAYS opened, and `markOpenedFresh` retires
|
|
300
|
+
// the hidden waiters so they don't reload into a duplicate.
|
|
301
|
+
//
|
|
302
|
+
// Opening a browser from a CLI is error-prone, so the outcome is REPORTED, not
|
|
303
|
+
// assumed: the opener's own result says whether anything launched, and the
|
|
304
|
+
// server's page-load tracker (`waitForPageLoad`, fed by the gate serving its
|
|
305
|
+
// root page) is the end-to-end proof a tab really opened. Every branch prints
|
|
306
|
+
// one agent-facing `BROWSER:` line that (a) says explicitly whether the gate
|
|
307
|
+
// auto-opened and (b) always carries the URL, so the agent can either tell the
|
|
308
|
+
// user "it's on your screen" or hand them the link to open manually.
|
|
309
|
+
//
|
|
310
|
+
// Returns { reused, launched, confirmed, url }: `launched` = an opener
|
|
311
|
+
// plausibly ran (null when reused — nothing was launched); `confirmed` = a tab
|
|
312
|
+
// provably fetched the gate page (null when the server exposes no
|
|
313
|
+
// waitForPageLoad, i.e. unknown). `open` is required — gates pass their browser
|
|
314
|
+
// opener so this module stays dependency-light and tests inject a fake; a
|
|
315
|
+
// legacy/fake opener returning undefined counts as launched (only an explicit
|
|
316
|
+
// `false` means the chain failed). The message nouns are per-gate; defaults
|
|
317
|
+
// speak the preview gate's.
|
|
318
|
+
export async function serveAndOpen(server, {
|
|
319
|
+
open,
|
|
320
|
+
graceMs = GRACE_MS,
|
|
321
|
+
openConfirmMs = OPEN_CONFIRM_MS,
|
|
322
|
+
readyLabel = "Preview review",
|
|
323
|
+
reuseNoun = "review tab",
|
|
324
|
+
gateNoun = "review",
|
|
325
|
+
waitLine = null,
|
|
326
|
+
} = {}) {
|
|
327
|
+
process.stderr.write(`${readyLabel} ready at ${server.url}\n`); // URL always printed first
|
|
328
|
+
const reused = await server.waitForHeartbeat(graceMs);
|
|
329
|
+
let launched = null;
|
|
330
|
+
let confirmed = null;
|
|
331
|
+
if (reused) {
|
|
332
|
+
process.stderr.write(
|
|
333
|
+
`BROWSER: reused the user's already-open ${reuseNoun} at ${server.url} — it reloaded into this round; no new window was opened.\n`,
|
|
334
|
+
);
|
|
335
|
+
confirmed = true;
|
|
336
|
+
} else {
|
|
337
|
+
if (server.markOpenedFresh) server.markOpenedFresh();
|
|
338
|
+
launched = (await open(server.url)) !== false;
|
|
339
|
+
if (launched && server.waitForPageLoad) {
|
|
340
|
+
confirmed = await server.waitForPageLoad(openConfirmMs);
|
|
341
|
+
}
|
|
342
|
+
if (!launched) {
|
|
343
|
+
process.stderr.write(
|
|
344
|
+
`BROWSER: could NOT be opened automatically (no opener worked on this machine). ` +
|
|
345
|
+
`The user must open the ${gateNoun} themselves — give them this link: ${server.url}\n`,
|
|
346
|
+
);
|
|
347
|
+
} else if (confirmed === false) {
|
|
348
|
+
process.stderr.write(
|
|
349
|
+
`BROWSER: open was attempted but NO tab has loaded the ${gateNoun} yet — it may not have opened. ` +
|
|
350
|
+
`If the user doesn't see it, give them this link to open manually: ${server.url}\n`,
|
|
351
|
+
);
|
|
352
|
+
} else {
|
|
353
|
+
// confirmed === true (a tab fetched the page) or null (server can't track
|
|
354
|
+
// page loads — trust the opener's success).
|
|
355
|
+
process.stderr.write(
|
|
356
|
+
`BROWSER: opened automatically — the ${gateNoun} is on the user's screen at ${server.url}.\n`,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
// Spoken to the AGENT running this command: blocking here is the designed
|
|
361
|
+
// behavior, not a hang — don't kill the call because it sits quiet.
|
|
362
|
+
if (waitLine) process.stderr.write(`${waitLine}\n`);
|
|
363
|
+
return { reused, launched, confirmed, url: server.url };
|
|
364
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline-edit round-trip for the plan gate's rendered markdown blocks.
|
|
3
|
+
*
|
|
4
|
+
* The plan preview renders each `contenteditable` block stamped with its
|
|
5
|
+
* on-disk source line (`data-md-line`, 1-based, from plan-md.mjs) and kind
|
|
6
|
+
* (`data-md-kind`). When the user commits an edit, `replaceRenderedMarkdownBlock`
|
|
7
|
+
* maps the block's edited innerText back into the markdown source at that
|
|
8
|
+
* line, preserving list markers, heading levels, and blockquote prefixes.
|
|
9
|
+
* `addEditableAttributes` injects the `contenteditable`/`spellcheck` attributes
|
|
10
|
+
* onto the editable block kinds only.
|
|
11
|
+
*
|
|
12
|
+
* Pure string logic — no DOM, no Office (unit-testable).
|
|
13
|
+
*
|
|
14
|
+
* 1:1 port of the add-in's plan-markdown-edit.ts. Top-level helpers are
|
|
15
|
+
* prefixed `pme…` to keep the single-IIFE browser bundle collision-free;
|
|
16
|
+
* exported API names match the add-in verbatim.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export function isEditableKind(kind) {
|
|
20
|
+
return (
|
|
21
|
+
kind === "p" ||
|
|
22
|
+
kind === "li" ||
|
|
23
|
+
kind === "blockquote" ||
|
|
24
|
+
/^h[1-6]$/.test(kind)
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function pmeIsBlockStart(line) {
|
|
29
|
+
return (
|
|
30
|
+
/^\s*$/.test(line) ||
|
|
31
|
+
/^(#{1,6})\s+/.test(line) ||
|
|
32
|
+
/^\s*```/.test(line) ||
|
|
33
|
+
/^\s*>/.test(line) ||
|
|
34
|
+
/^\s*(?:[-*]\s+|\d+[.)]\s+)/.test(line) ||
|
|
35
|
+
(line.includes("|") && /^\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?$/.test(line))
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function pmeGetParagraphRange(lines, index) {
|
|
40
|
+
let end = index + 1;
|
|
41
|
+
while (end < lines.length && !pmeIsBlockStart(lines[end] ?? "")) {
|
|
42
|
+
end++;
|
|
43
|
+
}
|
|
44
|
+
return { start: index, end };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function pmeNormalizeEditableText(text) {
|
|
48
|
+
return text
|
|
49
|
+
.replace(/\u00a0/g, " ")
|
|
50
|
+
.replace(/[ \t]+\n/g, "\n")
|
|
51
|
+
.trim();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function pmeMarkdownForEdit(kind, existingLine, text) {
|
|
55
|
+
const normalized = pmeNormalizeEditableText(text);
|
|
56
|
+
if (!normalized) return null;
|
|
57
|
+
|
|
58
|
+
const heading = normalized.match(/^(#{1,6})\s+(.*)$/);
|
|
59
|
+
if (heading) {
|
|
60
|
+
return `${heading[1]} ${(heading[2] ?? "").trim()}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const list = normalized.match(/^((?:[-*])|\d+[.)])\s+(.*)$/);
|
|
64
|
+
if (list) {
|
|
65
|
+
return `${list[1]} ${(list[2] ?? "").trim()}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (/^h[1-6]$/.test(kind)) {
|
|
69
|
+
const current = existingLine.match(/^(#{1,6})\s+/)?.[1] ?? "##";
|
|
70
|
+
return `${current} ${normalized}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (kind === "li") {
|
|
74
|
+
const marker = existingLine.match(/^\s*((?:[-*])|\d+[.)])\s+/)?.[1] ?? "-";
|
|
75
|
+
return `${marker} ${normalized}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (kind === "blockquote") {
|
|
79
|
+
return normalized
|
|
80
|
+
.split("\n")
|
|
81
|
+
.map((line) => `> ${line.trim()}`)
|
|
82
|
+
.join("\n");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return normalized;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Maps an edited rendered block's innerText back into the markdown source at
|
|
90
|
+
* the stamped 1-based line. Options: { markdown, line, kind, text }.
|
|
91
|
+
* No-op for non-editable kinds or out-of-range lines.
|
|
92
|
+
*/
|
|
93
|
+
export function replaceRenderedMarkdownBlock(options) {
|
|
94
|
+
const { markdown, line, kind, text } = options;
|
|
95
|
+
if (!isEditableKind(kind)) return markdown;
|
|
96
|
+
|
|
97
|
+
const lines = markdown.split("\n");
|
|
98
|
+
const index = line - 1;
|
|
99
|
+
if (index < 0 || index >= lines.length) return markdown;
|
|
100
|
+
|
|
101
|
+
const existingLine = lines[index] ?? "";
|
|
102
|
+
const range =
|
|
103
|
+
kind === "p"
|
|
104
|
+
? pmeGetParagraphRange(lines, index)
|
|
105
|
+
: { start: index, end: index + 1 };
|
|
106
|
+
const replacement = pmeMarkdownForEdit(kind, existingLine, text);
|
|
107
|
+
const nextLines = [
|
|
108
|
+
...lines.slice(0, range.start),
|
|
109
|
+
...(replacement === null ? [] : replacement.split("\n")),
|
|
110
|
+
...lines.slice(range.end),
|
|
111
|
+
];
|
|
112
|
+
return nextLines.join("\n");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function addEditableAttributes(html) {
|
|
116
|
+
return html.replace(
|
|
117
|
+
/data-md-kind="(h[1-6]|p|li|blockquote)"/g,
|
|
118
|
+
'data-md-kind="$1" contenteditable="true" spellcheck="true"',
|
|
119
|
+
);
|
|
120
|
+
}
|