@hypersocial/decks-cli 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -3
- package/package.json +1 -1
- package/skills/hyperdecks/SKILL.md +113 -1
- package/skills/hyperdecks/chat.md +49 -0
- package/src/check.mjs +8 -363
- package/src/checkRules.mjs +558 -0
- package/src/commands.mjs +101 -2
- package/src/files.mjs +35 -0
- package/src/guide.mjs +55 -0
- package/src/hosted.mjs +74 -0
- package/src/init.mjs +605 -0
- package/src/layouts.mjs +71 -0
- package/src/narrate.mjs +2 -12
- package/src/ops.mjs +19 -2
- package/src/runtime.mjs +62 -14
- package/src/scaffold.mjs +8 -73
- package/src/source.mjs +69 -0
- package/src/voice.mjs +11 -0
- package/templates/first-deck/character.json +26 -0
- package/templates/first-deck/self.json +26 -0
- package/templates/script.md +7 -2
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
// The narrated-deck rules, with no Node imports: the CLI's check (check.mjs,
|
|
2
|
+
// which adds the folder I/O and the Chromium measurements), the stdio MCP and
|
|
3
|
+
// the hosted check (convex/deckSources.ts) all run these, so a deck gets the same
|
|
4
|
+
// errors wherever it is checked. checkSources is the I/O-free core of checkDeck:
|
|
5
|
+
// the deck's index.html and script.md as text, with the manifest and clip fetches
|
|
6
|
+
// injected.
|
|
7
|
+
|
|
8
|
+
export const PLAYER_V1 = "https://decks.hypersocial.ai/player/v1/player.js";
|
|
9
|
+
export const DEFAULT_MAX_DURATION = 180;
|
|
10
|
+
export const MOBILE_TITLE_CHARS = 28;
|
|
11
|
+
export const CAPTION_MIN_CHARS = 4;
|
|
12
|
+
export const CAPTION_MAX_CHARS = 85;
|
|
13
|
+
// Same names as PRESENTER_STYLES in lib/deckManifest.ts (the published CLI cannot import it).
|
|
14
|
+
export const PRESENTER_STYLES = ["stage", "bubble", "glass", "corner"];
|
|
15
|
+
// Same names as TRANSITIONS in lib/deckManifest.ts and packages/player/transitions.js.
|
|
16
|
+
export const TRANSITIONS = ["drift", "cut", "push", "wipe", "zoom", "morph", "fly"];
|
|
17
|
+
export const MAX_TRANSITION_KINDS = 2;
|
|
18
|
+
|
|
19
|
+
export const MESSAGES = {
|
|
20
|
+
noPlayer: () => `no player script: add <script type="module" src="${PLAYER_V1}" data-manifest="…">`,
|
|
21
|
+
player: (src) => `player script must be ${PLAYER_V1} (got ${src})`,
|
|
22
|
+
noManifest: () => "the player script has no data-manifest: run `hyperdecks narrate <deck-dir>`, which sets it",
|
|
23
|
+
noManifestHosted: () => "the player script has no data-manifest: call narrate_deck (no script) and wait for check_narration_job; it sets it",
|
|
24
|
+
manifestUnavailable: (url, why) => `manifest ${url} could not be read (${why})`,
|
|
25
|
+
noSlides: () => 'no slides: each slide is a <section class="slide" id="…">',
|
|
26
|
+
noLoading: () => "no loading state: copy the <style data-hd-loading> block and the lines above it from the narrated template into <head>, and set --hd-ground to the first slide's colour",
|
|
27
|
+
clipField: (id, field) => `clip ${id}: ${field}`,
|
|
28
|
+
noCaptions: (id) => `clip ${id}: timeline has no captions`,
|
|
29
|
+
order: (slides, clips) => `slide ids (${slides.join(", ")}) must equal the manifest clip ids (${clips.join(", ")}), in order`,
|
|
30
|
+
total: (sum, declared) => `clip durations plus holds add up to ${sum}s but the manifest declares totalDuration ${declared}s`,
|
|
31
|
+
cap: (total, cap) => `narration runs ${total}s, over the ${cap}s cap (aim for 150 to 180 s)`,
|
|
32
|
+
url: (id, key, url, why) => `clip ${id}: ${key} ${url} ${why}`,
|
|
33
|
+
timing: (id, i, start, end, duration) => `clip ${id}: caption ${i + 1} runs ${start}s to ${end}s, outside 0 ≤ start < end ≤ ${duration}s + 0.1`,
|
|
34
|
+
words: (id) => `clip ${id}: captions do not cover the clip text, or a caption has no timed words`,
|
|
35
|
+
captionLength: (id, i, text) => `clip ${id}: caption ${i + 1} is ${text.length} characters ("${text}"); captions must be ${CAPTION_MIN_CHARS} to ${CAPTION_MAX_CHARS}`,
|
|
36
|
+
localRef: (ref) => `local reference does not resolve: ${ref}`,
|
|
37
|
+
outsideRef: (ref) => `local reference leaves the deck folder: ${ref}`,
|
|
38
|
+
hostedRef: (ref) => `local reference ${ref}: a hosted deck has no folder, so every file must be a URL; upload it with upload_deck_asset and use the URL it returns`,
|
|
39
|
+
forbidden: (phrase, where) => `forbidden phrase "${phrase}" appears in ${where}`,
|
|
40
|
+
note: (id) => `slide ${id}: needs a data-note naming the source of its facts`,
|
|
41
|
+
mobileTitle: (id, title) => `slide ${id}: title "${title}" is over ${MOBILE_TITLE_CHARS} characters and needs a data-title-mobile`,
|
|
42
|
+
slideId: (n) => `slide ${n} has no id`,
|
|
43
|
+
selector: (line, cue, selector, id) => `script.md line ${line}: [${cue}: ${selector}] matches nothing in slide ${id}`,
|
|
44
|
+
presenterStyle: (where, value) => `${where}: presenter style "${value}" is not one of ${PRESENTER_STYLES.join(", ")}`,
|
|
45
|
+
transition: (where, value) => `${where}: transition "${value}" is not one of ${TRANSITIONS.join(", ")} (zoom takes a selector: "zoom #row")`,
|
|
46
|
+
zoomTarget: (id, target, prev) => `slide ${id}: zoom target "${target}" matches nothing on the slide before it (${prev})`,
|
|
47
|
+
morphPair: (id, prev) => `slide ${id}: morph needs an element with the same data-morph="<name>" on this slide and the one before it (${prev})`,
|
|
48
|
+
transitionStale: (where, script, manifest) => `${where}: script.md says transition "${script}" but the manifest has "${manifest}": run \`hyperdecks narrate\` so the player gets it`,
|
|
49
|
+
flyTwice: (ids) => `fly is for the deck's one big moment, but slides ${ids.join(", ")} all use it`,
|
|
50
|
+
// Warnings (check --layout): the deck still passes.
|
|
51
|
+
layoutUnchecked: (why) => `layout not checked: ${why}`,
|
|
52
|
+
hotlink: (url) => `${url} is hotlinked from another site and can vanish after release; upload it with upload_deck_asset`,
|
|
53
|
+
transitionKinds: (kinds, deck) => `${kinds.length} kinds of transition besides the default (${deck}): ${kinds.join(", ")}; keep to ${MAX_TRANSITION_KINDS} so the deck reads as designed`,
|
|
54
|
+
overlap: (id, style, stage) => `slide ${id}: the ${style} presenter would overlap the title at ${stage}; move the .narration-slot or pick another style`,
|
|
55
|
+
// An error, though only check --layout can see it.
|
|
56
|
+
phoneOverflow: (id, px) => `slide ${id}: its content runs ${px}px past the slide on a ${PHONE.width}×${PHONE.height} phone; a slide must fit one screen without scrolling, so trim it or tighten its @media (max-width: 650px) rules`,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// The framed player's slide card on a phone (player.css): the screen less the
|
|
60
|
+
// 52px masthead, the 56px dock and 8px gutters.
|
|
61
|
+
export const HOSTED_LAYOUT_WHY = "presenter overlap and phone fit need a browser: run review_deck with draft=true";
|
|
62
|
+
export const PHONE = { width: 390, height: 844, card: { width: 374, height: 736 } };
|
|
63
|
+
|
|
64
|
+
const uncommented = (html) => html.replace(/<!--[\s\S]*?-->/g, "");
|
|
65
|
+
const attr = (tag, name) => {
|
|
66
|
+
const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`).exec(tag);
|
|
67
|
+
return m ? (m[1] ?? m[2]) : null;
|
|
68
|
+
};
|
|
69
|
+
const round = (n) => Math.round(n * 100) / 100;
|
|
70
|
+
const decode = (s) => s.replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'|'/g, "'");
|
|
71
|
+
const textOf = (html) => decode(html.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
|
|
72
|
+
|
|
73
|
+
// Comments blanked but newlines kept, so line numbers still match the file.
|
|
74
|
+
const blankComments = (text) => text.replace(/<!--[\s\S]*?-->/g, (c) => c.replace(/[^\n]/g, " "));
|
|
75
|
+
const lineAt = (text, index) => text.slice(0, index).split("\n").length;
|
|
76
|
+
|
|
77
|
+
// Unknown presenter styles, with their line: data-presenter-style in index.html,
|
|
78
|
+
// presenterStyle in script.md's front matter and presenter-style under a heading.
|
|
79
|
+
export function presenterStyleErrors(html, script = "") {
|
|
80
|
+
const errs = [];
|
|
81
|
+
const page = blankComments(html);
|
|
82
|
+
for (const m of page.matchAll(/\sdata-presenter-style\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
|
|
83
|
+
const value = m[1] ?? m[2];
|
|
84
|
+
if (!PRESENTER_STYLES.includes(value)) errs.push(MESSAGES.presenterStyle(`index.html line ${lineAt(page, m.index)}`, value));
|
|
85
|
+
}
|
|
86
|
+
const lines = script.replace(/\r\n?/g, "\n").split("\n");
|
|
87
|
+
const frontEnd = lines[0]?.trim() === "---" ? lines.indexOf("---", 1) : -1;
|
|
88
|
+
lines.forEach((line, i) => {
|
|
89
|
+
const m = (i > 0 && i < frontEnd ? /^\s*presenterStyle\s*:\s*(.*?)\s*$/ : /^\s*presenter-style\s*:\s*(.*?)\s*$/).exec(line);
|
|
90
|
+
if (m && !PRESENTER_STYLES.includes(m[1])) errs.push(MESSAGES.presenterStyle(`script.md line ${i + 1}`, m[1]));
|
|
91
|
+
});
|
|
92
|
+
return errs;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// "zoom #dash-row" → { name: "zoom", target: "#dash-row" }; null when it is not a transition.
|
|
96
|
+
export function parseTransition(value) {
|
|
97
|
+
if (typeof value !== "string") return null;
|
|
98
|
+
const [name, ...rest] = value.trim().split(/\s+/);
|
|
99
|
+
const target = rest.join(" ");
|
|
100
|
+
if (!TRANSITIONS.includes(name)) return null;
|
|
101
|
+
if (name === "zoom") return target ? { name, target } : null;
|
|
102
|
+
return target ? null : { name };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// script.md's transitions, with their line: `transition:` in the front matter
|
|
106
|
+
// (the deck's) and in the options right under a "## <id>" heading (a slide's).
|
|
107
|
+
function scriptTransitions(script) {
|
|
108
|
+
const lines = script.replace(/\r\n?/g, "\n").split("\n");
|
|
109
|
+
const frontEnd = lines[0]?.trim() === "---" ? lines.indexOf("---", 1) : -1;
|
|
110
|
+
const out = { deck: null, slides: new Map() };
|
|
111
|
+
let id = null, inOptions = false;
|
|
112
|
+
lines.forEach((line, i) => {
|
|
113
|
+
if (i > 0 && i < frontEnd) {
|
|
114
|
+
const m = /^\s*transition\s*:\s*(.*?)\s*$/.exec(line);
|
|
115
|
+
if (m) out.deck = { value: m[1], line: i + 1 };
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const heading = /^##\s+(\S+)/.exec(line);
|
|
119
|
+
if (heading) { id = heading[1]; inOptions = true; return; }
|
|
120
|
+
const option = inOptions && /^\s*([A-Za-z][A-Za-z0-9-]*)\s*:\s*(.*?)\s*$/.exec(line);
|
|
121
|
+
if (!option) { inOptions = false; return; }
|
|
122
|
+
if (option[1] === "transition") out.slides.set(id, { value: option[2], line: i + 1 });
|
|
123
|
+
});
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// How each slide enters, as the player resolves it: the slide's
|
|
128
|
+
// data-transition, its own (script.md, else its manifest clip), the player
|
|
129
|
+
// tag's, the deck's (script.md, else the manifest), then drift.
|
|
130
|
+
export function slideTransitions(html, script = "", manifest = null) {
|
|
131
|
+
const page = uncommented(html);
|
|
132
|
+
const tag = [...page.matchAll(/<script\b[^>]*>/g)].map((m) => m[0]).find((t) => /\/player\/v\d+\/player\.js"/.test(t)) ?? "";
|
|
133
|
+
const fromScript = scriptTransitions(script);
|
|
134
|
+
const clips = new Map((Array.isArray(manifest?.clips) ? manifest.clips : []).map((c) => [c?.id, c]));
|
|
135
|
+
const first = (...values) => values.map(parseTransition).find(Boolean) ?? { name: "drift" };
|
|
136
|
+
const deck = [attr(tag, "data-transition"), fromScript.deck?.value, manifest?.transition];
|
|
137
|
+
return {
|
|
138
|
+
deck: first(...deck),
|
|
139
|
+
slides: slides(html).map((s) => ({
|
|
140
|
+
id: s.id,
|
|
141
|
+
body: s.body,
|
|
142
|
+
transition: first(attr(s.tag, "data-transition"), fromScript.slides.get(s.id)?.value, clips.get(s.id)?.transition, ...deck),
|
|
143
|
+
})),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const morphNames = (body) => new Set([...uncommented(body).matchAll(/\sdata-morph\s*=\s*(?:"([^"]*)"|'([^']*)')/g)].map((m) => m[1] ?? m[2]));
|
|
148
|
+
|
|
149
|
+
// Unknown transitions (with their line), and the rules the moves need: a zoom's
|
|
150
|
+
// target on the slide before it, a morph's pair on both slides, one fly a deck.
|
|
151
|
+
// The first slide is never entered by a transition, so its own is not judged.
|
|
152
|
+
export function transitionErrors(html, script = "", manifest = null) {
|
|
153
|
+
const errs = [];
|
|
154
|
+
const page = blankComments(html);
|
|
155
|
+
for (const m of page.matchAll(/\sdata-transition\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
|
|
156
|
+
const value = m[1] ?? m[2];
|
|
157
|
+
if (!parseTransition(value)) errs.push(MESSAGES.transition(`index.html line ${lineAt(page, m.index)}`, value));
|
|
158
|
+
}
|
|
159
|
+
const fromScript = scriptTransitions(script);
|
|
160
|
+
for (const t of [fromScript.deck, ...fromScript.slides.values()].filter(Boolean)) {
|
|
161
|
+
if (!parseTransition(t.value)) errs.push(MESSAGES.transition(`script.md line ${t.line}`, t.value));
|
|
162
|
+
}
|
|
163
|
+
const all = slideTransitions(html, script, manifest).slides;
|
|
164
|
+
all.forEach(({ id, body, transition }, i) => {
|
|
165
|
+
if (i === 0) return;
|
|
166
|
+
const prev = all[i - 1];
|
|
167
|
+
if (transition.name === "zoom" && selectorMatches(prev.body, transition.target) === false) errs.push(MESSAGES.zoomTarget(id, transition.target, prev.id));
|
|
168
|
+
if (transition.name === "morph") {
|
|
169
|
+
const before = morphNames(prev.body);
|
|
170
|
+
if (![...morphNames(body)].some((n) => before.has(n))) errs.push(MESSAGES.morphPair(id, prev.id));
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
const flies = all.slice(1).filter((s) => s.transition.name === "fly").map((s) => s.id);
|
|
174
|
+
if (flies.length > 1) errs.push(MESSAGES.flyTwice(flies));
|
|
175
|
+
return errs;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// The player reads script.md's transitions from the manifest only, which
|
|
179
|
+
// narrate rebuilds: a script value the manifest does not carry yet is stale.
|
|
180
|
+
export function transitionStaleErrors(script, manifest) {
|
|
181
|
+
if (!script.trim() || !manifest) return [];
|
|
182
|
+
const fromScript = scriptTransitions(script);
|
|
183
|
+
const canonical = (v) => (parseTransition(v) ? v.trim().split(/\s+/).join(" ") : null);
|
|
184
|
+
const errs = [];
|
|
185
|
+
const same = (where, mine, theirs) => {
|
|
186
|
+
const a = mine === undefined ? "drift" : canonical(mine), b = theirs === undefined ? "drift" : canonical(theirs);
|
|
187
|
+
if (a !== null && b !== null && a !== b) errs.push(MESSAGES.transitionStale(where, a, b));
|
|
188
|
+
};
|
|
189
|
+
same("script.md front matter", fromScript.deck?.value, manifest.transition);
|
|
190
|
+
for (const clip of Array.isArray(manifest.clips) ? manifest.clips : []) same(`slide ${clip?.id}`, fromScript.slides.get(clip?.id)?.value, clip?.transition);
|
|
191
|
+
return errs;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// More than MAX_TRANSITION_KINDS kinds besides the deck's default reads as a
|
|
195
|
+
// sampler, not a design: a warning, since only a person can judge it.
|
|
196
|
+
export function transitionWarnings(html, script = "", manifest = null) {
|
|
197
|
+
const { deck, slides: all } = slideTransitions(html, script, manifest);
|
|
198
|
+
const kinds = [...new Set(all.slice(1).map((s) => s.transition.name).filter((n) => n !== deck.name))];
|
|
199
|
+
return kinds.length > MAX_TRANSITION_KINDS ? [MESSAGES.transitionKinds(kinds, deck.name)] : [];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Does a simple selector (tag, #id, .class, or one compound of them) match
|
|
203
|
+
// anything in this slide's markup? null when the selector is too complex to
|
|
204
|
+
// tell without a browser.
|
|
205
|
+
export function selectorMatches(body, selector) {
|
|
206
|
+
const m = /^([a-zA-Z][a-zA-Z0-9-]*)?(?:#([\w-]+))?((?:\.[\w-]+)*)$/.exec(selector.trim());
|
|
207
|
+
if (!m || !(m[1] || m[2] || m[3])) return null;
|
|
208
|
+
const [, tag, id, cls] = m;
|
|
209
|
+
const classes = cls.split(".").filter(Boolean);
|
|
210
|
+
return [...uncommented(body).matchAll(/<([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*>/g)].some(([open, name]) => {
|
|
211
|
+
if (tag && name.toLowerCase() !== tag.toLowerCase()) return false;
|
|
212
|
+
if (id && attr(open, "id") !== id) return false;
|
|
213
|
+
const have = (attr(open, "class") ?? "").split(/\s+/);
|
|
214
|
+
return classes.every((c) => have.includes(c));
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// [go: <selector>] and [look: <selector>] in script.md must match something in
|
|
219
|
+
// their slide ([go: slot] returns home and always resolves).
|
|
220
|
+
export function cueSelectorErrors(html, script = "") {
|
|
221
|
+
const bodies = new Map(slides(html).map((s) => [s.id, s.body]));
|
|
222
|
+
const errs = [];
|
|
223
|
+
let id = null;
|
|
224
|
+
script.replace(/\r\n?/g, "\n").split("\n").forEach((line, i) => {
|
|
225
|
+
const heading = /^##\s+(\S+)/.exec(line);
|
|
226
|
+
if (heading) { id = heading[1]; return; }
|
|
227
|
+
if (!id || !bodies.has(id)) return;
|
|
228
|
+
for (const [, cue, raw] of line.matchAll(/\[\s*(go|look)\s*:\s*([^\]]*?)\s*\]/gi)) {
|
|
229
|
+
if (raw === "slot" || !raw) continue;
|
|
230
|
+
if (selectorMatches(bodies.get(id), raw) === false) errs.push(MESSAGES.selector(i + 1, cue.toLowerCase(), raw, id));
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
return errs;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Same normalization as the source check: accents folded, case and punctuation dropped.
|
|
237
|
+
export const normalizeWords = (text) => text.normalize("NFKD").replace(/\p{Diacritic}/gu, "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
238
|
+
|
|
239
|
+
// `<section class="slide …">` blocks in document order: id, the opening tag, the
|
|
240
|
+
// title (first <h1>/<h2>) and its opening tag.
|
|
241
|
+
export function slides(html) {
|
|
242
|
+
return [...uncommented(html).matchAll(/(<section\b[^>]*\sclass="(?:[^"]*\s)?slide(?:\s[^"]*)?"[^>]*>)([\s\S]*?)<\/section>/g)].map(([, tag, body]) => {
|
|
243
|
+
const heading = /(<h[12]\b[^>]*>)([\s\S]*?)<\/h[12]>/.exec(body);
|
|
244
|
+
return { id: attr(tag, "id") ?? "", tag, body, title: heading ? textOf(heading[2]) : "", titleTag: heading?.[1] ?? "" };
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function playerScript(html) {
|
|
249
|
+
const tag = [...uncommented(html).matchAll(/<script\b[^>]*>/g)].map((m) => m[0]).find((t) => /\/player\/v\d+\/player\.js"/.test(t));
|
|
250
|
+
return tag ? { src: attr(tag, "src"), manifest: attr(tag, "data-manifest") || null } : null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Relative references: src, href, poster and data-presenter-image (either quote), srcset candidates and
|
|
254
|
+
// CSS url(…) in style attributes and <style> blocks; everything that is not a URL
|
|
255
|
+
// scheme, protocol-relative or a fragment. Query strings and fragments dropped.
|
|
256
|
+
export function localRefs(html) {
|
|
257
|
+
const doc = uncommented(html);
|
|
258
|
+
const raw = [
|
|
259
|
+
...[...doc.matchAll(/\s(?:src|href|poster|data-presenter-image)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].map((m) => m[1] ?? m[2]),
|
|
260
|
+
...[...doc.matchAll(/\ssrcset\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].flatMap((m) => (m[1] ?? m[2]).split(",").map((c) => c.trim().split(/\s+/)[0])),
|
|
261
|
+
...[...doc.matchAll(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)'"\s]+))\s*\)/gi)].map((m) => m[1] ?? m[2] ?? m[3]),
|
|
262
|
+
];
|
|
263
|
+
const refs = raw.map((r) => decode(String(r ?? "")).trim().split("#")[0].split("?")[0]).filter(Boolean);
|
|
264
|
+
// Folder links (href="/", "./") are navigation, not files.
|
|
265
|
+
return [...new Set(refs.filter((r) => !/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(r) && !r.endsWith("/")))];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function phraseList(value) {
|
|
269
|
+
if (!value) return [];
|
|
270
|
+
if (typeof value === "string") return value.split(",").map((s) => s.trim()).filter(Boolean);
|
|
271
|
+
return value.map((s) => String(s).trim()).filter(Boolean);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Everything checkable from the deck's own files. `exists(ref)` answers whether a
|
|
275
|
+
// local reference resolves inside the deck folder: true, false or "outside".
|
|
276
|
+
export function htmlErrors(html, { exists = () => true, forbid = [], script = "" } = {}) {
|
|
277
|
+
const errs = [...presenterStyleErrors(html, script), ...transitionErrors(html, script), ...cueSelectorErrors(html, script)];
|
|
278
|
+
const player = playerScript(html);
|
|
279
|
+
if (!player) errs.push(MESSAGES.noPlayer());
|
|
280
|
+
else if (player.src !== PLAYER_V1) errs.push(MESSAGES.player(player.src));
|
|
281
|
+
// The loading state must be in <head> to hold the page from its first paint,
|
|
282
|
+
// with its fail-open so a deck whose player never loads still shows.
|
|
283
|
+
const loading = /<style\b[^>]*\sdata-hd-loading(?=[\s>=])[^>]*>([\s\S]*?)<\/style>/i.exec(/<head\b[\s\S]*?<\/head>/i.exec(uncommented(html))?.[0] ?? "");
|
|
284
|
+
if (player && !loading?.[1].includes("hd-fail-open")) errs.push(MESSAGES.noLoading());
|
|
285
|
+
const all = slides(html);
|
|
286
|
+
if (!all.length) errs.push(MESSAGES.noSlides());
|
|
287
|
+
all.forEach((s, i) => {
|
|
288
|
+
const id = s.id || `${i + 1}`;
|
|
289
|
+
if (!s.id) errs.push(MESSAGES.slideId(i + 1));
|
|
290
|
+
if (!attr(s.tag, "data-note")?.trim()) errs.push(MESSAGES.note(id));
|
|
291
|
+
if (s.title.length > MOBILE_TITLE_CHARS && !attr(s.titleTag, "data-title-mobile") && !attr(s.tag, "data-title-mobile")) {
|
|
292
|
+
errs.push(MESSAGES.mobileTitle(id, s.title));
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
for (const ref of localRefs(html)) {
|
|
296
|
+
const found = exists(ref);
|
|
297
|
+
if (found === "hosted") errs.push(MESSAGES.hostedRef(ref));
|
|
298
|
+
else if (found === "outside") errs.push(MESSAGES.outsideRef(ref));
|
|
299
|
+
else if (!found) errs.push(MESSAGES.localRef(ref));
|
|
300
|
+
}
|
|
301
|
+
const page = textOf(uncommented(html).replace(/<(script|style)\b[\s\S]*?<\/\1>/g, " ")).toLowerCase();
|
|
302
|
+
for (const phrase of phraseList(forbid)) {
|
|
303
|
+
const p = phrase.toLowerCase();
|
|
304
|
+
if (page.includes(p) || uncommented(html).toLowerCase().includes(p)) errs.push(MESSAGES.forbidden(phrase, "index.html"));
|
|
305
|
+
if (script.toLowerCase().includes(p)) errs.push(MESSAGES.forbidden(phrase, "script.md"));
|
|
306
|
+
}
|
|
307
|
+
return errs;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// The manifest against the deck: order, total and cap. Clip text is checked for
|
|
311
|
+
// forbidden phrases too, since that is what the audience hears.
|
|
312
|
+
const isNum = (v) => typeof v === "number" && Number.isFinite(v);
|
|
313
|
+
const isStr = (v) => typeof v === "string" && v.trim().length > 0;
|
|
314
|
+
|
|
315
|
+
// Fields every clip needs before its other rules mean anything.
|
|
316
|
+
export function clipFieldErrors(clip, i) {
|
|
317
|
+
const id = isStr(clip?.id) ? clip.id : `#${i + 1}`;
|
|
318
|
+
const errs = [];
|
|
319
|
+
if (!isStr(clip?.id)) errs.push(MESSAGES.clipField(id, "needs an id"));
|
|
320
|
+
if (!(isNum(clip?.duration) && clip.duration > 0)) errs.push(MESSAGES.clipField(id, "duration must be a positive number of seconds"));
|
|
321
|
+
if (!(isNum(clip?.hold) && clip.hold >= 0)) errs.push(MESSAGES.clipField(id, "hold must be a number of seconds, 0 or more"));
|
|
322
|
+
if (!isStr(clip?.text)) errs.push(MESSAGES.clipField(id, "needs its text"));
|
|
323
|
+
for (const k of ["audio", "timeline"]) if (!isStr(clip?.[k])) errs.push(MESSAGES.clipField(id, `needs an ${k} URL`));
|
|
324
|
+
return errs;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function manifestErrors(manifest, slideIds, { maxDuration = DEFAULT_MAX_DURATION, forbid = [] } = {}) {
|
|
328
|
+
const clips = Array.isArray(manifest?.clips) ? manifest.clips : [];
|
|
329
|
+
const clipIds = clips.map((c) => c?.id ?? "");
|
|
330
|
+
const errs = clips.flatMap(clipFieldErrors);
|
|
331
|
+
if (slideIds.length !== clipIds.length || slideIds.some((id, i) => id !== clipIds[i])) errs.push(MESSAGES.order(slideIds, clipIds));
|
|
332
|
+
const sum = round(clips.reduce((n, c) => n + (Number(c?.duration) || 0) + (Number(c?.hold) || 0), 0));
|
|
333
|
+
const declared = Number(manifest?.totalDuration);
|
|
334
|
+
if (!(Math.abs(sum - declared) < 0.01)) errs.push(MESSAGES.total(sum, manifest?.totalDuration));
|
|
335
|
+
if (Number.isFinite(declared) && declared > maxDuration) errs.push(MESSAGES.cap(round(declared), maxDuration));
|
|
336
|
+
const badStyle = (v) => v !== undefined && !PRESENTER_STYLES.includes(v);
|
|
337
|
+
if (badStyle(manifest?.presenterStyle)) errs.push(MESSAGES.presenterStyle("manifest", manifest.presenterStyle));
|
|
338
|
+
for (const c of clips) if (badStyle(c?.presenterStyle)) errs.push(MESSAGES.presenterStyle(`clip ${c.id}`, c.presenterStyle));
|
|
339
|
+
const badTransition = (v) => v !== undefined && !parseTransition(v);
|
|
340
|
+
if (badTransition(manifest?.transition)) errs.push(MESSAGES.transition("manifest", manifest.transition));
|
|
341
|
+
for (const c of clips) if (badTransition(c?.transition)) errs.push(MESSAGES.transition(`clip ${c.id}`, c.transition));
|
|
342
|
+
for (const phrase of phraseList(forbid)) {
|
|
343
|
+
for (const c of clips) if (String(c?.text ?? "").toLowerCase().includes(phrase.toLowerCase())) errs.push(MESSAGES.forbidden(phrase, `clip ${c.id}`));
|
|
344
|
+
}
|
|
345
|
+
return errs;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// One clip's timeline: caption timing, coverage of the clip text, and length.
|
|
349
|
+
export function timelineErrors(clip, timeline) {
|
|
350
|
+
const errs = [];
|
|
351
|
+
if (!Array.isArray(timeline?.captions) || !timeline.captions.length) return [MESSAGES.noCaptions(clip.id)];
|
|
352
|
+
const captions = timeline.captions.map((c) => (c && typeof c === "object" ? c : {}));
|
|
353
|
+
const duration = Number(clip.duration) || 0;
|
|
354
|
+
captions.forEach((c, i) => {
|
|
355
|
+
if (!(c.start >= 0 && c.end > c.start && c.end <= duration + 0.1)) errs.push(MESSAGES.timing(clip.id, i, c.start, c.end, duration));
|
|
356
|
+
});
|
|
357
|
+
// Caption text carries the script's spelling; the timed words carry the
|
|
358
|
+
// transcript's (which may spell a name differently, as the narrate guard's
|
|
359
|
+
// pronunciation map allows), so coverage is checked on the text.
|
|
360
|
+
const covered = captions.map((c) => String(c.text ?? "")).join(" ");
|
|
361
|
+
const timed = captions.every((c) => Array.isArray(c.words) && c.words.length);
|
|
362
|
+
if (!timed || normalizeWords(covered) !== normalizeWords(String(clip.text ?? ""))) errs.push(MESSAGES.words(clip.id));
|
|
363
|
+
captions.forEach((c, i) => {
|
|
364
|
+
const text = String(c.text ?? "");
|
|
365
|
+
if (text.length < CAPTION_MIN_CHARS || text.length > CAPTION_MAX_CHARS) errs.push(MESSAGES.captionLength(clip.id, i, text));
|
|
366
|
+
});
|
|
367
|
+
return errs;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Each slide's presenter style, as the player resolves it at desktop size: the
|
|
371
|
+
// slide's data-presenter-style, its clip's, the player tag's, the manifest's,
|
|
372
|
+
// then stage for the live head and bubble otherwise.
|
|
373
|
+
export function slideStyles(html, manifest = null) {
|
|
374
|
+
const page = uncommented(html);
|
|
375
|
+
const tag = [...page.matchAll(/<script\b[^>]*>/g)].map((m) => m[0]).find((t) => /\/player\/v\d+\/player\.js"/.test(t)) ?? "";
|
|
376
|
+
const known = (v) => (PRESENTER_STYLES.includes(v) ? v : null);
|
|
377
|
+
const deck = known(attr(tag, "data-presenter-style")) ?? known(manifest?.presenterStyle) ?? (attr(tag, "data-presenter") === "head" ? "stage" : "bubble");
|
|
378
|
+
const clips = new Map((Array.isArray(manifest?.clips) ? manifest.clips : []).map((c) => [c?.id, c]));
|
|
379
|
+
return slides(html).map((s) => ({ id: s.id, style: known(attr(s.tag, "data-presenter-style")) ?? known(clips.get(s.id)?.presenterStyle) ?? deck }));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// What each style occupies at desktop size, in stage pixels (player.css): the
|
|
383
|
+
// head plus two caption lines. Corner sits in the stage's corner, not the slot.
|
|
384
|
+
export const PRESENTER_FOOTPRINT = {
|
|
385
|
+
stage: { width: 480, height: 490 },
|
|
386
|
+
glass: { width: 522, height: 534 },
|
|
387
|
+
bubble: { width: 446, height: 304 },
|
|
388
|
+
corner: { width: 400, height: 330, right: 28, bottom: 28 },
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
// Measured boxes → warnings. Each entry: { id, style, presenter, title } with
|
|
392
|
+
// { x, y, width, height } boxes (title null when the slide has none).
|
|
393
|
+
export function overlapWarnings(measured, stage = "1600×900") {
|
|
394
|
+
const hit = (a, b) => a && b && Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) > 4 && Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) > 4;
|
|
395
|
+
return measured.filter((m) => hit(m.presenter, m.title)).map((m) => MESSAGES.overlap(m.id, m.style, stage));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Slides whose content overflows the phone card, as errors. Each entry: { id, overflow } in px.
|
|
399
|
+
export function phoneOverflowErrors(measured) {
|
|
400
|
+
return measured.filter((m) => m.overflow > 1).map((m) => MESSAGES.phoneOverflow(m.id, Math.ceil(m.overflow)));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// The deck's own cap: `maxTotalSeconds` in script.md's front matter.
|
|
404
|
+
export function scriptCap(script) {
|
|
405
|
+
const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(script)?.[1] ?? "";
|
|
406
|
+
const m = /^maxTotalSeconds:\s*([\d.]+)\s*$/m.exec(front);
|
|
407
|
+
return m ? Number(m[1]) : null;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
async function fetchCheck(fetchImpl, url, want) {
|
|
412
|
+
try {
|
|
413
|
+
const r = await fetchImpl(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
414
|
+
if (!r.ok) {
|
|
415
|
+
await r.body?.cancel?.();
|
|
416
|
+
return { why: `returned HTTP ${r.status}` };
|
|
417
|
+
}
|
|
418
|
+
const type = (r.headers.get("content-type") || "").split(";")[0].trim();
|
|
419
|
+
if (!want.test(type)) {
|
|
420
|
+
await r.body?.cancel?.();
|
|
421
|
+
return { why: `is ${type || "untyped"}, expected ${want === AUDIO ? "audio/*" : "JSON"}` };
|
|
422
|
+
}
|
|
423
|
+
return { response: r };
|
|
424
|
+
} catch (e) {
|
|
425
|
+
return { why: `could not be fetched (${e.message})` };
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
const AUDIO = /^audio\//;
|
|
429
|
+
const FETCH_TIMEOUT_MS = 30_000;
|
|
430
|
+
const JSON_TYPE = /json/;
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
// Same rule as sectionIds() in lib/deckScript.ts (the published CLI cannot import it).
|
|
435
|
+
export const sectionIds = (html) => [...html.matchAll(/<section\b[^>]*?\sid\s*=\s*["']([^"']+)["']/gi)].map((m) => m[1]);
|
|
436
|
+
|
|
437
|
+
// Set data-manifest on the hosted player's <script> tag; html is unchanged when
|
|
438
|
+
// there is no player tag or it already names this URL.
|
|
439
|
+
export function setManifestUrl(html, url) {
|
|
440
|
+
return html.replace(/<script\b[^>]*\/player\/v\d+\/player\.js"[^>]*>/, (tag) =>
|
|
441
|
+
/\sdata-manifest\s*=\s*"[^"]*"/.test(tag)
|
|
442
|
+
? tag.replace(/(\sdata-manifest\s*=\s*)"[^"]*"/, (_, lead) => `${lead}"${url}"`)
|
|
443
|
+
: tag.replace(/\s*>$/, () => ` data-manifest="${url}">`));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// The stage size the player tag declares (data-stage="1600x900"), else 1600×900.
|
|
447
|
+
export function stageSize(html) {
|
|
448
|
+
const tag = [...uncommented(html).matchAll(/<script\b[^>]*>/g)].map((m) => m[0]).find((t) => /\/player\/v\d+\/player\.js"/.test(t)) ?? "";
|
|
449
|
+
const [w, h] = /^(\d{3,4})x(\d{3,4})$/.exec(attr(tag, "data-stage") ?? "")?.slice(1).map(Number) ?? [1600, 900];
|
|
450
|
+
return { width: w, height: h };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Absolute http(s) references (src, href, poster, srcset, url()), for the hosted
|
|
454
|
+
// check's hotlink warning.
|
|
455
|
+
export function absoluteRefs(html) {
|
|
456
|
+
const doc = uncommented(html);
|
|
457
|
+
const raw = [
|
|
458
|
+
...[...doc.matchAll(/\s(?:src|href|poster|data-presenter-image)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].map((m) => m[1] ?? m[2]),
|
|
459
|
+
...[...doc.matchAll(/\ssrcset\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].flatMap((m) => (m[1] ?? m[2]).split(",").map((c) => c.trim().split(/\s+/)[0])),
|
|
460
|
+
...[...doc.matchAll(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)'"\s]+))\s*\)/gi)].map((m) => m[1] ?? m[2] ?? m[3]),
|
|
461
|
+
];
|
|
462
|
+
return [...new Set(raw.map((r) => decode(String(r ?? "")).trim()).filter((r) => /^https?:\/\//i.test(r)))];
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Hosts a released deck can rely on: our storage and app, the player, and fonts.
|
|
466
|
+
const HOSTED_ASSET_HOSTS = [/\.convex\.cloud$/, /\.convex\.site$/, /(^|\.)hypersocial\.ai$/, /^fonts\.(googleapis|gstatic)\.com$/];
|
|
467
|
+
const isHostedAsset = (url) => {
|
|
468
|
+
try {
|
|
469
|
+
const host = new URL(url).hostname;
|
|
470
|
+
return HOSTED_ASSET_HOSTS.some((re) => re.test(host));
|
|
471
|
+
} catch {
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
// Every rule on the deck's text. `exists(ref)` answers whether a local reference
|
|
477
|
+
// resolves (true, false or "outside"); `hosted: true` means there is no folder,
|
|
478
|
+
// so every local reference is an error naming upload_deck_asset, a hotlinked
|
|
479
|
+
// image is a warning, and the layout rules (which need Chromium) are reported as
|
|
480
|
+
// not checked. Returns { slides, totalDuration, manifestUrl, manifest, passed, errors, warnings }.
|
|
481
|
+
export async function checkSources({ html, script = "" }, opts = {}, { fetch: fetchImpl = globalThis.fetch } = {}) {
|
|
482
|
+
const forbid = phraseList(opts.forbid);
|
|
483
|
+
const flagCap = opts["max-duration"] ?? opts.maxDuration;
|
|
484
|
+
const maxDuration = Number(flagCap ?? scriptCap(script) ?? DEFAULT_MAX_DURATION);
|
|
485
|
+
if (!Number.isFinite(maxDuration) || maxDuration <= 0) {
|
|
486
|
+
throw new Error(flagCap !== undefined ? "--max-duration needs a number of seconds" : "maxTotalSeconds in script.md must be a number of seconds");
|
|
487
|
+
}
|
|
488
|
+
const exists = opts.hosted ? () => "hosted" : (opts.exists ?? (() => true));
|
|
489
|
+
const errors = htmlErrors(html, { exists, forbid, script });
|
|
490
|
+
const ids = slides(html).map((s) => s.id);
|
|
491
|
+
const player = playerScript(html);
|
|
492
|
+
let manifest = null;
|
|
493
|
+
if (player && !player.manifest) errors.push(opts.hosted ? MESSAGES.noManifestHosted() : MESSAGES.noManifest());
|
|
494
|
+
if (player?.manifest) {
|
|
495
|
+
const got = await fetchCheck(fetchImpl, player.manifest, JSON_TYPE);
|
|
496
|
+
if (got.why) errors.push(MESSAGES.manifestUnavailable(player.manifest, got.why));
|
|
497
|
+
else {
|
|
498
|
+
try {
|
|
499
|
+
manifest = await got.response.json();
|
|
500
|
+
} catch (e) {
|
|
501
|
+
errors.push(MESSAGES.manifestUnavailable(player.manifest, `not JSON: ${e.message}`));
|
|
502
|
+
}
|
|
503
|
+
if (manifest !== null && !(manifest && typeof manifest === "object" && Array.isArray(manifest.clips) && manifest.clips.length)) {
|
|
504
|
+
errors.push(MESSAGES.manifestUnavailable(player.manifest, "not a manifest: needs a non-empty clips array"));
|
|
505
|
+
manifest = null;
|
|
506
|
+
} else if (manifest === null && !errors.some((e) => e.startsWith(`manifest ${player.manifest}`))) {
|
|
507
|
+
errors.push(MESSAGES.manifestUnavailable(player.manifest, "not a manifest: needs a non-empty clips array"));
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
if (manifest) {
|
|
512
|
+
errors.push(...manifestErrors(manifest, ids, { maxDuration, forbid }));
|
|
513
|
+
// The moves' rules again, now counting the manifest's own transitions (a deck without script.md, or a stale one).
|
|
514
|
+
for (const e of transitionErrors(html, script, manifest)) if (!errors.includes(e)) errors.push(e);
|
|
515
|
+
errors.push(...transitionStaleErrors(script, manifest));
|
|
516
|
+
for (const [i, clip] of manifest.clips.entries()) {
|
|
517
|
+
if (clipFieldErrors(clip, i).length) continue; // already reported
|
|
518
|
+
let audioUrl, timelineUrl;
|
|
519
|
+
try {
|
|
520
|
+
audioUrl = new URL(clip.audio, player.manifest).href;
|
|
521
|
+
timelineUrl = new URL(clip.timeline, player.manifest).href;
|
|
522
|
+
} catch {
|
|
523
|
+
errors.push(MESSAGES.clipField(clip.id, "audio and timeline must be URLs"));
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
const audio = await fetchCheck(fetchImpl, audioUrl, AUDIO);
|
|
527
|
+
if (audio.why) errors.push(MESSAGES.url(clip.id, "audio", audioUrl, audio.why));
|
|
528
|
+
else await audio.response.body?.cancel?.().catch(() => {});
|
|
529
|
+
const tl = await fetchCheck(fetchImpl, timelineUrl, JSON_TYPE);
|
|
530
|
+
if (tl.why) {
|
|
531
|
+
errors.push(MESSAGES.url(clip.id, "timeline", timelineUrl, tl.why));
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
let timeline;
|
|
535
|
+
try {
|
|
536
|
+
timeline = await tl.response.json();
|
|
537
|
+
} catch (e) {
|
|
538
|
+
errors.push(MESSAGES.url(clip.id, "timeline", timelineUrl, `is not JSON (${e.message})`));
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
errors.push(...timelineErrors(clip, timeline));
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
const warnings = transitionWarnings(html, script, manifest);
|
|
545
|
+
if (opts.hosted) {
|
|
546
|
+
for (const url of absoluteRefs(html)) if (!isHostedAsset(url) && /\.(png|jpe?g|gif|webp|avif|svg|mp4|webm|mov)(\?|#|$)/i.test(url)) warnings.push(MESSAGES.hotlink(url));
|
|
547
|
+
warnings.push(MESSAGES.layoutUnchecked(HOSTED_LAYOUT_WHY));
|
|
548
|
+
}
|
|
549
|
+
return {
|
|
550
|
+
slides: ids.length,
|
|
551
|
+
totalDuration: manifest ? Number(manifest.totalDuration) : null,
|
|
552
|
+
manifestUrl: player?.manifest ?? null,
|
|
553
|
+
manifest,
|
|
554
|
+
passed: errors.length === 0,
|
|
555
|
+
errors,
|
|
556
|
+
warnings: [...new Set(warnings)],
|
|
557
|
+
};
|
|
558
|
+
}
|