fb-slides 0.8.0 → 0.9.1
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 +60 -0
- package/README.md +47 -10
- package/lib/assets.mjs +227 -0
- package/lib/config.mjs +11 -2
- package/lib/dev.mjs +5 -4
- package/lib/edit.mjs +15 -2
- package/package.json +1 -1
- package/runtime/assets.js +556 -0
- package/runtime/edit.js +65 -15
- package/runtime/snippets.js +198 -69
- package/runtime/syntax.js +64 -0
- package/runtime/theme.base.css +470 -10
- package/templates/starter/assets/screen_2026_08_05_00_24_16.png +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,66 @@ 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.1 — 2026-09-08
|
|
11
|
+
|
|
12
|
+
- **The assets panel shows the folder, not just what you dropped.** The list
|
|
13
|
+
under the folder picker is now that folder as it is on disk — the screenshot
|
|
14
|
+
from a moment ago and the one from last month, side by side — so an asset
|
|
15
|
+
that was already there can be copied, written into the slide, or deleted
|
|
16
|
+
without going to the Finder for it. Picking another folder lists that one,
|
|
17
|
+
what this session put there is tinted, and a file under a kilobyte is counted
|
|
18
|
+
in bytes rather than rounding to `0 KB`.
|
|
19
|
+
|
|
20
|
+
## 0.9.0 — 2026-09-08
|
|
21
|
+
|
|
22
|
+
- **The editor answers `Shift+E`, not `T`.** The drawer's shortcut moved off
|
|
23
|
+
the QWERT row: `Shift+E` opens it now, and the toolbar badge, the tooltip
|
|
24
|
+
and reveal's help overlay all say so. Plain `E` still belongs to the
|
|
25
|
+
spotlight.
|
|
26
|
+
|
|
27
|
+
- **The slide box colours its markdown.** In the edit drawer, the slide's
|
|
28
|
+
textarea now sits over a highlighted copy of itself: headings, bold, fences,
|
|
29
|
+
links and the `<!-- .element -->` comments that steer reveal take colour as
|
|
30
|
+
you type. What you edit is still a plain textarea — caret, selection, undo
|
|
31
|
+
and drag untouched — and the palette is the theme's own, not the monokai the
|
|
32
|
+
slides wear. The grammar comes from reveal's highlight plugin, already in
|
|
33
|
+
the bundle; a deck without that plugin keeps the plain textarea.
|
|
34
|
+
|
|
35
|
+
- **Drag a file onto the deck and it lands in `assets/`.** With the edit drawer
|
|
36
|
+
open, dropping files anywhere over the slide — images, video, a PDF, anything
|
|
37
|
+
— raises the folders `assets/` already has, and they go into the one they are
|
|
38
|
+
dropped on; `+ new folder…` asks for a name and makes it, and the button
|
|
39
|
+
beside the `+` opens the same panel for files you would rather pick than drag.
|
|
40
|
+
What comes back is the path to use, with a button that copies it and one that
|
|
41
|
+
writes the markup at the cursor: `` for an image, a `<video>` with the
|
|
42
|
+
attributes that make it behave for a clip, a link for the rest. Names are
|
|
43
|
+
folded to something a URL can carry — `Schermata città (1).PNG` becomes
|
|
44
|
+
`Schermata-citta-1.png` — and nothing is overwritten, a second `clip.mp4`
|
|
45
|
+
lands as `clip-2.mp4`. A row's third button deletes the file — the wrong
|
|
46
|
+
screenshot, dropped a moment ago — and that one asks: **Delete** stays dead
|
|
47
|
+
until `CONFIRM` is typed, because it is the only thing in the drawer a reload
|
|
48
|
+
cannot undo. The folder is published with the deck whatever `static:` says, so
|
|
49
|
+
the path survives the build; `assets:` in `slides.config.js` moves it. Dev
|
|
50
|
+
server only, like the drawer it belongs to.
|
|
51
|
+
|
|
52
|
+
- **The block picker grew into the whole vocabulary.** The `+` in the edit
|
|
53
|
+
drawer — and `/` on an empty line — now offers every block the theme knows,
|
|
54
|
+
under group headers: the section divider, the table with its muted first
|
|
55
|
+
column, the blockquote, a sequence diagram beside the flowchart, an autoplay
|
|
56
|
+
video — `data-autoplay muted loop`, the clip that plays itself when the slide
|
|
57
|
+
shows — and a second fragment — the `<!-- .element: class="fragment" -->`
|
|
58
|
+
comment that tags any block above it, image or list or
|
|
59
|
+
box, where the old one only wrapped a paragraph. The demo markers now bring
|
|
60
|
+
their fallback along — the heading and iframe GitHub and Marp show where the
|
|
61
|
+
live demo would be — and the external-page marker's row says how `| src:`
|
|
62
|
+
hitches the source button to a folder. Each row
|
|
63
|
+
also carries the words you would actually type looking for it — `img`,
|
|
64
|
+
`mermaid`, `appear` — so the filter answers to those too, and a pane under
|
|
65
|
+
the list shows the markup the highlighted row is about to write, before it
|
|
66
|
+
lands. A project's own `snippets:` sit under their own header — `group:`
|
|
67
|
+
names it, `keys:` joins the search — and a filter that matches nothing now
|
|
68
|
+
says so instead of showing an empty list.
|
|
69
|
+
|
|
10
70
|
## 0.8.0 — 2026-09-07
|
|
11
71
|
|
|
12
72
|
- **`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, `
|
|
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,59 @@ 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
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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
|
+
The list under the folder picker is that folder, as it is on disk: what was just
|
|
277
|
+
dropped and what has been there since last month. Each row carries the path to
|
|
278
|
+
write in the slide, a button that copies it, and one that writes it for you at
|
|
279
|
+
the cursor: `` for an image, a `<video>` with the attributes that make it
|
|
280
|
+
behave for a clip, an `<audio>` for sound, a link for everything else. Picking
|
|
281
|
+
another folder lists that one; what this session put there is tinted, so it
|
|
282
|
+
stands out among files that were already around. Names are folded to something
|
|
283
|
+
a URL can carry — `Schermata città (1).PNG` becomes `Schermata-citta-1.png` —
|
|
284
|
+
and nothing is ever overwritten: a second `clip.mp4` lands as `clip-2.mp4`.
|
|
285
|
+
|
|
286
|
+
The third button on a row deletes the file — the wrong screenshot, dropped a
|
|
287
|
+
moment ago. It is the one thing here a reload cannot undo, so it asks: the
|
|
288
|
+
**Delete** button stays dead until `CONFIRM` is typed into the box, in capitals.
|
|
289
|
+
`Esc` backs out of the question, then out of the panel, then out of the drawer —
|
|
290
|
+
one layer per press.
|
|
291
|
+
|
|
292
|
+
`assets:` in `slides.config.js` names the folder. It is published with the deck
|
|
293
|
+
whatever else `static:` says, so a path the drawer hands out works in the build
|
|
294
|
+
too.
|
|
295
|
+
|
|
296
|
+
Both the drawer and the drop exist only under `fb-slides dev`: a built deck is
|
|
297
|
+
static files and never shows the button.
|
|
262
298
|
|
|
263
299
|
## Presenting
|
|
264
300
|
|
|
@@ -290,6 +326,7 @@ export default {
|
|
|
290
326
|
|
|
291
327
|
decks: 'decks', // folder of .md
|
|
292
328
|
demos: 'demo', // folder behind a bare `<!-- demo: name -->`
|
|
329
|
+
assets: 'assets', // where a file dropped on the deck lands
|
|
293
330
|
static: ['assets', 'demo'], // served and published; auto-detected when omitted
|
|
294
331
|
revealTheme: 'dracula', // one of reveal.js's own themes; omit for this one
|
|
295
332
|
webfonts: false, // let the reveal themes fetch their Google fonts
|
package/lib/assets.mjs
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
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=x → { dir, dirs, at, files } — where files land, the
|
|
10
|
+
// subfolders to aim at, and what is in the one asked for
|
|
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
|
+
// What is in one folder, as rows the drawer can list: files only, no dotfiles,
|
|
107
|
+
// each carrying the path a slide would write.
|
|
108
|
+
const filesIn = async (path, dir, relative) => {
|
|
109
|
+
const entries = await readdir(path, { withFileTypes: true }).catch(() => []);
|
|
110
|
+
const found = [];
|
|
111
|
+
for (const entry of entries) {
|
|
112
|
+
if (!entry.isFile() || entry.name.startsWith('.')) continue;
|
|
113
|
+
const info = await stat(join(path, entry.name)).catch(() => null);
|
|
114
|
+
found.push({
|
|
115
|
+
name: entry.name,
|
|
116
|
+
size: info?.size ?? 0,
|
|
117
|
+
path: [dir, relative, entry.name].filter(Boolean).join('/'),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return found.sort((a, b) => a.name.localeCompare(b.name, 'en', { numeric: true }));
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const subfolders = async (root) => {
|
|
124
|
+
const walk = async (dir, prefix, left) => {
|
|
125
|
+
if (!left) return [];
|
|
126
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
127
|
+
const found = [];
|
|
128
|
+
for (const entry of entries) {
|
|
129
|
+
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
|
130
|
+
const path = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
131
|
+
found.push(path, ...(await walk(join(dir, entry.name), path, left - 1)));
|
|
132
|
+
}
|
|
133
|
+
return found;
|
|
134
|
+
};
|
|
135
|
+
return (await walk(root, '', MAX_DEPTH)).sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// One file already in there, named the way the drawer was handed it —
|
|
139
|
+
// `assets/clips/demo.mp4`, the assets folder included. Null for anything that is
|
|
140
|
+
// not under that folder, and for the hidden files the server does not serve.
|
|
141
|
+
const fileIn = (root, dir, value) => {
|
|
142
|
+
const parts = value.split('/').map((part) => part.trim()).filter(Boolean);
|
|
143
|
+
if (parts.shift() !== dir || !parts.length) return null;
|
|
144
|
+
if (parts.some((part) => part.startsWith('.') || part === '..')) return null;
|
|
145
|
+
const path = resolve(root, ...parts);
|
|
146
|
+
return path.startsWith(root + sep) ? path : null;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// Counted on the way through rather than trusted from Content-Length, and the
|
|
150
|
+
// half-written file goes when the count runs over.
|
|
151
|
+
const capped = (limit) => {
|
|
152
|
+
let size = 0;
|
|
153
|
+
return new Transform({
|
|
154
|
+
transform(chunk, _encoding, next) {
|
|
155
|
+
size += chunk.length;
|
|
156
|
+
if (size > limit) next(new Error(`over the ${Math.round(limit / (1024 * 1024))} MB limit`));
|
|
157
|
+
else next(null, chunk);
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const receive = async (req, target) => {
|
|
163
|
+
try {
|
|
164
|
+
// `wx` rather than `w`: the free name was worked out a moment ago, and two
|
|
165
|
+
// files dropped together should not land on top of each other.
|
|
166
|
+
await pipeline(req, capped(MAX_BYTES), createWriteStream(target, { flags: 'wx' }));
|
|
167
|
+
} catch (error) {
|
|
168
|
+
await rm(target, { force: true });
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// `dir` is the folder as the browser will write it — `assets` — and `path` is
|
|
174
|
+
// where that is on disk.
|
|
175
|
+
export const createAssetApi = ({ dir, path: root }) => async (req, res) => {
|
|
176
|
+
if (req.method === 'GET') {
|
|
177
|
+
// `dir` is the folder the drawer is showing — the root when it says nothing,
|
|
178
|
+
// and nothing at all while a new folder is being named.
|
|
179
|
+
const asked = new URL(req.url, 'http://localhost').searchParams.get('dir') ?? '';
|
|
180
|
+
const at = folderIn(root, asked);
|
|
181
|
+
return json(res, 200, {
|
|
182
|
+
dir,
|
|
183
|
+
dirs: await subfolders(root).catch(() => []),
|
|
184
|
+
at: at?.relative ?? '',
|
|
185
|
+
files: at ? await filesIn(at.path, dir, at.relative) : [],
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (req.method !== 'POST' && req.method !== 'DELETE') {
|
|
190
|
+
return json(res, 405, { error: 'method not allowed' });
|
|
191
|
+
}
|
|
192
|
+
if (!req.headers['x-fb-slides']) return json(res, 403, { error: 'forbidden' });
|
|
193
|
+
|
|
194
|
+
if (req.method === 'DELETE') {
|
|
195
|
+
const asked = new URL(req.url, 'http://localhost').searchParams.get('path') ?? '';
|
|
196
|
+
const path = fileIn(root, dir, asked);
|
|
197
|
+
if (!path) return json(res, 400, { error: `${asked} is not a file in ${dir}/` });
|
|
198
|
+
const info = await stat(path).catch(() => null);
|
|
199
|
+
// A folder is not what the drawer offers to delete, and a file that is
|
|
200
|
+
// already gone is the outcome asked for.
|
|
201
|
+
if (info && !info.isFile()) return json(res, 409, { error: 'that is a folder' });
|
|
202
|
+
await rm(path, { force: true });
|
|
203
|
+
return json(res, 200, { ok: true, path: asked });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const folder = folderIn(root, decode(req.headers['x-fb-dir']));
|
|
207
|
+
const name = safeName(decode(req.headers['x-fb-name']));
|
|
208
|
+
if (!folder) return json(res, 400, { error: 'that is not a folder name' });
|
|
209
|
+
|
|
210
|
+
await mkdir(folder.path, { recursive: true });
|
|
211
|
+
const unique = await freeName(folder.path, name);
|
|
212
|
+
if (!unique) return json(res, 409, { error: `too many files called ${name}` });
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
await receive(req, join(folder.path, unique));
|
|
216
|
+
} catch (error) {
|
|
217
|
+
return json(res, 413, { error: `${name}: ${error.message}` });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return json(res, 200, {
|
|
221
|
+
name: unique,
|
|
222
|
+
dir: folder.relative,
|
|
223
|
+
// Relative, like everything else a slide points at: the deck is served from
|
|
224
|
+
// a root in dev and from wherever it was published in a build.
|
|
225
|
+
path: [dir, folder.relative, unique].filter(Boolean).join('/'),
|
|
226
|
+
});
|
|
227
|
+
};
|
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) =>
|
|
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
|
|
227
|
-
//
|
|
228
|
-
// /api/editable). Build and
|
|
229
|
-
|
|
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
|
-
|
|
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')
|
|
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
|
|