1use 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
11fn 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 let html_output = ammonia::clean(&html_output);
26 let html_output = html_output.replace("<table>", "<div class=\"surfdoc-table-wrap\"><table>");
28 let html_output = html_output.replace("</table>", "</table></div>");
29 substitute_cites_html(&html_output)
31}
32
33fn 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
59fn 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
75fn 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 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
124fn 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
136fn render_inline_markdown(content: &str) -> String {
151 let escaped = content
152 .replace('&', "&")
153 .replace('<', "<")
154 .replace('>', ">");
155 let escaped = escape_markdown_in_slot_markers(&escaped);
156 render_markdown(&escaped)
157}
158
159fn 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
190fn 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#[derive(Debug, Clone)]
209pub struct HeadIcon {
210 pub rel: String,
212 pub icon_type: Option<String>,
214 pub sizes: Option<String>,
216 pub href: String,
217}
218
219#[derive(Debug, Clone)]
221pub struct HeadScript {
222 pub src: String,
223 pub defer: bool,
225}
226
227#[derive(Debug, Clone, Default)]
231pub struct ReadingFrame {
232 pub title: Option<String>,
234 pub back_href: Option<String>,
236 pub back_label: Option<String>,
238}
239
240#[derive(Debug, Clone)]
242pub struct PageConfig {
243 pub source_path: String,
246 pub title: Option<String>,
248 pub canonical_url: Option<String>,
250 pub description: Option<String>,
252 pub lang: Option<String>,
254 pub og_image: Option<String>,
256 pub twitter_card: Option<String>,
258 pub icons: Vec<HeadIcon>,
260 pub stylesheets: Vec<String>,
263 pub scripts: Vec<HeadScript>,
265 pub theme_init: bool,
268 pub theme_key: Option<String>,
270 pub embed_css: bool,
273 pub head_extra: Option<String>,
275 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 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
368pub use crate::resolve::{accent_ink_color, contrast_ratio};
380use crate::resolve::{accent_text_color, resolve_font_preset};
381
382fn 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 let text = accent_text_color(&prop.value);
393 css_overrides.push_str(&format!("--accent-text: {};", text));
394 }
395 }
396 "font" => {
397 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
424fn 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 html.push_str(
435 "<input type=\"checkbox\" class=\"surfdoc-nav-toggle\" id=\"surfdoc-nav-toggle\" aria-hidden=\"true\">",
436 );
437
438 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 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 html.push_str("<label for=\"surfdoc-nav-toggle\" class=\"surfdoc-nav-scrim\" aria-hidden=\"true\"></label>");
499
500 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
507const 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
515fn 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
530fn 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\">®</sup>" } else { "" };
545 s.push_str(&format!("<span>{}{}</span>", escape_html(b), reg));
546 }
547 s
548}
549
550fn 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 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
576fn 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 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 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
679fn 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 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 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 String::new()
726 };
727 (style, dark, bottom, theme_ink)
728}
729
730fn nav_is_rich(groups: &[NavGroup], brand: &Option<String>, drawer: bool) -> bool {
732 drawer || !groups.is_empty() || brand.is_some()
733}
734
735fn 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
754fn 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
771fn 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
786fn 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
808fn wire_headings_and_toc(html: &str) -> String {
814 let mut result = String::with_capacity(html.len() + 256);
816 let mut headings: Vec<(u32, String, String)> = Vec::new(); 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 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 result.push('<');
860 rest = &after[1..];
861 }
862 result.push_str(rest);
863
864 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#[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 let mut stack: Vec<u32> = Vec::new();
924 let mut li_open = false;
926
927 for (lvl, slug, text) in items {
928 let lvl = *lvl;
929
930 if stack.is_empty() {
931 html.push_str("<ol>");
933 stack.push(lvl);
934 } else {
935 let cur = *stack.last().unwrap();
936 if lvl > cur {
937 html.push_str("<ol>");
939 stack.push(lvl);
940 li_open = false;
941 } else if lvl == cur {
942 if li_open {
944 html.push_str("</li>");
945 li_open = false;
946 }
947 } else {
948 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 li_open = true;
959 }
960 if li_open {
962 html.push_str("</li>");
963 li_open = false;
964 }
965 }
966 }
967
968 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 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 if !stack.is_empty() {
987 li_open = true;
988 }
989 }
990
991 html
992}
993
994pub fn to_html(doc: &SurfDoc) -> String {
995 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 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 for url in &font_imports {
1017 parts.push(format!("<style>@import url('{}');</style>", url));
1018 }
1019
1020 if !css_overrides.is_empty() {
1021 parts.push(format!("<style>.surfdoc {{ {} }}</style>", css_overrides));
1024 }
1025
1026 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 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 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 if matches!(block, Block::Nav { .. }) {
1056 continue;
1057 }
1058
1059 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 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 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
1096pub fn to_html_fragment(blocks: &[Block]) -> String {
1109 if blocks.is_empty() {
1110 return String::new();
1111 }
1112 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
1134pub 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 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 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
1205fn 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 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 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 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 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
1319pub 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 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 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 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}\">‹ {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 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
1464use crate::SURFDOC_CSS;
1467
1468pub(crate) fn escape_html(s: &str) -> String {
1472 s.replace('&', "&")
1473 .replace('<', "<")
1474 .replace('>', ">")
1475 .replace('"', """)
1476}
1477
1478fn 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 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("&"),
1504 '<' => result.push_str("<"),
1505 '>' => result.push_str(">"),
1506 '"' => result.push_str("""),
1507 c => result.push(c),
1508 }
1509 }
1510 i = close + 1;
1511 continue;
1512 }
1513 }
1514
1515 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 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 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 match chars[i] {
1544 '&' => result.push_str("&"),
1545 '<' => result.push_str("<"),
1546 '>' => result.push_str(">"),
1547 '"' => result.push_str("""),
1548 c => result.push(c),
1549 }
1550 i += 1;
1551 }
1552
1553 result
1554}
1555
1556fn try_parse_link(chars: &[char], pos: usize) -> Option<(String, usize)> {
1559 let len = chars.len();
1560 debug_assert!(chars[pos] == '[');
1561
1562 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 let close_bracket = j - 1;
1573
1574 if j >= len || chars[j] != '(' { return None; }
1576 let paren_start = j + 1;
1577
1578 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 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
1605fn 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; } 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
1626fn 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; } 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 chars[j] == '*' && j + 1 < len && chars[j + 1] == '*' {
1644 return None;
1645 }
1646 j += 1;
1647 }
1648 None
1649}
1650
1651fn sanitize_css_value(s: &str) -> String {
1656 let stripped: String = s.chars()
1657 .filter(|c| !matches!(c, ';' | '{' | '}' | '<' | '>' | '\\' | '"' | '\''))
1658 .collect();
1659 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
1667const 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
1732const 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
1810pub(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 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 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 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 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 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 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 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 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 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 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 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 Block::Deck { .. } => String::new(),
2400
2401 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 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 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 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 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 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 let is_surface = item
2652 .bg
2653 .as_deref()
2654 .is_some_and(|b| b.split_whitespace().next() == Some("surface"));
2655 let style_attr = if bg_style.is_empty() || is_image {
2659 String::new()
2660 } else {
2661 format!(" style=\"{}\"", bg_style)
2662 };
2663 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 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 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 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 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 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()); parts.push("</div>".to_string()); if !copy_bottom {
2788 if let Some(m) = &media {
2789 parts.push(m.clone());
2790 }
2791 }
2792 parts.push("</div>".to_string()); }
2794 parts.push("</div>".to_string()); }
2796 parts.push("</div>".to_string()); 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 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 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()); parts.push("</div>".to_string()); 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()); }
2886 parts.push("</div>".to_string()); 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()); parts.push("</a>".to_string());
2951 }
2952 parts.push("</div>".to_string()); parts.push("</div>".to_string()); 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()); parts.push("</section>".to_string());
2990 parts.join("")
2991 }
2992
2993 Block::Gallery { items, columns, .. } => {
2994 let cols = columns.unwrap_or(3);
2995 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 html.push_str(concat!(
3041 "<div class=\"surfdoc-lightbox\" hidden>",
3042 "<button class=\"sl-close\" aria-label=\"Close\">×</button>",
3043 "<button class=\"sl-prev\" aria-label=\"Previous\">‹</button>",
3044 "<button class=\"sl-next\" aria-label=\"Next\">›</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 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 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 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(§ion.heading)));
3077 for link in §ion.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(§ion.heading)));
3117 html.push_str("<ul>");
3118 for link in §ion.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 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 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 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 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 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 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 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 · 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 · 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 · 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 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 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 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\">↑ 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\">↑ 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\">—</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\">↓ 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 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\">‹</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\">›</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>"); 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 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…</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 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 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 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 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(" · "),
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> — {}</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> → <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 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 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 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 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…</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} — {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\">▼</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\">►</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 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’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 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’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 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
4861fn 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
4875fn 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
4952fn is_numeric_cell(cell: &str) -> bool {
4957 let trimmed = cell.trim();
4958 if trimmed.is_empty() {
4959 return false;
4960 }
4961 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 let s = s.strip_prefix(['+', '-']).unwrap_or(s);
4969 let cleaned: String = s.chars().filter(|c| *c != ',').collect();
4971 if cleaned.is_empty() {
4972 return false;
4973 }
4974 cleaned.chars().any(|c| c.is_ascii_digit()) && cleaned.parse::<f64>().is_ok()
4976}
4977
4978fn render_code_with_highlights(content: &str, highlight: &[String]) -> String {
4986 if highlight.is_empty() {
4987 return escape_html(content);
4988 }
4989 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 }
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; 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
5025fn callout_icon_svg(ct: CalloutType) -> &'static str {
5028 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 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#[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 pub theme: Option<String>,
5078 pub accent: Option<String>,
5079 pub font: Option<String>,
5080 pub base_path: Option<String>,
5084 pub properties: Vec<StyleProperty>,
5085}
5086
5087#[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 pub fn display_title(&self) -> String {
5103 self.title
5104 .clone()
5105 .unwrap_or_else(|| humanize_route(&self.route))
5106 }
5107}
5108
5109pub 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
5129pub 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
5188const 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
5285fn sanitize_js_key(key: &str) -> String {
5290 key.chars()
5291 .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | ':' | '_' | '-'))
5292 .collect()
5293}
5294
5295fn 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
5304fn 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 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
5351fn build_site_nav_html(
5365 site_name: &str,
5366 nav_items: &[(String, String)],
5367 current_route: &str,
5368 theme_key: &str,
5369 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 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 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 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 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 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 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
5441pub fn render_site_page(
5446 page: &PageEntry,
5447 site: &SiteConfig,
5448 nav_items: &[(String, String)], config: &PageConfig,
5450) -> String {
5451 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 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 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 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 let nav_html = build_site_nav_html(site_name, nav_items, &page.route, &theme_key, false);
5494
5495 let footer_html = format!(
5497 "<footer class=\"surfdoc-site-footer\">{}</footer>",
5498 escape_html(site_name),
5499 );
5500
5501 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 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 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
5631fn 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 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
5783fn 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
5811const 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
5856pub 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 let title = site_name.to_string();
5876
5877 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 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 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 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 let sections = wire_headings_and_toc(§ions);
5922
5923 crate::slots::resolve_slot_markers(render_site_document(
5924 site, config, &title, &nav_html, §ions, &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 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".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".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 #[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** 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".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("<script>"),
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 let doc = doc_with(vec![Block::PricingTable {
6168 headers: vec!["Tier".into(), "Price".into(), "Docs".into()],
6169 rows: vec".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 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("<hello>"), "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 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 assert!(!html.contains("surfdoc-diagram-cap"));
6345 }
6346
6347 #[test]
6348 fn html_diagram_unknown_type_falls_back() {
6349 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 => end</pre>"));
6360 assert!(!html.contains("<svg"));
6361 }
6362
6363 #[test]
6364 fn html_diagram_malformed_dsl_falls_back() {
6365 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 assert!(html.contains("<h1 id=\"hello\">Hello</h1>"));
6386 }
6387
6388 #[test]
6389 fn toc_auto_generates_from_headings() {
6390 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 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 assert!(!html.contains("#detail\">Detail</a>"));
6407 assert!(html.contains("<h3 id=\"detail\">Detail</h3>"));
6408 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 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 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("<script>"));
6456 }
6457
6458 #[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 #[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 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 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 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 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 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 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 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 assert!(html.contains("<a href=\"/live\" class=\"surfdoc-pg-card\""));
6841 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 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 assert!(html.contains("<span class=\"surfdoc-pg-tile-icon\"><svg"));
6858 assert!(!html.contains("surfdoc-pg-tile-emblem"));
6859 assert_eq!(html.matches("surfdoc-pg-tile-icon").count(), 1);
6861 assert!(!html.contains("icon:no-such-icon"));
6862 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 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 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 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 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 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 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 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 assert!(html.contains("surfdoc-pg-tiles"));
6940 assert!(!html.contains("surfdoc-pg-card"));
6941 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 assert_eq!(html.matches("surfdoc-pg-tile-dark").count(), 2);
6948 assert!(html.contains("surfdoc-pg-tile-name"));
6950 assert!(html.contains("Learn more"));
6951 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 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 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 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("<script>"));
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 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 assert!(html.contains("surfdoc-tier surfdoc-tier-featured"));
7108 assert!(html.contains("<div class=\"surfdoc-tier-name\">Team</div>"));
7109 assert!(html.contains("<div class=\"surfdoc-tier-price\">$9<span>/seat</span></div>"));
7111 assert!(html.contains("<li>3 seats</li>"));
7113 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("<script>"));
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")); assert!(html.contains("surfdoc-cta")); }
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>")); assert!(html.contains("surfdoc-hero-image")); }
7201
7202 #[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("<script>"));
7324 }
7325
7326 #[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 #[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 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 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 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 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 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 #[test]
7787 fn unknown_block_renders_markdown_not_raw() {
7788 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 assert!(html.contains("class=\"surfdoc-unknown\""), "{html}");
7799 assert!(html.contains("role=\"note\""), "{html}");
7800 assert!(html.contains("data-name=\"team\""), "{html}");
7801 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 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 assert!(html.contains(">Our Menu</a>"), "clean TOC label: {html}");
7831 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 #[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 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 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 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); }
7965
7966 #[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")); }
8001
8002 #[test]
8003 fn render_site_page_theme_is_hint_not_pin() {
8004 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 assert!(html.contains(":'dark');"), "palette hint feeds the resolver fallback");
8035 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 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 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 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 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 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 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 #[test]
8178 fn css_cta_selectors_use_parent_scope() {
8179 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 assert!(!SURFDOC_CSS.contains("a.surfdoc-cta"));
8186 }
8187
8188 #[test]
8189 fn cta_renders_as_anchor_with_classes() {
8190 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 assert!(SURFDOC_CSS.contains(".surfdoc .surfdoc-cta-primary { background: var(--accent); color: var(--accent-text, #fff);"));
8209 }
8210
8211 #[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 ("--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 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 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 assert!(!SURFDOC_CSS.contains("--ws-control-radius:"));
8266 }
8267
8268 #[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 assert!(SURFDOC_CSS.contains(".surfdoc-pg-card { display: flex;"));
8297 assert!(SURFDOC_CSS.contains("background: var(--surface); border: 1px solid var(--border);"));
8298 assert!(SURFDOC_CSS.contains(".surfdoc-pg-row { grid-template-columns: 1fr; }"));
8300 }
8301
8302 #[test]
8307 fn accent_colored_buttons_beat_link_color_specificity() {
8308 assert!(
8310 SURFDOC_CSS.contains(".surfdoc .surfdoc-hero-btn-primary {"),
8311 "hero primary button needs .surfdoc prefix to beat .surfdoc a specificity"
8312 );
8313 assert!(
8315 SURFDOC_CSS.contains(".surfdoc .surfdoc-product-cta {"),
8316 "product-card CTA needs .surfdoc prefix to beat .surfdoc a specificity"
8317 );
8318 assert!(
8320 SURFDOC_CSS.contains(".surfdoc .surfdoc-cta-primary {"),
8321 "CTA primary needs .surfdoc prefix to beat .surfdoc a specificity"
8322 );
8323 }
8324
8325 #[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 #[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("<script>"), "escaped form must be present");
8385 }
8386
8387 #[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 #[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 "--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 (".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 #[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 assert_eq!(html.matches("<section class=\"surfdoc-section\">").count(), 2);
8504 assert!(!html.contains("surfdoc-section-alt"));
8505 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 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 assert!(!SURFDOC_CSS.contains(".surfdoc-section-alt"));
8553 }
8554
8555 #[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 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 #[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 assert!(html.contains("surfdoc-nav-topbar"));
8602 assert!(html.contains("surfdoc-nav-drawer"));
8603 assert!(html.contains("surfdoc-nav-theme-toggle"));
8604 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 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 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("<script>"));
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 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 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 assert!(html.contains("CloudSurf<sup class=\"surfdoc-shell-reg\">®</sup>"));
8764 assert!(html.contains(">Explore</span>"));
8766 assert!(html.contains(">Products</span>"));
8767 assert!(html.contains("surfdoc-shell-drawer-link-icon"));
8768 assert!(html.contains(
8770 "<span class=\"surfdoc-shell-drawer-link-emblem\" style=\"--emblem:url('/surf.png')\">"
8771 ));
8772 assert!(html.contains("target=\"_blank\""));
8773 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 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 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 #[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 #[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 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 assert!(html_edit.contains("<svg"));
9045 assert_eq!(html_edit, html_pencil);
9046 }
9047
9048 #[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 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 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 assert!(SURFDOC_CSS.contains("font-family: var(--font-heading)"));
9140 }
9141
9142 #[test]
9143 fn css_type_scale_style_v2_defaults() {
9144 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 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 assert!(!SURFDOC_CSS.contains("clamp(2.25rem, 5vw, 3.5rem)"));
9158 }
9159
9160 #[test]
9161 fn css_glass_surface_token_hooks() {
9162 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 #[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 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 #[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 #[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 #[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 #[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 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 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 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 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 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 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 assert!(html.contains("--accent:"), "Accent override should be present");
9475 }
9476
9477 #[test]
9478 fn html_uploaded_image_relative_path() {
9479 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 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 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 assert_eq!(accent_text_color("#4CAF50"), "#1a1a2e"); assert_eq!(accent_text_color("#f59e0b"), "#1a1a2e"); assert_eq!(accent_text_color("#ffffff"), "#1a1a2e"); assert_eq!(accent_text_color("#eab308"), "#1a1a2e"); assert_eq!(accent_text_color("#3b82f6"), "#fff"); assert_eq!(accent_text_color("#283593"), "#fff"); assert_eq!(accent_text_color("#000000"), "#fff"); assert_eq!(accent_text_color("#ef4444"), "#fff"); assert_eq!(accent_text_color("#ec4899"), "#fff"); assert_eq!(accent_text_color("#8b5cf6"), "#fff"); assert_eq!(accent_text_color("#fff"), "#1a1a2e");
9565 assert_eq!(accent_text_color("#000"), "#fff");
9566 assert_eq!(accent_text_color("not-a-color"), "#fff");
9568 }
9569
9570 #[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 #[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 #[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 #[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 #[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 assert!(html.contains("<h1 id=\"hello\">Hello</h1>"), "Should render heading");
9753 assert!(html.contains("surfdoc-callout"), "Should render callout");
9754
9755 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 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 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 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("<script>"), "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 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 assert!(nav.contains("href=\"/\""), "Should have home link");
9919 assert!(
9922 nav.contains("href=\"/\" class=\"active\""),
9923 "Home link should be active when current_route is /"
9924 );
9925 }
9926
9927 #[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 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 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 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 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 assert!(
10004 site_nav_link_icon("/xyzzy", "Xyzzy").contains("M14 2H6a2 2 0 0 0-2 2"),
10005 "unknown → file-text default"
10006 );
10007 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let html = doc.to_html_fragment_with_context(&ctx);
10453 assert!(html.contains("Hello"), "Should render content");
10454 }
10455
10456 #[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 #[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".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 #[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 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("<script>alert(1)</script>"),
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 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 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 #[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("<script>alert(1)</script>"),
10966 "expected escaped script tag in: {html}"
10967 );
10968 }
10969
10970 #[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("<img"),
11021 "expected escaped <img in: {html}"
11022 );
11023 }
11024
11025 #[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("<script>"),
11079 "expected escaped script tag in: {html}"
11080 );
11081 }
11082
11083 #[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 #[test]
11135 fn summary_bold_roundtrip(s in "[a-zA-Z0-9][a-zA-Z0-9 ,.!?]{0,78}[a-zA-Z0-9]") {
11136 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 #[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}