fb-slides 0.6.3 → 0.7.0-rc.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,22 @@ 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
+ ## Unreleased
11
+
12
+ - **A `source ↗` button on demo slides.** `editor: true` in `slides.config.js`
13
+ starts `code serve-web` — the web server built into VS Code — alongside the
14
+ deck, and every demo slide gets a link that opens its folder in a real editor
15
+ in a new tab: file tree, search, terminal, extensions. Nothing is installed and
16
+ nothing is embedded in the slide. The folder is worked out from the marker for
17
+ a folder demo, and for one embedded by URL from the `servers:` entry serving
18
+ that address — which is the first thing `url:` has ever been used for.
19
+ `<!-- demo: … | src: demo/cart -->` says it outright when nothing else knows.
20
+ The editor's port — the deck's plus 100 — is checked before it starts, because
21
+ `serve-web` has no `--strictPort` and two decks are enough to collide. A built
22
+ deck never carries the button: the link is a localhost address and a path on
23
+ your disk. `--no-editor` turns it off for one run.
24
+ [docs/source-code.md](docs/source-code.md)
25
+
10
26
  ## 0.6.3 — 2026-09-05
11
27
 
12
28
  - **Disabled slides.** `<!-- disabled -->` on a line of its own keeps a slide in
package/README.md CHANGED
@@ -101,6 +101,14 @@ A demo can also be a whole application with its own dev server — Angular, or a
101
101
  Vite — copied into `demo/` as it is. It is embedded by **URL**, not by folder name:
102
102
  [docs/framework-demos.md](docs/framework-demos.md).
103
103
 
104
+ ### The source button
105
+
106
+ `editor: true` puts a `source ↗` link on every demo slide, opening that demo's folder in
107
+ **real VS Code** in a new tab — `code serve-web`, the web server built into the editor you
108
+ already have, started alongside the deck. Nothing is installed and nothing is embedded, and
109
+ 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. [docs/source-code.md](docs/source-code.md).
111
+
104
112
  ### Stepped code highlighting
105
113
 
106
114
  The line ranges live in the fence's info string — reveal reveals one group per click:
@@ -286,6 +294,8 @@ export default {
286
294
  { name: 'angular demo', cwd: 'demo/app', command: 'npm', args: ['start', '--', '--port', '4200'] },
287
295
  ],
288
296
 
297
+ editor: true, // a `source ↗` button on every demo slide
298
+
289
299
  port: 4000,
290
300
  open: true,
291
301
  outDir: 'dist',
@@ -304,6 +314,10 @@ Copying a real Angular or Vite app into `demo/` and wiring it up — including t
304
314
  Vite needs so a busy port fails instead of moving:
305
315
  [docs/framework-demos.md](docs/framework-demos.md).
306
316
 
317
+ `editor: true` is a side process of the same kind, and fails the same way — a missing
318
+ `code` in PATH, a port already taken, and you get a warning and demo slides without their
319
+ `source ↗` button: [docs/source-code.md](docs/source-code.md).
320
+
307
321
  ## Theming
308
322
 
309
323
  `theme.css` in the project is loaded **after** the package's base theme, so it overrides
package/bin/fb-slides.mjs CHANGED
@@ -34,7 +34,8 @@ const HELP = `
34
34
  ${self.name} v${self.version}
35
35
 
36
36
  fb-slides create <dir> scaffold a new deck
37
- fb-slides dev serve the deck and open it [--port n] [--no-open] [--no-servers]
37
+ fb-slides dev serve the deck and open it [--port n] [--no-open]
38
+ [--no-servers] [--no-editor]
38
39
  fb-slides build build dist/ [--out dir] [--watch]
39
40
  fb-slides preview build, then serve the result [--port n]
40
41
 
@@ -46,6 +47,7 @@ const overrides = () => ({
46
47
  outDir: option('out'),
47
48
  open: flag('no-open') ? false : undefined,
48
49
  servers: flag('no-servers') ? false : undefined,
50
+ editor: flag('no-editor') ? false : undefined,
49
51
  });
50
52
 
51
53
  const run = async () => {
@@ -0,0 +1,133 @@
1
+ # The source button
2
+
3
+ A demo slide shows the app running. `source ↗`, next to `open in a tab ↗`, shows
4
+ what it is made of: the demo's folder, open in **VS Code** in a new tab.
5
+
6
+ Not an editor written for this package, and not a viewer that resembles one. It
7
+ is `code serve-web` — the web server built into the VS Code on the machine
8
+ running the talk — pointed at the demo's folder. The file tree, the search, the
9
+ terminal, the extensions, the keybindings: the editor, in a tab.
10
+
11
+ ## Turning it on
12
+
13
+ ```js
14
+ // slides.config.js
15
+ export default {
16
+ editor: true,
17
+ };
18
+ ```
19
+
20
+ That is the whole configuration. `dev` starts it alongside the deck, on the
21
+ deck's port plus 100, and every demo slide grows the link.
22
+
23
+ ```
24
+ $ npm run dev
25
+
26
+ ↑ code serve-web → http://localhost:4100/
27
+
28
+ My Talk
29
+ → http://localhost:4000/
30
+ ```
31
+
32
+ Both defaults move if they have to:
33
+
34
+ ```js
35
+ editor: { port: 5000, command: 'code-insiders' },
36
+ ```
37
+
38
+ `command` is a name in PATH. `code-insiders` and `cursor` answer `serve-web`
39
+ too; whatever you name has to be a VS Code CLI, because that subcommand is what
40
+ this uses.
41
+
42
+ ## Run it once before you need it
43
+
44
+ **The first run downloads about 100 MB** — the server half of VS Code, into
45
+ `~/.vscode/cli`. It happens once per machine and it is cached from then on, but
46
+ a minute of silent downloading is not something to meet in front of a room.
47
+
48
+ Start `dev` once at your desk after turning the key on. That is the whole
49
+ precaution, and it is why `editor:` is off in a fresh deck rather than on.
50
+
51
+ If `code` is not in PATH at all, the deck says so and carries on without the
52
+ button:
53
+
54
+ ```
55
+ ⚠ code is not in PATH — the demo slides get no source button.
56
+ In VS Code: Shell Command: Install 'code' command in PATH.
57
+ ```
58
+
59
+ ## When the port is taken
60
+
61
+ The default is the deck's port plus 100, and **two decks are enough to collide**:
62
+ one on 4000 puts its editor on 4100, which is a perfectly ordinary port for the
63
+ second deck to be on.
64
+
65
+ `code serve-web` does not fail on a busy port the way the deck server does — a
66
+ process holding the IPv6 side leaves the IPv4 side free, both bind, and
67
+ `localhost` in the browser then lands on whichever the resolver picked. That is
68
+ the failure you find out about on stage. So the port is checked before the
69
+ editor is started, and a taken one is a message rather than a wrong tab:
70
+
71
+ ```
72
+ ⚠ port 4100 is in use — no source button.
73
+ Free it, or give the editor another one: `editor: { port: n }`.
74
+ ```
75
+
76
+ ## Where a slide's code is found
77
+
78
+ The button has to be told which folder a demo comes from. Three of the four
79
+ answers need nothing from you:
80
+
81
+ | the slide says | the folder |
82
+ | --- | --- |
83
+ | `<!-- demo: counter -->` | `demo/counter` — a name is a folder in the demos dir |
84
+ | `<!-- demo: ../shared/cart/ -->` | that path, as written |
85
+ | `<!-- demo: http://localhost:4200/ -->` | the `cwd` of whichever `servers:` entry has that `url` |
86
+ | `<!-- demo: https://example.com/ \| src: demo/cart -->` | `demo/cart` |
87
+
88
+ The third row is the one worth knowing about. A framework demo is embedded by
89
+ **URL** — the slide has no idea there is a folder anywhere. `servers:` does:
90
+
91
+ ```js
92
+ {
93
+ name: 'checkout',
94
+ cwd: 'demo/checkout', // ← the sources
95
+ command: 'npm',
96
+ args: ['start', '--', '--port', '4200'],
97
+ url: 'http://localhost:4200/', // ← what the slide points at
98
+ }
99
+ ```
100
+
101
+ That pairing used to be a log line and nothing else. It is now also how the
102
+ button finds the code behind a running app.
103
+
104
+ The fourth row, `| src:`, is the escape hatch for what is left: a demo deployed
105
+ somewhere, or embedded from a server the config does not declare. It wins over
106
+ everything else when it is there.
107
+
108
+ A demo whose folder is unknown simply has no button. Nothing breaks, and the
109
+ rest of the slide is unchanged.
110
+
111
+ ## What it does not do
112
+
113
+ - **It is not in the built deck.** `build` never writes it out: the link is a
114
+ `localhost` address and an absolute path on your disk, and neither means
115
+ anything to someone reading the deck on the web. This is a presenting tool,
116
+ like the demos that need `servers:` running.
117
+ - **It does not open in the slide.** A new tab, deliberately — an editor is
118
+ heavy, and a deck that never mounts one has nothing to tear down when the
119
+ slide moves on.
120
+ - **`--no-editor` turns it off** for one run. It is deliberately not part of
121
+ `--no-servers`: that flag is for skipping a demo's own dev server, and a demo
122
+ that is not running is exactly when you want to open its code. The URL → folder
123
+ table above is read from `servers:` as written in the config, so it still
124
+ works when none of those servers were started.
125
+
126
+ ## It is your real filesystem
127
+
128
+ The editor opens the actual folder, with write access, as an editor does. Saving
129
+ a file in that tab changes the file on disk.
130
+
131
+ That is the feature, if you mean to edit live: change a component in the tab,
132
+ switch back to the slide, watch the demo's own dev server pick it up. It is
133
+ worth knowing about all the same, because the same is true of a stray keystroke.
package/lib/config.mjs CHANGED
@@ -21,6 +21,19 @@ const AUTO_STATIC = ['assets', 'demo', 'public', 'images', 'img'];
21
21
  // Never served, never published — heavy, private, or generated.
22
22
  export const ALWAYS_EXCLUDED = ['node_modules', '.git', '.angular', '.next', '.cache', '.DS_Store'];
23
23
 
24
+ // `editor: true` turns the source button on with the defaults; `{ port, command }`
25
+ // changes them; leaving the key out is no button at all. The port follows the
26
+ // deck's so two talks open at once do not fight over it, and the command is a
27
+ // name in PATH — `code-insiders` and `cursor` answer `serve-web` too.
28
+ const editorSpec = (value, deckPort) => {
29
+ if (!value) return null;
30
+ const user = value === true ? {} : value;
31
+ return {
32
+ command: user.command ?? 'code',
33
+ port: Number(user.port ?? deckPort + 100),
34
+ };
35
+ };
36
+
24
37
  export const findConfigFile = (root) => CONFIG_FILES.map((name) => join(root, name)).find(existsSync) ?? null;
25
38
 
26
39
  const importConfig = async (file) => {
@@ -93,6 +106,19 @@ export const loadConfig = async (root = process.cwd(), overrides = {}) => {
93
106
  // silently wears the wrong clothes.
94
107
  if (config.revealTheme) await assertRevealTheme(config.revealTheme);
95
108
 
109
+ // `--no-editor` is its own flag rather than a part of `--no-servers`: that one
110
+ // is for skipping a demo's dev server, and a demo that is not running is
111
+ // exactly when you want to be able to open its code.
112
+ config.editor = overrides.editor === false ? null : editorSpec(user.editor, config.port);
113
+ // The folder behind each demo embedded by URL, taken from `servers:` as the
114
+ // project wrote it rather than from the list `dev` will start: `--no-servers`
115
+ // empties that one, and a demo that is not running is exactly when its code is
116
+ // worth opening. `url:` had never been used for anything until this.
117
+ config.demoSources = Object.fromEntries(
118
+ (user.servers ?? [])
119
+ .filter((spec) => spec.url && spec.cwd)
120
+ .map((spec) => [String(spec.url).replace(/\/$/, ''), spec.cwd]),
121
+ );
96
122
  config.decksPath = resolve(root, config.decksDir);
97
123
  config.outPath = resolve(root, config.outDir);
98
124
  // What the browser asks for, as opposed to where it is on disk.
package/lib/dev.mjs CHANGED
@@ -7,6 +7,7 @@
7
7
  // ---------------------------------------------------------------------------
8
8
 
9
9
  import { spawn } from 'node:child_process';
10
+ import { connect } from 'node:net';
10
11
  import { existsSync } from 'node:fs';
11
12
  import { join, resolve } from 'node:path';
12
13
 
@@ -54,6 +55,84 @@ const startSideServer = (spec, root, children) => {
54
55
  });
55
56
  };
56
57
 
58
+ // ---------------------------------------------------------------------------
59
+ // The source-code editor. `code serve-web` is VS Code's own web server, shipped
60
+ // inside the editor the presenter already has: no dependency is added here, and
61
+ // what opens in the tab is the real thing rather than a viewer pretending to be
62
+ // one. It is a side process like any other — it dies with the deck, and its
63
+ // failure is never the deck's failure, only a slide without its button.
64
+ // ---------------------------------------------------------------------------
65
+
66
+ // `serve-web` has no --strictPort, and it does not fail on a taken port the way
67
+ // the deck server does: another process holding the IPv6 side leaves the IPv4
68
+ // side free, both bind, and `localhost` in the browser then lands on whichever
69
+ // the resolver picked. Two fb-slides decks are enough to arrange that — one on
70
+ // 4000 puts its editor on 4100, which is a perfectly ordinary port for the
71
+ // second deck. So ask first, and say which port to use instead.
72
+ const portInUse = (port) =>
73
+ Promise.all(
74
+ ['127.0.0.1', '::1'].map(
75
+ (host) =>
76
+ new Promise((done) => {
77
+ const probe = connect({ host, port });
78
+ const settle = (taken) => {
79
+ probe.destroy();
80
+ done(taken);
81
+ };
82
+ probe.setTimeout(400, () => settle(false));
83
+ probe.on('connect', () => settle(true));
84
+ probe.on('error', () => settle(false));
85
+ }),
86
+ ),
87
+ ).then((answers) => answers.some(Boolean));
88
+
89
+ const hasCommand = (command) =>
90
+ new Promise((done) => {
91
+ const probe = spawn(command, ['--version'], { stdio: 'ignore' });
92
+ probe.on('error', () => done(false));
93
+ probe.on('exit', (code) => done(code === 0));
94
+ });
95
+
96
+ const startEditor = async (spec, children) => {
97
+ if (!(await hasCommand(spec.command))) {
98
+ console.warn(` ⚠ ${spec.command} is not in PATH — the demo slides get no source button.`);
99
+ console.warn(` In VS Code: Shell Command: Install 'code' command in PATH.`);
100
+ return null;
101
+ }
102
+
103
+ if (await portInUse(spec.port)) {
104
+ console.warn(` ⚠ port ${spec.port} is in use — no source button.`);
105
+ console.warn(` Free it, or give the editor another one: \`editor: { port: n }\`.`);
106
+ return null;
107
+ }
108
+
109
+ const url = `http://localhost:${spec.port}/`;
110
+ // First run downloads the server half of VS Code into ~/.vscode/cli, which is
111
+ // a hundred megabytes and a minute — hence `stdio: 'inherit'`, so it happens
112
+ // in front of whoever started the deck rather than silently before a talk.
113
+ console.log(` ↑ ${spec.command} serve-web → ${url}`);
114
+ const child = spawn(
115
+ spec.command,
116
+ [
117
+ 'serve-web',
118
+ '--port',
119
+ String(spec.port),
120
+ // No token in the URL: the slide has to be able to link straight to a
121
+ // folder, and this listens on localhost only.
122
+ '--without-connection-token',
123
+ '--accept-server-license-terms',
124
+ '--disable-telemetry',
125
+ ],
126
+ { stdio: 'inherit' },
127
+ );
128
+ children.push(child);
129
+ child.on('error', (error) => console.warn(` ⚠ ${spec.command}: ${error.message}`));
130
+ child.on('exit', (code) => {
131
+ if (code) console.warn(`\n ⚠ ${spec.command} serve-web stopped (exit ${code}) — no source button.\n`);
132
+ });
133
+ return url;
134
+ };
135
+
57
136
  // What the page is built from, read again on every request. A config with a
58
137
  // syntax error in it — the state it is in halfway through an edit — leaves the
59
138
  // last good one standing rather than serving a broken deck.
@@ -73,6 +152,7 @@ const reader = (config, reload) => {
73
152
  export const dev = async (config, runtimeDir, reload) => {
74
153
  assertUsable(config);
75
154
  const current = reader(config, reload);
155
+ let editorUrl = null;
76
156
 
77
157
  const mounts = [
78
158
  // The project first: a file next to the decks shadows the one this package
@@ -92,7 +172,13 @@ export const dev = async (config, runtimeDir, reload) => {
92
172
  // the theme or the signature and reloading is enough. What was fixed when
93
173
  // the server came up — the mounts, the port, the side processes — still
94
174
  // needs a restart.
95
- index: async () => renderIndex(await current(), runtimeDir),
175
+ index: async () => {
176
+ const now = await current();
177
+ // Read at request time, not at wiring time: the editor is started below,
178
+ // after the port is bound, and the first page load comes later still.
179
+ const editor = editorUrl ? { url: editorUrl, root: config.root, sources: now.demoSources } : null;
180
+ return renderIndex(now, runtimeDir, editor ? { editor } : {});
181
+ },
96
182
  // Editing a slide in the browser saves it back into its .md — dev only,
97
183
  // which is what makes the edit button appear at all (the runtime probes
98
184
  // /api/editable). Build and preview never mount this.
@@ -111,6 +197,7 @@ export const dev = async (config, runtimeDir, reload) => {
111
197
  const url = `http://localhost:${config.port}/`;
112
198
  const children = [];
113
199
  for (const spec of config.servers) startSideServer(spec, config.root, children);
200
+ if (config.editor) editorUrl = await startEditor(config.editor, children);
114
201
 
115
202
  console.log(`\n ${config.title}\n → ${url}\n`);
116
203
  if (config.open) setTimeout(() => openBrowser(url), 400);
package/lib/render.mjs CHANGED
@@ -38,7 +38,11 @@ const headHtml = (config) => {
38
38
  return tags.join('\n ');
39
39
  };
40
40
 
41
- export const renderIndex = async (config, runtimeDir) => {
41
+ // `extra` is what only one command knows. `dev` puts the source-code editor
42
+ // there — a localhost URL and absolute paths, both of which are true for the
43
+ // machine running the talk and nowhere else, so `build` never passes it and a
44
+ // published deck has no button to press.
45
+ export const renderIndex = async (config, runtimeDir, extra = {}) => {
42
46
  const template = await readFile(join(runtimeDir, 'index.html'), 'utf8');
43
47
 
44
48
  const runtime = {
@@ -47,6 +51,7 @@ export const renderIndex = async (config, runtimeDir) => {
47
51
  reveal: config.reveal,
48
52
  fragmentLists: config.fragmentLists,
49
53
  snippets: config.snippets,
54
+ ...extra,
50
55
  };
51
56
 
52
57
  // The chrome reads this to take its colours from the reveal theme instead of
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fb-slides",
3
- "version": "0.6.3",
3
+ "version": "0.7.0-rc.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": [
package/runtime/deck.js CHANGED
@@ -58,9 +58,36 @@ if (!DECKS.length) console.error(`[deck] no .md found in ${DECKS_DIR}`);
58
58
  // running in an iframe. The marker lives in the Markdown, so the position of a
59
59
  // demo is decided there — like any other slide. The value is a folder inside the
60
60
  // project's demos dir, a ./ or ../ path, or a full http(s) URL for anything external.
61
- const DEMO_MARKER = /^<!--\s*demo:\s*(\S+?)\s*-->/;
61
+ //
62
+ // `| src: demo/cart` after it names the folder that code comes from, for the
63
+ // source button. It is only ever needed for a URL whose server the config does
64
+ // not declare — every other form already says where its own sources are.
65
+ const DEMO_MARKER = /^<!--\s*demo:\s*(\S+?)(?:\s*\|\s*src:\s*(\S+?))?\s*-->/;
62
66
  const DEMOS_DIR = CFG.demos ?? 'demo/';
63
67
 
68
+ // `dev` fills this in when `editor:` is on: the address VS Code's own web server
69
+ // is answering at, the project's absolute path, and the folder behind each demo
70
+ // URL. A built deck never carries it — both halves are true of one machine only —
71
+ // so the button exists while presenting and nowhere else.
72
+ const EDITOR = CFG.editor ?? null;
73
+
74
+ // Where a demo's code sits, relative to the project root. A bare name is a
75
+ // folder in the demos dir, a ./ or ../ path is itself, and a URL is whichever
76
+ // `servers:` entry serves it — unless the slide said outright with `| src:`.
77
+ const sourceOf = (target, src, isUrl) => {
78
+ if (src) return src;
79
+ if (isUrl) return EDITOR?.sources?.[target.replace(/\/$/, '')] ?? null;
80
+ return /^[./]/.test(target) ? target : `${DEMOS_DIR}${target}`;
81
+ };
82
+
83
+ // VS Code for the Web opens whatever `?folder=` names, and it wants a path on
84
+ // disk — which the browser has no way of knowing, so the server sent the root.
85
+ const editorHref = (relative) => {
86
+ if (!EDITOR?.url || !relative) return null;
87
+ const folder = `${EDITOR.root}/${relative.replace(/^\.\//, '').replace(/\/+$/, '')}`;
88
+ return `${EDITOR.url}?folder=${encodeURIComponent(folder)}`;
89
+ };
90
+
64
91
  // A slide whose source carries `<!-- disabled -->` stays in the file and in the
65
92
  // navigator, but steps out of the talk: the arrows walk past it and it is not
66
93
  // counted. The line is stripped before rendering — what is left is an ordinary
@@ -107,7 +134,7 @@ const markdownSection = (source, deck) => {
107
134
  };
108
135
 
109
136
  // The real page, running, inside the slide.
110
- const demoSection = (target, deck) => {
137
+ const demoSection = (target, src, deck) => {
111
138
  const isUrl = /^https?:\/\//.test(target);
112
139
  const name = isUrl ? target : target.replace(/\/$/, '');
113
140
  // Bare names are folders in the demos dir; a ./ or ../ path and a full URL are
@@ -115,6 +142,9 @@ const demoSection = (target, deck) => {
115
142
  const path = isUrl || /^[./]/.test(name) ? name : `${DEMOS_DIR}${name}/`;
116
143
  // A whole URL in the header would drown the slide: show just the host.
117
144
  const label = isUrl ? new URL(name).host : name;
145
+ // A new tab on purpose: the editor is heavy, and a deck that never mounts it
146
+ // has nothing of it to tear down when the slide moves on.
147
+ const source = editorHref(sourceOf(name, src, isUrl));
118
148
  const section = document.createElement('section');
119
149
  section.className = 'demo-slide';
120
150
  section.dataset.deck = deck.label;
@@ -122,6 +152,7 @@ const demoSection = (target, deck) => {
122
152
  <header>
123
153
  <span class="tag">live</span>
124
154
  <code>${label}</code>
155
+ ${source ? `<a href="${source}" target="_blank" rel="noreferrer">source ↗</a>` : ''}
125
156
  <a href="${path}" target="_blank" rel="noreferrer">open in a tab ↗</a>
126
157
  </header>
127
158
  <iframe data-src="${path}" title="${name}" loading="lazy"></iframe>
@@ -146,7 +177,7 @@ for (const file of DECKS) {
146
177
  const disabled = DISABLED_MARKER.test(raw);
147
178
  const source = disabled ? raw.replace(DISABLED_MARKER, '') : raw;
148
179
  const marker = source.match(DEMO_MARKER);
149
- const section = marker ? demoSection(marker[1], deck) : markdownSection(source, deck);
180
+ const section = marker ? demoSection(marker[1], marker[2], deck) : markdownSection(source, deck);
150
181
  if (disabled) {
151
182
  section.classList.add(OFF);
152
183
  // Reveal's own answer to "in the deck, out of the count": it leaves an
@@ -335,6 +335,8 @@ html[data-reveal-theme] .reveal strong { color: var(--r-heading-color, var(--tex
335
335
  white-space: nowrap;
336
336
  }
337
337
  .reveal .slides section.demo-slide header a { margin-left: auto; font-size: 0.9em; }
338
+ /* The first link is the one that pushes right; the rest ride along on the gap. */
339
+ .reveal .slides section.demo-slide header a ~ a { margin-left: 0; }
338
340
  .reveal .slides section.demo-slide .tag {
339
341
  font: 600 0.8em/1 var(--sans);
340
342
  letter-spacing: 0.08em;
@@ -78,5 +78,6 @@ Note what never happens: the model never touches the DOM, the network, or your s
78
78
  - what follows it is the fallback for GitHub and Marp
79
79
  - `demo/angular-hello` is started by `servers:` in `slides.config.js`
80
80
  - the `+` in the edit drawer writes all three, and `/` opens the same list
81
+ - `editor: true` in the config adds `source ↗`: the demo's folder in real VS Code
81
82
 
82
83
  Note: the iframe is reloaded every time you arrive on the slide, so a demo is always in its initial state.
@@ -38,6 +38,17 @@ export default {
38
38
  },
39
39
  ],
40
40
 
41
+ // A `source ↗` button on every demo slide, opening that demo's folder in real
42
+ // VS Code in a new tab. It is `code serve-web` — the web server built into the
43
+ // editor you already have — started alongside the deck, so nothing is installed
44
+ // and nothing is embedded. Off by default because the very first run downloads
45
+ // VS Code's server half (~100 MB) and you do not want to discover that on
46
+ // stage: uncomment, run `dev` once at your desk, and it is cached from then on.
47
+ // `{ port: 4100, command: 'code' }` to change either — the default port is the
48
+ // deck's plus 100. `--no-editor` turns it off for one run, and a
49
+ // built deck never has it: the link is a localhost address and a path on disk.
50
+ editor: true,
51
+
41
52
  // The corner signature. Uncomment to show it.
42
53
  // signature: { name: 'fabiobiondi.dev', url: 'https://www.fabiobiondi.dev', logo: 'assets/logo.png' },
43
54