Skip to main content

surf_parse/
render_slides.rs

1//! Presentation deck rendering — Slides as a first-class SurfDoc output type.
2//!
3//! This mirrors the `::site`/`::page` website path in [`crate::render_html`]:
4//! a `::deck`/`::slide` block family is extracted into a [`DeckConfig`] plus a
5//! list of [`SlideEntry`], then rendered to a single self-contained HTML file
6//! using the canonical **`surf-dark`** reference theme (the shell ported from
7//! `decks/surf-platform-pitch.html` — top progress bar, keyboard/click/touch
8//! nav, fullscreen).
9//!
10//! Slide content reuses the existing block renderers ([`render_block`]) and the
11//! bundled [`crate::SURFDOC_CSS`], so a slide is an *arrangement of blocks that
12//! already exist* — no new content primitives. The deck chrome is layered on
13//! top.
14//!
15//! When a document has neither a `::deck` nor any `::slide`, every `#`/`##`
16//! heading boundary becomes a slide (Presentation Mode), so *any* SurfDoc is
17//! already a deck with zero edits.
18
19use crate::render_html::{escape_html, render_block};
20use crate::types::{Block, SlideLayout, StyleProperty};
21use crate::SurfDoc;
22
23/// Deck-level configuration extracted from a `::deck` block (peer of
24/// [`crate::SiteConfig`]).
25#[derive(Debug, Clone, Default)]
26pub struct DeckConfig {
27    pub theme: Option<String>,
28    pub aspect: Option<String>,
29    pub transition: Option<String>,
30    pub accent: Option<String>,
31    pub font: Option<String>,
32    pub title: Option<String>,
33    /// Custom footer text (defaults to the deck title).
34    pub footer: Option<String>,
35    /// Slide-number / progress chrome toggle (default on). Set `numbers: off`
36    /// (or `slide-numbers: false`) on `::deck` to hide the `n / N` counter.
37    pub numbers: Option<String>,
38    pub properties: Vec<StyleProperty>,
39}
40
41impl DeckConfig {
42    /// Resolve the theme name, defaulting to the canonical `surf-dark`.
43    pub fn theme_name(&self) -> &str {
44        self.theme.as_deref().unwrap_or("surf-dark")
45    }
46
47    /// Aspect ratio, defaulting to the canonical `16:9`.
48    pub fn aspect_ratio(&self) -> &str {
49        self.aspect.as_deref().unwrap_or("16:9")
50    }
51
52    /// Transition name, defaulting to `fade`.
53    pub fn transition_name(&self) -> &str {
54        self.transition.as_deref().unwrap_or("fade")
55    }
56
57    /// Whether to show the slide-number counter (default true; off when the
58    /// `numbers`/`slide-numbers` value is a falsy word).
59    pub fn show_numbers(&self) -> bool {
60        match self.numbers.as_deref() {
61            Some(v) => !matches!(
62                v.trim().to_ascii_lowercase().as_str(),
63                "off" | "false" | "no" | "none" | "0" | "hide" | "hidden"
64            ),
65            None => true,
66        }
67    }
68}
69
70/// A single slide extracted from a `::slide` block (or an auto-split section).
71#[derive(Debug, Clone)]
72pub struct SlideEntry {
73    pub layout: SlideLayout,
74    pub kicker: Option<String>,
75    pub notes: Option<String>,
76    pub children: Vec<Block>,
77}
78
79/// Extract deck config and slide list from a parsed [`SurfDoc`].
80///
81/// Returns `(deck_config, slides)`. If the document contains explicit
82/// `::slide` blocks they are used directly; otherwise the document body is
83/// auto-split into slides on `#`/`##` heading boundaries.
84pub fn extract_deck(doc: &SurfDoc) -> (DeckConfig, Vec<SlideEntry>) {
85    let mut config = DeckConfig::default();
86    let mut explicit: Vec<SlideEntry> = Vec::new();
87    let mut loose: Vec<Block> = Vec::new();
88
89    for block in &doc.blocks {
90        match block {
91            Block::Deck { properties, .. } => {
92                config.properties = properties.clone();
93                for prop in properties {
94                    match prop.key.as_str() {
95                        "theme" => config.theme = Some(prop.value.clone()),
96                        "aspect" => config.aspect = Some(prop.value.clone()),
97                        "transition" => config.transition = Some(prop.value.clone()),
98                        "accent" => config.accent = Some(prop.value.clone()),
99                        "font" => config.font = Some(prop.value.clone()),
100                        "title" => config.title = Some(prop.value.clone()),
101                        "footer" => config.footer = Some(prop.value.clone()),
102                        "numbers" | "slide-numbers" | "slide_numbers" => {
103                            config.numbers = Some(prop.value.clone())
104                        }
105                        _ => {}
106                    }
107                }
108            }
109            Block::Slide {
110                layout,
111                kicker,
112                notes,
113                children,
114                ..
115            } => {
116                explicit.push(SlideEntry {
117                    layout: layout.unwrap_or_default(),
118                    kicker: kicker.clone(),
119                    notes: notes.clone(),
120                    children: children.clone(),
121                });
122            }
123            other => loose.push(other.clone()),
124        }
125    }
126
127    // Title falls back to front-matter title.
128    if config.title.is_none()
129        && let Some(fm) = &doc.front_matter
130    {
131        config.title = fm.title.clone();
132    }
133
134    let slides = if !explicit.is_empty() {
135        explicit
136    } else {
137        auto_split(&loose)
138    };
139
140    (config, slides)
141}
142
143/// Auto-split loose top-level blocks into slides on heading boundaries.
144///
145/// A `# ` (H1) or `## ` (H2) line inside a [`Block::Markdown`] starts a new
146/// slide. H1-led slides get the [`SlideLayout::Cover`] layout; everything else
147/// is [`SlideLayout::Bullets`]. Non-markdown blocks attach to the current
148/// slide (or open one).
149fn auto_split(blocks: &[Block]) -> Vec<SlideEntry> {
150    let mut slides: Vec<SlideEntry> = Vec::new();
151    let mut current: Option<SlideEntry> = None;
152
153    for block in blocks {
154        match block {
155            Block::Markdown { content, span } => {
156                for (level, text) in split_md_sections(content) {
157                    let text = text.trim();
158                    if text.is_empty() {
159                        continue;
160                    }
161                    // A heading boundary opens a fresh slide.
162                    if level.is_some() {
163                        if let Some(done) = current.take() {
164                            slides.push(done);
165                        }
166                        current = Some(SlideEntry {
167                            layout: if level == Some(1) {
168                                SlideLayout::Cover
169                            } else {
170                                SlideLayout::Bullets
171                            },
172                            kicker: None,
173                            notes: None,
174                            children: Vec::new(),
175                        });
176                    } else if current.is_none() {
177                        current = Some(SlideEntry {
178                            layout: SlideLayout::Bullets,
179                            kicker: None,
180                            notes: None,
181                            children: Vec::new(),
182                        });
183                    }
184                    current.as_mut().unwrap().children.push(Block::Markdown {
185                        content: text.to_string(),
186                        span: *span,
187                    });
188                }
189            }
190            other => {
191                if current.is_none() {
192                    current = Some(SlideEntry {
193                        layout: SlideLayout::Bullets,
194                        kicker: None,
195                        notes: None,
196                        children: Vec::new(),
197                    });
198                }
199                current.as_mut().unwrap().children.push(other.clone());
200            }
201        }
202    }
203
204    if let Some(done) = current {
205        slides.push(done);
206    }
207    slides
208}
209
210/// Split a markdown string on `#`/`##` heading boundaries.
211///
212/// Returns `(heading_level, section_markdown)` pairs. The level is `Some(1)`
213/// for H1, `Some(2)` for H2, and `None` for a lead section before any heading.
214fn split_md_sections(md: &str) -> Vec<(Option<u8>, String)> {
215    let mut sections: Vec<(Option<u8>, String)> = Vec::new();
216    let mut cur_level: Option<u8> = None;
217    let mut cur = String::new();
218    let mut started = false;
219
220    for line in md.lines() {
221        let t = line.trim_start();
222        let level = if t.starts_with("# ") {
223            Some(1u8)
224        } else if t.starts_with("## ") {
225            Some(2u8)
226        } else {
227            None
228        };
229
230        if level.is_some() {
231            if started {
232                sections.push((cur_level, std::mem::take(&mut cur)));
233            }
234            cur_level = level;
235            started = true;
236        } else if !started {
237            started = true;
238            cur_level = None;
239        }
240        cur.push_str(line);
241        cur.push('\n');
242    }
243
244    if started {
245        sections.push((cur_level, cur));
246    }
247    sections
248}
249
250/// Render a parsed [`SurfDoc`] as a complete, self-contained HTML deck.
251pub fn to_slides_html(doc: &SurfDoc) -> String {
252    let (config, slides) = extract_deck(doc);
253    render_deck_html(&config, &slides)
254}
255
256/// Render a deck config + slide list into a single standalone HTML file.
257pub fn render_deck_html(config: &DeckConfig, slides: &[SlideEntry]) -> String {
258    let title = config.title.as_deref().unwrap_or("SurfDoc Deck");
259    let title_esc = escape_html(title);
260    let footer_text = config.footer.as_deref().unwrap_or(title);
261    let footer_esc = escape_html(footer_text);
262
263    let theme_css = theme_tokens(config.theme_name());
264
265    // Per-deck root overrides (author wins over theme default): accent + font.
266    let mut root_override = String::new();
267    if let Some(a) = &config.accent {
268        root_override.push_str(&format!("--accent:{};", escape_html(a)));
269    }
270    if let Some(f) = &config.font {
271        root_override.push_str(&format!("--sans:{};", escape_html(f)));
272    }
273    let accent_override = if root_override.is_empty() {
274        String::new()
275    } else {
276        format!(":root{{{root_override}}}")
277    };
278
279    let show_numbers = config.show_numbers();
280    let aspect = config.aspect_ratio();
281    let aspect_css = aspect_to_css(aspect);
282    let transition = config.transition_name();
283    let transition_esc = escape_html(transition);
284    let aspect_esc = escape_html(aspect);
285
286    let mut sections = String::new();
287    let total = slides.len().max(1);
288    for (i, slide) in slides.iter().enumerate() {
289        let active = if i == 0 { " active" } else { "" };
290        let layout = slide.layout.css_class();
291
292        let kicker_html = slide
293            .kicker
294            .as_ref()
295            .map(|k| format!("<div class=\"kicker\">{}</div>", escape_html(k)))
296            .unwrap_or_default();
297
298        let body = render_slide_body(slide);
299
300        let notes_html = slide
301            .notes
302            .as_ref()
303            .map(|n| format!("<aside class=\"notes\">{}</aside>", notes_to_html(n)))
304            .unwrap_or_default();
305
306        // Footer chrome: watermark + footer text + optional slide counter.
307        let counter = if show_numbers {
308            format!(
309                "<span class=\"page\">{} / {}</span>",
310                i + 1,
311                total
312            )
313        } else {
314            String::new()
315        };
316
317        sections.push_str(&format!(
318            "<section class=\"slide {layout}{active}\" data-index=\"{idx}\">{kicker}<div class=\"surfdoc slide-inner\">{body}</div>{notes}<div class=\"footer\"><span class=\"wm\"><span class=\"accent\">surf</span>://</span><span class=\"footer-text\">{footer}</span>{counter}</div></section>\n",
319            layout = layout,
320            active = active,
321            idx = i,
322            kicker = kicker_html,
323            body = body,
324            notes = notes_html,
325            footer = footer_esc,
326            counter = counter,
327        ));
328    }
329
330    format!(
331        r#"<!DOCTYPE html>
332<html lang="en" data-slides="{total}" data-aspect="{aspect}" data-transition="{transition}">
333<head>
334<meta charset="utf-8">
335<meta name="viewport" content="width=device-width, initial-scale=1">
336<title>{title}</title>
337<style>
338{theme_css}
339{accent_override}
340:root{{{aspect_css}}}
341{chrome}
342{surfdoc}
343</style>
344</head>
345<body class="transition-{transition}">
346<div id="bar"></div>
347<div id="deck">
348{sections}</div>
349<div id="notes-pane" aria-hidden="true"></div>
350<div class="hint">← / → · space · N notes · F fullscreen</div>
351<script>
352{js}
353</script>
354</body>
355</html>"#,
356        total = total,
357        title = title_esc,
358        aspect = aspect_esc,
359        transition = transition_esc,
360        theme_css = theme_css,
361        accent_override = accent_override,
362        aspect_css = aspect_css,
363        chrome = DECK_CHROME_CSS,
364        surfdoc = crate::SURFDOC_CSS,
365        sections = sections,
366        js = DECK_JS,
367    )
368}
369
370/// Render a slide's child blocks into its inner HTML, applying layout-specific
371/// structure. Most layouts let CSS (keyed off the `.slide.<layout>` class) do
372/// the styling; `two`/split wraps each top-level child as a column cell.
373fn render_slide_body(slide: &SlideEntry) -> String {
374    let parts: Vec<String> = slide.children.iter().map(render_block).collect();
375    match slide.layout {
376        SlideLayout::Two => {
377            // Two-column split: each top-level child becomes a grid cell.
378            let cells: String = parts
379                .iter()
380                .map(|p| format!("<div class=\"col\">{p}</div>"))
381                .collect::<Vec<_>>()
382                .join("");
383            format!("<div class=\"slide-cols\">{cells}</div>")
384        }
385        _ => parts.join("\n"),
386    }
387}
388
389/// Convert presenter-notes text to safe HTML, preserving line breaks.
390fn notes_to_html(notes: &str) -> String {
391    escape_html(notes.trim())
392        .lines()
393        .collect::<Vec<_>>()
394        .join("<br>")
395}
396
397/// Map an `aspect` like `16:9` / `4:3` / `16:10` to a CSS `--aspect` ratio
398/// custom property value. Unknown / malformed values fall back to `16 / 9`.
399fn aspect_to_css(aspect: &str) -> String {
400    let ratio = match aspect.split_once(':') {
401        Some((w, h)) => {
402            let w = w.trim();
403            let h = h.trim();
404            if w.chars().all(|c| c.is_ascii_digit())
405                && h.chars().all(|c| c.is_ascii_digit())
406                && !w.is_empty()
407                && !h.is_empty()
408                && h != "0"
409            {
410                format!("{w} / {h}")
411            } else {
412                "16 / 9".to_string()
413            }
414        }
415        None => "16 / 9".to_string(),
416    };
417    format!("--aspect:{ratio};")
418}
419
420/// Theme token sets (CSS custom properties). `surf-dark` is canonical.
421fn theme_tokens(theme: &str) -> &'static str {
422    match theme {
423        "surf-light" => THEME_SURF_LIGHT,
424        "mono" => THEME_MONO,
425        _ => THEME_SURF_DARK,
426    }
427}
428
429const THEME_SURF_DARK: &str = r#":root{
430  --bg:#000000; --bg2:#0a0a0a; --soft:#161616; --ink:#fafafa; --strong:#ffffff;
431  --muted:#a3a3a3; --faint:#525252; --line:#262626; --line-subtle:#1a1a1a;
432  --accent:#2563eb; --accent-soft:rgba(37,99,235,.18); --accent2:#8b5cf6;
433  --good:#22c55e; --warn:#f59e0b; --bad:#ef4444; --radius:2px;
434  --mono:"SF Mono","Fira Code","Fira Mono",ui-monospace,Menlo,Consolas,monospace;
435  --sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
436  --slide-bg:var(--bg);
437}"#;
438
439const THEME_SURF_LIGHT: &str = r#":root{
440  --bg:#fafbfd; --bg2:#ffffff; --soft:#f1f5f9; --ink:#0a0a0a; --strong:#000000;
441  --muted:#525252; --faint:#a3a3a3; --line:#e5e7eb; --line-subtle:#f1f5f9;
442  --accent:#2563eb; --accent-soft:rgba(37,99,235,.12); --accent2:#8b5cf6;
443  --good:#16a34a; --warn:#d97706; --bad:#dc2626; --radius:2px;
444  --mono:"SF Mono","Fira Code","Fira Mono",ui-monospace,Menlo,Consolas,monospace;
445  --sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
446  --slide-bg:var(--bg);
447}"#;
448
449const THEME_MONO: &str = r#":root{
450  --bg:#0a0a0a; --bg2:#111111; --soft:#1a1a1a; --ink:#e5e5e5; --strong:#ffffff;
451  --muted:#888888; --faint:#555555; --line:#2a2a2a; --line-subtle:#1a1a1a;
452  --accent:#e5e5e5; --accent-soft:rgba(229,229,229,.10); --accent2:#888888;
453  --good:#22c55e; --warn:#f59e0b; --bad:#ef4444; --radius:0px;
454  --mono:"SF Mono","Fira Code","Fira Mono",ui-monospace,Menlo,Consolas,monospace;
455  --sans:var(--mono);
456  --slide-bg:var(--bg);
457}"#;
458
459/// Deck chrome: slide positioning, progress bar, kicker, footer, per-layout
460/// styling, presenter-notes pane, nav hint. Aspect ratio is honored via the
461/// `--aspect` custom property (set per deck), centering a fixed-ratio stage.
462const DECK_CHROME_CSS: &str = r#"
463*{box-sizing:border-box}
464html,body{height:100%;margin:0;padding:0}
465body{background:var(--bg2);color:var(--ink);font-family:var(--sans);overflow:hidden;-webkit-font-smoothing:antialiased}
466/* Center a fixed-aspect stage; the deck fills the largest box at --aspect. */
467#deck{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);aspect-ratio:var(--aspect,16/9);width:min(100vw,calc(100vh*(var(--aspect,16/9))));height:min(100vh,calc(100vw/(var(--aspect,16/9))));background:var(--slide-bg);overflow:hidden;box-shadow:0 0 0 1px var(--line)}
468.slide{position:absolute;inset:0;display:none;flex-direction:column;justify-content:center;padding:7% 9%;opacity:0;transition:opacity .35s ease;background:var(--slide-bg);overflow:auto}
469.slide.active{display:flex;opacity:1}
470/* Transition variants (deterministic; default fade). */
471body.transition-none .slide{transition:none}
472body.transition-slide .slide{transition:opacity .35s ease, transform .35s ease}
473.slide .slide-inner{max-width:100%;width:100%}
474.kicker{font-family:var(--mono);font-size:.82rem;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin-bottom:1.4rem}
475.footer{position:absolute;bottom:3.2%;left:9%;right:9%;display:flex;justify-content:space-between;align-items:center;gap:1.5rem;font-family:var(--mono);font-size:.72rem;color:var(--muted);letter-spacing:.08em;z-index:6}
476.footer .wm{font-weight:700;color:var(--muted);white-space:nowrap}
477.footer .footer-text{flex:1;text-align:center}
478.footer .page{white-space:nowrap;color:var(--faint)}
479.footer .accent{color:var(--accent)}
480.notes{display:none}
481#bar{position:absolute;top:0;left:0;height:2px;background:linear-gradient(90deg,var(--accent),var(--accent2));transition:width .35s ease;z-index:10;width:0}
482.hint{position:absolute;bottom:1.6vh;left:50%;transform:translateX(-50%);font-family:var(--mono);font-size:.7rem;letter-spacing:.1em;color:var(--muted);pointer-events:none;white-space:nowrap;z-index:5}
483/* Let the embedded SurfDoc content size to the slide instead of its own page chrome. */
484.slide .surfdoc{background:transparent;max-width:100%;margin:0;padding:0}
485.slide .surfdoc h1{font-size:clamp(2rem,4.6vw,4rem);line-height:1.1;letter-spacing:-.02em}
486.slide .surfdoc h2{font-size:clamp(1.5rem,3vw,2.4rem);line-height:1.25;letter-spacing:-.02em}
487/* ---- Per-layout styling ---- */
488.slide.cover,.slide.title,.slide.section,.slide.quote,.slide.stat{justify-content:center;text-align:center;align-items:center}
489.slide.cover .slide-inner,.slide.title .slide-inner,.slide.section .slide-inner,.slide.quote .slide-inner{text-align:center}
490.slide.cover .surfdoc h1,.slide.title .surfdoc h1{font-weight:800;font-size:clamp(2.6rem,6vw,5rem)}
491.slide.section{background:var(--soft)}
492.slide.section .surfdoc h1,.slide.section .surfdoc h2{font-size:clamp(2rem,5vw,3.6rem);color:var(--strong)}
493.slide.section::before{content:"";position:absolute;left:9%;top:50%;width:48px;height:3px;background:var(--accent);transform:translateY(-2.4em)}
494.slide.quote .surfdoc blockquote,.slide.quote .surfdoc{font-size:clamp(1.6rem,3.4vw,2.8rem);line-height:1.3;font-weight:500;border:0;font-style:italic;color:var(--strong)}
495.slide.quote .surfdoc blockquote{padding:0;margin:0}
496.slide.image{padding:0;justify-content:center;align-items:center}
497.slide.image .slide-inner{height:100%;display:flex;align-items:center;justify-content:center}
498.slide.image img{max-width:100%;max-height:88%;object-fit:contain;border-radius:var(--radius)}
499.slide.code .slide-inner{display:flex;flex-direction:column;justify-content:center}
500.slide.code pre,.slide.code .surfdoc pre{font-size:clamp(.85rem,1.5vw,1.15rem);line-height:1.5;max-width:100%;overflow:auto}
501/* Two-column / split */
502.slide.two .slide-cols{display:grid;grid-template-columns:1fr 1fr;gap:3rem;align-items:start;width:100%}
503.slide.two .slide-cols .col{min-width:0}
504/* Charts & diagrams embedded in a slide: keep within the stage. */
505.slide .surfdoc figure{margin:0 auto}
506.slide .surfdoc svg{max-width:100%;height:auto;max-height:62vh}
507/* ---- Presenter notes pane (toggle with N/S) ---- */
508#notes-pane{display:none;position:absolute;left:0;right:0;bottom:0;max-height:38%;overflow:auto;padding:1.2rem 9% 1.4rem;background:rgba(0,0,0,.82);color:#fafafa;font-family:var(--sans);font-size:1rem;line-height:1.5;z-index:20;border-top:2px solid var(--accent)}
509#notes-pane::before{content:"NOTES";display:block;font-family:var(--mono);font-size:.65rem;letter-spacing:.22em;color:var(--accent);margin-bottom:.5rem}
510body.notes-on #notes-pane{display:block}
511"#;
512
513/// Deck navigation JS: keyboard/click/touch nav, progress bar, fullscreen, and
514/// a presenter-notes toggle (N or S) that mirrors the active slide's notes into
515/// `#notes-pane`. Deterministic, dependency-free.
516const DECK_JS: &str = r#"const slides=[...document.querySelectorAll('.slide')];
517let i=0;const bar=document.getElementById('bar');const pane=document.getElementById('notes-pane');
518function syncNotes(){if(!pane)return;const n=slides[i]?slides[i].querySelector('.notes'):null;pane.innerHTML=n?n.innerHTML:'<em>No notes for this slide.</em>';}
519function show(n){i=Math.max(0,Math.min(slides.length-1,n));slides.forEach((s,k)=>s.classList.toggle('active',k===i));bar.style.width=(slides.length>1?(i/(slides.length-1)*100):100)+'%';syncNotes();}
520function next(){show(i+1)}function prev(){show(i-1)}
521function toggleNotes(){document.body.classList.toggle('notes-on');}
522document.addEventListener('keydown',e=>{
523  if(['ArrowRight','ArrowDown',' ','PageDown'].includes(e.key)){e.preventDefault();next()}
524  else if(['ArrowLeft','ArrowUp','PageUp'].includes(e.key)){e.preventDefault();prev()}
525  else if(e.key==='Home'){show(0)}else if(e.key==='End'){show(slides.length-1)}
526  else if(e.key.toLowerCase()==='n'||e.key.toLowerCase()==='s'){e.preventDefault();toggleNotes()}
527  else if(e.key.toLowerCase()==='f'){if(!document.fullscreenElement)document.documentElement.requestFullscreen();else document.exitFullscreen()}
528});
529const deck=document.getElementById('deck');
530if(deck)deck.addEventListener('click',e=>{if(e.clientX<window.innerWidth*0.28)prev();else next();});
531show(0);"#;
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536    use crate::parse;
537
538    #[test]
539    fn explicit_deck_and_slides_extract() {
540        let src = "---\ntitle: Test Deck\ntype: deck\n---\n\n::deck\ntheme: surf-dark\naspect: 16:9\n::\n\n::slide[layout=cover kicker=\"Intro\"]\n# Hello\n::\n\n::slide[layout=bullets]\n## Points\n- one\n- two\n::\n";
541        let doc = parse(src).doc;
542        let (config, slides) = extract_deck(&doc);
543        assert_eq!(config.theme.as_deref(), Some("surf-dark"));
544        assert_eq!(config.aspect.as_deref(), Some("16:9"));
545        assert_eq!(slides.len(), 2);
546        assert_eq!(slides[0].layout, SlideLayout::Cover);
547        assert_eq!(slides[0].kicker.as_deref(), Some("Intro"));
548        assert_eq!(slides[1].layout, SlideLayout::Bullets);
549    }
550
551    #[test]
552    fn auto_split_on_headings() {
553        // No ::deck / ::slide — every # / ## becomes a slide.
554        let src = "---\ntitle: Auto\n---\n# Cover Title\nlead text\n\n## First\nbody one\n\n## Second\nbody two\n";
555        let doc = parse(src).doc;
556        let (_config, slides) = extract_deck(&doc);
557        assert_eq!(slides.len(), 3, "H1 + two H2 => 3 slides");
558        assert_eq!(slides[0].layout, SlideLayout::Cover);
559        assert_eq!(slides[1].layout, SlideLayout::Bullets);
560    }
561
562    #[test]
563    fn renders_self_contained_html() {
564        let src = "---\ntitle: Render Me\ntype: deck\n---\n\n::slide[layout=cover]\n# Big\n::\n";
565        let doc = parse(src).doc;
566        let html = to_slides_html(&doc);
567        assert!(html.starts_with("<!DOCTYPE html>"));
568        assert!(html.contains("class=\"slide cover active\""));
569        assert!(html.contains("Render Me")); // title in footer + <title>
570        assert!(html.contains("id=\"bar\"")); // progress bar chrome
571        assert!(html.contains(".surfdoc")); // bundled block CSS embedded
572        assert!(html.contains("data-slides=\"1\""));
573    }
574
575    #[test]
576    fn render_never_panics_on_empty() {
577        let doc = parse("---\ntitle: Empty\n---\n").doc;
578        let html = to_slides_html(&doc);
579        assert!(html.contains("<!DOCTYPE html>"));
580    }
581
582    #[test]
583    fn theme_defaults_to_surf_dark() {
584        let cfg = DeckConfig::default();
585        assert_eq!(cfg.theme_name(), "surf-dark");
586        assert!(theme_tokens(cfg.theme_name()).contains("#2563eb"));
587    }
588
589    // ---- Chunk 5: layouts ----
590
591    #[test]
592    fn every_layout_parses_and_renders_its_class() {
593        // One slide per supported layout keyword (incl. aliases).
594        let src = "\
595---
596type: presentation
597---
598::slide[layout=title]
599# T
600::
601::slide[layout=section]
602# S
603::
604::slide[layout=two-column]
605## A
606## B
607::
608::slide[layout=image]
609![alt](x.png)
610::
611::slide[layout=quote]
612> q
613::
614::slide[layout=code]
615```
616x
617```
618::
619::slide[layout=default]
620- a
621::
622";
623        let doc = parse(src).doc;
624        let (_cfg, slides) = extract_deck(&doc);
625        assert_eq!(slides.len(), 7);
626        assert_eq!(slides[0].layout, SlideLayout::Title);
627        assert_eq!(slides[1].layout, SlideLayout::Section);
628        assert_eq!(slides[2].layout, SlideLayout::Two);
629        assert_eq!(slides[3].layout, SlideLayout::Image);
630        assert_eq!(slides[4].layout, SlideLayout::Quote);
631        assert_eq!(slides[5].layout, SlideLayout::Code);
632        assert_eq!(slides[6].layout, SlideLayout::Bullets); // "default" → Bullets
633
634        let html = to_slides_html(&doc);
635        for cls in ["slide title", "slide section", "slide two", "slide image", "slide quote", "slide code", "slide bullets"] {
636            assert!(html.contains(cls), "missing layout class: {cls}");
637        }
638        // Two-column wraps children in column cells.
639        assert!(html.contains("slide-cols"));
640        assert!(html.contains("<div class=\"col\">"));
641    }
642
643    #[test]
644    fn notes_attr_and_notes_block_both_populate_notes() {
645        // Attribute form.
646        let attr = parse("---\ntype: deck\n---\n::slide[layout=title notes=\"hi there\"]\n# T\n::\n").doc;
647        let (_c, s) = extract_deck(&attr);
648        assert_eq!(s[0].notes.as_deref(), Some("hi there"));
649
650        // `:::notes` child form (deeper colon depth nests inside the slide).
651        let blk = parse("---\ntype: deck\n---\n::slide[layout=title]\n# T\n:::notes\nspeaker note\n:::\n::\n").doc;
652        let (_c2, s2) = extract_deck(&blk);
653        assert_eq!(s2[0].notes.as_deref(), Some("speaker note"));
654        // The notes block must NOT leak into the rendered slide children.
655        let html = to_slides_html(&blk);
656        assert!(html.contains("<aside class=\"notes\">speaker note</aside>"));
657    }
658
659    #[test]
660    fn deck_options_parse_and_render() {
661        let src = "\
662---
663type: presentation
664---
665::deck
666theme: surf-light
667accent: #ff0000
668font: Georgia, serif
669aspect: 4:3
670transition: slide
671footer: My Footer
672numbers: off
673::
674::slide[layout=title]
675# Hi
676::
677";
678        let doc = parse(src).doc;
679        let (cfg, _slides) = extract_deck(&doc);
680        assert_eq!(cfg.theme.as_deref(), Some("surf-light"));
681        assert_eq!(cfg.accent.as_deref(), Some("#ff0000"));
682        assert_eq!(cfg.aspect_ratio(), "4:3");
683        assert_eq!(cfg.transition_name(), "slide");
684        assert_eq!(cfg.footer.as_deref(), Some("My Footer"));
685        assert!(!cfg.show_numbers());
686
687        let html = to_slides_html(&doc);
688        assert!(html.contains("--accent:#ff0000;"));
689        assert!(html.contains("--sans:Georgia, serif;"));
690        assert!(html.contains("--aspect:4 / 3;"));
691        assert!(html.contains("data-transition=\"slide\""));
692        assert!(html.contains("My Footer"));
693        // numbers off → no slide-number counter span.
694        assert!(!html.contains("class=\"page\""));
695    }
696
697    #[test]
698    fn slide_numbers_default_on() {
699        let doc = parse("---\ntype: deck\n---\n::slide\n# A\n::\n::slide\n# B\n::\n").doc;
700        let html = to_slides_html(&doc);
701        assert!(html.contains("<span class=\"page\">1 / 2</span>"));
702        assert!(html.contains("<span class=\"page\">2 / 2</span>"));
703    }
704
705    #[test]
706    fn aspect_defaults_to_16_9_and_rejects_garbage() {
707        assert_eq!(aspect_to_css("16:9"), "--aspect:16 / 9;");
708        assert_eq!(aspect_to_css("4:3"), "--aspect:4 / 3;");
709        assert_eq!(aspect_to_css("nonsense"), "--aspect:16 / 9;");
710        assert_eq!(aspect_to_css("3:0"), "--aspect:16 / 9;");
711        assert_eq!(DeckConfig::default().aspect_ratio(), "16:9");
712    }
713
714    #[test]
715    fn embedded_chart_and_diagram_render_svg_in_slide() {
716        let src = "\
717---
718type: presentation
719---
720::slide[layout=bullets]
721## Data
722:::chart[type=line title=\"Users\"]
723Week | Users
724W1 | 10
725W2 | 20
726:::
727::
728::slide[layout=bullets]
729## Flow
730:::diagram[type=flowchart title=\"Flow\"]
731a: Start
732b: End
733a -> b
734:::
735::
736";
737        let doc = parse(src).doc;
738        let html = to_slides_html(&doc);
739        // Both the chart and the diagram produce inline SVG inside their slides.
740        assert!(html.matches("<svg").count() >= 2, "expected >=2 inline SVGs");
741        assert!(html.contains("Users")); // chart title
742        assert!(html.contains("Flow")); // diagram title
743    }
744
745    #[test]
746    fn deck_html_is_deterministic() {
747        let src = "\
748---
749type: presentation
750---
751::deck
752theme: surf-dark
753accent: #2563eb
754::
755::slide[layout=title kicker=\"K\"]
756# Title
757:::notes
758note
759:::
760::
761::slide[layout=two-column]
762## Left
763## Right
764:::chart[type=bar title=\"C\"]
765X | Y
766a | 1
767b | 2
768:::
769::
770";
771        let doc = parse(src).doc;
772        let a = to_slides_html(&doc);
773        let b = to_slides_html(&doc);
774        assert_eq!(a, b, "deck HTML must be byte-identical across renders");
775    }
776}