@hypersocial/decks-cli 0.8.1 → 0.8.2
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/package.json +1 -1
- package/skills/hyperdecks/SKILL.md +28 -1
- package/skills/hyperdecks/chat.md +3 -1
- package/src/check.mjs +25 -15
- package/src/checkRules.mjs +8 -1
- package/src/commands.mjs +36 -5
- package/src/deckPlayer.mjs +45 -0
- package/src/ops.mjs +38 -0
- package/src/release.mjs +5 -2
- package/src/runtime.mjs +4 -1
- package/src/source.mjs +45 -2
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
|
|
@@ -213,6 +223,15 @@ The deck contract (`check` enforces each line):
|
|
|
213
223
|
- The player script stays
|
|
214
224
|
`<script type="module" src="https://decks.hypersocial.ai/player/v1/player.js" data-manifest="…">`.
|
|
215
225
|
`narrate` fills `data-manifest`; do not edit it by hand.
|
|
226
|
+
- To read the deck as one long page instead of slide by slide, add
|
|
227
|
+
`data-view="scroll"` to that player script (on the hosted MCP, through
|
|
228
|
+
write_deck_source). The slides stay as they are: they stack and snap, the
|
|
229
|
+
page fades to each landed slide's background (`data-bg="#ffd166"` on a
|
|
230
|
+
`<section>` overrides it), the presenter travels to each slide's
|
|
231
|
+
`narration-slot` as you scroll, and the narration scrolls the page on by
|
|
232
|
+
itself. Remove the attribute to go back. For a released deck, prefer the
|
|
233
|
+
deck setting (`deck view <slug> scroll`, `set_deck_view`): it overrides the
|
|
234
|
+
attribute and needs no re-release.
|
|
216
235
|
- Local images and files sit inside the deck folder and are referenced
|
|
217
236
|
relatively (`img/chart.png`). Hosted images (`gen-image` URLs) are fine too.
|
|
218
237
|
- A file the deck's own JavaScript loads (`fetch('media/data.json')`, a clip
|
|
@@ -513,6 +532,10 @@ phone screenshots, the same `fix` bar.
|
|
|
513
532
|
- No `voice:` in the front matter means the user's first ready voice. If
|
|
514
533
|
`hyperdecks voice list` shows none, ask the user to record one and run
|
|
515
534
|
`voice clone` (it asks them for consent; never clone anyone else's voice).
|
|
535
|
+
- Names speech-to-text spells another way fail narrate's transcript check
|
|
536
|
+
(step 4). Map what it heard to what you wrote with a `normalize:` entry in
|
|
537
|
+
the front matter, `heard=written`, comma-separated: a clip that says
|
|
538
|
+
"Minimoji" but is transcribed as "Moji" needs `normalize: moji=minimoji`.
|
|
516
539
|
|
|
517
540
|
### 4. Narrate and read the guard
|
|
518
541
|
|
|
@@ -536,9 +559,13 @@ hyperdecks check decks/<slug> [--forbid "Other Co,codename"] [--layout]
|
|
|
536
559
|
At the stage size it warns, without failing, when a slide's presenter would
|
|
537
560
|
overlap its title. On a 390×844 phone (the share player's 374×736 slide card)
|
|
538
561
|
it fails any slide whose content overflows: the player never scrolls a slide.
|
|
562
|
+
`release` (local or `--hosted`) always runs this layout check and refuses an
|
|
563
|
+
overflowing slide; `check <slug> --hosted --layout` measures a hosted draft.
|
|
539
564
|
|
|
540
565
|
`--forbid` lists phrases that must not reach this audience (another customer,
|
|
541
|
-
an internal codename).
|
|
566
|
+
an internal codename). It matches the page's text, alt text, titles and
|
|
567
|
+
script.md, not URLs in attributes, so forbidding your own brand still passes
|
|
568
|
+
with the player's `decks.hypersocial.ai` script tag. Messages and what to do:
|
|
542
569
|
|
|
543
570
|
| Message starts with | Fix |
|
|
544
571
|
|---|---|
|
|
@@ -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.
|
package/src/check.mjs
CHANGED
|
@@ -156,6 +156,27 @@ export function deckFolder(deckDir) {
|
|
|
156
156
|
};
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
// The browser half of check --layout, on a folder holding the deck's index.html
|
|
160
|
+
// (the hosted release measures a draft the same way, from a temp folder). A
|
|
161
|
+
// presenter over the title is a warning, not a failure: only a person can judge
|
|
162
|
+
// it. A slide that overflows the phone card is an error: the player never
|
|
163
|
+
// scrolls a slide. No Chromium is a warning, as each measurement is optional.
|
|
164
|
+
export async function layoutCheck(deckDir, html, manifest = null, { measure = measureLayout, measureFit = measurePhoneFit } = {}) {
|
|
165
|
+
const errors = [], warnings = [];
|
|
166
|
+
const { width: w, height: h } = stageSize(html);
|
|
167
|
+
try {
|
|
168
|
+
warnings.push(...overlapWarnings(await measure(deckDir, slideStyles(html, manifest), { width: w, height: h }), `${w}×${h}`));
|
|
169
|
+
} catch (e) {
|
|
170
|
+
warnings.push(MESSAGES.layoutUnchecked(e.message));
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
errors.push(...phoneOverflowErrors(await measureFit(deckDir)));
|
|
174
|
+
} catch (e) {
|
|
175
|
+
warnings.push(MESSAGES.layoutUnchecked(e.message));
|
|
176
|
+
}
|
|
177
|
+
return { errors, warnings: [...new Set(warnings)] };
|
|
178
|
+
}
|
|
179
|
+
|
|
159
180
|
// Run every rule on a deck folder. Returns { deckDir, slug, slides, totalDuration, errors }.
|
|
160
181
|
export async function checkDeck(dirArg, opts = {}, { fetch: fetchImpl = globalThis.fetch, cwd, measure = measureLayout, measureFit = measurePhoneFit } = {}) {
|
|
161
182
|
const deckDir = resolveDeckDir(dirArg, cwd);
|
|
@@ -164,22 +185,11 @@ export async function checkDeck(dirArg, opts = {}, { fetch: fetchImpl = globalTh
|
|
|
164
185
|
const script = existsSync(scriptPath) ? readFileSync(scriptPath, "utf8") : "";
|
|
165
186
|
const { manifest, ...result } = await checkSources({ html, script }, { ...opts, hosted: false, exists: deckFileResolver(deckDir), ...deckFolder(deckDir) }, { fetch: fetchImpl });
|
|
166
187
|
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.
|
|
188
|
+
// Both need a browser, so they run only with --layout (release always asks).
|
|
170
189
|
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
|
-
}
|
|
190
|
+
const layout = await layoutCheck(deckDir, html, manifest, { measure, measureFit });
|
|
191
|
+
errors.push(...layout.errors);
|
|
192
|
+
warnings.push(...layout.warnings);
|
|
183
193
|
}
|
|
184
194
|
return {
|
|
185
195
|
deckDir,
|
package/src/checkRules.mjs
CHANGED
|
@@ -76,6 +76,9 @@ const attr = (tag, name) => {
|
|
|
76
76
|
};
|
|
77
77
|
const round = (n) => Math.round(n * 100) / 100;
|
|
78
78
|
const decode = (s) => s.replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'|'/g, "'");
|
|
79
|
+
// Blank every attribute value that is an absolute or protocol-relative URL
|
|
80
|
+
// (src, href, data-manifest, …); the audience never reads those.
|
|
81
|
+
const withoutUrlAttrs = (html) => html.replace(/(\s[\w:.-]+\s*=\s*)(?:"(?:https?:)?\/\/[^"]*"|'(?:https?:)?\/\/[^']*')/gi, '$1""');
|
|
79
82
|
const textOf = (html) => decode(html.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
|
|
80
83
|
|
|
81
84
|
// Comments blanked but newlines kept, so line numbers still match the file.
|
|
@@ -530,9 +533,13 @@ export function htmlErrors(html, { exists = () => true, forbid = [], script = ""
|
|
|
530
533
|
else if (!found) errs.push(MESSAGES.localRef(ref));
|
|
531
534
|
}
|
|
532
535
|
const page = textOf(uncommented(html).replace(/<(script|style)\b[\s\S]*?<\/\1>/g, " ")).toLowerCase();
|
|
536
|
+
// The raw HTML too (alt text, <title>, meta content, inline script), less
|
|
537
|
+
// attribute values that are URLs: the player's own src (PLAYER_V1) names
|
|
538
|
+
// decks.hypersocial.ai, so forbidding the brand would otherwise always fail.
|
|
539
|
+
const raw = withoutUrlAttrs(uncommented(html)).toLowerCase();
|
|
533
540
|
for (const phrase of phraseList(forbid)) {
|
|
534
541
|
const p = phrase.toLowerCase();
|
|
535
|
-
if (page.includes(p) ||
|
|
542
|
+
if (page.includes(p) || raw.includes(p)) errs.push(MESSAGES.forbidden(phrase, "index.html"));
|
|
536
543
|
if (script.toLowerCase().includes(p)) errs.push(MESSAGES.forbidden(phrase, "script.md"));
|
|
537
544
|
}
|
|
538
545
|
return errs;
|
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,9 +331,12 @@ 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)
|
|
@@ -676,8 +706,8 @@ function commandTable(pos, rest, opts) {
|
|
|
676
706
|
},
|
|
677
707
|
check: async () => {
|
|
678
708
|
const { check } = await import("./check.mjs");
|
|
679
|
-
const {
|
|
680
|
-
const result = opts.hosted ? await
|
|
709
|
+
const { hostedCheckCli } = await import("./source.mjs");
|
|
710
|
+
const result = opts.hosted ? await hostedCheckCli(opts, { slug: pos[1], forbid: opts.forbid, maxDuration: opts["max-duration"] }) : await check(pos.slice(1), opts);
|
|
681
711
|
if (opts.hosted && !opts.json) {
|
|
682
712
|
console.log(`deck "${pos[1]}" rev ${result.rev}: ${result.slides} slides${result.totalDuration != null ? `, ${result.totalDuration}s narrated` : ""}`);
|
|
683
713
|
for (const e of result.errors) console.log(` - ${e}`);
|
|
@@ -689,8 +719,8 @@ function commandTable(pos, rest, opts) {
|
|
|
689
719
|
},
|
|
690
720
|
release: async () => {
|
|
691
721
|
if (opts.hosted) {
|
|
692
|
-
const {
|
|
693
|
-
const out = await
|
|
722
|
+
const { hostedReleaseCli } = await import("./source.mjs");
|
|
723
|
+
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
724
|
if (opts.json) return void console.log(JSON.stringify(out, null, 2));
|
|
695
725
|
console.log(`✓ released ${out.slug} rev ${out.rev}\n deck: ${out.deckUrl}`);
|
|
696
726
|
const who = presenterLine(out.presenter);
|
|
@@ -849,6 +879,7 @@ function commandTable(pos, rest, opts) {
|
|
|
849
879
|
"deck open": () => deckOpen(rest, opts),
|
|
850
880
|
"deck rm": () => deckRemove(rest, opts),
|
|
851
881
|
"deck forward": () => deckForward(rest, opts),
|
|
882
|
+
"deck view": () => deckView(rest, opts),
|
|
852
883
|
"share new": () => shareNew(rest, opts),
|
|
853
884
|
"share list": () => shareList(rest, opts),
|
|
854
885
|
"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/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
|
@@ -76,14 +76,16 @@ export function recipients(opts) {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
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;
|
|
79
|
+
const { log = (line) => console.log(line), changes = deckChanges, untracked = untrackedFiles, commit = deckCommit, narrate = runNarrate, fetch, cwd, measure, measureFit } = io;
|
|
80
80
|
const deckDir = resolveDeckDir(pos[0], cwd);
|
|
81
81
|
const to = recipients(opts);
|
|
82
82
|
|
|
83
83
|
const dirty = changes(deckDir);
|
|
84
84
|
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
85
|
|
|
86
|
-
|
|
86
|
+
// Always with the layout check: a slide that overflows a phone must not ship
|
|
87
|
+
// (the hosted release runs the same measurement, hostedLayoutCheck in source.mjs).
|
|
88
|
+
const checked = await checkDeck(deckDir, { ...opts, layout: true }, { fetch, cwd, ...(measure ? { measure } : {}), ...(measureFit ? { measureFit } : {}) });
|
|
87
89
|
const slug = opts.slug || checked.slug;
|
|
88
90
|
if (!SLUG.test(slug)) throw new Error(`"${slug}" is not a deck slug (lowercase letters, digits, dashes): rename the folder or pass --slug`);
|
|
89
91
|
if (checked.errors.length) throw new Error(`check failed for ${slug}:\n${checked.errors.map((e) => ` - ${e}`).join("\n")}`);
|
|
@@ -91,6 +93,7 @@ export async function release(pos, opts = {}, io = {}) {
|
|
|
91
93
|
const loose = untracked(deckDir, bundle.files);
|
|
92
94
|
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
95
|
log(`✓ check passed: ${checked.slides} slides, ${checked.totalDuration}s`);
|
|
96
|
+
for (const w of checked.warnings) log(` ⚠ ${w}`);
|
|
94
97
|
for (const [from, paths] of Object.entries(bundle.added)) log(` +${paths.length} file(s) referenced from ${from}: ${paths.join(", ")}`);
|
|
95
98
|
|
|
96
99
|
// Cached slides cost nothing, so this only renders what changed since the commit.
|
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.8.
|
|
11
|
+
export const VERSION = "0.8.2";
|
|
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"),
|
package/src/source.mjs
CHANGED
|
@@ -6,15 +6,58 @@
|
|
|
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 { SOURCE_FILES, getDeckSource, hostedCheck, hostedRelease, uploadDeckAsset, writeDeckSource } from "./hosted.mjs";
|
|
13
15
|
|
|
14
16
|
export * from "./hosted.mjs";
|
|
15
17
|
|
|
16
18
|
// ---- CLI ------------------------------------------------------------------
|
|
17
19
|
|
|
20
|
+
// The browser half of check --layout for a hosted draft. The hosted check runs
|
|
21
|
+
// in Convex and can't open a browser (HOSTED_LAYOUT_WHY), so the CLI measures
|
|
22
|
+
// the draft's index.html here, from a temp folder, with check.mjs's Chromium
|
|
23
|
+
// code. Returns the rev it measured, and { errors, warnings } as check --layout.
|
|
24
|
+
export async function hostedLayoutCheck(opts, { slug } = {}, { measure, measureFit } = {}) {
|
|
25
|
+
if (!slug) throw new Error("slug is required");
|
|
26
|
+
const got = await getDeckSource(opts, { slug, file: "index.html" });
|
|
27
|
+
const html = got.files?.["index.html"];
|
|
28
|
+
if (typeof html !== "string") throw new Error(`hosted deck "${slug}" has no index.html`);
|
|
29
|
+
const { layoutCheck } = await import("./check.mjs");
|
|
30
|
+
const dir = mkdtempSync(join(tmpdir(), "hyperdecks-hosted-layout-"));
|
|
31
|
+
try {
|
|
32
|
+
writeFileSync(join(dir, "index.html"), html);
|
|
33
|
+
return { rev: got.rev, ...(await layoutCheck(dir, html, null, { ...(measure ? { measure } : {}), ...(measureFit ? { measureFit } : {}) })) };
|
|
34
|
+
} finally {
|
|
35
|
+
rmSync(dir, { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// check <slug> --hosted [--layout]: the hosted rules, plus the layout measured
|
|
40
|
+
// here when --layout asks for it (in place of the hosted check's "layout not
|
|
41
|
+
// checked" warning).
|
|
42
|
+
export async function hostedCheckCli(opts, args, io = {}) {
|
|
43
|
+
const result = await hostedCheck(opts, args);
|
|
44
|
+
if (!opts.layout) return result;
|
|
45
|
+
const layout = await hostedLayoutCheck(opts, args, io);
|
|
46
|
+
const errors = [...result.errors, ...layout.errors];
|
|
47
|
+
const unchecked = MESSAGES.layoutUnchecked(HOSTED_LAYOUT_WHY);
|
|
48
|
+
return { ...result, errors, passed: errors.length === 0, warnings: [...new Set([...result.warnings.filter((w) => w !== unchecked), ...layout.warnings])] };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// release <slug> --hosted: the same layout check the local release runs, then
|
|
52
|
+
// the release pinned to the rev that was measured, so a later write can't slip
|
|
53
|
+
// an unmeasured draft through. A slide that overflows a phone refuses it.
|
|
54
|
+
export async function hostedReleaseCli(opts, args, { log = (line) => console.log(line), ...io } = {}) {
|
|
55
|
+
const layout = await hostedLayoutCheck(opts, args, io);
|
|
56
|
+
if (layout.errors.length) throw new Error(`check failed for ${args.slug}:\n${layout.errors.map((e) => ` - ${e}`).join("\n")}`);
|
|
57
|
+
for (const w of layout.warnings) log(` ⚠ ${w}`);
|
|
58
|
+
return hostedRelease(opts, { ...args, rev: layout.rev });
|
|
59
|
+
}
|
|
60
|
+
|
|
18
61
|
export async function sourcePull(pos, opts = {}, { log = (line) => console.log(line), cwd = process.cwd() } = {}) {
|
|
19
62
|
const [slug, dirArg] = pos;
|
|
20
63
|
if (!slug) throw new Error("usage: source pull <slug> [dir]");
|