pindeck 0.0.0-stage → 2.8.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/lib/eids.mjs ADDED
@@ -0,0 +1,608 @@
1
+ /* Stable element ids for PinDeck.
2
+
3
+ Gives every slide <section> and every visible element inside it a readable
4
+ data-eid attribute ("summary", "summary/callout", "summary/li-2"), so the
5
+ editor, the pins and Claude can all name the same element.
6
+
7
+ The HTML is edited as text: only the attribute is inserted, so comments,
8
+ indentation and everything else in the file stay exactly as they are.
9
+ Existing ids are kept; duplicates (e.g. from a copied slide) are renamed. */
10
+
11
+ const VOID = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta",
12
+ "param", "source", "track", "wbr"]);
13
+ const RAW = new Set(["script", "style", "textarea", "title"]);
14
+
15
+ /* Elements that get an id. Inline formatting (strong, em, sup, br, ...) does not:
16
+ a pin on bold text resolves to the enclosing element plus the clicked word. */
17
+ const TAGGED = new Set(["section", "h1", "h2", "h3", "h4", "h5", "h6", "p", "ul", "ol", "li",
18
+ "dl", "dt", "dd", "figure", "figcaption", "img", "svg", "table", "caption", "tr", "div",
19
+ "button", "audio", "video", "iframe", "pre", "blockquote", "canvas", "header", "footer",
20
+ "aside", "nav", "article"]);
21
+ const GENERIC_CLASSES = new Set(["fragment", "slide", "visible", "current-fragment", "present",
22
+ "past", "future", "top", "left", "right", "center"]);
23
+
24
+ const ATTR_RE = /([^\s"'=\/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
25
+
26
+ function parseAttrs(src, offset) {
27
+ const attrs = {};
28
+ let m;
29
+ ATTR_RE.lastIndex = 0;
30
+ while ((m = ATTR_RE.exec(src))) {
31
+ const name = m[1].toLowerCase();
32
+ const value = m[2] ?? m[3] ?? m[4] ?? "";
33
+ let valueStart = -1;
34
+ if (m[2] !== undefined || m[3] !== undefined || m[4] !== undefined) {
35
+ let k = m[0].indexOf("=") + 1;
36
+ while (/\s/.test(m[0][k])) k++;
37
+ if (m[0][k] === '"' || m[0][k] === "'") k++;
38
+ valueStart = offset + m.index + k;
39
+ }
40
+ const start = offset + m.index;
41
+ attrs[name] = { value, valueStart, valueEnd: valueStart + value.length, start, end: start + m[0].length };
42
+ }
43
+ return attrs;
44
+ }
45
+
46
+ /* Minimal HTML tokenizer: builds an element tree with source offsets. */
47
+ export function parse(html) {
48
+ const root = { name: "#root", children: [], parent: null };
49
+ let cur = root;
50
+ let i = 0;
51
+ const n = html.length;
52
+ while (i < n) {
53
+ const lt = html.indexOf("<", i);
54
+ if (lt < 0) break;
55
+ if (html.startsWith("<!--", lt)) {
56
+ const end = html.indexOf("-->", lt + 4);
57
+ i = end < 0 ? n : end + 3;
58
+ continue;
59
+ }
60
+ if (html[lt + 1] === "!" || html[lt + 1] === "?") {
61
+ const end = html.indexOf(">", lt);
62
+ i = end < 0 ? n : end + 1;
63
+ continue;
64
+ }
65
+ if (html[lt + 1] === "/") {
66
+ const m = /^<\/\s*([a-zA-Z][\w:-]*)\s*>/.exec(html.slice(lt, lt + 80));
67
+ if (!m) { i = lt + 1; continue; }
68
+ const name = m[1].toLowerCase();
69
+ // pop to the matching element (tolerates unclosed <p>/<li>)
70
+ let node = cur;
71
+ while (node !== root && node.name !== name) node = node.parent;
72
+ if (node !== root) {
73
+ let c = cur;
74
+ while (c !== node) { c.closeStart = lt; c = c.parent; }
75
+ node.closeStart = lt;
76
+ cur = node.parent;
77
+ }
78
+ i = lt + m[0].length;
79
+ continue;
80
+ }
81
+ if (!/[a-zA-Z]/.test(html[lt + 1] || "")) { i = lt + 1; continue; }
82
+
83
+ // opening tag: scan to '>' outside quotes
84
+ let j = lt + 1;
85
+ while (j < n && /[\w:-]/.test(html[j])) j++;
86
+ const name = html.slice(lt + 1, j).toLowerCase();
87
+ const nameEnd = j;
88
+ let quote = null;
89
+ while (j < n) {
90
+ const c = html[j];
91
+ if (quote) { if (c === quote) quote = null; }
92
+ else if (c === '"' || c === "'") quote = c;
93
+ else if (c === ">") break;
94
+ j++;
95
+ }
96
+ const tagEnd = j; // index of '>'
97
+ const selfClose = html[tagEnd - 1] === "/";
98
+ const attrSrc = html.slice(nameEnd, selfClose ? tagEnd - 1 : tagEnd);
99
+ const node = {
100
+ name, start: lt, nameEnd, tagEnd, selfClose,
101
+ attrs: parseAttrs(attrSrc, nameEnd),
102
+ children: [], parent: cur, contentStart: tagEnd + 1, closeStart: -1,
103
+ };
104
+ cur.children.push(node);
105
+ i = tagEnd + 1;
106
+ if (VOID.has(name) || selfClose) continue;
107
+ if (RAW.has(name)) {
108
+ // a case-insensitive search in place: toLowerCase() can change the length (İ), and with it the offsets
109
+ const re = new RegExp("</" + name, "gi");
110
+ re.lastIndex = i;
111
+ const close = re.exec(html)?.index ?? -1;
112
+ node.closeStart = close < 0 ? n : close;
113
+ const gt = html.indexOf(">", node.closeStart);
114
+ i = gt < 0 ? n : gt + 1;
115
+ continue;
116
+ }
117
+ cur = node;
118
+ }
119
+ return root;
120
+ }
121
+
122
+ const classesOf = (node) => (node.attrs.class?.value || "").split(/\s+/).filter(Boolean);
123
+
124
+ const ENTITIES = { nbsp: " ", amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
125
+ function decode(s) { // one pass, so "&amp;lt;" stays "&lt;"
126
+ return s.replace(/&(?:(nbsp|amp|lt|gt|quot|apos)|#(\d+)|#x([0-9a-f]+));/gi, (m, name, d, x) =>
127
+ name ? ENTITIES[name.toLowerCase()] : String.fromCodePoint(d ? +d : parseInt(x, 16)));
128
+ }
129
+ function textOf(html, node) {
130
+ if (node.closeStart < 0) return "";
131
+ return decode(html.slice(node.contentStart, node.closeStart).replace(/<[^>]*>/g, " "))
132
+ .replace(/\\[()[\]]/g, " ").replace(/\s+/g, " ").trim();
133
+ }
134
+ function slug(text, maxWords = 4) {
135
+ const words = text.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase()
136
+ .replace(/[^a-z0-9]+/g, " ").trim().split(" ").filter(Boolean);
137
+ return words.slice(0, maxWords).join("-");
138
+ }
139
+ export function walk(node, fn) {
140
+ for (const c of node.children) { if (fn(c) !== false) walk(c, fn); }
141
+ }
142
+ function find(node, pred) {
143
+ let hit = null;
144
+ walk(node, (c) => { if (hit) return false; if (pred(c)) { hit = c; return false; } });
145
+ return hit;
146
+ }
147
+
148
+ /* Slides are the <section>s inside the element with class "slides" (Reveal.js). */
149
+ export function findSlides(tree) {
150
+ const slidesEl = find(tree, (c) => classesOf(c).includes("slides"));
151
+ if (!slidesEl) return [];
152
+ const out = [];
153
+ const visit = (node) => {
154
+ for (const c of node.children) {
155
+ if (c.name !== "section") continue;
156
+ out.push(c);
157
+ visit(c); // vertical stacks
158
+ }
159
+ };
160
+ visit(slidesEl);
161
+ return out;
162
+ }
163
+
164
+ function slideTitle(html, section) {
165
+ const cls = classesOf(section);
166
+ const heading = find(section, (c) => /^h[1-3]$/.test(c.name));
167
+ let main = "", sub = "";
168
+ if (heading) {
169
+ const subEl = find(heading, (c) => classesOf(c).includes("sub"));
170
+ sub = subEl ? textOf(html, subEl) : "";
171
+ main = textOf(html, heading);
172
+ if (sub && main.endsWith(sub)) main = main.slice(0, -sub.length).trim();
173
+ }
174
+ return { main, sub, kind: cls.find((c) => /-slide$/.test(c)) || "content" };
175
+ }
176
+
177
+ function elementName(node) {
178
+ if (node.name === "g") {
179
+ const idx = node.attrs["data-fragment-index"]?.value;
180
+ return idx ? `step-${idx}` : "g";
181
+ }
182
+ const cls = classesOf(node).find((c) => !GENERIC_CLASSES.has(c));
183
+ return cls ? slug(cls.replace(/-/g, " "), 3) || node.name : node.name;
184
+ }
185
+
186
+ /* Returns { html, changed, assigned: [eid...], renamed: [[old,new]...], slides: [{eid,title,kind}] } */
187
+ export function assignEids(html) {
188
+ const tree = parse(html);
189
+ const sections = findSlides(tree);
190
+ const used = new Set();
191
+ const edits = []; // { at, insert } or { start, end, replace }
192
+ const assigned = [], renamed = [];
193
+
194
+ // Pass 1: slide ids. Content slides that share a main title ("Background") are named by subtitle.
195
+ const titles = sections.map((s) => slideTitle(html, s));
196
+ const mainCount = {};
197
+ titles.forEach((t) => { if (t.main) mainCount[t.main] = (mainCount[t.main] || 0) + 1; });
198
+
199
+ const unique = (base) => {
200
+ let name = base, k = 2;
201
+ while (used.has(name)) name = `${base}-${k++}`;
202
+ used.add(name);
203
+ return name;
204
+ };
205
+ const setEid = (node, eid) => {
206
+ const existing = node.attrs["data-eid"];
207
+ if (existing) {
208
+ edits.push({ start: existing.valueStart, end: existing.valueEnd, replace: eid });
209
+ renamed.push([existing.value, eid]);
210
+ } else {
211
+ let at = node.selfClose ? node.tagEnd - 1 : node.tagEnd;
212
+ while (at > node.nameEnd && /\s/.test(html[at - 1])) at--;
213
+ edits.push({ start: at, end: at, replace: ` data-eid="${eid}"` });
214
+ assigned.push(eid);
215
+ }
216
+ node.eid = eid;
217
+ };
218
+
219
+ // Keep every existing, non-duplicate id first so new names never collide with them.
220
+ const keep = new Set();
221
+ sections.forEach((s) => walk({ children: [s] }, (c) => {
222
+ const v = c.attrs?.["data-eid"]?.value;
223
+ if (v && !used.has(v)) { used.add(v); keep.add(c); c.eid = v; }
224
+ }));
225
+
226
+ const slides = [];
227
+ sections.forEach((section, i) => {
228
+ const t = titles[i];
229
+ if (!keep.has(section)) {
230
+ let base;
231
+ if (t.kind === "title-slide") base = "title";
232
+ else if (t.kind === "closing-slide") base = "closing";
233
+ else if (t.sub && mainCount[t.main] > 1) base = slug(t.sub);
234
+ else base = slug(t.main) || slug(t.sub);
235
+ setEid(section, unique(base || `slide-${i + 1}`));
236
+ }
237
+ slides.push({ eid: section.eid, title: [t.main, t.sub].filter(Boolean).join(" / "), kind: t.kind });
238
+
239
+ // Pass 2: elements inside the slide
240
+ const visit = (node, inSvg) => {
241
+ for (const c of node.children) {
242
+ if (c.name === "section") continue; // nested slide, handled on its own
243
+ const cls = classesOf(c);
244
+ if (c.name === "aside" && cls.includes("notes")) continue;
245
+ if (c.attrs["data-noeid"]) continue;
246
+ const isSvgRoot = c.name === "svg";
247
+ const tagged = inSvg ? c.name === "g" : (TAGGED.has(c.name) || (c.name === "span" && cls.length > 0));
248
+ if (tagged && !keep.has(c)) setEid(c, unique(`${section.eid}/${elementName(c)}`));
249
+ visit(c, inSvg || isSvgRoot);
250
+ }
251
+ };
252
+ visit(section, false);
253
+ });
254
+
255
+ if (!edits.length) return { html, changed: false, assigned, renamed, slides };
256
+ edits.sort((a, b) => b.start - a.start);
257
+ let out = html;
258
+ for (const e of edits) out = out.slice(0, e.start) + e.replace + out.slice(e.end);
259
+ return { html: out, changed: true, assigned, renamed, slides };
260
+ }
261
+
262
+ /* ---- Inline style edits (drag and resize in the editor) ---------------------------------- */
263
+
264
+ /* Sets or removes CSS properties in a style attribute, keeping the untouched declarations
265
+ exactly as written. A value of null or "" removes the property. */
266
+ export function mergeStyle(src, set) {
267
+ const parts = src.split(";").filter((p) => p.trim());
268
+ const todo = Object.fromEntries(Object.entries(set).map(([k, v]) => [k.toLowerCase(), v]));
269
+ const out = [];
270
+ for (const p of parts) {
271
+ const name = p.split(":")[0].trim().toLowerCase();
272
+ if (!(name in todo)) { out.push(p); continue; }
273
+ const v = todo[name];
274
+ delete todo[name];
275
+ if (v != null && v !== "") out.push(`${p.match(/^\s*/)[0]}${name}: ${v}`);
276
+ }
277
+ for (const [k, v] of Object.entries(todo)) if (v != null && v !== "") out.push(`${out.length ? " " : ""}${k}: ${v}`);
278
+ return out.join(";").trim();
279
+ }
280
+
281
+ /* edits: [{ eid, set: { prop: value|null } }]. Returns { html, changed, missing: [eid] }. */
282
+ export function setStyles(html, edits) {
283
+ const tree = parse(html);
284
+ const byEid = new Map();
285
+ walk(tree, (c) => { const v = c.attrs?.["data-eid"]?.value; if (v && !byEid.has(v)) byEid.set(v, c); });
286
+ const ops = [], missing = [];
287
+ for (const e of edits) {
288
+ const node = byEid.get(e.eid);
289
+ if (!node) { missing.push(e.eid); continue; }
290
+ const st = node.attrs.style;
291
+ const value = mergeStyle(st ? st.value : "", e.set).replace(/"/g, "'");
292
+ if (st) {
293
+ if (value === st.value) continue;
294
+ if (value) ops.push({ start: st.valueStart, end: st.valueEnd, replace: value });
295
+ else {
296
+ let a = st.start;
297
+ while (a > node.nameEnd && /\s/.test(html[a - 1])) a--;
298
+ ops.push({ start: a, end: st.end, replace: "" });
299
+ }
300
+ } else if (value) {
301
+ let at = node.selfClose ? node.tagEnd - 1 : node.tagEnd;
302
+ while (at > node.nameEnd && /\s/.test(html[at - 1])) at--;
303
+ ops.push({ start: at, end: at, replace: ` style="${value}"` });
304
+ }
305
+ }
306
+ if (!ops.length) return { html, changed: false, missing };
307
+ ops.sort((a, b) => b.start - a.start);
308
+ let out = html;
309
+ for (const o of ops) out = out.slice(0, o.start) + o.replace + out.slice(o.end);
310
+ return { html: out, changed: true, missing };
311
+ }
312
+
313
+ /* ---- Text edits (inline editing in the editor) --------------------------------------------- */
314
+
315
+ /* The element with this id, or a descendant reached by element-child indices (path), e.g. a
316
+ table cell [2] under a row that has the id. */
317
+ function nodeAt(html, eid, path = [], { anyKind = false } = {}) {
318
+ let node = null;
319
+ walk(parse(html), (c) => { if (node) return false; if (c.attrs?.["data-eid"]?.value === eid) { node = c; return false; } });
320
+ for (const i of path) node = node?.children[i] || null;
321
+ return node && (anyKind || node.closeStart >= 0) ? node : null;
322
+ }
323
+ /* End of an element in the source: after its closing tag, or after the tag itself for <img> etc. */
324
+ export const elementEnd = (html, node) => (node.closeStart >= 0 ? html.indexOf(">", node.closeStart) + 1 : node.tagEnd + 1);
325
+
326
+ /* Inner HTML of an element exactly as written in the source (math still as TeX). */
327
+ export function getInner(html, eid, path) {
328
+ const node = nodeAt(html, eid, path);
329
+ return node ? html.slice(node.contentStart, node.closeStart) : null;
330
+ }
331
+
332
+ /* Replaces the inner HTML of one element. Returns { html, changed } or null if not found. */
333
+ export function setInner(html, eid, path, inner) {
334
+ const node = nodeAt(html, eid, path);
335
+ if (!node) return null;
336
+ const old = html.slice(node.contentStart, node.closeStart);
337
+ if (old === inner) return { html, changed: false };
338
+ return { html: html.slice(0, node.contentStart) + inner + html.slice(node.closeStart), changed: true };
339
+ }
340
+
341
+ /* ---- New bullets (Enter in a list item) ------------------------------------------------------ */
342
+
343
+ const allEids = (html) => {
344
+ const ids = new Set();
345
+ walk(parse(html), (c) => { const v = c.attrs?.["data-eid"]?.value; if (v) ids.add(v); });
346
+ return ids;
347
+ };
348
+ const closeEnd = elementEnd;
349
+ export const lineIndent = (html, at) => {
350
+ const ls = html.lastIndexOf("\n", at - 1) + 1;
351
+ const pre = html.slice(ls, at);
352
+ return /^\s*$/.test(pre) ? pre : "";
353
+ };
354
+
355
+ /* Inserts <tag class=...>inner</tag> right after the element `eid`, on its own line with the same
356
+ indentation. The new element gets `newEid`, or a free variant of it. Returns { html, eid }. */
357
+ export function insertAfter(html, eid, { tag = "li", cls = "", inner = "", newEid }) {
358
+ const node = nodeAt(html, eid);
359
+ if (!node) return null;
360
+ const used = allEids(html);
361
+ // ids are written into the attribute as they are: nothing that could end it
362
+ let id = String(newEid || "").replace(/["'<>&\s]/g, "") || `${eid}-next`, k = 2;
363
+ const base = id.replace(/-\d+$/, "");
364
+ while (used.has(id)) id = `${base}-${k++}`;
365
+ const end = closeEnd(html, node);
366
+ const indent = lineIndent(html, node.start);
367
+ const attrs = (cls ? ` class="${cls.replace(/"/g, "")}"` : "") + ` data-eid="${id}"`;
368
+ const el = `${indent ? "\n" + indent : ""}<${tag}${attrs}>${inner}</${tag}>`;
369
+ return { html: html.slice(0, end) + el + html.slice(end), eid: id };
370
+ }
371
+
372
+ /* Removes the element `eid`, and its line if nothing else is on it. Returns the new html or null. */
373
+ export function removeElement(html, eid) {
374
+ const node = nodeAt(html, eid, [], { anyKind: true });
375
+ if (!node) return null;
376
+ let a = node.start, b = closeEnd(html, node);
377
+ const indent = lineIndent(html, a);
378
+ if (indent || html[a - 1] === "\n") {
379
+ const rest = html.slice(b).match(/^[ \t]*(?=\n|$)/);
380
+ if (rest) { a -= indent.length; b += rest[0].length; if (html[a - 1] === "\n") a--; }
381
+ }
382
+ return html.slice(0, a) + html.slice(b);
383
+ }
384
+
385
+ /* ---- Deleting elements ------------------------------------------------------------------------ */
386
+
387
+ const MEDIA = new Set(["img", "svg", "video", "audio", "canvas", "iframe", "object", "picture"]);
388
+
389
+ /* What deleting `eid` would remove: the element, or its figure when it is the figure's only media,
390
+ with the element's source HTML. Returns { eid, tag, outer } or null. */
391
+ export function deleteTarget(html, eid) {
392
+ let node = nodeAt(html, eid, [], { anyKind: true });
393
+ if (!node) return null;
394
+ const p = node.parent;
395
+ if (MEDIA.has(node.name) && p?.name === "figure" && p.attrs?.["data-eid"]) {
396
+ let media = 0;
397
+ walk({ children: p.children }, (c) => { if (MEDIA.has(c.name)) media++; });
398
+ if (media === 1) node = p; // delete the whole figure, caption included
399
+ }
400
+ const cls = (node.attrs.class?.value || "").split(/\s+/).filter(Boolean);
401
+ return { eid: node.attrs["data-eid"].value, tag: node.name + (cls.length ? "." + cls.join(".") : ""),
402
+ outer: html.slice(node.start, elementEnd(html, node)) };
403
+ }
404
+
405
+ /* Relative file references (src, data, poster, href) in a piece of HTML. */
406
+ export function fileRefs(fragment) {
407
+ const out = new Set();
408
+ for (const m of fragment.matchAll(/\b(?:src|data|poster|href)\s*=\s*["']([^"'#?]+)/gi)) {
409
+ const v = m[1].trim();
410
+ if (v && !/^([a-z]+:|\/\/|\/)/i.test(v)) out.add(v.replace(/^\.\//, ""));
411
+ }
412
+ return [...out];
413
+ }
414
+
415
+ /* ---- New slides ---------------------------------------------------------------------------------- */
416
+
417
+ export const classList = (n) => (n.attrs?.class?.value || "").split(/\s+/).filter(Boolean);
418
+ const slideKind = (n) => classList(n).find((c) => /-slide$/.test(c)) || "content";
419
+
420
+ /* Inserts a new slide after the slide `afterEid`, modelled on a slide of the deck itself so it
421
+ matches the design:
422
+ content title and two placeholder bullets, from a content slide (the current one if it is one)
423
+ section a section divider, from the deck's first divider
424
+ duplicate a copy of the current slide
425
+ Element ids are left out; assignEids() gives the new slide fresh ones.
426
+ Returns { html, index } (index of the new slide) or { error }. */
427
+ export function insertSlide(html, kind, afterEid) {
428
+ const slides = findSlides(parse(html));
429
+ if (!slides.length) return { error: "The deck has no slides." };
430
+ const after = slides.find((s) => s.attrs["data-eid"]?.value === afterEid) || slides.at(-1);
431
+ const hasHead = (s) => { let h = false; walk({ children: [s] }, (c) => { if (/^h[1-3]$/.test(c.name)) h = true; }); return h; };
432
+ let tpl;
433
+ if (kind === "duplicate") tpl = after;
434
+ else if (kind === "section") tpl = slides.find((s) => slideKind(s) === "section-slide");
435
+ else tpl = [after, ...slides].find((s) => slideKind(s) === "content" && hasHead(s));
436
+ if (!tpl) return { error: kind === "section" ? "This deck has no section divider to model a new one on." : "This deck has no content slide to model a new one on." };
437
+
438
+ let frag = html.slice(tpl.start, elementEnd(html, tpl)).replace(/\s+data-eid="[^"]*"/g, "");
439
+ if (kind !== "duplicate") frag = placeholderSlide(frag, kind, slides.filter((s, i) => i <= slides.indexOf(after) && slideKind(s) === "section-slide").length + 1);
440
+
441
+ const indent = lineIndent(html, after.start);
442
+ const end = elementEnd(html, after);
443
+ return { html: html.slice(0, end) + `\n\n${indent}` + frag + html.slice(end), index: slides.indexOf(after) + 1 };
444
+ }
445
+
446
+ /* Turns a copied slide into an empty one: placeholder title and text, no notes or references. */
447
+ function placeholderSlide(frag, kind, sectionNumber) {
448
+ const root = parse(frag).children[0];
449
+ const edits = []; // { start, end, text }
450
+ const inner = (n, text) => edits.push({ start: n.contentStart, end: n.closeStart, text });
451
+ const drop = (n) => {
452
+ let a = n.start;
453
+ while (a > 0 && /[ \t]/.test(frag[a - 1])) a--;
454
+ if (frag[a - 1] === "\n") a--;
455
+ edits.push({ start: a, end: elementEnd(frag, n), text: "" });
456
+ };
457
+ const find = (pred, from = root) => { let hit = null; walk({ children: [from] }, (c) => { if (!hit && pred(c)) hit = c; }); return hit; };
458
+ const heading = find((c) => /^h[1-3]$/.test(c.name));
459
+ if (heading) {
460
+ const sub = find((c) => classList(c).includes("sub"), heading);
461
+ inner(heading, (kind === "section" ? "New Section" : "New Slide") +
462
+ (sub ? frag.slice(sub.start, sub.contentStart) + "Subtitle" + frag.slice(sub.closeStart, elementEnd(frag, sub)) : ""));
463
+ }
464
+ for (const c of root.children) {
465
+ if (c.name === "aside" || classList(c).includes("refs")) drop(c); // speaker notes, references
466
+ }
467
+ if (kind === "section") {
468
+ const num = find((c) => classList(c).includes("num"));
469
+ if (num) inner(num, String(sectionNumber).padStart(frag.slice(num.contentStart, num.closeStart).trim().length, "0"));
470
+ const p = find((c) => c.name === "p" && !classList(c).includes("kicker"));
471
+ if (p) inner(p, "One line on what this part is about");
472
+ } else {
473
+ const body = find((c) => classList(c).includes("slide-body"));
474
+ if (body) {
475
+ const ind = lineIndent(frag, body.start);
476
+ inner(body, `\n${ind} <ul class="bullets">\n${ind} <li>First point</li>\n${ind} <li>Second point</li>\n${ind} </ul>\n${ind}`);
477
+ }
478
+ // keep only the head and body of a content slide (no stray figures or callouts from the model slide)
479
+ for (const c of root.children) if (c !== body && !find((x) => x === heading, c) && c.name !== "aside" && !classList(c).includes("refs")) drop(c);
480
+ }
481
+ edits.sort((a, b) => b.start - a.start);
482
+ for (const e of edits) frag = frag.slice(0, e.start) + e.text + frag.slice(e.end);
483
+ return frag;
484
+ }
485
+
486
+ /* ---- Moving slides ------------------------------------------------------------------------------- */
487
+
488
+ /* A slide in the source: the <section> and the comments right above it (the banner naming its
489
+ layout), which move with it. prevEnd is the end of whatever precedes it in its parent. */
490
+ function slideBlock(html, s) {
491
+ const sib = s.parent.children, i = sib.indexOf(s);
492
+ const prevEnd = i > 0 ? elementEnd(html, sib[i - 1]) : s.parent.contentStart;
493
+ const c = html.slice(prevEnd, s.start).indexOf("<!--");
494
+ return { prevEnd, start: c >= 0 ? prevEnd + c : s.start, end: elementEnd(html, s) };
495
+ }
496
+
497
+ /* Moves the slide `eid` (with its comments, notes and everything in it) before the slide `beforeEid`,
498
+ or to the end when beforeEid is null. Only the slide's text moves; ids and attributes stay as they
499
+ are. Slides inside vertical stacks are not moved. Returns { html, changed, from, index } or { error }. */
500
+ export function moveSlide(html, eid, beforeEid) {
501
+ const byEid = (all, e) => all.find((s) => s.attrs["data-eid"]?.value === e);
502
+ const slides = findSlides(parse(html));
503
+ const slide = byEid(slides, eid), target = beforeEid == null ? null : byEid(slides, beforeEid);
504
+ if (!slide || (beforeEid != null && !target)) return { error: "Slide not found in the file." };
505
+ if ([slide, target].some((s) => s?.parent.name === "section")) return { error: "Slides inside a vertical stack cannot be moved yet." };
506
+ const from = slides.indexOf(slide);
507
+ const top = slide.parent.children.filter((c) => c.name === "section");
508
+ const next = top[top.indexOf(slide) + 1] || null;
509
+ if (target === slide || target === next) return { html, changed: false, from, index: from };
510
+
511
+ const b = slideBlock(html, slide);
512
+ const text = html.slice(b.start, b.end), indent = lineIndent(html, slide.start);
513
+ // cut from the end of whatever precedes the slide, so the whitespace before the next one stays
514
+ const cut = html.slice(0, b.prevEnd) + html.slice(b.end);
515
+ const rest = findSlides(parse(cut));
516
+ let out;
517
+ if (target) {
518
+ const at = slideBlock(cut, byEid(rest, beforeEid)).start;
519
+ out = cut.slice(0, at) + text + `\n\n${indent}` + cut.slice(at);
520
+ } else {
521
+ const last = rest.filter((s) => s.parent.name !== "section").at(-1);
522
+ const at = elementEnd(cut, last);
523
+ out = cut.slice(0, at) + `\n\n${indent}` + text + cut.slice(at);
524
+ }
525
+ const moved = findSlides(parse(out));
526
+ return { html: out, changed: true, from, index: moved.indexOf(byEid(moved, eid)) };
527
+ }
528
+
529
+ /* ---- Speaker notes ---------------------------------------------------------------------------------
530
+ A slide's notes are its <aside class="notes"> (reveal.js speaker view). Notes of plain text and <br>
531
+ (as the PowerPoint import writes them) are edited as text with line breaks; notes with other markup
532
+ are edited as their HTML, so no formatting is lost. */
533
+
534
+ const notesOf = (slide) => slide.children.find((c) => c.name === "aside" && classesOf(c).includes("notes")) || null;
535
+ const PLAIN_NOTES = /^[^<]*(?:<br\s*\/?>[^<]*)*$/i;
536
+ const escText = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
537
+ const slideByEid = (html, eid) => findSlides(parse(html)).find((s) => s.attrs["data-eid"]?.value === eid) || null;
538
+
539
+ /* Returns { text, html } (html: true when the notes are edited as HTML), or null if there is no such slide. */
540
+ export function getNotes(html, eid) {
541
+ const slide = slideByEid(html, eid);
542
+ if (!slide) return null;
543
+ const aside = notesOf(slide);
544
+ const inner = aside ? html.slice(aside.contentStart, aside.closeStart).replace(/\s+/g, " ").trim() : "";
545
+ if (!PLAIN_NOTES.test(inner)) return { text: inner, html: true };
546
+ return { text: inner.split(/\s*<br\s*\/?>\s*/i).map(decode).join("\n"), html: false };
547
+ }
548
+
549
+ /* Sets the notes of slide `eid` to `text` (or to the HTML `text` when asHtml). Empty notes remove the
550
+ aside; a slide without notes gets one as its last child, on its own line. Returns { html, changed } or null. */
551
+ export function setNotes(html, eid, text, asHtml = false) {
552
+ const slide = slideByEid(html, eid);
553
+ if (!slide) return null;
554
+ const t = String(text ?? "").trim();
555
+ const body = asHtml ? t : t.split(/\r?\n/).map((l) => escText(l.trim())).join("<br>");
556
+ const aside = notesOf(slide);
557
+ let out;
558
+ if (aside && !body) {
559
+ let a = aside.start;
560
+ while (a > 0 && /[ \t]/.test(html[a - 1])) a--;
561
+ if (html[a - 1] === "\n") a--;
562
+ out = html.slice(0, a) + html.slice(elementEnd(html, aside));
563
+ } else if (aside) {
564
+ out = html.slice(0, aside.contentStart) + body + html.slice(aside.closeStart);
565
+ } else if (body) {
566
+ const tag = `<aside class="notes">${body}</aside>`;
567
+ const ls = html.lastIndexOf("\n", slide.closeStart - 1) + 1;
568
+ if (/^[ \t]*$/.test(html.slice(ls, slide.closeStart))) {
569
+ const ind = (slide.children[0] && lineIndent(html, slide.children[0].start)) || html.slice(ls, slide.closeStart) + " ";
570
+ out = html.slice(0, ls) + `${ind}${tag}\n` + html.slice(ls);
571
+ } else {
572
+ out = html.slice(0, slide.closeStart) + tag + html.slice(slide.closeStart); // a slide written on one line
573
+ }
574
+ } else {
575
+ out = html;
576
+ }
577
+ return { html: out, changed: out !== html };
578
+ }
579
+
580
+ /* ---- Single attributes (hide or show a slide) ------------------------------------------------------ */
581
+
582
+ /* Sets attribute `name` on the element `eid` to `value`, or removes it when value is null.
583
+ Only that attribute changes in the source. Returns { html, changed } or null if not found. */
584
+ export function setAttr(html, eid, name, value) {
585
+ const node = nodeAt(html, eid, [], { anyKind: true });
586
+ if (!node) return null;
587
+ const a = node.attrs[name];
588
+ let out;
589
+ if (value == null) {
590
+ if (!a) return { html, changed: false };
591
+ let s = a.start;
592
+ while (s > node.nameEnd && /\s/.test(html[s - 1])) s--;
593
+ out = html.slice(0, s) + html.slice(a.end);
594
+ } else {
595
+ const v = String(value).replace(/"/g, "&quot;");
596
+ if (a && a.valueStart >= 0) {
597
+ if (a.value === v) return { html, changed: false };
598
+ out = html.slice(0, a.valueStart) + v + html.slice(a.valueEnd);
599
+ } else if (a) {
600
+ out = html.slice(0, a.start) + `${name}="${v}"` + html.slice(a.end);
601
+ } else {
602
+ // after the class attribute if there is one, so <section class="slide" data-visibility="hidden" ...>
603
+ const at = node.attrs.class ? node.attrs.class.end : node.nameEnd;
604
+ out = html.slice(0, at) + ` ${name}="${v}"` + html.slice(at);
605
+ }
606
+ }
607
+ return { html: out, changed: true };
608
+ }
package/lib/layout.mjs ADDED
@@ -0,0 +1,37 @@
1
+ /* Text formats shared by the chat prompt and the CLI. */
2
+
3
+ const pad = (s, n) => (s.length >= n ? s + " " : s + " ".repeat(n - s.length));
4
+ const clip = (s, n) => (s.length > n ? s.slice(0, n - 1) + "…" : s);
5
+
6
+ export function formatLayout(L) {
7
+ const s = L.slide;
8
+ const lines = [];
9
+ lines.push(`Slide ${s.index + 1} · ${s.eid} · ${s.kind} · "${s.title}"` + (s.hidden ? " · HIDDEN (skipped when presenting)" : ""));
10
+ lines.push(`Canvas ${L.width}×${L.height} px, origin top left.` +
11
+ (s.hasFooter && L.footerTop != null ? ` Footer rule at y=${L.footerTop}: keep content above it.` : ""));
12
+ lines.push(`Ids below are relative: "/h2" means "${s.eid}/h2".`);
13
+ for (const e of L.elements) {
14
+ const id = e.eid === s.eid ? "(slide)" : e.eid.startsWith(s.eid + "/") ? e.eid.slice(s.eid.length) : e.eid;
15
+ const geo = `x=${e.x} y=${e.y} w=${e.w} h=${e.h}`;
16
+ const extra = [e.font ? `font ${e.font}` : "", e.hidden ? "hidden" : "", e.locked ? "brand-locked" : "",
17
+ e.text ? `"${clip(e.text, 60)}"` : ""].filter(Boolean).join(" ");
18
+ lines.push(pad(" ".repeat(e.depth) + id, 34) + pad(e.tag, 22) + pad(geo, 30) + extra);
19
+ }
20
+ lines.push(L.issues.length ? "Issues:\n" + L.issues.map((i) => " - " + i).join("\n") : "Issues: none found");
21
+ return lines.join("\n");
22
+ }
23
+
24
+ export function formatPin(p) {
25
+ const s = p.slide;
26
+ const lines = [`[${p.n}] slide ${s.index + 1} (${s.eid}) → ${p.eid} (${p.tag})` + (p.locked ? ` [${p.locked}]` : "")];
27
+ const where = [`box x=${p.box.x} y=${p.box.y} w=${p.box.w} h=${p.box.h}`,
28
+ `clicked at x=${p.point.x} y=${p.point.y} (${Math.round(p.at.fx * 100)}% across, ${Math.round(p.at.fy * 100)}% down the element)`];
29
+ lines.push(" " + where.join("; "));
30
+ const detail = [];
31
+ if (p.word) detail.push(`word "${p.word}"`);
32
+ if (p.cell) detail.push(`table cell row ${p.cell.row + 1}, column ${p.cell.col + 1} ("${clip(p.cell.text, 40)}")`);
33
+ if (p.font) detail.push(`font ${p.font}`);
34
+ if (detail.length) lines.push(" " + detail.join("; "));
35
+ if (p.text) lines.push(` text: "${clip(p.text, 100)}"`);
36
+ return lines.join("\n");
37
+ }