@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,407 @@
|
|
|
1
|
+
/* ─────────────────────────────────────────────
|
|
2
|
+
mdslides — angular theme (slide deck)
|
|
3
|
+
Palette inherited from mdstack's angular theme.
|
|
4
|
+
───────────────────────────────────────────── */
|
|
5
|
+
|
|
6
|
+
:root {
|
|
7
|
+
--grad: linear-gradient(135deg, #ff0080 0%, #ff4500 50%, #ffa500 100%);
|
|
8
|
+
|
|
9
|
+
/* Dark (default) */
|
|
10
|
+
--bg: #0d0d0d;
|
|
11
|
+
--bg-2: #131314;
|
|
12
|
+
--surface: #1a1a1c;
|
|
13
|
+
--surface-2: #1f1f22;
|
|
14
|
+
--border: rgba(255,255,255,0.10);
|
|
15
|
+
--border-strong: rgba(255,255,255,0.18);
|
|
16
|
+
--text: #ededed;
|
|
17
|
+
--text-muted: #9ca3af;
|
|
18
|
+
--text-dim: #6b7280;
|
|
19
|
+
--code-bg: rgba(255,255,255,0.08);
|
|
20
|
+
--code-block-bg: #08080a;
|
|
21
|
+
|
|
22
|
+
--accent: #ff7a3c;
|
|
23
|
+
--accent-2: #ff4d6a;
|
|
24
|
+
--link: #ff9248;
|
|
25
|
+
--mark-bg: rgba(255,165,0,0.22);
|
|
26
|
+
--mark-text: #ffd9a8;
|
|
27
|
+
|
|
28
|
+
--canvas-w: 1280px;
|
|
29
|
+
--canvas-h: 720px;
|
|
30
|
+
|
|
31
|
+
--font: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', Roboto, system-ui, sans-serif;
|
|
32
|
+
--mono: 'JetBrains Mono', 'SF Mono', Menlo, Consolas, monospace;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
[data-theme="light"] {
|
|
36
|
+
--bg: #ffffff;
|
|
37
|
+
--bg-2: #fafafa;
|
|
38
|
+
--surface: #f5f5f6;
|
|
39
|
+
--surface-2: #ededf0;
|
|
40
|
+
--border: rgba(0,0,0,0.10);
|
|
41
|
+
--border-strong: rgba(0,0,0,0.18);
|
|
42
|
+
--text: #18181b;
|
|
43
|
+
--text-muted: #52525b;
|
|
44
|
+
--text-dim: #8a8a93;
|
|
45
|
+
--code-bg: rgba(0,0,0,0.06);
|
|
46
|
+
--code-block-bg: #0f1115;
|
|
47
|
+
--mark-bg: rgba(255,120,0,0.20);
|
|
48
|
+
--mark-text: #7a3a00;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
52
|
+
html, body { height: 100%; }
|
|
53
|
+
body {
|
|
54
|
+
font-family: var(--font);
|
|
55
|
+
background: var(--bg);
|
|
56
|
+
color: var(--text);
|
|
57
|
+
overflow: hidden;
|
|
58
|
+
-webkit-font-smoothing: antialiased;
|
|
59
|
+
}
|
|
60
|
+
body.blacked .deck { opacity: 0; }
|
|
61
|
+
|
|
62
|
+
/* ── Deck / fixed-canvas framing ── */
|
|
63
|
+
.deck {
|
|
64
|
+
position: fixed;
|
|
65
|
+
inset: 0;
|
|
66
|
+
background:
|
|
67
|
+
radial-gradient(1200px 600px at 80% -10%, rgba(255,69,0,0.10), transparent 60%),
|
|
68
|
+
var(--bg);
|
|
69
|
+
overflow: hidden;
|
|
70
|
+
}
|
|
71
|
+
.stage {
|
|
72
|
+
position: absolute;
|
|
73
|
+
top: 50%;
|
|
74
|
+
left: 50%;
|
|
75
|
+
width: var(--canvas-w);
|
|
76
|
+
height: var(--canvas-h);
|
|
77
|
+
transform: translate(-50%, -50%);
|
|
78
|
+
transform-origin: center center;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.slide {
|
|
82
|
+
position: absolute;
|
|
83
|
+
inset: 0;
|
|
84
|
+
display: flex;
|
|
85
|
+
background: var(--slide-bg, transparent);
|
|
86
|
+
opacity: 0;
|
|
87
|
+
visibility: hidden;
|
|
88
|
+
pointer-events: none;
|
|
89
|
+
}
|
|
90
|
+
.slide.active { opacity: 1; visibility: visible; pointer-events: auto; }
|
|
91
|
+
|
|
92
|
+
/* transitions */
|
|
93
|
+
[data-transition="fade"] .slide { transition: opacity .35s ease; }
|
|
94
|
+
[data-transition="slide"] .slide {
|
|
95
|
+
transition: opacity .35s ease, transform .35s ease;
|
|
96
|
+
transform: translateX(60px);
|
|
97
|
+
}
|
|
98
|
+
[data-transition="slide"] .slide.active { transform: translateX(0); }
|
|
99
|
+
[data-transition="slide"] .slide[data-state="past"] { transform: translateX(-60px); }
|
|
100
|
+
|
|
101
|
+
.slide-inner {
|
|
102
|
+
position: relative;
|
|
103
|
+
width: 100%;
|
|
104
|
+
height: 100%;
|
|
105
|
+
padding: 72px 92px;
|
|
106
|
+
display: flex;
|
|
107
|
+
flex-direction: column;
|
|
108
|
+
justify-content: center;
|
|
109
|
+
gap: 0.7em;
|
|
110
|
+
}
|
|
111
|
+
.slide.is-center .slide-inner { align-items: center; text-align: center; }
|
|
112
|
+
|
|
113
|
+
/* ── Title slide ── */
|
|
114
|
+
.slide.is-title .slide-inner {
|
|
115
|
+
align-items: center;
|
|
116
|
+
justify-content: center;
|
|
117
|
+
text-align: center;
|
|
118
|
+
gap: 0.35em;
|
|
119
|
+
}
|
|
120
|
+
.slide.is-title h1 { font-size: 96px; line-height: 1.12; }
|
|
121
|
+
.slide.is-title h2 {
|
|
122
|
+
font-size: 34px;
|
|
123
|
+
font-weight: 500;
|
|
124
|
+
letter-spacing: -0.01em;
|
|
125
|
+
color: var(--text-muted);
|
|
126
|
+
max-width: 22ch;
|
|
127
|
+
}
|
|
128
|
+
/* Last line = byline (presenters · date), pinned to the bottom. */
|
|
129
|
+
.slide.is-title .slide-inner > p:last-child {
|
|
130
|
+
position: absolute;
|
|
131
|
+
left: 92px;
|
|
132
|
+
right: 92px;
|
|
133
|
+
bottom: 64px;
|
|
134
|
+
margin: 0;
|
|
135
|
+
font-size: 24px;
|
|
136
|
+
color: var(--text-dim);
|
|
137
|
+
}
|
|
138
|
+
.slide.is-title .slide-inner > p:last-child strong { color: var(--text-muted); }
|
|
139
|
+
|
|
140
|
+
/* ── Content slide: fixed top-left title, text left / media right ── */
|
|
141
|
+
.slide.is-content .slide-inner { justify-content: flex-start; gap: 0; }
|
|
142
|
+
.slide-head { flex: 0 0 auto; margin-bottom: 44px; }
|
|
143
|
+
.slide-head h1 { font-size: 56px; text-align: left; }
|
|
144
|
+
.slide-body {
|
|
145
|
+
flex: 1 1 auto;
|
|
146
|
+
min-height: 0;
|
|
147
|
+
display: grid;
|
|
148
|
+
grid-template-columns: 1fr 1fr;
|
|
149
|
+
gap: 56px;
|
|
150
|
+
align-items: center;
|
|
151
|
+
}
|
|
152
|
+
.slide-body.no-media { grid-template-columns: 1fr; }
|
|
153
|
+
.slide-body.media-only { grid-template-columns: 1fr; justify-items: center; }
|
|
154
|
+
.col-left { align-self: center; display: flex; flex-direction: column; gap: 0.7em; min-width: 0; }
|
|
155
|
+
.col-right {
|
|
156
|
+
align-self: center;
|
|
157
|
+
display: flex; flex-direction: column;
|
|
158
|
+
align-items: center; justify-content: center; gap: 20px;
|
|
159
|
+
min-width: 0; max-height: 100%;
|
|
160
|
+
}
|
|
161
|
+
.slide-body.no-media .col-left { max-width: 60ch; }
|
|
162
|
+
.slide-body h2 { font-size: 34px; }
|
|
163
|
+
.slide-body h3 { font-size: 27px; }
|
|
164
|
+
.col-right :is(img, pre, table) { width: 100%; }
|
|
165
|
+
.col-right img { max-height: 540px; object-fit: contain; }
|
|
166
|
+
.col-right pre { font-size: 20px; }
|
|
167
|
+
.col-right .katex-display { margin: 0; }
|
|
168
|
+
|
|
169
|
+
/* ── Prose ── */
|
|
170
|
+
.slide-inner > :first-child { margin-top: 0; }
|
|
171
|
+
h1, h2, h3 { line-height: 1.08; letter-spacing: -0.02em; font-weight: 700; }
|
|
172
|
+
h1 {
|
|
173
|
+
font-size: 72px;
|
|
174
|
+
background: var(--grad);
|
|
175
|
+
-webkit-background-clip: text;
|
|
176
|
+
background-clip: text;
|
|
177
|
+
/* Solid fallback so the title is never invisible if clip-to-text is
|
|
178
|
+
unsupported (notably some print/PDF engines). */
|
|
179
|
+
color: var(--accent);
|
|
180
|
+
-webkit-text-fill-color: transparent;
|
|
181
|
+
/* clip-to-text crops the gradient to the line box, so a tight line-height
|
|
182
|
+
shears off descenders (the tail of g/y/p). Give the box headroom. */
|
|
183
|
+
line-height: 1.18;
|
|
184
|
+
padding-bottom: 0.08em;
|
|
185
|
+
}
|
|
186
|
+
h2 { font-size: 52px; }
|
|
187
|
+
h3 { font-size: 38px; color: var(--text-muted); font-weight: 600; }
|
|
188
|
+
p, li { font-size: 30px; line-height: 1.45; color: var(--text); }
|
|
189
|
+
.slide-inner > p { color: var(--text-muted); }
|
|
190
|
+
|
|
191
|
+
strong { color: #fff; font-weight: 700; }
|
|
192
|
+
[data-theme="light"] strong { color: #000; }
|
|
193
|
+
em { font-style: italic; }
|
|
194
|
+
del { color: var(--text-dim); }
|
|
195
|
+
a { color: var(--link); text-decoration: none; border-bottom: 2px solid color-mix(in srgb, var(--link) 40%, transparent); }
|
|
196
|
+
mark { background: var(--mark-bg); color: var(--mark-text); padding: 0 .18em; border-radius: 4px; }
|
|
197
|
+
|
|
198
|
+
ul, ol { padding-left: 1.3em; display: flex; flex-direction: column; gap: .45em; }
|
|
199
|
+
li { padding-left: .2em; }
|
|
200
|
+
li::marker { color: var(--accent); }
|
|
201
|
+
|
|
202
|
+
blockquote {
|
|
203
|
+
border-left: 4px solid var(--accent);
|
|
204
|
+
padding: .2em 0 .2em 1em;
|
|
205
|
+
color: var(--text-muted);
|
|
206
|
+
font-style: italic;
|
|
207
|
+
font-size: 32px;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
code {
|
|
211
|
+
font-family: var(--mono);
|
|
212
|
+
font-size: .9em;
|
|
213
|
+
background: var(--code-bg);
|
|
214
|
+
padding: .12em .35em;
|
|
215
|
+
border-radius: 6px;
|
|
216
|
+
}
|
|
217
|
+
pre {
|
|
218
|
+
position: relative;
|
|
219
|
+
background: var(--code-block-bg);
|
|
220
|
+
border: 1px solid var(--border);
|
|
221
|
+
border-radius: 12px;
|
|
222
|
+
padding: 28px 32px;
|
|
223
|
+
overflow: auto;
|
|
224
|
+
font-size: 24px;
|
|
225
|
+
line-height: 1.5;
|
|
226
|
+
}
|
|
227
|
+
pre code { background: none; padding: 0; font-size: 1em; color: #e6e6e6; }
|
|
228
|
+
|
|
229
|
+
table { border-collapse: collapse; font-size: 26px; }
|
|
230
|
+
th, td { border: 1px solid var(--border); padding: 10px 18px; text-align: left; }
|
|
231
|
+
th { background: var(--surface); font-weight: 700; }
|
|
232
|
+
|
|
233
|
+
img { max-width: 100%; max-height: 460px; border-radius: 12px; display: block; }
|
|
234
|
+
.slide.is-center img { margin-inline: auto; }
|
|
235
|
+
hr { border: none; border-top: 1px solid var(--border); width: 100%; }
|
|
236
|
+
|
|
237
|
+
.katex { font-size: 1.05em; }
|
|
238
|
+
|
|
239
|
+
/* ── Fragments ── */
|
|
240
|
+
.fragment { opacity: 0; transition: opacity .3s ease, transform .3s ease; transform: translateY(6px); }
|
|
241
|
+
.fragment.revealed { opacity: 1; transform: none; }
|
|
242
|
+
.deck.overview .fragment, html.print .fragment { opacity: 1; transform: none; }
|
|
243
|
+
|
|
244
|
+
/* ── Auto fade-in on slide open ──
|
|
245
|
+
Every block of content fades up when its slide becomes active — except
|
|
246
|
+
lists (ul/ol), which stay put, and manual-reveal `.fragment`s, which keep
|
|
247
|
+
their own step-by-step timing. A light stagger makes it read as intentional.
|
|
248
|
+
The animation re-runs each time you land on the slide (class toggles off
|
|
249
|
+
and back on), and is skipped in overview / print where everything shows at
|
|
250
|
+
once. */
|
|
251
|
+
@keyframes mds-enter {
|
|
252
|
+
from { opacity: 0; transform: translateY(10px); }
|
|
253
|
+
to { opacity: 1; transform: none; }
|
|
254
|
+
}
|
|
255
|
+
.slide.active :is(.slide-inner, .slide-head, .col-left, .col-right)
|
|
256
|
+
> :is(h1, h2, h3, h4, h5, h6, p, blockquote, pre, table, figure, img, hr, .katex-display):not(.fragment) {
|
|
257
|
+
animation: mds-enter .5s cubic-bezier(.21, .6, .35, 1) both;
|
|
258
|
+
}
|
|
259
|
+
/* Light stagger across the first several blocks in each container. */
|
|
260
|
+
.slide.active :is(.slide-inner, .slide-head, .col-left, .col-right) > :nth-child(2) { animation-delay: .06s; }
|
|
261
|
+
.slide.active :is(.slide-inner, .slide-head, .col-left, .col-right) > :nth-child(3) { animation-delay: .12s; }
|
|
262
|
+
.slide.active :is(.slide-inner, .slide-head, .col-left, .col-right) > :nth-child(4) { animation-delay: .18s; }
|
|
263
|
+
.slide.active :is(.slide-inner, .slide-head, .col-left, .col-right) > :nth-child(5) { animation-delay: .24s; }
|
|
264
|
+
.slide.active :is(.slide-inner, .slide-head, .col-left, .col-right) > :nth-child(n+6) { animation-delay: .3s; }
|
|
265
|
+
|
|
266
|
+
.deck.overview .slide :is(.slide-inner, .slide-head, .col-left, .col-right) > * { animation: none !important; }
|
|
267
|
+
@media (prefers-reduced-motion: reduce) {
|
|
268
|
+
.slide.active :is(.slide-inner, .slide-head, .col-left, .col-right) > * { animation: none !important; }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/* ── Chrome (progress + controls) ── */
|
|
272
|
+
.progress {
|
|
273
|
+
position: fixed; left: 0; right: 0; bottom: 0; height: 3px;
|
|
274
|
+
background: var(--border);
|
|
275
|
+
z-index: 30;
|
|
276
|
+
}
|
|
277
|
+
.progress-bar { display: block; height: 100%; width: 0; background: var(--grad); transition: width .3s ease; }
|
|
278
|
+
.chrome-bar {
|
|
279
|
+
position: fixed; left: 0; right: 0; bottom: 0; height: 0;
|
|
280
|
+
display: flex; align-items: flex-end; justify-content: space-between;
|
|
281
|
+
padding: 18px 24px; z-index: 31;
|
|
282
|
+
pointer-events: none;
|
|
283
|
+
}
|
|
284
|
+
.chrome-bar .brand, .chrome-bar .controls { pointer-events: auto; }
|
|
285
|
+
.brand { font-size: 14px; font-weight: 600; color: var(--text-dim); letter-spacing: .02em; }
|
|
286
|
+
/* Bottom-right: only the slide count shows by default; the buttons fan out
|
|
287
|
+
when you hover the cluster. */
|
|
288
|
+
.controls { display: flex; align-items: center; gap: 10px; }
|
|
289
|
+
.ctl-group {
|
|
290
|
+
display: flex; align-items: center; gap: 6px;
|
|
291
|
+
max-width: 0; overflow: hidden; opacity: 0;
|
|
292
|
+
transform: translateX(8px);
|
|
293
|
+
transition: max-width .25s ease, opacity .2s ease, transform .25s ease;
|
|
294
|
+
pointer-events: none;
|
|
295
|
+
}
|
|
296
|
+
.controls:hover .ctl-group { max-width: 280px; opacity: 1; transform: none; pointer-events: auto; }
|
|
297
|
+
.ctl {
|
|
298
|
+
width: 32px; height: 32px;
|
|
299
|
+
display: grid; place-items: center;
|
|
300
|
+
font-size: 18px; line-height: 1;
|
|
301
|
+
color: var(--text-muted);
|
|
302
|
+
background: color-mix(in srgb, var(--surface) 80%, transparent);
|
|
303
|
+
border: 1px solid var(--border);
|
|
304
|
+
border-radius: 8px;
|
|
305
|
+
cursor: pointer;
|
|
306
|
+
backdrop-filter: blur(8px);
|
|
307
|
+
}
|
|
308
|
+
.ctl:hover { color: var(--text); border-color: var(--border-strong); }
|
|
309
|
+
.ctl.busy { opacity: .5; pointer-events: none; animation: ctl-pulse 1s ease-in-out infinite; }
|
|
310
|
+
@keyframes ctl-pulse { 50% { opacity: .25; } }
|
|
311
|
+
.counter { font: 600 14px var(--mono); color: var(--text-muted); padding: 0 8px; }
|
|
312
|
+
|
|
313
|
+
.help {
|
|
314
|
+
position: fixed; left: 50%; bottom: 56px; transform: translateX(-50%);
|
|
315
|
+
background: var(--surface); border: 1px solid var(--border);
|
|
316
|
+
border-radius: 10px; padding: 10px 16px; font-size: 14px; color: var(--text-muted);
|
|
317
|
+
z-index: 40;
|
|
318
|
+
}
|
|
319
|
+
.help kbd {
|
|
320
|
+
font: 600 12px var(--mono); background: var(--surface-2);
|
|
321
|
+
border: 1px solid var(--border); border-bottom-width: 2px;
|
|
322
|
+
border-radius: 5px; padding: 1px 6px; margin: 0 2px; color: var(--text);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/* ── Overview / grid ── */
|
|
326
|
+
.deck.overview { overflow: auto; }
|
|
327
|
+
.deck.overview .stage {
|
|
328
|
+
position: static; transform: none !important;
|
|
329
|
+
width: 100%; height: 100%;
|
|
330
|
+
display: flex; flex-wrap: wrap; gap: 22px;
|
|
331
|
+
align-content: flex-start; justify-content: center;
|
|
332
|
+
padding: 44px;
|
|
333
|
+
}
|
|
334
|
+
.deck.overview .slide {
|
|
335
|
+
display: block; /* drop the flex context so the inner can't shrink */
|
|
336
|
+
position: relative; inset: auto;
|
|
337
|
+
flex: 0 0 auto;
|
|
338
|
+
width: 320px; height: 180px;
|
|
339
|
+
opacity: 1 !important; visibility: visible !important; pointer-events: auto;
|
|
340
|
+
transform: none !important; /* override transition transforms */
|
|
341
|
+
overflow: hidden; cursor: pointer;
|
|
342
|
+
border: 2px solid var(--border);
|
|
343
|
+
border-radius: 10px;
|
|
344
|
+
transition: border-color .15s;
|
|
345
|
+
}
|
|
346
|
+
.deck.overview .slide:hover { border-color: var(--border-strong); }
|
|
347
|
+
.deck.overview .slide.active { border-color: var(--accent); }
|
|
348
|
+
.deck.overview .slide-inner {
|
|
349
|
+
flex: none; flex-shrink: 0;
|
|
350
|
+
width: var(--canvas-w); height: var(--canvas-h);
|
|
351
|
+
transform: scale(0.25); transform-origin: top left;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/* ── Print / PDF: one page per slide ── */
|
|
355
|
+
html.print, html.print body {
|
|
356
|
+
overflow: visible;
|
|
357
|
+
background: #000;
|
|
358
|
+
-webkit-print-color-adjust: exact;
|
|
359
|
+
print-color-adjust: exact;
|
|
360
|
+
}
|
|
361
|
+
.print-deck { display: block; }
|
|
362
|
+
.print-deck .slide { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
|
363
|
+
|
|
364
|
+
/* Gradient clip-to-text (-webkit-background-clip: text) doesn't survive PDF /
|
|
365
|
+
print rendering — the gradient paints as a filled box, so titles show up as
|
|
366
|
+
black/orange bars. Render headings as a clean solid color when printing. */
|
|
367
|
+
html.print h1 {
|
|
368
|
+
background: none;
|
|
369
|
+
-webkit-background-clip: border-box;
|
|
370
|
+
background-clip: border-box;
|
|
371
|
+
color: var(--accent);
|
|
372
|
+
-webkit-text-fill-color: var(--accent);
|
|
373
|
+
}
|
|
374
|
+
.print-bar {
|
|
375
|
+
position: fixed; top: 16px; right: 16px; z-index: 100;
|
|
376
|
+
display: flex; gap: 10px; align-items: center;
|
|
377
|
+
}
|
|
378
|
+
.print-bar a, .print-bar button {
|
|
379
|
+
font: 600 14px var(--font);
|
|
380
|
+
border-radius: 9px; padding: 9px 16px; cursor: pointer;
|
|
381
|
+
border: 1px solid var(--border-strong); text-decoration: none;
|
|
382
|
+
color: var(--text); background: var(--surface);
|
|
383
|
+
}
|
|
384
|
+
.print-bar button { background: var(--accent); color: #fff; border-color: transparent; }
|
|
385
|
+
.print-bar a:hover { border-color: var(--text-dim); }
|
|
386
|
+
.print-bar button:hover { filter: brightness(1.08); }
|
|
387
|
+
@media print { .print-bar { display: none !important; } }
|
|
388
|
+
.print-deck .slide {
|
|
389
|
+
position: relative; inset: auto;
|
|
390
|
+
width: var(--canvas-w); height: var(--canvas-h);
|
|
391
|
+
opacity: 1; visibility: visible;
|
|
392
|
+
page-break-after: always; break-after: page;
|
|
393
|
+
background: var(--slide-bg, var(--bg));
|
|
394
|
+
}
|
|
395
|
+
@page { size: 1280px 720px; margin: 0; }
|
|
396
|
+
@media print {
|
|
397
|
+
* { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
|
|
398
|
+
html, body { background: #000 !important; }
|
|
399
|
+
h1 {
|
|
400
|
+
background: none !important;
|
|
401
|
+
-webkit-background-clip: border-box !important;
|
|
402
|
+
background-clip: border-box !important;
|
|
403
|
+
color: var(--accent) !important;
|
|
404
|
+
-webkit-text-fill-color: var(--accent) !important;
|
|
405
|
+
}
|
|
406
|
+
.chrome, .progress, .help, .print-bar { display: none !important; }
|
|
407
|
+
}
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
3
|
+
import { dirname, resolve, isAbsolute, relative, basename, extname } from 'node:path';
|
|
4
|
+
import { existsSync, statSync, writeFileSync, readFileSync, mkdirSync, readdirSync } from 'node:fs';
|
|
5
|
+
import { createInterface } from 'node:readline';
|
|
6
|
+
import { findChrome, printUrlToPdf } from '../app/integrations/pdf-export.js';
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = dirname(__filename);
|
|
10
|
+
const APP_ROOT = resolve(__dirname, '..', 'app');
|
|
11
|
+
|
|
12
|
+
const VALID_COMMANDS = new Set(['dev', 'build', 'preview', 'export']);
|
|
13
|
+
const VALID_THEMES = new Set(['angular']);
|
|
14
|
+
const DEFAULT_THEME = 'angular';
|
|
15
|
+
const CONFIG_FILENAME = 'mdslides.config.js';
|
|
16
|
+
|
|
17
|
+
const DEFAULT_CONFIG = `// mdslides deck config — edit to customize the look of your deck.
|
|
18
|
+
// Re-run mdslides after saving to apply changes.
|
|
19
|
+
|
|
20
|
+
export default {
|
|
21
|
+
brand: {
|
|
22
|
+
// Wordmark shown in the bottom-left corner. Set to '' to hide.
|
|
23
|
+
text: 'mdslides',
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
// Theme. Built-in: 'angular' (default).
|
|
27
|
+
theme: 'angular',
|
|
28
|
+
|
|
29
|
+
// Slide transition: 'fade' | 'slide' | 'none'.
|
|
30
|
+
transition: 'fade',
|
|
31
|
+
};
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
function fail(msg) {
|
|
35
|
+
console.error(`mdslides: ${msg}`);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseArgs(argv) {
|
|
40
|
+
const args = argv.slice(2);
|
|
41
|
+
let cmd = 'dev';
|
|
42
|
+
let theme = null;
|
|
43
|
+
let file;
|
|
44
|
+
|
|
45
|
+
if (args[0] && VALID_COMMANDS.has(args[0])) {
|
|
46
|
+
cmd = args.shift();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (let i = 0; i < args.length; i++) {
|
|
50
|
+
const a = args[i];
|
|
51
|
+
if (a === '--theme' || a === '-t') {
|
|
52
|
+
theme = args[++i];
|
|
53
|
+
if (!theme) fail('--theme requires a value');
|
|
54
|
+
} else if (a.startsWith('--theme=')) {
|
|
55
|
+
theme = a.slice('--theme='.length);
|
|
56
|
+
} else if (!file) {
|
|
57
|
+
file = a;
|
|
58
|
+
} else {
|
|
59
|
+
fail(`Unexpected argument: ${a}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!file) {
|
|
64
|
+
fail('Missing target.\nUsage: mdslides [dev|build|preview|export] [--theme <name>] <folder|file.md>');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { cmd, theme, file };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The primary model: point at a folder. mdslides looks for an existing deck
|
|
71
|
+
// (slides.md / index.md / any .md), and otherwise scaffolds a new project —
|
|
72
|
+
// asking for a name and dropping a `<name>.md` starter (3 sample slides) you
|
|
73
|
+
// then edit. A direct path to a .md file is also accepted (scaffolded as-is).
|
|
74
|
+
async function resolveDeck(input) {
|
|
75
|
+
let abs = isAbsolute(input) ? input : resolve(process.cwd(), input);
|
|
76
|
+
|
|
77
|
+
if (existsSync(abs) && statSync(abs).isDirectory()) {
|
|
78
|
+
abs = findDeckInDir(abs) ?? (await scaffoldProject(abs));
|
|
79
|
+
} else if (!existsSync(abs)) {
|
|
80
|
+
// A non-existent path: a `.md` file gets scaffolded under that exact name;
|
|
81
|
+
// anything else is treated as a new project folder to create a deck in.
|
|
82
|
+
abs = extname(abs).toLowerCase() === '.md'
|
|
83
|
+
? scaffoldDeck(abs)
|
|
84
|
+
: await scaffoldProject(abs);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!existsSync(abs) || !statSync(abs).isFile()) {
|
|
88
|
+
fail(`Deck not found: ${abs}`);
|
|
89
|
+
}
|
|
90
|
+
return abs;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Find an existing deck in a folder: prefer slides.md / index.md, then fall
|
|
94
|
+
// back to the first .md file present (so a `chemistry.md` is picked up too).
|
|
95
|
+
function findDeckInDir(dir) {
|
|
96
|
+
for (const name of ['slides.md', 'index.md']) {
|
|
97
|
+
const p = resolve(dir, name);
|
|
98
|
+
if (existsSync(p)) return p;
|
|
99
|
+
}
|
|
100
|
+
const md = readdirSync(dir).filter((f) => f.toLowerCase().endsWith('.md')).sort();
|
|
101
|
+
return md.length ? resolve(dir, md[0]) : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Turn a free-text presentation name into a safe filename stem.
|
|
105
|
+
function slugify(name) {
|
|
106
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Ask a question on the terminal, returning the trimmed answer (or `def` if
|
|
110
|
+
// there's no answer / no interactive TTY, e.g. when run in CI).
|
|
111
|
+
function ask(question, def) {
|
|
112
|
+
if (!process.stdin.isTTY) return Promise.resolve(def);
|
|
113
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
114
|
+
return new Promise((res) => {
|
|
115
|
+
let done = false;
|
|
116
|
+
const finish = (val) => { if (!done) { done = true; rl.close(); res(val); } };
|
|
117
|
+
rl.question(question, (ans) => finish(ans.trim() || def));
|
|
118
|
+
// EOF (piped/closed stdin) → fall back to the default instead of hanging.
|
|
119
|
+
rl.on('close', () => finish(def));
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Scaffold a new project inside `dir`: prompt for a presentation name and
|
|
124
|
+
// write `<name>.md` with the starter deck. The folder's own name is the
|
|
125
|
+
// default, so `mdslides ./chemistry` + Enter → chemistry/chemistry.md.
|
|
126
|
+
async function scaffoldProject(dir) {
|
|
127
|
+
const fallback = slugify(basename(dir)) || 'slides';
|
|
128
|
+
const answer = await ask(`Name your presentation (${fallback}): `, fallback);
|
|
129
|
+
const stem = slugify(answer.replace(/\.md$/i, '')) || fallback;
|
|
130
|
+
return scaffoldDeck(resolve(dir, `${stem}.md`));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function scaffoldDeck(deckPath) {
|
|
134
|
+
const templatePath = resolve(__dirname, 'templates', 'slides.md');
|
|
135
|
+
try {
|
|
136
|
+
mkdirSync(dirname(deckPath), { recursive: true });
|
|
137
|
+
const content = readFileSync(templatePath, 'utf8');
|
|
138
|
+
writeFileSync(deckPath, content, 'utf8');
|
|
139
|
+
const rel = relative(process.cwd(), deckPath) || deckPath;
|
|
140
|
+
console.log(`mdslides: created ${rel} (3 sample slides) — edit it and the deck live-reloads`);
|
|
141
|
+
return deckPath;
|
|
142
|
+
} catch (err) {
|
|
143
|
+
fail(`couldn't create a starter deck: ${err.message}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function ensureDefaultConfig(source) {
|
|
148
|
+
const configPath = resolve(source, CONFIG_FILENAME);
|
|
149
|
+
if (existsSync(configPath)) return;
|
|
150
|
+
try {
|
|
151
|
+
writeFileSync(configPath, DEFAULT_CONFIG, 'utf8');
|
|
152
|
+
const rel = relative(process.cwd(), configPath) || configPath;
|
|
153
|
+
console.log(`mdslides: created ${rel} with defaults — edit it to customize`);
|
|
154
|
+
} catch (err) {
|
|
155
|
+
console.warn(`mdslides: couldn't write default config: ${err.message}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function loadUserConfig(source) {
|
|
160
|
+
const configPath = resolve(source, CONFIG_FILENAME);
|
|
161
|
+
if (!existsSync(configPath)) return {};
|
|
162
|
+
try {
|
|
163
|
+
const mod = await import(pathToFileURL(configPath).href);
|
|
164
|
+
return mod.default ?? {};
|
|
165
|
+
} catch (err) {
|
|
166
|
+
console.warn(`mdslides: couldn't load ${CONFIG_FILENAME}: ${err.message}`);
|
|
167
|
+
return {};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const { cmd, theme: cliTheme, file } = parseArgs(process.argv);
|
|
172
|
+
const deckFile = await resolveDeck(file);
|
|
173
|
+
const source = dirname(deckFile);
|
|
174
|
+
|
|
175
|
+
ensureDefaultConfig(source);
|
|
176
|
+
const userConfig = await loadUserConfig(source);
|
|
177
|
+
|
|
178
|
+
const theme = cliTheme || userConfig.theme || DEFAULT_THEME;
|
|
179
|
+
if (!VALID_THEMES.has(theme)) fail(`Unknown theme: ${theme}. Available: ${[...VALID_THEMES].join(', ')}`);
|
|
180
|
+
|
|
181
|
+
process.env.MD_SOURCE = source;
|
|
182
|
+
process.env.MD_FILE = deckFile;
|
|
183
|
+
process.env.MD_THEME = theme;
|
|
184
|
+
|
|
185
|
+
const astro = await import('astro');
|
|
186
|
+
const outDir = resolve(source, 'dist');
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
if (cmd === 'dev') {
|
|
190
|
+
await astro.dev({ root: APP_ROOT });
|
|
191
|
+
} else if (cmd === 'build') {
|
|
192
|
+
await astro.build({ root: APP_ROOT, outDir });
|
|
193
|
+
console.log(`\nmdslides: built to ${outDir}`);
|
|
194
|
+
} else if (cmd === 'preview') {
|
|
195
|
+
await astro.preview({ root: APP_ROOT, outDir });
|
|
196
|
+
} else if (cmd === 'export') {
|
|
197
|
+
await astro.build({ root: APP_ROOT, outDir });
|
|
198
|
+
await exportPdf({ outDir, deckFile });
|
|
199
|
+
process.exit(0);
|
|
200
|
+
}
|
|
201
|
+
} catch (err) {
|
|
202
|
+
console.error(err);
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// PDF export: serve the built deck on a throwaway local server and print the
|
|
207
|
+
// /print route (one slide per page) to PDF with headless Chrome. Falls back to
|
|
208
|
+
// Playwright if present, then to manual instructions.
|
|
209
|
+
async function exportPdf({ outDir, deckFile }) {
|
|
210
|
+
const pdfPath = resolve(source, basename(deckFile).replace(/\.md$/i, '') + '.pdf');
|
|
211
|
+
const printHtml = resolve(outDir, 'print', 'index.html');
|
|
212
|
+
if (!existsSync(printHtml)) fail('print route was not built — cannot export.');
|
|
213
|
+
|
|
214
|
+
const chrome = findChrome();
|
|
215
|
+
const hasPlaywright = await canImport('playwright');
|
|
216
|
+
|
|
217
|
+
if (!chrome && !hasPlaywright) {
|
|
218
|
+
console.log(
|
|
219
|
+
'\nmdslides: no headless browser found — skipping automatic PDF.\n' +
|
|
220
|
+
' Option A: install Google Chrome (or set CHROME_PATH to a Chromium binary), then re-run export.\n' +
|
|
221
|
+
' Option B: npm i -D playwright && npx playwright install chromium, then re-run export.\n' +
|
|
222
|
+
` Option C: npx mdslides preview ${relative(process.cwd(), deckFile)}, open /print, and use the browser's "Save as PDF".`
|
|
223
|
+
);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Throwaway preview server on a random free port so /_astro, /images, and
|
|
228
|
+
// KaTeX fonts resolve over http (file:// would break absolute asset paths).
|
|
229
|
+
const server = await astro.preview({
|
|
230
|
+
root: APP_ROOT,
|
|
231
|
+
outDir,
|
|
232
|
+
logLevel: 'error',
|
|
233
|
+
server: { host: 'localhost', port: 0 },
|
|
234
|
+
});
|
|
235
|
+
const port = server.port ?? 4321;
|
|
236
|
+
const url = `http://localhost:${port}/print/`;
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
if (chrome) {
|
|
240
|
+
await printUrlToPdf(chrome, url, pdfPath);
|
|
241
|
+
} else {
|
|
242
|
+
await playwrightPdf(url, pdfPath);
|
|
243
|
+
}
|
|
244
|
+
console.log(`\nmdslides: exported ${relative(process.cwd(), pdfPath)}`);
|
|
245
|
+
} catch (err) {
|
|
246
|
+
console.error(`\nmdslides: PDF export failed — ${err.message}`);
|
|
247
|
+
} finally {
|
|
248
|
+
// Fire-and-forget: the preview server can keep the event loop alive and
|
|
249
|
+
// its stop() may not settle, so don't await it — the caller's
|
|
250
|
+
// process.exit(0) tears everything down cleanly right after.
|
|
251
|
+
try { server.stop?.(); } catch {}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function canImport(spec) {
|
|
256
|
+
try {
|
|
257
|
+
await import(spec);
|
|
258
|
+
return true;
|
|
259
|
+
} catch {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function playwrightPdf(url, pdfPath) {
|
|
265
|
+
const { chromium } = await import('playwright');
|
|
266
|
+
const browser = await chromium.launch();
|
|
267
|
+
try {
|
|
268
|
+
const page = await browser.newPage();
|
|
269
|
+
await page.goto(url, { waitUntil: 'networkidle' });
|
|
270
|
+
await page.pdf({ path: pdfPath, width: '1280px', height: '720px', printBackground: true });
|
|
271
|
+
} finally {
|
|
272
|
+
await browser.close();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Your presentation title
|
|
2
|
+
|
|
3
|
+
## A subtitle that frames the talk
|
|
4
|
+
|
|
5
|
+
**Your Name** · {{date}}
|
|
6
|
+
|
|
7
|
+
# Your first slide
|
|
8
|
+
|
|
9
|
+
Every `#` heading starts a new slide — write plain markdown in between.
|
|
10
|
+
|
|
11
|
+
- Bullets reveal one at a time
|
|
12
|
+
- Press `space` or `→` to advance, `←` to go back
|
|
13
|
+
- Mix in **bold**, *italic*, `code`, and ==highlights==
|
|
14
|
+
|
|
15
|
+
# Need help?
|
|
16
|
+
|
|
17
|
+
Drive the deck from the keyboard — `→` `space` next, `←` back, `O` overview,
|
|
18
|
+
`F` fullscreen, `P` PDF, and `?` for the full key list.
|
|
19
|
+
|
|
20
|
+
- Edit **slides.md** and the deck live-reloads
|
|
21
|
+
- Set the theme, brand and transition in **mdslides.config.js**
|
|
22
|
+
- Full docs → **github.com/eXor404/mdslides**
|