@hypersocial/decks-cli 0.8.1 → 0.9.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 +3 -1
- package/package.json +1 -1
- package/skills/hyperdecks/SKILL.md +145 -26
- package/skills/hyperdecks/chat.md +14 -5
- package/src/check.mjs +44 -18
- package/src/checkRules.mjs +64 -13
- package/src/commands.mjs +42 -8
- package/src/deckPlayer.mjs +45 -0
- package/src/guide.mjs +1 -1
- package/src/hosted.mjs +2 -2
- package/src/loadingBlock.mjs +72 -0
- package/src/ops.mjs +38 -0
- package/src/release.mjs +15 -5
- package/src/runtime.mjs +5 -2
- package/src/source.mjs +68 -3
package/README.md
CHANGED
|
@@ -154,7 +154,9 @@ Hi, I'm Selçuk's mini avatar. [wave] This is Hyperdecks. [mood: excited]
|
|
|
154
154
|
time of the word before it.
|
|
155
155
|
- Voice tags (`[whispers]`, `[laughs]`, `[sighs]`, `[excited]`, …) are sent to the
|
|
156
156
|
voice and left out of the captions.
|
|
157
|
-
-
|
|
157
|
+
- `[play]` / `[pause]` (the video cues, not `[pause: s]`) may open a line instead:
|
|
158
|
+
they fire at the start of that line's first word.
|
|
159
|
+
- Unknown tags, any other tag with no word before it, and headings with no matching section
|
|
158
160
|
fail with line numbers.
|
|
159
161
|
|
|
160
162
|
`--dry-run` lists each slide, its hash and whether it would render. It renders nothing.
|
package/package.json
CHANGED
|
@@ -147,6 +147,7 @@ Hyperdecks server; use the CLI where you have a shell and the tool otherwise.
|
|
|
147
147
|
| One more link | `hyperdecks share new <slug> "<name>" [--no-forward]` | `create_share` (`allowForward`) |
|
|
148
148
|
| Forwarding, one link | `hyperdecks share forward on\|off\|default <token>` | `set_share_forwarding` |
|
|
149
149
|
| Forwarding, whole deck | `hyperdecks deck forward on\|off <slug>` | `set_deck_forwarding` |
|
|
150
|
+
| Slides or scroll, viewer toggle | `hyperdecks deck view <slug> [slides\|scroll\|default] [--toggle on\|off]` | `set_deck_view` |
|
|
150
151
|
| Who watched | `hyperdecks deck stats <slug>` | `deck_stats` |
|
|
151
152
|
| All activity | `hyperdecks activity --deck <slug>` | `activity_overview` |
|
|
152
153
|
| What one person watched | `hyperdecks engagement "<name>" --deck <slug> --json` | `recipient_engagement` |
|
|
@@ -159,6 +160,15 @@ Disabling the parent link (`share off`) turns off its forwards too, and
|
|
|
159
160
|
`share forward default` (or `allowForward: null`) drops a link's own setting
|
|
160
161
|
so it follows the deck's.
|
|
161
162
|
|
|
163
|
+
How a deck opens (slides or scroll) is a deck setting too: `deck view <slug>
|
|
164
|
+
scroll` (`set_deck_view { slug, view: "scroll" }`) takes effect on every link's
|
|
165
|
+
next load, with no re-release; `default` (`view: null`) goes back to what the
|
|
166
|
+
HTML says. Viewers get a slides/scroll toggle in the player, remembered per
|
|
167
|
+
link, unless you turn it off (`--toggle off`, `viewToggle: false`). A viewer
|
|
168
|
+
who already switched keeps their own choice while the toggle is on; turn the
|
|
169
|
+
toggle off to make every viewer see the deck's view.
|
|
170
|
+
`deck list --json` / `list_decks` and `deck stats` / `deck_stats` show both.
|
|
171
|
+
|
|
162
172
|
### 1. Ground it in the user's own material
|
|
163
173
|
|
|
164
174
|
Build the deck from what you can already read in this session: files in the
|
|
@@ -197,13 +207,15 @@ Files, all in `decks/<slug>/`:
|
|
|
197
207
|
keep the cues at the words they belong to.
|
|
198
208
|
- `evidence.md`: the sources table. Private: release never uploads it.
|
|
199
209
|
|
|
200
|
-
The deck contract (`check` enforces each line):
|
|
210
|
+
The deck contract (`check` enforces or warns on each line):
|
|
201
211
|
|
|
202
212
|
- One `<section class="slide" id="<id>">` per slide; ids are lowercase,
|
|
203
213
|
unique, and in the same order as the `##` blocks in `script.md`.
|
|
204
214
|
- Every slide has `data-note="<source of its facts>"`, e.g.
|
|
205
215
|
`data-note="Q3 numbers: finance-update.pdf, 2026-10-02"`. A slide with no
|
|
206
|
-
facts says so: `data-note="Title slide: no facts."`.
|
|
216
|
+
facts says so: `data-note="Title slide: no facts."`. A missing note is a
|
|
217
|
+
warning, and an error on a pitch deck (`data-kind="pitch"` on the player
|
|
218
|
+
script: its claims need sources) or under `check --strict`.
|
|
207
219
|
- A title (first `<h1>`/`<h2>`) over 28 characters carries
|
|
208
220
|
`data-title-mobile="<shorter>"`. The scaffold puts one on every title.
|
|
209
221
|
- Each slide keeps its `<div class="narration-slot"></div>` (where the
|
|
@@ -213,6 +225,24 @@ The deck contract (`check` enforces each line):
|
|
|
213
225
|
- The player script stays
|
|
214
226
|
`<script type="module" src="https://decks.hypersocial.ai/player/v1/player.js" data-manifest="…">`.
|
|
215
227
|
`narrate` fills `data-manifest`; do not edit it by hand.
|
|
228
|
+
- A deck with no narration (a product tour, an imported design deck, a silent
|
|
229
|
+
walkthrough) says so: `data-silent` on the player script. `check` then warns
|
|
230
|
+
"no narration (silent deck)" instead of failing, release skips narrate, and
|
|
231
|
+
the player shows the slides, arrows, tabs and scroll mode with no Play
|
|
232
|
+
button, clock or caption bubble. Without `data-silent` a missing
|
|
233
|
+
`data-manifest` still fails, so a forgotten narrate is caught.
|
|
234
|
+
- The `<style data-hd-loading>` block in `<head>` is boilerplate the tools
|
|
235
|
+
write: `deck new` includes it, `check --fix` inserts or refreshes it, and a
|
|
236
|
+
hosted `write_deck_source` of index.html puts it right on the way in.
|
|
237
|
+
- To read the deck as one long page instead of slide by slide, add
|
|
238
|
+
`data-view="scroll"` to that player script (on the hosted MCP, through
|
|
239
|
+
write_deck_source). The slides stay as they are: they stack and snap, the
|
|
240
|
+
page fades to each landed slide's background (`data-bg="#ffd166"` on a
|
|
241
|
+
`<section>` overrides it), the presenter travels to each slide's
|
|
242
|
+
`narration-slot` as you scroll, and the narration scrolls the page on by
|
|
243
|
+
itself. Remove the attribute to go back. For a released deck, prefer the
|
|
244
|
+
deck setting (`deck view <slug> scroll`, `set_deck_view`): it overrides the
|
|
245
|
+
attribute and needs no re-release.
|
|
216
246
|
- Local images and files sit inside the deck folder and are referenced
|
|
217
247
|
relatively (`img/chart.png`). Hosted images (`gen-image` URLs) are fine too.
|
|
218
248
|
- A file the deck's own JavaScript loads (`fetch('media/data.json')`, a clip
|
|
@@ -238,6 +268,14 @@ its `##` heading.
|
|
|
238
268
|
| `glass` | dark or busy slides: head and captions on one frosted panel |
|
|
239
269
|
| `corner` | the slide is the point (a product shot, a chart, a demo): small head bottom-right, subtitles along the bottom |
|
|
240
270
|
|
|
271
|
+
Size it per slide with `data-presenter-size="s|m|l|xl"` on the `<section>`
|
|
272
|
+
(0.7×, 1×, 1.3×, 1.6× the style's head; on the player tag it is the deck's
|
|
273
|
+
default): `xl` on a slide with empty space, `s` on a busy product slide. Phones
|
|
274
|
+
keep `l`/`xl` at their usual size. A slide's CSS `--hd-presenter-scale: 1.15`
|
|
275
|
+
fine-tunes it. `check` warns on an unknown size. In `corner`, `[look]` turns
|
|
276
|
+
the head; `[go]` highlights the target and looks at it but the head stays in
|
|
277
|
+
its corner.
|
|
278
|
+
|
|
241
279
|
The live head's face follows script cues too (`[nod]`, `[look #card]`,
|
|
242
280
|
`[smile]`, `[gesture: lightbulb]`; see the script topic); a still face ignores them.
|
|
243
281
|
|
|
@@ -283,12 +321,79 @@ Viewers can poke the live head: a click or tap makes it react (an "oof", a
|
|
|
283
321
|
flinch) and a drag pulls it a little before it springs back, without pausing
|
|
284
322
|
the narration.
|
|
285
323
|
|
|
324
|
+
<!-- topic: scripts -->
|
|
325
|
+
#### What your scripts can do
|
|
326
|
+
|
|
327
|
+
A deck is a web page: its own `<script>`s run with the player, inline or from
|
|
328
|
+
a file in the deck folder (release ships both; the player strips nothing).
|
|
329
|
+
Put a module script after the player script tag, so `window.hyperdecks` is
|
|
330
|
+
there. What your code can hook into:
|
|
331
|
+
|
|
332
|
+
- **Controllers**: `window.hyperdecks.register("<slide-id>", { onEnter,
|
|
333
|
+
onExit, prebuild, onCue, onNarration })`. `onEnter(ctx)` / `onExit(ctx)`
|
|
334
|
+
run as the slide comes and goes, `prebuild(ctx)` during the hold before it
|
|
335
|
+
(load something heavy), `onCue(cue, ctx)` on each cue of the slide, and
|
|
336
|
+
`onNarration(state, ctx)` hears `playing`, `paused`, `ended` and `waiting`.
|
|
337
|
+
`ctx` is `{ id, index, slide, clip, position, presenter: { setMood, emote,
|
|
338
|
+
wave, go, look }, player: { show, next, prev, toggle, pause, resume } }`.
|
|
339
|
+
A classic script that runs before the player queues
|
|
340
|
+
`window.hyperdecks = [["<slide-id>", controller]]` instead.
|
|
341
|
+
- **Custom cues**: `[custom: confetti]` in script.md fires at the word before
|
|
342
|
+
it and reaches that slide's `onCue` (and the window's `deck:cue` event), as
|
|
343
|
+
`cue.action === "custom:confetti"` (words after the name arrive in `cue.args`).
|
|
344
|
+
- **Pausing the narrator**: `ctx.player.pause()` / `resume()`, or
|
|
345
|
+
`window.hyperdecks.pauseNarration()` / `resumeNarration()` from any script,
|
|
346
|
+
while something on the slide speaks. Resume undoes only your own pause.
|
|
347
|
+
- **Taps**: a click on a slide toggles narration, except on links, buttons,
|
|
348
|
+
form controls, `[role=button]`, `canvas`, focusable elements, anything drawn
|
|
349
|
+
with `cursor: pointer`, and anything marked `data-hd-tap`.
|
|
350
|
+
- **Interactive slides**: `data-interactive` on the `<section>`: the presenter
|
|
351
|
+
speaks its clip, then the deck waits (no timer) with a Continue button while
|
|
352
|
+
the viewer plays; taps and swipes inside the slide leave narration alone.
|
|
353
|
+
|
|
354
|
+
One slide using all of it (`## demo` in script.md: `Tap the bell, and [custom:
|
|
355
|
+
confetti] that's the whole trick.`):
|
|
356
|
+
|
|
357
|
+
```html
|
|
358
|
+
<section class="slide" id="demo" data-interactive data-note="Demo: no facts.">
|
|
359
|
+
<h2>Try it</h2>
|
|
360
|
+
<button class="bell" data-hd-tap>Ring the bell</button>
|
|
361
|
+
<div class="narration-slot"></div>
|
|
362
|
+
</section>
|
|
363
|
+
<script type="module">
|
|
364
|
+
window.hyperdecks.register("demo", {
|
|
365
|
+
onEnter(ctx) { ctx.slide.classList.remove("is-party"); },
|
|
366
|
+
onCue(cue, ctx) { if (cue.action === "custom:confetti") ctx.slide.classList.add("is-party"); },
|
|
367
|
+
});
|
|
368
|
+
document.querySelector("#demo .bell").addEventListener("click", () => {
|
|
369
|
+
window.hyperdecks.pauseNarration();
|
|
370
|
+
const bell = new Audio("media/bell.mp3");
|
|
371
|
+
bell.onended = () => window.hyperdecks.resumeNarration();
|
|
372
|
+
bell.play();
|
|
373
|
+
});
|
|
374
|
+
</script>
|
|
375
|
+
```
|
|
376
|
+
|
|
286
377
|
<!-- topic: design -->
|
|
287
378
|
### Design: make it look good
|
|
288
379
|
|
|
289
380
|
A narrated deck is only as convincing as it looks. Design before you write the
|
|
290
381
|
narration, and check the result at both widths (step 6).
|
|
291
382
|
|
|
383
|
+
#### Rules the check enforces
|
|
384
|
+
|
|
385
|
+
These fail `check` (step 5): a title over 28 characters with no
|
|
386
|
+
`data-title-mobile`; a layout placeholder left in (the hero's empty `.art`,
|
|
387
|
+
the product's `.shot`, the chart's made-up `<svg>`); an unknown presenter
|
|
388
|
+
style or transition, a second `fly`; on a pitch deck or with `--strict`, a
|
|
389
|
+
slide with no `data-note`; and, with `--layout` and always at release, a
|
|
390
|
+
slide that overflows a 390×844 phone.
|
|
391
|
+
|
|
392
|
+
#### House style (preferences)
|
|
393
|
+
|
|
394
|
+
Everything below is the owner's house style, not a rule: follow it by
|
|
395
|
+
default, and depart from it when the brief asks for something else.
|
|
396
|
+
|
|
292
397
|
**One idea per slide.** The title states the idea as a sentence a listener
|
|
293
398
|
could repeat ("Narration renders in two minutes", not "Speed"). Everything
|
|
294
399
|
else on the slide supports that sentence; if it needs a second idea, it is a
|
|
@@ -392,8 +497,9 @@ where there is room so slide changes move the presenter.
|
|
|
392
497
|
One style per deck, described the same way every time (subject, medium,
|
|
393
498
|
palette hexes, "no text"). `--model nano-banana-2.1` when the image must
|
|
394
499
|
carry legible type.
|
|
395
|
-
- Video: `gen-video` for a short loop behind a hero or a product moment
|
|
396
|
-
|
|
500
|
+
- Video: `gen-video` for a short loop behind a hero or a product moment;
|
|
501
|
+
never needed for a deck to work. A `muted loop` video plays while its slide
|
|
502
|
+
is current, no cue needed; a `data-hd-manual` one waits for `[play]`. A clip that
|
|
397
503
|
plays on repeat should be made with `--loop` (`loop: true`): it starts and
|
|
398
504
|
ends on the same frame, so there is no jump at the seam. Give it `--image`
|
|
399
505
|
to loop a still you already have; without one a keyframe is rendered first.
|
|
@@ -430,21 +536,6 @@ still.
|
|
|
430
536
|
- No CSS animation on slides beyond `.reveal`; the player animates the slide
|
|
431
537
|
change.
|
|
432
538
|
|
|
433
|
-
**Review before release (required).** After `check` passes and the deck is
|
|
434
|
-
imported, run `hyperdecks review <slug> --script decks/<slug>/script.md`
|
|
435
|
-
(`review_deck`, then poll `check_review_job`). Act on every finding:
|
|
436
|
-
|
|
437
|
-
- `fix`: change the slide or the script, re-import (or `release --qa`), and
|
|
438
|
-
review again until no `fix` is left. Do not release with a `fix` open.
|
|
439
|
-
- `consider`: apply it when it serves the slide's one idea; otherwise note in
|
|
440
|
-
your reply to the user why you left it.
|
|
441
|
-
- A finding you disagree with (a deliberate choice, a misread screenshot):
|
|
442
|
-
say so to the user with the reason instead of silently ignoring it.
|
|
443
|
-
|
|
444
|
-
If review cannot run (no credits, the service is down), say so to the user and
|
|
445
|
-
do the step 6 screenshot pass yourself as the reviewer: the same desktop and
|
|
446
|
-
phone screenshots, the same `fix` bar.
|
|
447
|
-
|
|
448
539
|
<!-- topic: script -->
|
|
449
540
|
### 3. Write slides and script
|
|
450
541
|
|
|
@@ -498,7 +589,7 @@ phone screenshots, the same `fix` bar.
|
|
|
498
589
|
| `[mood: excited]` / `[emote: name]` | the head's mood or emote |
|
|
499
590
|
| `[reveal: #id]` | adds `is-revealed` to `#id` (reset when the slide is left or replays); the live head glances at it unless a `[look]`/`[go]` is beside it |
|
|
500
591
|
| `[pause: 0.5]` | a beat of silence (≤ 5 s) |
|
|
501
|
-
| `[play
|
|
592
|
+
| `[play]` / `[pause]` (or `[play: .sel]`) | play or pause every video on the slide (or the matches); may open a line, firing as its first word starts. Muted loops play on their own, so this is for `data-hd-manual` clips |
|
|
502
593
|
| `[go: #step-2]` / `[go: slot]` | fly beside that item, grow it, fade the other items / back to the slot |
|
|
503
594
|
| `[look: h2]` / `[look #card]` | look at the title / the card, then back |
|
|
504
595
|
| `[look viewer]` / `[look away]` | look back at the viewer / glance aside |
|
|
@@ -513,6 +604,10 @@ phone screenshots, the same `fix` bar.
|
|
|
513
604
|
- No `voice:` in the front matter means the user's first ready voice. If
|
|
514
605
|
`hyperdecks voice list` shows none, ask the user to record one and run
|
|
515
606
|
`voice clone` (it asks them for consent; never clone anyone else's voice).
|
|
607
|
+
- Names speech-to-text spells another way fail narrate's transcript check
|
|
608
|
+
(step 4). Map what it heard to what you wrote with a `normalize:` entry in
|
|
609
|
+
the front matter, `heard=written`, comma-separated: a clip that says
|
|
610
|
+
"Minimoji" but is transcribed as "Moji" needs `normalize: moji=minimoji`.
|
|
516
611
|
|
|
517
612
|
### 4. Narrate and read the guard
|
|
518
613
|
|
|
@@ -529,16 +624,24 @@ reword the sentence (spell numbers and names the way they are said, or add a
|
|
|
529
624
|
### 5. Check
|
|
530
625
|
|
|
531
626
|
```bash
|
|
532
|
-
hyperdecks check decks/<slug> [--forbid "Other Co,codename"] [--layout]
|
|
627
|
+
hyperdecks check decks/<slug> [--forbid "Other Co,codename"] [--layout] [--strict] [--fix]
|
|
533
628
|
```
|
|
534
629
|
|
|
630
|
+
`--strict` fails every slide with no `data-note`, as a pitch deck does.
|
|
631
|
+
`--fix` writes what the tools can before checking: today, a missing or stale
|
|
632
|
+
loading state (`<style data-hd-loading>`).
|
|
633
|
+
|
|
535
634
|
`--layout` lays each slide out in Chromium (needs `hyperdecks preview setup`).
|
|
536
635
|
At the stage size it warns, without failing, when a slide's presenter would
|
|
537
636
|
overlap its title. On a 390×844 phone (the share player's 374×736 slide card)
|
|
538
637
|
it fails any slide whose content overflows: the player never scrolls a slide.
|
|
638
|
+
`release` (local or `--hosted`) always runs this layout check and refuses an
|
|
639
|
+
overflowing slide; `check <slug> --hosted --layout` measures a hosted draft.
|
|
539
640
|
|
|
540
641
|
`--forbid` lists phrases that must not reach this audience (another customer,
|
|
541
|
-
an internal codename).
|
|
642
|
+
an internal codename). It matches the page's text, alt text, titles and
|
|
643
|
+
script.md, not URLs in attributes, so forbidding your own brand still passes
|
|
644
|
+
with the player's `decks.hypersocial.ai` script tag. Messages and what to do:
|
|
542
645
|
|
|
543
646
|
| Message starts with | Fix |
|
|
544
647
|
|---|---|
|
|
@@ -557,10 +660,11 @@ an internal codename). Messages and what to do:
|
|
|
557
660
|
| ⚠ `<file> builds file paths at runtime` | list each file as a quoted path (an object or array, in a script or a `.json` it fetches) and pick from it |
|
|
558
661
|
| ⚠ `<file> loads files from computed values` | fine when every file it can load is also written out as a quoted path; otherwise list them so release ships them |
|
|
559
662
|
| `player script must be` / `no player script` | restore the player/v1 script tag |
|
|
560
|
-
| `the player script has no data-manifest` | run `narrate` |
|
|
561
|
-
| `no
|
|
663
|
+
| `the player script has no data-manifest` | run `narrate`; or, for a deck with no narration, add `data-silent` to the player script |
|
|
664
|
+
| ⚠ `no narration (silent deck)` | fine for a deck meant to be silent; remove `data-silent` and narrate to give it a voice |
|
|
665
|
+
| `no loading state` | `check --fix` writes it (a hosted draft gets it on its next write of index.html); set `--hd-ground` / `--hd-ground-ink` to the first slide's colours in `:root`, never inside the block (it is replaced from the template) |
|
|
562
666
|
| `forbidden phrase` | remove it from the page, script or narration |
|
|
563
|
-
| `slide <id>: needs a data-note` | add the source |
|
|
667
|
+
| `slide <id>: needs a data-note` / ⚠ `no data-note` | add the source (required on a `data-kind="pitch"` deck and with `--strict`) |
|
|
564
668
|
| `slide <id>: title … needs a data-title-mobile` | add a shorter title |
|
|
565
669
|
| `slide <id>: the hero's art` / `the product screenshot` / `the chart … is still the layout's placeholder` | make the real file (`gen-image`, `screenshot <url>`, `chart <spec.json>`) and swap the element for the `<img>` the message names; or delete the hero's `<div class="art">` for no art |
|
|
566
670
|
| `… presenter style "…" is not one of` | use stage, bubble, glass or corner |
|
|
@@ -574,6 +678,21 @@ an internal codename). Messages and what to do:
|
|
|
574
678
|
| `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) |
|
|
575
679
|
| ⚠ `slide <id>: the … presenter would overlap the title` | move that slide's `.narration-slot`, pad the text away from it, or use `corner` |
|
|
576
680
|
|
|
681
|
+
**Review before release (required).** After `check` passes and the deck is
|
|
682
|
+
imported, run `hyperdecks review <slug> --script decks/<slug>/script.md`
|
|
683
|
+
(`review_deck`, then poll `check_review_job`). Act on every finding:
|
|
684
|
+
|
|
685
|
+
- `fix`: change the slide or the script, re-import (or `release --qa`), and
|
|
686
|
+
review again until no `fix` is left. Do not release with a `fix` open.
|
|
687
|
+
- `consider`: apply it when it serves the slide's one idea; otherwise note in
|
|
688
|
+
your reply to the user why you left it.
|
|
689
|
+
- A finding you disagree with (a deliberate choice, a misread screenshot):
|
|
690
|
+
say so to the user with the reason instead of silently ignoring it.
|
|
691
|
+
|
|
692
|
+
If review cannot run (no credits, the service is down), say so to the user and
|
|
693
|
+
do the step 6 screenshot pass yourself as the reviewer: the same desktop and
|
|
694
|
+
phone screenshots, the same `fix` bar.
|
|
695
|
+
|
|
577
696
|
### 6. Look at it, at both widths
|
|
578
697
|
|
|
579
698
|
To watch it as it will really play, run `hyperdecks play decks/<slug>`. It plays
|
|
@@ -590,7 +709,7 @@ and screenshot it with Playwright at 1440×900 and 390×844, every slide
|
|
|
590
709
|
(`#scene=<id>`). Look at both: nothing clipped, the presenter and its captions
|
|
591
710
|
clear of the text, the mobile titles in use. Fix and repeat until both read well.
|
|
592
711
|
|
|
593
|
-
Then the required review (see **
|
|
712
|
+
Then the required review (see **5. Check**, *Review before release*): once the
|
|
594
713
|
deck is imported, `hyperdecks review <slug> --script decks/<slug>/script.md`
|
|
595
714
|
(`review_deck`, then `check_review_job`) has GPT-6 Luna look at every slide of
|
|
596
715
|
the deck's QA link on desktop and phone and list each problem as `fix` or
|
|
@@ -4,7 +4,7 @@ You are building a narrated deck through the Hyperdecks connector: no terminal,
|
|
|
4
4
|
no git, no folder. The deck's files (`index.html`, `script.md`, `evidence.md`)
|
|
5
5
|
live on Hyperdecks as a draft; you read and edit them with tools, and nothing
|
|
6
6
|
reaches a recipient until `release_deck`. Topics: call `deck_guide` with
|
|
7
|
-
`format`, `script`, `design`, `layouts`, `presenter`, `transitions` or `check`
|
|
7
|
+
`format`, `script`, `scripts`, `design`, `layouts`, `presenter`, `transitions` or `check`
|
|
8
8
|
before the step that needs it.
|
|
9
9
|
|
|
10
10
|
## First time here?
|
|
@@ -90,7 +90,9 @@ makes the hello deck; continue from step 5 below.
|
|
|
90
90
|
10. Later: `list_shares` and `deck_stats` (both show the card),
|
|
91
91
|
`activity_overview`; `email_embed { token }` for the HTML that pastes into an
|
|
92
92
|
email as the deck's moving preview (it needs a recipient's link, not a QA
|
|
93
|
-
one); `
|
|
93
|
+
one); `set_deck_view { slug, view: "scroll" | "slides" | null, viewToggle }`
|
|
94
|
+
for how the deck opens and whether viewers may switch (every link, next
|
|
95
|
+
load, no re-release); `set_share_notify` to mute a link's "opened" alerts;
|
|
94
96
|
`set_recipient_archived` to tidy the activity feed; `credits` for the
|
|
95
97
|
balance. `remove_deck` deletes a deck and breaks every link to it: confirm
|
|
96
98
|
with the user first.
|
|
@@ -98,9 +100,16 @@ makes the hello deck; continue from step 5 below.
|
|
|
98
100
|
## Rules that matter
|
|
99
101
|
|
|
100
102
|
- Every slide is `<section class="slide" id="…" data-note="<source of its facts>">`,
|
|
101
|
-
in the same order as the `## <id>` blocks in `script.md`.
|
|
102
|
-
|
|
103
|
-
|
|
103
|
+
in the same order as the `## <id>` blocks in `script.md`. A missing note
|
|
104
|
+
warns; on a pitch deck (`data-kind="pitch"` on the player script) it fails.
|
|
105
|
+
- Keep the player script tag; never write `data-manifest` yourself. The
|
|
106
|
+
`<style data-hd-loading>` block is written for you on each write of index.html.
|
|
107
|
+
- A deck with no narration (a product tour, a silent walkthrough): put
|
|
108
|
+
`data-silent` on the player script and skip narrate_deck; check warns, release
|
|
109
|
+
goes through, and the player shows no Play button or clock.
|
|
110
|
+
- The deck's own scripts run: slide controllers, `[custom: name]` cues,
|
|
111
|
+
pausing the narrator, tappable and interactive slides. `deck_guide
|
|
112
|
+
{ topic: "scripts" }` has the hooks and an example.
|
|
104
113
|
- Edit the draft freely: recipients keep seeing the last release until you call
|
|
105
114
|
`release_deck` again. A release is refused when the check fails, when
|
|
106
115
|
narration is stale (narrate again first), when the draft changed after
|
package/src/check.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// hyperdecks check <slug|deck-dir> [--forbid "<phrase>,<phrase>"] [--max-duration <s>] [--layout] [--json]
|
|
1
|
+
// hyperdecks check <slug|deck-dir> [--forbid "<phrase>,<phrase>"] [--max-duration <s>] [--layout] [--strict] [--fix] [--json]
|
|
2
2
|
//
|
|
3
3
|
// The narrated-deck integrity gate, ported from the Netflix presentation's
|
|
4
4
|
// scripts/check.mjs and its variant builders. Every rule returns its message from
|
|
@@ -8,11 +8,14 @@
|
|
|
8
8
|
// every clip's audio and timeline. `fetch` and `exists` are injectable for tests.
|
|
9
9
|
// The rules themselves live in checkRules.mjs (no Node imports, shared with the
|
|
10
10
|
// hosted check); this file adds the folder I/O and the Chromium measurements.
|
|
11
|
-
import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
|
|
11
|
+
import { existsSync, lstatSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
13
13
|
import { PHONE, PRESENTER_FOOTPRINT, checkSources, overlapWarnings, phoneOverflowErrors, slideStyles, stageSize, MESSAGES } from "./checkRules.mjs";
|
|
14
14
|
|
|
15
|
+
import { withLoadingBlock } from "./loadingBlock.mjs";
|
|
16
|
+
|
|
15
17
|
export * from "./checkRules.mjs";
|
|
18
|
+
export * from "./loadingBlock.mjs";
|
|
16
19
|
|
|
17
20
|
// Lay each slide out in Chromium at the deck's stage size with JavaScript off
|
|
18
21
|
// (no player, no deck scripts), put the style's footprint in its slot, and
|
|
@@ -156,6 +159,27 @@ export function deckFolder(deckDir) {
|
|
|
156
159
|
};
|
|
157
160
|
}
|
|
158
161
|
|
|
162
|
+
// The browser half of check --layout, on a folder holding the deck's index.html
|
|
163
|
+
// (the hosted release measures a draft the same way, from a temp folder). A
|
|
164
|
+
// presenter over the title is a warning, not a failure: only a person can judge
|
|
165
|
+
// it. A slide that overflows the phone card is an error: the player never
|
|
166
|
+
// scrolls a slide. No Chromium is a warning, as each measurement is optional.
|
|
167
|
+
export async function layoutCheck(deckDir, html, manifest = null, { measure = measureLayout, measureFit = measurePhoneFit } = {}) {
|
|
168
|
+
const errors = [], warnings = [];
|
|
169
|
+
const { width: w, height: h } = stageSize(html);
|
|
170
|
+
try {
|
|
171
|
+
warnings.push(...overlapWarnings(await measure(deckDir, slideStyles(html, manifest), { width: w, height: h }), `${w}×${h}`));
|
|
172
|
+
} catch (e) {
|
|
173
|
+
warnings.push(MESSAGES.layoutUnchecked(e.message));
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
errors.push(...phoneOverflowErrors(await measureFit(deckDir)));
|
|
177
|
+
} catch (e) {
|
|
178
|
+
warnings.push(MESSAGES.layoutUnchecked(e.message));
|
|
179
|
+
}
|
|
180
|
+
return { errors, warnings: [...new Set(warnings)] };
|
|
181
|
+
}
|
|
182
|
+
|
|
159
183
|
// Run every rule on a deck folder. Returns { deckDir, slug, slides, totalDuration, errors }.
|
|
160
184
|
export async function checkDeck(dirArg, opts = {}, { fetch: fetchImpl = globalThis.fetch, cwd, measure = measureLayout, measureFit = measurePhoneFit } = {}) {
|
|
161
185
|
const deckDir = resolveDeckDir(dirArg, cwd);
|
|
@@ -164,22 +188,11 @@ export async function checkDeck(dirArg, opts = {}, { fetch: fetchImpl = globalTh
|
|
|
164
188
|
const script = existsSync(scriptPath) ? readFileSync(scriptPath, "utf8") : "";
|
|
165
189
|
const { manifest, ...result } = await checkSources({ html, script }, { ...opts, hosted: false, exists: deckFileResolver(deckDir), ...deckFolder(deckDir) }, { fetch: fetchImpl });
|
|
166
190
|
const { errors, warnings } = result;
|
|
167
|
-
//
|
|
168
|
-
// judge it. A slide that overflows the phone card is an error. Both need a
|
|
169
|
-
// browser, so they run only with --layout.
|
|
191
|
+
// Both need a browser, so they run only with --layout (release always asks).
|
|
170
192
|
if (opts.layout) {
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
} catch (e) {
|
|
175
|
-
warnings.push(MESSAGES.layoutUnchecked(e.message));
|
|
176
|
-
}
|
|
177
|
-
// A slide that scrolls on a phone fails: the player never scrolls a slide.
|
|
178
|
-
try {
|
|
179
|
-
errors.push(...phoneOverflowErrors(await measureFit(deckDir)));
|
|
180
|
-
} catch (e) {
|
|
181
|
-
warnings.push(MESSAGES.layoutUnchecked(e.message));
|
|
182
|
-
}
|
|
193
|
+
const layout = await layoutCheck(deckDir, html, manifest, { measure, measureFit });
|
|
194
|
+
errors.push(...layout.errors);
|
|
195
|
+
warnings.push(...layout.warnings);
|
|
183
196
|
}
|
|
184
197
|
return {
|
|
185
198
|
deckDir,
|
|
@@ -190,8 +203,21 @@ export async function checkDeck(dirArg, opts = {}, { fetch: fetchImpl = globalTh
|
|
|
190
203
|
};
|
|
191
204
|
}
|
|
192
205
|
|
|
206
|
+
// --fix: what the tools can write for the deck, before the check reads it. Today
|
|
207
|
+
// that is the loading state (a missing or stale <style data-hd-loading>).
|
|
208
|
+
export function fixDeck(dirArg, { cwd } = {}) {
|
|
209
|
+
const deckDir = resolveDeckDir(dirArg, cwd);
|
|
210
|
+
const path = join(deckDir, "index.html");
|
|
211
|
+
const { html, fixed, skipped } = withLoadingBlock(readFileSync(path, "utf8"));
|
|
212
|
+
if (fixed) writeFileSync(path, html);
|
|
213
|
+
if (skipped) return [`index.html: not fixed: ${skipped}`];
|
|
214
|
+
return fixed ? [`index.html: ${fixed} the loading state (<style data-hd-loading>) from the template`] : [];
|
|
215
|
+
}
|
|
216
|
+
|
|
193
217
|
export async function check(pos, opts = {}, { log = (line) => console.log(line), ...io } = {}) {
|
|
194
|
-
const
|
|
218
|
+
const fixed = opts.fix ? fixDeck(pos[0], io) : [];
|
|
219
|
+
if (!opts.json) for (const f of fixed) log(f.includes(": not fixed:") ? `⚠ ${f}` : `✓ fixed ${f}`);
|
|
220
|
+
const result = { ...(await checkDeck(pos[0], opts, io)), ...(opts.fix ? { fixed } : {}) };
|
|
195
221
|
if (!opts.json) {
|
|
196
222
|
log(`deck "${result.slug}": ${result.slides} slides${result.totalDuration != null ? `, ${result.totalDuration}s narrated` : ""}`);
|
|
197
223
|
if (result.errors.length) {
|
package/src/checkRules.mjs
CHANGED
|
@@ -12,6 +12,8 @@ export const CAPTION_MIN_CHARS = 4;
|
|
|
12
12
|
export const CAPTION_MAX_CHARS = 85;
|
|
13
13
|
// Same names as PRESENTER_STYLES in lib/deckManifest.ts (the published CLI cannot import it).
|
|
14
14
|
export const PRESENTER_STYLES = ["stage", "bubble", "glass", "corner"];
|
|
15
|
+
// Same steps as PRESENTER_SIZES in packages/player/presenter-moves.js.
|
|
16
|
+
export const PRESENTER_SIZES = ["s", "m", "l", "xl"];
|
|
15
17
|
// Same names as TRANSITIONS in lib/deckManifest.ts and packages/player/transitions.js.
|
|
16
18
|
export const TRANSITIONS = ["drift", "cut", "push", "wipe", "zoom", "morph", "fly"];
|
|
17
19
|
export const MAX_TRANSITION_KINDS = 2;
|
|
@@ -19,11 +21,14 @@ export const MAX_TRANSITION_KINDS = 2;
|
|
|
19
21
|
export const MESSAGES = {
|
|
20
22
|
noPlayer: () => `no player script: add <script type="module" src="${PLAYER_V1}" data-manifest="…">`,
|
|
21
23
|
player: (src) => `player script must be ${PLAYER_V1} (got ${src})`,
|
|
22
|
-
noManifest: () => "the player script has no data-manifest: run `hyperdecks narrate <deck-dir>`, which sets it",
|
|
23
|
-
noManifestHosted: () => "the player script has no data-manifest: call narrate_deck (no script) and wait for check_narration_job; it sets it",
|
|
24
|
+
noManifest: () => "the player script has no data-manifest: run `hyperdecks narrate <deck-dir>`, which sets it (or, for a deck with no narration, add data-silent to the player script)",
|
|
25
|
+
noManifestHosted: () => "the player script has no data-manifest: call narrate_deck (no script) and wait for check_narration_job; it sets it (or, for a deck with no narration, add data-silent to the player script)",
|
|
26
|
+
// Warnings: a deck that declared it has no narration.
|
|
27
|
+
silent: () => "no narration (silent deck): data-silent on the player script, so it plays with no voice, Play button or clock; to narrate it, remove data-silent and run `hyperdecks narrate`",
|
|
28
|
+
silentHosted: () => "no narration (silent deck): data-silent on the player script, so it plays with no voice, Play button or clock; to narrate it, remove data-silent and call narrate_deck",
|
|
24
29
|
manifestUnavailable: (url, why) => `manifest ${url} could not be read (${why})`,
|
|
25
30
|
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
|
|
31
|
+
noLoading: () => "no loading state: run `hyperdecks check <deck> --fix` (`check <slug> --hosted --fix`; over the MCP, write index.html again with write_deck_source), which needs a <head>…</head> and the player script; or copy the <style data-hd-loading> block and the lines above it from the narrated template into <head>; set --hd-ground to the first slide's colour",
|
|
27
32
|
clipField: (id, field) => `clip ${id}: ${field}`,
|
|
28
33
|
noCaptions: (id) => `clip ${id}: timeline has no captions`,
|
|
29
34
|
order: (slides, clips) => `slide ids (${slides.join(", ")}) must equal the manifest clip ids (${clips.join(", ")}), in order`,
|
|
@@ -42,11 +47,14 @@ export const MESSAGES = {
|
|
|
42
47
|
scriptComputed: (from, calls) => `${from} loads files from computed values (${calls.join(", ")}); release only ships files the deck names literally, so make sure each file it can load is written out as a quoted path in index.html, a script or a .json file the deck loads`,
|
|
43
48
|
forbidden: (phrase, where) => `forbidden phrase "${phrase}" appears in ${where}`,
|
|
44
49
|
note: (id) => `slide ${id}: needs a data-note naming the source of its facts`,
|
|
50
|
+
// The same rule as a warning: required only on a pitch deck (data-kind="pitch") or with check --strict.
|
|
51
|
+
noteSoft: (id) => `slide ${id}: no data-note naming the source of its facts (required on a pitch deck, data-kind="pitch", and by check --strict)`,
|
|
45
52
|
mobileTitle: (id, title) => `slide ${id}: title "${title}" is over ${MOBILE_TITLE_CHARS} characters and needs a data-title-mobile`,
|
|
46
53
|
slideId: (n) => `slide ${n} has no id`,
|
|
47
54
|
selector: (line, cue, selector, id) => `script.md line ${line}: [${cue}: ${selector}] matches nothing in slide ${id}`,
|
|
48
55
|
gestureColon: (line, id) => `script.md line ${line}: [gesture ${id}] needs a colon: [gesture: ${id}]`,
|
|
49
56
|
gesture: (line, id) => `script.md line ${line}: [gesture: ${id}] is not one of the head's gestures: ${GESTURES.join(", ")}`,
|
|
57
|
+
presenterSize: (where, value) => `${where}: data-presenter-size "${value}" is not one of ${PRESENTER_SIZES.join(", ")}; the presenter keeps its default size`,
|
|
50
58
|
presenterStyle: (where, value) => `${where}: presenter style "${value}" is not one of ${PRESENTER_STYLES.join(", ")}`,
|
|
51
59
|
transition: (where, value) => `${where}: transition "${value}" is not one of ${TRANSITIONS.join(", ")} (zoom takes a selector: "zoom #row")`,
|
|
52
60
|
zoomTarget: (id, target, prev) => `slide ${id}: zoom target "${target}" matches nothing on the slide before it (${prev})`,
|
|
@@ -71,11 +79,14 @@ export const PHONE = { width: 390, height: 844, card: { width: 374, height: 736
|
|
|
71
79
|
|
|
72
80
|
const uncommented = (html) => html.replace(/<!--[\s\S]*?-->/g, "");
|
|
73
81
|
const attr = (tag, name) => {
|
|
74
|
-
const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`).exec(tag);
|
|
75
|
-
return m ? (m[1] ?? m[2]) : null;
|
|
82
|
+
const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'=<>\`]+))`).exec(tag);
|
|
83
|
+
return m ? (m[1] ?? m[2] ?? m[3]) : null;
|
|
76
84
|
};
|
|
77
85
|
const round = (n) => Math.round(n * 100) / 100;
|
|
78
86
|
const decode = (s) => s.replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'|'/g, "'");
|
|
87
|
+
// Blank every attribute value that is an absolute or protocol-relative URL
|
|
88
|
+
// (src, href, data-manifest, …); the audience never reads those.
|
|
89
|
+
const withoutUrlAttrs = (html) => html.replace(/(\s[\w:.-]+\s*=\s*)(?:"(?:https?:)?\/\/[^"]*"|'(?:https?:)?\/\/[^']*')/gi, '$1""');
|
|
79
90
|
const textOf = (html) => decode(html.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
|
|
80
91
|
|
|
81
92
|
// Comments blanked but newlines kept, so line numbers still match the file.
|
|
@@ -100,6 +111,15 @@ export function presenterStyleErrors(html, script = "") {
|
|
|
100
111
|
return errs;
|
|
101
112
|
}
|
|
102
113
|
|
|
114
|
+
// Unknown presenter sizes (data-presenter-size on a slide or the player tag), with
|
|
115
|
+
// their line: a warning, since the player falls back to the default size.
|
|
116
|
+
export function presenterSizeWarnings(html) {
|
|
117
|
+
const page = blankComments(html);
|
|
118
|
+
return [...page.matchAll(/\sdata-presenter-size\s*=\s*(?:"([^"]*)"|'([^']*)')/g)]
|
|
119
|
+
.filter((m) => !PRESENTER_SIZES.includes((m[1] ?? m[2]).trim()))
|
|
120
|
+
.map((m) => MESSAGES.presenterSize(`index.html line ${lineAt(page, m.index)}`, m[1] ?? m[2]));
|
|
121
|
+
}
|
|
122
|
+
|
|
103
123
|
// "zoom #dash-row" → { name: "zoom", target: "#dash-row" }; null when it is not a transition.
|
|
104
124
|
export function parseTransition(value) {
|
|
105
125
|
if (typeof value !== "string") return null;
|
|
@@ -279,11 +299,26 @@ export function slides(html) {
|
|
|
279
299
|
});
|
|
280
300
|
}
|
|
281
301
|
|
|
302
|
+
// The player tag also carries the deck's declarations: data-silent (no
|
|
303
|
+
// narration, on purpose) and data-kind="pitch" (every slide sources its facts).
|
|
304
|
+
// The check reads what the deck says, never guesses from its content.
|
|
305
|
+
const hasAttr = (tag, name) => new RegExp(`\\s${name}(?=[\\s=>/])`).test(tag);
|
|
282
306
|
export function playerScript(html) {
|
|
283
307
|
const tag = [...uncommented(html).matchAll(/<script\b[^>]*>/g)].map((m) => m[0]).find((t) => /\/player\/v\d+\/player\.js"/.test(t));
|
|
284
|
-
|
|
308
|
+
if (!tag) return null;
|
|
309
|
+
return {
|
|
310
|
+
src: attr(tag, "src"),
|
|
311
|
+
manifest: attr(tag, "data-manifest") || null,
|
|
312
|
+
silent: hasAttr(tag, "data-silent") && !/^(false|0|no)$/i.test(attr(tag, "data-silent") ?? ""),
|
|
313
|
+
kind: attr(tag, "data-kind")?.trim().toLowerCase() || null,
|
|
314
|
+
};
|
|
285
315
|
}
|
|
286
316
|
|
|
317
|
+
// Source notes are required on a pitch deck (its claims) and under check
|
|
318
|
+
// --strict; elsewhere a missing one is a warning.
|
|
319
|
+
export const notesRequired = (html, { strict = false } = {}) => !!strict || playerScript(html)?.kind === "pitch";
|
|
320
|
+
export const slidesWithoutNotes = (html) => slides(html).map((s, i) => (attr(s.tag, "data-note")?.trim() ? null : s.id || `${i + 1}`)).filter(Boolean);
|
|
321
|
+
|
|
287
322
|
// Relative references: src, href, poster, data-presenter-image and data-alpha-stacked (either quote), srcset candidates and
|
|
288
323
|
// CSS url(…) in style attributes and <style> blocks; everything that is not a URL
|
|
289
324
|
// scheme, protocol-relative or a fragment. Query strings and fragments dropped.
|
|
@@ -504,7 +539,7 @@ export function placeholderErrors(html, { hosted = false } = {}) {
|
|
|
504
539
|
|
|
505
540
|
// Everything checkable from the deck's own files. `exists(ref)` answers whether a
|
|
506
541
|
// local reference resolves inside the deck folder: true, false or "outside".
|
|
507
|
-
export function htmlErrors(html, { exists = () => true, forbid = [], script = "", hosted = false } = {}) {
|
|
542
|
+
export function htmlErrors(html, { exists = () => true, forbid = [], script = "", hosted = false, strict = false } = {}) {
|
|
508
543
|
const errs = [...presenterStyleErrors(html, script), ...transitionErrors(html, script), ...cueSelectorErrors(html, script), ...cueGestureErrors(script), ...placeholderErrors(html, { hosted })];
|
|
509
544
|
const player = playerScript(html);
|
|
510
545
|
if (!player) errs.push(MESSAGES.noPlayer());
|
|
@@ -515,10 +550,11 @@ export function htmlErrors(html, { exists = () => true, forbid = [], script = ""
|
|
|
515
550
|
if (player && !loading?.[1].includes("hd-fail-open")) errs.push(MESSAGES.noLoading());
|
|
516
551
|
const all = slides(html);
|
|
517
552
|
if (!all.length) errs.push(MESSAGES.noSlides());
|
|
553
|
+
const notes = notesRequired(html, { strict });
|
|
518
554
|
all.forEach((s, i) => {
|
|
519
555
|
const id = s.id || `${i + 1}`;
|
|
520
556
|
if (!s.id) errs.push(MESSAGES.slideId(i + 1));
|
|
521
|
-
if (!attr(s.tag, "data-note")?.trim()) errs.push(MESSAGES.note(id));
|
|
557
|
+
if (notes && !attr(s.tag, "data-note")?.trim()) errs.push(MESSAGES.note(id));
|
|
522
558
|
if (s.title.length > MOBILE_TITLE_CHARS && !attr(s.titleTag, "data-title-mobile") && !attr(s.tag, "data-title-mobile")) {
|
|
523
559
|
errs.push(MESSAGES.mobileTitle(id, s.title));
|
|
524
560
|
}
|
|
@@ -530,9 +566,13 @@ export function htmlErrors(html, { exists = () => true, forbid = [], script = ""
|
|
|
530
566
|
else if (!found) errs.push(MESSAGES.localRef(ref));
|
|
531
567
|
}
|
|
532
568
|
const page = textOf(uncommented(html).replace(/<(script|style)\b[\s\S]*?<\/\1>/g, " ")).toLowerCase();
|
|
569
|
+
// The raw HTML too (alt text, <title>, meta content, inline script), less
|
|
570
|
+
// attribute values that are URLs: the player's own src (PLAYER_V1) names
|
|
571
|
+
// decks.hypersocial.ai, so forbidding the brand would otherwise always fail.
|
|
572
|
+
const raw = withoutUrlAttrs(uncommented(html)).toLowerCase();
|
|
533
573
|
for (const phrase of phraseList(forbid)) {
|
|
534
574
|
const p = phrase.toLowerCase();
|
|
535
|
-
if (page.includes(p) ||
|
|
575
|
+
if (page.includes(p) || raw.includes(p)) errs.push(MESSAGES.forbidden(phrase, "index.html"));
|
|
536
576
|
if (script.toLowerCase().includes(p)) errs.push(MESSAGES.forbidden(phrase, "script.md"));
|
|
537
577
|
}
|
|
538
578
|
return errs;
|
|
@@ -708,7 +748,9 @@ const isHostedAsset = (url) => {
|
|
|
708
748
|
// resolves (true, false or "outside"); `hosted: true` means there is no folder,
|
|
709
749
|
// so every local reference is an error naming upload_deck_asset, a hotlinked
|
|
710
750
|
// image is a warning, and the layout rules (which need Chromium) are reported as
|
|
711
|
-
// not checked.
|
|
751
|
+
// not checked. `strict: true` makes every slide's data-note an error, as on a
|
|
752
|
+
// pitch deck. Returns { slides, totalDuration, manifestUrl, manifest, silent,
|
|
753
|
+
// passed, errors, warnings }; `silent` is a deck with no manifest that declared data-silent.
|
|
712
754
|
export async function checkSources({ html, script = "" }, opts = {}, { fetch: fetchImpl = globalThis.fetch } = {}) {
|
|
713
755
|
const forbid = phraseList(opts.forbid);
|
|
714
756
|
const flagCap = opts["max-duration"] ?? opts.maxDuration;
|
|
@@ -717,14 +759,22 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
|
|
|
717
759
|
throw new Error(flagCap !== undefined ? "--max-duration needs a number of seconds" : "maxTotalSeconds in script.md must be a number of seconds");
|
|
718
760
|
}
|
|
719
761
|
const exists = opts.hosted ? () => "hosted" : (opts.exists ?? (() => true));
|
|
720
|
-
const
|
|
762
|
+
const strict = !!opts.strict;
|
|
763
|
+
const errors = htmlErrors(html, { exists, forbid, script, hosted: !!opts.hosted, strict });
|
|
764
|
+
const warnings = [];
|
|
765
|
+
if (!notesRequired(html, { strict })) for (const id of slidesWithoutNotes(html)) warnings.push(MESSAGES.noteSoft(id));
|
|
721
766
|
// Without a folder (hosted, or a caller that passes no reader) only the inline scripts are read.
|
|
722
767
|
const walked = deckFiles(html, opts.hosted ? {} : { pathExists: opts.pathExists, read: opts.read, isDir: opts.isDir });
|
|
723
768
|
for (const m of walked.missing) errors.push(MESSAGES.scriptRef(m.ref, m.from));
|
|
724
769
|
const ids = slides(html).map((s) => s.id);
|
|
725
770
|
const player = playerScript(html);
|
|
726
771
|
let manifest = null;
|
|
727
|
-
|
|
772
|
+
// A deck that declares itself silent plays without narration; without the
|
|
773
|
+
// declaration a missing manifest is still a forgotten narrate. Slides with
|
|
774
|
+
// data-clip audio are narrated (the player plays them), so not silent.
|
|
775
|
+
const silent = !!player && !player.manifest && player.silent && !slides(html).some((s) => attr(s.tag, "data-clip"));
|
|
776
|
+
if (silent) warnings.push(opts.hosted ? MESSAGES.silentHosted() : MESSAGES.silent());
|
|
777
|
+
else if (player && !player.manifest) errors.push(opts.hosted ? MESSAGES.noManifestHosted() : MESSAGES.noManifest());
|
|
728
778
|
if (player?.manifest) {
|
|
729
779
|
const got = await fetchCheck(fetchImpl, player.manifest, JSON_TYPE);
|
|
730
780
|
if (got.why) errors.push(MESSAGES.manifestUnavailable(player.manifest, got.why));
|
|
@@ -775,7 +825,7 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
|
|
|
775
825
|
errors.push(...timelineErrors(clip, timeline));
|
|
776
826
|
}
|
|
777
827
|
}
|
|
778
|
-
|
|
828
|
+
warnings.push(...transitionWarnings(html, script, manifest), ...presenterSizeWarnings(html));
|
|
779
829
|
for (const t of walked.templates) warnings.push(MESSAGES.scriptTemplate(t.from, t.samples));
|
|
780
830
|
for (const c of walked.computed) warnings.push(MESSAGES.scriptComputed(c.from, c.calls));
|
|
781
831
|
if (opts.hosted) {
|
|
@@ -787,6 +837,7 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
|
|
|
787
837
|
totalDuration: manifest ? Number(manifest.totalDuration) : null,
|
|
788
838
|
manifestUrl: player?.manifest ?? null,
|
|
789
839
|
manifest,
|
|
840
|
+
silent,
|
|
790
841
|
passed: errors.length === 0,
|
|
791
842
|
errors,
|
|
792
843
|
warnings: [...new Set(warnings)],
|
package/src/commands.mjs
CHANGED
|
@@ -58,6 +58,7 @@ async function deckStats(pos, opts) {
|
|
|
58
58
|
console.log(` unique viewers: ${s.viewers}`);
|
|
59
59
|
console.log(` avg engaged: ${fmtMs(s.avgMs)}`);
|
|
60
60
|
console.log(` total engaged: ${fmtMs(s.totalMs)}`);
|
|
61
|
+
if (s.view !== undefined) console.log(` plays as: ${s.view ?? "as its HTML says"}${s.viewToggle === false ? " (viewer toggle off)" : ""}${s.usesPlayer === false ? " (won't apply: the deck doesn't load player/v1)" : ""}`);
|
|
61
62
|
if (s.recent?.length) {
|
|
62
63
|
console.log(" recent:");
|
|
63
64
|
for (const r of s.recent) {
|
|
@@ -153,6 +154,31 @@ async function deckForward(pos, opts) {
|
|
|
153
154
|
console.log(`OK forwarding ${mode} for deck ${slug} (links with their own setting keep it)`);
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
const VIEW_USAGE = "usage: deck view <slug> [slides|scroll|default] [--toggle on|off]";
|
|
158
|
+
const VIEWS = { slides: "slides", scroll: "scroll", default: null };
|
|
159
|
+
async function deckView(pos, opts) {
|
|
160
|
+
const [slug, mode] = pos;
|
|
161
|
+
const toggle = opts.toggle;
|
|
162
|
+
if (!slug || (mode === undefined && !Object.hasOwn(opts, "toggle"))) throw new Error(VIEW_USAGE);
|
|
163
|
+
if (mode !== undefined && !Object.hasOwn(VIEWS, mode)) throw new Error(VIEW_USAGE);
|
|
164
|
+
// A trailing --toggle with no value is a mistake, not "leave it".
|
|
165
|
+
if (Object.hasOwn(opts, "toggle") && toggle !== "on" && toggle !== "off") throw new Error(VIEW_USAGE);
|
|
166
|
+
const out = await ops.setDeckView(opts, slug, {
|
|
167
|
+
view: mode === undefined ? undefined : VIEWS[mode],
|
|
168
|
+
viewToggle: toggle === undefined ? undefined : toggle === "on",
|
|
169
|
+
});
|
|
170
|
+
if (opts.json) return void console.log(JSON.stringify(out, null, 2));
|
|
171
|
+
const said = [];
|
|
172
|
+
if (mode !== undefined) said.push(mode === "default" ? "opens as its HTML says" : `opens as ${mode}`);
|
|
173
|
+
if (toggle !== undefined) said.push(`viewer toggle ${toggle}`);
|
|
174
|
+
if (out.applies === false) {
|
|
175
|
+
console.log(`OK deck ${slug} ${said.join(", ")} (saved)`);
|
|
176
|
+
console.warn(`warning: ${out.warning}`);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
console.log(`OK deck ${slug} ${said.join(", ")} (every link, on its next load)`);
|
|
180
|
+
}
|
|
181
|
+
|
|
156
182
|
function shareUrl(pos, opts) {
|
|
157
183
|
const [token] = pos;
|
|
158
184
|
if (!token) throw new Error("usage: share url <token>");
|
|
@@ -264,6 +290,7 @@ async function importDeck(pos, opts) {
|
|
|
264
290
|
console.log(` html: ${(out.bytes / 1024).toFixed(1)} KB`);
|
|
265
291
|
console.log(` deck: ${out.deckUrl}`);
|
|
266
292
|
}
|
|
293
|
+
for (const w of out.warnings ?? []) console.warn(`warning: ${w}`);
|
|
267
294
|
}
|
|
268
295
|
|
|
269
296
|
async function importDesignDeck(pos, opts) {
|
|
@@ -304,23 +331,28 @@ commands:
|
|
|
304
331
|
login [--token <pat>] [--broker <url>] [--dev]
|
|
305
332
|
logout [--dev]
|
|
306
333
|
whoami [--json] [--dev]
|
|
307
|
-
deck list|stats|open|rm|forward
|
|
334
|
+
deck list|stats|open|rm|forward|view
|
|
308
335
|
deck forward on|off <slug>
|
|
309
336
|
whether viewers can forward the deck's links (on by default; a link's own setting wins)
|
|
337
|
+
deck view <slug> [slides|scroll|default] [--toggle on|off]
|
|
338
|
+
how the deck opens in the player (default: as its HTML says, data-view or slides) and
|
|
339
|
+
whether viewers get the slides/scroll toggle (on by default). Every link, next load; no re-release.
|
|
310
340
|
deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style stage|bubble|glass|corner] [--no-brand] [--dir <path>]
|
|
311
341
|
scaffold decks/<slug>/: index.html (8 slides, or one per layout, player/v1), script.md, evidence.md,
|
|
312
342
|
in your saved brand's colours, fonts and logo (--no-brand: the template's look)
|
|
313
343
|
layouts: hero, number, product, quote, compare, timeline, features, chart, team, closer
|
|
314
|
-
check <slug|deck-dir> [--forbid "<phrase>,<phrase>"] [--max-duration <s>] [--layout] [--json]
|
|
344
|
+
check <slug|deck-dir> [--forbid "<phrase>,<phrase>"] [--max-duration <s>] [--layout] [--strict] [--fix] [--json]
|
|
315
345
|
the narrated-deck gate: slides vs manifest, timing, captions, clip URLs,
|
|
316
|
-
local refs, pinned player,
|
|
346
|
+
local refs, pinned player, mobile titles. data-silent on the player script: no narration
|
|
347
|
+
(a warning). Missing data-notes warn, and fail on a data-kind="pitch" deck or with --strict.
|
|
348
|
+
--fix writes what the tools can (a missing or stale loading state) before checking
|
|
317
349
|
deck new|check|release … --hosted
|
|
318
350
|
the same on a hosted draft (no folder, no git: what chat agents use over the remote MCP)
|
|
319
351
|
source pull <slug> [dir] | source push <slug> [dir] [--base-rev N]
|
|
320
352
|
move a hosted draft (index.html, script.md, evidence.md) to a folder and back
|
|
321
353
|
asset add <slug> <file|url> [--name n]
|
|
322
354
|
file an image, video or font under the deck; prints its hosted URL
|
|
323
|
-
guide [workflow|format|script|design|layouts|presenter|transitions|check]
|
|
355
|
+
guide [workflow|format|script|scripts|design|layouts|presenter|transitions|check]
|
|
324
356
|
the skill, one topic at a time (what deck_guide serves)
|
|
325
357
|
play <slug|deck-dir> [--voice <the voice it was narrated in>] [--slug <slug>] [--port <n>] [--no-open] [--json]
|
|
326
358
|
play the folder as it is, committed or not, with its real face and voice, on 127.0.0.1
|
|
@@ -676,9 +708,10 @@ function commandTable(pos, rest, opts) {
|
|
|
676
708
|
},
|
|
677
709
|
check: async () => {
|
|
678
710
|
const { check } = await import("./check.mjs");
|
|
679
|
-
const {
|
|
680
|
-
const result = opts.hosted ? await
|
|
711
|
+
const { hostedCheckCli } = await import("./source.mjs");
|
|
712
|
+
const result = opts.hosted ? await hostedCheckCli(opts, { slug: pos[1], forbid: opts.forbid, maxDuration: opts["max-duration"], strict: opts.strict, fix: opts.fix }) : await check(pos.slice(1), opts);
|
|
681
713
|
if (opts.hosted && !opts.json) {
|
|
714
|
+
for (const f of result.fixed ?? []) console.log(f.includes(": not fixed:") ? `⚠ ${f}` : `✓ fixed ${f}`);
|
|
682
715
|
console.log(`deck "${pos[1]}" rev ${result.rev}: ${result.slides} slides${result.totalDuration != null ? `, ${result.totalDuration}s narrated` : ""}`);
|
|
683
716
|
for (const e of result.errors) console.log(` - ${e}`);
|
|
684
717
|
if (!result.errors.length) console.log("✓ check passed");
|
|
@@ -689,8 +722,8 @@ function commandTable(pos, rest, opts) {
|
|
|
689
722
|
},
|
|
690
723
|
release: async () => {
|
|
691
724
|
if (opts.hosted) {
|
|
692
|
-
const {
|
|
693
|
-
const out = await
|
|
725
|
+
const { hostedReleaseCli } = await import("./source.mjs");
|
|
726
|
+
const out = await hostedReleaseCli(opts, { slug: pos[1], recipients: opts.recipients, qa: opts.qa, title: opts.title, forbid: opts.forbid, captionsOnly: opts["captions-only"] }, opts.json ? { log: (line) => console.error(line) } : {});
|
|
694
727
|
if (opts.json) return void console.log(JSON.stringify(out, null, 2));
|
|
695
728
|
console.log(`✓ released ${out.slug} rev ${out.rev}\n deck: ${out.deckUrl}`);
|
|
696
729
|
const who = presenterLine(out.presenter);
|
|
@@ -849,6 +882,7 @@ function commandTable(pos, rest, opts) {
|
|
|
849
882
|
"deck open": () => deckOpen(rest, opts),
|
|
850
883
|
"deck rm": () => deckRemove(rest, opts),
|
|
851
884
|
"deck forward": () => deckForward(rest, opts),
|
|
885
|
+
"deck view": () => deckView(rest, opts),
|
|
852
886
|
"share new": () => shareNew(rest, opts),
|
|
853
887
|
"share list": () => shareList(rest, opts),
|
|
854
888
|
"share on": () => shareToggle(rest, opts, true),
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Whether a deck runs on the Hyperdecks player (player/v1). One rule, shared by
|
|
2
|
+
// Convex (decks.usesPlayer), the share route (owner chip vs fallback pill), the
|
|
3
|
+
// importers and the CLI, so they never disagree. Scroll mode, the viewer
|
|
4
|
+
// slides/scroll toggle and the owner chip all live in player/v1; a deck that
|
|
5
|
+
// runs its own runtime (a legacy copy-and-swap deck.js, a plain HTML export)
|
|
6
|
+
// gets none of them.
|
|
7
|
+
|
|
8
|
+
export const loadsPlayer = (html) => /\/player\/v1\/player\.js\b/.test(String(html ?? ""));
|
|
9
|
+
|
|
10
|
+
const SCRIPT_SRC = /<script\b[^>]*\ssrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))[^>]*>/gi;
|
|
11
|
+
const MAX_SCRIPT_NAME = 120;
|
|
12
|
+
|
|
13
|
+
// The deck's own entry script, for the warning ("it runs deck.js"): its first
|
|
14
|
+
// module script, else its first script with a src. Only the file name, so
|
|
15
|
+
// https://cdn.example/js/deck.js reads as deck.js. Null when
|
|
16
|
+
// the deck loads player/v1, has no script with a src, or its script is an
|
|
17
|
+
// uploaded asset (a storage URL names no file).
|
|
18
|
+
export function ownScript(html) {
|
|
19
|
+
const doc = String(html ?? "").replace(/<!--[\s\S]*?-->/g, "");
|
|
20
|
+
if (loadsPlayer(doc)) return null;
|
|
21
|
+
const tags = [...doc.matchAll(SCRIPT_SRC)].map((m) => ({ tag: m[0], src: (m[1] ?? m[2] ?? m[3] ?? "").trim() })).filter((s) => s.src);
|
|
22
|
+
const pick = tags.find((s) => /\stype\s*=\s*["']?module\b/i.test(s.tag)) ?? tags[0];
|
|
23
|
+
// An imported asset's src is a storage URL (…/api/storage/<uuid>): no name to show.
|
|
24
|
+
if (!pick || /\/api\/storage\//.test(pick.src)) return null;
|
|
25
|
+
const name = pick.src.split(/[?#]/)[0].split("/").filter(Boolean).pop() || pick.src;
|
|
26
|
+
return name.slice(0, MAX_SCRIPT_NAME);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// What Convex stores on the deck row.
|
|
30
|
+
export function deckPlayer(html) {
|
|
31
|
+
if (loadsPlayer(html)) return { usesPlayer: true, ownScript: undefined };
|
|
32
|
+
return { usesPlayer: false, ownScript: ownScript(html) ?? undefined };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const runs = (script) => (script ? ` (it runs its own ${script})` : "");
|
|
36
|
+
|
|
37
|
+
// Import: a warning, never a refusal (plain HTML exports are legitimate decks).
|
|
38
|
+
export const importPlayerWarning = (script) =>
|
|
39
|
+
`deck doesn't load player/v1${runs(script)}: scroll mode, the owner chip and the viewer toggle won't apply`;
|
|
40
|
+
|
|
41
|
+
// set_deck_view / `deck view` on such a deck: the value is saved for a later
|
|
42
|
+
// rebuild on player/v1, but does nothing today.
|
|
43
|
+
export const viewPlayerWarning = (slug, script) =>
|
|
44
|
+
`${slug} doesn't load player/v1${runs(script)}, so slides/scroll and the viewer toggle won't apply. ` +
|
|
45
|
+
"The setting is saved and takes effect once the deck is rebuilt on player/v1 (deck_guide explains the format).";
|
package/src/guide.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { packageText } from "./files.mjs";
|
|
|
8
8
|
import { LAYOUTS, readLayout } from "./layouts.mjs";
|
|
9
9
|
import { fontPairingsMarkdown } from "./brand.mjs";
|
|
10
10
|
|
|
11
|
-
export const GUIDE_TOPICS = ["workflow", "format", "script", "design", "layouts", "presenter", "transitions", "check"];
|
|
11
|
+
export const GUIDE_TOPICS = ["workflow", "format", "script", "scripts", "design", "layouts", "presenter", "transitions", "check"];
|
|
12
12
|
const CLI_NOTE = "_Written for the CLI. On the hosted MCP there is no folder: use the tool names in `deck_guide` (workflow), hosted URLs for every file, and write_deck_source for edits._\n\n";
|
|
13
13
|
|
|
14
14
|
// One <!-- topic: name --> section of SKILL.md: its heading and body, up to the
|
package/src/hosted.mjs
CHANGED
|
@@ -61,10 +61,10 @@ export async function uploadDeckAsset(opts, { slug, name, url, file } = {}) {
|
|
|
61
61
|
return (await client(opts)).action(api.assetsNode.importAssetFromUrl, { slug, name: assetName, url: String(from), ...(file?.mime_type ? { mimeType: String(file.mime_type) } : {}) });
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
export async function hostedCheck(opts, { slug, forbid, maxDuration } = {}) {
|
|
64
|
+
export async function hostedCheck(opts, { slug, forbid, maxDuration, strict } = {}) {
|
|
65
65
|
if (!slug) throw new Error("slug is required");
|
|
66
66
|
const list = typeof forbid === "string" ? forbid.split(",").map((s) => s.trim()).filter(Boolean) : forbid;
|
|
67
|
-
return (await client(opts)).action(api.deckSources.check, { slug, ...(list?.length ? { forbid: list } : {}), ...(maxDuration ? { maxDuration: Number(maxDuration) } : {}) });
|
|
67
|
+
return (await client(opts)).action(api.deckSources.check, { slug, ...(list?.length ? { forbid: list } : {}), ...(maxDuration ? { maxDuration: Number(maxDuration) } : {}), ...(strict ? { strict: true } : {}) });
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
export async function hostedRelease(opts, { slug, recipients, qa, title, forbid, rev, voice, captionsOnly } = {}) {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The loading state every player deck carries in <head> (check's noLoading):
|
|
2
|
+
// the preloads, the frame flag and the <style data-hd-loading> block, exactly as
|
|
3
|
+
// templates/narrated-deck.html and templates/layouts/_deck.html have it
|
|
4
|
+
// (lib/loadingBlock.test.ts keeps the three in step). A JS string, not a read of
|
|
5
|
+
// the template, because Convex (the hosted draft's write) cannot read package files.
|
|
6
|
+
// No Node imports: the CLI and Convex (and so the MCP route's writes) use it.
|
|
7
|
+
export const LOADING_BLOCK = [
|
|
8
|
+
"<!-- Loading state (keep verbatim; `hyperdecks check` requires it). Until the",
|
|
9
|
+
" player marks body[data-hd-ready] the page shows only the slide card, at its",
|
|
10
|
+
" final size, in the deck's --hd-ground (its first slide's colour) on the",
|
|
11
|
+
" page's brand colour, with a quiet loader in --hd-ground-ink; then the",
|
|
12
|
+
" slides fade in. If the player never arrives, the slides show after 10 s",
|
|
13
|
+
" rather than never. Put data-layout=\"overlay\" on <html> for the overlay. -->",
|
|
14
|
+
"<link rel=\"modulepreload\" href=\"https://decks.hypersocial.ai/player/v1/player.js\">",
|
|
15
|
+
"<link rel=\"preload\" as=\"style\" href=\"https://decks.hypersocial.ai/player/v1/player.css\">",
|
|
16
|
+
"<script>if (self !== top || /[?&]thumb\\b/.test(location.search)) document.documentElement.dataset.hdFrame = \"\";</script>",
|
|
17
|
+
"<style data-hd-loading>",
|
|
18
|
+
" body:not([data-hd-ready]) { overflow: hidden; background: var(--hd-frame, var(--hd-ground, var(--hd-accent, #ffda1b))); animation: hd-fail-open 0s 10s forwards; }",
|
|
19
|
+
" body:not([data-hd-ready]) > * { visibility: hidden; animation: hd-fail-open 0s 10s forwards; }",
|
|
20
|
+
" body[data-hd-ready] > * { animation: hd-in .2s ease-out; }",
|
|
21
|
+
" body::before, body::after { content: \"\"; position: fixed; z-index: -1; pointer-events: none; transition: opacity .2s ease-out; }",
|
|
22
|
+
" /* The card as the player draws it (player.css, framed): 16:9, scaled to fit",
|
|
23
|
+
" between the 64px masthead and the 56px dock, 24px in from the sides. */",
|
|
24
|
+
" body::before { inset: 64px 24px 56px; margin: auto; width: min(100vw - 48px, (100dvh - 120px) * 16 / 9); height: min(100dvh - 120px, (100vw - 48px) * 9 / 16); border-radius: calc(min(100vw - 48px, (100dvh - 120px) * 16 / 9) * .01125); background: var(--hd-ground, #fff); box-shadow: 0 0 0 1px color-mix(in srgb, var(--hd-ground-ink, #0b0b0f) 16%, transparent); }",
|
|
25
|
+
" body::after { left: 50%; top: 50%; width: 12px; height: 12px; margin: -6px 0 0 -6px; border-radius: 50%; color: var(--hd-ground-ink, #0b0b0f); background: currentColor; box-shadow: -24px 0 currentColor, 24px 0 currentColor; opacity: 0; animation: hd-dots .7s ease-in-out .6s infinite alternate; }",
|
|
26
|
+
" body[data-hd-ready]::before, body[data-hd-ready]::after { opacity: 0; animation: none; }",
|
|
27
|
+
" /* The overlay (embedded, ?thumb, or data-layout=\"overlay\"): the stage fills the window. */",
|
|
28
|
+
" :is([data-hd-frame], [data-layout=\"overlay\"]) body::before { inset: 0; width: min(100vw, 100dvh * 16 / 9); height: min(100dvh, 100vw * 9 / 16); border-radius: calc(min(100vw, 100dvh * 16 / 9) * .01125); box-shadow: none; }",
|
|
29
|
+
" [data-layout=\"overlay\"] body:not([data-hd-ready]) { background: var(--hd-backdrop, #16161c); }",
|
|
30
|
+
" [data-hd-frame] body:not([data-hd-ready]) { background: transparent; }",
|
|
31
|
+
" [data-hd-frame] body::before { border-radius: 0; }",
|
|
32
|
+
" /* Phones: the card fills the space between a 52px masthead and the dock, 8px in. */",
|
|
33
|
+
" @media (max-width: 650px) {",
|
|
34
|
+
" body::before { inset: 52px 8px calc(56px + env(safe-area-inset-bottom)); width: auto; height: auto; border-radius: 14px; }",
|
|
35
|
+
" :is([data-hd-frame], [data-layout=\"overlay\"]) body::before { inset: 0; width: auto; height: auto; border-radius: 0; }",
|
|
36
|
+
" }",
|
|
37
|
+
" @media (prefers-reduced-motion: reduce) {",
|
|
38
|
+
" body::before, body::after { transition: none; }",
|
|
39
|
+
" body:not([data-hd-ready])::after { animation: none; opacity: .4; }",
|
|
40
|
+
" body[data-hd-ready] > * { animation: none; }",
|
|
41
|
+
" }",
|
|
42
|
+
" @keyframes hd-in { from { opacity: 0; } }",
|
|
43
|
+
" @keyframes hd-dots { from { opacity: .15; } to { opacity: .6; } }",
|
|
44
|
+
" @keyframes hd-fail-open { to { visibility: visible; overflow: auto; } }",
|
|
45
|
+
"</style>",
|
|
46
|
+
].join("\n");
|
|
47
|
+
|
|
48
|
+
const STYLE = /<style\b[^>]*\sdata-hd-loading(?=[\s>=])[^>]*>[\s\S]*?<\/style>/i;
|
|
49
|
+
const HEAD = /<head\b[^>]*>[\s\S]*?<\/head>/i;
|
|
50
|
+
const PLAYER = /<script\b[^>]*\/player\/v\d+\/player\.js"[^>]*>/;
|
|
51
|
+
const LOADING_STYLE = STYLE.exec(LOADING_BLOCK)[0];
|
|
52
|
+
|
|
53
|
+
// The page with its loading state put right: inserted after <title> (or at the
|
|
54
|
+
// top of <head>) when <head> has none, or its <style data-hd-loading> replaced
|
|
55
|
+
// when it differs from the template's. Only a page with the player script, as
|
|
56
|
+
// the check asks only those. Returns { html, fixed: "inserted" | "refreshed" | null,
|
|
57
|
+
// skipped? }: a player page with no <head>…</head> is left alone, with `skipped` saying why.
|
|
58
|
+
export function withLoadingBlock(html) {
|
|
59
|
+
const live = html.replace(/<!--[\s\S]*?-->/g, (c) => " ".repeat(c.length)); // same offsets, comments blanked
|
|
60
|
+
const head = HEAD.exec(live);
|
|
61
|
+
if (!PLAYER.test(live)) return { html, fixed: null };
|
|
62
|
+
if (!head) return { html, fixed: null, skipped: "no <head>…</head> to put the loading state in" };
|
|
63
|
+
const style = STYLE.exec(head[0]);
|
|
64
|
+
if (style) {
|
|
65
|
+
const at = head.index + style.index;
|
|
66
|
+
if (html.slice(at, at + style[0].length) === LOADING_STYLE) return { html, fixed: null };
|
|
67
|
+
return { html: html.slice(0, at) + LOADING_STYLE + html.slice(at + style[0].length), fixed: "refreshed" };
|
|
68
|
+
}
|
|
69
|
+
const title = /<\/title>/i.exec(head[0]);
|
|
70
|
+
const at = head.index + (title ? title.index + title[0].length : /^<head\b[^>]*>/i.exec(head[0])[0].length);
|
|
71
|
+
return { html: `${html.slice(0, at)}\n${LOADING_BLOCK}${html.slice(at)}`, fixed: "inserted" };
|
|
72
|
+
}
|
package/src/ops.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { deckPlayer, importPlayerWarning, viewPlayerWarning } from "./deckPlayer.mjs";
|
|
1
2
|
import { zipSync } from "fflate";
|
|
2
3
|
import { COLOR_KEYS, FONT_PAIRINGS, FONT_ROLES, findPairing, googleFontsUrl } from "./brand.mjs";
|
|
3
4
|
import { MAX_IMPORT_FILE_BYTES, draftArgs, extractFile } from "./brandImport.mjs";
|
|
@@ -26,6 +27,12 @@ export async function listDecks(opts = {}) {
|
|
|
26
27
|
status: d.status,
|
|
27
28
|
ownerEmail: d.ownerEmail ?? null,
|
|
28
29
|
hasHtml: !!(d.html || d.htmlStorageId || d.hasHtml),
|
|
30
|
+
// How it opens (null: as its HTML says) and whether viewers may switch.
|
|
31
|
+
view: d.view ?? null,
|
|
32
|
+
viewToggle: d.viewToggle !== false,
|
|
33
|
+
// false: the deck runs its own player, so view/viewToggle do not apply
|
|
34
|
+
// (null: not known yet, treated as on).
|
|
35
|
+
usesPlayer: d.usesPlayer ?? null,
|
|
29
36
|
}));
|
|
30
37
|
}
|
|
31
38
|
|
|
@@ -466,6 +473,32 @@ export async function setDeckForwarding(opts, slug, allowForward) {
|
|
|
466
473
|
return { slug, allowForward: !!allowForward };
|
|
467
474
|
}
|
|
468
475
|
|
|
476
|
+
// How a deck opens in the player and whether viewers may switch: view is
|
|
477
|
+
// "slides", "scroll", or null to follow the deck's own HTML again; viewToggle
|
|
478
|
+
// true/false. Either may be left out; both go in one owner-only mutation, so a
|
|
479
|
+
// failure changes neither.
|
|
480
|
+
// Applies on every link's next load (the route injects it), with no re-release.
|
|
481
|
+
export const DECK_VIEWS = ["slides", "scroll"];
|
|
482
|
+
export async function setDeckView(opts, slug, { view, viewToggle } = {}) {
|
|
483
|
+
if (!slug) throw new Error("slug is required");
|
|
484
|
+
if (view === undefined && viewToggle === undefined) throw new Error("give a view (slides, scroll or default) and/or a viewToggle");
|
|
485
|
+
if (view !== undefined && view !== null && !DECK_VIEWS.includes(view)) throw new Error(`view must be slides, scroll or null (got ${JSON.stringify(view)})`);
|
|
486
|
+
const res = await (await client(opts)).mutation(api.decks.setViewSettings, {
|
|
487
|
+
slug,
|
|
488
|
+
...(view !== undefined ? { view } : {}),
|
|
489
|
+
...(viewToggle !== undefined ? { viewToggle: !!viewToggle } : {}),
|
|
490
|
+
});
|
|
491
|
+
// Saved either way; a deck on its own player says the setting won't apply.
|
|
492
|
+
const applies = res?.applies !== false;
|
|
493
|
+
return {
|
|
494
|
+
slug,
|
|
495
|
+
...(view !== undefined ? { view } : {}),
|
|
496
|
+
...(viewToggle !== undefined ? { viewToggle: !!viewToggle } : {}),
|
|
497
|
+
applies,
|
|
498
|
+
...(applies ? {} : { warning: viewPlayerWarning(slug, res?.ownScript ?? null) }),
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
469
502
|
// Register a new deck (draft) you own. Refuses any slug that already exists:
|
|
470
503
|
// upsert would otherwise reset your own live deck to a draft and rename it.
|
|
471
504
|
export async function createDeck(opts, { slug, title }) {
|
|
@@ -614,12 +647,17 @@ export async function importDeckFromBytes(opts = {}, fields = {}, adapter = {})
|
|
|
614
647
|
await c.mutation(api.decks.setHtmlStorage, { slug, storageId: htmlStorageId, bytes: htmlBytes.byteLength });
|
|
615
648
|
|
|
616
649
|
const t = target(opts);
|
|
650
|
+
// A warning, never a refusal: plain HTML exports are legitimate decks.
|
|
651
|
+
// Read before the asset rewrite, while the script still has its own name.
|
|
652
|
+
const player = deckPlayer(parsed.html);
|
|
617
653
|
return {
|
|
618
654
|
slug,
|
|
619
655
|
title,
|
|
620
656
|
status,
|
|
621
657
|
assets: parsed.assets.length,
|
|
622
658
|
bytes: htmlBytes.byteLength,
|
|
659
|
+
usesPlayer: player.usesPlayer,
|
|
660
|
+
warnings: player.usesPlayer ? [] : [importPlayerWarning(player.ownScript)],
|
|
623
661
|
deployment: t.name,
|
|
624
662
|
// The owner-facing dashboard view (Preview / Stats / Share / Present), not
|
|
625
663
|
// the raw /d/<slug> storage redirect.
|
package/src/release.mjs
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// 2. runs check (the same rules as `hyperdecks check`) and stops on any issue;
|
|
7
7
|
// 3. runs narrate, which re-renders only changed slides. If that changes the
|
|
8
8
|
// deck folder (a new manifest URL in index.html), release stops and asks for
|
|
9
|
-
// a commit, then a second release goes through
|
|
9
|
+
// a commit, then a second release goes through. A silent deck (data-silent
|
|
10
|
+
// on the player script, no manifest) skips narrate and gets no avatar attached;
|
|
10
11
|
// 4. imports index.html plus the local files it references (nothing else in the
|
|
11
12
|
// folder: script.md, evidence.md and narration/ stay private);
|
|
12
13
|
// 5. creates one share per --to (paired in order with --email) and prints the URLs.
|
|
@@ -76,23 +77,27 @@ export function recipients(opts) {
|
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
export async function release(pos, opts = {}, io = {}) {
|
|
79
|
-
const { log = (line) => console.log(line), changes = deckChanges, untracked = untrackedFiles, commit = deckCommit, narrate = runNarrate, fetch, cwd } = io;
|
|
80
|
+
const { log = (line) => console.log(line), changes = deckChanges, untracked = untrackedFiles, commit = deckCommit, narrate = runNarrate, fetch, cwd, measure, measureFit } = io;
|
|
80
81
|
const deckDir = resolveDeckDir(pos[0], cwd);
|
|
81
82
|
const to = recipients(opts);
|
|
82
83
|
|
|
83
84
|
const dirty = changes(deckDir);
|
|
84
85
|
if (dirty) throw new Error(`refusing to release: ${deckDir} has uncommitted changes\n${dirty}\ncommit them first so the release corresponds to a commit`);
|
|
85
86
|
|
|
86
|
-
|
|
87
|
+
// Always with the layout check: a slide that overflows a phone must not ship
|
|
88
|
+
// (the hosted release runs the same measurement, hostedLayoutCheck in source.mjs).
|
|
89
|
+
const checked = await checkDeck(deckDir, { ...opts, layout: true }, { fetch, cwd, ...(measure ? { measure } : {}), ...(measureFit ? { measureFit } : {}) });
|
|
87
90
|
const slug = opts.slug || checked.slug;
|
|
88
91
|
if (!SLUG.test(slug)) throw new Error(`"${slug}" is not a deck slug (lowercase letters, digits, dashes): rename the folder or pass --slug`);
|
|
89
92
|
if (checked.errors.length) throw new Error(`check failed for ${slug}:\n${checked.errors.map((e) => ` - ${e}`).join("\n")}`);
|
|
90
93
|
const bundle = releaseBundle(deckDir);
|
|
91
94
|
const loose = untracked(deckDir, bundle.files);
|
|
92
95
|
if (loose.length) throw new Error(`refusing to release: git does not track ${loose.join(", ")} (ignored or never added); commit them so the release corresponds to a commit`);
|
|
93
|
-
log(`✓ check passed: ${checked.slides} slides, ${checked.totalDuration}s`);
|
|
96
|
+
log(`✓ check passed: ${checked.slides} slides, ${checked.silent ? "silent (no narration)" : `${checked.totalDuration}s`}`);
|
|
97
|
+
for (const w of checked.warnings) log(` ⚠ ${w}`);
|
|
94
98
|
for (const [from, paths] of Object.entries(bundle.added)) log(` +${paths.length} file(s) referenced from ${from}: ${paths.join(", ")}`);
|
|
95
99
|
|
|
100
|
+
if (checked.silent) return publish({ opts, io: { log, commit }, deckDir, slug, to, checked, bundle, released: bundle, summary: null });
|
|
96
101
|
// Cached slides cost nothing, so this only renders what changed since the commit.
|
|
97
102
|
// Only the options narrate understands: an undefined --slide/--voice key would fail its flag check.
|
|
98
103
|
const narrateOpts = { ...opts, slug };
|
|
@@ -116,6 +121,11 @@ export async function release(pos, opts = {}, io = {}) {
|
|
|
116
121
|
if (presenter) log("✓ presented by your avatar");
|
|
117
122
|
// A terminal user sees this before it ships, so the CLI warns rather than refuses.
|
|
118
123
|
else if (job.presenter && !job.presenter.face) log(`⚠ releasing captions only: ${job.presenter.reason ?? "no face"}`);
|
|
124
|
+
return publish({ opts, io: { log, commit }, deckDir, slug, to, checked, bundle, released, summary: job.presenter ?? null });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Import the checked bundle and make the links: the end of every release.
|
|
128
|
+
async function publish({ opts, io: { log, commit }, deckDir, slug, to, checked, bundle, released, summary }) {
|
|
119
129
|
const imported = await ops.importDeckFromBytes(opts, {
|
|
120
130
|
slug,
|
|
121
131
|
title: opts.title,
|
|
@@ -150,5 +160,5 @@ export async function release(pos, opts = {}, io = {}) {
|
|
|
150
160
|
log(`✓ share for "${r.recipient}"${share.qa ? " (QA, untracked)" : ""} → ${share.url}`);
|
|
151
161
|
}
|
|
152
162
|
if (!shares.length) log(` no --to given: \`hyperdecks share new ${imported.slug} "Me" --qa\` makes a link for your own viewing`);
|
|
153
|
-
return { slug: imported.slug, deckDir, commit: sha, deckUrl: imported.deckUrl, totalDuration: checked.totalDuration,
|
|
163
|
+
return { slug: imported.slug, deckDir, commit: sha, deckUrl: imported.deckUrl, totalDuration: checked.totalDuration, silent: !!checked.silent, presenter: summary, files: bundle.files, shares };
|
|
154
164
|
}
|
package/src/runtime.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
|
|
|
8
8
|
import { ConvexHttpClient } from "convex/browser";
|
|
9
9
|
import { makeFunctionReference } from "convex/server";
|
|
10
10
|
|
|
11
|
-
export const VERSION = "0.
|
|
11
|
+
export const VERSION = "0.9.0";
|
|
12
12
|
const UPLOAD_TIMEOUT_MS = 125_000;
|
|
13
13
|
|
|
14
14
|
export const PROD = {
|
|
@@ -98,6 +98,9 @@ export const api = {
|
|
|
98
98
|
listForViewer: makeFunctionReference("decks:listForViewer"),
|
|
99
99
|
remove: makeFunctionReference("decks:remove"),
|
|
100
100
|
setAllowForward: makeFunctionReference("decks:setAllowForward"),
|
|
101
|
+
setView: makeFunctionReference("decks:setView"),
|
|
102
|
+
setViewToggle: makeFunctionReference("decks:setViewToggle"),
|
|
103
|
+
setViewSettings: makeFunctionReference("decks:setViewSettings"),
|
|
101
104
|
setHtmlStorage: makeFunctionReference("decks:setHtmlStorage"),
|
|
102
105
|
upsert: makeFunctionReference("decks:upsert"),
|
|
103
106
|
whoami: makeFunctionReference("decks:whoami"),
|
|
@@ -153,7 +156,7 @@ export const api = {
|
|
|
153
156
|
},
|
|
154
157
|
};
|
|
155
158
|
|
|
156
|
-
const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "qa", "no-notify", "allow-forward", "no-forward", "help", "version", "all-agents", "global", "project", "transparent", "loop", "upload", "dry-run", "narrated", "layout", "full-page", "force", "hosted", "yes", "no-voice", "no-skill", "no-release", "no-open", "save-to-minimoji", "captions-only", "no-brand", "no-logo"]);
|
|
159
|
+
const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "qa", "no-notify", "allow-forward", "no-forward", "help", "version", "all-agents", "global", "project", "transparent", "loop", "upload", "dry-run", "narrated", "layout", "strict", "fix", "full-page", "force", "hosted", "yes", "no-voice", "no-skill", "no-release", "no-open", "save-to-minimoji", "captions-only", "no-brand", "no-logo"]);
|
|
157
160
|
// release's recipients, in order: each --to starts one, and an --email right
|
|
158
161
|
// after it belongs to it (`--to A --email a --to B`). Collected as opts.recipients;
|
|
159
162
|
// opts.email stays the last --email for the other commands (share new).
|
package/src/source.mjs
CHANGED
|
@@ -6,15 +6,75 @@
|
|
|
6
6
|
// hyperdecks check <slug> --hosted
|
|
7
7
|
// hyperdecks release <slug> --hosted [--to "<name>" [--email e]]... [--qa]
|
|
8
8
|
// hyperdecks asset add <slug> <file|url> [--name n]
|
|
9
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
10
11
|
import { basename, extname, join, resolve } from "node:path";
|
|
11
12
|
import { api, client, uploadBytes } from "./runtime.mjs";
|
|
12
|
-
import {
|
|
13
|
+
import { HOSTED_LAYOUT_WHY, MESSAGES } from "./checkRules.mjs";
|
|
14
|
+
import { withLoadingBlock } from "./loadingBlock.mjs";
|
|
15
|
+
import { SOURCE_FILES, getDeckSource, hostedCheck, hostedRelease, uploadDeckAsset, writeDeckSource } from "./hosted.mjs";
|
|
13
16
|
|
|
14
17
|
export * from "./hosted.mjs";
|
|
15
18
|
|
|
16
19
|
// ---- CLI ------------------------------------------------------------------
|
|
17
20
|
|
|
21
|
+
// The browser half of check --layout for a hosted draft. The hosted check runs
|
|
22
|
+
// in Convex and can't open a browser (HOSTED_LAYOUT_WHY), so the CLI measures
|
|
23
|
+
// the draft's index.html here, from a temp folder, with check.mjs's Chromium
|
|
24
|
+
// code. Returns the rev it measured, and { errors, warnings } as check --layout.
|
|
25
|
+
export async function hostedLayoutCheck(opts, { slug } = {}, { measure, measureFit } = {}) {
|
|
26
|
+
if (!slug) throw new Error("slug is required");
|
|
27
|
+
const got = await getDeckSource(opts, { slug, file: "index.html" });
|
|
28
|
+
const html = got.files?.["index.html"];
|
|
29
|
+
if (typeof html !== "string") throw new Error(`hosted deck "${slug}" has no index.html`);
|
|
30
|
+
const { layoutCheck } = await import("./check.mjs");
|
|
31
|
+
const dir = mkdtempSync(join(tmpdir(), "hyperdecks-hosted-layout-"));
|
|
32
|
+
try {
|
|
33
|
+
writeFileSync(join(dir, "index.html"), html);
|
|
34
|
+
return { rev: got.rev, ...(await layoutCheck(dir, html, null, { ...(measure ? { measure } : {}), ...(measureFit ? { measureFit } : {}) })) };
|
|
35
|
+
} finally {
|
|
36
|
+
rmSync(dir, { recursive: true, force: true });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// check <slug> --hosted [--layout]: the hosted rules, plus the layout measured
|
|
41
|
+
// here when --layout asks for it (in place of the hosted check's "layout not
|
|
42
|
+
// checked" warning).
|
|
43
|
+
// --fix first puts the draft's loading state right (as check --fix does a folder's).
|
|
44
|
+
export async function hostedCheckCli(opts, args, io = {}) {
|
|
45
|
+
const fixed = args.fix ? await hostedFix(opts, args) : undefined;
|
|
46
|
+
const result = { ...(await hostedCheck(opts, args)), ...(fixed ? { fixed } : {}) };
|
|
47
|
+
if (!opts.layout) return result;
|
|
48
|
+
const layout = await hostedLayoutCheck(opts, args, io);
|
|
49
|
+
const errors = [...result.errors, ...layout.errors];
|
|
50
|
+
const unchecked = MESSAGES.layoutUnchecked(HOSTED_LAYOUT_WHY);
|
|
51
|
+
return { ...result, errors, passed: errors.length === 0, warnings: [...new Set([...result.warnings.filter((w) => w !== unchecked), ...layout.warnings])] };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function hostedFix(opts, { slug } = {}) {
|
|
55
|
+
if (!slug) throw new Error("slug is required");
|
|
56
|
+
const got = await getDeckSource(opts, { slug, file: "index.html" });
|
|
57
|
+
const html = got.files?.["index.html"];
|
|
58
|
+
if (typeof html !== "string") throw new Error(`hosted deck "${slug}" has no index.html`);
|
|
59
|
+
// The CLI's copy of the block only decides whether to write; the draft's write
|
|
60
|
+
// puts it right with the server's copy, and what it reports is what was stored.
|
|
61
|
+
const { fixed, skipped } = withLoadingBlock(html);
|
|
62
|
+
if (skipped) return [`index.html: not fixed: ${skipped}`];
|
|
63
|
+
if (!fixed) return [];
|
|
64
|
+
const written = await writeDeckSource(opts, { slug, file: "index.html", content: html, baseRev: got.rev });
|
|
65
|
+
return written?.fixed ? [`index.html: ${written.fixed}`] : [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// release <slug> --hosted: the same layout check the local release runs, then
|
|
69
|
+
// the release pinned to the rev that was measured, so a later write can't slip
|
|
70
|
+
// an unmeasured draft through. A slide that overflows a phone refuses it.
|
|
71
|
+
export async function hostedReleaseCli(opts, args, { log = (line) => console.log(line), ...io } = {}) {
|
|
72
|
+
const layout = await hostedLayoutCheck(opts, args, io);
|
|
73
|
+
if (layout.errors.length) throw new Error(`check failed for ${args.slug}:\n${layout.errors.map((e) => ` - ${e}`).join("\n")}`);
|
|
74
|
+
for (const w of layout.warnings) log(` ⚠ ${w}`);
|
|
75
|
+
return hostedRelease(opts, { ...args, rev: layout.rev });
|
|
76
|
+
}
|
|
77
|
+
|
|
18
78
|
export async function sourcePull(pos, opts = {}, { log = (line) => console.log(line), cwd = process.cwd() } = {}) {
|
|
19
79
|
const [slug, dirArg] = pos;
|
|
20
80
|
if (!slug) throw new Error("usage: source pull <slug> [dir]");
|
|
@@ -39,10 +99,15 @@ export async function sourcePush(pos, opts = {}, { log = (line) => console.log(l
|
|
|
39
99
|
if (!files.includes("index.html")) throw new Error(`no index.html in ${dir}`);
|
|
40
100
|
let baseRev = opts["base-rev"] !== undefined ? Number(opts["base-rev"]) : undefined;
|
|
41
101
|
let rev;
|
|
102
|
+
const fixed = [];
|
|
42
103
|
for (const file of files) {
|
|
43
|
-
|
|
104
|
+
let written;
|
|
105
|
+
({ rev, ...written } = await writeDeckSource(opts, { slug, file, content: readFileSync(join(/*turbopackIgnore: true*/ dir, file), "utf8"), baseRev }));
|
|
106
|
+
// The draft's write may put index.html's loading state right: say so, so the folder isn't silently behind.
|
|
107
|
+
if (written.fixed) fixed.push(`${file}: ${written.fixed}`);
|
|
44
108
|
baseRev = baseRev === undefined ? undefined : rev; // each write moves the rev
|
|
45
109
|
}
|
|
110
|
+
for (const f of fixed) log(`✓ the draft fixed ${f}; \`hyperdecks source pull ${slug}\` to bring it into the folder`);
|
|
46
111
|
log(`✓ pushed ${files.join(", ")} to ${slug}: rev ${rev}`);
|
|
47
112
|
return { slug, rev, files };
|
|
48
113
|
}
|