@hypersocial/decks-cli 0.8.0 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypersocial/decks-cli",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Command-line client for Hypersocial Decks live analytics and share management.",
5
5
  "type": "module",
6
6
  "main": "./src/commands.mjs",
@@ -215,6 +215,13 @@ The deck contract (`check` enforces each line):
215
215
  `narrate` fills `data-manifest`; do not edit it by hand.
216
216
  - Local images and files sit inside the deck folder and are referenced
217
217
  relatively (`img/chart.png`). Hosted images (`gen-image` URLs) are fine too.
218
+ - A file the deck's own JavaScript loads (`fetch('media/data.json')`, a clip
219
+ for `new Audio(…)`) is written out as a quoted path, in an inline `<script>`
220
+ or in a `.js` or `.json` file the deck loads. Release follows those paths and ships the files
221
+ (`play` serves the whole folder, the `/v/` link only what release shipped).
222
+ A path built at runtime (`` `media/lines/${id}.mp3` ``) can't be followed:
223
+ list each file literally (`{ otter: "media/lines/otter.mp3" }`) and pick from
224
+ that.
218
225
 
219
226
  <!-- topic: presenter -->
220
227
  #### Which presenter style
@@ -447,13 +454,32 @@ phone screenshots, the same `fix` bar.
447
454
  - Narration: short phrases, each sentence one caption of 4 to 85 characters
448
455
  (two lines in the bubble). Aim for 2.5 to 3 minutes in all; `maxTotalSeconds`
449
456
  in the front matter is the cap check uses (180 in the scaffold).
450
- - Holds: 0.4 s by default. Give a slide `hold: 1.5` when the viewer needs time to read a number, a chart or a reveal, and `hold: 2` on interactive slides.
451
- - Cues: `[reveal: #id]` reveals `.reveal#id` at the word before it; also
457
+ - Holds: 0.4 s by default. Give a slide `hold: 1.5` when the viewer needs time to read a number, a chart or a reveal.
458
+ - Interactive slides (something the viewer taps or plays with): add
459
+ `data-interactive` to the slide's `<section>`. The presenter finishes the
460
+ line, then the deck waits with a Continue button instead of advancing, and
461
+ taps inside the slide don't pause the narration. The slide hears the
462
+ presenter through `deck:presenter { slide, state }` on `window` (or a
463
+ controller's `onNarration(state, ctx)`), ducks its own audio while `state`
464
+ is `playing`, and can quiet the presenter with `ctx.player.pause()` /
465
+ `resume()` (or `window.hyperdecks.pauseNarration()` / `resumeNarration()`).
466
+ - Anything the viewer clicks on a slide (interactive or not) gets
467
+ `data-hd-tap` (or `cursor: pointer`), so the click doesn't also pause the
468
+ presenter. Buttons, links, canvases and focusable elements are exempt already.
469
+ - Cues: `[reveal: #id]` reveals `.reveal#id` at the word before it (going
470
+ back or replaying the slide replays its reveals from the first, and the
471
+ live head glances at each, unless a `[look]`/`[go]` is beside it or a
472
+ `go` has it docked; `data-reveal-glance="off"` on the slide or `<body>`
473
+ stops the glance); also
452
474
  `[wave]`, `[mood: excited]`, `[pause: 0.5]`, `[custom: name]`.
453
475
  - Moves, to point at part of a slide: `[go: <selector>]` flies the presenter
454
476
  beside that element (the side with room) until the next `go`, the slide's
455
- end, or `[go: slot]`; `[look: <selector>]` turns the head toward it without
456
- moving. One selector each, matched inside that slide; check fails one that
477
+ end, or `[go: slot]`. The element grows a little and, when it is one item
478
+ of a list or grid, the other items fade back (`.hd-dimmed`); the presenter
479
+ never covers them, so with no room beside it it stays put and looks at it.
480
+ Put `data-focus="ring"` on the element or slide for an accent ring instead,
481
+ or restyle `.hd-focus` / `.hd-dimmed` in the deck's CSS.
482
+ `[look: <selector>]` turns the head toward an element without moving. One selector each, matched inside that slide; check fails one that
457
483
  matches nothing. Use `go` when the narration walks through a list or chart,
458
484
  `look` for a glance at a title or number. Not in `corner` style. The best
459
485
  showcase is a slide of stacked cards, one point each, with a `go` to each
@@ -470,10 +496,10 @@ phone screenshots, the same `fix` bar.
470
496
  |---|---|
471
497
  | `[wave]` | the presenter greets (the live head's joy emote) |
472
498
  | `[mood: excited]` / `[emote: name]` | the head's mood or emote |
473
- | `[reveal: #id]` | adds `is-revealed` to `#id` |
499
+ | `[reveal: #id]` | adds `is-revealed` to `#id` (reset when the slide is left or replays); the live head glances at it unless a `[look]`/`[go]` is beside it |
474
500
  | `[pause: 0.5]` | a beat of silence (≤ 5 s) |
475
501
  | `[play: video]` / `[pause: video]` | play or pause the slide's video |
476
- | `[go: .list]` / `[go: slot]` | fly beside `.list` / back to the slot |
502
+ | `[go: #step-2]` / `[go: slot]` | fly beside that item, grow it, fade the other items / back to the slot |
477
503
  | `[look: h2]` / `[look #card]` | look at the title / the card, then back |
478
504
  | `[look viewer]` / `[look away]` | look back at the viewer / glance aside |
479
505
  | `[nod]` / `[shake]` | a nod or head shake that suits the mood |
@@ -527,6 +553,9 @@ an internal codename). Messages and what to do:
527
553
  | `clip <id>: captions do not cover the clip text` | narrate that slide again |
528
554
  | `clip <id>: caption N is … characters` | split or join sentences in that block |
529
555
  | `local reference does not resolve` / `leaves the deck folder` | move the file into the deck folder or fix the path |
556
+ | `<file> names <path>, which is not in the deck folder` | add the file, or fix the path in that script |
557
+ | ⚠ `<file> builds file paths at runtime` | list each file as a quoted path (an object or array, in a script or a `.json` it fetches) and pick from it |
558
+ | ⚠ `<file> loads files from computed values` | fine when every file it can load is also written out as a quoted path; otherwise list them so release ships them |
530
559
  | `player script must be` / `no player script` | restore the player/v1 script tag |
531
560
  | `the player script has no data-manifest` | run `narrate` |
532
561
  | `no loading state` | copy the template's `<style data-hd-loading>` block (and the preload lines above it) into `<head>`; set `--hd-ground` / `--hd-ground-ink` to the first slide's colours |
package/src/check.mjs CHANGED
@@ -119,7 +119,7 @@ export function resolveDeckDir(arg, cwd = process.cwd()) {
119
119
  // following symlinks). Anything that climbs out is refused, so release never
120
120
  // uploads a file from elsewhere on the machine.
121
121
  export function deckFileResolver(deckDir) {
122
- const root = realpathSync(deckDir);
122
+ const pathExists = deckPathResolver(deckDir);
123
123
  return (ref) => {
124
124
  let clean;
125
125
  try {
@@ -127,6 +127,14 @@ export function deckFileResolver(deckDir) {
127
127
  } catch {
128
128
  return false;
129
129
  }
130
+ return pathExists(clean);
131
+ };
132
+ }
133
+
134
+ // The same answer for an already-decoded path; `kind: "dir"` asks about a folder instead.
135
+ export function deckPathResolver(deckDir, { kind = "file" } = {}) {
136
+ const root = realpathSync(deckDir);
137
+ return (clean) => {
130
138
  const path = isAbsolute(clean) ? join(root, clean) : resolve(root, clean);
131
139
  const inside = (p) => p === root || p.startsWith(root + sep);
132
140
  if (!inside(path)) return "outside";
@@ -134,7 +142,17 @@ export function deckFileResolver(deckDir) {
134
142
  const real = realpathSync(path);
135
143
  if (!inside(real)) return "outside";
136
144
  if (relative(root, real).split(sep).some((part) => part.startsWith("."))) return "outside";
137
- return lstatSync(real).isFile();
145
+ return kind === "dir" ? lstatSync(real).isDirectory() : lstatSync(real).isFile();
146
+ };
147
+ }
148
+
149
+ // What deckFiles needs to walk a deck folder: whether a path is a file or a
150
+ // folder inside it, and a file's text.
151
+ export function deckFolder(deckDir) {
152
+ return {
153
+ pathExists: deckPathResolver(deckDir),
154
+ isDir: deckPathResolver(deckDir, { kind: "dir" }),
155
+ read: (path) => readFileSync(join(deckDir, path), "utf8"),
138
156
  };
139
157
  }
140
158
 
@@ -144,7 +162,7 @@ export async function checkDeck(dirArg, opts = {}, { fetch: fetchImpl = globalTh
144
162
  const html = readFileSync(join(deckDir, "index.html"), "utf8");
145
163
  const scriptPath = join(deckDir, "script.md");
146
164
  const script = existsSync(scriptPath) ? readFileSync(scriptPath, "utf8") : "";
147
- const { manifest, ...result } = await checkSources({ html, script }, { ...opts, hosted: false, exists: deckFileResolver(deckDir) }, { fetch: fetchImpl });
165
+ const { manifest, ...result } = await checkSources({ html, script }, { ...opts, hosted: false, exists: deckFileResolver(deckDir), ...deckFolder(deckDir) }, { fetch: fetchImpl });
148
166
  const { errors, warnings } = result;
149
167
  // A presenter over the title is a warning, not a failure: only a person can
150
168
  // judge it. A slide that overflows the phone card is an error. Both need a
@@ -36,6 +36,10 @@ export const MESSAGES = {
36
36
  localRef: (ref) => `local reference does not resolve: ${ref}`,
37
37
  outsideRef: (ref) => `local reference leaves the deck folder: ${ref}`,
38
38
  hostedRef: (ref) => `local reference ${ref}: a hosted deck has no folder, so every file must be a URL; upload it with upload_deck_asset and use the URL it returns`,
39
+ scriptRef: (ref, from) => `${from} names ${ref}, which is not in the deck folder; add the file or fix the path`,
40
+ // Warnings: deliberate sometimes (a path built from a CDN base), so the deck still passes.
41
+ scriptTemplate: (from, samples) => `${from} builds file paths at runtime (${samples.slice(0, 3).join(", ")}${samples.length > 3 ? ", …" : ""}); release can't see them, so they 404 on the shared link. Write each file out as a quoted path (an object or array of "media/…" strings, in a script or a .json file it fetches) and pick from that`,
42
+ scriptComputed: (from, calls) => `${from} loads files from computed values (${calls.join(", ")}); release only ships files the deck names literally, so make sure each file it can load is written out as a quoted path in index.html, a script or a .json file the deck loads`,
39
43
  forbidden: (phrase, where) => `forbidden phrase "${phrase}" appears in ${where}`,
40
44
  note: (id) => `slide ${id}: needs a data-note naming the source of its facts`,
41
45
  mobileTitle: (id, title) => `slide ${id}: title "${title}" is over ${MOBILE_TITLE_CHARS} characters and needs a data-title-mobile`,
@@ -61,7 +65,7 @@ export const MESSAGES = {
61
65
  };
62
66
 
63
67
  // The framed player's slide card on a phone (player.css): the screen less the
64
- // 52px masthead, the 56px dock and 8px gutters.
68
+ // 60px masthead, the 48px dock and 8px gutters.
65
69
  export const HOSTED_LAYOUT_WHY = "presenter overlap and phone fit need a browser: run review_deck with draft=true";
66
70
  export const PHONE = { width: 390, height: 844, card: { width: 374, height: 736 } };
67
71
 
@@ -295,6 +299,173 @@ export function localRefs(html) {
295
299
  return [...new Set(refs.filter((r) => !/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(r) && !r.endsWith("/")))];
296
300
  }
297
301
 
302
+ // Files a deck's own scripts load. `play` serves the folder, so any path a
303
+ // script asks for resolves; a released deck is uploaded file by file and its
304
+ // text rewritten to storage URLs, so a file only a script names has to be found
305
+ // here to ship, and a path built at runtime can't be rewritten at all.
306
+
307
+ // Extensions a quoted string must end in to count as a deck file.
308
+ const ASSET_EXT = /\.(?:json|csv|txt|vtt|mp3|m4a|aac|wav|ogg|opus|flac|mp4|m4v|webm|mov|png|jpe?g|webp|avif|gif|svg|ico|pdf|glb|gltf|bin|wasm|m?js|css|woff2?|ttf|otf|eot)$/i;
309
+ const SCHEME = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
310
+ const stripQuery = (s) => s.split("#")[0].split("?")[0];
311
+ const isScriptPath = (s) => !/[\s<>"'`(){}|\\^*]/.test(s) && !SCHEME.test(s) && ASSET_EXT.test(stripQuery(s));
312
+ const dirOf = (path) => path.slice(0, path.lastIndexOf("/") + 1);
313
+
314
+ // `ref` against the folder `dir` ("" for the deck root), as a decoded path
315
+ // inside the deck; null when it climbs out or does not decode. A leading "/"
316
+ // means the deck root, which is where `play` serves it from.
317
+ export function deckPath(dir, ref) {
318
+ let clean;
319
+ try {
320
+ clean = decodeURIComponent(stripQuery(ref.trim()));
321
+ } catch {
322
+ return null;
323
+ }
324
+ const parts = clean.startsWith("/") ? [] : dir.split("/").filter(Boolean);
325
+ for (const p of clean.split("/")) {
326
+ if (!p || p === ".") continue;
327
+ if (p !== "..") parts.push(p);
328
+ else if (!parts.pop()) return null;
329
+ }
330
+ return parts.join("/") || null;
331
+ }
332
+
333
+ // A JavaScript source's string literals, comments skipped: { value, interpolated }
334
+ // for each, plus `code`, the source with every string emptied, so a call can be
335
+ // read without its arguments' text getting in the way.
336
+ export function jsStrings(src) {
337
+ const strings = [];
338
+ let code = "";
339
+ let i = 0;
340
+ while (i < src.length) {
341
+ const c = src[i];
342
+ if (c === "/" && src[i + 1] === "/") {
343
+ const end = src.indexOf("\n", i);
344
+ i = end < 0 ? src.length : end;
345
+ } else if (c === "/" && src[i + 1] === "*") {
346
+ const end = src.indexOf("*/", i + 2);
347
+ i = end < 0 ? src.length : end + 2;
348
+ } else if (c === '"' || c === "'" || c === "`") {
349
+ let j = i + 1;
350
+ let depth = 0;
351
+ let interpolated = false;
352
+ while (j < src.length) {
353
+ const d = src[j];
354
+ if (d === "\\") j += 2;
355
+ else if (c === "`" && depth === 0 && d === "$" && src[j + 1] === "{") { interpolated = true; depth = 1; j += 2; }
356
+ else if (depth > 0) { depth += d === "{" ? 1 : d === "}" ? -1 : 0; j++; }
357
+ else if (d === c || (c !== "`" && d === "\n")) break;
358
+ else j++;
359
+ }
360
+ strings.push({ value: src.slice(i + 1, j), interpolated });
361
+ code += c + c;
362
+ i = j + 1;
363
+ } else {
364
+ code += c;
365
+ i++;
366
+ }
367
+ }
368
+ return { strings, code };
369
+ }
370
+
371
+ // A template string that builds a local file path: every static piece is
372
+ // path-like, and there is a folder in front of the interpolation or a file
373
+ // extension after it (`media/lines/${id}.mp3`, `${dir}/clip.mp4`).
374
+ function templatePath(raw) {
375
+ const pieces = raw.split(/\$\{(?:[^{}]|\{[^{}]*\})*\}/);
376
+ if (pieces.length < 2 || pieces.some((p) => !/^[\w./-]*$/.test(p))) return false;
377
+ const [head, tail] = [pieces[0], pieces.at(-1)];
378
+ if (SCHEME.test(head)) return false;
379
+ return head.includes("/") || ASSET_EXT.test(tail);
380
+ }
381
+
382
+ // Calls that load a file from a value check can't read.
383
+ // `code` has every string emptied, so a value starts with a name or call, or
384
+ // with an emptied string joined by + ('media/' + id + '.mp3').
385
+ const COMPUTED = "(?:(?![\\s)\"'`])|([\"'`])\\1\\s*\\+)";
386
+ const COMPUTED_LOADS = [
387
+ { name: "fetch(…)", re: new RegExp(String.raw`(?<![\w$.])fetch\s*\(\s*` + COMPUTED) },
388
+ { name: "new Audio(…)", re: new RegExp(String.raw`\bnew\s+Audio\s*\(\s*` + COMPUTED) },
389
+ { name: ".src = …", re: new RegExp(String.raw`\.src\s*=\s*(?!=)(?!URL\.createObjectURL)` + COMPUTED) },
390
+ ];
391
+
392
+ // Every file the released page needs, following references through the files
393
+ // it bundles: index.html's own refs (localRefs), the paths quoted in its inline
394
+ // scripts, and in each bundled .js or .json the quoted paths, in each .css its
395
+ // url()s and @imports, until nothing new turns up. A script path resolves against
396
+ // the page first (that is how fetch() and Audio resolve; an ES import resolves
397
+ // against its module, so a same-named file at the root wins, as it does in the
398
+ // importer's rewrite) and then the script's own folder; a stylesheet's against
399
+ // its own folder first. A quoted path counts only when it names a real file, so
400
+ // ordinary strings never get bundled.
401
+ //
402
+ // `pathExists(path)` answers for a decoded deck-relative path: true, false, "outside"
403
+ // or "hosted"; `read(path)` returns a file's text (omitted for a hosted deck,
404
+ // which has no folder); `isDir(path)` lets a quoted path in a real subfolder
405
+ // that names no file be reported as missing (one at the deck root could be
406
+ // anything, a vendored library's own "./worker.js" say, so it is left alone).
407
+ // Returns { files: [{ path, from }], missing: [{ ref, from }], templates: [{ from, samples }], computed: [{ from, calls }] }:
408
+ // `from` is "html" for index.html's own attribute refs, "index.html" for a path
409
+ // quoted in its inline scripts, or the bundled file that named it.
410
+ export function deckFiles(html, { pathExists: exists = () => false, read, isDir = () => false } = {}) {
411
+ const files = new Map();
412
+ const queue = [];
413
+ const missing = [];
414
+ const templates = [];
415
+ const computed = [];
416
+ const add = (path, from) => {
417
+ if (files.has(path)) return;
418
+ files.set(path, from);
419
+ queue.push(path);
420
+ };
421
+ for (const ref of localRefs(html)) {
422
+ const path = deckPath("", ref);
423
+ if (path) add(path, "html");
424
+ }
425
+ // `url` is true for a CSS url()/@import, which is a file reference whatever its extension.
426
+ const quoted = (value, from, dirs, { url = false } = {}) => {
427
+ if (url ? !value || SCHEME.test(value) || value.startsWith("#") : !isScriptPath(value)) return;
428
+ const candidates = [...new Set(dirs.map((d) => deckPath(d, value)).filter(Boolean))];
429
+ const found = candidates.find((p) => exists(p) === true);
430
+ // Missing only where the browser would look (the page for a script, the
431
+ // stylesheet's folder for CSS), and only in a real subfolder there.
432
+ if (found) add(found, from);
433
+ else if (candidates.length && candidates.every((p) => exists(p) === false) && dirOf(candidates[0]) && isDir(dirOf(candidates[0])) && !missing.some((m) => m.ref === value && m.from === from)) missing.push({ ref: value, from });
434
+ };
435
+ const scanJs = (src, from, dir, { json = false } = {}) => {
436
+ const { strings, code } = jsStrings(src);
437
+ if (json) {
438
+ for (const s of strings) quoted(s.value, from, ["", dir]);
439
+ return;
440
+ }
441
+ const built = [];
442
+ for (const s of strings) {
443
+ if (!s.interpolated) quoted(s.value, from, ["", dir]);
444
+ else if (templatePath(s.value)) built.push(`\`${s.value}\``);
445
+ }
446
+ if (built.length) templates.push({ from, samples: [...new Set(built)] });
447
+ const calls = COMPUTED_LOADS.filter((l) => l.re.test(code)).map((l) => l.name);
448
+ if (calls.length) computed.push({ from, calls });
449
+ };
450
+ for (const [, attrs, body] of uncommented(html).matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)) {
451
+ if (!/\ssrc\s*=/i.test(attrs) && !/\stype\s*=\s*["']?(?:application\/(?:ld\+)?json|importmap)/i.test(attrs)) scanJs(body, "index.html", "");
452
+ }
453
+ while (read && queue.length) {
454
+ const path = queue.shift();
455
+ if (!/\.(?:m?js|css|json)$/i.test(path) || exists(path) !== true) continue;
456
+ const text = read(path);
457
+ const dir = dirOf(path);
458
+ if (/\.css$/i.test(path)) {
459
+ const css = text.replace(/\/\*[\s\S]*?\*\//g, "");
460
+ for (const m of css.matchAll(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)'"\s]+))\s*\)|@import\s+(?:"([^"]*)"|'([^']*)')/gi)) {
461
+ const ref = m[1] ?? m[2] ?? m[3] ?? m[4] ?? m[5];
462
+ quoted(ref.trim(), path, [dir, ""], { url: true });
463
+ }
464
+ } else scanJs(text, path, dir, { json: /\.json$/i.test(path) });
465
+ }
466
+ return { files: [...files].map(([path, from]) => ({ path, from })), missing, templates, computed };
467
+ }
468
+
298
469
  export function phraseList(value) {
299
470
  if (!value) return [];
300
471
  if (typeof value === "string") return value.split(",").map((s) => s.trim()).filter(Boolean);
@@ -547,6 +718,9 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
547
718
  }
548
719
  const exists = opts.hosted ? () => "hosted" : (opts.exists ?? (() => true));
549
720
  const errors = htmlErrors(html, { exists, forbid, script, hosted: !!opts.hosted });
721
+ // Without a folder (hosted, or a caller that passes no reader) only the inline scripts are read.
722
+ const walked = deckFiles(html, opts.hosted ? {} : { pathExists: opts.pathExists, read: opts.read, isDir: opts.isDir });
723
+ for (const m of walked.missing) errors.push(MESSAGES.scriptRef(m.ref, m.from));
550
724
  const ids = slides(html).map((s) => s.id);
551
725
  const player = playerScript(html);
552
726
  let manifest = null;
@@ -602,6 +776,8 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
602
776
  }
603
777
  }
604
778
  const warnings = transitionWarnings(html, script, manifest);
779
+ for (const t of walked.templates) warnings.push(MESSAGES.scriptTemplate(t.from, t.samples));
780
+ for (const c of walked.computed) warnings.push(MESSAGES.scriptComputed(c.from, c.calls));
605
781
  if (opts.hosted) {
606
782
  for (const url of absoluteRefs(html)) if (!isHostedAsset(url) && /\.(png|jpe?g|gif|webp|avif|svg|mp4|webm|mov)(\?|#|$)/i.test(url)) warnings.push(MESSAGES.hotlink(url));
607
783
  warnings.push(MESSAGES.layoutUnchecked(HOSTED_LAYOUT_WHY));
package/src/importer.mjs CHANGED
@@ -180,6 +180,33 @@ export function rewriteHtmlAssetUrls(html, htmlEntry, assets, uploadedByName) {
180
180
  return rewriteTextRefs(rewriteCssUrls(out, assetMap), assetMap);
181
181
  }
182
182
 
183
+ // One text asset rewritten against the uploaded URLs.
184
+ export function rewriteTextAsset(asset, assets, uploadedByName) {
185
+ const text = textFromBytes(asset.data);
186
+ return asset.contentType === "text/css"
187
+ ? rewriteCssAssetUrls(text, asset.sourcePath, assets, uploadedByName)
188
+ : rewriteTextAssetUrls(text, asset.sourcePath, assets, uploadedByName);
189
+ }
190
+
191
+ // The text assets in the order they can be saved: each round holds the ones
192
+ // that name no text asset still unsaved. Saving a name again deletes its old
193
+ // copy, so a script rewritten before the file it fetches is saved would embed a
194
+ // dead URL. A cycle (two scripts naming each other) goes last as one round, its
195
+ // refs to each other left relative.
196
+ export function textAssetRounds(assets) {
197
+ const PENDING = "hd-pending:";
198
+ const rounds = [];
199
+ let pending = assets.filter(isTextAsset);
200
+ while (pending.length) {
201
+ const marks = new Map(pending.map((a) => [a.name, PENDING + a.name]));
202
+ const ready = pending.filter((a) => !rewriteTextAsset(a, assets, marks).split(PENDING + a.name).join("").includes(PENDING));
203
+ const round = ready.length ? ready : pending;
204
+ rounds.push(round);
205
+ pending = pending.filter((a) => !round.includes(a));
206
+ }
207
+ return rounds;
208
+ }
209
+
183
210
  export function isTextAsset(asset) {
184
211
  const type = String(asset.contentType || "").split(";")[0].trim().toLowerCase();
185
212
  return type.startsWith("text/") || TEXT_TYPES.has(type);
@@ -206,12 +233,23 @@ function rewriteCssUrls(input, assetMap) {
206
233
  });
207
234
  }
208
235
 
236
+ // Quoted strings that name an uploaded file. A pair that names nothing gives
237
+ // up only its opening quote, so one apostrophe ("Don't stop", a data-note's
238
+ // "company's") can't throw the pairing off for every path after it.
209
239
  function rewriteTextRefs(input, assetMap) {
210
- return rewriteEscapedTextRefs(input, assetMap).replace(/(["'`])([^"'`]+)\1/g, (match, quote, raw) => {
211
- if (quote === "`" && raw.includes("${")) return match;
212
- const url = resolveLocalUrl(raw, assetMap);
213
- return url ? `${quote}${url}${quote}` : match;
214
- });
240
+ const text = rewriteEscapedTextRefs(input, assetMap);
241
+ const pair = /(["'`])([^"'`]+)\1/g;
242
+ let out = "";
243
+ let from = 0;
244
+ for (let m = pair.exec(text); m; m = pair.exec(text)) {
245
+ const [match, quote, raw] = m;
246
+ const url = !(quote === "`" && raw.includes("${")) && resolveLocalUrl(raw, assetMap);
247
+ if (url) {
248
+ out += `${text.slice(from, m.index)}${quote}${url}${quote}`;
249
+ from = m.index + match.length;
250
+ } else pair.lastIndex = m.index + 1;
251
+ }
252
+ return out + text.slice(from);
215
253
  }
216
254
 
217
255
  function rewriteEscapedTextRefs(input, assetMap) {
package/src/ops.mjs CHANGED
@@ -6,10 +6,10 @@ import {
6
6
  bytesFromText,
7
7
  isTextAsset,
8
8
  prepareDeckImport,
9
- rewriteCssAssetUrls,
10
9
  rewriteHtmlAssetUrls,
11
- rewriteTextAssetUrls,
10
+ rewriteTextAsset,
12
11
  slugify,
12
+ textAssetRounds,
13
13
  textFromBytes,
14
14
  titleFromFilename,
15
15
  } from "./importer.mjs";
@@ -585,23 +585,24 @@ export async function importDeckFromBytes(opts = {}, fields = {}, adapter = {})
585
585
  deckCreated = true;
586
586
  }
587
587
 
588
- // Phase 1: upload every asset and fully populate uploadedByName.
589
- await mapWithConcurrency(parsed.assets, concurrency, async (asset, index) => {
588
+ // Phase 1: upload every binary asset.
589
+ const binary = parsed.assets.filter((a) => !isTextAsset(a));
590
+ await mapWithConcurrency(binary, concurrency, async (asset, index) => {
590
591
  onStage(`uploading asset ${index + 1}/${parsed.assets.length}: ${asset.name}`);
591
592
  await saveDeckAsset(asset);
592
593
  });
593
594
 
594
- // Phase 2 (hard barrier after phase 1): rewrite text assets against the now-
595
- // complete URL map and re-upload them under the same name. Must not interleave
596
- // with phase 1 or refs could resolve against a half-built map.
597
- const textAssets = parsed.assets.filter(isTextAsset);
598
- await mapWithConcurrency(textAssets, concurrency, async (asset, index) => {
599
- onStage(`rewriting asset ${index + 1}/${textAssets.length}: ${asset.name}`);
600
- const text = asset.contentType === "text/css"
601
- ? rewriteCssAssetUrls(textFromBytes(asset.data), asset.sourcePath, parsed.assets, uploadedByName)
602
- : rewriteTextAssetUrls(textFromBytes(asset.data), asset.sourcePath, parsed.assets, uploadedByName);
603
- await saveDeckAsset(asset, bytesFromText(text), asset.contentType);
604
- });
595
+ // Phase 2 (after phase 1): text assets, rewritten against the URLs saved so
596
+ // far, each after the text assets it names (textAssetRounds). Saving a name
597
+ // twice deletes the first copy, so a script must never embed a URL from a
598
+ // save that a later save replaces.
599
+ let done = binary.length;
600
+ for (const round of textAssetRounds(parsed.assets)) {
601
+ await mapWithConcurrency(round, concurrency, async (asset) => {
602
+ onStage(`uploading asset ${++done}/${parsed.assets.length}: ${asset.name}`);
603
+ await saveDeckAsset(asset, bytesFromText(rewriteTextAsset(asset, parsed.assets, uploadedByName)), asset.contentType);
604
+ });
605
+ }
605
606
 
606
607
  onStage("storing deck HTML");
607
608
  const html = rewriteHtmlAssetUrls(parsed.html, parsed.htmlEntry, parsed.assets, uploadedByName);
package/src/release.mjs CHANGED
@@ -12,12 +12,12 @@
12
12
  // 5. creates one share per --to (paired in order with --email) and prints the URLs.
13
13
  import { execFileSync } from "node:child_process";
14
14
  import { readFileSync } from "node:fs";
15
- import { join, posix } from "node:path";
15
+ import { join } from "node:path";
16
16
  import { zipSync } from "fflate";
17
17
  import * as ops from "./ops.mjs";
18
18
  import { withAvatar } from "./presenter.mjs";
19
19
  import { target } from "./runtime.mjs";
20
- import { checkDeck, localRefs, resolveDeckDir } from "./check.mjs";
20
+ import { checkDeck, deckFiles, deckFolder, resolveDeckDir } from "./check.mjs";
21
21
  import { narrate as runNarrate } from "./narrate.mjs";
22
22
 
23
23
  const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
@@ -47,18 +47,23 @@ export function deckCommit(deckDir) {
47
47
  return execFileSync("git", ["-C", deckDir, "rev-parse", "--short", "HEAD"], { encoding: "utf8" }).trim();
48
48
  }
49
49
 
50
- // What import uploads: the page alone, or a zip of the page and its local files.
50
+ // What import uploads: the page alone, or a zip of the page and its local files:
51
+ // what index.html names, and what the scripts and stylesheets it bundles name in
52
+ // turn (deckFiles). `added` maps each bundled script or stylesheet to the files
53
+ // it brought in, so release can say what came from where.
51
54
  // `presenter` (from avatars.presenterFor) puts the owner's avatar on the
52
55
  // uploaded page's player tag; the committed index.html is never changed.
53
56
  export function releaseBundle(deckDir, presenter = null) {
54
57
  const raw = readFileSync(join(deckDir, "index.html"));
55
58
  const html = presenter ? Buffer.from(withAvatar(raw.toString("utf8"), presenter)) : raw;
56
- // check has already refused refs that leave the folder or do not decode.
57
- const refs = [...new Set(localRefs(html.toString("utf8")).map((r) => posix.normalize(decodeURIComponent(r)).replace(/^(\.?\/)+/, "")))];
58
- if (!refs.length) return { bytes: html, filename: "index.html", files: ["index.html"] };
59
+ // check has already refused refs that leave the folder or do not resolve.
60
+ const found = deckFiles(html.toString("utf8"), deckFolder(deckDir)).files.filter((f) => f.path !== "index.html");
61
+ const added = {};
62
+ for (const f of found) if (f.from !== "html") (added[f.from] ??= []).push(f.path);
63
+ if (!found.length) return { bytes: html, filename: "index.html", files: ["index.html"], added };
59
64
  const files = { "index.html": html };
60
- for (const ref of refs) files[ref] = readFileSync(join(deckDir, ref));
61
- return { bytes: Buffer.from(zipSync(files)), filename: "deck.zip", htmlFile: "index.html", files: Object.keys(files) };
65
+ for (const f of found) files[f.path] = readFileSync(join(deckDir, f.path));
66
+ return { bytes: Buffer.from(zipSync(files)), filename: "deck.zip", htmlFile: "index.html", files: Object.keys(files), added };
62
67
  }
63
68
 
64
69
  // From the CLI, parse() collects opts.recipients in order (each --email follows
@@ -86,6 +91,7 @@ export async function release(pos, opts = {}, io = {}) {
86
91
  const loose = untracked(deckDir, bundle.files);
87
92
  if (loose.length) throw new Error(`refusing to release: git does not track ${loose.join(", ")} (ignored or never added); commit them so the release corresponds to a commit`);
88
93
  log(`✓ check passed: ${checked.slides} slides, ${checked.totalDuration}s`);
94
+ for (const [from, paths] of Object.entries(bundle.added)) log(` +${paths.length} file(s) referenced from ${from}: ${paths.join(", ")}`);
89
95
 
90
96
  // Cached slides cost nothing, so this only renders what changed since the commit.
91
97
  // Only the options narrate understands: an undefined --slide/--voice key would fail its flag check.
package/src/runtime.mjs CHANGED
@@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
8
8
  import { ConvexHttpClient } from "convex/browser";
9
9
  import { makeFunctionReference } from "convex/server";
10
10
 
11
- export const VERSION = "0.8.0";
11
+ export const VERSION = "0.8.1";
12
12
  const UPLOAD_TIMEOUT_MS = 125_000;
13
13
 
14
14
  export const PROD = {
@@ -10,8 +10,11 @@ One `## <slide-id>` block per slide, in slide order; the id is the slide's
10
10
  phrase (4 to 85 characters, two lines in the bubble). Aim for 150 to 180 s in
11
11
  all. Holds: 0.4 s by default, `hold: 1.5` for a number, chart or reveal,
12
12
  `hold: 2` on interactive slides. Stage tags become
13
- cues at the word before them: [wave], [mood: excited], [reveal: #detail],
14
- [go: #detail] (the presenter flies beside it; [go: slot] brings it home),
13
+ cues at the word before them: [wave], [mood: excited], [reveal: #detail]
14
+ (replays from the start when the slide does; the head glances at it unless
15
+ a [look] or [go] is right beside it),
16
+ [go: #detail] (it grows, its siblings fade and the presenter flies beside it,
17
+ or looks at it when there is no room; [go: slot] brings it home),
15
18
  [look: h2] (the head looks at it), [custom: confetti]. `presenterStyle: stage`
16
19
  above sets the deck's presenter; `presenter-style: corner` under a heading sets
17
20
  one slide's. `transition: drift` above is how every slide enters;