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