fb-slides 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +225 -0
  3. package/bin/fb-slides.mjs +111 -0
  4. package/lib/build.mjs +98 -0
  5. package/lib/config.mjs +90 -0
  6. package/lib/create.mjs +59 -0
  7. package/lib/decks.mjs +13 -0
  8. package/lib/dev.mjs +94 -0
  9. package/lib/render.mjs +52 -0
  10. package/lib/server.mjs +168 -0
  11. package/lib/vendor.mjs +32 -0
  12. package/package.json +51 -0
  13. package/runtime/annotate.js +458 -0
  14. package/runtime/deck.js +299 -0
  15. package/runtime/index.html +37 -0
  16. package/runtime/outline.js +354 -0
  17. package/runtime/shortcuts.js +53 -0
  18. package/runtime/spotlight.js +293 -0
  19. package/runtime/theme.base.css +879 -0
  20. package/templates/starter/README.md +27 -0
  21. package/templates/starter/_gitignore +4 -0
  22. package/templates/starter/_package.json +14 -0
  23. package/templates/starter/assets/.gitkeep +0 -0
  24. package/templates/starter/decks/01-intro.md +44 -0
  25. package/templates/starter/decks/02-demos.md +34 -0
  26. package/templates/starter/demo/angular-hello/README.md +15 -0
  27. package/templates/starter/demo/angular-hello/_package.json +24 -0
  28. package/templates/starter/demo/angular-hello/angular.json +34 -0
  29. package/templates/starter/demo/angular-hello/src/index.html +12 -0
  30. package/templates/starter/demo/angular-hello/src/main.ts +18 -0
  31. package/templates/starter/demo/angular-hello/src/styles.css +22 -0
  32. package/templates/starter/demo/angular-hello/tsconfig.app.json +5 -0
  33. package/templates/starter/demo/angular-hello/tsconfig.json +17 -0
  34. package/templates/starter/demo/counter/index.html +34 -0
  35. package/templates/starter/slides.config.js +34 -0
  36. package/templates/starter/theme.css +14 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fabio Biondi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,225 @@
1
+ # fb-slides
2
+
3
+ Slides that are **just Markdown**. A [reveal.js](https://revealjs.com) deck that reads
4
+ your `.md` files directly, embeds real pages as live demos, and ships as one
5
+ self-contained folder.
6
+
7
+ ```bash
8
+ npx fb-slides create my-talk
9
+ cd my-talk && npm install && npm run dev
10
+ ```
11
+
12
+ The engine is the package; a talk is only its own content. Bump the version and every
13
+ deck you have written gets the new features.
14
+
15
+ ## The shape of a talk
16
+
17
+ ```
18
+ my-talk/
19
+ ├─ decks/ 01-intro.md, 02-… ← the talk, in file-name order
20
+ ├─ demo/
21
+ │ ├─ counter/ a static page a slide embeds and runs
22
+ │ └─ angular-hello/ an app with its own dev server, started by `servers:`
23
+ ├─ assets/ images the Markdown links to
24
+ ├─ theme.css colour overrides (optional)
25
+ └─ slides.config.js (optional — every key has a default)
26
+ ```
27
+
28
+ That is what `create` gives you: two nearly empty decks showing the reveal features,
29
+ and one demo of each kind. Delete what the talk does not need — the Angular folder,
30
+ its `servers:` entry and its slide come out together.
31
+
32
+ | | |
33
+ | --- | --- |
34
+ | `npm run dev` | serves the deck on :4000 and opens it |
35
+ | `npm run build` | `dist/` — self-contained, relative URLs, works offline |
36
+ | `npm run preview` | build, then serve the result |
37
+
38
+ ## Writing
39
+
40
+ A slide is whatever sits between two `---` lines. Edit the `.md`, reload the page —
41
+ there is no build step and no export. Adding a section to the talk is dropping a file
42
+ into `decks/`; there is no list to keep in step anywhere.
43
+
44
+ Three things happen automatically, so you never mark them up by hand:
45
+
46
+ - a slide starting with an `#` h1 becomes a **section divider** (centred, gradient)
47
+ - a slide starting with `<!-- demo: … -->` becomes a **live demo** (below)
48
+ - bullet lists of 3+ items become **fragments**, revealed one at a time
49
+
50
+ ### Section names
51
+
52
+ The badge in the corner is the file's own `section:`, from the front matter:
53
+
54
+ ```markdown
55
+ ---
56
+ marp: true
57
+ title: My talk — imperative tools
58
+ section: Imperative
59
+ ---
60
+ ```
61
+
62
+ Marp ignores the key, so the file still exports on its own. Without a `section:` the
63
+ badge falls back to the file name.
64
+
65
+ ### Live demos
66
+
67
+ A slide that opens with a `demo:` comment is replaced by that page, **running in an
68
+ iframe**, full-bleed. You can click around in it without leaving the presentation.
69
+
70
+ ```markdown
71
+ ---
72
+
73
+ <!-- demo: cart -->
74
+
75
+ ## Live demo
76
+
77
+ [the fallback for GitHub and Marp goes here]
78
+ ```
79
+
80
+ The comment must be the first thing in the slide. The value is a folder name inside
81
+ `demo/`, a `./` or `../` path taken as-is, or a full `http(s)` URL for anything that is
82
+ not in the project:
83
+
84
+ ```markdown
85
+ <!-- demo: http://localhost:4200/checkout -->
86
+ ```
87
+
88
+ The header then shows the host rather than the whole URL. Everything after the marker is
89
+ what renderers that don't understand it will show — the deck replaces the whole slide.
90
+
91
+ A demo iframe is loaded when you arrive on its slide and unloaded when you leave, so the
92
+ page is **fresh every time**: the cart is empty again, the counter back to zero. The deck
93
+ also nudges the iframe's width once it loads — an embedded app that measures its
94
+ container at mount would otherwise come up blank, because reveal scales slides with a CSS
95
+ transform and that fires no resize inside the frame.
96
+
97
+ **Moving a demo is moving those lines.** Put the block wherever you want it, in any file;
98
+ delete it and the demo slide is gone.
99
+
100
+ ### Stepped code highlighting
101
+
102
+ The line ranges live in the fence's info string — reveal reveals one group per click:
103
+
104
+ ````markdown
105
+ ```js [2|3-4|5-9|10]
106
+ ````
107
+
108
+ Harmless anywhere else: every other Markdown renderer just sees a `js` block.
109
+
110
+ ### Diagrams
111
+
112
+ A ```` ```mermaid ```` fence becomes a real diagram, rendered locally.
113
+
114
+ ### Speaker notes
115
+
116
+ A `Note:` block at the end of a slide never shows on screen, only in the speaker view (`S`):
117
+
118
+ ```markdown
119
+ ## The description *is* the prompt
120
+
121
+ - `name` → how the agent **calls** it
122
+
123
+ Note: a bad description is the most common reason a tool never gets used.
124
+ ```
125
+
126
+ ## Presenting
127
+
128
+ | Key | |
129
+ | --- | --- |
130
+ | `→` / `Space` | next step (fragments included) |
131
+ | `P` | the pen — draw on the slide, the ink fades on its own |
132
+ | `Shift` (tap) | the spotlight — drag a rectangle, the rest of the slide dims and blurs |
133
+ | `S` | speaker view — notes, timer, next slide |
134
+ | `Esc` / `O` | overview — the slides as a grid |
135
+ | `V` | the vertical navigator — every slide by title, down the left edge |
136
+ | `F` | fullscreen |
137
+ | `B` / `.` | black out the screen |
138
+
139
+ - **`?nofrag`** — every bullet at once, for a fast read-through
140
+ - **`?print-pdf`** — open it, then print to PDF (the iframes won't render)
141
+
142
+ ## slides.config.js
143
+
144
+ Every key is optional.
145
+
146
+ ```js
147
+ export default {
148
+ title: 'My talk', // browser tab, and the built page
149
+ lang: 'en',
150
+
151
+ decks: 'decks', // folder of .md
152
+ demos: 'demo', // folder behind a bare `<!-- demo: name -->`
153
+ static: ['assets', 'demo'], // served and published; auto-detected when omitted
154
+ theme: 'theme.css', // loaded after the base theme; auto when it exists
155
+ favicon: 'assets/favicon.png',
156
+
157
+ signature: { // the corner logo. Omit the key, omit the logo
158
+ name: 'fabiobiondi.dev',
159
+ url: 'https://www.fabiobiondi.dev',
160
+ logo: 'assets/logo.png',
161
+ },
162
+
163
+ servers: [ // started by `dev`, alongside the deck
164
+ { name: 'angular demo', cwd: 'demo/app', command: 'npm', args: ['start', '--', '--port', '4200'] },
165
+ ],
166
+
167
+ port: 4000,
168
+ open: true,
169
+ outDir: 'dist',
170
+ exclude: ['dist'], // extra path segments the build skips
171
+ fragmentLists: true, // bullet lists reveal one item at a time
172
+ reveal: { transition: 'fade' },// passed to Reveal.initialize()
173
+ mermaid: {}, // passed to mermaid.initialize()
174
+ };
175
+ ```
176
+
177
+ A `servers:` entry that fails — a missing `node_modules`, a busy port — prints a warning
178
+ and nothing more: the slide that embeds it comes up empty, the rest of the deck works.
179
+
180
+ ## Theming
181
+
182
+ `theme.css` in the project is loaded **after** the package's base theme, so it overrides
183
+ rather than replaces. Almost everything hangs off the tokens in `:root`:
184
+
185
+ ```css
186
+ :root {
187
+ --bg: #0e1116;
188
+ --card: #1a1f27;
189
+ --text: #e8ebf0;
190
+ --muted: #97a1b0;
191
+ --accent: #6ea8fe;
192
+ --accent-2: #7ee2b8;
193
+ }
194
+ ```
195
+
196
+ ## Publishing
197
+
198
+ ```bash
199
+ npm run build # → dist/
200
+ ```
201
+
202
+ `dist/` carries reveal.js and mermaid with it and uses only relative URLs, so it can be
203
+ published at a domain root or in any subfolder, and it works with no network at all. It
204
+ also holds `decks.json`, the file list a static host cannot produce on its own —
205
+ generated, never hand-written.
206
+
207
+ What the build cannot fix: a slide embedding `http://localhost:…` shows an empty frame
208
+ once published. Those URLs exist only on the machine running them.
209
+
210
+ ## Also usable with Marp
211
+
212
+ Each `.md` keeps its own front matter, so the files still work standalone:
213
+
214
+ ```bash
215
+ npx @marp-team/marp-cli decks/01-intro.md --pdf
216
+ ```
217
+
218
+ Marp does not render the mermaid fences or the demo slides — the deck does.
219
+
220
+ ## Overriding the engine
221
+
222
+ The dev server serves the project **before** the package, so a file of the same name next
223
+ to your decks shadows the one that ships here: dropping in your own `deck.js` or
224
+ `theme.base.css` replaces it for that talk. It is the escape hatch, not the workflow —
225
+ what you shadow stops receiving updates.
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+ // ---------------------------------------------------------------------------
3
+ // fb-slides — Markdown-driven reveal.js decks.
4
+ //
5
+ // fb-slides create <dir> a new talk
6
+ // fb-slides dev serve it, open it
7
+ // fb-slides build dist/, self-contained
8
+ // fb-slides preview build, then serve dist/
9
+ // ---------------------------------------------------------------------------
10
+
11
+ import { createRequire } from 'node:module';
12
+ import { dirname, join, resolve } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ const require = createRequire(import.meta.url);
16
+ const self = require('../package.json');
17
+ const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
18
+ const RUNTIME = join(packageRoot, 'runtime');
19
+ const TEMPLATES = join(packageRoot, 'templates');
20
+
21
+ const argv = process.argv.slice(2);
22
+ const command = argv.find((arg) => !arg.startsWith('-')) ?? 'dev';
23
+ const positional = argv.filter((arg) => !arg.startsWith('-')).slice(1);
24
+
25
+ const flag = (name) => argv.includes(`--${name}`);
26
+ const option = (name, fallback) => {
27
+ const inline = argv.find((arg) => arg.startsWith(`--${name}=`));
28
+ if (inline) return inline.slice(name.length + 3);
29
+ const index = argv.indexOf(`--${name}`);
30
+ return index >= 0 && argv[index + 1] && !argv[index + 1].startsWith('-') ? argv[index + 1] : fallback;
31
+ };
32
+
33
+ const HELP = `
34
+ ${self.name} v${self.version}
35
+
36
+ fb-slides create <dir> scaffold a new deck
37
+ fb-slides dev serve the deck and open it [--port n] [--no-open] [--no-servers]
38
+ fb-slides build build dist/ [--out dir] [--watch]
39
+ fb-slides preview build, then serve the result [--port n]
40
+
41
+ Run from a folder holding decks/*.md. slides.config.js is optional.
42
+ `;
43
+
44
+ const overrides = () => ({
45
+ port: option('port'),
46
+ outDir: option('out'),
47
+ open: flag('no-open') ? false : undefined,
48
+ servers: flag('no-servers') ? false : undefined,
49
+ });
50
+
51
+ const run = async () => {
52
+ if (flag('help') || flag('h') || command === 'help') return console.log(HELP);
53
+ if (flag('version') || flag('v') || command === 'version') return console.log(self.version);
54
+
55
+ if (command === 'create' || command === 'init') {
56
+ const { create } = await import('../lib/create.mjs');
57
+ const { dir, name } = await create(positional[0], TEMPLATES);
58
+ console.log(`
59
+ ${name} is ready.
60
+
61
+ cd ${positional[0] ?? '.'}
62
+ npm install
63
+ npm run dev
64
+
65
+ The talk is decks/*.md — edit one and reload. Everything else comes from ${self.name}.
66
+ `);
67
+ return;
68
+ }
69
+
70
+ const { loadConfig } = await import('../lib/config.mjs');
71
+ const root = process.cwd();
72
+ const config = await loadConfig(root, overrides());
73
+
74
+ if (command === 'dev') {
75
+ const { dev } = await import('../lib/dev.mjs');
76
+ return void (await dev(config, RUNTIME));
77
+ }
78
+
79
+ if (command === 'build') {
80
+ const { build, buildWatch } = await import('../lib/build.mjs');
81
+ return void (await (flag('watch') ? buildWatch(config, RUNTIME) : build(config, RUNTIME)));
82
+ }
83
+
84
+ if (command === 'preview') {
85
+ const { build } = await import('../lib/build.mjs');
86
+ const { createDeckServer, listen } = await import('../lib/server.mjs');
87
+ const { readFile } = await import('node:fs/promises');
88
+ await build(config, RUNTIME);
89
+
90
+ // dist/ is served as it is — no runtime mounts, no rendering. What you see
91
+ // here is exactly what the host will serve.
92
+ const out = config.outPath;
93
+ const port = Number(option('port', config.port + 1));
94
+ const server = createDeckServer({
95
+ mounts: [{ prefix: '/', dir: out }],
96
+ decksPath: resolve(out, config.decksDir),
97
+ index: () => readFile(join(out, 'index.html'), 'utf8'),
98
+ });
99
+ await listen(server, port);
100
+ console.log(`\n preview of ${config.outDir}/\n → http://localhost:${port}/\n`);
101
+ return;
102
+ }
103
+
104
+ console.error(`unknown command: ${command}${HELP}`);
105
+ process.exitCode = 1;
106
+ };
107
+
108
+ run().catch((error) => {
109
+ console.error(`\n ✗ ${error.message}\n`);
110
+ process.exitCode = 1;
111
+ });
package/lib/build.mjs ADDED
@@ -0,0 +1,98 @@
1
+ // ---------------------------------------------------------------------------
2
+ // `fb-slides build` — dist/ as one self-contained folder, every URL in it
3
+ // relative, so it can be published at a domain root or in any subfolder.
4
+ //
5
+ // It carries reveal and mermaid too: a published deck depends on nothing but the
6
+ // host that serves it.
7
+ // ---------------------------------------------------------------------------
8
+
9
+ import { watch } from 'node:fs';
10
+ import { cp, mkdir, rm, writeFile } from 'node:fs/promises';
11
+ import { existsSync } from 'node:fs';
12
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
13
+
14
+ import { assertUsable } from './config.mjs';
15
+ import { listDecks } from './decks.mjs';
16
+ import { renderIndex } from './render.mjs';
17
+ import { VENDOR_FILES, VENDOR_MOUNTS, packageDir } from './vendor.mjs';
18
+
19
+ const copyFile = async (from, to) => {
20
+ await mkdir(dirname(to), { recursive: true });
21
+ await cp(from, to);
22
+ };
23
+
24
+ export const build = async (config, runtimeDir, { quiet = false } = {}) => {
25
+ assertUsable(config);
26
+ const started = Date.now();
27
+ const out = config.outPath;
28
+
29
+ if (out === config.root) throw new Error('outDir cannot be the project root');
30
+ await rm(out, { recursive: true, force: true });
31
+ await mkdir(out, { recursive: true });
32
+
33
+ // Matched on the path *inside* the project: a project that happens to live
34
+ // under a folder called `dist` would otherwise exclude every file in it.
35
+ const excluded = (src) =>
36
+ relative(config.root, src).split(sep).some((part) => config.exclude.includes(part));
37
+
38
+ // 1. the runtime — everything this package ships except the index template,
39
+ // which is rendered below rather than copied.
40
+ await cp(runtimeDir, out, {
41
+ recursive: true,
42
+ filter: (src) => basename(src) !== 'index.html' && !excluded(src),
43
+ });
44
+
45
+ // 2. reveal + mermaid, the UMD entry points only.
46
+ for (const [pkg, file] of VENDOR_FILES) {
47
+ const from = join(packageDir(pkg), file);
48
+ if (!existsSync(from)) continue; // a minor version moved a file: not fatal
49
+ await copyFile(from, join(out, 'vendor', VENDOR_MOUNTS[pkg], file));
50
+ }
51
+
52
+ // 3. the talk: the Markdown, then the folders it points at.
53
+ await cp(config.decksPath, join(out, config.decksDir), { recursive: true, filter: (src) => !excluded(src) });
54
+
55
+ for (const dir of config.static) {
56
+ const from = resolve(config.root, dir);
57
+ // A static dir that contains dist/ would copy the build into itself.
58
+ if (from === out || out.startsWith(from + sep)) continue;
59
+ await cp(from, join(out, dir), { recursive: true, filter: (src) => !excluded(src) });
60
+ }
61
+
62
+ // 4. loose root files the config names — a theme override, a favicon.
63
+ for (const file of [config.theme, config.favicon].filter(Boolean)) {
64
+ const from = resolve(config.root, file);
65
+ const to = join(out, file);
66
+ if (existsSync(from) && !existsSync(to)) await copyFile(from, to);
67
+ }
68
+
69
+ // 5. the page, and the deck list a static host cannot produce on its own.
70
+ await writeFile(join(out, 'index.html'), await renderIndex(config, runtimeDir));
71
+ const decks = await listDecks(config.decksPath);
72
+ if (!decks.length) throw new Error(`no .md in ${config.decksDir}/`);
73
+ await writeFile(join(out, 'decks.json'), `${JSON.stringify(decks, null, 2)}\n`);
74
+
75
+ if (!quiet) {
76
+ console.log(` ${config.outDir}/ ← ${decks.length} decks · ${config.static.join(', ') || 'no static dirs'} · ${Date.now() - started}ms`);
77
+ }
78
+ return { decks, out };
79
+ };
80
+
81
+ export const buildWatch = async (config, runtimeDir) => {
82
+ await build(config, runtimeDir);
83
+ console.log(' watching… (ctrl-c to stop)');
84
+
85
+ const roots = [config.decksPath, ...config.static.map((dir) => resolve(config.root, dir))];
86
+ let pending;
87
+ for (const dir of roots) {
88
+ if (!existsSync(dir)) continue;
89
+ watch(dir, { recursive: true }, (_event, filename) => {
90
+ // A framework demo's dev server writes to .angular/ and its own dist/
91
+ // constantly; without this the deck would rebuild several times a second.
92
+ if (filename && filename.split(sep).some((part) => config.exclude.includes(part))) return;
93
+ // Editors save in bursts; one rebuild per burst is plenty.
94
+ clearTimeout(pending);
95
+ pending = setTimeout(() => build(config, runtimeDir).catch((error) => console.error(` ✗ ${error.message}`)), 120);
96
+ });
97
+ }
98
+ };
package/lib/config.mjs ADDED
@@ -0,0 +1,90 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The project's slides.config — and the defaults that make it optional.
3
+ //
4
+ // Everything the deck used to hardcode lives here: the title, where the .md are,
5
+ // where the embedded demos are, the corner signature, the side processes a talk
6
+ // needs running. A project that follows the default shape needs no config at all.
7
+ // ---------------------------------------------------------------------------
8
+
9
+ import { existsSync } from 'node:fs';
10
+ import { basename, join, resolve } from 'node:path';
11
+ import { pathToFileURL } from 'node:url';
12
+
13
+ const CONFIG_FILES = ['slides.config.js', 'slides.config.mjs', 'slides.config.json'];
14
+
15
+ // Directories a project gets served and published for free when it has them, so
16
+ // the common shape (decks + assets + demos) needs nothing declared.
17
+ const AUTO_STATIC = ['assets', 'demo', 'public', 'images', 'img'];
18
+
19
+ // Never served, never published — heavy, private, or generated.
20
+ export const ALWAYS_EXCLUDED = ['node_modules', '.git', '.angular', '.next', '.cache', '.DS_Store'];
21
+
22
+ export const findConfigFile = (root) => CONFIG_FILES.map((name) => join(root, name)).find(existsSync) ?? null;
23
+
24
+ const importConfig = async (file) => {
25
+ const url = pathToFileURL(file).href;
26
+ const module = file.endsWith('.json')
27
+ ? await import(url, { with: { type: 'json' } })
28
+ : await import(url);
29
+ return module.default ?? module;
30
+ };
31
+
32
+ // A path the browser will use: always relative (so dist/ can be published in a
33
+ // subfolder) and always with a trailing slash.
34
+ const asDirUrl = (value) => `${String(value).replace(/^\.?\/+/, '').replace(/\/+$/, '')}/`;
35
+
36
+ export const loadConfig = async (root = process.cwd(), overrides = {}) => {
37
+ const file = findConfigFile(root);
38
+ const user = file ? await importConfig(file) : {};
39
+
40
+ const decksDir = user.decks ?? 'decks';
41
+ const demosDir = user.demos ?? 'demo';
42
+
43
+ // `static:` replaces the auto-detected list when given; the demos folder is
44
+ // added back regardless, because the deck's iframes point straight at it.
45
+ const declared = user.static ?? AUTO_STATIC.filter((dir) => existsSync(join(root, dir)));
46
+ const statics = [...new Set([...declared, demosDir])].filter((dir) => existsSync(join(root, dir)));
47
+
48
+ const config = {
49
+ root,
50
+ configFile: file,
51
+ title: user.title ?? basename(root),
52
+ lang: user.lang ?? 'en',
53
+ decksDir,
54
+ demosDir,
55
+ static: statics,
56
+ // The project's own CSS, loaded *after* the base theme: an override, not a
57
+ // replacement. `theme.css` at the root is picked up without being declared.
58
+ theme: user.theme ?? (existsSync(join(root, 'theme.css')) ? 'theme.css' : null),
59
+ favicon: user.favicon ?? null,
60
+ signature: user.signature ?? null,
61
+ port: Number(overrides.port ?? user.port ?? 4000),
62
+ open: overrides.open ?? user.open ?? true,
63
+ outDir: overrides.outDir ?? user.outDir ?? 'dist',
64
+ // Side processes a talk needs running: a framework demo's own dev server.
65
+ servers: (overrides.servers === false ? [] : user.servers) ?? [],
66
+ // Passed straight to Reveal.initialize(), over the deck's own defaults.
67
+ reveal: user.reveal ?? {},
68
+ // Bullet lists of 3+ items appear one item at a time.
69
+ fragmentLists: user.fragmentLists ?? true,
70
+ // The build output, at any depth: a framework demo's own dist/ is its dev
71
+ // server's business, and the deck ships that demo as sources.
72
+ exclude: [...ALWAYS_EXCLUDED, overrides.outDir ?? user.outDir ?? 'dist', ...(user.exclude ?? [])],
73
+ };
74
+
75
+ config.decksPath = resolve(root, config.decksDir);
76
+ config.outPath = resolve(root, config.outDir);
77
+ // What the browser asks for, as opposed to where it is on disk.
78
+ config.urls = { decks: asDirUrl(config.decksDir), demos: asDirUrl(config.demosDir) };
79
+
80
+ return config;
81
+ };
82
+
83
+ export const assertUsable = (config) => {
84
+ if (!existsSync(config.decksPath)) {
85
+ throw new Error(
86
+ `no decks: expected Markdown in ${config.decksDir}/\n` +
87
+ ` create it, or point \`decks:\` somewhere else in slides.config.js`,
88
+ );
89
+ }
90
+ };
package/lib/create.mjs ADDED
@@ -0,0 +1,59 @@
1
+ // ---------------------------------------------------------------------------
2
+ // `fb-slides create my-talk` — a project that is only its own content: the
3
+ // Markdown, the config, and a dependency on this package. Everything else —
4
+ // the engine, the theme, reveal, mermaid — arrives through npm and updates with it.
5
+ // ---------------------------------------------------------------------------
6
+
7
+ import { cp, mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises';
8
+ import { existsSync } from 'node:fs';
9
+ import { basename, dirname, join, resolve } from 'node:path';
10
+ import { createRequire } from 'node:module';
11
+
12
+ const require = createRequire(import.meta.url);
13
+ const self = require('../package.json');
14
+
15
+ // npm rewrites a published `.gitignore`, and a nested package.json confuses some
16
+ // tooling — so the template keeps both under a `_` and they are renamed here.
17
+ const RENAMED = { _gitignore: '.gitignore', '_package.json': 'package.json' };
18
+
19
+ const title = (name) =>
20
+ name
21
+ .replace(/[-_]+/g, ' ')
22
+ .replace(/\b\w/g, (char) => char.toUpperCase())
23
+ .trim();
24
+
25
+ export const create = async (target, templatesDir) => {
26
+ const dir = resolve(process.cwd(), target ?? '.');
27
+ const name = basename(dir);
28
+
29
+ if (existsSync(dir) && (await readdir(dir)).some((entry) => !entry.startsWith('.'))) {
30
+ throw new Error(`${target} already exists and is not empty`);
31
+ }
32
+
33
+ await mkdir(dir, { recursive: true });
34
+ await cp(join(templatesDir, 'starter'), dir, { recursive: true });
35
+
36
+ // At any depth: the Angular demo carries a package.json of its own.
37
+ for (const entry of await readdir(dir, { recursive: true })) {
38
+ const to = RENAMED[basename(entry)];
39
+ if (to) await rename(join(dir, entry), join(dir, dirname(entry), to));
40
+ }
41
+
42
+ // The two placeholders the scaffold fills in: what the project is called, and
43
+ // which version of the engine it starts on.
44
+ for (const file of ['package.json', 'slides.config.js', 'README.md', 'decks/01-intro.md']) {
45
+ const path = join(dir, file);
46
+ if (!existsSync(path)) continue;
47
+ const source = await readFile(path, 'utf8');
48
+ await writeFile(
49
+ path,
50
+ source
51
+ .replaceAll('__NAME__', name)
52
+ .replaceAll('__TITLE__', title(name))
53
+ .replaceAll('__PKG__', self.name)
54
+ .replaceAll('__VERSION__', `^${self.version}`),
55
+ );
56
+ }
57
+
58
+ return { dir, name };
59
+ };
package/lib/decks.mjs ADDED
@@ -0,0 +1,13 @@
1
+ // The deck list is the folder: every .md in decks/, in file-name order. Adding
2
+ // or removing a talk section is dropping a file in or deleting it — there is no
3
+ // list to keep in step, here or in the Markdown.
4
+
5
+ import { readdir } from 'node:fs/promises';
6
+
7
+ export const listDecks = async (dir) => {
8
+ const files = await readdir(dir);
9
+ return files
10
+ .filter((name) => name.endsWith('.md') && !name.startsWith('.'))
11
+ // `numeric` so 10- sorts after 09- rather than after 01-.
12
+ .sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
13
+ };