@hypersocial/decks-cli 0.4.0 → 0.6.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 +35 -4
- package/skills/hyperdecks/chat.md +55 -11
- package/src/brand.mjs +171 -0
- package/src/brandCli.mjs +127 -0
- package/src/checkRules.mjs +35 -3
- package/src/commands.mjs +52 -6
- package/src/files.mjs +3 -0
- package/src/firstDeck.mjs +107 -0
- package/src/guide.mjs +3 -1
- package/src/hosted.mjs +15 -6
- package/src/icons.mjs +66 -0
- package/src/init.mjs +4 -101
- package/src/narrate.mjs +8 -1
- package/src/ops.mjs +115 -0
- package/src/presenter.mjs +9 -0
- package/src/release.mjs +3 -1
- package/src/runtime.mjs +17 -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/src/commands.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
VERSION,
|
|
10
10
|
} from "./runtime.mjs";
|
|
11
11
|
import * as ops from "./ops.mjs";
|
|
12
|
+
import { presenterLine } from "./presenter.mjs";
|
|
12
13
|
|
|
13
14
|
export const playbackLabel = (p) => p.narrationFinished ? "narration finished"
|
|
14
15
|
: p.narrationStarted ? "narration started"
|
|
@@ -306,8 +307,9 @@ commands:
|
|
|
306
307
|
deck list|stats|open|rm|forward
|
|
307
308
|
deck forward on|off <slug>
|
|
308
309
|
whether viewers can forward the deck's links (on by default; a link's own setting wins)
|
|
309
|
-
deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style stage|bubble|glass|corner] [--dir <path>]
|
|
310
|
-
scaffold decks/<slug>/: index.html (8 slides, or one per layout, player/v1), script.md, evidence.md
|
|
310
|
+
deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style stage|bubble|glass|corner] [--no-brand] [--dir <path>]
|
|
311
|
+
scaffold decks/<slug>/: index.html (8 slides, or one per layout, player/v1), script.md, evidence.md,
|
|
312
|
+
in your saved brand's colours, fonts and logo (--no-brand: the template's look)
|
|
311
313
|
layouts: hero, number, product, quote, compare, timeline, features, chart, team, closer
|
|
312
314
|
check <slug|deck-dir> [--forbid "<phrase>,<phrase>"] [--max-duration <s>] [--layout] [--json]
|
|
313
315
|
the narrated-deck gate: slides vs manifest, timing, captions, clip URLs,
|
|
@@ -320,7 +322,7 @@ commands:
|
|
|
320
322
|
file an image, video or font under the deck; prints its hosted URL
|
|
321
323
|
guide [workflow|format|script|design|layouts|presenter|transitions|check]
|
|
322
324
|
the skill, one topic at a time (what deck_guide serves)
|
|
323
|
-
release <slug|deck-dir> [--to "<name>" [--email <e>]]... [--qa] [--no-notify] [--title t] [--voice v]
|
|
325
|
+
release <slug|deck-dir> [--to "<name>" [--email <e>]]... [--qa] [--no-notify] [--title t] [--voice v] [--captions-only (--hosted)]
|
|
324
326
|
on a committed deck: check → narrate → import → share new per --to
|
|
325
327
|
share new|list|on|off|forward|mute|unmute|rm|url
|
|
326
328
|
share new <slug> <recipient> [--qa] [--no-notify] [--allow-forward|--no-forward]
|
|
@@ -347,8 +349,17 @@ commands:
|
|
|
347
349
|
a PNG of a public web page (private and local addresses are refused)
|
|
348
350
|
chart <spec.json|-> [--slug <deck>] [--name <name>] [--download <file>]
|
|
349
351
|
a branded SVG: { "kind": "bar|line|donut|number", "labels", "series", "values", "value", "palette" }
|
|
352
|
+
icons search <word...> [--limit 8] [--stroke-width 2] [--json]
|
|
353
|
+
real icons from the Lucide library as inline SVG in currentColor (free, offline)
|
|
350
354
|
icons <subject...> --style "<style>" [--palette "#hex,#hex"] [--slug <deck>] [--name <prefix>]
|
|
351
|
-
a style-consistent set of
|
|
355
|
+
a style-consistent set of generated (illustrated) icons, one per subject (max 12)
|
|
356
|
+
brand [show] your saved brand (colours, fonts, logo, design.md) that every new deck starts from
|
|
357
|
+
brand set [--accent "#hex"] [--ink|--paper|--accent-2|--card "#hex"] [--fonts <pairing>]
|
|
358
|
+
[--display|--body|--mono "<Google Fonts family>"] [--logo <url|file>] [--no-logo]
|
|
359
|
+
[--presenter-style s] [--design <file.md|->] [--name "<company>"]
|
|
360
|
+
brand edit edit design.md (voice, do and don't) in $EDITOR
|
|
361
|
+
brand fonts the curated font pairings (display + body + mono)
|
|
362
|
+
brand clear forget the brand; new decks get the template look
|
|
352
363
|
review <slug> [--script <script.md>] [--json]
|
|
353
364
|
GPT-6 Luna reviews every slide (desktop + phone screenshots) and lists what to fix
|
|
354
365
|
review-job <jobId> [--json]
|
|
@@ -368,6 +379,8 @@ commands:
|
|
|
368
379
|
asks you to type "I consent" before anything is uploaded
|
|
369
380
|
voice upload <file...> upload recordings and print their sample ids (for the MCP clone_voice tool)
|
|
370
381
|
voice rm <name|id> delete a voice, also at the provider
|
|
382
|
+
voice hear <voice> | voice hear --narrator <name|id>
|
|
383
|
+
a short sample of the voice: its clip URL and the line it says
|
|
371
384
|
avatar [--json] your avatars (who presents decks in your voice) and the default
|
|
372
385
|
avatar new [--species <s>] pick a species, then an avatar (s shuffles); --json or an agent gets a pick link
|
|
373
386
|
avatar from-photo [file] [--save-to-minimoji]
|
|
@@ -381,6 +394,8 @@ commands:
|
|
|
381
394
|
narrator default <name|id|dash> make it the default
|
|
382
395
|
narrator remove <name|id> remove it; decks already shared keep working
|
|
383
396
|
narrator voice <name|id> <voice> change its voice (one of yours, or dash)
|
|
397
|
+
narrator rename <name|id> <new name> rename it
|
|
398
|
+
narrator face <name|id> <avatar> change its face (one of your avatars)
|
|
384
399
|
deck narrator <slug> <name|id|none|default> the deck's own narrator (none: captions only)
|
|
385
400
|
|
|
386
401
|
global:
|
|
@@ -625,7 +640,7 @@ function commandTable(pos, rest, opts) {
|
|
|
625
640
|
if (opts.hosted) {
|
|
626
641
|
const { hostedNewDeck } = await import("./source.mjs");
|
|
627
642
|
if (!opts.narrated) throw new Error("the published CLI scaffolds narrated decks only: add --narrated");
|
|
628
|
-
const out = await hostedNewDeck(opts, { slug: rest[0], title: rest[1], layouts: opts.layouts, presenterStyle: opts["presenter-style"] });
|
|
643
|
+
const out = await hostedNewDeck(opts, { slug: rest[0], title: rest[1], layouts: opts.layouts, presenterStyle: opts["presenter-style"], brand: !opts["no-brand"] });
|
|
629
644
|
return void console.log(opts.json ? JSON.stringify(out, null, 2) : `✓ created hosted deck "${out.slug}" (rev ${out.rev})\n preview: ${out.previewUrl}\n edit locally: hyperdecks source pull ${out.slug}`);
|
|
630
645
|
}
|
|
631
646
|
const { deckNew } = await import("./scaffold.mjs");
|
|
@@ -648,9 +663,11 @@ function commandTable(pos, rest, opts) {
|
|
|
648
663
|
release: async () => {
|
|
649
664
|
if (opts.hosted) {
|
|
650
665
|
const { hostedRelease } = await import("./source.mjs");
|
|
651
|
-
const out = await hostedRelease(opts, { slug: pos[1], recipients: opts.recipients, qa: opts.qa, title: opts.title, forbid: opts.forbid });
|
|
666
|
+
const out = await hostedRelease(opts, { slug: pos[1], recipients: opts.recipients, qa: opts.qa, title: opts.title, forbid: opts.forbid, captionsOnly: opts["captions-only"] });
|
|
652
667
|
if (opts.json) return void console.log(JSON.stringify(out, null, 2));
|
|
653
668
|
console.log(`✓ released ${out.slug} rev ${out.rev}\n deck: ${out.deckUrl}`);
|
|
669
|
+
const who = presenterLine(out.presenter);
|
|
670
|
+
if (who) console.log(who);
|
|
654
671
|
for (const s of out.shares) console.log(`✓ share for "${s.recipient}"${s.qa ? " (QA, untracked)" : ""} → ${s.url}`);
|
|
655
672
|
return;
|
|
656
673
|
}
|
|
@@ -766,6 +783,21 @@ function commandTable(pos, rest, opts) {
|
|
|
766
783
|
const out = await ops.setNarratorVoice(opts, rest[0], rest.slice(1).join(" "));
|
|
767
784
|
console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.name} speaks in ${out.voice} now`);
|
|
768
785
|
},
|
|
786
|
+
"narrator rename": async () => {
|
|
787
|
+
if (rest.length < 2) throw new Error("usage: narrator rename <name|id> <new name>");
|
|
788
|
+
const out = await ops.renameNarrator(opts, rest[0], rest.slice(1).join(" "));
|
|
789
|
+
console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.was} is ${out.name} now`);
|
|
790
|
+
},
|
|
791
|
+
"narrator face": async () => {
|
|
792
|
+
if (rest.length < 2) throw new Error("usage: narrator face <name|id> <avatar>");
|
|
793
|
+
const out = await ops.setNarratorFace(opts, rest[0], rest.slice(1).join(" "));
|
|
794
|
+
console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.name} wears ${out.face} now`);
|
|
795
|
+
},
|
|
796
|
+
"voice hear": async () => {
|
|
797
|
+
if (!rest[0] && !opts.narrator) throw new Error("usage: voice hear <voice> | voice hear --narrator <name|id>");
|
|
798
|
+
const out = await ops.hearVoice(opts, opts.narrator ? { narrator: opts.narrator } : { voice: rest.join(" ") });
|
|
799
|
+
console.log(opts.json ? JSON.stringify(out, null, 2) : `"${out.caption}"\n ${out.url}`);
|
|
800
|
+
},
|
|
769
801
|
"deck narrator": async () => {
|
|
770
802
|
if (rest.length < 2) throw new Error("usage: deck narrator <slug> <name|id|none|default>");
|
|
771
803
|
const out = await ops.setDeckNarrator(opts, rest[0], rest.slice(1).join(" "));
|
|
@@ -807,6 +839,20 @@ function commandTable(pos, rest, opts) {
|
|
|
807
839
|
screenshot: () => screenshot(pos.slice(1), opts),
|
|
808
840
|
chart: () => chart(pos.slice(1), opts),
|
|
809
841
|
icons: () => icons(pos.slice(1), opts),
|
|
842
|
+
// The icon library (Lucide); `icons search … --style` still generates.
|
|
843
|
+
"icons search": async () => {
|
|
844
|
+
if (opts.style) return icons(pos.slice(1), opts);
|
|
845
|
+
if (!rest.length) throw new Error("usage: icons search <word...> [--limit N] [--stroke-width N] [--json]");
|
|
846
|
+
const { icons: searchIcons } = await import("./icons.mjs");
|
|
847
|
+
const out = await searchIcons(rest, opts);
|
|
848
|
+
if (opts.json) console.log(JSON.stringify(out, null, 2));
|
|
849
|
+
},
|
|
850
|
+
brand: async () => (await import("./brandCli.mjs")).brandShow(opts),
|
|
851
|
+
"brand show": async () => (await import("./brandCli.mjs")).brandShow(opts),
|
|
852
|
+
"brand set": async () => (await import("./brandCli.mjs")).brandSet(opts),
|
|
853
|
+
"brand edit": async () => (await import("./brandCli.mjs")).brandEdit(opts),
|
|
854
|
+
"brand fonts": async () => (await import("./brandCli.mjs")).brandFonts(opts),
|
|
855
|
+
"brand clear": async () => (await import("./brandCli.mjs")).brandClear(opts),
|
|
810
856
|
review: () => review(pos.slice(1), opts),
|
|
811
857
|
"review-job": () => reviewJob(pos.slice(1), opts),
|
|
812
858
|
poster: () => poster(pos.slice(1), opts),
|
package/src/files.mjs
CHANGED
|
@@ -19,6 +19,9 @@ const FILES = {
|
|
|
19
19
|
"templates/layouts/quote.html": () => new URL("../templates/layouts/quote.html", import.meta.url),
|
|
20
20
|
"templates/layouts/team.html": () => new URL("../templates/layouts/team.html", import.meta.url),
|
|
21
21
|
"templates/layouts/timeline.html": () => new URL("../templates/layouts/timeline.html", import.meta.url),
|
|
22
|
+
"templates/icons/lucide.json": () => new URL("../templates/icons/lucide.json", import.meta.url),
|
|
23
|
+
"templates/first-deck/self.json": () => new URL("../templates/first-deck/self.json", import.meta.url),
|
|
24
|
+
"templates/first-deck/character.json": () => new URL("../templates/first-deck/character.json", import.meta.url),
|
|
22
25
|
"skills/hyperdecks/SKILL.md": () => new URL("../skills/hyperdecks/SKILL.md", import.meta.url),
|
|
23
26
|
"skills/hyperdecks/chat.md": () => new URL("../skills/hyperdecks/chat.md", import.meta.url),
|
|
24
27
|
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// The first deck `hyperdecks init` (and the hosted get_started tool) makes: a
|
|
2
|
+
// three-slide hello deck (hero, features, closer) filled from three answers.
|
|
3
|
+
// No file or process I/O beyond the packaged templates, so the hosted MCP route
|
|
4
|
+
// can bundle it.
|
|
5
|
+
import { packageText } from "./files.mjs";
|
|
6
|
+
import { slugify } from "./importer.mjs";
|
|
7
|
+
|
|
8
|
+
export const FIRST_DECK_LAYOUTS = "hero,features,closer";
|
|
9
|
+
export const DASH_AVATAR = "https://decks.hypersocial.ai/player/v1/dash-avatar.json";
|
|
10
|
+
export const ANSWER_MAX = { name: 40, about: 80, ask: 80 };
|
|
11
|
+
|
|
12
|
+
// One answer as it goes on a slide and into the script: one line, no stage-tag
|
|
13
|
+
// brackets, ending in a full stop so it is its own caption.
|
|
14
|
+
export function cleanAnswer(key, value) {
|
|
15
|
+
const text = String(value ?? "").replace(/[[\]]/g, "").replace(/\s+/g, " ").trim();
|
|
16
|
+
if (text.length < 2) throw new Error(`--${key} needs an answer`);
|
|
17
|
+
if (text.length > ANSWER_MAX[key]) throw new Error(`--${key} is ${text.length} characters; keep it to ${ANSWER_MAX[key]} so it fits a caption`);
|
|
18
|
+
return key === "name" ? text.replace(/[.!?]+$/, "") : /[.!?…]$/.test(text) ? text : `${text}.`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const firstDeckSlug = (email) => `hello-${slugify(String(email ?? "").split("@")[0]) || "there"}`.slice(0, 58).replace(/-+$/, "");
|
|
22
|
+
|
|
23
|
+
const escapeHtml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
24
|
+
|
|
25
|
+
export function firstDeckCopy(presenter) {
|
|
26
|
+
return JSON.parse(packageText(`templates/first-deck/${presenter === "dash" ? "character" : "self"}.json`));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function slots(text, answers, html) {
|
|
30
|
+
const map = { __NAME__: answers.name, __WHAT__: answers.about, __ASK__: answers.ask };
|
|
31
|
+
return text.replace(/__NAME__|__WHAT__|__ASK__/g, (k) => (html ? escapeHtml(map[k]) : map[k]));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Replace exactly one match, or fail loudly: a layout change must not ship a deck
|
|
35
|
+
// that still says "One sentence on what this deck is about."
|
|
36
|
+
function swap(html, pattern, to, what) {
|
|
37
|
+
let hit = 0;
|
|
38
|
+
const out = html.replace(pattern, (...m) => (hit++, typeof to === "function" ? to(...m) : to));
|
|
39
|
+
if (hit !== 1) throw new Error(`first deck: the ${what} in the layout template changed (${hit} matches); update init's first-deck fill`);
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function section(html, id, edit) {
|
|
44
|
+
return swap(html, new RegExp(`<section\\b[^>]*\\sid="${id}"[\\s\\S]*?</section>`), (s) => edit(s), `#${id} slide`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// index.html and script.md of the scaffolded hero,features,closer deck, filled
|
|
48
|
+
// with the answers: no template copy is left, and no generated imagery.
|
|
49
|
+
export function fillFirstDeck({ html, title, answers, presenter, voice }) {
|
|
50
|
+
const copy = firstDeckCopy(presenter);
|
|
51
|
+
const h = (t) => slots(t, answers, true);
|
|
52
|
+
const note = (s) => swap(s, /\sdata-note="[^"]*"/, ' data-note="From your init answers."', "data-note");
|
|
53
|
+
let out = section(html, "hero", (s) => {
|
|
54
|
+
s = note(s);
|
|
55
|
+
// No art: the presenter is the opening's visual, and check fails on the stand-in.
|
|
56
|
+
s = swap(s, /\s*<div class="art"[^>]*><\/div>/, "", "hero art");
|
|
57
|
+
s = swap(s, /<div class="kicker">[^<]*<\/div>/, `<div class="kicker">${h(copy.hero.kicker)}</div>`, "hero kicker");
|
|
58
|
+
s = swap(s, /<h1 class="tt"[^>]*>[\s\S]*?<\/h1>/, `<h1 class="tt" data-title-mobile="${h(copy.hero.title)}">${h(copy.hero.title)}</h1>`, "hero title");
|
|
59
|
+
return swap(s, /<p class="lead">[\s\S]*?<\/p>/, `<p class="lead">${h(copy.hero.lead)}</p>`, "hero lead");
|
|
60
|
+
});
|
|
61
|
+
out = section(out, "features", (s) => {
|
|
62
|
+
s = note(s);
|
|
63
|
+
s = swap(s, /<div class="kicker">[^<]*<\/div>/, `<div class="kicker">${h(copy.features.kicker)}</div>`, "features kicker");
|
|
64
|
+
s = swap(s, /<h2 class="tt"[^>]*>[\s\S]*?<\/h2>/, `<h2 class="tt" data-title-mobile="${h(copy.features.title)}">${h(copy.features.title)}</h2>`, "features title");
|
|
65
|
+
copy.features.cards.forEach((card, i) => {
|
|
66
|
+
s = swap(s, new RegExp(`(<li class="card" id="features-f${i + 1}">[\\s\\S]*?</svg>)<b>[^<]*</b><p>[^<]*</p>`), (_, icon) => `${icon}<b>${h(card.name)}</b><p>${h(card.line)}</p>`, `features card ${i + 1}`);
|
|
67
|
+
});
|
|
68
|
+
return s;
|
|
69
|
+
});
|
|
70
|
+
out = section(out, "closer", (s) => {
|
|
71
|
+
s = note(s);
|
|
72
|
+
s = swap(s, /<h2 class="tt"[^>]*>[\s\S]*?<\/h2>/, `<h2 class="tt" data-title-mobile="${h(copy.closer.title)}">${h(copy.closer.title)}</h2>`, "closer title");
|
|
73
|
+
s = swap(s, /<p class="lead">[\s\S]*?<\/p>/, `<p class="lead">${h(copy.closer.lead)}</p>`, "closer lead");
|
|
74
|
+
s = swap(s, /<a class="cta"[^>]*>[\s\S]*?<\/a>/, `<p class="cta" id="closer-cta">${h(copy.closer.cta)}</p>`, "closer call to action");
|
|
75
|
+
return swap(s, /<div class="contact muted">[^<]*<\/div>/, `<div class="contact muted">${h(copy.closer.contact)}</div>`, "closer contact");
|
|
76
|
+
});
|
|
77
|
+
// A character's live head presents its own deck; a person's voice ships with
|
|
78
|
+
// captions only (data-presenter="none"), never on a character's face.
|
|
79
|
+
if (presenter === "dash") {
|
|
80
|
+
out = swap(out, /(<script type="module" src="https:\/\/decks\.hypersocial\.ai\/player\/v1\/player\.js"[^>]*?)\sdata-presenter="none"/, (_, tag) => `${tag} data-presenter="head" data-avatar="${DASH_AVATAR}"`, "player tag");
|
|
81
|
+
}
|
|
82
|
+
const s = (t) => slots(t, answers, false);
|
|
83
|
+
const script = `---
|
|
84
|
+
voice: ${voice}
|
|
85
|
+
maxTotalSeconds: 90
|
|
86
|
+
transition: drift
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
# ${title} — narration script
|
|
90
|
+
|
|
91
|
+
Written by \`hyperdecks init\` from your three answers. Make it yours: rewrite
|
|
92
|
+
these lines and the slides in index.html, then
|
|
93
|
+
|
|
94
|
+
hyperdecks narrate .
|
|
95
|
+
hyperdecks release . --to "<name>"
|
|
96
|
+
|
|
97
|
+
## hero
|
|
98
|
+
${s(copy.hero.script)}
|
|
99
|
+
|
|
100
|
+
## features
|
|
101
|
+
${s(copy.features.script)}
|
|
102
|
+
|
|
103
|
+
## closer
|
|
104
|
+
${s(copy.closer.script)}
|
|
105
|
+
`;
|
|
106
|
+
return { html: out, script };
|
|
107
|
+
}
|
package/src/guide.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// each layout file's leading comment.
|
|
7
7
|
import { packageText } from "./files.mjs";
|
|
8
8
|
import { LAYOUTS, readLayout } from "./layouts.mjs";
|
|
9
|
+
import { fontPairingsMarkdown } from "./brand.mjs";
|
|
9
10
|
|
|
10
11
|
export const GUIDE_TOPICS = ["workflow", "format", "script", "design", "layouts", "presenter", "transitions", "check"];
|
|
11
12
|
const CLI_NOTE = "_Written for the CLI. On the hosted MCP there is no folder: use the tool names in `deck_guide` (workflow), hosted URLs for every file, and write_deck_source for edits._\n\n";
|
|
@@ -43,7 +44,8 @@ export function deckGuide(topic = "workflow") {
|
|
|
43
44
|
else {
|
|
44
45
|
const section = skillSection(packageText("skills/hyperdecks/SKILL.md"), t);
|
|
45
46
|
if (!section) throw new Error(`SKILL.md has no <!-- topic: ${t} --> section`);
|
|
46
|
-
|
|
47
|
+
// The design topic carries the curated font pairings (brand.mjs) in place of its marker.
|
|
48
|
+
text = CLI_NOTE + section.replace("<!-- font pairings -->", () => fontPairingsMarkdown());
|
|
47
49
|
}
|
|
48
50
|
return { topic: t, topics: GUIDE_TOPICS, text };
|
|
49
51
|
}
|
package/src/hosted.mjs
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// (source.mjs) share these; no local file I/O, so the route can bundle them.
|
|
4
4
|
import { api, client, target } from "./runtime.mjs";
|
|
5
5
|
import { PRESENTER_STYLES } from "./checkRules.mjs";
|
|
6
|
-
import { NARRATED_FILES, assembleLayoutDeck, fillTemplate } from "./layouts.mjs";
|
|
6
|
+
import { NARRATED_FILES, assembleLayoutDeck, fillTemplate, parseLayouts } from "./layouts.mjs";
|
|
7
|
+
import { applyBrand, brandForNewDeck, brandSummary } from "./brand.mjs";
|
|
8
|
+
import { getBrand } from "./ops.mjs";
|
|
7
9
|
|
|
8
10
|
export const SOURCE_FILES = Object.keys(NARRATED_FILES);
|
|
9
11
|
const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
@@ -13,16 +15,22 @@ const urls = (opts, slug, previewToken) => {
|
|
|
13
15
|
};
|
|
14
16
|
|
|
15
17
|
// Scaffold the files (the same templates as deck new) and register the draft.
|
|
16
|
-
|
|
18
|
+
// The account's brand (colours, fonts, logo, presenter style) goes on the
|
|
19
|
+
// template unless brand is false; an explicit presenterStyle wins over it.
|
|
20
|
+
export async function hostedNewDeck(opts, { slug, title, layouts, presenterStyle, brand: useBrand } = {}) {
|
|
17
21
|
if (!slug || !title) throw new Error("slug and title are required");
|
|
18
22
|
if (!SLUG.test(slug)) throw new Error("slug must be lowercase letters, digits and dashes (max 64)");
|
|
19
23
|
if (presenterStyle !== undefined && !PRESENTER_STYLES.includes(presenterStyle)) throw new Error(`presenterStyle must be one of ${PRESENTER_STYLES.join(", ")}`);
|
|
20
24
|
const list = Array.isArray(layouts) ? layouts.join(",") : layouts;
|
|
25
|
+
if (list) parseLayouts(list);
|
|
26
|
+
const { brand, readError } = await brandForNewDeck(() => getBrand(opts), useBrand);
|
|
27
|
+
const style = presenterStyle ?? (PRESENTER_STYLES.includes(brand?.presenterStyle) ? brand.presenterStyle : undefined);
|
|
21
28
|
const files = list
|
|
22
|
-
? assembleLayoutDeck({ slug, title, presenterStyle, layouts: list })
|
|
23
|
-
: Object.fromEntries(Object.entries(NARRATED_FILES).map(([out, file]) => [out, fillTemplate(file, { slug, title, presenterStyle })]));
|
|
29
|
+
? assembleLayoutDeck({ slug, title, presenterStyle: style, layouts: list })
|
|
30
|
+
: Object.fromEntries(Object.entries(NARRATED_FILES).map(([out, file]) => [out, fillTemplate(file, { slug, title, presenterStyle: style })]));
|
|
31
|
+
files["index.html"] = applyBrand(files["index.html"], brand);
|
|
24
32
|
const made = await (await client(opts)).mutation(api.deckSources.create, { slug, title, files });
|
|
25
|
-
return { slug, rev: made.rev, files, ...urls(opts, slug, made.previewToken) };
|
|
33
|
+
return { slug, rev: made.rev, files, brand: brandSummary(brand, readError), ...urls(opts, slug, made.previewToken) };
|
|
26
34
|
}
|
|
27
35
|
|
|
28
36
|
export async function getDeckSource(opts, { slug, file } = {}) {
|
|
@@ -59,7 +67,7 @@ export async function hostedCheck(opts, { slug, forbid, maxDuration } = {}) {
|
|
|
59
67
|
return (await client(opts)).action(api.deckSources.check, { slug, ...(list?.length ? { forbid: list } : {}), ...(maxDuration ? { maxDuration: Number(maxDuration) } : {}) });
|
|
60
68
|
}
|
|
61
69
|
|
|
62
|
-
export async function hostedRelease(opts, { slug, recipients, qa, title, forbid, rev, voice } = {}) {
|
|
70
|
+
export async function hostedRelease(opts, { slug, recipients, qa, title, forbid, rev, voice, captionsOnly } = {}) {
|
|
63
71
|
if (!slug) throw new Error("slug is required");
|
|
64
72
|
const list = typeof forbid === "string" ? forbid.split(",").map((s) => s.trim()).filter(Boolean) : forbid;
|
|
65
73
|
const args = { slug };
|
|
@@ -69,6 +77,7 @@ export async function hostedRelease(opts, { slug, recipients, qa, title, forbid,
|
|
|
69
77
|
if (list?.length) args.forbid = list;
|
|
70
78
|
if (rev !== undefined && rev !== null) args.rev = Number(rev);
|
|
71
79
|
if (voice) args.voice = String(voice);
|
|
80
|
+
if (captionsOnly) args.captionsOnly = true;
|
|
72
81
|
return (await client(opts)).action(api.deckSources.release, args);
|
|
73
82
|
}
|
|
74
83
|
|
package/src/icons.mjs
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// hyperdecks icons search <word> [--limit N] [--stroke-width N]
|
|
2
|
+
//
|
|
3
|
+
// The icon library: Lucide's open-source line icons (ISC licence, vendored in
|
|
4
|
+
// templates/icons/lucide.json by scripts/sync-icons.mjs). A search returns
|
|
5
|
+
// inline SVG stroked in currentColor, so a pasted icon takes the colour of the
|
|
6
|
+
// text around it, i.e. the deck's palette. No network and no credits; for
|
|
7
|
+
// illustrated icons in a style of your own, `hyperdecks icons <subject...>
|
|
8
|
+
// --style` (generate_icon_set) still draws them.
|
|
9
|
+
import { packageText } from "./files.mjs";
|
|
10
|
+
|
|
11
|
+
export const MAX_ICON_RESULTS = 24;
|
|
12
|
+
const DEFAULT_RESULTS = 8;
|
|
13
|
+
let library;
|
|
14
|
+
const lib = () => (library ??= JSON.parse(packageText("templates/icons/lucide.json")));
|
|
15
|
+
|
|
16
|
+
export function iconSvg(name, { strokeWidth = 2 } = {}) {
|
|
17
|
+
const icon = lib().icons[name];
|
|
18
|
+
if (!icon) return null;
|
|
19
|
+
const sw = Number(strokeWidth);
|
|
20
|
+
const width = Number.isFinite(sw) && sw > 0 && sw <= 4 ? sw : 2;
|
|
21
|
+
return `<svg class="icon lucide-${name}" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icon[0]}</svg>`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Best matches first: the exact name, a name starting with or containing a
|
|
25
|
+
// word, then an exact tag, then a tag containing a word. Several words rank by
|
|
26
|
+
// how many of them match.
|
|
27
|
+
export function searchIcons({ query, limit, strokeWidth } = {}) {
|
|
28
|
+
const words = String(query ?? "").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
29
|
+
if (!words.length) throw new Error("search needs a word, e.g. rocket");
|
|
30
|
+
const n = Math.min(Math.max(Number(limit) || DEFAULT_RESULTS, 1), MAX_ICON_RESULTS);
|
|
31
|
+
const phrase = words.join("-");
|
|
32
|
+
const scored = [];
|
|
33
|
+
for (const [name, [, tagText]] of Object.entries(lib().icons)) {
|
|
34
|
+
const parts = name.split("-");
|
|
35
|
+
const tags = tagText ? tagText.split(",") : [];
|
|
36
|
+
let score = name === phrase ? 100 : 0;
|
|
37
|
+
for (const w of words) {
|
|
38
|
+
if (parts[0] === w) score += 12;
|
|
39
|
+
else if (parts.includes(w)) score += 10;
|
|
40
|
+
else if (name.includes(w)) score += 6;
|
|
41
|
+
else if (tags.includes(w)) score += 5;
|
|
42
|
+
else if (tags.some((t) => t.split(" ").includes(w))) score += 3;
|
|
43
|
+
else if (tags.some((t) => t.includes(w))) score += 1;
|
|
44
|
+
}
|
|
45
|
+
// Plainer icons first among equals: rocket before rocket-launch.
|
|
46
|
+
if (score) scored.push({ name, tags, score: score - parts.length * 0.1 });
|
|
47
|
+
}
|
|
48
|
+
scored.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
|
|
49
|
+
return {
|
|
50
|
+
query: words.join(" "),
|
|
51
|
+
library: lib().source,
|
|
52
|
+
license: lib().license,
|
|
53
|
+
total: scored.length,
|
|
54
|
+
icons: scored.slice(0, n).map(({ name, tags }) => ({ name, tags, svg: iconSvg(name, { strokeWidth }) })),
|
|
55
|
+
usage: "Paste svg inline (e.g. in .l-features .card). It is stroked in currentColor: set `color` on it or its parent to put it in the deck's palette.",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function icons(pos, opts = {}, { log = (line) => console.log(line) } = {}) {
|
|
60
|
+
const out = searchIcons({ query: pos.join(" "), limit: opts.limit, strokeWidth: opts["stroke-width"] });
|
|
61
|
+
if (opts.json) return out;
|
|
62
|
+
if (!out.icons.length) log(`no icons match "${out.query}"; try a plainer word (e.g. chart, user, lock)`);
|
|
63
|
+
for (const icon of out.icons) log(`${icon.name}${icon.tags.length ? ` (${icon.tags.slice(0, 5).join(", ")})` : ""}\n${icon.svg}\n`);
|
|
64
|
+
if (out.icons.length) log(`${out.icons.length} of ${out.total} from ${out.library}. ${out.usage}`);
|
|
65
|
+
return out;
|
|
66
|
+
}
|
package/src/init.mjs
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// QA link and print it. Every step checks real state before it acts, so running
|
|
7
7
|
// init again resumes where it stopped and changes nothing once it is complete.
|
|
8
8
|
// Each step returns { step, status: "done" | "skipped" | "ran" | "needs-human", detail }.
|
|
9
|
-
//
|
|
9
|
+
// In chat (no folder, no git) the hosted MCP's get_started tool walks the same
|
|
10
|
+
// steps on a hosted draft (lib/getStarted.ts); the deck fill is shared (firstDeck.mjs).
|
|
10
11
|
import { execFileSync } from "node:child_process";
|
|
11
12
|
import { createHash, randomBytes } from "node:crypto";
|
|
12
13
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
@@ -15,7 +16,7 @@ import { dirname, join, relative, resolve } from "node:path";
|
|
|
15
16
|
import { createInterface } from "node:readline/promises";
|
|
16
17
|
import { fileURLToPath } from "node:url";
|
|
17
18
|
import * as ops from "./ops.mjs";
|
|
18
|
-
import {
|
|
19
|
+
import { ANSWER_MAX, DASH_AVATAR, FIRST_DECK_LAYOUTS, cleanAnswer, fillFirstDeck, firstDeckCopy, firstDeckSlug } from "./firstDeck.mjs";
|
|
19
20
|
import { agentTable, api, chooseAgents, client, CRED_DIR, installHyperdecksSkill, login as runLogin, openBrowser, target } from "./runtime.mjs";
|
|
20
21
|
import { READING_PASSAGE, voiceClone as runVoiceClone } from "./voice.mjs";
|
|
21
22
|
import { assembleLayoutDeck, deckNew as runDeckNew } from "./scaffold.mjs";
|
|
@@ -23,17 +24,13 @@ import { narrate as runNarrate } from "./narrate.mjs";
|
|
|
23
24
|
import { release as runRelease } from "./release.mjs";
|
|
24
25
|
|
|
25
26
|
export const STATE_FILE = join(CRED_DIR, "init.json");
|
|
26
|
-
export const FIRST_DECK_LAYOUTS = "hero,features,closer";
|
|
27
|
-
export const DASH_AVATAR = "https://decks.hypersocial.ai/player/v1/dash-avatar.json";
|
|
28
27
|
// Same numbers as convex/lib/tokenCost.ts (the published CLI cannot import it).
|
|
29
28
|
export const VOICE_CLONE_COST = 10;
|
|
30
29
|
export const STARTER_GRANT = 30;
|
|
31
|
-
export const ANSWER_MAX = { name: 40, about: 80, ask: 80 };
|
|
32
30
|
const SKILL_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "skills", "hyperdecks");
|
|
33
|
-
// CLI-only, so read next to the package rather than through files.mjs (the hosted route's list).
|
|
34
|
-
const FIRST_DECK_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "templates", "first-deck");
|
|
35
31
|
const CHARACTERS = ["dash", "d.a.s.h."];
|
|
36
32
|
const RESUME = "npx hyperdecks init";
|
|
33
|
+
export { ANSWER_MAX, DASH_AVATAR, FIRST_DECK_LAYOUTS, cleanAnswer, fillFirstDeck, firstDeckCopy, firstDeckSlug };
|
|
37
34
|
export const AGENT_VOICE_HELP = "ask the user to run `npx hyperdecks voice clone <file> --name <name>` in a terminal, " +
|
|
38
35
|
"or upload with `voice upload` and call `clone_voice` after they agree to the statement; " +
|
|
39
36
|
"or run init again with `--voice dash` to let D.A.S.H. present";
|
|
@@ -66,100 +63,6 @@ export function writeState(path, all) {
|
|
|
66
63
|
|
|
67
64
|
// ---- answers and the first deck -------------------------------------------
|
|
68
65
|
|
|
69
|
-
// One answer as it goes on a slide and into the script: one line, no stage-tag
|
|
70
|
-
// brackets, ending in a full stop so it is its own caption.
|
|
71
|
-
export function cleanAnswer(key, value) {
|
|
72
|
-
const text = String(value ?? "").replace(/[[\]]/g, "").replace(/\s+/g, " ").trim();
|
|
73
|
-
if (text.length < 2) throw new Error(`--${key} needs an answer`);
|
|
74
|
-
if (text.length > ANSWER_MAX[key]) throw new Error(`--${key} is ${text.length} characters; keep it to ${ANSWER_MAX[key]} so it fits a caption`);
|
|
75
|
-
return key === "name" ? text.replace(/[.!?]+$/, "") : /[.!?…]$/.test(text) ? text : `${text}.`;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export const firstDeckSlug = (email) => `hello-${slugify(String(email ?? "").split("@")[0]) || "there"}`.slice(0, 58).replace(/-+$/, "");
|
|
79
|
-
|
|
80
|
-
const escapeHtml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
81
|
-
|
|
82
|
-
export function firstDeckCopy(presenter) {
|
|
83
|
-
return JSON.parse(readFileSync(join(FIRST_DECK_DIR, `${presenter === "dash" ? "character" : "self"}.json`), "utf8"));
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function slots(text, answers, html) {
|
|
87
|
-
const map = { __NAME__: answers.name, __WHAT__: answers.about, __ASK__: answers.ask };
|
|
88
|
-
return text.replace(/__NAME__|__WHAT__|__ASK__/g, (k) => (html ? escapeHtml(map[k]) : map[k]));
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// Replace exactly one match, or fail loudly: a layout change must not ship a deck
|
|
92
|
-
// that still says "One sentence on what this deck is about."
|
|
93
|
-
function swap(html, pattern, to, what) {
|
|
94
|
-
let hit = 0;
|
|
95
|
-
const out = html.replace(pattern, (...m) => (hit++, typeof to === "function" ? to(...m) : to));
|
|
96
|
-
if (hit !== 1) throw new Error(`first deck: the ${what} in the layout template changed (${hit} matches); update init's first-deck fill`);
|
|
97
|
-
return out;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function section(html, id, edit) {
|
|
101
|
-
return swap(html, new RegExp(`<section\\b[^>]*\\sid="${id}"[\\s\\S]*?</section>`), (s) => edit(s), `#${id} slide`);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// index.html and script.md of the scaffolded hero,features,closer deck, filled
|
|
105
|
-
// with the answers: no template copy is left, and no generated imagery.
|
|
106
|
-
export function fillFirstDeck({ html, title, answers, presenter, voice }) {
|
|
107
|
-
const copy = firstDeckCopy(presenter);
|
|
108
|
-
const h = (t) => slots(t, answers, true);
|
|
109
|
-
const note = (s) => swap(s, /\sdata-note="[^"]*"/, ' data-note="From your init answers."', "data-note");
|
|
110
|
-
let out = section(html, "hero", (s) => {
|
|
111
|
-
s = note(s);
|
|
112
|
-
s = swap(s, /<div class="kicker">[^<]*<\/div>/, `<div class="kicker">${h(copy.hero.kicker)}</div>`, "hero kicker");
|
|
113
|
-
s = swap(s, /<h1 class="tt"[^>]*>[\s\S]*?<\/h1>/, `<h1 class="tt" data-title-mobile="${h(copy.hero.title)}">${h(copy.hero.title)}</h1>`, "hero title");
|
|
114
|
-
return swap(s, /<p class="lead">[\s\S]*?<\/p>/, `<p class="lead">${h(copy.hero.lead)}</p>`, "hero lead");
|
|
115
|
-
});
|
|
116
|
-
out = section(out, "features", (s) => {
|
|
117
|
-
s = note(s);
|
|
118
|
-
s = swap(s, /<div class="kicker">[^<]*<\/div>/, `<div class="kicker">${h(copy.features.kicker)}</div>`, "features kicker");
|
|
119
|
-
s = swap(s, /<h2 class="tt"[^>]*>[\s\S]*?<\/h2>/, `<h2 class="tt" data-title-mobile="${h(copy.features.title)}">${h(copy.features.title)}</h2>`, "features title");
|
|
120
|
-
copy.features.cards.forEach((card, i) => {
|
|
121
|
-
s = swap(s, new RegExp(`(<li class="card" id="features-f${i + 1}">[\\s\\S]*?</svg>)<b>[^<]*</b><p>[^<]*</p>`), (_, icon) => `${icon}<b>${h(card.name)}</b><p>${h(card.line)}</p>`, `features card ${i + 1}`);
|
|
122
|
-
});
|
|
123
|
-
return s;
|
|
124
|
-
});
|
|
125
|
-
out = section(out, "closer", (s) => {
|
|
126
|
-
s = note(s);
|
|
127
|
-
s = swap(s, /<h2 class="tt"[^>]*>[\s\S]*?<\/h2>/, `<h2 class="tt" data-title-mobile="${h(copy.closer.title)}">${h(copy.closer.title)}</h2>`, "closer title");
|
|
128
|
-
s = swap(s, /<p class="lead">[\s\S]*?<\/p>/, `<p class="lead">${h(copy.closer.lead)}</p>`, "closer lead");
|
|
129
|
-
s = swap(s, /<a class="cta"[^>]*>[\s\S]*?<\/a>/, `<p class="cta" id="closer-cta">${h(copy.closer.cta)}</p>`, "closer call to action");
|
|
130
|
-
return swap(s, /<div class="contact muted">[^<]*<\/div>/, `<div class="contact muted">${h(copy.closer.contact)}</div>`, "closer contact");
|
|
131
|
-
});
|
|
132
|
-
// A character's live head presents its own deck; a person's voice ships with
|
|
133
|
-
// captions only (data-presenter="none"), never on a character's face.
|
|
134
|
-
if (presenter === "dash") {
|
|
135
|
-
out = swap(out, /(<script type="module" src="https:\/\/decks\.hypersocial\.ai\/player\/v1\/player\.js"[^>]*?)\sdata-presenter="none"/, (_, tag) => `${tag} data-presenter="head" data-avatar="${DASH_AVATAR}"`, "player tag");
|
|
136
|
-
}
|
|
137
|
-
const s = (t) => slots(t, answers, false);
|
|
138
|
-
const script = `---
|
|
139
|
-
voice: ${voice}
|
|
140
|
-
maxTotalSeconds: 90
|
|
141
|
-
transition: drift
|
|
142
|
-
---
|
|
143
|
-
|
|
144
|
-
# ${title} — narration script
|
|
145
|
-
|
|
146
|
-
Written by \`hyperdecks init\` from your three answers. Make it yours: rewrite
|
|
147
|
-
these lines and the slides in index.html, then
|
|
148
|
-
|
|
149
|
-
hyperdecks narrate .
|
|
150
|
-
hyperdecks release . --to "<name>"
|
|
151
|
-
|
|
152
|
-
## hero
|
|
153
|
-
${s(copy.hero.script)}
|
|
154
|
-
|
|
155
|
-
## features
|
|
156
|
-
${s(copy.features.script)}
|
|
157
|
-
|
|
158
|
-
## closer
|
|
159
|
-
${s(copy.closer.script)}
|
|
160
|
-
`;
|
|
161
|
-
return { html: out, script };
|
|
162
|
-
}
|
|
163
66
|
|
|
164
67
|
// ---- git ------------------------------------------------------------------
|
|
165
68
|
|
package/src/narrate.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { basename, join, resolve } from "node:path";
|
|
10
10
|
import * as ops from "./ops.mjs";
|
|
11
|
+
import { presenterLine } from "./presenter.mjs";
|
|
11
12
|
export { sectionIds, setManifestUrl } from "./checkRules.mjs";
|
|
12
13
|
import { sectionIds, setManifestUrl } from "./checkRules.mjs";
|
|
13
14
|
|
|
@@ -46,10 +47,14 @@ export async function narrate(pos, opts = {}, { log = (line) => console.log(line
|
|
|
46
47
|
for (const s of preview.slides) log(`${s.render ? "render" : s.cached ? "cached" : "skip "} ${s.id} ${s.hash.slice(0, 12)} ${s.chars} chars${s.presenterStyle ? ` presenter-style: ${s.presenterStyle}` : ""}`);
|
|
47
48
|
const render = preview.slides.filter((s) => s.render);
|
|
48
49
|
log(`${render.length} of ${preview.slides.length} slide(s) would render in voice "${preview.voice}", ~${render.reduce((n, s) => n + s.chars, 0)} characters of TTS`);
|
|
50
|
+
const who = presenterLine(preview.presenter);
|
|
51
|
+
if (who) log(who);
|
|
49
52
|
return preview;
|
|
50
53
|
}
|
|
51
|
-
const { jobId, slides } = await ops.narrateDeck(opts, fields);
|
|
54
|
+
const { jobId, slides, presenter } = await ops.narrateDeck(opts, fields);
|
|
52
55
|
log(`narration job ${jobId}: ${slides.filter((s) => s.status === "render").length} to render, ${slides.filter((s) => s.status === "cached").length} cached`);
|
|
56
|
+
const who = presenterLine(presenter);
|
|
57
|
+
if (who) log(who);
|
|
53
58
|
const seen = new Map();
|
|
54
59
|
const job = await ops.pollNarrationJob(opts, jobId, {
|
|
55
60
|
onProgress: (j) => {
|
|
@@ -63,6 +68,8 @@ export async function narrate(pos, opts = {}, { log = (line) => console.log(line
|
|
|
63
68
|
},
|
|
64
69
|
});
|
|
65
70
|
if (job.status === "error") throw new Error(job.error || "narration failed");
|
|
71
|
+
// release reads who presents from here (older backends send no presenter).
|
|
72
|
+
if (presenter) job.presenter = presenter;
|
|
66
73
|
if (!job.manifest) {
|
|
67
74
|
log(`⚠ ${job.notice || "done, but no manifest was written"}`);
|
|
68
75
|
return job;
|