@hypersocial/decks-cli 0.2.2 → 0.3.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 +8 -3
- package/package.json +1 -1
- package/skills/hyperdecks/SKILL.md +86 -1
- package/skills/hyperdecks/chat.md +49 -0
- package/src/check.mjs +8 -363
- package/src/checkRules.mjs +558 -0
- package/src/commands.mjs +62 -1
- package/src/files.mjs +35 -0
- package/src/guide.mjs +55 -0
- package/src/hosted.mjs +74 -0
- package/src/init.mjs +605 -0
- package/src/layouts.mjs +71 -0
- package/src/narrate.mjs +2 -12
- package/src/ops.mjs +4 -2
- package/src/runtime.mjs +59 -14
- package/src/scaffold.mjs +8 -73
- package/src/source.mjs +69 -0
- package/src/voice.mjs +11 -0
- package/templates/first-deck/character.json +26 -0
- package/templates/first-deck/self.json +26 -0
- package/templates/script.md +7 -2
package/README.md
CHANGED
|
@@ -2,12 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
Command-line client for Hypersocial Decks live analytics and share management.
|
|
4
4
|
|
|
5
|
-
Most users should
|
|
5
|
+
Most users should start with the friendlier package name:
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npx hyperdecks
|
|
8
|
+
npx hyperdecks init
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
+
`init` signs you in, installs the skill into your agents, sets up a voice,
|
|
12
|
+
builds and narrates a three-slide hello deck and prints its link. Re-running it
|
|
13
|
+
resumes where it stopped; `--json --yes` is the agent form.
|
|
14
|
+
|
|
11
15
|
This package targets production by default:
|
|
12
16
|
|
|
13
17
|
```bash
|
|
@@ -128,7 +132,7 @@ normalize: hyper desk=hyperdecks
|
|
|
128
132
|
---
|
|
129
133
|
|
|
130
134
|
## hello
|
|
131
|
-
hold: 1.
|
|
135
|
+
hold: 1.5
|
|
132
136
|
presenter: wave
|
|
133
137
|
|
|
134
138
|
Hi, I'm Selçuk's mini avatar. [wave] This is Hyperdecks. [mood: excited]
|
|
@@ -137,6 +141,7 @@ Hi, I'm Selçuk's mini avatar. [wave] This is Hyperdecks. [mood: excited]
|
|
|
137
141
|
- `## <slide-id>` is the slide's `<section id>` in `index.html`. Text before the first
|
|
138
142
|
heading is notes for people and is ignored.
|
|
139
143
|
- `hold`, `presenter` and `voice` lines directly under a heading are slide options.
|
|
144
|
+
`hold` is the pause in seconds after the line before the next slide (0.4 by default).
|
|
140
145
|
- Sentences are the caption units, 4–85 characters each.
|
|
141
146
|
- Stage tags (`[wave]`, `[mood: x]`, `[emote: x]`, `[reveal: x]`, `[play: x]`,
|
|
142
147
|
`[pause: s]`, `[custom: name …]`) are not spoken. Each becomes a cue at the start
|
package/package.json
CHANGED
|
@@ -11,6 +11,32 @@ Hyperdecks is the CLI and MCP surface for Hypersocial Decks live analytics,
|
|
|
11
11
|
recipient links, and deck activity. Treat real recipient activity, live deck
|
|
12
12
|
stats, customer-facing links, and "what happened" questions as production work.
|
|
13
13
|
|
|
14
|
+
## First run
|
|
15
|
+
|
|
16
|
+
When `hyperdecks whoami` says not authenticated, or `hyperdecks voice list`
|
|
17
|
+
shows no ready voice, run the first-run command instead of working around it:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npx hyperdecks init --json --yes [--voice dash] [--name "<name>" --about "<one line>" --ask "<the ask>"]
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
It signs in, installs this skill, scaffolds a three-slide hello deck from the
|
|
24
|
+
three answers, narrates it, releases one QA link and prints one JSON object
|
|
25
|
+
(`status`, `steps`, `slug`, `deckDir`, `shareUrl`, `next`). Re-running it
|
|
26
|
+
resumes where it stopped. Signed out, it first opens the sign-in page in the
|
|
27
|
+
user's browser and waits up to 3 minutes: tell them to finish signing in there
|
|
28
|
+
(a new account can be made on that page) before you run it, or have them run
|
|
29
|
+
`npx hyperdecks login` first. Inside a git repository it puts the deck in
|
|
30
|
+
`~/hyperdecks` rather than committing into that repository; `--dir` picks the
|
|
31
|
+
folder. Relay every step whose `status` is `needs-human` to the user, word for
|
|
32
|
+
word, and wait for them:
|
|
33
|
+
|
|
34
|
+
- deck: it needs `--name`, `--about` and `--ask`; ask the user, do not invent them;
|
|
35
|
+
- voice: cloning needs them to type their consent in a terminal (`hyperdecks
|
|
36
|
+
voice clone <file> --name <name>`), or to agree to the statement before you
|
|
37
|
+
call `clone_voice`. Never type or confirm consent for them. `--voice dash`
|
|
38
|
+
lets D.A.S.H., a character voice, present instead.
|
|
39
|
+
|
|
14
40
|
## Target Selection
|
|
15
41
|
|
|
16
42
|
- Prefer production for real-world questions and actions. The published CLI is
|
|
@@ -81,6 +107,7 @@ Write `decks/<slug>/evidence.md` as you go: one row per fact with the slide, the
|
|
|
81
107
|
claim, the source (file path, thread, doc title) and its date. Never put a
|
|
82
108
|
number on a slide that has no row.
|
|
83
109
|
|
|
110
|
+
<!-- topic: format -->
|
|
84
111
|
### 2. Scaffold
|
|
85
112
|
|
|
86
113
|
```bash
|
|
@@ -126,6 +153,7 @@ The deck contract (`check` enforces each line):
|
|
|
126
153
|
- Local images and files sit inside the deck folder and are referenced
|
|
127
154
|
relatively (`img/chart.png`). Hosted images (`gen-image` URLs) are fine too.
|
|
128
155
|
|
|
156
|
+
<!-- topic: presenter -->
|
|
129
157
|
#### Which presenter style
|
|
130
158
|
|
|
131
159
|
Set the deck's on the player tag (`data-presenter-style="stage"`) or in
|
|
@@ -144,6 +172,45 @@ Below 650 px stage and glass become corner on their own. Run
|
|
|
144
172
|
`hyperdecks check <deck> --layout` to be warned about any slide whose presenter
|
|
145
173
|
would cover its title.
|
|
146
174
|
|
|
175
|
+
<!-- topic: transitions -->
|
|
176
|
+
#### Transitions
|
|
177
|
+
|
|
178
|
+
Every slide enters with `drift` unless it says otherwise. Use another only
|
|
179
|
+
where the slide's job matches its "use for"; never hand-write slide-change
|
|
180
|
+
animation in the deck's own JS (it fights the player's timing).
|
|
181
|
+
|
|
182
|
+
| Transition | Use for | What it does |
|
|
183
|
+
|---|---|---|
|
|
184
|
+
| `drift` (default) | most slides | the old slide drifts out, the new one in, its parts arriving title first |
|
|
185
|
+
| `cut` | a punchline, a fast beat | instant |
|
|
186
|
+
| `push` | the next step in a sequence | the new slide slides over; the old one sinks back and darkens |
|
|
187
|
+
| `wipe` | a new section | a band in the deck's second colour, then one in ink, sweeps across and leaves the new slide |
|
|
188
|
+
| `zoom <selector>` | going into the detail of one row, bar or card | the new slide opens out of that element on the slide before |
|
|
189
|
+
| `morph` (carry over) | a number or title on both slides | the element travels into its new place and size |
|
|
190
|
+
| `fly` | the deck's one big reveal | the presenter flies across and the new slide appears behind it |
|
|
191
|
+
|
|
192
|
+
Set the deck's in script.md's front matter (`transition: drift`) or on the
|
|
193
|
+
player tag (`data-transition`); a slide's under its `##` heading
|
|
194
|
+
(`transition: push`) or as `data-transition` on its `<section>`, which wins.
|
|
195
|
+
A slide's transition is how it enters; going back plays it in reverse.
|
|
196
|
+
A `transition:` in script.md reaches the player through the manifest, so it
|
|
197
|
+
takes effect after `narrate` (release runs it; nothing re-renders);
|
|
198
|
+
`data-transition` in index.html takes effect at once. Zoom
|
|
199
|
+
names its target on the slide before: `transition: zoom #dash-row`. Morph
|
|
200
|
+
pairs elements by name: put `data-morph="saved"` on the element on both
|
|
201
|
+
slides. Reduced motion turns every one into a short crossfade, and the
|
|
202
|
+
narration never waits for a transition.
|
|
203
|
+
|
|
204
|
+
`check` fails an unknown name, a `zoom` with no selector or one that matches
|
|
205
|
+
nothing on the slide before, a `morph` with no `data-morph` name on both
|
|
206
|
+
slides, and a second `fly`. It warns when a deck uses more than two kinds
|
|
207
|
+
besides its default: pick one or two and keep them for the same job.
|
|
208
|
+
|
|
209
|
+
Viewers can poke the live head: a click or tap makes it react (an "oof", a
|
|
210
|
+
flinch) and a drag pulls it a little before it springs back, without pausing
|
|
211
|
+
the narration.
|
|
212
|
+
|
|
213
|
+
<!-- topic: design -->
|
|
147
214
|
### Design: make it look good
|
|
148
215
|
|
|
149
216
|
A narrated deck is only as convincing as it looks. Design before you write the
|
|
@@ -256,6 +323,7 @@ If review cannot run (no credits, the service is down), say so to the user and
|
|
|
256
323
|
do the step 6 screenshot pass yourself as the reviewer: the same desktop and
|
|
257
324
|
phone screenshots, the same `fix` bar.
|
|
258
325
|
|
|
326
|
+
<!-- topic: script -->
|
|
259
327
|
### 3. Write slides and script
|
|
260
328
|
|
|
261
329
|
- One idea per slide; the title states it.
|
|
@@ -264,7 +332,7 @@ phone screenshots, the same `fix` bar.
|
|
|
264
332
|
- Narration: short phrases, each sentence one caption of 4 to 85 characters
|
|
265
333
|
(two lines in the bubble). Aim for 2.5 to 3 minutes in all; `maxTotalSeconds`
|
|
266
334
|
in the front matter is the cap check uses (180 in the scaffold).
|
|
267
|
-
- Holds:
|
|
335
|
+
- 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.
|
|
268
336
|
- Cues: `[reveal: #id]` reveals `.reveal#id` at the word before it; also
|
|
269
337
|
`[wave]`, `[mood: excited]`, `[pause: 0.5]`, `[custom: name]`.
|
|
270
338
|
- Moves, to point at part of a slide: `[go: <selector>]` flies the presenter
|
|
@@ -305,6 +373,7 @@ A slide fails when the transcript does not match the script: read the error,
|
|
|
305
373
|
reword the sentence (spell numbers and names the way they are said, or add a
|
|
306
374
|
`normalize:` entry), and narrate again; only that slide re-renders.
|
|
307
375
|
|
|
376
|
+
<!-- topic: check -->
|
|
308
377
|
### 5. Check
|
|
309
378
|
|
|
310
379
|
```bash
|
|
@@ -340,6 +409,12 @@ an internal codename). Messages and what to do:
|
|
|
340
409
|
| `slide <id>: title … needs a data-title-mobile` | add a shorter title |
|
|
341
410
|
| `… presenter style "…" is not one of` | use stage, bubble, glass or corner |
|
|
342
411
|
| `script.md line N: [go: …] matches nothing in slide <id>` | fix the selector, or add the element to that slide |
|
|
412
|
+
| `… transition "…" is not one of` | use drift, cut, push, wipe, zoom `<selector>`, morph or fly |
|
|
413
|
+
| `slide <id>: zoom target "…" matches nothing on the slide before it` | point the zoom at an element of the previous slide, or give that element the id |
|
|
414
|
+
| `slide <id>: morph needs an element with the same data-morph` | put `data-morph="<name>"` on the carried element on both slides, or use drift |
|
|
415
|
+
| `script.md says transition "…" but the manifest has "…"` | run `narrate` (no audio re-renders), or release, which runs it |
|
|
416
|
+
| `fly is for the deck's one big moment` | keep `fly` on one slide; use drift or push on the others |
|
|
417
|
+
| ⚠ `N kinds of transition besides the default` | use one or two kinds, each for its own job |
|
|
343
418
|
| `slide <id>: its content runs …px past the slide on a 390×844 phone` | trim that slide on phones: smaller type, fewer items, or hide decoration in its `@media (max-width: 650px)` rules (an `#id` rule outranks them; restate it there) |
|
|
344
419
|
| ⚠ `slide <id>: the … presenter would overlap the title` | move that slide's `.narration-slot`, pad the text away from it, or use `corner` |
|
|
345
420
|
|
|
@@ -396,6 +471,16 @@ hyperdecks activity --deck <slug> --limit 10
|
|
|
396
471
|
Tell the user who opened it, how far they got and whether the narration
|
|
397
472
|
finished. QA links never count.
|
|
398
473
|
|
|
474
|
+
### Hosted source (chat agents and the connector)
|
|
475
|
+
|
|
476
|
+
A deck can also live on Hyperdecks with no folder: chat agents build it through
|
|
477
|
+
the hosted MCP (`deck_guide`, `new_deck`, `get_deck_source`, `write_deck_source`,
|
|
478
|
+
`upload_deck_asset`, `check_deck`, `release_deck`; see `chat.md`). Move between
|
|
479
|
+
the two with `hyperdecks source pull <slug> [dir]` (the hosted draft into a
|
|
480
|
+
folder) and `hyperdecks source push <slug> [dir]` (a folder's files back to the
|
|
481
|
+
draft), `hyperdecks asset add <slug> <file|url>` for an image, `hyperdecks guide
|
|
482
|
+
[topic]` for these sections, and `--hosted` on `deck new`, `check` and `release`.
|
|
483
|
+
|
|
399
484
|
## Safety
|
|
400
485
|
|
|
401
486
|
- For read-only inspection, run commands directly and summarize the important
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Hyperdecks from a chat app (hosted MCP)
|
|
2
|
+
|
|
3
|
+
You are building a narrated deck through the Hyperdecks connector: no terminal,
|
|
4
|
+
no git, no folder. The deck's files (`index.html`, `script.md`, `evidence.md`)
|
|
5
|
+
live on Hyperdecks as a draft; you read and edit them with tools, and nothing
|
|
6
|
+
reaches a recipient until `release_deck`. Topics: call `deck_guide` with
|
|
7
|
+
`format`, `script`, `design`, `layouts`, `presenter`, `transitions` or `check`
|
|
8
|
+
before the step that needs it.
|
|
9
|
+
|
|
10
|
+
## The loop
|
|
11
|
+
|
|
12
|
+
1. `deck_guide` (this page), then `deck_guide { topic: "layouts" }` to pick slides.
|
|
13
|
+
2. `new_deck { slug, title, layouts }`: registers your draft and returns the
|
|
14
|
+
full text of every file plus a `previewUrl` (the draft, untracked).
|
|
15
|
+
3. Images: `generate_image`, `render_chart`, `capture_url`, or
|
|
16
|
+
`upload_deck_asset` for a picture the user gave you (a URL, or a file they
|
|
17
|
+
uploaded). Each returns a hosted URL: put that URL in the HTML. A hosted deck
|
|
18
|
+
has no folder, so a relative path like `img/a.png` is an error.
|
|
19
|
+
4. `write_deck_source` to change a file: `edits: [{ find, replace }]` with
|
|
20
|
+
exact text that occurs once (copy it from `get_deck_source`), or `content`
|
|
21
|
+
to replace the file. Pass `baseRev` (the `rev` you last read) so a
|
|
22
|
+
concurrent edit fails instead of being overwritten.
|
|
23
|
+
5. `check_deck { slug }` until it passes (the narration errors clear in step 6).
|
|
24
|
+
6. `narrate_deck { slug }` with no `script` narrates the stored `script.md`;
|
|
25
|
+
poll `check_narration_job` until `done`. The draft's player tag then has its
|
|
26
|
+
`data-manifest`. No voice of their own yet? Pass `voice: "dash"`.
|
|
27
|
+
7. `check_deck { slug }` again, then `review_deck { slug, draft: true }` and
|
|
28
|
+
`check_review_job`: it shoots desktop and 390×844 phone frames, which is how
|
|
29
|
+
you catch a slide that overflows a phone (the hosted check cannot measure
|
|
30
|
+
layout).
|
|
31
|
+
8. `release_deck { slug, qa: true }` gives the user a QA link to watch first.
|
|
32
|
+
`release_deck { slug, recipients: [{ recipient, email }] }` makes one
|
|
33
|
+
tracked link each. Release creates live links: confirm the recipients with
|
|
34
|
+
the user before you call it.
|
|
35
|
+
9. Later: `list_shares`, `deck_stats`, `activity_overview`.
|
|
36
|
+
|
|
37
|
+
## Rules that matter
|
|
38
|
+
|
|
39
|
+
- Every slide is `<section class="slide" id="…" data-note="<source of its facts>">`,
|
|
40
|
+
in the same order as the `## <id>` blocks in `script.md`.
|
|
41
|
+
- Keep the player script tag and the `<style data-hd-loading>` block the
|
|
42
|
+
scaffold wrote; never write `data-manifest` yourself.
|
|
43
|
+
- Edit the draft freely: recipients keep seeing the last release until you call
|
|
44
|
+
`release_deck` again. A release is refused when the check fails, when
|
|
45
|
+
narration is stale (narrate again first), or when the draft changed after
|
|
46
|
+
the check.
|
|
47
|
+
- Files are capped at 800 KB each: use hosted image URLs, never inline base64.
|
|
48
|
+
- Facts come from the user's material; put the source of each in `evidence.md`
|
|
49
|
+
and the slide's `data-note`.
|
package/src/check.mjs
CHANGED
|
@@ -6,279 +6,13 @@
|
|
|
6
6
|
// The rules are pure (htmlErrors, manifestErrors, timelineErrors); checkDeck does
|
|
7
7
|
// the I/O: it reads the deck folder, fetches the manifest the player names and
|
|
8
8
|
// every clip's audio and timeline. `fetch` and `exists` are injectable for tests.
|
|
9
|
+
// The rules themselves live in checkRules.mjs (no Node imports, shared with the
|
|
10
|
+
// hosted check); this file adds the folder I/O and the Chromium measurements.
|
|
9
11
|
import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
|
|
10
12
|
import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
13
|
+
import { PHONE, PRESENTER_FOOTPRINT, checkSources, overlapWarnings, phoneOverflowErrors, slideStyles, stageSize, MESSAGES } from "./checkRules.mjs";
|
|
11
14
|
|
|
12
|
-
export
|
|
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
|
-
// Same names as PRESENTER_STYLES in lib/deckManifest.ts (the published CLI cannot import it).
|
|
18
|
-
export const PRESENTER_STYLES = ["stage", "bubble", "glass", "corner"];
|
|
19
|
-
|
|
20
|
-
export const MESSAGES = {
|
|
21
|
-
noPlayer: () => `no player script: add <script type="module" src="${PLAYER_V1}" data-manifest="…">`,
|
|
22
|
-
player: (src) => `player script must be ${PLAYER_V1} (got ${src})`,
|
|
23
|
-
noManifest: () => "the player script has no data-manifest: run `hyperdecks narrate <deck-dir>`, which sets it",
|
|
24
|
-
manifestUnavailable: (url, why) => `manifest ${url} could not be read (${why})`,
|
|
25
|
-
noSlides: () => 'no slides: each slide is a <section class="slide" id="…">',
|
|
26
|
-
noLoading: () => "no loading state: copy the <style data-hd-loading> block and the lines above it from the narrated template into <head>, and set --hd-ground to the first slide's colour",
|
|
27
|
-
clipField: (id, field) => `clip ${id}: ${field}`,
|
|
28
|
-
noCaptions: (id) => `clip ${id}: timeline has no captions`,
|
|
29
|
-
order: (slides, clips) => `slide ids (${slides.join(", ")}) must equal the manifest clip ids (${clips.join(", ")}), in order`,
|
|
30
|
-
total: (sum, declared) => `clip durations plus holds add up to ${sum}s but the manifest declares totalDuration ${declared}s`,
|
|
31
|
-
cap: (total, cap) => `narration runs ${total}s, over the ${cap}s cap (aim for 150 to 180 s)`,
|
|
32
|
-
url: (id, key, url, why) => `clip ${id}: ${key} ${url} ${why}`,
|
|
33
|
-
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`,
|
|
34
|
-
words: (id) => `clip ${id}: captions do not cover the clip text, or a caption has no timed words`,
|
|
35
|
-
captionLength: (id, i, text) => `clip ${id}: caption ${i + 1} is ${text.length} characters ("${text}"); captions must be ${CAPTION_MIN_CHARS} to ${CAPTION_MAX_CHARS}`,
|
|
36
|
-
localRef: (ref) => `local reference does not resolve: ${ref}`,
|
|
37
|
-
outsideRef: (ref) => `local reference leaves the deck folder: ${ref}`,
|
|
38
|
-
forbidden: (phrase, where) => `forbidden phrase "${phrase}" appears in ${where}`,
|
|
39
|
-
note: (id) => `slide ${id}: needs a data-note naming the source of its facts`,
|
|
40
|
-
mobileTitle: (id, title) => `slide ${id}: title "${title}" is over ${MOBILE_TITLE_CHARS} characters and needs a data-title-mobile`,
|
|
41
|
-
slideId: (n) => `slide ${n} has no id`,
|
|
42
|
-
selector: (line, cue, selector, id) => `script.md line ${line}: [${cue}: ${selector}] matches nothing in slide ${id}`,
|
|
43
|
-
presenterStyle: (where, value) => `${where}: presenter style "${value}" is not one of ${PRESENTER_STYLES.join(", ")}`,
|
|
44
|
-
// Warnings (check --layout): the deck still passes.
|
|
45
|
-
layoutUnchecked: (why) => `layout not checked: ${why}`,
|
|
46
|
-
overlap: (id, style, stage) => `slide ${id}: the ${style} presenter would overlap the title at ${stage}; move the .narration-slot or pick another style`,
|
|
47
|
-
// An error, though only check --layout can see it.
|
|
48
|
-
phoneOverflow: (id, px) => `slide ${id}: its content runs ${px}px past the slide on a ${PHONE.width}×${PHONE.height} phone; a slide must fit one screen without scrolling, so trim it or tighten its @media (max-width: 650px) rules`,
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
// The framed player's slide card on a phone (player.css): the screen less the
|
|
52
|
-
// 52px masthead, the 56px dock and 8px gutters.
|
|
53
|
-
export const PHONE = { width: 390, height: 844, card: { width: 374, height: 736 } };
|
|
54
|
-
|
|
55
|
-
const uncommented = (html) => html.replace(/<!--[\s\S]*?-->/g, "");
|
|
56
|
-
const attr = (tag, name) => {
|
|
57
|
-
const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`).exec(tag);
|
|
58
|
-
return m ? (m[1] ?? m[2]) : null;
|
|
59
|
-
};
|
|
60
|
-
const round = (n) => Math.round(n * 100) / 100;
|
|
61
|
-
const decode = (s) => s.replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'|'/g, "'");
|
|
62
|
-
const textOf = (html) => decode(html.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
|
|
63
|
-
|
|
64
|
-
// Comments blanked but newlines kept, so line numbers still match the file.
|
|
65
|
-
const blankComments = (text) => text.replace(/<!--[\s\S]*?-->/g, (c) => c.replace(/[^\n]/g, " "));
|
|
66
|
-
const lineAt = (text, index) => text.slice(0, index).split("\n").length;
|
|
67
|
-
|
|
68
|
-
// Unknown presenter styles, with their line: data-presenter-style in index.html,
|
|
69
|
-
// presenterStyle in script.md's front matter and presenter-style under a heading.
|
|
70
|
-
export function presenterStyleErrors(html, script = "") {
|
|
71
|
-
const errs = [];
|
|
72
|
-
const page = blankComments(html);
|
|
73
|
-
for (const m of page.matchAll(/\sdata-presenter-style\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
|
|
74
|
-
const value = m[1] ?? m[2];
|
|
75
|
-
if (!PRESENTER_STYLES.includes(value)) errs.push(MESSAGES.presenterStyle(`index.html line ${lineAt(page, m.index)}`, value));
|
|
76
|
-
}
|
|
77
|
-
const lines = script.replace(/\r\n?/g, "\n").split("\n");
|
|
78
|
-
const frontEnd = lines[0]?.trim() === "---" ? lines.indexOf("---", 1) : -1;
|
|
79
|
-
lines.forEach((line, i) => {
|
|
80
|
-
const m = (i > 0 && i < frontEnd ? /^\s*presenterStyle\s*:\s*(.*?)\s*$/ : /^\s*presenter-style\s*:\s*(.*?)\s*$/).exec(line);
|
|
81
|
-
if (m && !PRESENTER_STYLES.includes(m[1])) errs.push(MESSAGES.presenterStyle(`script.md line ${i + 1}`, m[1]));
|
|
82
|
-
});
|
|
83
|
-
return errs;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Does a simple selector (tag, #id, .class, or one compound of them) match
|
|
87
|
-
// anything in this slide's markup? null when the selector is too complex to
|
|
88
|
-
// tell without a browser.
|
|
89
|
-
export function selectorMatches(body, selector) {
|
|
90
|
-
const m = /^([a-zA-Z][a-zA-Z0-9-]*)?(?:#([\w-]+))?((?:\.[\w-]+)*)$/.exec(selector.trim());
|
|
91
|
-
if (!m || !(m[1] || m[2] || m[3])) return null;
|
|
92
|
-
const [, tag, id, cls] = m;
|
|
93
|
-
const classes = cls.split(".").filter(Boolean);
|
|
94
|
-
return [...uncommented(body).matchAll(/<([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*>/g)].some(([open, name]) => {
|
|
95
|
-
if (tag && name.toLowerCase() !== tag.toLowerCase()) return false;
|
|
96
|
-
if (id && attr(open, "id") !== id) return false;
|
|
97
|
-
const have = (attr(open, "class") ?? "").split(/\s+/);
|
|
98
|
-
return classes.every((c) => have.includes(c));
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// [go: <selector>] and [look: <selector>] in script.md must match something in
|
|
103
|
-
// their slide ([go: slot] returns home and always resolves).
|
|
104
|
-
export function cueSelectorErrors(html, script = "") {
|
|
105
|
-
const bodies = new Map(slides(html).map((s) => [s.id, s.body]));
|
|
106
|
-
const errs = [];
|
|
107
|
-
let id = null;
|
|
108
|
-
script.replace(/\r\n?/g, "\n").split("\n").forEach((line, i) => {
|
|
109
|
-
const heading = /^##\s+(\S+)/.exec(line);
|
|
110
|
-
if (heading) { id = heading[1]; return; }
|
|
111
|
-
if (!id || !bodies.has(id)) return;
|
|
112
|
-
for (const [, cue, raw] of line.matchAll(/\[\s*(go|look)\s*:\s*([^\]]*?)\s*\]/gi)) {
|
|
113
|
-
if (raw === "slot" || !raw) continue;
|
|
114
|
-
if (selectorMatches(bodies.get(id), raw) === false) errs.push(MESSAGES.selector(i + 1, cue.toLowerCase(), raw, id));
|
|
115
|
-
}
|
|
116
|
-
});
|
|
117
|
-
return errs;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Same normalization as the source check: accents folded, case and punctuation dropped.
|
|
121
|
-
export const normalizeWords = (text) => text.normalize("NFKD").replace(/\p{Diacritic}/gu, "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
122
|
-
|
|
123
|
-
// `<section class="slide …">` blocks in document order: id, the opening tag, the
|
|
124
|
-
// title (first <h1>/<h2>) and its opening tag.
|
|
125
|
-
export function slides(html) {
|
|
126
|
-
return [...uncommented(html).matchAll(/(<section\b[^>]*\sclass="(?:[^"]*\s)?slide(?:\s[^"]*)?"[^>]*>)([\s\S]*?)<\/section>/g)].map(([, tag, body]) => {
|
|
127
|
-
const heading = /(<h[12]\b[^>]*>)([\s\S]*?)<\/h[12]>/.exec(body);
|
|
128
|
-
return { id: attr(tag, "id") ?? "", tag, body, title: heading ? textOf(heading[2]) : "", titleTag: heading?.[1] ?? "" };
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
export function playerScript(html) {
|
|
133
|
-
const tag = [...uncommented(html).matchAll(/<script\b[^>]*>/g)].map((m) => m[0]).find((t) => /\/player\/v\d+\/player\.js"/.test(t));
|
|
134
|
-
return tag ? { src: attr(tag, "src"), manifest: attr(tag, "data-manifest") || null } : null;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// Relative references: src, href, poster and data-presenter-image (either quote), srcset candidates and
|
|
138
|
-
// CSS url(…) in style attributes and <style> blocks; everything that is not a URL
|
|
139
|
-
// scheme, protocol-relative or a fragment. Query strings and fragments dropped.
|
|
140
|
-
export function localRefs(html) {
|
|
141
|
-
const doc = uncommented(html);
|
|
142
|
-
const raw = [
|
|
143
|
-
...[...doc.matchAll(/\s(?:src|href|poster|data-presenter-image)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].map((m) => m[1] ?? m[2]),
|
|
144
|
-
...[...doc.matchAll(/\ssrcset\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].flatMap((m) => (m[1] ?? m[2]).split(",").map((c) => c.trim().split(/\s+/)[0])),
|
|
145
|
-
...[...doc.matchAll(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)'"\s]+))\s*\)/gi)].map((m) => m[1] ?? m[2] ?? m[3]),
|
|
146
|
-
];
|
|
147
|
-
const refs = raw.map((r) => decode(String(r ?? "")).trim().split("#")[0].split("?")[0]).filter(Boolean);
|
|
148
|
-
// Folder links (href="/", "./") are navigation, not files.
|
|
149
|
-
return [...new Set(refs.filter((r) => !/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(r) && !r.endsWith("/")))];
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function phraseList(value) {
|
|
153
|
-
if (!value) return [];
|
|
154
|
-
if (typeof value === "string") return value.split(",").map((s) => s.trim()).filter(Boolean);
|
|
155
|
-
return value.map((s) => String(s).trim()).filter(Boolean);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// Everything checkable from the deck's own files. `exists(ref)` answers whether a
|
|
159
|
-
// local reference resolves inside the deck folder: true, false or "outside".
|
|
160
|
-
export function htmlErrors(html, { exists = () => true, forbid = [], script = "" } = {}) {
|
|
161
|
-
const errs = [...presenterStyleErrors(html, script), ...cueSelectorErrors(html, script)];
|
|
162
|
-
const player = playerScript(html);
|
|
163
|
-
if (!player) errs.push(MESSAGES.noPlayer());
|
|
164
|
-
else if (player.src !== PLAYER_V1) errs.push(MESSAGES.player(player.src));
|
|
165
|
-
// The loading state must be in <head> to hold the page from its first paint,
|
|
166
|
-
// with its fail-open so a deck whose player never loads still shows.
|
|
167
|
-
const loading = /<style\b[^>]*\sdata-hd-loading(?=[\s>=])[^>]*>([\s\S]*?)<\/style>/i.exec(/<head\b[\s\S]*?<\/head>/i.exec(uncommented(html))?.[0] ?? "");
|
|
168
|
-
if (player && !loading?.[1].includes("hd-fail-open")) errs.push(MESSAGES.noLoading());
|
|
169
|
-
const all = slides(html);
|
|
170
|
-
if (!all.length) errs.push(MESSAGES.noSlides());
|
|
171
|
-
all.forEach((s, i) => {
|
|
172
|
-
const id = s.id || `${i + 1}`;
|
|
173
|
-
if (!s.id) errs.push(MESSAGES.slideId(i + 1));
|
|
174
|
-
if (!attr(s.tag, "data-note")?.trim()) errs.push(MESSAGES.note(id));
|
|
175
|
-
if (s.title.length > MOBILE_TITLE_CHARS && !attr(s.titleTag, "data-title-mobile") && !attr(s.tag, "data-title-mobile")) {
|
|
176
|
-
errs.push(MESSAGES.mobileTitle(id, s.title));
|
|
177
|
-
}
|
|
178
|
-
});
|
|
179
|
-
for (const ref of localRefs(html)) {
|
|
180
|
-
const found = exists(ref);
|
|
181
|
-
if (found === "outside") errs.push(MESSAGES.outsideRef(ref));
|
|
182
|
-
else if (!found) errs.push(MESSAGES.localRef(ref));
|
|
183
|
-
}
|
|
184
|
-
const page = textOf(uncommented(html).replace(/<(script|style)\b[\s\S]*?<\/\1>/g, " ")).toLowerCase();
|
|
185
|
-
for (const phrase of phraseList(forbid)) {
|
|
186
|
-
const p = phrase.toLowerCase();
|
|
187
|
-
if (page.includes(p) || uncommented(html).toLowerCase().includes(p)) errs.push(MESSAGES.forbidden(phrase, "index.html"));
|
|
188
|
-
if (script.toLowerCase().includes(p)) errs.push(MESSAGES.forbidden(phrase, "script.md"));
|
|
189
|
-
}
|
|
190
|
-
return errs;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// The manifest against the deck: order, total and cap. Clip text is checked for
|
|
194
|
-
// forbidden phrases too, since that is what the audience hears.
|
|
195
|
-
const isNum = (v) => typeof v === "number" && Number.isFinite(v);
|
|
196
|
-
const isStr = (v) => typeof v === "string" && v.trim().length > 0;
|
|
197
|
-
|
|
198
|
-
// Fields every clip needs before its other rules mean anything.
|
|
199
|
-
export function clipFieldErrors(clip, i) {
|
|
200
|
-
const id = isStr(clip?.id) ? clip.id : `#${i + 1}`;
|
|
201
|
-
const errs = [];
|
|
202
|
-
if (!isStr(clip?.id)) errs.push(MESSAGES.clipField(id, "needs an id"));
|
|
203
|
-
if (!(isNum(clip?.duration) && clip.duration > 0)) errs.push(MESSAGES.clipField(id, "duration must be a positive number of seconds"));
|
|
204
|
-
if (!(isNum(clip?.hold) && clip.hold >= 0)) errs.push(MESSAGES.clipField(id, "hold must be a number of seconds, 0 or more"));
|
|
205
|
-
if (!isStr(clip?.text)) errs.push(MESSAGES.clipField(id, "needs its text"));
|
|
206
|
-
for (const k of ["audio", "timeline"]) if (!isStr(clip?.[k])) errs.push(MESSAGES.clipField(id, `needs an ${k} URL`));
|
|
207
|
-
return errs;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
export function manifestErrors(manifest, slideIds, { maxDuration = DEFAULT_MAX_DURATION, forbid = [] } = {}) {
|
|
211
|
-
const clips = Array.isArray(manifest?.clips) ? manifest.clips : [];
|
|
212
|
-
const clipIds = clips.map((c) => c?.id ?? "");
|
|
213
|
-
const errs = clips.flatMap(clipFieldErrors);
|
|
214
|
-
if (slideIds.length !== clipIds.length || slideIds.some((id, i) => id !== clipIds[i])) errs.push(MESSAGES.order(slideIds, clipIds));
|
|
215
|
-
const sum = round(clips.reduce((n, c) => n + (Number(c?.duration) || 0) + (Number(c?.hold) || 0), 0));
|
|
216
|
-
const declared = Number(manifest?.totalDuration);
|
|
217
|
-
if (!(Math.abs(sum - declared) < 0.01)) errs.push(MESSAGES.total(sum, manifest?.totalDuration));
|
|
218
|
-
if (Number.isFinite(declared) && declared > maxDuration) errs.push(MESSAGES.cap(round(declared), maxDuration));
|
|
219
|
-
const badStyle = (v) => v !== undefined && !PRESENTER_STYLES.includes(v);
|
|
220
|
-
if (badStyle(manifest?.presenterStyle)) errs.push(MESSAGES.presenterStyle("manifest", manifest.presenterStyle));
|
|
221
|
-
for (const c of clips) if (badStyle(c?.presenterStyle)) errs.push(MESSAGES.presenterStyle(`clip ${c.id}`, c.presenterStyle));
|
|
222
|
-
for (const phrase of phraseList(forbid)) {
|
|
223
|
-
for (const c of clips) if (String(c?.text ?? "").toLowerCase().includes(phrase.toLowerCase())) errs.push(MESSAGES.forbidden(phrase, `clip ${c.id}`));
|
|
224
|
-
}
|
|
225
|
-
return errs;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// One clip's timeline: caption timing, coverage of the clip text, and length.
|
|
229
|
-
export function timelineErrors(clip, timeline) {
|
|
230
|
-
const errs = [];
|
|
231
|
-
if (!Array.isArray(timeline?.captions) || !timeline.captions.length) return [MESSAGES.noCaptions(clip.id)];
|
|
232
|
-
const captions = timeline.captions.map((c) => (c && typeof c === "object" ? c : {}));
|
|
233
|
-
const duration = Number(clip.duration) || 0;
|
|
234
|
-
captions.forEach((c, i) => {
|
|
235
|
-
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));
|
|
236
|
-
});
|
|
237
|
-
// Caption text carries the script's spelling; the timed words carry the
|
|
238
|
-
// transcript's (which may spell a name differently, as the narrate guard's
|
|
239
|
-
// pronunciation map allows), so coverage is checked on the text.
|
|
240
|
-
const covered = captions.map((c) => String(c.text ?? "")).join(" ");
|
|
241
|
-
const timed = captions.every((c) => Array.isArray(c.words) && c.words.length);
|
|
242
|
-
if (!timed || normalizeWords(covered) !== normalizeWords(String(clip.text ?? ""))) errs.push(MESSAGES.words(clip.id));
|
|
243
|
-
captions.forEach((c, i) => {
|
|
244
|
-
const text = String(c.text ?? "");
|
|
245
|
-
if (text.length < CAPTION_MIN_CHARS || text.length > CAPTION_MAX_CHARS) errs.push(MESSAGES.captionLength(clip.id, i, text));
|
|
246
|
-
});
|
|
247
|
-
return errs;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// Each slide's presenter style, as the player resolves it at desktop size: the
|
|
251
|
-
// slide's data-presenter-style, its clip's, the player tag's, the manifest's,
|
|
252
|
-
// then stage for the live head and bubble otherwise.
|
|
253
|
-
export function slideStyles(html, manifest = null) {
|
|
254
|
-
const page = uncommented(html);
|
|
255
|
-
const tag = [...page.matchAll(/<script\b[^>]*>/g)].map((m) => m[0]).find((t) => /\/player\/v\d+\/player\.js"/.test(t)) ?? "";
|
|
256
|
-
const known = (v) => (PRESENTER_STYLES.includes(v) ? v : null);
|
|
257
|
-
const deck = known(attr(tag, "data-presenter-style")) ?? known(manifest?.presenterStyle) ?? (attr(tag, "data-presenter") === "head" ? "stage" : "bubble");
|
|
258
|
-
const clips = new Map((Array.isArray(manifest?.clips) ? manifest.clips : []).map((c) => [c?.id, c]));
|
|
259
|
-
return slides(html).map((s) => ({ id: s.id, style: known(attr(s.tag, "data-presenter-style")) ?? known(clips.get(s.id)?.presenterStyle) ?? deck }));
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// What each style occupies at desktop size, in stage pixels (player.css): the
|
|
263
|
-
// head plus two caption lines. Corner sits in the stage's corner, not the slot.
|
|
264
|
-
export const PRESENTER_FOOTPRINT = {
|
|
265
|
-
stage: { width: 480, height: 490 },
|
|
266
|
-
glass: { width: 522, height: 534 },
|
|
267
|
-
bubble: { width: 446, height: 304 },
|
|
268
|
-
corner: { width: 400, height: 330, right: 28, bottom: 28 },
|
|
269
|
-
};
|
|
270
|
-
|
|
271
|
-
// Measured boxes → warnings. Each entry: { id, style, presenter, title } with
|
|
272
|
-
// { x, y, width, height } boxes (title null when the slide has none).
|
|
273
|
-
export function overlapWarnings(measured, stage = "1600×900") {
|
|
274
|
-
const hit = (a, b) => a && b && Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) > 4 && Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) > 4;
|
|
275
|
-
return measured.filter((m) => hit(m.presenter, m.title)).map((m) => MESSAGES.overlap(m.id, m.style, stage));
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// Slides whose content overflows the phone card, as errors. Each entry: { id, overflow } in px.
|
|
279
|
-
export function phoneOverflowErrors(measured) {
|
|
280
|
-
return measured.filter((m) => m.overflow > 1).map((m) => MESSAGES.phoneOverflow(m.id, Math.ceil(m.overflow)));
|
|
281
|
-
}
|
|
15
|
+
export * from "./checkRules.mjs";
|
|
282
16
|
|
|
283
17
|
// Lay each slide out in Chromium at the deck's stage size with JavaScript off
|
|
284
18
|
// (no player, no deck scripts), put the style's footprint in its slot, and
|
|
@@ -371,13 +105,6 @@ async function withDeckPage(deckDir, viewport, measure) {
|
|
|
371
105
|
}
|
|
372
106
|
}
|
|
373
107
|
|
|
374
|
-
// The deck's own cap: `maxTotalSeconds` in script.md's front matter.
|
|
375
|
-
export function scriptCap(script) {
|
|
376
|
-
const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(script)?.[1] ?? "";
|
|
377
|
-
const m = /^maxTotalSeconds:\s*([\d.]+)\s*$/m.exec(front);
|
|
378
|
-
return m ? Number(m[1]) : null;
|
|
379
|
-
}
|
|
380
|
-
|
|
381
108
|
// "<slug>" means ./decks/<slug> unless it names a folder itself.
|
|
382
109
|
export function resolveDeckDir(arg, cwd = process.cwd()) {
|
|
383
110
|
if (!arg) throw new Error("usage: check <slug|deck-dir>");
|
|
@@ -411,98 +138,19 @@ export function deckFileResolver(deckDir) {
|
|
|
411
138
|
};
|
|
412
139
|
}
|
|
413
140
|
|
|
414
|
-
async function fetchCheck(fetchImpl, url, want) {
|
|
415
|
-
try {
|
|
416
|
-
const r = await fetchImpl(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
417
|
-
if (!r.ok) {
|
|
418
|
-
await r.body?.cancel?.();
|
|
419
|
-
return { why: `returned HTTP ${r.status}` };
|
|
420
|
-
}
|
|
421
|
-
const type = (r.headers.get("content-type") || "").split(";")[0].trim();
|
|
422
|
-
if (!want.test(type)) {
|
|
423
|
-
await r.body?.cancel?.();
|
|
424
|
-
return { why: `is ${type || "untyped"}, expected ${want === AUDIO ? "audio/*" : "JSON"}` };
|
|
425
|
-
}
|
|
426
|
-
return { response: r };
|
|
427
|
-
} catch (e) {
|
|
428
|
-
return { why: `could not be fetched (${e.message})` };
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
const AUDIO = /^audio\//;
|
|
432
|
-
const FETCH_TIMEOUT_MS = 30_000;
|
|
433
|
-
const JSON_TYPE = /json/;
|
|
434
|
-
|
|
435
141
|
// Run every rule on a deck folder. Returns { deckDir, slug, slides, totalDuration, errors }.
|
|
436
142
|
export async function checkDeck(dirArg, opts = {}, { fetch: fetchImpl = globalThis.fetch, cwd, measure = measureLayout, measureFit = measurePhoneFit } = {}) {
|
|
437
143
|
const deckDir = resolveDeckDir(dirArg, cwd);
|
|
438
144
|
const html = readFileSync(join(deckDir, "index.html"), "utf8");
|
|
439
145
|
const scriptPath = join(deckDir, "script.md");
|
|
440
146
|
const script = existsSync(scriptPath) ? readFileSync(scriptPath, "utf8") : "";
|
|
441
|
-
const
|
|
442
|
-
const
|
|
443
|
-
const maxDuration = Number(flagCap ?? scriptCap(script) ?? DEFAULT_MAX_DURATION);
|
|
444
|
-
if (!Number.isFinite(maxDuration) || maxDuration <= 0) {
|
|
445
|
-
throw new Error(flagCap !== undefined ? "--max-duration needs a number of seconds" : "maxTotalSeconds in script.md must be a number of seconds");
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
const errors = htmlErrors(html, { exists: deckFileResolver(deckDir), forbid, script });
|
|
449
|
-
const ids = slides(html).map((s) => s.id);
|
|
450
|
-
const player = playerScript(html);
|
|
451
|
-
let manifest = null;
|
|
452
|
-
if (player && !player.manifest) errors.push(MESSAGES.noManifest());
|
|
453
|
-
if (player?.manifest) {
|
|
454
|
-
const got = await fetchCheck(fetchImpl, player.manifest, JSON_TYPE);
|
|
455
|
-
if (got.why) errors.push(MESSAGES.manifestUnavailable(player.manifest, got.why));
|
|
456
|
-
else {
|
|
457
|
-
try {
|
|
458
|
-
manifest = await got.response.json();
|
|
459
|
-
} catch (e) {
|
|
460
|
-
errors.push(MESSAGES.manifestUnavailable(player.manifest, `not JSON: ${e.message}`));
|
|
461
|
-
}
|
|
462
|
-
if (manifest !== null && !(manifest && typeof manifest === "object" && Array.isArray(manifest.clips) && manifest.clips.length)) {
|
|
463
|
-
errors.push(MESSAGES.manifestUnavailable(player.manifest, "not a manifest: needs a non-empty clips array"));
|
|
464
|
-
manifest = null;
|
|
465
|
-
} else if (manifest === null && !errors.some((e) => e.startsWith(`manifest ${player.manifest}`))) {
|
|
466
|
-
errors.push(MESSAGES.manifestUnavailable(player.manifest, "not a manifest: needs a non-empty clips array"));
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
if (manifest) {
|
|
471
|
-
errors.push(...manifestErrors(manifest, ids, { maxDuration, forbid }));
|
|
472
|
-
for (const [i, clip] of manifest.clips.entries()) {
|
|
473
|
-
if (clipFieldErrors(clip, i).length) continue; // already reported
|
|
474
|
-
let audioUrl, timelineUrl;
|
|
475
|
-
try {
|
|
476
|
-
audioUrl = new URL(clip.audio, player.manifest).href;
|
|
477
|
-
timelineUrl = new URL(clip.timeline, player.manifest).href;
|
|
478
|
-
} catch {
|
|
479
|
-
errors.push(MESSAGES.clipField(clip.id, "audio and timeline must be URLs"));
|
|
480
|
-
continue;
|
|
481
|
-
}
|
|
482
|
-
const audio = await fetchCheck(fetchImpl, audioUrl, AUDIO);
|
|
483
|
-
if (audio.why) errors.push(MESSAGES.url(clip.id, "audio", audioUrl, audio.why));
|
|
484
|
-
else await audio.response.body?.cancel?.().catch(() => {});
|
|
485
|
-
const tl = await fetchCheck(fetchImpl, timelineUrl, JSON_TYPE);
|
|
486
|
-
if (tl.why) {
|
|
487
|
-
errors.push(MESSAGES.url(clip.id, "timeline", timelineUrl, tl.why));
|
|
488
|
-
continue;
|
|
489
|
-
}
|
|
490
|
-
let timeline;
|
|
491
|
-
try {
|
|
492
|
-
timeline = await tl.response.json();
|
|
493
|
-
} catch (e) {
|
|
494
|
-
errors.push(MESSAGES.url(clip.id, "timeline", timelineUrl, `is not JSON (${e.message})`));
|
|
495
|
-
continue;
|
|
496
|
-
}
|
|
497
|
-
errors.push(...timelineErrors(clip, timeline));
|
|
498
|
-
}
|
|
499
|
-
}
|
|
147
|
+
const { manifest, ...result } = await checkSources({ html, script }, { ...opts, hosted: false, exists: deckFileResolver(deckDir) }, { fetch: fetchImpl });
|
|
148
|
+
const { errors, warnings } = result;
|
|
500
149
|
// A presenter over the title is a warning, not a failure: only a person can
|
|
501
150
|
// judge it. A slide that overflows the phone card is an error. Both need a
|
|
502
151
|
// browser, so they run only with --layout.
|
|
503
|
-
const warnings = [];
|
|
504
152
|
if (opts.layout) {
|
|
505
|
-
const
|
|
153
|
+
const { width: w, height: h } = stageSize(html);
|
|
506
154
|
try {
|
|
507
155
|
warnings.push(...overlapWarnings(await measure(deckDir, slideStyles(html, manifest), { width: w, height: h }), `${w}×${h}`));
|
|
508
156
|
} catch (e) {
|
|
@@ -518,11 +166,8 @@ export async function checkDeck(dirArg, opts = {}, { fetch: fetchImpl = globalTh
|
|
|
518
166
|
return {
|
|
519
167
|
deckDir,
|
|
520
168
|
slug: opts.slug || basename(deckDir),
|
|
521
|
-
|
|
522
|
-
totalDuration: manifest ? Number(manifest.totalDuration) : null,
|
|
523
|
-
manifestUrl: player?.manifest ?? null,
|
|
169
|
+
...result,
|
|
524
170
|
passed: errors.length === 0,
|
|
525
|
-
errors,
|
|
526
171
|
warnings: [...new Set(warnings)],
|
|
527
172
|
};
|
|
528
173
|
}
|