fb-slides 0.7.0 → 0.9.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/CHANGELOG.md CHANGED
@@ -7,6 +7,80 @@ this — which makes it worth being able to see what arrived.
7
7
  Entries are written as the work lands, under **Unreleased**; the release commit
8
8
  that stamps the version renames that heading to the version and its date.
9
9
 
10
+ ## 0.9.0 — 2026-09-08
11
+
12
+ - **The editor answers `Shift+E`, not `T`.** The drawer's shortcut moved off
13
+ the QWERT row: `Shift+E` opens it now, and the toolbar badge, the tooltip
14
+ and reveal's help overlay all say so. Plain `E` still belongs to the
15
+ spotlight.
16
+
17
+ - **The slide box colours its markdown.** In the edit drawer, the slide's
18
+ textarea now sits over a highlighted copy of itself: headings, bold, fences,
19
+ links and the `<!-- .element -->` comments that steer reveal take colour as
20
+ you type. What you edit is still a plain textarea — caret, selection, undo
21
+ and drag untouched — and the palette is the theme's own, not the monokai the
22
+ slides wear. The grammar comes from reveal's highlight plugin, already in
23
+ the bundle; a deck without that plugin keeps the plain textarea.
24
+
25
+ - **Drag a file onto the deck and it lands in `assets/`.** With the edit drawer
26
+ open, dropping files anywhere over the slide — images, video, a PDF, anything
27
+ — raises the folders `assets/` already has, and they go into the one they are
28
+ dropped on; `+ new folder…` asks for a name and makes it, and the button
29
+ beside the `+` opens the same panel for files you would rather pick than drag.
30
+ What comes back is the path to use, with a button that copies it and one that
31
+ writes the markup at the cursor: `![](…)` for an image, a `<video>` with the
32
+ attributes that make it behave for a clip, a link for the rest. Names are
33
+ folded to something a URL can carry — `Schermata città (1).PNG` becomes
34
+ `Schermata-citta-1.png` — and nothing is overwritten, a second `clip.mp4`
35
+ lands as `clip-2.mp4`. A row's third button deletes the file — the wrong
36
+ screenshot, dropped a moment ago — and that one asks: **Delete** stays dead
37
+ until `CONFIRM` is typed, because it is the only thing in the drawer a reload
38
+ cannot undo. The folder is published with the deck whatever `static:` says, so
39
+ the path survives the build; `assets:` in `slides.config.js` moves it. Dev
40
+ server only, like the drawer it belongs to.
41
+
42
+ - **The block picker grew into the whole vocabulary.** The `+` in the edit
43
+ drawer — and `/` on an empty line — now offers every block the theme knows,
44
+ under group headers: the section divider, the table with its muted first
45
+ column, the blockquote, a sequence diagram beside the flowchart, an autoplay
46
+ video — `data-autoplay muted loop`, the clip that plays itself when the slide
47
+ shows — and a second fragment — the `<!-- .element: class="fragment" -->`
48
+ comment that tags any block above it, image or list or
49
+ box, where the old one only wrapped a paragraph. The demo markers now bring
50
+ their fallback along — the heading and iframe GitHub and Marp show where the
51
+ live demo would be — and the external-page marker's row says how `| src:`
52
+ hitches the source button to a folder. Each row
53
+ also carries the words you would actually type looking for it — `img`,
54
+ `mermaid`, `appear` — so the filter answers to those too, and a pane under
55
+ the list shows the markup the highlighted row is about to write, before it
56
+ lands. A project's own `snippets:` sit under their own header — `group:`
57
+ names it, `keys:` joins the search — and a filter that matches nothing now
58
+ says so instead of showing an empty list.
59
+
60
+ ## 0.8.0 — 2026-09-07
61
+
62
+ - **`npm run kill` — everything the talk left running, gone.** `dev` starts more
63
+ than itself, and it takes its side processes down with it only when it is asked
64
+ politely: killed outright, or with its terminal window closed, it leaves a
65
+ `vite` or an `ng serve` under the npm that started it and a `code serve-web`,
66
+ all holding the ports the next run wants. `fb-slides kill` clears them. It goes
67
+ by port rather than by process tree, because the ports are the one thing
68
+ already written down: the deck's, `preview`'s one above it, the `url:` of every
69
+ `servers:` entry, and the editor's 7100–7109 — the whole walk `dev` makes when
70
+ something holds the first one. Whatever is listening there is named, asked to
71
+ stop, and made to; the npm above it exits on its own once its server is gone. A
72
+ `servers:` entry with no `url:` is the one thing it cannot reach. New talks get
73
+ the script from `create`; a talk that already exists adds
74
+ `"kill": "fb-slides kill"` to its `scripts`.
75
+
76
+ - **`dev` says when a port is already taken.** Each `servers:` port is probed
77
+ before anything is spawned, so a port that answers before its demo has started
78
+ is a leftover from a run that did not come down cleanly — and it is named under
79
+ the deck's URL, where the demo servers' own banners cannot bury it, instead of
80
+ turning up as a slide showing yesterday's build. The deck's own port, which has
81
+ always failed outright rather than moved, now names the way out in the same
82
+ breath: ``port 4000 is already in use — `npx fb-slides kill` ``.
83
+
10
84
  ## 0.7.0 — 2026-09-07
11
85
 
12
86
  - **A `source ↗` button on demo slides.** `editor: true` in `slides.config.js`
package/README.md CHANGED
@@ -34,6 +34,7 @@ its `servers:` entry and its slide come out together.
34
34
  | `npm run dev` | serves the deck on :4000 and opens it |
35
35
  | `npm run build` | `dist/` — self-contained, relative URLs, works offline |
36
36
  | `npm run preview` | build, then serve the result |
37
+ | `npm run kill` | stops the deck, the demos and the editor — whatever is left holding their ports |
37
38
 
38
39
  ## Writing
39
40
 
@@ -107,7 +108,18 @@ Vite — copied into `demo/` as it is. It is embedded by **URL**, not by folder
107
108
  **real VS Code** in a new tab — `code serve-web`, the web server built into the editor you
108
109
  already have, started alongside the deck. Nothing is installed and nothing is embedded, and
109
110
  a built deck never carries it. Run `dev` once at your desk first: VS Code downloads its
110
- server half on the very first use. [docs/source-code.md](docs/source-code.md).
111
+ server half on the very first use.
112
+
113
+ > **It needs VS Code on the machine running the deck.** `serve-web` is a subcommand of
114
+ > VS Code's own CLI, so the button exists only where that CLI does: `code`, or
115
+ > `code-insiders`, or a fork that kept it — `editor: { command: 'cursor' }`. Without it
116
+ > nothing breaks and nothing is missing from the talk; `dev` says so under the deck's URL
117
+ > and every demo slide comes up as it always did, minus the button. The same is true when
118
+ > `code` is installed but not on the `PATH` of the shell you start the deck from, which an
119
+ > IDE's built-in terminal manages on its own — name the binary outright then:
120
+ > `editor: { command: '/usr/local/bin/code' }`.
121
+
122
+ [docs/source-code.md](docs/source-code.md).
111
123
 
112
124
  ### Stepped code highlighting
113
125
 
@@ -218,7 +230,7 @@ restarts every time the slide comes up.
218
230
 
219
231
  ### Editing in the browser
220
232
 
221
- On the dev server, `T` (or the last button on the toolbar) opens the slide you
233
+ On the dev server, `Shift+E` (or the last button on the toolbar) opens the slide you
222
234
  are looking at in a drawer: its markdown in one box, its speaker notes in
223
235
  another. Typing re-renders the real slide in place — same renderer, same theme —
224
236
  and **Save** writes the text back into the slide's own lines of the `.md`,
@@ -230,23 +242,56 @@ file, after asking. Clicking into the notes box trades the room with the slide
230
242
  box, so both are comfortable to write in.
231
243
 
232
244
  The `+` over the slide box — or `/` typed on an empty line — opens the block
233
- picker: the demo marker, a framed embed, a video with the attributes that make
234
- it behave, two columns, a fenced block, the speaker page. The block lands where
235
- the cursor was, with its first placeholder selected so it is ready to type over;
236
- text already selected takes that placeholder's place, so picking **Fragment**
237
- wraps the paragraph that was highlighted. A block that only works at the top of
238
- a slide, like the demo marker, goes there whatever the cursor was doing.
245
+ picker: every block the theme knows, under group headers — the demo markers,
246
+ the section divider and the speaker page; columns, callout, caption and framed
247
+ embed; image and video with the attributes that make them behave; plain,
248
+ stepped and mermaid fences; fragments, table and quote. The filter also answers
249
+ to the words you would actually type — `img`, `mermaid`, `appear` — and the
250
+ pane under the list shows the markup the highlighted row is about to write.
251
+ The block lands where the cursor was, with its first placeholder selected so it
252
+ is ready to type over; text already selected takes that placeholder's place, so
253
+ picking **Fragment** wraps the paragraph that was highlighted. A block that
254
+ only works at the top of a slide, like the demo marker, goes there whatever the
255
+ cursor was doing.
239
256
 
240
257
  `snippets:` in `slides.config.js` adds a project's own blocks to the list:
241
258
 
242
259
  ```js
243
260
  snippets: [
244
- { label: 'Pricing table', hint: 'the three tiers', body: '<div class="tiers">${…}</div>' },
261
+ { label: 'Pricing table', hint: 'the three tiers', keys: 'plans price', body: '<div class="tiers">${…}</div>' },
245
262
  ],
246
263
  ```
247
264
 
248
- The drawer exists only under `fb-slides dev`: a built deck is static files and
249
- never shows the button.
265
+ `keys` are extra words the filter answers to, and `group` names the header a
266
+ block sits under — without one, a project's blocks gather under **Project**.
267
+
268
+ ### Dropping files into `assets/`
269
+
270
+ While the drawer is open, dragging files anywhere over the deck — a screenshot,
271
+ a clip, a PDF — raises the folders `assets/` already has. Drop on one and the
272
+ files are saved there; drop on **+ new folder…** and it asks for a name and
273
+ makes it. The button next to the `+` opens the same panel for files you would
274
+ rather pick than drag.
275
+
276
+ Each file that lands is listed with the path to write in the slide, a button
277
+ that copies it, and one that writes it for you at the cursor: `![](…)` for an
278
+ image, a `<video>` with the attributes that make it behave for a clip, an
279
+ `<audio>` for sound, a link for everything else. Names are folded to something
280
+ a URL can carry — `Schermata città (1).PNG` becomes `Schermata-citta-1.png` —
281
+ and nothing is ever overwritten: a second `clip.mp4` lands as `clip-2.mp4`.
282
+
283
+ The third button on a row deletes the file — the wrong screenshot, dropped a
284
+ moment ago. It is the one thing here a reload cannot undo, so it asks: the
285
+ **Delete** button stays dead until `CONFIRM` is typed into the box, in capitals.
286
+ `Esc` backs out of the question, then out of the panel, then out of the drawer —
287
+ one layer per press.
288
+
289
+ `assets:` in `slides.config.js` names the folder. It is published with the deck
290
+ whatever else `static:` says, so a path the drawer hands out works in the build
291
+ too.
292
+
293
+ Both the drawer and the drop exist only under `fb-slides dev`: a built deck is
294
+ static files and never shows the button.
250
295
 
251
296
  ## Presenting
252
297
 
@@ -278,6 +323,7 @@ export default {
278
323
 
279
324
  decks: 'decks', // folder of .md
280
325
  demos: 'demo', // folder behind a bare `<!-- demo: name -->`
326
+ assets: 'assets', // where a file dropped on the deck lands
281
327
  static: ['assets', 'demo'], // served and published; auto-detected when omitted
282
328
  revealTheme: 'dracula', // one of reveal.js's own themes; omit for this one
283
329
  webfonts: false, // let the reveal themes fetch their Google fonts
@@ -294,7 +340,7 @@ export default {
294
340
  { name: 'angular demo', cwd: 'demo/app', command: 'npm', args: ['start', '--', '--port', '4200'] },
295
341
  ],
296
342
 
297
- editor: true, // a `source ↗` button on every demo slide
343
+ editor: true, // a `source ↗` button on every demo slide (needs VS Code)
298
344
 
299
345
  port: 4000,
300
346
  open: true,
@@ -314,9 +360,52 @@ Copying a real Angular or Vite app into `demo/` and wiring it up — including t
314
360
  Vite needs so a busy port fails instead of moving:
315
361
  [docs/framework-demos.md](docs/framework-demos.md).
316
362
 
317
- `editor: true` is a side process of the same kind, and fails the same way — a missing
318
- `code` in PATH, a port already taken, and you get a warning and demo slides without their
319
- `source ↗` button: [docs/source-code.md](docs/source-code.md).
363
+ `editor: true` is a side process of the same kind, and fails the same way: no VS Code on
364
+ the machine, or no `code` on the PATH of the shell that started the deck, and you get a
365
+ warning under the deck's URL and demo slides without their `source ↗` button. It listens on
366
+ 7100, clear of the ports the demos want, and walks up from there if something holds it:
367
+ [docs/source-code.md](docs/source-code.md).
368
+
369
+ ### When something survives
370
+
371
+ `dev` starts more than itself, and it takes its side processes down with it — as long as it
372
+ is asked politely. Killed outright, or with its terminal window closed, it leaves them: a
373
+ `vite` or an `ng serve` under the npm that started it, `code serve-web`, all still holding
374
+ the ports the next run wants.
375
+
376
+ ```bash
377
+ npm run kill
378
+ ```
379
+
380
+ It reads the ports out of this file — the deck's, `preview`'s one above it, the `url:` of
381
+ every `servers:` entry, the editor's 7100–7109 — and stops whatever is listening on them,
382
+ naming each one before it goes:
383
+
384
+ ```
385
+ ✗ 4000 deck node …/bin/fb-slides.mjs dev (39190)
386
+ ✗ 4200 angular demo ng serve (39212)
387
+ ✗ 7100 editor …/Visual Studio Code.app/… (32669)
388
+
389
+ 3 processes stopped.
390
+ ```
391
+
392
+ Going by port rather than by process tree is what makes it work at all after the deck is
393
+ gone: the port is the thing already written down, and the process holding it is the one in
394
+ the way. The npm above it exits on its own once its server is stopped.
395
+
396
+ A `servers:` entry with no `url:` is the one thing it cannot reach — that key is how the
397
+ config says where the demo answers.
398
+
399
+ `dev` also looks before it leaps: a port a `servers:` entry is about to use that is *already*
400
+ answering is a leftover from a previous run, and it says so under the deck's URL rather than
401
+ letting you find out from a demo slide showing yesterday's build.
402
+
403
+ ```
404
+ kill test
405
+ → http://localhost:4010/
406
+ ⚠ 4201 is already in use — angular-hello from an earlier run?
407
+ That server is not this deck's: `npx fb-slides kill` frees the ports it uses.
408
+ ```
320
409
 
321
410
  ## Theming
322
411
 
package/bin/fb-slides.mjs CHANGED
@@ -6,6 +6,7 @@
6
6
  // fb-slides dev serve it, open it
7
7
  // fb-slides build dist/, self-contained
8
8
  // fb-slides preview build, then serve dist/
9
+ // fb-slides kill stop whatever `dev` left running
9
10
  // ---------------------------------------------------------------------------
10
11
 
11
12
  import { createRequire } from 'node:module';
@@ -38,6 +39,7 @@ const HELP = `
38
39
  [--no-servers] [--no-editor]
39
40
  fb-slides build build dist/ [--out dir] [--watch]
40
41
  fb-slides preview build, then serve the result [--port n]
42
+ fb-slides kill stop the deck, the demos and the editor
41
43
 
42
44
  Run from a folder holding decks/*.md. slides.config.js is optional.
43
45
  `;
@@ -79,6 +81,11 @@ const run = async () => {
79
81
  return void (await dev(config, RUNTIME, reload));
80
82
  }
81
83
 
84
+ if (command === 'kill') {
85
+ const { kill } = await import('../lib/kill.mjs');
86
+ return void (await kill(config));
87
+ }
88
+
82
89
  if (command === 'build') {
83
90
  const { build, buildWatch } = await import('../lib/build.mjs');
84
91
  return void (await (flag('watch') ? buildWatch(config, RUNTIME) : build(config, RUNTIME)));
@@ -158,6 +158,26 @@ only on the machine running the app. A framework demo is for presenting live. If
158
158
  has to stand on its own on the web, point that slide at a deployed URL — the marker takes
159
159
  any `http(s)` address.
160
160
 
161
+ ## When one outlives the deck
162
+
163
+ `dev` kills the processes it spawned, but the process it spawned is npm and the one holding
164
+ 4200 is npm's child — and when the deck itself is killed outright, or its terminal window
165
+ closed, no handler runs at all. What is left is a dev server nobody can see, on the port the
166
+ next run needs.
167
+
168
+ ```bash
169
+ npm run kill
170
+ ```
171
+
172
+ Every port in `servers:` — that is what `url:` is read for — plus the deck's and the
173
+ editor's, and whatever is listening there is stopped. A demo declared without a `url:` is
174
+ the one this cannot reach.
175
+
176
+ And `dev` warns rather than leaving you to it: it probes each `servers:` port before
177
+ spawning anything, so a port that answers *before* its demo has started is reported under
178
+ the deck's URL. Otherwise the demo's own dev server fails, or moves one port along, and the
179
+ slide keeps showing the run you thought you had stopped.
180
+
161
181
  ## Removing one
162
182
 
163
183
  Three things, all yours: the folder, the `servers:` entry, the slide. Delete them and
@@ -8,6 +8,12 @@ is `code serve-web` — the web server built into the VS Code on the machine
8
8
  running the talk — pointed at the demo's folder. The file tree, the search, the
9
9
  terminal, the extensions, the keybindings: the editor, in a tab.
10
10
 
11
+ Which is also the one requirement: **VS Code has to be installed on the machine
12
+ running the deck**, because `serve-web` is a subcommand of its CLI and nothing
13
+ here reimplements it. A fork that kept the CLI does as well —
14
+ `editor: { command: 'cursor' }`. On a machine without one the deck is unchanged
15
+ except for the button, and `dev` says why under its own URL.
16
+
11
17
  ## Turning it on
12
18
 
13
19
  ```js
@@ -153,6 +159,15 @@ editor: { command: '/usr/local/bin/code' },
153
159
  table above is read from `servers:` as written in the config, so it still
154
160
  works when none of those servers were started.
155
161
 
162
+ ## When it outlives the deck
163
+
164
+ `serve-web` is a side process, and it goes down with the deck — unless the deck goes down
165
+ the hard way, killed outright or with its terminal window closed. Then it stays up, on
166
+ 7100, invisible, and the next run finds the port taken and walks past it to 7101.
167
+
168
+ `npm run kill` sweeps the whole walk, 7100 through 7109, along with the deck's port and the
169
+ demos'. Worth knowing before wondering why the editor is on a different port every day.
170
+
156
171
  ## It is your real filesystem
157
172
 
158
173
  The editor opens the actual folder, with write access, as an editor does. Saving
package/lib/assets.mjs ADDED
@@ -0,0 +1,201 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The asset API — what lets the browser drop a file into assets/.
3
+ //
4
+ // Mounted alongside the edit API, by `dev` only, and behind the same guards: a
5
+ // built deck is static files and never answers /api/ at all. Writing a slide
6
+ // and putting the picture it points at next to it are the same job, so the
7
+ // drawer that does one does the other.
8
+ //
9
+ // GET /api/assets → { dir, dirs } — where files land, and the subfolders
10
+ // that are already there to aim at
11
+ // POST /api/assets → the file's own bytes, its name and folder riding in
12
+ // headers (a filename is not latin-1, hence the encoding);
13
+ // answers { path } — what the slide should say
14
+ // DELETE /api/assets?path=assets/x/y.png → that file, gone from disk. The
15
+ // drawer asks the question; this only checks that what it
16
+ // names is a file under the assets folder.
17
+ //
18
+ // The folder is created on the way in, so naming one that does not exist yet is
19
+ // how you make it. Nothing is ever overwritten: a name already taken is walked
20
+ // to `-2`, `-3`, and the answer says which one it became.
21
+ // ---------------------------------------------------------------------------
22
+
23
+ import { createWriteStream } from 'node:fs';
24
+ import { access, mkdir, readdir, rm, stat } from 'node:fs/promises';
25
+ import { extname, join, resolve, sep } from 'node:path';
26
+ import { Transform } from 'node:stream';
27
+ import { pipeline } from 'node:stream/promises';
28
+
29
+ // A screen recording is the big one; past this something has gone wrong on the
30
+ // way in, and a dev server should not be filling a disk over it.
31
+ const MAX_BYTES = 512 * 1024 * 1024;
32
+
33
+ // How deep the folder list looks, and how deep a drop may aim.
34
+ const MAX_DEPTH = 4;
35
+
36
+ // A name that can be written in a slide without thinking about it: what lands
37
+ // here ends up in a URL — `![](assets/screen shot.png)` is not even an image —
38
+ // so everything else folds to a dash, and a leading dot (a hidden file, `..`)
39
+ // does not survive at all.
40
+ const slug = (raw) =>
41
+ raw
42
+ // `città.png` should be `citta.png`, not `citt.png`: the accent comes off
43
+ // the letter rather than taking it with it.
44
+ .normalize('NFD')
45
+ .replace(/[\u0300-\u036f]/g, '')
46
+ .replace(/[^A-Za-z0-9._-]+/g, '-')
47
+ // `screen shot (1).png` has folded to `screen-shot-1-.png` by now: the
48
+ // dashes that ended up hugging the dot, and each other, are noise.
49
+ .replace(/-*\.-*/g, '.')
50
+ .replace(/-{2,}/g, '-')
51
+ .replace(/^[.-]+/, '')
52
+ .replace(/[.-]+$/, '');
53
+
54
+ const json = (res, status, body) =>
55
+ res
56
+ .writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
57
+ .end(JSON.stringify(body));
58
+
59
+ const decode = (value) => {
60
+ try {
61
+ return decodeURIComponent(String(value ?? ''));
62
+ } catch {
63
+ return '';
64
+ }
65
+ };
66
+
67
+ const exists = (path) => access(path).then(() => true, () => false);
68
+
69
+ // The subfolder a drop aimed at, as `{ relative, path }` — or null for anything
70
+ // that is not a folder under the assets root. A name typed into the drawer is
71
+ // folded rather than refused, so `my folder` makes `my-folder`; a segment that
72
+ // folds away to nothing — `..` — takes the whole path with it.
73
+ const folderIn = (root, value) => {
74
+ const typed = value.split('/').map((part) => part.trim()).filter(Boolean);
75
+ const parts = typed.map(slug);
76
+ if (typed.length > MAX_DEPTH || parts.some((part) => !part)) return null;
77
+ const path = resolve(root, ...parts);
78
+ // Belt and braces: the folding above cannot produce a `..`, and this is what
79
+ // says so out loud.
80
+ if (path !== root && !path.startsWith(root + sep)) return null;
81
+ return { relative: parts.join('/'), path };
82
+ };
83
+
84
+ // The name the file is written under: its own, folded. The extension is kept
85
+ // out of the folding and lowercased — it is what decides whether the browser
86
+ // treats the file as a picture, so it is the one part that must survive a name
87
+ // written in an alphabet this does not carry.
88
+ const safeName = (raw) => {
89
+ const base = raw.split(/[\\/]/).pop().trim();
90
+ const ext = extname(base).toLowerCase();
91
+ const tail = /^\.[a-z0-9]+$/.test(ext) ? ext : '';
92
+ return `${slug(base.slice(0, base.length - ext.length)) || 'file'}${tail}`;
93
+ };
94
+
95
+ // Never overwrite: `clip.mp4` beside a `clip.mp4` becomes `clip-2.mp4`.
96
+ const freeName = async (dir, name) => {
97
+ const ext = extname(name);
98
+ const stem = name.slice(0, name.length - ext.length) || 'file';
99
+ for (let n = 1; n < 1000; n += 1) {
100
+ const candidate = n === 1 ? `${stem}${ext}` : `${stem}-${n}${ext}`;
101
+ if (!(await exists(join(dir, candidate)))) return candidate;
102
+ }
103
+ return null;
104
+ };
105
+
106
+ const subfolders = async (root) => {
107
+ const walk = async (dir, prefix, left) => {
108
+ if (!left) return [];
109
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
110
+ const found = [];
111
+ for (const entry of entries) {
112
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
113
+ const path = prefix ? `${prefix}/${entry.name}` : entry.name;
114
+ found.push(path, ...(await walk(join(dir, entry.name), path, left - 1)));
115
+ }
116
+ return found;
117
+ };
118
+ return (await walk(root, '', MAX_DEPTH)).sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
119
+ };
120
+
121
+ // One file already in there, named the way the drawer was handed it —
122
+ // `assets/clips/demo.mp4`, the assets folder included. Null for anything that is
123
+ // not under that folder, and for the hidden files the server does not serve.
124
+ const fileIn = (root, dir, value) => {
125
+ const parts = value.split('/').map((part) => part.trim()).filter(Boolean);
126
+ if (parts.shift() !== dir || !parts.length) return null;
127
+ if (parts.some((part) => part.startsWith('.') || part === '..')) return null;
128
+ const path = resolve(root, ...parts);
129
+ return path.startsWith(root + sep) ? path : null;
130
+ };
131
+
132
+ // Counted on the way through rather than trusted from Content-Length, and the
133
+ // half-written file goes when the count runs over.
134
+ const capped = (limit) => {
135
+ let size = 0;
136
+ return new Transform({
137
+ transform(chunk, _encoding, next) {
138
+ size += chunk.length;
139
+ if (size > limit) next(new Error(`over the ${Math.round(limit / (1024 * 1024))} MB limit`));
140
+ else next(null, chunk);
141
+ },
142
+ });
143
+ };
144
+
145
+ const receive = async (req, target) => {
146
+ try {
147
+ // `wx` rather than `w`: the free name was worked out a moment ago, and two
148
+ // files dropped together should not land on top of each other.
149
+ await pipeline(req, capped(MAX_BYTES), createWriteStream(target, { flags: 'wx' }));
150
+ } catch (error) {
151
+ await rm(target, { force: true });
152
+ throw error;
153
+ }
154
+ };
155
+
156
+ // `dir` is the folder as the browser will write it — `assets` — and `path` is
157
+ // where that is on disk.
158
+ export const createAssetApi = ({ dir, path: root }) => async (req, res) => {
159
+ if (req.method === 'GET') {
160
+ return json(res, 200, { dir, dirs: await subfolders(root).catch(() => []) });
161
+ }
162
+
163
+ if (req.method !== 'POST' && req.method !== 'DELETE') {
164
+ return json(res, 405, { error: 'method not allowed' });
165
+ }
166
+ if (!req.headers['x-fb-slides']) return json(res, 403, { error: 'forbidden' });
167
+
168
+ if (req.method === 'DELETE') {
169
+ const asked = new URL(req.url, 'http://localhost').searchParams.get('path') ?? '';
170
+ const path = fileIn(root, dir, asked);
171
+ if (!path) return json(res, 400, { error: `${asked} is not a file in ${dir}/` });
172
+ const info = await stat(path).catch(() => null);
173
+ // A folder is not what the drawer offers to delete, and a file that is
174
+ // already gone is the outcome asked for.
175
+ if (info && !info.isFile()) return json(res, 409, { error: 'that is a folder' });
176
+ await rm(path, { force: true });
177
+ return json(res, 200, { ok: true, path: asked });
178
+ }
179
+
180
+ const folder = folderIn(root, decode(req.headers['x-fb-dir']));
181
+ const name = safeName(decode(req.headers['x-fb-name']));
182
+ if (!folder) return json(res, 400, { error: 'that is not a folder name' });
183
+
184
+ await mkdir(folder.path, { recursive: true });
185
+ const unique = await freeName(folder.path, name);
186
+ if (!unique) return json(res, 409, { error: `too many files called ${name}` });
187
+
188
+ try {
189
+ await receive(req, join(folder.path, unique));
190
+ } catch (error) {
191
+ return json(res, 413, { error: `${name}: ${error.message}` });
192
+ }
193
+
194
+ return json(res, 200, {
195
+ name: unique,
196
+ dir: folder.relative,
197
+ // Relative, like everything else a slide points at: the deck is served from
198
+ // a root in dev and from wherever it was published in a build.
199
+ path: [dir, folder.relative, unique].filter(Boolean).join('/'),
200
+ });
201
+ };
package/lib/config.mjs CHANGED
@@ -29,6 +29,10 @@ export const ALWAYS_EXCLUDED = ['node_modules', '.git', '.angular', '.next', '.c
29
29
  // loses. Two decks at once are handled by walking up from here instead.
30
30
  const EDITOR_PORT = 7100;
31
31
 
32
+ // How far up from there `dev` walks when something already holds it, and so how
33
+ // far `kill` has to sweep to be sure it caught the editor of an earlier run.
34
+ export const EDITOR_PORT_TRIES = 10;
35
+
32
36
  // `editor: true` turns the source button on with the defaults; `{ port, command }`
33
37
  // changes them; leaving the key out is no button at all. The command is a name in
34
38
  // PATH — `code-insiders` and `cursor` answer `serve-web` too — or a path, for a
@@ -42,6 +46,17 @@ const editorSpec = (value) => {
42
46
  };
43
47
  };
44
48
 
49
+ // The port a `servers:` entry answers on, read out of its `url:`. Both `dev`
50
+ // (is something already sitting there?) and `kill` (what do I stop?) ask this.
51
+ export const portOf = (url) => {
52
+ try {
53
+ const { port, protocol } = new URL(url);
54
+ return Number(port || (protocol === 'https:' ? 443 : 80));
55
+ } catch {
56
+ return null;
57
+ }
58
+ };
59
+
45
60
  export const findConfigFile = (root) => CONFIG_FILES.map((name) => join(root, name)).find(existsSync) ?? null;
46
61
 
47
62
  const importConfig = async (file) => {
@@ -65,11 +80,16 @@ export const loadConfig = async (root = process.cwd(), overrides = {}) => {
65
80
 
66
81
  const decksDir = user.decks ?? 'decks';
67
82
  const demosDir = user.demos ?? 'demo';
83
+ const assetsDir = user.assets ?? 'assets';
68
84
 
69
85
  // `static:` replaces the auto-detected list when given; the demos folder is
70
- // added back regardless, because the deck's iframes point straight at it.
86
+ // added back regardless, because the deck's iframes point straight at it, and
87
+ // so is the assets folder, because the edit drawer drops files into it and a
88
+ // path it hands out has to survive the build.
71
89
  const declared = user.static ?? AUTO_STATIC.filter((dir) => existsSync(join(root, dir)));
72
- const statics = [...new Set([...declared, demosDir])].filter((dir) => existsSync(join(root, dir)));
90
+ const statics = [...new Set([...declared, demosDir, assetsDir])].filter((dir) =>
91
+ existsSync(join(root, dir)),
92
+ );
73
93
 
74
94
  const config = {
75
95
  root,
@@ -78,6 +98,9 @@ export const loadConfig = async (root = process.cwd(), overrides = {}) => {
78
98
  lang: user.lang ?? 'en',
79
99
  decksDir,
80
100
  demosDir,
101
+ // Where a file dropped on the deck lands, and what the path it hands back
102
+ // is written against.
103
+ assetsDir,
81
104
  static: statics,
82
105
  // One of reveal's own fifteen — 'dracula', 'sky', 'white'. It lands after
83
106
  // the base theme and takes the slides; the chrome follows it through the
@@ -128,6 +151,7 @@ export const loadConfig = async (root = process.cwd(), overrides = {}) => {
128
151
  .map((spec) => [String(spec.url).replace(/\/$/, ''), spec.cwd]),
129
152
  );
130
153
  config.decksPath = resolve(root, config.decksDir);
154
+ config.assetsPath = resolve(root, config.assetsDir);
131
155
  config.outPath = resolve(root, config.outDir);
132
156
  // What the browser asks for, as opposed to where it is on disk.
133
157
  config.urls = { decks: asDirUrl(config.decksDir), demos: asDirUrl(config.demosDir) };