pindeck 3.0.0-beta.2 → 3.1.0-beta.1

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 CHANGED
@@ -145,8 +145,97 @@ function find(node, pred) {
145
145
  return hit;
146
146
  }
147
147
 
148
- /* Slides are leaf <section>s inside .slides; vertical-stack containers are not slides. */
148
+ /* ---- Decks made without reveal.js ----------------------------------------------------------------
149
+ A deck names its slides with <meta name="pindeck:slides" content="main.deck > section.slide">, a CSS
150
+ selector, and its size with <meta name="pindeck:size" content="1440x1080">. Selectors stay simple, so
151
+ the server and the browser find the same slides: tag names, #id, .class, [attr] and [attr="value"],
152
+ joined by spaces (descendant) or ">" (child), in a comma-separated list. */
153
+
154
+ const metaOf = (tree, name) => {
155
+ const m = find(tree, (c) => c.name === "meta" && c.attrs.name?.value.trim().toLowerCase() === `pindeck:${name}`);
156
+ return m ? decode(m.attrs.content?.value || "").trim() || null : null;
157
+ };
158
+ /* The deck's slide selector, or null for a reveal.js deck */
159
+ export const slideSelector = (tree) => metaOf(tree, "slides");
160
+ /* The deck's slide size from <meta name="pindeck:size">: { width, height } or null */
161
+ export function deckSize(tree) {
162
+ const m = /^(\d{2,5})\s*[x×*,\s]\s*(\d{2,5})$/i.exec(metaOf(tree, "size") || "");
163
+ return m ? { width: +m[1], height: +m[2] } : null;
164
+ }
165
+
166
+ const IDENT = "-?[_a-zA-Z\\u00a0-\\uffff][\\w\\u00a0-\\uffff-]*";
167
+ const COMPOUND = new RegExp(`^(?:(\\*|${IDENT})|#(${IDENT})|\\.(${IDENT})|\\[\\s*(${IDENT})\\s*(?:=\\s*(?:"([^"]*)"|'([^']*)'|(${IDENT}))\\s*)?\\])`);
168
+ /* "main.deck > section.slide, aside.extra" -> one list of compounds and combinators per selector.
169
+ Throws with the part it cannot read (pseudo-classes, +, ~ ...). */
170
+ export function parseSelector(sel) {
171
+ const parts = []; // top-level commas only: not inside [attr="a,b"]
172
+ let quote = null, depth = 0, from = 0;
173
+ for (let i = 0; i < sel.length; i++) {
174
+ const c = sel[i];
175
+ if (quote) { if (c === quote) quote = null; }
176
+ else if (c === '"' || c === "'") quote = c;
177
+ else if (c === "[") depth++;
178
+ else if (c === "]") depth--;
179
+ else if (c === "," && !depth) { parts.push(sel.slice(from, i)); from = i + 1; }
180
+ }
181
+ parts.push(sel.slice(from));
182
+ return parts.map((part) => {
183
+ const steps = []; // [{ tag, ids, classes, attrs }, ">" | " ", { ... }, ...]
184
+ let s = part.trim(), cur = null;
185
+ if (!s) throw new Error(`empty selector in "${sel}"`);
186
+ while (s) {
187
+ const ws = /^\s+/.exec(s);
188
+ const gt = /^\s*>\s*/.exec(s);
189
+ if (gt) { if (!cur) throw new Error(`"${part.trim()}" starts with ">"`); steps.push(">"); cur = null; s = s.slice(gt[0].length); continue; }
190
+ if (ws) { steps.push(" "); cur = null; s = s.slice(ws[0].length); continue; }
191
+ const m = COMPOUND.exec(s);
192
+ if (!m) throw new Error(`"${s.slice(0, 20)}" in "${part.trim()}" is not supported (use tags, #id, .class, [attr], spaces and ">")`);
193
+ if (!cur) { cur = { tag: null, ids: [], classes: [], attrs: [] }; steps.push(cur); }
194
+ if (m[1]) {
195
+ if (cur.tag || cur.ids.length || cur.classes.length || cur.attrs.length) throw new Error(`a tag name must come first in "${part.trim()}"`);
196
+ cur.tag = m[1] === "*" ? null : m[1].toLowerCase();
197
+ }
198
+ else if (m[2]) cur.ids.push(m[2]);
199
+ else if (m[3]) cur.classes.push(m[3]);
200
+ else cur.attrs.push({ name: m[4].toLowerCase(), value: m[5] ?? m[6] ?? m[7] ?? null });
201
+ s = s.slice(m[0].length);
202
+ }
203
+ if (typeof steps.at(-1) === "string") throw new Error(`"${part.trim()}" ends with a combinator`);
204
+ return steps;
205
+ });
206
+ }
207
+ function compoundMatches(node, c) {
208
+ if (!node.attrs) return false; // the root
209
+ if (c.tag && node.name !== c.tag) return false;
210
+ if (c.ids.some((id) => node.attrs.id?.value !== id)) return false;
211
+ const cls = classesOf(node);
212
+ if (c.classes.some((k) => !cls.includes(k))) return false;
213
+ return c.attrs.every((a) => node.attrs[a.name] && (a.value == null || node.attrs[a.name].value === a.value));
214
+ }
215
+ /* steps[i] matches node, and the steps before it match its ancestors as their combinators say */
216
+ function stepsMatch(node, steps, i = steps.length - 1) {
217
+ if (!compoundMatches(node, steps[i])) return false;
218
+ if (i === 0) return true;
219
+ if (steps[i - 1] === ">") return !!node.parent && stepsMatch(node.parent, steps, i - 2);
220
+ for (let a = node.parent; a; a = a.parent) if (stepsMatch(a, steps, i - 2)) return true;
221
+ return false;
222
+ }
223
+ /* Elements matching the selector, in document order; an element holding another match is a stack of
224
+ slides, not a slide (as with reveal.js's vertical stacks) */
225
+ function selectorSlides(tree, sel) {
226
+ let list;
227
+ try { list = parseSelector(sel); } catch { return []; }
228
+ const hits = [];
229
+ walk(tree, (c) => { if (list.some((steps) => stepsMatch(c, steps))) hits.push(c); });
230
+ const inside = (a, b) => { for (let p = b.parent; p; p = p.parent) if (p === a) return true; return false; };
231
+ return hits.filter((h) => !hits.some((o) => o !== h && inside(h, o)));
232
+ }
233
+
234
+ /* Slides are leaf <section>s inside .slides; vertical-stack containers are not slides. A deck that names
235
+ its slides with <meta name="pindeck:slides"> has the elements that selector matches instead. */
149
236
  export function findSlides(tree) {
237
+ const sel = slideSelector(tree);
238
+ if (sel) return selectorSlides(tree, sel);
150
239
  const slidesEl = find(tree, (c) => classesOf(c).includes("slides"));
151
240
  if (!slidesEl) return [];
152
241
  const out = [];
@@ -187,6 +276,7 @@ function elementName(node) {
187
276
  export function assignEids(html) {
188
277
  const tree = parse(html);
189
278
  const sections = findSlides(tree);
279
+ const isSlide = slideSelector(tree) ? ((c) => sections.includes(c)) : ((c) => c.name === "section");
190
280
  const used = new Set();
191
281
  const edits = []; // { at, insert } or { start, end, replace }
192
282
  const assigned = [], renamed = [];
@@ -240,7 +330,7 @@ export function assignEids(html) {
240
330
  // Pass 2: elements inside the slide
241
331
  const visit = (node, inSvg) => {
242
332
  for (const c of node.children) {
243
- if (c.name === "section") continue; // nested slide, handled on its own
333
+ if (isSlide(c)) continue; // nested slide, handled on its own
244
334
  const cls = classesOf(c);
245
335
  if (c.name === "aside" && cls.includes("notes")) continue;
246
336
  if (c.attrs["data-noeid"]) continue;
@@ -423,10 +513,11 @@ const slideKind = (n) => classList(n).find((c) => /-slide$/.test(c)) || "content
423
513
  content title and two placeholder bullets, from a content slide (the current one if it is one)
424
514
  section a section divider, from the deck's first divider
425
515
  duplicate a copy of the current slide
426
- Element ids are left out; assignEids() gives the new slide fresh ones.
516
+ Element ids are left out; assignEids() gives the new slide fresh ones. In a deck made without reveal.js,
517
+ the slide's own id is left out too: such decks (and Present) go to slides by their id.
427
518
  Returns { html, index } (index of the new slide) or { error }. */
428
519
  export function insertSlide(html, kind, afterEid) {
429
- const slides = findSlides(parse(html));
520
+ const tree = parse(html), slides = findSlides(tree);
430
521
  if (!slides.length) return { error: "The deck has no slides." };
431
522
  const after = slides.find((s) => s.attrs["data-eid"]?.value === afterEid) || slides.at(-1);
432
523
  const hasHead = (s) => { let h = false; walk({ children: [s] }, (c) => { if (/^h[1-3]$/.test(c.name)) h = true; }); return h; };
@@ -437,6 +528,7 @@ export function insertSlide(html, kind, afterEid) {
437
528
  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." };
438
529
 
439
530
  let frag = html.slice(tpl.start, elementEnd(html, tpl)).replace(/\s+data-eid="[^"]*"/g, "");
531
+ if (slideSelector(tree)) frag = frag.replace(/^(<[^>]*?)\s+id=(?:"[^"]*"|'[^']*'|[^\s>]+)/, "$1"); // the opening tag only
440
532
  if (kind !== "duplicate") frag = placeholderSlide(frag, kind, slides.filter((s, i) => i <= slides.indexOf(after) && slideKind(s) === "section-slide").length + 1);
441
533
 
442
534
  const indent = lineIndent(html, after.start);
@@ -500,12 +592,13 @@ function slideBlock(html, s) {
500
592
  are. Slides inside vertical stacks are not moved. Returns { html, changed, from, index } or { error }. */
501
593
  export function moveSlide(html, eid, beforeEid) {
502
594
  const byEid = (all, e) => all.find((s) => s.attrs["data-eid"]?.value === e);
503
- const slides = findSlides(parse(html));
595
+ const tree = parse(html), slides = findSlides(tree);
596
+ const bySelector = !!slideSelector(tree); // such decks have no vertical stacks
504
597
  const slide = byEid(slides, eid), target = beforeEid == null ? null : byEid(slides, beforeEid);
505
598
  if (!slide || (beforeEid != null && !target)) return { error: "Slide not found in the file." };
506
- if ([slide, target].some((s) => s?.parent.name === "section")) return { error: "Slides inside a vertical stack cannot be moved yet." };
599
+ if (!bySelector && [slide, target].some((s) => s?.parent.name === "section")) return { error: "Slides inside a vertical stack cannot be moved yet." };
507
600
  const from = slides.indexOf(slide);
508
- const top = slide.parent.children.filter((c) => c.name === "section");
601
+ const top = slide.parent.children.filter((c) => (bySelector ? slides.includes(c) : c.name === "section"));
509
602
  const next = top[top.indexOf(slide) + 1] || null;
510
603
  if (target === slide || target === next) return { html, changed: false, from, index: from };
511
604
 
@@ -520,7 +613,7 @@ export function moveSlide(html, eid, beforeEid) {
520
613
  out = cut.slice(0, at) + text + `\n\n${indent}` + cut.slice(at);
521
614
  } else {
522
615
  // A final vertical stack is absent from the leaf-slide list, but we must insert after it.
523
- const last = find(parse(cut), (c) => classesOf(c).includes("slides"))
616
+ const last = bySelector ? rest.at(-1) : find(parse(cut), (c) => classesOf(c).includes("slides"))
524
617
  .children.filter((c) => c.name === "section").at(-1);
525
618
  const at = elementEnd(cut, last);
526
619
  out = cut.slice(0, at) + `\n\n${indent}` + text + cut.slice(at);
@@ -1,10 +1,11 @@
1
1
  /* Which slide framework a deck uses. PinDeck edits reveal.js decks: <section> slides inside an element
2
- with class "slides" (inside <div class="reveal">). Other frameworks are recognised by their markup and
3
- scripts, so the user gets a clear "not supported (yet)" before PinDeck changes anything, instead of an
4
- editor without slides. Only tags and scripts are looked at, never the slides' text, so a talk that
5
- mentions "impress.js" is not taken for one. */
2
+ with class "slides" (inside <div class="reveal">). Any other deck can name its slides itself with
3
+ <meta name="pindeck:slides" content="CSS selector"> (see lib/eids.mjs) and is then edited too. Other
4
+ frameworks are recognised by their markup and scripts, so the user gets a clear "not supported (yet)"
5
+ before PinDeck changes anything, instead of an editor without slides. Only tags and scripts are looked
6
+ at, never the slides' text, so a talk that mentions "impress.js" is not taken for one. */
6
7
 
7
- import { parse, findSlides } from "./eids.mjs";
8
+ import { parse, findSlides, slideSelector, deckSize, parseSelector } from "./eids.mjs";
8
9
 
9
10
  const OTHERS = [ // name, markers; the first match wins (Marp before bespoke.js, which it is built on)
10
11
  ["Marp", /\bmarpit\b|bespoke-marp|data-marpit|marp-cli/i],
@@ -20,9 +21,13 @@ const OTHERS = [ // name, markers; the first match wins (Marp before bespoke.j
20
21
  /* Tags and script contents only: the slides' text could name any framework */
21
22
  const markup = (html) => [...String(html).matchAll(/<script\b[^>]*>[\s\S]*?<\/script>|<[^>]+>/gi)].map((m) => m[0]).join("\n");
22
23
 
23
- /* { name, supported }: reveal.js decks are supported, including one without slides yet */
24
+ /* { name, supported }: reveal.js decks are supported, including one without slides yet, and so is a deck
25
+ whose <meta name="pindeck:slides"> selector finds slides and whose <meta name="pindeck:size"> gives their
26
+ size ({ name: "selector", selector, supported }) */
24
27
  export function detectFramework(html) {
25
- if (findSlides(parse(html)).length) return { name: "reveal.js", supported: true };
28
+ const tree = parse(html), selector = slideSelector(tree);
29
+ if (selector) return { name: "selector", selector, supported: findSlides(tree).length > 0 && !!deckSize(tree) };
30
+ if (findSlides(tree).length) return { name: "reveal.js", supported: true };
26
31
  const tags = markup(html);
27
32
  const other = OTHERS.find(([, re]) => re.test(tags));
28
33
  if (other) return { name: other[0], supported: false };
@@ -30,12 +35,25 @@ export function detectFramework(html) {
30
35
  return { name: null, supported: false };
31
36
  }
32
37
 
38
+ const HOW = `A deck made otherwise can name its slides in its <head>: ` +
39
+ `<meta name="pindeck:slides" content="main.deck > section.slide"> (a CSS selector for the slides) and ` +
40
+ `<meta name="pindeck:size" content="1440x1080"> (their size in px).`;
41
+
33
42
  /* The message for a deck PinDeck cannot edit, or null */
34
43
  export function unsupportedMessage(html, file) {
35
44
  const fw = detectFramework(html);
36
45
  if (fw.supported) return null;
46
+ if (fw.name === "selector") {
47
+ // the slides are found: the size is what is missing (headless Chrome and the PDF pages need it)
48
+ if (findSlides(parse(html)).length) return `${file}: <meta name="pindeck:size"> is missing or not a size ` +
49
+ `such as content="1440x1080" (the slides' width and height in px). PinDeck opens the slides at that size ` +
50
+ `for screenshots and PDFs. Nothing was changed.`;
51
+ let why = "matches no elements";
52
+ try { parseSelector(fw.selector); } catch (e) { why = `cannot be used: ${e.message}`; }
53
+ return `${file}: the slide selector "${fw.selector}" in <meta name="pindeck:slides"> ${why}. Nothing was changed.`;
54
+ }
37
55
  const what = fw.name ? `${file} is a ${fw.name} deck.` :
38
56
  `${file} has no reveal.js slides (a <div class="reveal"> with <div class="slides"> holding <section> slides).`;
39
- return `${what} PinDeck works with reveal.js decks only for now; other frameworks may follow. ` +
57
+ return `${what} PinDeck works with reveal.js decks; other frameworks may follow. ${HOW} ` +
40
58
  `Nothing was changed.`;
41
59
  }
package/lib/login.mjs ADDED
@@ -0,0 +1,61 @@
1
+ /* Who may read the editor, the deck and the API: shared by the front (pindeck) and each deck server.
2
+
3
+ The token never appears in a page or on a command line, where other users of a shared server could read
4
+ it (ps, curl). A browser logs in once with a one-time code (printed at start, or by `pindeck login`) and
5
+ then holds the token in an HttpOnly cookie; the CLI, Claude and the front send it in a header. Browsers
6
+ share cookies across all ports of a host, so the cookie name carries the server's port. */
7
+
8
+ import crypto from "node:crypto";
9
+
10
+ /* Pages opened as 127.0.0.1 or localhost only, on any port: an SSH tunnel may use another local port.
11
+ Another site's name pointed at 127.0.0.1 (DNS rebinding) must not read the editor page, its token or the deck. */
12
+ export const goodHost = (h) => /^(127\.0\.0\.1|localhost)(:\d{1,5})?$/.test(h);
13
+
14
+ export const LOGIN_PAGE = `<!doctype html><meta charset="utf-8"><title>PinDeck</title>
15
+ <body style="font:16px system-ui,sans-serif;max-width:36em;margin:4em auto;padding:0 1em">
16
+ <h1>Log in to PinDeck</h1><p>Open the login link that PinDeck printed when it started. Links work once;
17
+ for a new one, run <code>pindeck login</code> in the deck folder (or <code>node front.mjs login</code> in a
18
+ PinDeck checkout).</p>`;
19
+
20
+ export function cookie(req, name) {
21
+ for (const part of String(req.headers.cookie || "").split(";")) {
22
+ const i = part.indexOf("=");
23
+ if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
24
+ }
25
+ return null;
26
+ }
27
+
28
+ /* port() and origin() are read on use: --port 0 learns the port only once listening */
29
+ export function createLogin({ token, port, origin }) {
30
+ const loginCodes = new Map(); // code -> expiry (ms)
31
+ function loginCode(ttl) {
32
+ const now = Date.now();
33
+ for (const [c, exp] of loginCodes) if (exp < now) loginCodes.delete(c);
34
+ const code = crypto.randomBytes(16).toString("hex");
35
+ loginCodes.set(code, now + ttl);
36
+ return code;
37
+ }
38
+ const loginUrl = (code, next = "/") => `${origin()}/login?` + new URLSearchParams({ code, ...(next !== "/" ? { next } : {}) });
39
+ const cookieName = () => `pindeck-${port()}`;
40
+ const sameSecret = (a) => typeof a === "string" && a.length === token.length &&
41
+ crypto.timingSafeEqual(Buffer.from(a), Buffer.from(token));
42
+ const authed = (req) => sameSecret(req.headers["x-editor-token"]) || sameSecret(cookie(req, cookieName()));
43
+ /* A one-time code: set the cookie, then go on with a same-origin navigation (a redirect from a cross-site
44
+ start, such as a file:// page or a link in a terminal, would not carry a SameSite=Strict cookie). */
45
+ function login(res, url) {
46
+ const code = url.searchParams.get("code") || "";
47
+ const exp = loginCodes.get(code);
48
+ loginCodes.delete(code);
49
+ if (!exp || exp < Date.now()) {
50
+ res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
51
+ return res.end(LOGIN_PAGE.replace("<p>", "<p>This login link has expired or was used already. "));
52
+ }
53
+ let next = url.searchParams.get("next") || "/";
54
+ if (!next.startsWith("/") || next.startsWith("//") || next.includes("\\")) next = "/";
55
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store",
56
+ "set-cookie": `${cookieName()}=${token}; HttpOnly; SameSite=Strict; Path=/` });
57
+ const to = JSON.stringify(next).replace(/</g, "\\u003c");
58
+ res.end(`<!doctype html><meta charset="utf-8"><title>PinDeck</title><script>location.replace(${to})</script>`);
59
+ }
60
+ return { loginCode, loginUrl, cookieName, authed, login };
61
+ }
package/lib/usage.mjs ADDED
@@ -0,0 +1,15 @@
1
+ /* pindeck --help, and the hint after a bad argument: the front (front.mjs) and a deck server (server.mjs)
2
+ take the same arguments. */
3
+ export const USAGE = `Usage: pindeck [deck-folder | deck-file.html] [--port 5180] [--provider claude|codex] [--model <name>] [--asset-root <folder>]...
4
+ pindeck login [deck-folder | deck-file.html]
5
+
6
+ Opens a reveal.js deck in PinDeck: a deck file such as talk.html, or a folder (default: the current
7
+ folder), which then opens its index.html; a folder without one opens a list of its decks. A deck made
8
+ otherwise names its slides in its <head> with <meta name="pindeck:slides" content="<CSS selector>"> and
9
+ <meta name="pindeck:size" content="1440x1080">.
10
+ In the editor, the deck's name opens the other decks in the deck's folder and the asset roots (and one
11
+ folder down), each with its own chat and undo, on the same port and login.
12
+ login prints a new one-time login link for the running PinDeck (with a deck: one that opens that deck).
13
+ --asset-root lets the deck use files outside its folder: a folder that contains the deck (paths such as
14
+ ../shared/style.css then work), or one that symlinks in the deck may point to. Repeatable; also
15
+ "assetRoots" in ~/.pindeck-v3/config.json.`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pindeck",
3
- "version": "3.0.0-beta.2",
3
+ "version": "3.1.0-beta.1",
4
4
  "description": "PinDeck: an editor for HTML slide decks on macOS, and on Linux (beta) through an SSH tunnel. Pin elements, describe the change, and Claude Code or OpenAI Codex edits the slides.",
5
5
  "keywords": [
6
6
  "slides",
@@ -23,9 +23,10 @@
23
23
  "bugs": "https://github.com/ehabets/PinDeck/issues",
24
24
  "type": "module",
25
25
  "bin": {
26
- "pindeck": "server.mjs"
26
+ "pindeck": "front.mjs"
27
27
  },
28
28
  "files": [
29
+ "front.mjs",
29
30
  "server.mjs",
30
31
  "cli.mjs",
31
32
  "lib/",
@@ -34,7 +35,7 @@
34
35
  "LICENSE"
35
36
  ],
36
37
  "scripts": {
37
- "start": "node server.mjs",
38
+ "start": "node front.mjs",
38
39
  "test": "node --test"
39
40
  },
40
41
  "os": [