fb-slides 0.1.2

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.
Files changed (36) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +225 -0
  3. package/bin/fb-slides.mjs +111 -0
  4. package/lib/build.mjs +98 -0
  5. package/lib/config.mjs +90 -0
  6. package/lib/create.mjs +59 -0
  7. package/lib/decks.mjs +13 -0
  8. package/lib/dev.mjs +94 -0
  9. package/lib/render.mjs +52 -0
  10. package/lib/server.mjs +168 -0
  11. package/lib/vendor.mjs +32 -0
  12. package/package.json +51 -0
  13. package/runtime/annotate.js +458 -0
  14. package/runtime/deck.js +299 -0
  15. package/runtime/index.html +37 -0
  16. package/runtime/outline.js +354 -0
  17. package/runtime/shortcuts.js +53 -0
  18. package/runtime/spotlight.js +293 -0
  19. package/runtime/theme.base.css +879 -0
  20. package/templates/starter/README.md +27 -0
  21. package/templates/starter/_gitignore +4 -0
  22. package/templates/starter/_package.json +14 -0
  23. package/templates/starter/assets/.gitkeep +0 -0
  24. package/templates/starter/decks/01-intro.md +44 -0
  25. package/templates/starter/decks/02-demos.md +34 -0
  26. package/templates/starter/demo/angular-hello/README.md +15 -0
  27. package/templates/starter/demo/angular-hello/_package.json +24 -0
  28. package/templates/starter/demo/angular-hello/angular.json +34 -0
  29. package/templates/starter/demo/angular-hello/src/index.html +12 -0
  30. package/templates/starter/demo/angular-hello/src/main.ts +18 -0
  31. package/templates/starter/demo/angular-hello/src/styles.css +22 -0
  32. package/templates/starter/demo/angular-hello/tsconfig.app.json +5 -0
  33. package/templates/starter/demo/angular-hello/tsconfig.json +17 -0
  34. package/templates/starter/demo/counter/index.html +34 -0
  35. package/templates/starter/slides.config.js +34 -0
  36. package/templates/starter/theme.css +14 -0
package/lib/dev.mjs ADDED
@@ -0,0 +1,94 @@
1
+ // ---------------------------------------------------------------------------
2
+ // `fb-slides dev` — the deck on a port, plus whatever else the talk needs running.
3
+ //
4
+ // A talk that embeds a framework demo needs that demo's own dev server up too.
5
+ // That used to be one hardcoded Angular app; it is now `servers:` in the config,
6
+ // so any project can declare its own without touching this file.
7
+ // ---------------------------------------------------------------------------
8
+
9
+ import { spawn } from 'node:child_process';
10
+ import { existsSync } from 'node:fs';
11
+ import { join, resolve } from 'node:path';
12
+
13
+ import { assertUsable } from './config.mjs';
14
+ import { createDeckServer, listen } from './server.mjs';
15
+ import { renderIndex } from './render.mjs';
16
+ import { VENDOR_MOUNTS, packageDir } from './vendor.mjs';
17
+
18
+ const OPEN = { darwin: 'open', win32: 'start' };
19
+
20
+ const openBrowser = (url) => {
21
+ const opener = OPEN[process.platform] ?? 'xdg-open';
22
+ spawn(opener, [url], { stdio: 'ignore', detached: true, shell: process.platform === 'win32' }).unref();
23
+ };
24
+
25
+ // A side process: `{ name, cwd, command, args, env }`. Its failure is never the
26
+ // deck's failure — the slide that embeds it comes up empty, everything else works.
27
+ const startSideServer = (spec, root, children) => {
28
+ const cwd = resolve(root, spec.cwd ?? '.');
29
+ const name = spec.name ?? spec.cwd ?? spec.command;
30
+
31
+ if (!existsSync(cwd)) {
32
+ console.warn(` ⚠ ${name}: ${spec.cwd} does not exist — skipping`);
33
+ return;
34
+ }
35
+ if (existsSync(join(cwd, 'package.json')) && !existsSync(join(cwd, 'node_modules'))) {
36
+ console.warn(` ⚠ ${name}: no node_modules — run \`npm install\` in ${spec.cwd}.`);
37
+ console.warn(` Its slides will show an empty frame until you do.`);
38
+ return;
39
+ }
40
+
41
+ console.log(` ↑ ${name}${spec.url ? ` → ${spec.url}` : ''}`);
42
+ const child = spawn(spec.command, spec.args ?? [], {
43
+ cwd,
44
+ stdio: 'inherit',
45
+ shell: !spec.args,
46
+ env: { ...process.env, ...spec.env },
47
+ });
48
+ children.push(child);
49
+ child.on('error', (error) => console.warn(` ⚠ ${name}: ${error.message}`));
50
+ child.on('exit', (code) => {
51
+ if (code) console.warn(`\n ⚠ ${name} stopped (exit ${code}) — its slides will be empty.\n`);
52
+ });
53
+ };
54
+
55
+ export const dev = async (config, runtimeDir) => {
56
+ assertUsable(config);
57
+
58
+ const mounts = [
59
+ // The project first: a file next to the decks shadows the one this package
60
+ // ships, which is how you override deck.js or the theme for one talk.
61
+ { prefix: '/', dir: config.root },
62
+ { prefix: '/', dir: runtimeDir },
63
+ ...Object.entries(VENDOR_MOUNTS).map(([pkg, mount]) => ({
64
+ prefix: `/vendor/${mount}/`,
65
+ dir: packageDir(pkg),
66
+ })),
67
+ ];
68
+
69
+ const server = createDeckServer({
70
+ mounts,
71
+ decksPath: config.decksPath,
72
+ // Rendered per request: editing slides.config.js and reloading is enough.
73
+ index: () => renderIndex(config, runtimeDir),
74
+ });
75
+
76
+ await listen(server, config.port);
77
+
78
+ const url = `http://localhost:${config.port}/`;
79
+ const children = [];
80
+ for (const spec of config.servers) startSideServer(spec, config.root, children);
81
+
82
+ console.log(`\n ${config.title}\n → ${url}\n`);
83
+ if (config.open) setTimeout(() => openBrowser(url), 400);
84
+
85
+ for (const signal of ['SIGINT', 'SIGTERM']) {
86
+ process.on(signal, () => {
87
+ children.forEach((child) => child.kill(signal));
88
+ server.close();
89
+ process.exit(0);
90
+ });
91
+ }
92
+
93
+ return server;
94
+ };
package/lib/render.mjs ADDED
@@ -0,0 +1,52 @@
1
+ // ---------------------------------------------------------------------------
2
+ // index.html is a template, not a file the project owns: the runtime ships it and
3
+ // the config fills in the five things that differ between talks. Everything the
4
+ // page needs to know at runtime goes out in one `window.__FB_SLIDES__` object.
5
+ // ---------------------------------------------------------------------------
6
+
7
+ import { readFile } from 'node:fs/promises';
8
+ import { join } from 'node:path';
9
+
10
+ const escapeHtml = (value) =>
11
+ String(value).replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
12
+
13
+ // `</script>` inside a JSON literal would close the tag it sits in.
14
+ const escapeJson = (value) => JSON.stringify(value).replace(/</g, '\\u003c');
15
+
16
+ const signatureHtml = (signature) => {
17
+ if (!signature) return '';
18
+ const { name, url = '#', logo } = signature;
19
+ const image = logo
20
+ ? `\n <a class="sig-logo" href="${escapeHtml(url)}" target="_blank" rel="noreferrer"><img src="${escapeHtml(logo)}" alt="${escapeHtml(name ?? '')}" /></a>`
21
+ : '';
22
+ const label = name
23
+ ? `\n <a class="sig-name" href="${escapeHtml(url)}" target="_blank" rel="noreferrer">${escapeHtml(name)}</a>`
24
+ : '';
25
+ return `<div id="deck-sig">${image}${label}\n </div>`;
26
+ };
27
+
28
+ const headHtml = (config) => {
29
+ const tags = [];
30
+ if (config.favicon) tags.push(`<link rel="icon" href="${escapeHtml(config.favicon)}" />`);
31
+ // After the base theme on purpose: the project's CSS overrides, it does not replace.
32
+ if (config.theme) tags.push(`<link rel="stylesheet" href="${escapeHtml(config.theme)}" />`);
33
+ return tags.join('\n ');
34
+ };
35
+
36
+ export const renderIndex = async (config, runtimeDir) => {
37
+ const template = await readFile(join(runtimeDir, 'index.html'), 'utf8');
38
+
39
+ const runtime = {
40
+ decks: config.urls.decks,
41
+ demos: config.urls.demos,
42
+ reveal: config.reveal,
43
+ fragmentLists: config.fragmentLists,
44
+ };
45
+
46
+ return template
47
+ .replaceAll('{{lang}}', escapeHtml(config.lang))
48
+ .replaceAll('{{title}}', escapeHtml(config.title))
49
+ .replaceAll('{{head}}', headHtml(config))
50
+ .replaceAll('{{signature}}', signatureHtml(config.signature))
51
+ .replaceAll('{{config}}', escapeJson(runtime));
52
+ };
package/lib/server.mjs ADDED
@@ -0,0 +1,168 @@
1
+ // ---------------------------------------------------------------------------
2
+ // A static server over several roots, in ~150 lines and no dependencies.
3
+ //
4
+ // It exists because the deck is assembled from two places: the project (decks,
5
+ // assets, demos) and this package (deck.js, the theme, reveal and mermaid). The
6
+ // project comes first, so any runtime file can be shadowed by putting a file of
7
+ // the same name next to the decks — the escape hatch for one-off tweaks.
8
+ //
9
+ // It also answers /decks.json itself, from the folder as it is right now: the
10
+ // deck never has to parse a directory listing to find out what to show.
11
+ // ---------------------------------------------------------------------------
12
+
13
+ import { createServer } from 'node:http';
14
+ import { createReadStream } from 'node:fs';
15
+ import { readdir, stat } from 'node:fs/promises';
16
+ import { extname, join, relative, resolve, sep } from 'node:path';
17
+
18
+ import { listDecks } from './decks.mjs';
19
+
20
+ const MIME = {
21
+ '.html': 'text/html; charset=utf-8',
22
+ '.js': 'text/javascript; charset=utf-8',
23
+ '.mjs': 'text/javascript; charset=utf-8',
24
+ '.css': 'text/css; charset=utf-8',
25
+ '.json': 'application/json; charset=utf-8',
26
+ '.md': 'text/markdown; charset=utf-8',
27
+ '.svg': 'image/svg+xml',
28
+ '.png': 'image/png',
29
+ '.jpg': 'image/jpeg',
30
+ '.jpeg': 'image/jpeg',
31
+ '.gif': 'image/gif',
32
+ '.webp': 'image/webp',
33
+ '.avif': 'image/avif',
34
+ '.ico': 'image/x-icon',
35
+ '.woff': 'font/woff',
36
+ '.woff2': 'font/woff2',
37
+ '.ttf': 'font/ttf',
38
+ '.mp4': 'video/mp4',
39
+ '.webm': 'video/webm',
40
+ '.mp3': 'audio/mpeg',
41
+ '.wasm': 'application/wasm',
42
+ '.map': 'application/json; charset=utf-8',
43
+ '.txt': 'text/plain; charset=utf-8',
44
+ };
45
+
46
+ const mime = (path) => MIME[extname(path).toLowerCase()] ?? 'application/octet-stream';
47
+
48
+ // Nothing is cached: editing a .md and hitting reload is the whole authoring loop.
49
+ const NO_STORE = { 'Cache-Control': 'no-store' };
50
+
51
+ const send = (res, status, body, headers = {}) => {
52
+ res.writeHead(status, { ...NO_STORE, ...headers });
53
+ res.end(body);
54
+ };
55
+
56
+ const statOrNull = (path) => stat(path).then((info) => info, () => null);
57
+
58
+ // The project root is a mount, so everything beside the decks is reachable —
59
+ // including things that have no business on the network. Dotfiles (`.env`, `.git`)
60
+ // and dependency folders are off limits whatever the mount.
61
+ const DENIED = /(^|\/)(\.[^/]+|node_modules)(\/|$)/;
62
+
63
+ // Resolve inside `dir` only: a request for ../../etc/passwd resolves out of it.
64
+ const within = (dir, requestPath) => {
65
+ const target = resolve(dir, `.${requestPath}`);
66
+ const inside = target === dir || target.startsWith(dir + sep);
67
+ return inside ? target : null;
68
+ };
69
+
70
+ const directoryListing = async (dir, urlPath) => {
71
+ const entries = (await readdir(dir, { withFileTypes: true }))
72
+ .filter((entry) => !entry.name.startsWith('.'))
73
+ .sort((a, b) => a.name.localeCompare(b.name, 'en', { numeric: true }));
74
+ const links = entries
75
+ .map((entry) => {
76
+ const name = entry.name + (entry.isDirectory() ? '/' : '');
77
+ return `<li><a href="${encodeURIComponent(entry.name)}${entry.isDirectory() ? '/' : ''}">${name}</a></li>`;
78
+ })
79
+ .join('\n');
80
+ return `<!doctype html><meta charset="utf-8"><title>${urlPath}</title>
81
+ <h1>${urlPath}</h1><ul>${links}</ul>`;
82
+ };
83
+
84
+ // `<video>` in Safari will not play a file the server answers whole.
85
+ const sendFile = (req, res, path, size) => {
86
+ const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range ?? '');
87
+ const headers = { 'Content-Type': mime(path), ...NO_STORE, 'Accept-Ranges': 'bytes' };
88
+
89
+ if (range) {
90
+ const start = range[1] ? Number(range[1]) : Math.max(0, size - Number(range[2]));
91
+ const end = range[1] && range[2] ? Math.min(Number(range[2]), size - 1) : size - 1;
92
+ if (start > end || start >= size) return send(res, 416, '', { 'Content-Range': `bytes */${size}` });
93
+ res.writeHead(206, { ...headers, 'Content-Range': `bytes ${start}-${end}/${size}`, 'Content-Length': end - start + 1 });
94
+ return createReadStream(path, { start, end }).pipe(res);
95
+ }
96
+
97
+ res.writeHead(200, { ...headers, 'Content-Length': size });
98
+ createReadStream(path).pipe(res);
99
+ };
100
+
101
+ // mounts: [{ prefix: '/', dir }] — checked in order, first hit wins.
102
+ export const createDeckServer = ({ mounts, index, decksPath }) => {
103
+ const handler = async (req, res) => {
104
+ if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, 'method not allowed');
105
+
106
+ let pathname;
107
+ try {
108
+ pathname = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
109
+ } catch {
110
+ return send(res, 400, 'bad request');
111
+ }
112
+
113
+ if (pathname === '/' || pathname === '/index.html') {
114
+ return send(res, 200, await index(), { 'Content-Type': MIME['.html'] });
115
+ }
116
+
117
+ // The deck's own index of the folder, always current.
118
+ if (pathname === '/decks.json') {
119
+ const body = JSON.stringify(await listDecks(decksPath).catch(() => []), null, 2);
120
+ return send(res, 200, body, { 'Content-Type': MIME['.json'] });
121
+ }
122
+
123
+ if (DENIED.test(pathname)) return send(res, 403, 'forbidden', { 'Content-Type': MIME['.txt'] });
124
+
125
+ for (const { prefix, dir } of mounts) {
126
+ if (!pathname.startsWith(prefix)) continue;
127
+ const target = within(dir, `/${pathname.slice(prefix.length)}`);
128
+ if (!target) continue;
129
+
130
+ const info = await statOrNull(target);
131
+ if (!info) continue;
132
+
133
+ if (info.isDirectory()) {
134
+ // A folder is its index.html when it has one — the demos rely on this.
135
+ const indexFile = join(target, 'index.html');
136
+ const indexInfo = await statOrNull(indexFile);
137
+ if (indexInfo?.isFile()) return sendFile(req, res, indexFile, indexInfo.size);
138
+ if (!pathname.endsWith('/')) return send(res, 302, '', { Location: `${pathname}/` });
139
+ return send(res, 200, await directoryListing(target, pathname), { 'Content-Type': MIME['.html'] });
140
+ }
141
+
142
+ return sendFile(req, res, target, info.size);
143
+ }
144
+
145
+ send(res, 404, `not found: ${pathname}`, { 'Content-Type': MIME['.txt'] });
146
+ };
147
+
148
+ return createServer((req, res) => {
149
+ handler(req, res).catch((error) => {
150
+ console.error(`[fb-slides] ${req.url} → ${error.message}`);
151
+ if (!res.headersSent) send(res, 500, 'server error');
152
+ });
153
+ });
154
+ };
155
+
156
+ // Fail on a busy port rather than move to another one and leave the browser
157
+ // pointed at a server that is not this one.
158
+ export const listen = (server, port) =>
159
+ new Promise((resolve, reject) => {
160
+ server.once('error', (error) =>
161
+ reject(
162
+ error.code === 'EADDRINUSE'
163
+ ? new Error(`port ${port} is already in use — free it, or pass --port <n>`)
164
+ : error,
165
+ ),
166
+ );
167
+ server.listen(port, () => resolve(server));
168
+ });
package/lib/vendor.mjs ADDED
@@ -0,0 +1,32 @@
1
+ // ---------------------------------------------------------------------------
2
+ // reveal.js and mermaid come from node_modules, not from a CDN: a talk has to
3
+ // survive the conference wifi. They are dependencies of this package, so every
4
+ // project that installs it gets the exact versions this runtime was built for.
5
+ // ---------------------------------------------------------------------------
6
+
7
+ import { createRequire } from 'node:module';
8
+ import { dirname, join } from 'node:path';
9
+
10
+ const require = createRequire(import.meta.url);
11
+
12
+ export const packageDir = (name) => dirname(require.resolve(`${name}/package.json`));
13
+
14
+ export const packageVersion = (name) => require(`${name}/package.json`).version;
15
+
16
+ // In dev the whole package folder is mounted, so anything resolves. This is the
17
+ // list the build copies — the UMD entry points and nothing else, which keeps
18
+ // dist/ around 4 MB instead of 30.
19
+ export const VENDOR_FILES = [
20
+ ['reveal.js', 'dist/reveal.js'],
21
+ ['reveal.js', 'dist/reveal.css'],
22
+ ['reveal.js', 'dist/reset.css'],
23
+ ['reveal.js', 'plugin/markdown/markdown.js'],
24
+ ['reveal.js', 'plugin/highlight/highlight.js'],
25
+ ['reveal.js', 'plugin/highlight/monokai.css'],
26
+ ['reveal.js', 'plugin/notes/notes.js'],
27
+ ['reveal.js', 'plugin/notes/speaker-view.html'],
28
+ ['mermaid', 'dist/mermaid.min.js'],
29
+ ];
30
+
31
+ // Where each package is mounted for the browser: `vendor/reveal/dist/reveal.js`.
32
+ export const VENDOR_MOUNTS = { 'reveal.js': 'reveal', mermaid: 'mermaid' };
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "fb-slides",
3
+ "version": "0.1.2",
4
+ "type": "module",
5
+ "description": "Markdown-driven reveal.js decks: live demo embeds, annotation, mermaid, and a zero-config dev server",
6
+ "keywords": [
7
+ "slides",
8
+ "reveal.js",
9
+ "markdown",
10
+ "presentation",
11
+ "deck"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "Fabio Biondi <info@fabiobiondi.com> (https://www.fabiobiondi.dev)",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/fabiobiondi/fb-slides.git"
18
+ },
19
+ "homepage": "https://github.com/fabiobiondi/fb-slides#readme",
20
+ "bugs": "https://github.com/fabiobiondi/fb-slides/issues",
21
+ "bin": {
22
+ "fb-slides": "bin/fb-slides.mjs"
23
+ },
24
+ "exports": {
25
+ "./package.json": "./package.json"
26
+ },
27
+ "files": [
28
+ "bin",
29
+ "lib",
30
+ "runtime",
31
+ "templates",
32
+ "README.md",
33
+ "LICENSE"
34
+ ],
35
+ "engines": {
36
+ "node": ">=20.11"
37
+ },
38
+ "scripts": {
39
+ "starter": "cd templates/starter && node ../../bin/fb-slides.mjs dev --no-servers",
40
+ "starter:build": "cd templates/starter && node ../../bin/fb-slides.mjs build --out ../../.sandbox/starter-dist",
41
+ "starter:preview": "cd templates/starter && node ../../bin/fb-slides.mjs preview --out ../../.sandbox/starter-dist",
42
+ "sandbox": "rm -rf .sandbox/talk && node bin/fb-slides.mjs create .sandbox/talk && cd .sandbox/talk && node ../../bin/fb-slides.mjs dev --no-servers --port 4010"
43
+ },
44
+ "dependencies": {
45
+ "mermaid": "^10.9.1",
46
+ "reveal.js": "^5.1.0"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ }
51
+ }