@hypersocial/decks-cli 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/package.json +1 -1
- package/skills/hyperdecks/SKILL.md +54 -8
- package/skills/hyperdecks/chat.md +9 -4
- package/src/avatar.mjs +1 -1
- package/src/brandCli.mjs +46 -1
- package/src/brandImport.mjs +35 -25
- package/src/brandPreview.mjs +8 -1
- package/src/check.mjs +21 -3
- package/src/checkRules.mjs +180 -4
- package/src/commands.mjs +22 -3
- package/src/importer.mjs +43 -5
- package/src/ops.mjs +41 -15
- package/src/play.mjs +119 -0
- package/src/presenter.mjs +14 -7
- package/src/preview-capture.mjs +5 -3
- package/src/release.mjs +14 -8
- package/src/runtime.mjs +6 -1
- package/templates/script.md +5 -2
package/README.md
CHANGED
|
@@ -79,6 +79,7 @@ voice list|clone|upload|rm
|
|
|
79
79
|
|
|
80
80
|
deck new <slug> "<title>" --narrated [--dir <path>]
|
|
81
81
|
check <slug|deck-dir> [--forbid "<phrase>,<phrase>"] [--max-duration <s>] [--json]
|
|
82
|
+
play <slug|deck-dir> [--voice <v>] [--slug <slug>] [--port <n>] [--no-open] [--json]
|
|
82
83
|
release <slug|deck-dir> [--to "<name>" [--email <e>]]... [--qa] [--no-notify] [--title t] [--voice v]
|
|
83
84
|
```
|
|
84
85
|
|
|
@@ -93,7 +94,12 @@ clip's audio and timeline resolve with the right type; captions are in time,
|
|
|
93
94
|
cover the clip text and run 4 to 85 characters; local `src`/`href` resolve inside
|
|
94
95
|
the folder; the player is the pinned `player/v1`; `--forbid` phrases appear
|
|
95
96
|
nowhere; every slide has a `data-note` and long titles a `data-title-mobile`.
|
|
96
|
-
`
|
|
97
|
+
`play` plays the folder as it is, committed or not, with the real face and voice:
|
|
98
|
+
the face goes on the served copy in memory, on 127.0.0.1 behind a random path, until
|
|
99
|
+
Ctrl-C. It writes no file, uploads nothing, creates no share and renders no audio, so
|
|
100
|
+
changed lines need `narrate` first. The hosted draft link `/p/<token>` shows the same face.
|
|
101
|
+
`release` refuses uncommitted changes in the deck folder (`--qa` too: a release
|
|
102
|
+
replaces what every link to the deck plays), runs check and narrate
|
|
97
103
|
(stopping for a commit if narration changed the folder), imports `index.html` with
|
|
98
104
|
the local files it references, and creates one share per `--to`. The agent
|
|
99
105
|
procedure is the "Author a narrated deck" section of the bundled skill.
|
package/package.json
CHANGED
|
@@ -66,7 +66,8 @@ returns a link; hand it to them, word for word, and wait.
|
|
|
66
66
|
The faces narrators wear; these commands still work as before.
|
|
67
67
|
|
|
68
68
|
A deck narrated in the user's own voice is presented by their default avatar
|
|
69
|
-
(a cartoon Minimoji, never a photo)
|
|
69
|
+
(a cartoon Minimoji, never a photo): in `play` and the draft link at once, on
|
|
70
|
+
share links from its next release. With none it plays
|
|
70
71
|
captions only. D.A.S.H. presents only in its own voice. **Never pick or approve
|
|
71
72
|
a face for the person.** Every command that needs a choice returns a link (and,
|
|
72
73
|
for an import, a code): hand it to them, word for word, and wait.
|
|
@@ -125,6 +126,8 @@ Hyperdecks server; use the CLI where you have a shell and the tool otherwise.
|
|
|
125
126
|
|---|---|---|
|
|
126
127
|
| Brand (once; every new deck starts from it) | `hyperdecks brand show` / `brand set` / `brand edit` | `get_brand` / `set_brand` |
|
|
127
128
|
| Import a brand (website, .pptx/.potx, .pdf, a deck; preview first) | `hyperdecks brand import <url\|file\|slug>` | `import_brand` |
|
|
129
|
+
| Revise the brand with a prompt (preview first) | `hyperdecks brand revise "<change>"` | `revise_brand` |
|
|
130
|
+
| Brand history / undo a brand change | `hyperdecks brand history` / `brand undo [<version>]` | `brand_history` / `undo_brand` |
|
|
128
131
|
| Scaffold | `hyperdecks deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer]` | `new_deck` (`layouts`) |
|
|
129
132
|
| Images | `hyperdecks gen-image "<prompt>" --slug <slug>` | `generate_image` |
|
|
130
133
|
| Video clips | `hyperdecks gen-video "<prompt>" --slug <slug>` | `generate_video` |
|
|
@@ -137,6 +140,7 @@ Hyperdecks server; use the CLI where you have a shell and the tool otherwise.
|
|
|
137
140
|
| Who narrates (ask the person first; see *Narrators*) | `hyperdecks deck narrator <slug> <name\|none\|default>` | `list_narrators`, then `set_deck_narrator` |
|
|
138
141
|
| Narrate | `hyperdecks narrate decks/<slug>` | `narrate_deck` |
|
|
139
142
|
| Check | `hyperdecks check decks/<slug>` | `check_deck` |
|
|
143
|
+
| Watch a draft with its real face | `hyperdecks play decks/<slug>` | the `previewUrl` from `get_deck_source` |
|
|
140
144
|
| Ship | `hyperdecks release decks/<slug> --to "<name>" --email <e>` | `release_deck` |
|
|
141
145
|
| Share-link poster | `hyperdecks poster <slug>` | `generate_deck_poster` |
|
|
142
146
|
| Import only | `hyperdecks import <file> --slug <slug>` | `import_deck_from_url` |
|
|
@@ -211,6 +215,13 @@ The deck contract (`check` enforces each line):
|
|
|
211
215
|
`narrate` fills `data-manifest`; do not edit it by hand.
|
|
212
216
|
- Local images and files sit inside the deck folder and are referenced
|
|
213
217
|
relatively (`img/chart.png`). Hosted images (`gen-image` URLs) are fine too.
|
|
218
|
+
- A file the deck's own JavaScript loads (`fetch('media/data.json')`, a clip
|
|
219
|
+
for `new Audio(…)`) is written out as a quoted path, in an inline `<script>`
|
|
220
|
+
or in a `.js` or `.json` file the deck loads. Release follows those paths and ships the files
|
|
221
|
+
(`play` serves the whole folder, the `/v/` link only what release shipped).
|
|
222
|
+
A path built at runtime (`` `media/lines/${id}.mp3` ``) can't be followed:
|
|
223
|
+
list each file literally (`{ otter: "media/lines/otter.mp3" }`) and pick from
|
|
224
|
+
that.
|
|
214
225
|
|
|
215
226
|
<!-- topic: presenter -->
|
|
216
227
|
#### Which presenter style
|
|
@@ -318,6 +329,10 @@ fonts and logo (a PowerPoint's theme exactly) and returns a preview link, a
|
|
|
318
329
|
sample slide in that brand, and a token. Always show them the preview and
|
|
319
330
|
ask; save with `brand set --draft <token>` (`set_brand { draft }`) only after
|
|
320
331
|
they say yes.
|
|
332
|
+
To change it in words ("darker accent, serif titles"), `hyperdecks brand
|
|
333
|
+
revise "<change>"` (`revise_brand`) gives a draft and preview the same way.
|
|
334
|
+
Every brand save is kept in its history: `brand history` (`brand_history`)
|
|
335
|
+
lists the last changes and `brand undo [<version>]` (`undo_brand`) goes back.
|
|
321
336
|
|
|
322
337
|
**Fonts: pick a pairing.** Fonts load from Google Fonts. Use one of the
|
|
323
338
|
curated pairings below (display for titles, body for text, mono for kickers
|
|
@@ -439,13 +454,32 @@ phone screenshots, the same `fix` bar.
|
|
|
439
454
|
- Narration: short phrases, each sentence one caption of 4 to 85 characters
|
|
440
455
|
(two lines in the bubble). Aim for 2.5 to 3 minutes in all; `maxTotalSeconds`
|
|
441
456
|
in the front matter is the cap check uses (180 in the scaffold).
|
|
442
|
-
- 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
|
|
443
|
-
-
|
|
457
|
+
- 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.
|
|
458
|
+
- Interactive slides (something the viewer taps or plays with): add
|
|
459
|
+
`data-interactive` to the slide's `<section>`. The presenter finishes the
|
|
460
|
+
line, then the deck waits with a Continue button instead of advancing, and
|
|
461
|
+
taps inside the slide don't pause the narration. The slide hears the
|
|
462
|
+
presenter through `deck:presenter { slide, state }` on `window` (or a
|
|
463
|
+
controller's `onNarration(state, ctx)`), ducks its own audio while `state`
|
|
464
|
+
is `playing`, and can quiet the presenter with `ctx.player.pause()` /
|
|
465
|
+
`resume()` (or `window.hyperdecks.pauseNarration()` / `resumeNarration()`).
|
|
466
|
+
- Anything the viewer clicks on a slide (interactive or not) gets
|
|
467
|
+
`data-hd-tap` (or `cursor: pointer`), so the click doesn't also pause the
|
|
468
|
+
presenter. Buttons, links, canvases and focusable elements are exempt already.
|
|
469
|
+
- Cues: `[reveal: #id]` reveals `.reveal#id` at the word before it (going
|
|
470
|
+
back or replaying the slide replays its reveals from the first, and the
|
|
471
|
+
live head glances at each, unless a `[look]`/`[go]` is beside it or a
|
|
472
|
+
`go` has it docked; `data-reveal-glance="off"` on the slide or `<body>`
|
|
473
|
+
stops the glance); also
|
|
444
474
|
`[wave]`, `[mood: excited]`, `[pause: 0.5]`, `[custom: name]`.
|
|
445
475
|
- Moves, to point at part of a slide: `[go: <selector>]` flies the presenter
|
|
446
476
|
beside that element (the side with room) until the next `go`, the slide's
|
|
447
|
-
end, or `[go: slot]
|
|
448
|
-
|
|
477
|
+
end, or `[go: slot]`. The element grows a little and, when it is one item
|
|
478
|
+
of a list or grid, the other items fade back (`.hd-dimmed`); the presenter
|
|
479
|
+
never covers them, so with no room beside it it stays put and looks at it.
|
|
480
|
+
Put `data-focus="ring"` on the element or slide for an accent ring instead,
|
|
481
|
+
or restyle `.hd-focus` / `.hd-dimmed` in the deck's CSS.
|
|
482
|
+
`[look: <selector>]` turns the head toward an element without moving. One selector each, matched inside that slide; check fails one that
|
|
449
483
|
matches nothing. Use `go` when the narration walks through a list or chart,
|
|
450
484
|
`look` for a glance at a title or number. Not in `corner` style. The best
|
|
451
485
|
showcase is a slide of stacked cards, one point each, with a `go` to each
|
|
@@ -462,10 +496,10 @@ phone screenshots, the same `fix` bar.
|
|
|
462
496
|
|---|---|
|
|
463
497
|
| `[wave]` | the presenter greets (the live head's joy emote) |
|
|
464
498
|
| `[mood: excited]` / `[emote: name]` | the head's mood or emote |
|
|
465
|
-
| `[reveal: #id]` | adds `is-revealed` to `#id` |
|
|
499
|
+
| `[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 |
|
|
466
500
|
| `[pause: 0.5]` | a beat of silence (≤ 5 s) |
|
|
467
501
|
| `[play: video]` / `[pause: video]` | play or pause the slide's video |
|
|
468
|
-
| `[go:
|
|
502
|
+
| `[go: #step-2]` / `[go: slot]` | fly beside that item, grow it, fade the other items / back to the slot |
|
|
469
503
|
| `[look: h2]` / `[look #card]` | look at the title / the card, then back |
|
|
470
504
|
| `[look viewer]` / `[look away]` | look back at the viewer / glance aside |
|
|
471
505
|
| `[nod]` / `[shake]` | a nod or head shake that suits the mood |
|
|
@@ -519,6 +553,9 @@ an internal codename). Messages and what to do:
|
|
|
519
553
|
| `clip <id>: captions do not cover the clip text` | narrate that slide again |
|
|
520
554
|
| `clip <id>: caption N is … characters` | split or join sentences in that block |
|
|
521
555
|
| `local reference does not resolve` / `leaves the deck folder` | move the file into the deck folder or fix the path |
|
|
556
|
+
| `<file> names <path>, which is not in the deck folder` | add the file, or fix the path in that script |
|
|
557
|
+
| ⚠ `<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
|
+
| ⚠ `<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 |
|
|
522
559
|
| `player script must be` / `no player script` | restore the player/v1 script tag |
|
|
523
560
|
| `the player script has no data-manifest` | run `narrate` |
|
|
524
561
|
| `no loading state` | copy the template's `<style data-hd-loading>` block (and the preload lines above it) into `<head>`; set `--hd-ground` / `--hd-ground-ink` to the first slide's colours |
|
|
@@ -539,7 +576,16 @@ an internal codename). Messages and what to do:
|
|
|
539
576
|
|
|
540
577
|
### 6. Look at it, at both widths
|
|
541
578
|
|
|
542
|
-
|
|
579
|
+
To watch it as it will really play, run `hyperdecks play decks/<slug>`. It plays
|
|
580
|
+
the folder as it is, committed or not, with the real face and voice, on
|
|
581
|
+
127.0.0.1 until Ctrl-C. It writes nothing, uploads nothing, creates no share and
|
|
582
|
+
spends nothing, so it plays the last narration: changed lines need `narrate`
|
|
583
|
+
first. It prints who presents, or why it is captions only. `--no-open` skips the
|
|
584
|
+
browser (an agent reads the URL; `--json` prints it as one line).
|
|
585
|
+
`release --qa` is not a preview: it needs a commit, and it changes what every
|
|
586
|
+
link to this deck plays.
|
|
587
|
+
|
|
588
|
+
For the layout pass, serve the folder (`npx serve decks/<slug>` or `python3 -m http.server -d decks/<slug>`)
|
|
543
589
|
and screenshot it with Playwright at 1440×900 and 390×844, every slide
|
|
544
590
|
(`#scene=<id>`). Look at both: nothing clipped, the presenter and its captions
|
|
545
591
|
clear of the text, the mobile titles in use. Fix and repeat until both read well.
|
|
@@ -29,9 +29,14 @@ makes the hello deck; continue from step 5 below.
|
|
|
29
29
|
a `previewUrl` (a sample slide in that brand) and a `token`. Always give
|
|
30
30
|
them the preview link, say what it found (and its notes), and ask; only
|
|
31
31
|
after a yes call `set_brand { draft: token }`. Never save without the yes.
|
|
32
|
+
To change the brand in words ("less yellow", "serif titles"),
|
|
33
|
+
`revise_brand { instruction }` gives a draft and preview the same way.
|
|
34
|
+
Any brand change can be undone: `brand_history`, then `undo_brand`.
|
|
32
35
|
(`deck_guide { topic: "design" }` lists the font pairings.)
|
|
33
36
|
2. `new_deck { slug, title, layouts }`: registers your draft and returns the
|
|
34
|
-
full text of every file plus a `previewUrl` (the draft, untracked
|
|
37
|
+
full text of every file plus a `previewUrl` (the draft, untracked; once
|
|
38
|
+
narrated it plays with the face its release would have, so the person can
|
|
39
|
+
watch it before anything ships). It
|
|
35
40
|
starts in the saved brand's colours, fonts and logo (`brand` in the result
|
|
36
41
|
says what it took); `brand: false` gives the template's look.
|
|
37
42
|
3. Images: `generate_image`, `render_chart`, `capture_url`, or
|
|
@@ -69,9 +74,9 @@ makes the hello deck; continue from step 5 below.
|
|
|
69
74
|
`[smile]`, `[gesture: lightbulb]`; `deck_guide script` lists them) and
|
|
70
75
|
narrate again.
|
|
71
76
|
8. `check_deck { slug }` again, then `review_deck { slug, draft: true }` and
|
|
72
|
-
`check_review_job`: it shoots desktop and 390×844 phone frames,
|
|
73
|
-
you catch a slide that overflows a phone (the hosted check cannot
|
|
74
|
-
layout).
|
|
77
|
+
`check_review_job`: it shoots desktop and 390×844 phone frames, face included,
|
|
78
|
+
which is how you catch a slide that overflows a phone (the hosted check cannot
|
|
79
|
+
measure layout).
|
|
75
80
|
9. `release_deck { slug, qa: true }` gives the user a QA link to watch first.
|
|
76
81
|
`release_deck { slug, recipients: [{ recipient, email }] }` makes one
|
|
77
82
|
tracked link each. Release creates live links: confirm the recipients with
|
package/src/avatar.mjs
CHANGED
|
@@ -86,6 +86,6 @@ export async function avatarConnect(opts, { log = console.log, interactive = int
|
|
|
86
86
|
log("Waiting for you to pick a face…");
|
|
87
87
|
const done = await ops.waitForConnect(opts, out.connectId);
|
|
88
88
|
if (done.status !== "added") throw new Error(done.status === "denied" ? "not shared on minimoji.com" : done.status === "expired" ? "the code expired; run avatar connect again" : done.error || `the import ended (${done.status})`);
|
|
89
|
-
log("✓ Presenter: your Minimoji. Your
|
|
89
|
+
log("✓ Presenter: your Minimoji. Your drafts show it now; share links get it from the next release.");
|
|
90
90
|
return { status: "added", avatarId: done.avatarId };
|
|
91
91
|
}
|
package/src/brandCli.mjs
CHANGED
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
// [--draft <token>] (a brand import, saved after you saw its preview)
|
|
11
11
|
// brand import <url|file|slug> read a brand from a website, a .pptx / .potx /
|
|
12
12
|
// .pdf or one of your decks into a draft + preview
|
|
13
|
+
// brand revise "<change>" a draft + preview of the brand with that change
|
|
14
|
+
// brand history the last changes (from any surface)
|
|
15
|
+
// brand undo [<version>] undo the last change, or go back to a version
|
|
13
16
|
// brand edit design.md in $VISUAL / $EDITOR, saved on exit
|
|
14
17
|
// brand fonts the curated font pairings
|
|
15
18
|
// brand clear forget the brand (new decks get the template look)
|
|
@@ -135,7 +138,7 @@ export function importLines(d) {
|
|
|
135
138
|
const colors = Object.entries(d.colors ?? {}).map(([k, v]) => `${k} ${v}`).join(", ");
|
|
136
139
|
const fonts = Object.entries(d.fonts ?? {}).map(([k, v]) => `${k} ${v}`).join(", ");
|
|
137
140
|
return [
|
|
138
|
-
`read from ${d.source?.kind} ${d.source?.ref}`,
|
|
141
|
+
d.source?.kind === "revise" ? `revised: ${d.source.ref}` : `read from ${d.source?.kind} ${d.source?.ref}`,
|
|
139
142
|
...(d.name ? [`name: ${d.name}`] : []),
|
|
140
143
|
`colours: ${colors || "(none found)"}`,
|
|
141
144
|
`fonts: ${fonts || "(none found)"}`,
|
|
@@ -164,3 +167,45 @@ export async function brandImport(from, opts = {}, { log = (line) => console.log
|
|
|
164
167
|
else for (const line of importLines(d)) log(line);
|
|
165
168
|
return d;
|
|
166
169
|
}
|
|
170
|
+
|
|
171
|
+
// brand revise "<change>": the model's take on the brand, as a draft + preview.
|
|
172
|
+
export async function brandRevise(words, opts = {}, { log = (line) => console.log(line) } = {}) {
|
|
173
|
+
const instruction = (Array.isArray(words) ? words.join(" ") : String(words ?? "")).trim();
|
|
174
|
+
if (!instruction) throw new Error('usage: brand revise "<change>", e.g. brand revise "make the accent darker and use a serif for titles"');
|
|
175
|
+
const d = await ops.reviseBrand(opts, { instruction });
|
|
176
|
+
if (opts.json) log(JSON.stringify(d, null, 2));
|
|
177
|
+
else for (const line of importLines(d)) log(line);
|
|
178
|
+
return d;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const ago = (at, now = Date.now()) => {
|
|
182
|
+
const m = Math.round((now - at) / 60000);
|
|
183
|
+
if (m < 1) return "just now";
|
|
184
|
+
if (m < 60) return `${m} min ago`;
|
|
185
|
+
const h = Math.round(m / 60);
|
|
186
|
+
return h < 48 ? `${h} h ago` : `${Math.round(h / 24)} days ago`;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
export function historyLines(versions, now = Date.now()) {
|
|
190
|
+
if (!versions.length) return ["no brand changes yet"];
|
|
191
|
+
return [
|
|
192
|
+
...versions.map((v) => `${v.current ? "* " : " "}${v.label.padEnd(48)} ${ago(v.at, now).padEnd(12)} ${v.id}`),
|
|
193
|
+
"",
|
|
194
|
+
"hyperdecks brand undo undo the last change",
|
|
195
|
+
"hyperdecks brand undo <version> go back to one of these",
|
|
196
|
+
];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function brandHistory(opts = {}, { log = (line) => console.log(line) } = {}) {
|
|
200
|
+
const out = await ops.brandHistory(opts);
|
|
201
|
+
if (opts.json) log(JSON.stringify(out, null, 2));
|
|
202
|
+
else for (const line of historyLines(out.versions)) log(line);
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function brandUndo(version, opts = {}, { log = (line) => console.log(line) } = {}) {
|
|
207
|
+
const b = await ops.undoBrand(opts, { version });
|
|
208
|
+
if (opts.json) log(JSON.stringify(b, null, 2));
|
|
209
|
+
else for (const line of [version ? "✓ brand restored" : "✓ last change undone", ...brandLines(b)]) log(line);
|
|
210
|
+
return b;
|
|
211
|
+
}
|
package/src/brandImport.mjs
CHANGED
|
@@ -488,9 +488,11 @@ export function extractSite({ html, css = [], url }) {
|
|
|
488
488
|
if (card) colors.card = card.hex;
|
|
489
489
|
}
|
|
490
490
|
const named = Object.keys(from);
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
491
|
+
// For people, plain words; for the logs (details), which token gave what.
|
|
492
|
+
const details = /** @type {string[]} */ ([]);
|
|
493
|
+
if (named.length) details.push(`colours ${named.map((k) => `${k} from ${from[k]}`).join(", ")}; the rest sampled from the site's CSS`);
|
|
494
|
+
if (!named.length && Object.keys(colors).length) notes.push("The site doesn't name its colours, so they were picked from its styles: check them in the sample.");
|
|
495
|
+
else if (!Object.keys(colors).length) notes.push("No colours were found on the site (it may draw them with JavaScript): pick them by hand.");
|
|
494
496
|
|
|
495
497
|
// Fonts: Google Fonts links, @font-face, then font-family by role.
|
|
496
498
|
const linked = [];
|
|
@@ -530,7 +532,7 @@ export function extractSite({ html, css = [], url }) {
|
|
|
530
532
|
|
|
531
533
|
const candidates = logoCandidates(doc, url);
|
|
532
534
|
const name = siteName(doc);
|
|
533
|
-
return { name, colors, fonts, logoCandidates: candidates, notes, source: { kind: "url", ref: url } };
|
|
535
|
+
return { name, colors, fonts, logoCandidates: candidates, notes, details, source: { kind: "url", ref: url } };
|
|
534
536
|
}
|
|
535
537
|
|
|
536
538
|
// ---- a Hyperdecks deck -----------------------------------------------------
|
|
@@ -559,10 +561,13 @@ export function extractDeck(html, slug) {
|
|
|
559
561
|
const alt = logoTag && attr(logoTag.tag, "alt");
|
|
560
562
|
const name = alt && / logo$/i.test(alt) ? decodeEntities(alt).replace(/ logo$/i, "").trim() : undefined;
|
|
561
563
|
const notes = /** @type {string[]} */ ([]);
|
|
562
|
-
|
|
563
|
-
if (!src) notes.push("The deck has no
|
|
564
|
-
else if (!/^https:\/\//.test(src))
|
|
565
|
-
|
|
564
|
+
const details = [`colours and fonts are deck ${slug}'s :root tokens`];
|
|
565
|
+
if (!src) notes.push("The deck has no logo: add one.");
|
|
566
|
+
else if (!/^https:\/\//.test(src)) {
|
|
567
|
+
notes.push("The deck's logo is not hosted online, so it was not copied: add it.");
|
|
568
|
+
details.push(`deck logo src ${src.slice(0, 80)} is not an https URL`);
|
|
569
|
+
}
|
|
570
|
+
return { name, colors, fonts, logoCandidates: src && /^https:\/\//.test(src) ? [{ url: src, via: "deck" }] : [], notes, details, source: { kind: "deck", ref: slug } };
|
|
566
571
|
}
|
|
567
572
|
|
|
568
573
|
// ---- PowerPoint ------------------------------------------------------------
|
|
@@ -648,8 +653,8 @@ export function extractPptx(bytes, fileName = "presentation.pptx") {
|
|
|
648
653
|
if (major) fonts.display = major;
|
|
649
654
|
if (minor) fonts.body = minor;
|
|
650
655
|
const notes = /** @type {string[]} */ ([]);
|
|
651
|
-
|
|
652
|
-
if (/^office/i.test(scheme ?? "") && /^(calibri|aptos)/i.test(minor ?? "")) notes.push("This is PowerPoint's default
|
|
656
|
+
const details = [`theme of ${fileName}${scheme ? ` (colour scheme "${scheme}")` : ""}: ink dk1, paper lt1, accent accent1, accent2 accent2, card lt2; titles the major font, body the minor`];
|
|
657
|
+
if (/^office/i.test(scheme ?? "") && /^(calibri|aptos)/i.test(minor ?? "")) notes.push("This is PowerPoint's default theme, not a custom one, so the colours may not be your brand's.");
|
|
653
658
|
|
|
654
659
|
// The logo: the first picture on the slide master, else on its first layout.
|
|
655
660
|
let logo;
|
|
@@ -673,14 +678,15 @@ export function extractPptx(bytes, fileName = "presentation.pptx") {
|
|
|
673
678
|
// Only a picture under the logo limit is unpacked (its declared size; fflate inflates into exactly that).
|
|
674
679
|
let declared = 0;
|
|
675
680
|
const got = type ? unzipSync(bytes, { filter: (f) => f.name === media && (declared = f.originalSize) <= MAX_LOGO_BYTES })[media] : null;
|
|
676
|
-
if (!type) notes.push(`The
|
|
677
|
-
else if (declared > MAX_LOGO_BYTES) notes.push(`The
|
|
678
|
-
else if (!got) notes.push("
|
|
679
|
-
else if (got.byteLength > MAX_LOGO_BYTES) notes.push(`The
|
|
681
|
+
if (!type) notes.push(`The logo in the file is a .${ext} picture, which a deck cannot show: add it as PNG or SVG.`);
|
|
682
|
+
else if (declared > MAX_LOGO_BYTES) notes.push(`The logo in the file is ${Math.ceil(declared / 1024)} KB, over the 2 MB limit: add a smaller one.`);
|
|
683
|
+
else if (!got) notes.push("No logo was found in the file: add one.");
|
|
684
|
+
else if (got.byteLength > MAX_LOGO_BYTES) notes.push(`The logo in the file is ${Math.ceil(got.byteLength / 1024)} KB, over the 2 MB limit: add a smaller one.`);
|
|
680
685
|
else logo = { bytes: got, type, via: "slide master" };
|
|
681
|
-
|
|
686
|
+
if (!logo) details.push(`slide master picture ${media}`);
|
|
687
|
+
} else notes.push("No logo was found in the file: add one.");
|
|
682
688
|
const company = files["docProps/app.xml"] && /<Company>([^<]+)<\/Company>/.exec(xmlText(files["docProps/app.xml"]))?.[1]?.trim();
|
|
683
|
-
return { name: company ? decodeEntities(company).slice(0, 60) : undefined, colors, fonts, logo, logoCandidates: [], notes, source: { kind: "pptx", ref: fileName } };
|
|
689
|
+
return { name: company ? decodeEntities(company).slice(0, 60) : undefined, colors, fonts, logo, logoCandidates: [], notes, details, source: { kind: "pptx", ref: fileName } };
|
|
684
690
|
}
|
|
685
691
|
|
|
686
692
|
// ---- PDF -------------------------------------------------------------------
|
|
@@ -849,12 +855,13 @@ export function extractPdf(bytes, fileName = "document.pdf") {
|
|
|
849
855
|
if (second) colors.accent2 = second.hex;
|
|
850
856
|
|
|
851
857
|
const notes = /** @type {string[]} */ ([]);
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
if (
|
|
855
|
-
|
|
856
|
-
notes.push("
|
|
857
|
-
|
|
858
|
+
const details = [];
|
|
859
|
+
if (!families.size) notes.push("No fonts could be read from the file: pick them by hand.");
|
|
860
|
+
if (undecoded) details.push(`${undecoded} compressed stream${undecoded > 1 ? "s" : ""} could not be decoded`);
|
|
861
|
+
if (painted) notes.push(`Colours were picked from the first pages${paper ? "" : " (they draw no background, so it is white)"}: check them in the sample.`);
|
|
862
|
+
else notes.push("No colours could be read from the file: pick them by hand.");
|
|
863
|
+
notes.push("A PDF doesn't say which picture is the logo: add one.");
|
|
864
|
+
return { colors, fonts, logoCandidates: [], notes, details, source: { kind: "pdf", ref: fileName } };
|
|
858
865
|
}
|
|
859
866
|
|
|
860
867
|
// Whether a draft read anything worth saving: a colour (beyond the white page
|
|
@@ -914,6 +921,8 @@ const CLASS_PAIRING = { serif: "editorial", geometric: "geometric", friendly: "f
|
|
|
914
921
|
// A serif body reads best as the literary pairing's body.
|
|
915
922
|
const BODY_PAIRING = { serif: "literary" };
|
|
916
923
|
|
|
924
|
+
const ROLE_WORDS = { display: "titles", body: "body text", mono: "labels" };
|
|
925
|
+
|
|
917
926
|
// The draft's fonts as Google Fonts families. A family Google serves is kept
|
|
918
927
|
// (with Google's spelling); any other maps to the closest pairing's family for
|
|
919
928
|
// that role, with a note saying so. isGoogleFont(family) -> boolean | Promise.
|
|
@@ -949,7 +958,7 @@ export async function resolveFonts(fonts = {}, { isGoogleFont } = {}) {
|
|
|
949
958
|
const p = FONT_PAIRINGS.find((x) => x.id === id);
|
|
950
959
|
pairing ??= p;
|
|
951
960
|
out[role] = p[role];
|
|
952
|
-
notes.push(`${family}
|
|
961
|
+
notes.push(`${family} isn't a Google Font, so ${ROLE_WORDS[role]} use${role === "body" ? "s" : ""} ${p[role]}, the closest match.`);
|
|
953
962
|
}
|
|
954
963
|
if (!out.mono) {
|
|
955
964
|
const p =
|
|
@@ -959,9 +968,9 @@ export async function resolveFonts(fonts = {}, { isGoogleFont } = {}) {
|
|
|
959
968
|
FONT_PAIRINGS.find((x) => x.body === out.body) ??
|
|
960
969
|
FONT_PAIRINGS.find((x) => x.id === "inter");
|
|
961
970
|
out.mono = p.mono;
|
|
962
|
-
notes.push(`No
|
|
971
|
+
notes.push(`No label font was found, so labels use ${p.mono}.`);
|
|
963
972
|
}
|
|
964
|
-
for (const f of new Set(unconfirmed)) notes.push(`
|
|
973
|
+
for (const f of new Set(unconfirmed)) notes.push(`Couldn't check that ${f} is a Google Font, so it is kept: if the sample shows a fallback font, pick another.`);
|
|
965
974
|
// A role the source left empty takes the other's family.
|
|
966
975
|
if (!out.display && out.body) out.display = out.body;
|
|
967
976
|
if (!out.body && out.display) out.body = out.display;
|
|
@@ -972,6 +981,7 @@ export async function resolveFonts(fonts = {}, { isGoogleFont } = {}) {
|
|
|
972
981
|
// fields: Convex refuses them).
|
|
973
982
|
export function draftArgs(x) {
|
|
974
983
|
const args = { source: x.source, colors: x.colors, fonts: x.fonts, notes: x.notes };
|
|
984
|
+
if (x.details?.length) args.details = x.details;
|
|
975
985
|
if (x.name) args.name = x.name;
|
|
976
986
|
if (x.logo?.bytes) {
|
|
977
987
|
args.logoBytes = x.logo.bytes.slice().buffer;
|
package/src/brandPreview.mjs
CHANGED
|
@@ -7,11 +7,15 @@
|
|
|
7
7
|
import { applyBrand } from "./brand.mjs";
|
|
8
8
|
import { assembleLayoutDeck } from "./layouts.mjs";
|
|
9
9
|
|
|
10
|
+
const SAMPLE_CSS = ".hd-embedded :is(.hd-gate, .hd-controls, .hd-chapters, .hd-presenter, .hd-slide-note, .hd-note, .hd-flash) { display: none !important; }";
|
|
11
|
+
// The slides a sample shows, each whole in its own 16:9 frame.
|
|
12
|
+
export const SAMPLE_SLIDES = ["hero", "features"];
|
|
13
|
+
|
|
10
14
|
const escapeHtml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
11
15
|
|
|
12
16
|
export function brandPreviewHtml(draft, { saved = false } = {}) {
|
|
13
17
|
const name = (draft?.name ?? "").trim() || "Your brand";
|
|
14
|
-
let html = assembleLayoutDeck({ slug: "brand-preview", title: name, layouts:
|
|
18
|
+
let html = assembleLayoutDeck({ slug: "brand-preview", title: name, layouts: SAMPLE_SLIDES })["index.html"];
|
|
15
19
|
// The hero's art slot is a stand-in a real deck fills; the preview has no art.
|
|
16
20
|
html = html.replace(/\s*<div class="art" role="presentation"><\/div>/, "");
|
|
17
21
|
html = html.replace(/(<div class="kicker">)brand-preview(<\/div>)/, saved ? "$1Your brand$2" : "$1Brand preview$2");
|
|
@@ -19,6 +23,9 @@ export function brandPreviewHtml(draft, { saved = false } = {}) {
|
|
|
19
23
|
"One sentence on what this deck is about.",
|
|
20
24
|
saved ? "Your colours, fonts and logo on a sample slide. Every new deck starts from this." : "Your colours, fonts and logo on a sample slide. Nothing is saved until you say yes.",
|
|
21
25
|
);
|
|
26
|
+
// Framed on the Brand page (one iframe per slide, #scene=hero / #scene=features),
|
|
27
|
+
// the sample is a picture of the slide: no play gate, controls or presenter.
|
|
28
|
+
html = html.replace("</head>", () => `<style data-brand-sample>${SAMPLE_CSS}</style>\n</head>`);
|
|
22
29
|
html = html.replace(/<title>[^<]*<\/title>/, () => `<title>${escapeHtml(name)} · ${saved ? "brand" : "brand preview"}</title>`);
|
|
23
30
|
return applyBrand(html, { name: draft?.name ?? undefined, colors: draft?.colors ?? {}, fonts: draft?.fonts ?? {}, logoUrl: draft?.logoUrl ?? undefined });
|
|
24
31
|
}
|
package/src/check.mjs
CHANGED
|
@@ -119,7 +119,7 @@ export function resolveDeckDir(arg, cwd = process.cwd()) {
|
|
|
119
119
|
// following symlinks). Anything that climbs out is refused, so release never
|
|
120
120
|
// uploads a file from elsewhere on the machine.
|
|
121
121
|
export function deckFileResolver(deckDir) {
|
|
122
|
-
const
|
|
122
|
+
const pathExists = deckPathResolver(deckDir);
|
|
123
123
|
return (ref) => {
|
|
124
124
|
let clean;
|
|
125
125
|
try {
|
|
@@ -127,6 +127,14 @@ export function deckFileResolver(deckDir) {
|
|
|
127
127
|
} catch {
|
|
128
128
|
return false;
|
|
129
129
|
}
|
|
130
|
+
return pathExists(clean);
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The same answer for an already-decoded path; `kind: "dir"` asks about a folder instead.
|
|
135
|
+
export function deckPathResolver(deckDir, { kind = "file" } = {}) {
|
|
136
|
+
const root = realpathSync(deckDir);
|
|
137
|
+
return (clean) => {
|
|
130
138
|
const path = isAbsolute(clean) ? join(root, clean) : resolve(root, clean);
|
|
131
139
|
const inside = (p) => p === root || p.startsWith(root + sep);
|
|
132
140
|
if (!inside(path)) return "outside";
|
|
@@ -134,7 +142,17 @@ export function deckFileResolver(deckDir) {
|
|
|
134
142
|
const real = realpathSync(path);
|
|
135
143
|
if (!inside(real)) return "outside";
|
|
136
144
|
if (relative(root, real).split(sep).some((part) => part.startsWith("."))) return "outside";
|
|
137
|
-
return lstatSync(real).isFile();
|
|
145
|
+
return kind === "dir" ? lstatSync(real).isDirectory() : lstatSync(real).isFile();
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// What deckFiles needs to walk a deck folder: whether a path is a file or a
|
|
150
|
+
// folder inside it, and a file's text.
|
|
151
|
+
export function deckFolder(deckDir) {
|
|
152
|
+
return {
|
|
153
|
+
pathExists: deckPathResolver(deckDir),
|
|
154
|
+
isDir: deckPathResolver(deckDir, { kind: "dir" }),
|
|
155
|
+
read: (path) => readFileSync(join(deckDir, path), "utf8"),
|
|
138
156
|
};
|
|
139
157
|
}
|
|
140
158
|
|
|
@@ -144,7 +162,7 @@ export async function checkDeck(dirArg, opts = {}, { fetch: fetchImpl = globalTh
|
|
|
144
162
|
const html = readFileSync(join(deckDir, "index.html"), "utf8");
|
|
145
163
|
const scriptPath = join(deckDir, "script.md");
|
|
146
164
|
const script = existsSync(scriptPath) ? readFileSync(scriptPath, "utf8") : "";
|
|
147
|
-
const { manifest, ...result } = await checkSources({ html, script }, { ...opts, hosted: false, exists: deckFileResolver(deckDir) }, { fetch: fetchImpl });
|
|
165
|
+
const { manifest, ...result } = await checkSources({ html, script }, { ...opts, hosted: false, exists: deckFileResolver(deckDir), ...deckFolder(deckDir) }, { fetch: fetchImpl });
|
|
148
166
|
const { errors, warnings } = result;
|
|
149
167
|
// A presenter over the title is a warning, not a failure: only a person can
|
|
150
168
|
// judge it. A slide that overflows the phone card is an error. Both need a
|
package/src/checkRules.mjs
CHANGED
|
@@ -36,6 +36,10 @@ export const MESSAGES = {
|
|
|
36
36
|
localRef: (ref) => `local reference does not resolve: ${ref}`,
|
|
37
37
|
outsideRef: (ref) => `local reference leaves the deck folder: ${ref}`,
|
|
38
38
|
hostedRef: (ref) => `local reference ${ref}: a hosted deck has no folder, so every file must be a URL; upload it with upload_deck_asset and use the URL it returns`,
|
|
39
|
+
scriptRef: (ref, from) => `${from} names ${ref}, which is not in the deck folder; add the file or fix the path`,
|
|
40
|
+
// Warnings: deliberate sometimes (a path built from a CDN base), so the deck still passes.
|
|
41
|
+
scriptTemplate: (from, samples) => `${from} builds file paths at runtime (${samples.slice(0, 3).join(", ")}${samples.length > 3 ? ", …" : ""}); release can't see them, so they 404 on the shared link. Write each file out as a quoted path (an object or array of "media/…" strings, in a script or a .json file it fetches) and pick from that`,
|
|
42
|
+
scriptComputed: (from, calls) => `${from} loads files from computed values (${calls.join(", ")}); release only ships files the deck names literally, so make sure each file it can load is written out as a quoted path in index.html, a script or a .json file the deck loads`,
|
|
39
43
|
forbidden: (phrase, where) => `forbidden phrase "${phrase}" appears in ${where}`,
|
|
40
44
|
note: (id) => `slide ${id}: needs a data-note naming the source of its facts`,
|
|
41
45
|
mobileTitle: (id, title) => `slide ${id}: title "${title}" is over ${MOBILE_TITLE_CHARS} characters and needs a data-title-mobile`,
|
|
@@ -61,7 +65,7 @@ export const MESSAGES = {
|
|
|
61
65
|
};
|
|
62
66
|
|
|
63
67
|
// The framed player's slide card on a phone (player.css): the screen less the
|
|
64
|
-
//
|
|
68
|
+
// 60px masthead, the 48px dock and 8px gutters.
|
|
65
69
|
export const HOSTED_LAYOUT_WHY = "presenter overlap and phone fit need a browser: run review_deck with draft=true";
|
|
66
70
|
export const PHONE = { width: 390, height: 844, card: { width: 374, height: 736 } };
|
|
67
71
|
|
|
@@ -295,6 +299,173 @@ export function localRefs(html) {
|
|
|
295
299
|
return [...new Set(refs.filter((r) => !/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(r) && !r.endsWith("/")))];
|
|
296
300
|
}
|
|
297
301
|
|
|
302
|
+
// Files a deck's own scripts load. `play` serves the folder, so any path a
|
|
303
|
+
// script asks for resolves; a released deck is uploaded file by file and its
|
|
304
|
+
// text rewritten to storage URLs, so a file only a script names has to be found
|
|
305
|
+
// here to ship, and a path built at runtime can't be rewritten at all.
|
|
306
|
+
|
|
307
|
+
// Extensions a quoted string must end in to count as a deck file.
|
|
308
|
+
const ASSET_EXT = /\.(?:json|csv|txt|vtt|mp3|m4a|aac|wav|ogg|opus|flac|mp4|m4v|webm|mov|png|jpe?g|webp|avif|gif|svg|ico|pdf|glb|gltf|bin|wasm|m?js|css|woff2?|ttf|otf|eot)$/i;
|
|
309
|
+
const SCHEME = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
|
|
310
|
+
const stripQuery = (s) => s.split("#")[0].split("?")[0];
|
|
311
|
+
const isScriptPath = (s) => !/[\s<>"'`(){}|\\^*]/.test(s) && !SCHEME.test(s) && ASSET_EXT.test(stripQuery(s));
|
|
312
|
+
const dirOf = (path) => path.slice(0, path.lastIndexOf("/") + 1);
|
|
313
|
+
|
|
314
|
+
// `ref` against the folder `dir` ("" for the deck root), as a decoded path
|
|
315
|
+
// inside the deck; null when it climbs out or does not decode. A leading "/"
|
|
316
|
+
// means the deck root, which is where `play` serves it from.
|
|
317
|
+
export function deckPath(dir, ref) {
|
|
318
|
+
let clean;
|
|
319
|
+
try {
|
|
320
|
+
clean = decodeURIComponent(stripQuery(ref.trim()));
|
|
321
|
+
} catch {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
const parts = clean.startsWith("/") ? [] : dir.split("/").filter(Boolean);
|
|
325
|
+
for (const p of clean.split("/")) {
|
|
326
|
+
if (!p || p === ".") continue;
|
|
327
|
+
if (p !== "..") parts.push(p);
|
|
328
|
+
else if (!parts.pop()) return null;
|
|
329
|
+
}
|
|
330
|
+
return parts.join("/") || null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// A JavaScript source's string literals, comments skipped: { value, interpolated }
|
|
334
|
+
// for each, plus `code`, the source with every string emptied, so a call can be
|
|
335
|
+
// read without its arguments' text getting in the way.
|
|
336
|
+
export function jsStrings(src) {
|
|
337
|
+
const strings = [];
|
|
338
|
+
let code = "";
|
|
339
|
+
let i = 0;
|
|
340
|
+
while (i < src.length) {
|
|
341
|
+
const c = src[i];
|
|
342
|
+
if (c === "/" && src[i + 1] === "/") {
|
|
343
|
+
const end = src.indexOf("\n", i);
|
|
344
|
+
i = end < 0 ? src.length : end;
|
|
345
|
+
} else if (c === "/" && src[i + 1] === "*") {
|
|
346
|
+
const end = src.indexOf("*/", i + 2);
|
|
347
|
+
i = end < 0 ? src.length : end + 2;
|
|
348
|
+
} else if (c === '"' || c === "'" || c === "`") {
|
|
349
|
+
let j = i + 1;
|
|
350
|
+
let depth = 0;
|
|
351
|
+
let interpolated = false;
|
|
352
|
+
while (j < src.length) {
|
|
353
|
+
const d = src[j];
|
|
354
|
+
if (d === "\\") j += 2;
|
|
355
|
+
else if (c === "`" && depth === 0 && d === "$" && src[j + 1] === "{") { interpolated = true; depth = 1; j += 2; }
|
|
356
|
+
else if (depth > 0) { depth += d === "{" ? 1 : d === "}" ? -1 : 0; j++; }
|
|
357
|
+
else if (d === c || (c !== "`" && d === "\n")) break;
|
|
358
|
+
else j++;
|
|
359
|
+
}
|
|
360
|
+
strings.push({ value: src.slice(i + 1, j), interpolated });
|
|
361
|
+
code += c + c;
|
|
362
|
+
i = j + 1;
|
|
363
|
+
} else {
|
|
364
|
+
code += c;
|
|
365
|
+
i++;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return { strings, code };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// A template string that builds a local file path: every static piece is
|
|
372
|
+
// path-like, and there is a folder in front of the interpolation or a file
|
|
373
|
+
// extension after it (`media/lines/${id}.mp3`, `${dir}/clip.mp4`).
|
|
374
|
+
function templatePath(raw) {
|
|
375
|
+
const pieces = raw.split(/\$\{(?:[^{}]|\{[^{}]*\})*\}/);
|
|
376
|
+
if (pieces.length < 2 || pieces.some((p) => !/^[\w./-]*$/.test(p))) return false;
|
|
377
|
+
const [head, tail] = [pieces[0], pieces.at(-1)];
|
|
378
|
+
if (SCHEME.test(head)) return false;
|
|
379
|
+
return head.includes("/") || ASSET_EXT.test(tail);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Calls that load a file from a value check can't read.
|
|
383
|
+
// `code` has every string emptied, so a value starts with a name or call, or
|
|
384
|
+
// with an emptied string joined by + ('media/' + id + '.mp3').
|
|
385
|
+
const COMPUTED = "(?:(?![\\s)\"'`])|([\"'`])\\1\\s*\\+)";
|
|
386
|
+
const COMPUTED_LOADS = [
|
|
387
|
+
{ name: "fetch(…)", re: new RegExp(String.raw`(?<![\w$.])fetch\s*\(\s*` + COMPUTED) },
|
|
388
|
+
{ name: "new Audio(…)", re: new RegExp(String.raw`\bnew\s+Audio\s*\(\s*` + COMPUTED) },
|
|
389
|
+
{ name: ".src = …", re: new RegExp(String.raw`\.src\s*=\s*(?!=)(?!URL\.createObjectURL)` + COMPUTED) },
|
|
390
|
+
];
|
|
391
|
+
|
|
392
|
+
// Every file the released page needs, following references through the files
|
|
393
|
+
// it bundles: index.html's own refs (localRefs), the paths quoted in its inline
|
|
394
|
+
// scripts, and in each bundled .js or .json the quoted paths, in each .css its
|
|
395
|
+
// url()s and @imports, until nothing new turns up. A script path resolves against
|
|
396
|
+
// the page first (that is how fetch() and Audio resolve; an ES import resolves
|
|
397
|
+
// against its module, so a same-named file at the root wins, as it does in the
|
|
398
|
+
// importer's rewrite) and then the script's own folder; a stylesheet's against
|
|
399
|
+
// its own folder first. A quoted path counts only when it names a real file, so
|
|
400
|
+
// ordinary strings never get bundled.
|
|
401
|
+
//
|
|
402
|
+
// `pathExists(path)` answers for a decoded deck-relative path: true, false, "outside"
|
|
403
|
+
// or "hosted"; `read(path)` returns a file's text (omitted for a hosted deck,
|
|
404
|
+
// which has no folder); `isDir(path)` lets a quoted path in a real subfolder
|
|
405
|
+
// that names no file be reported as missing (one at the deck root could be
|
|
406
|
+
// anything, a vendored library's own "./worker.js" say, so it is left alone).
|
|
407
|
+
// Returns { files: [{ path, from }], missing: [{ ref, from }], templates: [{ from, samples }], computed: [{ from, calls }] }:
|
|
408
|
+
// `from` is "html" for index.html's own attribute refs, "index.html" for a path
|
|
409
|
+
// quoted in its inline scripts, or the bundled file that named it.
|
|
410
|
+
export function deckFiles(html, { pathExists: exists = () => false, read, isDir = () => false } = {}) {
|
|
411
|
+
const files = new Map();
|
|
412
|
+
const queue = [];
|
|
413
|
+
const missing = [];
|
|
414
|
+
const templates = [];
|
|
415
|
+
const computed = [];
|
|
416
|
+
const add = (path, from) => {
|
|
417
|
+
if (files.has(path)) return;
|
|
418
|
+
files.set(path, from);
|
|
419
|
+
queue.push(path);
|
|
420
|
+
};
|
|
421
|
+
for (const ref of localRefs(html)) {
|
|
422
|
+
const path = deckPath("", ref);
|
|
423
|
+
if (path) add(path, "html");
|
|
424
|
+
}
|
|
425
|
+
// `url` is true for a CSS url()/@import, which is a file reference whatever its extension.
|
|
426
|
+
const quoted = (value, from, dirs, { url = false } = {}) => {
|
|
427
|
+
if (url ? !value || SCHEME.test(value) || value.startsWith("#") : !isScriptPath(value)) return;
|
|
428
|
+
const candidates = [...new Set(dirs.map((d) => deckPath(d, value)).filter(Boolean))];
|
|
429
|
+
const found = candidates.find((p) => exists(p) === true);
|
|
430
|
+
// Missing only where the browser would look (the page for a script, the
|
|
431
|
+
// stylesheet's folder for CSS), and only in a real subfolder there.
|
|
432
|
+
if (found) add(found, from);
|
|
433
|
+
else if (candidates.length && candidates.every((p) => exists(p) === false) && dirOf(candidates[0]) && isDir(dirOf(candidates[0])) && !missing.some((m) => m.ref === value && m.from === from)) missing.push({ ref: value, from });
|
|
434
|
+
};
|
|
435
|
+
const scanJs = (src, from, dir, { json = false } = {}) => {
|
|
436
|
+
const { strings, code } = jsStrings(src);
|
|
437
|
+
if (json) {
|
|
438
|
+
for (const s of strings) quoted(s.value, from, ["", dir]);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
const built = [];
|
|
442
|
+
for (const s of strings) {
|
|
443
|
+
if (!s.interpolated) quoted(s.value, from, ["", dir]);
|
|
444
|
+
else if (templatePath(s.value)) built.push(`\`${s.value}\``);
|
|
445
|
+
}
|
|
446
|
+
if (built.length) templates.push({ from, samples: [...new Set(built)] });
|
|
447
|
+
const calls = COMPUTED_LOADS.filter((l) => l.re.test(code)).map((l) => l.name);
|
|
448
|
+
if (calls.length) computed.push({ from, calls });
|
|
449
|
+
};
|
|
450
|
+
for (const [, attrs, body] of uncommented(html).matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)) {
|
|
451
|
+
if (!/\ssrc\s*=/i.test(attrs) && !/\stype\s*=\s*["']?(?:application\/(?:ld\+)?json|importmap)/i.test(attrs)) scanJs(body, "index.html", "");
|
|
452
|
+
}
|
|
453
|
+
while (read && queue.length) {
|
|
454
|
+
const path = queue.shift();
|
|
455
|
+
if (!/\.(?:m?js|css|json)$/i.test(path) || exists(path) !== true) continue;
|
|
456
|
+
const text = read(path);
|
|
457
|
+
const dir = dirOf(path);
|
|
458
|
+
if (/\.css$/i.test(path)) {
|
|
459
|
+
const css = text.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
460
|
+
for (const m of css.matchAll(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)'"\s]+))\s*\)|@import\s+(?:"([^"]*)"|'([^']*)')/gi)) {
|
|
461
|
+
const ref = m[1] ?? m[2] ?? m[3] ?? m[4] ?? m[5];
|
|
462
|
+
quoted(ref.trim(), path, [dir, ""], { url: true });
|
|
463
|
+
}
|
|
464
|
+
} else scanJs(text, path, dir, { json: /\.json$/i.test(path) });
|
|
465
|
+
}
|
|
466
|
+
return { files: [...files].map(([path, from]) => ({ path, from })), missing, templates, computed };
|
|
467
|
+
}
|
|
468
|
+
|
|
298
469
|
export function phraseList(value) {
|
|
299
470
|
if (!value) return [];
|
|
300
471
|
if (typeof value === "string") return value.split(",").map((s) => s.trim()).filter(Boolean);
|
|
@@ -414,9 +585,9 @@ export function timelineErrors(clip, timeline) {
|
|
|
414
585
|
captions.forEach((c, i) => {
|
|
415
586
|
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));
|
|
416
587
|
});
|
|
417
|
-
// Caption text carries the script's spelling; the timed words
|
|
418
|
-
// transcript's (which may spell a name differently, as the
|
|
419
|
-
// pronunciation map allows), so coverage is checked on the text.
|
|
588
|
+
// Caption text carries the script's spelling; the timed words of an older
|
|
589
|
+
// clip carry the transcript's (which may spell a name differently, as the
|
|
590
|
+
// narrate guard's pronunciation map allows), so coverage is checked on the text.
|
|
420
591
|
const covered = captions.map((c) => String(c.text ?? "")).join(" ");
|
|
421
592
|
const timed = captions.every((c) => Array.isArray(c.words) && c.words.length);
|
|
422
593
|
if (!timed || normalizeWords(covered) !== normalizeWords(String(clip.text ?? ""))) errs.push(MESSAGES.words(clip.id));
|
|
@@ -547,6 +718,9 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
|
|
|
547
718
|
}
|
|
548
719
|
const exists = opts.hosted ? () => "hosted" : (opts.exists ?? (() => true));
|
|
549
720
|
const errors = htmlErrors(html, { exists, forbid, script, hosted: !!opts.hosted });
|
|
721
|
+
// Without a folder (hosted, or a caller that passes no reader) only the inline scripts are read.
|
|
722
|
+
const walked = deckFiles(html, opts.hosted ? {} : { pathExists: opts.pathExists, read: opts.read, isDir: opts.isDir });
|
|
723
|
+
for (const m of walked.missing) errors.push(MESSAGES.scriptRef(m.ref, m.from));
|
|
550
724
|
const ids = slides(html).map((s) => s.id);
|
|
551
725
|
const player = playerScript(html);
|
|
552
726
|
let manifest = null;
|
|
@@ -602,6 +776,8 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
|
|
|
602
776
|
}
|
|
603
777
|
}
|
|
604
778
|
const warnings = transitionWarnings(html, script, manifest);
|
|
779
|
+
for (const t of walked.templates) warnings.push(MESSAGES.scriptTemplate(t.from, t.samples));
|
|
780
|
+
for (const c of walked.computed) warnings.push(MESSAGES.scriptComputed(c.from, c.calls));
|
|
605
781
|
if (opts.hosted) {
|
|
606
782
|
for (const url of absoluteRefs(html)) if (!isHostedAsset(url) && /\.(png|jpe?g|gif|webp|avif|svg|mp4|webm|mov)(\?|#|$)/i.test(url)) warnings.push(MESSAGES.hotlink(url));
|
|
607
783
|
warnings.push(MESSAGES.layoutUnchecked(HOSTED_LAYOUT_WHY));
|
package/src/commands.mjs
CHANGED
|
@@ -322,6 +322,9 @@ commands:
|
|
|
322
322
|
file an image, video or font under the deck; prints its hosted URL
|
|
323
323
|
guide [workflow|format|script|design|layouts|presenter|transitions|check]
|
|
324
324
|
the skill, one topic at a time (what deck_guide serves)
|
|
325
|
+
play <slug|deck-dir> [--voice <the voice it was narrated in>] [--slug <slug>] [--port <n>] [--no-open] [--json]
|
|
326
|
+
play the folder as it is, committed or not, with its real face and voice, on 127.0.0.1
|
|
327
|
+
until Ctrl-C; nothing is written, uploaded or shared, and changed lines need narrate first
|
|
325
328
|
release <slug|deck-dir> [--to "<name>" [--email <e>]]... [--qa] [--no-notify] [--title t] [--voice v] [--captions-only (--hosted)]
|
|
326
329
|
on a committed deck: check → narrate → import → share new per --to
|
|
327
330
|
share new|list|on|off|forward|mute|unmute|rm|url
|
|
@@ -362,6 +365,12 @@ commands:
|
|
|
362
365
|
brand import <url|file|slug> [--json]
|
|
363
366
|
read a brand from your website, a .pptx / .potx / .pdf or one of your decks: shows its
|
|
364
367
|
colours, fonts and logo and a preview link; save it with brand set --draft <token>
|
|
368
|
+
brand revise "<change>" [--json]
|
|
369
|
+
a draft of your brand with that change ("darker accent, serif titles") and a
|
|
370
|
+
preview link; save it with brand set --draft <token>
|
|
371
|
+
brand history the brand's last changes, from the dashboard, chat or here
|
|
372
|
+
brand undo [<version>]
|
|
373
|
+
undo the last change, or go back to a version from brand history
|
|
365
374
|
brand edit edit design.md (voice, do and don't) in $EDITOR
|
|
366
375
|
brand fonts the curated font pairings (display + body + mono)
|
|
367
376
|
brand clear forget the brand; new decks get the template look
|
|
@@ -393,7 +402,7 @@ commands:
|
|
|
393
402
|
avatar connect import your avatar from Minimoji with a code you enter on minimoji.com
|
|
394
403
|
avatar default <name|id|dash> make it the default
|
|
395
404
|
avatar remove <name|id> remove it; decks already shared keep working, captions only
|
|
396
|
-
deck avatar <slug> <name|id|none|default> the deck's own presenter (
|
|
405
|
+
deck avatar <slug> <name|id|none|default> the deck's own presenter (play and the draft link now; share links at its next release)
|
|
397
406
|
narrator [--json] who narrates your decks (a face plus a voice) and the default
|
|
398
407
|
narrator new a link where you pick a face and a voice; it becomes the default
|
|
399
408
|
narrator default <name|id|dash> make it the default
|
|
@@ -693,6 +702,13 @@ function commandTable(pos, rest, opts) {
|
|
|
693
702
|
const out = await release(pos.slice(1), opts, opts.json ? { log: (line) => console.error(line) } : {});
|
|
694
703
|
if (opts.json) console.log(JSON.stringify(out, null, 2));
|
|
695
704
|
},
|
|
705
|
+
play: async () => {
|
|
706
|
+
const { play } = await import("./play.mjs");
|
|
707
|
+
await play(pos.slice(1), opts, opts.json ? {
|
|
708
|
+
log: (line) => console.error(line),
|
|
709
|
+
started: ({ url, slug, deckDir, presenter, who }) => console.log(JSON.stringify({ url, slug, deckDir, presenter, who })),
|
|
710
|
+
} : {});
|
|
711
|
+
},
|
|
696
712
|
narrate: async () => {
|
|
697
713
|
const { narrate } = await import("./narrate.mjs");
|
|
698
714
|
const result = await narrate(pos.slice(1), opts, opts.json ? { log: (line) => console.error(line) } : {});
|
|
@@ -774,7 +790,7 @@ function commandTable(pos, rest, opts) {
|
|
|
774
790
|
"deck avatar": async () => {
|
|
775
791
|
if (rest.length < 2) throw new Error("usage: deck avatar <slug> <name|id|none|default>");
|
|
776
792
|
const out = await ops.setDeckAvatar(opts, rest[0], rest.slice(1).join(" "));
|
|
777
|
-
console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.slug} → ${out.avatar} (
|
|
793
|
+
console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.slug} → ${out.avatar} (play and the draft link now; share links at its next release)`);
|
|
778
794
|
},
|
|
779
795
|
narrator: async () => {
|
|
780
796
|
const { narratorList } = await import("./narrator.mjs");
|
|
@@ -819,7 +835,7 @@ function commandTable(pos, rest, opts) {
|
|
|
819
835
|
"deck narrator": async () => {
|
|
820
836
|
if (rest.length < 2) throw new Error("usage: deck narrator <slug> <name|id|none|default>");
|
|
821
837
|
const out = await ops.setDeckNarrator(opts, rest[0], rest.slice(1).join(" "));
|
|
822
|
-
console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.slug} → ${out.narrator} (
|
|
838
|
+
console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.slug} → ${out.narrator} (voice at its next narrate; face in play and the draft link now, on share links at its next release)`);
|
|
823
839
|
},
|
|
824
840
|
"preview setup": async () => {
|
|
825
841
|
const { setupCapture } = await import("./preview-capture.mjs");
|
|
@@ -872,6 +888,9 @@ function commandTable(pos, rest, opts) {
|
|
|
872
888
|
"brand fonts": async () => (await import("./brandCli.mjs")).brandFonts(opts),
|
|
873
889
|
"brand clear": async () => (await import("./brandCli.mjs")).brandClear(opts),
|
|
874
890
|
"brand import": async () => (await import("./brandCli.mjs")).brandImport(rest[0], opts),
|
|
891
|
+
"brand revise": async () => (await import("./brandCli.mjs")).brandRevise(rest, opts),
|
|
892
|
+
"brand history": async () => (await import("./brandCli.mjs")).brandHistory(opts),
|
|
893
|
+
"brand undo": async () => (await import("./brandCli.mjs")).brandUndo(rest[0], opts),
|
|
875
894
|
review: () => review(pos.slice(1), opts),
|
|
876
895
|
"review-job": () => reviewJob(pos.slice(1), opts),
|
|
877
896
|
poster: () => poster(pos.slice(1), opts),
|
package/src/importer.mjs
CHANGED
|
@@ -180,6 +180,33 @@ export function rewriteHtmlAssetUrls(html, htmlEntry, assets, uploadedByName) {
|
|
|
180
180
|
return rewriteTextRefs(rewriteCssUrls(out, assetMap), assetMap);
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
// One text asset rewritten against the uploaded URLs.
|
|
184
|
+
export function rewriteTextAsset(asset, assets, uploadedByName) {
|
|
185
|
+
const text = textFromBytes(asset.data);
|
|
186
|
+
return asset.contentType === "text/css"
|
|
187
|
+
? rewriteCssAssetUrls(text, asset.sourcePath, assets, uploadedByName)
|
|
188
|
+
: rewriteTextAssetUrls(text, asset.sourcePath, assets, uploadedByName);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// The text assets in the order they can be saved: each round holds the ones
|
|
192
|
+
// that name no text asset still unsaved. Saving a name again deletes its old
|
|
193
|
+
// copy, so a script rewritten before the file it fetches is saved would embed a
|
|
194
|
+
// dead URL. A cycle (two scripts naming each other) goes last as one round, its
|
|
195
|
+
// refs to each other left relative.
|
|
196
|
+
export function textAssetRounds(assets) {
|
|
197
|
+
const PENDING = "hd-pending:";
|
|
198
|
+
const rounds = [];
|
|
199
|
+
let pending = assets.filter(isTextAsset);
|
|
200
|
+
while (pending.length) {
|
|
201
|
+
const marks = new Map(pending.map((a) => [a.name, PENDING + a.name]));
|
|
202
|
+
const ready = pending.filter((a) => !rewriteTextAsset(a, assets, marks).split(PENDING + a.name).join("").includes(PENDING));
|
|
203
|
+
const round = ready.length ? ready : pending;
|
|
204
|
+
rounds.push(round);
|
|
205
|
+
pending = pending.filter((a) => !round.includes(a));
|
|
206
|
+
}
|
|
207
|
+
return rounds;
|
|
208
|
+
}
|
|
209
|
+
|
|
183
210
|
export function isTextAsset(asset) {
|
|
184
211
|
const type = String(asset.contentType || "").split(";")[0].trim().toLowerCase();
|
|
185
212
|
return type.startsWith("text/") || TEXT_TYPES.has(type);
|
|
@@ -206,12 +233,23 @@ function rewriteCssUrls(input, assetMap) {
|
|
|
206
233
|
});
|
|
207
234
|
}
|
|
208
235
|
|
|
236
|
+
// Quoted strings that name an uploaded file. A pair that names nothing gives
|
|
237
|
+
// up only its opening quote, so one apostrophe ("Don't stop", a data-note's
|
|
238
|
+
// "company's") can't throw the pairing off for every path after it.
|
|
209
239
|
function rewriteTextRefs(input, assetMap) {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
240
|
+
const text = rewriteEscapedTextRefs(input, assetMap);
|
|
241
|
+
const pair = /(["'`])([^"'`]+)\1/g;
|
|
242
|
+
let out = "";
|
|
243
|
+
let from = 0;
|
|
244
|
+
for (let m = pair.exec(text); m; m = pair.exec(text)) {
|
|
245
|
+
const [match, quote, raw] = m;
|
|
246
|
+
const url = !(quote === "`" && raw.includes("${")) && resolveLocalUrl(raw, assetMap);
|
|
247
|
+
if (url) {
|
|
248
|
+
out += `${text.slice(from, m.index)}${quote}${url}${quote}`;
|
|
249
|
+
from = m.index + match.length;
|
|
250
|
+
} else pair.lastIndex = m.index + 1;
|
|
251
|
+
}
|
|
252
|
+
return out + text.slice(from);
|
|
215
253
|
}
|
|
216
254
|
|
|
217
255
|
function rewriteEscapedTextRefs(input, assetMap) {
|
package/src/ops.mjs
CHANGED
|
@@ -6,10 +6,10 @@ import {
|
|
|
6
6
|
bytesFromText,
|
|
7
7
|
isTextAsset,
|
|
8
8
|
prepareDeckImport,
|
|
9
|
-
rewriteCssAssetUrls,
|
|
10
9
|
rewriteHtmlAssetUrls,
|
|
11
|
-
|
|
10
|
+
rewriteTextAsset,
|
|
12
11
|
slugify,
|
|
12
|
+
textAssetRounds,
|
|
13
13
|
textFromBytes,
|
|
14
14
|
titleFromFilename,
|
|
15
15
|
} from "./importer.mjs";
|
|
@@ -585,23 +585,24 @@ export async function importDeckFromBytes(opts = {}, fields = {}, adapter = {})
|
|
|
585
585
|
deckCreated = true;
|
|
586
586
|
}
|
|
587
587
|
|
|
588
|
-
// Phase 1: upload every asset
|
|
589
|
-
|
|
588
|
+
// Phase 1: upload every binary asset.
|
|
589
|
+
const binary = parsed.assets.filter((a) => !isTextAsset(a));
|
|
590
|
+
await mapWithConcurrency(binary, concurrency, async (asset, index) => {
|
|
590
591
|
onStage(`uploading asset ${index + 1}/${parsed.assets.length}: ${asset.name}`);
|
|
591
592
|
await saveDeckAsset(asset);
|
|
592
593
|
});
|
|
593
594
|
|
|
594
|
-
// Phase 2 (
|
|
595
|
-
//
|
|
596
|
-
//
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
}
|
|
595
|
+
// Phase 2 (after phase 1): text assets, rewritten against the URLs saved so
|
|
596
|
+
// far, each after the text assets it names (textAssetRounds). Saving a name
|
|
597
|
+
// twice deletes the first copy, so a script must never embed a URL from a
|
|
598
|
+
// save that a later save replaces.
|
|
599
|
+
let done = binary.length;
|
|
600
|
+
for (const round of textAssetRounds(parsed.assets)) {
|
|
601
|
+
await mapWithConcurrency(round, concurrency, async (asset) => {
|
|
602
|
+
onStage(`uploading asset ${++done}/${parsed.assets.length}: ${asset.name}`);
|
|
603
|
+
await saveDeckAsset(asset, bytesFromText(rewriteTextAsset(asset, parsed.assets, uploadedByName)), asset.contentType);
|
|
604
|
+
});
|
|
605
|
+
}
|
|
605
606
|
|
|
606
607
|
onStage("storing deck HTML");
|
|
607
608
|
const html = rewriteHtmlAssetUrls(parsed.html, parsed.htmlEntry, parsed.assets, uploadedByName);
|
|
@@ -964,6 +965,11 @@ export async function presenterFor(opts, slug, voice) {
|
|
|
964
965
|
return (await client(opts)).query(api.avatars.presenterFor, { slug, ...(voice ? { voice: String(voice) } : {}) });
|
|
965
966
|
}
|
|
966
967
|
|
|
968
|
+
// The same answer as presenterFor plus who presents in words: { presenter, who }.
|
|
969
|
+
export async function presenterWithReason(opts, slug, voice) {
|
|
970
|
+
return (await client(opts)).query(api.avatars.presenterWithReason, { slug, ...(voice ? { voice: String(voice) } : {}) });
|
|
971
|
+
}
|
|
972
|
+
|
|
967
973
|
// ---- narrators (convex/narrators.ts) ------------------------------------------
|
|
968
974
|
// Who narrates your decks: a face plus a voice. D.A.S.H. is built in. As with
|
|
969
975
|
// avatars, an agent never adds one: `narrator new` hands back a link.
|
|
@@ -1155,6 +1161,26 @@ export async function importBrand(opts, { url, slug, bytes, fileName } = {}) {
|
|
|
1155
1161
|
return c.action(api.brandNode.createDraft, draftArgs(extractFile(data, fileName)));
|
|
1156
1162
|
}
|
|
1157
1163
|
|
|
1164
|
+
// Revise the brand with a prompt ("darker accent, serif titles"): a draft
|
|
1165
|
+
// like importBrand's, with a previewUrl; nothing is saved until
|
|
1166
|
+
// setBrand({ draft: token }).
|
|
1167
|
+
export async function reviseBrand(opts, { instruction } = {}) {
|
|
1168
|
+
const ask = String(instruction ?? "").trim();
|
|
1169
|
+
if (!ask) throw new Error('say what to change, e.g. "make the accent darker and use a serif for titles"');
|
|
1170
|
+
return (await client(opts)).action(api.brandRevise.revise, { instruction: ask });
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// The brand's last changes, newest first (the first is the brand now).
|
|
1174
|
+
export async function brandHistory(opts = {}) {
|
|
1175
|
+
return { versions: await (await client(opts)).query(api.brand.history, {}) };
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// Undo the brand's last change, or go back to one version from brandHistory.
|
|
1179
|
+
export async function undoBrand(opts = {}, { version } = {}) {
|
|
1180
|
+
const c = await client(opts);
|
|
1181
|
+
return version ? c.mutation(api.brand.restore, { version: String(version) }) : c.mutation(api.brand.undo, {});
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1158
1184
|
export async function clearBrand(opts = {}) {
|
|
1159
1185
|
await (await client(opts)).mutation(api.brand.clear, {});
|
|
1160
1186
|
return { cleared: true };
|
package/src/play.mjs
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// hyperdecks play <slug|deck-dir> [--voice v] [--slug s] [--port n] [--no-open] [--json]
|
|
2
|
+
//
|
|
3
|
+
// Plays the working folder as it is, committed or not, the way it will play once
|
|
4
|
+
// released: the owner's face goes on the player tag of the page it serves, in
|
|
5
|
+
// memory only. It writes no file, uploads nothing, creates no share and renders
|
|
6
|
+
// no audio, so it plays the manifest index.html already points at; changed lines
|
|
7
|
+
// need `narrate` first. The server listens on 127.0.0.1 behind a random path until
|
|
8
|
+
// Ctrl-C. The deck's script.md and evidence.md are not served (preview-capture.mjs
|
|
9
|
+
// serves only the file types a page loads).
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { basename, join, relative } from "node:path";
|
|
12
|
+
import { playerScript, sectionIds } from "./checkRules.mjs";
|
|
13
|
+
import { resolveDeckDir } from "./check.mjs";
|
|
14
|
+
import * as ops from "./ops.mjs";
|
|
15
|
+
import { playsCaptionsOnly, presenterLine, withAvatar } from "./presenter.mjs";
|
|
16
|
+
import { serveSource } from "./preview-capture.mjs";
|
|
17
|
+
import { openBrowser } from "./runtime.mjs";
|
|
18
|
+
|
|
19
|
+
const USAGE = "usage: play <slug|deck-dir> [--voice <name|id>] [--slug <slug>] [--port <n>] [--no-open]";
|
|
20
|
+
|
|
21
|
+
function portOf(value) {
|
|
22
|
+
if (value === undefined) return 0;
|
|
23
|
+
const port = Number(value);
|
|
24
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`--port must be a port number (got ${value})`);
|
|
25
|
+
return port;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// A path `narrate` and `import` take: the argument to play may be a slug.
|
|
29
|
+
function pathFrom(cwd, deckDir) {
|
|
30
|
+
const rel = relative(cwd ?? process.cwd(), deckDir);
|
|
31
|
+
return !rel ? "." : rel.startsWith("..") ? deckDir : rel;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The face to add, and { who } in words. Never stops the play: without an answer
|
|
35
|
+
// the page plays as written, as a release does when this lookup fails.
|
|
36
|
+
async function lookUpPresenter(opts, { slug, path, html, log }) {
|
|
37
|
+
const narrate = `hyperdecks narrate ${path}`;
|
|
38
|
+
const lead = playsCaptionsOnly(html) ? "captions only" : "playing as written";
|
|
39
|
+
if (!playerScript(html)?.manifest) {
|
|
40
|
+
log(`⚠ no narration yet, so this plays silent: run \`${narrate}\` first`);
|
|
41
|
+
return { presenter: null, who: null };
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const found = await ops.presenterWithReason(opts, slug, opts.voice);
|
|
45
|
+
if (!found.who) log(`⚠ ${lead}: no finished narration on this account for "${slug}": run \`${narrate}\` first`);
|
|
46
|
+
return found;
|
|
47
|
+
} catch (e) {
|
|
48
|
+
const why = ops.errorText(e);
|
|
49
|
+
if (/deck not found/.test(why)) log(`⚠ ${lead}: "${slug}" is not on this account yet, so it has no presenter. Register it (\`hyperdecks import ${join(path, "index.html")} --slug ${slug} --status draft\`), then run \`${narrate}\``);
|
|
50
|
+
else if (/forbidden/.test(why)) log(`⚠ ${lead}: the deck "${slug}" belongs to another account. Log in as its owner, or pass --slug with yours`);
|
|
51
|
+
else log(`⚠ ${lead}: could not ask who presents (${why}). Your avatar is not the problem; try again, and if it keeps failing this CLI may be newer than the backend`);
|
|
52
|
+
return { presenter: null, who: null };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Says what the served page does, not what the lookup answered.
|
|
57
|
+
function sayWhoPresents({ html, page, presenter, who, log }) {
|
|
58
|
+
if (presenter && page === html) log("⚠ the player tag already names a presenter, so it plays as written");
|
|
59
|
+
else if (who?.face && !presenter && playsCaptionsOnly(html)) log(`⚠ captions only: the page names no presenter, and a face for voice "${who.voice}" is not added here`);
|
|
60
|
+
else if (who) log(presenterLine(who));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// A warning only. An error with text is the backend's answer (the local script
|
|
64
|
+
// differs from the hosted one, a line that won't compile), so it is shown; no
|
|
65
|
+
// script.md, or a backend that could not say, is not worth a line.
|
|
66
|
+
async function warnIfScriptChanged(opts, { slug, deckDir, path, html, who, log }) {
|
|
67
|
+
try {
|
|
68
|
+
const script = readFileSync(join(deckDir, "script.md"), "utf8");
|
|
69
|
+
const plan = await ops.previewNarration(opts, { slug, script, voice: who.voice, sectionIds: sectionIds(html) });
|
|
70
|
+
const changed = plan.slides.filter((s) => s.render).map((s) => s.id);
|
|
71
|
+
if (changed.length) log(`⚠ script.md changed since the last narration (${changed.join(", ")}); this plays the old audio. Run \`hyperdecks narrate ${path}\` to hear the new lines`);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
if (typeof e?.data === "string") log(`⚠ could not compare script.md with the last narration: ${e.data}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Starts serving and returns; the caller closes it. `play` is the command.
|
|
78
|
+
export async function startPlay(pos, opts = {}, { log = (line) => console.log(line), open = openBrowser, cwd } = {}) {
|
|
79
|
+
const [deckDirArg] = pos;
|
|
80
|
+
if (!deckDirArg) throw new Error(USAGE);
|
|
81
|
+
for (const k of ["voice", "slug", "port"]) if (k in opts && typeof opts[k] !== "string") throw new Error(`--${k} requires a value`);
|
|
82
|
+
const port = portOf(opts.port);
|
|
83
|
+
const deckDir = resolveDeckDir(deckDirArg, cwd);
|
|
84
|
+
const path = pathFrom(cwd, deckDir);
|
|
85
|
+
const slug = opts.slug || basename(deckDir);
|
|
86
|
+
const file = join(deckDir, "index.html");
|
|
87
|
+
const html = readFileSync(file, "utf8");
|
|
88
|
+
|
|
89
|
+
const { presenter, who } = await lookUpPresenter(opts, { slug, path, html, log });
|
|
90
|
+
const page = presenter ? withAvatar(html, presenter) : html;
|
|
91
|
+
sayWhoPresents({ html, page, presenter, who, log });
|
|
92
|
+
// --voice names the voice it was narrated in, so there is nothing to compare it to.
|
|
93
|
+
if (who && !opts.voice) await warnIfScriptChanged(opts, { slug, deckDir, path, html, who, log });
|
|
94
|
+
|
|
95
|
+
let server;
|
|
96
|
+
try {
|
|
97
|
+
server = await serveSource({ file, html: page, port });
|
|
98
|
+
} catch (e) {
|
|
99
|
+
throw new Error(e?.code === "EADDRINUSE" ? `port ${port} is in use; pick another with --port` : e?.message || String(e));
|
|
100
|
+
}
|
|
101
|
+
log(`▶ playing ${slug}: ${server.url}\n Ctrl-C to stop. Nothing is written, uploaded or shared.`);
|
|
102
|
+
if (!opts["no-open"]) open(server.url);
|
|
103
|
+
return { url: server.url, close: server.close, deckDir, slug, presenter: page === html ? null : presenter, who: who ?? null };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function play(pos, opts = {}, io = {}) {
|
|
107
|
+
const session = await startPlay(pos, opts, io);
|
|
108
|
+
await new Promise((stopped) => {
|
|
109
|
+
const stop = () => {
|
|
110
|
+
process.off("SIGINT", stop);
|
|
111
|
+
process.off("SIGTERM", stop);
|
|
112
|
+
stopped();
|
|
113
|
+
};
|
|
114
|
+
process.on("SIGINT", stop);
|
|
115
|
+
process.on("SIGTERM", stop);
|
|
116
|
+
io.started?.(session);
|
|
117
|
+
});
|
|
118
|
+
await session.close();
|
|
119
|
+
}
|
package/src/presenter.mjs
CHANGED
|
@@ -1,26 +1,33 @@
|
|
|
1
1
|
// The released deck's presenter (roadmap: avatars-pick-make-from-a-selfie-or-
|
|
2
2
|
// import-your-minimoji). A deck narrated in the owner's own voice is written
|
|
3
3
|
// captions-only (data-presenter="none"); at release, when the owner has an
|
|
4
|
-
// avatar, its player tag gets that face instead. Shared by
|
|
5
|
-
// (convex/deckSources.ts
|
|
6
|
-
// rewrite the tag the same way. A tag that already names a presenter (D.A.S.H.
|
|
4
|
+
// avatar, its player tag gets that face instead. Shared by both releases
|
|
5
|
+
// (convex/deckSources.ts, release.mjs), `hyperdecks play` and the /p draft
|
|
6
|
+
// preview, so all rewrite the tag the same way; only a release stores it. A tag that already names a presenter (D.A.S.H.
|
|
7
7
|
// on a character-voice deck, or one set by hand) is left alone.
|
|
8
8
|
|
|
9
9
|
const PLAYER_TAG = /<script\b[^>]*\bsrc="[^"]*\/player\/v1\/player\.js"[^>]*>/i;
|
|
10
10
|
|
|
11
11
|
const attr = (s) => String(s).replace(/&/g, "&").replace(/"/g, """);
|
|
12
12
|
|
|
13
|
+
// Whether this tag plays captions only: data-presenter="none", or no presenter
|
|
14
|
+
// at all (the player's default when there is no data-presenter-image either).
|
|
15
|
+
const noPresenter = (tag) => /\sdata-presenter="none"/.test(tag) || (!/\sdata-presenter=/.test(tag) && !/\sdata-presenter-image=/.test(tag));
|
|
16
|
+
|
|
17
|
+
// Whether the page's player tag names nobody, so without a face it has captions only.
|
|
18
|
+
export function playsCaptionsOnly(html) {
|
|
19
|
+
const tag = PLAYER_TAG.exec(html)?.[0];
|
|
20
|
+
return !!tag && !/\sdata-avatar=/.test(tag) && noPresenter(tag);
|
|
21
|
+
}
|
|
22
|
+
|
|
13
23
|
// html with the player tag set to present `avatarUrl` (the /avatar/<token> doc
|
|
14
24
|
// the player loads) and `imageUrl` (its still, for thumbnails and no-WebGL),
|
|
15
25
|
// or html unchanged when there is no such tag or it already has a presenter.
|
|
16
26
|
export function withAvatar(html, { avatarUrl, imageUrl }) {
|
|
17
27
|
const tag = PLAYER_TAG.exec(html)?.[0];
|
|
18
28
|
if (!tag || /\sdata-avatar=/.test(tag)) return html;
|
|
19
|
-
|
|
20
|
-
// default when there is no data-presenter-image either).
|
|
29
|
+
if (!noPresenter(tag)) return html;
|
|
21
30
|
const none = /\sdata-presenter="none"/.test(tag);
|
|
22
|
-
const unset = !/\sdata-presenter=/.test(tag) && !/\sdata-presenter-image=/.test(tag);
|
|
23
|
-
if (!none && !unset) return html;
|
|
24
31
|
const image = imageUrl && !/\sdata-presenter-image=/.test(tag) ? ` data-presenter-image="${attr(imageUrl)}"` : "";
|
|
25
32
|
const face = ` data-presenter="head" data-avatar="${attr(avatarUrl)}"${image}`;
|
|
26
33
|
const next = none ? tag.replace(/\sdata-presenter="none"/, face) : tag.replace(/>$/, `${face}>`);
|
package/src/preview-capture.mjs
CHANGED
|
@@ -19,7 +19,9 @@ export async function setupCapture(pos, opts) {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
// Capability URL + realpath containment; captured code gets no general file server.
|
|
22
|
-
|
|
22
|
+
// `html` alone serves that page; with `file` it replaces the page and the file's
|
|
23
|
+
// folder still serves the rest (play's in-memory copy with the face on it).
|
|
24
|
+
export async function serveSource({ file, html, port = 0 }) {
|
|
23
25
|
const root = file ? await realpath(dirname(file)) : null;
|
|
24
26
|
const prefix = '/' + randomUUID() + '/';
|
|
25
27
|
const entry = prefix + (file ? encodeURIComponent(resolve(file).split(sep).pop()) : 'index.html');
|
|
@@ -27,7 +29,7 @@ export async function serveSource({ file, html }) {
|
|
|
27
29
|
try {
|
|
28
30
|
const path = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
|
|
29
31
|
if (req.method !== 'GET' || !path.startsWith(prefix)) { res.writeHead(404).end(); return; }
|
|
30
|
-
if (
|
|
32
|
+
if (html != null && path === entry) { res.writeHead(200, { 'Content-Type':'text/html; charset=utf-8', 'Cache-Control':'no-store' }).end(html); return; }
|
|
31
33
|
const rel = path.slice(prefix.length);
|
|
32
34
|
if (!root || rel.split('/').some(s => s.startsWith('.') || s.includes('\\')) || !MIME[extname(rel).toLowerCase()]) { res.writeHead(404).end(); return; }
|
|
33
35
|
const full = await realpath(resolve(root, rel));
|
|
@@ -36,7 +38,7 @@ export async function serveSource({ file, html }) {
|
|
|
36
38
|
res.end(await readFile(full));
|
|
37
39
|
} catch { res.writeHead(404).end(); }
|
|
38
40
|
});
|
|
39
|
-
await new Promise((ok, fail) => { server.once('error', fail); server.listen(
|
|
41
|
+
await new Promise((ok, fail) => { server.once('error', fail); server.listen(port, '127.0.0.1', ok); });
|
|
40
42
|
const url = `http://127.0.0.1:${server.address().port}${entry}`;
|
|
41
43
|
return { url, close: () => new Promise((ok, fail) => { server.close(e => e ? fail(e) : ok()); server.closeAllConnections(); }) };
|
|
42
44
|
}
|
package/src/release.mjs
CHANGED
|
@@ -12,12 +12,12 @@
|
|
|
12
12
|
// 5. creates one share per --to (paired in order with --email) and prints the URLs.
|
|
13
13
|
import { execFileSync } from "node:child_process";
|
|
14
14
|
import { readFileSync } from "node:fs";
|
|
15
|
-
import { join
|
|
15
|
+
import { join } from "node:path";
|
|
16
16
|
import { zipSync } from "fflate";
|
|
17
17
|
import * as ops from "./ops.mjs";
|
|
18
18
|
import { withAvatar } from "./presenter.mjs";
|
|
19
19
|
import { target } from "./runtime.mjs";
|
|
20
|
-
import { checkDeck,
|
|
20
|
+
import { checkDeck, deckFiles, deckFolder, resolveDeckDir } from "./check.mjs";
|
|
21
21
|
import { narrate as runNarrate } from "./narrate.mjs";
|
|
22
22
|
|
|
23
23
|
const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
@@ -47,18 +47,23 @@ export function deckCommit(deckDir) {
|
|
|
47
47
|
return execFileSync("git", ["-C", deckDir, "rev-parse", "--short", "HEAD"], { encoding: "utf8" }).trim();
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
// What import uploads: the page alone, or a zip of the page and its local files
|
|
50
|
+
// What import uploads: the page alone, or a zip of the page and its local files:
|
|
51
|
+
// what index.html names, and what the scripts and stylesheets it bundles name in
|
|
52
|
+
// turn (deckFiles). `added` maps each bundled script or stylesheet to the files
|
|
53
|
+
// it brought in, so release can say what came from where.
|
|
51
54
|
// `presenter` (from avatars.presenterFor) puts the owner's avatar on the
|
|
52
55
|
// uploaded page's player tag; the committed index.html is never changed.
|
|
53
56
|
export function releaseBundle(deckDir, presenter = null) {
|
|
54
57
|
const raw = readFileSync(join(deckDir, "index.html"));
|
|
55
58
|
const html = presenter ? Buffer.from(withAvatar(raw.toString("utf8"), presenter)) : raw;
|
|
56
|
-
// check has already refused refs that leave the folder or do not
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
+
// check has already refused refs that leave the folder or do not resolve.
|
|
60
|
+
const found = deckFiles(html.toString("utf8"), deckFolder(deckDir)).files.filter((f) => f.path !== "index.html");
|
|
61
|
+
const added = {};
|
|
62
|
+
for (const f of found) if (f.from !== "html") (added[f.from] ??= []).push(f.path);
|
|
63
|
+
if (!found.length) return { bytes: html, filename: "index.html", files: ["index.html"], added };
|
|
59
64
|
const files = { "index.html": html };
|
|
60
|
-
for (const
|
|
61
|
-
return { bytes: Buffer.from(zipSync(files)), filename: "deck.zip", htmlFile: "index.html", files: Object.keys(files) };
|
|
65
|
+
for (const f of found) files[f.path] = readFileSync(join(deckDir, f.path));
|
|
66
|
+
return { bytes: Buffer.from(zipSync(files)), filename: "deck.zip", htmlFile: "index.html", files: Object.keys(files), added };
|
|
62
67
|
}
|
|
63
68
|
|
|
64
69
|
// From the CLI, parse() collects opts.recipients in order (each --email follows
|
|
@@ -86,6 +91,7 @@ export async function release(pos, opts = {}, io = {}) {
|
|
|
86
91
|
const loose = untracked(deckDir, bundle.files);
|
|
87
92
|
if (loose.length) throw new Error(`refusing to release: git does not track ${loose.join(", ")} (ignored or never added); commit them so the release corresponds to a commit`);
|
|
88
93
|
log(`✓ check passed: ${checked.slides} slides, ${checked.totalDuration}s`);
|
|
94
|
+
for (const [from, paths] of Object.entries(bundle.added)) log(` +${paths.length} file(s) referenced from ${from}: ${paths.join(", ")}`);
|
|
89
95
|
|
|
90
96
|
// Cached slides cost nothing, so this only renders what changed since the commit.
|
|
91
97
|
// Only the options narrate understands: an undefined --slide/--voice key would fail its flag check.
|
package/src/runtime.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
|
|
|
8
8
|
import { ConvexHttpClient } from "convex/browser";
|
|
9
9
|
import { makeFunctionReference } from "convex/server";
|
|
10
10
|
|
|
11
|
-
export const VERSION = "0.
|
|
11
|
+
export const VERSION = "0.8.1";
|
|
12
12
|
const UPLOAD_TIMEOUT_MS = 125_000;
|
|
13
13
|
|
|
14
14
|
export const PROD = {
|
|
@@ -32,6 +32,7 @@ export const api = {
|
|
|
32
32
|
createLink: makeFunctionReference("avatars:createLink"),
|
|
33
33
|
getLink: makeFunctionReference("avatars:getLink"),
|
|
34
34
|
presenterFor: makeFunctionReference("avatars:presenterFor"),
|
|
35
|
+
presenterWithReason: makeFunctionReference("avatars:presenterWithReason"),
|
|
35
36
|
},
|
|
36
37
|
narrators: {
|
|
37
38
|
list: makeFunctionReference("narrators:list"),
|
|
@@ -47,7 +48,11 @@ export const api = {
|
|
|
47
48
|
get: makeFunctionReference("brand:get"),
|
|
48
49
|
set: makeFunctionReference("brand:set"),
|
|
49
50
|
clear: makeFunctionReference("brand:clear"),
|
|
51
|
+
history: makeFunctionReference("brand:history"),
|
|
52
|
+
undo: makeFunctionReference("brand:undo"),
|
|
53
|
+
restore: makeFunctionReference("brand:restore"),
|
|
50
54
|
},
|
|
55
|
+
brandRevise: { revise: makeFunctionReference("brandRevise:revise") },
|
|
51
56
|
brandNode: {
|
|
52
57
|
importLogo: makeFunctionReference("brandNode:importLogo"),
|
|
53
58
|
uploadLogo: makeFunctionReference("brandNode:uploadLogo"),
|
package/templates/script.md
CHANGED
|
@@ -10,8 +10,11 @@ One `## <slide-id>` block per slide, in slide order; the id is the slide's
|
|
|
10
10
|
phrase (4 to 85 characters, two lines in the bubble). Aim for 150 to 180 s in
|
|
11
11
|
all. Holds: 0.4 s by default, `hold: 1.5` for a number, chart or reveal,
|
|
12
12
|
`hold: 2` on interactive slides. Stage tags become
|
|
13
|
-
cues at the word before them: [wave], [mood: excited], [reveal: #detail]
|
|
14
|
-
|
|
13
|
+
cues at the word before them: [wave], [mood: excited], [reveal: #detail]
|
|
14
|
+
(replays from the start when the slide does; the head glances at it unless
|
|
15
|
+
a [look] or [go] is right beside it),
|
|
16
|
+
[go: #detail] (it grows, its siblings fade and the presenter flies beside it,
|
|
17
|
+
or looks at it when there is no room; [go: slot] brings it home),
|
|
15
18
|
[look: h2] (the head looks at it), [custom: confetti]. `presenterStyle: stage`
|
|
16
19
|
above sets the deck's presenter; `presenter-style: corner` under a heading sets
|
|
17
20
|
one slide's. `transition: drift` above is how every slide enters;
|