1use crate::render_html::{escape_html, render_block};
20use crate::types::{Block, SlideLayout, StyleProperty};
21use crate::SurfDoc;
22
23#[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 pub footer: Option<String>,
35 pub numbers: Option<String>,
38 pub properties: Vec<StyleProperty>,
39}
40
41impl DeckConfig {
42 pub fn theme_name(&self) -> &str {
44 self.theme.as_deref().unwrap_or("surf-dark")
45 }
46
47 pub fn aspect_ratio(&self) -> &str {
49 self.aspect.as_deref().unwrap_or("16:9")
50 }
51
52 pub fn transition_name(&self) -> &str {
54 self.transition.as_deref().unwrap_or("fade")
55 }
56
57 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#[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
79pub 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 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
143fn 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 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
210fn 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
250pub fn to_slides_html(doc: &SurfDoc) -> String {
252 let (config, slides) = extract_deck(doc);
253 render_deck_html(&config, &slides)
254}
255
256pub 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 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 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
370fn 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 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
389fn notes_to_html(notes: &str) -> String {
391 escape_html(notes.trim())
392 .lines()
393 .collect::<Vec<_>>()
394 .join("<br>")
395}
396
397fn 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
420fn 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
459const 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
513const 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 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")); assert!(html.contains("id=\"bar\"")); assert!(html.contains(".surfdoc")); 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 #[test]
592 fn every_layout_parses_and_renders_its_class() {
593 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
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); 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 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 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 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 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 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 assert!(html.matches("<svg").count() >= 2, "expected >=2 inline SVGs");
741 assert!(html.contains("Users")); assert!(html.contains("Flow")); }
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}