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
@@ -0,0 +1,299 @@
1
+ import RevealAnnotate from './annotate.js';
2
+ import RevealSpotlight from './spotlight.js';
3
+ import RevealOutline from './outline.js';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // The decks are the Markdown files. This file only assembles them into reveal.js
7
+ // slides, so the .md stay the single source of truth and readable on their own.
8
+ //
9
+ // Nothing here is specific to a talk: what differs between projects arrives in
10
+ // `window.__FB_SLIDES__`, written into index.html from slides.config.js.
11
+ // ---------------------------------------------------------------------------
12
+
13
+ const CFG = window.__FB_SLIDES__ ?? {};
14
+
15
+ // The decks are whatever `.md` sits in decks/, in file-name order: adding or
16
+ // deleting a file is the whole workflow, there is no list to keep in step.
17
+ const DECKS_DIR = CFG.decks ?? 'decks/';
18
+
19
+ // The dev server answers this from the folder as it is right now, and the build
20
+ // writes it into dist/ — so the list is never stale and never hand-written.
21
+ const MANIFEST = 'decks.json';
22
+
23
+ // Last resort: a plain static server with no manifest but a directory listing
24
+ // (`python3 -m http.server`, `npx serve`). The listing is HTML, so it gets parsed.
25
+ const listFromDirectory = async () => {
26
+ const response = await fetch(DECKS_DIR, { cache: 'no-cache' });
27
+ if (!response.ok) throw new Error(`${DECKS_DIR} → ${response.status}`);
28
+
29
+ const listing = new DOMParser().parseFromString(await response.text(), 'text/html');
30
+ const names = [...listing.querySelectorAll('a[href]')]
31
+ .map((a) => decodeURIComponent(a.getAttribute('href').split('?')[0].split('/').pop()))
32
+ .filter((name) => name.endsWith('.md'));
33
+
34
+ // `numeric` so 10-… sorts after 09-… rather than after 01-….
35
+ return [...new Set(names)].sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
36
+ };
37
+
38
+ const listDecks = async () => {
39
+ const manifest = await fetch(MANIFEST, { cache: 'no-cache' }).catch(() => null);
40
+ if (manifest?.ok) {
41
+ const names = await manifest.json().catch(() => []);
42
+ if (names.length) return names;
43
+ }
44
+ try {
45
+ return await listFromDirectory();
46
+ } catch (error) {
47
+ console.warn(`[deck] no ${MANIFEST} and no directory listing (${error.message})`);
48
+ return [];
49
+ }
50
+ };
51
+
52
+ const DECKS = await listDecks();
53
+ if (!DECKS.length) console.error(`[deck] no .md found in ${DECKS_DIR}`);
54
+
55
+ // A slide whose source opens with `<!-- demo: cart -->` is replaced by that demo,
56
+ // running in an iframe. The marker lives in the Markdown, so the position of a
57
+ // demo is decided there — like any other slide. The value is a folder inside the
58
+ // project's demos dir, a ./ or ../ path, or a full http(s) URL for anything external.
59
+ const DEMO_MARKER = /^<!--\s*demo:\s*(\S+?)\s*-->/;
60
+ const DEMOS_DIR = CFG.demos ?? 'demo/';
61
+
62
+ // Bullet lists reveal themselves one item at a time. `?nofrag` turns it off for
63
+ // one visit; `fragmentLists: false` in the config turns it off for the project.
64
+ const AUTO_FRAGMENT = CFG.fragmentLists !== false && !new URLSearchParams(location.search).has('nofrag');
65
+
66
+ const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/;
67
+ const stripFrontMatter = (md) => md.replace(FRONT_MATTER, '');
68
+
69
+ // Enough YAML for `key: value` lines — which is all the front matter holds.
70
+ const parseFrontMatter = (md) =>
71
+ Object.fromEntries(
72
+ (md.match(FRONT_MATTER)?.[1] ?? '')
73
+ .split(/\r?\n/)
74
+ .map((line) => line.match(/^([A-Za-z_][\w-]*):\s*(.+)$/))
75
+ .filter(Boolean)
76
+ .map(([, key, value]) => [key, value.trim().replace(/^['"]|['"]$/g, '')]),
77
+ );
78
+ const splitSlides = (md) => md.split(/\r?\n---\r?\n/).map((s) => s.trim()).filter(Boolean);
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Build the DOM before Reveal.initialize(): the markdown plugin picks up every
82
+ // `data-markdown` section at init time.
83
+ // ---------------------------------------------------------------------------
84
+
85
+ const slidesEl = document.querySelector('.slides');
86
+
87
+ const markdownSection = (source, deck) => {
88
+ const section = document.createElement('section');
89
+ section.setAttribute('data-markdown', '');
90
+ section.dataset.deck = deck.label;
91
+ // A slide opening with an `# h1` is a deck divider, not content.
92
+ if (/^#\s/.test(source)) section.classList.add('deck-title');
93
+ const template = document.createElement('script');
94
+ template.type = 'text/template';
95
+ template.textContent = source;
96
+ section.append(template);
97
+ return section;
98
+ };
99
+
100
+ // The real page, running, inside the slide.
101
+ const demoSection = (target, deck) => {
102
+ const isUrl = /^https?:\/\//.test(target);
103
+ const name = isUrl ? target : target.replace(/\/$/, '');
104
+ // Bare names are folders in the demos dir; a ./ or ../ path and a full URL are
105
+ // used as given.
106
+ const path = isUrl || /^[./]/.test(name) ? name : `${DEMOS_DIR}${name}/`;
107
+ // A whole URL in the header would drown the slide: show just the host.
108
+ const label = isUrl ? new URL(name).host : name;
109
+ const section = document.createElement('section');
110
+ section.className = 'demo-slide';
111
+ section.dataset.deck = deck.label;
112
+ section.innerHTML = `
113
+ <header>
114
+ <span class="tag">live</span>
115
+ <code>${label}</code>
116
+ <a href="${path}" target="_blank" rel="noreferrer">open in a tab ↗</a>
117
+ </header>
118
+ <iframe data-src="${path}" title="${name}" loading="lazy"></iframe>
119
+ `;
120
+ return section;
121
+ };
122
+
123
+ for (const file of DECKS) {
124
+ // `no-cache` revalidates on every load: editing a .md and hitting reload is the
125
+ // whole authoring loop, and fetch() ignores the browser's reload button.
126
+ const response = await fetch(DECKS_DIR + file, { cache: 'no-cache' });
127
+ if (!response.ok) {
128
+ console.error(`[deck] cannot load ${file}: ${response.status}`);
129
+ continue;
130
+ }
131
+ const markdown = await response.text();
132
+ const front = parseFrontMatter(markdown);
133
+ // `section:` names the deck; without one the file name has to do.
134
+ const deck = { file, label: front.section ?? file.replace(/^\d+-|\.md$/g, '') };
135
+
136
+ for (const source of splitSlides(stripFrontMatter(markdown))) {
137
+ const marker = source.match(DEMO_MARKER);
138
+ slidesEl.append(marker ? demoSection(marker[1], deck) : markdownSection(source, deck));
139
+ }
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+
144
+ await Reveal.initialize({
145
+ width: 1280,
146
+ height: 720,
147
+ margin: 0.055,
148
+ minScale: 0.2,
149
+ maxScale: 1.8,
150
+ hash: true,
151
+ slideNumber: 'c/t',
152
+ transition: 'slide',
153
+ transitionSpeed: 'fast',
154
+ backgroundTransition: 'fade',
155
+ // `data-src` on the demo iframes: `false` loads one only once its slide is on
156
+ // screen, and unloads it on the way out. Loading early is worse than it sounds —
157
+ // an embed that measures its container at mount comes up blank when it mounts
158
+ // hidden, which is what you get when you deep-link straight to a demo slide.
159
+ // The trade-off is a fresh page on every visit, which is what you want on stage.
160
+ preloadIframes: false,
161
+ pdfSeparateFragments: false,
162
+ // Anything the project wants to change, from `reveal:` in slides.config.js.
163
+ ...(CFG.reveal ?? {}),
164
+ // Spotlight after Annotate: it hangs its button on the toolbar the pen builds.
165
+ plugins: [RevealMarkdown, RevealHighlight, RevealNotes, RevealAnnotate(), RevealSpotlight(), RevealOutline()],
166
+ });
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Embedded apps that measure their container once, at mount, come up blank in a
170
+ // deck: reveal scales slides with a CSS transform, which fires no resize inside
171
+ // the frame. Changing the iframe's own width does fire one — so nudge it whenever
172
+ // a demo slide is shown, and again when its page finishes loading.
173
+ // ---------------------------------------------------------------------------
174
+
175
+ const nudge = (slide) => {
176
+ const iframe = slide?.querySelector('iframe');
177
+ if (!iframe) return;
178
+
179
+ const kick = () => {
180
+ iframe.style.width = '99%';
181
+ setTimeout(() => { iframe.style.width = ''; }, 150);
182
+ };
183
+
184
+ iframe.addEventListener('load', kick, { once: true });
185
+ kick(); // already loaded: revisiting the slide
186
+ };
187
+
188
+ Reveal.on('ready', ({ currentSlide }) => nudge(currentSlide));
189
+ Reveal.on('slidechanged', ({ currentSlide }) => nudge(currentSlide));
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // Post-processing — everything below runs on markdown the plugin has rendered.
193
+ // ---------------------------------------------------------------------------
194
+
195
+ // ```mermaid fences become real diagrams. Rendering after highlight.js keeps the
196
+ // original source intact in textContent.
197
+ mermaid.initialize({
198
+ startOnLoad: false,
199
+ theme: 'base',
200
+ securityLevel: 'strict',
201
+ // Explicit width on the SVG: reveal hides off-screen slides with `display:none`,
202
+ // so a diagram sized against its container would come out zero-width.
203
+ // `htmlLabels: false` keeps mermaid measuring labels with the same metrics it
204
+ // draws them with — HTML labels get clipped at these font settings.
205
+ flowchart: { useMaxWidth: false, htmlLabels: false, curve: 'basis', padding: 18, nodeSpacing: 55, rankSpacing: 70 },
206
+ themeVariables: {
207
+ fontFamily: 'ui-sans-serif, system-ui, sans-serif',
208
+ fontSize: '17px',
209
+ primaryColor: '#1a1f27',
210
+ primaryTextColor: '#e8ebf0',
211
+ primaryBorderColor: '#6ea8fe',
212
+ lineColor: '#97a1b0',
213
+ secondaryColor: '#12151b',
214
+ tertiaryColor: '#12151b',
215
+ },
216
+ // `mermaid:` in slides.config.js, for a deck on a light theme.
217
+ ...(CFG.mermaid ?? {}),
218
+ });
219
+
220
+ const diagrams = document.querySelectorAll('pre code.language-mermaid, pre code.mermaid');
221
+ for (const [i, code] of [...diagrams].entries()) {
222
+ const holder = document.createElement('div');
223
+ holder.className = 'mermaid';
224
+ try {
225
+ // `render()` draws into its own detached sandbox and hands back markup, so it
226
+ // does not care that the slide it is going into is currently hidden.
227
+ const { svg } = await mermaid.render(`mermaid-${i}`, code.textContent.trim());
228
+ holder.innerHTML = svg;
229
+ } catch (error) {
230
+ console.error('[deck] mermaid failed', error);
231
+ continue;
232
+ }
233
+ code.closest('pre').replaceWith(holder);
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // highlight.js stops at the edge of a template literal's `${…}`: it tags the
238
+ // interpolation but leaves most of its contents untokenised, so
239
+ // `${addToCounter(amount)}` inherits the string colour and reads as static text.
240
+ // The same expression on its own tokenises fine — so re-run the highlighter on
241
+ // what sits inside each `${…}`, and tag the delimiters so the seam between
242
+ // string and code is visible.
243
+ // ---------------------------------------------------------------------------
244
+
245
+ const { hljs } = RevealHighlight();
246
+
247
+ const relightSubst = (subst) => {
248
+ const source = subst.textContent;
249
+ // `data-line-numbers` re-splits highlighted code row by row: a `${…}` broken
250
+ // across two lines arrives here in halves, and half an expression is not one.
251
+ if (!source.startsWith('${') || !source.endsWith('}')) return;
252
+
253
+ const code = hljs.highlight(source.slice(2, -1), { language: 'javascript', ignoreIllegals: true }).value;
254
+ subst.innerHTML = `<span class="hljs-subst-mark">\${</span>${code}<span class="hljs-subst-mark">}</span>`;
255
+ // A nested `${…}` comes back out of the highlighter as flat as its parent did.
256
+ subst.querySelectorAll('.hljs-subst').forEach(relightSubst);
257
+ };
258
+
259
+ // Outermost first: the nested ones are reached through the recursion above, and
260
+ // filtering before the first rewrite keeps every node in the list still attached.
261
+ [...document.querySelectorAll('.slides pre code .hljs-subst')]
262
+ .filter((subst) => !subst.parentElement.closest('.hljs-subst'))
263
+ .forEach(relightSubst);
264
+
265
+ // Bullets appear one at a time; tables, code and short lists stay whole.
266
+ if (AUTO_FRAGMENT) {
267
+ for (const section of document.querySelectorAll('.slides section:not(.deck-title)')) {
268
+ const lists = [...section.querySelectorAll(':scope > ul')].filter((ul) => ul.children.length > 2);
269
+ if (!lists.length) continue;
270
+ for (const list of lists) for (const li of list.children) li.classList.add('fragment');
271
+
272
+ // Reveal numbered the fragments the Markdown declared itself — an
273
+ // `<!-- .element: class="fragment" -->` — back at init, before these bullets
274
+ // existed, so they would keep those low indices and step first wherever they
275
+ // sit. Dropping the numbers lets the sync below order the slide by the DOM.
276
+ for (const numbered of section.querySelectorAll('.fragment[data-fragment-index]')) {
277
+ numbered.removeAttribute('data-fragment-index');
278
+ }
279
+ }
280
+ }
281
+
282
+ // Long code blocks scroll instead of overflowing the slide.
283
+ for (const pre of document.querySelectorAll('.slides pre')) {
284
+ const lines = pre.textContent.trimEnd().split('\n').length;
285
+ if (lines > 14) pre.classList.add('pre--dense');
286
+ }
287
+
288
+ Reveal.sync();
289
+
290
+ // Which deck are we in? Shown in the corner, hidden on dividers.
291
+ const badge = document.querySelector('#deck-badge');
292
+ const updateBadge = () => {
293
+ const current = Reveal.getCurrentSlide();
294
+ badge.textContent = current?.dataset.deck ?? '';
295
+ badge.hidden = !current || current.classList.contains('deck-title');
296
+ };
297
+ Reveal.on('slidechanged', updateBadge);
298
+ Reveal.on('ready', updateBadge);
299
+ updateBadge();
@@ -0,0 +1,37 @@
1
+ <!doctype html>
2
+ <html lang="{{lang}}">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>{{title}}</title>
7
+
8
+ <!-- reveal.js and mermaid are served from this project, not from a CDN:
9
+ a talk has to survive the conference wifi. -->
10
+ <link rel="stylesheet" href="vendor/reveal/dist/reveal.css" />
11
+ <link rel="stylesheet" href="vendor/reveal/plugin/highlight/monokai.css" />
12
+ <link rel="stylesheet" href="theme.base.css" />
13
+ {{head}}
14
+ </head>
15
+
16
+ <body>
17
+ <div class="reveal">
18
+ <div class="slides"><!-- built at runtime from the .md files --></div>
19
+ </div>
20
+
21
+ <div id="deck-badge" aria-hidden="true"></div>
22
+
23
+ {{signature}}
24
+
25
+ <script>
26
+ window.__FB_SLIDES__ = {{config}};
27
+ </script>
28
+
29
+ <script src="vendor/reveal/dist/reveal.js"></script>
30
+ <script src="vendor/reveal/plugin/markdown/markdown.js"></script>
31
+ <script src="vendor/reveal/plugin/highlight/highlight.js"></script>
32
+ <script src="vendor/reveal/plugin/notes/notes.js"></script>
33
+ <script src="vendor/mermaid/dist/mermaid.min.js"></script>
34
+
35
+ <script type="module" src="deck.js"></script>
36
+ </body>
37
+ </html>