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 +74 -0
- package/README.md +104 -15
- package/bin/fb-slides.mjs +7 -0
- package/docs/framework-demos.md +20 -0
- package/docs/source-code.md +15 -0
- package/lib/assets.mjs +201 -0
- package/lib/config.mjs +26 -2
- package/lib/dev.mjs +34 -8
- package/lib/edit.mjs +15 -2
- package/lib/kill.mjs +221 -0
- package/lib/server.mjs +1 -1
- package/package.json +1 -1
- package/runtime/assets.js +513 -0
- package/runtime/edit.js +65 -15
- package/runtime/snippets.js +198 -69
- package/runtime/syntax.js +64 -0
- package/runtime/theme.base.css +457 -10
- package/templates/starter/_package.json +2 -1
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.
|
|
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, `
|
|
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
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
249
|
-
|
|
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
|
|
318
|
-
`code`
|
|
319
|
-
`source ↗` button
|
|
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)));
|
package/docs/framework-demos.md
CHANGED
|
@@ -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
|
package/docs/source-code.md
CHANGED
|
@@ -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 — `` 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) =>
|
|
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) };
|