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::citation::{self, CiteRef};
8use crate::icons::get_icon;
9use crate::types::{Block, CalloutType, ChartType, DecisionStatus, Format, FormFieldType, HttpMethod, ListDisplay, NavGroup, NavItem, RowState, StyleProperty, SurfDoc, Trend};
10
11/// Render a markdown string to HTML using pulldown-cmark with GFM extensions.
12///
13/// Tables are wrapped in `<div class="surfdoc-table-wrap">` for responsive scrolling.
14fn render_markdown(content: &str) -> String {
15    let mut options = pulldown_cmark::Options::empty();
16    options.insert(pulldown_cmark::Options::ENABLE_TABLES);
17    options.insert(pulldown_cmark::Options::ENABLE_STRIKETHROUGH);
18    options.insert(pulldown_cmark::Options::ENABLE_TASKLISTS);
19    let parser = pulldown_cmark::Parser::new_ext(content, options);
20    let mut html_output = String::new();
21    pulldown_cmark::html::push_html(&mut html_output, parser);
22    // Sanitize HTML to prevent XSS — strips dangerous tags (script, iframe,
23    // object, embed, etc.) and event-handler attributes while preserving safe
24    // formatting produced by pulldown-cmark.
25    let html_output = ammonia::clean(&html_output);
26    // Wrap bare <table> tags in scroll containers for mobile responsiveness
27    let html_output = html_output.replace("<table>", "<div class=\"surfdoc-table-wrap\"><table>");
28    let html_output = html_output.replace("</table>", "</table></div>");
29    // Resolve inline `[@key]` citations against the ambient citation context.
30    substitute_cites_html(&html_output)
31}
32
33/// Replace inline `[@key]` citation tokens in rendered HTML with anchored
34/// in-text citations, using the ambient [`citation::CiteContext`]. The tokens
35/// survive markdown/sanitisation as literal text; we splice our own (trusted)
36/// anchor HTML in afterwards so `class`/`id`/`href` are preserved. No-op without
37/// an installed context or references.
38fn substitute_cites_html(html: &str) -> String {
39    citation::with_active(|ctx| match ctx {
40        Some(ctx) if !ctx.references.is_empty() => {
41            let cites = crate::inline::find_inline_cites(html);
42            if cites.is_empty() {
43                return html.to_string();
44            }
45            let mut out = String::with_capacity(html.len());
46            let mut last = 0;
47            for (s, e, cr) in cites {
48                out.push_str(&html[last..s]);
49                out.push_str(&render_cite_html(&cr, ctx));
50                last = e;
51            }
52            out.push_str(&html[last..]);
53            out
54        }
55        _ => html.to_string(),
56    })
57}
58
59/// Render a single inline citation group as an anchor linking to its first
60/// reference's bibliography entry. Numbered styles wrap in `<sup>`.
61fn render_cite_html(cr: &CiteRef, ctx: &citation::CiteContext) -> String {
62    let text = citation::format_in_text(&ctx.references, cr, ctx.style, &ctx.numbers);
63    let first_key = cr.items.first().map(|i| i.key.as_str()).unwrap_or("");
64    let href = format!("#ref-{}", escape_html(first_key));
65    let inner = escape_html(&text);
66    if citation::is_numbered(ctx.style) {
67        format!(
68            "<sup class=\"surfdoc-cite\"><a href=\"{href}\">{inner}</a></sup>"
69        )
70    } else {
71        format!("<a class=\"surfdoc-cite\" href=\"{href}\">{inner}</a>")
72    }
73}
74
75/// Render a `::bibliography` block from the ambient citation context.
76fn render_bibliography_html(style_override: Option<Format>) -> String {
77    citation::with_active(|ctx| {
78        let Some(ctx) = ctx else {
79            return String::new();
80        };
81        if ctx.references.is_empty() {
82            return String::new();
83        }
84        let style = style_override.unwrap_or(ctx.style);
85        // With no override, use citation-number order for numbered styles; with
86        // an override, pass definition order (the keyed list sorts author styles
87        // and numbers numbered styles by input order).
88        let refs = if style_override.is_some() {
89            ctx.references.clone()
90        } else {
91            citation::ordered_references(ctx)
92        };
93        let entries = citation::reference_list_keyed(&refs, style);
94        let heading = citation::bibliography_heading(style);
95        let style_slug = format_slug(style);
96        let mut body = String::new();
97        if citation::is_numbered(style) {
98            body.push_str("<ol class=\"surfdoc-bibliography-list\">");
99            for (key, formatted) in &entries {
100                body.push_str(&format!(
101                    "<li id=\"ref-{}\" class=\"surfdoc-bibliography-entry\">{}</li>",
102                    escape_html(key),
103                    render_inline_markdown_phrasing(formatted)
104                ));
105            }
106            body.push_str("</ol>");
107        } else {
108            body.push_str("<div class=\"surfdoc-bibliography-list\">");
109            for (key, formatted) in &entries {
110                body.push_str(&format!(
111                    "<p id=\"ref-{}\" class=\"surfdoc-bibliography-entry\">{}</p>",
112                    escape_html(key),
113                    render_inline_markdown_phrasing(formatted)
114                ));
115            }
116            body.push_str("</div>");
117        }
118        format!(
119            "<section class=\"surfdoc-bibliography surfdoc-bib-{style_slug}\" aria-label=\"{heading}\"><h2 class=\"surfdoc-bibliography-heading\">{heading}</h2>{body}</section>"
120        )
121    })
122}
123
124/// Lowercase slug for a citation [`Format`] (used in CSS class names).
125fn format_slug(f: Format) -> &'static str {
126    match f {
127        Format::Ieee => "ieee",
128        Format::Acm => "acm",
129        Format::Article => "article",
130        Format::Mla => "mla",
131        Format::Apa => "apa",
132        Format::Chicago => "chicago",
133    }
134}
135
136/// Render inline markdown (bold, italic, links, code) while escaping raw HTML.
137///
138/// Used for block content (callouts, decisions, etc.) where we want markdown
139/// formatting but must prevent HTML injection. Escapes `<`, `>`, `&` first,
140/// then runs through pulldown-cmark so `*italic*` and `**bold**` still render.
141///
142/// `«FILL: …»` / `«IMG: …»` generation slot markers (resolved later, on the
143/// final page HTML, by [`crate::slots::resolve_slot_markers`]) are treated as
144/// atomic: markdown syntax authors put inside a FILL default or IMG
145/// description (e.g. `**bold**`) must NOT be parsed here. If it were,
146/// pulldown-cmark would splice a `<strong>` tag into the middle of the
147/// marker's own `«…»` body, and `resolve_slot_markers`'s grammar rejects any
148/// marker containing raw `<`/`>` — so the whole marker leaks verbatim into
149/// the served page instead of resolving to its default text.
150fn render_inline_markdown(content: &str) -> String {
151    let escaped = content
152        .replace('&', "&amp;")
153        .replace('<', "&lt;")
154        .replace('>', "&gt;");
155    let escaped = escape_markdown_in_slot_markers(&escaped);
156    render_markdown(&escaped)
157}
158
159/// Backslash-escape CommonMark special characters (`* _ ` ~ [ ] \`) that
160/// appear inside `«…»` spans, so [`render_markdown`]'s pulldown-cmark pass
161/// leaves them as literal text instead of parsing emphasis/code/link/
162/// strikethrough syntax across the marker boundary. See
163/// [`render_inline_markdown`] for why this matters.
164fn escape_markdown_in_slot_markers(s: &str) -> String {
165    if !s.contains('\u{ab}') {
166        return s.to_string();
167    }
168    let mut out = String::with_capacity(s.len() + 16);
169    let mut in_marker = false;
170    for ch in s.chars() {
171        match ch {
172            '\u{ab}' => {
173                in_marker = true;
174                out.push(ch);
175            }
176            '\u{bb}' => {
177                in_marker = false;
178                out.push(ch);
179            }
180            '*' | '_' | '`' | '~' | '[' | ']' | '\\' if in_marker => {
181                out.push('\\');
182                out.push(ch);
183            }
184            _ => out.push(ch),
185        }
186    }
187    out
188}
189
190/// Render inline markdown WITHOUT a wrapping `<p>` element.
191///
192/// Use in phrasing contexts where a `<p>` is invalid (e.g. inside `<label>`
193/// of a task list checkbox row). pulldown-cmark always wraps a single
194/// paragraph of input in `<p>...</p>\n`; this strips that wrapper when
195/// present and returns the inner phrasing content untouched otherwise.
196fn render_inline_markdown_phrasing(content: &str) -> String {
197    let html = render_inline_markdown(content);
198    let trimmed = html.trim_end_matches('\n');
199    if let Some(inner) = trimmed.strip_prefix("<p>").and_then(|s| s.strip_suffix("</p>")) {
200        if !inner.contains("<p>") {
201            return inner.to_string();
202        }
203    }
204    html
205}
206
207/// A `<link rel="icon">` / apple-touch-icon entry for the page head.
208#[derive(Debug, Clone)]
209pub struct HeadIcon {
210    /// `rel` value: `icon`, `apple-touch-icon`, `mask-icon`, …
211    pub rel: String,
212    /// Optional `type` (e.g. `image/png`).
213    pub icon_type: Option<String>,
214    /// Optional `sizes` (e.g. `16x16`, `any`).
215    pub sizes: Option<String>,
216    pub href: String,
217}
218
219/// A `<script src>` entry for the page head.
220#[derive(Debug, Clone)]
221pub struct HeadScript {
222    pub src: String,
223    /// Emit the `defer` attribute.
224    pub defer: bool,
225}
226
227/// Reading-frame chrome for long documents (papers, legal, support): a sticky
228/// toolbar with a back-link + title over a centered reading "sheet". Set on
229/// [`PageConfig::reading_frame`] to wrap the shell page content.
230#[derive(Debug, Clone, Default)]
231pub struct ReadingFrame {
232    /// Document title shown centered in the toolbar.
233    pub title: Option<String>,
234    /// Back-link target. Defaults to "/".
235    pub back_href: Option<String>,
236    /// Back-link label. Defaults to "Home".
237    pub back_label: Option<String>,
238}
239
240/// Configuration for full-page HTML rendering with SurfDoc discovery metadata.
241#[derive(Debug, Clone)]
242pub struct PageConfig {
243    /// Path to the original `.surf` source file (served alongside the built site).
244    /// Used in `<link rel="alternate">` and the HTML comment.
245    pub source_path: String,
246    /// Page title. Falls back to front matter `title`, then "SurfDoc".
247    pub title: Option<String>,
248    /// Optional canonical URL for `<link rel="canonical">`.
249    pub canonical_url: Option<String>,
250    /// Optional meta description. Falls back to front matter `description`.
251    pub description: Option<String>,
252    /// Optional language code (default: "en").
253    pub lang: Option<String>,
254    /// Optional OG image URL for social sharing.
255    pub og_image: Option<String>,
256    /// Twitter card type: `summary` (default) or `summary_large_image`.
257    pub twitter_card: Option<String>,
258    /// Favicon / apple-touch-icon links, emitted in order.
259    pub icons: Vec<HeadIcon>,
260    /// External stylesheet hrefs, emitted in order (after the inlined base CSS
261    /// when `embed_css` is true). Cache-bust by including `?v=N` in the href.
262    pub stylesheets: Vec<String>,
263    /// External scripts, emitted in `<head>` in order.
264    pub scripts: Vec<HeadScript>,
265    /// Emit the inline pre-paint theme resolver + `toggleTheme`/`toggleDrawer`/
266    /// `closeDrawer` helpers in `<head>` (FOUC-safe; must run before paint).
267    pub theme_init: bool,
268    /// `localStorage` key for the persisted theme. Defaults to `theme`.
269    pub theme_key: Option<String>,
270    /// Inline the full SurfDoc CSS as a `<style>` block (default `true`). Set
271    /// `false` when the consumer links the stylesheet itself (via `stylesheets`).
272    pub embed_css: bool,
273    /// Arbitrary extra markup injected at the end of `<head>`.
274    pub head_extra: Option<String>,
275    /// When set, [`to_shell_page`] wraps the page content in a reading-frame
276    /// (sticky toolbar + centered sheet). `None` leaves pages unchanged.
277    pub reading_frame: Option<ReadingFrame>,
278}
279
280impl Default for PageConfig {
281    fn default() -> Self {
282        Self {
283            source_path: "source.surf".to_string(),
284            title: None,
285            canonical_url: None,
286            description: None,
287            lang: None,
288            og_image: None,
289            twitter_card: None,
290            icons: Vec::new(),
291            stylesheets: Vec::new(),
292            scripts: Vec::new(),
293            theme_init: false,
294            theme_key: None,
295            embed_css: true,
296            head_extra: None,
297            reading_frame: None,
298        }
299    }
300}
301
302impl PageConfig {
303    /// Build a `PageConfig` whose head config is read from the document's
304    /// `::site` block, so the page is self-describing. Recognised `::site`
305    /// properties:
306    ///
307    /// - `description`, `og-image`, `twitter-card`, `lang`, `theme-key`
308    /// - `canonical` / `url` (canonical URL)
309    /// - `favicon` (rel=icon, sizes=any), `icon-16` / `icon-32` (png),
310    ///   `apple-touch-icon`
311    /// - `stylesheet` (repeatable), `script` / `script-defer` (repeatable)
312    /// - `theme-init: true`, `embed-css: false`
313    ///
314    /// Per-page fields (`title`, `description`, `canonical_url`) can still be
315    /// overridden by the caller after construction.
316    pub fn from_site_doc(doc: &SurfDoc) -> Self {
317        let mut cfg = PageConfig::default();
318        let props = doc.blocks.iter().find_map(|b| match b {
319            Block::Site { properties, .. } => Some(properties),
320            _ => None,
321        });
322        let Some(props) = props else { return cfg };
323        for p in props {
324            let v = p.value.clone();
325            match p.key.as_str() {
326                "description" => cfg.description = Some(v),
327                "og-image" | "og_image" => cfg.og_image = Some(v),
328                "twitter-card" | "twitter_card" => cfg.twitter_card = Some(v),
329                "lang" => cfg.lang = Some(v),
330                "theme-key" | "theme_key" => cfg.theme_key = Some(v),
331                "canonical" | "url" => cfg.canonical_url = Some(v),
332                "favicon" => cfg.icons.push(HeadIcon {
333                    rel: "icon".into(),
334                    icon_type: None,
335                    sizes: Some("any".into()),
336                    href: v,
337                }),
338                "icon-16" => cfg.icons.push(HeadIcon {
339                    rel: "icon".into(),
340                    icon_type: Some("image/png".into()),
341                    sizes: Some("16x16".into()),
342                    href: v,
343                }),
344                "icon-32" => cfg.icons.push(HeadIcon {
345                    rel: "icon".into(),
346                    icon_type: Some("image/png".into()),
347                    sizes: Some("32x32".into()),
348                    href: v,
349                }),
350                "apple-touch-icon" => cfg.icons.push(HeadIcon {
351                    rel: "apple-touch-icon".into(),
352                    icon_type: None,
353                    sizes: None,
354                    href: v,
355                }),
356                "stylesheet" => cfg.stylesheets.push(v),
357                "script" => cfg.scripts.push(HeadScript { src: v, defer: false }),
358                "script-defer" => cfg.scripts.push(HeadScript { src: v, defer: true }),
359                "theme-init" | "theme_init" => cfg.theme_init = v == "true" || v == "yes",
360                "embed-css" | "embed_css" => cfg.embed_css = !(v == "false" || v == "no"),
361                _ => {}
362            }
363        }
364        cfg
365    }
366}
367
368/// Render a `SurfDoc` as an HTML fragment.
369///
370/// The output is a sequence of semantic HTML elements with `surfdoc-*` CSS
371/// classes. No `<html>`, `<head>`, or `<body>` wrapper is added.
372/// If a `::site` block sets an accent color, a `<style>` override scoped to
373/// `.surfdoc` is injected (not `:root`, to avoid leaking into editor chrome).
374// Font presets, hex/luminance/contrast math, accent text + ink colors, and
375// the style-pack token registry live in the shared resolve step — the single
376// owner of token semantics for BOTH render targets (web CSS here, NativeTheme
377// over FFI in render_native). Public re-exports preserve this module's
378// historical API (`render_html::contrast_ratio`, `::accent_ink_color`).
379pub use crate::resolve::{accent_ink_color, contrast_ratio};
380use crate::resolve::{accent_text_color, resolve_font_preset};
381
382/// Apply font/style properties from a `StyleProperty` list to CSS overrides.
383/// Collects any required font imports into the `imports` set.
384fn apply_style_overrides(properties: &[StyleProperty], css_overrides: &mut String, imports: &mut Vec<&'static str>) {
385    for prop in properties {
386        match prop.key.as_str() {
387            "accent" => {
388                let safe = sanitize_css_value(&prop.value);
389                if !safe.is_empty() {
390                    css_overrides.push_str(&format!("--accent: {};", safe));
391                    // Compute ADA-compliant text color for accent backgrounds
392                    let text = accent_text_color(&prop.value);
393                    css_overrides.push_str(&format!("--accent-text: {};", text));
394                }
395            }
396            "font" => {
397                // Legacy: sets both heading and body
398                if let Some(preset) = resolve_font_preset(&prop.value) {
399                    css_overrides.push_str(&format!("--font-heading: {};", preset.stack));
400                    css_overrides.push_str(&format!("--font-body: {};", preset.stack));
401                    if let Some(url) = preset.import
402                        && !imports.contains(&url) { imports.push(url); }
403                }
404            }
405            "heading-font" => {
406                if let Some(preset) = resolve_font_preset(&prop.value) {
407                    css_overrides.push_str(&format!("--font-heading: {};", preset.stack));
408                    if let Some(url) = preset.import
409                        && !imports.contains(&url) { imports.push(url); }
410                }
411            }
412            "body-font" => {
413                if let Some(preset) = resolve_font_preset(&prop.value) {
414                    css_overrides.push_str(&format!("--font-body: {};", preset.stack));
415                    if let Some(url) = preset.import
416                        && !imports.contains(&url) { imports.push(url); }
417                }
418            }
419            _ => {}
420        }
421    }
422}
423
424/// Render the nav shell: a sticky topbar (hamburger + logo + theme toggle)
425/// with a slide-in left drawer holding the author-supplied nav items.
426///
427/// The `<input class="surfdoc-nav-toggle">` is the first child of `.surfdoc-nav`
428/// so the CSS `:checked ~ ...` sibling selectors drive the drawer/scrim.
429fn render_nav_shell_html(items: &[crate::types::NavItem], effective_logo: Option<&str>) -> String {
430    let mut html = String::from(
431        "<nav class=\"surfdoc-nav\" role=\"navigation\" aria-label=\"Page navigation\">",
432    );
433    // Toggle checkbox MUST be the first child so sibling selectors work.
434    html.push_str(
435        "<input type=\"checkbox\" class=\"surfdoc-nav-toggle\" id=\"surfdoc-nav-toggle\" aria-hidden=\"true\">",
436    );
437
438    // Topbar: hamburger + logo on the left, theme toggle on the right.
439    html.push_str("<div class=\"surfdoc-nav-topbar\"><div class=\"surfdoc-nav-topbar-left\">");
440    html.push_str(
441        "<label for=\"surfdoc-nav-toggle\" class=\"surfdoc-nav-hamburger\" aria-label=\"Toggle navigation\"><span></span><span></span><span></span></label>",
442    );
443    if let Some(logo_text) = effective_logo {
444        let is_image = logo_text.ends_with(".svg")
445            || logo_text.ends_with(".png")
446            || logo_text.ends_with(".jpg")
447            || logo_text.ends_with(".jpeg")
448            || logo_text.ends_with(".webp")
449            || logo_text.ends_with(".gif")
450            || logo_text.starts_with("http://")
451            || logo_text.starts_with("https://")
452            || logo_text.starts_with("data:");
453        if is_image {
454            html.push_str(&format!(
455                "<a class=\"surfdoc-nav-logo\" href=\"/\"><img src=\"{}\" alt=\"Logo\" class=\"surfdoc-nav-logo-img\" onerror=\"this.style.display='none';this.parentElement.insertAdjacentText('afterbegin','Surf');this.onerror=null\"></a>",
456                escape_html(logo_text),
457            ));
458        } else {
459            html.push_str(&format!(
460                "<a class=\"surfdoc-nav-logo\" href=\"/\">{}</a>",
461                escape_html(logo_text),
462            ));
463        }
464    }
465    html.push_str("</div><div class=\"surfdoc-nav-topbar-right\">");
466    html.push_str(
467        "<button type=\"button\" class=\"surfdoc-nav-theme-toggle\" aria-label=\"Toggle dark mode\" title=\"Toggle dark mode\" onclick=\"(function(d){var t=d.getAttribute('data-theme')==='dark'?'light':'dark';d.setAttribute('data-theme',t);try{localStorage.setItem('surfdoc-theme',t)}catch(e){}})(document.documentElement)\">",
468    );
469    html.push_str(
470        "<svg class=\"surfdoc-nav-icon-moon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z\"></path></svg>",
471    );
472    html.push_str(
473        "<svg class=\"surfdoc-nav-icon-sun\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><circle cx=\"12\" cy=\"12\" r=\"5\"></circle><line x1=\"12\" y1=\"1\" x2=\"12\" y2=\"3\"></line><line x1=\"12\" y1=\"21\" x2=\"12\" y2=\"23\"></line><line x1=\"4.22\" y1=\"4.22\" x2=\"5.64\" y2=\"5.64\"></line><line x1=\"18.36\" y1=\"18.36\" x2=\"19.78\" y2=\"19.78\"></line><line x1=\"1\" y1=\"12\" x2=\"3\" y2=\"12\"></line><line x1=\"21\" y1=\"12\" x2=\"23\" y2=\"12\"></line><line x1=\"4.22\" y1=\"19.78\" x2=\"5.64\" y2=\"18.36\"></line><line x1=\"18.36\" y1=\"5.64\" x2=\"19.78\" y2=\"4.22\"></line></svg>",
474    );
475    html.push_str("</button></div></div>");
476
477    // Drawer: one row per author-supplied nav item.
478    html.push_str(
479        "<aside class=\"surfdoc-nav-drawer\" aria-label=\"Navigation\"><nav class=\"surfdoc-nav-drawer-nav\">",
480    );
481    for item in items {
482        let icon_html = item
483            .icon
484            .as_deref()
485            .and_then(get_icon)
486            .map(|svg| format!("<span class=\"surfdoc-icon\">{}</span>", svg))
487            .unwrap_or_default();
488        html.push_str(&format!(
489            "<a href=\"{}\" class=\"surfdoc-nav-drawer-row\">{}<span class=\"surfdoc-nav-drawer-label\">{}</span></a>",
490            escape_html(&item.href),
491            icon_html,
492            escape_html(&item.label),
493        ));
494    }
495    html.push_str("</nav></aside>");
496
497    // Scrim: clicking it (a label for the toggle) closes the drawer.
498    html.push_str("<label for=\"surfdoc-nav-toggle\" class=\"surfdoc-nav-scrim\" aria-hidden=\"true\"></label>");
499
500    // Apply persisted/preferred theme early and mark the active drawer row.
501    html.push_str("<script>(function(){var d=document.documentElement;var s=null;try{s=localStorage.getItem('surfdoc-theme')}catch(e){}var t=s||((window.matchMedia&&matchMedia('(prefers-color-scheme: dark)').matches)?'dark':'light');d.setAttribute('data-theme',t);var p=location.pathname;document.querySelectorAll('.surfdoc-nav-drawer-row').forEach(function(a){var h=a.getAttribute('href');if(h&&(p===h||p.endsWith(h))){a.classList.add('is-active');a.setAttribute('aria-current','page');}});})();</script>");
502
503    html.push_str("</nav>");
504    html
505}
506
507// Rich-shell inline SVGs (mirror the cloudsurf.com chrome). The theme-toggle
508// sun/moon carry `surfdoc-shell-icon-*` classes so CSS shows the right one per
509// theme; the menu/close glyphs are plain.
510const SHELL_ICON_MENU: &str = "<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><line x1=\"3\" y1=\"6\" x2=\"21\" y2=\"6\"/><line x1=\"3\" y1=\"12\" x2=\"21\" y2=\"12\"/><line x1=\"3\" y1=\"18\" x2=\"21\" y2=\"18\"/></svg>";
511const SHELL_ICON_CLOSE: &str = "<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><line x1=\"18\" y1=\"6\" x2=\"6\" y2=\"18\"/><line x1=\"6\" y1=\"6\" x2=\"18\" y2=\"18\"/></svg>";
512const SHELL_ICON_SUN: &str = "<svg class=\"surfdoc-shell-icon-sun\" width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><circle cx=\"12\" cy=\"12\" r=\"5\"/><line x1=\"12\" y1=\"1\" x2=\"12\" y2=\"3\"/><line x1=\"12\" y1=\"21\" x2=\"12\" y2=\"23\"/><line x1=\"4.22\" y1=\"4.22\" x2=\"5.64\" y2=\"5.64\"/><line x1=\"18.36\" y1=\"18.36\" x2=\"19.78\" y2=\"19.78\"/><line x1=\"1\" y1=\"12\" x2=\"3\" y2=\"12\"/><line x1=\"21\" y1=\"12\" x2=\"23\" y2=\"12\"/><line x1=\"4.22\" y1=\"19.78\" x2=\"5.64\" y2=\"18.36\"/><line x1=\"18.36\" y1=\"5.64\" x2=\"19.78\" y2=\"4.22\"/></svg>";
513const SHELL_ICON_MOON: &str = "<svg class=\"surfdoc-shell-icon-moon\" width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z\"/></svg>";
514
515/// True when a logo/emblem value points at an image (vs. plain wordmark text).
516fn is_image_src(s: &str) -> bool {
517    let l = s.to_ascii_lowercase();
518    l.ends_with(".svg")
519        || l.ends_with(".png")
520        || l.ends_with(".jpg")
521        || l.ends_with(".jpeg")
522        || l.ends_with(".webp")
523        || l.ends_with(".gif")
524        || s.starts_with("http://")
525        || s.starts_with("https://")
526        || s.starts_with("data:")
527        || s.starts_with('/')
528}
529
530/// Brand mark inner HTML: optional logo image + optional wordmark with an
531/// optional `®` superscript. Shared by the nav topbar, drawer head, and footer.
532fn shell_brand_inner(logo: Option<&str>, brand: Option<&str>, brand_reg: bool, emblem_class: &str) -> String {
533    let mut s = String::new();
534    if let Some(l) = logo
535        && is_image_src(l)
536    {
537        s.push_str(&format!(
538            "<img src=\"{}\" alt=\"\" width=\"34\" height=\"34\" class=\"{}\">",
539            escape_html(l),
540            emblem_class,
541        ));
542    }
543    if let Some(b) = brand {
544        let reg = if brand_reg { "<sup class=\"surfdoc-shell-reg\">&reg;</sup>" } else { "" };
545        s.push_str(&format!("<span>{}{}</span>", escape_html(b), reg));
546    }
547    s
548}
549
550/// Render a single rich-shell drawer row: emblem/icon lead + label, with an
551/// optional `target="_blank"` for external links.
552fn render_shell_drawer_link(it: &NavItem) -> String {
553    let tgt = if it.external { " target=\"_blank\" rel=\"noopener\"" } else { "" };
554    let lead = if let Some(img) = &it.image {
555        // Emit as a masked span (not an <img>) so the emblem tints to a single
556        // color via currentColor and recolors with the row on hover, matching
557        // the monochrome line icons. The asset path rides the --emblem property.
558        format!(
559            "<span class=\"surfdoc-shell-drawer-link-emblem\" style=\"--emblem:url('{}')\"></span>",
560            escape_html(img),
561        )
562    } else if let Some(svg) = it.icon.as_deref().and_then(get_icon) {
563        format!("<span class=\"surfdoc-shell-drawer-link-icon\">{}</span>", svg)
564    } else {
565        String::new()
566    };
567    format!(
568        "<a href=\"{}\" class=\"surfdoc-shell-drawer-link\"{}>{}{}</a>",
569        escape_html(&it.href),
570        tgt,
571        lead,
572        escape_html(&it.label),
573    )
574}
575
576/// Render the rich cloudsurf.com-quality nav shell: a sticky topbar (hamburger
577/// + brand + per-page CTA + theme toggle), a JS-driven slide-in drawer with
578/// grouped/iconned links + product emblems, and a scrim.
579///
580/// The toggle/drawer controls call `toggleTheme()` / `toggleDrawer()` /
581/// `closeDrawer()`, which [`to_html_page`] emits in `<head>` when
582/// `PageConfig::theme_init` is set — so this shell is meant to be served via the
583/// full-page renderer (not the bare `to_html` fragment).
584fn render_shell_nav_html(
585    items: &[NavItem],
586    groups: &[NavGroup],
587    logo: Option<&str>,
588    brand: Option<&str>,
589    brand_reg: bool,
590    cta: Option<&NavItem>,
591) -> String {
592    let brand_top = shell_brand_inner(logo, brand, brand_reg, "surfdoc-shell-emblem");
593    let brand_drawer = shell_brand_inner(logo, brand, brand_reg, "surfdoc-shell-drawer-emblem");
594
595    let cta_html = cta
596        .map(|c| {
597            let tgt = if c.external { " target=\"_blank\" rel=\"noopener\"" } else { "" };
598            format!(
599                "<div class=\"surfdoc-shell-nav-links\"><a href=\"{}\" class=\"surfdoc-shell-nav-cta\"{}>{}</a></div>",
600                escape_html(&c.href),
601                tgt,
602                escape_html(&c.label),
603            )
604        })
605        .unwrap_or_default();
606
607    // Flat items also surface as inline text links on the bar itself at larger
608    // screen sizes (CSS hides `.surfdoc-shell-nav-inline` below 1024px); the
609    // grouped/product links remain drawer-only.
610    let mut inline_links = String::new();
611    for it in items {
612        let tgt = if it.external { " target=\"_blank\" rel=\"noopener\"" } else { "" };
613        inline_links.push_str(&format!(
614            "<a href=\"{}\" class=\"surfdoc-shell-nav-link\"{}>{}</a>",
615            escape_html(&it.href),
616            tgt,
617            escape_html(&it.label),
618        ));
619    }
620    let inline_nav = if inline_links.is_empty() {
621        String::new()
622    } else {
623        format!(
624            "<nav class=\"surfdoc-shell-nav-inline\" aria-label=\"Primary\">{}</nav>",
625            inline_links,
626        )
627    };
628
629    // Flat items render as an unlabelled lead group; named groups follow.
630    let mut links = String::new();
631    for it in items {
632        links.push_str(&render_shell_drawer_link(it));
633    }
634    for g in groups {
635        if let Some(label) = &g.label {
636            links.push_str(&format!(
637                "<span class=\"surfdoc-shell-drawer-label\">{}</span>",
638                escape_html(label),
639            ));
640        }
641        for it in &g.items {
642            links.push_str(&render_shell_drawer_link(it));
643        }
644    }
645
646    format!(
647        "<nav class=\"surfdoc-shell-nav\" aria-label=\"Main navigation\">\
648           <div class=\"surfdoc-shell-nav-inner\">\
649             <button class=\"surfdoc-shell-menu-btn\" type=\"button\" aria-label=\"Open menu\" aria-controls=\"surfdoc-shell-drawer\" aria-expanded=\"false\" onclick=\"toggleDrawer()\">{menu}</button>\
650             <a href=\"/\" class=\"surfdoc-shell-brand\">{brand_top}</a>\
651             {inline_nav}\
652             <div class=\"surfdoc-shell-nav-spacer\"></div>{cta}\
653             <button class=\"surfdoc-shell-theme-toggle\" type=\"button\" onclick=\"toggleTheme()\" aria-label=\"Toggle theme\">{sun}{moon}</button>\
654           </div>\
655         </nav>\
656         <div class=\"surfdoc-shell-scrim\" onclick=\"closeDrawer()\" aria-hidden=\"true\"></div>\
657         <aside class=\"surfdoc-shell-drawer\" id=\"surfdoc-shell-drawer\" aria-label=\"Site menu\">\
658           <div class=\"surfdoc-shell-drawer-inner\">\
659             <div class=\"surfdoc-shell-drawer-head\">\
660               <span class=\"surfdoc-shell-drawer-brand\">{brand_drawer}</span>\
661               <button class=\"surfdoc-shell-drawer-close\" type=\"button\" aria-label=\"Close menu\" onclick=\"closeDrawer()\">{close}</button>\
662             </div>\
663             <nav class=\"surfdoc-shell-drawer-nav\" aria-label=\"Site sections\">{links}</nav>\
664           </div>\
665         </aside>\
666         <script>(function(){{var p=location.pathname;document.querySelectorAll('.surfdoc-shell-nav-link,.surfdoc-shell-drawer-link').forEach(function(a){{var h=a.getAttribute('href');if(h&&(h===p||(h!=='/'&&p.indexOf(h)===0))){{a.classList.add('is-active');a.setAttribute('aria-current','page')}}}})}})();</script>",
667        menu = SHELL_ICON_MENU,
668        inline_nav = inline_nav,
669        brand_top = brand_top,
670        cta = cta_html,
671        sun = SHELL_ICON_SUN,
672        moon = SHELL_ICON_MOON,
673        brand_drawer = brand_drawer,
674        close = SHELL_ICON_CLOSE,
675        links = links,
676    )
677}
678
679/// Resolve a product-tile `bg` spec into an inline style + light-text flag.
680///
681/// Spec forms (5th pipe field on a `::product-grid[tiles]` row):
682/// `image:<src>` | `color:<css-color>` | `gradient:<css-gradient>` |
683/// `transparent`/absent (no style). A trailing ` dark` token marks the
684/// background as dark → the tile renders light text. A trailing ` theme`
685/// token makes the ink follow the site theme anyway — for translucent
686/// (low-alpha) backgrounds where the page backdrop dominates and the
687/// pinned-ink rationale doesn't apply.
688fn product_tile_bg(bg: Option<&str>) -> (String, bool, bool, bool) {
689    let Some(raw) = bg else {
690        return (String::new(), false, false, false);
691    };
692    let mut spec = raw.trim();
693    let mut dark = false;
694    let mut bottom = false;
695    let mut theme_ink = false;
696    loop {
697        if let Some(stripped) = spec.strip_suffix(" dark") {
698            spec = stripped.trim();
699            dark = true;
700        } else if let Some(stripped) = spec.strip_suffix(" bottom") {
701            // ` bottom` token: media/imagery on top, title/CTA at the bottom
702            // (apple.com AirPods-tile shape). Default is copy on top.
703            spec = stripped.trim();
704            bottom = true;
705        } else if let Some(stripped) = spec.strip_suffix(" theme") {
706            spec = stripped.trim();
707            theme_ink = true;
708        } else {
709            break;
710        }
711    }
712    let style = if let Some(src) = spec.strip_prefix("image:") {
713        // Rendered as a media area BELOW the tile copy (apple.com tile shape),
714        // not as a cover background: the caller puts this on the media div.
715        format!("--tile-image:url('{}')", escape_html(src.trim()))
716    } else if let Some(color) = spec.strip_prefix("color:") {
717        format!("background:{}", escape_html(color.trim()))
718    } else if let Some(gradient) = spec.strip_prefix("gradient:") {
719        format!("background:{}", escape_html(gradient.trim()))
720    } else {
721        // "transparent" (explicit), "surface" (opaque theme surface — the
722        // caller styles it via the .surfdoc-pg-tile-surface class so the ink
723        // keeps following the theme, unlike color:/gradient:), or an
724        // unrecognized spec: no inline style.
725        String::new()
726    };
727    (style, dark, bottom, theme_ink)
728}
729
730/// True when a `Block::Nav`'s fields request the rich shell layout.
731fn nav_is_rich(groups: &[NavGroup], brand: &Option<String>, drawer: bool) -> bool {
732    drawer || !groups.is_empty() || brand.is_some()
733}
734
735/// Render a stripped topbar: brand/logo + theme toggle only. No hamburger menu,
736/// drawer, or scrim. Reuses the shell-nav chrome classes (and CSS var-hooks) so
737/// it sits inline with the rich shell visually.
738fn render_minimal_nav_html(logo: Option<&str>, brand: Option<&str>, brand_reg: bool) -> String {
739    let brand_top = shell_brand_inner(logo, brand, brand_reg, "surfdoc-shell-emblem");
740    format!(
741        "<nav class=\"surfdoc-shell-nav surfdoc-shell-nav-minimal\" aria-label=\"Main navigation\">\
742           <div class=\"surfdoc-shell-nav-inner\">\
743             <a href=\"/\" class=\"surfdoc-shell-brand\">{brand_top}</a>\
744             <div class=\"surfdoc-shell-nav-spacer\"></div>\
745             <button class=\"surfdoc-shell-theme-toggle\" type=\"button\" onclick=\"toggleTheme()\" aria-label=\"Toggle theme\">{sun}{moon}</button>\
746           </div>\
747         </nav>",
748        brand_top = brand_top,
749        sun = SHELL_ICON_SUN,
750        moon = SHELL_ICON_MOON,
751    )
752}
753
754/// Convert heading text to a URL anchor slug: lowercase, runs of non-alphanumeric
755/// characters collapsed to a single `-`, no leading/trailing `-`.
756fn slugify(text: &str) -> String {
757    let mut slug = String::new();
758    for c in text.chars() {
759        if c.is_ascii_alphanumeric() {
760            slug.push(c.to_ascii_lowercase());
761        } else if !slug.ends_with('-') && !slug.is_empty() {
762            slug.push('-');
763        }
764    }
765    while slug.ends_with('-') {
766        slug.pop();
767    }
768    slug
769}
770
771/// Strip HTML tags from a fragment, leaving the text content (for slug + label).
772fn strip_tags(html: &str) -> String {
773    let mut out = String::new();
774    let mut in_tag = false;
775    for c in html.chars() {
776        match c {
777            '<' => in_tag = true,
778            '>' => in_tag = false,
779            _ if !in_tag => out.push(c),
780            _ => {}
781        }
782    }
783    out
784}
785
786/// Split an explicit `{#slug}` anchor suffix off a heading's inner HTML.
787///
788/// `Our Menu {#our-menu}` → `("Our Menu", "our-menu")`. Slug charset is
789/// `[A-Za-z0-9_-]+`; anything else (prose braces, template syntax) is left
790/// untouched. Returns `None` when no valid trailing anchor exists.
791fn split_explicit_anchor(inner: &str) -> Option<(&str, &str)> {
792    let trimmed = inner.trim_end();
793    if !trimmed.ends_with('}') {
794        return None;
795    }
796    let open = trimmed.rfind("{#")?;
797    let slug = &trimmed[open + 2..trimmed.len() - 1];
798    if slug.is_empty()
799        || !slug
800            .bytes()
801            .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
802    {
803        return None;
804    }
805    Some((trimmed[..open].trim_end(), slug))
806}
807
808/// Document-level post-pass: give every prose (class-less) `<hN>` heading a unique
809/// anchor `id`, then fill any empty `<nav class="surfdoc-toc" data-depth="N">` with
810/// links to those headings (filtered to level ≤ N). Block-internal headings carry a
811/// class (e.g. `surfdoc-hero-headline`) and are left untouched, so they stay out of
812/// the table of contents. Slug de-duplication is global and runs in document order.
813fn wire_headings_and_toc(html: &str) -> String {
814    // Pass 1 — collect class-less headings and inject `id` anchors.
815    let mut result = String::with_capacity(html.len() + 256);
816    let mut headings: Vec<(u32, String, String)> = Vec::new(); // (level, slug, text)
817    let mut slug_counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
818    let mut rest = html;
819    while let Some(lt) = rest.find('<') {
820        result.push_str(&rest[..lt]);
821        let after = &rest[lt..];
822        let b = after.as_bytes();
823        let is_heading = b.len() >= 4 && b[1] == b'h' && b[2].is_ascii_digit() && b[3] == b'>';
824        let level = if is_heading { (b[2] - b'0') as u32 } else { 0 };
825        if is_heading && (1..=6).contains(&level) {
826            let close = format!("</h{level}>");
827            if let Some(close_rel) = after[4..].find(&close) {
828                let raw_inner = &after[4..4 + close_rel];
829                // An explicit `{#slug}` anchor at the end of the heading text
830                // (`## Our Menu {#our-menu}`) becomes the heading id instead
831                // of leaking as visible copy.
832                let (inner, explicit) = match split_explicit_anchor(raw_inner) {
833                    Some((clean, slug)) => (clean, Some(slug)),
834                    None => (raw_inner, None),
835                };
836                let text = strip_tags(inner).trim().to_string();
837                let base = match explicit {
838                    Some(slug) => slug.to_string(),
839                    None => slugify(&text),
840                };
841                let slug = if base.is_empty() {
842                    format!("section-{}", headings.len() + 1)
843                } else {
844                    let n = slug_counts.entry(base.clone()).or_insert(0);
845                    *n += 1;
846                    if *n == 1 {
847                        base.clone()
848                    } else {
849                        format!("{base}-{n}")
850                    }
851                };
852                result.push_str(&format!("<h{level} id=\"{slug}\">{inner}{close}"));
853                headings.push((level, slug, text));
854                rest = &after[4 + close_rel + close.len()..];
855                continue;
856            }
857        }
858        // Not a class-less heading — copy the '<' and keep scanning.
859        result.push('<');
860        rest = &after[1..];
861    }
862    result.push_str(rest);
863
864    // Pass 2 — fill empty TOC navs from the collected headings.
865    let needle = "<nav class=\"surfdoc-toc\" data-depth=\"";
866    if !result.contains(needle) {
867        return result;
868    }
869    let mut out = String::with_capacity(result.len() + 256);
870    let mut rest = result.as_str();
871    while let Some(pos) = rest.find(needle) {
872        out.push_str(&rest[..pos]);
873        let after = &rest[pos..];
874        let depth: u32 = after[needle.len()..]
875            .chars()
876            .take_while(|c| c.is_ascii_digit())
877            .collect::<String>()
878            .parse()
879            .unwrap_or(3);
880        match after.find("</nav>") {
881            Some(close_rel) => {
882                let nav_end = close_rel + "</nav>".len();
883                let items: Vec<&(u32, String, String)> =
884                    headings.iter().filter(|(lvl, _, _)| *lvl <= depth).collect();
885                if items.is_empty() {
886                    out.push_str(&after[..nav_end]);
887                } else {
888                    out.push_str(&format!(
889                        "<nav class=\"surfdoc-toc\" data-depth=\"{depth}\"><div class=\"surfdoc-toc-label\">Contents</div>"
890                    ));
891                    out.push_str(&toc_nested_ol(&items));
892                    out.push_str("</nav>");
893                }
894                rest = &after[nav_end..];
895            }
896            None => {
897                out.push_str(after);
898                rest = "";
899                break;
900            }
901        }
902    }
903    out.push_str(rest);
904    out
905}
906
907/// Build a hierarchically nested `<ol>` from a flat list of (level, slug, text) tuples.
908///
909/// Sub-headings are wrapped in nested `<ol>` elements so CSS `list-style: decimal`
910/// produces hierarchical numbers (1, 2, 2.1, 3, …) natively. The algorithm
911/// is a depth-tracking state machine:
912///
913/// - `stack` tracks the heading levels of currently-open `<ol>` containers.
914/// - Each new item opens a nested `<ol>` when deeper than the current level,
915///   closes `</ol></li>` pairs when shallower, and closes `</li>` for siblings.
916#[allow(unused_assignments)]
917fn toc_nested_ol(items: &[&(u32, String, String)]) -> String {
918    if items.is_empty() {
919        return String::new();
920    }
921    let mut html = String::new();
922    // stack[i] = heading level of the i-th open <ol>
923    let mut stack: Vec<u32> = Vec::new();
924    // whether the current top-of-stack <ol> has an open (unclosed) <li>
925    let mut li_open = false;
926
927    for (lvl, slug, text) in items {
928        let lvl = *lvl;
929
930        if stack.is_empty() {
931            // First item: open the root <ol>.
932            html.push_str("<ol>");
933            stack.push(lvl);
934        } else {
935            let cur = *stack.last().unwrap();
936            if lvl > cur {
937                // Going deeper: open a nested <ol> inside the current (open) <li>.
938                html.push_str("<ol>");
939                stack.push(lvl);
940                li_open = false;
941            } else if lvl == cur {
942                // Sibling: close the previous <li>.
943                if li_open {
944                    html.push_str("</li>");
945                    li_open = false;
946                }
947            } else {
948                // Going up: close levels until we reach the right one.
949                while stack.len() > 1 && *stack.last().unwrap() > lvl {
950                    if li_open {
951                        html.push_str("</li>");
952                        li_open = false;
953                    }
954                    html.push_str("</ol>");
955                    stack.pop();
956                    // After closing a nested <ol>, we're back inside the parent <li>
957                    // which was left open (without </li>) when we went deeper.
958                    li_open = true;
959                }
960                // Now close the parent <li> that was holding the sub-list.
961                if li_open {
962                    html.push_str("</li>");
963                    li_open = false;
964                }
965            }
966        }
967
968        // Emit the new <li> (left open — will be closed on next iteration or teardown).
969        html.push_str(&format!(
970            "<li><a href=\"#{}\">{}</a>",
971            escape_html(slug),
972            escape_html(text)
973        ));
974        li_open = true;
975    }
976
977    // Teardown: close remaining open levels.
978    while !stack.is_empty() {
979        if li_open {
980            html.push_str("</li>");
981            li_open = false;
982        }
983        html.push_str("</ol>");
984        stack.pop();
985        // After closing a nested <ol> we're back in the parent <li>.
986        if !stack.is_empty() {
987            li_open = true;
988        }
989    }
990
991    html
992}
993
994pub fn to_html(doc: &SurfDoc) -> String {
995    // Install the citation context so inline `[@key]` cites + `::bibliography`
996    // resolve during this render (cleared on drop).
997    let _cite_scope = citation::install_context(citation::build_context(
998        &doc.blocks,
999        doc.front_matter.as_ref().and_then(|fm| fm.format),
1000    ));
1001
1002    let mut parts: Vec<String> = Vec::new();
1003    let mut css_overrides = String::new();
1004    let mut font_imports: Vec<&'static str> = Vec::new();
1005
1006    // Scan for CSS variable overrides from ::site and ::style blocks.
1007    for block in &doc.blocks {
1008        match block {
1009            Block::Site { properties, .. } => apply_style_overrides(properties, &mut css_overrides, &mut font_imports),
1010            Block::Style { properties, .. } => apply_style_overrides(properties, &mut css_overrides, &mut font_imports),
1011            _ => {}
1012        }
1013    }
1014
1015    // Emit @import rules for Google Fonts (must come before other styles)
1016    for url in &font_imports {
1017        parts.push(format!("<style>@import url('{}');</style>", url));
1018    }
1019
1020    if !css_overrides.is_empty() {
1021        // Scope overrides to .surfdoc (not :root) so accent colors don't leak
1022        // into the parent page when rendered as a fragment inside the editor.
1023        parts.push(format!("<style>.surfdoc {{ {} }}</style>", css_overrides));
1024    }
1025
1026    // Extract site name for nav logo fallback
1027    let site_name: Option<String> = doc.blocks.iter().find_map(|b| {
1028        if let Block::Site { properties, .. } = b {
1029            properties.iter().find(|p| p.key == "name").map(|p| p.value.clone())
1030        } else {
1031            None
1032        }
1033    });
1034
1035    // Render nav blocks first (before section wrapping)
1036    for block in &doc.blocks {
1037        if let Block::Nav { items, logo, groups, brand, brand_reg, cta, drawer, minimal, .. } = block {
1038            if *minimal {
1039                parts.push(render_minimal_nav_html(logo.as_deref(), brand.as_deref(), *brand_reg));
1040            } else if nav_is_rich(groups, brand, *drawer) {
1041                parts.push(render_shell_nav_html(items, groups, logo.as_deref(), brand.as_deref(), *brand_reg, cta.as_ref()));
1042            } else {
1043                // Use explicit logo, fall back to ::site name
1044                let effective_logo = logo.as_deref().or(site_name.as_deref());
1045                parts.push(render_nav_shell_html(items, effective_logo));
1046            }
1047        }
1048    }
1049
1050    let mut in_section = false;
1051    let mut cta_group: Vec<String> = Vec::new();
1052
1053    for block in &doc.blocks {
1054        // Skip nav blocks — already rendered above
1055        if matches!(block, Block::Nav { .. }) {
1056            continue;
1057        }
1058
1059        // Group consecutive CTA blocks into a centered wrapper
1060        if matches!(block, Block::Cta { .. }) {
1061            cta_group.push(render_block(block));
1062            continue;
1063        }
1064        if !cta_group.is_empty() {
1065            parts.push(format!("<div class=\"surfdoc-cta-group\">{}</div>", cta_group.join("\n")));
1066            cta_group.clear();
1067        }
1068
1069        let rendered = render_block(block);
1070
1071        // Detect section boundaries: h1 or h2 starts a new visual section
1072        let starts_section = rendered.starts_with("<h1>") || rendered.starts_with("<h2>");
1073        if starts_section {
1074            if in_section {
1075                parts.push("</section>".to_string());
1076            }
1077            parts.push("<section class=\"surfdoc-section\">".to_string());
1078            in_section = true;
1079        }
1080
1081        parts.push(rendered);
1082    }
1083
1084    // Flush any trailing CTA group
1085    if !cta_group.is_empty() {
1086        parts.push(format!("<div class=\"surfdoc-cta-group\">{}</div>", cta_group.join("\n")));
1087    }
1088
1089    if in_section {
1090        parts.push("</section>".to_string());
1091    }
1092
1093    wire_headings_and_toc(&parts.join("\n"))
1094}
1095
1096/// Render a slice of blocks as bare HTML fragments.
1097///
1098/// Unlike [`to_html()`], this does NOT:
1099/// - Scan for `::site`/`::style` blocks or emit CSS variable overrides
1100/// - Extract or render navigation blocks separately
1101/// - Wrap content in auto-sectioning (h1/h2 boundary detection)
1102///
1103/// Each block is rendered through [`render_block()`] and the results are joined
1104/// with newlines. The caller controls the CSS context — fragment HTML assumes
1105/// `surfdoc-*` class names are available from a parent stylesheet.
1106///
1107/// Returns an empty string for an empty slice.
1108pub fn to_html_fragment(blocks: &[Block]) -> String {
1109    if blocks.is_empty() {
1110        return String::new();
1111    }
1112    // Resolve cites within the fragment using the fragment's own ::cite blocks
1113    // (default APA style — fragments carry no front matter).
1114    let _cite_scope = citation::install_context(citation::build_context(blocks, None));
1115    let mut parts: Vec<String> = Vec::new();
1116    let mut cta_group: Vec<String> = Vec::new();
1117    for block in blocks {
1118        if matches!(block, Block::Cta { .. }) {
1119            cta_group.push(render_block(block));
1120            continue;
1121        }
1122        if !cta_group.is_empty() {
1123            parts.push(format!("<div class=\"surfdoc-cta-group\">{}</div>", cta_group.join("\n")));
1124            cta_group.clear();
1125        }
1126        parts.push(render_block(block));
1127    }
1128    if !cta_group.is_empty() {
1129        parts.push(format!("<div class=\"surfdoc-cta-group\">{}</div>", cta_group.join("\n")));
1130    }
1131    wire_headings_and_toc(&parts.join("\n"))
1132}
1133
1134/// Render a `SurfDoc` as a complete HTML page with SurfDoc discovery metadata.
1135///
1136/// Produces a full `<!DOCTYPE html>` document with:
1137/// - `<meta name="generator" content="SurfDoc v0.1">`
1138/// - `<link rel="alternate" type="text/surfdoc" href="...">` pointing to source
1139/// - HTML comment identifying the source file
1140/// - Standard viewport and charset meta tags
1141/// - Embedded dark-theme CSS for all SurfDoc block types
1142pub fn to_html_page(doc: &SurfDoc, config: &PageConfig) -> String {
1143    let body = to_html(doc);
1144    let lang = config.lang.as_deref().unwrap_or("en");
1145
1146    // Resolve title: explicit config > front matter > fallback
1147    let title = config
1148        .title
1149        .clone()
1150        .or_else(|| {
1151            doc.front_matter
1152                .as_ref()
1153                .and_then(|fm| fm.title.clone())
1154        })
1155        .unwrap_or_else(|| "SurfDoc".to_string());
1156
1157    // Resolve description: explicit config > front matter > ::site property
1158    let description = config
1159        .description
1160        .clone()
1161        .or_else(|| {
1162            doc.front_matter
1163                .as_ref()
1164                .and_then(|fm| fm.description.clone())
1165        })
1166        .or_else(|| {
1167            doc.blocks.iter().find_map(|b| {
1168                if let Block::Site { properties, .. } = b {
1169                    properties.iter().find(|p| p.key == "description").map(|p| p.value.clone())
1170                } else {
1171                    None
1172                }
1173            })
1174        });
1175
1176    let source_path = escape_html(&config.source_path);
1177    let title_escaped = escape_html(&title);
1178    let head_tail = build_head_tail(&title_escaped, description.as_deref(), config);
1179
1180    format!(
1181        r#"<!-- Built with SurfDoc — source: {source_path} -->
1182<!DOCTYPE html>
1183<html lang="{lang}">
1184<head>
1185    <meta charset="utf-8">
1186    <meta name="viewport" content="width=device-width, initial-scale=1">
1187    <meta name="generator" content="SurfDoc v0.1">
1188    <link rel="alternate" type="text/surfdoc" href="{source_path}">
1189    <title>{title}</title>{head_tail}
1190</head>
1191<body>
1192<article class="surfdoc">
1193{body}
1194</article>
1195</body>
1196</html>"#,
1197        source_path = source_path,
1198        lang = escape_html(lang),
1199        title = title_escaped,
1200        head_tail = head_tail,
1201        body = body,
1202    )
1203}
1204
1205/// Build the `<head>` tail — everything after `<title>…</title>` up to (not
1206/// including) `</head>`: meta/OG/Twitter, favicons, stylesheets, FOUC theme
1207/// init, scripts, and `head_extra`. Shared by [`to_html_page`] and
1208/// [`to_shell_page`]. `title_escaped` must already be HTML-escaped.
1209fn build_head_tail(title_escaped: &str, description: Option<&str>, config: &PageConfig) -> String {
1210    let mut meta_extra = String::new();
1211    if let Some(desc) = description {
1212        meta_extra.push_str(&format!(
1213            "\n    <meta name=\"description\" content=\"{}\">",
1214            escape_html(desc)
1215        ));
1216    }
1217    if let Some(url) = &config.canonical_url {
1218        let url_escaped = escape_html(url);
1219        meta_extra.push_str(&format!("\n    <link rel=\"canonical\" href=\"{}\">", url_escaped));
1220        meta_extra.push_str(&format!("\n    <meta property=\"og:url\" content=\"{}\">", url_escaped));
1221    }
1222    meta_extra.push_str(&format!("\n    <meta property=\"og:title\" content=\"{}\">", title_escaped));
1223    meta_extra.push_str("\n    <meta property=\"og:type\" content=\"website\">");
1224    if let Some(desc) = description {
1225        meta_extra.push_str(&format!("\n    <meta property=\"og:description\" content=\"{}\">", escape_html(desc)));
1226    }
1227    if let Some(img) = &config.og_image {
1228        meta_extra.push_str(&format!("\n    <meta property=\"og:image\" content=\"{}\">", escape_html(img)));
1229    }
1230    let twitter_card = config.twitter_card.as_deref().unwrap_or("summary");
1231    meta_extra.push_str(&format!("\n    <meta name=\"twitter:card\" content=\"{}\">", escape_html(twitter_card)));
1232    meta_extra.push_str(&format!("\n    <meta name=\"twitter:title\" content=\"{}\">", title_escaped));
1233    if let Some(desc) = description {
1234        meta_extra.push_str(&format!("\n    <meta name=\"twitter:description\" content=\"{}\">", escape_html(desc)));
1235    }
1236    if let Some(img) = &config.og_image {
1237        meta_extra.push_str(&format!("\n    <meta name=\"twitter:image\" content=\"{}\">", escape_html(img)));
1238    }
1239
1240    // Favicons / apple-touch-icon.
1241    for icon in &config.icons {
1242        let type_attr = icon
1243            .icon_type
1244            .as_deref()
1245            .map(|t| format!(" type=\"{}\"", escape_html(t)))
1246            .unwrap_or_default();
1247        let sizes_attr = icon
1248            .sizes
1249            .as_deref()
1250            .map(|s| format!(" sizes=\"{}\"", escape_html(s)))
1251            .unwrap_or_default();
1252        meta_extra.push_str(&format!(
1253            "\n    <link rel=\"{}\"{}{} href=\"{}\">",
1254            escape_html(&icon.rel),
1255            type_attr,
1256            sizes_attr,
1257            escape_html(&icon.href),
1258        ));
1259    }
1260
1261    // CSS: inline the base stylesheet (default) and/or link external sheets.
1262    let mut style_block = String::new();
1263    if config.embed_css {
1264        style_block.push_str(&format!("\n    <style>{}</style>", SURFDOC_CSS));
1265    }
1266    for href in &config.stylesheets {
1267        style_block.push_str(&format!("\n    <link rel=\"stylesheet\" href=\"{}\">", escape_html(href)));
1268    }
1269
1270    // FOUC-safe theme init + drawer/toggle helpers (must run before paint, so
1271    // emitted inline and NOT deferred). The rich shell nav buttons call these.
1272    let theme_block = if config.theme_init {
1273        let key = sanitize_js_key(config.theme_key.as_deref().unwrap_or("theme"));
1274        format!(
1275            "\n    <script>\
1276             (function(){{var k='{key}';var t=null;try{{t=localStorage.getItem(k)}}catch(e){{}}\
1277             if(!t)t=(window.matchMedia&&matchMedia('(prefers-color-scheme:dark)').matches)?'dark':'light';\
1278             document.documentElement.setAttribute('data-theme',t)}})();\
1279             function toggleTheme(){{var d=document.documentElement,k='{key}',t=d.getAttribute('data-theme')==='dark'?'light':'dark';d.setAttribute('data-theme',t);try{{localStorage.setItem(k,t)}}catch(e){{}}}}\
1280             function toggleDrawer(){{var o=document.documentElement.classList.toggle('drawer-open');var b=document.querySelector('.surfdoc-shell-menu-btn');if(b)b.setAttribute('aria-expanded',o)}}\
1281             function closeDrawer(){{document.documentElement.classList.remove('drawer-open');var b=document.querySelector('.surfdoc-shell-menu-btn');if(b)b.setAttribute('aria-expanded','false')}}\
1282             document.addEventListener('keydown',function(e){{if(e.key==='Escape')closeDrawer()}});\
1283             document.addEventListener('DOMContentLoaded',function(){{\
1284             if(window.matchMedia&&matchMedia('(prefers-reduced-motion: reduce)').matches)return;\
1285             var pending=Array.prototype.slice.call(document.querySelectorAll('main.surfdoc .surfdoc-banner,main.surfdoc .surfdoc-product-grid:not(.surfdoc-pg-tiles-band),main.surfdoc .surfdoc-pg-tile,main.surfdoc .surfdoc-features,main.surfdoc .surfdoc-section,main.surfdoc .surfdoc-post-grid,main.surfdoc .surfdoc-reveal-target'));\
1286             if(!pending.length)return;\
1287             pending.forEach(function(el){{el.classList.add('surfdoc-reveal')}});\
1288             var t=null;\
1289             function sweep(){{t=null;var vh=window.innerHeight;\
1290             pending=pending.filter(function(el){{var r=el.getBoundingClientRect();\
1291             if(r.top<vh*0.92||r.bottom<0){{el.classList.add('surfdoc-reveal-in');return false}}return true}});\
1292             if(!pending.length){{removeEventListener('scroll',onmove);removeEventListener('resize',onmove)}}}}\
1293             function onmove(){{if(t===null)t=requestAnimationFrame(sweep)}}\
1294             addEventListener('scroll',onmove,{{passive:true}});\
1295             addEventListener('resize',onmove);\
1296             sweep()}});\
1297             </script>"
1298        )
1299    } else {
1300        String::new()
1301    };
1302
1303    // External scripts (htmx / analytics / custom).
1304    let mut script_block = String::new();
1305    for s in &config.scripts {
1306        let defer = if s.defer { " defer" } else { "" };
1307        script_block.push_str(&format!("\n    <script src=\"{}\"{}></script>", escape_html(&s.src), defer));
1308    }
1309
1310    let head_extra = config
1311        .head_extra
1312        .as_deref()
1313        .map(|h| format!("\n    {}", h))
1314        .unwrap_or_default();
1315
1316    format!("{meta_extra}{style_block}{theme_block}{script_block}{head_extra}")
1317}
1318
1319/// Render a full HTML page from a **site shell** plus pre-rendered body HTML.
1320///
1321/// This is the site-shell entrypoint for consumers (e.g. cloudsurf.com) that
1322/// keep a shared `::site`/`::nav`/`::footer` shell in one `.surf` file and feed
1323/// per-route content as already-rendered HTML. The `<head>` is built from
1324/// `config` (favicons, stylesheets, scripts, FOUC theme init, OG/Twitter); the
1325/// `Block::Nav` blocks in `shell` render before `<main>` (rich shell when their
1326/// fields request it) and the `Block::Footer` blocks render after it.
1327/// `body_html` is inserted verbatim inside `<main class="surfdoc">`.
1328pub fn to_shell_page(shell: &SurfDoc, body_html: &str, config: &PageConfig) -> String {
1329    let lang = config.lang.as_deref().unwrap_or("en");
1330    let title = config
1331        .title
1332        .clone()
1333        .or_else(|| shell.front_matter.as_ref().and_then(|fm| fm.title.clone()))
1334        .unwrap_or_else(|| "SurfDoc".to_string());
1335    let description = config.description.clone().or_else(|| {
1336        shell.blocks.iter().find_map(|b| {
1337            if let Block::Site { properties, .. } = b {
1338                properties.iter().find(|p| p.key == "description").map(|p| p.value.clone())
1339            } else {
1340                None
1341            }
1342        })
1343    });
1344    let title_escaped = escape_html(&title);
1345    let head_tail = build_head_tail(&title_escaped, description.as_deref(), config);
1346
1347    // Site-level CSS variable overrides (accent/font) from ::site / ::style.
1348    let mut css_overrides = String::new();
1349    let mut font_imports: Vec<&'static str> = Vec::new();
1350    let mut font_import_tags = String::new();
1351    for block in &shell.blocks {
1352        match block {
1353            Block::Site { properties, .. } | Block::Style { properties, .. } => {
1354                apply_style_overrides(properties, &mut css_overrides, &mut font_imports)
1355            }
1356            _ => {}
1357        }
1358    }
1359    for url in &font_imports {
1360        font_import_tags.push_str(&format!("<style>@import url('{}');</style>\n", url));
1361    }
1362    let override_tag = if css_overrides.is_empty() {
1363        String::new()
1364    } else {
1365        format!("<style>.surfdoc {{ {} }}</style>\n", css_overrides)
1366    };
1367
1368    let nav_html: String = shell
1369        .blocks
1370        .iter()
1371        .filter(|b| matches!(b, Block::Nav { .. }))
1372        .map(render_block)
1373        .collect::<Vec<_>>()
1374        .join("\n");
1375    let footer_html: String = shell
1376        .blocks
1377        .iter()
1378        .filter(|b| matches!(b, Block::Footer { .. }))
1379        .map(render_block)
1380        .collect::<Vec<_>>()
1381        .join("\n");
1382
1383    // Reading-frame: wrap the body in a sticky toolbar + centered sheet.
1384    let body_wrapped = match &config.reading_frame {
1385        Some(rf) => {
1386            let back_href = rf.back_href.as_deref().unwrap_or("/");
1387            let back_label = rf.back_label.as_deref().unwrap_or("Home");
1388            let title_html = rf
1389                .title
1390                .as_deref()
1391                .map(escape_html)
1392                .unwrap_or_default();
1393            // Title + balancing spacer render only when a title is supplied;
1394            // otherwise the toolbar is a back button only (a clean floating pill).
1395            let title_markup = if title_html.is_empty() {
1396                String::new()
1397            } else {
1398                format!(
1399                    "<span class=\"surfdoc-doc-title\">{title}</span>\
1400                     <span class=\"surfdoc-doc-toolbar-spacer\"></span>",
1401                    title = title_html,
1402                )
1403            };
1404            format!(
1405                "<div class=\"surfdoc-doc-viewer\">\
1406                   <div class=\"surfdoc-doc-toolbar\"><div class=\"surfdoc-doc-toolbar-inner\">\
1407                     <a class=\"surfdoc-doc-back\" href=\"{back_href}\">&lsaquo; {back_label}</a>\
1408                     {title_markup}\
1409                   </div></div>\
1410                   <div class=\"surfdoc-doc-stage\">\
1411                     <article class=\"surfdoc-doc-page surfdoc\">{content}</article>\
1412                   </div>\
1413                 </div>",
1414                back_href = escape_html(back_href),
1415                back_label = escape_html(back_label),
1416                title_markup = title_markup,
1417                content = body_html,
1418            )
1419        }
1420        None => body_html.to_string(),
1421    };
1422
1423    // The reading frame hosts its own centered sheet; give <main> a class that
1424    // lifts the base `.surfdoc` document clamp (max-width/padding) so the
1425    // toolbar + stage run full-bleed. Plain pages keep the clamp.
1426    let main_class = if config.reading_frame.is_some() {
1427        "surfdoc surfdoc-doc-main"
1428    } else {
1429        "surfdoc"
1430    };
1431
1432    let source_path = escape_html(&config.source_path);
1433    format!(
1434        r#"<!-- Built with SurfDoc — source: {source_path} -->
1435<!DOCTYPE html>
1436<html lang="{lang}">
1437<head>
1438    <meta charset="utf-8">
1439    <meta name="viewport" content="width=device-width, initial-scale=1">
1440    <meta name="generator" content="SurfDoc v0.1">
1441    <title>{title}</title>{head_tail}
1442</head>
1443<body>
1444{font_imports}{override_tag}{nav}
1445<main class="{main_class}">
1446{body}
1447</main>
1448{footer}
1449</body>
1450</html>"#,
1451        source_path = source_path,
1452        lang = escape_html(lang),
1453        title = title_escaped,
1454        head_tail = head_tail,
1455        font_imports = font_import_tags,
1456        override_tag = override_tag,
1457        nav = nav_html,
1458        main_class = main_class,
1459        body = body_wrapped,
1460        footer = footer_html,
1461    )
1462}
1463
1464// SURFDOC_CSS is now a public constant in lib.rs via include_str!("../assets/surfdoc.css").
1465// It's referenced here as crate::SURFDOC_CSS.
1466use crate::SURFDOC_CSS;
1467
1468// The old inline CSS has been moved to assets/surfdoc.css and is loaded via include_str! in lib.rs.
1469
1470/// Escape HTML special characters to prevent XSS.
1471pub(crate) fn escape_html(s: &str) -> String {
1472    s.replace('&', "&amp;")
1473        .replace('<', "&lt;")
1474        .replace('>', "&gt;")
1475        .replace('"', "&quot;")
1476}
1477
1478/// Render inline markdown (links, bold, italic) within a table cell value.
1479///
1480/// Handles:
1481/// - `[text](url)` → `<a>` tags (relative URLs get `/wiki/` prefix, absolute are kept as-is)
1482/// - `**text**` → `<strong>`
1483/// - `*text*` → `<em>`
1484///
1485/// All non-markdown text is HTML-escaped to prevent XSS.
1486fn render_cell_inline_markdown(input: &str) -> String {
1487    let mut result = String::with_capacity(input.len() * 2);
1488    let chars: Vec<char> = input.chars().collect();
1489    let len = chars.len();
1490    let mut i = 0;
1491
1492    while i < len {
1493        // `«FILL: …»` / `«IMG: …»` generation slot markers are atomic: they
1494        // are resolved later, on the final page HTML, by
1495        // `crate::slots::resolve_slot_markers`. Markdown syntax inside a FILL
1496        // default / IMG description (e.g. `**bold**`, `[text](url)`) must not
1497        // be parsed here — splicing a tag into the marker body would break
1498        // its `«…»` grammar and leak the raw marker into the page.
1499        if chars[i] == '\u{ab}' {
1500            if let Some(close) = (i + 1..len).find(|&j| chars[j] == '\u{bb}') {
1501                for &c in &chars[i..=close] {
1502                    match c {
1503                        '&' => result.push_str("&amp;"),
1504                        '<' => result.push_str("&lt;"),
1505                        '>' => result.push_str("&gt;"),
1506                        '"' => result.push_str("&quot;"),
1507                        c => result.push(c),
1508                    }
1509                }
1510                i = close + 1;
1511                continue;
1512            }
1513        }
1514
1515        // Check for markdown link: [text](url)
1516        if chars[i] == '[' {
1517            if let Some((link_html, advance)) = try_parse_link(&chars, i) {
1518                result.push_str(&link_html);
1519                i += advance;
1520                continue;
1521            }
1522        }
1523
1524        // Check for bold: **text**
1525        if i + 1 < len && chars[i] == '*' && chars[i + 1] == '*' {
1526            if let Some((bold_html, advance)) = try_parse_bold(&chars, i) {
1527                result.push_str(&bold_html);
1528                i += advance;
1529                continue;
1530            }
1531        }
1532
1533        // Check for italic: *text* (but not **)
1534        if chars[i] == '*' && (i + 1 >= len || chars[i + 1] != '*') {
1535            if let Some((em_html, advance)) = try_parse_italic(&chars, i) {
1536                result.push_str(&em_html);
1537                i += advance;
1538                continue;
1539            }
1540        }
1541
1542        // Plain character — escape HTML
1543        match chars[i] {
1544            '&' => result.push_str("&amp;"),
1545            '<' => result.push_str("&lt;"),
1546            '>' => result.push_str("&gt;"),
1547            '"' => result.push_str("&quot;"),
1548            c => result.push(c),
1549        }
1550        i += 1;
1551    }
1552
1553    result
1554}
1555
1556/// Try to parse a markdown link `[text](url)` starting at position `pos`.
1557/// Returns the rendered HTML and the number of characters consumed, or `None`.
1558fn try_parse_link(chars: &[char], pos: usize) -> Option<(String, usize)> {
1559    let len = chars.len();
1560    debug_assert!(chars[pos] == '[');
1561
1562    // Find closing ]
1563    let mut j = pos + 1;
1564    let mut depth = 1;
1565    while j < len && depth > 0 {
1566        if chars[j] == '[' { depth += 1; }
1567        if chars[j] == ']' { depth -= 1; }
1568        j += 1;
1569    }
1570    if depth != 0 { return None; }
1571    // j is now past the ']'
1572    let close_bracket = j - 1;
1573
1574    // Must be followed by '('
1575    if j >= len || chars[j] != '(' { return None; }
1576    let paren_start = j + 1;
1577
1578    // Find closing )
1579    let mut k = paren_start;
1580    let mut paren_depth = 1;
1581    while k < len && paren_depth > 0 {
1582        if chars[k] == '(' { paren_depth += 1; }
1583        if chars[k] == ')' { paren_depth -= 1; }
1584        k += 1;
1585    }
1586    if paren_depth != 0 { return None; }
1587    let close_paren = k - 1;
1588
1589    let text: String = chars[pos + 1..close_bracket].iter().collect();
1590    let url: String = chars[paren_start..close_paren].iter().collect();
1591
1592    if text.is_empty() || url.is_empty() { return None; }
1593
1594    // Determine href: absolute URLs stay as-is, relative get /wiki/ prefix
1595    let href = if url.starts_with("http://") || url.starts_with("https://") || url.starts_with("mailto:") || url.starts_with('/') {
1596        escape_html(&url)
1597    } else {
1598        format!("/wiki/{}", escape_html(&url))
1599    };
1600
1601    let html = format!("<a href=\"{}\">{}</a>", href, escape_html(&text));
1602    Some((html, k - pos))
1603}
1604
1605/// Try to parse `**text**` starting at position `pos`.
1606/// Returns the rendered HTML and the number of characters consumed, or `None`.
1607/// Inner content is processed for inline markdown (links, etc.).
1608fn try_parse_bold(chars: &[char], pos: usize) -> Option<(String, usize)> {
1609    let len = chars.len();
1610    debug_assert!(pos + 1 < len && chars[pos] == '*' && chars[pos + 1] == '*');
1611
1612    let start = pos + 2;
1613    let mut j = start;
1614    while j + 1 < len {
1615        if chars[j] == '*' && chars[j + 1] == '*' {
1616            if j == start { return None; } // empty bold
1617            let inner: String = chars[start..j].iter().collect();
1618            let html = format!("<strong>{}</strong>", render_cell_inline_markdown(&inner));
1619            return Some((html, j + 2 - pos));
1620        }
1621        j += 1;
1622    }
1623    None
1624}
1625
1626/// Try to parse `*text*` starting at position `pos`.
1627/// Returns the rendered HTML and the number of characters consumed, or `None`.
1628/// Inner content is processed for inline markdown (links, etc.).
1629fn try_parse_italic(chars: &[char], pos: usize) -> Option<(String, usize)> {
1630    let len = chars.len();
1631    debug_assert!(chars[pos] == '*');
1632
1633    let start = pos + 1;
1634    let mut j = start;
1635    while j < len {
1636        if chars[j] == '*' && (j + 1 >= len || chars[j + 1] != '*') {
1637            if j == start { return None; } // empty italic
1638            let inner: String = chars[start..j].iter().collect();
1639            let html = format!("<em>{}</em>", render_cell_inline_markdown(&inner));
1640            return Some((html, j + 1 - pos));
1641        }
1642        // If we hit **, this is not a valid *italic* span
1643        if chars[j] == '*' && j + 1 < len && chars[j + 1] == '*' {
1644            return None;
1645        }
1646        j += 1;
1647    }
1648    None
1649}
1650
1651/// Sanitize a value for use inside a CSS declaration.
1652///
1653/// Strips characters that could break out of a CSS property value context:
1654/// semicolons, braces, angle brackets, backslashes, and url()/expression().
1655fn sanitize_css_value(s: &str) -> String {
1656    let stripped: String = s.chars()
1657        .filter(|c| !matches!(c, ';' | '{' | '}' | '<' | '>' | '\\' | '"' | '\''))
1658        .collect();
1659    // Block CSS function injection (url(), expression(), etc.)
1660    let lower = stripped.to_lowercase();
1661    if lower.contains("url(") || lower.contains("expression(") || lower.contains("javascript:") {
1662        return String::new();
1663    }
1664    stripped
1665}
1666
1667/// Self-contained client script for every `::booking` widget on a page.
1668///
1669/// Guarded by a window flag so it initialises all `[data-booking]` widgets
1670/// exactly once regardless of how many `::booking` blocks emit it. Reads each
1671/// widget's inlined JSON, builds the month calendar with the browser `Date`
1672/// API, and drives service → date → slot → confirmation entirely client-side
1673/// (static data-bound; no network). Hidden SPA sections still init fine.
1674const BOOKING_WIDGET_JS: &str = r#"<script>(function(){
1675if(window.__surfBookingInit)return;window.__surfBookingInit=1;
1676var MON=['January','February','March','April','May','June','July','August','September','October','November','December'];
1677function fmt(iso){var p=iso.split('-');var d=new Date(+p[0],+p[1]-1,+p[2]);return d.toLocaleDateString(undefined,{weekday:'long',month:'long',day:'numeric'});}
1678function init(root){
1679 var dataEl=root.querySelector('[data-bk-data]');if(!dataEl)return;
1680 var data={};try{data=JSON.parse(dataEl.textContent)}catch(e){return;}
1681 var days=data.days||[],byDate={},months=[],seen={};
1682 days.forEach(function(d){byDate[d.date]=d.slots||[];var m=d.date.slice(0,7);if(!seen[m]){seen[m]=1;months.push(m);}});
1683 months.sort();if(!months.length)return;
1684 var mi=0,selService=null,selDate=null,selSlot=null;
1685 var svcWrap=root.querySelector('[data-bk-services]');
1686 if(svcWrap&&data.services&&data.services.length){
1687  data.services.forEach(function(s,i){
1688   var b=document.createElement('button');b.type='button';b.className='surfdoc-booking-service';b.setAttribute('role','radio');b.setAttribute('aria-checked',i===0?'true':'false');
1689   var meta=[s.duration,s.price].filter(Boolean).join(' · ');
1690   var n=document.createElement('span');n.className='surfdoc-booking-service-name';n.textContent=s.name;b.appendChild(n);
1691   if(meta){var mEl=document.createElement('span');mEl.className='surfdoc-booking-service-meta';mEl.textContent=meta;b.appendChild(mEl);}
1692   b.addEventListener('click',function(){selService=s.name;svcWrap.querySelectorAll('.surfdoc-booking-service').forEach(function(x){x.classList.remove('is-sel');x.setAttribute('aria-checked','false');});b.classList.add('is-sel');b.setAttribute('aria-checked','true');});
1693   if(i===0){selService=s.name;b.classList.add('is-sel');}
1694   svcWrap.appendChild(b);
1695  });
1696 }
1697 var monthEl=root.querySelector('[data-bk-month]'),daysEl=root.querySelector('[data-bk-days]'),slotsEl=root.querySelector('[data-bk-slots]');
1698 var form=root.querySelector('[data-bk-form]'),summaryEl=root.querySelector('[data-bk-summary]'),confirmEl=root.querySelector('[data-bk-confirm]');
1699 var prev=root.querySelector('[data-bk-prev]'),next=root.querySelector('[data-bk-next]');
1700 function renderMonth(){
1701  var m=months[mi],y=+m.slice(0,4),mo=+m.slice(5,7)-1;
1702  monthEl.textContent=MON[mo]+' '+y;
1703  var first=new Date(y,mo,1).getDay(),dim=new Date(y,mo+1,0).getDate();
1704  daysEl.innerHTML='';
1705  for(var i=0;i<first;i++){var pad=document.createElement('span');pad.className='surfdoc-booking-day is-pad';daysEl.appendChild(pad);}
1706  for(var d=1;d<=dim;d++){
1707   var iso=m+'-'+(d<10?'0'+d:d),slots=byDate[iso],cell;
1708   if(slots&&slots.length){cell=document.createElement('button');cell.type='button';cell.className='surfdoc-booking-day is-open';(function(x){cell.addEventListener('click',function(){selectDate(x);});})(iso);}
1709   else{cell=document.createElement('span');cell.className='surfdoc-booking-day'+(byDate[iso]!==undefined?' is-full':' is-empty');}
1710   cell.textContent=d;if(iso===selDate)cell.classList.add('is-sel');daysEl.appendChild(cell);
1711  }
1712  if(prev)prev.disabled=mi<=0;if(next)next.disabled=mi>=months.length-1;
1713 }
1714 function selectDate(iso){selDate=iso;selSlot=null;renderMonth();renderSlots();if(form)form.hidden=true;if(confirmEl)confirmEl.hidden=true;}
1715 function renderSlots(){
1716  slotsEl.innerHTML='';var slots=byDate[selDate]||[];
1717  var h=document.createElement('div');h.className='surfdoc-booking-slots-head';h.textContent=fmt(selDate);slotsEl.appendChild(h);
1718  var grid=document.createElement('div');grid.className='surfdoc-booking-slot-grid';
1719  slots.forEach(function(t){var b=document.createElement('button');b.type='button';b.className='surfdoc-booking-slot';b.textContent=t;b.addEventListener('click',function(){selSlot=t;grid.querySelectorAll('.surfdoc-booking-slot').forEach(function(x){x.classList.remove('is-sel');});b.classList.add('is-sel');showForm();});grid.appendChild(b);});
1720  slotsEl.appendChild(grid);
1721 }
1722 function showForm(){if(!form)return;form.hidden=false;var bits=[];if(selService)bits.push(selService);bits.push(fmt(selDate));if(selSlot)bits.push(selSlot);if(summaryEl)summaryEl.textContent=bits.join(' · ');}
1723 if(form){form.addEventListener('submit',function(e){e.preventDefault();if(!selDate||!selSlot)return;var nf=form.querySelector('[name=name]');var name=nf?nf.value:'';var ref='BK-'+selDate.replace(/-/g,'')+'-'+(selSlot.replace(/[^0-9]/g,'').slice(0,4)||'0000');confirmEl.innerHTML='';var card=document.createElement('div');card.className='surfdoc-booking-confirm-card';var t=document.createElement('div');t.className='surfdoc-booking-confirm-title';t.textContent='Booking confirmed';var p=document.createElement('p');p.className='surfdoc-booking-confirm-detail';p.textContent=(name?name+', your ':'Your ')+(selService?selService+' ':'')+'is booked for '+fmt(selDate)+' at '+selSlot+'.';var r=document.createElement('p');r.className='surfdoc-booking-confirm-ref';r.textContent='Confirmation '+ref;card.appendChild(t);card.appendChild(p);card.appendChild(r);confirmEl.appendChild(card);confirmEl.hidden=false;form.hidden=true;});}
1724 if(prev)prev.addEventListener('click',function(){if(mi>0){mi--;renderMonth();}});
1725 if(next)next.addEventListener('click',function(){if(mi<months.length-1){mi++;renderMonth();}});
1726 renderMonth();
1727}
1728function boot(){document.querySelectorAll('[data-booking]').forEach(init);}
1729if(document.readyState!=='loading')boot();else document.addEventListener('DOMContentLoaded',boot);
1730})();</script>"#;
1731
1732/// Self-contained client script for every `::store` widget on a page.
1733///
1734/// Guarded by a window flag (inits all `[data-store]` widgets once). Reads each
1735/// widget's inlined JSON and drives category filter → add-to-cart → qty/line
1736/// totals → checkout → order confirmation entirely client-side. Static
1737/// data-bound; payment is out of scope (links out).
1738const STORE_WIDGET_JS: &str = r#"<script>(function(){
1739if(window.__surfStoreInit)return;window.__surfStoreInit=1;
1740function init(root){
1741 var dataEl=root.querySelector('[data-st-data]');if(!dataEl)return;
1742 var data={};try{data=JSON.parse(dataEl.textContent)}catch(e){return;}
1743 var items=data.items||[],cur=data.currency||'$';
1744 var cats=[],seen={};items.forEach(function(it){var c=it.category||'All';if(!seen[c]){seen[c]=1;cats.push(c);}});
1745 var hasCats=cats.length>1||(cats.length===1&&cats[0]!=='All');
1746 var filter='All',cart={};
1747 var gridEl=root.querySelector('[data-st-grid]'),filtEl=root.querySelector('[data-st-filters]');
1748 var itemsEl=root.querySelector('[data-st-items]'),totalEl=root.querySelector('[data-st-total]');
1749 var checkoutBtn=root.querySelector('[data-st-checkout]'),form=root.querySelector('[data-st-form]'),confirmEl=root.querySelector('[data-st-confirm]');
1750 function money(n){return cur+(Number.isInteger(n)?n:n.toFixed(2));}
1751 function price(it){var p=parseFloat(it.price);return isNaN(p)?0:p;}
1752 function renderFilters(){
1753  if(!filtEl||!hasCats)return;
1754  var all=['All'].concat(cats.filter(function(c){return c!=='All';}));
1755  filtEl.innerHTML='';
1756  all.forEach(function(c){var b=document.createElement('button');b.type='button';b.className='surfdoc-store-chip'+(c===filter?' is-sel':'');b.textContent=c;b.addEventListener('click',function(){filter=c;renderFilters();renderGrid();});filtEl.appendChild(b);});
1757 }
1758 function renderGrid(){
1759  gridEl.innerHTML='';
1760  items.filter(function(it){return filter==='All'||(it.category||'All')===filter;}).forEach(function(it){
1761   var card=document.createElement('div');card.className='surfdoc-store-card';
1762   if(it.badge){var bd=document.createElement('span');bd.className='surfdoc-store-badge';bd.textContent=it.badge;card.appendChild(bd);}
1763   var nm=document.createElement('div');nm.className='surfdoc-store-name';nm.textContent=it.name;card.appendChild(nm);
1764   if(it.blurb){var bl=document.createElement('p');bl.className='surfdoc-store-blurb';bl.textContent=it.blurb;card.appendChild(bl);}
1765   var foot=document.createElement('div');foot.className='surfdoc-store-card-foot';
1766   var pr=document.createElement('span');pr.className='surfdoc-store-price';pr.textContent=money(price(it));foot.appendChild(pr);
1767   var add=document.createElement('button');add.type='button';add.className='surfdoc-store-add';add.textContent='Add';add.addEventListener('click',function(){addToCart(it);});foot.appendChild(add);
1768   card.appendChild(foot);gridEl.appendChild(card);
1769  });
1770 }
1771 function addToCart(it){var k=it.name;if(!cart[k])cart[k]={item:it,qty:0};cart[k].qty++;renderCart();}
1772 function renderCart(){
1773  var keys=Object.keys(cart);itemsEl.innerHTML='';var total=0;
1774  if(!keys.length){var e=document.createElement('p');e.className='surfdoc-store-empty';e.textContent='Your cart is empty.';itemsEl.appendChild(e);}
1775  keys.forEach(function(k){var c=cart[k];total+=price(c.item)*c.qty;
1776   var row=document.createElement('div');row.className='surfdoc-store-line';
1777   var nm=document.createElement('span');nm.className='surfdoc-store-line-name';nm.textContent=c.item.name;row.appendChild(nm);
1778   var qty=document.createElement('div');qty.className='surfdoc-store-qty';
1779   var minus=document.createElement('button');minus.type='button';minus.textContent='−';minus.setAttribute('aria-label','Decrease quantity');minus.addEventListener('click',function(){c.qty--;if(c.qty<=0)delete cart[k];renderCart();});
1780   var n=document.createElement('span');n.className='surfdoc-store-qty-n';n.textContent=c.qty;
1781   var plus=document.createElement('button');plus.type='button';plus.textContent='+';plus.setAttribute('aria-label','Increase quantity');plus.addEventListener('click',function(){c.qty++;renderCart();});
1782   qty.appendChild(minus);qty.appendChild(n);qty.appendChild(plus);row.appendChild(qty);
1783   var lt=document.createElement('span');lt.className='surfdoc-store-line-total';lt.textContent=money(price(c.item)*c.qty);row.appendChild(lt);
1784   itemsEl.appendChild(row);
1785  });
1786  if(totalEl)totalEl.textContent=money(total);
1787  if(checkoutBtn)checkoutBtn.disabled=!keys.length;
1788  if(form&&!keys.length)form.hidden=true;
1789 }
1790 if(checkoutBtn)checkoutBtn.addEventListener('click',function(){if(form){form.hidden=false;form.scrollIntoView({behavior:'smooth',block:'nearest'});}});
1791 if(form)form.addEventListener('submit',function(e){
1792  e.preventDefault();var keys=Object.keys(cart);if(!keys.length)return;
1793  var count=0,total=0;keys.forEach(function(k){count+=cart[k].qty;total+=price(cart[k].item)*cart[k].qty;});
1794  var nf=form.querySelector('[name=name]');var name=nf?nf.value:'';
1795  var ref='ORD-'+(String(Math.round(total))+'').slice(0,4)+'-'+count;
1796  confirmEl.innerHTML='';
1797  var card=document.createElement('div');card.className='surfdoc-store-confirm-card';
1798  var t=document.createElement('div');t.className='surfdoc-store-confirm-title';t.textContent='Order placed';
1799  var p=document.createElement('p');p.className='surfdoc-store-confirm-detail';p.textContent=(name?name+', your':'Your')+' order of '+count+' item'+(count===1?'':'s')+' ('+money(total)+') is confirmed.';
1800  var r=document.createElement('p');r.className='surfdoc-store-confirm-ref';r.textContent='Order '+ref;
1801  card.appendChild(t);card.appendChild(p);card.appendChild(r);confirmEl.appendChild(card);
1802  cart={};renderCart();form.hidden=true;confirmEl.hidden=false;confirmEl.scrollIntoView({behavior:'smooth',block:'nearest'});
1803 });
1804 renderFilters();renderGrid();renderCart();
1805}
1806function boot(){document.querySelectorAll('[data-store]').forEach(init);}
1807if(document.readyState!=='loading')boot();else document.addEventListener('DOMContentLoaded',boot);
1808})();</script>"#;
1809
1810/// Canonical lowercase slug for a [`ChartType`] (used in class names + data
1811/// attributes). Single source of truth for the HTML renderer.
1812pub(crate) fn chart_type_str(t: ChartType) -> &'static str {
1813    match t {
1814        ChartType::Line => "line",
1815        ChartType::Bar => "bar",
1816        ChartType::Pie => "pie",
1817        ChartType::Area => "area",
1818        ChartType::Scatter => "scatter",
1819        ChartType::Donut => "donut",
1820        ChartType::StackedBar => "stacked-bar",
1821        ChartType::Radar => "radar",
1822    }
1823}
1824
1825pub(crate) fn render_block(block: &Block) -> String {
1826    match block {
1827        Block::Markdown { content, .. } => render_markdown(content),
1828
1829        Block::Callout {
1830            callout_type,
1831            title,
1832            content,
1833            ..
1834        } => {
1835            let type_str = callout_type_str(*callout_type);
1836            let role = if matches!(callout_type, CalloutType::Danger) { "alert" } else { "note" };
1837            let icon = callout_icon_svg(*callout_type);
1838            let title_html = match title {
1839                Some(t) => format!(
1840                    "<div class=\"surfdoc-callout-title\">{}</div>",
1841                    escape_html(t)
1842                ),
1843                None => String::new(),
1844            };
1845            format!(
1846                "<div class=\"surfdoc-callout surfdoc-callout-{type_str}\" role=\"{role}\">{icon}<div class=\"surfdoc-callout-body\">{title_html}{}</div></div>",
1847                render_inline_markdown(content),
1848            )
1849        }
1850
1851        Block::Data {
1852            headers, rows, ..
1853        } => {
1854            let mut html = String::from("<div class=\"surfdoc-table-wrap\"><table class=\"surfdoc-data\">");
1855            if !headers.is_empty() {
1856                html.push_str("<thead><tr>");
1857                for h in headers {
1858                    html.push_str(&format!(
1859                        "<th scope=\"col\" aria-sort=\"none\">{}</th>",
1860                        render_cell_inline_markdown(h)
1861                    ));
1862                }
1863                html.push_str("</tr></thead>");
1864            }
1865            html.push_str("<tbody>");
1866            for row in rows {
1867                html.push_str("<tr>");
1868                for cell in row {
1869                    let num_class = if is_numeric_cell(cell) { " class=\"num\"" } else { "" };
1870                    html.push_str(&format!(
1871                        "<td{num_class}>{}</td>",
1872                        render_cell_inline_markdown(cell)
1873                    ));
1874                }
1875                html.push_str("</tr>");
1876            }
1877            html.push_str("</tbody></table></div>");
1878            html
1879        }
1880
1881        Block::Code {
1882            lang,
1883            file,
1884            highlight,
1885            content,
1886            ..
1887        } => {
1888            let class = match lang {
1889                Some(l) => format!(" class=\"language-{}\"", escape_html(l)),
1890                None => String::new(),
1891            };
1892            let aria = match lang {
1893                Some(l) => format!(" aria-label=\"{} code\"", escape_html(l)),
1894                None => String::new(),
1895            };
1896            let data_lang = match lang {
1897                Some(l) => format!(" data-lang=\"{}\"", escape_html(l)),
1898                None => String::new(),
1899            };
1900            // Build the figcaption header. Render the file span only when present;
1901            // the lang badge (uppercased) is shown whenever a language is known.
1902            let file_span = match file {
1903                Some(f) => format!("<span class=\"surfdoc-code-file\">{}</span>", escape_html(f)),
1904                None => String::new(),
1905            };
1906            let lang_span = match lang {
1907                Some(l) => format!(
1908                    "<span class=\"surfdoc-code-lang\">{}</span>",
1909                    escape_html(&l.to_uppercase())
1910                ),
1911                None => String::new(),
1912            };
1913            let head = if file_span.is_empty() && lang_span.is_empty() {
1914                String::new()
1915            } else {
1916                format!(
1917                    "<figcaption class=\"surfdoc-code-head\">{file_span}{lang_span}</figcaption>"
1918                )
1919            };
1920            let code_body = render_code_with_highlights(content, highlight);
1921            format!(
1922                "<figure class=\"surfdoc-code\">{head}<pre{}{}><code{}>{}</code></pre></figure>",
1923                aria,
1924                data_lang,
1925                class,
1926                code_body,
1927            )
1928        }
1929
1930        Block::Tasks { items, .. } => {
1931            let mut html = String::from("<ul class=\"surfdoc-tasks\">");
1932            for item in items {
1933                let li_class = if item.done { "surfdoc-task is-done" } else { "surfdoc-task" };
1934                let check = if item.done { "\u{2713}" } else { "" };
1935                let assignee_html = match &item.assignee {
1936                    Some(a) => format!("<span class=\"surfdoc-assignee\">@{}</span>", escape_html(a)),
1937                    None => String::new(),
1938                };
1939                html.push_str(&format!(
1940                    "<li class=\"{li_class}\"><span class=\"surfdoc-check\">{check}</span><span class=\"surfdoc-task-text\">{}</span>{assignee_html}</li>",
1941                    render_inline_markdown_phrasing(&item.text),
1942                ));
1943            }
1944            html.push_str("</ul>");
1945            html
1946        }
1947
1948        Block::Decision {
1949            status,
1950            date,
1951            deciders,
1952            content,
1953            ..
1954        } => {
1955            let status_str = decision_status_str(*status);
1956            let date_html = match date {
1957                Some(d) => format!("<span class=\"surfdoc-decision-date\">{}</span>", escape_html(d)),
1958                None => String::new(),
1959            };
1960            let deciders_html = if deciders.is_empty() {
1961                String::new()
1962            } else {
1963                let joined = deciders
1964                    .iter()
1965                    .map(|d| escape_html(d))
1966                    .collect::<Vec<_>>()
1967                    .join(", ");
1968                format!("<footer class=\"surfdoc-decision-meta\">Deciders: {joined}</footer>")
1969            };
1970            format!(
1971                "<article class=\"surfdoc-decision surfdoc-decision-{status_str}\" role=\"note\" aria-label=\"Decision: {status_str}\"><div class=\"surfdoc-decision-header\"><span class=\"surfdoc-decision-status surfdoc-decision-status--{status_str}\">{}</span>{date_html}</div><div class=\"surfdoc-decision-body\">{}</div>{deciders_html}</article>",
1972                capitalize(status_str),
1973                render_inline_markdown(content),
1974            )
1975        }
1976
1977        Block::Metric {
1978            label,
1979            value,
1980            trend,
1981            unit,
1982            ..
1983        } => {
1984            let trend_html = match trend {
1985                Some(Trend::Up) => "<span class=\"surfdoc-trend surfdoc-trend--up\">\u{25B2}</span>".to_string(),
1986                Some(Trend::Down) => "<span class=\"surfdoc-trend surfdoc-trend--down\">\u{25BC}</span>".to_string(),
1987                Some(Trend::Flat) => "<span class=\"surfdoc-trend surfdoc-trend--flat\">\u{2192}</span>".to_string(),
1988                None => String::new(),
1989            };
1990            let unit_html = match unit {
1991                Some(u) => format!(" <span class=\"surfdoc-metric-unit\">{}</span>", escape_html(u)),
1992                None => String::new(),
1993            };
1994            let trend_text = match trend {
1995                Some(Trend::Up) => ", trending up",
1996                Some(Trend::Down) => ", trending down",
1997                Some(Trend::Flat) => ", flat",
1998                None => "",
1999            };
2000            let unit_text = match unit {
2001                Some(u) => format!(" {}", u),
2002                None => String::new(),
2003            };
2004            let aria_label = format!("{}: {}{}{}", label, value, unit_text, trend_text);
2005            // Metrics render one per block; still wrap the single card in a
2006            // `surfdoc-metric-row` so adjacent metrics lay out as a flex row and
2007            // each card carries the per-metric value/label structure.
2008            format!(
2009                "<div class=\"surfdoc-metric-row\"><div class=\"surfdoc-metric\" role=\"group\" aria-label=\"{}\"><div class=\"surfdoc-metric-value\">{}{unit_html}</div><div class=\"surfdoc-metric-label\">{}{trend_html}</div></div></div>",
2010                escape_html(&aria_label),
2011                escape_html(value),
2012                escape_html(label),
2013            )
2014        }
2015
2016        // `::cite` is a definition only — it renders nothing visible.
2017        Block::Cite { .. } => String::new(),
2018
2019        Block::Bibliography { style, .. } => render_bibliography_html(*style),
2020
2021        Block::Summary { content, .. } => {
2022            format!(
2023                "<div class=\"surfdoc-summary\" role=\"doc-abstract\"><div class=\"surfdoc-summary-label\">Summary</div><p>{}</p></div>",
2024                render_inline_markdown(content),
2025            )
2026        }
2027
2028        Block::Figure {
2029            src,
2030            caption,
2031            alt,
2032            ..
2033        } => {
2034            let alt_attr = alt.as_deref().unwrap_or("");
2035            let caption_html = match caption {
2036                Some(c) => format!("<figcaption class=\"surfdoc-figure-cap\">{}</figcaption>", escape_html(c)),
2037                None => String::new(),
2038            };
2039            // Wrap the image in a div so that onerror can hide the <img> and
2040            // the placeholder div still fills the aspect-ratio box cleanly.
2041            format!(
2042                "<figure class=\"surfdoc-figure\"><div class=\"surfdoc-figure-img\"><img src=\"{}\" alt=\"{}\" onerror=\"this.style.display='none';this.onerror=null\" /></div>{caption_html}</figure>",
2043                escape_html(src),
2044                escape_html(alt_attr),
2045            )
2046        }
2047
2048        Block::Diagram {
2049            diagram_type,
2050            title,
2051            content,
2052            ..
2053        } => {
2054            let caption_html = match title {
2055                Some(t) => format!(
2056                    "<figcaption class=\"surfdoc-diagram-cap\">{}</figcaption>",
2057                    escape_html(t)
2058                ),
2059                None => String::new(),
2060            };
2061            match crate::diagram::parse_diagram_source(diagram_type, content) {
2062                Ok(model) => {
2063                    let svg = crate::diagram::render_svg(&model, title.as_deref());
2064                    format!(
2065                        "<figure class=\"surfdoc-diagram surfdoc-diagram-{}\">{caption_html}{svg}</figure>",
2066                        escape_html(diagram_type),
2067                    )
2068                }
2069                // Malformed DSL or empty/unknown type NEVER fails the render —
2070                // degrade to a preformatted prose fallback.
2071                Err(_) => format!(
2072                    "<figure class=\"surfdoc-diagram surfdoc-diagram-fallback\">{caption_html}<pre class=\"surfdoc-diagram-src\">{}</pre></figure>",
2073                    escape_html(content),
2074                ),
2075            }
2076        }
2077
2078        Block::Tabs { tabs, .. } => {
2079            let mut html = String::from("<div class=\"surfdoc-tabs\">");
2080            html.push_str("<nav role=\"tablist\">");
2081            for (i, tab) in tabs.iter().enumerate() {
2082                let selected = if i == 0 { "true" } else { "false" };
2083                let tabindex = if i == 0 { "0" } else { "-1" };
2084                html.push_str(&format!(
2085                    "<button class=\"tab-btn{}\" role=\"tab\" aria-selected=\"{}\" aria-controls=\"surfdoc-panel-{}\" id=\"surfdoc-tab-{}\" tabindex=\"{}\">{}</button>",
2086                    if i == 0 { " active" } else { "" },
2087                    selected,
2088                    i,
2089                    i,
2090                    tabindex,
2091                    escape_html(&tab.label)
2092                ));
2093            }
2094            html.push_str("</nav>");
2095            for (i, tab) in tabs.iter().enumerate() {
2096                let active = if i == 0 { " active" } else { "" };
2097                let hidden = if i == 0 { "" } else { " hidden" };
2098                let content_html = render_markdown(&tab.content);
2099                html.push_str(&format!(
2100                    "<div class=\"tab-panel{}\" role=\"tabpanel\" id=\"surfdoc-panel-{}\" aria-labelledby=\"surfdoc-tab-{}\" tabindex=\"0\"{}>{}</div>",
2101                    active, i, i, hidden, content_html
2102                ));
2103            }
2104            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>"#);
2105            html.push_str("</div>");
2106            html
2107        }
2108
2109        Block::Columns { columns, .. } => {
2110            let count = columns.len();
2111            let mut html = format!(
2112                "<div class=\"surfdoc-columns\" role=\"group\" data-cols=\"{}\">",
2113                count
2114            );
2115            for col in columns {
2116                let col_html = render_markdown(&col.content);
2117                html.push_str(&format!(
2118                    "<div class=\"surfdoc-column\">{}</div>",
2119                    col_html
2120                ));
2121            }
2122            html.push_str("</div>");
2123            html
2124        }
2125
2126        Block::Quote {
2127            content,
2128            attribution,
2129            cite,
2130            ..
2131        } => {
2132            let mut html = String::from("<blockquote class=\"surfdoc-quote\"><p>");
2133            html.push_str(&render_inline_markdown_phrasing(content));
2134            html.push_str("</p>");
2135            if let Some(attr) = attribution {
2136                let cite_part = match cite {
2137                    Some(c) => format!(", <cite>{}</cite>", escape_html(c)),
2138                    None => String::new(),
2139                };
2140                html.push_str(&format!(
2141                    "<div class=\"surfdoc-quote-by\">{}{}</div>",
2142                    escape_html(attr),
2143                    cite_part,
2144                ));
2145            }
2146            html.push_str("</blockquote>");
2147            html
2148        }
2149
2150        Block::Cta {
2151            label,
2152            href,
2153            primary,
2154            icon,
2155            ..
2156        } => {
2157            let class = if *primary { "surfdoc-cta surfdoc-cta-primary" } else { "surfdoc-cta surfdoc-cta-secondary" };
2158            let icon_html = icon
2159                .as_deref()
2160                .and_then(get_icon)
2161                .map(|svg| format!("<span class=\"surfdoc-icon\">{}</span> ", svg))
2162                .unwrap_or_default();
2163            format!(
2164                "<a class=\"{}\" href=\"{}\">{}{}</a>",
2165                class,
2166                escape_html(href),
2167                icon_html,
2168                escape_html(label),
2169            )
2170        }
2171
2172        Block::HeroImage { src, alt, .. } => {
2173            let alt_attr = alt.as_deref().unwrap_or("");
2174            let role_attr = if !alt_attr.is_empty() {
2175                format!(" role=\"img\" aria-label=\"{}\"", escape_html(alt_attr))
2176            } else {
2177                String::new()
2178            };
2179            format!(
2180                "<div class=\"surfdoc-hero-image\"{}><img src=\"{}\" alt=\"{}\" onerror=\"this.classList.add('broken');this.onerror=null\" /></div>",
2181                role_attr,
2182                escape_html(src),
2183                escape_html(alt_attr),
2184            )
2185        }
2186
2187        Block::Testimonial {
2188            content,
2189            author,
2190            role,
2191            company,
2192            ..
2193        } => {
2194            let aria_label = match author {
2195                Some(a) => format!(" aria-label=\"Testimonial from {}\"", escape_html(a)),
2196                None => " aria-label=\"Testimonial\"".to_string(),
2197            };
2198            // Reference look (.sd-testimonial): quoted (not italic) blockquote, then
2199            // a figcaption with an initials avatar + name/role meta.
2200            let mut html =
2201                format!("<figure class=\"surfdoc-testimonial\" role=\"figure\"{aria_label}>");
2202            html.push_str(&format!(
2203                "<blockquote>\u{201C}{}\u{201D}</blockquote>",
2204                render_inline_markdown_phrasing(content)
2205            ));
2206            if author.is_some() || role.is_some() || company.is_some() {
2207                html.push_str("<figcaption class=\"surfdoc-testimonial-by\">");
2208                if let Some(a) = author {
2209                    // Avatar: up to two initials from the author's name.
2210                    let initials: String = a
2211                        .split_whitespace()
2212                        .filter_map(|w| w.chars().next())
2213                        .take(2)
2214                        .collect::<String>()
2215                        .to_uppercase();
2216                    if !initials.is_empty() {
2217                        html.push_str(&format!(
2218                            "<span class=\"surfdoc-testimonial-avatar\" aria-hidden=\"true\">{}</span>",
2219                            escape_html(&initials)
2220                        ));
2221                    }
2222                }
2223                html.push_str("<span class=\"surfdoc-testimonial-meta\">");
2224                if let Some(a) = author {
2225                    html.push_str(&format!("<strong>{}</strong>", escape_html(a)));
2226                }
2227                let details: Vec<&str> = [role.as_deref(), company.as_deref()]
2228                    .iter()
2229                    .filter_map(|v| *v)
2230                    .collect();
2231                if !details.is_empty() {
2232                    html.push_str(&format!("<br><span>{}</span>", escape_html(&details.join(", "))));
2233                }
2234                html.push_str("</span>");
2235                html.push_str("</figcaption>");
2236            }
2237            html.push_str("</figure>");
2238            html
2239        }
2240
2241        Block::Style { properties, .. } => {
2242            // Style blocks are metadata — rendered as a hidden data element
2243            let pairs: Vec<String> = properties
2244                .iter()
2245                .map(|p| format!("{}={}", escape_html(&p.key), escape_html(&p.value)))
2246                .collect();
2247            format!(
2248                "<div class=\"surfdoc-style\" aria-hidden=\"true\" data-properties=\"{}\"></div>",
2249                escape_html(&pairs.join(";"))
2250            )
2251        }
2252
2253        Block::Faq { items, .. } => {
2254            let mut html = String::from("<div class=\"surfdoc-faq\">");
2255            for item in items {
2256                html.push_str(&format!(
2257                    "<details class=\"surfdoc-faq-item\"><summary>{}</summary><div class=\"surfdoc-faq-answer\">{}</div></details>",
2258                    escape_html(&item.question),
2259                    escape_html(&item.answer),
2260                ));
2261            }
2262            html.push_str("</div>");
2263            html
2264        }
2265
2266        Block::PricingTable {
2267            headers, rows, ..
2268        } => {
2269            // Reference look (.sd-pricing): a grid of tier CARDS, not a table.
2270            // Each row is one tier — col 0 = tier name, col 1 = price, remaining
2271            // cols become "<value> <header>" feature bullets. A tier whose name
2272            // is bold (`**Team**`) is the featured tier (accent border). A `/...`
2273            // suffix in the price (e.g. `$6.99/seat`) renders as a muted span.
2274            let mut html = String::from("<div class=\"surfdoc-pricing\" aria-label=\"Pricing\">");
2275            for row in rows {
2276                if row.is_empty() {
2277                    continue;
2278                }
2279                let raw_name = row[0].trim();
2280                let (name, featured) = if raw_name.len() >= 4
2281                    && raw_name.starts_with("**")
2282                    && raw_name.ends_with("**")
2283                {
2284                    (raw_name[2..raw_name.len() - 2].trim(), true)
2285                } else {
2286                    (raw_name, false)
2287                };
2288                let tier_cls = if featured {
2289                    "surfdoc-tier surfdoc-tier-featured"
2290                } else {
2291                    "surfdoc-tier"
2292                };
2293                html.push_str(&format!("<div class=\"{tier_cls}\">"));
2294                html.push_str(&format!(
2295                    "<div class=\"surfdoc-tier-name\">{}</div>",
2296                    escape_html(name)
2297                ));
2298                if let Some(price) = row.get(1) {
2299                    let price = price.trim();
2300                    if let Some(slash) = price.find('/') {
2301                        let (amount, suffix) = price.split_at(slash);
2302                        html.push_str(&format!(
2303                            "<div class=\"surfdoc-tier-price\">{}<span>{}</span></div>",
2304                            escape_html(amount.trim()),
2305                            escape_html(suffix)
2306                        ));
2307                    } else {
2308                        html.push_str(&format!(
2309                            "<div class=\"surfdoc-tier-price\">{}</div>",
2310                            escape_html(price)
2311                        ));
2312                    }
2313                }
2314                if row.len() > 2 {
2315                    html.push_str("<ul>");
2316                    for (i, cell) in row.iter().enumerate().skip(2) {
2317                        let val = cell.trim();
2318                        if val.is_empty() {
2319                            continue;
2320                        }
2321                        let header = headers.get(i).map(|h| h.trim()).unwrap_or("");
2322                        let bullet = if header.is_empty() {
2323                            val.to_string()
2324                        } else {
2325                            format!("{} {}", val, header.to_lowercase())
2326                        };
2327                        html.push_str(&format!(
2328                            "<li>{}</li>",
2329                            render_cell_inline_markdown(&bullet)
2330                        ));
2331                    }
2332                    html.push_str("</ul>");
2333                }
2334                // Subscribe CTA — every tier card gets an action button so the
2335                // pricing grid is actionable. Featured tier = primary fill;
2336                // free/$0 tier reads "Get started", paid tiers "Subscribe".
2337                let price_l = row
2338                    .get(1)
2339                    .map(|p| p.trim().to_lowercase())
2340                    .unwrap_or_default();
2341                let is_free = price_l.is_empty()
2342                    || price_l.contains("free")
2343                    || price_l.starts_with("$0")
2344                    || price_l == "0";
2345                let cta_label = if is_free { "Get started" } else { "Subscribe" };
2346                let cta_cls = if featured {
2347                    "surfdoc-tier-cta surfdoc-tier-cta-primary"
2348                } else {
2349                    "surfdoc-tier-cta surfdoc-tier-cta-secondary"
2350                };
2351                html.push_str(&format!(
2352                    "<a class=\"{cta_cls}\" href=\"#\">{}</a>",
2353                    escape_html(cta_label)
2354                ));
2355                html.push_str("</div>");
2356            }
2357            html.push_str("</div>");
2358            html
2359        }
2360
2361        Block::Site { properties, domain, .. } => {
2362            // Site config is metadata — hidden element with data attributes
2363            let domain_attr = match domain {
2364                Some(d) => format!(" data-domain=\"{}\"", escape_html(d)),
2365                None => String::new(),
2366            };
2367            let pairs: Vec<String> = properties
2368                .iter()
2369                .map(|p| format!("{}={}", escape_html(&p.key), escape_html(&p.value)))
2370                .collect();
2371            format!(
2372                "<div class=\"surfdoc-site\" aria-hidden=\"true\"{} data-properties=\"{}\"></div>",
2373                domain_attr,
2374                escape_html(&pairs.join(";")),
2375            )
2376        }
2377
2378        Block::Page {
2379            route, layout, title, children, ..
2380        } => {
2381            let layout_attr = match layout {
2382                Some(l) => format!(" data-layout=\"{}\"", escape_html(l)),
2383                None => String::new(),
2384            };
2385            let aria_label = match title {
2386                Some(t) => format!(" aria-label=\"{}\"", escape_html(t)),
2387                None => format!(" aria-label=\"Page: {}\"", escape_html(route)),
2388            };
2389            let mut html = format!("<section class=\"surfdoc-page\"{layout_attr}{aria_label}>");
2390            for child in children {
2391                html.push_str(&render_block(child));
2392            }
2393            html.push_str("</section>");
2394            html
2395        }
2396
2397        // A `::deck` is a config block; rendered inline (in a non-deck context)
2398        // it produces nothing — the presentation chrome lives in render_slides.
2399        Block::Deck { .. } => String::new(),
2400
2401        // A `::slide` rendered inline (outside the deck renderer) degrades to a
2402        // labeled section of its child blocks.
2403        Block::Slide {
2404            kicker, children, ..
2405        } => {
2406            let kicker_html = kicker
2407                .as_ref()
2408                .map(|k| format!("<p class=\"surfdoc-kicker\">{}</p>", escape_html(k)))
2409                .unwrap_or_default();
2410            let mut html = format!("<section class=\"surfdoc-slide\">{kicker_html}");
2411            for child in children {
2412                html.push_str(&render_block(child));
2413            }
2414            html.push_str("</section>");
2415            html
2416        }
2417
2418        Block::Nav { items, logo, groups, brand, brand_reg, cta, drawer, minimal, .. } => {
2419            if *minimal {
2420                render_minimal_nav_html(logo.as_deref(), brand.as_deref(), *brand_reg)
2421            } else if nav_is_rich(groups, brand, *drawer) {
2422                render_shell_nav_html(items, groups, logo.as_deref(), brand.as_deref(), *brand_reg, cta.as_ref())
2423            } else {
2424                render_nav_shell_html(items, logo.as_deref())
2425            }
2426        }
2427
2428        Block::Embed {
2429            src, embed_type, title, width, height, ..
2430        } => {
2431            let title_text = title.as_deref().unwrap_or(src.as_str());
2432            // Generic embeds (no recognized provider type) with a real src and an
2433            // explicit height are first-class iframes — e.g. a Termly policy viewer
2434            // or any hosted page meant to render inline. Provider types (video/
2435            // audio/map) keep the safe link-card so unembeddable share URLs
2436            // (youtu.be/xyz etc.) don't produce an error box.
2437            let is_generic = matches!(embed_type, None | Some(crate::types::EmbedType::Generic));
2438            if is_generic && !src.is_empty() && height.is_some() {
2439                let h = height.as_deref().unwrap();
2440                let w = width.as_deref().unwrap_or("100%");
2441                let title_attr = match title {
2442                    Some(t) => format!(" title=\"{}\"", escape_html(t)),
2443                    None => String::new(),
2444                };
2445                return format!(
2446                    "<iframe class=\"surfdoc-embed-frame\" src=\"{}\"{} style=\"width:{};height:{};border:0\" loading=\"lazy\"></iframe>",
2447                    escape_html(src),
2448                    title_attr,
2449                    escape_html(w),
2450                    escape_html(h),
2451                );
2452            }
2453            let type_label = match embed_type {
2454                Some(crate::types::EmbedType::Map) => "map",
2455                Some(crate::types::EmbedType::Video) => "video",
2456                Some(crate::types::EmbedType::Audio) => "audio",
2457                _ => "embed",
2458            };
2459            // Compact link-card: icon + title + source URL + type badge.
2460            // Matches the reference .sd-embed design — no iframe so broken URLs
2461            // (youtu.be/xyz etc.) don't produce an error box.
2462            let icon_svg = match embed_type {
2463                Some(crate::types::EmbedType::Video) => {
2464                    "<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\"><path d=\"M8 5v14l11-7z\"/></svg>"
2465                }
2466                Some(crate::types::EmbedType::Audio) => {
2467                    "<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M9 18V5l12-2v13\"/><circle cx=\"6\" cy=\"18\" r=\"3\"/><circle cx=\"18\" cy=\"16\" r=\"3\"/></svg>"
2468                }
2469                Some(crate::types::EmbedType::Map) => {
2470                    "<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><polygon points=\"1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6\"/><line x1=\"8\" y1=\"2\" x2=\"8\" y2=\"18\"/><line x1=\"16\" y1=\"6\" x2=\"16\" y2=\"22\"/></svg>"
2471                }
2472                _ => {
2473                    "<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"3\" y=\"3\" width=\"7\" height=\"7\"/><rect x=\"14\" y=\"3\" width=\"7\" height=\"7\"/><rect x=\"14\" y=\"14\" width=\"7\" height=\"7\"/><rect x=\"3\" y=\"14\" width=\"7\" height=\"7\"/></svg>"
2474                }
2475            };
2476            format!(
2477                "<div class=\"surfdoc-embed\"><div class=\"surfdoc-embed-icon\">{icon_svg}</div><div class=\"surfdoc-embed-body\"><div class=\"surfdoc-embed-title\">{}</div><div class=\"surfdoc-embed-src\">{} · {}</div></div></div>",
2478                escape_html(title_text),
2479                escape_html(src),
2480                escape_html(type_label),
2481            )
2482        }
2483
2484        Block::Form {
2485            fields, submit_label, action, method, honeypot, ..
2486        } => {
2487            let btn_label = submit_label.as_deref().unwrap_or("Submit");
2488            // A real submission target makes the form POST to a server route.
2489            let target_attrs = match action {
2490                Some(a) => {
2491                    let m = method.as_deref().unwrap_or("post");
2492                    format!(" method=\"{}\" action=\"{}\"", escape_html(m), escape_html(a))
2493                }
2494                None => String::new(),
2495            };
2496            let mut html = format!("<form class=\"surfdoc-form\"{target_attrs}>");
2497            if *honeypot {
2498                html.push_str("<input type=\"text\" name=\"_honey\" class=\"surfdoc-form-honey\" tabindex=\"-1\" autocomplete=\"off\" aria-hidden=\"true\">");
2499            }
2500            for field in fields {
2501                let req = if field.required { " required" } else { "" };
2502                let req_star = if field.required { " <span class=\"required\">*</span>" } else { "" };
2503                html.push_str(&format!(
2504                    "<div class=\"surfdoc-form-field\"><label>{}{}</label>",
2505                    escape_html(&field.label),
2506                    req_star,
2507                ));
2508                match field.field_type {
2509                    FormFieldType::Textarea => {
2510                        let ph = field.placeholder.as_deref().unwrap_or("");
2511                        html.push_str(&format!(
2512                            "<textarea name=\"{}\" placeholder=\"{}\" rows=\"4\"{}></textarea>",
2513                            escape_html(&field.name),
2514                            escape_html(ph),
2515                            req,
2516                        ));
2517                    }
2518                    FormFieldType::Select => {
2519                        html.push_str(&format!(
2520                            "<select name=\"{}\"{}>",
2521                            escape_html(&field.name),
2522                            req,
2523                        ));
2524                        html.push_str("<option value=\"\">Select...</option>");
2525                        for opt in &field.options {
2526                            html.push_str(&format!(
2527                                "<option value=\"{}\">{}</option>",
2528                                escape_html(opt),
2529                                escape_html(opt),
2530                            ));
2531                        }
2532                        html.push_str("</select>");
2533                    }
2534                    _ => {
2535                        let input_type = match field.field_type {
2536                            FormFieldType::Email => "email",
2537                            FormFieldType::Tel => "tel",
2538                            FormFieldType::Date => "date",
2539                            FormFieldType::Number => "number",
2540                            FormFieldType::Password => "password",
2541                            _ => "text",
2542                        };
2543                        let ph = field.placeholder.as_deref().unwrap_or("");
2544                        html.push_str(&format!(
2545                            "<input type=\"{}\" name=\"{}\" placeholder=\"{}\"{}/>",
2546                            input_type,
2547                            escape_html(&field.name),
2548                            escape_html(ph),
2549                            req,
2550                        ));
2551                    }
2552                }
2553                html.push_str("</div>");
2554            }
2555            html.push_str(&format!(
2556                "<button type=\"submit\" class=\"surfdoc-form-submit\">{}</button>",
2557                escape_html(btn_label),
2558            ));
2559            html.push_str("</form>");
2560            html
2561        }
2562
2563        Block::Banner {
2564            headline,
2565            subtitle,
2566            buttons,
2567            id,
2568            ..
2569        } => {
2570            let id_attr = id
2571                .as_ref()
2572                .map(|i| format!(" id=\"{}\"", escape_html(i)))
2573                .unwrap_or_default();
2574            let mut parts = Vec::new();
2575            parts.push(format!("<section class=\"surfdoc-banner\"{id_attr}>"));
2576            parts.push("<div class=\"surfdoc-banner-inner\">".to_string());
2577            if let Some(h) = headline {
2578                parts.push(format!(
2579                    "<h2 class=\"surfdoc-banner-headline\">{}</h2>",
2580                    render_inline_markdown_phrasing(h)
2581                ));
2582            }
2583            if let Some(s) = subtitle {
2584                parts.push(format!(
2585                    "<p class=\"surfdoc-banner-subtitle\">{}</p>",
2586                    render_inline_markdown_phrasing(s)
2587                ));
2588            }
2589            if !buttons.is_empty() {
2590                parts.push("<div class=\"surfdoc-banner-actions\">".to_string());
2591                for btn in buttons {
2592                    let cls = if btn.primary {
2593                        "surfdoc-banner-btn surfdoc-banner-btn-primary"
2594                    } else {
2595                        "surfdoc-banner-btn surfdoc-banner-btn-secondary"
2596                    };
2597                    let target = if btn.external { " target=\"_blank\" rel=\"noopener\"" } else { "" };
2598                    parts.push(format!(
2599                        "<a href=\"{}\" class=\"{}\"{}>{}</a>",
2600                        escape_html(&btn.href),
2601                        cls,
2602                        target,
2603                        escape_html(&btn.label)
2604                    ));
2605                }
2606                parts.push("</div>".to_string());
2607            }
2608            parts.push("</div>".to_string());
2609            parts.push("</section>".to_string());
2610            parts.join("")
2611        }
2612
2613        Block::ProductGrid { groups, tiles: true, .. } => {
2614            // apple.com-style promo tiles: a full-bleed (site-capped) 2-up band;
2615            // each tile is one link with centered copy at the top over a per-card
2616            // background (image/color/gradient/transparent via the bg field).
2617            let mut parts = Vec::new();
2618            parts.push("<section class=\"surfdoc-product-grid surfdoc-pg-tiles-band\">".to_string());
2619            parts.push("<div class=\"surfdoc-pg-tiles\">".to_string());
2620            for group in groups {
2621                if let Some(label) = &group.label {
2622                    parts.push(format!(
2623                        "<h3 class=\"surfdoc-pg-label surfdoc-pg-tiles-label\">{}</h3>",
2624                        render_inline_markdown_phrasing(label)
2625                    ));
2626                }
2627                // Each group is one row; {cols=N} (1–3) sets its column count,
2628                // default 2. Mobile always collapses to 1 (CSS).
2629                parts.push(format!(
2630                    "<div class=\"surfdoc-pg-tiles-row\" data-cols=\"{}\">",
2631                    group.cols.unwrap_or(2).clamp(1, 3)
2632                ));
2633                for item in &group.items {
2634                    let slug: String = item
2635                        .name
2636                        .to_lowercase()
2637                        .chars()
2638                        .map(|c| if c.is_alphanumeric() { c } else { '-' })
2639                        .collect::<String>()
2640                        .split('-')
2641                        .filter(|s| !s.is_empty())
2642                        .collect::<Vec<_>>()
2643                        .join("-");
2644                    let (bg_style, dark, copy_bottom, theme_ink) = product_tile_bg(item.bg.as_deref());
2645                    let is_image = item
2646                        .bg
2647                        .as_deref()
2648                        .is_some_and(|b| b.trim().starts_with("image:"));
2649                    // `surface` keyword: opaque theme surface via a class (no
2650                    // inline style, no -bg ink pinning — text follows the theme).
2651                    let is_surface = item
2652                        .bg
2653                        .as_deref()
2654                        .is_some_and(|b| b.split_whitespace().next() == Some("surface"));
2655                    // image: → media div below the copy; color:/gradient: →
2656                    // tile background. Only the latter pins the -bg ink scheme
2657                    // (an image tile's surface still follows the theme).
2658                    let style_attr = if bg_style.is_empty() || is_image {
2659                        String::new()
2660                    } else {
2661                        format!(" style=\"{}\"", bg_style)
2662                    };
2663                    // An explicit background doesn't change with the site theme,
2664                    // so its copy can't follow var(--text): -bg pins dark ink,
2665                    // the ` dark` flag flips it to white. Transparent tiles
2666                    // keep the theme-following default.
2667                    let mut scheme_class = String::new();
2668                    if !bg_style.is_empty() && !is_image {
2669                        scheme_class.push_str(" surfdoc-pg-tile-bg");
2670                    }
2671                    if is_surface {
2672                        scheme_class.push_str(" surfdoc-pg-tile-surface");
2673                    }
2674                    if dark {
2675                        scheme_class.push_str(" surfdoc-pg-tile-dark");
2676                    }
2677                    if theme_ink {
2678                        scheme_class.push_str(" surfdoc-pg-tile-theme");
2679                    }
2680                    if copy_bottom {
2681                        scheme_class.push_str(" surfdoc-pg-tile-copy-bottom");
2682                    }
2683                    // Whole-tile click: the primary action is the explicit
2684                    // first CTA when given, else the card href. A linked tile
2685                    // carries an invisible stretched anchor to it (the pills
2686                    // stay clickable above); linkless tiles keep no affordance.
2687                    let (p_label, p_href) = match (&item.cta1_label, &item.cta1_href) {
2688                        (Some(l), Some(h)) => (l.as_str(), h.as_str()),
2689                        _ => ("Learn more", item.href.as_str()),
2690                    };
2691                    let linked = !p_href.is_empty() && p_href != "-";
2692                    let p_rel = if p_href.contains("://") { " target=\"_blank\" rel=\"noopener\"" } else { "" };
2693                    // An href-less tile whose only action is the secondary
2694                    // pill (the one-link grammar form) stretches to that
2695                    // instead — it IS the tile's primary action.
2696                    let stretch_href = if linked {
2697                        Some(p_href)
2698                    } else {
2699                        item.cta2_href
2700                            .as_deref()
2701                            .filter(|h| !h.is_empty() && *h != "-")
2702                    };
2703                    if stretch_href.is_some() {
2704                        scheme_class.push_str(" surfdoc-pg-tile-linked");
2705                    }
2706                    parts.push(format!(
2707                        "<div class=\"surfdoc-pg-tile{}\" data-product=\"{}\"{}>",
2708                        scheme_class,
2709                        escape_html(&slug),
2710                        style_attr,
2711                    ));
2712                    if let Some(sh) = stretch_href {
2713                        // aria-hidden + tabindex=-1: the visible pill is the
2714                        // accessible link; the stretch is mouse-only.
2715                        let s_rel = if sh.contains("://") { " target=\"_blank\" rel=\"noopener\"" } else { "" };
2716                        parts.push(format!(
2717                            "<a href=\"{}\" class=\"surfdoc-pg-tile-stretch\" tabindex=\"-1\" aria-hidden=\"true\"{}></a>",
2718                            escape_html(sh),
2719                            s_rel,
2720                        ));
2721                    }
2722                    let media = if is_image && !bg_style.is_empty() {
2723                        Some(format!(
2724                            "<div class=\"surfdoc-pg-tile-media\" style=\"{}\" aria-hidden=\"true\"></div>",
2725                            bg_style
2726                        ))
2727                    } else {
2728                        None
2729                    };
2730                    if copy_bottom {
2731                        if let Some(m) = &media {
2732                            parts.push(m.clone());
2733                        }
2734                    }
2735                    parts.push("<div class=\"surfdoc-pg-tile-copy\">".to_string());
2736                    if let Some(emblem) = &item.emblem {
2737                        // `icon:<lucide-name>` renders the built-in icon as an
2738                        // accent chip instead of an image (unknown name → omit).
2739                        if let Some(name) = emblem.strip_prefix("icon:") {
2740                            if let Some(svg) = get_icon(name.trim()) {
2741                                parts.push(format!(
2742                                    "<span class=\"surfdoc-pg-tile-icon\">{}</span>",
2743                                    svg
2744                                ));
2745                            }
2746                        } else {
2747                            parts.push(format!(
2748                                "<img src=\"{}\" alt=\"\" width=\"48\" height=\"48\" class=\"surfdoc-pg-tile-emblem\" loading=\"lazy\" onerror=\"this.classList.add('broken');this.onerror=null\">",
2749                                escape_html(emblem)
2750                            ));
2751                        }
2752                    }
2753                    parts.push(format!(
2754                        "<h3 class=\"surfdoc-pg-tile-name\">{}</h3>",
2755                        escape_html(&item.name)
2756                    ));
2757                    if let Some(tagline) = &item.tagline {
2758                        parts.push(format!(
2759                            "<p class=\"surfdoc-pg-tile-tagline\">{}</p>",
2760                            render_inline_markdown_phrasing(tagline)
2761                        ));
2762                    }
2763                    // CTA row: primary ("Learn more" -> the card href) + the
2764                    // optional secondary outline pill from the 6th field. A
2765                    // linkless card (href `-`/empty, no override) gets no
2766                    // primary pill — there is nowhere to go yet.
2767                    parts.push("<span class=\"surfdoc-pg-tile-actions\">".to_string());
2768                    if linked {
2769                        parts.push(format!(
2770                            "<a href=\"{}\" class=\"surfdoc-pg-tile-cta\"{}>{}</a>",
2771                            escape_html(p_href),
2772                            p_rel,
2773                            escape_html(p_label),
2774                        ));
2775                    }
2776                    if let (Some(l), Some(h)) = (&item.cta2_label, &item.cta2_href) {
2777                        let rel2 = if h.contains("://") { " target=\"_blank\" rel=\"noopener\"" } else { "" };
2778                        parts.push(format!(
2779                            "<a href=\"{}\" class=\"surfdoc-pg-tile-cta surfdoc-pg-tile-cta-secondary\"{}>{}</a>",
2780                            escape_html(h),
2781                            rel2,
2782                            escape_html(l),
2783                        ));
2784                    }
2785                    parts.push("</span>".to_string()); // .surfdoc-pg-tile-actions
2786                    parts.push("</div>".to_string()); // .surfdoc-pg-tile-copy
2787                    if !copy_bottom {
2788                        if let Some(m) = &media {
2789                            parts.push(m.clone());
2790                        }
2791                    }
2792                    parts.push("</div>".to_string()); // .surfdoc-pg-tile
2793                }
2794                parts.push("</div>".to_string()); // .surfdoc-pg-tiles-row
2795            }
2796            parts.push("</div>".to_string()); // .surfdoc-pg-tiles
2797            parts.push("</section>".to_string());
2798            parts.join("")
2799        }
2800
2801        Block::ProductGrid { groups, .. } => {
2802            const ARROW: &str = "<svg class=\"surfdoc-pg-arrow\" width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/><polyline points=\"12 5 19 12 12 19\"/></svg>";
2803            let mut parts = Vec::new();
2804            parts.push("<section class=\"surfdoc-product-grid\">".to_string());
2805            parts.push("<div class=\"surfdoc-pg-inner\">".to_string());
2806            for group in groups {
2807                if let Some(label) = &group.label {
2808                    parts.push(format!(
2809                        "<h3 class=\"surfdoc-pg-label\">{}</h3>",
2810                        render_inline_markdown_phrasing(label)
2811                    ));
2812                }
2813                parts.push("<div class=\"surfdoc-pg-row\">".to_string());
2814                for item in &group.items {
2815                    let slug: String = item
2816                        .name
2817                        .to_lowercase()
2818                        .chars()
2819                        .map(|c| if c.is_alphanumeric() { c } else { '-' })
2820                        .collect::<String>()
2821                        .split('-')
2822                        .filter(|s| !s.is_empty())
2823                        .collect::<Vec<_>>()
2824                        .join("-");
2825                    // Linkless card (href `-`/empty): a static div — no link,
2826                    // no arrow affordance, no hover invitation.
2827                    let linkless = item.href.is_empty() || item.href == "-";
2828                    if linkless {
2829                        parts.push(format!(
2830                            "<div class=\"surfdoc-pg-card surfdoc-pg-card-static\" data-product=\"{}\">",
2831                            escape_html(&slug),
2832                        ));
2833                    } else {
2834                        let external = item.href.contains("://");
2835                        let rel = if external {
2836                            " target=\"_blank\" rel=\"noopener\""
2837                        } else {
2838                            ""
2839                        };
2840                        parts.push(format!(
2841                            "<a href=\"{}\" class=\"surfdoc-pg-card\" data-product=\"{}\"{}>",
2842                            escape_html(&item.href),
2843                            escape_html(&slug),
2844                            rel
2845                        ));
2846                    }
2847                    parts.push("<div class=\"surfdoc-pg-body\">".to_string());
2848                    if let Some(emblem) = &item.emblem {
2849                        // `icon:<lucide-name>` — same grammar as tiles mode.
2850                        if let Some(name) = emblem.strip_prefix("icon:") {
2851                            if let Some(svg) = get_icon(name.trim()) {
2852                                parts.push(format!(
2853                                    "<span class=\"surfdoc-pg-icon\">{}</span>",
2854                                    svg
2855                                ));
2856                            }
2857                        } else {
2858                            parts.push(format!(
2859                                "<img src=\"{}\" alt=\"\" width=\"40\" height=\"40\" class=\"surfdoc-pg-emblem\" loading=\"lazy\" onerror=\"this.classList.add('broken');this.onerror=null\">",
2860                                escape_html(emblem)
2861                            ));
2862                        }
2863                    }
2864                    parts.push("<div class=\"surfdoc-pg-text\">".to_string());
2865                    parts.push(format!(
2866                        "<span class=\"surfdoc-pg-name\">{}</span>",
2867                        escape_html(&item.name)
2868                    ));
2869                    if let Some(tagline) = &item.tagline {
2870                        parts.push(format!(
2871                            "<p class=\"surfdoc-pg-tagline\">{}</p>",
2872                            render_inline_markdown_phrasing(tagline)
2873                        ));
2874                    }
2875                    parts.push("</div>".to_string()); // .surfdoc-pg-text
2876                    parts.push("</div>".to_string()); // .surfdoc-pg-body
2877                    if linkless {
2878                        parts.push("</div>".to_string());
2879                    } else {
2880                        parts.push(ARROW.to_string());
2881                        parts.push("</a>".to_string());
2882                    }
2883                }
2884                parts.push("</div>".to_string()); // .surfdoc-pg-row
2885            }
2886            parts.push("</div>".to_string()); // .surfdoc-pg-inner
2887            parts.push("</section>".to_string());
2888            parts.join("")
2889        }
2890
2891        Block::PostGrid { title, subtitle, items, .. } => {
2892            let mut parts = Vec::new();
2893            parts.push("<section class=\"surfdoc-post-grid\">".to_string());
2894            parts.push("<div class=\"surfdoc-pg2-inner\">".to_string());
2895            if title.is_some() || subtitle.is_some() {
2896                parts.push("<header class=\"surfdoc-post-grid-head\">".to_string());
2897                if let Some(t) = title {
2898                    parts.push(format!(
2899                        "<h1 class=\"surfdoc-post-grid-title\">{}</h1>",
2900                        render_inline_markdown_phrasing(t)
2901                    ));
2902                }
2903                if let Some(s) = subtitle {
2904                    parts.push(format!(
2905                        "<p class=\"surfdoc-post-grid-subtitle\">{}</p>",
2906                        render_inline_markdown_phrasing(s)
2907                    ));
2908                }
2909                parts.push("</header>".to_string());
2910            }
2911            parts.push("<div class=\"surfdoc-post-cards\">".to_string());
2912            for item in items {
2913                let rel = if item.external {
2914                    " target=\"_blank\" rel=\"noopener\""
2915                } else {
2916                    ""
2917                };
2918                parts.push(format!(
2919                    "<a class=\"surfdoc-post-card\" href=\"{}\"{}>",
2920                    escape_html(&item.href),
2921                    rel
2922                ));
2923                if let Some(image) = &item.image {
2924                    parts.push(format!(
2925                        "<span class=\"surfdoc-post-card-img\" style=\"--img:url('{}')\"></span>",
2926                        escape_html(image)
2927                    ));
2928                }
2929                parts.push("<span class=\"surfdoc-post-card-body\">".to_string());
2930                if let Some(meta) = &item.meta {
2931                    parts.push(format!(
2932                        "<span class=\"surfdoc-post-card-meta\">{}</span>",
2933                        render_inline_markdown_phrasing(meta)
2934                    ));
2935                }
2936                parts.push(format!(
2937                    "<h3 class=\"surfdoc-post-card-title\">{}</h3>",
2938                    escape_html(&item.title)
2939                ));
2940                if let Some(excerpt) = &item.excerpt {
2941                    parts.push(format!(
2942                        "<p class=\"surfdoc-post-card-excerpt\">{}</p>",
2943                        render_inline_markdown_phrasing(excerpt)
2944                    ));
2945                }
2946                parts.push(
2947                    "<span class=\"surfdoc-post-card-more\">Read more →</span>".to_string(),
2948                );
2949                parts.push("</span>".to_string()); // .surfdoc-post-card-body
2950                parts.push("</a>".to_string());
2951            }
2952            parts.push("</div>".to_string()); // .surfdoc-post-cards
2953            parts.push("</div>".to_string()); // .surfdoc-pg2-inner
2954            parts.push("</section>".to_string());
2955            parts.join("")
2956        }
2957
2958        Block::Gate { title, subtitle, action, field_label, submit_label, error, .. } => {
2959            let placeholder = field_label.as_deref().unwrap_or("Access code");
2960            let submit = submit_label.as_deref().unwrap_or("Continue");
2961            let mut parts = Vec::new();
2962            parts.push("<section class=\"surfdoc-gate\">".to_string());
2963            parts.push("<div class=\"surfdoc-gate-card\">".to_string());
2964            if let Some(t) = title {
2965                parts.push(format!(
2966                    "<h1 class=\"surfdoc-gate-title\">{}</h1>",
2967                    render_inline_markdown_phrasing(t)
2968                ));
2969            }
2970            if let Some(s) = subtitle {
2971                parts.push(format!(
2972                    "<p class=\"surfdoc-gate-subtitle\">{}</p>",
2973                    render_inline_markdown_phrasing(s)
2974                ));
2975            }
2976            if let Some(e) = error {
2977                parts.push(format!(
2978                    "<p class=\"surfdoc-gate-error\" role=\"alert\">{}</p>",
2979                    escape_html(e)
2980                ));
2981            }
2982            parts.push(format!(
2983                "<form method=\"post\" action=\"{}\"><input type=\"password\" name=\"code\" class=\"surfdoc-gate-input\" placeholder=\"{}\" required autofocus autocomplete=\"off\"><button class=\"surfdoc-gate-submit\" type=\"submit\">{}</button></form>",
2984                escape_html(action),
2985                escape_html(placeholder),
2986                escape_html(submit),
2987            ));
2988            parts.push("</div>".to_string()); // .surfdoc-gate-card
2989            parts.push("</section>".to_string());
2990            parts.join("")
2991        }
2992
2993        Block::Gallery { items, columns, .. } => {
2994            let cols = columns.unwrap_or(3);
2995            // Collect unique categories for filter
2996            let categories: Vec<&str> = {
2997                let mut cats: Vec<&str> = items.iter()
2998                    .filter_map(|i| i.category.as_deref())
2999                    .collect();
3000                cats.sort();
3001                cats.dedup();
3002                cats
3003            };
3004            let mut html = format!("<div class=\"surfdoc-gallery\" data-cols=\"{}\">", cols);
3005            if !categories.is_empty() {
3006                html.push_str("<div class=\"surfdoc-gallery-filters\">");
3007                html.push_str("<button class=\"filter-btn active\" data-filter=\"all\">All</button>");
3008                for cat in &categories {
3009                    html.push_str(&format!(
3010                        "<button class=\"filter-btn\" data-filter=\"{}\">{}</button>",
3011                        escape_html(cat),
3012                        escape_html(cat),
3013                    ));
3014                }
3015                html.push_str("</div>");
3016            }
3017            html.push_str("<div class=\"surfdoc-gallery-grid\">");
3018            for (i, item) in items.iter().enumerate() {
3019                let alt = item.alt.as_deref().unwrap_or("");
3020                let cat_attr = match &item.category {
3021                    Some(c) => format!(" data-category=\"{}\"", escape_html(c)),
3022                    None => String::new(),
3023                };
3024                html.push_str(&format!(
3025                    "<figure class=\"surfdoc-gallery-item\" data-index=\"{}\" style=\"cursor:pointer\" tabindex=\"0\" role=\"button\" aria-label=\"Open image in lightbox\"{cat_attr}>",
3026                    i
3027                ));
3028                html.push_str(&format!(
3029                    "<img src=\"{}\" alt=\"{}\" loading=\"lazy\" onerror=\"this.style.display='none';this.onerror=null\" />",
3030                    escape_html(&item.src),
3031                    escape_html(alt),
3032                ));
3033                if let Some(cap) = &item.caption {
3034                    html.push_str(&format!("<figcaption>{}</figcaption>", escape_html(cap)));
3035                }
3036                html.push_str("</figure>");
3037            }
3038            html.push_str("</div>");
3039            // Lightbox overlay
3040            html.push_str(concat!(
3041                "<div class=\"surfdoc-lightbox\" hidden>",
3042                "<button class=\"sl-close\" aria-label=\"Close\">&times;</button>",
3043                "<button class=\"sl-prev\" aria-label=\"Previous\">&#8249;</button>",
3044                "<button class=\"sl-next\" aria-label=\"Next\">&#8250;</button>",
3045                "<div class=\"sl-img-wrap\"><img class=\"sl-img\" src=\"\" alt=\"\" /></div>",
3046                "<div class=\"sl-caption\"></div>",
3047                "<div class=\"sl-counter\"></div>",
3048                "</div>",
3049            ));
3050            // Gallery filter JS
3051            if !categories.is_empty() {
3052                html.push_str(r#"<script>document.querySelectorAll('.surfdoc-gallery').forEach(g=>{g.querySelectorAll('.filter-btn').forEach(b=>{b.onclick=()=>{g.querySelectorAll('.filter-btn').forEach(e=>e.classList.remove('active'));b.classList.add('active');var f=b.dataset.filter;g.querySelectorAll('.surfdoc-gallery-item').forEach(i=>{i.style.display=f==='all'||i.dataset.category===f?'':'none'})}})})</script>"#);
3053            }
3054            // Lightbox JS
3055            html.push_str(r#"<script>document.querySelectorAll('.surfdoc-gallery').forEach(function(g){var lb=g.querySelector('.surfdoc-lightbox');if(!lb)return;var si=lb.querySelector('.sl-img'),sc=lb.querySelector('.sl-caption'),sn=lb.querySelector('.sl-counter'),items,idx;function vis(){return Array.prototype.filter.call(g.querySelectorAll('.surfdoc-gallery-item'),function(i){return i.style.display!=='none'})}function show(i){items=vis();idx=i;var f=items[idx],im=f.querySelector('img'),fc=f.querySelector('figcaption');si.src=im.src;si.alt=im.alt||'';sc.textContent=fc?fc.textContent:'';sn.textContent=(idx+1)+' / '+items.length;lb.hidden=false;document.body.style.overflow='hidden'}function hide(){lb.hidden=true;document.body.style.overflow=''}function nav(d){show((idx+d+items.length)%items.length)}g.querySelectorAll('.surfdoc-gallery-item').forEach(function(f){f.onclick=function(){var v=vis();show(v.indexOf(f))};f.onkeydown=function(e){if(e.key==='Enter'||e.key===' '){e.preventDefault();f.onclick()}}});lb.querySelector('.sl-close').onclick=hide;lb.querySelector('.sl-prev').onclick=function(){nav(-1)};lb.querySelector('.sl-next').onclick=function(){nav(1)};lb.onclick=function(e){if(e.target===lb)hide()};document.addEventListener('keydown',function(e){if(lb.hidden)return;if(e.key==='Escape')hide();if(e.key==='ArrowLeft')nav(-1);if(e.key==='ArrowRight')nav(1)});var tx;lb.addEventListener('touchstart',function(e){tx=e.touches[0].clientX});lb.addEventListener('touchend',function(e){var dx=e.changedTouches[0].clientX-tx;if(Math.abs(dx)>50)nav(dx>0?-1:1)})})</script>"#);
3056            html.push_str("</div>");
3057            html
3058        }
3059
3060        Block::Footer {
3061            sections, copyright, social, brand, brand_reg, brand_logo, tagline, ..
3062        } => {
3063            // Rich shell footer: brand column + column grid + copyright,
3064            // matching the cloudsurf.com chrome. Opt-in via brand/logo/tagline.
3065            if brand.is_some() || brand_logo.is_some() || tagline.is_some() {
3066                let mut html = String::from("<footer class=\"surfdoc-shell-footer\"><div class=\"surfdoc-shell-footer-inner\"><div class=\"surfdoc-shell-footer-grid\">");
3067                html.push_str("<div class=\"surfdoc-shell-footer-brand-col\"><a href=\"/\" class=\"surfdoc-shell-footer-brand\">");
3068                html.push_str(&shell_brand_inner(brand_logo.as_deref(), brand.as_deref(), *brand_reg, "surfdoc-shell-footer-emblem"));
3069                html.push_str("</a>");
3070                if let Some(t) = tagline {
3071                    html.push_str(&format!("<p class=\"surfdoc-shell-footer-tagline\">{}</p>", escape_html(t)));
3072                }
3073                html.push_str("</div>");
3074                for section in sections {
3075                    html.push_str("<div class=\"surfdoc-shell-footer-col\">");
3076                    html.push_str(&format!("<h4 class=\"surfdoc-shell-footer-heading\">{}</h4>", escape_html(&section.heading)));
3077                    for link in &section.links {
3078                        let tgt = if link.external { " target=\"_blank\" rel=\"noopener\"" } else { "" };
3079                        if link.href.is_empty() {
3080                            html.push_str(&format!("<span>{}</span>", escape_html(&link.label)));
3081                        } else {
3082                            html.push_str(&format!(
3083                                "<a href=\"{}\"{}>{}</a>",
3084                                escape_html(&link.href),
3085                                tgt,
3086                                escape_html(&link.label),
3087                            ));
3088                        }
3089                    }
3090                    html.push_str("</div>");
3091                }
3092                html.push_str("</div>");
3093                if !social.is_empty() {
3094                    html.push_str("<div class=\"surfdoc-shell-footer-social\">");
3095                    for link in social {
3096                        html.push_str(&format!(
3097                            "<a href=\"{}\" class=\"social-link\" aria-label=\"{}\">{}</a>",
3098                            escape_html(&link.href),
3099                            escape_html(&link.platform),
3100                            escape_html(&link.platform),
3101                        ));
3102                    }
3103                    html.push_str("</div>");
3104                }
3105                if let Some(cr) = copyright {
3106                    html.push_str(&format!("<p class=\"surfdoc-shell-footer-copyright\">{}</p>", escape_html(cr)));
3107                }
3108                html.push_str("</div></footer>");
3109                return html;
3110            }
3111            let mut html = String::from("<footer class=\"surfdoc-footer\">");
3112            if !sections.is_empty() {
3113                html.push_str("<div class=\"surfdoc-footer-sections\">");
3114                for section in sections {
3115                    html.push_str("<div class=\"surfdoc-footer-col\">");
3116                    html.push_str(&format!("<strong class=\"surfdoc-footer-col-head\">{}</strong>", escape_html(&section.heading)));
3117                    html.push_str("<ul>");
3118                    for link in &section.links {
3119                        if link.href.is_empty() {
3120                            html.push_str(&format!("<li>{}</li>", escape_html(&link.label)));
3121                        } else {
3122                            html.push_str(&format!(
3123                                "<li><a href=\"{}\">{}</a></li>",
3124                                escape_html(&link.href),
3125                                escape_html(&link.label),
3126                            ));
3127                        }
3128                    }
3129                    html.push_str("</ul></div>");
3130                }
3131                html.push_str("</div>");
3132            }
3133            if !social.is_empty() {
3134                html.push_str("<div class=\"surfdoc-footer-social\">");
3135                for link in social {
3136                    html.push_str(&format!(
3137                        "<a href=\"{}\" class=\"social-link\" aria-label=\"{}\">{}</a>",
3138                        escape_html(&link.href),
3139                        escape_html(&link.platform),
3140                        escape_html(&link.platform),
3141                    ));
3142                }
3143                html.push_str("</div>");
3144            }
3145            if let Some(cr) = copyright {
3146                html.push_str(&format!(
3147                    "<div class=\"surfdoc-footer-copyright\">{}</div>",
3148                    escape_html(cr),
3149                ));
3150            }
3151            html.push_str("</footer>");
3152            html
3153        }
3154
3155        Block::Details {
3156            title,
3157            open,
3158            content,
3159            ..
3160        } => {
3161            let open_attr = if *open { " open" } else { "" };
3162            let summary = title.as_deref().unwrap_or("Details");
3163            format!(
3164                "<details class=\"surfdoc-details\"{open_attr}>\
3165                 <summary class=\"surfdoc-details-summary\">{}</summary>\
3166                 <div class=\"surfdoc-details-body\">{}</div>\
3167                 </details>",
3168                escape_html(summary),
3169                render_markdown(content),
3170            )
3171        }
3172
3173        Block::Divider { label, .. } => {
3174            match label {
3175                Some(text) => format!(
3176                    "<div class=\"surfdoc-divider\" role=\"separator\">\
3177                     <span>{}</span>\
3178                     </div>",
3179                    escape_html(text)
3180                ),
3181                None => "<hr class=\"surfdoc-divider-plain\" />".to_string(),
3182            }
3183        }
3184
3185        Block::Hero {
3186            headline,
3187            subtitle,
3188            badge,
3189            align,
3190            image,
3191            image_alt,
3192            layout,
3193            transparent,
3194            buttons,
3195            content: _,
3196            ..
3197        } => {
3198            // `layout=cover` makes the image a full-bleed hero BACKGROUND (with
3199            // a darkening overlay + white text, see surfdoc.css §50) — the
3200            // home-page "header" look. `layout=stacked` forces the image above
3201            // the headline; otherwise the legacy align-driven placement applies
3202            // (centered → above, left → side).
3203            let cover = layout.as_deref() == Some("cover") && image.is_some();
3204            let stacked = layout.as_deref() == Some("stacked");
3205            let image_above = !cover && (stacked || align != "left");
3206            let image_side = !cover && !image_above;
3207            let align_cls = if align == "left" { " surfdoc-hero-left" } else { "" };
3208            let layout_cls = layout
3209                .as_deref()
3210                .map(|l| format!(" surfdoc-hero-{}", l))
3211                .unwrap_or_default();
3212            let transparent_cls = if *transparent { " surfdoc-hero-transparent" } else { "" };
3213            let alt = image_alt.as_deref().unwrap_or("");
3214            let cover_style = if cover {
3215                format!(
3216                    " style=\"background-image:url('{}')\"",
3217                    escape_html(image.as_deref().unwrap_or(""))
3218                )
3219            } else {
3220                String::new()
3221            };
3222            let mut parts = Vec::new();
3223            parts.push(format!("<section class=\"surfdoc-hero{}{}{}\"{}>", align_cls, layout_cls, transparent_cls, cover_style));
3224            parts.push("<div class=\"surfdoc-hero-inner\">".to_string());
3225            // Centered/stacked layout: image above text (logo/product image)
3226            if image_above {
3227                if let Some(img) = image {
3228                    parts.push(format!("<div class=\"surfdoc-hero-image\"><img src=\"{}\" alt=\"{}\" onerror=\"this.classList.add('broken');this.onerror=null\"></div>", escape_html(img), escape_html(alt)));
3229                }
3230            }
3231            if let Some(b) = badge {
3232                parts.push(format!("<span class=\"surfdoc-hero-badge\">{}</span>", escape_html(b)));
3233            }
3234            if let Some(h) = headline {
3235                parts.push(format!("<h1 class=\"surfdoc-hero-headline\">{}</h1>", render_inline_markdown_phrasing(h)));
3236            }
3237            if let Some(s) = subtitle {
3238                parts.push(format!("<p class=\"surfdoc-hero-subtitle\">{}</p>", render_inline_markdown_phrasing(s)));
3239            }
3240            if !buttons.is_empty() {
3241                parts.push("<div class=\"surfdoc-hero-actions\">".to_string());
3242                for btn in buttons {
3243                    let cls = if btn.primary { "surfdoc-hero-btn surfdoc-hero-btn-primary" } else { "surfdoc-hero-btn surfdoc-hero-btn-secondary" };
3244                    let target = if btn.external { " target=\"_blank\" rel=\"noopener\"" } else { "" };
3245                    parts.push(format!("<a href=\"{}\" class=\"{}\"{}>{}</a>", escape_html(&btn.href), cls, target, escape_html(&btn.label)));
3246                }
3247                parts.push("</div>".to_string());
3248            }
3249            parts.push("</div>".to_string());
3250            // Left-aligned layout: image to the side (side-by-side)
3251            if image_side {
3252                if let Some(img) = image {
3253                    parts.push(format!("<div class=\"surfdoc-hero-image-side\"><img src=\"{}\" alt=\"{}\" onerror=\"this.classList.add('broken');this.onerror=null\"></div>", escape_html(img), escape_html(alt)));
3254                }
3255            }
3256            parts.push("</section>".to_string());
3257            parts.join("")
3258        }
3259
3260        Block::Features { cards, cols, .. } => {
3261            let col_attr = cols.map(|c| format!(" data-cols=\"{}\"", c)).unwrap_or_default();
3262            let mut parts = Vec::new();
3263            parts.push(format!("<div class=\"surfdoc-features\"{}>", col_attr));
3264            for card in cards {
3265                parts.push("<div class=\"surfdoc-feature-card\">".to_string());
3266                if let Some(icon) = &card.icon {
3267                    if let Some(svg) = get_icon(icon) {
3268                        parts.push(format!("<span class=\"surfdoc-feature-icon\">{}</span>", svg));
3269                    }
3270                }
3271                parts.push(format!("<h3 class=\"surfdoc-feature-title\">{}</h3>", render_inline_markdown_phrasing(&card.title)));
3272                if !card.body.is_empty() {
3273                    parts.push(format!("<p class=\"surfdoc-feature-body\">{}</p>", render_inline_markdown_phrasing(&card.body)));
3274                }
3275                if let (Some(label), Some(href)) = (&card.link_label, &card.link_href) {
3276                    parts.push(format!("<a href=\"{}\" class=\"surfdoc-feature-link\">{} \u{2192}</a>", escape_html(href), escape_html(label)));
3277                }
3278                parts.push("</div>".to_string());
3279            }
3280            parts.push("</div>".to_string());
3281            parts.join("")
3282        }
3283
3284        Block::Steps { steps, .. } => {
3285            let mut parts = Vec::new();
3286            parts.push("<ol class=\"surfdoc-steps\">".to_string());
3287            for (i, step) in steps.iter().enumerate() {
3288                parts.push("<li class=\"surfdoc-step\">".to_string());
3289                parts.push(format!("<span class=\"surfdoc-step-number\">{}</span>", i + 1));
3290                parts.push("<div class=\"surfdoc-step-content\">".to_string());
3291                let time_html = step.time.as_ref().map(|t| format!("<span class=\"surfdoc-step-time\">{}</span>", escape_html(t))).unwrap_or_default();
3292                parts.push(format!("<h3 class=\"surfdoc-step-title\">{}{}</h3>", render_inline_markdown_phrasing(&step.title), time_html));
3293                if !step.body.is_empty() {
3294                    parts.push(format!("<p class=\"surfdoc-step-body\">{}</p>", render_inline_markdown_phrasing(&step.body)));
3295                }
3296                parts.push("</div>".to_string());
3297                parts.push("</li>".to_string());
3298            }
3299            parts.push("</ol>".to_string());
3300            parts.join("")
3301        }
3302
3303        Block::Stats { items, .. } => {
3304            let mut parts = Vec::new();
3305            parts.push("<div class=\"surfdoc-stats\">".to_string());
3306            for item in items {
3307                let style = item.color.as_ref().map(|c| format!(" style=\"color:{}\"", escape_html(c))).unwrap_or_default();
3308                parts.push(format!(
3309                    "<div class=\"surfdoc-stat\"><span class=\"surfdoc-stat-value\"{}>{}</span><span class=\"surfdoc-stat-label\">{}</span></div>",
3310                    style, escape_html(&item.value), escape_html(&item.label)
3311                ));
3312            }
3313            parts.push("</div>".to_string());
3314            parts.join("")
3315        }
3316
3317        Block::Comparison {
3318            headers,
3319            rows,
3320            highlight,
3321            ..
3322        } => {
3323            let mut parts = Vec::new();
3324            parts.push("<div class=\"surfdoc-table-wrap\"><table class=\"surfdoc-comparison\">".to_string());
3325            parts.push("<thead><tr>".to_string());
3326            for h in headers {
3327                let cls = if highlight.as_deref() == Some(h.as_str()) { " class=\"surfdoc-comparison-highlight\"" } else { "" };
3328                parts.push(format!("<th{}>{}</th>", cls, render_cell_inline_markdown(h)));
3329            }
3330            parts.push("</tr></thead>".to_string());
3331            parts.push("<tbody>".to_string());
3332            for row in rows {
3333                parts.push("<tr>".to_string());
3334                for (i, cell) in row.iter().enumerate() {
3335                    let cls = if headers.get(i).and_then(|h| highlight.as_ref().map(|hi| h == hi)).unwrap_or(false) {
3336                        " class=\"surfdoc-comparison-highlight\""
3337                    } else {
3338                        ""
3339                    };
3340                    let rendered = comparison_cell(cell);
3341                    parts.push(format!("<td{}>{}</td>", cls, rendered));
3342                }
3343                parts.push("</tr>".to_string());
3344            }
3345            parts.push("</tbody></table></div>".to_string());
3346            parts.join("")
3347        }
3348
3349        Block::Logo { src, alt, size, .. } => {
3350            let alt_attr = alt.as_ref().map(|a| escape_html(a)).unwrap_or_default();
3351            let style = size.map(|s| format!(" style=\"max-width:{}px\"", s)).unwrap_or_default();
3352            format!(
3353                "<div class=\"surfdoc-logo\"><img src=\"{}\" alt=\"{}\"{}></div>",
3354                escape_html(src), alt_attr, style
3355            )
3356        }
3357
3358        Block::Toc { depth, entries, .. } => {
3359            if entries.is_empty() {
3360                format!("<nav class=\"surfdoc-toc\" data-depth=\"{}\"></nav>", depth)
3361            } else {
3362                let items: Vec<(u32, String, String)> = entries
3363                    .iter()
3364                    .map(|e| (e.level, e.id.clone(), e.text.clone()))
3365                    .collect();
3366                let refs: Vec<&(u32, String, String)> = items.iter().collect();
3367                format!(
3368                    "<nav class=\"surfdoc-toc\" data-depth=\"{}\"><div class=\"surfdoc-toc-label\">Contents</div>{}</nav>",
3369                    depth,
3370                    toc_nested_ol(&refs)
3371                )
3372            }
3373        }
3374
3375        Block::BeforeAfter {
3376            before_items,
3377            after_items,
3378            transition,
3379            ..
3380        } => {
3381            let mut parts = Vec::new();
3382            parts.push("<div class=\"surfdoc-before-after\">".to_string());
3383            parts.push("<div class=\"surfdoc-ba-before\">".to_string());
3384            parts.push("<h3 class=\"surfdoc-ba-heading\">Before</h3>".to_string());
3385            for item in before_items {
3386                parts.push(format!(
3387                    "<div class=\"surfdoc-ba-item\"><span class=\"surfdoc-ba-dot surfdoc-ba-dot-red\"></span><strong>{}</strong><span>{}</span></div>",
3388                    escape_html(&item.label),
3389                    escape_html(&item.detail)
3390                ));
3391            }
3392            parts.push("</div>".to_string());
3393            if let Some(t) = transition {
3394                parts.push(format!(
3395                    "<div class=\"surfdoc-ba-transition\"><span class=\"surfdoc-ba-line\"></span><span class=\"surfdoc-ba-label\">{}</span><span class=\"surfdoc-ba-line\"></span></div>",
3396                    escape_html(t)
3397                ));
3398            }
3399            parts.push("<div class=\"surfdoc-ba-after\">".to_string());
3400            parts.push("<h3 class=\"surfdoc-ba-heading\">After</h3>".to_string());
3401            for item in after_items {
3402                parts.push(format!(
3403                    "<div class=\"surfdoc-ba-item surfdoc-ba-item-green\"><span class=\"surfdoc-ba-dot surfdoc-ba-dot-green\"></span><strong>{}</strong><span>{}</span></div>",
3404                    escape_html(&item.label),
3405                    escape_html(&item.detail)
3406                ));
3407            }
3408            parts.push("</div>".to_string());
3409            parts.push("</div>".to_string());
3410            parts.join("")
3411        }
3412
3413        Block::Pipeline { steps, .. } => {
3414            let mut parts = Vec::new();
3415            parts.push("<div class=\"surfdoc-pipeline\">".to_string());
3416            for (i, step) in steps.iter().enumerate() {
3417                if i > 0 {
3418                    parts.push("<span class=\"surfdoc-pipeline-arrow\">\u{2192}</span>".to_string());
3419                }
3420                parts.push("<div class=\"surfdoc-pipeline-step\">".to_string());
3421                parts.push(format!("<strong class=\"surfdoc-pipeline-label\">{}</strong>", escape_html(&step.label)));
3422                if let Some(desc) = &step.description {
3423                    parts.push(format!("<span class=\"surfdoc-pipeline-desc\">{}</span>", escape_html(desc)));
3424                }
3425                parts.push("</div>".to_string());
3426            }
3427            parts.push("</div>".to_string());
3428            parts.join("")
3429        }
3430
3431        Block::Section {
3432            bg,
3433            headline,
3434            subtitle,
3435            children,
3436            ..
3437        } => {
3438            let bg_cls = bg.as_ref().map(|b| format!(" section-{}", escape_html(b))).unwrap_or_default();
3439            let mut html = format!("<section class=\"surfdoc-section{bg_cls}\">");
3440            html.push_str("<div class=\"surfdoc-section-inner\">");
3441            if headline.is_some() || subtitle.is_some() {
3442                html.push_str("<div class=\"surfdoc-section-header\">");
3443                if let Some(h) = headline {
3444                    html.push_str(&format!("<h2>{}</h2>", render_inline_markdown_phrasing(h)));
3445                }
3446                if let Some(s) = subtitle {
3447                    html.push_str(&format!("<p>{}</p>", render_inline_markdown_phrasing(s)));
3448                }
3449                html.push_str("</div>");
3450            }
3451            for child in children {
3452                html.push_str(&render_block(child));
3453            }
3454            html.push_str("</div>");
3455            html.push_str("</section>");
3456            html
3457        }
3458
3459        Block::ProductCard {
3460            title,
3461            subtitle,
3462            badge,
3463            badge_color,
3464            body,
3465            features,
3466            cta_label,
3467            cta_href,
3468            ..
3469        } => {
3470            let mut parts = Vec::new();
3471            parts.push("<div class=\"surfdoc-product-card\">".to_string());
3472            parts.push("<div class=\"surfdoc-product-header\">".to_string());
3473            parts.push("<div class=\"surfdoc-product-titles\">".to_string());
3474            parts.push(format!("<h3 class=\"surfdoc-product-title\">{}</h3>", escape_html(title)));
3475            if let Some(s) = subtitle {
3476                parts.push(format!("<p class=\"surfdoc-product-subtitle\">{}</p>", render_inline_markdown_phrasing(s)));
3477            }
3478            parts.push("</div>".to_string());
3479            if let Some(b) = badge {
3480                let color_cls = badge_color.as_ref().map(|c| format!(" surfdoc-badge-{}", escape_html(c))).unwrap_or_default();
3481                parts.push(format!("<span class=\"surfdoc-badge{color_cls}\">{}</span>", escape_html(b)));
3482            }
3483            parts.push("</div>".to_string());
3484            if !body.is_empty() {
3485                parts.push(format!("<div class=\"surfdoc-product-body\">{}</div>", render_markdown(body)));
3486            }
3487            if !features.is_empty() {
3488                parts.push("<ul class=\"surfdoc-product-features\">".to_string());
3489                for f in features {
3490                    parts.push(format!("<li>{}</li>", render_inline_markdown_phrasing(f)));
3491                }
3492                parts.push("</ul>".to_string());
3493            }
3494            if let (Some(label), Some(href)) = (cta_label, cta_href) {
3495                parts.push(format!(
3496                    "<a href=\"{}\" class=\"surfdoc-product-cta\">{}</a>",
3497                    escape_html(href),
3498                    escape_html(label)
3499                ));
3500            }
3501            parts.push("</div>".to_string());
3502            parts.join("")
3503        }
3504
3505        // ----- App description blocks -----
3506
3507        Block::List {
3508            source,
3509            display,
3510            item_template,
3511            filters,
3512            sort,
3513            preload,
3514            ..
3515        } => {
3516            let display_cls = match display {
3517                ListDisplay::Card => "card",
3518                ListDisplay::Table => "table",
3519                ListDisplay::Compact => "compact",
3520            };
3521            let mut html = format!(
3522                "<div class=\"surfdoc-list surfdoc-list-{}\" data-surf-source=\"{}\"",
3523                display_cls,
3524                escape_html(source),
3525            );
3526            if *preload {
3527                html.push_str(" data-surf-preload");
3528            }
3529            if let Some(s) = sort {
3530                html.push_str(&format!(
3531                    " data-surf-sort=\"{}\" data-surf-sort-dir=\"{}\"",
3532                    escape_html(&s.field),
3533                    if s.descending { "desc" } else { "asc" },
3534                ));
3535            }
3536            html.push('>');
3537            // Render filter controls
3538            if !filters.is_empty() {
3539                html.push_str("<div class=\"surfdoc-list-filters\">");
3540                for f in filters {
3541                    html.push_str(&format!(
3542                        "<label class=\"surfdoc-list-filter\">{}</label>",
3543                        escape_html(&f.field),
3544                    ));
3545                }
3546                html.push_str("</div>");
3547            }
3548            // Item template stored as data attribute for runtime
3549            if !item_template.is_empty() {
3550                html.push_str(&format!(
3551                    "<template class=\"surfdoc-list-item-template\">{}</template>",
3552                    escape_html(item_template),
3553                ));
3554            }
3555            // Static preview: sample cards so the block looks complete without a live API
3556            html.push_str("<div class=\"surfdoc-list-items\" aria-live=\"polite\">\
3557              <div class=\"surfdoc-list-item surfdoc-list-item-sample\">\
3558                <strong class=\"surfdoc-list-item-title\">Design API schema</strong>\
3559                <span class=\"surfdoc-list-item-meta\">Status: In Progress &middot; Assigned: Taylor</span>\
3560              </div>\
3561              <div class=\"surfdoc-list-item surfdoc-list-item-sample\">\
3562                <strong class=\"surfdoc-list-item-title\">Write integration tests</strong>\
3563                <span class=\"surfdoc-list-item-meta\">Status: Todo &middot; Assigned: Alex</span>\
3564              </div>\
3565              <div class=\"surfdoc-list-item surfdoc-list-item-sample\">\
3566                <strong class=\"surfdoc-list-item-title\">Deploy to staging</strong>\
3567                <span class=\"surfdoc-list-item-meta\">Status: Done &middot; Assigned: Jordan</span>\
3568              </div>\
3569            </div>");
3570            html.push_str("</div>");
3571            html
3572        }
3573
3574        Block::Board {
3575            source,
3576            columns,
3577            card_template,
3578            preload,
3579            ..
3580        } => {
3581            let mut html = format!(
3582                "<div class=\"surfdoc-board surfdoc-board-preview\" data-surf-source=\"{}\"",
3583                escape_html(source),
3584            );
3585            if *preload {
3586                html.push_str(" data-surf-preload");
3587            }
3588            html.push('>');
3589            if let Some(tmpl) = card_template {
3590                html.push_str(&format!(
3591                    "<template class=\"surfdoc-board-card-template\">{}</template>",
3592                    escape_html(tmpl),
3593                ));
3594            }
3595            // Sample cards per column for preview state
3596            let sample_cards: &[(&str, &[&str])] = &[
3597                ("Todo", &["Design API schema", "Write integration tests"]),
3598                ("In Progress", &["Build board renderer"]),
3599                ("Done", &["Set up CI/CD", "Deploy to staging"]),
3600            ];
3601            html.push_str("<div class=\"surfdoc-board-columns\">");
3602            let col_list: Vec<&str> = columns.iter().map(|c| c.as_str()).collect();
3603            // If author gave explicit columns use them; otherwise fall back to sample labels
3604            let effective_cols: Vec<(&str, &[&str])> = if col_list.is_empty() {
3605                sample_cards.to_vec()
3606            } else {
3607                col_list.iter().enumerate().map(|(i, &col)| {
3608                    let cards = sample_cards.get(i).map(|&(_, cards)| cards).unwrap_or(&[]);
3609                    (col, cards)
3610                }).collect()
3611            };
3612            for (col, cards) in &effective_cols {
3613                html.push_str(&format!(
3614                    "<div class=\"surfdoc-board-column\" data-column=\"{}\"><h3 class=\"surfdoc-board-column-title\">{}</h3><div class=\"surfdoc-board-cards\" aria-live=\"polite\">",
3615                    escape_html(col),
3616                    escape_html(col),
3617                ));
3618                for card in *cards {
3619                    html.push_str(&format!(
3620                        "<div class=\"surfdoc-board-card surfdoc-board-card-sample\">{}</div>",
3621                        escape_html(card),
3622                    ));
3623                }
3624                html.push_str("</div></div>");
3625            }
3626            html.push_str("</div></div>");
3627            html
3628        }
3629
3630        Block::Action {
3631            method,
3632            target,
3633            label,
3634            fields,
3635            confirm,
3636            ..
3637        } => {
3638            let method_str = match method {
3639                HttpMethod::Get => "get",
3640                HttpMethod::Post => "post",
3641                HttpMethod::Put => "put",
3642                HttpMethod::Patch => "patch",
3643                HttpMethod::Delete => "delete",
3644            };
3645            let mut html = format!(
3646                "<form class=\"surfdoc-action\" method=\"{}\" action=\"{}\" data-surf-method=\"{}\" data-surf-action=\"{}\"",
3647                method_str,
3648                escape_html(target),
3649                method_str,
3650                escape_html(target),
3651            );
3652            if let Some(c) = confirm {
3653                html.push_str(&format!(" data-surf-confirm=\"{}\"", escape_html(c)));
3654            }
3655            html.push('>');
3656            for field in fields {
3657                let req = if field.required { " required" } else { "" };
3658                let req_star = if field.required { " <span class=\"required\">*</span>" } else { "" };
3659                html.push_str(&format!(
3660                    "<div class=\"surfdoc-form-field\"><label>{}{}</label>",
3661                    escape_html(&field.label),
3662                    req_star,
3663                ));
3664                match field.field_type {
3665                    FormFieldType::Textarea => {
3666                        let ph = field.placeholder.as_deref().unwrap_or("");
3667                        html.push_str(&format!(
3668                            "<textarea name=\"{}\" placeholder=\"{}\" rows=\"4\"{}></textarea>",
3669                            escape_html(&field.name),
3670                            escape_html(ph),
3671                            req,
3672                        ));
3673                    }
3674                    FormFieldType::Select => {
3675                        html.push_str(&format!(
3676                            "<select name=\"{}\"{}>",
3677                            escape_html(&field.name),
3678                            req,
3679                        ));
3680                        html.push_str("<option value=\"\">Select...</option>");
3681                        for opt in &field.options {
3682                            html.push_str(&format!(
3683                                "<option value=\"{}\">{}</option>",
3684                                escape_html(opt),
3685                                escape_html(opt),
3686                            ));
3687                        }
3688                        html.push_str("</select>");
3689                    }
3690                    _ => {
3691                        let input_type = match field.field_type {
3692                            FormFieldType::Email => "email",
3693                            FormFieldType::Tel => "tel",
3694                            FormFieldType::Date => "date",
3695                            FormFieldType::Number => "number",
3696                            FormFieldType::Password => "password",
3697                            _ => "text",
3698                        };
3699                        let ph = field.placeholder.as_deref().unwrap_or("");
3700                        html.push_str(&format!(
3701                            "<input type=\"{}\" name=\"{}\" placeholder=\"{}\"{}/>",
3702                            input_type,
3703                            escape_html(&field.name),
3704                            escape_html(ph),
3705                            req,
3706                        ));
3707                    }
3708                }
3709                html.push_str("</div>");
3710            }
3711            html.push_str(&format!(
3712                "<button type=\"submit\" class=\"surfdoc-cta surfdoc-cta-primary\">{}</button>",
3713                escape_html(label),
3714            ));
3715            html.push_str("</form>");
3716            html
3717        }
3718
3719        Block::FilterBar {
3720            target_selector,
3721            fields,
3722            ..
3723        } => {
3724            let mut html = format!(
3725                "<div class=\"surfdoc-filter-bar\" data-surf-target=\"{}\">",
3726                escape_html(target_selector),
3727            );
3728            if fields.is_empty() {
3729                // Static preview: sample filter chips so the bar is never empty
3730                html.push_str("\
3731                  <label class=\"surfdoc-filter-field\">Status\
3732                    <select name=\"status\">\
3733                      <option value=\"all\">All</option>\
3734                      <option value=\"todo\">Todo</option>\
3735                      <option value=\"done\">Done</option>\
3736                    </select>\
3737                  </label>\
3738                  <label class=\"surfdoc-filter-field\">Assignee\
3739                    <select name=\"assignee\">\
3740                      <option value=\"all\">All</option>\
3741                      <option value=\"me\">Me</option>\
3742                    </select>\
3743                  </label>");
3744            } else {
3745                for field in fields {
3746                    html.push_str(&format!(
3747                        "<label class=\"surfdoc-filter-field\">{}<select name=\"{}\">",
3748                        escape_html(&field.label),
3749                        escape_html(&field.name),
3750                    ));
3751                    for opt in &field.options {
3752                        html.push_str(&format!(
3753                            "<option value=\"{}\">{}</option>",
3754                            escape_html(opt),
3755                            escape_html(opt),
3756                        ));
3757                    }
3758                    html.push_str("</select></label>");
3759                }
3760            }
3761            html.push_str("</div>");
3762            html
3763        }
3764
3765        Block::Search {
3766            source,
3767            placeholder,
3768            ..
3769        } => {
3770            let ph = placeholder.as_deref().unwrap_or("Search...");
3771            format!(
3772                "<div class=\"surfdoc-search\" data-surf-source=\"{}\"><input type=\"search\" placeholder=\"{}\" aria-label=\"{}\" autocomplete=\"off\"/><div class=\"surfdoc-search-results\" aria-live=\"polite\"></div></div>",
3773                escape_html(source),
3774                escape_html(ph),
3775                escape_html(ph),
3776            )
3777        }
3778
3779        Block::Dashboard {
3780            source, refresh, ..
3781        } => {
3782            let mut html = format!(
3783                "<div class=\"surfdoc-dashboard surfdoc-dashboard-preview\" data-surf-source=\"{}\"",
3784                escape_html(source),
3785            );
3786            if let Some(r) = refresh {
3787                html.push_str(&format!(" data-surf-refresh=\"{}\"", r));
3788            }
3789            html.push_str(">\
3790                <div class=\"surfdoc-dashboard-grid\" aria-live=\"polite\">\
3791                  <div class=\"surfdoc-dashboard-tile\">\
3792                    <span class=\"surfdoc-dashboard-tile-value\">1,284</span>\
3793                    <span class=\"surfdoc-dashboard-tile-label\">Active users</span>\
3794                    <span class=\"surfdoc-dashboard-tile-trend surfdoc-trend--up\">&uarr; 12%</span>\
3795                  </div>\
3796                  <div class=\"surfdoc-dashboard-tile\">\
3797                    <span class=\"surfdoc-dashboard-tile-value\">$4,820</span>\
3798                    <span class=\"surfdoc-dashboard-tile-label\">MRR</span>\
3799                    <span class=\"surfdoc-dashboard-tile-trend surfdoc-trend--up\">&uarr; 8%</span>\
3800                  </div>\
3801                  <div class=\"surfdoc-dashboard-tile\">\
3802                    <span class=\"surfdoc-dashboard-tile-value\">98.2%</span>\
3803                    <span class=\"surfdoc-dashboard-tile-label\">Uptime</span>\
3804                    <span class=\"surfdoc-dashboard-tile-trend surfdoc-trend--flat\">&mdash;</span>\
3805                  </div>\
3806                  <div class=\"surfdoc-dashboard-tile\">\
3807                    <span class=\"surfdoc-dashboard-tile-value\">142ms</span>\
3808                    <span class=\"surfdoc-dashboard-tile-label\">P95 latency</span>\
3809                    <span class=\"surfdoc-dashboard-tile-trend surfdoc-trend--down\">&darr; 5%</span>\
3810                  </div>\
3811                </div>\
3812                </div>");
3813            html
3814        }
3815
3816        Block::ChatInput {
3817            action,
3818            placeholder,
3819            modes,
3820            ..
3821        } => {
3822            let ph = placeholder.as_deref().unwrap_or("Type a message...");
3823            let mut html = format!(
3824                "<div class=\"surfdoc-chat-input\" data-surf-action=\"{}\">",
3825                escape_html(action),
3826            );
3827            if !modes.is_empty() {
3828                html.push_str("<div class=\"surfdoc-chat-modes\">");
3829                for (i, mode) in modes.iter().enumerate() {
3830                    let active = if i == 0 { " active" } else { "" };
3831                    html.push_str(&format!(
3832                        "<button type=\"button\" class=\"surfdoc-chat-mode{}\" data-mode=\"{}\">{}</button>",
3833                        active,
3834                        escape_html(mode),
3835                        escape_html(mode),
3836                    ));
3837                }
3838                html.push_str("</div>");
3839            }
3840            html.push_str(&format!(
3841                "<form class=\"surfdoc-chat-form\"><input type=\"text\" placeholder=\"{}\" aria-label=\"{}\" autocomplete=\"off\"/><button type=\"submit\">Send</button></form>",
3842                escape_html(ph),
3843                escape_html(ph),
3844            ));
3845            html.push_str("</div>");
3846            html
3847        }
3848
3849        Block::Feed {
3850            source, stream, ..
3851        } => {
3852            let stream_attr = if *stream { " data-surf-stream" } else { "" };
3853            format!(
3854                "<div class=\"surfdoc-feed surfdoc-feed-preview\" data-surf-source=\"{}\"{}>\
3855                 <div class=\"surfdoc-feed-items\" aria-live=\"polite\">\
3856                   <div class=\"surfdoc-feed-item\"><span class=\"surfdoc-feed-avatar\">JD</span><div class=\"surfdoc-feed-body\"><span class=\"surfdoc-feed-name\">Jane Doe</span><span class=\"surfdoc-feed-text\">Merged PR #142: Add board renderer</span><span class=\"surfdoc-feed-time\">2m ago</span></div></div>\
3857                   <div class=\"surfdoc-feed-item\"><span class=\"surfdoc-feed-avatar\">BM</span><div class=\"surfdoc-feed-body\"><span class=\"surfdoc-feed-name\">Brady M</span><span class=\"surfdoc-feed-text\">Deployed v1.4.0 to production</span><span class=\"surfdoc-feed-time\">18m ago</span></div></div>\
3858                   <div class=\"surfdoc-feed-item\"><span class=\"surfdoc-feed-avatar\">AI</span><div class=\"surfdoc-feed-body\"><span class=\"surfdoc-feed-name\">Mako</span><span class=\"surfdoc-feed-text\">Generated 3 task descriptions</span><span class=\"surfdoc-feed-time\">1h ago</span></div></div>\
3859                 </div>\
3860                 </div>",
3861                escape_html(source),
3862                stream_attr,
3863            )
3864        }
3865
3866        Block::Store {
3867            title,
3868            currency,
3869            items,
3870            ..
3871        } => {
3872            let js = |s: &str| -> String {
3873                let mut o = String::from("\"");
3874                for c in s.chars() {
3875                    match c {
3876                        '"' => o.push_str("\\\""),
3877                        '\\' => o.push_str("\\\\"),
3878                        '\n' => o.push_str("\\n"),
3879                        '\r' => o.push_str("\\r"),
3880                        '\t' => o.push_str("\\t"),
3881                        '<' => o.push_str("\\u003c"),
3882                        '&' => o.push_str("\\u0026"),
3883                        c if (c as u32) < 0x20 => o.push_str(&format!("\\u{:04x}", c as u32)),
3884                        c => o.push(c),
3885                    }
3886                }
3887                o.push('"');
3888                o
3889            };
3890            let opt = |v: &Option<String>| -> String {
3891                v.as_deref().map(js).unwrap_or_else(|| "null".to_string())
3892            };
3893            let cur = currency.as_deref().unwrap_or("$");
3894            let items_json = items
3895                .iter()
3896                .map(|it| {
3897                    format!(
3898                        "{{\"name\":{},\"price\":{},\"blurb\":{},\"badge\":{},\"category\":{}}}",
3899                        js(&it.name),
3900                        js(&it.price),
3901                        opt(&it.blurb),
3902                        opt(&it.badge),
3903                        opt(&it.category),
3904                    )
3905                })
3906                .collect::<Vec<_>>()
3907                .join(",");
3908            let data_json = format!("{{\"currency\":{},\"items\":[{items_json}]}}", js(cur));
3909
3910            let mut html = String::from("<div class=\"surfdoc-store\" data-store>");
3911            if let Some(t) = title {
3912                html.push_str(&format!(
3913                    "<div class=\"surfdoc-store-head\">{}</div>",
3914                    escape_html(t)
3915                ));
3916            }
3917            html.push_str(
3918                "<div class=\"surfdoc-store-layout\">\
3919                   <div class=\"surfdoc-store-main\">\
3920                     <div class=\"surfdoc-store-filters\" data-st-filters></div>\
3921                     <div class=\"surfdoc-store-grid\" data-st-grid></div>\
3922                   </div>\
3923                   <aside class=\"surfdoc-store-cart\" data-st-cart aria-label=\"Cart\">\
3924                     <div class=\"surfdoc-store-cart-head\">Your cart</div>\
3925                     <div class=\"surfdoc-store-cart-items\" data-st-items aria-live=\"polite\"></div>\
3926                     <div class=\"surfdoc-store-cart-foot\">\
3927                       <div class=\"surfdoc-store-total\">Total <span data-st-total></span></div>\
3928                       <button type=\"button\" class=\"surfdoc-store-checkout-btn\" data-st-checkout disabled>Checkout</button>\
3929                     </div>\
3930                   </aside>\
3931                 </div>",
3932            );
3933            html.push_str(
3934                "<form class=\"surfdoc-store-form\" data-st-form hidden>\
3935                   <div class=\"surfdoc-store-form-title\">Checkout</div>\
3936                   <label class=\"surfdoc-store-field\">Name<input type=\"text\" name=\"name\" autocomplete=\"name\" required></label>\
3937                   <label class=\"surfdoc-store-field\">Email<input type=\"email\" name=\"email\" autocomplete=\"email\" required></label>\
3938                   <label class=\"surfdoc-store-field\">Shipping address<input type=\"text\" name=\"address\" autocomplete=\"street-address\" required></label>\
3939                   <button type=\"submit\" class=\"surfdoc-store-place-btn\">Place order</button>\
3940                 </form>\
3941                 <div class=\"surfdoc-store-confirm\" data-st-confirm hidden aria-live=\"polite\"></div>",
3942            );
3943            html.push_str(&format!(
3944                "<script type=\"application/json\" data-st-data>{data_json}</script>"
3945            ));
3946            html.push_str(STORE_WIDGET_JS);
3947            html.push_str("</div>");
3948            html
3949        }
3950
3951        Block::Booking {
3952            title,
3953            service_label,
3954            services,
3955            days,
3956            ..
3957        } => {
3958            // JSON-encode a string for safe embedding in an inline
3959            // <script type="application/json"> (escape quote/backslash/control
3960            // chars plus `<` and `&` to defuse any `</script>`/entity tricks).
3961            let js = |s: &str| -> String {
3962                let mut o = String::from("\"");
3963                for c in s.chars() {
3964                    match c {
3965                        '"' => o.push_str("\\\""),
3966                        '\\' => o.push_str("\\\\"),
3967                        '\n' => o.push_str("\\n"),
3968                        '\r' => o.push_str("\\r"),
3969                        '\t' => o.push_str("\\t"),
3970                        '<' => o.push_str("\\u003c"),
3971                        '&' => o.push_str("\\u0026"),
3972                        c if (c as u32) < 0x20 => o.push_str(&format!("\\u{:04x}", c as u32)),
3973                        c => o.push(c),
3974                    }
3975                }
3976                o.push('"');
3977                o
3978            };
3979            let opt = |v: &Option<String>| -> String {
3980                v.as_deref().map(js).unwrap_or_else(|| "null".to_string())
3981            };
3982
3983            let services_json = services
3984                .iter()
3985                .map(|s| {
3986                    format!(
3987                        "{{\"name\":{},\"duration\":{},\"price\":{}}}",
3988                        js(&s.name),
3989                        opt(&s.duration),
3990                        opt(&s.price),
3991                    )
3992                })
3993                .collect::<Vec<_>>()
3994                .join(",");
3995            let days_json = days
3996                .iter()
3997                .map(|d| {
3998                    let slots = d
3999                        .slots
4000                        .iter()
4001                        .map(|s| js(s))
4002                        .collect::<Vec<_>>()
4003                        .join(",");
4004                    format!("{{\"date\":{},\"slots\":[{}]}}", js(&d.date), slots)
4005                })
4006                .collect::<Vec<_>>()
4007                .join(",");
4008            let data_json =
4009                format!("{{\"services\":[{services_json}],\"days\":[{days_json}]}}");
4010
4011            let mut html = String::from("<div class=\"surfdoc-booking\" data-booking>");
4012            if let Some(t) = title {
4013                html.push_str(&format!(
4014                    "<div class=\"surfdoc-booking-head\">{}</div>",
4015                    escape_html(t)
4016                ));
4017            }
4018            html.push_str("<div class=\"surfdoc-booking-grid\">");
4019            if !services.is_empty() {
4020                let label = service_label.as_deref().unwrap_or("Service");
4021                html.push_str(&format!(
4022                    "<div class=\"surfdoc-booking-col surfdoc-booking-svc-col\"><div class=\"surfdoc-booking-label\">{}</div><div class=\"surfdoc-booking-services\" data-bk-services role=\"radiogroup\" aria-label=\"{}\"></div></div>",
4023                    escape_html(label),
4024                    escape_html(label),
4025                ));
4026            }
4027            html.push_str(
4028                "<div class=\"surfdoc-booking-col surfdoc-booking-cal\">\
4029                   <div class=\"surfdoc-booking-cal-head\">\
4030                     <button type=\"button\" class=\"surfdoc-booking-nav\" data-bk-prev aria-label=\"Previous month\">&lsaquo;</button>\
4031                     <span class=\"surfdoc-booking-month\" data-bk-month aria-live=\"polite\"></span>\
4032                     <button type=\"button\" class=\"surfdoc-booking-nav\" data-bk-next aria-label=\"Next month\">&rsaquo;</button>\
4033                   </div>\
4034                   <div class=\"surfdoc-booking-weekdays\"><span>Sun</span><span>Mon</span><span>Tue</span><span>Wed</span><span>Thu</span><span>Fri</span><span>Sat</span></div>\
4035                   <div class=\"surfdoc-booking-days\" data-bk-days></div>\
4036                 </div>\
4037                 <div class=\"surfdoc-booking-col surfdoc-booking-slots\" data-bk-slots>\
4038                   <p class=\"surfdoc-booking-hint\">Select an available date to see open times.</p>\
4039                 </div>",
4040            );
4041            html.push_str("</div>"); // .surfdoc-booking-grid
4042            html.push_str(
4043                "<form class=\"surfdoc-booking-form\" data-bk-form hidden>\
4044                   <div class=\"surfdoc-booking-summary\" data-bk-summary></div>\
4045                   <label class=\"surfdoc-booking-field\">Name<input type=\"text\" name=\"name\" autocomplete=\"name\" required></label>\
4046                   <label class=\"surfdoc-booking-field\">Email<input type=\"email\" name=\"email\" autocomplete=\"email\" required></label>\
4047                   <button type=\"submit\" class=\"surfdoc-booking-confirm-btn\">Confirm booking</button>\
4048                 </form>\
4049                 <div class=\"surfdoc-booking-confirm\" data-bk-confirm hidden aria-live=\"polite\"></div>",
4050            );
4051            html.push_str(&format!(
4052                "<script type=\"application/json\" data-bk-data>{data_json}</script>"
4053            ));
4054            html.push_str(BOOKING_WIDGET_JS);
4055            html.push_str("</div>");
4056            html
4057        }
4058
4059        // Compound widget mount points
4060
4061        Block::Editor {
4062            source, lang, preview, ..
4063        } => {
4064            let mut html = String::from("<div class=\"surfdoc-editor\"");
4065            if let Some(s) = source {
4066                html.push_str(&format!(" data-surf-source=\"{}\"", escape_html(s)));
4067            }
4068            if let Some(l) = lang {
4069                html.push_str(&format!(" data-lang=\"{}\"", escape_html(l)));
4070            }
4071            if *preview {
4072                html.push_str(" data-preview");
4073            }
4074            html.push_str(">\
4075              <div class=\"surfdoc-editor-preview\">\
4076                <div class=\"surfdoc-block-editor-block surfdoc-block-editor-h1\">Untitled document</div>\
4077                <div class=\"surfdoc-block-editor-block surfdoc-block-editor-p\">Start writing or press <kbd>/</kbd> to add a block&hellip;</div>\
4078              </div></div>");
4079            html
4080        }
4081
4082        Block::Chart {
4083            chart_type, source, period, title, data, ..
4084        } => {
4085            let type_str = chart_type_str(*chart_type);
4086            // Inline data → render a real deterministic SVG chart.
4087            if let Some(d) = data {
4088                let svg = crate::chart::render_svg(*chart_type, d, title.as_deref());
4089                let caption = match title {
4090                    Some(t) => format!(
4091                        "<figcaption class=\"surfdoc-chart-cap\">{}</figcaption>",
4092                        escape_html(t)
4093                    ),
4094                    None => String::new(),
4095                };
4096                return format!(
4097                    "<figure class=\"surfdoc-chart surfdoc-chart-{type_str}\" data-chart-type=\"{type_str}\">{caption}{svg}</figure>",
4098                );
4099            }
4100            // No inline data → keep the live-data mount point / static placeholder.
4101            let mut html = format!(
4102                "<div class=\"surfdoc-chart surfdoc-chart-preview\" data-chart-type=\"{}\" data-surf-source=\"{}\"",
4103                type_str,
4104                escape_html(source),
4105            );
4106            if let Some(p) = period {
4107                html.push_str(&format!(" data-period=\"{}\"", escape_html(p)));
4108            }
4109            // Static preview: SVG sparkline axes + a sample polyline
4110            html.push_str(">\
4111                <div class=\"surfdoc-chart-header\">\
4112                  <span class=\"surfdoc-chart-type-label\">");
4113            html.push_str(type_str);
4114            html.push_str(" chart</span>\
4115                  <span class=\"surfdoc-chart-source-label\">");
4116            html.push_str(&escape_html(source));
4117            html.push_str("</span>\
4118                </div>\
4119                <svg class=\"surfdoc-chart-svg\" viewBox=\"0 0 320 120\" xmlns=\"http://www.w3.org/2000/svg\" aria-label=\"Sample chart preview\">\
4120                  <line x1=\"32\" y1=\"8\" x2=\"32\" y2=\"100\" stroke=\"currentColor\" stroke-width=\"1\" opacity=\"0.25\"/>\
4121                  <line x1=\"32\" y1=\"100\" x2=\"312\" y2=\"100\" stroke=\"currentColor\" stroke-width=\"1\" opacity=\"0.25\"/>\
4122                  <text x=\"28\" y=\"104\" font-size=\"9\" fill=\"currentColor\" opacity=\"0.4\" text-anchor=\"end\">0</text>\
4123                  <text x=\"28\" y=\"70\" font-size=\"9\" fill=\"currentColor\" opacity=\"0.4\" text-anchor=\"end\">50</text>\
4124                  <text x=\"28\" y=\"36\" font-size=\"9\" fill=\"currentColor\" opacity=\"0.4\" text-anchor=\"end\">100</text>\
4125                  <polyline points=\"40,80 80,60 120,70 160,40 200,55 240,30 280,45 310,20\" fill=\"none\" stroke=\"var(--accent)\" stroke-width=\"2\" stroke-linejoin=\"round\"/>\
4126                  <polyline points=\"40,80 80,60 120,70 160,40 200,55 240,30 280,45 310,20 310,100 40,100\" fill=\"var(--accent)\" opacity=\"0.08\" stroke=\"none\"/>\
4127                  <circle cx=\"310\" cy=\"20\" r=\"3\" fill=\"var(--accent)\"/>\
4128                </svg>\
4129                </div>");
4130            html
4131        }
4132
4133        Block::SplitPane { ratio, .. } => {
4134            format!(
4135                "<div class=\"surfdoc-split-pane\" data-ratio=\"{}\"><div class=\"surfdoc-split-left\"></div><div class=\"surfdoc-split-right\"></div></div>",
4136                escape_html(ratio),
4137            )
4138        }
4139
4140        // ----- Infrastructure manifest blocks -----
4141
4142        Block::App { name, binary, region, port, platform, children, .. } => {
4143            let mut meta = vec![format!("<strong>{}</strong>", escape_html(name))];
4144            if let Some(b) = binary { meta.push(format!("binary: {}", escape_html(b))); }
4145            if let Some(r) = region { meta.push(format!("region: {}", escape_html(r))); }
4146            if let Some(p) = port { meta.push(format!("port: {p}")); }
4147            if let Some(pl) = platform { meta.push(format!("platform: {}", escape_html(pl))); }
4148            let mut html = format!(
4149                "<section class=\"surfdoc-app\" aria-label=\"App: {}\"><div class=\"surfdoc-app-header\">{}</div>",
4150                escape_html(name), meta.join(" &middot; "),
4151            );
4152            for child in children { html.push_str(&render_block(child)); }
4153            html.push_str("</section>");
4154            html
4155        }
4156
4157        Block::Build { base, runtime, edition, properties, .. } => {
4158            let mut items = Vec::new();
4159            if let Some(b) = base { items.push(format!("<li>base: {}</li>", escape_html(b))); }
4160            if let Some(r) = runtime { items.push(format!("<li>runtime: {}</li>", escape_html(r))); }
4161            if let Some(e) = edition { items.push(format!("<li>edition: {}</li>", escape_html(e))); }
4162            for p in properties { items.push(format!("<li>{}: {}</li>", escape_html(&p.key), escape_html(&p.value))); }
4163            format!("<div class=\"surfdoc-infra-card surfdoc-build\"><strong class=\"surfdoc-infra-label\">Build</strong><ul>{}</ul></div>", items.join(""))
4164        }
4165
4166        Block::InfraDatabase { name, shared_auth, volume_gb, properties, .. } => {
4167            let mut items = Vec::new();
4168            if let Some(n) = name { items.push(format!("<li>name: {}</li>", escape_html(n))); }
4169            if *shared_auth { items.push("<li>shared_auth: true</li>".to_string()); }
4170            if let Some(v) = volume_gb { items.push(format!("<li>volume: {v} GB</li>")); }
4171            for p in properties { items.push(format!("<li>{}: {}</li>", escape_html(&p.key), escape_html(&p.value))); }
4172            format!("<div class=\"surfdoc-infra-card surfdoc-database\"><strong class=\"surfdoc-infra-label\">Database</strong><ul>{}</ul></div>", items.join(""))
4173        }
4174
4175        Block::Deploy { env, app, machines, memory, auto_stop, min_machines, strategy, properties, .. } => {
4176            let env_str = env.as_deref().unwrap_or("unknown");
4177            let mut items = Vec::new();
4178            if let Some(a) = app { items.push(format!("<li>app: {}</li>", escape_html(a))); }
4179            if let Some(m) = machines { items.push(format!("<li>machines: {m}</li>")); }
4180            if let Some(m) = memory { items.push(format!("<li>memory: {m} MB</li>")); }
4181            if let Some(a) = auto_stop { items.push(format!("<li>auto_stop: {}</li>", escape_html(a))); }
4182            if let Some(m) = min_machines { items.push(format!("<li>min_machines: {m}</li>")); }
4183            if let Some(s) = strategy { items.push(format!("<li>strategy: {}</li>", escape_html(s))); }
4184            for p in properties { items.push(format!("<li>{}: {}</li>", escape_html(&p.key), escape_html(&p.value))); }
4185            format!(
4186                "<div class=\"surfdoc-infra-card surfdoc-deploy\"><strong class=\"surfdoc-infra-label\">Deploy</strong><span class=\"surfdoc-deploy-badge\">{}</span><ul>{}</ul></div>",
4187                escape_html(env_str), items.join(""),
4188            )
4189        }
4190
4191        Block::InfraEnv { tier, entries, .. } => {
4192            let tier_str = tier.as_deref().unwrap_or("env");
4193            let items: Vec<String> = entries.iter().map(|e| {
4194                match &e.default_value {
4195                    Some(v) => format!("<li><code>{}</code> = <code>{}</code></li>", escape_html(&e.name), escape_html(v)),
4196                    None => format!("<li><code>{}</code></li>", escape_html(&e.name)),
4197                }
4198            }).collect();
4199            format!("<div class=\"surfdoc-infra-card surfdoc-env\"><strong class=\"surfdoc-infra-label\">Env ({})</strong><ul>{}</ul></div>", escape_html(tier_str), items.join(""))
4200        }
4201
4202        Block::Health { path, method, grace, interval, timeout, .. } => {
4203            let mut items = Vec::new();
4204            if let Some(p) = path { items.push(format!("<li>path: {}</li>", escape_html(p))); }
4205            if let Some(m) = method { items.push(format!("<li>method: {}</li>", escape_html(m))); }
4206            if let Some(g) = grace { items.push(format!("<li>grace: {}</li>", escape_html(g))); }
4207            if let Some(i) = interval { items.push(format!("<li>interval: {}</li>", escape_html(i))); }
4208            if let Some(t) = timeout { items.push(format!("<li>timeout: {}</li>", escape_html(t))); }
4209            format!("<div class=\"surfdoc-infra-card surfdoc-health\"><strong class=\"surfdoc-infra-label\">Health Check</strong><ul>{}</ul></div>", items.join(""))
4210        }
4211
4212        Block::Concurrency { concurrency_type, hard_limit, soft_limit, force_https, .. } => {
4213            let mut items = Vec::new();
4214            if let Some(t) = concurrency_type { items.push(format!("<li>type: {}</li>", escape_html(t))); }
4215            if let Some(h) = hard_limit { items.push(format!("<li>hard_limit: {h}</li>")); }
4216            if let Some(s) = soft_limit { items.push(format!("<li>soft_limit: {s}</li>")); }
4217            if *force_https { items.push("<li>force_https: true</li>".to_string()); }
4218            format!("<div class=\"surfdoc-infra-card surfdoc-concurrency\"><strong class=\"surfdoc-infra-label\">Concurrency</strong><ul>{}</ul></div>", items.join(""))
4219        }
4220
4221        Block::Cicd { provider, properties, .. } => {
4222            let prov = provider.as_deref().unwrap_or("CI/CD");
4223            let items: Vec<String> = properties.iter()
4224                .map(|p| format!("<li>{}: {}</li>", escape_html(&p.key), escape_html(&p.value)))
4225                .collect();
4226            format!("<div class=\"surfdoc-infra-card surfdoc-cicd\"><strong class=\"surfdoc-infra-label\">{}</strong><ul>{}</ul></div>", escape_html(prov), items.join(""))
4227        }
4228
4229        Block::Smoke { script, checks, .. } => {
4230            let script_attr = script.as_ref()
4231                .map(|s| format!(" data-script=\"{}\"", escape_html(s)))
4232                .unwrap_or_default();
4233            let rows: Vec<String> = checks.iter().map(|c| {
4234                format!(
4235                    "<tr><td>{}</td><td><code>{}</code></td><td>{}</td></tr>",
4236                    escape_html(&c.method), escape_html(&c.path), c.expected,
4237                )
4238            }).collect();
4239            format!(
4240                "<table class=\"surfdoc-smoke\"{}><thead><tr><th>Method</th><th>Path</th><th>Expected</th></tr></thead><tbody>{}</tbody></table>",
4241                script_attr, rows.join(""),
4242            )
4243        }
4244
4245        Block::Domains { entries, .. } => {
4246            let items: Vec<String> = entries.iter().map(|e| {
4247                match &e.description {
4248                    Some(d) => format!("<li><strong>{}</strong> &mdash; {}</li>", escape_html(&e.domain), escape_html(d)),
4249                    None => format!("<li><strong>{}</strong></li>", escape_html(&e.domain)),
4250                }
4251            }).collect();
4252            format!("<div class=\"surfdoc-infra-card surfdoc-domains\"><strong class=\"surfdoc-infra-label\">Domains</strong><ul>{}</ul></div>", items.join(""))
4253        }
4254
4255        Block::Crates { entries, .. } => {
4256            let items: Vec<String> = entries.iter().map(|e| {
4257                let mut detail = Vec::new();
4258                if let Some(s) = &e.source { detail.push(format!("source: {}", escape_html(s))); }
4259                if let Some(f) = &e.features { detail.push(format!("features: {}", escape_html(f))); }
4260                if detail.is_empty() {
4261                    format!("<li><code>{}</code></li>", escape_html(&e.name))
4262                } else {
4263                    format!("<li><code>{}</code> ({})</li>", escape_html(&e.name), detail.join(", "))
4264                }
4265            }).collect();
4266            format!("<div class=\"surfdoc-infra-card surfdoc-crates\"><strong class=\"surfdoc-infra-label\">Crates</strong><ul>{}</ul></div>", items.join(""))
4267        }
4268
4269        Block::DeployUrls { entries, .. } => {
4270            let items: Vec<String> = entries.iter()
4271                .map(|p| format!("<li>{}: <a href=\"{}\">{}</a></li>", escape_html(&p.key), escape_html(&p.value), escape_html(&p.value)))
4272                .collect();
4273            format!("<div class=\"surfdoc-infra-card surfdoc-deploy-urls\"><strong class=\"surfdoc-infra-label\">Deploy URLs</strong><ul>{}</ul></div>", items.join(""))
4274        }
4275
4276        Block::Volumes { entries, .. } => {
4277            let items: Vec<String> = entries.iter()
4278                .map(|v| format!("<li><code>{}</code> &rarr; <code>{}</code></li>", escape_html(&v.name), escape_html(&v.mount)))
4279                .collect();
4280            format!("<div class=\"surfdoc-infra-card surfdoc-volumes\"><strong class=\"surfdoc-infra-label\">Volumes</strong><ul>{}</ul></div>", items.join(""))
4281        }
4282
4283        Block::Model { name, fields, .. } => {
4284            let mut rows = String::new();
4285            for f in fields {
4286                let type_str = model_field_type_str(&f.field_type);
4287                let constraints_str: Vec<String> = f.constraints.iter().map(|c| constraint_str(c)).collect();
4288                let constraints_html = if constraints_str.is_empty() {
4289                    String::new()
4290                } else {
4291                    format!(" <span class=\"surfdoc-model-constraints\">[{}]</span>", constraints_str.join(", "))
4292                };
4293                rows.push_str(&format!(
4294                    "<tr><td><code>{}</code></td><td><code>{}</code></td><td>{}</td></tr>",
4295                    escape_html(&f.name), escape_html(&type_str), constraints_html,
4296                ));
4297            }
4298            format!(
4299                "<div class=\"surfdoc-infra-card surfdoc-model\"><strong class=\"surfdoc-infra-label\">Model: {}</strong><table class=\"surfdoc-model-table\">\
4300                 <thead><tr><th>Field</th><th>Type</th><th>Constraints</th></tr></thead>\
4301                 <tbody>{}</tbody></table></div>",
4302                escape_html(name), rows,
4303            )
4304        }
4305
4306        Block::Route { method, path, auth, returns, body, handler, content, .. } => {
4307            let method_str = http_method_str(*method);
4308            if let Some(code) = handler {
4309                // Route with embedded handler — render as documentation block.
4310                let auth_html = match auth {
4311                    Some(a) => format!(" <span class=\"surfdoc-route-auth\">[auth: {}]</span>", escape_html(a)),
4312                    None => String::new(),
4313                };
4314                format!(
4315                    "<div class=\"surfdoc-infra-card surfdoc-route\">\
4316                     <div class=\"surfdoc-route-head\"><span class=\"surfdoc-route-method surfdoc-method-{}\">{}</span> \
4317                     <code class=\"surfdoc-route-path\">{}</code>{}</div>\
4318                     <pre class=\"surfdoc-code\"><code>{}</code></pre></div>",
4319                    method_str.to_lowercase(), method_str, escape_html(path), auth_html, escape_html(code),
4320                )
4321            } else {
4322                let mut details = Vec::new();
4323                if let Some(a) = auth { details.push(format!("<li>auth: {}</li>", escape_html(a))); }
4324                if let Some(r) = returns { details.push(format!("<li>returns: <code>{}</code></li>", escape_html(r))); }
4325                if let Some(b) = body { details.push(format!("<li>body: <code>{}</code></li>", escape_html(b))); }
4326                if !content.is_empty() { details.push(format!("<li>{}</li>", escape_html(content))); }
4327                let details_html = if details.is_empty() { String::new() } else { format!("<ul>{}</ul>", details.join("")) };
4328                format!(
4329                    "<div class=\"surfdoc-infra-card surfdoc-route\"><span class=\"surfdoc-route-method surfdoc-method-{}\">{}</span> \
4330                     <code class=\"surfdoc-route-path\">{}</code>{}</div>",
4331                    method_str.to_lowercase(), method_str, escape_html(path), details_html,
4332                )
4333            }
4334        }
4335
4336        Block::Auth { provider, session, roles, default_role, .. } => {
4337            let provider_str = auth_provider_str(*provider);
4338            let mut items = vec![format!("<li>provider: {}</li>", provider_str)];
4339            if let Some(s) = session { items.push(format!("<li>session: {}</li>", escape_html(s))); }
4340            if !roles.is_empty() { items.push(format!("<li>roles: {}</li>", roles.iter().map(|r| escape_html(r)).collect::<Vec<_>>().join(", "))); }
4341            if let Some(dr) = default_role { items.push(format!("<li>default role: {}</li>", escape_html(dr))); }
4342            format!("<div class=\"surfdoc-infra-card surfdoc-auth\"><strong class=\"surfdoc-infra-label\">Authentication</strong><ul>{}</ul></div>", items.join(""))
4343        }
4344
4345        Block::Binding { source, target, events, .. } => {
4346            let mut items = vec![
4347                format!("<li>source: <code>{}</code></li>", escape_html(source)),
4348                format!("<li>target: <code>{}</code></li>", escape_html(target)),
4349            ];
4350            for e in events {
4351                items.push(format!("<li>{}: {}</li>", escape_html(&e.event), escape_html(&e.action)));
4352            }
4353            format!("<div class=\"surfdoc-infra-card surfdoc-binding\"><strong class=\"surfdoc-infra-label\">Binding</strong><ul>{}</ul></div>", items.join(""))
4354        }
4355
4356        Block::Schema { name, fields, .. } => {
4357            let mut rows = String::new();
4358            for f in fields {
4359                let constraints_html = if f.constraints.is_empty() {
4360                    String::new()
4361                } else {
4362                    f.constraints.iter().map(|c| escape_html(&constraint_str(c))).collect::<Vec<_>>().join(", ")
4363                };
4364                rows.push_str(&format!(
4365                    "<tr><td>{}</td><td>{}</td><td>{}</td></tr>",
4366                    escape_html(&f.name), escape_html(&model_field_type_str(&f.field_type)), constraints_html,
4367                ));
4368            }
4369            format!(
4370                "<div class=\"surfdoc-schema\">\
4371                 <h4 class=\"surfdoc-schema-name\">{}</h4>\
4372                 <table class=\"surfdoc-data\">\
4373                 <thead><tr><th>Column</th><th>Type</th><th>Constraints</th></tr></thead>\
4374                 <tbody>{}</tbody></table></div>",
4375                escape_html(name), rows,
4376            )
4377        }
4378
4379        Block::Use { crates, .. } => {
4380            let items: Vec<String> = crates.iter().map(|c| {
4381                let mut parts = vec![escape_html(&c.name)];
4382                if let Some(v) = &c.version {
4383                    parts.push(escape_html(v));
4384                }
4385                if !c.features.is_empty() {
4386                    parts.push(format!("[{}]", c.features.iter().map(|f| escape_html(f)).collect::<Vec<_>>().join(", ")));
4387                }
4388                format!("<li><code>{}</code></li>", parts.join(" "))
4389            }).collect();
4390            format!("<div class=\"surfdoc-infra-card surfdoc-use\"><strong class=\"surfdoc-infra-label\">Dependencies</strong><ul>{}</ul></div>", items.join(""))
4391        }
4392
4393        Block::AppEnv { vars, .. } => {
4394            let mut rows = String::new();
4395            for v in vars {
4396                let req = if v.required { "yes" } else { "no" };
4397                let desc = v.description.as_deref().unwrap_or("");
4398                rows.push_str(&format!(
4399                    "<tr><td><code>{}</code></td><td>{}</td><td>{}</td></tr>",
4400                    escape_html(&v.name), req, escape_html(desc),
4401                ));
4402            }
4403            format!(
4404                "<div class=\"surfdoc-infra-card surfdoc-app-env\">\
4405                 <strong class=\"surfdoc-infra-label\">Environment Variables</strong>\
4406                 <table class=\"surfdoc-data\">\
4407                 <thead><tr><th>Name</th><th>Required</th><th>Description</th></tr></thead>\
4408                 <tbody>{}</tbody></table></div>",
4409                rows,
4410            )
4411        }
4412
4413        Block::AppDeploy { region, scale, domain, memory, properties, .. } => {
4414            let mut items = Vec::new();
4415            if let Some(r) = region { items.push(format!("<li>region: {}</li>", escape_html(r))); }
4416            if let Some(s) = scale { items.push(format!("<li>scale: {s}</li>")); }
4417            if let Some(d) = domain { items.push(format!("<li>domain: {}</li>", escape_html(d))); }
4418            if let Some(m) = memory { items.push(format!("<li>memory: {}</li>", escape_html(m))); }
4419            for (k, v) in properties {
4420                items.push(format!("<li>{}: {}</li>", escape_html(k), escape_html(v)));
4421            }
4422            format!(
4423                "<div class=\"surfdoc-infra-card surfdoc-app-deploy\"><strong class=\"surfdoc-infra-label\">Deploy Configuration</strong><ul>{}</ul></div>",
4424                items.join(""),
4425            )
4426        }
4427
4428        Block::Row { icon, title, description, href, state, .. } => {
4429            let state_class = match state {
4430                RowState::Loading => " surfdoc-row--loading",
4431                RowState::Empty => " surfdoc-row--empty",
4432                _ => "",
4433            };
4434            let icon_svg = row_icon_svg(icon);
4435            let arrow_svg = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><polyline points=\"9 18 15 12 9 6\"/></svg>";
4436            let tag = if href.is_some() { "a" } else { "div" };
4437            let href_attr = match href {
4438                Some(h) => format!(" href=\"{}\"", escape_html(h)),
4439                None => String::new(),
4440            };
4441            format!(
4442                "<{tag} class=\"surfdoc-row{state_class}\"{href_attr}>\
4443                 <span class=\"surfdoc-row-icon\">{icon_svg}</span>\
4444                 <span class=\"surfdoc-row-body\">\
4445                   <span class=\"surfdoc-row-title\">{title}</span>\
4446                   <span class=\"surfdoc-row-desc\">{desc}</span>\
4447                 </span>\
4448                 <span class=\"surfdoc-row-arrow\">{arrow_svg}</span>\
4449                 </{tag}>",
4450                title = escape_html(title),
4451                desc = escape_html(description),
4452            )
4453        }
4454
4455        Block::InfoCard { intent, title, subtitle, summary, image, facts, steps, state, .. } => {
4456            let state_class = match state {
4457                RowState::Loading => " surfdoc-infocard--loading",
4458                RowState::Empty => " surfdoc-infocard--empty",
4459                _ => "",
4460            };
4461            let mut html = format!("<div class=\"surfdoc-infocard{state_class}\">");
4462            html.push_str("<div class=\"surfdoc-infocard-header\">");
4463            if let Some(img) = image {
4464                html.push_str(&format!("<img class=\"surfdoc-infocard-image\" src=\"{}\" alt=\"{}\">", escape_html(img), escape_html(title)));
4465            }
4466            html.push_str("<div class=\"surfdoc-infocard-info\">");
4467            html.push_str(&format!("<h3 class=\"surfdoc-infocard-title\">{}</h3>", escape_html(title)));
4468            if !subtitle.is_empty() {
4469                html.push_str(&format!("<div class=\"surfdoc-infocard-subtitle\">{}</div>", escape_html(subtitle)));
4470            }
4471            html.push_str(&format!("<span class=\"surfdoc-infocard-badge {}\">{}</span>", escape_html(intent), escape_html(intent)));
4472            html.push_str("</div></div>");
4473            if !summary.is_empty() {
4474                html.push_str(&format!("<p class=\"surfdoc-infocard-summary\">{}</p>", escape_html(summary)));
4475            }
4476            if !steps.is_empty() {
4477                html.push_str("<div class=\"surfdoc-infocard-steps\">");
4478                for (i, step) in steps.iter().enumerate() {
4479                    html.push_str(&format!(
4480                        "<div class=\"surfdoc-infocard-step\"><span class=\"surfdoc-infocard-step-num\">{}</span><span class=\"surfdoc-infocard-step-text\">{}</span></div>",
4481                        i + 1, escape_html(step)
4482                    ));
4483                }
4484                html.push_str("</div>");
4485            } else if !facts.is_empty() {
4486                html.push_str("<div class=\"surfdoc-infocard-facts\">");
4487                for fact in facts {
4488                    html.push_str(&format!(
4489                        "<span class=\"surfdoc-infocard-fact-label\">{}</span><span class=\"surfdoc-infocard-fact-value\">{}</span>",
4490                        escape_html(&fact[0]), escape_html(&fact[1])
4491                    ));
4492                }
4493                html.push_str("</div>");
4494            }
4495            html.push_str("</div>");
4496            html
4497        }
4498
4499        // ── Interactive / application blocks ──────────────────────
4500
4501        Block::AppShell { layout, children, .. } => {
4502            let mut html = format!(
4503                "<div class=\"surfdoc-app-shell surfdoc-layout-{}\">",
4504                escape_html(layout),
4505            );
4506            for child in children { html.push_str(&render_block(child)); }
4507            html.push_str("</div>");
4508            html
4509        }
4510
4511        Block::Sidebar { position, collapsible, width, children, .. } => {
4512            let style = match width {
4513                Some(w) => format!(" style=\"width:{}px\"", w),
4514                None => String::new(),
4515            };
4516            let mut html = format!(
4517                "<aside class=\"surfdoc-sidebar surfdoc-sidebar-{}\" data-collapsible=\"{}\"{}>",
4518                escape_html(position), collapsible, style,
4519            );
4520            for child in children { html.push_str(&render_block(child)); }
4521            html.push_str("</aside>");
4522            html
4523        }
4524
4525        Block::Panel { position, resizable, height, desktop_only, children, .. } => {
4526            let style = match height {
4527                Some(h) => format!(" style=\"height:{}px\"", h),
4528                None => String::new(),
4529            };
4530            let mut html = format!(
4531                "<div class=\"surfdoc-panel surfdoc-panel-{}\" data-resizable=\"{}\" data-desktop-only=\"{}\"{}>",
4532                escape_html(position), resizable, desktop_only, style,
4533            );
4534            for child in children { html.push_str(&render_block(child)); }
4535            html.push_str("</div>");
4536            html
4537        }
4538
4539        Block::TabBar { active, items, .. } => {
4540            let mut html = String::from("<nav class=\"surfdoc-tab-bar\" role=\"tablist\">");
4541            for item in items {
4542                let is_active = active.as_ref().is_some_and(|a| a == &item.id);
4543                let active_cls = if is_active { " class=\"active\"" } else { "" };
4544                html.push_str(&format!(
4545                    "<button role=\"tab\" data-tab=\"{}\"{} aria-selected=\"{}\">{}</button>",
4546                    escape_html(&item.id),
4547                    active_cls,
4548                    is_active,
4549                    escape_html(&item.label),
4550                ));
4551            }
4552            html.push_str("</nav>");
4553            // Inline JS for tab switching — self-contained, no external deps
4554            html.push_str(r#"<script>document.querySelectorAll('.surfdoc-tab-bar').forEach(bar=>{bar.querySelectorAll('[role="tab"]').forEach(btn=>{btn.onclick=()=>{const tab=btn.dataset.tab;const parent=bar.parentElement||document;bar.querySelectorAll('[role="tab"]').forEach(b=>{b.classList.remove('active');b.setAttribute('aria-selected','false')});btn.classList.add('active');btn.setAttribute('aria-selected','true');parent.querySelectorAll('.surfdoc-tab-content').forEach(tc=>{if(tc.dataset.tab===tab){tc.classList.add('active');tc.style.display='block'}else{tc.classList.remove('active');tc.style.display='none'}})}})})</script>"#);
4555            html
4556        }
4557
4558        Block::TabContent { tab, children, .. } => {
4559            // First tab-content is visible by default, others hidden
4560            let mut html = format!(
4561                "<div class=\"surfdoc-tab-content\" data-tab=\"{}\" role=\"tabpanel\">",
4562                escape_html(tab),
4563            );
4564            for child in children { html.push_str(&render_block(child)); }
4565            html.push_str("</div>");
4566            html
4567        }
4568
4569        Block::Toolbar { items, .. } => {
4570            let mut html = String::from("<div class=\"surfdoc-toolbar\">");
4571            for item in items {
4572                match item {
4573                    crate::types::ToolbarItem::Button { label, action, style, .. } => {
4574                        let cls = match style {
4575                            Some(s) => format!(" surfdoc-toolbar-btn-{}", escape_html(s)),
4576                            None => String::new(),
4577                        };
4578                        let action_attr = match action {
4579                            Some(a) => format!(" data-action=\"{}\"", escape_html(a)),
4580                            None => String::new(),
4581                        };
4582                        html.push_str(&format!(
4583                            "<button class=\"surfdoc-toolbar-btn{}\"{}>{}</button>",
4584                            cls, action_attr, escape_html(label.as_deref().unwrap_or("")),
4585                        ));
4586                    }
4587                    crate::types::ToolbarItem::Separator => {
4588                        html.push_str("<span class=\"surfdoc-toolbar-separator\"></span>");
4589                    }
4590                    crate::types::ToolbarItem::Spacer => {
4591                        html.push_str("<span class=\"surfdoc-toolbar-spacer\"></span>");
4592                    }
4593                    crate::types::ToolbarItem::Badge { value, color } => {
4594                        let cls = match color {
4595                            Some(c) => format!(" surfdoc-badge-{}", escape_html(c)),
4596                            None => String::new(),
4597                        };
4598                        html.push_str(&format!(
4599                            "<span class=\"surfdoc-badge{}\">{}</span>",
4600                            cls, escape_html(value),
4601                        ));
4602                    }
4603                    crate::types::ToolbarItem::Dropdown { label, .. } => {
4604                        html.push_str(&format!(
4605                            "<select class=\"surfdoc-toolbar-dropdown\"><option>{}</option></select>",
4606                            escape_html(label),
4607                        ));
4608                    }
4609                    crate::types::ToolbarItem::Text { value, .. } => {
4610                        html.push_str(&format!(
4611                            "<span class=\"surfdoc-toolbar-text\">{}</span>",
4612                            escape_html(value),
4613                        ));
4614                    }
4615                }
4616            }
4617            html.push_str("</div>");
4618            html
4619        }
4620
4621        Block::Drawer { name, position, width, children, .. } => {
4622            let style = match width {
4623                Some(w) => format!(" style=\"width:{}px\"", w),
4624                None => String::new(),
4625            };
4626            let mut html = format!(
4627                "<div class=\"surfdoc-drawer surfdoc-drawer-{}\" data-name=\"{}\"{}>",
4628                escape_html(position), escape_html(name), style,
4629            );
4630            for child in children { html.push_str(&render_block(child)); }
4631            html.push_str("</div>");
4632            html
4633        }
4634
4635        Block::Modal { name, title, children, .. } => {
4636            let mut html = format!(
4637                "<dialog class=\"surfdoc-modal\" data-name=\"{}\">",
4638                escape_html(name),
4639            );
4640            if let Some(t) = title {
4641                html.push_str(&format!("<strong class=\"surfdoc-modal-title\">{}</strong>", escape_html(t)));
4642            }
4643            for child in children { html.push_str(&render_block(child)); }
4644            html.push_str("</dialog>");
4645            html
4646        }
4647
4648        Block::CommandPalette { items, .. } => {
4649            let mut html = String::from("<div class=\"surfdoc-command-palette\"><input type=\"search\" placeholder=\"Search commands...\" class=\"surfdoc-command-search\">");
4650            html.push_str("<ul class=\"surfdoc-command-list\">");
4651            for item in items {
4652                let group_attr = match &item.group {
4653                    Some(g) => format!(" data-group=\"{}\"", escape_html(g)),
4654                    None => String::new(),
4655                };
4656                let desc = match &item.description {
4657                    Some(d) => format!("<span class=\"surfdoc-command-desc\">{}</span>", escape_html(d)),
4658                    None => String::new(),
4659                };
4660                html.push_str(&format!(
4661                    "<li class=\"surfdoc-command-item\"{}><span class=\"surfdoc-command-label\">{}</span>{}</li>",
4662                    group_attr, escape_html(&item.label), desc,
4663                ));
4664            }
4665            html.push_str("</ul></div>");
4666            html
4667        }
4668
4669        Block::CodeEditor { lang, source, line_numbers, content, .. } => {
4670            let lang_attr = match lang {
4671                Some(l) => format!(" data-lang=\"{}\"", escape_html(l)),
4672                None => String::new(),
4673            };
4674            let source_attr = match source {
4675                Some(s) => format!(" data-source=\"{}\"", escape_html(s)),
4676                None => String::new(),
4677            };
4678            let ln_attr = if *line_numbers { " data-line-numbers=\"true\"" } else { "" };
4679            format!(
4680                "<div class=\"surfdoc-code-editor\"{}{}{}><pre><code>{}</code></pre></div>",
4681                lang_attr, source_attr, ln_attr, escape_html(content),
4682            )
4683        }
4684
4685        Block::BlockEditor { source, .. } => {
4686            let source_attr = match source {
4687                Some(s) => format!(" data-source=\"{}\"", escape_html(s)),
4688                None => String::new(),
4689            };
4690            format!(
4691                "<div class=\"surfdoc-block-editor surfdoc-block-editor-preview\"{}>\
4692                 <div class=\"surfdoc-block-editor-block surfdoc-block-editor-h1\">Untitled document</div>\
4693                 <div class=\"surfdoc-block-editor-block surfdoc-block-editor-p\">Start writing or press <kbd>/</kbd> to add a block&hellip;</div>\
4694                 <div class=\"surfdoc-block-editor-add\" aria-label=\"Add block\">+ Add block</div>\
4695                 </div>",
4696                source_attr,
4697            )
4698        }
4699
4700        Block::Terminal { shell, cwd, .. } => {
4701            let shell_attr = match shell {
4702                Some(s) => format!(" data-shell=\"{}\"", escape_html(s)),
4703                None => String::new(),
4704            };
4705            let cwd_attr = match cwd {
4706                Some(c) => format!(" data-cwd=\"{}\"", escape_html(c)),
4707                None => String::new(),
4708            };
4709            let cwd_display = cwd.as_deref().unwrap_or("~");
4710            let shell_display = shell.as_deref().unwrap_or("bash");
4711            format!(
4712                "<div class=\"surfdoc-terminal surfdoc-terminal-preview\"{shell_attr}{cwd_attr}>\
4713                 <div class=\"surfdoc-terminal-bar\"><span class=\"surfdoc-terminal-dot surfdoc-terminal-dot-red\"></span><span class=\"surfdoc-terminal-dot surfdoc-terminal-dot-yellow\"></span><span class=\"surfdoc-terminal-dot surfdoc-terminal-dot-green\"></span><span class=\"surfdoc-terminal-title\">{shell_display} &mdash; {cwd_display}</span></div>\
4714                 <pre class=\"surfdoc-terminal-body\"><code><span class=\"surfdoc-terminal-prompt\">$</span> cargo build --release\n<span class=\"surfdoc-terminal-out\">   Compiling surf-parse v1.0.0\n   Finished release [optimized] target(s) in 4.82s</span>\n<span class=\"surfdoc-terminal-prompt\">$</span> <span class=\"surfdoc-terminal-cursor\"> </span></code></pre>\
4715                 </div>",
4716                shell_display = escape_html(shell_display),
4717                cwd_display = escape_html(cwd_display),
4718            )
4719        }
4720
4721        Block::NavTree { source, .. } => {
4722            let source_attr = match source {
4723                Some(s) => format!(" data-source=\"{}\"", escape_html(s)),
4724                None => String::new(),
4725            };
4726            format!(
4727                "<nav class=\"surfdoc-nav-tree surfdoc-nav-tree-preview\"{}>\
4728                 <ul class=\"surfdoc-tree-list\">\
4729                   <li class=\"surfdoc-tree-folder surfdoc-tree-open\"><span class=\"surfdoc-tree-toggle\">&#9660;</span> <span class=\"surfdoc-tree-name\">src</span>\
4730                     <ul class=\"surfdoc-tree-list\">\
4731                       <li class=\"surfdoc-tree-file surfdoc-tree-active\"><span class=\"surfdoc-tree-name\">main.rs</span></li>\
4732                       <li class=\"surfdoc-tree-file\"><span class=\"surfdoc-tree-name\">render_html.rs</span></li>\
4733                       <li class=\"surfdoc-tree-file\"><span class=\"surfdoc-tree-name\">blocks.rs</span></li>\
4734                     </ul>\
4735                   </li>\
4736                   <li class=\"surfdoc-tree-folder\"><span class=\"surfdoc-tree-toggle\">&#9658;</span> <span class=\"surfdoc-tree-name\">assets</span></li>\
4737                   <li class=\"surfdoc-tree-file\"><span class=\"surfdoc-tree-name\">Cargo.toml</span></li>\
4738                 </ul>\
4739                 </nav>",
4740                source_attr,
4741            )
4742        }
4743
4744        Block::Badge { value, color, .. } => {
4745            let cls = match color {
4746                Some(c) => format!(" surfdoc-badge-{}", escape_html(c)),
4747                None => String::new(),
4748            };
4749            format!(
4750                "<span class=\"surfdoc-badge{}\">{}</span>",
4751                cls, escape_html(value),
4752            )
4753        }
4754
4755        Block::SuggestionChips { source, max, dismissible, .. } => {
4756            let source_attr = match source {
4757                Some(s) => format!(" data-source=\"{}\"", escape_html(s)),
4758                None => String::new(),
4759            };
4760            let max_attr = match max {
4761                Some(m) => format!(" data-max=\"{}\"", m),
4762                None => String::new(),
4763            };
4764            // Static preview: sample chips so the block is never empty
4765            format!(
4766                "<div class=\"surfdoc-suggestion-chips\"{} data-dismissible=\"{}\"{}>\
4767                  <button class=\"surfdoc-chip\">Show open tasks</button>\
4768                  <button class=\"surfdoc-chip\">Summarise last standup</button>\
4769                  <button class=\"surfdoc-chip\">What&rsquo;s blocking the team?</button>\
4770                </div>",
4771                source_attr, dismissible, max_attr,
4772            )
4773        }
4774
4775        Block::ChatThread { source, .. } => {
4776            let source_attr = match source {
4777                Some(s) => format!(" data-source=\"{}\"", escape_html(s)),
4778                None => String::new(),
4779            };
4780            // Static preview: sample 2-message thread so the block is never empty
4781            format!("<div class=\"surfdoc-chat-thread\"{source_attr}>\
4782              <div class=\"surfdoc-chat-msg surfdoc-chat-msg-user\">How do I add a new task?</div>\
4783              <div class=\"surfdoc-chat-msg surfdoc-chat-msg-assistant\">Click <strong>+ New Task</strong> in the board toolbar, fill in the title, and assign an owner — it&rsquo;ll appear in the Todo column instantly.</div>\
4784            </div>")
4785        }
4786
4787        Block::ChatInputSimple { placeholder, action, .. } => {
4788            let ph = placeholder.as_deref().unwrap_or("");
4789            let action_attr = match action {
4790                Some(a) => format!(" data-action=\"{}\"", escape_html(a)),
4791                None => String::new(),
4792            };
4793            format!(
4794                "<div class=\"surfdoc-chat-input\"><input type=\"text\" placeholder=\"{}\"><button{}>Send</button></div>",
4795                escape_html(ph), action_attr,
4796            )
4797        }
4798
4799        Block::Progress { steps, .. } => {
4800            let mut html = String::from("<ol class=\"surfdoc-progress\">");
4801            for step in steps {
4802                html.push_str(&format!(
4803                    "<li class=\"surfdoc-step-{}\">{}</li>",
4804                    escape_html(&step.status), escape_html(&step.label),
4805                ));
4806            }
4807            html.push_str("</ol>");
4808            html
4809        }
4810
4811        Block::LogStream { source, tail, .. } => {
4812            let source_attr = match source {
4813                Some(s) => format!(" data-source=\"{}\"", escape_html(s)),
4814                None => String::new(),
4815            };
4816            let tail_attr = match tail {
4817                Some(t) => format!(" data-tail=\"{}\"", t),
4818                None => String::new(),
4819            };
4820            format!(
4821                "<div class=\"surfdoc-log-stream surfdoc-log-stream-preview\"{}{}>\
4822                 <pre><code><span class=\"surfdoc-log-info\">INFO</span>  [2026-05-26T10:42:01Z] Server listening on :8080\n<span class=\"surfdoc-log-info\">INFO</span>  [2026-05-26T10:42:03Z] GET /healthz 200 1ms\n<span class=\"surfdoc-log-info\">INFO</span>  [2026-05-26T10:42:11Z] POST /api/tasks 201 12ms\n<span class=\"surfdoc-log-warn\">WARN</span>  [2026-05-26T10:42:30Z] DB connection pool at 80%% capacity\n<span class=\"surfdoc-log-info\">INFO</span>  [2026-05-26T10:43:01Z] GET /api/tasks 200 4ms</code></pre>\
4823                 </div>",
4824                source_attr, tail_attr,
4825            )
4826        }
4827
4828        Block::ProblemList { source, .. } => {
4829            let source_attr = match source {
4830                Some(s) => format!(" data-source=\"{}\"", escape_html(s)),
4831                None => String::new(),
4832            };
4833            format!(
4834                "<div class=\"surfdoc-problem-list surfdoc-problem-list-preview\"{}>\
4835                 <div class=\"surfdoc-problem-empty\">\
4836                   <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" aria-hidden=\"true\"><polyline points=\"20 6 9 17 4 12\"/></svg>\
4837                   No problems detected\
4838                 </div>\
4839                 </div>",
4840                source_attr,
4841            )
4842        }
4843
4844        Block::Unknown {
4845            name, content, ..
4846        } => {
4847            // Render the content as markdown (sanitized) rather than dumping
4848            // the raw escaped source: a hallucinated block name (`::team`)
4849            // must not surface literal `##` / list dashes as visible copy.
4850            // Wrapper contract unchanged: class + role + data-name.
4851            format!(
4852                "<div class=\"surfdoc-unknown\" role=\"note\" data-name=\"{}\">{}</div>",
4853                escape_html(name),
4854                render_markdown(content),
4855            )
4856        }
4857
4858    }
4859}
4860
4861/// SVG icons for row blocks
4862fn row_icon_svg(icon: &str) -> &'static str {
4863    match icon {
4864        "sparkle" | "ai" => "<svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\"><path d=\"M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z\"/></svg>",
4865        "book" | "wiki" | "knowledge" => "<svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\"><path d=\"M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20\"/><path d=\"M8 7h6\"/><path d=\"M8 11h8\"/></svg>",
4866        "folder" => "<svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\"><path d=\"M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z\"/></svg>",
4867        "doc" | "file" => "<svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\"><path d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z\"/><polyline points=\"14 2 14 8 20 8\"/></svg>",
4868        "search" => "<svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\"><circle cx=\"11\" cy=\"11\" r=\"8\"/><line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\"/></svg>",
4869        "login" | "signin" => "<svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\"><path d=\"M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4\"/><polyline points=\"10 17 15 12 10 7\"/><line x1=\"15\" y1=\"12\" x2=\"3\" y2=\"12\"/></svg>",
4870        "task" | "check" => "<svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\"><polyline points=\"9 11 12 14 22 4\"/><path d=\"M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11\"/></svg>",
4871        _ => "<svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\"><circle cx=\"12\" cy=\"12\" r=\"10\"/></svg>",
4872    }
4873}
4874
4875/// Render a comparison cell value: "yes"/"true"/"✓" → green check, "no"/"false"/"✗"/"-" → muted dash, else literal with inline markdown.
4876fn comparison_cell(cell: &str) -> String {
4877    match cell.trim().to_lowercase().as_str() {
4878        "yes" | "true" | "✓" | "✔" => "<span class=\"surfdoc-check\">\u{2713}</span>".to_string(),
4879        "no" | "false" | "✗" | "✘" | "-" | "—" => "<span class=\"surfdoc-dash\">\u{2014}</span>".to_string(),
4880        _ => render_cell_inline_markdown(cell),
4881    }
4882}
4883
4884fn model_field_type_str(ft: &crate::types::ModelFieldType) -> String {
4885    use crate::types::ModelFieldType;
4886    match ft {
4887        ModelFieldType::Uuid => "uuid".to_string(),
4888        ModelFieldType::String => "string".to_string(),
4889        ModelFieldType::Int => "int".to_string(),
4890        ModelFieldType::Float => "float".to_string(),
4891        ModelFieldType::Bool => "bool".to_string(),
4892        ModelFieldType::Datetime => "datetime".to_string(),
4893        ModelFieldType::Text => "text".to_string(),
4894        ModelFieldType::Json => "json".to_string(),
4895        ModelFieldType::Money => "money".to_string(),
4896        ModelFieldType::Image => "image".to_string(),
4897        ModelFieldType::Email => "email".to_string(),
4898        ModelFieldType::Url => "url".to_string(),
4899        ModelFieldType::Enum(variants) => format!("enum({})", variants.join(", ")),
4900        ModelFieldType::Ref(target) => format!("ref({target})"),
4901    }
4902}
4903
4904fn constraint_str(c: &crate::types::FieldConstraint) -> String {
4905    use crate::types::FieldConstraint;
4906    match c {
4907        FieldConstraint::Primary => "primary".to_string(),
4908        FieldConstraint::Auto => "auto".to_string(),
4909        FieldConstraint::Required => "required".to_string(),
4910        FieldConstraint::Optional => "optional".to_string(),
4911        FieldConstraint::Unique => "unique".to_string(),
4912        FieldConstraint::Index => "index".to_string(),
4913        FieldConstraint::Max(n) => format!("max={n}"),
4914        FieldConstraint::Min(n) => format!("min={n}"),
4915        FieldConstraint::Default(v) => format!("default={v}"),
4916    }
4917}
4918
4919fn http_method_str(m: crate::types::HttpMethod) -> &'static str {
4920    use crate::types::HttpMethod;
4921    match m {
4922        HttpMethod::Get => "GET",
4923        HttpMethod::Post => "POST",
4924        HttpMethod::Put => "PUT",
4925        HttpMethod::Patch => "PATCH",
4926        HttpMethod::Delete => "DELETE",
4927    }
4928}
4929
4930fn auth_provider_str(p: crate::types::AuthProvider) -> &'static str {
4931    use crate::types::AuthProvider;
4932    match p {
4933        AuthProvider::Email => "email",
4934        AuthProvider::OAuth => "oauth",
4935        AuthProvider::ApiKey => "api-key",
4936        AuthProvider::Token => "token",
4937    }
4938}
4939
4940fn callout_type_str(ct: CalloutType) -> &'static str {
4941    match ct {
4942        CalloutType::Info => "info",
4943        CalloutType::Warning => "warning",
4944        CalloutType::Danger => "danger",
4945        CalloutType::Tip => "tip",
4946        CalloutType::Note => "note",
4947        CalloutType::Success => "success",
4948        CalloutType::Context => "context",
4949    }
4950}
4951
4952/// Returns true when a data-table cell should be right-aligned as a number.
4953/// Detects plain numbers, percentages, and currency-prefixed amounts after
4954/// stripping common formatting (commas, surrounding whitespace, leading sign,
4955/// and a single trailing `%` or leading currency symbol).
4956fn is_numeric_cell(cell: &str) -> bool {
4957    let trimmed = cell.trim();
4958    if trimmed.is_empty() {
4959        return false;
4960    }
4961    // Strip one leading currency symbol and one trailing percent sign.
4962    let mut s = trimmed;
4963    if let Some(rest) = s.strip_prefix(['$', '€', '£', '¥']) {
4964        s = rest;
4965    }
4966    let s = s.strip_suffix('%').unwrap_or(s);
4967    // Strip a leading sign.
4968    let s = s.strip_prefix(['+', '-']).unwrap_or(s);
4969    // Remove thousands separators.
4970    let cleaned: String = s.chars().filter(|c| *c != ',').collect();
4971    if cleaned.is_empty() {
4972        return false;
4973    }
4974    // Must contain at least one digit and parse as a float.
4975    cleaned.chars().any(|c| c.is_ascii_digit()) && cleaned.parse::<f64>().is_ok()
4976}
4977
4978/// Renders escaped code content, wrapping highlighted lines in
4979/// `<span class="surfdoc-code-hl">…</span>`.
4980///
4981/// `highlight` is a list of 1-based line specs as parsed in `blocks.rs`:
4982/// each entry is either a single line number (e.g. `"3"`) or an inclusive
4983/// range (e.g. `"5-7"`). Non-numeric or unparseable entries are ignored
4984/// (best-effort). When no lines match, the content is returned escaped as-is.
4985fn render_code_with_highlights(content: &str, highlight: &[String]) -> String {
4986    if highlight.is_empty() {
4987        return escape_html(content);
4988    }
4989    // Collect the set of 1-based line numbers to highlight.
4990    let mut lines_to_hl: std::collections::BTreeSet<usize> = std::collections::BTreeSet::new();
4991    for spec in highlight {
4992        let spec = spec.trim();
4993        if let Some((start, end)) = spec.split_once('-') {
4994            if let (Ok(s), Ok(e)) = (start.trim().parse::<usize>(), end.trim().parse::<usize>()) {
4995                let (lo, hi) = if s <= e { (s, e) } else { (e, s) };
4996                for n in lo..=hi {
4997                    lines_to_hl.insert(n);
4998                }
4999            }
5000        } else if let Ok(n) = spec.parse::<usize>() {
5001            lines_to_hl.insert(n);
5002        }
5003        // else: non-numeric spec ignored (best-effort).
5004    }
5005    if lines_to_hl.is_empty() {
5006        return escape_html(content);
5007    }
5008    let mut out = String::new();
5009    for (idx, line) in content.split('\n').enumerate() {
5010        if idx > 0 {
5011            out.push('\n');
5012        }
5013        let line_no = idx + 1; // 1-based
5014        if lines_to_hl.contains(&line_no) {
5015            out.push_str("<span class=\"surfdoc-code-hl\">");
5016            out.push_str(&escape_html(line));
5017            out.push_str("</span>");
5018        } else {
5019            out.push_str(&escape_html(line));
5020        }
5021    }
5022    out
5023}
5024
5025/// Inline SVG icon for a callout, per the reference block spec (Feather style).
5026/// Emitted with the `surfdoc-callout-icon` class so CSS can size/color it.
5027fn callout_icon_svg(ct: CalloutType) -> &'static str {
5028    // Paths sourced from A1-reference-block-spec.md §Callout.
5029    match ct {
5030        CalloutType::Warning => {
5031            "<svg class=\"surfdoc-callout-icon\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z\"/><line x1=\"12\" y1=\"9\" x2=\"12\" y2=\"13\"/><line x1=\"12\" y1=\"17\" x2=\"12.01\" y2=\"17\"/></svg>"
5032        }
5033        CalloutType::Danger => {
5034            "<svg class=\"surfdoc-callout-icon\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"><circle cx=\"12\" cy=\"12\" r=\"10\"/><line x1=\"15\" y1=\"9\" x2=\"9\" y2=\"15\"/><line x1=\"9\" y1=\"9\" x2=\"15\" y2=\"15\"/></svg>"
5035        }
5036        // Tip + info/note/success/context share the lightbulb tip icon.
5037        CalloutType::Tip
5038        | CalloutType::Info
5039        | CalloutType::Note
5040        | CalloutType::Success
5041        | CalloutType::Context => {
5042            "<svg class=\"surfdoc-callout-icon\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"M9 18h6M10 22h4M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.3 1 2.1h6c0-.8.4-1.6 1-2.1A7 7 0 0 0 12 2z\"/></svg>"
5043        }
5044    }
5045}
5046
5047fn decision_status_str(ds: DecisionStatus) -> &'static str {
5048    match ds {
5049        DecisionStatus::Proposed => "proposed",
5050        DecisionStatus::Accepted => "accepted",
5051        DecisionStatus::Rejected => "rejected",
5052        DecisionStatus::Superseded => "superseded",
5053    }
5054}
5055
5056fn capitalize(s: &str) -> String {
5057    let mut chars = s.chars();
5058    match chars.next() {
5059        None => String::new(),
5060        Some(c) => c.to_uppercase().to_string() + chars.as_str(),
5061    }
5062}
5063
5064// -- Multi-page site extraction and rendering --------------------------
5065
5066/// Extracted site-level configuration from a `::site` block.
5067#[derive(Debug, Clone, Default)]
5068pub struct SiteConfig {
5069    pub domain: Option<String>,
5070    pub name: Option<String>,
5071    pub description: Option<String>,
5072    pub tagline: Option<String>,
5073    /// The palette's *designed-for* hint ("light" | "dark"). NOT a pin: the
5074    /// rendered page follows the visitor's stored choice, then the device
5075    /// theme — this hint only orders the no-preference fallback
5076    /// (BR-SITE-THEME).
5077    pub theme: Option<String>,
5078    pub accent: Option<String>,
5079    pub font: Option<String>,
5080    /// The site's serving prefix (e.g. `/s/{slug}`), used to scope the
5081    /// visitor's persisted theme choice per site on a shared origin. `None`
5082    /// (standalone export on its own domain) scopes to `/`.
5083    pub base_path: Option<String>,
5084    pub properties: Vec<StyleProperty>,
5085}
5086
5087/// A single page extracted from a `::page` block.
5088#[derive(Debug, Clone)]
5089pub struct PageEntry {
5090    pub route: String,
5091    pub layout: Option<String>,
5092    pub title: Option<String>,
5093    pub sidebar: bool,
5094    pub children: Vec<Block>,
5095}
5096
5097impl PageEntry {
5098    /// Returns the human-readable display title for this page.
5099    ///
5100    /// If the page has an explicit `title`, returns that. Otherwise, converts
5101    /// the route to a readable label using [`humanize_route`].
5102    pub fn display_title(&self) -> String {
5103        self.title
5104            .clone()
5105            .unwrap_or_else(|| humanize_route(&self.route))
5106    }
5107}
5108
5109/// Convert a route path to a human-readable nav label.
5110///
5111/// `"/"` → `"Home"`, `"/gallery"` → `"Gallery"`, `"/about-us"` → `"About Us"`.
5112pub fn humanize_route(route: &str) -> String {
5113    let r = route.trim_matches('/');
5114    if r.is_empty() {
5115        return "Home".to_string();
5116    }
5117    r.split('-')
5118        .map(|word| {
5119            let mut c = word.chars();
5120            match c.next() {
5121                Some(first) => first.to_uppercase().collect::<String>() + c.as_str(),
5122                None => String::new(),
5123            }
5124        })
5125        .collect::<Vec<_>>()
5126        .join(" ")
5127}
5128
5129/// Extract site config and page list from a parsed SurfDoc.
5130///
5131/// Returns `(site_config, pages, loose_blocks)` where `loose_blocks` are
5132/// top-level blocks that are neither `Site` nor `Page`.
5133pub fn extract_site(doc: &SurfDoc) -> (Option<SiteConfig>, Vec<PageEntry>, Vec<Block>) {
5134    let mut site_config: Option<SiteConfig> = None;
5135    let mut pages: Vec<PageEntry> = Vec::new();
5136    let mut loose: Vec<Block> = Vec::new();
5137
5138    for block in &doc.blocks {
5139        match block {
5140            Block::Site {
5141                domain,
5142                properties,
5143                ..
5144            } => {
5145                let mut config = SiteConfig {
5146                    domain: domain.clone(),
5147                    properties: properties.clone(),
5148                    ..Default::default()
5149                };
5150                for prop in properties {
5151                    match prop.key.as_str() {
5152                        "name" => config.name = Some(prop.value.clone()),
5153                        "description" => config.description = Some(prop.value.clone()),
5154                        "tagline" => config.tagline = Some(prop.value.clone()),
5155                        "theme" => config.theme = Some(prop.value.clone()),
5156                        "accent" => config.accent = Some(prop.value.clone()),
5157                        "font" => config.font = Some(prop.value.clone()),
5158                        _ => {}
5159                    }
5160                }
5161                site_config = Some(config);
5162            }
5163            Block::Page {
5164                route,
5165                layout,
5166                title,
5167                sidebar,
5168                children,
5169                ..
5170            } => {
5171                pages.push(PageEntry {
5172                    route: route.clone(),
5173                    layout: layout.clone(),
5174                    title: title.clone(),
5175                    sidebar: *sidebar,
5176                    children: children.clone(),
5177                });
5178            }
5179            other => {
5180                loose.push(other.clone());
5181            }
5182        }
5183    }
5184
5185    (site_config, pages, loose)
5186}
5187
5188/// CSS for site-level navigation and footer (uses unified variable names).
5189const SITE_NAV_CSS: &str = r#"
5190/* Skip link (BR-SITE-A11Y): first focusable on every site page; visually
5191   hidden until keyboard focus, then a pill above the sticky nav. */
5192.surfdoc-skip-link { position: absolute; left: -9999px; top: 0.75rem; z-index: 200; background: var(--surface); color: var(--accent-ink, var(--accent)); padding: 0.5rem 1rem; border: 1px solid var(--border); border-radius: 6px; font-size: 0.875rem; font-weight: 600; text-decoration: none; }
5193.surfdoc-skip-link:focus { left: 0.75rem; }
5194
5195/* Site navigation — a sticky topbar (hamburger + logo on the left, theme
5196   toggle far right) that opens a LEFT DRAWER of page links at ALL screen sizes
5197   (the wavesite "app" nav, matching the Surf shell). Pure-CSS checkbox toggle
5198   + click-anywhere scrim; the SPA router also unchecks the toggle on navigate.
5199   Drawer look mirrors the Surf rich shell (.surfdoc-shell-drawer): a floating
5200   rounded panel with a brand head, close affordance, group label, and
5201   line-icon links. */
5202.surfdoc-site-nav { display: flex; align-items: center; gap: 10px; height: 60px; padding: 0 16px; background: var(--surface); border-bottom: 1px solid var(--border); max-width: 100%; position: sticky; top: 0; z-index: 100; }
5203.surfdoc-site-nav .site-name { order: 1; display: inline-flex; align-items: center; font-weight: 700; color: var(--text); font-size: 1rem; text-decoration: none; }
5204/* Monogram logo — accent rounded square standing in for a brand emblem
5205   (generated apps ship no logo asset). Used in the topbar anchor AND the
5206   drawer-head brand. */
5207.site-nav-logo { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; flex-shrink: 0; border-radius: 7px; background: var(--accent); color: var(--accent-text, #ffffff); font-size: 0.8rem; font-weight: 700; line-height: 1; margin-right: 8px; }
5208/* Hamburger — always visible, far left; a circular shell-style control that
5209   animates to an X when the drawer opens. */
5210.site-nav-hamburger { order: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 5px; width: 38px; height: 38px; flex-shrink: 0; cursor: pointer; border: 1px solid var(--border); border-radius: 50%; color: var(--text); transition: background 0.15s; }
5211.site-nav-hamburger:hover { background: var(--surface-hover); }
5212.site-nav-hamburger span { display: block; width: 18px; height: 2px; background: currentColor; border-radius: 1px; transition: transform 0.2s, opacity 0.2s; }
5213/* Toggle checkbox: visually hidden but focusable (keyboard-operable menu). */
5214.site-nav-toggle { position: absolute; width: 1px; height: 1px; margin: -1px; opacity: 0; pointer-events: none; }
5215.site-nav-toggle:focus-visible ~ .site-nav-hamburger { outline: 2px solid var(--accent-ink, var(--accent)); outline-offset: 2px; }
5216/* Left drawer holding the page links — a floating rounded panel inset 12px
5217   from the viewport edges, floating OVER the topbar (z 200 > nav z 100),
5218   matching the Surf shell drawer. Close via the head button or the scrim. */
5219.site-nav-links { position: fixed; top: 12px; bottom: 12px; left: 12px; z-index: 200; width: min(320px, calc(100vw - 24px)); display: flex; flex-direction: column; align-items: stretch; gap: 2px; padding: 18px 14px 24px; background: var(--surface); border: 1px solid var(--border); border-radius: 20px; box-shadow: 0 20px 60px rgba(15, 23, 42, 0.25); transform: translateX(calc(-100% - 16px)); transition: transform 280ms cubic-bezier(0.22, 1, 0.36, 1); overflow-y: auto; }
5220/* Drawer head: brand (monogram + name) left, circular close button right. */
5221.site-nav-drawer-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
5222.site-nav-drawer-brand { display: inline-flex; align-items: center; font-weight: 700; font-size: 1rem; color: var(--text); }
5223.site-nav-close { display: inline-flex; align-items: center; justify-content: center; width: 34px; height: 34px; flex-shrink: 0; border: 1px solid var(--border); border-radius: 50%; background: var(--surface); color: var(--text); cursor: pointer; transition: background 0.15s; }
5224.site-nav-close:hover { background: var(--surface-hover); }
5225.site-nav-close svg { width: 18px; height: 18px; }
5226/* Uppercase section label above the page links. */
5227.site-nav-group-label { font-size: 0.6875rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); padding: 14px 12px 6px; }
5228.site-nav-links a { display: flex; align-items: center; gap: 12px; color: var(--text); text-decoration: none; font-size: 0.9375rem; font-weight: 500; padding: 11px 12px; border-radius: 12px; transition: color 0.15s, background 0.15s; }
5229.site-nav-links a:hover { color: var(--accent); background: var(--surface-hover); }
5230.site-nav-links a.active { color: var(--accent-ink, var(--accent)); background: var(--accent-soft); font-weight: 600; }
5231/* Per-link line icon; follows the link color on hover/active. */
5232.site-nav-link-icon { display: inline-flex; width: 18px; height: 18px; flex-shrink: 0; color: var(--text-muted); transition: color 0.15s; }
5233.site-nav-link-icon svg { width: 18px; height: 18px; }
5234.site-nav-links a:hover .site-nav-link-icon { color: var(--accent); }
5235.site-nav-links a.active .site-nav-link-icon { color: var(--accent-ink, var(--accent)); }
5236.site-nav-toggle:checked ~ .site-nav-links { transform: translateX(0); }
5237/* Scrim behind the open drawer (click to close) — full-viewport, under the
5238   floating panel. */
5239.site-nav-scrim { position: fixed; inset: 0; z-index: 150; background: rgba(8, 12, 24, 0.30); opacity: 0; visibility: hidden; transition: opacity 220ms ease, visibility 220ms; cursor: pointer; }
5240.site-nav-toggle:checked ~ .site-nav-scrim { opacity: 1; visibility: visible; }
5241/* Hamburger → X when the drawer is open. */
5242.site-nav-toggle:checked ~ .site-nav-hamburger span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
5243.site-nav-toggle:checked ~ .site-nav-hamburger span:nth-child(2) { opacity: 0; }
5244.site-nav-toggle:checked ~ .site-nav-hamburger span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
5245/* Deeper panel shadow on dark surfaces — both theme arms, mirroring the
5246   theme-icon-swap pattern below. */
5247[data-theme="dark"] .site-nav-links { box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); }
5248@media (prefers-color-scheme: dark) {
5249  :root:not([data-theme]) .site-nav-links { box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); }
5250}
5251@media (prefers-reduced-motion: reduce) {
5252  .site-nav-links, .site-nav-scrim { transition: none; }
5253}
5254
5255/* Theme toggle (BR-SITE-THEME): ≥24px target, theme-aware icon swap; same
5256   circular shell-style control as the hamburger. */
5257.site-nav-theme-toggle { order: 2; display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px; flex-shrink: 0; margin-left: auto; padding: 0; background: none; border: 1px solid var(--border); border-radius: 50%; color: var(--text-muted); cursor: pointer; transition: color 0.15s, background 0.15s; }
5258.site-nav-theme-toggle:hover { background: var(--surface-hover); color: var(--text); }
5259.site-nav-theme-toggle svg { width: 18px; height: 18px; }
5260.site-nav-theme-toggle .site-theme-icon-sun { display: none; }
5261[data-theme="dark"] .site-nav-theme-toggle .site-theme-icon-sun { display: block; }
5262[data-theme="dark"] .site-nav-theme-toggle .site-theme-icon-moon { display: none; }
5263@media (prefers-color-scheme: dark) {
5264  :root:not([data-theme]) .site-nav-theme-toggle .site-theme-icon-sun { display: block; }
5265  :root:not([data-theme]) .site-nav-theme-toggle .site-theme-icon-moon { display: none; }
5266}
5267
5268/* Focus visibility (BR-SITE-A11Y): every interactive chrome element shows a
5269   ring; offset keeps it outside style-pack borders (Comic's 3px). */
5270.surfdoc-site-nav a:focus-visible, .site-nav-theme-toggle:focus-visible, .surfdoc-skip-link:focus-visible, .site-nav-close:focus-visible { outline: 2px solid var(--accent-ink, var(--accent)); outline-offset: 2px; }
5271
5272@media (max-width: 640px) {
5273  .surfdoc-site-nav { padding: 0 12px; }
5274}
5275
5276/* Site footer */
5277.surfdoc-site-footer { margin-top: 4rem; padding: 1.5rem; border-top: 1px solid var(--border); text-align: center; color: var(--text-faint); font-size: 0.8rem; }
5278
5279/* Single-file multi-page (SPA): one <section> per route; the client router
5280   toggles [hidden] to show exactly one at a time. !important defends against
5281   any block style that sets display on the section. */
5282.surfdoc-page[hidden] { display: none !important; }
5283"#;
5284
5285/// Sanitize a localStorage key for embedding inside a single-quoted JS string
5286/// in emitted markup: strip everything outside a conservative allowlist
5287/// (alphanumerics, `/ : _ -`). The platform constructs these keys from slugs,
5288/// so this is suspenders, not the law.
5289fn sanitize_js_key(key: &str) -> String {
5290    key.chars()
5291        .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | ':' | '_' | '-'))
5292        .collect()
5293}
5294
5295/// The accent monogram standing in for a logo emblem: first character of the
5296/// trimmed site name, uppercased and HTML-escaped ("?" for an empty name).
5297fn site_nav_monogram(site_name: &str) -> String {
5298    match site_name.trim().chars().next() {
5299        Some(c) => escape_html(&c.to_uppercase().to_string()),
5300        None => "?".to_string(),
5301    }
5302}
5303
5304/// Keyword-match a nav route/title to a small built-in line-icon set
5305/// (24×24 stroke `currentColor`, the lucide subset the Surf shell drawer
5306/// uses). Route is matched first, then the title; unknown pages get the
5307/// generic document icon. Purely cosmetic — a miss is never an error.
5308fn site_nav_link_icon(route: &str, title: &str) -> &'static str {
5309    const ICON_HOUSE: &str = r#"<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>"#;
5310    const ICON_BAG: &str = r#"<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z"/><line x1="3" y1="6" x2="21" y2="6"/><path d="M16 10a4 4 0 0 1-8 0"/></svg>"#;
5311    const ICON_USERS: &str = r#"<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>"#;
5312    const ICON_MAIL: &str = r#"<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>"#;
5313    const ICON_NEWS: &str = r#"<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2"/><line x1="18" y1="14" x2="10" y2="14"/><line x1="15" y1="18" x2="10" y2="18"/><rect x="10" y="6" width="8" height="4"/></svg>"#;
5314    const ICON_TAG: &str = r#"<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>"#;
5315    const ICON_IMAGE: &str = r#"<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>"#;
5316    const ICON_LAYERS: &str = r#"<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>"#;
5317    const ICON_HELP: &str = r#"<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>"#;
5318    const ICON_CALENDAR: &str = r#"<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>"#;
5319    const ICON_UTENSILS: &str = r#"<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 2v7c0 1.1.9 2 2 2h4a2 2 0 0 0 2-2V2"/><path d="M7 2v20"/><path d="M21 15V2a5 5 0 0 0-5 5v6c0 1.1.9 2 2 2h3zm0 0v7"/></svg>"#;
5320    const ICON_BOOK: &str = r#"<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>"#;
5321    const ICON_FILE_TEXT: &str = r#"<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>"#;
5322    // (keywords, icon) in priority order; first hit on the haystack wins.
5323    const MAP: &[(&[&str], &str)] = &[
5324        (&["home"], ICON_HOUSE),
5325        (&["shop", "store", "product"], ICON_BAG),
5326        (&["about", "team"], ICON_USERS),
5327        (&["contact"], ICON_MAIL),
5328        (&["blog", "news"], ICON_NEWS),
5329        (&["pricing", "plan"], ICON_TAG),
5330        (&["gallery", "portfolio", "photo"], ICON_IMAGE),
5331        (&["service"], ICON_LAYERS),
5332        (&["faq", "help", "support"], ICON_HELP),
5333        (&["event", "schedule", "calendar"], ICON_CALENDAR),
5334        (&["menu", "food"], ICON_UTENSILS),
5335        (&["docs", "guide", "learn"], ICON_BOOK),
5336    ];
5337    fn pick(hay: &str) -> Option<&'static str> {
5338        MAP.iter()
5339            .find(|(keys, _)| keys.iter().any(|k| hay.contains(k)))
5340            .map(|(_, icon)| *icon)
5341    }
5342    let route = route.to_lowercase();
5343    if route == "/" || route.is_empty() {
5344        return ICON_HOUSE;
5345    }
5346    pick(&route)
5347        .or_else(|| pick(&title.to_lowercase()))
5348        .unwrap_or(ICON_FILE_TEXT)
5349}
5350
5351/// Build the site-level navigation HTML bar.
5352///
5353/// Produces a `<nav class="surfdoc-site-nav">` element with:
5354/// - Site name (monogram + wordmark) as a linked logo
5355/// - Keyboard-operable hamburger toggle for mobile (focusable checkbox)
5356/// - A drawer panel: brand head with close affordance, "Pages" group label,
5357///   navigation links with per-page line icons, active-class +
5358///   `aria-current="page"` detection
5359/// - A light/dark theme toggle button writing `theme_key` (BR-SITE-THEME)
5360///
5361/// This is separate from the inline `Block::Nav` rendering in `to_html()`,
5362/// which uses `surfdoc-nav` class names and a different data model
5363/// (NavItem structs vs route/title pairs).
5364fn build_site_nav_html(
5365    site_name: &str,
5366    nav_items: &[(String, String)],
5367    current_route: &str,
5368    theme_key: &str,
5369    // When true, emit a single-file (SPA) nav: links are `#route` fragments and
5370    // carry `data-route` so the client router can switch sections and manage
5371    // active state. When false, links are clean URLs (one HTML file per route).
5372    spa: bool,
5373) -> String {
5374    let home_href = if spa { "#/" } else { "/" };
5375    let monogram = site_nav_monogram(site_name);
5376    let name = escape_html(site_name);
5377    // The topbar anchor's opening tag is byte-pinned by surf's container
5378    // (LOGO_NEEDLE, first occurrence rewritten to the site's base path) —
5379    // only its CONTENTS may change.
5380    let mut nav_html = format!(
5381        "<nav class=\"surfdoc-site-nav\" role=\"navigation\" aria-label=\"Site navigation\">\n  <a href=\"{home_href}\" class=\"site-name\"><span class=\"site-nav-logo\" aria-hidden=\"true\">{monogram}</span>{name}</a>\n",
5382    );
5383    // Hamburger toggle for mobile: the checkbox is focusable (visually hidden
5384    // by CSS, never display:none/aria-hidden) so the menu is keyboard-operable.
5385    nav_html.push_str("  <input type=\"checkbox\" class=\"site-nav-toggle\" id=\"site-nav-toggle\" aria-label=\"Toggle menu\">\n");
5386    nav_html.push_str("  <label for=\"site-nav-toggle\" class=\"site-nav-hamburger\" aria-hidden=\"true\"><span></span><span></span><span></span></label>\n");
5387    nav_html.push_str("  <div class=\"site-nav-links\">\n");
5388    // Drawer head — brand (a <span>, NOT a link: keeps LOGO_NEEDLE's
5389    // first-occurrence semantics trivially safe) + a close label for the
5390    // toggle. Decorative like the hamburger/scrim labels; keyboard users
5391    // operate the focusable checkbox.
5392    nav_html.push_str(&format!(
5393        "    <div class=\"site-nav-drawer-head\">\n      <span class=\"site-nav-drawer-brand\"><span class=\"site-nav-logo\" aria-hidden=\"true\">{monogram}</span>{name}</span>\n      <label for=\"site-nav-toggle\" class=\"site-nav-close\" aria-hidden=\"true\" title=\"Close menu\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><line x1=\"18\" y1=\"6\" x2=\"6\" y2=\"18\"/><line x1=\"6\" y1=\"6\" x2=\"18\" y2=\"18\"/></svg></label>\n    </div>\n",
5394    ));
5395    nav_html.push_str("    <span class=\"site-nav-group-label\">Pages</span>\n");
5396    for (route, nav_title) in nav_items {
5397        let href = if spa {
5398            format!("#{route}")
5399        } else {
5400            route.to_string()
5401        };
5402        let active = *route == current_route;
5403        // In SPA mode the client router reads `data-route` to switch sections;
5404        // it is appended AFTER the active class so clean-URL output (the
5405        // `else` branch) stays byte-identical to the pre-SPA renderer.
5406        let data_attr = if spa {
5407            format!(" data-route=\"{}\"", escape_html(route))
5408        } else {
5409            String::new()
5410        };
5411        nav_html.push_str(&format!(
5412            "    <a href=\"{}\"{}{}><span class=\"site-nav-link-icon\" aria-hidden=\"true\">{}</span>{}</a>\n",
5413            escape_html(&href),
5414            if active {
5415                " class=\"active\" aria-current=\"page\"".to_string()
5416            } else {
5417                String::new()
5418            },
5419            data_attr,
5420            site_nav_link_icon(route, nav_title),
5421            escape_html(nav_title),
5422        ));
5423    }
5424    nav_html.push_str("  </div>\n");
5425    // Theme toggle (BR-SITE-THEME): flips data-theme and persists the choice
5426    // under the per-site key. Markup order (after .site-nav-links) puts it at
5427    // the far right on desktop and keeps it visible in the mobile topbar.
5428    let key = sanitize_js_key(theme_key);
5429    nav_html.push_str(&format!(
5430        "  <button type=\"button\" class=\"site-nav-theme-toggle\" aria-label=\"Switch between light and dark theme\" title=\"Toggle theme\" onclick=\"(function(d){{var t=d.getAttribute('data-theme')==='dark'?'light':'dark';d.setAttribute('data-theme',t);try{{localStorage.setItem('{key}',t)}}catch(e){{}}}})(document.documentElement)\">",
5431    ));
5432    nav_html.push_str("<svg class=\"site-theme-icon-moon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z\"></path></svg>");
5433    nav_html.push_str("<svg class=\"site-theme-icon-sun\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><circle cx=\"12\" cy=\"12\" r=\"5\"></circle><line x1=\"12\" y1=\"1\" x2=\"12\" y2=\"3\"></line><line x1=\"12\" y1=\"21\" x2=\"12\" y2=\"23\"></line><line x1=\"4.22\" y1=\"4.22\" x2=\"5.64\" y2=\"5.64\"></line><line x1=\"18.36\" y1=\"18.36\" x2=\"19.78\" y2=\"19.78\"></line><line x1=\"1\" y1=\"12\" x2=\"3\" y2=\"12\"></line><line x1=\"21\" y1=\"12\" x2=\"23\" y2=\"12\"></line><line x1=\"4.22\" y1=\"19.78\" x2=\"5.64\" y2=\"18.36\"></line><line x1=\"18.36\" y1=\"5.64\" x2=\"19.78\" y2=\"4.22\"></line></svg>");
5434    // Scrim behind the open left drawer — a <label> for the toggle, so clicking
5435    // it closes the drawer with no JS. Sibling of the toggle ⇒ CSS `:checked ~`
5436    // drives it. Additive: keeps LOGO_NEEDLE / NAV_CLOSE_NEEDLE intact.
5437    nav_html.push_str("</button>\n  <label for=\"site-nav-toggle\" class=\"site-nav-scrim\" aria-hidden=\"true\"></label>\n</nav>");
5438    nav_html
5439}
5440
5441/// Render a full HTML page for one route within a multi-page site.
5442///
5443/// Produces a `<!DOCTYPE html>` page with site-level `<nav>`, page content,
5444/// and a footer. Theme and accent from `SiteConfig` are applied via CSS variables.
5445pub fn render_site_page(
5446    page: &PageEntry,
5447    site: &SiteConfig,
5448    nav_items: &[(String, String)], // (route, title) pairs
5449    config: &PageConfig,
5450) -> String {
5451    // Render page children as HTML, grouping consecutive CTAs
5452    let mut body_parts: Vec<String> = Vec::new();
5453    let mut cta_group: Vec<String> = Vec::new();
5454    for child in &page.children {
5455        if matches!(child, Block::Cta { .. }) {
5456            cta_group.push(render_block(child));
5457            continue;
5458        }
5459        if !cta_group.is_empty() {
5460            body_parts.push(format!("<div class=\"surfdoc-cta-group\">{}</div>", cta_group.join("\n")));
5461            cta_group.clear();
5462        }
5463        body_parts.push(render_block(child));
5464    }
5465    if !cta_group.is_empty() {
5466        body_parts.push(format!("<div class=\"surfdoc-cta-group\">{}</div>", cta_group.join("\n")));
5467    }
5468    // Heading/TOC post-pass (same as to_html): anchors prose headings —
5469    // including explicit `{#slug}` suffixes, which otherwise leak as copy.
5470    let body = wire_headings_and_toc(&body_parts.join("\n"));
5471
5472    let lang = config.lang.as_deref().unwrap_or("en");
5473    let site_name = site
5474        .name
5475        .as_deref()
5476        .unwrap_or("SurfDoc Site");
5477
5478    // Title: page title > humanized route + site name
5479    let title = match &page.title {
5480        Some(t) => format!("{} — {}", t, site_name),
5481        None if page.route == "/" => site_name.to_string(),
5482        None => format!("{} — {}", humanize_route(&page.route), site_name),
5483    };
5484
5485    let source_path = escape_html(&config.source_path);
5486
5487    // Per-site theme persistence key (BR-SITE-THEME): scoped to the site's
5488    // serving prefix so two sites on one origin keep independent choices.
5489    let base = site.base_path.as_deref().filter(|b| !b.is_empty()).unwrap_or("/");
5490    let theme_key = sanitize_js_key(&format!("surfdoc-site-theme:{base}"));
5491
5492    // Build navigation HTML (clean URLs — no /index.html suffix)
5493    let nav_html = build_site_nav_html(site_name, nav_items, &page.route, &theme_key, false);
5494
5495    // Build footer
5496    let footer_html = format!(
5497        "<footer class=\"surfdoc-site-footer\">{}</footer>",
5498        escape_html(site_name),
5499    );
5500
5501    // Build CSS variable overrides from site config. The accent-ink pair
5502    // (BR-SITE-A11Y) is ALWAYS emitted: the accent rendered AS text (active
5503    // nav link, prose links) adjusted to WCAG AA against each theme's
5504    // surfaces — the toggle means every palette faces both backgrounds.
5505    let mut css_overrides = String::new();
5506    if let Some(accent) = &site.accent {
5507        css_overrides.push_str(&format!("--accent: {};\n", escape_html(accent)));
5508        let text = accent_text_color(accent);
5509        css_overrides.push_str(&format!("--accent-text: {};\n", text));
5510    }
5511    let effective_accent = site.accent.as_deref().unwrap_or("#2563eb");
5512    let ink_light = accent_ink_color(effective_accent, false);
5513    let ink_dark = accent_ink_color(effective_accent, true);
5514    css_overrides.push_str(&format!("--accent-ink: {ink_light};\n"));
5515    let override_block = format!(
5516        "\n:root {{\n{css_overrides}}}\n\
5517         [data-theme=\"dark\"] {{ --accent-ink: {ink_dark}; }}\n\
5518         @media (prefers-color-scheme: dark) {{ :root:not([data-theme]) {{ --accent-ink: {ink_dark}; }} }}"
5519    );
5520
5521    // Build meta tags: description, canonical, OG, Twitter
5522    let mut meta_extra = String::new();
5523    let description = config.description.as_deref().or(site.description.as_deref());
5524    if let Some(desc) = description {
5525        meta_extra.push_str(&format!(
5526            "\n    <meta name=\"description\" content=\"{}\">",
5527            escape_html(desc)
5528        ));
5529    }
5530    if let Some(url) = &config.canonical_url {
5531        let url_escaped = escape_html(url);
5532        meta_extra.push_str(&format!(
5533            "\n    <link rel=\"canonical\" href=\"{}\">",
5534            url_escaped
5535        ));
5536        meta_extra.push_str(&format!(
5537            "\n    <meta property=\"og:url\" content=\"{}\">",
5538            url_escaped
5539        ));
5540    }
5541    let title_escaped = escape_html(&title);
5542    meta_extra.push_str(&format!(
5543        "\n    <meta property=\"og:title\" content=\"{}\">",
5544        title_escaped
5545    ));
5546    meta_extra.push_str("\n    <meta property=\"og:type\" content=\"website\">");
5547    if let Some(desc) = description {
5548        meta_extra.push_str(&format!(
5549            "\n    <meta property=\"og:description\" content=\"{}\">",
5550            escape_html(desc)
5551        ));
5552    }
5553    if let Some(img) = &config.og_image {
5554        meta_extra.push_str(&format!(
5555            "\n    <meta property=\"og:image\" content=\"{}\">",
5556            escape_html(img)
5557        ));
5558    }
5559    meta_extra.push_str("\n    <meta name=\"twitter:card\" content=\"summary\">");
5560    meta_extra.push_str(&format!(
5561        "\n    <meta name=\"twitter:title\" content=\"{}\">",
5562        title_escaped
5563    ));
5564    if let Some(desc) = description {
5565        meta_extra.push_str(&format!(
5566            "\n    <meta name=\"twitter:description\" content=\"{}\">",
5567            escape_html(desc)
5568        ));
5569    }
5570    if let Some(img) = &config.og_image {
5571        meta_extra.push_str(&format!(
5572            "\n    <meta name=\"twitter:image\" content=\"{}\">",
5573            escape_html(img)
5574        ));
5575    }
5576
5577    // BR-SITE-THEME: no hard-pinned data-theme. The pre-paint resolver sets
5578    // it before first paint: stored per-site choice → device preference →
5579    // the palette's designed-for hint (only when the device expresses
5580    // nothing). Without JS, the :root:not([data-theme]) media arms in
5581    // SURFDOC_CSS follow the device theme.
5582    let theme_hint = match site.theme.as_deref() {
5583        Some("dark") => "dark",
5584        _ => "light",
5585    };
5586    let theme_resolver = format!(
5587        "<script>(function(){{var d=document.documentElement;var s=null;\
5588         try{{s=localStorage.getItem('{theme_key}')}}catch(e){{}}\
5589         var mm=window.matchMedia;\
5590         var t=s||((mm&&mm('(prefers-color-scheme: dark)').matches)?'dark':\
5591         (mm&&mm('(prefers-color-scheme: light)').matches)?'light':'{theme_hint}');\
5592         d.setAttribute('data-theme',t);}})();</script>"
5593    );
5594
5595    crate::slots::resolve_slot_markers(format!(
5596        r##"<!-- Built with SurfDoc — source: {source_path} -->
5597<!DOCTYPE html>
5598<html lang="{lang}">
5599<head>
5600    <meta charset="utf-8">
5601    <meta name="viewport" content="width=device-width, initial-scale=1">
5602    <meta name="generator" content="SurfDoc v0.1">
5603    <link rel="alternate" type="text/surfdoc" href="{source_path}">
5604    <title>{title}</title>{meta_extra}
5605    <style>{css}{nav_css}{override_block}</style>
5606    {theme_resolver}
5607</head>
5608<body>
5609<a class="surfdoc-skip-link" href="#surfdoc-main">Skip to content</a>
5610{nav}
5611<main id="surfdoc-main" class="surfdoc">
5612{body}
5613</main>
5614{footer}
5615</body>
5616</html>"##,
5617        source_path = source_path,
5618        lang = escape_html(lang),
5619        title = title_escaped,
5620        meta_extra = meta_extra,
5621        css = SURFDOC_CSS,
5622        nav_css = SITE_NAV_CSS,
5623        override_block = override_block,
5624        theme_resolver = theme_resolver,
5625        nav = nav_html,
5626        body = body,
5627        footer = footer_html,
5628    ))
5629}
5630
5631/// Assemble a complete `<!DOCTYPE html>` site document from an already-rendered
5632/// `nav_html` and `body`. `extra_body` is injected just before `</body>` (e.g.
5633/// the single-file SPA router). Mirrors the document chrome emitted by
5634/// [`render_site_page`]; used by [`render_site_single_file`].
5635fn render_site_document(
5636    site: &SiteConfig,
5637    config: &PageConfig,
5638    title: &str,
5639    nav_html: &str,
5640    body: &str,
5641    extra_body: &str,
5642) -> String {
5643    let lang = config.lang.as_deref().unwrap_or("en");
5644    let site_name = site.name.as_deref().unwrap_or("SurfDoc Site");
5645    let source_path = escape_html(&config.source_path);
5646
5647    let base = site
5648        .base_path
5649        .as_deref()
5650        .filter(|b| !b.is_empty())
5651        .unwrap_or("/");
5652    let theme_key = sanitize_js_key(&format!("surfdoc-site-theme:{base}"));
5653
5654    let footer_html = format!(
5655        "<footer class=\"surfdoc-site-footer\">{}</footer>",
5656        escape_html(site_name),
5657    );
5658
5659    // CSS variable overrides from site config (accent + WCAG-AA accent-ink pair,
5660    // BR-SITE-A11Y) — identical to render_site_page().
5661    let mut css_overrides = String::new();
5662    if let Some(accent) = &site.accent {
5663        css_overrides.push_str(&format!("--accent: {};\n", escape_html(accent)));
5664        let text = accent_text_color(accent);
5665        css_overrides.push_str(&format!("--accent-text: {};\n", text));
5666    }
5667    let effective_accent = site.accent.as_deref().unwrap_or("#2563eb");
5668    let ink_light = accent_ink_color(effective_accent, false);
5669    let ink_dark = accent_ink_color(effective_accent, true);
5670    css_overrides.push_str(&format!("--accent-ink: {ink_light};\n"));
5671    let override_block = format!(
5672        "\n:root {{\n{css_overrides}}}\n\
5673         [data-theme=\"dark\"] {{ --accent-ink: {ink_dark}; }}\n\
5674         @media (prefers-color-scheme: dark) {{ :root:not([data-theme]) {{ --accent-ink: {ink_dark}; }} }}"
5675    );
5676
5677    let mut meta_extra = String::new();
5678    let description = config.description.as_deref().or(site.description.as_deref());
5679    if let Some(desc) = description {
5680        meta_extra.push_str(&format!(
5681            "\n    <meta name=\"description\" content=\"{}\">",
5682            escape_html(desc)
5683        ));
5684    }
5685    if let Some(url) = &config.canonical_url {
5686        let url_escaped = escape_html(url);
5687        meta_extra.push_str(&format!(
5688            "\n    <link rel=\"canonical\" href=\"{}\">",
5689            url_escaped
5690        ));
5691        meta_extra.push_str(&format!(
5692            "\n    <meta property=\"og:url\" content=\"{}\">",
5693            url_escaped
5694        ));
5695    }
5696    let title_escaped = escape_html(title);
5697    meta_extra.push_str(&format!(
5698        "\n    <meta property=\"og:title\" content=\"{}\">",
5699        title_escaped
5700    ));
5701    meta_extra.push_str("\n    <meta property=\"og:type\" content=\"website\">");
5702    if let Some(desc) = description {
5703        meta_extra.push_str(&format!(
5704            "\n    <meta property=\"og:description\" content=\"{}\">",
5705            escape_html(desc)
5706        ));
5707    }
5708    if let Some(img) = &config.og_image {
5709        meta_extra.push_str(&format!(
5710            "\n    <meta property=\"og:image\" content=\"{}\">",
5711            escape_html(img)
5712        ));
5713    }
5714    meta_extra.push_str("\n    <meta name=\"twitter:card\" content=\"summary\">");
5715    meta_extra.push_str(&format!(
5716        "\n    <meta name=\"twitter:title\" content=\"{}\">",
5717        title_escaped
5718    ));
5719    if let Some(desc) = description {
5720        meta_extra.push_str(&format!(
5721            "\n    <meta name=\"twitter:description\" content=\"{}\">",
5722            escape_html(desc)
5723        ));
5724    }
5725    if let Some(img) = &config.og_image {
5726        meta_extra.push_str(&format!(
5727            "\n    <meta name=\"twitter:image\" content=\"{}\">",
5728            escape_html(img)
5729        ));
5730    }
5731
5732    let theme_hint = match site.theme.as_deref() {
5733        Some("dark") => "dark",
5734        _ => "light",
5735    };
5736    let theme_resolver = format!(
5737        "<script>(function(){{var d=document.documentElement;var s=null;\
5738         try{{s=localStorage.getItem('{theme_key}')}}catch(e){{}}\
5739         var mm=window.matchMedia;\
5740         var t=s||((mm&&mm('(prefers-color-scheme: dark)').matches)?'dark':\
5741         (mm&&mm('(prefers-color-scheme: light)').matches)?'light':'{theme_hint}');\
5742         d.setAttribute('data-theme',t);}})();</script>"
5743    );
5744
5745    format!(
5746        r##"<!-- Built with SurfDoc — source: {source_path} -->
5747<!DOCTYPE html>
5748<html lang="{lang}">
5749<head>
5750    <meta charset="utf-8">
5751    <meta name="viewport" content="width=device-width, initial-scale=1">
5752    <meta name="generator" content="SurfDoc v0.1">
5753    <link rel="alternate" type="text/surfdoc" href="{source_path}">
5754    <title>{title}</title>{meta_extra}
5755    <style>{css}{nav_css}{override_block}</style>
5756    {theme_resolver}
5757</head>
5758<body>
5759<a class="surfdoc-skip-link" href="#surfdoc-main">Skip to content</a>
5760{nav}
5761<main id="surfdoc-main" class="surfdoc">
5762{body}
5763</main>
5764{footer}
5765{extra_body}
5766</body>
5767</html>"##,
5768        source_path = source_path,
5769        lang = escape_html(lang),
5770        title = title_escaped,
5771        meta_extra = meta_extra,
5772        css = SURFDOC_CSS,
5773        nav_css = SITE_NAV_CSS,
5774        override_block = override_block,
5775        theme_resolver = theme_resolver,
5776        nav = nav_html,
5777        body = body,
5778        footer = footer_html,
5779        extra_body = extra_body,
5780    )
5781}
5782
5783/// Render a page's child blocks to body HTML, grouping consecutive CTAs into a
5784/// `.surfdoc-cta-group` wrapper. Shared by the single-file multi-page renderer.
5785fn render_page_body(children: &[Block]) -> String {
5786    let mut body_parts: Vec<String> = Vec::new();
5787    let mut cta_group: Vec<String> = Vec::new();
5788    for child in children {
5789        if matches!(child, Block::Cta { .. }) {
5790            cta_group.push(render_block(child));
5791            continue;
5792        }
5793        if !cta_group.is_empty() {
5794            body_parts.push(format!(
5795                "<div class=\"surfdoc-cta-group\">{}</div>",
5796                cta_group.join("\n")
5797            ));
5798            cta_group.clear();
5799        }
5800        body_parts.push(render_block(child));
5801    }
5802    if !cta_group.is_empty() {
5803        body_parts.push(format!(
5804            "<div class=\"surfdoc-cta-group\">{}</div>",
5805            cta_group.join("\n")
5806        ));
5807    }
5808    body_parts.join("\n")
5809}
5810
5811/// The client-side router for single-file multi-page sites.
5812///
5813/// All routes live in one HTML document as `<section class="surfdoc-page"
5814/// data-route="…">` elements; exactly one is visible at a time. The router:
5815/// - shows the section matching `location.hash` (default `/`, falling back to
5816///   the first route if the hash is unknown),
5817/// - keeps the nav's active class + `aria-current` in sync,
5818/// - intercepts clicks on internal `#route` and absolute `/route` links that
5819///   match a known route, converting them to hash navigation (so authored
5820///   `[Browse](/browse)` links resolve without a server),
5821/// - closes the mobile menu and resets scroll on each navigation.
5822///
5823/// `{routes}` is replaced with a JSON array of the site's routes.
5824const SITE_SPA_ROUTER_JS: &str = r#"<script>(function(){
5825var routes=__ROUTES__;
5826function norm(h){h=(h||'').replace(/^#/,'');if(!h||h==='/')return '/';return h.replace(/\/+$/,'')||'/';}
5827function pick(r){return routes.indexOf(r)>=0?r:(routes.indexOf('/')>=0?'/':routes[0]);}
5828function show(r){
5829 r=pick(r);
5830 var secs=document.querySelectorAll('.surfdoc-page');
5831 for(var i=0;i<secs.length;i++){secs[i].hidden=(secs[i].getAttribute('data-route')!==r);}
5832 var links=document.querySelectorAll('.site-nav-links a[data-route]');
5833 for(var j=0;j<links.length;j++){
5834  var on=links[j].getAttribute('data-route')===r;
5835  links[j].classList.toggle('active',on);
5836  if(on){links[j].setAttribute('aria-current','page');}else{links[j].removeAttribute('aria-current');}
5837 }
5838 var t=document.getElementById('site-nav-toggle');if(t){t.checked=false;}
5839 var m=document.getElementById('surfdoc-main');if(m){m.scrollTop=0;}
5840 try{window.scrollTo(0,0);}catch(e){}
5841}
5842function onhash(){show(norm(location.hash));}
5843document.addEventListener('click',function(e){
5844 var a=e.target&&e.target.closest?e.target.closest('a[href]'):null;
5845 if(!a)return;
5846 var href=a.getAttribute('href');if(!href)return;
5847 var r=null;
5848 if(href.charAt(0)==='#'&&href.charAt(1)==='/'){r=norm(href);}
5849 else if(href.charAt(0)==='/'&&href.charAt(1)!=='/'){var c=norm(href);if(routes.indexOf(c)>=0){r=c;}}
5850 if(r!==null){e.preventDefault();if(norm(location.hash)===r){onhash();}else{location.hash=r;}}
5851});
5852window.addEventListener('hashchange',onhash);
5853if(document.readyState!=='loading'){onhash();}else{document.addEventListener('DOMContentLoaded',onhash);}
5854})();</script>"#;
5855
5856/// Render an entire multi-page site as a SINGLE self-contained HTML document.
5857///
5858/// Every `::page` route becomes a `<section class="surfdoc-page" data-route>`
5859/// inside one `<main>`; a shared `::nav`/footer chrome wraps them and the
5860/// inlined [`SITE_SPA_ROUTER_JS`] switches sections client-side. This is the
5861/// model used by the example showcase, where each app is one artifact loaded
5862/// in an iframe (absolute-path nav can't work across nested iframe origins).
5863///
5864/// Contrast [`render_site_page`], which emits one file per route (clean URLs,
5865/// needs a server that maps routes → files).
5866pub fn render_site_single_file(
5867    site: &SiteConfig,
5868    pages: &[PageEntry],
5869    nav_items: &[(String, String)],
5870    config: &PageConfig,
5871) -> String {
5872    let site_name = site.name.as_deref().unwrap_or("SurfDoc Site");
5873    // The document title is the home/site title; per-route <title> updates are
5874    // not attempted (single document, single <title>).
5875    let title = site_name.to_string();
5876
5877    // Per-site theme persistence key (BR-SITE-THEME) for the nav theme toggle.
5878    let base = site
5879        .base_path
5880        .as_deref()
5881        .filter(|b| !b.is_empty())
5882        .unwrap_or("/");
5883    let theme_key = sanitize_js_key(&format!("surfdoc-site-theme:{base}"));
5884
5885    // SPA nav: `#route` links + data-route for the client router. Initial
5886    // active route is "/" (or the first route if there is no home).
5887    let has_home = pages.iter().any(|p| p.route == "/");
5888    let initial_route = if has_home {
5889        "/"
5890    } else {
5891        pages.first().map(|p| p.route.as_str()).unwrap_or("/")
5892    };
5893    let nav_html = build_site_nav_html(site_name, nav_items, initial_route, &theme_key, true);
5894
5895    // One <section> per route; the initial route is visible, the rest hidden.
5896    let mut sections = String::new();
5897    for page in pages {
5898        let inner = render_page_body(&page.children);
5899        let visible = page.route == initial_route;
5900        sections.push_str(&format!(
5901            "<section class=\"surfdoc-page\" data-route=\"{}\"{}>\n{}\n</section>\n",
5902            escape_html(&page.route),
5903            if visible { "" } else { " hidden" },
5904            inner,
5905        ));
5906    }
5907
5908    // Routes as a JSON array for the inlined router.
5909    let routes_json = format!(
5910        "[{}]",
5911        pages
5912            .iter()
5913            .map(|p| format!("\"{}\"", p.route.replace('\\', "\\\\").replace('"', "\\\"")))
5914            .collect::<Vec<_>>()
5915            .join(",")
5916    );
5917    let router = SITE_SPA_ROUTER_JS.replace("__ROUTES__", &routes_json);
5918
5919    // Heading/TOC post-pass over all sections at once so anchor slugs stay
5920    // unique across the whole single-file document.
5921    let sections = wire_headings_and_toc(&sections);
5922
5923    crate::slots::resolve_slot_markers(render_site_document(
5924        site, config, &title, &nav_html, &sections, &router,
5925    ))
5926}
5927
5928#[cfg(test)]
5929mod tests {
5930    use super::*;
5931    use crate::types::*;
5932
5933    fn span() -> Span {
5934        Span {
5935            start_line: 1,
5936            end_line: 1,
5937            start_offset: 0,
5938            end_offset: 0,
5939        }
5940    }
5941
5942    fn doc_with(blocks: Vec<Block>) -> SurfDoc {
5943        SurfDoc {
5944            front_matter: None,
5945            blocks,
5946            source: String::new(),
5947        }
5948    }
5949
5950    #[test]
5951    fn html_callout() {
5952        let doc = doc_with(vec![Block::Callout {
5953            callout_type: CalloutType::Warning,
5954            title: Some("Caution".into()),
5955            content: "Be careful.".into(),
5956            span: span(),
5957        }]);
5958        let html = to_html(&doc);
5959        assert!(html.contains("class=\"surfdoc-callout surfdoc-callout-warning\""));
5960        assert!(html.contains("class=\"surfdoc-callout-icon\""));
5961        assert!(html.contains("<div class=\"surfdoc-callout-title\">Caution</div>"));
5962        assert!(html.contains("class=\"surfdoc-callout-body\""));
5963        assert!(html.contains("Be careful."));
5964    }
5965
5966    #[test]
5967    fn html_data_table() {
5968        let doc = doc_with(vec![Block::Data {
5969            id: None,
5970            format: DataFormat::Table,
5971            sortable: false,
5972            headers: vec!["Name".into(), "Age".into()],
5973            rows: vec![vec!["Alice".into(), "30".into()]],
5974            raw_content: String::new(),
5975            span: span(),
5976        }]);
5977        let html = to_html(&doc);
5978        assert!(html.contains("<table class=\"surfdoc-data\">"));
5979        assert!(html.contains("<thead>"));
5980        assert!(html.contains("<tbody>"));
5981        assert!(html.contains("<th scope=\"col\" aria-sort=\"none\">Name</th>"));
5982        assert!(html.contains("<td>Alice</td>"));
5983        // "30" is numeric and should be right-aligned via the num class.
5984        assert!(html.contains("<td class=\"num\">30</td>"));
5985    }
5986
5987    #[test]
5988    fn data_table_cell_inline_link_relative() {
5989        let doc = doc_with(vec![Block::Data {
5990            id: None,
5991            format: DataFormat::Table,
5992            sortable: false,
5993            headers: vec!["Company".into()],
5994            rows: vec![vec!["[ZAPiT Games](zapit-games)".into()]],
5995            raw_content: String::new(),
5996            span: span(),
5997        }]);
5998        let html = to_html(&doc);
5999        assert!(
6000            html.contains("<td><a href=\"/wiki/zapit-games\">ZAPiT Games</a></td>"),
6001            "Relative link should get /wiki/ prefix. Got: {}",
6002            html
6003        );
6004    }
6005
6006    #[test]
6007    fn data_table_cell_inline_link_absolute() {
6008        let doc = doc_with(vec![Block::Data {
6009            id: None,
6010            format: DataFormat::Table,
6011            sortable: false,
6012            headers: vec!["Link".into()],
6013            rows: vec![vec!["[Example](https://example.com)".into()]],
6014            raw_content: String::new(),
6015            span: span(),
6016        }]);
6017        let html = to_html(&doc);
6018        assert!(
6019            html.contains("<td><a href=\"https://example.com\">Example</a></td>"),
6020            "Absolute link should keep full URL. Got: {}",
6021            html
6022        );
6023    }
6024
6025    #[test]
6026    fn data_table_cell_inline_bold() {
6027        let doc = doc_with(vec![Block::Data {
6028            id: None,
6029            format: DataFormat::Table,
6030            sortable: false,
6031            headers: vec!["Status".into()],
6032            rows: vec![vec!["**Active**".into()]],
6033            raw_content: String::new(),
6034            span: span(),
6035        }]);
6036        let html = to_html(&doc);
6037        assert!(
6038            html.contains("<td><strong>Active</strong></td>"),
6039            "Bold should render as <strong>. Got: {}",
6040            html
6041        );
6042    }
6043
6044    #[test]
6045    fn data_table_cell_inline_italic() {
6046        let doc = doc_with(vec![Block::Data {
6047            id: None,
6048            format: DataFormat::Table,
6049            sortable: false,
6050            headers: vec!["Note".into()],
6051            rows: vec![vec!["*pending*".into()]],
6052            raw_content: String::new(),
6053            span: span(),
6054        }]);
6055        let html = to_html(&doc);
6056        assert!(
6057            html.contains("<td><em>pending</em></td>"),
6058            "Italic should render as <em>. Got: {}",
6059            html
6060        );
6061    }
6062
6063    // p1-fill-render: a `«FILL: label | default»` marker surviving intact
6064    // through a table cell (split_pipe_row must not split on the marker's own
6065    // `|`) and through cell rendering (render_cell_inline_markdown must not
6066    // parse markdown inside the marker) resolves cleanly end-to-end.
6067    #[test]
6068    fn data_table_cell_fill_marker_resolves_cleanly() {
6069        let doc = doc_with(vec![Block::Data {
6070            id: None,
6071            format: DataFormat::Table,
6072            sortable: false,
6073            headers: vec!["Item".into()],
6074            rows: vec![vec!["\u{ab}FILL: deck price | $120\u{bb}".into()]],
6075            raw_content: String::new(),
6076            span: span(),
6077        }]);
6078        let html = crate::slots::resolve_slot_markers(to_html(&doc));
6079        assert!(
6080            !html.contains('\u{ab}') && !html.contains('\u{bb}'),
6081            "marker must not leak raw. Got: {html}"
6082        );
6083        assert!(html.contains("<td>$120</td>"), "default text must render. Got: {html}");
6084    }
6085
6086    #[test]
6087    fn data_table_cell_inline_mixed() {
6088        let doc = doc_with(vec![Block::Data {
6089            id: None,
6090            format: DataFormat::Table,
6091            sortable: false,
6092            headers: vec!["Info".into()],
6093            rows: vec![vec!["See **[Docs](https://docs.example.com)** for *details*".into()]],
6094            raw_content: String::new(),
6095            span: span(),
6096        }]);
6097        let html = to_html(&doc);
6098        assert!(
6099            html.contains("<strong><a href=\"https://docs.example.com\">Docs</a></strong>"),
6100            "Bold wrapping a link should render both. Got: {}",
6101            html
6102        );
6103        assert!(
6104            html.contains("<em>details</em>"),
6105            "Italic should render. Got: {}",
6106            html
6107        );
6108    }
6109
6110    #[test]
6111    fn data_table_cell_inline_xss_safety() {
6112        let doc = doc_with(vec![Block::Data {
6113            id: None,
6114            format: DataFormat::Table,
6115            sortable: false,
6116            headers: vec!["Input".into()],
6117            rows: vec![vec!["<script>alert(1)</script> and [safe](link)".into()]],
6118            raw_content: String::new(),
6119            span: span(),
6120        }]);
6121        let html = to_html(&doc);
6122        assert!(
6123            !html.contains("<script>"),
6124            "Script tags must be escaped. Got: {}",
6125            html
6126        );
6127        assert!(
6128            html.contains("&lt;script&gt;"),
6129            "Script tags must be HTML-escaped. Got: {}",
6130            html
6131        );
6132        assert!(
6133            html.contains("<a href=\"/wiki/link\">safe</a>"),
6134            "Link should still render. Got: {}",
6135            html
6136        );
6137    }
6138
6139    #[test]
6140    fn data_table_header_inline_markdown() {
6141        let doc = doc_with(vec![Block::Data {
6142            id: None,
6143            format: DataFormat::Table,
6144            sortable: false,
6145            headers: vec!["**Bold Header**".into(), "*Italic Header*".into()],
6146            rows: vec![vec!["a".into(), "b".into()]],
6147            raw_content: String::new(),
6148            span: span(),
6149        }]);
6150        let html = to_html(&doc);
6151        assert!(
6152            html.contains("<th scope=\"col\" aria-sort=\"none\"><strong>Bold Header</strong></th>"),
6153            "Header bold should render. Got: {}",
6154            html
6155        );
6156        assert!(
6157            html.contains("<th scope=\"col\" aria-sort=\"none\"><em>Italic Header</em></th>"),
6158            "Header italic should render. Got: {}",
6159            html
6160        );
6161    }
6162
6163    #[test]
6164    fn pricing_table_cell_inline_link() {
6165        // Row = tier: col 0 name, col 1 price, remaining cols = feature bullets
6166        // (which render inline markdown, so links work).
6167        let doc = doc_with(vec![Block::PricingTable {
6168            headers: vec!["Tier".into(), "Price".into(), "Docs".into()],
6169            rows: vec![vec![
6170                "Pro".into(),
6171                "$9".into(),
6172                "[Details](https://example.com/pricing)".into(),
6173            ]],
6174            span: span(),
6175        }]);
6176        let html = to_html(&doc);
6177        assert!(
6178            html.contains("<a href=\"https://example.com/pricing\">Details</a>"),
6179            "Pricing tier feature bullets should render inline links. Got: {}",
6180            html
6181        );
6182    }
6183
6184    #[test]
6185    fn data_table_cell_plain_text_unchanged() {
6186        let doc = doc_with(vec![Block::Data {
6187            id: None,
6188            format: DataFormat::Table,
6189            sortable: false,
6190            headers: vec!["Name".into()],
6191            rows: vec![vec!["Plain text".into()]],
6192            raw_content: String::new(),
6193            span: span(),
6194        }]);
6195        let html = to_html(&doc);
6196        assert!(
6197            html.contains("<td>Plain text</td>"),
6198            "Plain text should remain unchanged. Got: {}",
6199            html
6200        );
6201    }
6202
6203    #[test]
6204    fn html_code() {
6205        let doc = doc_with(vec![Block::Code {
6206            lang: Some("rust".into()),
6207            file: None,
6208            highlight: vec![],
6209            content: "fn main() { println!(\"<hello>\"); }".into(),
6210            span: span(),
6211        }]);
6212        let html = to_html(&doc);
6213        assert!(html.contains("<figure class=\"surfdoc-code\">"));
6214        assert!(html.contains("<pre aria-label=\"rust code\" data-lang=\"rust\">"));
6215        assert!(html.contains("class=\"language-rust\""));
6216        // No file given, but lang badge should still render in the head.
6217        assert!(html.contains("<figcaption class=\"surfdoc-code-head\">"));
6218        assert!(html.contains("<span class=\"surfdoc-code-lang\">RUST</span>"));
6219        assert!(!html.contains("surfdoc-code-file"), "No file span when file is None");
6220        assert!(html.contains("&lt;hello&gt;"), "Angle brackets should be escaped");
6221    }
6222
6223    #[test]
6224    fn html_code_with_file_and_highlight() {
6225        let doc = doc_with(vec![Block::Code {
6226            lang: Some("rust".into()),
6227            file: Some("main.rs".into()),
6228            highlight: vec!["2".into(), "4-5".into()],
6229            content: "line one\nline two\nline three\nline four\nline five".into(),
6230            span: span(),
6231        }]);
6232        let html = to_html(&doc);
6233        assert!(html.contains("<span class=\"surfdoc-code-file\">main.rs</span>"));
6234        assert!(html.contains("<span class=\"surfdoc-code-lang\">RUST</span>"));
6235        // Lines 2, 4, 5 highlighted; lines 1 and 3 not.
6236        assert!(html.contains("<span class=\"surfdoc-code-hl\">line two</span>"));
6237        assert!(html.contains("<span class=\"surfdoc-code-hl\">line four</span>"));
6238        assert!(html.contains("<span class=\"surfdoc-code-hl\">line five</span>"));
6239        assert!(!html.contains("<span class=\"surfdoc-code-hl\">line one</span>"));
6240        assert!(!html.contains("<span class=\"surfdoc-code-hl\">line three</span>"));
6241    }
6242
6243    #[test]
6244    fn html_tasks() {
6245        let doc = doc_with(vec![Block::Tasks {
6246            items: vec![
6247                TaskItem {
6248                    done: true,
6249                    text: "Done item".into(),
6250                    assignee: None,
6251                },
6252                TaskItem {
6253                    done: false,
6254                    text: "Pending item".into(),
6255                    assignee: None,
6256                },
6257            ],
6258            span: span(),
6259        }]);
6260        let html = to_html(&doc);
6261        assert!(html.contains("<li class=\"surfdoc-task is-done\">"));
6262        assert!(html.contains("<li class=\"surfdoc-task\">"));
6263        assert!(html.contains("<span class=\"surfdoc-check\">\u{2713}</span>"));
6264        assert!(html.contains("<span class=\"surfdoc-check\"></span>"));
6265        assert!(html.contains("<span class=\"surfdoc-task-text\">Done item</span>"));
6266        assert!(html.contains("<span class=\"surfdoc-task-text\">Pending item</span>"));
6267    }
6268
6269    #[test]
6270    fn html_tasks_assignee() {
6271        let doc = doc_with(vec![Block::Tasks {
6272            items: vec![TaskItem {
6273                done: false,
6274                text: "Ship it".into(),
6275                assignee: Some("brady".into()),
6276            }],
6277            span: span(),
6278        }]);
6279        let html = to_html(&doc);
6280        assert!(html.contains("<span class=\"surfdoc-assignee\">@brady</span>"));
6281    }
6282
6283    #[test]
6284    fn html_metric() {
6285        let doc = doc_with(vec![Block::Metric {
6286            label: "Revenue".into(),
6287            value: "$10K".into(),
6288            trend: Some(Trend::Up),
6289            unit: None,
6290            span: span(),
6291        }]);
6292        let html = to_html(&doc);
6293        assert!(html.contains("class=\"surfdoc-metric-row\""));
6294        assert!(html.contains("class=\"surfdoc-metric\""));
6295        assert!(html.contains("<div class=\"surfdoc-metric-value\">$10K</div>"));
6296        assert!(html.contains("<div class=\"surfdoc-metric-label\">Revenue"));
6297        assert!(html.contains("class=\"surfdoc-trend surfdoc-trend--up\""));
6298    }
6299
6300    #[test]
6301    fn html_figure() {
6302        let doc = doc_with(vec![Block::Figure {
6303            src: "arch.png".into(),
6304            caption: Some("Architecture diagram".into()),
6305            alt: Some("System architecture".into()),
6306            width: None,
6307            span: span(),
6308        }]);
6309        let html = to_html(&doc);
6310        assert!(html.contains("<figure class=\"surfdoc-figure\">"));
6311        assert!(html.contains("img src=\"arch.png\" alt=\"System architecture\""));
6312        assert!(html.contains("onerror="));
6313        assert!(html.contains("Architecture diagram"));
6314    }
6315
6316    #[test]
6317    fn html_diagram_architecture_success() {
6318        let doc = doc_with(vec![Block::Diagram {
6319            diagram_type: "architecture".into(),
6320            title: Some("System Map".into()),
6321            content: "web: Web\napi: API\nweb -> api: HTTPS".into(),
6322            span: span(),
6323        }]);
6324        let html = to_html(&doc);
6325        assert!(html.contains("<figure class=\"surfdoc-diagram surfdoc-diagram-architecture\">"));
6326        assert!(html.contains("<figcaption class=\"surfdoc-diagram-cap\">System Map</figcaption>"));
6327        assert!(html.contains("<svg class=\"surfdoc-diagram-svg\""));
6328        assert!(html.contains("surfdoc-diagram-node"));
6329        assert!(!html.contains("surfdoc-diagram-fallback"));
6330    }
6331
6332    #[test]
6333    fn html_diagram_erd_success() {
6334        let doc = doc_with(vec![Block::Diagram {
6335            diagram_type: "erd".into(),
6336            title: None,
6337            content: "users: id pk\norders: id pk, user_id fk\nusers 1--* orders".into(),
6338            span: span(),
6339        }]);
6340        let html = to_html(&doc);
6341        assert!(html.contains("<figure class=\"surfdoc-diagram surfdoc-diagram-erd\">"));
6342        assert!(html.contains("surfdoc-diagram-entity"));
6343        // No title attr -> no figcaption.
6344        assert!(!html.contains("surfdoc-diagram-cap"));
6345    }
6346
6347    #[test]
6348    fn html_diagram_unknown_type_falls_back() {
6349        // render_block directly: page chrome has its own inline svg icons,
6350        // so the no-svg assertion must scope to the block fragment.
6351        let html = render_block(&Block::Diagram {
6352            diagram_type: "venn".into(),
6353            title: Some("Later".into()),
6354            content: "start => end".into(),
6355            span: span(),
6356        });
6357        assert!(html.contains("<figure class=\"surfdoc-diagram surfdoc-diagram-fallback\">"));
6358        assert!(html.contains("<figcaption class=\"surfdoc-diagram-cap\">Later</figcaption>"));
6359        assert!(html.contains("<pre class=\"surfdoc-diagram-src\">start =&gt; end</pre>"));
6360        assert!(!html.contains("<svg"));
6361    }
6362
6363    #[test]
6364    fn html_diagram_malformed_dsl_falls_back() {
6365        // Malformed DSL must NEVER fail a render — prose fallback always.
6366        let doc = doc_with(vec![Block::Diagram {
6367            diagram_type: "architecture".into(),
6368            title: None,
6369            content: "this is not a node or an edge".into(),
6370            span: span(),
6371        }]);
6372        let html = to_html(&doc);
6373        assert!(html.contains("surfdoc-diagram-fallback"));
6374        assert!(html.contains("<pre class=\"surfdoc-diagram-src\">this is not a node or an edge</pre>"));
6375    }
6376
6377    #[test]
6378    fn html_markdown_rendered() {
6379        let doc = doc_with(vec![Block::Markdown {
6380            content: "# Hello\n\nWorld".into(),
6381            span: span(),
6382        }]);
6383        let html = to_html(&doc);
6384        // Prose headings get an auto-generated anchor id (for TOC linking).
6385        assert!(html.contains("<h1 id=\"hello\">Hello</h1>"));
6386    }
6387
6388    #[test]
6389    fn toc_auto_generates_from_headings() {
6390        // ::toc[depth=2] should list document headings up to level 2, linked to
6391        // the auto-generated heading anchors. Deeper headings (### …) are excluded.
6392        let doc = doc_with(vec![
6393            Block::Toc { depth: 2, entries: Vec::new(), span: span() },
6394            Block::Markdown {
6395                content: "# Overview\n\n## Setup\n\n### Detail\n\n## Usage".into(),
6396                span: span(),
6397            },
6398        ]);
6399        let html = to_html(&doc);
6400        // TOC is populated (no longer an empty <nav>) and carries a "Contents" label.
6401        assert!(html.contains("<nav class=\"surfdoc-toc\" data-depth=\"2\"><div class=\"surfdoc-toc-label\">Contents</div><ol>"));
6402        assert!(html.contains("<a href=\"#overview\">Overview</a>"));
6403        assert!(html.contains("<a href=\"#setup\">Setup</a>"));
6404        assert!(html.contains("<a href=\"#usage\">Usage</a>"));
6405        // Level-3 heading is below depth=2 → not in the TOC, but still anchored.
6406        assert!(!html.contains("#detail\">Detail</a>"));
6407        assert!(html.contains("<h3 id=\"detail\">Detail</h3>"));
6408        // Heading anchors match the TOC hrefs.
6409        assert!(html.contains("<h1 id=\"overview\">Overview</h1>"));
6410        assert!(html.contains("<h2 id=\"setup\">Setup</h2>"));
6411    }
6412
6413    #[test]
6414    fn toc_dedupes_duplicate_slugs() {
6415        // Two headings with the same text get distinct anchors (foo, foo-2).
6416        let doc = doc_with(vec![
6417            Block::Toc { depth: 3, entries: Vec::new(), span: span() },
6418            Block::Markdown {
6419                content: "# Setup\n\n## Setup\n\n## Setup".into(),
6420                span: span(),
6421            },
6422        ]);
6423        let html = to_html(&doc);
6424        assert!(html.contains("<h1 id=\"setup\">Setup</h1>"));
6425        assert!(html.contains("<h2 id=\"setup-2\">Setup</h2>"));
6426        assert!(html.contains("<h2 id=\"setup-3\">Setup</h2>"));
6427        assert!(html.contains("<a href=\"#setup-2\">Setup</a>"));
6428    }
6429
6430    #[test]
6431    fn toc_empty_when_no_headings() {
6432        // With no document headings, the TOC stays an empty <nav> (no <ul>).
6433        let doc = doc_with(vec![
6434            Block::Toc { depth: 2, entries: Vec::new(), span: span() },
6435            Block::Markdown { content: "Just a paragraph.".into(), span: span() },
6436        ]);
6437        let html = to_html(&doc);
6438        assert!(html.contains("<nav class=\"surfdoc-toc\" data-depth=\"2\"></nav>"));
6439        assert!(!html.contains("<ol>"));
6440    }
6441
6442    #[test]
6443    fn html_escaping() {
6444        let doc = doc_with(vec![Block::Callout {
6445            callout_type: CalloutType::Info,
6446            title: None,
6447            content: "<script>alert('xss')</script>".into(),
6448            span: span(),
6449        }]);
6450        let html = to_html(&doc);
6451        assert!(
6452            !html.contains("<script>"),
6453            "Script tags must be escaped"
6454        );
6455        assert!(html.contains("&lt;script&gt;"));
6456    }
6457
6458    // -- New block types (tabs, columns, quote) -------------------------
6459
6460    #[test]
6461    fn html_tabs() {
6462        let doc = doc_with(vec![Block::Tabs {
6463            tabs: vec![
6464                crate::types::TabPanel {
6465                    label: "Overview".into(),
6466                    content: "Intro text.".into(),
6467                },
6468                crate::types::TabPanel {
6469                    label: "Details".into(),
6470                    content: "Technical info.".into(),
6471                },
6472            ],
6473            span: span(),
6474        }]);
6475        let html = to_html(&doc);
6476        assert!(html.contains("class=\"surfdoc-tabs\""));
6477        assert!(html.contains("Overview"));
6478        assert!(html.contains("Details"));
6479        assert!(html.contains("Intro text."));
6480        assert!(html.contains("Technical info."));
6481        assert!(html.contains("tab-btn"));
6482        assert!(html.contains("tab-panel"));
6483    }
6484
6485    #[test]
6486    fn html_columns() {
6487        let doc = doc_with(vec![Block::Columns {
6488            columns: vec![
6489                crate::types::ColumnContent {
6490                    content: "Left side.".into(),
6491                },
6492                crate::types::ColumnContent {
6493                    content: "Right side.".into(),
6494                },
6495            ],
6496            span: span(),
6497        }]);
6498        let html = to_html(&doc);
6499        assert!(html.contains("class=\"surfdoc-columns\""));
6500        assert!(html.contains("data-cols=\"2\""));
6501        assert!(html.contains("class=\"surfdoc-column\""));
6502        assert!(html.contains("Left side."));
6503        assert!(html.contains("Right side."));
6504    }
6505
6506    #[test]
6507    fn html_quote_with_attribution() {
6508        let doc = doc_with(vec![Block::Quote {
6509            content: "The best way to predict the future is to invent it.".into(),
6510            attribution: Some("Alan Kay".into()),
6511            cite: Some("ACM 1971".into()),
6512            span: span(),
6513        }]);
6514        let html = to_html(&doc);
6515        assert!(html.contains("<blockquote class=\"surfdoc-quote\">"));
6516        assert!(html.contains("<p>The best way to predict the future is to invent it.</p>"));
6517        assert!(html.contains("class=\"surfdoc-quote-by\""));
6518        assert!(html.contains("Alan Kay"));
6519        assert!(html.contains("<cite>ACM 1971</cite>"));
6520    }
6521
6522    #[test]
6523    fn html_quote_no_attribution() {
6524        let doc = doc_with(vec![Block::Quote {
6525            content: "Anonymous wisdom.".into(),
6526            attribution: None,
6527            cite: None,
6528            span: span(),
6529        }]);
6530        let html = to_html(&doc);
6531        assert!(html.contains("class=\"surfdoc-quote\""));
6532        assert!(html.contains("Anonymous wisdom."));
6533        assert!(!html.contains("attribution"));
6534    }
6535
6536    // -- Web blocks (cta, hero-image, testimonial, style) ---------------
6537
6538    #[test]
6539    fn html_cta_primary() {
6540        let doc = doc_with(vec![Block::Cta {
6541            label: "Get Started".into(),
6542            href: "/signup".into(),
6543            primary: true,
6544            icon: None,
6545            span: span(),
6546        }]);
6547        let html = to_html(&doc);
6548        assert!(html.contains("class=\"surfdoc-cta surfdoc-cta-primary\""));
6549        assert!(html.contains("href=\"/signup\""));
6550        assert!(html.contains("Get Started"));
6551    }
6552
6553    #[test]
6554    fn html_cta_secondary() {
6555        let doc = doc_with(vec![Block::Cta {
6556            label: "Learn More".into(),
6557            href: "https://example.com".into(),
6558            primary: false,
6559            icon: None,
6560            span: span(),
6561        }]);
6562        let html = to_html(&doc);
6563        assert!(html.contains("surfdoc-cta-secondary"));
6564        assert!(html.contains("Learn More"));
6565    }
6566
6567    #[test]
6568    fn html_hero_image() {
6569        let doc = doc_with(vec![Block::HeroImage {
6570            src: "screenshot.png".into(),
6571            alt: Some("App screenshot".into()),
6572            span: span(),
6573        }]);
6574        let html = to_html(&doc);
6575        assert!(html.contains("class=\"surfdoc-hero-image\""));
6576        assert!(html.contains("src=\"screenshot.png\""));
6577        assert!(html.contains("alt=\"App screenshot\""));
6578    }
6579
6580    #[test]
6581    fn html_hero_no_image_backward_compatible() {
6582        // A hero with no image/layout attrs must render byte-identically to the
6583        // pre-image-alt/-layout output.
6584        let mk = |image_alt, layout| Block::Hero {
6585            headline: Some("CloudSurf".into()),
6586            subtitle: Some("Innovate".into()),
6587            badge: None,
6588            align: "center".into(),
6589            image: None,
6590            image_alt,
6591            layout,
6592            transparent: false,
6593            buttons: vec![],
6594            content: String::new(),
6595            span: span(),
6596        };
6597        let baseline = to_html(&doc_with(vec![mk(None, None)]));
6598        assert!(baseline.contains("<section class=\"surfdoc-hero\">"));
6599        assert!(!baseline.contains("surfdoc-hero-image"));
6600    }
6601
6602    #[test]
6603    fn html_post_grid() {
6604        let doc = doc_with(vec![Block::PostGrid {
6605            title: Some("Blog".into()),
6606            subtitle: Some("Latest".into()),
6607            items: vec![
6608                PostItem {
6609                    title: "First".into(),
6610                    href: "/blog/first".into(),
6611                    meta: Some("News · 2026".into()),
6612                    excerpt: Some("An excerpt".into()),
6613                    image: Some("/img/a.png".into()),
6614                    external: true,
6615                },
6616                PostItem {
6617                    title: "Bare".into(),
6618                    href: "/blog/bare".into(),
6619                    meta: None,
6620                    excerpt: None,
6621                    image: None,
6622                    external: false,
6623                },
6624            ],
6625            span: span(),
6626        }]);
6627        let html = to_html(&doc);
6628        assert!(html.contains("class=\"surfdoc-post-grid\""));
6629        assert!(html.contains("class=\"surfdoc-pg2-inner\""));
6630        assert!(html.contains("<h1 class=\"surfdoc-post-grid-title\">Blog</h1>"));
6631        assert!(html.contains("class=\"surfdoc-post-cards\""));
6632        assert!(html.contains("href=\"/blog/first\""));
6633        assert!(html.contains("target=\"_blank\" rel=\"noopener\""));
6634        assert!(html.contains("style=\"--img:url('/img/a.png')\""));
6635        assert!(html.contains("<span class=\"surfdoc-post-card-meta\">News · 2026</span>"));
6636        assert!(html.contains("<h3 class=\"surfdoc-post-card-title\">First</h3>"));
6637        assert!(html.contains("<p class=\"surfdoc-post-card-excerpt\">An excerpt</p>"));
6638        assert!(html.contains("Read more"));
6639        // Bare card: no meta/excerpt/img.
6640        assert!(html.contains("<h3 class=\"surfdoc-post-card-title\">Bare</h3>"));
6641    }
6642
6643    #[test]
6644    fn html_gate() {
6645        let doc = doc_with(vec![Block::Gate {
6646            title: Some("Members".into()),
6647            subtitle: Some("Enter code".into()),
6648            action: "/unlock".into(),
6649            field_label: Some("Your code".into()),
6650            submit_label: Some("Unlock".into()),
6651            error: Some("Wrong".into()),
6652            span: span(),
6653        }]);
6654        let html = to_html(&doc);
6655        assert!(html.contains("class=\"surfdoc-gate\""));
6656        assert!(html.contains("class=\"surfdoc-gate-card\""));
6657        assert!(html.contains("<h1 class=\"surfdoc-gate-title\">Members</h1>"));
6658        assert!(html.contains("<p class=\"surfdoc-gate-subtitle\">Enter code</p>"));
6659        assert!(html.contains("<p class=\"surfdoc-gate-error\" role=\"alert\">Wrong</p>"));
6660        assert!(html.contains("method=\"post\" action=\"/unlock\""));
6661        // Password field name MUST be `code` (matches website AccessForm).
6662        assert!(html.contains("type=\"password\" name=\"code\""));
6663        assert!(html.contains("placeholder=\"Your code\""));
6664        assert!(html.contains(">Unlock</button>"));
6665    }
6666
6667    #[test]
6668    fn html_gate_defaults() {
6669        let doc = doc_with(vec![Block::Gate {
6670            title: None,
6671            subtitle: None,
6672            action: String::new(),
6673            field_label: None,
6674            submit_label: None,
6675            error: None,
6676            span: span(),
6677        }]);
6678        let html = to_html(&doc);
6679        assert!(html.contains("name=\"code\""));
6680        assert!(html.contains("placeholder=\"Access code\""));
6681        assert!(html.contains(">Continue</button>"));
6682        assert!(!html.contains("surfdoc-gate-error"));
6683    }
6684
6685    #[test]
6686    fn html_nav_minimal_renders_topbar_only() {
6687        let doc = doc_with(vec![Block::Nav {
6688            items: vec![nav_item("Home", "/", None)],
6689            logo: None,
6690            groups: vec![],
6691            brand: Some("Acme".into()),
6692            brand_reg: false,
6693            cta: None,
6694            drawer: false,
6695            minimal: true,
6696            span: span(),
6697        }]);
6698        let html = to_html(&doc);
6699        assert!(html.contains("surfdoc-shell-nav-minimal"));
6700        assert!(html.contains("surfdoc-shell-brand"));
6701        assert!(html.contains("onclick=\"toggleTheme()\""));
6702        // No hamburger, drawer, or scrim in minimal mode.
6703        assert!(!html.contains("toggleDrawer()"));
6704        assert!(!html.contains("surfdoc-shell-drawer"));
6705        assert!(!html.contains("surfdoc-shell-scrim"));
6706        assert!(!html.contains("surfdoc-shell-menu-btn"));
6707    }
6708
6709    #[test]
6710    fn shell_page_reading_frame_wraps_when_set() {
6711        let shell = doc_with(vec![]);
6712        let mut config = PageConfig::default();
6713        config.reading_frame = Some(ReadingFrame {
6714            title: Some("White Paper".into()),
6715            back_href: Some("/papers".into()),
6716            back_label: Some("Papers".into()),
6717        });
6718        let html = to_shell_page(&shell, "<p>Body</p>", &config);
6719        assert!(html.contains("class=\"surfdoc-doc-viewer\""));
6720        assert!(html.contains("class=\"surfdoc-doc-toolbar\""));
6721        assert!(html.contains("href=\"/papers\""));
6722        assert!(html.contains("Papers"));
6723        assert!(html.contains("<span class=\"surfdoc-doc-title\">White Paper</span>"));
6724        assert!(html.contains("class=\"surfdoc-doc-page surfdoc\""));
6725        assert!(html.contains("<p>Body</p>"));
6726        // Framed pages unclamp <main> so the frame runs full-bleed.
6727        assert!(html.contains("<main class=\"surfdoc surfdoc-doc-main\">"));
6728    }
6729
6730    #[test]
6731    fn shell_page_reading_frame_absent_by_default() {
6732        let shell = doc_with(vec![]);
6733        let config = PageConfig::default();
6734        let html = to_shell_page(&shell, "<p>Body</p>", &config);
6735        assert!(!html.contains("surfdoc-doc-viewer"));
6736        // Plain pages keep the bare .surfdoc main (document clamp applies).
6737        assert!(html.contains("<main class=\"surfdoc\">"));
6738        assert!(!html.contains("<main class=\"surfdoc surfdoc-doc-main\">"));
6739        assert!(html.contains("<p>Body</p>"));
6740    }
6741
6742    #[test]
6743    fn html_hero_stacked_image_with_alt() {
6744        let doc = doc_with(vec![Block::Hero {
6745            headline: Some("CloudSurf".into()),
6746            subtitle: None,
6747            badge: None,
6748            align: "center".into(),
6749            image: Some("/logo.png".into()),
6750            image_alt: Some("CloudSurf logo".into()),
6751            layout: Some("stacked".into()),
6752            transparent: true,
6753            buttons: vec![],
6754            content: String::new(),
6755            span: span(),
6756        }]);
6757        let html = to_html(&doc);
6758        assert!(html.contains("surfdoc-hero-stacked"));
6759        assert!(html.contains("surfdoc-hero-transparent"));
6760        assert!(html.contains("class=\"surfdoc-hero-image\""));
6761        assert!(html.contains("src=\"/logo.png\""));
6762        assert!(html.contains("alt=\"CloudSurf logo\""));
6763    }
6764
6765    #[test]
6766    fn html_form_action_method_honeypot() {
6767        let doc = doc_with(vec![Block::Form {
6768            fields: vec![],
6769            submit_label: Some("Send".into()),
6770            action: Some("/contact".into()),
6771            method: Some("post".into()),
6772            honeypot: true,
6773            span: span(),
6774        }]);
6775        let html = to_html(&doc);
6776        assert!(html.contains("method=\"post\""));
6777        assert!(html.contains("action=\"/contact\""));
6778        assert!(html.contains("name=\"_honey\""));
6779        assert!(html.contains(">Send</button>"));
6780    }
6781
6782    #[test]
6783    fn html_form_no_action_omits_target() {
6784        let doc = doc_with(vec![Block::Form {
6785            fields: vec![],
6786            submit_label: None,
6787            action: None,
6788            method: None,
6789            honeypot: false,
6790            span: span(),
6791        }]);
6792        let html = to_html(&doc);
6793        assert!(html.contains("<form class=\"surfdoc-form\">"));
6794        assert!(!html.contains("action="));
6795        assert!(!html.contains("_honey"));
6796    }
6797
6798    #[test]
6799    fn html_banner_renders_band_with_buttons() {
6800        let doc = doc_with(vec![Block::Banner {
6801            headline: Some("Building something custom?".into()),
6802            subtitle: Some("Tell us what you need.".into()),
6803            buttons: vec![crate::types::HeroButton {
6804                label: "Start a project".into(),
6805                href: "/contact".into(),
6806                primary: true,
6807                external: false,
6808            }],
6809            id: Some("contact".into()),
6810            content: String::new(),
6811            span: span(),
6812        }]);
6813        let html = to_html(&doc);
6814        assert!(html.contains("class=\"surfdoc-banner\""));
6815        assert!(html.contains("id=\"contact\""));
6816        assert!(html.contains("surfdoc-banner-headline"));
6817        assert!(html.contains("Building something custom?"));
6818        assert!(html.contains("surfdoc-banner-btn-primary"));
6819        assert!(html.contains("href=\"/contact\""));
6820    }
6821
6822    #[test]
6823    fn html_hero_external_button_opens_new_tab() {
6824        let src = "::hero\n# H\n\n[Go](https://x.com){primary external}\n[Stay](/here){primary}\n::";
6825        let doc = crate::parse(src).doc;
6826        let html = to_html(&doc);
6827        assert!(html.contains(
6828            "href=\"https://x.com\" class=\"surfdoc-hero-btn surfdoc-hero-btn-primary\" target=\"_blank\" rel=\"noopener\""
6829        ));
6830        // The internal button must NOT pick up the target.
6831        assert!(html.contains("href=\"/here\" class=\"surfdoc-hero-btn surfdoc-hero-btn-primary\">"));
6832    }
6833
6834    #[test]
6835    fn html_product_grid_linkless_card_renders_static_div() {
6836        let src = "::product-grid\n- Live | | /live | Ships\n- Soon | | - | Not public yet\n::";
6837        let doc = crate::parse(src).doc;
6838        let html = to_html(&doc);
6839        // Linked card: an anchor with the arrow affordance.
6840        assert!(html.contains("<a href=\"/live\" class=\"surfdoc-pg-card\""));
6841        // Linkless card: a static div, no href, no arrow inside it.
6842        assert!(html.contains("<div class=\"surfdoc-pg-card surfdoc-pg-card-static\" data-product=\"soon\">"));
6843        assert!(!html.contains("href=\"-\""));
6844        let static_card = html.split("surfdoc-pg-card-static").nth(1).unwrap();
6845        let card_inner = static_card.split("</div>").next().unwrap();
6846        assert!(!card_inner.contains("surfdoc-pg-arrow"));
6847        // The static-card CSS ships with the sheet.
6848        assert!(SURFDOC_CSS.contains(".surfdoc-pg-card-static"));
6849    }
6850
6851    #[test]
6852    fn html_product_grid_tiles_icon_emblem_and_surface_bg() {
6853        let src = "::product-grid[tiles]\n- Innovate | icon:code | - | Invent things. | surface\n- Bad | icon:no-such-icon | - | x | surface\n::";
6854        let doc = crate::parse(src).doc;
6855        let html = to_html(&doc);
6856        // Known icon → accent chip with the inline svg; no <img>.
6857        assert!(html.contains("<span class=\"surfdoc-pg-tile-icon\"><svg"));
6858        assert!(!html.contains("surfdoc-pg-tile-emblem"));
6859        // Unknown icon name → omitted entirely (never a broken img).
6860        assert_eq!(html.matches("surfdoc-pg-tile-icon").count(), 1);
6861        assert!(!html.contains("icon:no-such-icon"));
6862        // `surface` → class-based opaque bg, no inline style, no -bg ink pin.
6863        assert!(html.contains("surfdoc-pg-tile-surface"));
6864        assert!(!html.contains("surfdoc-pg-tile-bg"));
6865        assert!(SURFDOC_CSS.contains(".surfdoc-pg-tile-surface"));
6866        assert!(SURFDOC_CSS.contains(".surfdoc-pg-tile-icon"));
6867    }
6868
6869    #[test]
6870    fn html_product_grid_tiles_theme_ink_token() {
6871        let src = "::product-grid[tiles]\n- A | | /a | x | gradient:linear-gradient(160deg,rgba(1,2,3,0.2),rgba(4,5,6,0.2)) theme\n- B | | /b | y | color:#123456 dark\n::";
6872        let doc = crate::parse(src).doc;
6873        let html = to_html(&doc);
6874        // ` theme` → -bg class (background applies) + -theme (ink follows the
6875        // site theme); the token itself never leaks into the style attr.
6876        assert!(html.contains("surfdoc-pg-tile-bg surfdoc-pg-tile-theme"));
6877        assert!(html.contains("style=\"background:linear-gradient(160deg,rgba(1,2,3,0.2),rgba(4,5,6,0.2))\""));
6878        // ` dark` unaffected.
6879        assert!(html.contains("surfdoc-pg-tile-bg surfdoc-pg-tile-dark"));
6880        assert!(SURFDOC_CSS.contains(".surfdoc-pg-tile-theme"));
6881    }
6882
6883    #[test]
6884    fn html_product_grid_classic_icon_emblem() {
6885        let src = "::product-grid\n- Thing | icon:globe | /thing | Does things\n::";
6886        let doc = crate::parse(src).doc;
6887        let html = to_html(&doc);
6888        assert!(html.contains("<span class=\"surfdoc-pg-icon\"><svg"));
6889        assert!(!html.contains("surfdoc-pg-emblem"));
6890    }
6891
6892    #[test]
6893    fn html_product_grid_tiles_linkless_card_drops_default_cta() {
6894        let src = "::product-grid[tiles]\n- Live | | /live | Ships\n- Soon | | - | Not public yet\n::";
6895        let doc = crate::parse(src).doc;
6896        let html = to_html(&doc);
6897        // Linked tile keeps the default pill; linkless tile has none.
6898        assert!(html.contains("<a href=\"/live\" class=\"surfdoc-pg-tile-cta\""));
6899        assert!(!html.contains("href=\"-\""));
6900        let soon_tile = html.split("data-product=\"soon\"").nth(1).unwrap();
6901        let tile_inner = soon_tile.split("surfdoc-pg-tile-actions").nth(1).unwrap();
6902        assert!(!tile_inner.split("</span>").next().unwrap().contains("surfdoc-pg-tile-cta"));
6903    }
6904
6905    #[test]
6906    fn html_product_grid_tiles_whole_card_stretch_link_and_hover_outline() {
6907        let src = "::product-grid[tiles]\n- Surfspace | | https://surf.space/ | Workspace. | | [Get started](https://surf.space/)\n- Grid | | - | Fleet.\n- Innovate | icon:code | - | Invent. | surface | [View Research](/research)\n::";
6908        let doc = crate::parse(src).doc;
6909        let html = to_html(&doc);
6910        // A linked tile (explicit CTA or card href) is marked and carries the
6911        // invisible stretched anchor to its primary action; external stays
6912        // external, internal doesn't grow a target.
6913        assert!(html.contains("<a href=\"https://surf.space/\" class=\"surfdoc-pg-tile-stretch\" tabindex=\"-1\" aria-hidden=\"true\" target=\"_blank\" rel=\"noopener\"></a>"));
6914        assert!(html.contains("<a href=\"/research\" class=\"surfdoc-pg-tile-stretch\" tabindex=\"-1\" aria-hidden=\"true\"></a>"));
6915        assert_eq!(html.matches("surfdoc-pg-tile-linked").count(), 2);
6916        assert_eq!(html.matches("surfdoc-pg-tile-stretch").count(), 2);
6917        // The linkless tile keeps no affordance at all.
6918        let grid_tile = html.split("data-product=\"grid\"").nth(1).unwrap();
6919        assert!(!grid_tile.split("data-product").next().unwrap().contains("surfdoc-pg-tile-stretch"));
6920        // CSS ships the stretch geometry, the pointer-events layering, and the
6921        // hover outline with its host re-skin hook.
6922        assert!(SURFDOC_CSS.contains(".surfdoc-pg-tile-stretch { position: absolute; inset: 0;"));
6923        assert!(SURFDOC_CSS.contains(".surfdoc-pg-tile-linked .surfdoc-pg-tile-copy { pointer-events: none; }"));
6924        assert!(SURFDOC_CSS.contains(".surfdoc-pg-tile-linked .surfdoc-pg-tile-actions { pointer-events: auto; }"));
6925        assert!(SURFDOC_CSS.contains(".surfdoc-pg-tile-linked:hover"));
6926        assert!(SURFDOC_CSS.contains("--ws-pg-tile-hover-outline"));
6927        // Hosts can round the tiles and re-skin/kill the hover glow; both
6928        // default to no-op (square, glow follows the outline accent).
6929        assert!(SURFDOC_CSS.contains("border-radius: var(--ws-pg-tile-radius, 0)"));
6930        assert!(SURFDOC_CSS.contains("--ws-pg-tile-hover-shadow"));
6931    }
6932
6933    #[test]
6934    fn html_product_grid_tiles_with_backgrounds() {
6935        let src = "::product-grid[tiles]\n- Mac | | /mac | Now with M5. | image:/assets/mac.jpg dark\n- Watch | | /watch | Health. | color:#f5f5f7\n- Pad | | /pad | AI. | gradient:linear-gradient(180deg,#0af,#03d) dark\n::";
6936        let doc = crate::parse(src).doc;
6937        let html = to_html(&doc);
6938        // Tiles band, not the classic card grid
6939        assert!(html.contains("surfdoc-pg-tiles"));
6940        assert!(!html.contains("surfdoc-pg-card"));
6941        // image: → media div below the copy (custom prop); color/gradient → tile bg
6942        assert!(html.contains("surfdoc-pg-tile-media"));
6943        assert!(html.contains("--tile-image:url('/assets/mac.jpg')"));
6944        assert!(html.contains("background:#f5f5f7"));
6945        assert!(html.contains("background:linear-gradient(180deg,#0af,#03d)"));
6946        // ` dark` flag → light-text class on tiles 1 and 3 only
6947        assert_eq!(html.matches("surfdoc-pg-tile-dark").count(), 2);
6948        // Copy + CTA pill
6949        assert!(html.contains("surfdoc-pg-tile-name"));
6950        assert!(html.contains("Learn more"));
6951        // Round-trip: builder re-emits the [tiles] attr and the bg field
6952        let surf = crate::builder::to_surf_source(&doc);
6953        assert!(surf.contains("::product-grid[tiles]"));
6954        assert!(surf.contains("| image:/assets/mac.jpg dark"));
6955    }
6956
6957    #[test]
6958    fn html_product_grid_groups_and_cards() {
6959        let doc = doc_with(vec![Block::ProductGrid {
6960            groups: vec![crate::types::ProductGroup {
6961                label: Some("Platforms".into()),
6962                cols: None,
6963                items: vec![
6964                    crate::types::ProductItem {
6965                        name: "Surf CLI".into(),
6966                        href: "https://app.surf".into(),
6967                        emblem: Some("/surf.png".into()),
6968                        tagline: Some("Docs & tasks.".into()),
6969                        cta1_label: None,
6970                        cta1_href: None,
6971                        cta2_label: None,
6972                        cta2_href: None,
6973                        bg: None,
6974                    },
6975                    crate::types::ProductItem {
6976                        name: "Local".into(),
6977                        href: "/local".into(),
6978                        emblem: None,
6979                        tagline: None,
6980                        cta1_label: None,
6981                        cta1_href: None,
6982                        cta2_label: None,
6983                        cta2_href: None,
6984                        bg: None,
6985                    },
6986                ],
6987            }],
6988            tiles: false,
6989            span: span(),
6990        }]);
6991        let html = to_html(&doc);
6992        assert!(html.contains("class=\"surfdoc-product-grid\""));
6993        assert!(html.contains("surfdoc-pg-label"));
6994        assert!(html.contains(">Platforms</h3>"));
6995        assert!(html.contains("data-product=\"surf-cli\""));
6996        assert!(html.contains("href=\"https://app.surf\""));
6997        assert!(html.contains("target=\"_blank\""));
6998        assert!(html.contains("surfdoc-pg-emblem"));
6999        // Internal link with no emblem: no target, no img.
7000        assert!(html.contains("data-product=\"local\""));
7001        assert!(html.contains("surfdoc-pg-arrow"));
7002    }
7003
7004    #[test]
7005    fn html_testimonial() {
7006        let doc = doc_with(vec![Block::Testimonial {
7007            content: "Amazing product!".into(),
7008            author: Some("Jane Dev".into()),
7009            role: Some("Engineer".into()),
7010            company: Some("Acme".into()),
7011            span: span(),
7012        }]);
7013        let html = to_html(&doc);
7014        assert!(html.contains("class=\"surfdoc-testimonial\""));
7015        assert!(html.contains("Amazing product!"));
7016        assert!(html.contains("Jane Dev"));
7017        assert!(html.contains("Engineer, Acme"));
7018        // Avatar with initials derived from the author name ("Jane Dev" -> "JD").
7019        assert!(html.contains("class=\"surfdoc-testimonial-by\""));
7020        assert!(html.contains("class=\"surfdoc-testimonial-avatar\""));
7021        assert!(html.contains(">JD</span>"));
7022    }
7023
7024    #[test]
7025    fn html_testimonial_anonymous() {
7026        let doc = doc_with(vec![Block::Testimonial {
7027            content: "Great tool.".into(),
7028            author: None,
7029            role: None,
7030            company: None,
7031            span: span(),
7032        }]);
7033        let html = to_html(&doc);
7034        assert!(html.contains("Great tool."));
7035        assert!(!html.contains("class=\"author\""));
7036        // No author -> no avatar / figcaption.
7037        assert!(!html.contains("surfdoc-testimonial-avatar"));
7038        assert!(!html.contains("surfdoc-testimonial-by"));
7039    }
7040
7041    #[test]
7042    fn html_style_hidden() {
7043        let doc = doc_with(vec![Block::Style {
7044            properties: vec![
7045                crate::types::StyleProperty { key: "accent".into(), value: "#6366f1".into() },
7046            ],
7047            span: span(),
7048        }]);
7049        let html = to_html(&doc);
7050        assert!(html.contains("class=\"surfdoc-style\""));
7051    }
7052
7053    #[test]
7054    fn html_cta_escapes_xss() {
7055        let doc = doc_with(vec![Block::Cta {
7056            label: "<script>alert('xss')</script>".into(),
7057            href: "javascript:alert(1)".into(),
7058            primary: true,
7059            icon: None,
7060            span: span(),
7061        }]);
7062        let html = to_html(&doc);
7063        assert!(!html.contains("<script>"));
7064        assert!(html.contains("&lt;script&gt;"));
7065    }
7066
7067    #[test]
7068    fn html_faq() {
7069        let doc = doc_with(vec![Block::Faq {
7070            items: vec![
7071                crate::types::FaqItem {
7072                    question: "Is it free?".into(),
7073                    answer: "Yes, the free tier is forever.".into(),
7074                },
7075                crate::types::FaqItem {
7076                    question: "Can I self-host?".into(),
7077                    answer: "Docker image available.".into(),
7078                },
7079            ],
7080            span: span(),
7081        }]);
7082        let html = to_html(&doc);
7083        assert!(html.contains("class=\"surfdoc-faq\""));
7084        assert!(html.contains("<details class=\"surfdoc-faq-item\">"));
7085        assert!(html.contains("<summary>Is it free?</summary>"));
7086        assert!(html.contains("<summary>Can I self-host?</summary>"));
7087        assert!(html.contains("class=\"surfdoc-faq-answer\""));
7088        assert!(html.contains("Yes, the free tier is forever."));
7089    }
7090
7091    #[test]
7092    fn html_pricing_table() {
7093        // Each row is a tier card. A bold name (**Team**) is the featured tier;
7094        // a `/suffix` price renders as a muted span; remaining cols are bullets.
7095        let doc = doc_with(vec![Block::PricingTable {
7096            headers: vec!["Tier".into(), "Price".into(), "Seats".into()],
7097            rows: vec![
7098                vec!["Free".into(), "$0".into(), "3".into()],
7099                vec!["**Team**".into(), "$9/seat".into(), "5".into()],
7100            ],
7101            span: span(),
7102        }]);
7103        let html = to_html(&doc);
7104        assert!(html.contains("class=\"surfdoc-pricing\""));
7105        assert!(html.contains("<div class=\"surfdoc-tier-name\">Free</div>"));
7106        // Bold name → featured tier, with the ** stripped from the label.
7107        assert!(html.contains("surfdoc-tier surfdoc-tier-featured"));
7108        assert!(html.contains("<div class=\"surfdoc-tier-name\">Team</div>"));
7109        // Price `/seat` suffix becomes a muted span.
7110        assert!(html.contains("<div class=\"surfdoc-tier-price\">$9<span>/seat</span></div>"));
7111        // Feature bullet = "<value> <header lowercased>".
7112        assert!(html.contains("<li>3 seats</li>"));
7113        // Old table markup must be gone.
7114        assert!(!html.contains("<th scope=\"col\">"));
7115    }
7116
7117    #[test]
7118    fn html_faq_escapes_xss() {
7119        let doc = doc_with(vec![Block::Faq {
7120            items: vec![crate::types::FaqItem {
7121                question: "<script>alert('q')</script>".into(),
7122                answer: "<img onerror=alert(1)>".into(),
7123            }],
7124            span: span(),
7125        }]);
7126        let html = to_html(&doc);
7127        assert!(!html.contains("<script>"));
7128        assert!(html.contains("&lt;script&gt;"));
7129    }
7130
7131    #[test]
7132    fn html_site_hidden() {
7133        let doc = doc_with(vec![Block::Site {
7134            domain: Some("notesurf.io".into()),
7135            properties: vec![
7136                crate::types::StyleProperty { key: "name".into(), value: "NoteSurf".into() },
7137            ],
7138            span: span(),
7139        }]);
7140        let html = to_html(&doc);
7141        assert!(html.contains("class=\"surfdoc-site\""));
7142        assert!(html.contains("data-domain=\"notesurf.io\""));
7143    }
7144
7145    #[test]
7146    fn html_page_hero_layout() {
7147        let doc = doc_with(vec![Block::Page {
7148            route: "/".into(),
7149            layout: Some("hero".into()),
7150            title: None,
7151            sidebar: false,
7152            content: "# Welcome".into(),
7153            children: vec![
7154                Block::Markdown {
7155                    content: "# Welcome".into(),
7156                    span: span(),
7157                },
7158                Block::Cta {
7159                    label: "Get Started".into(),
7160                    href: "/signup".into(),
7161                    primary: true,
7162                    icon: None,
7163                    span: span(),
7164                },
7165            ],
7166            span: span(),
7167        }]);
7168        let html = to_html(&doc);
7169        assert!(html.contains("class=\"surfdoc-page\""));
7170        assert!(html.contains("data-layout=\"hero\""));
7171        assert!(html.contains("Get Started")); // CTA rendered
7172        assert!(html.contains("surfdoc-cta")); // CTA has class
7173    }
7174
7175    #[test]
7176    fn html_page_renders_children() {
7177        let doc = doc_with(vec![Block::Page {
7178            route: "/pricing".into(),
7179            layout: None,
7180            title: Some("Pricing".into()),
7181            sidebar: false,
7182            content: String::new(),
7183            children: vec![
7184                Block::Markdown {
7185                    content: "# Pricing".into(),
7186                    span: span(),
7187                },
7188                Block::HeroImage {
7189                    src: "pricing.png".into(),
7190                    alt: Some("Plans".into()),
7191                    span: span(),
7192                },
7193            ],
7194            span: span(),
7195        }]);
7196        let html = to_html(&doc);
7197        assert!(html.contains("<section class=\"surfdoc-page\" aria-label=\"Pricing\">"));
7198        assert!(html.contains("<h1 id=\"pricing\">Pricing</h1>")); // Markdown rendered (with anchor id)
7199        assert!(html.contains("surfdoc-hero-image")); // Hero image rendered
7200    }
7201
7202    // -- Full-page discovery mechanism ---------------------------------
7203
7204    #[test]
7205    fn html_page_has_generator_meta() {
7206        let doc = doc_with(vec![Block::Markdown {
7207            content: "# Hello".into(),
7208            span: span(),
7209        }]);
7210        let config = PageConfig::default();
7211        let html = to_html_page(&doc, &config);
7212        assert!(html.contains("<meta name=\"generator\" content=\"SurfDoc v0.1\">"));
7213    }
7214
7215    #[test]
7216    fn html_page_has_link_alternate() {
7217        let doc = doc_with(vec![]);
7218        let config = PageConfig::default();
7219        let html = to_html_page(&doc, &config);
7220        assert!(html.contains(
7221            "<link rel=\"alternate\" type=\"text/surfdoc\" href=\"source.surf\">"
7222        ));
7223    }
7224
7225    #[test]
7226    fn html_page_has_source_comment() {
7227        let doc = doc_with(vec![]);
7228        let config = PageConfig {
7229            source_path: "site.surf".to_string(),
7230            ..Default::default()
7231        };
7232        let html = to_html_page(&doc, &config);
7233        assert!(html.starts_with("<!-- Built with SurfDoc — source: site.surf -->"));
7234    }
7235
7236    #[test]
7237    fn html_page_uses_front_matter_title() {
7238        let doc = SurfDoc {
7239            front_matter: Some(FrontMatter {
7240                title: Some("My Site".to_string()),
7241                ..Default::default()
7242            }),
7243            blocks: vec![],
7244            source: String::new(),
7245        };
7246        let config = PageConfig::default();
7247        let html = to_html_page(&doc, &config);
7248        assert!(html.contains("<title>My Site</title>"));
7249    }
7250
7251    #[test]
7252    fn html_page_config_title_overrides_front_matter() {
7253        let doc = SurfDoc {
7254            front_matter: Some(FrontMatter {
7255                title: Some("FM Title".to_string()),
7256                ..Default::default()
7257            }),
7258            blocks: vec![],
7259            source: String::new(),
7260        };
7261        let config = PageConfig {
7262            title: Some("Override Title".to_string()),
7263            ..Default::default()
7264        };
7265        let html = to_html_page(&doc, &config);
7266        assert!(html.contains("<title>Override Title</title>"));
7267        assert!(!html.contains("FM Title"));
7268    }
7269
7270    #[test]
7271    fn html_page_has_doctype_and_structure() {
7272        let doc = doc_with(vec![Block::Markdown {
7273            content: "Hello".into(),
7274            span: span(),
7275        }]);
7276        let config = PageConfig::default();
7277        let html = to_html_page(&doc, &config);
7278        assert!(html.contains("<!DOCTYPE html>"));
7279        assert!(html.contains("<html lang=\"en\">"));
7280        assert!(html.contains("<meta charset=\"utf-8\">"));
7281        assert!(html.contains("<meta name=\"viewport\""));
7282        assert!(html.contains("<body>"));
7283        assert!(html.contains("</body>"));
7284        assert!(html.contains("</html>"));
7285    }
7286
7287    #[test]
7288    fn html_page_includes_description_and_canonical() {
7289        let doc = doc_with(vec![]);
7290        let config = PageConfig {
7291            description: Some("A test page".to_string()),
7292            canonical_url: Some("https://example.com/page".to_string()),
7293            ..Default::default()
7294        };
7295        let html = to_html_page(&doc, &config);
7296        assert!(html.contains("<meta name=\"description\" content=\"A test page\">"));
7297        assert!(html.contains(
7298            "<link rel=\"canonical\" href=\"https://example.com/page\">"
7299        ));
7300    }
7301
7302    #[test]
7303    fn html_page_custom_source_path() {
7304        let doc = doc_with(vec![]);
7305        let config = PageConfig {
7306            source_path: "/docs/readme.surf".to_string(),
7307            ..Default::default()
7308        };
7309        let html = to_html_page(&doc, &config);
7310        assert!(html.contains("href=\"/docs/readme.surf\""));
7311        assert!(html.contains("source: /docs/readme.surf"));
7312    }
7313
7314    #[test]
7315    fn html_page_escapes_title_xss() {
7316        let doc = doc_with(vec![]);
7317        let config = PageConfig {
7318            title: Some("<script>alert('xss')</script>".to_string()),
7319            ..Default::default()
7320        };
7321        let html = to_html_page(&doc, &config);
7322        assert!(!html.contains("<script>alert"));
7323        assert!(html.contains("&lt;script&gt;"));
7324    }
7325
7326    // -- SEO meta tag tests -----------------------------------------------
7327
7328    #[test]
7329    fn html_page_og_tags_from_config() {
7330        let doc = doc_with(vec![]);
7331        let config = PageConfig {
7332            title: Some("My Page".into()),
7333            description: Some("A great page".into()),
7334            canonical_url: Some("https://example.com".into()),
7335            ..Default::default()
7336        };
7337        let html = to_html_page(&doc, &config);
7338        assert!(html.contains("<meta property=\"og:title\" content=\"My Page\">"));
7339        assert!(html.contains("<meta property=\"og:description\" content=\"A great page\">"));
7340        assert!(html.contains("<meta property=\"og:url\" content=\"https://example.com\">"));
7341        assert!(html.contains("<meta property=\"og:type\" content=\"website\">"));
7342    }
7343
7344    #[test]
7345    fn html_page_twitter_card_tags() {
7346        let doc = doc_with(vec![]);
7347        let config = PageConfig {
7348            title: Some("My Page".into()),
7349            description: Some("A great page".into()),
7350            ..Default::default()
7351        };
7352        let html = to_html_page(&doc, &config);
7353        assert!(html.contains("<meta name=\"twitter:card\" content=\"summary\">"));
7354        assert!(html.contains("<meta name=\"twitter:title\" content=\"My Page\">"));
7355        assert!(html.contains("<meta name=\"twitter:description\" content=\"A great page\">"));
7356    }
7357
7358    #[test]
7359    fn html_page_og_image() {
7360        let doc = doc_with(vec![]);
7361        let config = PageConfig {
7362            title: Some("My Page".into()),
7363            og_image: Some("https://example.com/img.png".into()),
7364            ..Default::default()
7365        };
7366        let html = to_html_page(&doc, &config);
7367        assert!(html.contains("<meta property=\"og:image\" content=\"https://example.com/img.png\">"));
7368        assert!(html.contains("<meta name=\"twitter:image\" content=\"https://example.com/img.png\">"));
7369    }
7370
7371    #[test]
7372    fn html_page_description_from_front_matter() {
7373        let doc = SurfDoc {
7374            front_matter: Some(FrontMatter {
7375                title: Some("FM Title".into()),
7376                description: Some("FM description".into()),
7377                ..Default::default()
7378            }),
7379            blocks: vec![],
7380            source: String::new(),
7381        };
7382        let config = PageConfig::default();
7383        let html = to_html_page(&doc, &config);
7384        assert!(html.contains("<meta name=\"description\" content=\"FM description\">"));
7385        assert!(html.contains("<meta property=\"og:description\" content=\"FM description\">"));
7386    }
7387
7388    #[test]
7389    fn html_page_description_from_site_block() {
7390        let doc = doc_with(vec![Block::Site {
7391            domain: None,
7392            properties: vec![
7393                StyleProperty { key: "name".into(), value: "My Site".into() },
7394                StyleProperty { key: "description".into(), value: "Site block desc".into() },
7395            ],
7396            span: span(),
7397        }]);
7398        let config = PageConfig::default();
7399        let html = to_html_page(&doc, &config);
7400        assert!(html.contains("<meta name=\"description\" content=\"Site block desc\">"));
7401        assert!(html.contains("<meta property=\"og:description\" content=\"Site block desc\">"));
7402    }
7403
7404    #[test]
7405    fn html_page_config_description_overrides_front_matter() {
7406        let doc = SurfDoc {
7407            front_matter: Some(FrontMatter {
7408                description: Some("FM desc".into()),
7409                ..Default::default()
7410            }),
7411            blocks: vec![],
7412            source: String::new(),
7413        };
7414        let config = PageConfig {
7415            description: Some("Config desc".into()),
7416            ..Default::default()
7417        };
7418        let html = to_html_page(&doc, &config);
7419        assert!(html.contains("Config desc"));
7420        assert!(!html.contains("FM desc"));
7421    }
7422
7423    // -- ARIA accessibility tests -----------------------------------------
7424
7425    #[test]
7426    fn aria_callout_danger_role_alert() {
7427        let doc = doc_with(vec![Block::Callout {
7428            callout_type: CalloutType::Danger,
7429            title: None,
7430            content: "Critical error.".into(),
7431            span: span(),
7432        }]);
7433        let html = to_html(&doc);
7434        assert!(html.contains("role=\"alert\""));
7435    }
7436
7437    #[test]
7438    fn aria_callout_info_role_note() {
7439        let doc = doc_with(vec![Block::Callout {
7440            callout_type: CalloutType::Info,
7441            title: None,
7442            content: "FYI.".into(),
7443            span: span(),
7444        }]);
7445        let html = to_html(&doc);
7446        assert!(html.contains("role=\"note\""));
7447    }
7448
7449    #[test]
7450    fn aria_data_table_scope_col() {
7451        let doc = doc_with(vec![Block::Data {
7452            id: None,
7453            format: DataFormat::Table,
7454            sortable: false,
7455            headers: vec!["Col1".into()],
7456            rows: vec![],
7457            raw_content: String::new(),
7458            span: span(),
7459        }]);
7460        let html = to_html(&doc);
7461        assert!(html.contains("scope=\"col\""));
7462    }
7463
7464    #[test]
7465    fn aria_code_label() {
7466        let doc = doc_with(vec![Block::Code {
7467            lang: Some("python".into()),
7468            file: None,
7469            highlight: vec![],
7470            content: "print()".into(),
7471            span: span(),
7472        }]);
7473        let html = to_html(&doc);
7474        assert!(html.contains("aria-label=\"python code\""));
7475    }
7476
7477    #[test]
7478    fn tasks_open_item_structure() {
7479        let doc = doc_with(vec![Block::Tasks {
7480            items: vec![TaskItem {
7481                done: false,
7482                text: "Do thing".into(),
7483                assignee: None,
7484            }],
7485            span: span(),
7486        }]);
7487        let html = to_html(&doc);
7488        assert!(html.contains("<li class=\"surfdoc-task\">"));
7489        assert!(html.contains("<span class=\"surfdoc-check\"></span>"));
7490        assert!(html.contains("<span class=\"surfdoc-task-text\">Do thing</span>"));
7491    }
7492
7493    #[test]
7494    fn aria_decision_role_note() {
7495        let doc = doc_with(vec![Block::Decision {
7496            status: DecisionStatus::Accepted,
7497            date: None,
7498            deciders: vec![],
7499            content: "We decided.".into(),
7500            span: span(),
7501        }]);
7502        let html = to_html(&doc);
7503        assert!(html.contains("role=\"note\""));
7504        assert!(html.contains("aria-label=\"Decision: accepted\""));
7505    }
7506
7507    #[test]
7508    fn html_decision_structure_and_deciders() {
7509        let doc = doc_with(vec![Block::Decision {
7510            status: DecisionStatus::Accepted,
7511            date: Some("2026-02-08".into()),
7512            deciders: vec!["Brady".into(), "Alice".into()],
7513            content: "We will ship.".into(),
7514            span: span(),
7515        }]);
7516        let html = to_html(&doc);
7517        assert!(html.contains("<article class=\"surfdoc-decision surfdoc-decision-accepted\""));
7518        assert!(html.contains("<div class=\"surfdoc-decision-header\">"));
7519        assert!(html.contains(
7520            "<span class=\"surfdoc-decision-status surfdoc-decision-status--accepted\">Accepted</span>"
7521        ));
7522        assert!(html.contains("<span class=\"surfdoc-decision-date\">2026-02-08</span>"));
7523        assert!(html.contains("<div class=\"surfdoc-decision-body\">"));
7524        assert!(html.contains("<footer class=\"surfdoc-decision-meta\">Deciders: Brady, Alice</footer>"));
7525    }
7526
7527    #[test]
7528    fn html_generic_embed_with_height_renders_iframe_not_card() {
7529        // A generic embed (no provider type) with a real src + explicit height —
7530        // e.g. a Termly policy viewer — must render a responsive <iframe>, NOT a
7531        // bare link-preview card. Regression: the 3 legal pages showed no content.
7532        let doc = doc_with(vec![Block::Embed {
7533            src: "https://app.termly.io/policy-viewer/policy.html?policyUUID=abc123".into(),
7534            embed_type: None,
7535            width: None,
7536            height: Some("80vh".into()),
7537            title: Some("Privacy Policy".into()),
7538            span: span(),
7539        }]);
7540        let html = to_html(&doc);
7541        assert!(html.contains("<iframe"), "generic embed must render an iframe");
7542        assert!(html.contains("src=\"https://app.termly.io/policy-viewer/policy.html?policyUUID=abc123\""));
7543        assert!(html.contains("height:80vh"), "explicit height must carry through");
7544        assert!(html.contains("title=\"Privacy Policy\""));
7545        assert!(html.contains("loading=\"lazy\""));
7546        // Must NOT degrade to a link-preview card.
7547        assert!(!html.contains("surfdoc-embed-title"), "generic embed must not be a link card");
7548    }
7549
7550    #[test]
7551    fn html_generic_embed_explicit_type_renders_iframe() {
7552        // type=generic is equally first-class for iframe rendering.
7553        let doc = doc_with(vec![Block::Embed {
7554            src: "https://example.com/widget".into(),
7555            embed_type: Some(EmbedType::Generic),
7556            width: Some("600px".into()),
7557            height: Some("400px".into()),
7558            title: None,
7559            span: span(),
7560        }]);
7561        let html = to_html(&doc);
7562        assert!(html.contains("<iframe"));
7563        assert!(html.contains("width:600px"));
7564        assert!(html.contains("height:400px"));
7565    }
7566
7567    #[test]
7568    fn html_video_embed_stays_link_card() {
7569        // Provider embeds (video/audio/map) keep the safe link-card so
7570        // unembeddable share URLs don't produce an iframe error box.
7571        let doc = doc_with(vec![Block::Embed {
7572            src: "https://youtu.be/abc".into(),
7573            embed_type: Some(EmbedType::Video),
7574            width: None,
7575            height: Some("400px".into()),
7576            title: Some("My Video".into()),
7577            span: span(),
7578        }]);
7579        let html = to_html(&doc);
7580        assert!(html.contains("surfdoc-embed-title"), "video embed stays a link card");
7581        assert!(!html.contains("<iframe"));
7582    }
7583
7584    #[test]
7585    fn html_generic_embed_without_height_stays_card() {
7586        // No height → no responsive iframe target, so keep the link card.
7587        let doc = doc_with(vec![Block::Embed {
7588            src: "https://example.com/page".into(),
7589            embed_type: None,
7590            width: None,
7591            height: None,
7592            title: None,
7593            span: span(),
7594        }]);
7595        let html = to_html(&doc);
7596        assert!(html.contains("surfdoc-embed-title"));
7597        assert!(!html.contains("<iframe"));
7598    }
7599
7600    #[test]
7601    fn html_decision_no_deciders_omits_footer() {
7602        let doc = doc_with(vec![Block::Decision {
7603            status: DecisionStatus::Proposed,
7604            date: None,
7605            deciders: vec![],
7606            content: "Maybe.".into(),
7607            span: span(),
7608        }]);
7609        let html = to_html(&doc);
7610        assert!(!html.contains("surfdoc-decision-meta"));
7611        assert!(html.contains("surfdoc-decision-status--proposed"));
7612    }
7613
7614    #[test]
7615    fn html_details_body_and_summary_classes() {
7616        let doc = doc_with(vec![Block::Details {
7617            title: Some("Notes".into()),
7618            open: true,
7619            content: "Body text.".into(),
7620            span: span(),
7621        }]);
7622        let html = to_html(&doc);
7623        assert!(html.contains("<details class=\"surfdoc-details\" open>"));
7624        assert!(html.contains("<summary class=\"surfdoc-details-summary\">Notes</summary>"));
7625        assert!(html.contains("<div class=\"surfdoc-details-body\">"));
7626    }
7627
7628    #[test]
7629    fn html_summary_has_label() {
7630        let doc = doc_with(vec![Block::Summary {
7631            content: "TL;DR here.".into(),
7632            span: span(),
7633        }]);
7634        let html = to_html(&doc);
7635        assert!(html.contains("<div class=\"surfdoc-summary-label\">Summary</div>"));
7636    }
7637
7638    #[test]
7639    fn is_numeric_cell_detection() {
7640        assert!(is_numeric_cell("30"));
7641        assert!(is_numeric_cell(" $0 "));
7642        assert!(is_numeric_cell("$6.99"));
7643        assert!(is_numeric_cell("12%"));
7644        assert!(is_numeric_cell("-5"));
7645        assert!(is_numeric_cell("1,200"));
7646        assert!(!is_numeric_cell("Free"));
7647        assert!(!is_numeric_cell(""));
7648        assert!(!is_numeric_cell("N/A"));
7649        assert!(!is_numeric_cell("$"));
7650    }
7651
7652    #[test]
7653    fn aria_metric_group_label() {
7654        let doc = doc_with(vec![Block::Metric {
7655            label: "MRR".into(),
7656            value: "$5K".into(),
7657            trend: Some(Trend::Up),
7658            unit: Some("USD".into()),
7659            span: span(),
7660        }]);
7661        let html = to_html(&doc);
7662        assert!(html.contains("role=\"group\""));
7663        assert!(html.contains("aria-label=\"MRR: $5K USD, trending up\""));
7664    }
7665
7666    #[test]
7667    fn aria_summary_doc_abstract() {
7668        let doc = doc_with(vec![Block::Summary {
7669            content: "TL;DR.".into(),
7670            span: span(),
7671        }]);
7672        let html = to_html(&doc);
7673        assert!(html.contains("role=\"doc-abstract\""));
7674    }
7675
7676    #[test]
7677    fn aria_tabs_tablist_pattern() {
7678        let doc = doc_with(vec![Block::Tabs {
7679            tabs: vec![
7680                TabPanel { label: "A".into(), content: "First.".into() },
7681                TabPanel { label: "B".into(), content: "Second.".into() },
7682            ],
7683            span: span(),
7684        }]);
7685        let html = to_html(&doc);
7686        assert!(html.contains("role=\"tablist\""));
7687        assert!(html.contains("role=\"tab\""));
7688        assert!(html.contains("role=\"tabpanel\""));
7689        assert!(html.contains("aria-selected=\"true\""));
7690        assert!(html.contains("aria-selected=\"false\""));
7691        assert!(html.contains("aria-controls=\"surfdoc-panel-0\""));
7692        assert!(html.contains("aria-labelledby=\"surfdoc-tab-0\""));
7693    }
7694
7695    #[test]
7696    fn aria_hero_image_role_img() {
7697        let doc = doc_with(vec![Block::HeroImage {
7698            src: "hero.png".into(),
7699            alt: Some("Product shot".into()),
7700            span: span(),
7701        }]);
7702        let html = to_html(&doc);
7703        assert!(html.contains("role=\"img\""));
7704        assert!(html.contains("aria-label=\"Product shot\""));
7705    }
7706
7707    #[test]
7708    fn aria_testimonial_role_figure() {
7709        let doc = doc_with(vec![Block::Testimonial {
7710            content: "Great!".into(),
7711            author: Some("Ada".into()),
7712            role: None,
7713            company: None,
7714            span: span(),
7715        }]);
7716        let html = to_html(&doc);
7717        assert!(html.contains("role=\"figure\""));
7718        assert!(html.contains("aria-label=\"Testimonial from Ada\""));
7719    }
7720
7721    #[test]
7722    fn aria_style_hidden() {
7723        let doc = doc_with(vec![Block::Style {
7724            properties: vec![],
7725            span: span(),
7726        }]);
7727        let html = to_html(&doc);
7728        assert!(html.contains("aria-hidden=\"true\""));
7729    }
7730
7731    #[test]
7732    fn aria_site_hidden() {
7733        let doc = doc_with(vec![Block::Site {
7734            domain: None,
7735            properties: vec![],
7736            span: span(),
7737        }]);
7738        let html = to_html(&doc);
7739        assert!(html.contains("aria-hidden=\"true\""));
7740    }
7741
7742    #[test]
7743    fn aria_page_label_from_title() {
7744        let doc = doc_with(vec![Block::Page {
7745            route: "/about".into(),
7746            layout: None,
7747            title: Some("About Us".into()),
7748            sidebar: false,
7749            content: String::new(),
7750            children: vec![],
7751            span: span(),
7752        }]);
7753        let html = to_html(&doc);
7754        assert!(html.contains("aria-label=\"About Us\""));
7755    }
7756
7757    #[test]
7758    fn aria_page_label_from_route() {
7759        let doc = doc_with(vec![Block::Page {
7760            route: "/pricing".into(),
7761            layout: None,
7762            title: None,
7763            sidebar: false,
7764            content: String::new(),
7765            children: vec![],
7766            span: span(),
7767        }]);
7768        let html = to_html(&doc);
7769        assert!(html.contains("aria-label=\"Page: /pricing\""));
7770    }
7771
7772    #[test]
7773    fn aria_unknown_role_note() {
7774        let doc = doc_with(vec![Block::Unknown {
7775            name: "custom".into(),
7776            attrs: Default::default(),
7777            content: "stuff".into(),
7778            span: span(),
7779        }]);
7780        let html = to_html(&doc);
7781        assert!(html.contains("role=\"note\""));
7782    }
7783
7784    // -- p3-copy-sweep: unknown blocks + explicit heading anchors ----------
7785
7786    #[test]
7787    fn unknown_block_renders_markdown_not_raw() {
7788        // A hallucinated block name (`::team`) renders its content as
7789        // markdown, not as a raw escaped dump with visible `##`.
7790        let doc = doc_with(vec![Block::Unknown {
7791            name: "team".into(),
7792            attrs: Default::default(),
7793            content: "## Crew\n- Ada\n- Lin".into(),
7794            span: span(),
7795        }]);
7796        let html = to_html(&doc);
7797        // Wrapper contract intact.
7798        assert!(html.contains("class=\"surfdoc-unknown\""), "{html}");
7799        assert!(html.contains("role=\"note\""), "{html}");
7800        assert!(html.contains("data-name=\"team\""), "{html}");
7801        // Content is rendered markdown.
7802        assert!(html.contains("Crew</h2>"), "heading rendered: {html}");
7803        assert!(html.contains("<li>Ada</li>"), "list rendered: {html}");
7804        assert!(!html.contains("## Crew"), "literal ## leaked: {html}");
7805        // Sanitizer still applies inside unknown blocks.
7806        let doc = doc_with(vec![Block::Unknown {
7807            name: "team".into(),
7808            attrs: Default::default(),
7809            content: "<script>alert(1)</script>Hi".into(),
7810            span: span(),
7811        }]);
7812        let html = to_html(&doc);
7813        assert!(!html.contains("<script>alert"), "script must be stripped: {html}");
7814        assert!(html.contains("Hi"));
7815    }
7816
7817    #[test]
7818    fn heading_anchor_brace_strips_and_sets_id() {
7819        let doc = doc_with(vec![
7820            Block::Markdown {
7821                content: "## Our Menu {#our-menu}\n\nFood.\n".into(),
7822                span: span(),
7823            },
7824            Block::Toc { depth: 2, entries: Vec::new(), span: span() },
7825        ]);
7826        let html = to_html(&doc);
7827        assert!(html.contains("id=\"our-menu\""), "explicit anchor as id: {html}");
7828        assert!(!html.contains("{#"), "anchor braces leaked: {html}");
7829        // TOC label is the clean text.
7830        assert!(html.contains(">Our Menu</a>"), "clean TOC label: {html}");
7831        // Non-anchor braces are left untouched.
7832        assert_eq!(split_explicit_anchor("Costs {2 + 2}"), None);
7833        assert_eq!(split_explicit_anchor("Menu {#our menu}"), None);
7834        assert_eq!(
7835            split_explicit_anchor("Our Menu {#our-menu}"),
7836            Some(("Our Menu", "our-menu"))
7837        );
7838    }
7839
7840    #[test]
7841    fn aria_pricing_table_label() {
7842        let doc = doc_with(vec![Block::PricingTable {
7843            headers: vec!["Tier".into(), "Price".into()],
7844            rows: vec![vec!["Basic".into(), "$0".into()]],
7845            span: span(),
7846        }]);
7847        let html = to_html(&doc);
7848        assert!(html.contains("aria-label=\"Pricing\""));
7849        assert!(html.contains("class=\"surfdoc-tier\""));
7850    }
7851
7852    #[test]
7853    fn aria_columns_role_group() {
7854        let doc = doc_with(vec![Block::Columns {
7855            columns: vec![
7856                ColumnContent { content: "A".into() },
7857                ColumnContent { content: "B".into() },
7858            ],
7859            span: span(),
7860        }]);
7861        let html = to_html(&doc);
7862        assert!(html.contains("role=\"group\""));
7863    }
7864
7865    // -- extract_site() unit tests -----------------------------------------
7866
7867    #[test]
7868    fn extract_site_separates_blocks() {
7869        let doc = doc_with(vec![
7870            Block::Site {
7871                domain: Some("example.com".into()),
7872                properties: vec![
7873                    StyleProperty { key: "name".into(), value: "My Site".into() },
7874                    StyleProperty { key: "accent".into(), value: "#ff0000".into() },
7875                ],
7876                span: span(),
7877            },
7878            Block::Markdown {
7879                content: "Loose block".into(),
7880                span: span(),
7881            },
7882            Block::Page {
7883                route: "/".into(),
7884                layout: Some("hero".into()),
7885                title: Some("Home".into()),
7886                sidebar: false,
7887                content: "# Welcome".into(),
7888                children: vec![Block::Markdown {
7889                    content: "# Welcome".into(),
7890                    span: span(),
7891                }],
7892                span: span(),
7893            },
7894            Block::Page {
7895                route: "/about".into(),
7896                layout: None,
7897                title: Some("About".into()),
7898                sidebar: false,
7899                content: "# About".into(),
7900                children: vec![Block::Markdown {
7901                    content: "# About".into(),
7902                    span: span(),
7903                }],
7904                span: span(),
7905            },
7906        ]);
7907
7908        let (site, pages, loose) = extract_site(&doc);
7909
7910        // Site config extracted
7911        let site = site.expect("should have site config");
7912        assert_eq!(site.domain.as_deref(), Some("example.com"));
7913        assert_eq!(site.name.as_deref(), Some("My Site"));
7914        assert_eq!(site.accent.as_deref(), Some("#ff0000"));
7915
7916        // Pages extracted
7917        assert_eq!(pages.len(), 2);
7918        assert_eq!(pages[0].route, "/");
7919        assert_eq!(pages[0].title.as_deref(), Some("Home"));
7920        assert_eq!(pages[1].route, "/about");
7921
7922        // Loose blocks
7923        assert_eq!(loose.len(), 1);
7924    }
7925
7926    #[test]
7927    fn extract_site_no_site_block() {
7928        let doc = doc_with(vec![
7929            Block::Markdown {
7930                content: "Just markdown".into(),
7931                span: span(),
7932            },
7933        ]);
7934
7935        let (site, pages, loose) = extract_site(&doc);
7936        assert!(site.is_none());
7937        assert!(pages.is_empty());
7938        assert_eq!(loose.len(), 1);
7939    }
7940
7941    #[test]
7942    fn extract_site_config_fields() {
7943        let doc = doc_with(vec![Block::Site {
7944            domain: Some("test.io".into()),
7945            properties: vec![
7946                StyleProperty { key: "name".into(), value: "Test".into() },
7947                StyleProperty { key: "tagline".into(), value: "A tagline".into() },
7948                StyleProperty { key: "theme".into(), value: "dark".into() },
7949                StyleProperty { key: "accent".into(), value: "#00ff00".into() },
7950                StyleProperty { key: "font".into(), value: "inter".into() },
7951                StyleProperty { key: "custom".into(), value: "value".into() },
7952            ],
7953            span: span(),
7954        }]);
7955
7956        let (site, _, _) = extract_site(&doc);
7957        let site = site.unwrap();
7958        assert_eq!(site.name.as_deref(), Some("Test"));
7959        assert_eq!(site.tagline.as_deref(), Some("A tagline"));
7960        assert_eq!(site.theme.as_deref(), Some("dark"));
7961        assert_eq!(site.accent.as_deref(), Some("#00ff00"));
7962        assert_eq!(site.font.as_deref(), Some("inter"));
7963        assert_eq!(site.properties.len(), 6); // all properties preserved
7964    }
7965
7966    // -- render_site_page() unit tests ------------------------------------
7967
7968    #[test]
7969    fn render_site_page_produces_valid_html() {
7970        let site = SiteConfig {
7971            name: Some("Test Site".into()),
7972            accent: Some("#3b82f6".into()),
7973            ..Default::default()
7974        };
7975        let page = PageEntry {
7976            route: "/".into(),
7977            layout: None,
7978            title: Some("Home".into()),
7979            sidebar: false,
7980            children: vec![Block::Markdown {
7981                content: "# Hello World".into(),
7982                span: span(),
7983            }],
7984        };
7985        let nav_items = vec![
7986            ("/".into(), "Home".into()),
7987            ("/about".into(), "About".into()),
7988        ];
7989        let config = PageConfig::default();
7990
7991        let html = render_site_page(&page, &site, &nav_items, &config);
7992
7993        assert!(html.contains("<!DOCTYPE html>"));
7994        assert!(html.contains("<html lang=\"en\">"));
7995        assert!(html.contains("surfdoc-site-nav"));
7996        assert!(html.contains("Test Site"));
7997        assert!(html.contains("Hello World"));
7998        assert!(html.contains("surfdoc-site-footer"));
7999        assert!(html.contains("#3b82f6")); // accent override
8000    }
8001
8002    #[test]
8003    fn render_site_page_theme_is_hint_not_pin() {
8004        // BR-SITE-THEME (flipped 2026-06-11, deliberately — was
8005        // `render_site_page_respects_theme_light`): `theme` no longer pins
8006        // `data-theme` on <html>. The pre-paint resolver decides instead:
8007        // stored per-site choice → device preference → this hint.
8008        let site = SiteConfig {
8009            name: Some("Dark-designed Site".into()),
8010            theme: Some("dark".into()),
8011            base_path: Some("/s/midnight-co".into()),
8012            ..Default::default()
8013        };
8014        let page = PageEntry {
8015            route: "/".into(),
8016            layout: None,
8017            title: Some("Home".into()),
8018            sidebar: false,
8019            children: vec![],
8020        };
8021        let config = PageConfig::default();
8022
8023        let html = render_site_page(&page, &site, &[], &config);
8024
8025        assert!(
8026            !html.contains("data-theme=\"dark\"") || html.contains("setAttribute('data-theme'"),
8027            "no served data-theme pin"
8028        );
8029        assert!(
8030            html.contains("<html lang=\"en\">"),
8031            "the <html> tag carries NO data-theme attribute — the resolver sets it pre-paint"
8032        );
8033        // The hint survives as the resolver's no-preference fallback…
8034        assert!(html.contains(":'dark');"), "palette hint feeds the resolver fallback");
8035        // …and the persistence key is scoped to the site's base path.
8036        assert!(
8037            html.contains("localStorage.getItem('surfdoc-site-theme:/s/midnight-co')"),
8038            "per-site persistence key"
8039        );
8040    }
8041
8042    #[test]
8043    fn render_site_page_no_theme_attr_when_unset() {
8044        let site = SiteConfig {
8045            name: Some("Default Site".into()),
8046            ..Default::default()
8047        };
8048        let page = PageEntry {
8049            route: "/".into(),
8050            layout: None,
8051            title: Some("Home".into()),
8052            sidebar: false,
8053            children: vec![],
8054        };
8055        let config = PageConfig::default();
8056
8057        let html = render_site_page(&page, &site, &[], &config);
8058
8059        assert!(html.contains("<html lang=\"en\">"), "bare <html> tag when no theme set");
8060        // No theme + no base_path: resolver still emitted, hint defaults to
8061        // light, key scopes to "/".
8062        assert!(html.contains(":'light');"), "default hint is light");
8063        assert!(
8064            html.contains("localStorage.getItem('surfdoc-site-theme:/')"),
8065            "default key scopes to /"
8066        );
8067    }
8068
8069    #[test]
8070    fn render_site_page_sets_accent_text_for_light_accent() {
8071        let site = SiteConfig {
8072            name: Some("Green Site".into()),
8073            accent: Some("#22c55e".into()),
8074            ..Default::default()
8075        };
8076        let page = PageEntry {
8077            route: "/".into(),
8078            layout: None,
8079            title: Some("Home".into()),
8080            sidebar: false,
8081            children: vec![],
8082        };
8083        let config = PageConfig::default();
8084
8085        let html = render_site_page(&page, &site, &[], &config);
8086
8087        // Light accent (#22c55e) must get dark text for WCAG contrast
8088        assert!(html.contains("--accent-text: #1a1a2e"), "light accent must set dark --accent-text");
8089        assert!(html.contains("--accent: #22c55e"), "accent color must be set");
8090    }
8091
8092    #[test]
8093    fn render_site_page_sets_accent_text_for_dark_accent() {
8094        let site = SiteConfig {
8095            name: Some("Blue Site".into()),
8096            accent: Some("#3b82f6".into()),
8097            ..Default::default()
8098        };
8099        let page = PageEntry {
8100            route: "/".into(),
8101            layout: None,
8102            title: Some("Home".into()),
8103            sidebar: false,
8104            children: vec![],
8105        };
8106        let config = PageConfig::default();
8107
8108        let html = render_site_page(&page, &site, &[], &config);
8109
8110        // Dark accent (#3b82f6) must get white text
8111        assert!(html.contains("--accent-text: #fff"), "dark accent must set white --accent-text");
8112    }
8113
8114    #[test]
8115    fn render_site_page_has_nav_links() {
8116        let site = SiteConfig {
8117            name: Some("Nav Test".into()),
8118            ..Default::default()
8119        };
8120        let page = PageEntry {
8121            route: "/about".into(),
8122            layout: None,
8123            title: Some("About".into()),
8124            sidebar: false,
8125            children: vec![],
8126        };
8127        let nav_items = vec![
8128            ("/".into(), "Home".into()),
8129            ("/about".into(), "About".into()),
8130            ("/pricing".into(), "Pricing".into()),
8131        ];
8132        let config = PageConfig::default();
8133
8134        let html = render_site_page(&page, &site, &nav_items, &config);
8135
8136        assert!(html.contains("href=\"/\""));
8137        assert!(html.contains("href=\"/about\""));
8138        assert!(html.contains("href=\"/pricing\""));
8139        // Active link for about page (carries aria-current for AT users);
8140        // the c1-drawer icon span sits between the tag and the label.
8141        assert!(html.contains("class=\"active\" aria-current=\"page\"><span class=\"site-nav-link-icon\""));
8142        assert!(html.contains("</span>About</a>"));
8143    }
8144
8145    #[test]
8146    fn render_site_page_title_format() {
8147        let site = SiteConfig {
8148            name: Some("My Site".into()),
8149            ..Default::default()
8150        };
8151
8152        // Page with title
8153        let page = PageEntry {
8154            route: "/about".into(),
8155            layout: None,
8156            title: Some("About Us".into()),
8157            sidebar: false,
8158            children: vec![],
8159        };
8160        let html = render_site_page(&page, &site, &[], &PageConfig::default());
8161        assert!(html.contains("<title>About Us — My Site</title>"));
8162
8163        // Home page without title
8164        let home = PageEntry {
8165            route: "/".into(),
8166            layout: None,
8167            title: None,
8168            sidebar: false,
8169            children: vec![],
8170        };
8171        let html = render_site_page(&home, &site, &[], &PageConfig::default());
8172        assert!(html.contains("<title>My Site</title>"));
8173    }
8174
8175    // -- Bug regression: CTA specificity fix (.surfdoc .surfdoc-cta beats .surfdoc a) --
8176
8177    #[test]
8178    fn css_cta_selectors_use_parent_scope() {
8179        // The CSS must use `.surfdoc .surfdoc-cta-primary` (specificity 0-2-0) to beat
8180        // `.surfdoc a` (0-1-1). This works for both <a> and <button> elements.
8181        assert!(SURFDOC_CSS.contains(".surfdoc .surfdoc-cta-primary"));
8182        assert!(SURFDOC_CSS.contains(".surfdoc .surfdoc-cta-secondary"));
8183        assert!(SURFDOC_CSS.contains(".surfdoc .surfdoc-cta {"));
8184        // Must NOT use element-qualified selectors (a.surfdoc-cta) since <button> also uses these classes
8185        assert!(!SURFDOC_CSS.contains("a.surfdoc-cta"));
8186    }
8187
8188    #[test]
8189    fn cta_renders_as_anchor_with_classes() {
8190        // CTA must render as <a> tag with both base and variant class so the
8191        // element-qualified CSS selectors match.
8192        let doc = doc_with(vec![Block::Cta {
8193            label: "Download".into(),
8194            href: "https://example.com/dl".into(),
8195            primary: true,
8196            icon: None,
8197            span: span(),
8198        }]);
8199        let html = to_html(&doc);
8200        assert!(html.contains("<a "));
8201        assert!(html.contains("class=\"surfdoc-cta surfdoc-cta-primary\""));
8202        assert!(html.contains("href=\"https://example.com/dl\""));
8203    }
8204
8205    #[test]
8206    fn cta_primary_css_sets_white_text() {
8207        // Verify the CSS uses --accent-text for ADA-compliant button text color
8208        assert!(SURFDOC_CSS.contains(".surfdoc .surfdoc-cta-primary { background: var(--accent); color: var(--accent-text, #fff);"));
8209    }
8210
8211    /// DOG-3: hero/feature web-block component tokens are declared in `:root`
8212    /// AND consumed by their rules, so a consumer WaveSite can retheme these
8213    /// blocks via token VALUES (not specificity-tie overrides). Each `:root`
8214    /// default equals the literal it replaced, so unset == visual no-op.
8215    #[test]
8216    fn css_web_block_component_tokens_declared_and_consumed() {
8217        for (decl, default) in [
8218            ("--ws-hero-btn-radius:", "var(--ws-radius-btn)"),
8219            ("--ws-cta-radius:", "var(--ws-radius-btn)"),
8220            (
8221                "--ws-feature-card-bg:",
8222                "color-mix(in srgb, var(--surface) 50%, var(--surface-alt) 50%)",
8223            ),
8224            ("--ws-feature-card-pad:", "1.5rem"),
8225            ("--ws-feature-card-radius:", "var(--ws-radius-card)"),
8226            ("--ws-feature-card-hover-transform:", "translateY(-2px)"),
8227            // SS-1 (0.9.3): same pattern for the remaining component tokens —
8228            // each :root default == the per-element fallback it mirrors.
8229            ("--ws-tile-surface-bg:", "var(--surface)"),
8230            ("--ws-post-card-bg:", "var(--surface)"),
8231            ("--ws-post-card-radius:", "var(--ws-radius-card)"),
8232            ("--ws-pg-card-bg:", "color-mix(in srgb, var(--surface) 72%, transparent)"),
8233            ("--ws-pg-card-radius:", "var(--ws-radius-card-lg, 20px)"),
8234            ("--ws-pg-tile-radius:", "0"),
8235            ("--ws-details-bg:", "var(--surface-alt)"),
8236            ("--ws-details-radius:", "var(--radius-sm)"),
8237            ("--ws-form-input-bg:", "var(--surface)"),
8238            ("--ws-form-submit-radius:", "var(--ws-control-radius, var(--radius-sm))"),
8239            ("--ws-doc-page-bg:", "var(--surface)"),
8240            ("--ws-doc-page-radius:", "var(--ws-radius-card)"),
8241            ("--ws-drawer-link-size:", "0.9375rem"),
8242            ("--ws-drawer-link-weight:", "500"),
8243        ] {
8244            assert!(
8245                SURFDOC_CSS.contains(&format!("{decl} {default};")),
8246                "{decl} must be declared in :root with its no-op default `{default}`"
8247            );
8248        }
8249        // Consumed by the component rules (not just declared).
8250        assert!(SURFDOC_CSS.contains("border-radius: var(--ws-hero-btn-radius);"));
8251        assert!(SURFDOC_CSS.contains("border-radius: var(--ws-cta-radius);"));
8252        assert!(SURFDOC_CSS.contains("padding: var(--ws-feature-card-pad);"));
8253        assert!(SURFDOC_CSS.contains("border-radius: var(--ws-feature-card-radius);"));
8254        assert!(SURFDOC_CSS.contains("background: var(--ws-feature-card-bg);"));
8255        assert!(SURFDOC_CSS.contains("transform: var(--ws-feature-card-hover-transform);"));
8256        // SS-1 tokens keep their per-element consumption (fallback form), so
8257        // container-level overrides still reach the rules by VALUE.
8258        assert!(SURFDOC_CSS.contains("var(--ws-tile-surface-bg, var(--surface))"));
8259        assert!(SURFDOC_CSS.contains("var(--ws-form-input-bg, var(--surface))"));
8260        assert!(SURFDOC_CSS.contains("var(--ws-drawer-link-size, 0.9375rem)"));
8261        assert!(SURFDOC_CSS.contains("var(--ws-drawer-link-weight, 500)"));
8262        assert!(SURFDOC_CSS.contains("var(--ws-pg-tile-radius, 0)"));
8263        // --ws-control-radius is deliberately NOT :root-declared (divergent
8264        // per-element defaults: forms --radius-sm, app controls 8px).
8265        assert!(!SURFDOC_CSS.contains("--ws-control-radius:"));
8266    }
8267
8268    /// The ::banner and ::product-grid blocks are emitted by render_html, so
8269    /// their structural geometry (band/inner/actions/button + grid/card/body/
8270    /// emblem/name/tagline/arrow) must ship with the crate — not live only in a
8271    /// consumer's stylesheet. Guards against the geometry regressing to
8272    /// consumer-only (the DOG-2 upstreaming).
8273    #[test]
8274    fn css_banner_and_product_grid_geometry_shipped() {
8275        for sel in [
8276            ".surfdoc-banner-inner {",
8277            ".surfdoc-banner-actions {",
8278            ".surfdoc-banner-btn {",
8279            ".surfdoc-banner-btn-secondary {",
8280            ".surfdoc-pg-inner {",
8281            ".surfdoc-pg-label {",
8282            ".surfdoc-pg-row {",
8283            ".surfdoc-pg-card {",
8284            ".surfdoc-pg-body {",
8285            ".surfdoc-pg-emblem {",
8286            ".surfdoc-pg-name {",
8287            ".surfdoc-pg-tagline {",
8288            ".surfdoc-pg-arrow {",
8289        ] {
8290            assert!(
8291                SURFDOC_CSS.contains(sel),
8292                "{sel} structural geometry must ship in surfdoc.css (DOG-2)"
8293            );
8294        }
8295        // Geometry references only standard surfdoc tokens (self-contained).
8296        assert!(SURFDOC_CSS.contains(".surfdoc-pg-card { display: flex;"));
8297        assert!(SURFDOC_CSS.contains("background: var(--surface); border: 1px solid var(--border);"));
8298        // The 2-col grid collapses to 1 on mobile.
8299        assert!(SURFDOC_CSS.contains(".surfdoc-pg-row { grid-template-columns: 1fr; }"));
8300    }
8301
8302    /// Regression: hero and product-card CTA buttons are `<a>` tags inside
8303    /// `.surfdoc`. Without the `.surfdoc` prefix their specificity (0,1,0)
8304    /// loses to `.surfdoc a { color: var(--accent) }` (0,1,1), making button
8305    /// text the same color as the background — invisible on accent-colored buttons.
8306    #[test]
8307    fn accent_colored_buttons_beat_link_color_specificity() {
8308        // Hero primary button must have .surfdoc prefix
8309        assert!(
8310            SURFDOC_CSS.contains(".surfdoc .surfdoc-hero-btn-primary {"),
8311            "hero primary button needs .surfdoc prefix to beat .surfdoc a specificity"
8312        );
8313        // Product card CTA must have .surfdoc prefix
8314        assert!(
8315            SURFDOC_CSS.contains(".surfdoc .surfdoc-product-cta {"),
8316            "product-card CTA needs .surfdoc prefix to beat .surfdoc a specificity"
8317        );
8318        // CTA primary (already correct, guard against regression)
8319        assert!(
8320            SURFDOC_CSS.contains(".surfdoc .surfdoc-cta-primary {"),
8321            "CTA primary needs .surfdoc prefix to beat .surfdoc a specificity"
8322        );
8323    }
8324
8325    // -- Bug regression: web-block prose fields render inline markdown --------
8326
8327    /// Regression: hero headline/subtitle and feature title/body went through
8328    /// escape_html() only, so `**bold**` / `*italic*` showed literal asterisks
8329    /// on published sites. Prose fields must run the inline markdown pass.
8330    #[test]
8331    fn web_block_prose_renders_inline_markdown() {
8332        let doc = doc_with(vec![
8333            Block::Hero {
8334                headline: Some("Home of **Scouter**".into()),
8335                subtitle: Some("Mixed by **Darrell Thorp**".into()),
8336                badge: None,
8337                align: "center".into(),
8338                image: None,
8339                image_alt: None,
8340                layout: None,
8341                transparent: false,
8342                buttons: vec![],
8343                content: String::new(),
8344                span: span(),
8345            },
8346            Block::Features {
8347                cards: vec![crate::types::FeatureCard {
8348                    title: "*Time Traveler*".into(),
8349                    icon: None,
8350                    body: "Mastered by **Darrell Thorp** — vinyl-ready.".into(),
8351                    link_label: None,
8352                    link_href: None,
8353                }],
8354                cols: None,
8355                span: span(),
8356            },
8357        ]);
8358        let html = to_html(&doc);
8359        assert!(html.contains("<strong>Scouter</strong>"), "hero headline bold: {html}");
8360        assert!(html.contains("<strong>Darrell Thorp</strong>"), "hero subtitle / feature body bold");
8361        assert!(html.contains("<em>Time Traveler</em>"), "feature title italic");
8362        assert!(!html.contains("**"), "no literal asterisks may survive");
8363    }
8364
8365    /// The inline pass must still escape raw HTML (injection safety) — the
8366    /// content is escaped BEFORE pulldown-cmark sees it.
8367    #[test]
8368    fn web_block_prose_escapes_raw_html() {
8369        let doc = doc_with(vec![Block::Hero {
8370            headline: Some("<script>alert(1)</script>".into()),
8371            subtitle: None,
8372            badge: None,
8373            align: "center".into(),
8374            image: None,
8375            image_alt: None,
8376            layout: None,
8377            transparent: false,
8378            buttons: vec![],
8379            content: String::new(),
8380            span: span(),
8381        }]);
8382        let html = to_html(&doc);
8383        assert!(!html.contains("<script>"), "raw HTML must be escaped");
8384        assert!(html.contains("&lt;script&gt;"), "escaped form must be present");
8385    }
8386
8387    // p1-fill-render: a FILL default containing markdown emphasis must not
8388    // let pulldown-cmark splice a <strong> tag into the marker body — that
8389    // breaks resolve_slot_markers's grammar (it bails on raw `<`/`>` inside
8390    // `«…»`) and the raw marker leaks straight into the hero H1.
8391    #[test]
8392    fn hero_headline_fill_marker_with_emphasis_resolves_cleanly() {
8393        let doc = doc_with(vec![Block::Hero {
8394            headline: Some("\u{ab}FILL: brand tag | Skate **or** Die\u{bb}".into()),
8395            subtitle: None,
8396            badge: None,
8397            align: "center".into(),
8398            image: None,
8399            image_alt: None,
8400            layout: None,
8401            transparent: false,
8402            buttons: vec![],
8403            content: String::new(),
8404            span: span(),
8405        }]);
8406        let html = crate::slots::resolve_slot_markers(to_html(&doc));
8407        assert!(
8408            !html.contains('\u{ab}') && !html.contains('\u{bb}'),
8409            "marker must not leak raw. Got: {html}"
8410        );
8411        assert!(
8412            html.contains("<h1 class=\"surfdoc-hero-headline\">"),
8413            "hero H1 must still render. Got: {html}"
8414        );
8415    }
8416
8417    // -- Style-pack contract (rendering annex) ---------------------------------
8418
8419    /// The --ws-* tokens are the style-pack contract: packs (container-injected
8420    /// overrides) re-set ONLY these. Defaults = Surf Simple (rounded-rect
8421    /// controls, 16px cards — the app-shell language). Web blocks must consume
8422    /// the tokens, not --radius-sm, or packs silently stop reaching them.
8423    #[test]
8424    fn style_pack_tokens_defined_with_surf_simple_defaults() {
8425        for decl in [
8426            "--ws-radius-card: 16px",
8427            "--ws-radius-btn: 10px",
8428            "--ws-radius-chip: 999px",
8429            "--ws-radius-img: 10px",
8430            "--ws-border-w: 1px",
8431            "--ws-shadow: 0 1px 2px rgba(15, 23, 42, 0.04)",
8432            "--ws-shadow-hover:",
8433            "--ws-bg-texture: none",
8434            "--ws-hero-bg:",
8435            // SS-1 (0.9.3) additive contract tokens — declared with their
8436            // identity (no-op) defaults so packs reach them by VALUE.
8437            "--ws-hero-btn-radius: var(--ws-radius-btn)",
8438            "--ws-banner-btn-radius: var(--radius-sm)",
8439            "--ws-cta-radius: var(--ws-radius-btn)",
8440            "--ws-form-submit-radius: var(--ws-control-radius, var(--radius-sm))",
8441            "--ws-feature-card-radius: var(--ws-radius-card)",
8442            "--ws-feature-card-pad: 1.5rem",
8443            "--ws-feature-card-hover-transform: translateY(-2px)",
8444            "--ws-feature-card-bg: color-mix(in srgb, var(--surface) 50%, var(--surface-alt) 50%)",
8445            "--ws-tile-surface-bg: var(--surface)",
8446            "--ws-post-card-bg: var(--surface)",
8447            "--ws-post-card-radius: var(--ws-radius-card)",
8448            "--ws-pg-card-bg: color-mix(in srgb, var(--surface) 72%, transparent)",
8449            "--ws-pg-card-radius: var(--ws-radius-card-lg, 20px)",
8450            "--ws-pg-tile-radius: 0",
8451            "--ws-details-bg: var(--surface-alt)",
8452            "--ws-details-radius: var(--radius-sm)",
8453            "--ws-form-input-bg: var(--surface)",
8454            "--ws-doc-page-bg: var(--surface)",
8455            "--ws-doc-page-radius: var(--ws-radius-card)",
8456            "--ws-drawer-link-size: 0.9375rem",
8457            "--ws-drawer-link-weight: 500",
8458        ] {
8459            assert!(SURFDOC_CSS.contains(decl), "missing contract token default: {decl}");
8460        }
8461    }
8462
8463    #[test]
8464    fn web_blocks_consume_style_pack_tokens() {
8465        for (selector, token) in [
8466            (".surfdoc-hero {", "--ws-radius-card"),
8467            (".surfdoc-hero-badge {", "--ws-radius-chip"),
8468            // hero-btn/cta/feature-card now read a dedicated component token that
8469            // CHAINS to the shared radius default (DOG-3), so the style-pack
8470            // contract still holds transitively while staying consumer-overridable.
8471            (".surfdoc-hero-btn {", "--ws-hero-btn-radius"),
8472            (".surfdoc-feature-card {", "--ws-feature-card-radius"),
8473            (".surfdoc-stat {", "--ws-radius-card"),
8474            (".surfdoc-testimonial {", "--ws-radius-card"),
8475            (".surfdoc-product-card {", "--ws-radius-card"),
8476            (".surfdoc .surfdoc-cta {", "--ws-cta-radius"),
8477            (".surfdoc .surfdoc-product-cta {", "--ws-radius-btn"),
8478        ] {
8479            let rule = SURFDOC_CSS
8480                .split(selector)
8481                .nth(1)
8482                .and_then(|rest| rest.split('}').next())
8483                .unwrap_or_else(|| panic!("selector not found: {selector}"));
8484            assert!(
8485                rule.contains(token),
8486                "{selector} must use var({token}), got: {rule}"
8487            );
8488        }
8489    }
8490
8491    // -- Sections wrap h1/h2 boundaries (no background alternation) -----------
8492
8493    #[test]
8494    fn sections_wrap_h1_boundaries() {
8495        let doc = doc_with(vec![
8496            Block::Markdown { content: "# Section One".into(), span: span() },
8497            Block::Markdown { content: "Content under section one.".into(), span: span() },
8498            Block::Markdown { content: "# Section Two".into(), span: span() },
8499            Block::Markdown { content: "Content under section two.".into(), span: span() },
8500        ]);
8501        let html = to_html(&doc);
8502        // Every section is a uniform .surfdoc-section — no auto-alternation.
8503        assert_eq!(html.matches("<section class=\"surfdoc-section\">").count(), 2);
8504        assert!(!html.contains("surfdoc-section-alt"));
8505        // Both sections should be closed
8506        assert_eq!(html.matches("</section>").count(), 2);
8507    }
8508
8509    #[test]
8510    fn sections_wrap_h2_boundaries() {
8511        let doc = doc_with(vec![
8512            Block::Markdown { content: "## First".into(), span: span() },
8513            Block::Markdown { content: "Body A.".into(), span: span() },
8514            Block::Markdown { content: "## Second".into(), span: span() },
8515            Block::Markdown { content: "Body B.".into(), span: span() },
8516        ]);
8517        let html = to_html(&doc);
8518        assert_eq!(html.matches("<section class=\"surfdoc-section\">").count(), 2);
8519        assert!(!html.contains("surfdoc-section-alt"));
8520        assert_eq!(html.matches("</section>").count(), 2);
8521    }
8522
8523    #[test]
8524    fn sections_do_not_alternate_backgrounds() {
8525        let doc = doc_with(vec![
8526            Block::Markdown { content: "# A".into(), span: span() },
8527            Block::Markdown { content: "# B".into(), span: span() },
8528            Block::Markdown { content: "# C".into(), span: span() },
8529        ]);
8530        let html = to_html(&doc);
8531        // No section ever gets the alt class (auto-alternation removed).
8532        assert_eq!(html.matches("surfdoc-section-alt").count(), 0);
8533        assert_eq!(html.matches("<section class=\"surfdoc-section\">").count(), 3);
8534        assert_eq!(html.matches("</section>").count(), 3);
8535    }
8536
8537    #[test]
8538    fn no_sections_without_headings() {
8539        let doc = doc_with(vec![
8540            Block::Markdown { content: "Just a paragraph.".into(), span: span() },
8541            Block::Cta { label: "Go".into(), href: "/".into(), primary: true, icon: None, span: span() },
8542        ]);
8543        let html = to_html(&doc);
8544        assert!(!html.contains("<section"));
8545        assert!(!html.contains("</section>"));
8546    }
8547
8548    #[test]
8549    fn section_css_exists() {
8550        assert!(SURFDOC_CSS.contains(".surfdoc-section {"));
8551        // Auto-alternating backgrounds were removed — the alt rule must be gone.
8552        assert!(!SURFDOC_CSS.contains(".surfdoc-section-alt"));
8553    }
8554
8555    // -- Bug regression: to_html_page embeds CSS ------------------------------
8556
8557    #[test]
8558    fn html_page_embeds_surfdoc_css() {
8559        let doc = doc_with(vec![Block::Markdown {
8560            content: "# Test".into(),
8561            span: span(),
8562        }]);
8563        let config = PageConfig::default();
8564        let html = to_html_page(&doc, &config);
8565        // Must contain key CSS rules from SURFDOC_CSS
8566        assert!(html.contains("<style>"));
8567        assert!(html.contains("--background:"));
8568        assert!(html.contains(".surfdoc {"));
8569        assert!(html.contains(".surfdoc .surfdoc-cta-primary"));
8570    }
8571
8572    #[test]
8573    fn html_page_wraps_body_in_surfdoc_div() {
8574        let doc = doc_with(vec![Block::Markdown {
8575            content: "Hello".into(),
8576            span: span(),
8577        }]);
8578        let config = PageConfig::default();
8579        let html = to_html_page(&doc, &config);
8580        assert!(html.contains("<article class=\"surfdoc\">"));
8581    }
8582
8583    // -- Nav block tests --------------------------------------------------
8584
8585    #[test]
8586    fn html_nav_renders_links() {
8587        let doc = doc_with(vec![Block::Nav {
8588            items: vec![
8589                crate::types::NavItem { label: "Home".into(), href: "/".into(), icon: None, image: None, external: false },
8590                crate::types::NavItem { label: "About".into(), href: "#about".into(), icon: None, image: None, external: false },
8591            ],
8592            logo: Some("MySite".into()),
8593            groups: vec![], brand: None, brand_reg: false, cta: None, drawer: false, minimal: false,
8594            span: span(),
8595        }]);
8596        let html = to_html(&doc);
8597        assert!(html.contains("class=\"surfdoc-nav\""));
8598        assert!(html.contains("surfdoc-nav-logo"));
8599        assert!(html.contains("MySite"));
8600        // New nav shell: sticky topbar + slide-in left drawer + theme toggle.
8601        assert!(html.contains("surfdoc-nav-topbar"));
8602        assert!(html.contains("surfdoc-nav-drawer"));
8603        assert!(html.contains("surfdoc-nav-theme-toggle"));
8604        // Toggle checkbox must be the first child so :checked ~ selectors work.
8605        let nav_open = html.find("<nav class=\"surfdoc-nav\"").unwrap();
8606        let toggle = html.find("class=\"surfdoc-nav-toggle\"").unwrap();
8607        let topbar = html.find("surfdoc-nav-topbar").unwrap();
8608        assert!(nav_open < toggle && toggle < topbar, "toggle must precede topbar");
8609        // Drawer rows come from the block's items.
8610        assert!(html.contains("href=\"/\""));
8611        assert!(html.contains("href=\"#about\""));
8612        assert!(html.contains("class=\"surfdoc-nav-drawer-row\""));
8613        assert!(html.contains(">Home</span></a>"));
8614        assert!(html.contains(">About</span></a>"));
8615    }
8616
8617    #[test]
8618    fn html_nav_renders_before_sections() {
8619        let doc = doc_with(vec![
8620            Block::Markdown { content: "# Section One".into(), span: span() },
8621            Block::Nav {
8622                items: vec![
8623                    crate::types::NavItem { label: "Top".into(), href: "#top".into(), icon: None, image: None, external: false },
8624                ],
8625                logo: None,
8626                groups: vec![], brand: None, brand_reg: false, cta: None, drawer: false, minimal: false,
8627                span: span(),
8628            },
8629        ]);
8630        let html = to_html(&doc);
8631        let nav_pos = html.find("surfdoc-nav").unwrap();
8632        let section_pos = html.find("surfdoc-section").unwrap();
8633        assert!(nav_pos < section_pos, "Nav must render before sections");
8634    }
8635
8636    #[test]
8637    fn html_nav_uses_site_name_as_logo_fallback() {
8638        let doc = doc_with(vec![
8639            Block::Site {
8640                domain: None,
8641                properties: vec![StyleProperty { key: "name".into(), value: "Surf".into() }],
8642                span: span(),
8643            },
8644            Block::Nav {
8645                items: vec![
8646                    crate::types::NavItem { label: "Docs".into(), href: "/docs".into(), icon: None, image: None, external: false },
8647                ],
8648                logo: None,
8649                groups: vec![], brand: None, brand_reg: false, cta: None, drawer: false, minimal: false,
8650                span: span(),
8651            },
8652        ]);
8653        let html = to_html(&doc);
8654        assert!(html.contains("surfdoc-nav-logo"));
8655        assert!(html.contains("Surf"));
8656    }
8657
8658    #[test]
8659    fn html_nav_with_icons() {
8660        let doc = doc_with(vec![Block::Nav {
8661            items: vec![
8662                crate::types::NavItem {
8663                    label: "GitHub".into(),
8664                    href: "https://github.com".into(),
8665                    icon: Some("github".into()),
8666                    image: None,
8667                    external: false,
8668                },
8669            ],
8670            logo: None,
8671            groups: vec![], brand: None, brand_reg: false, cta: None, drawer: false, minimal: false,
8672            span: span(),
8673        }]);
8674        let html = to_html(&doc);
8675        // Icon support is preserved on drawer rows.
8676        assert!(html.contains("surfdoc-icon"));
8677        assert!(html.contains("<svg"));
8678        assert!(html.contains("surfdoc-nav-drawer-row"));
8679        assert!(html.contains(">GitHub</span></a>"));
8680    }
8681
8682    #[test]
8683    fn html_nav_escapes_xss() {
8684        let doc = doc_with(vec![Block::Nav {
8685            items: vec![
8686                crate::types::NavItem {
8687                    label: "<script>alert('x')</script>".into(),
8688                    href: "javascript:alert(1)".into(),
8689                    icon: None,
8690                    image: None,
8691                    external: false,
8692                },
8693            ],
8694            logo: Some("<img onerror=alert(1)>".into()),
8695            groups: vec![], brand: None, brand_reg: false, cta: None, drawer: false, minimal: false,
8696            span: span(),
8697        }]);
8698        let html = to_html(&doc);
8699        assert!(!html.contains("<script>alert"));
8700        assert!(!html.contains("<img onerror"));
8701        assert!(html.contains("&lt;script&gt;"));
8702    }
8703
8704    #[test]
8705    fn html_nav_css_exists() {
8706        assert!(SURFDOC_CSS.contains(".surfdoc-nav {"));
8707        assert!(SURFDOC_CSS.contains(".surfdoc-nav-logo"));
8708        assert!(SURFDOC_CSS.contains(".surfdoc-nav-topbar"));
8709        assert!(SURFDOC_CSS.contains(".surfdoc-nav-drawer"));
8710        assert!(SURFDOC_CSS.contains(".surfdoc-nav-theme-toggle"));
8711        assert!(SURFDOC_CSS.contains(".surfdoc-nav-scrim"));
8712        assert!(SURFDOC_CSS.contains(".surfdoc-icon"));
8713    }
8714
8715    // -- Rich shell nav/footer tests -------------------------------------
8716
8717    fn nav_item(label: &str, href: &str, icon: Option<&str>) -> NavItem {
8718        NavItem {
8719            label: label.into(),
8720            href: href.into(),
8721            icon: icon.map(|s| s.into()),
8722            image: None,
8723            external: false,
8724        }
8725    }
8726
8727    #[test]
8728    fn shell_nav_renders_groups_brand_cta_and_drawer() {
8729        let doc = doc_with(vec![Block::Nav {
8730            items: vec![],
8731            logo: Some("/logo.png".into()),
8732            groups: vec![
8733                NavGroup {
8734                    label: Some("Explore".into()),
8735                    items: vec![nav_item("Research", "/research", Some("flask"))],
8736                },
8737                NavGroup {
8738                    label: Some("Products".into()),
8739                    items: vec![NavItem {
8740                        label: "Surf".into(),
8741                        href: "https://app.surf".into(),
8742                        icon: None,
8743                        image: Some("/surf.png".into()),
8744                        external: true,
8745                    }],
8746                },
8747            ],
8748            brand: Some("CloudSurf".into()),
8749            brand_reg: true,
8750            cta: Some(nav_item("Get in touch", "#contact", None)),
8751            drawer: true,
8752            minimal: false,
8753            span: span(),
8754        }]);
8755        let html = to_html(&doc);
8756        // Rich shell markup + JS hooks.
8757        assert!(html.contains("class=\"surfdoc-shell-nav\""));
8758        assert!(html.contains("onclick=\"toggleDrawer()\""));
8759        assert!(html.contains("onclick=\"toggleTheme()\""));
8760        assert!(html.contains("id=\"surfdoc-shell-drawer\""));
8761        assert!(html.contains("surfdoc-shell-scrim"));
8762        // Brand wordmark with ® superscript.
8763        assert!(html.contains("CloudSurf<sup class=\"surfdoc-shell-reg\">&reg;</sup>"));
8764        // Grouped labels + icon + product emblem + external target.
8765        assert!(html.contains(">Explore</span>"));
8766        assert!(html.contains(">Products</span>"));
8767        assert!(html.contains("surfdoc-shell-drawer-link-icon"));
8768        // Emblem is a masked span carrying the asset path via --emblem (no <img>).
8769        assert!(html.contains(
8770            "<span class=\"surfdoc-shell-drawer-link-emblem\" style=\"--emblem:url('/surf.png')\">"
8771        ));
8772        assert!(html.contains("target=\"_blank\""));
8773        // Per-page CTA.
8774        assert!(html.contains("surfdoc-shell-nav-cta"));
8775        assert!(html.contains(">Get in touch</a>"));
8776    }
8777
8778    #[test]
8779    fn plain_nav_stays_legacy() {
8780        // No groups/brand/drawer → legacy checkbox nav (back-compat).
8781        let doc = doc_with(vec![Block::Nav {
8782            items: vec![nav_item("Home", "/", None)],
8783            logo: Some("Acme".into()),
8784            groups: vec![],
8785            brand: None,
8786            brand_reg: false,
8787            cta: None,
8788            drawer: false,
8789            minimal: false,
8790            span: span(),
8791        }]);
8792        let html = to_html(&doc);
8793        assert!(html.contains("class=\"surfdoc-nav\""));
8794        assert!(!html.contains("surfdoc-shell-nav"));
8795    }
8796
8797    #[test]
8798    fn shell_footer_renders_brand_column() {
8799        let doc = doc_with(vec![Block::Footer {
8800            sections: vec![crate::types::FooterSection {
8801                heading: "Products".into(),
8802                links: vec![nav_item("Surf", "https://app.surf", None)],
8803            }],
8804            copyright: Some("(c) 2026 CloudSurf".into()),
8805            social: vec![],
8806            brand: Some("CloudSurf".into()),
8807            brand_reg: true,
8808            brand_logo: Some("/logo.png".into()),
8809            tagline: Some("Innovate • Simplify • Scale".into()),
8810            span: span(),
8811        }]);
8812        let html = render_block(&doc.blocks[0]);
8813        assert!(html.contains("surfdoc-shell-footer"));
8814        assert!(html.contains("surfdoc-shell-footer-brand-col"));
8815        assert!(html.contains("surfdoc-shell-footer-tagline"));
8816        assert!(html.contains("Innovate"));
8817        assert!(html.contains("surfdoc-shell-footer-heading"));
8818        assert!(html.contains("surfdoc-shell-footer-copyright"));
8819    }
8820
8821    #[test]
8822    fn page_head_emits_icons_scripts_theme_init() {
8823        let doc = doc_with(vec![]);
8824        let config = PageConfig {
8825            theme_init: true,
8826            theme_key: Some("theme".into()),
8827            embed_css: false,
8828            twitter_card: Some("summary_large_image".into()),
8829            og_image: Some("https://x/og.png".into()),
8830            stylesheets: vec!["/static/css/surf-ui.css".into(), "/static/css/app.css?v=14".into()],
8831            scripts: vec![HeadScript { src: "/static/js/htmx.min.js".into(), defer: true }],
8832            icons: vec![HeadIcon {
8833                rel: "icon".into(),
8834                icon_type: None,
8835                sizes: Some("any".into()),
8836                href: "/favicon.ico".into(),
8837            }],
8838            ..PageConfig::default()
8839        };
8840        let html = to_html_page(&doc, &config);
8841        assert!(html.contains("<meta name=\"twitter:card\" content=\"summary_large_image\">"));
8842        assert!(html.contains("rel=\"icon\" sizes=\"any\" href=\"/favicon.ico\""));
8843        assert!(html.contains("<link rel=\"stylesheet\" href=\"/static/css/app.css?v=14\">"));
8844        assert!(html.contains("src=\"/static/js/htmx.min.js\" defer>"));
8845        assert!(html.contains("function toggleTheme()"));
8846        assert!(html.contains("function toggleDrawer()"));
8847        // embed_css=false → no inlined base stylesheet.
8848        assert!(!html.contains("<style>"));
8849    }
8850
8851    #[test]
8852    fn page_config_from_site_doc_reads_head() {
8853        let doc = doc_with(vec![Block::Site {
8854            domain: Some("cloudsurf.com".into()),
8855            properties: vec![
8856                StyleProperty { key: "og-image".into(), value: "/og.png".into() },
8857                StyleProperty { key: "twitter-card".into(), value: "summary_large_image".into() },
8858                StyleProperty { key: "theme-init".into(), value: "true".into() },
8859                StyleProperty { key: "theme-key".into(), value: "theme".into() },
8860                StyleProperty { key: "favicon".into(), value: "/favicon.ico".into() },
8861                StyleProperty { key: "stylesheet".into(), value: "/a.css".into() },
8862                StyleProperty { key: "stylesheet".into(), value: "/b.css".into() },
8863                StyleProperty { key: "script-defer".into(), value: "/x.js".into() },
8864            ],
8865            span: span(),
8866        }]);
8867        let cfg = PageConfig::from_site_doc(&doc);
8868        assert_eq!(cfg.og_image.as_deref(), Some("/og.png"));
8869        assert_eq!(cfg.twitter_card.as_deref(), Some("summary_large_image"));
8870        assert!(cfg.theme_init);
8871        assert_eq!(cfg.theme_key.as_deref(), Some("theme"));
8872        assert_eq!(cfg.icons.len(), 1);
8873        assert_eq!(cfg.stylesheets, vec!["/a.css".to_string(), "/b.css".to_string()]);
8874        assert_eq!(cfg.scripts.len(), 1);
8875        assert!(cfg.scripts[0].defer);
8876    }
8877
8878    // -- Icon on CTA tests ------------------------------------------------
8879
8880    #[test]
8881    fn html_cta_with_icon() {
8882        let doc = doc_with(vec![Block::Cta {
8883            label: "Download".into(),
8884            href: "/dl".into(),
8885            primary: true,
8886            icon: Some("download".into()),
8887            span: span(),
8888        }]);
8889        let html = to_html(&doc);
8890        assert!(html.contains("surfdoc-icon"));
8891        assert!(html.contains("<svg"));
8892        assert!(html.contains("Download</a>"));
8893    }
8894
8895    #[test]
8896    fn html_cta_unknown_icon_omitted() {
8897        let doc = doc_with(vec![Block::Cta {
8898            label: "Go".into(),
8899            href: "/go".into(),
8900            primary: true,
8901            icon: Some("nonexistent-icon".into()),
8902            span: span(),
8903        }]);
8904        let html = to_html(&doc);
8905        assert!(!html.contains("surfdoc-icon"));
8906        assert!(html.contains(">Go</a>"));
8907    }
8908
8909    #[test]
8910    fn html_cta_no_icon_no_svg() {
8911        let doc = doc_with(vec![Block::Cta {
8912            label: "Click".into(),
8913            href: "/click".into(),
8914            primary: false,
8915            icon: None,
8916            span: span(),
8917        }]);
8918        let html = to_html(&doc);
8919        assert!(!html.contains("surfdoc-icon"));
8920        assert!(!html.contains("<svg"));
8921    }
8922
8923    // -- Features icon tests ----------------------------------------------
8924
8925    #[test]
8926    fn html_features_with_known_icon() {
8927        let doc = doc_with(vec![Block::Features {
8928            cards: vec![crate::types::FeatureCard {
8929                title: "Fast".into(),
8930                icon: Some("zap".into()),
8931                body: "Lightning fast".into(),
8932                link_label: None,
8933                link_href: None,
8934            }],
8935            cols: None,
8936            span: span(),
8937        }]);
8938        let html = to_html(&doc);
8939        assert!(html.contains("surfdoc-feature-icon"), "should have icon wrapper");
8940        assert!(html.contains("<svg"), "should contain inline SVG");
8941        assert!(!html.contains(">zap<"), "should NOT render icon name as text");
8942    }
8943
8944    #[test]
8945    fn html_features_with_unknown_icon_omitted() {
8946        let doc = doc_with(vec![Block::Features {
8947            cards: vec![crate::types::FeatureCard {
8948                title: "Mystery".into(),
8949                icon: Some("nonexistent-icon".into()),
8950                body: "No icon".into(),
8951                link_label: None,
8952                link_href: None,
8953            }],
8954            cols: None,
8955            span: span(),
8956        }]);
8957        let html = to_html(&doc);
8958        assert!(!html.contains("surfdoc-feature-icon"), "unknown icon should be omitted");
8959        assert!(!html.contains("nonexistent-icon"), "icon name should not appear as text");
8960        assert!(html.contains("Mystery"), "title should still render");
8961    }
8962
8963    #[test]
8964    fn html_features_no_icon_no_svg() {
8965        let doc = doc_with(vec![Block::Features {
8966            cards: vec![crate::types::FeatureCard {
8967                title: "Plain".into(),
8968                icon: None,
8969                body: "No icon".into(),
8970                link_label: None,
8971                link_href: None,
8972            }],
8973            cols: None,
8974            span: span(),
8975        }]);
8976        let html = to_html(&doc);
8977        assert!(!html.contains("surfdoc-feature-icon"));
8978        assert!(!html.contains("<svg"));
8979        assert!(html.contains("Plain"));
8980    }
8981
8982    #[test]
8983    fn html_features_new_icons_resolve() {
8984        // Test all the newly added icons render as SVGs
8985        let new_icons = &[
8986            "clock", "edit", "pencil", "shield", "zap", "lock", "phone",
8987            "map-pin", "calendar", "users", "truck", "message-circle",
8988            "image", "briefcase", "award", "layers", "package",
8989            "trending-up", "coffee", "scissors", "wrench",
8990        ];
8991        for icon_name in new_icons {
8992            let doc = doc_with(vec![Block::Features {
8993                cards: vec![crate::types::FeatureCard {
8994                    title: format!("Test {}", icon_name),
8995                    icon: Some(icon_name.to_string()),
8996                    body: String::new(),
8997                    link_label: None,
8998                    link_href: None,
8999                }],
9000                cols: None,
9001                span: span(),
9002            }]);
9003            let html = to_html(&doc);
9004            assert!(
9005                html.contains("<svg"),
9006                "Icon '{}' should render as SVG in features block",
9007                icon_name
9008            );
9009            assert!(
9010                html.contains("surfdoc-feature-icon"),
9011                "Icon '{}' should have feature-icon wrapper",
9012                icon_name
9013            );
9014        }
9015    }
9016
9017    #[test]
9018    fn html_features_edit_and_pencil_are_same() {
9019        let doc_edit = doc_with(vec![Block::Features {
9020            cards: vec![crate::types::FeatureCard {
9021                title: "Edit".into(),
9022                icon: Some("edit".into()),
9023                body: String::new(),
9024                link_label: None,
9025                link_href: None,
9026            }],
9027            cols: None,
9028            span: span(),
9029        }]);
9030        let doc_pencil = doc_with(vec![Block::Features {
9031            cards: vec![crate::types::FeatureCard {
9032                title: "Edit".into(),
9033                icon: Some("pencil".into()),
9034                body: String::new(),
9035                link_label: None,
9036                link_href: None,
9037            }],
9038            cols: None,
9039            span: span(),
9040        }]);
9041        let html_edit = to_html(&doc_edit);
9042        let html_pencil = to_html(&doc_pencil);
9043        // Both should produce the same SVG
9044        assert!(html_edit.contains("<svg"));
9045        assert_eq!(html_edit, html_pencil);
9046    }
9047
9048    // -- Font preset tests ------------------------------------------------
9049
9050    #[test]
9051    fn font_presets_resolve() {
9052        assert!(resolve_font_preset("system").unwrap().stack.contains("apple-system"));
9053        assert!(resolve_font_preset("sans").unwrap().stack.contains("apple-system"));
9054        assert!(resolve_font_preset("serif").unwrap().stack.contains("Georgia"));
9055        assert!(resolve_font_preset("editorial").unwrap().stack.contains("Georgia"));
9056        assert!(resolve_font_preset("mono").unwrap().stack.contains("Menlo"));
9057        assert!(resolve_font_preset("monospace").unwrap().stack.contains("Menlo"));
9058        assert!(resolve_font_preset("technical").unwrap().stack.contains("Menlo"));
9059        assert!(resolve_font_preset("inter").unwrap().stack.contains("Inter"));
9060        assert!(resolve_font_preset("montserrat").unwrap().stack.contains("Montserrat"));
9061        assert!(resolve_font_preset("jetbrains-mono").unwrap().stack.contains("JetBrains Mono"));
9062        assert!(resolve_font_preset("playfair").unwrap().stack.contains("Playfair Display"));
9063        assert!(resolve_font_preset("playfair-display").unwrap().stack.contains("Playfair Display"));
9064        assert!(resolve_font_preset("lato").unwrap().stack.contains("Lato"));
9065        assert!(resolve_font_preset("unknown").is_none());
9066    }
9067
9068    #[test]
9069    fn font_presets_case_insensitive() {
9070        assert!(resolve_font_preset("Serif").is_some());
9071        assert!(resolve_font_preset("MONO").is_some());
9072        assert!(resolve_font_preset("System").is_some());
9073        assert!(resolve_font_preset("Inter").is_some());
9074    }
9075
9076    #[test]
9077    fn google_font_presets_have_imports() {
9078        assert!(resolve_font_preset("inter").unwrap().import.is_some());
9079        assert!(resolve_font_preset("montserrat").unwrap().import.is_some());
9080        assert!(resolve_font_preset("jetbrains-mono").unwrap().import.is_some());
9081        assert!(resolve_font_preset("playfair").unwrap().import.is_some());
9082        assert!(resolve_font_preset("lato").unwrap().import.is_some());
9083        // System fonts have no imports
9084        assert!(resolve_font_preset("system").unwrap().import.is_none());
9085        assert!(resolve_font_preset("serif").unwrap().import.is_none());
9086    }
9087
9088    #[test]
9089    fn style_block_sets_heading_font() {
9090        let doc = doc_with(vec![
9091            Block::Style {
9092                properties: vec![StyleProperty { key: "heading-font".into(), value: "serif".into() }],
9093                span: span(),
9094            },
9095            Block::Markdown { content: "# Hello".into(), span: span() },
9096        ]);
9097        let html = to_html(&doc);
9098        assert!(html.contains("--font-heading: Georgia"));
9099    }
9100
9101    #[test]
9102    fn style_block_sets_body_font() {
9103        let doc = doc_with(vec![
9104            Block::Style {
9105                properties: vec![StyleProperty { key: "body-font".into(), value: "mono".into() }],
9106                span: span(),
9107            },
9108            Block::Markdown { content: "Hello".into(), span: span() },
9109        ]);
9110        let html = to_html(&doc);
9111        assert!(html.contains("--font-body:"));
9112        assert!(html.contains("Menlo"));
9113    }
9114
9115    #[test]
9116    fn font_legacy_sets_both() {
9117        let doc = doc_with(vec![Block::Site {
9118            domain: None,
9119            properties: vec![StyleProperty { key: "font".into(), value: "serif".into() }],
9120            span: span(),
9121        }]);
9122        let html = to_html(&doc);
9123        assert!(html.contains("--font-heading: Georgia"));
9124        assert!(html.contains("--font-body: Georgia"));
9125    }
9126
9127    #[test]
9128    fn css_has_font_variables() {
9129        assert!(SURFDOC_CSS.contains("--font-heading:"));
9130        assert!(SURFDOC_CSS.contains("--font-body:"));
9131        // Body/display fonts route through the style-pack hooks (--ws-font-body
9132        // / --ws-font-display), which default to the base stacks — so a pack can
9133        // swap the display/body face while Surf stays byte-identical.
9134        assert!(SURFDOC_CSS.contains("font-family: var(--ws-font-body)"));
9135        assert!(SURFDOC_CSS.contains("font-family: var(--ws-font-display)"));
9136        assert!(SURFDOC_CSS.contains("--ws-font-body: var(--font-body)"));
9137        assert!(SURFDOC_CSS.contains("--ws-font-display: var(--font-heading)"));
9138        // h5/h6 micro-eyebrows stay on the UI heading font.
9139        assert!(SURFDOC_CSS.contains("font-family: var(--font-heading)"));
9140    }
9141
9142    #[test]
9143    fn css_type_scale_style_v2_defaults() {
9144        // Style-v2 scale, proven on cloudsurf.com then upstreamed as the spec
9145        // default (every step ~+2–6px over the pre-v2 values).
9146        assert!(SURFDOC_CSS.contains("--font-size-body: 17px"));
9147        assert!(SURFDOC_CSS.contains("--font-size-h3: 21px"));
9148        assert!(SURFDOC_CSS.contains("--font-size-h2: 24px"));
9149        assert!(SURFDOC_CSS.contains("--font-size-h1: 28px"));
9150        assert!(SURFDOC_CSS.contains("--font-size-display: 38px"));
9151        // Display headings h1–h3 + the hero headline default to the black
9152        // weight; packs still win via --ws-heading-weight.
9153        assert!(SURFDOC_CSS.contains("--font-weight-black: 900"));
9154        assert!(SURFDOC_CSS.contains("var(--ws-heading-weight, var(--font-weight-black))"));
9155        assert!(SURFDOC_CSS.contains("font-size: clamp(2rem, 4.5vw, 3rem)"));
9156        // The pre-v2 hero clamp must not linger anywhere.
9157        assert!(!SURFDOC_CSS.contains("clamp(2.25rem, 5vw, 3.5rem)"));
9158    }
9159
9160    #[test]
9161    fn css_glass_surface_token_hooks() {
9162        // Consumer-overridable surface/radius hooks (proven on cloudsurf.com's
9163        // glass-over-sky skin, upstreamed as no-op inline fallbacks): each
9164        // resolves to the pre-hook literal unless the consumer sets a value.
9165        assert!(SURFDOC_CSS.contains("var(--ws-post-card-bg, var(--surface))"));
9166        assert!(SURFDOC_CSS.contains("var(--ws-post-card-radius, var(--ws-radius-card))"));
9167        assert!(SURFDOC_CSS.contains("var(--ws-pg-card-radius, var(--ws-radius-card-lg, 20px))"));
9168        assert!(SURFDOC_CSS.contains("var(--ws-form-input-bg, var(--surface))"));
9169        assert!(SURFDOC_CSS.contains("var(--ws-form-submit-radius, var(--ws-control-radius, var(--radius-sm)))"));
9170        assert!(SURFDOC_CSS.contains("var(--ws-details-bg, var(--surface-alt))"));
9171        assert!(SURFDOC_CSS.contains("var(--ws-details-radius, var(--radius-sm))"));
9172        assert!(SURFDOC_CSS.contains("var(--ws-doc-page-bg, var(--surface))"));
9173        assert!(SURFDOC_CSS.contains("var(--ws-doc-page-radius, var(--ws-radius-card))"));
9174    }
9175
9176    // -- Bug regression: accent color must not leak into editor chrome ---------
9177
9178    #[test]
9179    fn accent_override_scoped_to_surfdoc_not_root() {
9180        let doc = doc_with(vec![Block::Site {
9181            domain: None,
9182            properties: vec![StyleProperty {
9183                key: "accent".into(),
9184                value: "#ec4899".into(),
9185            }],
9186            span: span(),
9187        }]);
9188        let html = to_html(&doc);
9189        // Must scope to .surfdoc, NOT :root
9190        assert!(html.contains("<style>.surfdoc { --accent: #ec4899;--accent-text: #fff; }</style>"),
9191            "accent override should be scoped to .surfdoc with accent-text, got: {}", html);
9192        assert!(!html.contains(":root { --accent:"),
9193            "accent override must NOT use :root (leaks into editor chrome)");
9194    }
9195
9196    #[test]
9197    fn no_style_tag_without_overrides() {
9198        let doc = doc_with(vec![Block::Markdown {
9199            content: "Hello".into(),
9200            span: span(),
9201        }]);
9202        let html = to_html(&doc);
9203        assert!(!html.contains("<style>"),
9204            "no style tag when there are no CSS overrides");
9205    }
9206
9207    // -- humanize_route() unit tests ----------------------------------------
9208
9209    #[test]
9210    fn humanize_route_home() {
9211        assert_eq!(humanize_route("/"), "Home");
9212    }
9213
9214    #[test]
9215    fn humanize_route_simple() {
9216        assert_eq!(humanize_route("/gallery"), "Gallery");
9217    }
9218
9219    #[test]
9220    fn humanize_route_hyphenated() {
9221        assert_eq!(humanize_route("/about-us"), "About Us");
9222    }
9223
9224    #[test]
9225    fn humanize_route_contact() {
9226        assert_eq!(humanize_route("/contact"), "Contact");
9227    }
9228
9229    #[test]
9230    fn humanize_route_multi_hyphen() {
9231        assert_eq!(humanize_route("/terms-of-service"), "Terms Of Service");
9232    }
9233
9234    #[test]
9235    fn humanize_route_no_leading_slash() {
9236        assert_eq!(humanize_route("pricing"), "Pricing");
9237    }
9238
9239    #[test]
9240    fn humanize_route_trailing_slash() {
9241        assert_eq!(humanize_route("/blog/"), "Blog");
9242    }
9243
9244    #[test]
9245    fn humanize_route_empty_string() {
9246        assert_eq!(humanize_route(""), "Home");
9247    }
9248
9249    // -- PageEntry::display_title() tests -----------------------------------
9250
9251    #[test]
9252    fn display_title_uses_explicit_title() {
9253        let page = PageEntry {
9254            route: "/about".into(),
9255            layout: None,
9256            title: Some("About Our Team".into()),
9257            sidebar: false,
9258            children: vec![],
9259        };
9260        assert_eq!(page.display_title(), "About Our Team");
9261    }
9262
9263    #[test]
9264    fn display_title_falls_back_to_humanized_route() {
9265        let page = PageEntry {
9266            route: "/about-us".into(),
9267            layout: None,
9268            title: None,
9269            sidebar: false,
9270            children: vec![],
9271        };
9272        assert_eq!(page.display_title(), "About Us");
9273    }
9274
9275    #[test]
9276    fn display_title_home_route() {
9277        let page = PageEntry {
9278            route: "/".into(),
9279            layout: None,
9280            title: None,
9281            sidebar: false,
9282            children: vec![],
9283        };
9284        assert_eq!(page.display_title(), "Home");
9285    }
9286
9287    // -- render_site_page title uses humanize_route -------------------------
9288
9289    #[test]
9290    fn render_site_page_humanizes_untitled_page() {
9291        let site = SiteConfig {
9292            name: Some("My Site".into()),
9293            ..Default::default()
9294        };
9295        let page = PageEntry {
9296            route: "/about-us".into(),
9297            layout: None,
9298            title: None,
9299            sidebar: false,
9300            children: vec![],
9301        };
9302        let html = render_site_page(&page, &site, &[], &PageConfig::default());
9303        assert!(html.contains("<title>About Us — My Site</title>"));
9304    }
9305
9306    // -- E2E: full parse → extract → render pipeline -----------------------
9307
9308    #[test]
9309    fn e2e_multipage_site_nav_labels() {
9310        let source = r#"::site
9311name = "E2E Test"
9312::
9313
9314::page[route="/"]
9315# Home
9316::
9317
9318::page[route="/gallery"]
9319# Photos
9320::
9321
9322::page[route="/about-us"]
9323About our company
9324::
9325
9326::page[route="/terms-of-service"]
9327Legal text
9328::"#;
9329
9330        let result = crate::parse(source);
9331        let (site_config, pages, _) = extract_site(&result.doc);
9332        let site = site_config.unwrap();
9333
9334        // Build nav items the way all consumers should
9335        let nav_items: Vec<(String, String)> = pages
9336            .iter()
9337            .map(|p| (p.route.clone(), p.display_title()))
9338            .collect();
9339
9340        assert_eq!(nav_items.len(), 4);
9341        assert_eq!(nav_items[0], ("/".into(), "Home".into()));
9342        assert_eq!(nav_items[1], ("/gallery".into(), "Gallery".into()));
9343        assert_eq!(nav_items[2], ("/about-us".into(), "About Us".into()));
9344        assert_eq!(nav_items[3], ("/terms-of-service".into(), "Terms Of Service".into()));
9345
9346        // Render home page and verify nav in HTML
9347        let config = PageConfig::default();
9348        let html = render_site_page(&pages[0], &site, &nav_items, &config);
9349
9350        assert!(html.contains(">Home</a>"));
9351        assert!(html.contains(">Gallery</a>"));
9352        assert!(html.contains(">About Us</a>"));
9353        assert!(html.contains(">Terms Of Service</a>"));
9354    }
9355
9356    #[test]
9357    fn e2e_explicit_titles_not_overridden() {
9358        let source = r#"::site
9359name = "Title Test"
9360::
9361
9362::page[route="/" title="Welcome"]
9363# Welcome
9364::
9365
9366::page[route="/team" title="Our Team"]
9367# Team
9368::"#;
9369
9370        let result = crate::parse(source);
9371        let (_, pages, _) = extract_site(&result.doc);
9372
9373        let nav_items: Vec<(String, String)> = pages
9374            .iter()
9375            .map(|p| (p.route.clone(), p.display_title()))
9376            .collect();
9377
9378        assert_eq!(nav_items[0].1, "Welcome");
9379        assert_eq!(nav_items[1].1, "Our Team");
9380    }
9381
9382    #[test]
9383    fn e2e_all_consumers_get_same_nav() {
9384        // Simulates the three consumer patterns:
9385        // 1. Wavesite (publish/preview): pages.iter().map(|p| (p.route.clone(), p.display_title()))
9386        // 2. Surf Browser: same pattern (after fix)
9387        // 3. iOS: delegates to server's /api/preview which uses pattern 1
9388
9389        let source = r#"::site
9390name = "Consistency"
9391::
9392
9393::page[route="/"]
9394Home
9395::
9396
9397::page[route="/about-us"]
9398About
9399::"#;
9400
9401        let result = crate::parse(source);
9402        let (_, pages, _) = extract_site(&result.doc);
9403
9404        // All consumers use display_title() — verify it's deterministic
9405        for _ in 0..3 {
9406            let labels: Vec<String> = pages.iter().map(|p| p.display_title()).collect();
9407            assert_eq!(labels, vec!["Home", "About Us"]);
9408        }
9409    }
9410
9411    #[test]
9412    fn html_hero_image_missing_alt_renders_empty() {
9413        let doc = doc_with(vec![Block::HeroImage {
9414            src: "https://example.com/photo.jpg".into(),
9415            alt: None,
9416            span: span(),
9417        }]);
9418        let html = to_html(&doc);
9419        assert!(html.contains("alt=\"\""), "Missing alt should render as empty string, got: {html}");
9420        assert!(html.contains("src=\"https://example.com/photo.jpg\""));
9421    }
9422
9423    #[test]
9424    fn html_figure_missing_alt_renders_empty() {
9425        let doc = doc_with(vec![Block::Figure {
9426            src: "photo.jpg".into(),
9427            caption: Some("A photo".into()),
9428            alt: None,
9429            width: None,
9430            span: span(),
9431        }]);
9432        let html = to_html(&doc);
9433        assert!(html.contains("alt=\"\""), "Missing alt should render as empty string");
9434    }
9435
9436    #[test]
9437    fn html_image_src_xss_escaped() {
9438        let doc = doc_with(vec![Block::HeroImage {
9439            src: "javascript:alert(1)".into(),
9440            alt: Some("test".into()),
9441            span: span(),
9442        }]);
9443        let html = to_html(&doc);
9444        // Should still render (browser won't execute in img src), but verify no unescaped injection
9445        assert!(!html.contains("<script>"), "No script injection");
9446    }
9447
9448    #[test]
9449    fn html_utf8_content_renders_correctly() {
9450        let doc = doc_with(vec![Block::Markdown {
9451            content: "# Café ☕\n\nWillkommen in unserem Geschäft! 日本語テスト 🎉\n".into(),
9452            span: span(),
9453        }]);
9454        let html = to_html(&doc);
9455        assert!(html.contains("Café"), "UTF-8 content should render");
9456        assert!(html.contains("☕"), "Emoji should render");
9457        assert!(html.contains("日本語"), "CJK should render");
9458    }
9459
9460    #[test]
9461    fn html_style_accent_semicolon_escaped() {
9462        // Verify CSS accent values with semicolons are HTML-escaped
9463        let doc = doc_with(vec![Block::Style {
9464            properties: vec![StyleProperty {
9465                key: "accent".into(),
9466                value: "#ff0000; color: white; --x:".into(),
9467            }],
9468            span: span(),
9469        }]);
9470        let html = to_html(&doc);
9471        // escape_html converts & < > " but not ; — documenting current behavior
9472        // The injected CSS is: --accent: #ff0000; color: white; --x:;
9473        // This IS a CSS injection but scope is limited to the :root selector
9474        assert!(html.contains("--accent:"), "Accent override should be present");
9475    }
9476
9477    #[test]
9478    fn html_uploaded_image_relative_path() {
9479        // User-uploaded images use /uploads/ paths — verify they render as-is
9480        let doc = doc_with(vec![Block::Figure {
9481            src: "/uploads/abc123-photo.jpg".into(),
9482            caption: Some("My uploaded photo".into()),
9483            alt: Some("Photo".into()),
9484            width: None,
9485            span: span(),
9486        }]);
9487        let html = to_html(&doc);
9488        assert!(
9489            html.contains("src=\"/uploads/abc123-photo.jpg\""),
9490            "Uploaded image path should be preserved verbatim"
9491        );
9492    }
9493
9494    #[test]
9495    fn html_gallery_images_have_alt() {
9496        use crate::types::GalleryItem;
9497        let doc = doc_with(vec![Block::Gallery {
9498            items: vec![
9499                GalleryItem { src: "a.jpg".into(), alt: Some("First".into()), caption: None, category: None },
9500                GalleryItem { src: "b.jpg".into(), alt: None, caption: None, category: None },
9501            ],
9502            columns: None,
9503            span: span(),
9504        }]);
9505        let html = to_html(&doc);
9506        assert!(html.contains("alt=\"First\""), "Gallery item with alt should render it");
9507        assert!(html.contains("alt=\"\""), "Gallery item without alt should render empty");
9508    }
9509
9510    #[test]
9511    fn css_accent_sanitizes_semicolon_injection() {
9512        use super::sanitize_css_value;
9513        // Semicolons and braces are stripped — can't break out of CSS value
9514        let result = sanitize_css_value("red; } body { background: red");
9515        assert!(!result.contains(';'), "Semicolons should be stripped");
9516        assert!(!result.contains('{'), "Open braces should be stripped");
9517        assert!(!result.contains('}'), "Close braces should be stripped");
9518        assert!(!result.is_empty(), "Non-dangerous text should remain");
9519    }
9520
9521    #[test]
9522    fn css_accent_sanitizes_url_injection() {
9523        let doc = doc_with(vec![Block::Style {
9524            properties: vec![StyleProperty {
9525                key: "accent".into(),
9526                value: "url(https://evil.com/track)".into(),
9527            }],
9528            span: span(),
9529        }]);
9530        let html = to_html(&doc);
9531        // url() function calls are blocked entirely (returns empty → no accent set)
9532        assert!(!html.contains("--accent:"), "url() injection should prevent accent from being set");
9533    }
9534
9535    #[test]
9536    fn css_accent_allows_valid_colors() {
9537        let doc = doc_with(vec![Block::Style {
9538            properties: vec![StyleProperty {
9539                key: "accent".into(),
9540                value: "#0052CC".into(),
9541            }],
9542            span: span(),
9543        }]);
9544        let html = to_html(&doc);
9545        assert!(html.contains("--accent: #0052CC"), "Valid hex color should pass through");
9546        assert!(html.contains("--accent-text:"), "accent-text should be computed for valid accent");
9547    }
9548
9549    #[test]
9550    fn accent_text_color_wcag_compliance() {
9551        // Light accents → dark text (luminance > 0.25)
9552        assert_eq!(accent_text_color("#4CAF50"), "#1a1a2e"); // Green (L≈0.33)
9553        assert_eq!(accent_text_color("#f59e0b"), "#1a1a2e"); // Amber (L≈0.57)
9554        assert_eq!(accent_text_color("#ffffff"), "#1a1a2e"); // White (L=1.0)
9555        assert_eq!(accent_text_color("#eab308"), "#1a1a2e"); // Yellow (L≈0.55)
9556        // Dark accents → white text (luminance ≤ 0.25)
9557        assert_eq!(accent_text_color("#3b82f6"), "#fff");    // Blue (L≈0.24)
9558        assert_eq!(accent_text_color("#283593"), "#fff");    // Indigo (L≈0.04)
9559        assert_eq!(accent_text_color("#000000"), "#fff");    // Black (L=0)
9560        assert_eq!(accent_text_color("#ef4444"), "#fff");    // Red (L≈0.23)
9561        assert_eq!(accent_text_color("#ec4899"), "#fff");    // Pink (L≈0.25)
9562        assert_eq!(accent_text_color("#8b5cf6"), "#fff");    // Purple (L≈0.13)
9563        // Short hex
9564        assert_eq!(accent_text_color("#fff"), "#1a1a2e");
9565        assert_eq!(accent_text_color("#000"), "#fff");
9566        // Invalid → default white
9567        assert_eq!(accent_text_color("not-a-color"), "#fff");
9568    }
9569
9570    // -- BeforeAfter -----------------------------------------------
9571
9572    #[test]
9573    fn html_before_after_basic() {
9574        let doc = doc_with(vec![Block::BeforeAfter {
9575            before_items: vec![crate::types::BeforeAfterItem {
9576                label: "Manual".into(),
9577                detail: "Hand-written".into(),
9578            }],
9579            after_items: vec![crate::types::BeforeAfterItem {
9580                label: "Automated".into(),
9581                detail: "One-click".into(),
9582            }],
9583            transition: Some("SurfDoc".into()),
9584            span: span(),
9585        }]);
9586        let html = to_html(&doc);
9587        assert!(html.contains("surfdoc-before-after"));
9588        assert!(html.contains("surfdoc-ba-dot-red"));
9589        assert!(html.contains("surfdoc-ba-dot-green"));
9590        assert!(html.contains("surfdoc-ba-transition"));
9591        assert!(html.contains("SurfDoc"));
9592        assert!(html.contains("Manual"));
9593        assert!(html.contains("Automated"));
9594    }
9595
9596    #[test]
9597    fn html_before_after_no_transition() {
9598        let doc = doc_with(vec![Block::BeforeAfter {
9599            before_items: vec![crate::types::BeforeAfterItem {
9600                label: "Old".into(),
9601                detail: "Legacy".into(),
9602            }],
9603            after_items: vec![crate::types::BeforeAfterItem {
9604                label: "New".into(),
9605                detail: "Modern".into(),
9606            }],
9607            transition: None,
9608            span: span(),
9609        }]);
9610        let html = to_html(&doc);
9611        assert!(html.contains("surfdoc-before-after"));
9612        assert!(!html.contains("surfdoc-ba-transition"));
9613    }
9614
9615    // -- Pipeline --------------------------------------------------
9616
9617    #[test]
9618    fn html_pipeline_basic() {
9619        let doc = doc_with(vec![Block::Pipeline {
9620            steps: vec![
9621                crate::types::PipelineStep { label: "Phone".into(), description: Some("Input".into()) },
9622                crate::types::PipelineStep { label: "AI".into(), description: None },
9623                crate::types::PipelineStep { label: "App".into(), description: Some("Output".into()) },
9624            ],
9625            span: span(),
9626        }]);
9627        let html = to_html(&doc);
9628        assert!(html.contains("surfdoc-pipeline"));
9629        assert!(html.contains("surfdoc-pipeline-step"));
9630        assert!(html.contains("surfdoc-pipeline-arrow"));
9631        assert!(html.contains("Phone"));
9632        assert!(html.contains("Input"));
9633    }
9634
9635    #[test]
9636    fn html_pipeline_no_arrows_single_step() {
9637        let doc = doc_with(vec![Block::Pipeline {
9638            steps: vec![
9639                crate::types::PipelineStep { label: "Solo".into(), description: None },
9640            ],
9641            span: span(),
9642        }]);
9643        let html = to_html(&doc);
9644        assert!(html.contains("surfdoc-pipeline"));
9645        assert!(!html.contains("surfdoc-pipeline-arrow"));
9646    }
9647
9648    // -- Section ---------------------------------------------------
9649
9650    #[test]
9651    fn html_section_muted() {
9652        let doc = doc_with(vec![Block::Section {
9653            bg: Some("muted".into()),
9654            headline: Some("Features".into()),
9655            subtitle: Some("What we offer".into()),
9656            content: String::new(),
9657            children: vec![Block::Markdown {
9658                content: "Some content".into(),
9659                span: span(),
9660            }],
9661            span: span(),
9662        }]);
9663        let html = to_html(&doc);
9664        assert!(html.contains("surfdoc-section section-muted"));
9665        assert!(html.contains("surfdoc-section-header"));
9666        assert!(html.contains("Features"));
9667        assert!(html.contains("What we offer"));
9668    }
9669
9670    #[test]
9671    fn html_section_no_bg() {
9672        let doc = doc_with(vec![Block::Section {
9673            bg: None,
9674            headline: Some("Title".into()),
9675            subtitle: None,
9676            content: String::new(),
9677            children: vec![],
9678            span: span(),
9679        }]);
9680        let html = to_html(&doc);
9681        assert!(html.contains("class=\"surfdoc-section\""));
9682        assert!(!html.contains("section-muted"));
9683    }
9684
9685    // -- ProductCard -----------------------------------------------
9686
9687    #[test]
9688    fn html_product_card_full() {
9689        let doc = doc_with(vec![Block::ProductCard {
9690            title: "Surf Browser".into(),
9691            subtitle: Some("Native viewer".into()),
9692            badge: Some("Available".into()),
9693            badge_color: Some("green".into()),
9694            body: "Render .surf files.".into(),
9695            features: vec!["Fast".into(), "Dark mode".into()],
9696            cta_label: Some("Download".into()),
9697            cta_href: Some("/download".into()),
9698            span: span(),
9699        }]);
9700        let html = to_html(&doc);
9701        assert!(html.contains("surfdoc-product-card"));
9702        assert!(html.contains("surfdoc-badge-green"));
9703        assert!(html.contains("Available"));
9704        assert!(html.contains("Surf Browser"));
9705        assert!(html.contains("Native viewer"));
9706        assert!(html.contains("surfdoc-product-features"));
9707        assert!(html.contains("Fast"));
9708        assert!(html.contains("surfdoc-product-cta"));
9709        assert!(html.contains("/download"));
9710    }
9711
9712    #[test]
9713    fn html_product_card_minimal() {
9714        let doc = doc_with(vec![Block::ProductCard {
9715            title: "Basic".into(),
9716            subtitle: None,
9717            badge: None,
9718            badge_color: None,
9719            body: String::new(),
9720            features: vec![],
9721            cta_label: None,
9722            cta_href: None,
9723            span: span(),
9724        }]);
9725        let html = to_html(&doc);
9726        assert!(html.contains("surfdoc-product-card"));
9727        assert!(html.contains("Basic"));
9728        assert!(!html.contains("surfdoc-badge"));
9729        assert!(!html.contains("surfdoc-product-features"));
9730        assert!(!html.contains("surfdoc-product-cta"));
9731    }
9732
9733    // -- Fragment rendering tests -----------------------------------------
9734
9735    #[test]
9736    fn fragment_no_page_chrome() {
9737        let doc = doc_with(vec![
9738            Block::Markdown {
9739                content: "# Hello".into(),
9740                span: span(),
9741            },
9742            Block::Callout {
9743                callout_type: CalloutType::Info,
9744                title: None,
9745                content: "A note.".into(),
9746                span: span(),
9747            },
9748        ]);
9749        let html = to_html_fragment(&doc.blocks);
9750
9751        // Must contain rendered content
9752        assert!(html.contains("<h1 id=\"hello\">Hello</h1>"), "Should render heading");
9753        assert!(html.contains("surfdoc-callout"), "Should render callout");
9754
9755        // Must NOT contain page chrome
9756        assert!(!html.contains("<!DOCTYPE"), "No DOCTYPE in fragment");
9757        assert!(!html.contains("<html"), "No <html> wrapper in fragment");
9758        assert!(!html.contains("<head"), "No <head> in fragment");
9759        assert!(!html.contains("<body"), "No <body> in fragment");
9760        assert!(!html.contains("<article"), "No <article> wrapper in fragment");
9761    }
9762
9763    #[test]
9764    fn fragment_skips_style_scanning() {
9765        let doc = doc_with(vec![
9766            Block::Site {
9767                domain: Some("example.com".into()),
9768                properties: vec![StyleProperty {
9769                    key: "accent".into(),
9770                    value: "#ff0000".into(),
9771                }],
9772                span: span(),
9773            },
9774            Block::Markdown {
9775                content: "Hello".into(),
9776                span: span(),
9777            },
9778        ]);
9779        let fragment = to_html_fragment(&doc.blocks);
9780
9781        // Fragment should NOT contain style override injection
9782        assert!(!fragment.contains("<style>"), "Fragment must not emit <style> tags");
9783        assert!(!fragment.contains("--accent"), "Fragment must not emit CSS variable overrides");
9784    }
9785
9786    #[test]
9787    fn fragment_no_auto_section_wrap() {
9788        let doc = doc_with(vec![
9789            Block::Markdown {
9790                content: "# Section One".into(),
9791                span: span(),
9792            },
9793            Block::Markdown {
9794                content: "Content.".into(),
9795                span: span(),
9796            },
9797            Block::Markdown {
9798                content: "# Section Two".into(),
9799                span: span(),
9800            },
9801        ]);
9802        let fragment = to_html_fragment(&doc.blocks);
9803
9804        // to_html() wraps h1/h2 in <section> tags; fragment must not
9805        assert!(
9806            !fragment.contains("surfdoc-section"),
9807            "Fragment must not add section wrapping"
9808        );
9809        assert!(
9810            !fragment.contains("<section"),
9811            "Fragment must not contain <section> elements from auto-wrapping"
9812        );
9813    }
9814
9815    #[test]
9816    fn fragment_empty_input() {
9817        let html = to_html_fragment(&[]);
9818        assert_eq!(html, "", "Empty block slice should produce empty string");
9819    }
9820
9821    #[test]
9822    fn fragment_single_block() {
9823        let blocks = vec![Block::Code {
9824            lang: Some("rust".into()),
9825            file: None,
9826            highlight: vec![],
9827            content: "let x = 1;".into(),
9828            span: span(),
9829        }];
9830        let html = to_html_fragment(&blocks);
9831
9832        assert!(html.contains("surfdoc-code"), "Should render code block");
9833        assert!(html.contains("language-rust"), "Should have language class");
9834        assert!(html.contains("let x = 1;"), "Should contain code content");
9835        // Single block means no newline joiner to worry about, but verify no chrome
9836        assert!(!html.contains("<html"), "No page chrome");
9837    }
9838
9839    #[test]
9840    fn fragment_escapes_html() {
9841        let blocks = vec![Block::Callout {
9842            callout_type: CalloutType::Info,
9843            title: None,
9844            content: "<script>alert('xss')</script>".into(),
9845            span: span(),
9846        }];
9847        let html = to_html_fragment(&blocks);
9848
9849        assert!(!html.contains("<script>"), "Script tags must be escaped in fragments");
9850        assert!(html.contains("&lt;script&gt;"), "Should contain escaped script tag");
9851    }
9852
9853    #[test]
9854    fn fragment_renders_nav_inline() {
9855        let blocks = vec![
9856            Block::Nav {
9857                items: vec![crate::types::NavItem {
9858                    label: "Home".into(),
9859                    href: "/".into(),
9860                    icon: None,
9861                    image: None, external: false,
9862                }],
9863                logo: Some("MySite".into()),
9864                groups: vec![], brand: None, brand_reg: false, cta: None, drawer: false, minimal: false,
9865                span: span(),
9866            },
9867            Block::Markdown {
9868                content: "Content after nav.".into(),
9869                span: span(),
9870            },
9871        ];
9872        let html = to_html_fragment(&blocks);
9873
9874        // Nav should render as a regular block in document order
9875        // (to_html() extracts nav blocks and renders them first)
9876        assert!(html.contains("surfdoc-nav"), "Should render nav block");
9877        assert!(html.contains("Content after nav."), "Should render content block");
9878    }
9879
9880    #[test]
9881    fn site_nav_helper_basic() {
9882        let nav = build_site_nav_html(
9883            "My Site",
9884            &[
9885                ("/".into(), "Home".into()),
9886                ("/about".into(), "About".into()),
9887            ],
9888            "/about",
9889            "surfdoc-site-theme:/",
9890            false,
9891        );
9892
9893        assert!(nav.contains("surfdoc-site-nav"), "Should have site nav class");
9894        assert!(nav.contains("My Site"), "Should contain site name");
9895        assert!(nav.contains("class=\"active\""), "Active route should be marked");
9896        assert!(
9897            nav.contains("class=\"active\" aria-current=\"page\""),
9898            "Active route should carry aria-current"
9899        );
9900        assert!(nav.contains("href=\"/about\""), "Should have about link");
9901        assert!(nav.contains("Home"), "Should have home link text");
9902    }
9903
9904    #[test]
9905    fn site_nav_helper_active_home() {
9906        let nav = build_site_nav_html(
9907            "Site",
9908            &[
9909                ("/".into(), "Home".into()),
9910                ("/docs".into(), "Docs".into()),
9911            ],
9912            "/",
9913            "surfdoc-site-theme:/",
9914            false,
9915        );
9916
9917        // The "/" route link should be active
9918        assert!(nav.contains("href=\"/\""), "Should have home link");
9919        // Verify active class is applied (check the pattern in the output)
9920        // The active link is: <a href="/" class="active" aria-current="page">Home</a>
9921        assert!(
9922            nav.contains("href=\"/\" class=\"active\""),
9923            "Home link should be active when current_route is /"
9924        );
9925    }
9926
9927    // ── c1-drawer: the shell-style drawer ported into the site nav ────────
9928
9929    #[test]
9930    fn site_nav_drawer_matches_shell_contract() {
9931        let items: Vec<(String, String)> = vec![
9932            ("/".into(), "Home".into()),
9933            ("/about".into(), "About".into()),
9934            ("/contact".into(), "Contact".into()),
9935        ];
9936        let nav = build_site_nav_html("Acme Studio", &items, "/", "surfdoc-site-theme:/", false);
9937
9938        assert!(
9939            nav.contains("<div class=\"site-nav-drawer-head\">"),
9940            "drawer head present"
9941        );
9942        assert!(
9943            nav.contains("<span class=\"site-nav-drawer-brand\"><span class=\"site-nav-logo\" aria-hidden=\"true\">A</span>Acme Studio</span>"),
9944            "drawer brand is a span carrying the site-name monogram"
9945        );
9946        assert!(
9947            nav.contains("<label for=\"site-nav-toggle\" class=\"site-nav-close\""),
9948            "close affordance is a label for the toggle checkbox"
9949        );
9950        assert!(
9951            nav.contains("<span class=\"site-nav-group-label\">Pages</span>"),
9952            "labeled section group above the links"
9953        );
9954        assert_eq!(
9955            nav.matches("class=\"site-nav-link-icon\"").count(),
9956            items.len(),
9957            "exactly one line icon per nav item"
9958        );
9959        // Topbar anchor carries the same monogram.
9960        assert!(nav.contains(
9961            "class=\"site-name\"><span class=\"site-nav-logo\" aria-hidden=\"true\">A</span>Acme Studio</a>"
9962        ));
9963    }
9964
9965    #[test]
9966    fn site_nav_needles_stable_for_surf_container() {
9967        // surf frontend/src/sites/container.rs post-processes this markup with
9968        // two byte-pinned needles (LOGO_NEEDLE / NAV_CLOSE_NEEDLE, mirrored
9969        // here verbatim). Any drift breaks the container's logo rewrite and
9970        // nav-CTA injection — keep these literals in lockstep.
9971        const LOGO_NEEDLE: &str = "<a href=\"/\" class=\"site-name\">";
9972        const NAV_CLOSE_NEEDLE: &str =
9973            "  </div>\n  <button type=\"button\" class=\"site-nav-theme-toggle\"";
9974
9975        let nav = build_site_nav_html(
9976            "Acme",
9977            &[("/".into(), "Home".into()), ("/about".into(), "About".into())],
9978            "/",
9979            "surfdoc-site-theme:/",
9980            false,
9981        );
9982        assert!(nav.contains(LOGO_NEEDLE), "LOGO_NEEDLE bytes present");
9983        assert!(nav.contains(NAV_CLOSE_NEEDLE), "NAV_CLOSE_NEEDLE bytes present");
9984        // The topbar anchor is the FIRST (and only) site-name match, so the
9985        // container's first-occurrence rewrite hits the topbar, never the
9986        // drawer brand (a <span>).
9987        assert_eq!(nav.matches("class=\"site-name\"").count(), 1);
9988        assert!(
9989            nav.find(LOGO_NEEDLE).unwrap() < nav.find("site-nav-drawer-head").unwrap(),
9990            "topbar anchor precedes the drawer head"
9991        );
9992    }
9993
9994    #[test]
9995    fn site_nav_icon_mapper_defaults() {
9996        // Route keyword hits.
9997        assert!(site_nav_link_icon("/", "Home").contains("m3 9 9-7 9 7"), "/ → house");
9998        assert!(
9999            site_nav_link_icon("/shop", "Shop").contains("M16 10a4 4 0 0 1-8 0"),
10000            "/shop → shopping bag"
10001        );
10002        // Unknown route AND title → generic document icon.
10003        assert!(
10004            site_nav_link_icon("/xyzzy", "Xyzzy").contains("M14 2H6a2 2 0 0 0-2 2"),
10005            "unknown → file-text default"
10006        );
10007        // Title fallback when the route says nothing.
10008        assert!(
10009            site_nav_link_icon("/p1", "Contact Us").contains("22,6 12,13 2,6"),
10010            "title keyword → mail"
10011        );
10012    }
10013
10014    #[test]
10015    fn site_nav_spa_contract_kept() {
10016        let nav = build_site_nav_html(
10017            "Shop",
10018            &[
10019                ("/".into(), "Home".into()),
10020                ("/browse".into(), "Browse".into()),
10021                ("/faq".into(), "FAQ".into()),
10022            ],
10023            "/",
10024            "surfdoc-site-theme:/",
10025            true,
10026        );
10027        // The SPA router queries `.site-nav-links a[data-route]` — every page
10028        // link carries data-route (the head/close/scrim labels are not <a>s).
10029        for route in ["/", "/browse", "/faq"] {
10030            assert!(
10031                nav.contains(&format!(" data-route=\"{route}\">")),
10032                "page link for {route} carries data-route"
10033            );
10034        }
10035        assert_eq!(nav.matches(" data-route=\"").count(), 3, "one data-route per page link");
10036        assert_eq!(
10037            nav.matches("id=\"site-nav-toggle\"").count(),
10038            1,
10039            "exactly one toggle checkbox for getElementById"
10040        );
10041    }
10042
10043    #[test]
10044    fn site_nav_css_theme_arms() {
10045        assert!(
10046            SITE_NAV_CSS.contains(
10047                "[data-theme=\"dark\"] .site-nav-links { box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); }"
10048            ),
10049            "explicit dark arm deepens the panel shadow"
10050        );
10051        assert!(
10052            SITE_NAV_CSS.contains(
10053                ":root:not([data-theme]) .site-nav-links { box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); }"
10054            ),
10055            "device-dark (no data-theme) arm deepens the panel shadow"
10056        );
10057        assert!(
10058            SITE_NAV_CSS.contains("@media (prefers-reduced-motion: reduce)"),
10059            "reduced-motion arm present"
10060        );
10061        assert!(
10062            SITE_NAV_CSS.contains(".site-nav-links, .site-nav-scrim { transition: none; }"),
10063            "reduced-motion disables drawer + scrim transitions"
10064        );
10065    }
10066
10067    #[test]
10068    fn single_file_site_has_all_routes_in_one_doc() {
10069        let pages = vec![
10070            PageEntry {
10071                route: "/".into(),
10072                layout: None,
10073                title: Some("Home".into()),
10074                sidebar: false,
10075                children: vec![Block::Markdown {
10076                    content: "Welcome home.".into(),
10077                    span: span(),
10078                }],
10079            },
10080            PageEntry {
10081                route: "/browse".into(),
10082                layout: None,
10083                title: Some("Browse".into()),
10084                sidebar: false,
10085                children: vec![Block::Markdown {
10086                    content: "Browse items.".into(),
10087                    span: span(),
10088                }],
10089            },
10090        ];
10091        let site = SiteConfig {
10092            name: Some("Shop".into()),
10093            ..Default::default()
10094        };
10095        let nav_items: Vec<(String, String)> = pages
10096            .iter()
10097            .map(|p| (p.route.clone(), p.display_title()))
10098            .collect();
10099        let html = render_site_single_file(&site, &pages, &nav_items, &PageConfig::default());
10100
10101        // One document, both routes present as sections.
10102        assert_eq!(html.matches("<!DOCTYPE html>").count(), 1);
10103        assert_eq!(html.matches("<main").count(), 1, "single <main> landmark");
10104        assert!(html.contains("<section class=\"surfdoc-page\" data-route=\"/\">"));
10105        assert!(html.contains("<section class=\"surfdoc-page\" data-route=\"/browse\" hidden>"));
10106        assert!(html.contains("Welcome home.") && html.contains("Browse items."));
10107
10108        // SPA nav: hash hrefs + data-route, home initially active.
10109        assert!(html.contains(
10110            "<a href=\"#/browse\" data-route=\"/browse\"><span class=\"site-nav-link-icon\""
10111        ));
10112        assert!(html.contains("</span>Browse</a>"));
10113        assert!(html.contains("data-route=\"/\""));
10114
10115        // Router is inlined with the resolved route list.
10116        assert!(html.contains("var routes=[\"/\",\"/browse\"];"));
10117        assert!(!html.contains("__ROUTES__"), "router placeholder substituted");
10118    }
10119
10120    #[test]
10121    fn site_page_nav_refactor_stable() {
10122        // This test captures that render_site_page() produces identical output
10123        // after the nav logic is extracted into build_site_nav_html().
10124        // The expected output is validated against known content patterns.
10125        let page = PageEntry {
10126            route: "/about".into(),
10127            layout: None,
10128            title: Some("About".into()),
10129            sidebar: false,
10130            children: vec![Block::Markdown {
10131                content: "About content.".into(),
10132                span: span(),
10133            }],
10134        };
10135        let site = SiteConfig {
10136            name: Some("Test Site".into()),
10137            ..Default::default()
10138        };
10139        let nav_items = vec![
10140            ("/".into(), "Home".into()),
10141            ("/about".into(), "About".into()),
10142        ];
10143        let config = PageConfig::default();
10144
10145        let html = render_site_page(&page, &site, &nav_items, &config);
10146
10147        // Verify nav structure is present and correct
10148        assert!(html.contains("surfdoc-site-nav"), "Should have site nav");
10149        assert!(html.contains("Test Site"), "Should have site name in nav");
10150        assert!(
10151            html.contains("href=\"/about\" class=\"active\""),
10152            "About should be active"
10153        );
10154        // Verify it is still a full page
10155        assert!(html.contains("<!DOCTYPE html>"), "Should be a full page");
10156        assert!(
10157            html.contains("<main id=\"surfdoc-main\" class=\"surfdoc\">"),
10158            "Should have the main landmark wrapper (BR-SITE-A11Y; was <article>)"
10159        );
10160    }
10161
10162    // ── BR-SITE-A11Y / BR-SITE-THEME: structural accessibility layer ──────
10163    // "By design" means the suite enforces it: these pins make a regression
10164    // in the emitted site structure fail the build, not an audit.
10165
10166    /// A representative site page: hero + markdown section + image + gallery,
10167    /// nav with several routes — the sweep-matrix page shape.
10168    fn a11y_fixture_page() -> (PageEntry, SiteConfig, Vec<(String, String)>) {
10169        use crate::types::GalleryItem;
10170        let page = PageEntry {
10171            route: "/".into(),
10172            layout: None,
10173            title: Some("Home".into()),
10174            sidebar: false,
10175            children: vec![
10176                Block::Hero {
10177                    headline: Some("Welcome to Acme".into()),
10178                    subtitle: Some("We make things.".into()),
10179                    badge: None,
10180                    align: "center".into(),
10181                    image: Some("hero.jpg".into()),
10182                    image_alt: None,
10183                    layout: None,
10184                    transparent: false,
10185                    buttons: vec![],
10186                    content: String::new(),
10187                    span: span(),
10188                },
10189                Block::Markdown {
10190                    content: "## What we do\n\nThings, mostly.".into(),
10191                    span: span(),
10192                },
10193                Block::Gallery {
10194                    items: vec![GalleryItem {
10195                        src: "a.jpg".into(),
10196                        alt: Some("Workshop".into()),
10197                        caption: None,
10198                        category: None,
10199                    }],
10200                    columns: None,
10201                    span: span(),
10202                },
10203            ],
10204        };
10205        let site = SiteConfig {
10206            name: Some("Acme".into()),
10207            theme: Some("light".into()),
10208            accent: Some("#22c55e".into()),
10209            base_path: Some("/s/acme".into()),
10210            ..Default::default()
10211        };
10212        let nav = vec![
10213            ("/s/acme".into(), "Home".into()),
10214            ("/s/acme/about".into(), "About".into()),
10215        ];
10216        (page, site, nav)
10217    }
10218
10219    #[test]
10220    fn a11y_exactly_one_main_landmark() {
10221        let (page, site, nav) = a11y_fixture_page();
10222        let html = render_site_page(&page, &site, &nav, &PageConfig::default());
10223        assert_eq!(
10224            html.matches("<main").count(),
10225            1,
10226            "exactly one <main> landmark per page"
10227        );
10228        assert_eq!(html.matches("<nav class=\"surfdoc-site-nav\"").count(), 1);
10229        assert_eq!(html.matches("<footer class=\"surfdoc-site-footer\"").count(), 1);
10230    }
10231
10232    #[test]
10233    fn a11y_skip_link_is_first_focusable() {
10234        let (page, site, nav) = a11y_fixture_page();
10235        let html = render_site_page(&page, &site, &nav, &PageConfig::default());
10236        let body_at = html.find("<body>").expect("body present");
10237        let skip_at = html
10238            .find("<a class=\"surfdoc-skip-link\" href=\"#surfdoc-main\">")
10239            .expect("skip link present");
10240        let nav_at = html.find("<nav class=\"surfdoc-site-nav\"").expect("nav present");
10241        assert!(
10242            body_at < skip_at && skip_at < nav_at,
10243            "the skip link is the FIRST focusable in <body>, before the nav"
10244        );
10245        assert!(
10246            html.contains("id=\"surfdoc-main\""),
10247            "skip link target exists"
10248        );
10249        assert!(
10250            SITE_NAV_CSS.contains(".surfdoc-skip-link:focus"),
10251            "skip link becomes visible on focus"
10252        );
10253    }
10254
10255    #[test]
10256    fn a11y_hamburger_is_keyboard_operable() {
10257        let (page, site, nav) = a11y_fixture_page();
10258        let html = render_site_page(&page, &site, &nav, &PageConfig::default());
10259        // The checkbox is the keyboard surface: focusable + labeled, never
10260        // aria-hidden. The visual hamburger label is decorative.
10261        assert!(
10262            html.contains(
10263                "<input type=\"checkbox\" class=\"site-nav-toggle\" id=\"site-nav-toggle\" aria-label=\"Toggle menu\">"
10264            ),
10265            "menu checkbox is labeled and NOT aria-hidden"
10266        );
10267        assert!(
10268            html.contains("class=\"site-nav-hamburger\" aria-hidden=\"true\""),
10269            "the visual hamburger label is decorative"
10270        );
10271        // CSS keeps the checkbox focusable at all sizes (visually hidden, not
10272        // display:none) and shows its focus ring on the hamburger.
10273        assert!(
10274            SITE_NAV_CSS.contains(
10275                ".site-nav-toggle { position: absolute; width: 1px; height: 1px;"
10276            ),
10277            "checkbox is visually-hidden-but-focusable"
10278        );
10279        assert!(
10280            SITE_NAV_CSS.contains(".site-nav-toggle:focus-visible ~ .site-nav-hamburger"),
10281            "checkbox focus ring renders on the hamburger"
10282        );
10283    }
10284
10285    #[test]
10286    fn a11y_focus_visible_rules_cover_site_chrome() {
10287        assert!(
10288            SITE_NAV_CSS.contains(
10289                ".surfdoc-site-nav a:focus-visible, .site-nav-theme-toggle:focus-visible, .surfdoc-skip-link:focus-visible, .site-nav-close:focus-visible"
10290            ),
10291            "nav links, theme toggle, skip link, and drawer close all carry :focus-visible rings"
10292        );
10293    }
10294
10295    #[test]
10296    fn a11y_hero_page_has_exactly_one_h1() {
10297        let (page, site, nav) = a11y_fixture_page();
10298        let html = render_site_page(&page, &site, &nav, &PageConfig::default());
10299        assert_eq!(
10300            html.matches("<h1").count(),
10301            1,
10302            "hero headline is the page's single h1; chrome adds none"
10303        );
10304    }
10305
10306    #[test]
10307    fn a11y_every_img_carries_alt() {
10308        let (page, site, nav) = a11y_fixture_page();
10309        let html = render_site_page(&page, &site, &nav, &PageConfig::default());
10310        let img_count = html.matches("<img").count();
10311        assert!(img_count >= 2, "fixture renders hero + gallery images");
10312        for (i, seg) in html.split("<img").skip(1).enumerate() {
10313            let tag = &seg[..seg.find('>').unwrap_or(seg.len())];
10314            assert!(
10315                tag.contains("alt="),
10316                "img #{} missing alt attribute: <img{}>",
10317                i + 1,
10318                tag
10319            );
10320        }
10321    }
10322
10323    #[test]
10324    fn a11y_gallery_items_keyboard_activatable() {
10325        let (page, site, nav) = a11y_fixture_page();
10326        let html = render_site_page(&page, &site, &nav, &PageConfig::default());
10327        assert!(
10328            html.contains("class=\"surfdoc-gallery-item\" data-index=\"0\" style=\"cursor:pointer\" tabindex=\"0\" role=\"button\""),
10329            "gallery items are focusable buttons"
10330        );
10331        assert!(
10332            html.contains("f.onkeydown=function(e){if(e.key==='Enter'||e.key===' ')"),
10333            "Enter/Space activate the lightbox"
10334        );
10335    }
10336
10337    #[test]
10338    fn a11y_accent_ink_meets_aa_for_platform_palettes() {
10339        // The container's six palette accents (surf frontend
10340        // sites/container.rs PALETTES) — the toggle makes every one face
10341        // BOTH themes, so both inks must read as normal text at AA.
10342        for accent in ["#3b82f6", "#22c55e", "#f59e0b", "#ef4444", "#8b5cf6", "#2563eb"] {
10343            let light_ink = accent_ink_color(accent, false);
10344            let dark_ink = accent_ink_color(accent, true);
10345            // Strictest surface per theme; AA there implies AA on the page
10346            // + sheet surfaces too.
10347            assert!(
10348                contrast_ratio(&light_ink, "#eef1f7") >= 4.5,
10349                "{accent} light ink {light_ink} must be AA on light surfaces"
10350            );
10351            assert!(
10352                contrast_ratio(&dark_ink, "#161616") >= 4.5,
10353                "{accent} dark ink {dark_ink} must be AA on dark surfaces"
10354            );
10355        }
10356        // Unparseable input falls back to the theme-safe inks.
10357        assert_eq!(accent_ink_color("nope", false), "#1a1a2e");
10358        assert_eq!(accent_ink_color("nope", true), "#ffffff");
10359    }
10360
10361    #[test]
10362    fn a11y_site_page_emits_theme_scoped_ink_overrides() {
10363        let (page, site, nav) = a11y_fixture_page();
10364        let html = render_site_page(&page, &site, &nav, &PageConfig::default());
10365        let light_ink = accent_ink_color("#22c55e", false);
10366        let dark_ink = accent_ink_color("#22c55e", true);
10367        assert!(
10368            html.contains(&format!("--accent-ink: {light_ink};")),
10369            "light ink emitted in :root"
10370        );
10371        assert!(
10372            html.contains(&format!("[data-theme=\"dark\"] {{ --accent-ink: {dark_ink}; }}")),
10373            "dark ink emitted for the explicit dark arm"
10374        );
10375        assert!(
10376            html.contains(&format!(
10377                "@media (prefers-color-scheme: dark) {{ :root:not([data-theme]) {{ --accent-ink: {dark_ink}; }} }}"
10378            )),
10379            "dark ink emitted for the no-JS device-dark arm"
10380        );
10381    }
10382
10383    #[test]
10384    fn a11y_muted_tokens_are_aa_pinned() {
10385        // Drift-pins the stylesheet's muted/faint text tokens to AA values
10386        // (the 2026-06-11 retune: light muted 4.19→4.77 on --surface-alt,
10387        // dark faint 2.82→4.52).
10388        assert!(SURFDOC_CSS.contains("--text-muted: #636a7e;"), "light muted retuned");
10389        assert!(SURFDOC_CSS.contains("--text-faint: #7f7f7f;"), "dark faint retuned");
10390        assert!(contrast_ratio("#636a7e", "#eef1f7") >= 4.5);
10391        assert!(contrast_ratio("#7f7f7f", "#161616") >= 4.5);
10392        // Accent-as-text reads through the ink (with the plain accent as the
10393        // doc-page fallback).
10394        assert!(
10395            SURFDOC_CSS.contains(".surfdoc a { color: var(--accent-ink, var(--accent)); text-decoration: none; }"),
10396            "prose links read through --accent-ink"
10397        );
10398    }
10399
10400    #[test]
10401    fn theme_toggle_button_on_every_site_page() {
10402        let (page, site, nav) = a11y_fixture_page();
10403        let html = render_site_page(&page, &site, &nav, &PageConfig::default());
10404        assert!(
10405            html.contains("<button type=\"button\" class=\"site-nav-theme-toggle\" aria-label=\"Switch between light and dark theme\""),
10406            "labeled toggle button present"
10407        );
10408        assert!(
10409            html.contains("localStorage.setItem('surfdoc-site-theme:/s/acme',t)"),
10410            "toggle persists to the per-site key"
10411        );
10412        assert!(html.contains("site-theme-icon-moon") && html.contains("site-theme-icon-sun"));
10413        // Touch target ≥24px (38px circular shell control) pinned in CSS.
10414        assert!(SITE_NAV_CSS.contains(".site-nav-theme-toggle { order: 2; display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px;"));
10415    }
10416
10417    #[test]
10418    fn theme_resolver_runs_before_first_paint() {
10419        let (page, site, nav) = a11y_fixture_page();
10420        let html = render_site_page(&page, &site, &nav, &PageConfig::default());
10421        let head_close = html.find("</head>").expect("head present");
10422        let resolver = html
10423            .find("localStorage.getItem('surfdoc-site-theme:/s/acme')")
10424            .expect("resolver present");
10425        assert!(resolver < head_close, "resolver script lives in <head> (pre-paint)");
10426        // Device preference outranks the palette hint; the hint is only the
10427        // no-preference fallback (the ratified palette-semantics decision).
10428        assert!(html.contains("(prefers-color-scheme: dark)"));
10429        assert!(html.contains("(prefers-color-scheme: light)"));
10430        assert!(html.contains(":'light');"), "fixture palette hint = light fallback");
10431    }
10432
10433    #[test]
10434    fn sanitize_js_key_strips_breakouts() {
10435        assert_eq!(
10436            sanitize_js_key("surfdoc-site-theme:/s/acme"),
10437            "surfdoc-site-theme:/s/acme"
10438        );
10439        assert_eq!(sanitize_js_key("a'b\\c\"d<e>f"), "abcdef");
10440    }
10441
10442    #[test]
10443    fn fragment_with_context() {
10444        let doc = doc_with(vec![Block::Markdown {
10445            content: "Hello {= name =}!".into(),
10446            span: span(),
10447        }]);
10448        let ctx = crate::TemplateContext::new();
10449        // Note: TemplateContext::new() starts empty.
10450        // Variables not found resolve to empty string or are left as-is
10451        // depending on the implementation. This test verifies the call chain works.
10452        let html = doc.to_html_fragment_with_context(&ctx);
10453        assert!(html.contains("Hello"), "Should render content");
10454    }
10455
10456    // ── Interactive / application block HTML render tests ──────────
10457
10458    #[test]
10459    fn html_app_shell() {
10460        let doc = doc_with(vec![Block::AppShell {
10461            layout: "sidebar-main-panel".into(),
10462            children: vec![Block::Markdown { content: "Inner".into(), span: span() }],
10463            span: span(),
10464        }]);
10465        let html = to_html(&doc);
10466        assert!(html.contains("surfdoc-app-shell surfdoc-layout-sidebar-main-panel"));
10467        assert!(html.contains("Inner"));
10468    }
10469
10470    #[test]
10471    fn html_sidebar() {
10472        let doc = doc_with(vec![Block::Sidebar {
10473            position: "left".into(),
10474            collapsible: true,
10475            width: Some(250),
10476            children: vec![],
10477            span: span(),
10478        }]);
10479        let html = to_html(&doc);
10480        assert!(html.contains("surfdoc-sidebar surfdoc-sidebar-left"));
10481        assert!(html.contains("data-collapsible=\"true\""));
10482        assert!(html.contains("style=\"width:250px\""));
10483    }
10484
10485    #[test]
10486    fn html_panel() {
10487        let doc = doc_with(vec![Block::Panel {
10488            position: "bottom".into(),
10489            resizable: true,
10490            height: Some(200),
10491            desktop_only: true,
10492            children: vec![],
10493            span: span(),
10494        }]);
10495        let html = to_html(&doc);
10496        assert!(html.contains("surfdoc-panel surfdoc-panel-bottom"));
10497        assert!(html.contains("data-resizable=\"true\""));
10498        assert!(html.contains("data-desktop-only=\"true\""));
10499        assert!(html.contains("style=\"height:200px\""));
10500    }
10501
10502    #[test]
10503    fn html_tab_bar() {
10504        let doc = doc_with(vec![Block::TabBar {
10505            active: Some("preview".into()),
10506            items: vec![
10507                TabBarItem { id: "preview".into(), label: "Preview".into() },
10508                TabBarItem { id: "edit".into(), label: "Edit".into() },
10509            ],
10510            span: span(),
10511        }]);
10512        let html = to_html(&doc);
10513        assert!(html.contains("surfdoc-tab-bar"));
10514        assert!(html.contains("role=\"tablist\""));
10515        assert!(html.contains("data-tab=\"preview\""));
10516        assert!(html.contains("class=\"active\""));
10517        assert!(html.contains("Preview"));
10518    }
10519
10520    #[test]
10521    fn html_tab_content() {
10522        let doc = doc_with(vec![Block::TabContent {
10523            tab: "preview".into(),
10524            children: vec![Block::Markdown { content: "Tab body".into(), span: span() }],
10525            span: span(),
10526        }]);
10527        let html = to_html(&doc);
10528        assert!(html.contains("surfdoc-tab-content"));
10529        assert!(html.contains("data-tab=\"preview\""));
10530        assert!(html.contains("Tab body"));
10531    }
10532
10533    #[test]
10534    fn html_toolbar() {
10535        let doc = doc_with(vec![Block::Toolbar {
10536            items: vec![
10537                ToolbarItem::Button { label: Some("Deploy".into()), action: Some("deploy".into()), icon: None, style: Some("primary".into()), disabled: false },
10538                ToolbarItem::Separator,
10539                ToolbarItem::Spacer,
10540                ToolbarItem::Badge { value: "Live".into(), color: Some("green".into()) },
10541            ],
10542            span: span(),
10543        }]);
10544        let html = to_html(&doc);
10545        assert!(html.contains("surfdoc-toolbar"));
10546        assert!(html.contains("Deploy"));
10547        assert!(html.contains("surfdoc-toolbar-separator"));
10548        assert!(html.contains("surfdoc-toolbar-spacer"));
10549        assert!(html.contains("surfdoc-badge-green"));
10550        assert!(html.contains("Live"));
10551    }
10552
10553    #[test]
10554    fn html_drawer() {
10555        let doc = doc_with(vec![Block::Drawer {
10556            name: "mako".into(),
10557            position: "right".into(),
10558            width: Some(320),
10559            trigger: Some("icon".into()),
10560            children: vec![],
10561            span: span(),
10562        }]);
10563        let html = to_html(&doc);
10564        assert!(html.contains("surfdoc-drawer surfdoc-drawer-right"));
10565        assert!(html.contains("data-name=\"mako\""));
10566        assert!(html.contains("style=\"width:320px\""));
10567    }
10568
10569    #[test]
10570    fn html_modal() {
10571        let doc = doc_with(vec![Block::Modal {
10572            name: "deploy".into(),
10573            title: Some("Deploy App".into()),
10574            children: vec![],
10575            span: span(),
10576        }]);
10577        let html = to_html(&doc);
10578        assert!(html.contains("surfdoc-modal"));
10579        assert!(html.contains("data-name=\"deploy\""));
10580        assert!(html.contains("<strong class=\"surfdoc-modal-title\">Deploy App</strong>"));
10581    }
10582
10583    #[test]
10584    fn html_command_palette() {
10585        let doc = doc_with(vec![Block::CommandPalette {
10586            trigger: Some("cmd+/".into()),
10587            items: vec![CommandItem {
10588                label: "Data Table".into(),
10589                description: Some("Define data".into()),
10590                action: Some("add_schema".into()),
10591                icon: Some("table".into()),
10592                group: Some("Data".into()),
10593            }],
10594            span: span(),
10595        }]);
10596        let html = to_html(&doc);
10597        assert!(html.contains("surfdoc-command-palette"));
10598        assert!(html.contains("Data Table"));
10599        assert!(html.contains("Define data"));
10600        assert!(html.contains("data-group=\"Data\""));
10601    }
10602
10603    #[test]
10604    fn html_code_editor() {
10605        let doc = doc_with(vec![Block::CodeEditor {
10606            lang: Some("surf".into()),
10607            source: Some("doc.source".into()),
10608            line_numbers: true,
10609            content: "::hero\n::".into(),
10610            span: span(),
10611        }]);
10612        let html = to_html(&doc);
10613        assert!(html.contains("surfdoc-code-editor"));
10614        assert!(html.contains("data-lang=\"surf\""));
10615        assert!(html.contains("data-source=\"doc.source\""));
10616        assert!(html.contains("data-line-numbers=\"true\""));
10617    }
10618
10619    #[test]
10620    fn html_block_editor() {
10621        let doc = doc_with(vec![Block::BlockEditor {
10622            source: Some("doc.blocks".into()),
10623            span: span(),
10624        }]);
10625        let html = to_html(&doc);
10626        assert!(html.contains("surfdoc-block-editor"));
10627        assert!(html.contains("data-source=\"doc.blocks\""));
10628    }
10629
10630    #[test]
10631    fn html_terminal() {
10632        let doc = doc_with(vec![Block::Terminal {
10633            shell: Some("default".into()),
10634            cwd: Some("workspace.path".into()),
10635            span: span(),
10636        }]);
10637        let html = to_html(&doc);
10638        assert!(html.contains("surfdoc-terminal"));
10639        assert!(html.contains("data-shell=\"default\""));
10640        assert!(html.contains("data-cwd=\"workspace.path\""));
10641    }
10642
10643    #[test]
10644    fn html_nav_tree() {
10645        let doc = doc_with(vec![Block::NavTree {
10646            source: Some("workspace.files".into()),
10647            on_select: Some("open_file".into()),
10648            on_rename: None,
10649            on_delete: None,
10650            span: span(),
10651        }]);
10652        let html = to_html(&doc);
10653        assert!(html.contains("surfdoc-nav-tree"));
10654        assert!(html.contains("data-source=\"workspace.files\""));
10655    }
10656
10657    #[test]
10658    fn html_badge() {
10659        let doc = doc_with(vec![Block::Badge {
10660            value: "Live".into(),
10661            color: Some("green".into()),
10662            span: span(),
10663        }]);
10664        let html = to_html(&doc);
10665        assert!(html.contains("surfdoc-badge surfdoc-badge-green"));
10666        assert!(html.contains("Live"));
10667    }
10668
10669    #[test]
10670    fn html_suggestion_chips() {
10671        let doc = doc_with(vec![Block::SuggestionChips {
10672            source: Some("mako.suggestions".into()),
10673            max: Some(3),
10674            dismissible: true,
10675            span: span(),
10676        }]);
10677        let html = to_html(&doc);
10678        assert!(html.contains("surfdoc-suggestion-chips"));
10679        assert!(html.contains("data-source=\"mako.suggestions\""));
10680        assert!(html.contains("data-dismissible=\"true\""));
10681        assert!(html.contains("data-max=\"3\""));
10682    }
10683
10684    #[test]
10685    fn html_chat_thread() {
10686        let doc = doc_with(vec![Block::ChatThread {
10687            source: Some("mako.conversation".into()),
10688            on_action: None,
10689            span: span(),
10690        }]);
10691        let html = to_html(&doc);
10692        assert!(html.contains("surfdoc-chat-thread"));
10693        assert!(html.contains("data-source=\"mako.conversation\""));
10694    }
10695
10696    #[test]
10697    fn html_chat_input_simple() {
10698        let doc = doc_with(vec![Block::ChatInputSimple {
10699            placeholder: Some("Message Mako...".into()),
10700            action: Some("mako.send".into()),
10701            span: span(),
10702        }]);
10703        let html = to_html(&doc);
10704        assert!(html.contains("surfdoc-chat-input"));
10705        assert!(html.contains("placeholder=\"Message Mako...\""));
10706        assert!(html.contains("data-action=\"mako.send\""));
10707        assert!(html.contains("Send"));
10708    }
10709
10710    #[test]
10711    fn html_progress() {
10712        let doc = doc_with(vec![Block::Progress {
10713            source: Some("deploy.progress".into()),
10714            steps: vec![
10715                ProgressStep { label: "Parsing app".into(), status: "done".into() },
10716                ProgressStep { label: "Creating database".into(), status: "active".into() },
10717                ProgressStep { label: "Registering routes".into(), status: "pending".into() },
10718            ],
10719            span: span(),
10720        }]);
10721        let html = to_html(&doc);
10722        assert!(html.contains("surfdoc-progress"));
10723        assert!(html.contains("surfdoc-step-done"));
10724        assert!(html.contains("surfdoc-step-active"));
10725        assert!(html.contains("surfdoc-step-pending"));
10726        assert!(html.contains("Parsing app"));
10727    }
10728
10729    #[test]
10730    fn html_log_stream() {
10731        let doc = doc_with(vec![Block::LogStream {
10732            source: Some("app.logs".into()),
10733            tail: Some(100),
10734            span: span(),
10735        }]);
10736        let html = to_html(&doc);
10737        assert!(html.contains("surfdoc-log-stream"));
10738        assert!(html.contains("data-source=\"app.logs\""));
10739        assert!(html.contains("data-tail=\"100\""));
10740    }
10741
10742    #[test]
10743    fn html_problem_list() {
10744        let doc = doc_with(vec![Block::ProblemList {
10745            source: Some("doc.diagnostics".into()),
10746            span: span(),
10747        }]);
10748        let html = to_html(&doc);
10749        assert!(html.contains("surfdoc-problem-list"));
10750        assert!(html.contains("data-source=\"doc.diagnostics\""));
10751    }
10752
10753    // ---- HTML sanitization (XSS prevention) ----
10754
10755    #[test]
10756    fn script_tags_stripped_from_markdown() {
10757        let doc = doc_with(vec![Block::Markdown {
10758            content: "Hello <script>alert('xss')</script> world".into(),
10759            span: span(),
10760        }]);
10761        let html = to_html(&doc);
10762        assert!(!html.contains("<script>"), "script tags must be stripped");
10763        assert!(!html.contains("</script>"), "script close tags must be stripped");
10764        assert!(html.contains("Hello"), "safe text before script preserved");
10765        assert!(html.contains("world"), "safe text after script preserved");
10766    }
10767
10768    #[test]
10769    fn safe_html_preserved_in_markdown() {
10770        let doc = doc_with(vec![Block::Markdown {
10771            content: "**bold** and *italic* and [link](https://example.com)".into(),
10772            span: span(),
10773        }]);
10774        let html = to_html(&doc);
10775        assert!(html.contains("<strong>bold</strong>"), "bold preserved");
10776        assert!(html.contains("<em>italic</em>"), "italic preserved");
10777        assert!(html.contains("<a"), "link tag preserved");
10778        assert!(html.contains("https://example.com"), "link href preserved");
10779    }
10780
10781    #[test]
10782    fn event_handlers_stripped_from_markdown() {
10783        let doc = doc_with(vec![Block::Markdown {
10784            content: "<img src=\"x\" onerror=\"alert(1)\">".into(),
10785            span: span(),
10786        }]);
10787        let html = to_html(&doc);
10788        assert!(!html.contains("onerror"), "event handler attributes must be stripped");
10789    }
10790
10791    #[test]
10792    fn iframe_stripped_from_markdown() {
10793        let doc = doc_with(vec![Block::Markdown {
10794            content: "<iframe src=\"https://evil.com\"></iframe>".into(),
10795            span: span(),
10796        }]);
10797        let html = to_html(&doc);
10798        assert!(!html.contains("<iframe"), "iframe tags must be stripped");
10799    }
10800
10801    #[test]
10802    fn javascript_uri_stripped_from_markdown() {
10803        let doc = doc_with(vec![Block::Markdown {
10804            content: "<a href=\"javascript:alert(1)\">click</a>".into(),
10805            span: span(),
10806        }]);
10807        let html = to_html(&doc);
10808        assert!(!html.contains("javascript:"), "javascript: URIs must be stripped");
10809    }
10810
10811    // -- Summary inline markdown emphasis tests ---------------------------
10812    // Regression: Block::Summary previously used escape_html() on its body,
10813    // leaking literal asterisks for **bold** / *italic*. It now uses
10814    // render_inline_markdown() to match the Callout sibling.
10815
10816    #[test]
10817    fn summary_renders_bold_emphasis() {
10818        let doc = doc_with(vec![Block::Summary {
10819            content: "This is **bold** text.".into(),
10820            span: span(),
10821        }]);
10822        let html = to_html(&doc);
10823        assert!(
10824            html.contains("<strong>bold</strong>"),
10825            "expected <strong>bold</strong> in: {html}"
10826        );
10827        assert!(!html.contains("**bold**"), "literal asterisks leaked: {html}");
10828    }
10829
10830    #[test]
10831    fn summary_renders_italic_emphasis() {
10832        let doc = doc_with(vec![Block::Summary {
10833            content: "This is *italic* text.".into(),
10834            span: span(),
10835        }]);
10836        let html = to_html(&doc);
10837        assert!(
10838            html.contains("<em>italic</em>"),
10839            "expected <em>italic</em> in: {html}"
10840        );
10841    }
10842
10843    #[test]
10844    fn summary_renders_nested_emphasis() {
10845        let doc = doc_with(vec![Block::Summary {
10846            content: "Mixed ***x*** here.".into(),
10847            span: span(),
10848        }]);
10849        let html = to_html(&doc);
10850        assert!(html.contains("<strong>"), "expected <strong> tag in: {html}");
10851        assert!(html.contains("<em>"), "expected <em> tag in: {html}");
10852        assert!(html.contains(">x<"), "expected literal x between tags in: {html}");
10853    }
10854
10855    #[test]
10856    fn summary_with_no_emphasis_unchanged() {
10857        let doc = doc_with(vec![Block::Summary {
10858            content: "Plain text.".into(),
10859            span: span(),
10860        }]);
10861        let html = to_html(&doc);
10862        // No spurious emphasis tags injected.
10863        assert!(!html.contains("<strong>"), "unexpected <strong> in plain summary: {html}");
10864        assert!(!html.contains("<em>"), "unexpected <em> in plain summary: {html}");
10865        assert!(html.contains("Plain text."), "plain text missing in: {html}");
10866    }
10867
10868    #[test]
10869    fn summary_escapes_html_special_chars() {
10870        let doc = doc_with(vec![Block::Summary {
10871            content: "<script>alert(1)</script>".into(),
10872            span: span(),
10873        }]);
10874        let html = to_html(&doc);
10875        assert!(
10876            !html.contains("<script>"),
10877            "raw <script> tag must not appear: {html}"
10878        );
10879        assert!(
10880            html.contains("&lt;script&gt;alert(1)&lt;/script&gt;"),
10881            "expected escaped script tag in: {html}"
10882        );
10883    }
10884
10885    #[test]
10886    fn summary_with_link_renders_anchor() {
10887        let doc = doc_with(vec![Block::Summary {
10888            content: "See [CloudSurf](https://app.surf) for details.".into(),
10889            span: span(),
10890        }]);
10891        let html = to_html(&doc);
10892        assert!(
10893            html.contains("<a href=\"https://app.surf\""),
10894            "expected anchor tag in: {html}"
10895        );
10896        assert!(html.contains(">CloudSurf</a>"), "expected anchor text in: {html}");
10897    }
10898
10899    #[test]
10900    fn callout_inline_emphasis_still_works() {
10901        // Regression guard: the sibling Callout arm must continue to render
10902        // inline markdown (the source of the fix pattern).
10903        let doc = doc_with(vec![Block::Callout {
10904            callout_type: CalloutType::Info,
10905            title: None,
10906            content: "**bold**".into(),
10907            span: span(),
10908        }]);
10909        let html = to_html(&doc);
10910        assert!(
10911            html.contains("<strong>bold</strong>"),
10912            "callout regression: expected <strong>bold</strong> in: {html}"
10913        );
10914    }
10915
10916    // -- Quote inline markdown emphasis tests -----------------------------
10917    // Regression: Block::Quote previously used escape_html() on its body,
10918    // leaking literal asterisks for **bold** / *italic*.
10919
10920    #[test]
10921    fn quote_renders_bold_emphasis() {
10922        let doc = doc_with(vec![Block::Quote {
10923            content: "This is **bold** wisdom.".into(),
10924            attribution: None,
10925            cite: None,
10926            span: span(),
10927        }]);
10928        let html = to_html(&doc);
10929        assert!(
10930            html.contains("<strong>bold</strong>"),
10931            "expected <strong>bold</strong> in: {html}"
10932        );
10933        assert!(!html.contains("**bold**"), "literal asterisks leaked: {html}");
10934    }
10935
10936    #[test]
10937    fn quote_renders_italic_emphasis() {
10938        let doc = doc_with(vec![Block::Quote {
10939            content: "This is *italic* wisdom.".into(),
10940            attribution: None,
10941            cite: None,
10942            span: span(),
10943        }]);
10944        let html = to_html(&doc);
10945        assert!(
10946            html.contains("<em>italic</em>"),
10947            "expected <em>italic</em> in: {html}"
10948        );
10949    }
10950
10951    #[test]
10952    fn quote_escapes_html_special_chars() {
10953        let doc = doc_with(vec![Block::Quote {
10954            content: "<script>alert(1)</script>".into(),
10955            attribution: None,
10956            cite: None,
10957            span: span(),
10958        }]);
10959        let html = to_html(&doc);
10960        assert!(
10961            !html.contains("<script>"),
10962            "raw <script> tag must not appear: {html}"
10963        );
10964        assert!(
10965            html.contains("&lt;script&gt;alert(1)&lt;/script&gt;"),
10966            "expected escaped script tag in: {html}"
10967        );
10968    }
10969
10970    // -- Testimonial inline markdown emphasis tests -----------------------
10971
10972    #[test]
10973    fn testimonial_renders_bold_emphasis() {
10974        let doc = doc_with(vec![Block::Testimonial {
10975            content: "Truly **amazing** product.".into(),
10976            author: None,
10977            role: None,
10978            company: None,
10979            span: span(),
10980        }]);
10981        let html = to_html(&doc);
10982        assert!(
10983            html.contains("<strong>amazing</strong>"),
10984            "expected <strong>amazing</strong> in: {html}"
10985        );
10986        assert!(!html.contains("**amazing**"), "literal asterisks leaked: {html}");
10987    }
10988
10989    #[test]
10990    fn testimonial_renders_italic_emphasis() {
10991        let doc = doc_with(vec![Block::Testimonial {
10992            content: "Truly *amazing* product.".into(),
10993            author: None,
10994            role: None,
10995            company: None,
10996            span: span(),
10997        }]);
10998        let html = to_html(&doc);
10999        assert!(
11000            html.contains("<em>amazing</em>"),
11001            "expected <em>amazing</em> in: {html}"
11002        );
11003    }
11004
11005    #[test]
11006    fn testimonial_escapes_html_special_chars() {
11007        let doc = doc_with(vec![Block::Testimonial {
11008            content: "<img src=x onerror=alert(1)>".into(),
11009            author: None,
11010            role: None,
11011            company: None,
11012            span: span(),
11013        }]);
11014        let html = to_html(&doc);
11015        assert!(
11016            !html.contains("<img src=x onerror"),
11017            "raw <img> with onerror must not appear: {html}"
11018        );
11019        assert!(
11020            html.contains("&lt;img"),
11021            "expected escaped <img in: {html}"
11022        );
11023    }
11024
11025    // -- Tasks item.text inline markdown emphasis tests -------------------
11026
11027    #[test]
11028    fn tasks_item_text_renders_bold_emphasis() {
11029        let doc = doc_with(vec![Block::Tasks {
11030            items: vec![TaskItem {
11031                done: false,
11032                text: "Ship the **bold** fix".into(),
11033                assignee: None,
11034            }],
11035            span: span(),
11036        }]);
11037        let html = to_html(&doc);
11038        assert!(
11039            html.contains("<strong>bold</strong>"),
11040            "expected <strong>bold</strong> in tasks item: {html}"
11041        );
11042        assert!(!html.contains("**bold**"), "literal asterisks leaked: {html}");
11043    }
11044
11045    #[test]
11046    fn tasks_item_text_renders_italic_emphasis() {
11047        let doc = doc_with(vec![Block::Tasks {
11048            items: vec![TaskItem {
11049                done: true,
11050                text: "Reviewed *carefully*".into(),
11051                assignee: None,
11052            }],
11053            span: span(),
11054        }]);
11055        let html = to_html(&doc);
11056        assert!(
11057            html.contains("<em>carefully</em>"),
11058            "expected <em>carefully</em> in tasks item: {html}"
11059        );
11060    }
11061
11062    #[test]
11063    fn tasks_item_text_escapes_html_special_chars() {
11064        let doc = doc_with(vec![Block::Tasks {
11065            items: vec![TaskItem {
11066                done: false,
11067                text: "<script>alert(1)</script>".into(),
11068                assignee: None,
11069            }],
11070            span: span(),
11071        }]);
11072        let html = to_html(&doc);
11073        assert!(
11074            !html.contains("<script>"),
11075            "raw <script> tag must not appear: {html}"
11076        );
11077        assert!(
11078            html.contains("&lt;script&gt;"),
11079            "expected escaped script tag in: {html}"
11080        );
11081    }
11082
11083    // -- CalloutType::Context tests ---------------------------------------
11084    // Regression: ::callout [type=context] previously fell through to Info;
11085    // CalloutType::Context now wires through every renderer.
11086
11087    #[test]
11088    fn callout_context_type_renders_with_context_class() {
11089        let doc = doc_with(vec![Block::Callout {
11090            callout_type: CalloutType::Context,
11091            title: None,
11092            content: "Background info.".into(),
11093            span: span(),
11094        }]);
11095        let html = to_html(&doc);
11096        assert!(
11097            html.contains("surfdoc-callout-context"),
11098            "expected surfdoc-callout-context class in: {html}"
11099        );
11100        assert!(
11101            !html.contains("surfdoc-callout-info"),
11102            "Context callout must not fall back to Info class: {html}"
11103        );
11104    }
11105}
11106
11107#[cfg(test)]
11108mod proptest_summary {
11109    use super::*;
11110    use crate::types::*;
11111    use proptest::prelude::*;
11112
11113    fn span() -> Span {
11114        Span {
11115            start_line: 1,
11116            end_line: 1,
11117            start_offset: 0,
11118            end_offset: 0,
11119        }
11120    }
11121
11122    fn doc_with(blocks: Vec<Block>) -> SurfDoc {
11123        SurfDoc {
11124            front_matter: None,
11125            blocks,
11126            source: String::new(),
11127        }
11128    }
11129
11130    proptest! {
11131        // For any printable ASCII string free of markdown-significant
11132        // characters, wrapping it in `**...**` inside a Summary block must
11133        // yield <strong>{s}</strong> in the rendered HTML.
11134        #[test]
11135        fn summary_bold_roundtrip(s in "[a-zA-Z0-9][a-zA-Z0-9 ,.!?]{0,78}[a-zA-Z0-9]") {
11136            // Anchored on alphanumerics at both ends to avoid pulldown-cmark
11137            // edge cases (e.g. `** **` becomes <hr>, leading/trailing spaces
11138            // break emphasis).
11139            let content = format!("**{}**", s);
11140            let doc = doc_with(vec![Block::Summary {
11141                content,
11142                span: span(),
11143            }]);
11144            let html = to_html(&doc);
11145            let expected = format!("<strong>{}</strong>", s);
11146            prop_assert!(
11147                html.contains(&expected),
11148                "expected {:?} in HTML: {}", expected, html
11149            );
11150        }
11151
11152        // Rendering must never panic for any printable Unicode string.
11153        #[test]
11154        fn summary_never_panics(s in r"\PC*") {
11155            let doc = doc_with(vec![Block::Summary {
11156                content: s,
11157                span: span(),
11158            }]);
11159            let _ = to_html(&doc);
11160        }
11161    }
11162}