Skip to main content

surf_parse/
render_html.rs

1//! HTML fragment renderer.
2//!
3//! Produces semantic HTML with `surfdoc-*` CSS classes. Markdown blocks are
4//! rendered through `pulldown-cmark`. All other content is HTML-escaped to
5//! prevent XSS.
6
7use crate::types::{Block, CalloutType, DecisionStatus, StyleProperty, SurfDoc, Trend};
8
9/// Configuration for full-page HTML rendering with SurfDoc discovery metadata.
10#[derive(Debug, Clone)]
11pub struct PageConfig {
12    /// Path to the original `.surf` source file (served alongside the built site).
13    /// Used in `<link rel="alternate">` and the HTML comment.
14    pub source_path: String,
15    /// Page title. Falls back to front matter `title`, then "SurfDoc".
16    pub title: Option<String>,
17    /// Optional canonical URL for `<link rel="canonical">`.
18    pub canonical_url: Option<String>,
19    /// Optional meta description.
20    pub description: Option<String>,
21    /// Optional language code (default: "en").
22    pub lang: Option<String>,
23}
24
25impl Default for PageConfig {
26    fn default() -> Self {
27        Self {
28            source_path: "source.surf".to_string(),
29            title: None,
30            canonical_url: None,
31            description: None,
32            lang: None,
33        }
34    }
35}
36
37/// Render a `SurfDoc` as an HTML fragment.
38///
39/// The output is a sequence of semantic HTML elements with `surfdoc-*` CSS
40/// classes. No `<html>`, `<head>`, or `<body>` wrapper is added.
41pub fn to_html(doc: &SurfDoc) -> String {
42    let mut parts: Vec<String> = Vec::new();
43
44    for block in &doc.blocks {
45        parts.push(render_block(block));
46    }
47
48    parts.join("\n")
49}
50
51/// Render a `SurfDoc` as a complete HTML page with SurfDoc discovery metadata.
52///
53/// Produces a full `<!DOCTYPE html>` document with:
54/// - `<meta name="generator" content="SurfDoc v0.1">`
55/// - `<link rel="alternate" type="text/surfdoc" href="...">` pointing to source
56/// - HTML comment identifying the source file
57/// - Standard viewport and charset meta tags
58/// - Embedded dark-theme CSS for all SurfDoc block types
59pub fn to_html_page(doc: &SurfDoc, config: &PageConfig) -> String {
60    let body = to_html(doc);
61    let lang = config.lang.as_deref().unwrap_or("en");
62
63    // Resolve title: explicit config > front matter > fallback
64    let title = config
65        .title
66        .clone()
67        .or_else(|| {
68            doc.front_matter
69                .as_ref()
70                .and_then(|fm| fm.title.clone())
71        })
72        .unwrap_or_else(|| "SurfDoc".to_string());
73
74    let source_path = escape_html(&config.source_path);
75
76    // Build optional meta tags
77    let mut meta_extra = String::new();
78    if let Some(desc) = &config.description {
79        meta_extra.push_str(&format!(
80            "\n    <meta name=\"description\" content=\"{}\">",
81            escape_html(desc)
82        ));
83    }
84    if let Some(url) = &config.canonical_url {
85        meta_extra.push_str(&format!(
86            "\n    <link rel=\"canonical\" href=\"{}\">",
87            escape_html(url)
88        ));
89    }
90
91    format!(
92        r#"<!-- Built with SurfDoc — source: {source_path} -->
93<!DOCTYPE html>
94<html lang="{lang}">
95<head>
96    <meta charset="utf-8">
97    <meta name="viewport" content="width=device-width, initial-scale=1">
98    <meta name="generator" content="SurfDoc v0.1">
99    <link rel="alternate" type="text/surfdoc" href="{source_path}">
100    <title>{title}</title>{meta_extra}
101    <style>{css}</style>
102</head>
103<body>
104<article class="surfdoc">
105{body}
106</article>
107</body>
108</html>"#,
109        source_path = source_path,
110        lang = escape_html(lang),
111        title = escape_html(&title),
112        meta_extra = meta_extra,
113        css = SURFDOC_CSS,
114        body = body,
115    )
116}
117
118/// Embedded CSS for standalone SurfDoc pages.
119///
120/// Dark theme ported from the CloudSurf strategy-web reference implementation.
121/// Covers base typography, markdown elements, and all SurfDoc block types.
122const SURFDOC_CSS: &str = r#"
123:root {
124    --bg: #0a0a0f;
125    --bg-card: #12121a;
126    --bg-hover: #1a1a26;
127    --border: #2a2a3a;
128    --border-subtle: #1e1e2e;
129    --text: #e8e8f0;
130    --text-dim: #8888a0;
131    --text-muted: #5a5a72;
132    --accent: #3b82f6;
133}
134
135*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
136body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, sans-serif; -webkit-font-smoothing: antialiased; }
137::-webkit-scrollbar { width: 6px; height: 6px; }
138::-webkit-scrollbar-track { background: transparent; }
139::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
140
141/* Layout */
142.surfdoc { max-width: 48rem; margin: 0 auto; padding: 2rem 1.5rem 4rem; line-height: 1.7; }
143
144/* Typography */
145.surfdoc h1 { font-size: 1.875rem; font-weight: 700; margin: 2rem 0 1rem; letter-spacing: -0.025em; }
146.surfdoc h2 { font-size: 1.5rem; font-weight: 600; margin: 1.75rem 0 0.75rem; padding-bottom: 0.5rem; border-bottom: 1px solid var(--border-subtle); }
147.surfdoc h3 { font-size: 1.25rem; font-weight: 600; margin: 1.5rem 0 0.5rem; }
148.surfdoc h4 { font-size: 1.1rem; font-weight: 600; margin: 1.25rem 0 0.5rem; color: var(--text-dim); }
149.surfdoc p { margin: 0.75rem 0; }
150.surfdoc a { color: var(--accent); text-decoration: none; }
151.surfdoc a:hover { text-decoration: underline; }
152.surfdoc strong { font-weight: 600; color: #fff; }
153.surfdoc em { color: var(--text-dim); }
154.surfdoc ul, .surfdoc ol { margin: 0.5rem 0; padding-left: 1.5rem; }
155.surfdoc li { margin: 0.25rem 0; }
156.surfdoc li::marker { color: var(--text-muted); }
157.surfdoc blockquote { border-left: 3px solid var(--accent); padding: 0.5rem 1rem; margin: 1rem 0; background: rgba(59,130,246,0.05); border-radius: 0 6px 6px 0; }
158.surfdoc blockquote p { margin: 0.25rem 0; }
159.surfdoc code { font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace; font-size: 0.85em; background: rgba(255,255,255,0.06); padding: 0.15em 0.4em; border-radius: 4px; }
160.surfdoc pre { background: #0d1117 !important; border: 1px solid var(--border-subtle); border-radius: 8px; padding: 1rem; overflow-x: auto; margin: 1rem 0; }
161.surfdoc pre code { background: transparent; padding: 0; font-size: 0.8rem; line-height: 1.6; }
162.surfdoc table { width: 100%; border-collapse: collapse; margin: 1rem 0; font-size: 0.875rem; }
163.surfdoc th { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 2px solid var(--border); font-weight: 600; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; }
164.surfdoc td { padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--border-subtle); }
165.surfdoc tr:hover td { background: rgba(255,255,255,0.02); }
166.surfdoc hr { border: none; border-top: 1px solid var(--border-subtle); margin: 2rem 0; }
167.surfdoc img { max-width: 100%; border-radius: 8px; }
168
169/* Callout blocks */
170.surfdoc-callout { border-left: 3px solid; padding: 0.75rem 1rem; margin: 1rem 0; border-radius: 0 8px 8px 0; background: var(--bg-card); }
171.surfdoc-callout strong { display: block; margin-bottom: 0.25rem; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.04em; }
172.surfdoc-callout p { margin: 0; }
173.surfdoc-callout-info { border-color: #3b82f6; }
174.surfdoc-callout-info strong { color: #3b82f6; }
175.surfdoc-callout-warning { border-color: #f59e0b; }
176.surfdoc-callout-warning strong { color: #f59e0b; }
177.surfdoc-callout-danger { border-color: #ef4444; }
178.surfdoc-callout-danger strong { color: #ef4444; }
179.surfdoc-callout-tip { border-color: #22c55e; }
180.surfdoc-callout-tip strong { color: #22c55e; }
181.surfdoc-callout-note { border-color: #06b6d4; }
182.surfdoc-callout-note strong { color: #06b6d4; }
183.surfdoc-callout-success { border-color: #22c55e; background: rgba(34,197,94,0.05); }
184.surfdoc-callout-success strong { color: #22c55e; }
185
186/* Data tables */
187.surfdoc-data { width: 100%; border-collapse: collapse; margin: 1rem 0; font-size: 0.875rem; border: 1px solid var(--border-subtle); border-radius: 8px; overflow: hidden; }
188.surfdoc-data thead { background: var(--bg-card); }
189.surfdoc-data th { text-align: left; padding: 0.625rem 0.75rem; font-weight: 600; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; border-bottom: 2px solid var(--border); }
190.surfdoc-data td { padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--border-subtle); }
191.surfdoc-data tr:hover td { background: rgba(255,255,255,0.02); }
192.surfdoc-data tr:last-child td { border-bottom: none; }
193
194/* Code blocks */
195.surfdoc-code { background: #0d1117; border: 1px solid var(--border-subtle); border-radius: 8px; padding: 1rem; overflow-x: auto; margin: 1rem 0; font-size: 0.8rem; line-height: 1.6; }
196.surfdoc-code code { background: transparent; padding: 0; font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace; }
197
198/* Task lists */
199.surfdoc-tasks { list-style: none; padding-left: 0; margin: 1rem 0; }
200.surfdoc-tasks li { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.75rem; margin: 0.125rem 0; border-radius: 6px; font-size: 0.9rem; }
201.surfdoc-tasks li:hover { background: var(--bg-hover); }
202.surfdoc-tasks input[type="checkbox"] { accent-color: var(--accent); width: 16px; height: 16px; }
203.surfdoc-tasks .assignee { color: var(--accent); font-size: 0.8rem; margin-left: auto; }
204
205/* Decision records */
206.surfdoc-decision { border-left: 3px solid; padding: 0.75rem 1rem; margin: 1rem 0; border-radius: 0 8px 8px 0; background: var(--bg-card); }
207.surfdoc-decision .status { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; margin-right: 0.5rem; }
208.surfdoc-decision .date { color: var(--text-muted); font-size: 0.8rem; }
209.surfdoc-decision p { margin: 0.5rem 0 0; }
210.surfdoc-decision-accepted { border-color: #22c55e; }
211.surfdoc-decision-accepted .status { background: rgba(34,197,94,0.15); color: #22c55e; }
212.surfdoc-decision-rejected { border-color: #ef4444; }
213.surfdoc-decision-rejected .status { background: rgba(239,68,68,0.15); color: #ef4444; }
214.surfdoc-decision-proposed { border-color: #f59e0b; }
215.surfdoc-decision-proposed .status { background: rgba(245,158,11,0.15); color: #f59e0b; }
216.surfdoc-decision-superseded { border-color: var(--text-muted); }
217.surfdoc-decision-superseded .status { background: rgba(90,90,114,0.15); color: var(--text-muted); }
218
219/* Metric displays */
220.surfdoc-metric { display: inline-flex; align-items: baseline; gap: 0.5rem; padding: 0.625rem 1rem; margin: 0.5rem 0.5rem 0.5rem 0; background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: 8px; }
221.surfdoc-metric .label { color: var(--text-dim); font-size: 0.8rem; font-weight: 500; }
222.surfdoc-metric .value { font-size: 1.25rem; font-weight: 700; color: #fff; }
223.surfdoc-metric .unit { color: var(--text-muted); font-size: 0.8rem; }
224.surfdoc-metric .trend { font-size: 1rem; }
225.surfdoc-metric .trend.up { color: #22c55e; }
226.surfdoc-metric .trend.down { color: #ef4444; }
227.surfdoc-metric .trend.flat { color: var(--text-muted); }
228
229/* Summary blocks */
230.surfdoc-summary { border-left: 3px solid var(--accent); padding: 0.75rem 1rem; margin: 1rem 0; background: rgba(59,130,246,0.04); border-radius: 0 8px 8px 0; font-style: italic; color: var(--text-dim); }
231.surfdoc-summary p { margin: 0; }
232
233/* Figure blocks */
234.surfdoc-figure { margin: 1.5rem 0; text-align: center; }
235.surfdoc-figure img { max-width: 100%; border-radius: 8px; border: 1px solid var(--border-subtle); }
236.surfdoc-figure figcaption { margin-top: 0.5rem; font-size: 0.85rem; color: var(--text-muted); font-style: italic; }
237
238/* Unknown blocks */
239.surfdoc-unknown { padding: 0.75rem 1rem; margin: 1rem 0; background: var(--bg-card); border: 1px dashed var(--border); border-radius: 8px; color: var(--text-dim); font-size: 0.875rem; }
240
241/* Tabs blocks */
242.surfdoc-tabs { margin: 1rem 0; border: 1px solid var(--border-subtle); border-radius: 8px; overflow: hidden; }
243.surfdoc-tabs nav { display: flex; background: var(--bg-card); border-bottom: 1px solid var(--border-subtle); }
244.surfdoc-tabs nav button { padding: 0.5rem 1rem; background: none; border: none; color: var(--text-muted); font-size: 0.85rem; cursor: pointer; border-bottom: 2px solid transparent; transition: all 0.15s; }
245.surfdoc-tabs nav button:hover { color: var(--text); background: var(--bg-hover); }
246.surfdoc-tabs nav button.active { color: var(--accent); border-bottom-color: var(--accent); }
247.surfdoc-tabs .tab-panel { padding: 1rem; display: none; }
248.surfdoc-tabs .tab-panel.active { display: block; }
249
250/* Columns layout */
251.surfdoc-columns { display: grid; gap: 1.5rem; margin: 1rem 0; }
252.surfdoc-columns[data-cols="2"] { grid-template-columns: repeat(2, 1fr); }
253.surfdoc-columns[data-cols="3"] { grid-template-columns: repeat(3, 1fr); }
254.surfdoc-columns[data-cols="4"] { grid-template-columns: repeat(4, 1fr); }
255.surfdoc-column { min-width: 0; }
256@media (max-width: 640px) {
257    .surfdoc-columns { grid-template-columns: 1fr !important; }
258}
259
260/* Quote blocks */
261.surfdoc-quote { border-left: 3px solid var(--text-muted); padding: 0.75rem 1.25rem; margin: 1.5rem 0; }
262.surfdoc-quote blockquote { border: none; padding: 0; margin: 0; background: none; font-size: 1.1rem; font-style: italic; color: var(--text-dim); line-height: 1.6; }
263.surfdoc-quote .attribution { margin-top: 0.5rem; font-size: 0.85rem; color: var(--text-muted); font-style: normal; }
264.surfdoc-quote .attribution::before { content: "— "; }
265
266/* CTA buttons */
267.surfdoc-cta { display: inline-block; padding: 0.625rem 1.5rem; margin: 0.5rem 0.5rem 0.5rem 0; border-radius: 8px; font-weight: 600; font-size: 0.95rem; text-decoration: none; transition: all 0.15s; cursor: pointer; }
268.surfdoc-cta-primary { background: var(--accent); color: #fff; border: 1px solid var(--accent); }
269.surfdoc-cta-primary:hover { background: #2563eb; text-decoration: none; }
270.surfdoc-cta-secondary { background: transparent; color: var(--accent); border: 1px solid var(--border); }
271.surfdoc-cta-secondary:hover { background: var(--bg-hover); text-decoration: none; }
272
273/* Hero image */
274.surfdoc-hero-image { margin: 2rem 0; text-align: center; }
275.surfdoc-hero-image img { max-width: 100%; border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.3); border: 1px solid var(--border-subtle); }
276
277/* Testimonials */
278.surfdoc-testimonial { padding: 1.25rem 1.5rem; margin: 1rem 0; background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: 12px; position: relative; }
279.surfdoc-testimonial blockquote { border: none; background: none; padding: 0; margin: 0 0 0.75rem; font-size: 1rem; font-style: italic; color: var(--text-dim); line-height: 1.6; }
280.surfdoc-testimonial .author { font-weight: 600; color: var(--text); font-size: 0.9rem; }
281.surfdoc-testimonial .role { color: var(--text-muted); font-size: 0.8rem; }
282
283/* Style blocks — invisible, metadata only */
284.surfdoc-style { display: none; }
285
286/* FAQ accordion */
287.surfdoc-faq { margin: 1rem 0; }
288.surfdoc-faq details { border: 1px solid var(--border-subtle); border-radius: 8px; margin: 0.5rem 0; overflow: hidden; }
289.surfdoc-faq summary { padding: 0.75rem 1rem; font-weight: 600; cursor: pointer; background: var(--bg-card); color: var(--text); font-size: 0.95rem; }
290.surfdoc-faq summary:hover { background: var(--bg-hover); }
291.surfdoc-faq .faq-answer { padding: 0.75rem 1rem; color: var(--text-dim); line-height: 1.6; border-top: 1px solid var(--border-subtle); }
292
293/* Pricing table */
294.surfdoc-pricing { width: 100%; border-collapse: collapse; margin: 1rem 0; font-size: 0.875rem; border: 1px solid var(--border-subtle); border-radius: 8px; overflow: hidden; }
295.surfdoc-pricing thead { background: var(--bg-card); }
296.surfdoc-pricing th { text-align: center; padding: 0.75rem; font-weight: 600; color: var(--text); border-bottom: 2px solid var(--border); font-size: 0.95rem; }
297.surfdoc-pricing th:first-child { text-align: left; color: var(--text-muted); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; }
298.surfdoc-pricing td { padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--border-subtle); text-align: center; }
299.surfdoc-pricing td:first-child { text-align: left; font-weight: 500; color: var(--text-dim); }
300.surfdoc-pricing tr:hover td { background: rgba(255,255,255,0.02); }
301.surfdoc-pricing tr:last-child td { border-bottom: none; }
302
303/* Site config — invisible, metadata only */
304.surfdoc-site { display: none; }
305
306/* Page sections */
307.surfdoc-page { margin: 2rem 0; padding: 2rem 0; border-top: 2px solid var(--border-subtle); }
308.surfdoc-page[data-layout="hero"] { text-align: center; padding: 4rem 0; }
309.surfdoc-page[data-layout="hero"] h1 { font-size: 2.5rem; margin-bottom: 1rem; }
310.surfdoc-page[data-layout="hero"] p { font-size: 1.15rem; color: var(--text-dim); max-width: 36rem; margin: 0 auto 1.5rem; }
311.surfdoc-page[data-layout="hero"] .surfdoc-cta { margin: 0.5rem; }
312.surfdoc-page[data-layout="cards"] { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem; }
313.surfdoc-page[data-layout="split"] { display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; align-items: center; }
314@media (max-width: 640px) {
315    .surfdoc-page[data-layout="split"] { grid-template-columns: 1fr; }
316    .surfdoc-page[data-layout="hero"] h1 { font-size: 1.75rem; }
317}
318"#;
319
320/// Escape HTML special characters to prevent XSS.
321fn escape_html(s: &str) -> String {
322    s.replace('&', "&amp;")
323        .replace('<', "&lt;")
324        .replace('>', "&gt;")
325        .replace('"', "&quot;")
326}
327
328fn render_block(block: &Block) -> String {
329    match block {
330        Block::Markdown { content, .. } => {
331            let parser = pulldown_cmark::Parser::new(content);
332            let mut html_output = String::new();
333            pulldown_cmark::html::push_html(&mut html_output, parser);
334            html_output
335        }
336
337        Block::Callout {
338            callout_type,
339            title,
340            content,
341            ..
342        } => {
343            let type_str = callout_type_str(*callout_type);
344            let role = if matches!(callout_type, CalloutType::Danger) { "alert" } else { "note" };
345            let title_html = match title {
346                Some(t) => format!(": {}", escape_html(t)),
347                None => String::new(),
348            };
349            format!(
350                "<div class=\"surfdoc-callout surfdoc-callout-{type_str}\" role=\"{role}\"><strong>{}</strong>{title_html}<p>{}</p></div>",
351                capitalize(type_str),
352                escape_html(content),
353            )
354        }
355
356        Block::Data {
357            headers, rows, ..
358        } => {
359            let mut html = String::from("<table class=\"surfdoc-data\">");
360            if !headers.is_empty() {
361                html.push_str("<thead><tr>");
362                for h in headers {
363                    html.push_str(&format!("<th scope=\"col\">{}</th>", escape_html(h)));
364                }
365                html.push_str("</tr></thead>");
366            }
367            html.push_str("<tbody>");
368            for row in rows {
369                html.push_str("<tr>");
370                for cell in row {
371                    html.push_str(&format!("<td>{}</td>", escape_html(cell)));
372                }
373                html.push_str("</tr>");
374            }
375            html.push_str("</tbody></table>");
376            html
377        }
378
379        Block::Code {
380            lang, content, ..
381        } => {
382            let class = match lang {
383                Some(l) => format!(" class=\"language-{}\"", escape_html(l)),
384                None => String::new(),
385            };
386            let aria = match lang {
387                Some(l) => format!(" aria-label=\"{} code\"", escape_html(l)),
388                None => String::new(),
389            };
390            format!(
391                "<pre class=\"surfdoc-code\"{}><code{}>{}</code></pre>",
392                aria,
393                class,
394                escape_html(content),
395            )
396        }
397
398        Block::Tasks { items, .. } => {
399            let mut html = String::from("<ul class=\"surfdoc-tasks\">");
400            for item in items {
401                let checked = if item.done { " checked" } else { "" };
402                let assignee_html = match &item.assignee {
403                    Some(a) => format!(" <span class=\"assignee\">@{}</span>", escape_html(a)),
404                    None => String::new(),
405                };
406                html.push_str(&format!(
407                    "<li><label><input type=\"checkbox\"{checked} disabled> {}</label>{assignee_html}</li>",
408                    escape_html(&item.text),
409                ));
410            }
411            html.push_str("</ul>");
412            html
413        }
414
415        Block::Decision {
416            status,
417            date,
418            content,
419            ..
420        } => {
421            let status_str = decision_status_str(*status);
422            let date_html = match date {
423                Some(d) => format!("<span class=\"date\">{}</span>", escape_html(d)),
424                None => String::new(),
425            };
426            format!(
427                "<div class=\"surfdoc-decision surfdoc-decision-{status_str}\" role=\"note\" aria-label=\"Decision: {status_str}\"><span class=\"status\">{status_str}</span>{date_html}<p>{}</p></div>",
428                escape_html(content),
429            )
430        }
431
432        Block::Metric {
433            label,
434            value,
435            trend,
436            unit,
437            ..
438        } => {
439            let trend_html = match trend {
440                Some(Trend::Up) => "<span class=\"trend up\">\u{2191}</span>".to_string(),
441                Some(Trend::Down) => "<span class=\"trend down\">\u{2193}</span>".to_string(),
442                Some(Trend::Flat) => "<span class=\"trend flat\">\u{2192}</span>".to_string(),
443                None => String::new(),
444            };
445            let unit_html = match unit {
446                Some(u) => format!("<span class=\"unit\">{}</span>", escape_html(u)),
447                None => String::new(),
448            };
449            let trend_text = match trend {
450                Some(Trend::Up) => ", trending up",
451                Some(Trend::Down) => ", trending down",
452                Some(Trend::Flat) => ", flat",
453                None => "",
454            };
455            let unit_text = match unit {
456                Some(u) => format!(" {}", u),
457                None => String::new(),
458            };
459            let aria_label = format!("{}: {}{}{}", label, value, unit_text, trend_text);
460            format!(
461                "<div class=\"surfdoc-metric\" role=\"group\" aria-label=\"{}\"><span class=\"label\">{}</span><span class=\"value\">{}</span>{unit_html}{trend_html}</div>",
462                escape_html(&aria_label),
463                escape_html(label),
464                escape_html(value),
465            )
466        }
467
468        Block::Summary { content, .. } => {
469            format!(
470                "<div class=\"surfdoc-summary\" role=\"doc-abstract\"><p>{}</p></div>",
471                escape_html(content),
472            )
473        }
474
475        Block::Figure {
476            src,
477            caption,
478            alt,
479            ..
480        } => {
481            let alt_attr = alt.as_deref().unwrap_or("");
482            let caption_html = match caption {
483                Some(c) => format!("<figcaption>{}</figcaption>", escape_html(c)),
484                None => String::new(),
485            };
486            format!(
487                "<figure class=\"surfdoc-figure\"><img src=\"{}\" alt=\"{}\" />{caption_html}</figure>",
488                escape_html(src),
489                escape_html(alt_attr),
490            )
491        }
492
493        Block::Tabs { tabs, .. } => {
494            let mut html = String::from("<div class=\"surfdoc-tabs\">");
495            html.push_str("<nav role=\"tablist\">");
496            for (i, tab) in tabs.iter().enumerate() {
497                let selected = if i == 0 { "true" } else { "false" };
498                let tabindex = if i == 0 { "0" } else { "-1" };
499                html.push_str(&format!(
500                    "<button class=\"tab-btn{}\" role=\"tab\" aria-selected=\"{}\" aria-controls=\"surfdoc-panel-{}\" id=\"surfdoc-tab-{}\" tabindex=\"{}\">{}</button>",
501                    if i == 0 { " active" } else { "" },
502                    selected,
503                    i,
504                    i,
505                    tabindex,
506                    escape_html(&tab.label)
507                ));
508            }
509            html.push_str("</nav>");
510            for (i, tab) in tabs.iter().enumerate() {
511                let active = if i == 0 { " active" } else { "" };
512                let hidden = if i == 0 { "" } else { " hidden" };
513                let parser = pulldown_cmark::Parser::new(&tab.content);
514                let mut content_html = String::new();
515                pulldown_cmark::html::push_html(&mut content_html, parser);
516                html.push_str(&format!(
517                    "<div class=\"tab-panel{}\" role=\"tabpanel\" id=\"surfdoc-panel-{}\" aria-labelledby=\"surfdoc-tab-{}\" tabindex=\"0\"{}>{}</div>",
518                    active, i, i, hidden, content_html
519                ));
520            }
521            html.push_str(r#"<script>document.querySelectorAll('.surfdoc-tabs').forEach(t=>{t.querySelectorAll('[role="tab"]').forEach(b=>{b.onclick=()=>{t.querySelectorAll('[role="tab"]').forEach(e=>{e.classList.remove('active');e.setAttribute('aria-selected','false');e.tabIndex=-1});b.classList.add('active');b.setAttribute('aria-selected','true');b.tabIndex=0;t.querySelectorAll('[role="tabpanel"]').forEach(p=>{p.classList.remove('active');p.hidden=true});var panel=document.getElementById(b.getAttribute('aria-controls'));if(panel){panel.classList.add('active');panel.hidden=false}}})})</script>"#);
522            html.push_str("</div>");
523            html
524        }
525
526        Block::Columns { columns, .. } => {
527            let count = columns.len();
528            let mut html = format!(
529                "<div class=\"surfdoc-columns\" role=\"group\" data-cols=\"{}\">",
530                count
531            );
532            for col in columns {
533                let parser = pulldown_cmark::Parser::new(&col.content);
534                let mut col_html = String::new();
535                pulldown_cmark::html::push_html(&mut col_html, parser);
536                html.push_str(&format!(
537                    "<div class=\"surfdoc-column\">{}</div>",
538                    col_html
539                ));
540            }
541            html.push_str("</div>");
542            html
543        }
544
545        Block::Quote {
546            content,
547            attribution,
548            cite,
549            ..
550        } => {
551            let mut html = String::from("<div class=\"surfdoc-quote\"><blockquote>");
552            html.push_str(&escape_html(content));
553            html.push_str("</blockquote>");
554            if let Some(attr) = attribution {
555                let cite_part = match cite {
556                    Some(c) => format!(", <cite>{}</cite>", escape_html(c)),
557                    None => String::new(),
558                };
559                html.push_str(&format!(
560                    "<div class=\"attribution\">{}{}</div>",
561                    escape_html(attr),
562                    cite_part,
563                ));
564            }
565            html.push_str("</div>");
566            html
567        }
568
569        Block::Cta {
570            label,
571            href,
572            primary,
573            ..
574        } => {
575            let class = if *primary { "surfdoc-cta surfdoc-cta-primary" } else { "surfdoc-cta surfdoc-cta-secondary" };
576            format!(
577                "<a class=\"{}\" href=\"{}\">{}</a>",
578                class,
579                escape_html(href),
580                escape_html(label),
581            )
582        }
583
584        Block::HeroImage { src, alt, .. } => {
585            let alt_attr = alt.as_deref().unwrap_or("");
586            let role_attr = if !alt_attr.is_empty() {
587                format!(" role=\"img\" aria-label=\"{}\"", escape_html(alt_attr))
588            } else {
589                String::new()
590            };
591            format!(
592                "<div class=\"surfdoc-hero-image\"{}><img src=\"{}\" alt=\"{}\" /></div>",
593                role_attr,
594                escape_html(src),
595                escape_html(alt_attr),
596            )
597        }
598
599        Block::Testimonial {
600            content,
601            author,
602            role,
603            company,
604            ..
605        } => {
606            let aria_label = match author {
607                Some(a) => format!(" aria-label=\"Testimonial from {}\"", escape_html(a)),
608                None => " aria-label=\"Testimonial\"".to_string(),
609            };
610            let mut html = format!("<div class=\"surfdoc-testimonial\" role=\"figure\"{}><blockquote>", aria_label);
611            html.push_str(&escape_html(content));
612            html.push_str("</blockquote>");
613            if author.is_some() || role.is_some() || company.is_some() {
614                html.push_str("<div class=\"author\">");
615                if let Some(a) = author {
616                    html.push_str(&escape_html(a));
617                }
618                let details: Vec<&str> = [role.as_deref(), company.as_deref()]
619                    .iter()
620                    .filter_map(|v| *v)
621                    .collect();
622                if !details.is_empty() {
623                    html.push_str(&format!(
624                        " <span class=\"role\">{}</span>",
625                        escape_html(&details.join(", "))
626                    ));
627                }
628                html.push_str("</div>");
629            }
630            html.push_str("</div>");
631            html
632        }
633
634        Block::Style { properties, .. } => {
635            // Style blocks are metadata — rendered as a hidden data element
636            let pairs: Vec<String> = properties
637                .iter()
638                .map(|p| format!("{}={}", escape_html(&p.key), escape_html(&p.value)))
639                .collect();
640            format!(
641                "<div class=\"surfdoc-style\" aria-hidden=\"true\" data-properties=\"{}\"></div>",
642                escape_html(&pairs.join(";"))
643            )
644        }
645
646        Block::Faq { items, .. } => {
647            let mut html = String::from("<div class=\"surfdoc-faq\">");
648            for item in items {
649                html.push_str(&format!(
650                    "<details><summary>{}</summary><div class=\"faq-answer\">{}</div></details>",
651                    escape_html(&item.question),
652                    escape_html(&item.answer),
653                ));
654            }
655            html.push_str("</div>");
656            html
657        }
658
659        Block::PricingTable {
660            headers, rows, ..
661        } => {
662            let mut html = String::from("<table class=\"surfdoc-pricing\" aria-label=\"Pricing comparison\">");
663            if !headers.is_empty() {
664                html.push_str("<thead><tr>");
665                for h in headers {
666                    html.push_str(&format!("<th scope=\"col\">{}</th>", escape_html(h)));
667                }
668                html.push_str("</tr></thead>");
669            }
670            html.push_str("<tbody>");
671            for row in rows {
672                html.push_str("<tr>");
673                for cell in row {
674                    html.push_str(&format!("<td>{}</td>", escape_html(cell)));
675                }
676                html.push_str("</tr>");
677            }
678            html.push_str("</tbody></table>");
679            html
680        }
681
682        Block::Site { properties, domain, .. } => {
683            // Site config is metadata — hidden element with data attributes
684            let domain_attr = match domain {
685                Some(d) => format!(" data-domain=\"{}\"", escape_html(d)),
686                None => String::new(),
687            };
688            let pairs: Vec<String> = properties
689                .iter()
690                .map(|p| format!("{}={}", escape_html(&p.key), escape_html(&p.value)))
691                .collect();
692            format!(
693                "<div class=\"surfdoc-site\" aria-hidden=\"true\"{} data-properties=\"{}\"></div>",
694                domain_attr,
695                escape_html(&pairs.join(";")),
696            )
697        }
698
699        Block::Page {
700            route, layout, title, children, ..
701        } => {
702            let layout_attr = match layout {
703                Some(l) => format!(" data-layout=\"{}\"", escape_html(l)),
704                None => String::new(),
705            };
706            let aria_label = match title {
707                Some(t) => format!(" aria-label=\"{}\"", escape_html(t)),
708                None => format!(" aria-label=\"Page: {}\"", escape_html(route)),
709            };
710            let mut html = format!("<section class=\"surfdoc-page\"{layout_attr}{aria_label}>");
711            for child in children {
712                html.push_str(&render_block(child));
713            }
714            html.push_str("</section>");
715            html
716        }
717
718        Block::Unknown {
719            name, content, ..
720        } => {
721            format!(
722                "<div class=\"surfdoc-unknown\" role=\"note\" data-name=\"{}\">{}</div>",
723                escape_html(name),
724                escape_html(content),
725            )
726        }
727    }
728}
729
730fn callout_type_str(ct: CalloutType) -> &'static str {
731    match ct {
732        CalloutType::Info => "info",
733        CalloutType::Warning => "warning",
734        CalloutType::Danger => "danger",
735        CalloutType::Tip => "tip",
736        CalloutType::Note => "note",
737        CalloutType::Success => "success",
738    }
739}
740
741fn decision_status_str(ds: DecisionStatus) -> &'static str {
742    match ds {
743        DecisionStatus::Proposed => "proposed",
744        DecisionStatus::Accepted => "accepted",
745        DecisionStatus::Rejected => "rejected",
746        DecisionStatus::Superseded => "superseded",
747    }
748}
749
750fn capitalize(s: &str) -> String {
751    let mut chars = s.chars();
752    match chars.next() {
753        None => String::new(),
754        Some(c) => c.to_uppercase().to_string() + chars.as_str(),
755    }
756}
757
758// -- Multi-page site extraction and rendering --------------------------
759
760/// Extracted site-level configuration from a `::site` block.
761#[derive(Debug, Clone, Default)]
762pub struct SiteConfig {
763    pub domain: Option<String>,
764    pub name: Option<String>,
765    pub tagline: Option<String>,
766    pub theme: Option<String>,
767    pub accent: Option<String>,
768    pub font: Option<String>,
769    pub properties: Vec<StyleProperty>,
770}
771
772/// A single page extracted from a `::page` block.
773#[derive(Debug, Clone)]
774pub struct PageEntry {
775    pub route: String,
776    pub layout: Option<String>,
777    pub title: Option<String>,
778    pub sidebar: bool,
779    pub children: Vec<Block>,
780}
781
782/// Extract site config and page list from a parsed SurfDoc.
783///
784/// Returns `(site_config, pages, loose_blocks)` where `loose_blocks` are
785/// top-level blocks that are neither `Site` nor `Page`.
786pub fn extract_site(doc: &SurfDoc) -> (Option<SiteConfig>, Vec<PageEntry>, Vec<Block>) {
787    let mut site_config: Option<SiteConfig> = None;
788    let mut pages: Vec<PageEntry> = Vec::new();
789    let mut loose: Vec<Block> = Vec::new();
790
791    for block in &doc.blocks {
792        match block {
793            Block::Site {
794                domain,
795                properties,
796                ..
797            } => {
798                let mut config = SiteConfig {
799                    domain: domain.clone(),
800                    properties: properties.clone(),
801                    ..Default::default()
802                };
803                for prop in properties {
804                    match prop.key.as_str() {
805                        "name" => config.name = Some(prop.value.clone()),
806                        "tagline" => config.tagline = Some(prop.value.clone()),
807                        "theme" => config.theme = Some(prop.value.clone()),
808                        "accent" => config.accent = Some(prop.value.clone()),
809                        "font" => config.font = Some(prop.value.clone()),
810                        _ => {}
811                    }
812                }
813                site_config = Some(config);
814            }
815            Block::Page {
816                route,
817                layout,
818                title,
819                sidebar,
820                children,
821                ..
822            } => {
823                pages.push(PageEntry {
824                    route: route.clone(),
825                    layout: layout.clone(),
826                    title: title.clone(),
827                    sidebar: *sidebar,
828                    children: children.clone(),
829                });
830            }
831            other => {
832                loose.push(other.clone());
833            }
834        }
835    }
836
837    (site_config, pages, loose)
838}
839
840/// CSS for site-level navigation and footer.
841const SITE_NAV_CSS: &str = r#"
842/* Site navigation */
843.surfdoc-site-nav { display: flex; align-items: center; gap: 1.5rem; padding: 0.75rem 1.5rem; background: var(--bg-card); border-bottom: 1px solid var(--border-subtle); max-width: 100%; position: sticky; top: 0; z-index: 100; }
844.surfdoc-site-nav .site-name { font-weight: 700; color: #fff; font-size: 1rem; text-decoration: none; margin-right: auto; }
845.surfdoc-site-nav a { color: var(--text-dim); text-decoration: none; font-size: 0.875rem; padding: 0.25rem 0.5rem; border-radius: 4px; transition: color 0.15s, background 0.15s; }
846.surfdoc-site-nav a:hover { color: var(--text); background: var(--bg-hover); }
847.surfdoc-site-nav a.active { color: var(--accent); font-weight: 600; }
848
849/* Site footer */
850.surfdoc-site-footer { margin-top: 4rem; padding: 1.5rem; border-top: 1px solid var(--border-subtle); text-align: center; color: var(--text-muted); font-size: 0.8rem; }
851"#;
852
853/// Render a full HTML page for one route within a multi-page site.
854///
855/// Produces a `<!DOCTYPE html>` page with site-level `<nav>`, page content,
856/// and a footer. Theme and accent from `SiteConfig` are applied via CSS variables.
857pub fn render_site_page(
858    page: &PageEntry,
859    site: &SiteConfig,
860    nav_items: &[(String, String)], // (route, title) pairs
861    config: &PageConfig,
862) -> String {
863    // Render page children as HTML
864    let mut body_parts: Vec<String> = Vec::new();
865    for child in &page.children {
866        body_parts.push(render_block(child));
867    }
868    let body = body_parts.join("\n");
869
870    let lang = config.lang.as_deref().unwrap_or("en");
871    let site_name = site
872        .name
873        .as_deref()
874        .unwrap_or("SurfDoc Site");
875
876    // Title: page title > site name + route
877    let title = match &page.title {
878        Some(t) => format!("{} — {}", t, site_name),
879        None if page.route == "/" => site_name.to_string(),
880        None => format!("{} — {}", page.route.trim_start_matches('/'), site_name),
881    };
882
883    let source_path = escape_html(&config.source_path);
884
885    // Build navigation HTML
886    let mut nav_html = format!(
887        "<nav class=\"surfdoc-site-nav\" role=\"navigation\" aria-label=\"Site navigation\">\n  <a href=\"/index.html\" class=\"site-name\">{}</a>\n",
888        escape_html(site_name)
889    );
890    for (route, nav_title) in nav_items {
891        let href = if route == "/" {
892            "/index.html".to_string()
893        } else {
894            format!("{}/index.html", route)
895        };
896        let active = if *route == page.route { " active" } else { "" };
897        nav_html.push_str(&format!(
898            "  <a href=\"{}\"{}>{}</a>\n",
899            escape_html(&href),
900            if active.is_empty() {
901                String::new()
902            } else {
903                format!(" class=\"active\"")
904            },
905            escape_html(nav_title),
906        ));
907    }
908    nav_html.push_str("</nav>");
909
910    // Build footer
911    let footer_html = format!(
912        "<footer class=\"surfdoc-site-footer\">{}</footer>",
913        escape_html(site_name),
914    );
915
916    // Build optional CSS variable overrides from site config
917    let mut css_overrides = String::new();
918    if let Some(accent) = &site.accent {
919        css_overrides.push_str(&format!("--accent: {};\n", escape_html(accent)));
920    }
921    let override_block = if css_overrides.is_empty() {
922        String::new()
923    } else {
924        format!("\n:root {{\n{}}}", css_overrides)
925    };
926
927    // Build optional meta tags
928    let mut meta_extra = String::new();
929    if let Some(desc) = &config.description {
930        meta_extra.push_str(&format!(
931            "\n    <meta name=\"description\" content=\"{}\">",
932            escape_html(desc)
933        ));
934    }
935    if let Some(url) = &config.canonical_url {
936        meta_extra.push_str(&format!(
937            "\n    <link rel=\"canonical\" href=\"{}\">",
938            escape_html(url)
939        ));
940    }
941
942    format!(
943        r#"<!-- Built with SurfDoc — source: {source_path} -->
944<!DOCTYPE html>
945<html lang="{lang}">
946<head>
947    <meta charset="utf-8">
948    <meta name="viewport" content="width=device-width, initial-scale=1">
949    <meta name="generator" content="SurfDoc v0.1">
950    <link rel="alternate" type="text/surfdoc" href="{source_path}">
951    <title>{title}</title>{meta_extra}
952    <style>{css}{nav_css}{override_block}</style>
953</head>
954<body>
955{nav}
956<article class="surfdoc">
957{body}
958</article>
959{footer}
960</body>
961</html>"#,
962        source_path = source_path,
963        lang = escape_html(lang),
964        title = escape_html(&title),
965        meta_extra = meta_extra,
966        css = SURFDOC_CSS,
967        nav_css = SITE_NAV_CSS,
968        override_block = override_block,
969        nav = nav_html,
970        body = body,
971        footer = footer_html,
972    )
973}
974
975#[cfg(test)]
976mod tests {
977    use super::*;
978    use crate::types::*;
979
980    fn span() -> Span {
981        Span {
982            start_line: 1,
983            end_line: 1,
984            start_offset: 0,
985            end_offset: 0,
986        }
987    }
988
989    fn doc_with(blocks: Vec<Block>) -> SurfDoc {
990        SurfDoc {
991            front_matter: None,
992            blocks,
993            source: String::new(),
994        }
995    }
996
997    #[test]
998    fn html_callout() {
999        let doc = doc_with(vec![Block::Callout {
1000            callout_type: CalloutType::Warning,
1001            title: Some("Caution".into()),
1002            content: "Be careful.".into(),
1003            span: span(),
1004        }]);
1005        let html = to_html(&doc);
1006        assert!(html.contains("class=\"surfdoc-callout surfdoc-callout-warning\""));
1007        assert!(html.contains("<strong>Warning</strong>"));
1008        assert!(html.contains("Be careful."));
1009    }
1010
1011    #[test]
1012    fn html_data_table() {
1013        let doc = doc_with(vec![Block::Data {
1014            id: None,
1015            format: DataFormat::Table,
1016            sortable: false,
1017            headers: vec!["Name".into(), "Age".into()],
1018            rows: vec![vec!["Alice".into(), "30".into()]],
1019            raw_content: String::new(),
1020            span: span(),
1021        }]);
1022        let html = to_html(&doc);
1023        assert!(html.contains("<table class=\"surfdoc-data\">"));
1024        assert!(html.contains("<thead>"));
1025        assert!(html.contains("<tbody>"));
1026        assert!(html.contains("<th scope=\"col\">Name</th>"));
1027        assert!(html.contains("<td>Alice</td>"));
1028    }
1029
1030    #[test]
1031    fn html_code() {
1032        let doc = doc_with(vec![Block::Code {
1033            lang: Some("rust".into()),
1034            file: None,
1035            highlight: vec![],
1036            content: "fn main() { println!(\"<hello>\"); }".into(),
1037            span: span(),
1038        }]);
1039        let html = to_html(&doc);
1040        assert!(html.contains("<pre class=\"surfdoc-code\" aria-label=\"rust code\">"));
1041        assert!(html.contains("class=\"language-rust\""));
1042        assert!(html.contains("&lt;hello&gt;"), "Angle brackets should be escaped");
1043    }
1044
1045    #[test]
1046    fn html_tasks() {
1047        let doc = doc_with(vec![Block::Tasks {
1048            items: vec![
1049                TaskItem {
1050                    done: true,
1051                    text: "Done item".into(),
1052                    assignee: None,
1053                },
1054                TaskItem {
1055                    done: false,
1056                    text: "Pending item".into(),
1057                    assignee: None,
1058                },
1059            ],
1060            span: span(),
1061        }]);
1062        let html = to_html(&doc);
1063        assert!(html.contains("<input type=\"checkbox\" checked disabled>"));
1064        assert!(html.contains("<input type=\"checkbox\" disabled>"));
1065    }
1066
1067    #[test]
1068    fn html_metric() {
1069        let doc = doc_with(vec![Block::Metric {
1070            label: "Revenue".into(),
1071            value: "$10K".into(),
1072            trend: Some(Trend::Up),
1073            unit: None,
1074            span: span(),
1075        }]);
1076        let html = to_html(&doc);
1077        assert!(html.contains("class=\"surfdoc-metric\""));
1078        assert!(html.contains("<span class=\"label\">Revenue</span>"));
1079        assert!(html.contains("<span class=\"value\">$10K</span>"));
1080        assert!(html.contains("class=\"trend up\""));
1081    }
1082
1083    #[test]
1084    fn html_figure() {
1085        let doc = doc_with(vec![Block::Figure {
1086            src: "arch.png".into(),
1087            caption: Some("Architecture diagram".into()),
1088            alt: Some("System architecture".into()),
1089            width: None,
1090            span: span(),
1091        }]);
1092        let html = to_html(&doc);
1093        assert!(html.contains("<figure class=\"surfdoc-figure\">"));
1094        assert!(html.contains("<img src=\"arch.png\" alt=\"System architecture\" />"));
1095        assert!(html.contains("<figcaption>Architecture diagram</figcaption>"));
1096    }
1097
1098    #[test]
1099    fn html_markdown_rendered() {
1100        let doc = doc_with(vec![Block::Markdown {
1101            content: "# Hello\n\nWorld".into(),
1102            span: span(),
1103        }]);
1104        let html = to_html(&doc);
1105        assert!(html.contains("<h1>Hello</h1>"));
1106    }
1107
1108    #[test]
1109    fn html_escaping() {
1110        let doc = doc_with(vec![Block::Callout {
1111            callout_type: CalloutType::Info,
1112            title: None,
1113            content: "<script>alert('xss')</script>".into(),
1114            span: span(),
1115        }]);
1116        let html = to_html(&doc);
1117        assert!(
1118            !html.contains("<script>"),
1119            "Script tags must be escaped"
1120        );
1121        assert!(html.contains("&lt;script&gt;"));
1122    }
1123
1124    // -- New block types (tabs, columns, quote) -------------------------
1125
1126    #[test]
1127    fn html_tabs() {
1128        let doc = doc_with(vec![Block::Tabs {
1129            tabs: vec![
1130                crate::types::TabPanel {
1131                    label: "Overview".into(),
1132                    content: "Intro text.".into(),
1133                },
1134                crate::types::TabPanel {
1135                    label: "Details".into(),
1136                    content: "Technical info.".into(),
1137                },
1138            ],
1139            span: span(),
1140        }]);
1141        let html = to_html(&doc);
1142        assert!(html.contains("class=\"surfdoc-tabs\""));
1143        assert!(html.contains("Overview"));
1144        assert!(html.contains("Details"));
1145        assert!(html.contains("Intro text."));
1146        assert!(html.contains("Technical info."));
1147        assert!(html.contains("tab-btn"));
1148        assert!(html.contains("tab-panel"));
1149    }
1150
1151    #[test]
1152    fn html_columns() {
1153        let doc = doc_with(vec![Block::Columns {
1154            columns: vec![
1155                crate::types::ColumnContent {
1156                    content: "Left side.".into(),
1157                },
1158                crate::types::ColumnContent {
1159                    content: "Right side.".into(),
1160                },
1161            ],
1162            span: span(),
1163        }]);
1164        let html = to_html(&doc);
1165        assert!(html.contains("class=\"surfdoc-columns\""));
1166        assert!(html.contains("data-cols=\"2\""));
1167        assert!(html.contains("class=\"surfdoc-column\""));
1168        assert!(html.contains("Left side."));
1169        assert!(html.contains("Right side."));
1170    }
1171
1172    #[test]
1173    fn html_quote_with_attribution() {
1174        let doc = doc_with(vec![Block::Quote {
1175            content: "The best way to predict the future is to invent it.".into(),
1176            attribution: Some("Alan Kay".into()),
1177            cite: Some("ACM 1971".into()),
1178            span: span(),
1179        }]);
1180        let html = to_html(&doc);
1181        assert!(html.contains("class=\"surfdoc-quote\""));
1182        assert!(html.contains("<blockquote>"));
1183        assert!(html.contains("class=\"attribution\""));
1184        assert!(html.contains("Alan Kay"));
1185        assert!(html.contains("<cite>ACM 1971</cite>"));
1186    }
1187
1188    #[test]
1189    fn html_quote_no_attribution() {
1190        let doc = doc_with(vec![Block::Quote {
1191            content: "Anonymous wisdom.".into(),
1192            attribution: None,
1193            cite: None,
1194            span: span(),
1195        }]);
1196        let html = to_html(&doc);
1197        assert!(html.contains("class=\"surfdoc-quote\""));
1198        assert!(html.contains("Anonymous wisdom."));
1199        assert!(!html.contains("attribution"));
1200    }
1201
1202    // -- Web blocks (cta, hero-image, testimonial, style) ---------------
1203
1204    #[test]
1205    fn html_cta_primary() {
1206        let doc = doc_with(vec![Block::Cta {
1207            label: "Get Started".into(),
1208            href: "/signup".into(),
1209            primary: true,
1210            span: span(),
1211        }]);
1212        let html = to_html(&doc);
1213        assert!(html.contains("class=\"surfdoc-cta surfdoc-cta-primary\""));
1214        assert!(html.contains("href=\"/signup\""));
1215        assert!(html.contains("Get Started"));
1216    }
1217
1218    #[test]
1219    fn html_cta_secondary() {
1220        let doc = doc_with(vec![Block::Cta {
1221            label: "Learn More".into(),
1222            href: "https://example.com".into(),
1223            primary: false,
1224            span: span(),
1225        }]);
1226        let html = to_html(&doc);
1227        assert!(html.contains("surfdoc-cta-secondary"));
1228        assert!(html.contains("Learn More"));
1229    }
1230
1231    #[test]
1232    fn html_hero_image() {
1233        let doc = doc_with(vec![Block::HeroImage {
1234            src: "screenshot.png".into(),
1235            alt: Some("App screenshot".into()),
1236            span: span(),
1237        }]);
1238        let html = to_html(&doc);
1239        assert!(html.contains("class=\"surfdoc-hero-image\""));
1240        assert!(html.contains("src=\"screenshot.png\""));
1241        assert!(html.contains("alt=\"App screenshot\""));
1242    }
1243
1244    #[test]
1245    fn html_testimonial() {
1246        let doc = doc_with(vec![Block::Testimonial {
1247            content: "Amazing product!".into(),
1248            author: Some("Jane Dev".into()),
1249            role: Some("Engineer".into()),
1250            company: Some("Acme".into()),
1251            span: span(),
1252        }]);
1253        let html = to_html(&doc);
1254        assert!(html.contains("class=\"surfdoc-testimonial\""));
1255        assert!(html.contains("Amazing product!"));
1256        assert!(html.contains("Jane Dev"));
1257        assert!(html.contains("Engineer, Acme"));
1258    }
1259
1260    #[test]
1261    fn html_testimonial_anonymous() {
1262        let doc = doc_with(vec![Block::Testimonial {
1263            content: "Great tool.".into(),
1264            author: None,
1265            role: None,
1266            company: None,
1267            span: span(),
1268        }]);
1269        let html = to_html(&doc);
1270        assert!(html.contains("Great tool."));
1271        assert!(!html.contains("class=\"author\""));
1272    }
1273
1274    #[test]
1275    fn html_style_hidden() {
1276        let doc = doc_with(vec![Block::Style {
1277            properties: vec![
1278                crate::types::StyleProperty { key: "accent".into(), value: "#6366f1".into() },
1279            ],
1280            span: span(),
1281        }]);
1282        let html = to_html(&doc);
1283        assert!(html.contains("class=\"surfdoc-style\""));
1284    }
1285
1286    #[test]
1287    fn html_cta_escapes_xss() {
1288        let doc = doc_with(vec![Block::Cta {
1289            label: "<script>alert('xss')</script>".into(),
1290            href: "javascript:alert(1)".into(),
1291            primary: true,
1292            span: span(),
1293        }]);
1294        let html = to_html(&doc);
1295        assert!(!html.contains("<script>"));
1296        assert!(html.contains("&lt;script&gt;"));
1297    }
1298
1299    #[test]
1300    fn html_faq() {
1301        let doc = doc_with(vec![Block::Faq {
1302            items: vec![
1303                crate::types::FaqItem {
1304                    question: "Is it free?".into(),
1305                    answer: "Yes, the free tier is forever.".into(),
1306                },
1307                crate::types::FaqItem {
1308                    question: "Can I self-host?".into(),
1309                    answer: "Docker image available.".into(),
1310                },
1311            ],
1312            span: span(),
1313        }]);
1314        let html = to_html(&doc);
1315        assert!(html.contains("class=\"surfdoc-faq\""));
1316        assert!(html.contains("<summary>Is it free?</summary>"));
1317        assert!(html.contains("<summary>Can I self-host?</summary>"));
1318        assert!(html.contains("class=\"faq-answer\""));
1319        assert!(html.contains("Yes, the free tier is forever."));
1320    }
1321
1322    #[test]
1323    fn html_pricing_table() {
1324        let doc = doc_with(vec![Block::PricingTable {
1325            headers: vec!["".into(), "Free".into(), "Pro".into()],
1326            rows: vec![
1327                vec!["Price".into(), "$0".into(), "$9/mo".into()],
1328                vec!["Storage".into(), "1GB".into(), "100GB".into()],
1329            ],
1330            span: span(),
1331        }]);
1332        let html = to_html(&doc);
1333        assert!(html.contains("class=\"surfdoc-pricing\""));
1334        assert!(html.contains("<th scope=\"col\">Free</th>"));
1335        assert!(html.contains("<th scope=\"col\">Pro</th>"));
1336        assert!(html.contains("<td>$9/mo</td>"));
1337    }
1338
1339    #[test]
1340    fn html_faq_escapes_xss() {
1341        let doc = doc_with(vec![Block::Faq {
1342            items: vec![crate::types::FaqItem {
1343                question: "<script>alert('q')</script>".into(),
1344                answer: "<img onerror=alert(1)>".into(),
1345            }],
1346            span: span(),
1347        }]);
1348        let html = to_html(&doc);
1349        assert!(!html.contains("<script>"));
1350        assert!(html.contains("&lt;script&gt;"));
1351    }
1352
1353    #[test]
1354    fn html_site_hidden() {
1355        let doc = doc_with(vec![Block::Site {
1356            domain: Some("notesurf.io".into()),
1357            properties: vec![
1358                crate::types::StyleProperty { key: "name".into(), value: "NoteSurf".into() },
1359            ],
1360            span: span(),
1361        }]);
1362        let html = to_html(&doc);
1363        assert!(html.contains("class=\"surfdoc-site\""));
1364        assert!(html.contains("data-domain=\"notesurf.io\""));
1365    }
1366
1367    #[test]
1368    fn html_page_hero_layout() {
1369        let doc = doc_with(vec![Block::Page {
1370            route: "/".into(),
1371            layout: Some("hero".into()),
1372            title: None,
1373            sidebar: false,
1374            content: "# Welcome".into(),
1375            children: vec![
1376                Block::Markdown {
1377                    content: "# Welcome".into(),
1378                    span: span(),
1379                },
1380                Block::Cta {
1381                    label: "Get Started".into(),
1382                    href: "/signup".into(),
1383                    primary: true,
1384                    span: span(),
1385                },
1386            ],
1387            span: span(),
1388        }]);
1389        let html = to_html(&doc);
1390        assert!(html.contains("class=\"surfdoc-page\""));
1391        assert!(html.contains("data-layout=\"hero\""));
1392        assert!(html.contains("Get Started")); // CTA rendered
1393        assert!(html.contains("surfdoc-cta")); // CTA has class
1394    }
1395
1396    #[test]
1397    fn html_page_renders_children() {
1398        let doc = doc_with(vec![Block::Page {
1399            route: "/pricing".into(),
1400            layout: None,
1401            title: Some("Pricing".into()),
1402            sidebar: false,
1403            content: String::new(),
1404            children: vec![
1405                Block::Markdown {
1406                    content: "# Pricing".into(),
1407                    span: span(),
1408                },
1409                Block::HeroImage {
1410                    src: "pricing.png".into(),
1411                    alt: Some("Plans".into()),
1412                    span: span(),
1413                },
1414            ],
1415            span: span(),
1416        }]);
1417        let html = to_html(&doc);
1418        assert!(html.contains("<section class=\"surfdoc-page\" aria-label=\"Pricing\">"));
1419        assert!(html.contains("<h1>Pricing</h1>")); // Markdown rendered
1420        assert!(html.contains("surfdoc-hero-image")); // Hero image rendered
1421    }
1422
1423    // -- Full-page discovery mechanism ---------------------------------
1424
1425    #[test]
1426    fn html_page_has_generator_meta() {
1427        let doc = doc_with(vec![Block::Markdown {
1428            content: "# Hello".into(),
1429            span: span(),
1430        }]);
1431        let config = PageConfig::default();
1432        let html = to_html_page(&doc, &config);
1433        assert!(html.contains("<meta name=\"generator\" content=\"SurfDoc v0.1\">"));
1434    }
1435
1436    #[test]
1437    fn html_page_has_link_alternate() {
1438        let doc = doc_with(vec![]);
1439        let config = PageConfig::default();
1440        let html = to_html_page(&doc, &config);
1441        assert!(html.contains(
1442            "<link rel=\"alternate\" type=\"text/surfdoc\" href=\"source.surf\">"
1443        ));
1444    }
1445
1446    #[test]
1447    fn html_page_has_source_comment() {
1448        let doc = doc_with(vec![]);
1449        let config = PageConfig {
1450            source_path: "site.surf".to_string(),
1451            ..Default::default()
1452        };
1453        let html = to_html_page(&doc, &config);
1454        assert!(html.starts_with("<!-- Built with SurfDoc — source: site.surf -->"));
1455    }
1456
1457    #[test]
1458    fn html_page_uses_front_matter_title() {
1459        let doc = SurfDoc {
1460            front_matter: Some(FrontMatter {
1461                title: Some("My Site".to_string()),
1462                ..Default::default()
1463            }),
1464            blocks: vec![],
1465            source: String::new(),
1466        };
1467        let config = PageConfig::default();
1468        let html = to_html_page(&doc, &config);
1469        assert!(html.contains("<title>My Site</title>"));
1470    }
1471
1472    #[test]
1473    fn html_page_config_title_overrides_front_matter() {
1474        let doc = SurfDoc {
1475            front_matter: Some(FrontMatter {
1476                title: Some("FM Title".to_string()),
1477                ..Default::default()
1478            }),
1479            blocks: vec![],
1480            source: String::new(),
1481        };
1482        let config = PageConfig {
1483            title: Some("Override Title".to_string()),
1484            ..Default::default()
1485        };
1486        let html = to_html_page(&doc, &config);
1487        assert!(html.contains("<title>Override Title</title>"));
1488        assert!(!html.contains("FM Title"));
1489    }
1490
1491    #[test]
1492    fn html_page_has_doctype_and_structure() {
1493        let doc = doc_with(vec![Block::Markdown {
1494            content: "Hello".into(),
1495            span: span(),
1496        }]);
1497        let config = PageConfig::default();
1498        let html = to_html_page(&doc, &config);
1499        assert!(html.contains("<!DOCTYPE html>"));
1500        assert!(html.contains("<html lang=\"en\">"));
1501        assert!(html.contains("<meta charset=\"utf-8\">"));
1502        assert!(html.contains("<meta name=\"viewport\""));
1503        assert!(html.contains("<body>"));
1504        assert!(html.contains("</body>"));
1505        assert!(html.contains("</html>"));
1506    }
1507
1508    #[test]
1509    fn html_page_includes_description_and_canonical() {
1510        let doc = doc_with(vec![]);
1511        let config = PageConfig {
1512            description: Some("A test page".to_string()),
1513            canonical_url: Some("https://example.com/page".to_string()),
1514            ..Default::default()
1515        };
1516        let html = to_html_page(&doc, &config);
1517        assert!(html.contains("<meta name=\"description\" content=\"A test page\">"));
1518        assert!(html.contains(
1519            "<link rel=\"canonical\" href=\"https://example.com/page\">"
1520        ));
1521    }
1522
1523    #[test]
1524    fn html_page_custom_source_path() {
1525        let doc = doc_with(vec![]);
1526        let config = PageConfig {
1527            source_path: "/docs/readme.surf".to_string(),
1528            ..Default::default()
1529        };
1530        let html = to_html_page(&doc, &config);
1531        assert!(html.contains("href=\"/docs/readme.surf\""));
1532        assert!(html.contains("source: /docs/readme.surf"));
1533    }
1534
1535    #[test]
1536    fn html_page_escapes_title_xss() {
1537        let doc = doc_with(vec![]);
1538        let config = PageConfig {
1539            title: Some("<script>alert('xss')</script>".to_string()),
1540            ..Default::default()
1541        };
1542        let html = to_html_page(&doc, &config);
1543        assert!(!html.contains("<script>alert"));
1544        assert!(html.contains("&lt;script&gt;"));
1545    }
1546
1547    // -- ARIA accessibility tests -----------------------------------------
1548
1549    #[test]
1550    fn aria_callout_danger_role_alert() {
1551        let doc = doc_with(vec![Block::Callout {
1552            callout_type: CalloutType::Danger,
1553            title: None,
1554            content: "Critical error.".into(),
1555            span: span(),
1556        }]);
1557        let html = to_html(&doc);
1558        assert!(html.contains("role=\"alert\""));
1559    }
1560
1561    #[test]
1562    fn aria_callout_info_role_note() {
1563        let doc = doc_with(vec![Block::Callout {
1564            callout_type: CalloutType::Info,
1565            title: None,
1566            content: "FYI.".into(),
1567            span: span(),
1568        }]);
1569        let html = to_html(&doc);
1570        assert!(html.contains("role=\"note\""));
1571    }
1572
1573    #[test]
1574    fn aria_data_table_scope_col() {
1575        let doc = doc_with(vec![Block::Data {
1576            id: None,
1577            format: DataFormat::Table,
1578            sortable: false,
1579            headers: vec!["Col1".into()],
1580            rows: vec![],
1581            raw_content: String::new(),
1582            span: span(),
1583        }]);
1584        let html = to_html(&doc);
1585        assert!(html.contains("scope=\"col\""));
1586    }
1587
1588    #[test]
1589    fn aria_code_label() {
1590        let doc = doc_with(vec![Block::Code {
1591            lang: Some("python".into()),
1592            file: None,
1593            highlight: vec![],
1594            content: "print()".into(),
1595            span: span(),
1596        }]);
1597        let html = to_html(&doc);
1598        assert!(html.contains("aria-label=\"python code\""));
1599    }
1600
1601    #[test]
1602    fn aria_tasks_label_wraps_checkbox() {
1603        let doc = doc_with(vec![Block::Tasks {
1604            items: vec![TaskItem {
1605                done: false,
1606                text: "Do thing".into(),
1607                assignee: None,
1608            }],
1609            span: span(),
1610        }]);
1611        let html = to_html(&doc);
1612        assert!(html.contains("<label><input type=\"checkbox\" disabled> Do thing</label>"));
1613    }
1614
1615    #[test]
1616    fn aria_decision_role_note() {
1617        let doc = doc_with(vec![Block::Decision {
1618            status: DecisionStatus::Accepted,
1619            date: None,
1620            deciders: vec![],
1621            content: "We decided.".into(),
1622            span: span(),
1623        }]);
1624        let html = to_html(&doc);
1625        assert!(html.contains("role=\"note\""));
1626        assert!(html.contains("aria-label=\"Decision: accepted\""));
1627    }
1628
1629    #[test]
1630    fn aria_metric_group_label() {
1631        let doc = doc_with(vec![Block::Metric {
1632            label: "MRR".into(),
1633            value: "$5K".into(),
1634            trend: Some(Trend::Up),
1635            unit: Some("USD".into()),
1636            span: span(),
1637        }]);
1638        let html = to_html(&doc);
1639        assert!(html.contains("role=\"group\""));
1640        assert!(html.contains("aria-label=\"MRR: $5K USD, trending up\""));
1641    }
1642
1643    #[test]
1644    fn aria_summary_doc_abstract() {
1645        let doc = doc_with(vec![Block::Summary {
1646            content: "TL;DR.".into(),
1647            span: span(),
1648        }]);
1649        let html = to_html(&doc);
1650        assert!(html.contains("role=\"doc-abstract\""));
1651    }
1652
1653    #[test]
1654    fn aria_tabs_tablist_pattern() {
1655        let doc = doc_with(vec![Block::Tabs {
1656            tabs: vec![
1657                TabPanel { label: "A".into(), content: "First.".into() },
1658                TabPanel { label: "B".into(), content: "Second.".into() },
1659            ],
1660            span: span(),
1661        }]);
1662        let html = to_html(&doc);
1663        assert!(html.contains("role=\"tablist\""));
1664        assert!(html.contains("role=\"tab\""));
1665        assert!(html.contains("role=\"tabpanel\""));
1666        assert!(html.contains("aria-selected=\"true\""));
1667        assert!(html.contains("aria-selected=\"false\""));
1668        assert!(html.contains("aria-controls=\"surfdoc-panel-0\""));
1669        assert!(html.contains("aria-labelledby=\"surfdoc-tab-0\""));
1670    }
1671
1672    #[test]
1673    fn aria_hero_image_role_img() {
1674        let doc = doc_with(vec![Block::HeroImage {
1675            src: "hero.png".into(),
1676            alt: Some("Product shot".into()),
1677            span: span(),
1678        }]);
1679        let html = to_html(&doc);
1680        assert!(html.contains("role=\"img\""));
1681        assert!(html.contains("aria-label=\"Product shot\""));
1682    }
1683
1684    #[test]
1685    fn aria_testimonial_role_figure() {
1686        let doc = doc_with(vec![Block::Testimonial {
1687            content: "Great!".into(),
1688            author: Some("Ada".into()),
1689            role: None,
1690            company: None,
1691            span: span(),
1692        }]);
1693        let html = to_html(&doc);
1694        assert!(html.contains("role=\"figure\""));
1695        assert!(html.contains("aria-label=\"Testimonial from Ada\""));
1696    }
1697
1698    #[test]
1699    fn aria_style_hidden() {
1700        let doc = doc_with(vec![Block::Style {
1701            properties: vec![],
1702            span: span(),
1703        }]);
1704        let html = to_html(&doc);
1705        assert!(html.contains("aria-hidden=\"true\""));
1706    }
1707
1708    #[test]
1709    fn aria_site_hidden() {
1710        let doc = doc_with(vec![Block::Site {
1711            domain: None,
1712            properties: vec![],
1713            span: span(),
1714        }]);
1715        let html = to_html(&doc);
1716        assert!(html.contains("aria-hidden=\"true\""));
1717    }
1718
1719    #[test]
1720    fn aria_page_label_from_title() {
1721        let doc = doc_with(vec![Block::Page {
1722            route: "/about".into(),
1723            layout: None,
1724            title: Some("About Us".into()),
1725            sidebar: false,
1726            content: String::new(),
1727            children: vec![],
1728            span: span(),
1729        }]);
1730        let html = to_html(&doc);
1731        assert!(html.contains("aria-label=\"About Us\""));
1732    }
1733
1734    #[test]
1735    fn aria_page_label_from_route() {
1736        let doc = doc_with(vec![Block::Page {
1737            route: "/pricing".into(),
1738            layout: None,
1739            title: None,
1740            sidebar: false,
1741            content: String::new(),
1742            children: vec![],
1743            span: span(),
1744        }]);
1745        let html = to_html(&doc);
1746        assert!(html.contains("aria-label=\"Page: /pricing\""));
1747    }
1748
1749    #[test]
1750    fn aria_unknown_role_note() {
1751        let doc = doc_with(vec![Block::Unknown {
1752            name: "custom".into(),
1753            attrs: Default::default(),
1754            content: "stuff".into(),
1755            span: span(),
1756        }]);
1757        let html = to_html(&doc);
1758        assert!(html.contains("role=\"note\""));
1759    }
1760
1761    #[test]
1762    fn aria_pricing_table_scope() {
1763        let doc = doc_with(vec![Block::PricingTable {
1764            headers: vec!["".into(), "Basic".into()],
1765            rows: vec![vec!["Price".into(), "$0".into()]],
1766            span: span(),
1767        }]);
1768        let html = to_html(&doc);
1769        assert!(html.contains("scope=\"col\""));
1770        assert!(html.contains("aria-label=\"Pricing comparison\""));
1771    }
1772
1773    #[test]
1774    fn aria_columns_role_group() {
1775        let doc = doc_with(vec![Block::Columns {
1776            columns: vec![
1777                ColumnContent { content: "A".into() },
1778                ColumnContent { content: "B".into() },
1779            ],
1780            span: span(),
1781        }]);
1782        let html = to_html(&doc);
1783        assert!(html.contains("role=\"group\""));
1784    }
1785
1786    // -- extract_site() unit tests -----------------------------------------
1787
1788    #[test]
1789    fn extract_site_separates_blocks() {
1790        let doc = doc_with(vec![
1791            Block::Site {
1792                domain: Some("example.com".into()),
1793                properties: vec![
1794                    StyleProperty { key: "name".into(), value: "My Site".into() },
1795                    StyleProperty { key: "accent".into(), value: "#ff0000".into() },
1796                ],
1797                span: span(),
1798            },
1799            Block::Markdown {
1800                content: "Loose block".into(),
1801                span: span(),
1802            },
1803            Block::Page {
1804                route: "/".into(),
1805                layout: Some("hero".into()),
1806                title: Some("Home".into()),
1807                sidebar: false,
1808                content: "# Welcome".into(),
1809                children: vec![Block::Markdown {
1810                    content: "# Welcome".into(),
1811                    span: span(),
1812                }],
1813                span: span(),
1814            },
1815            Block::Page {
1816                route: "/about".into(),
1817                layout: None,
1818                title: Some("About".into()),
1819                sidebar: false,
1820                content: "# About".into(),
1821                children: vec![Block::Markdown {
1822                    content: "# About".into(),
1823                    span: span(),
1824                }],
1825                span: span(),
1826            },
1827        ]);
1828
1829        let (site, pages, loose) = extract_site(&doc);
1830
1831        // Site config extracted
1832        let site = site.expect("should have site config");
1833        assert_eq!(site.domain.as_deref(), Some("example.com"));
1834        assert_eq!(site.name.as_deref(), Some("My Site"));
1835        assert_eq!(site.accent.as_deref(), Some("#ff0000"));
1836
1837        // Pages extracted
1838        assert_eq!(pages.len(), 2);
1839        assert_eq!(pages[0].route, "/");
1840        assert_eq!(pages[0].title.as_deref(), Some("Home"));
1841        assert_eq!(pages[1].route, "/about");
1842
1843        // Loose blocks
1844        assert_eq!(loose.len(), 1);
1845    }
1846
1847    #[test]
1848    fn extract_site_no_site_block() {
1849        let doc = doc_with(vec![
1850            Block::Markdown {
1851                content: "Just markdown".into(),
1852                span: span(),
1853            },
1854        ]);
1855
1856        let (site, pages, loose) = extract_site(&doc);
1857        assert!(site.is_none());
1858        assert!(pages.is_empty());
1859        assert_eq!(loose.len(), 1);
1860    }
1861
1862    #[test]
1863    fn extract_site_config_fields() {
1864        let doc = doc_with(vec![Block::Site {
1865            domain: Some("test.io".into()),
1866            properties: vec![
1867                StyleProperty { key: "name".into(), value: "Test".into() },
1868                StyleProperty { key: "tagline".into(), value: "A tagline".into() },
1869                StyleProperty { key: "theme".into(), value: "dark".into() },
1870                StyleProperty { key: "accent".into(), value: "#00ff00".into() },
1871                StyleProperty { key: "font".into(), value: "inter".into() },
1872                StyleProperty { key: "custom".into(), value: "value".into() },
1873            ],
1874            span: span(),
1875        }]);
1876
1877        let (site, _, _) = extract_site(&doc);
1878        let site = site.unwrap();
1879        assert_eq!(site.name.as_deref(), Some("Test"));
1880        assert_eq!(site.tagline.as_deref(), Some("A tagline"));
1881        assert_eq!(site.theme.as_deref(), Some("dark"));
1882        assert_eq!(site.accent.as_deref(), Some("#00ff00"));
1883        assert_eq!(site.font.as_deref(), Some("inter"));
1884        assert_eq!(site.properties.len(), 6); // all properties preserved
1885    }
1886
1887    // -- render_site_page() unit tests ------------------------------------
1888
1889    #[test]
1890    fn render_site_page_produces_valid_html() {
1891        let site = SiteConfig {
1892            name: Some("Test Site".into()),
1893            accent: Some("#3b82f6".into()),
1894            ..Default::default()
1895        };
1896        let page = PageEntry {
1897            route: "/".into(),
1898            layout: None,
1899            title: Some("Home".into()),
1900            sidebar: false,
1901            children: vec![Block::Markdown {
1902                content: "# Hello World".into(),
1903                span: span(),
1904            }],
1905        };
1906        let nav_items = vec![
1907            ("/".into(), "Home".into()),
1908            ("/about".into(), "About".into()),
1909        ];
1910        let config = PageConfig::default();
1911
1912        let html = render_site_page(&page, &site, &nav_items, &config);
1913
1914        assert!(html.contains("<!DOCTYPE html>"));
1915        assert!(html.contains("<html lang=\"en\">"));
1916        assert!(html.contains("surfdoc-site-nav"));
1917        assert!(html.contains("Test Site"));
1918        assert!(html.contains("Hello World"));
1919        assert!(html.contains("surfdoc-site-footer"));
1920        assert!(html.contains("#3b82f6")); // accent override
1921    }
1922
1923    #[test]
1924    fn render_site_page_has_nav_links() {
1925        let site = SiteConfig {
1926            name: Some("Nav Test".into()),
1927            ..Default::default()
1928        };
1929        let page = PageEntry {
1930            route: "/about".into(),
1931            layout: None,
1932            title: Some("About".into()),
1933            sidebar: false,
1934            children: vec![],
1935        };
1936        let nav_items = vec![
1937            ("/".into(), "Home".into()),
1938            ("/about".into(), "About".into()),
1939            ("/pricing".into(), "Pricing".into()),
1940        ];
1941        let config = PageConfig::default();
1942
1943        let html = render_site_page(&page, &site, &nav_items, &config);
1944
1945        assert!(html.contains("/index.html"));
1946        assert!(html.contains("/about/index.html"));
1947        assert!(html.contains("/pricing/index.html"));
1948        // Active link for about page
1949        assert!(html.contains("class=\"active\">About</a>"));
1950    }
1951
1952    #[test]
1953    fn render_site_page_title_format() {
1954        let site = SiteConfig {
1955            name: Some("My Site".into()),
1956            ..Default::default()
1957        };
1958
1959        // Page with title
1960        let page = PageEntry {
1961            route: "/about".into(),
1962            layout: None,
1963            title: Some("About Us".into()),
1964            sidebar: false,
1965            children: vec![],
1966        };
1967        let html = render_site_page(&page, &site, &[], &PageConfig::default());
1968        assert!(html.contains("<title>About Us — My Site</title>"));
1969
1970        // Home page without title
1971        let home = PageEntry {
1972            route: "/".into(),
1973            layout: None,
1974            title: None,
1975            sidebar: false,
1976            children: vec![],
1977        };
1978        let html = render_site_page(&home, &site, &[], &PageConfig::default());
1979        assert!(html.contains("<title>My Site</title>"));
1980    }
1981}