decklight 0.1.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/LICENSE +21 -0
- package/README.md +257 -0
- package/SPEC.md +372 -0
- package/cli/bundle.mjs +388 -0
- package/cli/decklight.mjs +95 -0
- package/cli/edit.mjs +142 -0
- package/cli/init.mjs +215 -0
- package/cli/rec.mjs +538 -0
- package/dist/decklight.css +1068 -0
- package/dist/decklight.js +239 -0
- package/dist/decklight.js.map +7 -0
- package/docs/architecture.svg +94 -0
- package/docs/demo.svg +124 -0
- package/package.json +54 -0
- package/themes/README.md +61 -0
- package/themes/aliens.css +84 -0
- package/themes/apple2.css +126 -0
- package/themes/aurora.css +84 -0
- package/themes/berry.css +80 -0
- package/themes/blade-runner.css +83 -0
- package/themes/c64.css +85 -0
- package/themes/carbon.css +82 -0
- package/themes/citrus.css +80 -0
- package/themes/coastal.css +80 -0
- package/themes/cosmos.css +86 -0
- package/themes/dune.css +80 -0
- package/themes/eclipse.css +82 -0
- package/themes/ember.css +80 -0
- package/themes/fjord.css +80 -0
- package/themes/friends.css +82 -0
- package/themes/gallery.html +203 -0
- package/themes/gameboy.css +118 -0
- package/themes/genesis.css +84 -0
- package/themes/glacier.css +82 -0
- package/themes/godfather.css +82 -0
- package/themes/graphite.css +80 -0
- package/themes/harvest.css +80 -0
- package/themes/ibm-modern.css +83 -0
- package/themes/ibm-oldschool.css +84 -0
- package/themes/ink.css +82 -0
- package/themes/latte.css +80 -0
- package/themes/linen.css +80 -0
- package/themes/meadow.css +80 -0
- package/themes/metropolis.css +85 -0
- package/themes/miami-vice.css +84 -0
- package/themes/midnight.css +80 -0
- package/themes/mint.css +82 -0
- package/themes/moss.css +82 -0
- package/themes/obsidian.css +82 -0
- package/themes/orchid.css +82 -0
- package/themes/packs.json +89 -0
- package/themes/paper.css +83 -0
- package/themes/peony.css +84 -0
- package/themes/porcelain.css +82 -0
- package/themes/pulp-fiction.css +84 -0
- package/themes/reveal-beige.css +87 -0
- package/themes/reveal-black.css +83 -0
- package/themes/reveal-blood.css +84 -0
- package/themes/reveal-dracula.css +83 -0
- package/themes/reveal-league.css +88 -0
- package/themes/reveal-moon.css +83 -0
- package/themes/reveal-night.css +86 -0
- package/themes/reveal-serif.css +82 -0
- package/themes/reveal-simple.css +84 -0
- package/themes/reveal-sky.css +85 -0
- package/themes/reveal-solarized.css +83 -0
- package/themes/reveal-white.css +82 -0
- package/themes/sepia.css +81 -0
- package/themes/seriph.css +82 -0
- package/themes/severance.css +82 -0
- package/themes/slate.css +80 -0
- package/themes/snes.css +82 -0
- package/themes/star-wars.css +85 -0
- package/themes/storm.css +80 -0
- package/themes/stranger-things.css +84 -0
- package/themes/synthwave.css +90 -0
- package/themes/terminator.css +84 -0
- package/themes/velvet.css +80 -0
- package/tools/gemini-tts.mjs +140 -0
- package/tools/publish-site-voices.mjs +71 -0
- package/tools/voiceover-server.mjs +0 -0
- package/tools/voiceover.mjs +155 -0
package/cli/bundle.mjs
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* decklight bundle — flatten Decklight decks into ONE self-contained HTML file
|
|
4
|
+
* (send it to anyone; opens from disk, no server, no sibling files).
|
|
5
|
+
*
|
|
6
|
+
* decklight bundle <deck.html> [-o out.html] [--themes …] single deck
|
|
7
|
+
* decklight bundle <deck.html> --all [-o out.html] [--title "…"] whole playlist, merged
|
|
8
|
+
* decklight bundle <a.html> <b.html> … [-o out.html] [--title "…"] explicit list, merged
|
|
9
|
+
*
|
|
10
|
+
* What gets inlined:
|
|
11
|
+
* - the runtime : <script src=…decklight.js> → <script>…</script>
|
|
12
|
+
* - structure css: <link …decklight.css> → <style>…</style>
|
|
13
|
+
* - themes : the theme <link> is replaced by <style data-theme="name">
|
|
14
|
+
* blocks (inactive ones carry media="not all"; the engine's
|
|
15
|
+
* inline-theme mode toggles them — picker/?theme= work).
|
|
16
|
+
* - terminals : data-cast="url" casts are embedded and switched to
|
|
17
|
+
* data-cast-inline (fetch is blocked on file://).
|
|
18
|
+
* - images : <img src> → data: URIs.
|
|
19
|
+
*
|
|
20
|
+
* MERGE mode (--all or several inputs): every module's <section>s are
|
|
21
|
+
* concatenated into one deck, in order. Each module's first section gets
|
|
22
|
+
* data-module="<title>" — the engine's module menu (M) and chrome tag then
|
|
23
|
+
* navigate in-file instead of across files, and the per-module playlist
|
|
24
|
+
* config is stripped (it has no meaning inside a single file).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
|
|
30
|
+
function fail(msg) {
|
|
31
|
+
process.stderr.write(`decklight bundle: ${msg}\n`);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Inline <script> content must never contain "</script" (terminates the tag)
|
|
36
|
+
// NOR "<!--" (flips the HTML tokenizer into script-data-escaped mode, after
|
|
37
|
+
// which closers mis-parse — marked's comment regexes contain it). "\/" is an
|
|
38
|
+
// identity escape everywhere. "<!--" is broken by rewriting the bang as a
|
|
39
|
+
// backslash-u0021 unicode escape, NOT as backslash-bang: the latter is fine
|
|
40
|
+
// in strings and flagless regexes but an INVALID escape inside u-flagged
|
|
41
|
+
// regexes — highlight.js composes its XML grammar's comment regex with /imu
|
|
42
|
+
// the first time a deck highlights language-html, which turned the old
|
|
43
|
+
// escape into a lazy SyntaxError. The unicode escape is valid in strings,
|
|
44
|
+
// templates, JSON, and regexes with or without the u flag.
|
|
45
|
+
const scriptSafe = (s) => s.replace(/<\/script/gi, '<\\/script').replace(/<!--/g, '<\\u0021--');
|
|
46
|
+
const jsonSafe = (s) => s.replace(/<\/script/gi, '<\\/script').replace(/<!--/g, '<\\u0021--');
|
|
47
|
+
|
|
48
|
+
const escAttr = (s) => s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
49
|
+
|
|
50
|
+
// Unescape a single-quoted JS string body ('·', '’', \' …).
|
|
51
|
+
function unescapeJs(raw) {
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse('"' + raw.replace(/\\'/g, "'").replace(/"/g, '\\"') + '"');
|
|
54
|
+
} catch {
|
|
55
|
+
return raw;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Locate the .decklight container and its content bounds (div-depth aware —
|
|
60
|
+
* sections contain nested divs, so a lazy regex would close too early). */
|
|
61
|
+
function containerBounds(html) {
|
|
62
|
+
const openM = html.match(/<div\b[^>]*class=["'][^"']*\bdecklight\b[^"']*["'][^>]*>/i);
|
|
63
|
+
if (!openM) return null;
|
|
64
|
+
const contentStart = openM.index + openM[0].length;
|
|
65
|
+
const re = /<div\b[^>]*>|<\/div>/gi;
|
|
66
|
+
re.lastIndex = contentStart;
|
|
67
|
+
let depth = 1, m;
|
|
68
|
+
while ((m = re.exec(html))) {
|
|
69
|
+
depth += m[0][1] === '/' ? -1 : 1;
|
|
70
|
+
if (depth === 0) {
|
|
71
|
+
return { start: openM.index, contentStart, contentEnd: m.index, end: m.index + m[0].length };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Cut `const PLAYLIST = {…};` (brace-matched) and the `playlist: X` init
|
|
78
|
+
* property out of a merged deck — cross-file navigation has no meaning
|
|
79
|
+
* inside one file; in-file data-module markers replace it. */
|
|
80
|
+
function stripPlaylist(html) {
|
|
81
|
+
const declM = html.match(/const\s+PLAYLIST\s*=\s*/);
|
|
82
|
+
if (declM) {
|
|
83
|
+
let i = declM.index + declM[0].length, depth = 0, end = -1;
|
|
84
|
+
for (; i < html.length; i++) {
|
|
85
|
+
const c = html[i];
|
|
86
|
+
if (c === '{') depth++;
|
|
87
|
+
else if (c === '}') { depth--; if (depth === 0) { end = i + 1; break; } }
|
|
88
|
+
}
|
|
89
|
+
if (end !== -1) {
|
|
90
|
+
if (html[end] === ';') end++;
|
|
91
|
+
html = html.slice(0, declM.index) + html.slice(end);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
html = html.replace(/,\s*playlist\s*:\s*(?:[A-Za-z_$][\w$]*|\{[^{}]*\})/, '');
|
|
95
|
+
html = html.replace(/playlist\s*:\s*(?:[A-Za-z_$][\w$]*|\{[^{}]*\})\s*,\s*/, '');
|
|
96
|
+
return html;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Parse the playlist modules ({title, href} pairs) out of a deck's source. */
|
|
100
|
+
function parsePlaylist(html) {
|
|
101
|
+
const out = [];
|
|
102
|
+
const re = /\{\s*title:\s*'((?:[^'\\]|\\.)*)'\s*,\s*href:\s*'((?:[^'\\]|\\.)*)'\s*\}/g;
|
|
103
|
+
let m;
|
|
104
|
+
while ((m = re.exec(html))) out.push({ title: unescapeJs(m[1]), href: unescapeJs(m[2]) });
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function titleOf(html, fallback) {
|
|
109
|
+
const m = html.match(/<title>([^<]*)<\/title>/i);
|
|
110
|
+
return m ? m[1].replace(/\s*\([^)]*port\)\s*$/i, '').trim() : fallback;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ------------------------------------------------------------------- merge
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Merge module decks into the first one: concatenated sections, per-module
|
|
117
|
+
* data-module markers on each first section, cast <script type=json> blocks
|
|
118
|
+
* carried along with per-module id prefixes, relative asset refs rebased
|
|
119
|
+
* onto the first deck's directory.
|
|
120
|
+
*/
|
|
121
|
+
function mergeDecks(jobs, baseDir, notices) {
|
|
122
|
+
const base = fs.readFileSync(jobs[0].path, 'utf8');
|
|
123
|
+
const baseBounds = containerBounds(base);
|
|
124
|
+
if (!baseBounds) fail(`no .decklight container in ${jobs[0].path}`);
|
|
125
|
+
|
|
126
|
+
const markSections = (inner, title) =>
|
|
127
|
+
inner.replace(/<section\b/, `<section data-module="${escAttr(title)}"`);
|
|
128
|
+
|
|
129
|
+
const castScriptRe = /<script\b[^>]*type=["']application\/json["'][^>]*id=["']([^"']+)["'][^>]*>[\s\S]*?<\/script>/gi;
|
|
130
|
+
|
|
131
|
+
let mergedInner = markSections(
|
|
132
|
+
base.slice(baseBounds.contentStart, baseBounds.contentEnd), jobs[0].title);
|
|
133
|
+
const carried = [];
|
|
134
|
+
const seenIds = new Set([...base.matchAll(castScriptRe)].map((m) => m[1]));
|
|
135
|
+
|
|
136
|
+
for (let k = 1; k < jobs.length; k++) {
|
|
137
|
+
const { path: modPath, title } = jobs[k];
|
|
138
|
+
let mod = fs.readFileSync(modPath, 'utf8');
|
|
139
|
+
const modDir = path.dirname(modPath);
|
|
140
|
+
const prefix = `m${k + 1}-`;
|
|
141
|
+
|
|
142
|
+
// Pull the module's embedded cast blocks out (they live outside the
|
|
143
|
+
// container) and prefix their ids so merged ids stay unique.
|
|
144
|
+
const blocks = [];
|
|
145
|
+
mod = mod.replace(castScriptRe, (tag, id) => {
|
|
146
|
+
blocks.push(tag.replace(`id="${id}"`, `id="${prefix}${id}"`)
|
|
147
|
+
.replace(`id='${id}'`, `id='${prefix}${id}'`));
|
|
148
|
+
return '';
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const bounds = containerBounds(mod);
|
|
152
|
+
if (!bounds) fail(`no .decklight container in ${modPath}`);
|
|
153
|
+
let inner = mod.slice(bounds.contentStart, bounds.contentEnd);
|
|
154
|
+
|
|
155
|
+
// Rewire inline-cast refs to the prefixed ids.
|
|
156
|
+
inner = inner.replace(/data-cast-inline=["']#([^"']+)["']/g,
|
|
157
|
+
(t, id) => `data-cast-inline="#${prefix}${id}"`);
|
|
158
|
+
|
|
159
|
+
// Rebase relative asset urls onto the first deck's directory.
|
|
160
|
+
if (path.resolve(modDir) !== path.resolve(baseDir)) {
|
|
161
|
+
const rebase = (rel) => path.relative(baseDir, path.resolve(modDir, rel)).split(path.sep).join('/');
|
|
162
|
+
inner = inner.replace(/\b(data-cast|src)=["'](?!#|data:|https?:|\/\/)([^"']+)["']/g,
|
|
163
|
+
(t, attr, rel) => `${attr}="${rebase(rel)}"`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
for (const b of blocks) {
|
|
167
|
+
const id = b.match(/id=["']([^"']+)["']/)[1];
|
|
168
|
+
if (seenIds.has(id)) fail(`duplicate embedded cast id after merge: ${id}`);
|
|
169
|
+
seenIds.add(id);
|
|
170
|
+
carried.push(b);
|
|
171
|
+
}
|
|
172
|
+
mergedInner += '\n\n <!-- ==================== module: ' + title.replace(/--/g, '—') + ' ==================== -->\n'
|
|
173
|
+
+ markSections(inner, title);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Carried cast blocks must land BEFORE the runtime/init scripts: classic
|
|
177
|
+
// scripts execute while the parser is mid-document, so anything inserted
|
|
178
|
+
// after them is not yet in the DOM when the player looks its id up.
|
|
179
|
+
// Right after the container's closing </div> matches the layout of decks
|
|
180
|
+
// that author their casts inline (which is why single-deck bundles worked).
|
|
181
|
+
const tail = base.slice(baseBounds.contentEnd); // begins with the container's </div>
|
|
182
|
+
const closeLen = tail.match(/^<\/div>/i)[0].length;
|
|
183
|
+
let html = base.slice(0, baseBounds.contentStart) + mergedInner
|
|
184
|
+
+ tail.slice(0, closeLen)
|
|
185
|
+
+ (carried.length ? '\n' + carried.join('\n') : '')
|
|
186
|
+
+ tail.slice(closeLen);
|
|
187
|
+
html = stripPlaylist(html);
|
|
188
|
+
notices.push(`merged ${jobs.length} modules: ${jobs.map((j) => j.title).join(' · ')}`);
|
|
189
|
+
return html;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------- arguments
|
|
193
|
+
|
|
194
|
+
export async function bundleMain(argv = process.argv.slice(2)) {
|
|
195
|
+
|
|
196
|
+
if (!argv.length || argv.includes('--help') || argv.includes('-h')) {
|
|
197
|
+
process.stdout.write(`decklight bundle — flatten deck(s) into one self-contained HTML file
|
|
198
|
+
|
|
199
|
+
Usage:
|
|
200
|
+
decklight bundle <deck.html> [-o out.html] [--themes current|all|name,name,…]
|
|
201
|
+
decklight bundle <deck.html> --all [-o out.html] [--title "…"] [--themes …]
|
|
202
|
+
decklight bundle <a.html> <b.html> … [-o out.html] [--title "…"] [--themes …]
|
|
203
|
+
|
|
204
|
+
Options:
|
|
205
|
+
-o <file> output path (default: <deck>-standalone.html, or
|
|
206
|
+
<deck>-course.html when merging)
|
|
207
|
+
--all follow the deck's playlist and merge EVERY module into
|
|
208
|
+
one single-file presentation (in-file module menu via
|
|
209
|
+
data-module markers)
|
|
210
|
+
--title <t> <title> for a merged presentation
|
|
211
|
+
--themes <sel> which themes to embed:
|
|
212
|
+
current just the deck's linked theme (default)
|
|
213
|
+
all every theme in the deck's themes/ directory
|
|
214
|
+
name,name,… an explicit list (the deck's linked theme
|
|
215
|
+
stays active when included, else the first)
|
|
216
|
+
`);
|
|
217
|
+
process.exit(0);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const inputs = [];
|
|
221
|
+
let outPath = null, themesSel = 'current', all = false, mergedTitle = null;
|
|
222
|
+
for (let i = 0; i < argv.length; i++) {
|
|
223
|
+
const a = argv[i];
|
|
224
|
+
if (a === '-o') outPath = argv[++i];
|
|
225
|
+
else if (a === '--themes') themesSel = argv[++i];
|
|
226
|
+
else if (a === '--all') all = true;
|
|
227
|
+
else if (a === '--title') mergedTitle = argv[++i];
|
|
228
|
+
else if (!a.startsWith('-')) inputs.push(a);
|
|
229
|
+
else fail(`unknown argument: ${a}`);
|
|
230
|
+
}
|
|
231
|
+
if (!inputs.length) fail('no deck given');
|
|
232
|
+
if (all && inputs.length > 1) fail('--all takes a single deck (it follows that deck’s playlist)');
|
|
233
|
+
|
|
234
|
+
const firstPath = path.resolve(inputs[0]);
|
|
235
|
+
if (!fs.existsSync(firstPath)) fail(`deck not found: ${firstPath}`);
|
|
236
|
+
const deckDir = path.dirname(firstPath);
|
|
237
|
+
const notices = [];
|
|
238
|
+
|
|
239
|
+
// Build the job list (merge mode when --all or several inputs).
|
|
240
|
+
let jobs = null;
|
|
241
|
+
if (all) {
|
|
242
|
+
const src = fs.readFileSync(firstPath, 'utf8');
|
|
243
|
+
const modules = parsePlaylist(src);
|
|
244
|
+
if (!modules.length) fail('--all: the deck has no parseable playlist ({ title, href } modules)');
|
|
245
|
+
jobs = modules.map((m) => {
|
|
246
|
+
const p = path.resolve(deckDir, m.href);
|
|
247
|
+
if (!fs.existsSync(p)) fail(`playlist module not found: ${m.href} (${p})`);
|
|
248
|
+
return { path: p, title: m.title };
|
|
249
|
+
});
|
|
250
|
+
} else if (inputs.length > 1) {
|
|
251
|
+
jobs = inputs.map((rel, i) => {
|
|
252
|
+
const p = path.resolve(rel);
|
|
253
|
+
if (!fs.existsSync(p)) fail(`deck not found: ${p}`);
|
|
254
|
+
return { path: p, title: titleOf(fs.readFileSync(p, 'utf8'), `Module ${i + 1}`) };
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
let html;
|
|
259
|
+
if (jobs) {
|
|
260
|
+
html = mergeDecks(jobs, deckDir, notices);
|
|
261
|
+
const t = mergedTitle ||
|
|
262
|
+
(jobs[0].title || '').replace(/^\d+\s*[·.:-]\s*/, '') || 'Presentation';
|
|
263
|
+
html = html.replace(/<title>[^<]*<\/title>/i, `<title>${escAttr(t)}</title>`);
|
|
264
|
+
outPath = path.resolve(outPath ||
|
|
265
|
+
path.join(deckDir, path.basename(firstPath, '.html') + '-course.html'));
|
|
266
|
+
} else {
|
|
267
|
+
html = fs.readFileSync(firstPath, 'utf8');
|
|
268
|
+
outPath = path.resolve(outPath ||
|
|
269
|
+
path.join(deckDir, path.basename(firstPath, '.html') + '-standalone.html'));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const read = (rel) => {
|
|
273
|
+
const p = path.resolve(deckDir, rel);
|
|
274
|
+
if (!fs.existsSync(p)) fail(`referenced file not found: ${rel} (${p})`);
|
|
275
|
+
return fs.readFileSync(p, 'utf8');
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// ---------------------------------------------------------- theme selection
|
|
279
|
+
|
|
280
|
+
const themeLinkRe = /<link\b[^>]*rel=["']stylesheet["'][^>]*href=["']([^"']*themes\/([\w-]+)\.css)["'][^>]*>/i;
|
|
281
|
+
const themeLinkM = html.match(themeLinkRe);
|
|
282
|
+
if (!themeLinkM) fail('no theme <link> (href matching themes/<name>.css) found in the deck');
|
|
283
|
+
const [themeLinkTag, themeHref, linkedTheme] = themeLinkM;
|
|
284
|
+
const themesDir = path.resolve(deckDir, path.dirname(themeHref));
|
|
285
|
+
|
|
286
|
+
let themeNames;
|
|
287
|
+
if (themesSel === 'current') {
|
|
288
|
+
themeNames = [linkedTheme];
|
|
289
|
+
} else if (themesSel === 'all') {
|
|
290
|
+
themeNames = fs.readdirSync(themesDir).filter((f) => f.endsWith('.css'))
|
|
291
|
+
.map((f) => f.replace(/\.css$/, '')).sort();
|
|
292
|
+
} else {
|
|
293
|
+
themeNames = themesSel.split(',').map((s) => s.trim()).filter(Boolean);
|
|
294
|
+
}
|
|
295
|
+
if (!themeNames.length) fail('no themes selected');
|
|
296
|
+
const activeTheme = themeNames.includes(linkedTheme) ? linkedTheme : themeNames[0];
|
|
297
|
+
if (activeTheme !== linkedTheme) {
|
|
298
|
+
notices.push(`linked theme "${linkedTheme}" not in --themes list; "${activeTheme}" is active`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const themeBlocks = themeNames.map((name) => {
|
|
302
|
+
const cssPath = path.join(themesDir, name + '.css');
|
|
303
|
+
if (!fs.existsSync(cssPath)) fail(`theme not found: ${name} (${cssPath})`);
|
|
304
|
+
const css = fs.readFileSync(cssPath, 'utf8');
|
|
305
|
+
const media = name === activeTheme ? '' : ' media="not all"';
|
|
306
|
+
return `<style data-theme="${name}"${media}>\n${css}\n</style>`;
|
|
307
|
+
}).join('\n');
|
|
308
|
+
html = html.replace(themeLinkTag, themeBlocks);
|
|
309
|
+
|
|
310
|
+
// ------------------------------------------------- structure stylesheet(s)
|
|
311
|
+
|
|
312
|
+
html = html.replace(
|
|
313
|
+
/<link\b[^>]*rel=["']stylesheet["'][^>]*href=["']([^"']+)["'][^>]*>/gi,
|
|
314
|
+
(tag, href) => {
|
|
315
|
+
if (/^(https?:)?\/\//.test(href)) { notices.push(`external stylesheet kept as link: ${href}`); return tag; }
|
|
316
|
+
if (/themes\/[\w-]+\.css/.test(href)) return tag; // already handled
|
|
317
|
+
return `<style>\n${read(href)}\n</style>`;
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// -------------------------------------------------------------------- images
|
|
321
|
+
|
|
322
|
+
html = html.replace(
|
|
323
|
+
/<img\b([^>]*)\bsrc=["']([^"']+)["']/gi,
|
|
324
|
+
(tag, pre, src) => {
|
|
325
|
+
if (/^(data:|https?:|\/\/)/.test(src)) return tag;
|
|
326
|
+
const p = path.resolve(deckDir, src);
|
|
327
|
+
if (!fs.existsSync(p)) { notices.push(`image not found, left as-is: ${src}`); return tag; }
|
|
328
|
+
const ext = path.extname(p).slice(1).toLowerCase();
|
|
329
|
+
const mime = { svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp' }[ext] || 'application/octet-stream';
|
|
330
|
+
const b64 = fs.readFileSync(p).toString('base64');
|
|
331
|
+
return `<img${pre}src="data:${mime};base64,${b64}"`;
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// ------------------------------------------------------------ runtime script
|
|
335
|
+
|
|
336
|
+
html = html.replace(
|
|
337
|
+
/<script\b[^>]*src=["']([^"']+)["'][^>]*>\s*<\/script>/gi,
|
|
338
|
+
(tag, src) => {
|
|
339
|
+
if (/^(https?:)?\/\//.test(src)) { notices.push(`external script kept as src: ${src}`); return tag; }
|
|
340
|
+
const js = read(src).replace(/\/\/# sourceMappingURL=.*$/m, '');
|
|
341
|
+
return `<script>\n${scriptSafe(js)}\n</script>`;
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// ------------------------------------------------------------------- casts
|
|
345
|
+
|
|
346
|
+
const embeds = [];
|
|
347
|
+
let castN = 0;
|
|
348
|
+
html = html.replace(
|
|
349
|
+
/<div\b([^>]*class=["'][^"']*\bterminal\b[^"']*["'][^>]*)>/gi,
|
|
350
|
+
(tag, attrs) => {
|
|
351
|
+
const m = attrs.match(/\bdata-cast=["']([^"']+)["']/);
|
|
352
|
+
if (!m) return tag; // data-cast-inline (or no cast) passes through
|
|
353
|
+
const id = `bundled-cast-${++castN}`;
|
|
354
|
+
const json = jsonSafe(read(m[1]));
|
|
355
|
+
embeds.push(`<script type="application/json" id="${id}">\n${json}\n</script>`);
|
|
356
|
+
return tag.replace(m[0], `data-cast-inline="#${id}"`);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// -------------------------------------------------------------- assemble
|
|
360
|
+
|
|
361
|
+
// Anchor to the LAST </body>: the inlined runtime contains the speaker-view
|
|
362
|
+
// popup template, whose "</body></html>" string would match a first-occurrence
|
|
363
|
+
// search and corrupt the JS mid-payload.
|
|
364
|
+
if (embeds.length) {
|
|
365
|
+
const at = html.toLowerCase().lastIndexOf('</body>');
|
|
366
|
+
if (at === -1) fail('deck has no </body>');
|
|
367
|
+
html = html.slice(0, at) + embeds.join('\n') + '\n' + html.slice(at);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (!jobs) {
|
|
371
|
+
const playlistM = html.match(/playlist\s*:/);
|
|
372
|
+
if (playlistM) {
|
|
373
|
+
const hrefs = [...html.matchAll(/href:\s*['"]([^'"]+\.html)['"]/g)].map((m) => m[1]);
|
|
374
|
+
notices.push('deck has a playlist — cross-file module links cannot resolve inside a single file:' +
|
|
375
|
+
(hrefs.length ? '\n ' + [...new Set(hrefs)].join('\n ') : ''));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
fs.writeFileSync(outPath, html);
|
|
380
|
+
const kb = (fs.statSync(outPath).size / 1024).toFixed(1);
|
|
381
|
+
const what = jobs ? `${jobs.length} modules` : path.basename(firstPath);
|
|
382
|
+
process.stdout.write(`bundled ${what} → ${outPath} (${kb} KB, themes: ${themeNames.join(', ')}; active: ${activeTheme})\n`);
|
|
383
|
+
for (const n of notices) process.stdout.write(`note: ${n}\n`);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
import { fileURLToPath } from 'node:url';
|
|
387
|
+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
388
|
+
if (isMain) bundleMain().catch((e) => fail(e.message));
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* decklight — the Decklight command line.
|
|
4
|
+
*
|
|
5
|
+
* decklight rec record a terminal cast from a YAML script
|
|
6
|
+
* decklight refresh re-run embedded scripts, rewrite drifted casts
|
|
7
|
+
* decklight export convert a cast to asciicast v2
|
|
8
|
+
* decklight bundle flatten a deck into one self-contained HTML file
|
|
9
|
+
* decklight tts serve the live voice bridge (on-the-fly Gemini narration)
|
|
10
|
+
*
|
|
11
|
+
* The subcommand implementations live in rec.mjs and
|
|
12
|
+
* bundle.mjs (importable modules; direct execution still works but
|
|
13
|
+
* this dispatcher is the documented entry point).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const GLOBAL_HELP = `decklight — author, record, and package Decklight presentations
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
decklight <command> [options] (decklight <command> --help for full flags)
|
|
20
|
+
|
|
21
|
+
Commands:
|
|
22
|
+
init scaffold a starter deck, plus an agent skill (.claude/skills/decklight/, AGENTS.md)
|
|
23
|
+
EXAMPLE: decklight init "My Deck"
|
|
24
|
+
rec record a truthful terminal cast by running a YAML command script in a real PTY
|
|
25
|
+
EXAMPLE: decklight rec deck.term.yaml -o deck.cast.json
|
|
26
|
+
refresh re-execute the script embedded in each cast; rewrite the ones whose output drifted
|
|
27
|
+
EXAMPLE: decklight refresh casts/
|
|
28
|
+
export flatten a cast to asciicast v2 (markers per step) for the asciinema ecosystem
|
|
29
|
+
EXAMPLE: decklight export demo.cast.json && agg demo.cast demo.gif
|
|
30
|
+
bundle flatten a deck into ONE self-contained HTML file (runtime, themes, casts, images inlined)
|
|
31
|
+
EXAMPLE: decklight bundle demo/showcase.html --themes midnight,graphite
|
|
32
|
+
EXAMPLE: decklight bundle deck.html --all --title "My Course" (merge the whole playlist into one file)
|
|
33
|
+
tts serve the live voice bridge — the player synthesizes narration on the fly through it
|
|
34
|
+
EXAMPLE: decklight tts (then pick "Live voice…" in the deck's / palette)
|
|
35
|
+
edit serve the deck with live reload; E in the player edits speaker notes back into the file
|
|
36
|
+
EXAMPLE: decklight edit demo/showcase.html (then open the printed URL)
|
|
37
|
+
help show this help, or a command's help: decklight help bundle
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
function globalHelp(exitCode = 0) {
|
|
41
|
+
process.stdout.write(GLOBAL_HELP);
|
|
42
|
+
process.exit(exitCode);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const argv = process.argv.slice(2);
|
|
46
|
+
let cmd = argv[0];
|
|
47
|
+
let rest = argv.slice(1);
|
|
48
|
+
|
|
49
|
+
if (!cmd || cmd === '--help' || cmd === '-h') globalHelp();
|
|
50
|
+
if (cmd === 'help') {
|
|
51
|
+
if (!rest[0]) globalHelp();
|
|
52
|
+
cmd = rest[0];
|
|
53
|
+
rest = ['--help'];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
switch (cmd) {
|
|
57
|
+
case 'init': {
|
|
58
|
+
const { initMain } = await import('./init.mjs');
|
|
59
|
+
await initMain(rest);
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
case 'rec': {
|
|
63
|
+
const { recMain } = await import('./rec.mjs');
|
|
64
|
+
await recMain(rest);
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
case 'refresh': {
|
|
68
|
+
const { recMain } = await import('./rec.mjs');
|
|
69
|
+
await recMain(rest.includes('--help') ? ['--help'] : ['refresh', ...rest]);
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
case 'export': {
|
|
73
|
+
const { recMain } = await import('./rec.mjs');
|
|
74
|
+
await recMain(rest.includes('--help') ? ['--help'] : ['export', ...rest]);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
case 'bundle': {
|
|
78
|
+
const { bundleMain } = await import('./bundle.mjs');
|
|
79
|
+
await bundleMain(rest);
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
case 'tts': {
|
|
83
|
+
const { ttsMain } = await import('../tools/voiceover-server.mjs');
|
|
84
|
+
await ttsMain(rest);
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
case 'edit': {
|
|
88
|
+
const { editMain } = await import('./edit.mjs');
|
|
89
|
+
await editMain(rest);
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
default:
|
|
93
|
+
process.stderr.write(`decklight: unknown command "${cmd}"\n\n`);
|
|
94
|
+
globalHelp(1);
|
|
95
|
+
}
|
package/cli/edit.mjs
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// decklight edit — the live-editing dev server (SPEC §8 edit mode).
|
|
3
|
+
//
|
|
4
|
+
// decklight edit <deck.html> [--port 8788]
|
|
5
|
+
//
|
|
6
|
+
// Serves the current working directory over localhost (so decks that
|
|
7
|
+
// reference ../dist and ../themes just work), watches the deck file, and:
|
|
8
|
+
//
|
|
9
|
+
// GET /edit/ping → { ok, deck } (player probes availability)
|
|
10
|
+
// GET /edit/events → SSE; a `reload` event fires when the deck changes
|
|
11
|
+
// POST /edit/notes → { slide, text } rewrite that slide's notes
|
|
12
|
+
//
|
|
13
|
+
// The player's E editor posts ⟨CLICK⟩-separated plain text; the server
|
|
14
|
+
// rebuilds the <aside class="notes"> (one <p> per segment, escaped) and
|
|
15
|
+
// writes the file — the watcher then tells every connected browser to
|
|
16
|
+
// reload, and the #/slide/step hash restores the position.
|
|
17
|
+
|
|
18
|
+
import { createServer } from 'node:http';
|
|
19
|
+
import { readFileSync, writeFileSync, watch, existsSync, statSync } from 'node:fs';
|
|
20
|
+
import { resolve, extname, sep, basename } from 'node:path';
|
|
21
|
+
|
|
22
|
+
// file://-opened decks probe http://127.0.0.1:8788 directly (origin "null"),
|
|
23
|
+
// exactly like the tts bridge — so the endpoints are CORS-open. The server
|
|
24
|
+
// still binds 127.0.0.1 only.
|
|
25
|
+
const CORS = {
|
|
26
|
+
'access-control-allow-origin': '*',
|
|
27
|
+
'access-control-allow-methods': 'GET, POST, OPTIONS',
|
|
28
|
+
'access-control-allow-headers': 'content-type',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const MIME = {
|
|
32
|
+
'.html': 'text/html; charset=utf-8',
|
|
33
|
+
'.js': 'text/javascript', '.mjs': 'text/javascript',
|
|
34
|
+
'.css': 'text/css', '.json': 'application/json',
|
|
35
|
+
'.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg',
|
|
36
|
+
'.gif': 'image/gif', '.ico': 'image/x-icon', '.webp': 'image/webp',
|
|
37
|
+
'.wav': 'audio/wav', '.m4a': 'audio/mp4', '.mp3': 'audio/mpeg',
|
|
38
|
+
'.woff': 'font/woff', '.woff2': 'font/woff2', '.map': 'application/json',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const escapeHtml = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
42
|
+
|
|
43
|
+
/** ⟨CLICK⟩-separated plain text → the aside's inner HTML (one <p> per segment). */
|
|
44
|
+
export function notesTextToAside(text) {
|
|
45
|
+
const segs = text.split(/\s*⟨CLICK⟩\s*/).map((s) => s.replace(/\s+/g, ' ').trim());
|
|
46
|
+
const ps = [];
|
|
47
|
+
segs.forEach((seg, i) => {
|
|
48
|
+
if (i > 0) ps.push('<p>⟨CLICK⟩</p>');
|
|
49
|
+
if (seg) ps.push(`<p>${escapeHtml(seg)}</p>`);
|
|
50
|
+
});
|
|
51
|
+
return ps.join('\n ');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Replace (or insert) slide N's <aside class="notes"> in the deck html. */
|
|
55
|
+
export function setSlideNotes(html, slide, asideInner) {
|
|
56
|
+
// top-level sections can't nest, so splitting on the open tag is exact
|
|
57
|
+
const parts = html.split(/(<section\b)/);
|
|
58
|
+
const idx = 2 * slide; // parts[0] preamble, then [tag, content] pairs
|
|
59
|
+
if (!parts[idx]) throw new Error(`no slide ${slide} (deck has ${(parts.length - 1) / 2})`);
|
|
60
|
+
const aside = `<aside class="notes">\n ${asideInner}\n </aside>`;
|
|
61
|
+
const seg = parts[idx];
|
|
62
|
+
parts[idx] = /<aside class="notes">[\s\S]*?<\/aside>/.test(seg)
|
|
63
|
+
? seg.replace(/<aside class="notes">[\s\S]*?<\/aside>/, aside)
|
|
64
|
+
: seg.replace(/<\/section>/, ` ${aside}\n </section>`);
|
|
65
|
+
return parts.join('');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function editMain(args) {
|
|
69
|
+
if (args.includes('--help') || args.includes('-h') || !args.filter((a) => !a.startsWith('-')).length) {
|
|
70
|
+
console.log('usage: decklight edit <deck.html> [--port 8788]\n serves the cwd, live-reloads the deck on change, and accepts notes edits from the player (E)');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const opt = (flag, dflt) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : dflt; };
|
|
74
|
+
const port = Number(opt('--port', 8788));
|
|
75
|
+
const root = process.cwd();
|
|
76
|
+
const deckPath = resolve(root, args.find((a) => !a.startsWith('-')));
|
|
77
|
+
if (!existsSync(deckPath)) { console.error(`deck not found: ${deckPath}`); process.exitCode = 1; return; }
|
|
78
|
+
if (!deckPath.startsWith(root + sep)) { console.error('deck must live under the current directory'); process.exitCode = 1; return; }
|
|
79
|
+
const deckUrl = '/' + deckPath.slice(root.length + 1).split(sep).join('/');
|
|
80
|
+
|
|
81
|
+
// ── live reload: watch the deck, broadcast SSE (debounced — editors fire
|
|
82
|
+
// multiple fs events per save) ─────────────────────────────────────────
|
|
83
|
+
const clients = new Set();
|
|
84
|
+
let pending = null;
|
|
85
|
+
watch(deckPath, () => {
|
|
86
|
+
clearTimeout(pending);
|
|
87
|
+
pending = setTimeout(() => {
|
|
88
|
+
for (const res of clients) res.write('data: reload\n\n');
|
|
89
|
+
console.log(` changed → reload × ${clients.size}`);
|
|
90
|
+
}, 150);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const server = createServer(async (req, res) => {
|
|
94
|
+
try {
|
|
95
|
+
const url = new URL(req.url, 'http://x');
|
|
96
|
+
if (req.method === 'OPTIONS') { res.writeHead(204, CORS); return res.end(); }
|
|
97
|
+
if (req.method === 'GET' && url.pathname === '/edit/ping') {
|
|
98
|
+
res.writeHead(200, { ...CORS, 'content-type': 'application/json' });
|
|
99
|
+
return res.end(JSON.stringify({ ok: true, deck: deckUrl, name: basename(deckPath) }));
|
|
100
|
+
}
|
|
101
|
+
if (req.method === 'GET' && url.pathname === '/edit/events') {
|
|
102
|
+
res.writeHead(200, { ...CORS, 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
103
|
+
res.write(': connected\n\n');
|
|
104
|
+
clients.add(res);
|
|
105
|
+
req.on('close', () => clients.delete(res));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (req.method === 'POST' && url.pathname === '/edit/notes') {
|
|
109
|
+
let body = '';
|
|
110
|
+
for await (const chunk of req) { body += chunk; if (body.length > 1e6) throw new Error('too large'); }
|
|
111
|
+
const { slide, text } = JSON.parse(body);
|
|
112
|
+
if (!Number.isInteger(slide) || slide < 1 || typeof text !== 'string') throw new Error('bad payload');
|
|
113
|
+
const html = readFileSync(deckPath, 'utf8');
|
|
114
|
+
writeFileSync(deckPath, setSlideNotes(html, slide, notesTextToAside(text)));
|
|
115
|
+
console.log(` notes saved: slide ${slide} (${text.length} chars)`);
|
|
116
|
+
res.writeHead(200, { ...CORS, 'content-type': 'application/json' });
|
|
117
|
+
return res.end(JSON.stringify({ ok: true }));
|
|
118
|
+
}
|
|
119
|
+
// ── static files from the cwd ────────────────────────────────────
|
|
120
|
+
if (req.method === 'GET') {
|
|
121
|
+
const rel = url.pathname === '/' ? deckUrl : decodeURIComponent(url.pathname);
|
|
122
|
+
const file = resolve(root, '.' + rel);
|
|
123
|
+
if (!file.startsWith(root + sep) && file !== root) { res.writeHead(403); return res.end('forbidden'); }
|
|
124
|
+
if (!existsSync(file) || !statSync(file).isFile()) { res.writeHead(404); return res.end('not found'); }
|
|
125
|
+
res.writeHead(200, { 'content-type': MIME[extname(file).toLowerCase()] ?? 'application/octet-stream', 'cache-control': 'no-cache' });
|
|
126
|
+
return res.end(readFileSync(file));
|
|
127
|
+
}
|
|
128
|
+
res.writeHead(405);
|
|
129
|
+
res.end();
|
|
130
|
+
} catch (e) {
|
|
131
|
+
console.error(` edit error: ${String(e).slice(0, 120)}`);
|
|
132
|
+
if (!res.headersSent) res.writeHead(400, CORS);
|
|
133
|
+
res.end(String(e.message || e));
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
server.listen(port, '127.0.0.1', () => {
|
|
138
|
+
console.log(`decklight edit on http://127.0.0.1:${port}${deckUrl} — E edits notes, saves reload the deck. Ctrl-C stops`);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (import.meta.url === `file://${process.argv[1]}`) editMain(process.argv.slice(2));
|