@hypersocial/decks-cli 0.1.12 → 0.2.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/README.md CHANGED
@@ -66,8 +66,111 @@ recipient archive|unarchive <name>
66
66
  import <html-or-zip-or-url> --slug <slug> [--title title] [--status draft|live|archived]
67
67
  skills install [--agent codex,claude-code] [--all-agents]
68
68
  activity [--deck <slug>] [--limit <n>] [--json]
69
+
70
+ gen-video "<prompt>" [--image <url>] [--seconds 5] [--aspect 16:9|9:16] [--slug <deck>] [--name <name>] [--download <file>]
71
+ video-job <jobId> [--json]
72
+
73
+ narrate <deck-dir> [--slide <id>] [--voice <v>] [--slug <slug>] [--dry-run] [--json]
74
+ voice list|clone|upload|rm
75
+
76
+ deck new <slug> "<title>" --narrated [--dir <path>]
77
+ check <slug|deck-dir> [--forbid "<phrase>,<phrase>"] [--max-duration <s>] [--json]
78
+ release <slug|deck-dir> [--to "<name>" [--email <e>]]... [--qa] [--no-notify] [--title t] [--voice v]
69
79
  ```
70
80
 
81
+ ## Narrated decks
82
+
83
+ `deck new --narrated` scaffolds `decks/<slug>/` (`index.html` with eight slides for
84
+ the hosted player, `script.md`, `evidence.md`) and registers the draft. `narrate`
85
+ renders it and sets the player's `data-manifest`. `check` is the gate: slide ids
86
+ equal the manifest's clip ids in order; durations plus holds equal the declared
87
+ total and stay under the cap (`maxTotalSeconds` in `script.md`, else 180 s); every
88
+ clip's audio and timeline resolve with the right type; captions are in time,
89
+ cover the clip text and run 4 to 85 characters; local `src`/`href` resolve inside
90
+ the folder; the player is the pinned `player/v1`; `--forbid` phrases appear
91
+ nowhere; every slide has a `data-note` and long titles a `data-title-mobile`.
92
+ `release` refuses uncommitted changes in the deck folder, runs check and narrate
93
+ (stopping for a commit if narration changed the folder), imports `index.html` with
94
+ the local files it references, and creates one share per `--to`. The agent
95
+ procedure is the "Author a narrated deck" section of the bundled skill.
96
+
97
+ ## Video clips
98
+
99
+ `gen-video` generates a short clip on the Hyperdecks backend (fal, Kling v3 Standard)
100
+ and prints its hosted MP4 URL: text-to-video from a prompt, or `--image <url>` to
101
+ animate a still as the first frame (the clip keeps the still's aspect ratio). Clips are
102
+ 3–10 seconds (`--seconds`, default 5), 16:9 or 9:16 (`--aspect`), with no audio, so use
103
+ them as `<video src="…" muted loop playsinline autoplay>`. `--slug` files the clip into
104
+ that deck's assets as `video/mp4`. A clip takes one to a few minutes; the command waits,
105
+ printing progress to stderr, and `video-job <jobId>` checks a job by id. Clips cost 3
106
+ credits per second, refunded if fal fails to produce the clip.
107
+
108
+ ## Narration
109
+
110
+ `narrate` turns a deck's `script.md` into voiced clips in your own voice, word-timed
111
+ captions, 60 fps mouth frames and a deck manifest. Rendering runs on the Hyperdecks
112
+ backend with the platform's ElevenLabs key; the CLI only sends the script and polls.
113
+ Each line is cached by a hash of its spoken text and voice, so after an edit only the
114
+ slides whose words changed re-render.
115
+
116
+ ```text
117
+ narrate <deck-dir> [--slide <id>[,<id>]] [--voice <handle|name|id>] [--slug <slug>] [--dry-run] [--json]
118
+ voice list [--json]
119
+ ```
120
+
121
+ `script.md` is the deck script markup:
122
+
123
+ ```markdown
124
+ ---
125
+ voice: selcuk
126
+ maxTotalSeconds: 200
127
+ normalize: hyper desk=hyperdecks
128
+ ---
129
+
130
+ ## hello
131
+ hold: 1.1
132
+ presenter: wave
133
+
134
+ Hi, I'm Selçuk's mini avatar. [wave] This is Hyperdecks. [mood: excited]
135
+ ```
136
+
137
+ - `## <slide-id>` is the slide's `<section id>` in `index.html`. Text before the first
138
+ heading is notes for people and is ignored.
139
+ - `hold`, `presenter` and `voice` lines directly under a heading are slide options.
140
+ - Sentences are the caption units, 4–85 characters each.
141
+ - Stage tags (`[wave]`, `[mood: x]`, `[emote: x]`, `[reveal: x]`, `[play: x]`,
142
+ `[pause: s]`, `[custom: name …]`) are not spoken. Each becomes a cue at the start
143
+ time of the word before it.
144
+ - Voice tags (`[whispers]`, `[laughs]`, `[sighs]`, `[excited]`, …) are sent to the
145
+ voice and left out of the captions.
146
+ - Unknown tags, a tag with no word before it, and headings with no matching section
147
+ fail with line numbers.
148
+
149
+ `--dry-run` lists each slide, its hash and whether it would render. It renders nothing.
150
+ A full run writes `<deck-dir>/narration/manifest.json` and prints the manifest URL and
151
+ each clip's URL. Clips, timelines and the manifest are deck assets with
152
+ content-addressed names (`narration-<id>-<hash>.mp3|json`), so re-narrating never
153
+ breaks a URL a live deck already serves. If the transcript does not match the script,
154
+ the slide fails and the message shows both strings. Add a `normalize` entry when the
155
+ speech-to-text spells a name differently, or reword the line.
156
+
157
+ ## Voices
158
+
159
+ Narration speaks in a voice you own. Clone yours from a short recording:
160
+
161
+ ```text
162
+ voice clone <file...> --name <name> 1-3 mp3/m4a/wav recordings, 30 s to 3 min in total, 10 MB each
163
+ voice list [--json] your voices: ready, cloning or failed
164
+ voice upload <file...> upload recordings, print sample ids (for the MCP clone_voice tool)
165
+ voice rm <name|id> delete a voice, also at the provider
166
+ ```
167
+
168
+ `voice clone` prints a consent statement and uploads nothing until you type
169
+ `I consent`. Cloning runs on the backend with the platform's ElevenLabs key and costs
170
+ credits, refunded if it fails (a recording that is too short, too long or not audio
171
+ fails before the provider is called). Then `narrate <deck-dir> --voice <name>`.
172
+ Deleting a voice removes it at the provider; decks already narrated with it keep playing.
173
+
71
174
  ## Development Target
72
175
 
73
176
  Production is the default because this CLI is for real recipient activity and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypersocial/decks-cli",
3
- "version": "0.1.12",
3
+ "version": "0.2.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",
@@ -24,6 +24,7 @@
24
24
  "bin/",
25
25
  "src/",
26
26
  "skills/",
27
+ "templates/",
27
28
  "README.md"
28
29
  ],
29
30
  "engines": {
@@ -37,5 +38,10 @@
37
38
  },
38
39
  "publishConfig": {
39
40
  "access": "public"
41
+ },
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/hypersocialinc/hyperdecks.git",
45
+ "directory": "packages/decks-cli"
40
46
  }
41
47
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: hyperdecks
3
- description: Use when working with Hypersocial Decks, Hyperdecks CLI, production deck analytics, recipient share links, deck activity, MCP deck tools, or real-world/customer-facing deck checks. Prefer production for real activity and analytics; use development only for local implementation/testing when explicitly requested.
3
+ description: Use when asked to make, narrate, check or ship a deck ("make me a narrated deck", "turn this into a deck and send it"), or when working with Hypersocial Decks, Hyperdecks CLI, production deck analytics, recipient share links, deck activity, MCP deck tools, or real-world/customer-facing deck checks. Prefer production for real activity and analytics; use development only for local implementation/testing when explicitly requested.
4
4
  ---
5
5
 
6
6
  # Hyperdecks
@@ -45,6 +45,158 @@ If authentication is missing, run or ask the user to run:
45
45
  hyperdecks login
46
46
  ```
47
47
 
48
+ ## Author a narrated deck
49
+
50
+ The path from a brief to a share link, run by you, the agent. Every step is a
51
+ CLI command (`hyperdecks …`, also installed as `decks`) and an MCP tool on the
52
+ Hyperdecks server; use the CLI where you have a shell and the tool otherwise.
53
+
54
+ | Step | CLI | MCP tool |
55
+ |---|---|---|
56
+ | Scaffold | `hyperdecks deck new <slug> "<title>" --narrated` | `new_deck` |
57
+ | Images | `hyperdecks gen-image "<prompt>" --slug <slug>` | `generate_image` |
58
+ | Video clips | `hyperdecks gen-video "<prompt>" --slug <slug>` | `generate_video` |
59
+ | Your voice (once) | `hyperdecks voice clone <file...> --name <name>` | `clone_voice` |
60
+ | Narrate | `hyperdecks narrate decks/<slug>` | `narrate_deck` |
61
+ | Check | `hyperdecks check decks/<slug>` | `check_deck` |
62
+ | Ship | `hyperdecks release decks/<slug> --to "<name>" --email <e>` | `release_deck` |
63
+ | Import only | `hyperdecks import <file> --slug <slug>` | `import_deck_from_url` |
64
+ | One more link | `hyperdecks share new <slug> "<name>"` | `create_share` |
65
+ | Who watched | `hyperdecks deck stats <slug>` | `deck_stats` |
66
+ | All activity | `hyperdecks activity --deck <slug>` | `activity_overview` |
67
+
68
+ ### 1. Ground it in the user's own material
69
+
70
+ Build the deck from what you can already read in this session: files in the
71
+ working directory, connected tools (email, Notion, Drive, Slack) and the earlier
72
+ conversation. Do not ask the user to paste anything you could open yourself. Ask
73
+ only for what no source holds (the audience, the ask, the deadline).
74
+
75
+ Write `decks/<slug>/evidence.md` as you go: one row per fact with the slide, the
76
+ claim, the source (file path, thread, doc title) and its date. Never put a
77
+ number on a slide that has no row.
78
+
79
+ ### 2. Scaffold
80
+
81
+ ```bash
82
+ hyperdecks deck new <slug> "<title>" --narrated # writes decks/<slug>/, registers a draft you own
83
+ ```
84
+
85
+ Files, all in `decks/<slug>/`:
86
+
87
+ - `index.html`: eight `<section class="slide" id="…">` slides (hello, problem,
88
+ why-now, idea, how, proof, ask, bye) and the hosted player script. Keep, cut or
89
+ add slides; keep the contract below.
90
+ - `script.md`: the narration, one `## <slide-id>` block per slide.
91
+ - `evidence.md`: the sources table. Private: release never uploads it.
92
+
93
+ The deck contract (`check` enforces each line):
94
+
95
+ - One `<section class="slide" id="<id>">` per slide; ids are lowercase,
96
+ unique, and in the same order as the `##` blocks in `script.md`.
97
+ - Every slide has `data-note="<source of its facts>"`, e.g.
98
+ `data-note="Q3 numbers: finance-update.pdf, 2026-10-02"`. A slide with no
99
+ facts says so: `data-note="Title slide: no facts."`.
100
+ - A title (first `<h1>`/`<h2>`) over 28 characters carries
101
+ `data-title-mobile="<shorter>"`. The scaffold puts one on every title.
102
+ - Each slide keeps its `<div class="narration-slot"></div>` (where the
103
+ presenter's face and caption bubble sit); the first slide has `data-intro`.
104
+ - The player script stays
105
+ `<script type="module" src="https://decks.hypersocial.ai/player/v1/player.js" data-manifest="…">`.
106
+ `narrate` fills `data-manifest`; do not edit it by hand.
107
+ - Local images and files sit inside the deck folder and are referenced
108
+ relatively (`img/chart.png`). Hosted images (`gen-image` URLs) are fine too.
109
+
110
+ ### 3. Write slides and script
111
+
112
+ - One idea per slide; the title states it.
113
+ - Anything not agreed yet is labelled **Proposed** on the slide and in the
114
+ narration ("This part is a proposal.").
115
+ - Narration: short phrases, each sentence one caption of 4 to 85 characters
116
+ (two lines in the bubble). Aim for 2.5 to 3 minutes in all; `maxTotalSeconds`
117
+ in the front matter is the cap check uses (180 in the scaffold).
118
+ - Holds: 1.1 s by default; `hold: 2` under the heading on interactive slides.
119
+ - Cues: `[reveal: #id]` reveals `.reveal#id` at the word before it; also
120
+ `[wave]`, `[mood: excited]`, `[pause: 0.5]`, `[custom: name]`.
121
+ - No `voice:` in the front matter means the user's first ready voice. If
122
+ `hyperdecks voice list` shows none, ask the user to record one and run
123
+ `voice clone` (it asks them for consent; never clone anyone else's voice).
124
+
125
+ ### 4. Narrate and read the guard
126
+
127
+ ```bash
128
+ hyperdecks narrate decks/<slug> --dry-run # what would render, and the characters of TTS
129
+ hyperdecks narrate decks/<slug> # renders changed slides, saves narration/manifest.json, sets data-manifest
130
+ ```
131
+
132
+ A slide fails when the transcript does not match the script: read the error,
133
+ reword the sentence (spell numbers and names the way they are said, or add a
134
+ `normalize:` entry), and narrate again; only that slide re-renders.
135
+
136
+ ### 5. Check
137
+
138
+ ```bash
139
+ hyperdecks check decks/<slug> [--forbid "Other Co,codename"]
140
+ ```
141
+
142
+ `--forbid` lists phrases that must not reach this audience (another customer,
143
+ an internal codename). Messages and what to do:
144
+
145
+ | Message starts with | Fix |
146
+ |---|---|
147
+ | `no slides` / `slide N has no id` | every slide is `<section class="slide" id="…">` |
148
+ | `manifest … could not be read` | narrate again; the manifest URL is wrong or broken |
149
+ | `clip <id>: duration …`, `needs its text`, `needs an audio URL` … / `timeline has no captions` | narrate again (a hand-edited or partial manifest) |
150
+ | `slide ids (…) must equal the manifest clip ids` | reorder or rename slides or `##` blocks to match, narrate again |
151
+ | `clip durations plus holds add up to` | narrate again (the manifest is stale or edited) |
152
+ | `narration runs …s, over the …s cap` | shorten the script |
153
+ | `clip <id>: audio …` / `clip <id>: timeline …` | narrate again; the asset is missing or wrong |
154
+ | `clip <id>: caption N runs` | narrate that slide again (`--slide <id>`) |
155
+ | `clip <id>: caption words do not match` | narrate that slide again |
156
+ | `clip <id>: caption N is … characters` | split or join sentences in that block |
157
+ | `local reference does not resolve` / `leaves the deck folder` | move the file into the deck folder or fix the path |
158
+ | `player script must be` / `no player script` | restore the player/v1 script tag |
159
+ | `the player script has no data-manifest` | run `narrate` |
160
+ | `forbidden phrase` | remove it from the page, script or narration |
161
+ | `slide <id>: needs a data-note` | add the source |
162
+ | `slide <id>: title … needs a data-title-mobile` | add a shorter title |
163
+
164
+ ### 6. Look at it, at both widths
165
+
166
+ Serve the folder (`npx serve decks/<slug>` or `python3 -m http.server -d decks/<slug>`)
167
+ and screenshot it with Playwright at 1440×900 and 390×844, every slide
168
+ (`#scene=<id>`). Look at both: nothing clipped, the bubble clear of the text, the
169
+ mobile titles in use. Fix and repeat until both read well.
170
+
171
+ ### 7. Release
172
+
173
+ Commit the deck folder first: release refuses uncommitted changes so every
174
+ release matches a commit.
175
+
176
+ ```bash
177
+ git add decks/<slug> && git commit -m "deck: <slug>" # include narration/ and any images
178
+ hyperdecks release decks/<slug> --to "Me" --qa # your own viewing, untracked
179
+ hyperdecks release decks/<slug> --to "Ana Diaz" --email ana@x.com --to "Sam"
180
+ ```
181
+
182
+ Release runs check, then narrate (free when nothing changed). If narration
183
+ changed the folder it stops: check, commit, release again. Files the page
184
+ references must be tracked by git (an ignored `*.mp4` is refused). Then it imports
185
+ `index.html` and the files it references (never `script.md`, `evidence.md` or
186
+ `narration/`), marks the deck live and prints one `/v/<token>` link per `--to`
187
+ (each `--email` pairs with the `--to` before it). Confirm recipients with the
188
+ user before sending real links; send the links yourself only if asked.
189
+
190
+ ### 8. Report who watched
191
+
192
+ ```bash
193
+ hyperdecks deck stats <slug>
194
+ hyperdecks activity --deck <slug> --limit 10
195
+ ```
196
+
197
+ Tell the user who opened it, how far they got and whether the narration
198
+ finished. QA links never count.
199
+
48
200
  ## Safety
49
201
 
50
202
  - For read-only inspection, run commands directly and summarize the important
package/src/check.mjs ADDED
@@ -0,0 +1,316 @@
1
+ // hyperdecks check <slug|deck-dir> [--forbid "<phrase>,<phrase>"] [--max-duration <s>] [--json]
2
+ //
3
+ // The narrated-deck integrity gate, ported from the Netflix presentation's
4
+ // scripts/check.mjs and its variant builders. Every rule returns its message from
5
+ // MESSAGES so the skill can document them and tests can match them exactly.
6
+ // The rules are pure (htmlErrors, manifestErrors, timelineErrors); checkDeck does
7
+ // the I/O: it reads the deck folder, fetches the manifest the player names and
8
+ // every clip's audio and timeline. `fetch` and `exists` are injectable for tests.
9
+ import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
10
+ import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
11
+
12
+ export const PLAYER_V1 = "https://decks.hypersocial.ai/player/v1/player.js";
13
+ export const DEFAULT_MAX_DURATION = 180;
14
+ export const MOBILE_TITLE_CHARS = 28;
15
+ export const CAPTION_MIN_CHARS = 4;
16
+ export const CAPTION_MAX_CHARS = 85;
17
+
18
+ export const MESSAGES = {
19
+ noPlayer: () => `no player script: add <script type="module" src="${PLAYER_V1}" data-manifest="…">`,
20
+ player: (src) => `player script must be ${PLAYER_V1} (got ${src})`,
21
+ noManifest: () => "the player script has no data-manifest: run `hyperdecks narrate <deck-dir>`, which sets it",
22
+ manifestUnavailable: (url, why) => `manifest ${url} could not be read (${why})`,
23
+ noSlides: () => 'no slides: each slide is a <section class="slide" id="…">',
24
+ clipField: (id, field) => `clip ${id}: ${field}`,
25
+ noCaptions: (id) => `clip ${id}: timeline has no captions`,
26
+ order: (slides, clips) => `slide ids (${slides.join(", ")}) must equal the manifest clip ids (${clips.join(", ")}), in order`,
27
+ total: (sum, declared) => `clip durations plus holds add up to ${sum}s but the manifest declares totalDuration ${declared}s`,
28
+ cap: (total, cap) => `narration runs ${total}s, over the ${cap}s cap (aim for 150 to 180 s)`,
29
+ url: (id, key, url, why) => `clip ${id}: ${key} ${url} ${why}`,
30
+ timing: (id, i, start, end, duration) => `clip ${id}: caption ${i + 1} runs ${start}s to ${end}s, outside 0 ≤ start < end ≤ ${duration}s + 0.1`,
31
+ words: (id) => `clip ${id}: caption words do not match the clip text (the captions must cover the whole narration)`,
32
+ captionLength: (id, i, text) => `clip ${id}: caption ${i + 1} is ${text.length} characters ("${text}"); captions must be ${CAPTION_MIN_CHARS} to ${CAPTION_MAX_CHARS}`,
33
+ localRef: (ref) => `local reference does not resolve: ${ref}`,
34
+ outsideRef: (ref) => `local reference leaves the deck folder: ${ref}`,
35
+ forbidden: (phrase, where) => `forbidden phrase "${phrase}" appears in ${where}`,
36
+ note: (id) => `slide ${id}: needs a data-note naming the source of its facts`,
37
+ mobileTitle: (id, title) => `slide ${id}: title "${title}" is over ${MOBILE_TITLE_CHARS} characters and needs a data-title-mobile`,
38
+ slideId: (n) => `slide ${n} has no id`,
39
+ };
40
+
41
+ const uncommented = (html) => html.replace(/<!--[\s\S]*?-->/g, "");
42
+ const attr = (tag, name) => {
43
+ const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`).exec(tag);
44
+ return m ? (m[1] ?? m[2]) : null;
45
+ };
46
+ const round = (n) => Math.round(n * 100) / 100;
47
+ const decode = (s) => s.replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;|&apos;/g, "'");
48
+ const textOf = (html) => decode(html.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
49
+
50
+ // Same normalization as the source check: accents folded, case and punctuation dropped.
51
+ export const normalizeWords = (text) => text.normalize("NFKD").replace(/\p{Diacritic}/gu, "").toLowerCase().replace(/[^a-z0-9]/g, "");
52
+
53
+ // `<section class="slide …">` blocks in document order: id, the opening tag, the
54
+ // title (first <h1>/<h2>) and its opening tag.
55
+ export function slides(html) {
56
+ return [...uncommented(html).matchAll(/(<section\b[^>]*\sclass="(?:[^"]*\s)?slide(?:\s[^"]*)?"[^>]*>)([\s\S]*?)<\/section>/g)].map(([, tag, body]) => {
57
+ const heading = /(<h[12]\b[^>]*>)([\s\S]*?)<\/h[12]>/.exec(body);
58
+ return { id: attr(tag, "id") ?? "", tag, body, title: heading ? textOf(heading[2]) : "", titleTag: heading?.[1] ?? "" };
59
+ });
60
+ }
61
+
62
+ export function playerScript(html) {
63
+ const tag = [...uncommented(html).matchAll(/<script\b[^>]*>/g)].map((m) => m[0]).find((t) => /\/player\/v\d+\/player\.js"/.test(t));
64
+ return tag ? { src: attr(tag, "src"), manifest: attr(tag, "data-manifest") || null } : null;
65
+ }
66
+
67
+ // Relative references: src, href and poster (either quote), srcset candidates and
68
+ // CSS url(…) in style attributes and <style> blocks; everything that is not a URL
69
+ // scheme, protocol-relative or a fragment. Query strings and fragments dropped.
70
+ export function localRefs(html) {
71
+ const doc = uncommented(html);
72
+ const raw = [
73
+ ...[...doc.matchAll(/\s(?:src|href|poster)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].map((m) => m[1] ?? m[2]),
74
+ ...[...doc.matchAll(/\ssrcset\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].flatMap((m) => (m[1] ?? m[2]).split(",").map((c) => c.trim().split(/\s+/)[0])),
75
+ ...[...doc.matchAll(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)'"\s]+))\s*\)/gi)].map((m) => m[1] ?? m[2] ?? m[3]),
76
+ ];
77
+ const refs = raw.map((r) => decode(String(r ?? "")).trim().split("#")[0].split("?")[0]).filter(Boolean);
78
+ // Folder links (href="/", "./") are navigation, not files.
79
+ return [...new Set(refs.filter((r) => !/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(r) && !r.endsWith("/")))];
80
+ }
81
+
82
+ function phraseList(value) {
83
+ if (!value) return [];
84
+ if (typeof value === "string") return value.split(",").map((s) => s.trim()).filter(Boolean);
85
+ return value.map((s) => String(s).trim()).filter(Boolean);
86
+ }
87
+
88
+ // Everything checkable from the deck's own files. `exists(ref)` answers whether a
89
+ // local reference resolves inside the deck folder: true, false or "outside".
90
+ export function htmlErrors(html, { exists = () => true, forbid = [], script = "" } = {}) {
91
+ const errs = [];
92
+ const player = playerScript(html);
93
+ if (!player) errs.push(MESSAGES.noPlayer());
94
+ else if (player.src !== PLAYER_V1) errs.push(MESSAGES.player(player.src));
95
+ const all = slides(html);
96
+ if (!all.length) errs.push(MESSAGES.noSlides());
97
+ all.forEach((s, i) => {
98
+ const id = s.id || `${i + 1}`;
99
+ if (!s.id) errs.push(MESSAGES.slideId(i + 1));
100
+ if (!attr(s.tag, "data-note")?.trim()) errs.push(MESSAGES.note(id));
101
+ if (s.title.length > MOBILE_TITLE_CHARS && !attr(s.titleTag, "data-title-mobile") && !attr(s.tag, "data-title-mobile")) {
102
+ errs.push(MESSAGES.mobileTitle(id, s.title));
103
+ }
104
+ });
105
+ for (const ref of localRefs(html)) {
106
+ const found = exists(ref);
107
+ if (found === "outside") errs.push(MESSAGES.outsideRef(ref));
108
+ else if (!found) errs.push(MESSAGES.localRef(ref));
109
+ }
110
+ const page = textOf(uncommented(html).replace(/<(script|style)\b[\s\S]*?<\/\1>/g, " ")).toLowerCase();
111
+ for (const phrase of phraseList(forbid)) {
112
+ const p = phrase.toLowerCase();
113
+ if (page.includes(p) || uncommented(html).toLowerCase().includes(p)) errs.push(MESSAGES.forbidden(phrase, "index.html"));
114
+ if (script.toLowerCase().includes(p)) errs.push(MESSAGES.forbidden(phrase, "script.md"));
115
+ }
116
+ return errs;
117
+ }
118
+
119
+ // The manifest against the deck: order, total and cap. Clip text is checked for
120
+ // forbidden phrases too, since that is what the audience hears.
121
+ const isNum = (v) => typeof v === "number" && Number.isFinite(v);
122
+ const isStr = (v) => typeof v === "string" && v.trim().length > 0;
123
+
124
+ // Fields every clip needs before its other rules mean anything.
125
+ export function clipFieldErrors(clip, i) {
126
+ const id = isStr(clip?.id) ? clip.id : `#${i + 1}`;
127
+ const errs = [];
128
+ if (!isStr(clip?.id)) errs.push(MESSAGES.clipField(id, "needs an id"));
129
+ if (!(isNum(clip?.duration) && clip.duration > 0)) errs.push(MESSAGES.clipField(id, "duration must be a positive number of seconds"));
130
+ if (!(isNum(clip?.hold) && clip.hold >= 0)) errs.push(MESSAGES.clipField(id, "hold must be a number of seconds, 0 or more"));
131
+ if (!isStr(clip?.text)) errs.push(MESSAGES.clipField(id, "needs its text"));
132
+ for (const k of ["audio", "timeline"]) if (!isStr(clip?.[k])) errs.push(MESSAGES.clipField(id, `needs an ${k} URL`));
133
+ return errs;
134
+ }
135
+
136
+ export function manifestErrors(manifest, slideIds, { maxDuration = DEFAULT_MAX_DURATION, forbid = [] } = {}) {
137
+ const clips = Array.isArray(manifest?.clips) ? manifest.clips : [];
138
+ const clipIds = clips.map((c) => c?.id ?? "");
139
+ const errs = clips.flatMap(clipFieldErrors);
140
+ if (slideIds.length !== clipIds.length || slideIds.some((id, i) => id !== clipIds[i])) errs.push(MESSAGES.order(slideIds, clipIds));
141
+ const sum = round(clips.reduce((n, c) => n + (Number(c?.duration) || 0) + (Number(c?.hold) || 0), 0));
142
+ const declared = Number(manifest?.totalDuration);
143
+ if (!(Math.abs(sum - declared) < 0.01)) errs.push(MESSAGES.total(sum, manifest?.totalDuration));
144
+ if (Number.isFinite(declared) && declared > maxDuration) errs.push(MESSAGES.cap(round(declared), maxDuration));
145
+ for (const phrase of phraseList(forbid)) {
146
+ for (const c of clips) if (String(c?.text ?? "").toLowerCase().includes(phrase.toLowerCase())) errs.push(MESSAGES.forbidden(phrase, `clip ${c.id}`));
147
+ }
148
+ return errs;
149
+ }
150
+
151
+ // One clip's timeline: caption timing, coverage of the clip text, and length.
152
+ export function timelineErrors(clip, timeline) {
153
+ const errs = [];
154
+ if (!Array.isArray(timeline?.captions) || !timeline.captions.length) return [MESSAGES.noCaptions(clip.id)];
155
+ const captions = timeline.captions.map((c) => (c && typeof c === "object" ? c : {}));
156
+ const duration = Number(clip.duration) || 0;
157
+ captions.forEach((c, i) => {
158
+ if (!(c.start >= 0 && c.end > c.start && c.end <= duration + 0.1)) errs.push(MESSAGES.timing(clip.id, i, c.start, c.end, duration));
159
+ });
160
+ const spoken = captions.flatMap((c) => (Array.isArray(c.words) ? c.words.map((w) => w?.text ?? "") : [])).join(" ");
161
+ if (normalizeWords(spoken) !== normalizeWords(String(clip.text ?? ""))) errs.push(MESSAGES.words(clip.id));
162
+ captions.forEach((c, i) => {
163
+ const text = String(c.text ?? "");
164
+ if (text.length < CAPTION_MIN_CHARS || text.length > CAPTION_MAX_CHARS) errs.push(MESSAGES.captionLength(clip.id, i, text));
165
+ });
166
+ return errs;
167
+ }
168
+
169
+ // The deck's own cap: `maxTotalSeconds` in script.md's front matter.
170
+ export function scriptCap(script) {
171
+ const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(script)?.[1] ?? "";
172
+ const m = /^maxTotalSeconds:\s*([\d.]+)\s*$/m.exec(front);
173
+ return m ? Number(m[1]) : null;
174
+ }
175
+
176
+ // "<slug>" means ./decks/<slug> unless it names a folder itself.
177
+ export function resolveDeckDir(arg, cwd = process.cwd()) {
178
+ if (!arg) throw new Error("usage: check <slug|deck-dir>");
179
+ const direct = resolve(cwd, arg);
180
+ if (existsSync(join(direct, "index.html"))) return direct;
181
+ const named = join(cwd, "decks", arg);
182
+ if (existsSync(join(named, "index.html"))) return named;
183
+ throw new Error(`no deck at ${direct} or ${named} (expected an index.html)`);
184
+ }
185
+
186
+ // A local ref resolves when it names a file inside the deck folder (after
187
+ // following symlinks). Anything that climbs out is refused, so release never
188
+ // uploads a file from elsewhere on the machine.
189
+ export function deckFileResolver(deckDir) {
190
+ const root = realpathSync(deckDir);
191
+ return (ref) => {
192
+ let clean;
193
+ try {
194
+ clean = decodeURIComponent(ref);
195
+ } catch {
196
+ return false;
197
+ }
198
+ const path = isAbsolute(clean) ? join(root, clean) : resolve(root, clean);
199
+ const inside = (p) => p === root || p.startsWith(root + sep);
200
+ if (!inside(path)) return "outside";
201
+ if (!existsSync(path)) return false;
202
+ const real = realpathSync(path);
203
+ if (!inside(real)) return "outside";
204
+ if (relative(root, real).split(sep).some((part) => part.startsWith("."))) return "outside";
205
+ return lstatSync(real).isFile();
206
+ };
207
+ }
208
+
209
+ async function fetchCheck(fetchImpl, url, want) {
210
+ try {
211
+ const r = await fetchImpl(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
212
+ if (!r.ok) {
213
+ await r.body?.cancel?.();
214
+ return { why: `returned HTTP ${r.status}` };
215
+ }
216
+ const type = (r.headers.get("content-type") || "").split(";")[0].trim();
217
+ if (!want.test(type)) {
218
+ await r.body?.cancel?.();
219
+ return { why: `is ${type || "untyped"}, expected ${want === AUDIO ? "audio/*" : "JSON"}` };
220
+ }
221
+ return { response: r };
222
+ } catch (e) {
223
+ return { why: `could not be fetched (${e.message})` };
224
+ }
225
+ }
226
+ const AUDIO = /^audio\//;
227
+ const FETCH_TIMEOUT_MS = 30_000;
228
+ const JSON_TYPE = /json/;
229
+
230
+ // Run every rule on a deck folder. Returns { deckDir, slug, slides, totalDuration, errors }.
231
+ export async function checkDeck(dirArg, opts = {}, { fetch: fetchImpl = globalThis.fetch, cwd } = {}) {
232
+ const deckDir = resolveDeckDir(dirArg, cwd);
233
+ const html = readFileSync(join(deckDir, "index.html"), "utf8");
234
+ const scriptPath = join(deckDir, "script.md");
235
+ const script = existsSync(scriptPath) ? readFileSync(scriptPath, "utf8") : "";
236
+ const forbid = phraseList(opts.forbid);
237
+ const flagCap = opts["max-duration"] ?? opts.maxDuration;
238
+ const maxDuration = Number(flagCap ?? scriptCap(script) ?? DEFAULT_MAX_DURATION);
239
+ if (!Number.isFinite(maxDuration) || maxDuration <= 0) {
240
+ throw new Error(flagCap !== undefined ? "--max-duration needs a number of seconds" : "maxTotalSeconds in script.md must be a number of seconds");
241
+ }
242
+
243
+ const errors = htmlErrors(html, { exists: deckFileResolver(deckDir), forbid, script });
244
+ const ids = slides(html).map((s) => s.id);
245
+ const player = playerScript(html);
246
+ let manifest = null;
247
+ if (player && !player.manifest) errors.push(MESSAGES.noManifest());
248
+ if (player?.manifest) {
249
+ const got = await fetchCheck(fetchImpl, player.manifest, JSON_TYPE);
250
+ if (got.why) errors.push(MESSAGES.manifestUnavailable(player.manifest, got.why));
251
+ else {
252
+ try {
253
+ manifest = await got.response.json();
254
+ } catch (e) {
255
+ errors.push(MESSAGES.manifestUnavailable(player.manifest, `not JSON: ${e.message}`));
256
+ }
257
+ if (manifest !== null && !(manifest && typeof manifest === "object" && Array.isArray(manifest.clips) && manifest.clips.length)) {
258
+ errors.push(MESSAGES.manifestUnavailable(player.manifest, "not a manifest: needs a non-empty clips array"));
259
+ manifest = null;
260
+ } else if (manifest === null && !errors.some((e) => e.startsWith(`manifest ${player.manifest}`))) {
261
+ errors.push(MESSAGES.manifestUnavailable(player.manifest, "not a manifest: needs a non-empty clips array"));
262
+ }
263
+ }
264
+ }
265
+ if (manifest) {
266
+ errors.push(...manifestErrors(manifest, ids, { maxDuration, forbid }));
267
+ for (const [i, clip] of manifest.clips.entries()) {
268
+ if (clipFieldErrors(clip, i).length) continue; // already reported
269
+ let audioUrl, timelineUrl;
270
+ try {
271
+ audioUrl = new URL(clip.audio, player.manifest).href;
272
+ timelineUrl = new URL(clip.timeline, player.manifest).href;
273
+ } catch {
274
+ errors.push(MESSAGES.clipField(clip.id, "audio and timeline must be URLs"));
275
+ continue;
276
+ }
277
+ const audio = await fetchCheck(fetchImpl, audioUrl, AUDIO);
278
+ if (audio.why) errors.push(MESSAGES.url(clip.id, "audio", audioUrl, audio.why));
279
+ else await audio.response.body?.cancel?.().catch(() => {});
280
+ const tl = await fetchCheck(fetchImpl, timelineUrl, JSON_TYPE);
281
+ if (tl.why) {
282
+ errors.push(MESSAGES.url(clip.id, "timeline", timelineUrl, tl.why));
283
+ continue;
284
+ }
285
+ let timeline;
286
+ try {
287
+ timeline = await tl.response.json();
288
+ } catch (e) {
289
+ errors.push(MESSAGES.url(clip.id, "timeline", timelineUrl, `is not JSON (${e.message})`));
290
+ continue;
291
+ }
292
+ errors.push(...timelineErrors(clip, timeline));
293
+ }
294
+ }
295
+ return {
296
+ deckDir,
297
+ slug: opts.slug || basename(deckDir),
298
+ slides: ids.length,
299
+ totalDuration: manifest ? Number(manifest.totalDuration) : null,
300
+ manifestUrl: player?.manifest ?? null,
301
+ passed: errors.length === 0,
302
+ errors,
303
+ };
304
+ }
305
+
306
+ export async function check(pos, opts = {}, { log = (line) => console.log(line), ...io } = {}) {
307
+ const result = await checkDeck(pos[0], opts, io);
308
+ if (!opts.json) {
309
+ log(`deck "${result.slug}": ${result.slides} slides${result.totalDuration != null ? `, ${result.totalDuration}s narrated` : ""}`);
310
+ if (result.errors.length) {
311
+ log(`✗ ${result.errors.length} issue(s):`);
312
+ for (const e of result.errors) log(` - ${e}`);
313
+ } else log("✓ check passed");
314
+ }
315
+ return result;
316
+ }