@hypersocial/decks-cli 0.1.11 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +103 -0
- package/package.json +2 -1
- package/skills/hyperdecks/SKILL.md +153 -1
- package/src/check.mjs +316 -0
- package/src/commands.mjs +134 -12
- package/src/narrate.mjs +95 -0
- package/src/ops.mjs +182 -0
- package/src/preview-capture.mjs +93 -20
- package/src/release.mjs +133 -0
- package/src/runtime.mjs +34 -3
- package/src/scaffold.mjs +43 -0
- package/src/voice.mjs +87 -0
- package/templates/evidence.md +16 -0
- package/templates/narrated-deck.html +125 -0
- package/templates/script.md +42 -0
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.
|
|
3
|
+
"version": "0.2.0",
|
|
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": {
|
|
@@ -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(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'|'/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
|
+
}
|