@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.
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/app/astro.config.mjs +27 -0
- package/app/integrations/mdslides-assets.js +167 -0
- package/app/integrations/pdf-export.js +86 -0
- package/app/integrations/rehype-shiki.js +59 -0
- package/app/integrations/remark-highlight.js +45 -0
- package/app/package.json +6 -0
- package/app/public/favicon.svg +17 -0
- package/app/src/layouts/Deck.astro +281 -0
- package/app/src/lib/deck.js +272 -0
- package/app/src/pages/index.astro +14 -0
- package/app/src/pages/print.astro +11 -0
- package/app/src/themes/angular.css +407 -0
- package/bin/cli.js +274 -0
- package/bin/templates/slides.md +22 -0
- package/package.json +63 -0
|
@@ -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 />
|