@hypersocial/decks-cli 0.8.0 → 0.8.2

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.2",
4
4
  "description": "Command-line client for Hypersocial Decks live analytics and share management.",
5
5
  "type": "module",
6
6
  "main": "./src/commands.mjs",
@@ -147,6 +147,7 @@ Hyperdecks server; use the CLI where you have a shell and the tool otherwise.
147
147
  | One more link | `hyperdecks share new <slug> "<name>" [--no-forward]` | `create_share` (`allowForward`) |
148
148
  | Forwarding, one link | `hyperdecks share forward on\|off\|default <token>` | `set_share_forwarding` |
149
149
  | Forwarding, whole deck | `hyperdecks deck forward on\|off <slug>` | `set_deck_forwarding` |
150
+ | Slides or scroll, viewer toggle | `hyperdecks deck view <slug> [slides\|scroll\|default] [--toggle on\|off]` | `set_deck_view` |
150
151
  | Who watched | `hyperdecks deck stats <slug>` | `deck_stats` |
151
152
  | All activity | `hyperdecks activity --deck <slug>` | `activity_overview` |
152
153
  | What one person watched | `hyperdecks engagement "<name>" --deck <slug> --json` | `recipient_engagement` |
@@ -159,6 +160,15 @@ Disabling the parent link (`share off`) turns off its forwards too, and
159
160
  `share forward default` (or `allowForward: null`) drops a link's own setting
160
161
  so it follows the deck's.
161
162
 
163
+ How a deck opens (slides or scroll) is a deck setting too: `deck view <slug>
164
+ scroll` (`set_deck_view { slug, view: "scroll" }`) takes effect on every link's
165
+ next load, with no re-release; `default` (`view: null`) goes back to what the
166
+ HTML says. Viewers get a slides/scroll toggle in the player, remembered per
167
+ link, unless you turn it off (`--toggle off`, `viewToggle: false`). A viewer
168
+ who already switched keeps their own choice while the toggle is on; turn the
169
+ toggle off to make every viewer see the deck's view.
170
+ `deck list --json` / `list_decks` and `deck stats` / `deck_stats` show both.
171
+
162
172
  ### 1. Ground it in the user's own material
163
173
 
164
174
  Build the deck from what you can already read in this session: files in the
@@ -213,8 +223,24 @@ The deck contract (`check` enforces each line):
213
223
  - The player script stays
214
224
  `<script type="module" src="https://decks.hypersocial.ai/player/v1/player.js" data-manifest="…">`.
215
225
  `narrate` fills `data-manifest`; do not edit it by hand.
226
+ - To read the deck as one long page instead of slide by slide, add
227
+ `data-view="scroll"` to that player script (on the hosted MCP, through
228
+ write_deck_source). The slides stay as they are: they stack and snap, the
229
+ page fades to each landed slide's background (`data-bg="#ffd166"` on a
230
+ `<section>` overrides it), the presenter travels to each slide's
231
+ `narration-slot` as you scroll, and the narration scrolls the page on by
232
+ itself. Remove the attribute to go back. For a released deck, prefer the
233
+ deck setting (`deck view <slug> scroll`, `set_deck_view`): it overrides the
234
+ attribute and needs no re-release.
216
235
  - Local images and files sit inside the deck folder and are referenced
217
236
  relatively (`img/chart.png`). Hosted images (`gen-image` URLs) are fine too.
237
+ - A file the deck's own JavaScript loads (`fetch('media/data.json')`, a clip
238
+ for `new Audio(…)`) is written out as a quoted path, in an inline `<script>`
239
+ or in a `.js` or `.json` file the deck loads. Release follows those paths and ships the files
240
+ (`play` serves the whole folder, the `/v/` link only what release shipped).
241
+ A path built at runtime (`` `media/lines/${id}.mp3` ``) can't be followed:
242
+ list each file literally (`{ otter: "media/lines/otter.mp3" }`) and pick from
243
+ that.
218
244
 
219
245
  <!-- topic: presenter -->
220
246
  #### Which presenter style
@@ -447,13 +473,32 @@ phone screenshots, the same `fix` bar.
447
473
  - Narration: short phrases, each sentence one caption of 4 to 85 characters
448
474
  (two lines in the bubble). Aim for 2.5 to 3 minutes in all; `maxTotalSeconds`
449
475
  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
476
+ - 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.
477
+ - Interactive slides (something the viewer taps or plays with): add
478
+ `data-interactive` to the slide's `<section>`. The presenter finishes the
479
+ line, then the deck waits with a Continue button instead of advancing, and
480
+ taps inside the slide don't pause the narration. The slide hears the
481
+ presenter through `deck:presenter { slide, state }` on `window` (or a
482
+ controller's `onNarration(state, ctx)`), ducks its own audio while `state`
483
+ is `playing`, and can quiet the presenter with `ctx.player.pause()` /
484
+ `resume()` (or `window.hyperdecks.pauseNarration()` / `resumeNarration()`).
485
+ - Anything the viewer clicks on a slide (interactive or not) gets
486
+ `data-hd-tap` (or `cursor: pointer`), so the click doesn't also pause the
487
+ presenter. Buttons, links, canvases and focusable elements are exempt already.
488
+ - Cues: `[reveal: #id]` reveals `.reveal#id` at the word before it (going
489
+ back or replaying the slide replays its reveals from the first, and the
490
+ live head glances at each, unless a `[look]`/`[go]` is beside it or a
491
+ `go` has it docked; `data-reveal-glance="off"` on the slide or `<body>`
492
+ stops the glance); also
452
493
  `[wave]`, `[mood: excited]`, `[pause: 0.5]`, `[custom: name]`.
453
494
  - Moves, to point at part of a slide: `[go: <selector>]` flies the presenter
454
495
  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
496
+ end, or `[go: slot]`. The element grows a little and, when it is one item
497
+ of a list or grid, the other items fade back (`.hd-dimmed`); the presenter
498
+ never covers them, so with no room beside it it stays put and looks at it.
499
+ Put `data-focus="ring"` on the element or slide for an accent ring instead,
500
+ or restyle `.hd-focus` / `.hd-dimmed` in the deck's CSS.
501
+ `[look: <selector>]` turns the head toward an element without moving. One selector each, matched inside that slide; check fails one that
457
502
  matches nothing. Use `go` when the narration walks through a list or chart,
458
503
  `look` for a glance at a title or number. Not in `corner` style. The best
459
504
  showcase is a slide of stacked cards, one point each, with a `go` to each
@@ -470,10 +515,10 @@ phone screenshots, the same `fix` bar.
470
515
  |---|---|
471
516
  | `[wave]` | the presenter greets (the live head's joy emote) |
472
517
  | `[mood: excited]` / `[emote: name]` | the head's mood or emote |
473
- | `[reveal: #id]` | adds `is-revealed` to `#id` |
518
+ | `[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
519
  | `[pause: 0.5]` | a beat of silence (≤ 5 s) |
475
520
  | `[play: video]` / `[pause: video]` | play or pause the slide's video |
476
- | `[go: .list]` / `[go: slot]` | fly beside `.list` / back to the slot |
521
+ | `[go: #step-2]` / `[go: slot]` | fly beside that item, grow it, fade the other items / back to the slot |
477
522
  | `[look: h2]` / `[look #card]` | look at the title / the card, then back |
478
523
  | `[look viewer]` / `[look away]` | look back at the viewer / glance aside |
479
524
  | `[nod]` / `[shake]` | a nod or head shake that suits the mood |
@@ -487,6 +532,10 @@ phone screenshots, the same `fix` bar.
487
532
  - No `voice:` in the front matter means the user's first ready voice. If
488
533
  `hyperdecks voice list` shows none, ask the user to record one and run
489
534
  `voice clone` (it asks them for consent; never clone anyone else's voice).
535
+ - Names speech-to-text spells another way fail narrate's transcript check
536
+ (step 4). Map what it heard to what you wrote with a `normalize:` entry in
537
+ the front matter, `heard=written`, comma-separated: a clip that says
538
+ "Minimoji" but is transcribed as "Moji" needs `normalize: moji=minimoji`.
490
539
 
491
540
  ### 4. Narrate and read the guard
492
541
 
@@ -510,9 +559,13 @@ hyperdecks check decks/<slug> [--forbid "Other Co,codename"] [--layout]
510
559
  At the stage size it warns, without failing, when a slide's presenter would
511
560
  overlap its title. On a 390×844 phone (the share player's 374×736 slide card)
512
561
  it fails any slide whose content overflows: the player never scrolls a slide.
562
+ `release` (local or `--hosted`) always runs this layout check and refuses an
563
+ overflowing slide; `check <slug> --hosted --layout` measures a hosted draft.
513
564
 
514
565
  `--forbid` lists phrases that must not reach this audience (another customer,
515
- an internal codename). Messages and what to do:
566
+ an internal codename). It matches the page's text, alt text, titles and
567
+ script.md, not URLs in attributes, so forbidding your own brand still passes
568
+ with the player's `decks.hypersocial.ai` script tag. Messages and what to do:
516
569
 
517
570
  | Message starts with | Fix |
518
571
  |---|---|
@@ -527,6 +580,9 @@ an internal codename). Messages and what to do:
527
580
  | `clip <id>: captions do not cover the clip text` | narrate that slide again |
528
581
  | `clip <id>: caption N is … characters` | split or join sentences in that block |
529
582
  | `local reference does not resolve` / `leaves the deck folder` | move the file into the deck folder or fix the path |
583
+ | `<file> names <path>, which is not in the deck folder` | add the file, or fix the path in that script |
584
+ | ⚠ `<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 |
585
+ | ⚠ `<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
586
  | `player script must be` / `no player script` | restore the player/v1 script tag |
531
587
  | `the player script has no data-manifest` | run `narrate` |
532
588
  | `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 |
@@ -90,7 +90,9 @@ makes the hello deck; continue from step 5 below.
90
90
  10. Later: `list_shares` and `deck_stats` (both show the card),
91
91
  `activity_overview`; `email_embed { token }` for the HTML that pastes into an
92
92
  email as the deck's moving preview (it needs a recipient's link, not a QA
93
- one); `set_share_notify` to mute a link's "opened" alerts;
93
+ one); `set_deck_view { slug, view: "scroll" | "slides" | null, viewToggle }`
94
+ for how the deck opens and whether viewers may switch (every link, next
95
+ load, no re-release); `set_share_notify` to mute a link's "opened" alerts;
94
96
  `set_recipient_archived` to tidy the activity feed; `credits` for the
95
97
  balance. `remove_deck` deletes a deck and breaks every link to it: confirm
96
98
  with the user first.
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,34 +142,54 @@ 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();
138
146
  };
139
147
  }
140
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"),
156
+ };
157
+ }
158
+
159
+ // The browser half of check --layout, on a folder holding the deck's index.html
160
+ // (the hosted release measures a draft the same way, from a temp folder). A
161
+ // presenter over the title is a warning, not a failure: only a person can judge
162
+ // it. A slide that overflows the phone card is an error: the player never
163
+ // scrolls a slide. No Chromium is a warning, as each measurement is optional.
164
+ export async function layoutCheck(deckDir, html, manifest = null, { measure = measureLayout, measureFit = measurePhoneFit } = {}) {
165
+ const errors = [], warnings = [];
166
+ const { width: w, height: h } = stageSize(html);
167
+ try {
168
+ warnings.push(...overlapWarnings(await measure(deckDir, slideStyles(html, manifest), { width: w, height: h }), `${w}×${h}`));
169
+ } catch (e) {
170
+ warnings.push(MESSAGES.layoutUnchecked(e.message));
171
+ }
172
+ try {
173
+ errors.push(...phoneOverflowErrors(await measureFit(deckDir)));
174
+ } catch (e) {
175
+ warnings.push(MESSAGES.layoutUnchecked(e.message));
176
+ }
177
+ return { errors, warnings: [...new Set(warnings)] };
178
+ }
179
+
141
180
  // Run every rule on a deck folder. Returns { deckDir, slug, slides, totalDuration, errors }.
142
181
  export async function checkDeck(dirArg, opts = {}, { fetch: fetchImpl = globalThis.fetch, cwd, measure = measureLayout, measureFit = measurePhoneFit } = {}) {
143
182
  const deckDir = resolveDeckDir(dirArg, cwd);
144
183
  const html = readFileSync(join(deckDir, "index.html"), "utf8");
145
184
  const scriptPath = join(deckDir, "script.md");
146
185
  const script = existsSync(scriptPath) ? readFileSync(scriptPath, "utf8") : "";
147
- const { manifest, ...result } = await checkSources({ html, script }, { ...opts, hosted: false, exists: deckFileResolver(deckDir) }, { fetch: fetchImpl });
186
+ const { manifest, ...result } = await checkSources({ html, script }, { ...opts, hosted: false, exists: deckFileResolver(deckDir), ...deckFolder(deckDir) }, { fetch: fetchImpl });
148
187
  const { errors, warnings } = result;
149
- // A presenter over the title is a warning, not a failure: only a person can
150
- // judge it. A slide that overflows the phone card is an error. Both need a
151
- // browser, so they run only with --layout.
188
+ // Both need a browser, so they run only with --layout (release always asks).
152
189
  if (opts.layout) {
153
- const { width: w, height: h } = stageSize(html);
154
- try {
155
- warnings.push(...overlapWarnings(await measure(deckDir, slideStyles(html, manifest), { width: w, height: h }), `${w}×${h}`));
156
- } catch (e) {
157
- warnings.push(MESSAGES.layoutUnchecked(e.message));
158
- }
159
- // A slide that scrolls on a phone fails: the player never scrolls a slide.
160
- try {
161
- errors.push(...phoneOverflowErrors(await measureFit(deckDir)));
162
- } catch (e) {
163
- warnings.push(MESSAGES.layoutUnchecked(e.message));
164
- }
190
+ const layout = await layoutCheck(deckDir, html, manifest, { measure, measureFit });
191
+ errors.push(...layout.errors);
192
+ warnings.push(...layout.warnings);
165
193
  }
166
194
  return {
167
195
  deckDir,
@@ -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
 
@@ -72,6 +76,9 @@ const attr = (tag, name) => {
72
76
  };
73
77
  const round = (n) => Math.round(n * 100) / 100;
74
78
  const decode = (s) => s.replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;|&apos;/g, "'");
79
+ // Blank every attribute value that is an absolute or protocol-relative URL
80
+ // (src, href, data-manifest, …); the audience never reads those.
81
+ const withoutUrlAttrs = (html) => html.replace(/(\s[\w:.-]+\s*=\s*)(?:"(?:https?:)?\/\/[^"]*"|'(?:https?:)?\/\/[^']*')/gi, '$1""');
75
82
  const textOf = (html) => decode(html.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
76
83
 
77
84
  // Comments blanked but newlines kept, so line numbers still match the file.
@@ -295,6 +302,173 @@ export function localRefs(html) {
295
302
  return [...new Set(refs.filter((r) => !/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(r) && !r.endsWith("/")))];
296
303
  }
297
304
 
305
+ // Files a deck's own scripts load. `play` serves the folder, so any path a
306
+ // script asks for resolves; a released deck is uploaded file by file and its
307
+ // text rewritten to storage URLs, so a file only a script names has to be found
308
+ // here to ship, and a path built at runtime can't be rewritten at all.
309
+
310
+ // Extensions a quoted string must end in to count as a deck file.
311
+ 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;
312
+ const SCHEME = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
313
+ const stripQuery = (s) => s.split("#")[0].split("?")[0];
314
+ const isScriptPath = (s) => !/[\s<>"'`(){}|\\^*]/.test(s) && !SCHEME.test(s) && ASSET_EXT.test(stripQuery(s));
315
+ const dirOf = (path) => path.slice(0, path.lastIndexOf("/") + 1);
316
+
317
+ // `ref` against the folder `dir` ("" for the deck root), as a decoded path
318
+ // inside the deck; null when it climbs out or does not decode. A leading "/"
319
+ // means the deck root, which is where `play` serves it from.
320
+ export function deckPath(dir, ref) {
321
+ let clean;
322
+ try {
323
+ clean = decodeURIComponent(stripQuery(ref.trim()));
324
+ } catch {
325
+ return null;
326
+ }
327
+ const parts = clean.startsWith("/") ? [] : dir.split("/").filter(Boolean);
328
+ for (const p of clean.split("/")) {
329
+ if (!p || p === ".") continue;
330
+ if (p !== "..") parts.push(p);
331
+ else if (!parts.pop()) return null;
332
+ }
333
+ return parts.join("/") || null;
334
+ }
335
+
336
+ // A JavaScript source's string literals, comments skipped: { value, interpolated }
337
+ // for each, plus `code`, the source with every string emptied, so a call can be
338
+ // read without its arguments' text getting in the way.
339
+ export function jsStrings(src) {
340
+ const strings = [];
341
+ let code = "";
342
+ let i = 0;
343
+ while (i < src.length) {
344
+ const c = src[i];
345
+ if (c === "/" && src[i + 1] === "/") {
346
+ const end = src.indexOf("\n", i);
347
+ i = end < 0 ? src.length : end;
348
+ } else if (c === "/" && src[i + 1] === "*") {
349
+ const end = src.indexOf("*/", i + 2);
350
+ i = end < 0 ? src.length : end + 2;
351
+ } else if (c === '"' || c === "'" || c === "`") {
352
+ let j = i + 1;
353
+ let depth = 0;
354
+ let interpolated = false;
355
+ while (j < src.length) {
356
+ const d = src[j];
357
+ if (d === "\\") j += 2;
358
+ else if (c === "`" && depth === 0 && d === "$" && src[j + 1] === "{") { interpolated = true; depth = 1; j += 2; }
359
+ else if (depth > 0) { depth += d === "{" ? 1 : d === "}" ? -1 : 0; j++; }
360
+ else if (d === c || (c !== "`" && d === "\n")) break;
361
+ else j++;
362
+ }
363
+ strings.push({ value: src.slice(i + 1, j), interpolated });
364
+ code += c + c;
365
+ i = j + 1;
366
+ } else {
367
+ code += c;
368
+ i++;
369
+ }
370
+ }
371
+ return { strings, code };
372
+ }
373
+
374
+ // A template string that builds a local file path: every static piece is
375
+ // path-like, and there is a folder in front of the interpolation or a file
376
+ // extension after it (`media/lines/${id}.mp3`, `${dir}/clip.mp4`).
377
+ function templatePath(raw) {
378
+ const pieces = raw.split(/\$\{(?:[^{}]|\{[^{}]*\})*\}/);
379
+ if (pieces.length < 2 || pieces.some((p) => !/^[\w./-]*$/.test(p))) return false;
380
+ const [head, tail] = [pieces[0], pieces.at(-1)];
381
+ if (SCHEME.test(head)) return false;
382
+ return head.includes("/") || ASSET_EXT.test(tail);
383
+ }
384
+
385
+ // Calls that load a file from a value check can't read.
386
+ // `code` has every string emptied, so a value starts with a name or call, or
387
+ // with an emptied string joined by + ('media/' + id + '.mp3').
388
+ const COMPUTED = "(?:(?![\\s)\"'`])|([\"'`])\\1\\s*\\+)";
389
+ const COMPUTED_LOADS = [
390
+ { name: "fetch(…)", re: new RegExp(String.raw`(?<![\w$.])fetch\s*\(\s*` + COMPUTED) },
391
+ { name: "new Audio(…)", re: new RegExp(String.raw`\bnew\s+Audio\s*\(\s*` + COMPUTED) },
392
+ { name: ".src = …", re: new RegExp(String.raw`\.src\s*=\s*(?!=)(?!URL\.createObjectURL)` + COMPUTED) },
393
+ ];
394
+
395
+ // Every file the released page needs, following references through the files
396
+ // it bundles: index.html's own refs (localRefs), the paths quoted in its inline
397
+ // scripts, and in each bundled .js or .json the quoted paths, in each .css its
398
+ // url()s and @imports, until nothing new turns up. A script path resolves against
399
+ // the page first (that is how fetch() and Audio resolve; an ES import resolves
400
+ // against its module, so a same-named file at the root wins, as it does in the
401
+ // importer's rewrite) and then the script's own folder; a stylesheet's against
402
+ // its own folder first. A quoted path counts only when it names a real file, so
403
+ // ordinary strings never get bundled.
404
+ //
405
+ // `pathExists(path)` answers for a decoded deck-relative path: true, false, "outside"
406
+ // or "hosted"; `read(path)` returns a file's text (omitted for a hosted deck,
407
+ // which has no folder); `isDir(path)` lets a quoted path in a real subfolder
408
+ // that names no file be reported as missing (one at the deck root could be
409
+ // anything, a vendored library's own "./worker.js" say, so it is left alone).
410
+ // Returns { files: [{ path, from }], missing: [{ ref, from }], templates: [{ from, samples }], computed: [{ from, calls }] }:
411
+ // `from` is "html" for index.html's own attribute refs, "index.html" for a path
412
+ // quoted in its inline scripts, or the bundled file that named it.
413
+ export function deckFiles(html, { pathExists: exists = () => false, read, isDir = () => false } = {}) {
414
+ const files = new Map();
415
+ const queue = [];
416
+ const missing = [];
417
+ const templates = [];
418
+ const computed = [];
419
+ const add = (path, from) => {
420
+ if (files.has(path)) return;
421
+ files.set(path, from);
422
+ queue.push(path);
423
+ };
424
+ for (const ref of localRefs(html)) {
425
+ const path = deckPath("", ref);
426
+ if (path) add(path, "html");
427
+ }
428
+ // `url` is true for a CSS url()/@import, which is a file reference whatever its extension.
429
+ const quoted = (value, from, dirs, { url = false } = {}) => {
430
+ if (url ? !value || SCHEME.test(value) || value.startsWith("#") : !isScriptPath(value)) return;
431
+ const candidates = [...new Set(dirs.map((d) => deckPath(d, value)).filter(Boolean))];
432
+ const found = candidates.find((p) => exists(p) === true);
433
+ // Missing only where the browser would look (the page for a script, the
434
+ // stylesheet's folder for CSS), and only in a real subfolder there.
435
+ if (found) add(found, from);
436
+ 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 });
437
+ };
438
+ const scanJs = (src, from, dir, { json = false } = {}) => {
439
+ const { strings, code } = jsStrings(src);
440
+ if (json) {
441
+ for (const s of strings) quoted(s.value, from, ["", dir]);
442
+ return;
443
+ }
444
+ const built = [];
445
+ for (const s of strings) {
446
+ if (!s.interpolated) quoted(s.value, from, ["", dir]);
447
+ else if (templatePath(s.value)) built.push(`\`${s.value}\``);
448
+ }
449
+ if (built.length) templates.push({ from, samples: [...new Set(built)] });
450
+ const calls = COMPUTED_LOADS.filter((l) => l.re.test(code)).map((l) => l.name);
451
+ if (calls.length) computed.push({ from, calls });
452
+ };
453
+ for (const [, attrs, body] of uncommented(html).matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)) {
454
+ if (!/\ssrc\s*=/i.test(attrs) && !/\stype\s*=\s*["']?(?:application\/(?:ld\+)?json|importmap)/i.test(attrs)) scanJs(body, "index.html", "");
455
+ }
456
+ while (read && queue.length) {
457
+ const path = queue.shift();
458
+ if (!/\.(?:m?js|css|json)$/i.test(path) || exists(path) !== true) continue;
459
+ const text = read(path);
460
+ const dir = dirOf(path);
461
+ if (/\.css$/i.test(path)) {
462
+ const css = text.replace(/\/\*[\s\S]*?\*\//g, "");
463
+ for (const m of css.matchAll(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)'"\s]+))\s*\)|@import\s+(?:"([^"]*)"|'([^']*)')/gi)) {
464
+ const ref = m[1] ?? m[2] ?? m[3] ?? m[4] ?? m[5];
465
+ quoted(ref.trim(), path, [dir, ""], { url: true });
466
+ }
467
+ } else scanJs(text, path, dir, { json: /\.json$/i.test(path) });
468
+ }
469
+ return { files: [...files].map(([path, from]) => ({ path, from })), missing, templates, computed };
470
+ }
471
+
298
472
  export function phraseList(value) {
299
473
  if (!value) return [];
300
474
  if (typeof value === "string") return value.split(",").map((s) => s.trim()).filter(Boolean);
@@ -359,9 +533,13 @@ export function htmlErrors(html, { exists = () => true, forbid = [], script = ""
359
533
  else if (!found) errs.push(MESSAGES.localRef(ref));
360
534
  }
361
535
  const page = textOf(uncommented(html).replace(/<(script|style)\b[\s\S]*?<\/\1>/g, " ")).toLowerCase();
536
+ // The raw HTML too (alt text, <title>, meta content, inline script), less
537
+ // attribute values that are URLs: the player's own src (PLAYER_V1) names
538
+ // decks.hypersocial.ai, so forbidding the brand would otherwise always fail.
539
+ const raw = withoutUrlAttrs(uncommented(html)).toLowerCase();
362
540
  for (const phrase of phraseList(forbid)) {
363
541
  const p = phrase.toLowerCase();
364
- if (page.includes(p) || uncommented(html).toLowerCase().includes(p)) errs.push(MESSAGES.forbidden(phrase, "index.html"));
542
+ if (page.includes(p) || raw.includes(p)) errs.push(MESSAGES.forbidden(phrase, "index.html"));
365
543
  if (script.toLowerCase().includes(p)) errs.push(MESSAGES.forbidden(phrase, "script.md"));
366
544
  }
367
545
  return errs;
@@ -547,6 +725,9 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
547
725
  }
548
726
  const exists = opts.hosted ? () => "hosted" : (opts.exists ?? (() => true));
549
727
  const errors = htmlErrors(html, { exists, forbid, script, hosted: !!opts.hosted });
728
+ // Without a folder (hosted, or a caller that passes no reader) only the inline scripts are read.
729
+ const walked = deckFiles(html, opts.hosted ? {} : { pathExists: opts.pathExists, read: opts.read, isDir: opts.isDir });
730
+ for (const m of walked.missing) errors.push(MESSAGES.scriptRef(m.ref, m.from));
550
731
  const ids = slides(html).map((s) => s.id);
551
732
  const player = playerScript(html);
552
733
  let manifest = null;
@@ -602,6 +783,8 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
602
783
  }
603
784
  }
604
785
  const warnings = transitionWarnings(html, script, manifest);
786
+ for (const t of walked.templates) warnings.push(MESSAGES.scriptTemplate(t.from, t.samples));
787
+ for (const c of walked.computed) warnings.push(MESSAGES.scriptComputed(c.from, c.calls));
605
788
  if (opts.hosted) {
606
789
  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
790
  warnings.push(MESSAGES.layoutUnchecked(HOSTED_LAYOUT_WHY));
package/src/commands.mjs CHANGED
@@ -58,6 +58,7 @@ async function deckStats(pos, opts) {
58
58
  console.log(` unique viewers: ${s.viewers}`);
59
59
  console.log(` avg engaged: ${fmtMs(s.avgMs)}`);
60
60
  console.log(` total engaged: ${fmtMs(s.totalMs)}`);
61
+ if (s.view !== undefined) console.log(` plays as: ${s.view ?? "as its HTML says"}${s.viewToggle === false ? " (viewer toggle off)" : ""}${s.usesPlayer === false ? " (won't apply: the deck doesn't load player/v1)" : ""}`);
61
62
  if (s.recent?.length) {
62
63
  console.log(" recent:");
63
64
  for (const r of s.recent) {
@@ -153,6 +154,31 @@ async function deckForward(pos, opts) {
153
154
  console.log(`OK forwarding ${mode} for deck ${slug} (links with their own setting keep it)`);
154
155
  }
155
156
 
157
+ const VIEW_USAGE = "usage: deck view <slug> [slides|scroll|default] [--toggle on|off]";
158
+ const VIEWS = { slides: "slides", scroll: "scroll", default: null };
159
+ async function deckView(pos, opts) {
160
+ const [slug, mode] = pos;
161
+ const toggle = opts.toggle;
162
+ if (!slug || (mode === undefined && !Object.hasOwn(opts, "toggle"))) throw new Error(VIEW_USAGE);
163
+ if (mode !== undefined && !Object.hasOwn(VIEWS, mode)) throw new Error(VIEW_USAGE);
164
+ // A trailing --toggle with no value is a mistake, not "leave it".
165
+ if (Object.hasOwn(opts, "toggle") && toggle !== "on" && toggle !== "off") throw new Error(VIEW_USAGE);
166
+ const out = await ops.setDeckView(opts, slug, {
167
+ view: mode === undefined ? undefined : VIEWS[mode],
168
+ viewToggle: toggle === undefined ? undefined : toggle === "on",
169
+ });
170
+ if (opts.json) return void console.log(JSON.stringify(out, null, 2));
171
+ const said = [];
172
+ if (mode !== undefined) said.push(mode === "default" ? "opens as its HTML says" : `opens as ${mode}`);
173
+ if (toggle !== undefined) said.push(`viewer toggle ${toggle}`);
174
+ if (out.applies === false) {
175
+ console.log(`OK deck ${slug} ${said.join(", ")} (saved)`);
176
+ console.warn(`warning: ${out.warning}`);
177
+ return;
178
+ }
179
+ console.log(`OK deck ${slug} ${said.join(", ")} (every link, on its next load)`);
180
+ }
181
+
156
182
  function shareUrl(pos, opts) {
157
183
  const [token] = pos;
158
184
  if (!token) throw new Error("usage: share url <token>");
@@ -264,6 +290,7 @@ async function importDeck(pos, opts) {
264
290
  console.log(` html: ${(out.bytes / 1024).toFixed(1)} KB`);
265
291
  console.log(` deck: ${out.deckUrl}`);
266
292
  }
293
+ for (const w of out.warnings ?? []) console.warn(`warning: ${w}`);
267
294
  }
268
295
 
269
296
  async function importDesignDeck(pos, opts) {
@@ -304,9 +331,12 @@ commands:
304
331
  login [--token <pat>] [--broker <url>] [--dev]
305
332
  logout [--dev]
306
333
  whoami [--json] [--dev]
307
- deck list|stats|open|rm|forward
334
+ deck list|stats|open|rm|forward|view
308
335
  deck forward on|off <slug>
309
336
  whether viewers can forward the deck's links (on by default; a link's own setting wins)
337
+ deck view <slug> [slides|scroll|default] [--toggle on|off]
338
+ how the deck opens in the player (default: as its HTML says, data-view or slides) and
339
+ whether viewers get the slides/scroll toggle (on by default). Every link, next load; no re-release.
310
340
  deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style stage|bubble|glass|corner] [--no-brand] [--dir <path>]
311
341
  scaffold decks/<slug>/: index.html (8 slides, or one per layout, player/v1), script.md, evidence.md,
312
342
  in your saved brand's colours, fonts and logo (--no-brand: the template's look)
@@ -676,8 +706,8 @@ function commandTable(pos, rest, opts) {
676
706
  },
677
707
  check: async () => {
678
708
  const { check } = await import("./check.mjs");
679
- const { hostedCheck } = await import("./source.mjs");
680
- const result = opts.hosted ? await hostedCheck(opts, { slug: pos[1], forbid: opts.forbid, maxDuration: opts["max-duration"] }) : await check(pos.slice(1), opts);
709
+ const { hostedCheckCli } = await import("./source.mjs");
710
+ const result = opts.hosted ? await hostedCheckCli(opts, { slug: pos[1], forbid: opts.forbid, maxDuration: opts["max-duration"] }) : await check(pos.slice(1), opts);
681
711
  if (opts.hosted && !opts.json) {
682
712
  console.log(`deck "${pos[1]}" rev ${result.rev}: ${result.slides} slides${result.totalDuration != null ? `, ${result.totalDuration}s narrated` : ""}`);
683
713
  for (const e of result.errors) console.log(` - ${e}`);
@@ -689,8 +719,8 @@ function commandTable(pos, rest, opts) {
689
719
  },
690
720
  release: async () => {
691
721
  if (opts.hosted) {
692
- const { hostedRelease } = await import("./source.mjs");
693
- const out = await hostedRelease(opts, { slug: pos[1], recipients: opts.recipients, qa: opts.qa, title: opts.title, forbid: opts.forbid, captionsOnly: opts["captions-only"] });
722
+ const { hostedReleaseCli } = await import("./source.mjs");
723
+ const out = await hostedReleaseCli(opts, { slug: pos[1], recipients: opts.recipients, qa: opts.qa, title: opts.title, forbid: opts.forbid, captionsOnly: opts["captions-only"] }, opts.json ? { log: (line) => console.error(line) } : {});
694
724
  if (opts.json) return void console.log(JSON.stringify(out, null, 2));
695
725
  console.log(`✓ released ${out.slug} rev ${out.rev}\n deck: ${out.deckUrl}`);
696
726
  const who = presenterLine(out.presenter);
@@ -849,6 +879,7 @@ function commandTable(pos, rest, opts) {
849
879
  "deck open": () => deckOpen(rest, opts),
850
880
  "deck rm": () => deckRemove(rest, opts),
851
881
  "deck forward": () => deckForward(rest, opts),
882
+ "deck view": () => deckView(rest, opts),
852
883
  "share new": () => shareNew(rest, opts),
853
884
  "share list": () => shareList(rest, opts),
854
885
  "share on": () => shareToggle(rest, opts, true),
@@ -0,0 +1,45 @@
1
+ // Whether a deck runs on the Hyperdecks player (player/v1). One rule, shared by
2
+ // Convex (decks.usesPlayer), the share route (owner chip vs fallback pill), the
3
+ // importers and the CLI, so they never disagree. Scroll mode, the viewer
4
+ // slides/scroll toggle and the owner chip all live in player/v1; a deck that
5
+ // runs its own runtime (a legacy copy-and-swap deck.js, a plain HTML export)
6
+ // gets none of them.
7
+
8
+ export const loadsPlayer = (html) => /\/player\/v1\/player\.js\b/.test(String(html ?? ""));
9
+
10
+ const SCRIPT_SRC = /<script\b[^>]*\ssrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))[^>]*>/gi;
11
+ const MAX_SCRIPT_NAME = 120;
12
+
13
+ // The deck's own entry script, for the warning ("it runs deck.js"): its first
14
+ // module script, else its first script with a src. Only the file name, so
15
+ // https://cdn.example/js/deck.js reads as deck.js. Null when
16
+ // the deck loads player/v1, has no script with a src, or its script is an
17
+ // uploaded asset (a storage URL names no file).
18
+ export function ownScript(html) {
19
+ const doc = String(html ?? "").replace(/<!--[\s\S]*?-->/g, "");
20
+ if (loadsPlayer(doc)) return null;
21
+ const tags = [...doc.matchAll(SCRIPT_SRC)].map((m) => ({ tag: m[0], src: (m[1] ?? m[2] ?? m[3] ?? "").trim() })).filter((s) => s.src);
22
+ const pick = tags.find((s) => /\stype\s*=\s*["']?module\b/i.test(s.tag)) ?? tags[0];
23
+ // An imported asset's src is a storage URL (…/api/storage/<uuid>): no name to show.
24
+ if (!pick || /\/api\/storage\//.test(pick.src)) return null;
25
+ const name = pick.src.split(/[?#]/)[0].split("/").filter(Boolean).pop() || pick.src;
26
+ return name.slice(0, MAX_SCRIPT_NAME);
27
+ }
28
+
29
+ // What Convex stores on the deck row.
30
+ export function deckPlayer(html) {
31
+ if (loadsPlayer(html)) return { usesPlayer: true, ownScript: undefined };
32
+ return { usesPlayer: false, ownScript: ownScript(html) ?? undefined };
33
+ }
34
+
35
+ const runs = (script) => (script ? ` (it runs its own ${script})` : "");
36
+
37
+ // Import: a warning, never a refusal (plain HTML exports are legitimate decks).
38
+ export const importPlayerWarning = (script) =>
39
+ `deck doesn't load player/v1${runs(script)}: scroll mode, the owner chip and the viewer toggle won't apply`;
40
+
41
+ // set_deck_view / `deck view` on such a deck: the value is saved for a later
42
+ // rebuild on player/v1, but does nothing today.
43
+ export const viewPlayerWarning = (slug, script) =>
44
+ `${slug} doesn't load player/v1${runs(script)}, so slides/scroll and the viewer toggle won't apply. ` +
45
+ "The setting is saved and takes effect once the deck is rebuilt on player/v1 (deck_guide explains the format).";
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
@@ -1,3 +1,4 @@
1
+ import { deckPlayer, importPlayerWarning, viewPlayerWarning } from "./deckPlayer.mjs";
1
2
  import { zipSync } from "fflate";
2
3
  import { COLOR_KEYS, FONT_PAIRINGS, FONT_ROLES, findPairing, googleFontsUrl } from "./brand.mjs";
3
4
  import { MAX_IMPORT_FILE_BYTES, draftArgs, extractFile } from "./brandImport.mjs";
@@ -6,10 +7,10 @@ import {
6
7
  bytesFromText,
7
8
  isTextAsset,
8
9
  prepareDeckImport,
9
- rewriteCssAssetUrls,
10
10
  rewriteHtmlAssetUrls,
11
- rewriteTextAssetUrls,
11
+ rewriteTextAsset,
12
12
  slugify,
13
+ textAssetRounds,
13
14
  textFromBytes,
14
15
  titleFromFilename,
15
16
  } from "./importer.mjs";
@@ -26,6 +27,12 @@ export async function listDecks(opts = {}) {
26
27
  status: d.status,
27
28
  ownerEmail: d.ownerEmail ?? null,
28
29
  hasHtml: !!(d.html || d.htmlStorageId || d.hasHtml),
30
+ // How it opens (null: as its HTML says) and whether viewers may switch.
31
+ view: d.view ?? null,
32
+ viewToggle: d.viewToggle !== false,
33
+ // false: the deck runs its own player, so view/viewToggle do not apply
34
+ // (null: not known yet, treated as on).
35
+ usesPlayer: d.usesPlayer ?? null,
29
36
  }));
30
37
  }
31
38
 
@@ -466,6 +473,32 @@ export async function setDeckForwarding(opts, slug, allowForward) {
466
473
  return { slug, allowForward: !!allowForward };
467
474
  }
468
475
 
476
+ // How a deck opens in the player and whether viewers may switch: view is
477
+ // "slides", "scroll", or null to follow the deck's own HTML again; viewToggle
478
+ // true/false. Either may be left out; both go in one owner-only mutation, so a
479
+ // failure changes neither.
480
+ // Applies on every link's next load (the route injects it), with no re-release.
481
+ export const DECK_VIEWS = ["slides", "scroll"];
482
+ export async function setDeckView(opts, slug, { view, viewToggle } = {}) {
483
+ if (!slug) throw new Error("slug is required");
484
+ if (view === undefined && viewToggle === undefined) throw new Error("give a view (slides, scroll or default) and/or a viewToggle");
485
+ if (view !== undefined && view !== null && !DECK_VIEWS.includes(view)) throw new Error(`view must be slides, scroll or null (got ${JSON.stringify(view)})`);
486
+ const res = await (await client(opts)).mutation(api.decks.setViewSettings, {
487
+ slug,
488
+ ...(view !== undefined ? { view } : {}),
489
+ ...(viewToggle !== undefined ? { viewToggle: !!viewToggle } : {}),
490
+ });
491
+ // Saved either way; a deck on its own player says the setting won't apply.
492
+ const applies = res?.applies !== false;
493
+ return {
494
+ slug,
495
+ ...(view !== undefined ? { view } : {}),
496
+ ...(viewToggle !== undefined ? { viewToggle: !!viewToggle } : {}),
497
+ applies,
498
+ ...(applies ? {} : { warning: viewPlayerWarning(slug, res?.ownScript ?? null) }),
499
+ };
500
+ }
501
+
469
502
  // Register a new deck (draft) you own. Refuses any slug that already exists:
470
503
  // upsert would otherwise reset your own live deck to a draft and rename it.
471
504
  export async function createDeck(opts, { slug, title }) {
@@ -585,23 +618,24 @@ export async function importDeckFromBytes(opts = {}, fields = {}, adapter = {})
585
618
  deckCreated = true;
586
619
  }
587
620
 
588
- // Phase 1: upload every asset and fully populate uploadedByName.
589
- await mapWithConcurrency(parsed.assets, concurrency, async (asset, index) => {
621
+ // Phase 1: upload every binary asset.
622
+ const binary = parsed.assets.filter((a) => !isTextAsset(a));
623
+ await mapWithConcurrency(binary, concurrency, async (asset, index) => {
590
624
  onStage(`uploading asset ${index + 1}/${parsed.assets.length}: ${asset.name}`);
591
625
  await saveDeckAsset(asset);
592
626
  });
593
627
 
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
- });
628
+ // Phase 2 (after phase 1): text assets, rewritten against the URLs saved so
629
+ // far, each after the text assets it names (textAssetRounds). Saving a name
630
+ // twice deletes the first copy, so a script must never embed a URL from a
631
+ // save that a later save replaces.
632
+ let done = binary.length;
633
+ for (const round of textAssetRounds(parsed.assets)) {
634
+ await mapWithConcurrency(round, concurrency, async (asset) => {
635
+ onStage(`uploading asset ${++done}/${parsed.assets.length}: ${asset.name}`);
636
+ await saveDeckAsset(asset, bytesFromText(rewriteTextAsset(asset, parsed.assets, uploadedByName)), asset.contentType);
637
+ });
638
+ }
605
639
 
606
640
  onStage("storing deck HTML");
607
641
  const html = rewriteHtmlAssetUrls(parsed.html, parsed.htmlEntry, parsed.assets, uploadedByName);
@@ -613,12 +647,17 @@ export async function importDeckFromBytes(opts = {}, fields = {}, adapter = {})
613
647
  await c.mutation(api.decks.setHtmlStorage, { slug, storageId: htmlStorageId, bytes: htmlBytes.byteLength });
614
648
 
615
649
  const t = target(opts);
650
+ // A warning, never a refusal: plain HTML exports are legitimate decks.
651
+ // Read before the asset rewrite, while the script still has its own name.
652
+ const player = deckPlayer(parsed.html);
616
653
  return {
617
654
  slug,
618
655
  title,
619
656
  status,
620
657
  assets: parsed.assets.length,
621
658
  bytes: htmlBytes.byteLength,
659
+ usesPlayer: player.usesPlayer,
660
+ warnings: player.usesPlayer ? [] : [importPlayerWarning(player.ownScript)],
622
661
  deployment: t.name,
623
662
  // The owner-facing dashboard view (Preview / Stats / Share / Present), not
624
663
  // the raw /d/<slug> storage redirect.
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
@@ -71,14 +76,16 @@ export function recipients(opts) {
71
76
  }
72
77
 
73
78
  export async function release(pos, opts = {}, io = {}) {
74
- const { log = (line) => console.log(line), changes = deckChanges, untracked = untrackedFiles, commit = deckCommit, narrate = runNarrate, fetch, cwd } = io;
79
+ const { log = (line) => console.log(line), changes = deckChanges, untracked = untrackedFiles, commit = deckCommit, narrate = runNarrate, fetch, cwd, measure, measureFit } = io;
75
80
  const deckDir = resolveDeckDir(pos[0], cwd);
76
81
  const to = recipients(opts);
77
82
 
78
83
  const dirty = changes(deckDir);
79
84
  if (dirty) throw new Error(`refusing to release: ${deckDir} has uncommitted changes\n${dirty}\ncommit them first so the release corresponds to a commit`);
80
85
 
81
- const checked = await checkDeck(deckDir, opts, { fetch, cwd });
86
+ // Always with the layout check: a slide that overflows a phone must not ship
87
+ // (the hosted release runs the same measurement, hostedLayoutCheck in source.mjs).
88
+ const checked = await checkDeck(deckDir, { ...opts, layout: true }, { fetch, cwd, ...(measure ? { measure } : {}), ...(measureFit ? { measureFit } : {}) });
82
89
  const slug = opts.slug || checked.slug;
83
90
  if (!SLUG.test(slug)) throw new Error(`"${slug}" is not a deck slug (lowercase letters, digits, dashes): rename the folder or pass --slug`);
84
91
  if (checked.errors.length) throw new Error(`check failed for ${slug}:\n${checked.errors.map((e) => ` - ${e}`).join("\n")}`);
@@ -86,6 +93,8 @@ export async function release(pos, opts = {}, io = {}) {
86
93
  const loose = untracked(deckDir, bundle.files);
87
94
  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
95
  log(`✓ check passed: ${checked.slides} slides, ${checked.totalDuration}s`);
96
+ for (const w of checked.warnings) log(` ⚠ ${w}`);
97
+ for (const [from, paths] of Object.entries(bundle.added)) log(` +${paths.length} file(s) referenced from ${from}: ${paths.join(", ")}`);
89
98
 
90
99
  // Cached slides cost nothing, so this only renders what changed since the commit.
91
100
  // 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.2";
12
12
  const UPLOAD_TIMEOUT_MS = 125_000;
13
13
 
14
14
  export const PROD = {
@@ -98,6 +98,9 @@ export const api = {
98
98
  listForViewer: makeFunctionReference("decks:listForViewer"),
99
99
  remove: makeFunctionReference("decks:remove"),
100
100
  setAllowForward: makeFunctionReference("decks:setAllowForward"),
101
+ setView: makeFunctionReference("decks:setView"),
102
+ setViewToggle: makeFunctionReference("decks:setViewToggle"),
103
+ setViewSettings: makeFunctionReference("decks:setViewSettings"),
101
104
  setHtmlStorage: makeFunctionReference("decks:setHtmlStorage"),
102
105
  upsert: makeFunctionReference("decks:upsert"),
103
106
  whoami: makeFunctionReference("decks:whoami"),
package/src/source.mjs CHANGED
@@ -6,15 +6,58 @@
6
6
  // hyperdecks check <slug> --hosted
7
7
  // hyperdecks release <slug> --hosted [--to "<name>" [--email e]]... [--qa]
8
8
  // hyperdecks asset add <slug> <file|url> [--name n]
9
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
10
11
  import { basename, extname, join, resolve } from "node:path";
11
12
  import { api, client, uploadBytes } from "./runtime.mjs";
12
- import { SOURCE_FILES, getDeckSource, uploadDeckAsset, writeDeckSource } from "./hosted.mjs";
13
+ import { HOSTED_LAYOUT_WHY, MESSAGES } from "./checkRules.mjs";
14
+ import { SOURCE_FILES, getDeckSource, hostedCheck, hostedRelease, uploadDeckAsset, writeDeckSource } from "./hosted.mjs";
13
15
 
14
16
  export * from "./hosted.mjs";
15
17
 
16
18
  // ---- CLI ------------------------------------------------------------------
17
19
 
20
+ // The browser half of check --layout for a hosted draft. The hosted check runs
21
+ // in Convex and can't open a browser (HOSTED_LAYOUT_WHY), so the CLI measures
22
+ // the draft's index.html here, from a temp folder, with check.mjs's Chromium
23
+ // code. Returns the rev it measured, and { errors, warnings } as check --layout.
24
+ export async function hostedLayoutCheck(opts, { slug } = {}, { measure, measureFit } = {}) {
25
+ if (!slug) throw new Error("slug is required");
26
+ const got = await getDeckSource(opts, { slug, file: "index.html" });
27
+ const html = got.files?.["index.html"];
28
+ if (typeof html !== "string") throw new Error(`hosted deck "${slug}" has no index.html`);
29
+ const { layoutCheck } = await import("./check.mjs");
30
+ const dir = mkdtempSync(join(tmpdir(), "hyperdecks-hosted-layout-"));
31
+ try {
32
+ writeFileSync(join(dir, "index.html"), html);
33
+ return { rev: got.rev, ...(await layoutCheck(dir, html, null, { ...(measure ? { measure } : {}), ...(measureFit ? { measureFit } : {}) })) };
34
+ } finally {
35
+ rmSync(dir, { recursive: true, force: true });
36
+ }
37
+ }
38
+
39
+ // check <slug> --hosted [--layout]: the hosted rules, plus the layout measured
40
+ // here when --layout asks for it (in place of the hosted check's "layout not
41
+ // checked" warning).
42
+ export async function hostedCheckCli(opts, args, io = {}) {
43
+ const result = await hostedCheck(opts, args);
44
+ if (!opts.layout) return result;
45
+ const layout = await hostedLayoutCheck(opts, args, io);
46
+ const errors = [...result.errors, ...layout.errors];
47
+ const unchecked = MESSAGES.layoutUnchecked(HOSTED_LAYOUT_WHY);
48
+ return { ...result, errors, passed: errors.length === 0, warnings: [...new Set([...result.warnings.filter((w) => w !== unchecked), ...layout.warnings])] };
49
+ }
50
+
51
+ // release <slug> --hosted: the same layout check the local release runs, then
52
+ // the release pinned to the rev that was measured, so a later write can't slip
53
+ // an unmeasured draft through. A slide that overflows a phone refuses it.
54
+ export async function hostedReleaseCli(opts, args, { log = (line) => console.log(line), ...io } = {}) {
55
+ const layout = await hostedLayoutCheck(opts, args, io);
56
+ if (layout.errors.length) throw new Error(`check failed for ${args.slug}:\n${layout.errors.map((e) => ` - ${e}`).join("\n")}`);
57
+ for (const w of layout.warnings) log(` ⚠ ${w}`);
58
+ return hostedRelease(opts, { ...args, rev: layout.rev });
59
+ }
60
+
18
61
  export async function sourcePull(pos, opts = {}, { log = (line) => console.log(line), cwd = process.cwd() } = {}) {
19
62
  const [slug, dirArg] = pos;
20
63
  if (!slug) throw new Error("usage: source pull <slug> [dir]");
@@ -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;