@exor404/mdslides 0.1.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.
@@ -0,0 +1,281 @@
1
+ ---
2
+ import '~theme';
3
+ import 'katex/dist/katex.min.css';
4
+
5
+ interface Slide {
6
+ html: string;
7
+ center?: boolean;
8
+ bg?: string | null;
9
+ classes?: string[];
10
+ title?: boolean;
11
+ content?: boolean;
12
+ }
13
+
14
+ interface Props {
15
+ title: string;
16
+ slides: Slide[];
17
+ transition?: string;
18
+ brand?: string;
19
+ print?: boolean;
20
+ }
21
+
22
+ const { title, slides, transition = 'fade', brand = '', print = false } = Astro.props;
23
+
24
+ function slideClass(s: Slide) {
25
+ return [
26
+ 'slide',
27
+ s.title ? 'is-title' : '',
28
+ s.content ? 'is-content' : '',
29
+ s.center ? 'is-center' : '',
30
+ ...(s.classes ?? []),
31
+ ]
32
+ .filter(Boolean)
33
+ .join(' ');
34
+ }
35
+ function slideStyle(s: Slide) {
36
+ return s.bg ? `--slide-bg:${s.bg}` : undefined;
37
+ }
38
+ ---
39
+ <!doctype html>
40
+ <html lang="en" data-transition={transition} class={print ? 'print' : undefined}>
41
+ <head>
42
+ <meta charset="utf-8" />
43
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
44
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
45
+ <title>{title}</title>
46
+ </head>
47
+ <body>
48
+ {print ? (
49
+ <>
50
+ <div class="print-bar" id="print-bar">
51
+ <a class="print-back" href="/">‹ Back to deck</a>
52
+ <button class="print-do" id="print-do">Save as PDF</button>
53
+ </div>
54
+ <div class="print-deck">
55
+ {slides.map((s) => (
56
+ <div class={slideClass(s)} style={slideStyle(s)}>
57
+ <div class="slide-inner" set:html={s.html} />
58
+ </div>
59
+ ))}
60
+ </div>
61
+ <script is:inline>
62
+ (() => {
63
+ const go = () => window.print();
64
+ document.getElementById('print-do')?.addEventListener('click', go);
65
+ // Opened from the deck's PDF button (#print) → auto-open the dialog
66
+ // once layout/fonts settle. A manual visit to /print just shows the
67
+ // slides plus the button.
68
+ if (location.hash === '#print') {
69
+ window.addEventListener('load', () => setTimeout(go, 450));
70
+ }
71
+ })();
72
+ </script>
73
+ </>
74
+ ) : (
75
+ <>
76
+ <div class="deck" id="deck">
77
+ <div class="stage" id="stage">
78
+ {slides.map((s, i) => (
79
+ <section
80
+ class={slideClass(s)}
81
+ style={slideStyle(s)}
82
+ data-index={i}
83
+ aria-hidden={i === 0 ? 'false' : 'true'}
84
+ >
85
+ <div class="slide-inner" set:html={s.html} />
86
+ </section>
87
+ ))}
88
+ </div>
89
+ </div>
90
+
91
+ <div class="chrome">
92
+ <div class="progress" id="progress"><span class="progress-bar" id="progress-bar"></span></div>
93
+ <div class="chrome-bar">
94
+ {brand && <span class="brand">{brand}</span>}
95
+ <div class="controls">
96
+ <div class="ctl-group">
97
+ <button class="ctl" id="prev" aria-label="Previous slide" title="Previous (←)">‹</button>
98
+ <button class="ctl" id="next" aria-label="Next slide" title="Next (→)">›</button>
99
+ <button class="ctl" id="overview-btn" aria-label="Overview" title="Overview (O)">▦</button>
100
+ <button class="ctl" id="print-btn" aria-label="Print to PDF" title="Print / Save as PDF (P)">
101
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9V2h12v7"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
102
+ </button>
103
+ <button class="ctl" id="fullscreen-btn" aria-label="Fullscreen" title="Fullscreen (F)">⤢</button>
104
+ <button class="ctl" id="theme-btn" aria-label="Toggle theme" title="Theme">◐</button>
105
+ </div>
106
+ <span class="counter" id="counter">1 / {slides.length}</span>
107
+ </div>
108
+ </div>
109
+ </div>
110
+
111
+ <div class="help" id="help" hidden>
112
+ <kbd>←</kbd><kbd>→</kbd> navigate · <kbd>O</kbd> overview · <kbd>F</kbd> fullscreen · <kbd>P</kbd> pdf · <kbd>.</kbd> black · <kbd>?</kbd> help
113
+ </div>
114
+
115
+ <script is:inline>
116
+ (() => {
117
+ const stage = document.getElementById('stage');
118
+ const slides = [...document.querySelectorAll('.slide')];
119
+ const total = slides.length;
120
+ const counter = document.getElementById('counter');
121
+ const bar = document.getElementById('progress-bar');
122
+ const deck = document.getElementById('deck');
123
+ const help = document.getElementById('help');
124
+ if (!stage || total === 0) return;
125
+
126
+ const BASE_W = 1280, BASE_H = 720;
127
+ let cur = 0; // current slide index
128
+ let frag = 0; // fragments revealed on current slide
129
+ let overview = false;
130
+
131
+ const fragmentsOf = (i) => [...slides[i].querySelectorAll('.fragment')];
132
+
133
+ // ── Fit the fixed canvas into the viewport ──
134
+ function fit() {
135
+ const scale = Math.min(window.innerWidth / BASE_W, window.innerHeight / BASE_H);
136
+ stage.style.transform = `translate(-50%, -50%) scale(${scale})`;
137
+ }
138
+
139
+ // ── Render current state ──
140
+ function show() {
141
+ slides.forEach((s, i) => {
142
+ s.classList.toggle('active', i === cur);
143
+ s.setAttribute('aria-hidden', i === cur ? 'false' : 'true');
144
+ const dir = i < cur ? 'past' : i > cur ? 'future' : 'present';
145
+ s.dataset.state = dir;
146
+ });
147
+ const frags = fragmentsOf(cur);
148
+ frags.forEach((f, i) => f.classList.toggle('revealed', i < frag));
149
+ counter.textContent = `${cur + 1} / ${total}`;
150
+ bar.style.width = `${((cur + 1) / total) * 100}%`;
151
+ const hash = `#/${cur}`;
152
+ if (location.hash !== hash) history.replaceState(null, '', hash);
153
+ if (overview) slides[cur].scrollIntoView({ block: 'nearest' });
154
+ }
155
+
156
+ function goto(i, opts = {}) {
157
+ cur = Math.max(0, Math.min(total - 1, i));
158
+ frag = opts.endFragments ? fragmentsOf(cur).length : 0;
159
+ show();
160
+ }
161
+
162
+ function next() {
163
+ const frags = fragmentsOf(cur);
164
+ if (frag < frags.length) { frag++; show(); return; }
165
+ if (cur < total - 1) goto(cur + 1);
166
+ }
167
+ function prev() {
168
+ if (frag > 0) { frag--; show(); return; }
169
+ if (cur > 0) goto(cur - 1, { endFragments: true });
170
+ }
171
+
172
+ // ── Overview / grid ──
173
+ // Selection happens in the single stage click handler below — no
174
+ // per-slide onclick (which used to flip `overview` mid-bubble and
175
+ // make the stage handler advance an extra slide).
176
+ function setOverview(on) {
177
+ overview = on;
178
+ deck.classList.toggle('overview', on);
179
+ show();
180
+ }
181
+
182
+ // ── Black / pause ──
183
+ function toggleBlack() { document.body.classList.toggle('blacked'); }
184
+
185
+ // ── Keyboard ──
186
+ window.addEventListener('keydown', (e) => {
187
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
188
+ switch (e.key) {
189
+ case 'ArrowRight': case ' ': case 'PageDown':
190
+ e.preventDefault(); overview ? goto(cur + 1) : next(); break;
191
+ case 'ArrowLeft': case 'PageUp':
192
+ e.preventDefault(); overview ? goto(cur - 1) : prev(); break;
193
+ case 'Home': e.preventDefault(); goto(0); break;
194
+ case 'End': e.preventDefault(); goto(total - 1); break;
195
+ case 'o': case 'O': setOverview(!overview); break;
196
+ case 'p': case 'P': openPrint(); break;
197
+ case 'Enter': if (overview) { e.preventDefault(); setOverview(false); } break;
198
+ case 'Escape': if (overview) setOverview(false); break;
199
+ case 'f': case 'F':
200
+ if (document.fullscreenElement) document.exitFullscreen();
201
+ else document.documentElement.requestFullscreen?.(); break;
202
+ case '.': toggleBlack(); break;
203
+ case '?': help.hidden = !help.hidden; break;
204
+ }
205
+ });
206
+
207
+ // ── Click zones (ignore links/buttons) ──
208
+ stage.addEventListener('click', (e) => {
209
+ if (overview) {
210
+ const sec = e.target.closest('.slide');
211
+ const i = sec ? slides.indexOf(sec) : -1;
212
+ if (i >= 0) { setOverview(false); goto(i); }
213
+ return;
214
+ }
215
+ if (e.target.closest('a, button')) return;
216
+ if (e.clientX < window.innerWidth * 0.25) prev(); else next();
217
+ });
218
+
219
+ // ── Print / PDF ──
220
+ // Under the mdslides dev server, generate a pixel-accurate PDF with
221
+ // headless Chrome (works identically in every browser). On a static
222
+ // deploy that endpoint is absent, so fall back to the browser's own
223
+ // Save-as-PDF on the /print route.
224
+ async function openPrint() {
225
+ const btn = document.getElementById('print-btn');
226
+ btn?.classList.add('busy');
227
+ try {
228
+ const res = await fetch('/__export.pdf');
229
+ if (res.ok && (res.headers.get('content-type') || '').includes('pdf')) {
230
+ const url = URL.createObjectURL(await res.blob());
231
+ const a = document.createElement('a');
232
+ a.href = url;
233
+ a.download = (document.title || 'slides') + '.pdf';
234
+ document.body.appendChild(a);
235
+ a.click();
236
+ a.remove();
237
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
238
+ return;
239
+ }
240
+ } catch {} finally {
241
+ btn?.classList.remove('busy');
242
+ }
243
+ window.open('/print/#print', '_blank');
244
+ }
245
+
246
+ // ── Chrome buttons ──
247
+ document.getElementById('next')?.addEventListener('click', next);
248
+ document.getElementById('prev')?.addEventListener('click', prev);
249
+ document.getElementById('overview-btn')?.addEventListener('click', () => setOverview(!overview));
250
+ document.getElementById('print-btn')?.addEventListener('click', openPrint);
251
+ document.getElementById('fullscreen-btn')?.addEventListener('click', () => {
252
+ if (document.fullscreenElement) document.exitFullscreen();
253
+ else document.documentElement.requestFullscreen?.();
254
+ });
255
+
256
+ // ── Theme toggle (light / dark) ──
257
+ (() => {
258
+ const root = document.documentElement;
259
+ if (localStorage.getItem('mdslides-theme') === 'light') root.setAttribute('data-theme', 'light');
260
+ document.getElementById('theme-btn')?.addEventListener('click', () => {
261
+ const light = root.getAttribute('data-theme') === 'light';
262
+ if (light) { root.removeAttribute('data-theme'); localStorage.setItem('mdslides-theme', 'dark'); }
263
+ else { root.setAttribute('data-theme', 'light'); localStorage.setItem('mdslides-theme', 'light'); }
264
+ });
265
+ })();
266
+
267
+ // ── Init: honor deep-link hash ──
268
+ const fromHash = () => {
269
+ const m = location.hash.match(/^#\/(\d+)/);
270
+ return m ? parseInt(m[1], 10) : 0;
271
+ };
272
+ window.addEventListener('resize', fit);
273
+ window.addEventListener('hashchange', () => { const i = fromHash(); if (i !== cur) goto(i); });
274
+ fit();
275
+ goto(fromHash());
276
+ })();
277
+ </script>
278
+ </>
279
+ )}
280
+ </body>
281
+ </html>
@@ -0,0 +1,272 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { resolve, relative, sep } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { unified } from 'unified';
5
+ import remarkParse from 'remark-parse';
6
+ import remarkGfm from 'remark-gfm';
7
+ import remarkMath from 'remark-math';
8
+ import remarkRehype from 'remark-rehype';
9
+ import rehypeKatex from 'rehype-katex';
10
+ import rehypeStringify from 'rehype-stringify';
11
+ import remarkHighlight from '../../integrations/remark-highlight.js';
12
+ import rehypeShiki from '../../integrations/rehype-shiki.js';
13
+
14
+ const DEFAULT_CONFIG = {
15
+ brand: { text: 'mdslides' },
16
+ theme: 'angular',
17
+ transition: 'fade',
18
+ };
19
+
20
+ export async function loadDeckConfig() {
21
+ const source = process.env.MD_SOURCE;
22
+ if (!source) return DEFAULT_CONFIG;
23
+ const cfgPath = resolve(source, 'mdslides.config.js');
24
+ if (!existsSync(cfgPath)) return DEFAULT_CONFIG;
25
+ try {
26
+ const mod = await import(pathToFileURL(cfgPath).href);
27
+ const user = mod.default ?? {};
28
+ return {
29
+ ...DEFAULT_CONFIG,
30
+ ...user,
31
+ brand: { ...DEFAULT_CONFIG.brand, ...(user.brand ?? {}) },
32
+ };
33
+ } catch {
34
+ return DEFAULT_CONFIG;
35
+ }
36
+ }
37
+
38
+ // Rewrite relative image URLs to absolute-from-source-root, so they resolve
39
+ // through the asset middleware / build copy. The deck file sits at the source
40
+ // root, so a relative URL resolves against `source`.
41
+ function remarkImagePaths() {
42
+ const source = process.env.MD_SOURCE;
43
+ const ABSOLUTE_OR_PROTOCOL = /^(?:[a-z][a-z0-9+\-.]*:|\/\/|\/|#|data:)/i;
44
+ const rewrite = (url) => {
45
+ if (!url || ABSOLUTE_OR_PROTOCOL.test(url)) return url;
46
+ const cleaned = url.replace(/[?#].*$/, '');
47
+ const trailing = url.slice(cleaned.length);
48
+ const abs = resolve(source, cleaned);
49
+ const withSep = source.endsWith(sep) ? source : source + sep;
50
+ if (abs !== source && !abs.startsWith(withSep)) return url;
51
+ return '/' + relative(source, abs).split(sep).join('/') + trailing;
52
+ };
53
+ const visit = (node) => {
54
+ if (node.type === 'image') node.url = rewrite(node.url);
55
+ if (Array.isArray(node.children)) node.children.forEach(visit);
56
+ };
57
+ return (tree) => visit(tree);
58
+ }
59
+
60
+ // `Text {.fragment}` → add class `fragment` to the containing element and
61
+ // strip the marker. Fragments reveal one step at a time during the talk.
62
+ function rehypeFragments() {
63
+ const walk = (node, parent) => {
64
+ if (node.type === 'text' && node.value.includes('{.fragment}')) {
65
+ node.value = node.value.replace(/\s*\{\.fragment\}/g, '');
66
+ if (parent && parent.type === 'element') {
67
+ const props = (parent.properties ||= {});
68
+ const cls = props.className;
69
+ props.className = Array.isArray(cls) ? [...cls, 'fragment'] : cls ? [cls, 'fragment'] : ['fragment'];
70
+ }
71
+ }
72
+ if (Array.isArray(node.children)) node.children.forEach((c) => walk(c, node));
73
+ };
74
+ return (tree) => walk(tree, null);
75
+ }
76
+
77
+ // Every list item is a fragment by default: lists start hidden when the slide
78
+ // opens and reveal one bullet at a time on Space / →, reusing the same
79
+ // `.fragment` machinery as manual `{.fragment}` markers.
80
+ function addClass(node, name) {
81
+ const props = (node.properties ||= {});
82
+ const cls = props.className;
83
+ if (Array.isArray(cls)) { if (!cls.includes(name)) cls.push(name); }
84
+ else if (cls) { if (cls !== name) props.className = [cls, name]; }
85
+ else props.className = [name];
86
+ }
87
+ function rehypeListFragments() {
88
+ const walk = (node) => {
89
+ if (node.type === 'element' && node.tagName === 'li') addClass(node, 'fragment');
90
+ if (Array.isArray(node.children)) node.children.forEach(walk);
91
+ };
92
+ return (tree) => walk(tree);
93
+ }
94
+
95
+ // Content-slide layout: pull the heading into a fixed top-left header, then
96
+ // split the body into a left column (prose / bullets) and a right column
97
+ // (media — images, fenced code, tables, block math). Only runs when the
98
+ // vfile is tagged `slideLayout: 'content'` (title / centered slides skip it).
99
+ function hasClass(node, name) {
100
+ const c = node.properties?.className;
101
+ if (Array.isArray(c)) return c.includes(name);
102
+ if (typeof c === 'string') return c.split(/\s+/).includes(name);
103
+ return false;
104
+ }
105
+ function realChildren(node) {
106
+ return (node.children ?? []).filter((k) => !(k.type === 'text' && !k.value.trim()));
107
+ }
108
+ function isMedia(node) {
109
+ if (node.type !== 'element') return false;
110
+ const t = node.tagName;
111
+ if (t === 'img' || t === 'pre' || t === 'table' || t === 'figure') return true;
112
+ if (hasClass(node, 'math-display') || hasClass(node, 'katex-display')) return true;
113
+ // A paragraph whose sole content is an image or a block of display math.
114
+ if (t === 'p') {
115
+ const kids = realChildren(node);
116
+ if (kids.length === 1 && kids[0].type === 'element') {
117
+ const k = kids[0];
118
+ if (k.tagName === 'img') return true;
119
+ if (hasClass(k, 'math-display') || hasClass(k, 'katex-display')) return true;
120
+ }
121
+ }
122
+ return false;
123
+ }
124
+ function el(tagName, className, children) {
125
+ return { type: 'element', tagName, properties: { className }, children };
126
+ }
127
+ function rehypeSlideLayout() {
128
+ return (tree, file) => {
129
+ if (file?.data?.slideLayout !== 'content') return;
130
+ const kids = realChildren(tree);
131
+
132
+ let head = null;
133
+ const rest = [];
134
+ for (const n of kids) {
135
+ if (!head && n.type === 'element' && /^h[1-6]$/.test(n.tagName)) head = n;
136
+ else rest.push(n);
137
+ }
138
+
139
+ const left = [];
140
+ const right = [];
141
+ for (const n of rest) (isMedia(n) ? right : left).push(n);
142
+
143
+ const hasLeft = left.length > 0;
144
+ const hasRight = right.length > 0;
145
+ const variant = hasLeft && hasRight ? 'has-media' : hasRight ? 'media-only' : 'no-media';
146
+
147
+ const cols = [];
148
+ if (hasLeft || !hasRight) cols.push(el('div', ['col-left'], left));
149
+ if (hasRight) cols.push(el('div', ['col-right'], right));
150
+ const body = el('div', ['slide-body', variant], cols);
151
+
152
+ tree.children = head ? [el('header', ['slide-head'], [head]), body] : [body];
153
+ };
154
+ }
155
+
156
+ const processor = unified()
157
+ .use(remarkParse)
158
+ .use(remarkGfm)
159
+ .use(remarkMath)
160
+ .use(remarkHighlight)
161
+ .use(remarkImagePaths)
162
+ .use(remarkRehype)
163
+ .use(rehypeKatex)
164
+ .use(rehypeShiki)
165
+ .use(rehypeFragments)
166
+ .use(rehypeListFragments)
167
+ .use(rehypeSlideLayout)
168
+ .use(rehypeStringify);
169
+
170
+ async function renderMarkdown(md, layout) {
171
+ const file = await processor.process({ value: md, data: { slideLayout: layout } });
172
+ return String(file);
173
+ }
174
+
175
+ // Per-slide directive: `<!-- slide: center bg=#0d0d0d -->`.
176
+ // Supported tokens: `title`, `plain`, `center`, `bg=<color|url(...)>`,
177
+ // `class=<name>`.
178
+ function extractDirectives(chunk) {
179
+ const directives = { center: false, bg: null, classes: [], title: false, plain: false };
180
+ const stripped = chunk.replace(/<!--\s*slide:([^>]*?)-->\s*/i, (_m, body) => {
181
+ for (const tok of body.trim().split(/\s+/)) {
182
+ if (!tok) continue;
183
+ if (tok === 'center') directives.center = true;
184
+ else if (tok === 'title') directives.title = true;
185
+ else if (tok === 'plain') directives.plain = true;
186
+ else if (tok.startsWith('bg=')) directives.bg = tok.slice(3);
187
+ else if (tok.startsWith('class=')) directives.classes.push(tok.slice(6));
188
+ }
189
+ return '';
190
+ });
191
+ return { directives, body: stripped };
192
+ }
193
+
194
+ // Today's date, e.g. "June 10, 2026". Computed per run (dev reloads pick up
195
+ // the current day; a static build freezes it at build time).
196
+ function todayLong() {
197
+ return new Date().toLocaleDateString('en-US', {
198
+ year: 'numeric',
199
+ month: 'long',
200
+ day: 'numeric',
201
+ });
202
+ }
203
+
204
+ // Split the deck into slides. A new slide starts at every `#` (H1) heading.
205
+ // A standalone `---` line is also an explicit break (escape hatch for a
206
+ // title slide with no heading, or splitting one section across slides).
207
+ function splitSlides(src) {
208
+ // Drop a leading YAML frontmatter block, if any.
209
+ let text = src.replace(/^/, '');
210
+ text = text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
211
+
212
+ const lines = text.split(/\r?\n/);
213
+ const chunks = [];
214
+ let current = [];
215
+ let inFence = false;
216
+
217
+ const flush = () => {
218
+ const joined = current.join('\n').trim();
219
+ if (joined) chunks.push(joined);
220
+ current = [];
221
+ };
222
+
223
+ for (const line of lines) {
224
+ if (/^```/.test(line)) inFence = !inFence;
225
+ const isH1 = !inFence && /^#\s+/.test(line);
226
+ const isBreak = !inFence && /^---\s*$/.test(line);
227
+ if (isH1 && current.some((l) => l.trim())) {
228
+ flush();
229
+ } else if (isBreak) {
230
+ flush();
231
+ continue; // the rule itself is not content
232
+ }
233
+ current.push(line);
234
+ }
235
+ flush();
236
+ return chunks;
237
+ }
238
+
239
+ export async function loadSlides() {
240
+ const deckFile = process.env.MD_FILE;
241
+ if (!deckFile) {
242
+ throw new Error('MD_FILE not set — run mdslides via the CLI (e.g. `npx mdslides ./slides.md`).');
243
+ }
244
+ const src = readFileSync(deckFile, 'utf8');
245
+ const today = todayLong();
246
+ const chunks = splitSlides(src);
247
+
248
+ const slides = [];
249
+ for (let i = 0; i < chunks.length; i++) {
250
+ const { directives, body } = extractDirectives(chunks[i]);
251
+ // `{{date}}` → today's date (any slide).
252
+ const withDate = body.replace(/\{\{\s*date\s*\}\}/g, today);
253
+ // The first slide is the presentation title slide by default; opt out
254
+ // with `<!-- slide: plain -->`, or force elsewhere with `title`.
255
+ const isTitle = directives.title || (i === 0 && !directives.plain);
256
+ // Everything that isn't a title or an explicitly centered slide gets the
257
+ // structured content layout (top-left title + text-left / media-right).
258
+ const isContent = !isTitle && !directives.center;
259
+ const html = await renderMarkdown(withDate, isContent ? 'content' : 'plain');
260
+ slides.push({ html, ...directives, title: isTitle, content: isContent });
261
+ }
262
+ return slides;
263
+ }
264
+
265
+ // Deck title = text of the first H1, else the filename.
266
+ export function deckTitle(slides) {
267
+ for (const s of slides) {
268
+ const m = s.html.match(/<h1[^>]*>(.*?)<\/h1>/is);
269
+ if (m) return m[1].replace(/<[^>]+>/g, '').trim();
270
+ }
271
+ return 'mdslides';
272
+ }
@@ -0,0 +1,14 @@
1
+ ---
2
+ import Deck from '../layouts/Deck.astro';
3
+ import { loadSlides, loadDeckConfig, deckTitle } from '../lib/deck.js';
4
+
5
+ const slides = await loadSlides();
6
+ const config = await loadDeckConfig();
7
+ const title = deckTitle(slides);
8
+ ---
9
+ <Deck
10
+ title={title}
11
+ slides={slides}
12
+ transition={config.transition}
13
+ brand={config.brand?.text ?? ''}
14
+ />
@@ -0,0 +1,11 @@
1
+ ---
2
+ import Deck from '../layouts/Deck.astro';
3
+ import { loadSlides, loadDeckConfig, deckTitle } from '../lib/deck.js';
4
+
5
+ // One page per slide, all fragments shown — used by `mdslides export` (PDF)
6
+ // and the browser's own "Save as PDF".
7
+ const slides = await loadSlides();
8
+ const config = await loadDeckConfig();
9
+ const title = deckTitle(slides);
10
+ ---
11
+ <Deck title={title} slides={slides} transition="none" brand={config.brand?.text ?? ''} print />