@hypersocial/decks-cli 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/hyperdecks/SKILL.md +73 -7
- package/skills/hyperdecks/chat.md +46 -11
- package/src/brand.mjs +171 -0
- package/src/brandCli.mjs +166 -0
- package/src/brandImport.mjs +981 -0
- package/src/brandPreview.mjs +24 -0
- package/src/checkRules.mjs +70 -10
- package/src/commands.mjs +59 -13
- package/src/files.mjs +1 -0
- package/src/firstDeck.mjs +2 -0
- package/src/guide.mjs +3 -1
- package/src/hosted.mjs +15 -6
- package/src/icons.mjs +66 -0
- package/src/importer.mjs +1 -1
- package/src/narrate.mjs +8 -1
- package/src/ops.mjs +82 -8
- package/src/presenter.mjs +9 -0
- package/src/release.mjs +3 -1
- package/src/runtime.mjs +14 -2
- package/src/scaffold.mjs +15 -7
- package/templates/icons/LICENSE +43 -0
- package/templates/icons/lucide.json +1 -0
- package/templates/layouts/_deck.html +1 -1
- package/templates/layouts/chart.html +1 -1
- package/templates/layouts/features.html +8 -8
- package/templates/layouts/hero.html +6 -8
- package/templates/layouts/product.html +1 -1
- package/templates/narrated-deck.html +1 -1
package/package.json
CHANGED
|
@@ -123,14 +123,18 @@ Hyperdecks server; use the CLI where you have a shell and the tool otherwise.
|
|
|
123
123
|
|
|
124
124
|
| Step | CLI | MCP tool |
|
|
125
125
|
|---|---|---|
|
|
126
|
+
| Brand (once; every new deck starts from it) | `hyperdecks brand show` / `brand set` / `brand edit` | `get_brand` / `set_brand` |
|
|
127
|
+
| Import a brand (website, .pptx/.potx, .pdf, a deck; preview first) | `hyperdecks brand import <url\|file\|slug>` | `import_brand` |
|
|
126
128
|
| Scaffold | `hyperdecks deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer]` | `new_deck` (`layouts`) |
|
|
127
129
|
| Images | `hyperdecks gen-image "<prompt>" --slug <slug>` | `generate_image` |
|
|
128
130
|
| Video clips | `hyperdecks gen-video "<prompt>" --slug <slug>` | `generate_video` |
|
|
129
131
|
| Product screenshot | `hyperdecks screenshot <url> --slug <slug>` | `capture_url` |
|
|
130
132
|
| Chart from numbers | `hyperdecks chart <spec.json> --slug <slug>` | `render_chart` |
|
|
131
|
-
|
|
|
133
|
+
| Real icons (Lucide, inline SVG) | `hyperdecks icons search <word>` | `search_icons` |
|
|
134
|
+
| Illustrated icon set | `hyperdecks icons <subject...> --style "<style>" --slug <slug>` | `generate_icon_set` |
|
|
132
135
|
| Outside review (required before release) | `hyperdecks review <slug> --script decks/<slug>/script.md` | `review_deck` |
|
|
133
136
|
| Your voice (once) | `hyperdecks voice clone <file...> --name <name>` | `clone_voice` |
|
|
137
|
+
| Who narrates (ask the person first; see *Narrators*) | `hyperdecks deck narrator <slug> <name\|none\|default>` | `list_narrators`, then `set_deck_narrator` |
|
|
134
138
|
| Narrate | `hyperdecks narrate decks/<slug>` | `narrate_deck` |
|
|
135
139
|
| Check | `hyperdecks check decks/<slug>` | `check_deck` |
|
|
136
140
|
| Ship | `hyperdecks release decks/<slug> --to "<name>" --email <e>` | `release_deck` |
|
|
@@ -223,6 +227,9 @@ its `##` heading.
|
|
|
223
227
|
| `glass` | dark or busy slides: head and captions on one frosted panel |
|
|
224
228
|
| `corner` | the slide is the point (a product shot, a chart, a demo): small head bottom-right, subtitles along the bottom |
|
|
225
229
|
|
|
230
|
+
The live head's face follows script cues too (`[nod]`, `[look #card]`,
|
|
231
|
+
`[smile]`, `[gesture: lightbulb]`; see the script topic); a still face ignores them.
|
|
232
|
+
|
|
226
233
|
Below 650 px stage and glass become corner on their own. Run
|
|
227
234
|
`hyperdecks check <deck> --layout` to be warned about any slide whose presenter
|
|
228
235
|
would cover its title.
|
|
@@ -299,13 +306,39 @@ plain, `on-ink`) so the slide changes read, and keep every number, source and
|
|
|
299
306
|
label at full contrast. Change the palette and fonts once, in `:root`; every
|
|
300
307
|
layout reads them.
|
|
301
308
|
|
|
309
|
+
**Start from the brand.** The account keeps a brand: colours, fonts, a logo,
|
|
310
|
+
a presenter style and a design.md (voice, do and don't). `deck new` and
|
|
311
|
+
`new_deck` put its tokens in `:root` and its logo on the first slide, so read
|
|
312
|
+
it first (`hyperdecks brand show --json`, `get_brand`) and write in its voice.
|
|
313
|
+
An explicit request for this deck wins over the brand; `--no-brand`
|
|
314
|
+
(`brand: false`) starts from the template's look. No brand yet? Offer to
|
|
315
|
+
import it from what they already have: `hyperdecks brand import
|
|
316
|
+
<url|file.pptx|file.pdf|deck-slug>` (`import_brand`) reads the real colours,
|
|
317
|
+
fonts and logo (a PowerPoint's theme exactly) and returns a preview link, a
|
|
318
|
+
sample slide in that brand, and a token. Always show them the preview and
|
|
319
|
+
ask; save with `brand set --draft <token>` (`set_brand { draft }`) only after
|
|
320
|
+
they say yes.
|
|
321
|
+
|
|
322
|
+
**Fonts: pick a pairing.** Fonts load from Google Fonts. Use one of the
|
|
323
|
+
curated pairings below (display for titles, body for text, mono for kickers
|
|
324
|
+
and labels) rather than whatever family comes to mind; `brand set --fonts <id>`
|
|
325
|
+
saves one.
|
|
326
|
+
|
|
327
|
+
<!-- font pairings -->
|
|
328
|
+
|
|
329
|
+
**Icons: real ones first.** `hyperdecks icons search <word>` (`search_icons`)
|
|
330
|
+
returns Lucide line icons as inline SVG stroked in `currentColor`, so they take
|
|
331
|
+
the deck's colours: paste the `<svg>` in place of a features card's icon. Use
|
|
332
|
+
`icons <subject...> --style` (`generate_icon_set`) only for illustrated icons
|
|
333
|
+
in a style of your own.
|
|
334
|
+
|
|
302
335
|
**Pick the layout from what the slide says.** Each layout's file in
|
|
303
336
|
`templates/layouts/` opens with its slot side, presenter and the cues that
|
|
304
337
|
suit it.
|
|
305
338
|
|
|
306
339
|
| Layout | Use it for | Presenter, moves |
|
|
307
340
|
|---|---|---|
|
|
308
|
-
| `hero` | the opening, a section opener; the presenter is the visual, no `.art` unless it shows the real subject | stage, bottom-right; `[wave]`, `[look: h1]` |
|
|
341
|
+
| `hero` | the opening, a section opener; the presenter is the visual, no `.art` unless it shows the real subject (check fails while the placeholder `<div class="art">` is in: swap it for an `<img class="art">` or delete it) | stage, bottom-right; `[wave]`, `[look: h1]` |
|
|
309
342
|
| `number` | one number with its source: proof, traction, a delta | stage; `[look: #<id>-figure]` as it is said |
|
|
310
343
|
| `product` | a screenshot in a browser frame (`.frame.phone` for a phone shot) with the point beside it | stage; `[look: #<id>-frame]`, `[reveal: #<id>-callout]` |
|
|
311
344
|
| `quote` | a customer or user in their own words, quoted exactly | stage, bottom-left; `[look: #<id>-quote]` |
|
|
@@ -345,7 +378,26 @@ where there is room so slide changes move the presenter.
|
|
|
345
378
|
palette hexes, "no text"). `--model nano-banana-2.1` when the image must
|
|
346
379
|
carry legible type.
|
|
347
380
|
- Video: `gen-video` for a short loop behind a hero or a product moment, with
|
|
348
|
-
`[play: video]` at its word; never needed for a deck to work.
|
|
381
|
+
`[play: video]` at its word; never needed for a deck to work. A clip that
|
|
382
|
+
plays on repeat should be made with `--loop` (`loop: true`): it starts and
|
|
383
|
+
ends on the same frame, so there is no jump at the seam. Give it `--image`
|
|
384
|
+
to loop a still you already have; without one a keyframe is rendered first.
|
|
385
|
+
- Moving cut-outs: `--transparent` (`transparent: true`) mattes the
|
|
386
|
+
background out, the moving version of a `gen-image --transparent` PNG: a
|
|
387
|
+
spinning part, a product turning, over the slide's own background. Use it
|
|
388
|
+
with `--loop` and a subject on a plain background. It returns a VP9 WebM
|
|
389
|
+
with alpha (Chrome, Firefox, Edge) and a stacked-alpha MP4 that the hosted
|
|
390
|
+
player draws on a canvas in Safari and on iOS, which ignore WebM alpha. The
|
|
391
|
+
job's `markup` is the tag to paste, as is:
|
|
392
|
+
|
|
393
|
+
```html
|
|
394
|
+
<video muted loop playsinline autoplay crossorigin="anonymous"
|
|
395
|
+
src="(video.url)" data-alpha-stacked="(video.stacked.url)"></video>
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
Keep `crossorigin` and `data-alpha-stacked`: the player needs both. A deck
|
|
399
|
+
without the hosted player shows the clip in a box in Safari. A transparent
|
|
400
|
+
clip costs 2 credits a second more than an opaque one.
|
|
349
401
|
- People: real photos only with permission; initials otherwise.
|
|
350
402
|
|
|
351
403
|
**Motion.** The presenter's moves are the deck's motion; keep everything else
|
|
@@ -399,6 +451,12 @@ phone screenshots, the same `fix` bar.
|
|
|
399
451
|
showcase is a slide of stacked cards, one point each, with a `go` to each
|
|
400
452
|
card at the word that starts it: `Write [go: #step-1] the slides, write
|
|
401
453
|
[go: #step-2] what you'd say`. Give each card an id.
|
|
454
|
+
- Face: the live head already moves with the speech (eyes, brows, a nod on a
|
|
455
|
+
falling end); add a few face cues only where they carry the point, each on
|
|
456
|
+
the word before it: `This is the big one [nod] and here [look #m3] is the
|
|
457
|
+
proof.` / `Not a slide tool [shake], a presenter.` / `Then it clicked
|
|
458
|
+
[gesture: lightbulb].` / `Really? [brow up] Yes [smile].` A still or
|
|
459
|
+
captions-only presenter ignores them.
|
|
402
460
|
|
|
403
461
|
| Cue | Effect |
|
|
404
462
|
|---|---|
|
|
@@ -408,7 +466,12 @@ phone screenshots, the same `fix` bar.
|
|
|
408
466
|
| `[pause: 0.5]` | a beat of silence (≤ 5 s) |
|
|
409
467
|
| `[play: video]` / `[pause: video]` | play or pause the slide's video |
|
|
410
468
|
| `[go: .list]` / `[go: slot]` | fly beside `.list` / back to the slot |
|
|
411
|
-
| `[look: h2]` | look at the title |
|
|
469
|
+
| `[look: h2]` / `[look #card]` | look at the title / the card, then back |
|
|
470
|
+
| `[look viewer]` / `[look away]` | look back at the viewer / glance aside |
|
|
471
|
+
| `[nod]` / `[shake]` | a nod or head shake that suits the mood |
|
|
472
|
+
| `[brow up]` / `[brow down]` | raise or lower the brows |
|
|
473
|
+
| `[smile]` / `[think]` | `[mood: happy]` / `[mood: thinking]` |
|
|
474
|
+
| `[gesture: <id>]` | one of the head's tracks: `knowing-nod`, `ponder-tilt`, `lightbulb`, `side-eye-hmm`, `chuckle-bob`, `sunny-nod`, `double-take`… (check lists them all) |
|
|
412
475
|
| `[custom: name]` | only the slide's controller sees it |
|
|
413
476
|
- `voice: dash` narrates with D.A.S.H.'s own robot voice, a synthetic
|
|
414
477
|
character voice (not a clone of anyone) any account may use, for decks that
|
|
@@ -462,6 +525,7 @@ an internal codename). Messages and what to do:
|
|
|
462
525
|
| `forbidden phrase` | remove it from the page, script or narration |
|
|
463
526
|
| `slide <id>: needs a data-note` | add the source |
|
|
464
527
|
| `slide <id>: title … needs a data-title-mobile` | add a shorter title |
|
|
528
|
+
| `slide <id>: the hero's art` / `the product screenshot` / `the chart … is still the layout's placeholder` | make the real file (`gen-image`, `screenshot <url>`, `chart <spec.json>`) and swap the element for the `<img>` the message names; or delete the hero's `<div class="art">` for no art |
|
|
465
529
|
| `… presenter style "…" is not one of` | use stage, bubble, glass or corner |
|
|
466
530
|
| `script.md line N: [go: …] matches nothing in slide <id>` | fix the selector, or add the element to that slide |
|
|
467
531
|
| `… transition "…" is not one of` | use drift, cut, push, wipe, zoom `<selector>`, morph or fly |
|
|
@@ -488,9 +552,11 @@ the deck's QA link on desktop and phone and list each problem as `fix` or
|
|
|
488
552
|
|
|
489
553
|
Pick image models by job: `gen-image` defaults to GPT Image 2.5; `--model
|
|
490
554
|
nano-banana-2.1` is stronger on typography and photoreal scenes. `gen-video`
|
|
491
|
-
defaults to MiniMax H3 Max (`--model kling-v3` is the fallback)
|
|
492
|
-
|
|
493
|
-
|
|
555
|
+
defaults to MiniMax H3 Max (`--model kling-v3` is the fallback); `--loop` and
|
|
556
|
+
`--transparent` work with both. Use `chart` for
|
|
557
|
+
real numbers instead of a generated picture of a chart, `icons search` for
|
|
558
|
+
real line icons, and `icons --style` with the deck's palette for an
|
|
559
|
+
illustrated set that matches.
|
|
494
560
|
|
|
495
561
|
### 7. Release
|
|
496
562
|
|
|
@@ -22,32 +22,67 @@ makes the hello deck; continue from step 5 below.
|
|
|
22
22
|
## The loop
|
|
23
23
|
|
|
24
24
|
1. `deck_guide` (this page), then `deck_guide { topic: "layouts" }` to pick slides.
|
|
25
|
+
`get_brand` for the person's saved brand: write in its designMd's voice.
|
|
26
|
+
None yet? Offer to import it from what they have: `import_brand` with
|
|
27
|
+
their website (`url`), a PowerPoint or PDF they attach (`file`) or one of
|
|
28
|
+
their decks (`slug`). It reads the real colours, fonts and logo and returns
|
|
29
|
+
a `previewUrl` (a sample slide in that brand) and a `token`. Always give
|
|
30
|
+
them the preview link, say what it found (and its notes), and ask; only
|
|
31
|
+
after a yes call `set_brand { draft: token }`. Never save without the yes.
|
|
32
|
+
(`deck_guide { topic: "design" }` lists the font pairings.)
|
|
25
33
|
2. `new_deck { slug, title, layouts }`: registers your draft and returns the
|
|
26
|
-
full text of every file plus a `previewUrl` (the draft, untracked).
|
|
34
|
+
full text of every file plus a `previewUrl` (the draft, untracked). It
|
|
35
|
+
starts in the saved brand's colours, fonts and logo (`brand` in the result
|
|
36
|
+
says what it took); `brand: false` gives the template's look.
|
|
27
37
|
3. Images: `generate_image`, `render_chart`, `capture_url`, or
|
|
28
38
|
`upload_deck_asset` for a picture the user gave you (a URL, or a file they
|
|
29
39
|
uploaded). Each returns a hosted URL: put that URL in the HTML. A hosted deck
|
|
30
|
-
has no folder, so a relative path like `img/a.png` is an error.
|
|
40
|
+
has no folder, so a relative path like `img/a.png` is an error. The
|
|
41
|
+
layouts' stand-ins (hero's `<div class="art">`, product's
|
|
42
|
+
`<div class="shot">`, chart's placeholder `<svg>`) fail `check_deck` until
|
|
43
|
+
you swap each for an `<img>` of its real file, or delete the hero's art.
|
|
44
|
+
Icons: `search_icons { query }` returns real line icons as inline `<svg>`
|
|
45
|
+
in `currentColor`; paste one where a features card has its icon.
|
|
46
|
+
For motion, `generate_video` (poll `check_video_job`): `loop: true` for a
|
|
47
|
+
clip that repeats with no jump, `transparent: true` for a moving cut-out
|
|
48
|
+
over the slide. Paste the finished job's `markup` as is (a transparent
|
|
49
|
+
clip's `<video>` names a second file in `data-alpha-stacked`, which the
|
|
50
|
+
player shows in Safari and on iOS).
|
|
51
|
+
`deck_guide { topic: "design" }` says when each fits.
|
|
31
52
|
4. `write_deck_source` to change a file: `edits: [{ find, replace }]` with
|
|
32
53
|
exact text that occurs once (copy it from `get_deck_source`), or `content`
|
|
33
54
|
to replace the file. Pass `baseRev` (the `rev` you last read) so a
|
|
34
55
|
concurrent edit fails instead of being overwritten.
|
|
35
|
-
5. `check_deck { slug }` until it passes (the narration errors clear in step
|
|
36
|
-
6.
|
|
56
|
+
5. `check_deck { slug }` until it passes (the narration errors clear in step 7).
|
|
57
|
+
6. **Ask who narrates** before the first narrate, unless they already named
|
|
58
|
+
one. `list_narrators`, then offer their narrators (`hear_voice` to listen)
|
|
59
|
+
and D.A.S.H. If none of theirs has a face, say so: their voice alone plays
|
|
60
|
+
captions only, no face. Offer `new_narrator_link` (they make one, a face plus
|
|
61
|
+
their voice) or D.A.S.H. Then `set_deck_narrator { slug, narrator }`
|
|
62
|
+
(`none` only if they want captions only). Never pick for them.
|
|
63
|
+
7. `narrate_deck { slug }` with no `script` narrates the stored `script.md`;
|
|
37
64
|
poll `check_narration_job` until `done`. The draft's player tag then has its
|
|
38
|
-
`data-manifest`.
|
|
39
|
-
|
|
65
|
+
`data-manifest`. Its `presenter` says who will present (`face: null` is
|
|
66
|
+
captions only, `reason` says why): tell the person. No voice of their own
|
|
67
|
+
yet? Pass `voice: "dash"`. To direct the face, put a few cues in
|
|
68
|
+
script.md on the words that carry the point (`[nod]`, `[look #card]`,
|
|
69
|
+
`[smile]`, `[gesture: lightbulb]`; `deck_guide script` lists them) and
|
|
70
|
+
narrate again.
|
|
71
|
+
8. `check_deck { slug }` again, then `review_deck { slug, draft: true }` and
|
|
40
72
|
`check_review_job`: it shoots desktop and 390×844 phone frames, which is how
|
|
41
73
|
you catch a slide that overflows a phone (the hosted check cannot measure
|
|
42
74
|
layout).
|
|
43
|
-
|
|
75
|
+
9. `release_deck { slug, qa: true }` gives the user a QA link to watch first.
|
|
44
76
|
`release_deck { slug, recipients: [{ recipient, email }] }` makes one
|
|
45
77
|
tracked link each. Release creates live links: confirm the recipients with
|
|
46
78
|
the user before you call it. In Claude and ChatGPT the result shows as a
|
|
47
79
|
deck card: the deck playing from its QA link (the draft before release),
|
|
48
80
|
with each share link and its opens. Never paste a recipient's link to "test"
|
|
49
|
-
it: opening it counts as their open.
|
|
50
|
-
|
|
81
|
+
it: opening it counts as their open. A release that would play captions
|
|
82
|
+
only, when nobody chose that, is refused: hand the person its Add narrator
|
|
83
|
+
link, or offer D.A.S.H., and pass `captionsOnly: true` only if they say so.
|
|
84
|
+
The result's `presenter` names who presents.
|
|
85
|
+
10. Later: `list_shares` and `deck_stats` (both show the card),
|
|
51
86
|
`activity_overview`; `email_embed { token }` for the HTML that pastes into an
|
|
52
87
|
email as the deck's moving preview (it needs a recipient's link, not a QA
|
|
53
88
|
one); `set_share_notify` to mute a link's "opened" alerts;
|
|
@@ -63,8 +98,8 @@ makes the hello deck; continue from step 5 below.
|
|
|
63
98
|
scaffold wrote; never write `data-manifest` yourself.
|
|
64
99
|
- Edit the draft freely: recipients keep seeing the last release until you call
|
|
65
100
|
`release_deck` again. A release is refused when the check fails, when
|
|
66
|
-
narration is stale (narrate again first),
|
|
67
|
-
the check.
|
|
101
|
+
narration is stale (narrate again first), when the draft changed after
|
|
102
|
+
the check, or when it would play captions only and nobody chose that.
|
|
68
103
|
- Files are capped at 800 KB each: use hosted image URLs, never inline base64.
|
|
69
104
|
- Facts come from the user's material; put the source of each in `evidence.md`
|
|
70
105
|
and the slide's `data-note`.
|
package/src/brand.mjs
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// The account's brand (convex/brand.ts) applied to a new deck, and the curated
|
|
2
|
+
// font pairings it can name. Text in, text out (no I/O), so the hosted MCP route
|
|
3
|
+
// (new_deck) and the CLI (deck new) put the same brand on the same templates.
|
|
4
|
+
//
|
|
5
|
+
// A brand is { colors: { ink, paper, accent, accent2, card }, fonts: { display,
|
|
6
|
+
// body, mono }, logoUrl, presenterStyle, designMd }; every field is optional and
|
|
7
|
+
// an absent one keeps the template's own. designMd is guidance the agent reads;
|
|
8
|
+
// only the tokens here touch the deck.
|
|
9
|
+
|
|
10
|
+
// Weights each role loads: the templates set body text at 500, titles at 700
|
|
11
|
+
// and labels in mono at 400/700. Every family below has these on Google Fonts.
|
|
12
|
+
const ROLE_WEIGHTS = { display: "600;700", body: "400;500;700", mono: "400;700" };
|
|
13
|
+
|
|
14
|
+
// 16 pairings, display + body + mono, each a family Google Fonts serves at the
|
|
15
|
+
// role's weights. `use` is the one line an agent picks by.
|
|
16
|
+
export const FONT_PAIRINGS = [
|
|
17
|
+
{ id: "space", display: "Space Grotesk", body: "Space Grotesk", mono: "Space Mono", use: "The template's own: techy, playful, a startup demo." },
|
|
18
|
+
{ id: "inter", display: "Inter", body: "Inter", mono: "JetBrains Mono", use: "Neutral product and SaaS; reads like the app itself." },
|
|
19
|
+
{ id: "geist", display: "Geist", body: "Geist", mono: "Geist Mono", use: "Developer tools and infrastructure; crisp and modern." },
|
|
20
|
+
{ id: "plex", display: "IBM Plex Sans", body: "IBM Plex Sans", mono: "IBM Plex Mono", use: "Enterprise, data and engineering; sober and precise." },
|
|
21
|
+
{ id: "editorial", display: "Fraunces", body: "Inter", mono: "IBM Plex Mono", use: "Editorial and thoughtful: essays, research, a founder's letter." },
|
|
22
|
+
{ id: "classic", display: "Playfair Display", body: "Source Sans 3", mono: "Source Code Pro", use: "Premium, luxury, finance or hospitality; classic contrast." },
|
|
23
|
+
{ id: "newsroom", display: "Newsreader", body: "Public Sans", mono: "IBM Plex Mono", use: "Reports, policy and media; a newspaper's calm authority." },
|
|
24
|
+
{ id: "bold", display: "Bricolage Grotesque", body: "DM Sans", mono: "DM Mono", use: "Consumer brands and launches; characterful and loud." },
|
|
25
|
+
{ id: "friendly", display: "Plus Jakarta Sans", body: "Plus Jakarta Sans", mono: "Fira Code", use: "Friendly consumer and fintech apps; soft and approachable." },
|
|
26
|
+
{ id: "geometric", display: "Sora", body: "Manrope", mono: "JetBrains Mono", use: "Web3, AI and future-facing products; geometric and clean." },
|
|
27
|
+
{ id: "studio", display: "Syne", body: "Inter", mono: "Space Mono", use: "Design studios, agencies and portfolios; art-school edge." },
|
|
28
|
+
{ id: "warm", display: "Outfit", body: "Nunito Sans", mono: "Red Hat Mono", use: "Education, health and community; warm and open." },
|
|
29
|
+
{ id: "swiss", display: "Archivo", body: "Archivo", mono: "Azeret Mono", use: "Swiss-style grids, architecture and hardware; tight and rational." },
|
|
30
|
+
{ id: "literary", display: "Libre Baskerville", body: "Lora", mono: "Courier Prime", use: "Books, writing, nonprofits; literary and humane." },
|
|
31
|
+
{ id: "display", display: "Unbounded", body: "Hanken Grotesk", mono: "Martian Mono", use: "Events, gaming and culture; wide display type that shouts." },
|
|
32
|
+
{ id: "grotesk", display: "Schibsted Grotesk", body: "Work Sans", mono: "Roboto Mono", use: "Marketplaces and media brands; confident and newsy." },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
// Weights for families that do not have every role weight (load what exists).
|
|
36
|
+
const FAMILY_WEIGHTS = { "Space Mono": "400;700", "DM Mono": "400;500", "Libre Baskerville": "400;700", "Courier Prime": "400;700", "Syne": "600;700" };
|
|
37
|
+
|
|
38
|
+
const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
39
|
+
// A Google Fonts family name: letters, digits and spaces (e.g. "Source Sans 3").
|
|
40
|
+
const FAMILY = /^[A-Za-z0-9][A-Za-z0-9 ]{0,59}$/;
|
|
41
|
+
export const COLOR_KEYS = ["ink", "paper", "accent", "accent2", "card"];
|
|
42
|
+
export const FONT_ROLES = ["display", "body", "mono"];
|
|
43
|
+
const CSS_VAR = { ink: "--ink", paper: "--paper", accent: "--accent", accent2: "--accent-2", card: "--card" };
|
|
44
|
+
const FALLBACK = { display: "system-ui, sans-serif", body: "system-ui, sans-serif", mono: "ui-monospace, monospace" };
|
|
45
|
+
|
|
46
|
+
export const isHexColor = (s) => typeof s === "string" && HEX.test(s.trim());
|
|
47
|
+
export const isFontFamily = (s) => typeof s === "string" && FAMILY.test(s.trim()) && !/ {2}/.test(s.trim());
|
|
48
|
+
|
|
49
|
+
export function findPairing(id) {
|
|
50
|
+
const want = String(id ?? "").trim().toLowerCase();
|
|
51
|
+
return FONT_PAIRINGS.find((p) => p.id === want) ?? null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The Google Fonts stylesheet URL for one family at the weights its roles use
|
|
55
|
+
// (a family that is both display and body loads both roles' weights).
|
|
56
|
+
export function googleFontsUrl(family, roles = "body") {
|
|
57
|
+
const wanted = [roles].flat().flatMap((r) => (ROLE_WEIGHTS[r] ?? "400;700").split(";"));
|
|
58
|
+
const weights = FAMILY_WEIGHTS[family] ?? [...new Set(wanted)].sort().join(";");
|
|
59
|
+
return `https://fonts.googleapis.com/css2?family=${family.trim().replace(/ /g, "+")}:wght@${weights}&display=swap`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The pairings as markdown, for deck_guide { topic: "design" } and `hyperdecks brand fonts`.
|
|
63
|
+
export function fontPairingsMarkdown() {
|
|
64
|
+
const rows = FONT_PAIRINGS.map((p) => `| \`${p.id}\` | ${p.display} | ${p.body} | ${p.mono} | ${p.use} |`);
|
|
65
|
+
return [
|
|
66
|
+
"Pick one, then either save it as the brand (`set_brand { fontPairing: \"<id>\" }`, `hyperdecks brand set --fonts <id>`) or set the deck's own fonts: link each family from Google Fonts (one `<link rel=\"stylesheet\">` each, at the weights shown in the URL) and set `--font` (body), `--mono` and, for titles, `--display` in `:root`.",
|
|
67
|
+
"",
|
|
68
|
+
"| id | display | body | mono | use for |",
|
|
69
|
+
"| --- | --- | --- | --- | --- |",
|
|
70
|
+
...rows,
|
|
71
|
+
"",
|
|
72
|
+
"URL for one family: `https://fonts.googleapis.com/css2?family=<Name+With+Pluses>:wght@400;700&display=swap`.",
|
|
73
|
+
].join("\n");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Only the fields that are well formed: a brand read from the server (or a
|
|
77
|
+
// test double) never puts anything but a hex colour, a family name or an
|
|
78
|
+
// https URL into the deck.
|
|
79
|
+
export function cleanBrand(brand) {
|
|
80
|
+
if (!brand || typeof brand !== "object") return null;
|
|
81
|
+
const colors = {};
|
|
82
|
+
for (const k of COLOR_KEYS) if (isHexColor(brand.colors?.[k])) colors[k] = brand.colors[k].trim().toLowerCase();
|
|
83
|
+
const fonts = {};
|
|
84
|
+
for (const r of FONT_ROLES) if (isFontFamily(brand.fonts?.[r])) fonts[r] = brand.fonts[r].trim();
|
|
85
|
+
const logoUrl = typeof brand.logoUrl === "string" && /^https:\/\/[^\s"'<>]+$/.test(brand.logoUrl) ? brand.logoUrl : undefined;
|
|
86
|
+
const presenterStyle = typeof brand.presenterStyle === "string" ? brand.presenterStyle : undefined;
|
|
87
|
+
const name = typeof brand.name === "string" && brand.name.trim() ? brand.name.trim().slice(0, 60) : undefined;
|
|
88
|
+
const hasDesignMd = typeof brand.designMd === "string" && !!brand.designMd.trim();
|
|
89
|
+
if (!Object.keys(colors).length && !Object.keys(fonts).length && !logoUrl && !presenterStyle && !hasDesignMd && !name) return null;
|
|
90
|
+
return { name, colors, fonts, logoUrl, presenterStyle, hasDesignMd };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const escapeAttr = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
94
|
+
const fontStack = (family, role) => `"${family}", ${FALLBACK[role]}`;
|
|
95
|
+
const LOGO_CSS = `
|
|
96
|
+
/* The brand's logo (hyperdecks brand), on the first slide. */
|
|
97
|
+
.brand-logo { align-self: flex-start; height: 56px; width: auto; max-width: 320px; object-fit: contain; position: relative; z-index: 1; }
|
|
98
|
+
@media (max-width: 650px) { .brand-logo { height: 32px; max-width: 180px; } }`;
|
|
99
|
+
|
|
100
|
+
// index.html with the brand's colours, fonts and logo in place of the
|
|
101
|
+
// template's. Tokens in the deck's `:root` are replaced (or added), the Google
|
|
102
|
+
// Fonts link is swapped for the brand's families and the logo goes at the top
|
|
103
|
+
// of the first slide. A brand with nothing set returns the html unchanged.
|
|
104
|
+
export function applyBrand(html, brand) {
|
|
105
|
+
const b = cleanBrand(brand);
|
|
106
|
+
if (!b || (!Object.keys(b.colors).length && !Object.keys(b.fonts).length && !b.logoUrl)) return html;
|
|
107
|
+
// Our templates all have these; a template that lost them is a bug, not a brand-less deck.
|
|
108
|
+
const root = /:root\s*\{[^}]*--ink:[^}]*\}/.exec(html);
|
|
109
|
+
if (!root) throw new Error("applyBrand: the template has no :root block with --ink");
|
|
110
|
+
let block = root[0];
|
|
111
|
+
const set = (name, value) => {
|
|
112
|
+
const decl = new RegExp(`(${name}\\s*:\\s*)[^;}]*`);
|
|
113
|
+
block = decl.test(block) ? block.replace(decl, (_, k) => `${k}${value}`) : block.replace(/\s*\}$/, (end) => ` ${name}: ${value};${end}`);
|
|
114
|
+
};
|
|
115
|
+
for (const [k, v] of Object.entries(b.colors)) set(CSS_VAR[k], v);
|
|
116
|
+
if (b.fonts.body) set("--font", fontStack(b.fonts.body, "body"));
|
|
117
|
+
if (b.fonts.mono) set("--mono", fontStack(b.fonts.mono, "mono"));
|
|
118
|
+
if (b.fonts.display) set("--display", fontStack(b.fonts.display, "display"));
|
|
119
|
+
let out = html.slice(0, root.index) + block + html.slice(root.index + root[0].length);
|
|
120
|
+
|
|
121
|
+
const families = FONT_ROLES.filter((r) => b.fonts[r]).map((r) => [b.fonts[r], r]);
|
|
122
|
+
if (families.length) {
|
|
123
|
+
// Keep the template's link for the roles the brand leaves alone.
|
|
124
|
+
const kept = /<link rel="stylesheet" href="(https:\/\/fonts\.googleapis\.com\/css2\?[^"]*)">/.exec(out);
|
|
125
|
+
const rolesOf = new Map();
|
|
126
|
+
for (const [f, r] of families) rolesOf.set(f, [...(rolesOf.get(f) ?? []), r]);
|
|
127
|
+
const links = [...rolesOf].map(([f, roles]) => `<link rel="stylesheet" href="${escapeAttr(googleFontsUrl(f, roles))}">`);
|
|
128
|
+
if (kept && families.length < FONT_ROLES.length) links.unshift(kept[0]);
|
|
129
|
+
out = kept ? out.replace(kept[0], () => links.join("\n")) : out.replace("</head>", () => `${links.join("\n")}\n</head>`);
|
|
130
|
+
}
|
|
131
|
+
if (b.logoUrl) {
|
|
132
|
+
// The logo's rule goes in the style block that holds :root (not the loading state's).
|
|
133
|
+
const close = out.indexOf("</style>", out.indexOf(block));
|
|
134
|
+
if (close === -1) throw new Error("applyBrand: no </style> after the template's :root");
|
|
135
|
+
out = out.slice(0, close) + LOGO_CSS + "\n" + out.slice(close);
|
|
136
|
+
// The first real slide: the templates name `<section class="slide" id>` in comments too.
|
|
137
|
+
const inComment = (i) => out.lastIndexOf("<!--", i) > out.lastIndexOf("-->", i);
|
|
138
|
+
const first = [...out.matchAll(/<section\b[^>]*class="[^"]*\bslide\b[^"]*"[^>]*>/g)].find((m) => !inComment(m.index));
|
|
139
|
+
if (!first) throw new Error("applyBrand: the template has no <section class=\"slide\"> for the logo");
|
|
140
|
+
const img = `\n <img class="brand-logo" src="${escapeAttr(b.logoUrl)}" alt="${escapeAttr(b.name ? `${b.name} logo` : "Logo")}">`;
|
|
141
|
+
out = out.slice(0, first.index + first[0].length) + img + out.slice(first.index + first[0].length);
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The brand a new deck starts from: { brand: null } when asked not to
|
|
147
|
+
// (brand: false / --no-brand) or when nothing is saved. A failed read does not
|
|
148
|
+
// stop the deck (making decks must not depend on the brand, e.g. during a
|
|
149
|
+
// rollout); it starts in the template's look and readError says so.
|
|
150
|
+
export async function brandForNewDeck(getBrand, useBrand) {
|
|
151
|
+
if (useBrand === false) return { brand: null };
|
|
152
|
+
try {
|
|
153
|
+
return { brand: cleanBrand(await getBrand()) };
|
|
154
|
+
} catch (e) {
|
|
155
|
+
return { brand: null, readError: String(e?.data ?? e?.message ?? e) };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// What a new deck took from the brand, for the agent to read back.
|
|
160
|
+
export function brandSummary(b, readError) {
|
|
161
|
+
if (readError) return { applied: [], error: readError, note: `Could not read your saved brand (${readError}), so the deck has the template's look. Try get_brand (hyperdecks brand show); once it works, apply the brand's colours, fonts and logo yourself or make the deck again.` };
|
|
162
|
+
if (!b) return { applied: [], note: "No brand saved: the deck has the template's look. set_brand (hyperdecks brand set) saves one for every new deck." };
|
|
163
|
+
const applied = [
|
|
164
|
+
Object.keys(b.colors).length && "colors",
|
|
165
|
+
Object.keys(b.fonts).length && "fonts",
|
|
166
|
+
b.logoUrl && "logo",
|
|
167
|
+
b.presenterStyle && "presenterStyle",
|
|
168
|
+
].filter(Boolean);
|
|
169
|
+
if (!applied.length) return { applied, note: "Your brand has no colours, fonts or logo yet, so the deck has the template's look. Read its designMd (get_brand / hyperdecks brand show) for voice and do's and don'ts." };
|
|
170
|
+
return { applied, note: "Started from your saved brand. Read its designMd (get_brand / hyperdecks brand show) for voice and do's and don'ts; an explicit request for this deck wins over the brand." };
|
|
171
|
+
}
|
package/src/brandCli.mjs
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// hyperdecks brand show|set|edit|fonts|clear
|
|
2
|
+
//
|
|
3
|
+
// The account's brand (convex/brand.ts): colours, fonts, a logo and a
|
|
4
|
+
// design.md that every new deck (deck new, new_deck) starts from.
|
|
5
|
+
// brand show the saved brand (--json for all of it)
|
|
6
|
+
// brand set [--accent #hex] [--ink …] [--paper …] [--accent-2 …] [--card …]
|
|
7
|
+
// [--fonts <pairing>] [--display "<family>"] [--body …] [--mono …]
|
|
8
|
+
// [--logo <url|file>] [--no-logo] [--presenter-style s]
|
|
9
|
+
// [--design <file.md|->] [--name "<company>"]
|
|
10
|
+
// [--draft <token>] (a brand import, saved after you saw its preview)
|
|
11
|
+
// brand import <url|file|slug> read a brand from a website, a .pptx / .potx /
|
|
12
|
+
// .pdf or one of your decks into a draft + preview
|
|
13
|
+
// brand edit design.md in $VISUAL / $EDITOR, saved on exit
|
|
14
|
+
// brand fonts the curated font pairings
|
|
15
|
+
// brand clear forget the brand (new decks get the template look)
|
|
16
|
+
import { spawnSync } from "node:child_process";
|
|
17
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { basename, extname, join } from "node:path";
|
|
20
|
+
import * as ops from "./ops.mjs";
|
|
21
|
+
import { FONT_PAIRINGS } from "./brand.mjs";
|
|
22
|
+
|
|
23
|
+
const LOGO_TYPES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif", ".svg": "image/svg+xml", ".avif": "image/avif" };
|
|
24
|
+
const COLOR_FLAGS = { ink: "ink", paper: "paper", accent: "accent", "accent-2": "accent2", card: "card" };
|
|
25
|
+
const FONT_FLAGS = ["display", "body", "mono"];
|
|
26
|
+
const SET_USAGE = 'usage: brand set [--draft <token>] [--accent "#hex"] [--ink …] [--paper …] [--accent-2 …] [--card …] [--fonts <pairing>] [--display "<family>"] [--body …] [--mono …] [--logo <url|file>] [--no-logo] [--presenter-style stage|bubble|glass|corner] [--design <file.md|->] [--name "<company>"]';
|
|
27
|
+
|
|
28
|
+
export function brandLines(b) {
|
|
29
|
+
if (!b) return ["no brand saved: new decks get the template look", " set one: hyperdecks brand set --accent \"#ff5a1f\" --fonts editorial --logo ./logo.svg"];
|
|
30
|
+
const colors = Object.entries(b.colors ?? {}).map(([k, v]) => `${k} ${v}`).join(", ");
|
|
31
|
+
const fonts = Object.entries(b.fonts ?? {}).map(([k, v]) => `${k} ${v}`).join(", ");
|
|
32
|
+
const design = b.designMd ? `${b.designMd.split("\n").length} lines (hyperdecks brand show --json, or brand edit)` : "(none: hyperdecks brand edit)";
|
|
33
|
+
return [
|
|
34
|
+
...(b.name ? [`name: ${b.name}`] : []),
|
|
35
|
+
`colours: ${colors || "(template)"}`,
|
|
36
|
+
`fonts: ${fonts || "(template)"}`,
|
|
37
|
+
`logo: ${b.logoUrl ?? "(none)"}`,
|
|
38
|
+
`presenter: ${b.presenterStyle ?? "(template)"}`,
|
|
39
|
+
`design.md: ${design}`,
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const flag = (opts, k) => (opts[k] === undefined || opts[k] === true ? undefined : String(opts[k]));
|
|
44
|
+
|
|
45
|
+
// brand set's flags as setBrand's arguments; a --logo that is not a URL is a file.
|
|
46
|
+
export function brandArgs(opts, { readFile = readFileSync, stdin = () => readFileSync(0, "utf8") } = {}) {
|
|
47
|
+
const colors = {};
|
|
48
|
+
for (const [f, k] of Object.entries(COLOR_FLAGS)) if (flag(opts, f) !== undefined) colors[k] = flag(opts, f);
|
|
49
|
+
const fonts = {};
|
|
50
|
+
for (const r of FONT_FLAGS) if (flag(opts, r) !== undefined) fonts[r] = flag(opts, r);
|
|
51
|
+
const args = {};
|
|
52
|
+
if (Object.keys(colors).length) args.colors = colors;
|
|
53
|
+
if (Object.keys(fonts).length) args.fonts = fonts;
|
|
54
|
+
if (flag(opts, "fonts") !== undefined) args.fontPairing = flag(opts, "fonts");
|
|
55
|
+
if (flag(opts, "name") !== undefined) args.name = flag(opts, "name");
|
|
56
|
+
if (flag(opts, "presenter-style") !== undefined) args.presenterStyle = flag(opts, "presenter-style");
|
|
57
|
+
const design = flag(opts, "design");
|
|
58
|
+
if (design !== undefined) args.designMd = design === "-" ? stdin() : readFile(design, "utf8");
|
|
59
|
+
const logo = flag(opts, "logo");
|
|
60
|
+
if (logo !== undefined) {
|
|
61
|
+
if (/^https?:\/\//i.test(logo)) args.logoUrl = logo;
|
|
62
|
+
else {
|
|
63
|
+
const type = LOGO_TYPES[extname(logo).toLowerCase()];
|
|
64
|
+
if (!type) throw new Error(`--logo ${logo}: use a .png, .jpg, .webp, .gif, .svg or .avif file, or an image URL`);
|
|
65
|
+
args.logoBytes = readFile(logo);
|
|
66
|
+
args.logoType = type;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (opts["no-logo"]) args.clearLogo = true;
|
|
70
|
+
if (flag(opts, "draft") !== undefined) args.draft = flag(opts, "draft");
|
|
71
|
+
return args;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function brandShow(opts = {}, { log = (line) => console.log(line) } = {}) {
|
|
75
|
+
const b = await ops.getBrand(opts);
|
|
76
|
+
if (opts.json) log(JSON.stringify(b, null, 2));
|
|
77
|
+
else for (const line of brandLines(b)) log(line);
|
|
78
|
+
return b;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function brandSet(opts = {}, { log = (line) => console.log(line), ...io } = {}) {
|
|
82
|
+
const args = brandArgs(opts, io);
|
|
83
|
+
if (!Object.keys(args).length) throw new Error(SET_USAGE);
|
|
84
|
+
const b = await ops.setBrand(opts, args);
|
|
85
|
+
if (opts.json) log(JSON.stringify(b, null, 2));
|
|
86
|
+
else for (const line of ["✓ brand saved; new decks start from it", ...brandLines(b)]) log(line);
|
|
87
|
+
return b;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// design.md in the person's editor; saved only when it changed.
|
|
91
|
+
export async function brandEdit(opts = {}, { log = (line) => console.log(line), spawn = spawnSync, env = process.env } = {}) {
|
|
92
|
+
const b = await ops.getBrand(opts);
|
|
93
|
+
const before = b?.designMd ?? "# Brand\n\nVoice: …\n\nDo:\n- …\n\nDon't:\n- …\n";
|
|
94
|
+
const dir = mkdtempSync(join(tmpdir(), "hyperdecks-brand-"));
|
|
95
|
+
const file = join(dir, "design.md");
|
|
96
|
+
writeFileSync(file, before);
|
|
97
|
+
const editor = env.VISUAL || env.EDITOR || "vi";
|
|
98
|
+
const ran = spawn(`${editor} "${file}"`, { stdio: "inherit", shell: true });
|
|
99
|
+
const after = existsSync(file) ? readFileSync(file, "utf8") : before;
|
|
100
|
+
if (ran.error || ran.status !== 0 || after === before) {
|
|
101
|
+
rmSync(dir, { recursive: true, force: true });
|
|
102
|
+
if (ran.error) throw new Error(`could not start ${editor}: ${ran.error.message}`);
|
|
103
|
+
if (ran.status !== 0) throw new Error(`${editor} exited with ${ran.signal ?? ran.status}; design.md not saved`);
|
|
104
|
+
log("design.md unchanged");
|
|
105
|
+
return b;
|
|
106
|
+
}
|
|
107
|
+
let saved;
|
|
108
|
+
try {
|
|
109
|
+
saved = await ops.setBrand(opts, { designMd: after });
|
|
110
|
+
} catch (e) {
|
|
111
|
+
// Keep what they wrote: the temp file stays, and the error says where.
|
|
112
|
+
throw new Error(`design.md not saved (${ops.errorText(e)}); your text is still in ${file}. Retry with: hyperdecks brand set --design ${file}`);
|
|
113
|
+
}
|
|
114
|
+
rmSync(dir, { recursive: true, force: true });
|
|
115
|
+
log("✓ design.md saved");
|
|
116
|
+
return saved;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function brandFonts(opts = {}, { log = (line) => console.log(line) } = {}) {
|
|
120
|
+
const out = ops.listFontPairings();
|
|
121
|
+
if (opts.json) log(JSON.stringify(out, null, 2));
|
|
122
|
+
else for (const p of FONT_PAIRINGS) log(`${p.id.padEnd(10)} ${`${p.display} / ${p.body} / ${p.mono}`.padEnd(54)} ${p.use}`);
|
|
123
|
+
if (!opts.json) log("\nhyperdecks brand set --fonts <id>");
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function brandClear(opts = {}, { log = (line) => console.log(line) } = {}) {
|
|
128
|
+
const out = await ops.clearBrand(opts);
|
|
129
|
+
log(opts.json ? JSON.stringify(out) : "✓ brand cleared; new decks get the template look");
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// What `brand import` read, for the terminal.
|
|
134
|
+
export function importLines(d) {
|
|
135
|
+
const colors = Object.entries(d.colors ?? {}).map(([k, v]) => `${k} ${v}`).join(", ");
|
|
136
|
+
const fonts = Object.entries(d.fonts ?? {}).map(([k, v]) => `${k} ${v}`).join(", ");
|
|
137
|
+
return [
|
|
138
|
+
`read from ${d.source?.kind} ${d.source?.ref}`,
|
|
139
|
+
...(d.name ? [`name: ${d.name}`] : []),
|
|
140
|
+
`colours: ${colors || "(none found)"}`,
|
|
141
|
+
`fonts: ${fonts || "(none found)"}`,
|
|
142
|
+
`logo: ${d.logoUrl ?? "(none found)"}`,
|
|
143
|
+
...(d.notes ?? []).map((n) => ` · ${n}`),
|
|
144
|
+
"",
|
|
145
|
+
`preview: ${d.previewUrl}`,
|
|
146
|
+
`Nothing is saved yet. To make it your brand: hyperdecks brand set --draft ${d.token}`,
|
|
147
|
+
];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// brand import <url|file|slug>: an http(s) URL or a domain is a website, an
|
|
151
|
+
// existing path a file, anything else one of your deck slugs.
|
|
152
|
+
export async function brandImport(from, opts = {}, { log = (line) => console.log(line), readFile = readFileSync, exists = existsSync } = {}) {
|
|
153
|
+
const src = String(from ?? "").trim();
|
|
154
|
+
if (!src) throw new Error("usage: brand import <https://your.site | deck.pptx | brand.pdf | deck-slug> [--json]");
|
|
155
|
+
let args;
|
|
156
|
+
if (/^https?:\/\//i.test(src)) args = { url: src };
|
|
157
|
+
else if (exists(src)) args = { bytes: new Uint8Array(readFile(src)), fileName: basename(src) };
|
|
158
|
+
else if (/\.(pptx|potx|pdf)$/i.test(src)) throw new Error(`${src}: no such file`);
|
|
159
|
+
else if (/^[a-z0-9][a-z0-9-]*$/.test(src)) args = { slug: src };
|
|
160
|
+
else if (/^[\w-]+(\.[\w-]+)+(\/.*)?$/.test(src)) args = { url: `https://${src}` };
|
|
161
|
+
else throw new Error(`${src}: not a URL, a file or a deck slug`);
|
|
162
|
+
const d = await ops.importBrand(opts, args);
|
|
163
|
+
if (opts.json) log(JSON.stringify(d, null, 2));
|
|
164
|
+
else for (const line of importLines(d)) log(line);
|
|
165
|
+
return d;
|
|
166
|
+
}
|