fb-slides 0.8.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,56 @@ 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
+
10
60
  ## 0.8.0 — 2026-09-07
11
61
 
12
62
  - **`npm run kill` — everything the talk left running, gone.** `dev` starts more
package/README.md CHANGED
@@ -230,7 +230,7 @@ restarts every time the slide comes up.
230
230
 
231
231
  ### Editing in the browser
232
232
 
233
- 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
234
234
  are looking at in a drawer: its markdown in one box, its speaker notes in
235
235
  another. Typing re-renders the real slide in place — same renderer, same theme —
236
236
  and **Save** writes the text back into the slide's own lines of the `.md`,
@@ -242,23 +242,56 @@ file, after asking. Clicking into the notes box trades the room with the slide
242
242
  box, so both are comfortable to write in.
243
243
 
244
244
  The `+` over the slide box — or `/` typed on an empty line — opens the block
245
- picker: the demo marker, a framed embed, a video with the attributes that make
246
- it behave, two columns, a fenced block, the speaker page. The block lands where
247
- the cursor was, with its first placeholder selected so it is ready to type over;
248
- text already selected takes that placeholder's place, so picking **Fragment**
249
- wraps the paragraph that was highlighted. A block that only works at the top of
250
- 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.
251
256
 
252
257
  `snippets:` in `slides.config.js` adds a project's own blocks to the list:
253
258
 
254
259
  ```js
255
260
  snippets: [
256
- { 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>' },
257
262
  ],
258
263
  ```
259
264
 
260
- The drawer exists only under `fb-slides dev`: a built deck is static files and
261
- 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.
262
295
 
263
296
  ## Presenting
264
297
 
@@ -290,6 +323,7 @@ export default {
290
323
 
291
324
  decks: 'decks', // folder of .md
292
325
  demos: 'demo', // folder behind a bare `<!-- demo: name -->`
326
+ assets: 'assets', // where a file dropped on the deck lands
293
327
  static: ['assets', 'demo'], // served and published; auto-detected when omitted
294
328
  revealTheme: 'dracula', // one of reveal.js's own themes; omit for this one
295
329
  webfonts: false, // let the reveal themes fetch their Google fonts
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
@@ -80,11 +80,16 @@ export const loadConfig = async (root = process.cwd(), overrides = {}) => {
80
80
 
81
81
  const decksDir = user.decks ?? 'decks';
82
82
  const demosDir = user.demos ?? 'demo';
83
+ const assetsDir = user.assets ?? 'assets';
83
84
 
84
85
  // `static:` replaces the auto-detected list when given; the demos folder is
85
- // 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.
86
89
  const declared = user.static ?? AUTO_STATIC.filter((dir) => existsSync(join(root, dir)));
87
- 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
+ );
88
93
 
89
94
  const config = {
90
95
  root,
@@ -93,6 +98,9 @@ export const loadConfig = async (root = process.cwd(), overrides = {}) => {
93
98
  lang: user.lang ?? 'en',
94
99
  decksDir,
95
100
  demosDir,
101
+ // Where a file dropped on the deck lands, and what the path it hands back
102
+ // is written against.
103
+ assetsDir,
96
104
  static: statics,
97
105
  // One of reveal's own fifteen — 'dracula', 'sky', 'white'. It lands after
98
106
  // the base theme and takes the slides; the chrome follows it through the
@@ -143,6 +151,7 @@ export const loadConfig = async (root = process.cwd(), overrides = {}) => {
143
151
  .map((spec) => [String(spec.url).replace(/\/$/, ''), spec.cwd]),
144
152
  );
145
153
  config.decksPath = resolve(root, config.decksDir);
154
+ config.assetsPath = resolve(root, config.assetsDir);
146
155
  config.outPath = resolve(root, config.outDir);
147
156
  // What the browser asks for, as opposed to where it is on disk.
148
157
  config.urls = { decks: asDirUrl(config.decksDir), demos: asDirUrl(config.demosDir) };
package/lib/dev.mjs CHANGED
@@ -223,10 +223,11 @@ export const dev = async (config, runtimeDir, reload) => {
223
223
  const editor = editorUrl ? { url: editorUrl, root: config.root, sources: now.demoSources } : null;
224
224
  return renderIndex(now, runtimeDir, editor ? { editor } : {});
225
225
  },
226
- // Editing a slide in the browser saves it back into its .md — dev only,
227
- // which is what makes the edit button appear at all (the runtime probes
228
- // /api/editable). Build and preview never mount this.
229
- api: createEditApi(config.decksPath),
226
+ // Editing a slide in the browser saves it back into its .md, and dropping
227
+ // a file on the deck puts it in assets/ — dev only, which is what makes the
228
+ // edit button appear at all (the runtime probes /api/editable). Build and
229
+ // preview never mount this.
230
+ api: createEditApi(config.decksPath, { dir: config.assetsDir, path: config.assetsPath }),
230
231
  generated: {
231
232
  [`/${REVEAL_THEME_URL}`]: async () => {
232
233
  const now = await current();
package/lib/edit.mjs CHANGED
@@ -11,6 +11,7 @@
11
11
  // POST /api/slide → { op?, file, index, expected, source }
12
12
  // op 'save' (default) replaces the slide, 'add' inserts a new one right
13
13
  // after it, 'delete' removes it — separator and all.
14
+ // /api/assets → dropping a file into assets/ (assets.mjs)
14
15
  //
15
16
  // The slide boundaries are computed here with the same split deck.js uses, so
16
17
  // the index the browser counted is the index this file finds. The splice
@@ -21,6 +22,8 @@
21
22
  import { readFile, writeFile } from 'node:fs/promises';
22
23
  import { resolve, sep } from 'node:path';
23
24
 
25
+ import { createAssetApi } from './assets.mjs';
26
+
24
27
  // Kept in step with deck.js: front matter comes off first, then slides split on
25
28
  // `---` lines, are trimmed, and empty segments are dropped.
26
29
  const FRONT_MATTER = /^---\r?\n[\s\S]*?\r?\n---\r?\n/;
@@ -78,7 +81,11 @@ const readBody = (req, limit = 1024 * 1024) =>
78
81
  // the request arrives same-origin under a name that is not ours.
79
82
  const LOCAL_HOST = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/;
80
83
 
81
- export const createEditApi = (decksPath) => {
84
+ // `assets` is `{ dir, path }` — the folder a dropped file lands in. Without it
85
+ // the drawer keeps its editing, and says so in the probe, but drops nothing.
86
+ export const createEditApi = (decksPath, assets = null) => {
87
+ const assetApi = assets ? createAssetApi(assets) : null;
88
+
82
89
  const deckFile = (name) => {
83
90
  if (typeof name !== 'string' || !FILE_NAME.test(name) || name.startsWith('.')) return null;
84
91
  const target = resolve(decksPath, name);
@@ -88,7 +95,13 @@ export const createEditApi = (decksPath) => {
88
95
  return async (req, res, pathname) => {
89
96
  if (!LOCAL_HOST.test(req.headers.host ?? '')) return json(res, 403, { error: 'forbidden' });
90
97
 
91
- if (pathname === '/api/editable') return json(res, 200, { editable: true });
98
+ if (pathname === '/api/editable') {
99
+ return json(res, 200, { editable: true, assets: assetApi ? assets.dir : null });
100
+ }
101
+
102
+ if (pathname === '/api/assets') {
103
+ return assetApi ? assetApi(req, res) : json(res, 404, { error: 'not found' });
104
+ }
92
105
 
93
106
  if (pathname !== '/api/slide') return json(res, 404, { error: 'not found' });
94
107
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fb-slides",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "description": "Markdown-driven reveal.js decks: live demo embeds, annotation, mermaid, and a zero-config dev server",
6
6
  "keywords": [