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.
- package/LICENSE +21 -0
- package/README.md +225 -0
- package/bin/fb-slides.mjs +111 -0
- package/lib/build.mjs +98 -0
- package/lib/config.mjs +90 -0
- package/lib/create.mjs +59 -0
- package/lib/decks.mjs +13 -0
- package/lib/dev.mjs +94 -0
- package/lib/render.mjs +52 -0
- package/lib/server.mjs +168 -0
- package/lib/vendor.mjs +32 -0
- package/package.json +51 -0
- package/runtime/annotate.js +458 -0
- package/runtime/deck.js +299 -0
- package/runtime/index.html +37 -0
- package/runtime/outline.js +354 -0
- package/runtime/shortcuts.js +53 -0
- package/runtime/spotlight.js +293 -0
- package/runtime/theme.base.css +879 -0
- package/templates/starter/README.md +27 -0
- package/templates/starter/_gitignore +4 -0
- package/templates/starter/_package.json +14 -0
- package/templates/starter/assets/.gitkeep +0 -0
- package/templates/starter/decks/01-intro.md +44 -0
- package/templates/starter/decks/02-demos.md +34 -0
- package/templates/starter/demo/angular-hello/README.md +15 -0
- package/templates/starter/demo/angular-hello/_package.json +24 -0
- package/templates/starter/demo/angular-hello/angular.json +34 -0
- package/templates/starter/demo/angular-hello/src/index.html +12 -0
- package/templates/starter/demo/angular-hello/src/main.ts +18 -0
- package/templates/starter/demo/angular-hello/src/styles.css +22 -0
- package/templates/starter/demo/angular-hello/tsconfig.app.json +5 -0
- package/templates/starter/demo/angular-hello/tsconfig.json +17 -0
- package/templates/starter/demo/counter/index.html +34 -0
- package/templates/starter/slides.config.js +34 -0
- 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
|
+
};
|