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/lib/dev.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import { connect } from 'node:net';
|
|
|
11
11
|
import { existsSync } from 'node:fs';
|
|
12
12
|
import { join, resolve } from 'node:path';
|
|
13
13
|
|
|
14
|
-
import { assertUsable } from './config.mjs';
|
|
14
|
+
import { EDITOR_PORT_TRIES, assertUsable, portOf } from './config.mjs';
|
|
15
15
|
import { createEditApi } from './edit.mjs';
|
|
16
16
|
import { createDeckServer, listen } from './server.mjs';
|
|
17
17
|
import { renderIndex } from './render.mjs';
|
|
@@ -55,6 +55,26 @@ const startSideServer = (spec, root, children) => {
|
|
|
55
55
|
});
|
|
56
56
|
};
|
|
57
57
|
|
|
58
|
+
// A port that answers before the deck has started anything is almost always the
|
|
59
|
+
// same demo from a run that did not come down cleanly — `dev` kills what it
|
|
60
|
+
// spawned, but a deck killed outright, or with its terminal window closed, kills
|
|
61
|
+
// nothing. The demo's own dev server then either fails or quietly moves one port
|
|
62
|
+
// along, and the slide embedding the old URL shows yesterday's build: the kind of
|
|
63
|
+
// thing that costs ten minutes to work out, and you find it on stage. Probed
|
|
64
|
+
// before anything is spawned, so whatever answers is certainly not ours.
|
|
65
|
+
const leftovers = async (servers) => {
|
|
66
|
+
const withPorts = servers.map((spec) => ({ spec, port: portOf(spec.url) })).filter((each) => each.port);
|
|
67
|
+
const taken = await Promise.all(withPorts.map((each) => portInUse(each.port)));
|
|
68
|
+
const busy = withPorts.filter((_, i) => taken[i]);
|
|
69
|
+
if (!busy.length) return [];
|
|
70
|
+
return [
|
|
71
|
+
...busy.map(
|
|
72
|
+
({ spec, port }) => `⚠ ${port} is already in use — ${spec.name ?? spec.cwd} from an earlier run?`,
|
|
73
|
+
),
|
|
74
|
+
'That server is not this deck\'s: `npx fb-slides kill` frees the ports it uses.',
|
|
75
|
+
];
|
|
76
|
+
};
|
|
77
|
+
|
|
58
78
|
// ---------------------------------------------------------------------------
|
|
59
79
|
// The source-code editor. `code serve-web` is VS Code's own web server, shipped
|
|
60
80
|
// inside the editor the presenter already has: no dependency is added here, and
|
|
@@ -86,8 +106,7 @@ const portInUse = (port) =>
|
|
|
86
106
|
|
|
87
107
|
// A second deck, or anything else already sitting there, moves the editor along
|
|
88
108
|
// rather than turning it off: nobody types this address — the page is handed it.
|
|
89
|
-
|
|
90
|
-
|
|
109
|
+
// How far it walks is in config.mjs, because `kill` sweeps the same range.
|
|
91
110
|
const freePort = async (start) => {
|
|
92
111
|
for (let port = start; port < start + EDITOR_PORT_TRIES; port += 1) {
|
|
93
112
|
if (!(await portInUse(port))) return port;
|
|
@@ -154,6 +173,10 @@ const startEditor = async (spec, children) => {
|
|
|
154
173
|
return { url };
|
|
155
174
|
};
|
|
156
175
|
|
|
176
|
+
// Indented under the deck's own URL: first line at the summary's margin, the
|
|
177
|
+
// rest under it.
|
|
178
|
+
const note = (lines) => lines.map((line, i) => (i ? ` ${line}` : ` ${line}`));
|
|
179
|
+
|
|
157
180
|
// What the page is built from, read again on every request. A config with a
|
|
158
181
|
// syntax error in it — the state it is in halfway through an edit — leaves the
|
|
159
182
|
// last good one standing rather than serving a broken deck.
|
|
@@ -200,10 +223,11 @@ export const dev = async (config, runtimeDir, reload) => {
|
|
|
200
223
|
const editor = editorUrl ? { url: editorUrl, root: config.root, sources: now.demoSources } : null;
|
|
201
224
|
return renderIndex(now, runtimeDir, editor ? { editor } : {});
|
|
202
225
|
},
|
|
203
|
-
// Editing a slide in the browser saves it back into its .md
|
|
204
|
-
//
|
|
205
|
-
// /api/editable). Build and
|
|
206
|
-
|
|
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 }),
|
|
207
231
|
generated: {
|
|
208
232
|
[`/${REVEAL_THEME_URL}`]: async () => {
|
|
209
233
|
const now = await current();
|
|
@@ -217,13 +241,15 @@ export const dev = async (config, runtimeDir, reload) => {
|
|
|
217
241
|
|
|
218
242
|
const url = `http://localhost:${config.port}/`;
|
|
219
243
|
const children = [];
|
|
244
|
+
const stale = await leftovers(config.servers);
|
|
220
245
|
for (const spec of config.servers) startSideServer(spec, config.root, children);
|
|
221
246
|
const editor = config.editor ? await startEditor(config.editor, children) : {};
|
|
222
247
|
editorUrl = editor.url ?? null;
|
|
223
248
|
|
|
224
249
|
// The last thing printed, so nothing scrolls over it.
|
|
225
250
|
const summary = [``, ` ${config.title}`, ` → ${url}`];
|
|
226
|
-
if (
|
|
251
|
+
if (stale.length) summary.push(...note(stale));
|
|
252
|
+
if (editor.note) summary.push(...note(editor.note));
|
|
227
253
|
console.log(summary.join('\n') + '\n');
|
|
228
254
|
if (config.open) setTimeout(() => openBrowser(url), 400);
|
|
229
255
|
|
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
|
|
package/lib/kill.mjs
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// `fb-slides kill` — everything the talk left running, gone.
|
|
3
|
+
//
|
|
4
|
+
// `dev` starts more than itself: a demo's own dev server, `code serve-web`. The
|
|
5
|
+
// handler in dev.mjs kills the children it spawned, but the child is npm and the
|
|
6
|
+
// thing holding the port is npm's child; and when the deck goes down by SIGKILL
|
|
7
|
+
// or by a closed terminal window, no handler runs at all. What is left is a
|
|
8
|
+
// process nobody can see holding a port the next run needs.
|
|
9
|
+
//
|
|
10
|
+
// So this does not chase the process tree — it goes at the ports, which are the
|
|
11
|
+
// one thing already written down. `slides.config.js` declares the deck's port,
|
|
12
|
+
// every demo's `url:`, and the editor's; whatever is listening there is what the
|
|
13
|
+
// talk started, and killing the listener is enough — npm exits on its own once
|
|
14
|
+
// the server under it is gone.
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
import { execFile } from 'node:child_process';
|
|
18
|
+
import { promisify } from 'node:util';
|
|
19
|
+
|
|
20
|
+
import { EDITOR_PORT_TRIES, portOf } from './config.mjs';
|
|
21
|
+
|
|
22
|
+
const run = promisify(execFile);
|
|
23
|
+
|
|
24
|
+
// Returns the output, `''` when the tool ran and found nothing (lsof exits 1 on
|
|
25
|
+
// an empty match), and `null` when the tool is not on this machine at all.
|
|
26
|
+
const exec = async (command, args) => {
|
|
27
|
+
try {
|
|
28
|
+
const { stdout } = await run(command, args, { maxBuffer: 8 * 1024 * 1024 });
|
|
29
|
+
return stdout;
|
|
30
|
+
} catch (error) {
|
|
31
|
+
return error.code === 'ENOENT' ? null : (error.stdout ?? '');
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Every port the talk can be sitting on, each with the name to print for it.
|
|
36
|
+
// `preview` builds and serves on the port above the deck's; the editor walks up
|
|
37
|
+
// from its own when something holds it (dev.mjs), so the whole walk is fair game.
|
|
38
|
+
const targets = (config) => {
|
|
39
|
+
const found = new Map();
|
|
40
|
+
const add = (port, label) => {
|
|
41
|
+
if (Number.isInteger(port) && port > 0 && port < 65536 && !found.has(port)) found.set(port, label);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
add(config.port, 'deck');
|
|
45
|
+
add(config.port + 1, 'preview');
|
|
46
|
+
for (const spec of config.servers ?? []) {
|
|
47
|
+
add(portOf(spec.url), spec.name ?? spec.cwd ?? spec.command);
|
|
48
|
+
}
|
|
49
|
+
if (config.editor) {
|
|
50
|
+
for (let i = 0; i < EDITOR_PORT_TRIES; i += 1) add(config.editor.port + i, 'editor');
|
|
51
|
+
}
|
|
52
|
+
return [...found].map(([port, label]) => ({ port, label }));
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const collect = (into, port, pid) => {
|
|
56
|
+
if (!port || !pid) return;
|
|
57
|
+
if (!into.has(port)) into.set(port, new Set());
|
|
58
|
+
into.get(port).add(pid);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// `-F pn` is lsof's machine-readable form: a `p<pid>` line, then an `n<address>`
|
|
62
|
+
// line for each socket that process holds.
|
|
63
|
+
const fromLsof = (text) => {
|
|
64
|
+
const ports = new Map();
|
|
65
|
+
let pid = null;
|
|
66
|
+
for (const line of text.split('\n')) {
|
|
67
|
+
if (line[0] === 'p') pid = Number(line.slice(1));
|
|
68
|
+
else if (line[0] === 'n') collect(ports, Number(line.match(/:(\d+)$/)?.[1]), pid);
|
|
69
|
+
}
|
|
70
|
+
return ports;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// `ss -tlnpH`: LISTEN … 0.0.0.0:4000 … users:(("node",pid=123,fd=20))
|
|
74
|
+
const fromSs = (text) => {
|
|
75
|
+
const ports = new Map();
|
|
76
|
+
for (const line of text.split('\n')) {
|
|
77
|
+
const port = Number(line.trim().split(/\s+/)[3]?.match(/:(\d+)$/)?.[1]);
|
|
78
|
+
for (const [, pid] of line.matchAll(/pid=(\d+)/g)) collect(ports, port, Number(pid));
|
|
79
|
+
}
|
|
80
|
+
return ports;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// `netstat -ano -p tcp`: TCP 0.0.0.0:4000 0.0.0.0:0 LISTENING 1234
|
|
84
|
+
const fromNetstat = (text) => {
|
|
85
|
+
const ports = new Map();
|
|
86
|
+
for (const line of text.split('\n')) {
|
|
87
|
+
if (!/LISTENING/i.test(line)) continue;
|
|
88
|
+
const fields = line.trim().split(/\s+/);
|
|
89
|
+
collect(ports, Number(fields[1]?.match(/:(\d+)$/)?.[1]), Number(fields[4]));
|
|
90
|
+
}
|
|
91
|
+
return ports;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// One call, not one per port: asking the machine what it is listening on is the
|
|
95
|
+
// expensive half, and it answers about everything at once anyway.
|
|
96
|
+
const listening = async () => {
|
|
97
|
+
if (process.platform === 'win32') {
|
|
98
|
+
const netstat = await exec('netstat', ['-ano', '-p', 'tcp']);
|
|
99
|
+
return netstat === null ? null : fromNetstat(netstat);
|
|
100
|
+
}
|
|
101
|
+
const lsof = await exec('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN', '-F', 'pn']);
|
|
102
|
+
if (lsof !== null) return fromLsof(lsof);
|
|
103
|
+
const ss = await exec('ss', ['-tlnpH']);
|
|
104
|
+
return ss === null ? null : fromSs(ss);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const CUT = 56;
|
|
108
|
+
|
|
109
|
+
// What to print next to the pid, so it is obvious this is killing the demo and
|
|
110
|
+
// not something else that happens to want 3000.
|
|
111
|
+
const describe = async (pids) => {
|
|
112
|
+
const names = new Map();
|
|
113
|
+
if (!pids.length) return names;
|
|
114
|
+
|
|
115
|
+
if (process.platform === 'win32') {
|
|
116
|
+
const list = (await exec('tasklist', ['/FO', 'CSV', '/NH'])) ?? '';
|
|
117
|
+
for (const line of list.split('\n')) {
|
|
118
|
+
const [, name, pid] = line.match(/^"([^"]*)","(\d+)"/) ?? [];
|
|
119
|
+
if (pid) names.set(Number(pid), name);
|
|
120
|
+
}
|
|
121
|
+
return names;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const ps = (await exec('ps', ['-o', 'pid=,command=', '-p', pids.join(',')])) ?? '';
|
|
125
|
+
for (const line of ps.split('\n')) {
|
|
126
|
+
const [, pid, command] = line.trim().match(/^(\d+)\s+(.*)$/) ?? [];
|
|
127
|
+
if (pid) names.set(Number(pid), command.length > CUT ? `${command.slice(0, CUT - 1)}…` : command);
|
|
128
|
+
}
|
|
129
|
+
return names;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const alive = (pid) => {
|
|
133
|
+
try {
|
|
134
|
+
process.kill(pid, 0);
|
|
135
|
+
return true;
|
|
136
|
+
} catch (error) {
|
|
137
|
+
// Someone else's process: still there, just not ours to signal.
|
|
138
|
+
return error.code === 'EPERM';
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
143
|
+
|
|
144
|
+
const signal = (pid, name) => {
|
|
145
|
+
try {
|
|
146
|
+
process.kill(pid, name);
|
|
147
|
+
return null;
|
|
148
|
+
} catch (error) {
|
|
149
|
+
return error.code === 'ESRCH' ? null : error.code;
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// A range prints as a range: the editor alone is ten of these.
|
|
154
|
+
const ranges = (ports) => {
|
|
155
|
+
const sorted = [...ports].sort((a, b) => a - b);
|
|
156
|
+
const out = [];
|
|
157
|
+
for (const port of sorted) {
|
|
158
|
+
const last = out[out.length - 1];
|
|
159
|
+
if (last && port === last[1] + 1) last[1] = port;
|
|
160
|
+
else out.push([port, port]);
|
|
161
|
+
}
|
|
162
|
+
return out.map(([from, to]) => (from === to ? `${from}` : `${from}–${to}`)).join(', ');
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
export const kill = async (config) => {
|
|
166
|
+
const wanted = targets(config);
|
|
167
|
+
const ports = await listening();
|
|
168
|
+
|
|
169
|
+
if (ports === null) {
|
|
170
|
+
console.log(
|
|
171
|
+
`\n ✗ cannot look up ports: no ${process.platform === 'win32' ? 'netstat' : 'lsof or ss'} on this machine.\n`,
|
|
172
|
+
);
|
|
173
|
+
process.exitCode = 1;
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const seen = new Set([process.pid]);
|
|
178
|
+
const hits = [];
|
|
179
|
+
for (const { port, label } of wanted) {
|
|
180
|
+
for (const pid of ports.get(port) ?? []) {
|
|
181
|
+
if (seen.has(pid)) continue;
|
|
182
|
+
seen.add(pid);
|
|
183
|
+
hits.push({ port, label, pid });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!hits.length) {
|
|
188
|
+
console.log(`\n nothing to kill — ${ranges(wanted.map((t) => t.port))} are free.\n`);
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const names = await describe(hits.map((hit) => hit.pid));
|
|
193
|
+
const width = Math.max(...hits.map((hit) => hit.label.length));
|
|
194
|
+
console.log('');
|
|
195
|
+
for (const hit of hits) {
|
|
196
|
+
console.log(` ✗ ${String(hit.port).padEnd(5)} ${hit.label.padEnd(width)} ${names.get(hit.pid) ?? ''} (${hit.pid})`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Asked first, insisted on after: a vite or an `ng serve` given the chance to
|
|
200
|
+
// put the terminal back the way it found it usually takes it.
|
|
201
|
+
const refused = [];
|
|
202
|
+
for (const hit of hits) signal(hit.pid, 'SIGTERM');
|
|
203
|
+
await wait(900);
|
|
204
|
+
for (const hit of hits) {
|
|
205
|
+
if (!alive(hit.pid)) continue;
|
|
206
|
+
const error = signal(hit.pid, 'SIGKILL');
|
|
207
|
+
if (error) refused.push({ ...hit, error });
|
|
208
|
+
}
|
|
209
|
+
await wait(200);
|
|
210
|
+
|
|
211
|
+
const left = hits.filter((hit) => alive(hit.pid));
|
|
212
|
+
if (left.length) {
|
|
213
|
+
console.log(`\n ⚠ still up: ${left.map((hit) => hit.pid).join(', ')} — not this user's to kill?`);
|
|
214
|
+
if (refused.length) console.log(` ${refused.map((hit) => `${hit.pid}: ${hit.error}`).join(', ')}`);
|
|
215
|
+
process.exitCode = 1;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const stopped = hits.length - left.length;
|
|
219
|
+
console.log(`\n ${stopped} ${stopped === 1 ? 'process' : 'processes'} stopped.\n`);
|
|
220
|
+
return hits;
|
|
221
|
+
};
|
package/lib/server.mjs
CHANGED
|
@@ -170,7 +170,7 @@ export const listen = (server, port) =>
|
|
|
170
170
|
server.once('error', (error) =>
|
|
171
171
|
reject(
|
|
172
172
|
error.code === 'EADDRINUSE'
|
|
173
|
-
? new Error(`port ${port} is already in use —
|
|
173
|
+
? new Error(`port ${port} is already in use — \`npx fb-slides kill\`, or pass --port <n>`)
|
|
174
174
|
: error,
|
|
175
175
|
),
|
|
176
176
|
);
|