fb-slides
v0.13.0
Published
Markdown-driven reveal.js decks: live demo embeds, annotation, mermaid, and a zero-config dev server
Downloads
2,521
Maintainers
Readme
fb-slides
Slides that are just Markdown. A reveal.js deck that reads
your .md files directly, embeds real pages as live demos, and ships as one
self-contained folder.
npx fb-slides create my-talk
cd my-talk && npm install && npm run devThe engine is the package; a talk is only its own content. Bump the version and every deck you have written gets the new features — CHANGELOG.md says which.
The shape of a talk
my-talk/
├─ decks/ 01-intro.md, 02-… ← the talk, in file-name order
├─ demo/
│ ├─ counter/ a static page a slide embeds and runs
│ └─ angular-hello/ an app with its own dev server, started by `servers:`
├─ assets/ images the Markdown links to
├─ theme.css colour overrides (optional)
└─ slides.config.js (optional — every key has a default)That is what create gives you: two nearly empty decks showing the reveal features,
and one demo of each kind. Delete what the talk does not need — the Angular folder,
its servers: entry and its slide come out together.
| | |
| --- | --- |
| npm run dev | serves the deck on :4000 and opens it |
| npm run build | dist/ — self-contained, relative URLs, works offline |
| npm run preview | build, then serve the result |
| npm run kill | stops the deck, the demos and the editor — whatever is left holding their ports |
Writing
A slide is whatever sits between two --- lines. Edit the .md, reload the page —
there is no build step and no export. Adding a section to the talk is dropping a file
into decks/; there is no list to keep in step anywhere.
Three things happen automatically, so you never mark them up by hand:
- a slide starting with an
#h1 becomes a section divider (centred, gradient) - a slide starting with
<!-- demo: … -->becomes a live demo (below) - bullet lists of 3+ items become fragments, revealed one at a time
Section names
The badge in the corner is the file's own section:, from the front matter:
---
marp: true
title: My talk — imperative tools
section: Imperative
---Marp ignores the key, so the file still exports on its own. Without a section: the
badge falls back to the file name.
Live demos
A slide that opens with a demo: comment is replaced by that page, running in an
iframe, full-bleed. You can click around in it without leaving the presentation.
---
<!-- demo: cart -->
## Live demo
[the fallback for GitHub and Marp goes here]The comment must be the first thing in the slide. The value is a folder name inside
demo/, a ./ or ../ path taken as-is, or a full http(s) URL for anything that is
not in the project:
<!-- demo: http://localhost:4200/checkout -->The header then shows the host rather than the whole URL. Everything after the marker is what renderers that don't understand it will show — the deck replaces the whole slide.
A demo iframe is loaded when you arrive on its slide and unloaded when you leave, so the page is fresh every time: the cart is empty again, the counter back to zero. The deck also nudges the iframe's width once it loads — an embedded app that measures its container at mount would otherwise come up blank, because reveal scales slides with a CSS transform and that fires no resize inside the frame.
Moving a demo is moving those lines. Put the block wherever you want it, in any file; delete it and the demo slide is gone.
A demo can also be a whole application with its own dev server — Angular, or anything on
Vite — copied into demo/ as it is. It is embedded by URL, not by folder name:
docs/framework-demos.md.
The source button
editor: true puts a source ↗ link on every demo slide, opening that demo's folder in
real VS Code in a new tab — code serve-web, the web server built into the editor you
already have, started alongside the deck. Nothing is installed and nothing is embedded, and
a built deck never carries it. Run dev once at your desk first: VS Code downloads its
server half on the very first use.
It needs VS Code on the machine running the deck.
serve-webis a subcommand of VS Code's own CLI, so the button exists only where that CLI does:code, orcode-insiders, or a fork that kept it —editor: { command: 'cursor' }. Without it nothing breaks and nothing is missing from the talk;devsays so under the deck's URL and every demo slide comes up as it always did, minus the button. The same is true whencodeis installed but not on thePATHof the shell you start the deck from, which an IDE's built-in terminal manages on its own — name the binary outright then:editor: { command: '/usr/local/bin/code' }.
Stepped code highlighting
The line ranges live in the fence's info string — reveal reveals one group per click:
```js [2|3-4|5-9|10]Harmless anywhere else: every other Markdown renderer just sees a js block.
Diagrams
A ```mermaid fence becomes a real diagram, rendered locally.
Speaker notes
A Note: block at the end of a slide never shows on screen, only in the speaker view (S):
## The description *is* the prompt
- `name` → how the agent **calls** it
Note: a bad description is the most common reason a tool never gets used.Disabled slides
A slide whose source carries <!-- disabled --> on a line of its own stays in the
file and in the navigator, but steps out of the talk:
---
<!-- disabled -->
## The version of this I cut on the train
---The arrows walk past it in whichever direction they were going, it holds no slide
number, and the count and the progress bar are computed as if it were not there.
It is still listed in the navigator (V) and in reveal's overview, marked off —
and clicking it there is how you look at it, which is the point of keeping it.
Looking at one, the corner reads off where the number would be.
Half a talk is the slides you decided against but are not ready to delete. This is where they live, next to the ones that made it.
<!-- draft --> works the same way — out of the talk, uncounted, walked past — for a
slide that is still being written rather than one you cut. It says so louder: a sky-blue
DRAFT in the navigator and on the stage, where a disabled slide reads off.
On the dev server you do not need to type either marker. In edit mode — the edit drawer out, see Editing in the browser — every row of the navigator grows a ⋮ in its top right corner on hover, with:
- Disabled, Draft and To Delete — tick one to set the marker, tick it
again to take it off; a slide wears one of the three at most.
<!-- to-delete -->is off like the others and flagged with a red TO DELETE: a slide you mean to get rid of but have not yet. - Separator above… — a line over the slide's row in the navigator, with an
optional label:
<!-- separator -->or<!-- separator: Part two -->at the top of the slide. It groups the list and nothing else — no slide, nothing on stage — and it travels with its slide when you move it. Double-click a separator — its label or the line — to rename it in place:Entersaves,Escleaves it be. - Add page after — a new
## New slideright after this one. - Duplicate — a copy of the slide, notes and markers included (its separator excepted), right after it.
- Delete — asks first; there is no undo. A file's only slide stays put.
Each one is written into the .md and the deck reloads with the navigator still open.
A file that changed on disk since the page loaded is refused rather than guessed at.
Blocks
Six classes cover the layouts a talk keeps needing, so a slide asks for one by name instead of carrying a paragraph of inline styles:
| | |
| --- | --- |
| .cols / .col | two columns — with the min-width: 0 a code block falls over without |
| .frame | an embed that shares the slide instead of taking it whole |
| .caption | a quiet line under a figure |
| .box | a bordered block: the point the slide comes back to |
| .author-slide | the speaker page — photos down the left edge, the bio on the right |
| <img>, <video> | centred, unframed, and never wider than the slide |
<div class="cols">
<div class="col">
**In the widget**
```js
app.openLink({ url: 'https://example.com/' });
```
</div>
<div class="col">
The blank lines matter: Markdown inside a block-level tag is only parsed
when there is one on each side of it.
</div>
</div>Each of them fixes its numbers in CSS variables — --cols-gap, --frame-w,
--frame-h, --author-photo, --author-hold — so a project retunes one from its own
theme.css without restating the rule.
The speaker page is markup you write and the theme lays out. Give .author-photo as
many <img> as you like: they cross-fade in the order they are written, on a loop that
restarts every time the slide comes up.
<!-- .slide: class="author-slide" -->
<div class="author-photo">
<img src="assets/author/on-stage.jpg" alt="" />
<img src="assets/author/portrait.jpg" alt="" />
</div>
<div class="author-bio">
<h1>Your name</h1>
<ul><li>What you do</li></ul>
<p class="author-meta">The stack · you · work · with</p>
</div>Editing in the browser
On the dev server, Shift+E (or the last button on the toolbar) opens the slide you
are looking at in a drawer: its markdown in one box, its speaker notes in
another. Typing re-renders the real slide in place — same renderer, same theme —
and Save writes the text back into the slide's own lines of the .md,
leaving the rest of the file untouched. Esc closes without saving; moving to
another slide — the header's arrows do it without leaving the editor — keeps
the drawer open on it, dropping anything unsaved. Add inserts a fresh slide
right after the current one and opens it; Delete removes the slide from its
file, after asking. Clicking into the notes box trades the room with the slide
box, so both are comfortable to write in.
The drawer is also the navigator's edit mode, and the pencil in the navigator's
header turns it on and off from there too. While it is out, the navigator (V)
is editable whether it is open or not — closed, it opens that way: pick a row up
— a title or a preview, whichever face the list is showing — and drop it where it
belongs, under another deck file's header too, or use the ⋮ on the row.
A line in the accent shows where it would land. The slide is moved in the .md
files, its own lines and nothing else, and the deck reloads on it with the
navigator and the drawer still open. Esc drops a drag without moving anything;
a click that does not travel still opens the slide. Closing the drawer leaves
edit mode, and the navigator goes back to a list you only read. The only slide in a file stays in it —
delete the file instead. Both the navigator and edit mode outlive a reload:
refresh with either one on and it comes back on — already in place, with no
slide-in, and the slide in hand back at the same height in the list.
The + over the slide box — or / typed on an empty line — opens the block
picker: every block the theme knows, under group headers — the demo markers,
the section divider and the speaker page; columns, callout, caption and framed
embed; image and video with the attributes that make them behave; plain,
stepped and mermaid fences; fragments, table and quote. The filter also answers
to the words you would actually type — img, mermaid, appear — and the
pane under the list shows the markup the highlighted row is about to write.
The block lands where the cursor was, with its first placeholder selected so it
is ready to type over; text already selected takes that placeholder's place, so
picking Fragment wraps the paragraph that was highlighted. A block that
only works at the top of a slide, like the demo marker, goes there whatever the
cursor was doing.
snippets: in slides.config.js adds a project's own blocks to the list:
snippets: [
{ label: 'Pricing table', hint: 'the three tiers', keys: 'plans price', body: '<div class="tiers">${…}</div>' },
],keys are extra words the filter answers to, and group names the header a
block sits under — without one, a project's blocks gather under Project.
Dropping files into assets/
While the drawer is open, dragging files anywhere over the deck — a screenshot,
a clip, a PDF — raises the folders assets/ already has. Drop on one and the
files are saved there; drop on + new folder… and it asks for a name and
makes it. The button next to the + opens the same panel for files you would
rather pick than drag.
The list under the folder picker is that folder, as it is on disk: what was just
dropped and what has been there since last month. Each row carries the path to
write in the slide, a button that copies it, and one that writes it for you at
the cursor:  for an image, a <video> with the attributes that make it
behave for a clip, an <audio> for sound, a link for everything else. Picking
another folder lists that one; what this session put there is tinted, so it
stands out among files that were already around. Names are folded to something
a URL can carry — Schermata città (1).PNG becomes Schermata-citta-1.png —
and nothing is ever overwritten: a second clip.mp4 lands as clip-2.mp4.
The third button on a row deletes the file — the wrong screenshot, dropped a
moment ago. It is the one thing here a reload cannot undo, so it asks: the
Delete button stays dead until CONFIRM is typed into the box, in capitals.
Esc backs out of the question, then out of the panel, then out of the drawer —
one layer per press.
assets: in slides.config.js names the folder. It is published with the deck
whatever else static: says, so a path the drawer hands out works in the build
too.
The drawer, the drop and the navigator's edit mode exist only under fb-slides dev, and only at
localhost (or 127.0.0.1): a built deck is static files and never shows the
button, and from any other address — a phone on the LAN, a tunnel, GitHub
Pages — the page does not even ask for the edit API.
Presenting
| Key | |
| --- | --- |
| → / Space | next step (fragments included) |
| Q | the pen — draw on the slide, the ink fades on its own |
| W | the arrow — click the tail, then the tip; same colours as the pen |
| E | the spotlight — drag a rectangle, the rest of the slide dims and blurs |
| R | the pointer — replaces the cursor: a glowing halo, a dart aimed at the centre, or the normal mouse |
| X | put the toolbar away, or bring it back — it comes down when the mouse goes for the top edge or a tool arms, and stays until this |
| Shift+E | edit the slide — markdown and notes, saved back into the .md (dev server only) |
| S | speaker view — notes, timer, next slide |
| Esc / O | overview — the slides as a grid |
| V | the vertical navigator — every slide by title, down the left edge |
| F | fullscreen |
| B / . | black out the screen |
?nofrag— every bullet at once, for a fast read-through?print-pdf— open it, then print to PDF (the iframes won't render)
slides.config.js
Every key is optional.
export default {
title: 'My talk', // browser tab, and the built page
lang: 'en',
decks: 'decks', // folder of .md
demos: 'demo', // folder behind a bare `<!-- demo: name -->`
assets: 'assets', // where a file dropped on the deck lands
static: ['assets', 'demo'], // served and published; auto-detected when omitted
revealTheme: 'dracula', // one of reveal.js's own themes; omit for this one
webfonts: false, // let the reveal themes fetch their Google fonts
themePack: 'custom-aurora', // one of this package's own named themes
theme: 'theme.css', // loaded last, over both; auto when it exists
favicon: 'assets/favicon.png',
signature: { // the corner logo. Omit the key, omit the logo
name: 'fabiobiondi.dev',
url: 'https://www.fabiobiondi.dev',
logo: 'assets/logo.png',
},
servers: [ // started by `dev`, alongside the deck
{ name: 'angular demo', cwd: 'demo/app', command: 'npm', args: ['start', '--', '--port', '4200'] },
],
editor: true, // a `source ↗` button on every demo slide (needs VS Code)
port: 4000,
open: true,
outDir: 'dist',
exclude: ['dist'], // extra path segments the build skips
fragmentLists: true, // bullet lists reveal one item at a time
snippets: [], // your own blocks in the edit drawer's picker
reveal: { transition: 'fade' },// passed to Reveal.initialize()
mermaid: {}, // passed to mermaid.initialize()
};A servers: entry that fails — a missing node_modules, a busy port — prints a warning
and nothing more: the slide that embeds it comes up empty, the rest of the deck works.
Copying a real Angular or Vite app into demo/ and wiring it up — including the port flag
Vite needs so a busy port fails instead of moving:
docs/framework-demos.md.
editor: true is a side process of the same kind, and fails the same way: no VS Code on
the machine, or no code on the PATH of the shell that started the deck, and you get a
warning under the deck's URL and demo slides without their source ↗ button. It listens on
7100, clear of the ports the demos want, and walks up from there if something holds it:
docs/source-code.md.
When something survives
dev starts more than itself, and it takes its side processes down with it — as long as it
is asked politely. Killed outright, or with its terminal window closed, it leaves them: a
vite or an ng serve under the npm that started it, code serve-web, all still holding
the ports the next run wants.
npm run killIt reads the ports out of this file — the deck's, preview's one above it, the url: of
every servers: entry, the editor's 7100–7109 — and stops whatever is listening on them,
naming each one before it goes:
✗ 4000 deck node …/bin/fb-slides.mjs dev (39190)
✗ 4200 angular demo ng serve (39212)
✗ 7100 editor …/Visual Studio Code.app/… (32669)
3 processes stopped.Going by port rather than by process tree is what makes it work at all after the deck is gone: the port is the thing already written down, and the process holding it is the one in the way. The npm above it exits on its own once its server is stopped.
A servers: entry with no url: is the one thing it cannot reach — that key is how the
config says where the demo answers.
dev also looks before it leaps: a port a servers: entry is about to use that is already
answering is a leftover from a previous run, and it says so under the deck's URL rather than
letting you find out from a demo slide showing yesterday's build.
kill test
→ http://localhost:4010/
⚠ 4201 is already in use — angular-hello from an earlier run?
That server is not this deck's: `npx fb-slides kill` frees the ports it uses.Theming
theme.css in the project is loaded after the package's base theme, so it overrides
rather than replaces. Almost everything hangs off the tokens in :root:
:root {
--bg: #0e1116;
--card: #1a1f27;
--text: #e8ebf0;
--muted: #97a1b0;
--accent: #6ea8fe;
--accent-2: #7ee2b8;
}reveal.js themes
The fifteen themes from revealjs.com/themes are all in the box — pick one by name and nothing else changes:
export default { revealTheme: 'dracula' };beige · black · black-contrast · blood · dracula · league · moon · night ·
serif · simple · sky · solarized · white · white-contrast
Only the chosen one is ever loaded — one <link>, ~7 KB — and the build copies that file
and nothing else. Under dev all fifteen are reachable, one URL each, so the picker below
can try them without a restart. Two things happen beyond the link:
It is served from the deck, not from a CDN. Six of the themes open with
@import url(https://fonts.googleapis.com/…). Those lines are stripped, so a talk still
renders with the wifi off; the theme falls back to the next font in its own stack. Pass
webfonts: true to let them through and get the typography of the previews exactly. The
font folders reveal ships itself — league-gothic, source-sans-pro — are copied into
dist/ when the theme asks for them.
The chrome follows the theme. The navigator, the badge and the signature are not
.reveal elements, so a borrowed theme would leave a black panel down the side of a white
deck. Every reveal 5 theme declares its palette as --r-* custom properties, and the base
theme points its own tokens at them, so the whole page moves together. A theme.css in the
project still has the last word over both.
The named themes
Two whole looks ship with the package, picked by name:
export default { themePack: 'custom-aurora' };custom-aurora — atmospheric. Coloured light pooling behind the slide, glass panels
floating on it, a display serif carrying the titles.
custom-editoriale — flat and typographic. No panels and almost no colour: one serif,
hairlines for dividing, and the accent kept for the two or three words a slide is about.
They are not reveal themes and they are not a second renderer. A pack is written against
the same --bg / --accent / --text tokens as the base theme and styles the same slide
vocabulary, which is what lets both of them dress the same Markdown: the chips line
that is a row of pills under custom-aurora is a // LIKE THIS rule under
custom-editoriale, and the .md does not change a character.
The three layers compose, in this order — reveal's theme, then the pack, then your
theme.css. Wearing a pack and still overriding two of its colours is a two-line file.
Neither pack fetches a webfont. The serif is whatever the machine already has, closest first; a talk that has to survive the conference wifi cannot open with a request to fonts.googleapis.com.
Switching themes while you work
fb-slides dev puts a theme picker in the toolbar — the last button, next to the pen. It
lists everything the deck could wear, under two headings: the packs above, reveal's fifteen
below, with the bare base theme at the top. Picking swaps one <link>: no reload, no
restart, and the choice survives a refresh.
One choice, not two. The config will let you name a pack and a reveal theme at once, but
layering them gives you neither look — only whichever rule happened to come last — so the
picker treats them as one question. Your theme.css still lands on top of whatever is
picked, as it always does.
It is a preview, not a setting. Nothing is written to disk — slides.config.js is a module
with your own comments in it, and a picker that rewrites it is a worse problem than the one
it solves — so the panel prints the line to paste when you have decided.
The picker exists only under dev, and by construction rather than by a flag: dev renders
the page with the theme list in it and build does not, so a published deck has nothing for
the switcher to read and it never builds itself. The fifteen are the same way: dev can
rewrite any of them on request, build writes only the one the config names.
The slide vocabulary the packs dress
Four blocks both packs know. All four are in the edit drawer's + picker, so none of this
has to be remembered:
<p class="chips"><span>Angular</span><span>Signals</span><span>v22</span></p>The line above the title. The first chip is the loud one — in a row of equals nothing is being asked.
<div class="rows">
<div class="row">
<span class="row-icon">( )</span>
<div><strong>Signals</strong><p>UI state, derived values, clean templates.</p></div>
</div>
</div>Two or three things, each with a glyph and one sentence. class="rows rows--cta" turns the
same block into the last slide's ways out: every row grows an arrow, and the first is filled.
<!-- file: cart.component.ts -->
```ts
…
```A header bar on the code block: the name on the left, the language on the right. The
language is never written down — it is the file's own extension. The marker is a comment,
in the shape the demo slides already use, so the .md still renders anywhere else.
A two-column Markdown table with an empty header row is a definition list — the term on the left, one sentence on the right — and the empty bar is dropped rather than drawn.
Publishing
npm run build # → dist/dist/ carries reveal.js and mermaid with it and uses only relative URLs, so it can be
published at a domain root or in any subfolder, and it works with no network at all. It
also holds decks.json, the file list a static host cannot produce on its own —
generated, never hand-written.
What the build cannot fix: a slide embedding http://localhost:… shows an empty frame
once published. Those URLs exist only on the machine running them.
Also usable with Marp
Each .md keeps its own front matter, so the files still work standalone:
npx @marp-team/marp-cli decks/01-intro.md --pdfMarp does not render the mermaid fences or the demo slides — the deck does.
Overriding the engine
The dev server serves the project before the package, so a file of the same name next
to your decks shadows the one that ships here: dropping in your own deck.js or
theme.base.css replaces it for that talk. It is the escape hatch, not the workflow —
what you shadow stops receiving updates.
