1use crate::types::*;
11
12pub(crate) const SURFDOC_TEMPLATE: &str = include_str!("../assets/surfdoc.typ");
14
15use std::cell::RefCell;
28use std::collections::HashMap;
29
30thread_local! {
31 static ACTIVE_IMAGES: RefCell<Option<HashMap<String, String>>> = const { RefCell::new(None) };
32}
33
34pub struct ImageScope {
36 _private: (),
37}
38
39impl Drop for ImageScope {
40 fn drop(&mut self) {
41 ACTIVE_IMAGES.with(|c| *c.borrow_mut() = None);
42 }
43}
44
45pub fn install_image_context(map: HashMap<String, String>) -> ImageScope {
50 ACTIVE_IMAGES.with(|c| *c.borrow_mut() = Some(map));
51 ImageScope { _private: () }
52}
53
54fn resolved_image(src: &str) -> Option<String> {
56 ACTIVE_IMAGES.with(|c| c.borrow().as_ref().and_then(|m| m.get(src).cloned()))
57}
58
59pub fn to_typst(doc: &SurfDoc) -> String {
65 let format = doc.front_matter.as_ref().and_then(|fm| fm.format);
66 let doc_type = doc.front_matter.as_ref().and_then(|fm| fm.doc_type);
67 let _cite_scope =
68 crate::citation::install_context(crate::citation::build_context(&doc.blocks, format));
69
70 match crate::types::render_profile(doc_type, format) {
73 RenderProfile::Paper(f) => render_paper(doc, f),
74 RenderProfile::Report(f) => render_report(doc, f),
75 _ => render_generic(doc),
76 }
77}
78
79fn render_generic(doc: &SurfDoc) -> String {
81 let mut out = String::with_capacity(8192);
82
83 out.push_str(SURFDOC_TEMPLATE);
85 out.push_str("\n\n");
86
87 if let Some(ref fm) = doc.front_matter {
89 render_front_matter(fm, &mut out);
90 }
91
92 for block in &doc.blocks {
94 render_block(block, &mut out);
95 out.push('\n');
96 }
97
98 out
99}
100
101fn fm_extra(fm: &FrontMatter, keys: &[&str]) -> Option<String> {
120 for k in keys {
121 if let Some(v) = fm.extra.get(*k) {
122 if let Some(s) = v.as_str() {
123 let t = s.trim();
124 if !t.is_empty() {
125 return Some(t.to_string());
126 }
127 }
128 if let Some(seq) = v.as_sequence() {
129 let parts: Vec<String> = seq
130 .iter()
131 .filter_map(|x| x.as_str().map(|s| s.trim().to_string()))
132 .filter(|s| !s.is_empty())
133 .collect();
134 if !parts.is_empty() {
135 return Some(parts.join("; "));
136 }
137 }
138 }
139 }
140 None
141}
142
143fn doc_title(doc: &SurfDoc) -> String {
145 doc.front_matter
146 .as_ref()
147 .and_then(|fm| fm.title.clone())
148 .filter(|t| !t.trim().is_empty())
149 .unwrap_or_else(|| "Untitled".to_string())
150}
151
152fn doc_authors(doc: &SurfDoc) -> Vec<String> {
154 let mut names: Vec<String> = Vec::new();
155 if let Some(fm) = doc.front_matter.as_ref() {
156 let raw = fm_extra(fm, &["authors"]).or_else(|| fm.author.clone());
157 if let Some(raw) = raw {
158 for part in raw.split(';') {
159 let t = part.trim();
160 if !t.is_empty() && !names.iter().any(|n| n == t) {
161 names.push(t.to_string());
162 }
163 }
164 }
165 if let Some(contribs) = fm.contributors.as_ref() {
166 for c in contribs {
167 let t = c.trim();
168 if !t.is_empty() && !names.iter().any(|n| n == t) {
169 names.push(t.to_string());
170 }
171 }
172 }
173 }
174 names
175}
176
177fn doc_date(doc: &SurfDoc) -> Option<String> {
179 let fm = doc.front_matter.as_ref()?;
180 fm_extra(fm, &["date"])
181 .or_else(|| fm.created.clone())
182 .or_else(|| fm.updated.clone())
183}
184
185fn substitute_block_cites(b: &Block) -> Block {
188 use crate::citation::substitute_text_cites as sub;
189 let mut b = b.clone();
190 match &mut b {
191 Block::Markdown { content, .. }
192 | Block::Callout { content, .. }
193 | Block::Summary { content, .. }
194 | Block::Quote { content, .. }
195 | Block::Details { content, .. } => {
196 *content = sub(content);
197 }
198 Block::Section {
199 content, children, ..
200 } => {
201 *content = sub(content);
202 for c in children.iter_mut() {
203 *c = substitute_block_cites(c);
204 }
205 }
206 Block::Page { children, .. } | Block::Slide { children, .. } => {
207 for c in children.iter_mut() {
208 *c = substitute_block_cites(c);
209 }
210 }
211 _ => {}
212 }
213 b
214}
215
216fn render_academic_body(blocks: &[Block], style: Format, out: &mut String) {
220 for b in blocks {
221 match b {
222 Block::Cite { .. }
223 | Block::Site { .. }
224 | Block::Style { .. }
225 | Block::Page { .. } => {}
226 Block::Bibliography { style: bstyle, .. } => {
227 render_academic_bibliography(bstyle.unwrap_or(style), out);
228 }
229 Block::Figure { src, caption, alt, .. } => match resolved_image(src) {
235 Some(path) => {
236 out.push_str(&format!("#figure(\n image(\"{}\")", escape_typst(&path)));
237 if let Some(c) = caption.as_deref() {
238 out.push_str(&format!(",\n caption: [{}]", md_to_typst_inline(c)));
239 }
240 out.push_str("\n)\n\n");
241 let _ = alt;
242 }
243 None => render_academic_figure(caption.as_deref(), alt.as_deref(), out),
244 },
245 Block::Chart { title, data, .. } => {
246 render_academic_chart(title.as_deref(), data.as_ref(), out);
247 }
248 _ => {
249 render_block(&substitute_block_cites(b), out);
250 out.push('\n');
251 }
252 }
253 }
254}
255
256fn render_academic_figure(caption: Option<&str>, alt: Option<&str>, out: &mut String) {
258 let label = alt.filter(|s| !s.is_empty()).unwrap_or("Figure");
259 out.push_str("#figure(\n");
260 out.push_str(&format!(
261 " rect(width: 100%, height: 90pt, stroke: 0.5pt + luma(180), fill: luma(248), inset: 8pt)[#align(center + horizon)[#text(fill: luma(140))[{}]]]",
262 escape_typst(label)
263 ));
264 if let Some(c) = caption {
265 out.push_str(&format!(",\n caption: [{}]", md_to_typst_inline(c)));
266 }
267 out.push_str("\n)\n\n");
268}
269
270fn render_academic_chart(title: Option<&str>, data: Option<&ChartData>, out: &mut String) {
272 let Some(d) = data else { return };
273 if let Some(t) = title {
274 out.push_str(&format!(
275 "#align(center)[#text(weight: \"bold\")[{}]]\n",
276 escape_typst(t)
277 ));
278 }
279 let mut headers = vec![String::new()];
280 headers.extend(d.series.iter().map(|s| s.name.clone()));
281 let rows: Vec<Vec<String>> = d
282 .categories
283 .iter()
284 .enumerate()
285 .map(|(i, cat)| {
286 let mut row = vec![cat.clone()];
287 for s in &d.series {
288 row.push(s.values.get(i).map(|v| format!("{v}")).unwrap_or_default());
289 }
290 row
291 })
292 .collect();
293 render_data_table(&headers, &rows, out);
294 out.push('\n');
295}
296
297fn render_academic_bibliography(style: Format, out: &mut String) {
300 crate::citation::with_active(|ctx| {
301 let refs = match ctx {
302 Some(c) if !c.references.is_empty() => {
303 if style == c.style {
304 crate::citation::ordered_references(c)
305 } else {
306 c.references.clone()
307 }
308 }
309 _ => return,
310 };
311 let heading = crate::citation::bibliography_heading(style);
312 out.push_str(&format!(
313 "\n#heading(numbering: none)[{}]\n\n",
314 escape_typst(heading)
315 ));
316 if crate::citation::is_numbered(style) {
317 for (i, line) in crate::citation::reference_list(&refs, style)
318 .iter()
319 .enumerate()
320 {
321 let body = line.splitn(2, "] ").nth(1).unwrap_or(line.as_str());
323 out.push_str(&format!("{}. {}\n\n", i + 1, md_to_typst_inline(body)));
324 }
325 } else {
326 for line in crate::citation::reference_list(&refs, style) {
327 out.push_str(&md_to_typst_inline(&line));
328 out.push_str("\n\n");
329 }
330 }
331 });
332}
333
334fn render_paper(doc: &SurfDoc, format: Format) -> String {
336 let mut out = String::with_capacity(8192);
337 out.push_str(SURFDOC_TEMPLATE);
338 out.push_str("\n\n");
339
340 let title = doc_title(doc);
341 let authors = doc_authors(doc);
342 let affiliation = doc
343 .front_matter
344 .as_ref()
345 .and_then(|fm| fm_extra(fm, &["affiliation", "affiliations", "institution"]));
346 let abstract_text = doc
347 .front_matter
348 .as_ref()
349 .and_then(|fm| fm_extra(fm, &["abstract"]));
350
351 let two_column = matches!(format, Format::Ieee | Format::Acm);
352
353 out.push_str("#set page(paper: \"us-letter\", margin: (x: 0.75in, y: 0.85in)");
355 if two_column {
356 out.push_str(", columns: 2");
357 }
358 out.push_str(")\n");
359 out.push_str("#set text(font: (\"Libertinus Serif\", \"Liberation Sans\"), size: 10pt, fill: rgb(\"#000000\"))\n");
360 out.push_str("#set par(justify: true, leading: 0.62em)\n");
361 out.push_str("#set heading(numbering: \"1.\")\n\n");
362
363 let title_body = render_paper_title_block(&title, &authors, affiliation.as_deref(), abstract_text.as_deref());
366 if two_column {
367 out.push_str(&format!(
368 "#place(top + center, scope: \"parent\", float: true, clearance: 1.6em)[\n{title_body}]\n\n"
369 ));
370 } else {
371 out.push_str(&format!("{title_body}\n"));
372 }
373
374 render_academic_body(&doc.blocks, format, &mut out);
375 out
376}
377
378fn render_paper_title_block(
381 title: &str,
382 authors: &[String],
383 affiliation: Option<&str>,
384 abstract_text: Option<&str>,
385) -> String {
386 let mut s = String::new();
387 s.push_str(" #align(center)[\n");
388 s.push_str(&format!(
389 " #text(size: 17pt, weight: \"bold\")[{}]\n",
390 escape_typst(title)
391 ));
392 if !authors.is_empty() {
393 let names: Vec<String> = authors.iter().map(|a| escape_typst(a)).collect();
394 s.push_str(&format!(
395 " #v(0.6em)\n #text(size: 11pt)[{}]\n",
396 names.join(" #h(1.5em) ")
397 ));
398 }
399 if let Some(aff) = affiliation {
400 s.push_str(&format!(
401 " #v(0.2em)\n #text(size: 9.5pt, style: \"italic\")[{}]\n",
402 escape_typst(aff)
403 ));
404 }
405 s.push_str(" ]\n");
406 if let Some(abs) = abstract_text {
407 s.push_str(" #v(0.8em)\n");
408 s.push_str(" #block(width: 100%)[\n");
409 s.push_str(&format!(
410 " #text(weight: \"bold\")[Abstract.] {}\n",
411 md_to_typst_inline(abs)
412 ));
413 s.push_str(" ]\n #v(0.8em)\n");
414 }
415 s
416}
417
418fn render_report(doc: &SurfDoc, format: Format) -> String {
420 let mut out = String::with_capacity(8192);
421 out.push_str(SURFDOC_TEMPLATE);
422 out.push_str("\n\n");
423
424 let title = doc_title(doc);
425 let authors = doc_authors(doc);
426 let date = doc_date(doc);
427 let instructor = doc
428 .front_matter
429 .as_ref()
430 .and_then(|fm| fm_extra(fm, &["instructor", "professor"]));
431 let course = doc
432 .front_matter
433 .as_ref()
434 .and_then(|fm| fm_extra(fm, &["course", "class"]));
435 let institution = doc
436 .front_matter
437 .as_ref()
438 .and_then(|fm| fm_extra(fm, &["institution", "affiliation", "university"]));
439 let running_head = doc
440 .front_matter
441 .as_ref()
442 .and_then(|fm| fm_extra(fm, &["running-head", "running_head"]));
443
444 out.push_str("#set page(paper: \"us-letter\", margin: 1in");
446 if matches!(format, Format::Apa) {
447 if let Some(rh) = &running_head {
448 out.push_str(&format!(
449 ", header: [#text(size: 10pt)[{} #h(1fr) #counter(page).display()]]",
450 escape_typst(&rh.to_uppercase())
451 ));
452 }
453 }
454 out.push_str(")\n");
455 out.push_str("#set text(font: (\"Libertinus Serif\", \"Liberation Sans\"), size: 12pt, fill: rgb(\"#000000\"))\n");
456 out.push_str("#set par(justify: false, leading: 1.5em, first-line-indent: 0.5in)\n");
457 out.push_str("#set heading(numbering: none)\n\n");
458
459 match format {
460 Format::Mla => {
461 for line in [
463 authors.join(", "),
464 instructor.clone().unwrap_or_default(),
465 course.clone().unwrap_or_default(),
466 date.clone().unwrap_or_default(),
467 ] {
468 if !line.is_empty() {
469 out.push_str(&format!("{} \\\n", escape_typst(&line)));
470 }
471 }
472 out.push_str(&format!(
473 "#v(0.5em)\n#align(center)[{}]\n#v(0.5em)\n\n",
474 escape_typst(&title)
475 ));
476 }
477 _ => {
478 out.push_str("#align(center)[\n #v(3.5in)\n");
480 out.push_str(&format!(
481 " #text(weight: \"bold\")[{}]\n",
482 escape_typst(&title)
483 ));
484 if !authors.is_empty() {
485 out.push_str(&format!(
486 " #v(1em)\n {}\n",
487 escape_typst(&authors.join(", "))
488 ));
489 }
490 if let Some(inst) = &institution {
491 out.push_str(&format!(" #v(0.5em)\n {}\n", escape_typst(inst)));
492 }
493 if let Some(c) = &course {
494 out.push_str(&format!(" #v(0.5em)\n {}\n", escape_typst(c)));
495 }
496 if let Some(i) = &instructor {
497 out.push_str(&format!(" #v(0.5em)\n {}\n", escape_typst(i)));
498 }
499 if let Some(d) = &date {
500 out.push_str(&format!(" #v(0.5em)\n {}\n", escape_typst(d)));
501 }
502 out.push_str("]\n#pagebreak()\n");
503 out.push_str(&format!(
504 "#align(center)[#text(weight: \"bold\")[{}]]\n#v(0.5em)\n\n",
505 escape_typst(&title)
506 ));
507 }
508 }
509
510 render_academic_body(&doc.blocks, format, &mut out);
511 out
512}
513
514fn render_front_matter(fm: &FrontMatter, out: &mut String) {
516 let has_title = fm.title.as_ref().is_some_and(|t| !t.is_empty());
517 let has_meta = fm.author.is_some()
518 || fm.created.is_some()
519 || fm.status.is_some()
520 || fm.tags.as_ref().is_some_and(|t| !t.is_empty());
521
522 if !has_title && !has_meta {
523 return;
524 }
525
526 out.push_str("#align(center)[\n");
527
528 if let Some(ref title) = fm.title {
529 out.push_str(&format!(
530 " #text(size: 2em, weight: \"bold\")[{}]\n",
531 escape_typst(title)
532 ));
533 }
534
535 let mut meta_parts: Vec<String> = Vec::new();
537 if let Some(ref author) = fm.author {
538 meta_parts.push(escape_typst(author));
539 }
540 if let Some(ref created) = fm.created {
541 meta_parts.push(escape_typst(created));
542 }
543 if let Some(ref status) = fm.status {
544 meta_parts.push(format!("{status:?}"));
545 }
546 if !meta_parts.is_empty() {
547 out.push_str(" #v(0.5em)\n");
548 out.push_str(&format!(
549 " #text(fill: luma(100))[{}]\n",
550 meta_parts.join(" · ")
551 ));
552 }
553
554 if let Some(ref tags) = fm.tags {
556 if !tags.is_empty() {
557 out.push_str(" #v(0.3em)\n");
558 let tag_str: Vec<String> = tags.iter().map(|t| escape_typst(t)).collect();
559 out.push_str(&format!(
560 " #text(size: 0.9em, fill: luma(120))[{}]\n",
561 tag_str.join(" · ")
562 ));
563 }
564 }
565
566 out.push_str("]\n");
567 out.push_str("#v(1em)\n");
568 out.push_str("#line(length: 100%, stroke: 0.5pt + luma(200))\n");
569 out.push_str("#v(1em)\n\n");
570}
571
572fn render_block(block: &Block, out: &mut String) {
574 match block {
575 Block::Markdown { content, .. } => {
576 out.push_str(&md_to_typst(content));
577 out.push('\n');
578 }
579
580 Block::Callout {
581 callout_type,
582 title,
583 content,
584 ..
585 } => {
586 let type_name = callout_type_str(*callout_type);
587 let title_arg = match title {
588 Some(t) => format!("\"{}\"", escape_typst(t)),
589 None => "none".to_string(),
590 };
591 out.push_str(&format!(
592 "#surfdoc-callout(\"{type_name}\", {title_arg})[\n{}\n]\n",
593 md_to_typst(content)
594 ));
595 }
596
597 Block::Data {
598 headers, rows, ..
599 } => {
600 render_data_table(headers, rows, out);
601 }
602
603 Block::Code {
604 lang, content, ..
605 } => {
606 let lang_str = match lang {
607 Some(l) if !l.is_empty() => l.as_str(),
608 _ => "",
609 };
610 out.push_str(&format!("```{}\n{}\n```\n", lang_str, content));
611 }
612
613 Block::Tasks { items, .. } => {
614 for item in items {
615 let marker = if item.done { "☑" } else { "☐" };
616 let assignee = match &item.assignee {
617 Some(a) => format!(" #text(fill: luma(120))[\\@{}]", escape_typst(a)),
618 None => String::new(),
619 };
620 out.push_str(&format!(
621 "- {} {}{}\n",
622 marker,
623 md_to_typst_inline(&item.text),
624 assignee
625 ));
626 }
627 out.push('\n');
628 }
629
630 Block::Decision {
631 status,
632 date,
633 deciders,
634 content,
635 ..
636 } => {
637 let status_str = decision_status_str(*status);
638 out.push_str(&format!("#surfdoc-decision-badge(\"{status_str}\")"));
639 if let Some(d) = date {
640 out.push_str(&format!(" #text(fill: luma(120))[{}]", escape_typst(d)));
641 }
642 if !deciders.is_empty() {
643 let names: Vec<String> = deciders.iter().map(|d| escape_typst(d)).collect();
644 out.push_str(&format!(
645 " #text(fill: luma(120))[— {}]",
646 names.join(", ")
647 ));
648 }
649 out.push('\n');
650 out.push_str(&md_to_typst(content));
651 out.push('\n');
652 }
653
654 Block::Metric {
655 label,
656 value,
657 trend,
658 unit,
659 ..
660 } => {
661 let trend_arg = match trend {
662 Some(t) => format!("\"{}\"", trend_str(*t)),
663 None => "none".to_string(),
664 };
665 let unit_arg = match unit {
666 Some(u) => format!(", unit: \"{}\"", escape_typst(u)),
667 None => String::new(),
668 };
669 out.push_str(&format!(
670 "#surfdoc-metric(\"{}\", \"{}\"{}, trend: {})\n",
671 escape_typst(label),
672 escape_typst(value),
673 unit_arg,
674 trend_arg
675 ));
676 }
677
678 Block::Summary { content, .. } => {
679 out.push_str("#block(fill: luma(245), inset: 12pt, radius: 4pt, width: 100%)[\n");
680 out.push_str(" *Summary* \\\n");
681 out.push_str(&format!(" {}\n", md_to_typst(content)));
682 out.push_str("]\n");
683 }
684
685 Block::Figure {
686 src,
687 caption,
688 alt,
689 width,
690 ..
691 } => {
692 let Some(path) = resolved_image(src) else {
697 render_academic_figure(caption.as_deref(), alt.as_deref(), out);
698 return;
699 };
700 let width_attr = match width {
701 Some(w) => format!(", width: {}", w),
702 None => String::new(),
703 };
704 let alt_val = alt.as_deref().unwrap_or("");
705 out.push_str(&format!(
706 "#figure(\n image(\"{}\"{}){}",
707 escape_typst(&path),
708 width_attr,
709 if !alt_val.is_empty() {
710 format!(",\n supplement: none")
711 } else {
712 String::new()
713 }
714 ));
715 if let Some(cap) = caption {
716 out.push_str(&format!(",\n caption: [{}]", md_to_typst_inline(cap)));
717 }
718 out.push_str("\n)\n");
719 }
720
721 Block::Quote {
722 content,
723 attribution,
724 ..
725 } => {
726 if let Some(attr) = attribution {
727 out.push_str(&format!(
728 "#quote(attribution: [{}])[\n{}\n]\n",
729 escape_typst(attr),
730 md_to_typst(content)
731 ));
732 } else {
733 out.push_str(&format!(
734 "#quote[\n{}\n]\n",
735 md_to_typst(content)
736 ));
737 }
738 }
739
740 Block::Tabs { tabs, .. } => {
741 for tab in tabs {
742 out.push_str(&format!("=== {}\n\n", escape_typst(&tab.label)));
743 out.push_str(&md_to_typst(&tab.content));
744 out.push('\n');
745 }
746 }
747
748 Block::Columns { columns, .. } => {
749 let n = columns.len();
750 out.push_str(&format!("#grid(\n columns: ({}),\n gutter: 1em,\n", "1fr, ".repeat(n).trim_end_matches(", ")));
751 for col in columns {
752 out.push_str(&format!(" [\n {}\n ],\n", md_to_typst(&col.content)));
753 }
754 out.push_str(")\n");
755 }
756
757 Block::Section {
758 headline,
759 subtitle,
760 children,
761 content,
762 ..
763 } => {
764 if let Some(h) = headline {
765 out.push_str(&format!("== {}\n\n", escape_typst(h)));
766 }
767 if let Some(s) = subtitle {
768 out.push_str(&format!(
769 "#text(fill: luma(100))[{}]\n\n",
770 escape_typst(s)
771 ));
772 }
773 if children.is_empty() {
774 out.push_str(&md_to_typst(content));
775 out.push('\n');
776 } else {
777 for child in children {
778 render_block(child, out);
779 out.push('\n');
780 }
781 }
782 }
783
784 Block::Details {
785 title, content, ..
786 } => {
787 if let Some(t) = title {
788 out.push_str(&format!("*{}*\n\n", escape_typst(t)));
789 }
790 out.push_str(&md_to_typst(content));
791 out.push('\n');
792 }
793
794 Block::Divider { label, .. } => {
795 if let Some(l) = label {
796 out.push_str(&format!(
797 "#v(0.5em)\n#align(center)[#text(fill: luma(150), size: 0.9em)[— {} —]]\n#v(0.5em)\n",
798 escape_typst(l)
799 ));
800 } else {
801 out.push_str("#line(length: 100%, stroke: 0.5pt + luma(200))\n");
802 }
803 }
804
805 Block::Hero {
806 headline,
807 subtitle,
808 badge,
809 buttons,
810 content,
811 ..
812 } => {
813 out.push_str("#align(center)[\n");
814 if let Some(b) = badge {
815 out.push_str(&format!(
816 " #box(fill: luma(235), radius: 12pt, inset: (x: 10pt, y: 4pt))[#text(size: 0.85em)[{}]]\n #v(0.5em)\n",
817 escape_typst(b)
818 ));
819 }
820 if let Some(h) = headline {
821 out.push_str(&format!(
822 " #text(size: 2.5em, weight: \"bold\")[{}]\n",
823 escape_typst(h)
824 ));
825 }
826 if let Some(s) = subtitle {
827 out.push_str(&format!(
828 " #v(0.3em)\n #text(size: 1.2em, fill: luma(100))[{}]\n",
829 escape_typst(s)
830 ));
831 }
832 if !buttons.is_empty() {
833 out.push_str(" #v(0.5em)\n");
834 for btn in buttons {
835 if btn.primary {
836 out.push_str(&format!(
837 " #box(fill: surfdoc-blue, radius: 4pt, inset: (x: 12pt, y: 6pt))[#link(\"{}\")[#text(fill: white, weight: \"bold\")[{}]]]\n",
838 escape_typst(&btn.href),
839 escape_typst(&btn.label)
840 ));
841 } else {
842 out.push_str(&format!(
843 " #box(stroke: 1pt + luma(200), radius: 4pt, inset: (x: 12pt, y: 6pt))[#link(\"{}\")[{}]]\n",
844 escape_typst(&btn.href),
845 escape_typst(&btn.label)
846 ));
847 }
848 out.push_str(" #h(0.5em)\n");
849 }
850 }
851 if !content.is_empty() {
852 out.push_str(&format!(" #v(0.5em)\n {}\n", md_to_typst(content)));
853 }
854 out.push_str("]\n#v(1em)\n");
855 }
856
857 Block::Features { cards, cols, .. } => {
858 let ncols = cols.unwrap_or(3);
859 let cols_str = (0..ncols).map(|_| "1fr").collect::<Vec<_>>().join(", ");
860 out.push_str(&format!(
861 "#grid(\n columns: ({}),\n gutter: 1.5em,\n",
862 cols_str
863 ));
864 for card in cards {
865 out.push_str(" block(stroke: 0.5pt + luma(220), radius: 6pt, inset: 12pt, width: 100%)[\n");
866 out.push_str(&format!(
867 " *{}*\n",
868 escape_typst(&card.title)
869 ));
870 if !card.body.is_empty() {
871 out.push_str(&format!(" \\\n {}\n", md_to_typst_inline(&card.body)));
872 }
873 if let (Some(label), Some(href)) = (&card.link_label, &card.link_href) {
874 out.push_str(&format!(
875 " \\\n #link(\"{}\")[{}]\n",
876 escape_typst(href),
877 escape_typst(label)
878 ));
879 }
880 out.push_str(" ],\n");
881 }
882 out.push_str(")\n");
883 }
884
885 Block::Steps { steps, .. } => {
886 for (i, step) in steps.iter().enumerate() {
887 out.push_str(&format!(
888 "+ *{}*",
889 escape_typst(&step.title)
890 ));
891 if let Some(ref time) = step.time {
892 out.push_str(&format!(
893 " #text(fill: luma(120), size: 0.9em)[{}]",
894 escape_typst(time)
895 ));
896 }
897 if !step.body.is_empty() {
898 out.push_str(&format!(" — {}", md_to_typst_inline(&step.body)));
899 }
900 out.push('\n');
901 if i < steps.len() - 1 {
902 }
904 }
905 out.push('\n');
906 }
907
908 Block::Stats { items, .. } => {
909 let n = items.len();
910 let cols_str = (0..n).map(|_| "1fr").collect::<Vec<_>>().join(", ");
911 out.push_str(&format!(
912 "#grid(\n columns: ({}),\n gutter: 1em,\n",
913 cols_str
914 ));
915 for item in items {
916 out.push_str(" align(center)[\n");
917 out.push_str(&format!(
918 " #text(size: 2em, weight: \"bold\")[{}]\n \\\n {}\n",
919 escape_typst(&item.value),
920 escape_typst(&item.label)
921 ));
922 out.push_str(" ],\n");
923 }
924 out.push_str(")\n");
925 }
926
927 Block::Comparison {
928 headers,
929 rows,
930 highlight,
931 ..
932 } => {
933 render_comparison_table(headers, rows, highlight.as_deref(), out);
934 }
935
936 Block::Cta {
937 label, href, primary, ..
938 } => {
939 if *primary {
940 out.push_str(&format!(
941 "#align(center)[#box(fill: surfdoc-blue, radius: 4pt, inset: (x: 14pt, y: 8pt))[#link(\"{}\")[#text(fill: white, weight: \"bold\")[{}]]]]\n",
942 escape_typst(href),
943 escape_typst(label)
944 ));
945 } else {
946 out.push_str(&format!(
947 "#align(center)[#box(stroke: 1pt + surfdoc-blue, radius: 4pt, inset: (x: 14pt, y: 8pt))[#link(\"{}\")[#text(fill: surfdoc-blue)[{}]]]]\n",
948 escape_typst(href),
949 escape_typst(label)
950 ));
951 }
952 }
953
954 Block::Nav { items, .. } => {
955 let links: Vec<String> = items
956 .iter()
957 .map(|item| {
958 format!(
959 "#link(\"{}\")[{}]",
960 escape_typst(&item.href),
961 escape_typst(&item.label)
962 )
963 })
964 .collect();
965 out.push_str(&format!(
966 "#text(size: 0.9em)[{}]\n",
967 links.join(" #h(1em) ")
968 ));
969 }
970
971 Block::Form { fields, .. } => {
972 out.push_str("#block(stroke: 0.5pt + luma(200), radius: 4pt, inset: 12pt, width: 100%)[\n");
973 out.push_str(" *Form Fields*\n");
974 for field in fields {
975 let required = if field.required { " \\*" } else { "" };
976 out.push_str(&format!(
977 " - *{}*{} #text(fill: luma(120))[({:?})]\n",
978 escape_typst(&field.label),
979 required,
980 field.field_type
981 ));
982 }
983 out.push_str("]\n");
984 }
985
986 Block::Gallery { items, columns, .. } => {
987 let ncols = columns.unwrap_or(3);
988 let cols_str = (0..ncols).map(|_| "1fr").collect::<Vec<_>>().join(", ");
989 out.push_str(&format!(
990 "#grid(\n columns: ({}),\n gutter: 1em,\n",
991 cols_str
992 ));
993 for item in items {
994 match resolved_image(&item.src) {
998 Some(path) => out.push_str(&format!(
999 " figure(\n image(\"{}\"),\n",
1000 escape_typst(&path)
1001 )),
1002 None => out.push_str(
1003 " figure(\n rect(width: 100%, height: 60pt, stroke: 0.5pt + luma(180), fill: luma(248)),\n",
1004 ),
1005 }
1006 if let Some(ref cap) = item.caption {
1007 out.push_str(&format!(" caption: [{}],\n", escape_typst(cap)));
1008 }
1009 out.push_str(" ),\n");
1010 }
1011 out.push_str(")\n");
1012 }
1013
1014 Block::Footer {
1015 sections,
1016 copyright,
1017 social,
1018 ..
1019 } => {
1020 out.push_str("#line(length: 100%, stroke: 0.5pt + luma(200))\n#v(0.5em)\n");
1021 if !sections.is_empty() {
1022 let n = sections.len();
1023 let cols_str = (0..n).map(|_| "1fr").collect::<Vec<_>>().join(", ");
1024 out.push_str(&format!(
1025 "#grid(\n columns: ({}),\n gutter: 1em,\n",
1026 cols_str
1027 ));
1028 for section in sections {
1029 out.push_str(&format!(
1030 " [\n *{}*\n",
1031 escape_typst(§ion.heading)
1032 ));
1033 for link in §ion.links {
1034 out.push_str(&format!(
1035 " - #link(\"{}\")[{}]\n",
1036 escape_typst(&link.href),
1037 escape_typst(&link.label)
1038 ));
1039 }
1040 out.push_str(" ],\n");
1041 }
1042 out.push_str(")\n");
1043 }
1044 if let Some(cr) = copyright {
1045 out.push_str(&format!(
1046 "#v(0.5em)\n#align(center)[#text(size: 0.85em, fill: luma(120))[{}]]\n",
1047 escape_typst(cr)
1048 ));
1049 }
1050 if !social.is_empty() {
1051 let links: Vec<String> = social
1052 .iter()
1053 .map(|s| {
1054 format!(
1055 "#link(\"{}\")[{}]",
1056 escape_typst(&s.href),
1057 escape_typst(&s.platform)
1058 )
1059 })
1060 .collect();
1061 out.push_str(&format!(
1062 "#align(center)[#text(size: 0.85em)[{}]]\n",
1063 links.join(" #h(0.5em) ")
1064 ));
1065 }
1066 }
1067
1068 Block::Testimonial {
1069 content,
1070 author,
1071 role,
1072 company,
1073 ..
1074 } => {
1075 let mut attr_parts: Vec<String> = Vec::new();
1076 if let Some(a) = author {
1077 attr_parts.push(escape_typst(a));
1078 }
1079 if let Some(r) = role {
1080 attr_parts.push(escape_typst(r));
1081 }
1082 if let Some(c) = company {
1083 attr_parts.push(escape_typst(c));
1084 }
1085 if attr_parts.is_empty() {
1086 out.push_str(&format!(
1087 "#quote[\n{}\n]\n",
1088 md_to_typst(content)
1089 ));
1090 } else {
1091 out.push_str(&format!(
1092 "#quote(attribution: [{}])[\n{}\n]\n",
1093 attr_parts.join(", "),
1094 md_to_typst(content)
1095 ));
1096 }
1097 }
1098
1099 Block::PricingTable {
1100 headers, rows, ..
1101 } => {
1102 render_data_table(headers, rows, out);
1103 }
1104
1105 Block::Faq { items, .. } => {
1106 for item in items {
1107 out.push_str(&format!(
1108 "*Q: {}*\n\n{}\n\n",
1109 escape_typst(&item.question),
1110 md_to_typst(&item.answer)
1111 ));
1112 }
1113 }
1114
1115 Block::Embed {
1116 src, title, ..
1117 } => {
1118 let label = title
1119 .as_deref()
1120 .unwrap_or(src.as_str());
1121 out.push_str(&format!(
1122 "#text(fill: luma(120))[\\[Embedded: #link(\"{}\")[{}]\\]]\n",
1123 escape_typst(src),
1124 escape_typst(label)
1125 ));
1126 }
1127
1128 Block::BeforeAfter {
1129 before_items,
1130 after_items,
1131 transition,
1132 ..
1133 } => {
1134 out.push_str("*Before:*\n");
1135 for item in before_items {
1136 out.push_str(&format!(
1137 "- *{}* — {}\n",
1138 escape_typst(&item.label),
1139 escape_typst(&item.detail)
1140 ));
1141 }
1142 let arrow = transition.as_deref().unwrap_or("↓");
1143 out.push_str(&format!("\n#align(center)[#text(size: 1.5em)[{}]]\n\n", escape_typst(arrow)));
1144 out.push_str("*After:*\n");
1145 for item in after_items {
1146 out.push_str(&format!(
1147 "- *{}* — {}\n",
1148 escape_typst(&item.label),
1149 escape_typst(&item.detail)
1150 ));
1151 }
1152 out.push('\n');
1153 }
1154
1155 Block::Pipeline { steps, .. } => {
1156 let labels: Vec<String> = steps
1157 .iter()
1158 .map(|s| {
1159 let desc = match &s.description {
1160 Some(d) => format!(" #text(fill: luma(120), size: 0.9em)[{}]", escape_typst(d)),
1161 None => String::new(),
1162 };
1163 format!("*{}*{}", escape_typst(&s.label), desc)
1164 })
1165 .collect();
1166 out.push_str(&format!(
1167 "#align(center)[{}]\n",
1168 labels.join(" #h(0.3em) → #h(0.3em) ")
1169 ));
1170 }
1171
1172 Block::ProductCard {
1173 title,
1174 subtitle,
1175 badge,
1176 body,
1177 features,
1178 cta_label,
1179 cta_href,
1180 ..
1181 } => {
1182 out.push_str("#block(stroke: 0.5pt + luma(200), radius: 6pt, inset: 14pt, width: 100%)[\n");
1183 if let Some(b) = badge {
1184 out.push_str(&format!(
1185 " #box(fill: surfdoc-blue, radius: 2pt, inset: (x: 6pt, y: 2pt))[#text(fill: white, size: 0.8em)[{}]]\n #v(0.3em)\n",
1186 escape_typst(b)
1187 ));
1188 }
1189 out.push_str(&format!(
1190 " #text(size: 1.3em, weight: \"bold\")[{}]\n",
1191 escape_typst(title)
1192 ));
1193 if let Some(s) = subtitle {
1194 out.push_str(&format!(
1195 " \\\n #text(fill: luma(100))[{}]\n",
1196 escape_typst(s)
1197 ));
1198 }
1199 if !body.is_empty() {
1200 out.push_str(&format!(" \\\n {}\n", md_to_typst_inline(body)));
1201 }
1202 if !features.is_empty() {
1203 out.push_str(" \\\n");
1204 for f in features {
1205 out.push_str(&format!(" - {}\n", escape_typst(f)));
1206 }
1207 }
1208 if let (Some(label), Some(href)) = (cta_label, cta_href) {
1209 out.push_str(&format!(
1210 " #v(0.5em)\n #link(\"{}\")[#text(fill: surfdoc-blue, weight: \"bold\")[{}]]\n",
1211 escape_typst(href),
1212 escape_typst(label)
1213 ));
1214 }
1215 out.push_str("]\n");
1216 }
1217
1218 Block::Logo { src, alt, size, .. } => {
1219 let width = size.map_or("60pt".to_string(), |s| format!("{s}pt"));
1220 match resolved_image(src) {
1221 Some(path) => out.push_str(&format!(
1222 "#align(center)[#image(\"{}\", width: {})]\n",
1223 escape_typst(&path),
1224 width
1225 )),
1226 None => out.push_str(&format!(
1229 "#align(center)[#text(fill: luma(140))[{}]]\n",
1230 escape_typst(alt.as_deref().filter(|s| !s.is_empty()).unwrap_or("Logo"))
1231 )),
1232 }
1233 }
1234
1235 Block::Toc { depth, .. } => {
1236 out.push_str(&format!("#outline(depth: {})\n", depth));
1237 }
1238
1239 Block::HeroImage { src, alt, .. } => {
1240 match resolved_image(src) {
1241 Some(path) => out.push_str(&format!(
1242 "#image(\"{}\", width: 100%)\n",
1243 escape_typst(&path)
1244 )),
1245 None => render_academic_figure(None, alt.as_deref(), out),
1247 }
1248 }
1249
1250 Block::Site { .. } | Block::Style { .. } | Block::Page { .. } => {}
1252
1253 Block::List { source, .. } => {
1255 out.push_str(&format!(
1256 "#block(stroke: 0.5pt + luma(180), radius: 4pt, inset: 10pt, width: 100%)[\n #text(fill: luma(120))[Data List — source: {}]\n]\n",
1257 escape_typst(source)
1258 ));
1259 }
1260 Block::Board { columns, .. } => {
1261 let cols = columns.join(", ");
1262 out.push_str(&format!(
1263 "#block(stroke: 0.5pt + luma(180), radius: 4pt, inset: 10pt, width: 100%)[\n #text(fill: luma(120))[Board — columns: {}]\n]\n",
1264 escape_typst(&cols)
1265 ));
1266 }
1267 Block::Action { label, .. } => {
1268 out.push_str(&format!(
1269 "#block(stroke: 0.5pt + luma(180), radius: 4pt, inset: 10pt, width: 100%)[\n #text(fill: luma(120))[Action: {}]\n]\n",
1270 escape_typst(label)
1271 ));
1272 }
1273 Block::FilterBar { .. } | Block::Search { .. } | Block::Dashboard { .. }
1274 | Block::ChatInput { .. } | Block::Feed { .. } | Block::Editor { .. }
1275 | Block::Chart { .. } | Block::SplitPane { .. } => {
1276 }
1278
1279 Block::Diagram { title, content, .. } => {
1280 if let Some(t) = title {
1283 out.push_str(&format!("*{}* \\\n", escape_typst(t)));
1284 }
1285 out.push_str(&format!("```\n{}\n```\n", content));
1286 }
1287
1288 Block::Unknown { name, content, .. } => {
1289 out.push_str(&format!(
1290 "#block(stroke: 0.5pt + luma(180), radius: 4pt, inset: 10pt, width: 100%)[\n #text(fill: luma(120), size: 0.9em)[Unknown block: {}]\n \\\n {}\n]\n",
1291 escape_typst(name),
1292 md_to_typst(content)
1293 ));
1294 }
1295
1296 Block::Cite { .. } => {}
1298
1299 Block::Bibliography { style, .. } => {
1300 let style = *style;
1301 crate::citation::with_active(|ctx| {
1302 let Some(ctx) = ctx else { return };
1303 if ctx.references.is_empty() {
1304 return;
1305 }
1306 let style = style.unwrap_or(ctx.style);
1307 let refs = if style == ctx.style {
1308 crate::citation::ordered_references(ctx)
1309 } else {
1310 ctx.references.clone()
1311 };
1312 out.push_str(&format!(
1313 "\n== {}\n\n",
1314 escape_typst(crate::citation::bibliography_heading(style))
1315 ));
1316 for line in crate::citation::reference_list(&refs, style) {
1317 out.push_str(&md_to_typst(&line));
1318 out.push_str(" \\\n");
1319 }
1320 out.push('\n');
1321 });
1322 }
1323
1324 _ => {}
1326 }
1327}
1328
1329fn render_data_table(headers: &[String], rows: &[Vec<String>], out: &mut String) {
1331 let ncols = if !headers.is_empty() {
1332 headers.len()
1333 } else if let Some(first) = rows.first() {
1334 first.len()
1335 } else {
1336 return;
1337 };
1338
1339 out.push_str(&format!("#table(\n columns: {},\n", ncols));
1340
1341 if !headers.is_empty() {
1343 for h in headers {
1344 out.push_str(&format!(" [*{}*],\n", escape_typst(h)));
1345 }
1346 }
1347
1348 for row in rows {
1350 for cell in row {
1351 out.push_str(&format!(" [{}],\n", md_to_typst_inline(cell)));
1352 }
1353 }
1354
1355 out.push_str(")\n");
1356}
1357
1358fn render_comparison_table(
1360 headers: &[String],
1361 rows: &[Vec<String>],
1362 highlight: Option<&str>,
1363 out: &mut String,
1364) {
1365 let ncols = if !headers.is_empty() {
1366 headers.len()
1367 } else if let Some(first) = rows.first() {
1368 first.len()
1369 } else {
1370 return;
1371 };
1372
1373 out.push_str(&format!("#table(\n columns: {},\n", ncols));
1374
1375 if !headers.is_empty() {
1377 for h in headers {
1378 let is_highlight = highlight.is_some_and(|hl| hl == h);
1379 if is_highlight {
1380 out.push_str(&format!(
1381 " [*#text(fill: surfdoc-blue)[{}]*],\n",
1382 escape_typst(h)
1383 ));
1384 } else {
1385 out.push_str(&format!(" [*{}*],\n", escape_typst(h)));
1386 }
1387 }
1388 }
1389
1390 for row in rows {
1392 for cell in row {
1393 let trimmed = cell.trim().to_lowercase();
1394 match trimmed.as_str() {
1395 "yes" | "true" | "✓" | "✔" | "check" => {
1396 out.push_str(" [#text(fill: surfdoc-green)[●]],\n");
1397 }
1398 "no" | "false" | "✗" | "✘" | "cross" | "-" | "—" => {
1399 out.push_str(" [#text(fill: luma(200))[●]],\n");
1400 }
1401 _ => {
1402 out.push_str(&format!(" [{}],\n", md_to_typst_inline(cell)));
1403 }
1404 }
1405 }
1406 }
1407
1408 out.push_str(")\n");
1409}
1410
1411pub fn md_to_typst(md: &str) -> String {
1417 let mut out = String::with_capacity(md.len());
1418 let mut in_code_block = false;
1419 let mut code_lang = String::new();
1420 let mut code_content = String::new();
1421 let mut table_buf: Vec<Vec<String>> = Vec::new();
1423 let mut table_has_header = false;
1424
1425 for line in md.lines() {
1426 let table_trimmed = line.trim();
1427 let is_table_row = !in_code_block
1428 && table_trimmed.len() > 1
1429 && table_trimmed.starts_with('|')
1430 && table_trimmed.ends_with('|');
1431 if !is_table_row {
1432 flush_md_table(&mut out, &mut table_buf, &mut table_has_header);
1433 }
1434
1435 if line.starts_with("```") {
1437 if in_code_block {
1438 out.push_str(&format!(
1440 "```{}\n{}\n```\n",
1441 code_lang,
1442 code_content.trim_end()
1443 ));
1444 in_code_block = false;
1445 code_lang.clear();
1446 code_content.clear();
1447 continue;
1448 } else {
1449 in_code_block = true;
1451 code_lang = line[3..].trim().to_string();
1452 continue;
1453 }
1454 }
1455
1456 if in_code_block {
1457 code_content.push_str(line);
1458 code_content.push('\n');
1459 continue;
1460 }
1461
1462 if let Some(rest) = line.strip_prefix("# ") {
1464 out.push_str(&format!("= {}\n", md_to_typst_inline(rest)));
1465 continue;
1466 }
1467 if let Some(rest) = line.strip_prefix("## ") {
1468 out.push_str(&format!("== {}\n", md_to_typst_inline(rest)));
1469 continue;
1470 }
1471 if let Some(rest) = line.strip_prefix("### ") {
1472 out.push_str(&format!("=== {}\n", md_to_typst_inline(rest)));
1473 continue;
1474 }
1475 if let Some(rest) = line.strip_prefix("#### ") {
1476 out.push_str(&format!("==== {}\n", md_to_typst_inline(rest)));
1477 continue;
1478 }
1479 if let Some(rest) = line.strip_prefix("##### ") {
1480 out.push_str(&format!("===== {}\n", md_to_typst_inline(rest)));
1481 continue;
1482 }
1483 if let Some(rest) = line.strip_prefix("###### ") {
1484 out.push_str(&format!("====== {}\n", md_to_typst_inline(rest)));
1485 continue;
1486 }
1487
1488 let trimmed = line.trim();
1490 if trimmed == "---" || trimmed == "***" || trimmed == "___" {
1491 out.push_str("#line(length: 100%, stroke: 0.5pt + luma(200))\n");
1492 continue;
1493 }
1494
1495 if let Some(rest) = line.strip_prefix("- ") {
1497 out.push_str(&format!("- {}\n", md_to_typst_inline(rest)));
1498 continue;
1499 }
1500 if let Some(rest) = line.strip_prefix("* ") {
1501 out.push_str(&format!("- {}\n", md_to_typst_inline(rest)));
1502 continue;
1503 }
1504 if let Some(rest) = line.strip_prefix(" - ") {
1506 out.push_str(&format!(" - {}\n", md_to_typst_inline(rest)));
1507 continue;
1508 }
1509 if let Some(rest) = line.strip_prefix(" - ") {
1510 out.push_str(&format!(" - {}\n", md_to_typst_inline(rest)));
1511 continue;
1512 }
1513
1514 if let Some((num_str, rest)) = split_ordered_list(line) {
1516 out.push_str(&format!("{}. {}\n", num_str, md_to_typst_inline(rest)));
1517 continue;
1518 }
1519
1520 if let Some(rest) = line.strip_prefix("> ") {
1522 out.push_str(&format!("#quote[{}]\n", md_to_typst_inline(rest)));
1523 continue;
1524 }
1525 if trimmed == ">" {
1526 continue;
1528 }
1529
1530 if is_table_row {
1533 if trimmed.chars().all(|c| c == '|' || c == '-' || c == ':' || c == ' ') {
1535 if table_buf.len() == 1 {
1536 table_has_header = true;
1537 }
1538 continue;
1539 }
1540 let cells: Vec<String> = trimmed
1541 .trim_matches('|')
1542 .split('|')
1543 .map(|c| c.trim().to_string())
1544 .collect();
1545 table_buf.push(cells);
1546 continue;
1547 }
1548
1549 if trimmed.is_empty() {
1551 out.push('\n');
1552 continue;
1553 }
1554
1555 out.push_str(&md_to_typst_inline(line));
1557 out.push('\n');
1558 }
1559
1560 flush_md_table(&mut out, &mut table_buf, &mut table_has_header);
1561
1562 if in_code_block {
1564 out.push_str(&format!(
1565 "```{}\n{}\n```\n",
1566 code_lang,
1567 code_content.trim_end()
1568 ));
1569 }
1570
1571 out
1572}
1573
1574fn flush_md_table(out: &mut String, buf: &mut Vec<Vec<String>>, has_header: &mut bool) {
1577 if buf.is_empty() {
1578 *has_header = false;
1579 return;
1580 }
1581 let ncols = buf.iter().map(|r| r.len()).max().unwrap_or(0);
1582 if ncols == 0 {
1583 buf.clear();
1584 *has_header = false;
1585 return;
1586 }
1587 out.push_str(&format!("#table(\n columns: {},\n", ncols));
1588 for (i, row) in buf.iter().enumerate() {
1589 for c in 0..ncols {
1590 let cell = row.get(c).map(String::as_str).unwrap_or("");
1591 let converted = md_to_typst_inline(cell);
1592 if i == 0 && *has_header {
1593 out.push_str(&format!(" [*{}*],\n", converted));
1594 } else {
1595 out.push_str(&format!(" [{}],\n", converted));
1596 }
1597 }
1598 }
1599 out.push_str(")\n");
1600 buf.clear();
1601 *has_header = false;
1602}
1603
1604pub fn md_to_typst_inline(text: &str) -> String {
1610 let mut out = String::with_capacity(text.len());
1611 let chars: Vec<char> = text.chars().collect();
1612 let len = chars.len();
1613 let mut i = 0;
1614
1615 while i < len {
1616 if chars[i] == '\\' && i + 1 < len {
1618 out.push(chars[i + 1]);
1619 i += 2;
1620 continue;
1621 }
1622
1623 if chars[i] == '!' && i + 1 < len && chars[i + 1] == '[' {
1627 if let Some((alt, src, end)) = parse_link(&chars, i + 1) {
1628 match resolved_image(&src) {
1629 Some(path) => {
1630 out.push_str(&format!("#image(\"{}\")", escape_typst(&path)));
1631 }
1632 None => {
1633 let label = if alt.trim().is_empty() { "image" } else { alt.trim() };
1634 out.push_str(&format!(
1635 "#text(fill: luma(140), style: \"italic\")[[{}]]",
1636 escape_typst(label)
1637 ));
1638 }
1639 }
1640 i = end;
1641 continue;
1642 }
1643 }
1644
1645 if chars[i] == '[' {
1647 if let Some((text_content, href, end)) = parse_link(&chars, i) {
1648 out.push_str(&format!(
1649 "#link(\"{}\")[{}]",
1650 escape_typst(&href),
1651 escape_typst(&text_content)
1652 ));
1653 i = end;
1654 continue;
1655 }
1656 }
1657
1658 if chars[i] == '`' {
1660 if let Some((code, end)) = parse_backtick_code(&chars, i) {
1661 out.push('`');
1662 out.push_str(&code);
1663 out.push('`');
1664 i = end;
1665 continue;
1666 }
1667 }
1668
1669 if i + 1 < len && chars[i] == '*' && chars[i + 1] == '*' {
1671 if let Some((content, end)) = parse_delimited(&chars, i, "**") {
1672 out.push('*');
1673 out.push_str(&md_to_typst_inline(&content));
1674 out.push('*');
1675 i = end;
1676 continue;
1677 }
1678 }
1679 if i + 1 < len && chars[i] == '_' && chars[i + 1] == '_' {
1680 if let Some((content, end)) = parse_delimited(&chars, i, "__") {
1681 out.push('*');
1682 out.push_str(&md_to_typst_inline(&content));
1683 out.push('*');
1684 i = end;
1685 continue;
1686 }
1687 }
1688
1689 if chars[i] == '*' && (i + 1 < len && chars[i + 1] != '*') {
1691 let next_not_space = i + 1 < len && !chars[i + 1].is_whitespace();
1693 if next_not_space {
1694 if let Some((content, end)) = parse_delimited(&chars, i, "*") {
1695 out.push('_');
1696 out.push_str(&md_to_typst_inline(&content));
1697 out.push('_');
1698 i = end;
1699 continue;
1700 }
1701 }
1702 }
1703 if chars[i] == '_' && (i + 1 < len && chars[i + 1] != '_') {
1704 let prev_space = i == 0 || !chars[i - 1].is_alphanumeric();
1706 if prev_space {
1707 if let Some((content, end)) = parse_delimited(&chars, i, "_") {
1708 out.push('_');
1709 out.push_str(&md_to_typst_inline(&content));
1710 out.push('_');
1711 i = end;
1712 continue;
1713 }
1714 }
1715 }
1716
1717 if i + 1 < len && chars[i] == '~' && chars[i + 1] == '~' {
1719 if let Some((content, end)) = parse_delimited(&chars, i, "~~") {
1720 out.push_str(&format!("#strike[{}]", md_to_typst_inline(&content)));
1721 i = end;
1722 continue;
1723 }
1724 }
1725
1726 match chars[i] {
1728 '#' => out.push_str("\\#"),
1729 '@' => out.push_str("\\@"),
1730 '<' => out.push_str("\\<"),
1731 '>' => out.push_str("\\>"),
1732 '$' => out.push_str("\\$"),
1733 '*' => out.push_str("\\*"),
1734 '_' => out.push_str("\\_"),
1735 c => out.push(c),
1736 }
1737
1738 i += 1;
1739 }
1740
1741 out
1742}
1743
1744pub(crate) fn parse_link(chars: &[char], start: usize) -> Option<(String, String, usize)> {
1747 if start >= chars.len() || chars[start] != '[' {
1748 return None;
1749 }
1750
1751 let mut depth = 0;
1753 let mut i = start;
1754 let mut text_end = None;
1755 while i < chars.len() {
1756 match chars[i] {
1757 '[' => depth += 1,
1758 ']' => {
1759 depth -= 1;
1760 if depth == 0 {
1761 text_end = Some(i);
1762 break;
1763 }
1764 }
1765 _ => {}
1766 }
1767 i += 1;
1768 }
1769
1770 let text_end = text_end?;
1771 let text: String = chars[start + 1..text_end].iter().collect();
1772
1773 if text_end + 1 >= chars.len() || chars[text_end + 1] != '(' {
1775 return None;
1776 }
1777
1778 let href_start = text_end + 2;
1780 let mut paren_depth = 1;
1781 let mut j = href_start;
1782 while j < chars.len() {
1783 match chars[j] {
1784 '(' => paren_depth += 1,
1785 ')' => {
1786 paren_depth -= 1;
1787 if paren_depth == 0 {
1788 let href: String = chars[href_start..j].iter().collect();
1789 return Some((text, href, j + 1));
1790 }
1791 }
1792 _ => {}
1793 }
1794 j += 1;
1795 }
1796
1797 None
1798}
1799
1800pub(crate) fn parse_backtick_code(chars: &[char], start: usize) -> Option<(String, usize)> {
1802 if start >= chars.len() || chars[start] != '`' {
1803 return None;
1804 }
1805
1806 let mut i = start + 1;
1807 while i < chars.len() {
1808 if chars[i] == '`' {
1809 let code: String = chars[start + 1..i].iter().collect();
1810 return Some((code, i + 1));
1811 }
1812 i += 1;
1813 }
1814
1815 None
1816}
1817
1818pub(crate) fn parse_delimited(chars: &[char], start: usize, delim: &str) -> Option<(String, usize)> {
1820 let delim_chars: Vec<char> = delim.chars().collect();
1821 let dlen = delim_chars.len();
1822
1823 if start + dlen > chars.len() {
1824 return None;
1825 }
1826
1827 for (k, dc) in delim_chars.iter().enumerate() {
1829 if chars[start + k] != *dc {
1830 return None;
1831 }
1832 }
1833
1834 let content_start = start + dlen;
1835 let mut i = content_start;
1836
1837 while i + dlen <= chars.len() {
1838 let mut found = true;
1840 for (k, dc) in delim_chars.iter().enumerate() {
1841 if chars[i + k] != *dc {
1842 found = false;
1843 break;
1844 }
1845 }
1846 if found && i > content_start {
1847 let content: String = chars[content_start..i].iter().collect();
1848 return Some((content, i + dlen));
1849 }
1850 i += 1;
1851 }
1852
1853 None
1854}
1855
1856pub(crate) fn split_ordered_list(line: &str) -> Option<(&str, &str)> {
1858 let bytes = line.as_bytes();
1859 let mut i = 0;
1860 while i < bytes.len() && bytes[i] == b' ' {
1862 i += 1;
1863 }
1864 let num_start = i;
1865 while i < bytes.len() && bytes[i].is_ascii_digit() {
1867 i += 1;
1868 }
1869 if i == num_start || i >= bytes.len() {
1870 return None;
1871 }
1872 if bytes[i] == b'.' && i + 1 < bytes.len() && bytes[i + 1] == b' ' {
1874 let num = &line[num_start..i];
1875 let rest = &line[i + 2..];
1876 Some((num, rest))
1877 } else {
1878 None
1879 }
1880}
1881
1882fn escape_typst(s: &str) -> String {
1886 let mut out = String::with_capacity(s.len());
1887 for c in s.chars() {
1888 match c {
1889 '#' => out.push_str("\\#"),
1890 '@' => out.push_str("\\@"),
1891 '<' => out.push_str("\\<"),
1892 '>' => out.push_str("\\>"),
1893 '$' => out.push_str("\\$"),
1894 '*' => out.push_str("\\*"),
1895 '_' => out.push_str("\\_"),
1896 '\\' => out.push_str("\\\\"),
1897 '"' if false => out.push_str("\\\""), _ => out.push(c),
1899 }
1900 }
1901 out
1902}
1903
1904fn callout_type_str(ct: CalloutType) -> &'static str {
1905 match ct {
1906 CalloutType::Info => "info",
1907 CalloutType::Warning => "warning",
1908 CalloutType::Danger => "danger",
1909 CalloutType::Tip => "tip",
1910 CalloutType::Note => "note",
1911 CalloutType::Success => "success",
1912 CalloutType::Context => "context",
1913 }
1914}
1915
1916fn decision_status_str(ds: DecisionStatus) -> &'static str {
1917 match ds {
1918 DecisionStatus::Proposed => "proposed",
1919 DecisionStatus::Accepted => "accepted",
1920 DecisionStatus::Rejected => "rejected",
1921 DecisionStatus::Superseded => "superseded",
1922 }
1923}
1924
1925fn trend_str(t: Trend) -> &'static str {
1926 match t {
1927 Trend::Up => "up",
1928 Trend::Down => "down",
1929 Trend::Flat => "flat",
1930 }
1931}
1932
1933#[cfg(test)]
1934mod tests {
1935 use super::*;
1936
1937 #[test]
1938 fn escape_typst_special_chars() {
1939 assert_eq!(escape_typst("hello #world"), "hello \\#world");
1940 assert_eq!(escape_typst("a@b"), "a\\@b");
1941 assert_eq!(escape_typst("x < y > z"), "x \\< y \\> z");
1942 assert_eq!(escape_typst("cost: $10"), "cost: \\$10");
1943 assert_eq!(escape_typst("A* search"), "A\\* search");
1944 assert_eq!(escape_typst("snake_case"), "snake\\_case");
1945 }
1946
1947 #[test]
1948 fn md_to_typst_inline_bold() {
1949 assert_eq!(md_to_typst_inline("**hello**"), "*hello*");
1950 assert_eq!(md_to_typst_inline("a **bold** word"), "a *bold* word");
1951 }
1952
1953 #[test]
1954 fn md_to_typst_inline_italic() {
1955 assert_eq!(md_to_typst_inline("*hello*"), "_hello_");
1956 assert_eq!(md_to_typst_inline("an *italic* word"), "an _italic_ word");
1957 }
1958
1959 #[test]
1960 fn md_to_typst_inline_code() {
1961 assert_eq!(md_to_typst_inline("`code`"), "`code`");
1962 }
1963
1964 #[test]
1965 fn md_to_typst_inline_link() {
1966 assert_eq!(
1967 md_to_typst_inline("[click](https://example.com)"),
1968 "#link(\"https://example.com\")[click]"
1969 );
1970 }
1971
1972 #[test]
1973 fn md_to_typst_inline_strikethrough() {
1974 assert_eq!(md_to_typst_inline("~~deleted~~"), "#strike[deleted]");
1975 }
1976
1977 #[test]
1978 fn md_to_typst_inline_literal_asterisk() {
1979 let result = md_to_typst_inline("in A* search");
1981 assert!(result.contains("A\\*"), "A* should produce escaped asterisk, got: {}", result);
1982 assert!(!result.contains("A_"), "A* should not become A_ italic, got: {}", result);
1983 }
1984
1985 #[test]
1986 fn md_to_typst_inline_stray_star_escaped() {
1987 let result = md_to_typst_inline("cost is 5*");
1989 assert!(result.contains("5\\*"), "trailing * should be escaped, got: {}", result);
1990 }
1991
1992 #[test]
1993 fn md_to_typst_headings() {
1994 assert!(md_to_typst("# Title\n").contains("= Title"));
1995 assert!(md_to_typst("## Subtitle\n").contains("== Subtitle"));
1996 assert!(md_to_typst("### H3\n").contains("=== H3"));
1997 }
1998
1999 #[test]
2000 fn md_to_typst_lists() {
2001 let md = "- item 1\n- item 2\n";
2002 let result = md_to_typst(md);
2003 assert!(result.contains("- item 1"));
2004 assert!(result.contains("- item 2"));
2005 }
2006
2007 #[test]
2008 fn md_to_typst_ordered_lists() {
2009 let md = "1. first\n2. second\n";
2010 let result = md_to_typst(md);
2011 assert!(result.contains("1. first"));
2012 assert!(result.contains("2. second"));
2013 }
2014
2015 #[test]
2016 fn md_to_typst_code_block() {
2017 let md = "```rust\nfn main() {}\n```\n";
2018 let result = md_to_typst(md);
2019 assert!(result.contains("```rust"));
2020 assert!(result.contains("fn main() {}"));
2021 }
2022
2023 #[test]
2024 fn md_to_typst_horizontal_rule() {
2025 assert!(md_to_typst("---\n").contains("#line("));
2026 }
2027
2028 #[test]
2029 fn md_to_typst_gfm_table_becomes_typst_table() {
2030 let md = "| Name | Qty |\n| --- | --- |\n| Bolt | 4 |\n| Nut | 8 |\n";
2031 let result = md_to_typst(md);
2032 assert!(result.contains("#table(\n columns: 2,"), "got: {result}");
2033 assert!(result.contains("[*Name*],"), "header row is bold: {result}");
2034 assert!(result.contains("[Bolt],"));
2035 assert!(result.contains("[8],"));
2036 assert!(!result.contains(" | "), "no pipe pass-through: {result}");
2037 }
2038
2039 #[test]
2040 fn md_to_typst_table_without_separator_has_no_header() {
2041 let md = "| a | b |\n| c | d |\n";
2042 let result = md_to_typst(md);
2043 assert!(result.contains("#table(\n columns: 2,"));
2044 assert!(!result.contains("[*a*],"), "no bold header: {result}");
2045 }
2046
2047 #[test]
2048 fn md_to_typst_table_flushes_before_following_paragraph() {
2049 let md = "| a | b |\n| --- | --- |\n| c | d |\nAfter text\n";
2050 let result = md_to_typst(md);
2051 let table_pos = result.find("#table(").expect("table emitted");
2052 let text_pos = result.find("After text").expect("paragraph kept");
2053 assert!(table_pos < text_pos, "table renders before the paragraph");
2054 }
2055
2056 #[test]
2057 fn render_markdown_block() {
2058 let doc = SurfDoc {
2059 front_matter: None,
2060 blocks: vec![Block::Markdown {
2061 content: "Hello **world**".to_string(),
2062 span: Span::SYNTHETIC,
2063 }],
2064 source: String::new(),
2065 };
2066 let result = to_typst(&doc);
2067 assert!(result.contains("Hello *world*"));
2068 }
2069
2070 #[test]
2071 fn render_callout_block() {
2072 let doc = SurfDoc {
2073 front_matter: None,
2074 blocks: vec![Block::Callout {
2075 callout_type: CalloutType::Warning,
2076 title: Some("Heads up".to_string()),
2077 content: "Be careful".to_string(),
2078 span: Span::SYNTHETIC,
2079 }],
2080 source: String::new(),
2081 };
2082 let result = to_typst(&doc);
2083 assert!(result.contains("surfdoc-callout"));
2084 assert!(result.contains("warning"));
2085 assert!(result.contains("Heads up"));
2086 assert!(result.contains("Be careful"));
2087 }
2088
2089 #[test]
2090 fn render_data_block() {
2091 let doc = SurfDoc {
2092 front_matter: None,
2093 blocks: vec![Block::Data {
2094 id: None,
2095 format: DataFormat::Table,
2096 sortable: false,
2097 headers: vec!["Name".into(), "Value".into()],
2098 rows: vec![vec!["A".into(), "1".into()], vec!["B".into(), "2".into()]],
2099 raw_content: String::new(),
2100 span: Span::SYNTHETIC,
2101 }],
2102 source: String::new(),
2103 };
2104 let result = to_typst(&doc);
2105 assert!(result.contains("#table("));
2106 assert!(result.contains("[*Name*]"));
2107 assert!(result.contains("[*Value*]"));
2108 assert!(result.contains("[A]"));
2109 }
2110
2111 #[test]
2112 fn render_code_block() {
2113 let doc = SurfDoc {
2114 front_matter: None,
2115 blocks: vec![Block::Code {
2116 lang: Some("rust".to_string()),
2117 file: None,
2118 highlight: vec![],
2119 content: "fn main() {}".to_string(),
2120 span: Span::SYNTHETIC,
2121 }],
2122 source: String::new(),
2123 };
2124 let result = to_typst(&doc);
2125 assert!(result.contains("```rust"));
2126 assert!(result.contains("fn main() {}"));
2127 }
2128
2129 #[test]
2130 fn render_tasks_block() {
2131 let doc = SurfDoc {
2132 front_matter: None,
2133 blocks: vec![Block::Tasks {
2134 items: vec![
2135 TaskItem { done: true, text: "Done task".to_string(), assignee: None },
2136 TaskItem { done: false, text: "Open task".to_string(), assignee: Some("brady".to_string()) },
2137 ],
2138 span: Span::SYNTHETIC,
2139 }],
2140 source: String::new(),
2141 };
2142 let result = to_typst(&doc);
2143 assert!(result.contains("☑"));
2144 assert!(result.contains("☐"));
2145 assert!(result.contains("Done task"));
2146 assert!(result.contains("\\@brady"));
2147 }
2148
2149 #[test]
2150 fn render_decision_block() {
2151 let doc = SurfDoc {
2152 front_matter: None,
2153 blocks: vec![Block::Decision {
2154 status: DecisionStatus::Accepted,
2155 date: Some("2026-02-22".to_string()),
2156 deciders: vec!["Brady".to_string()],
2157 content: "We chose Typst".to_string(),
2158 span: Span::SYNTHETIC,
2159 }],
2160 source: String::new(),
2161 };
2162 let result = to_typst(&doc);
2163 assert!(result.contains("surfdoc-decision-badge"));
2164 assert!(result.contains("accepted"));
2165 assert!(result.contains("2026-02-22"));
2166 }
2167
2168 #[test]
2169 fn render_metric_block() {
2170 let doc = SurfDoc {
2171 front_matter: None,
2172 blocks: vec![Block::Metric {
2173 label: "Revenue".to_string(),
2174 value: "$12K".to_string(),
2175 trend: Some(Trend::Up),
2176 unit: Some("MRR".to_string()),
2177 span: Span::SYNTHETIC,
2178 }],
2179 source: String::new(),
2180 };
2181 let result = to_typst(&doc);
2182 assert!(result.contains("surfdoc-metric"));
2183 assert!(result.contains("Revenue"));
2184 assert!(result.contains("\\$12K"));
2185 }
2186
2187 #[test]
2188 fn render_summary_block() {
2189 let doc = SurfDoc {
2190 front_matter: None,
2191 blocks: vec![Block::Summary {
2192 content: "Key points here".to_string(),
2193 span: Span::SYNTHETIC,
2194 }],
2195 source: String::new(),
2196 };
2197 let result = to_typst(&doc);
2198 assert!(result.contains("*Summary*"));
2199 assert!(result.contains("Key points here"));
2200 }
2201
2202 #[test]
2203 fn render_quote_block() {
2204 let doc = SurfDoc {
2205 front_matter: None,
2206 blocks: vec![Block::Quote {
2207 content: "The best code is no code.".to_string(),
2208 attribution: Some("Someone".to_string()),
2209 cite: None,
2210 span: Span::SYNTHETIC,
2211 }],
2212 source: String::new(),
2213 };
2214 let result = to_typst(&doc);
2215 assert!(result.contains("#quote(attribution: [Someone])"));
2216 assert!(result.contains("The best code is no code."));
2217 }
2218
2219 #[test]
2220 fn render_divider_block() {
2221 let doc = SurfDoc {
2222 front_matter: None,
2223 blocks: vec![Block::Divider {
2224 label: None,
2225 span: Span::SYNTHETIC,
2226 }],
2227 source: String::new(),
2228 };
2229 let result = to_typst(&doc);
2230 assert!(result.contains("#line(length: 100%"));
2231 }
2232
2233 #[test]
2234 fn render_front_matter() {
2235 let doc = SurfDoc {
2236 front_matter: Some(FrontMatter {
2237 title: Some("My Document".to_string()),
2238 author: Some("Brady".to_string()),
2239 created: Some("2026-02-22".to_string()),
2240 tags: Some(vec!["strategy".to_string(), "pdf".to_string()]),
2241 ..FrontMatter::default()
2242 }),
2243 blocks: vec![],
2244 source: String::new(),
2245 };
2246 let result = to_typst(&doc);
2247 assert!(result.contains("My Document"));
2248 assert!(result.contains("Brady"));
2249 assert!(result.contains("2026-02-22"));
2250 assert!(result.contains("strategy"));
2251 }
2252
2253 #[test]
2254 fn render_hero_block() {
2255 let doc = SurfDoc {
2256 front_matter: None,
2257 blocks: vec![Block::Hero {
2258 headline: Some("Welcome".to_string()),
2259 subtitle: Some("To the future".to_string()),
2260 badge: None,
2261 align: "center".to_string(),
2262 image: None,
2263 image_alt: None,
2264 layout: None,
2265 transparent: false,
2266 buttons: vec![HeroButton {
2267 label: "Get Started".to_string(),
2268 href: "/start".to_string(),
2269 primary: true,
2270 external: false,
2271 }],
2272 content: String::new(),
2273 span: Span::SYNTHETIC,
2274 }],
2275 source: String::new(),
2276 };
2277 let result = to_typst(&doc);
2278 assert!(result.contains("Welcome"));
2279 assert!(result.contains("To the future"));
2280 assert!(result.contains("Get Started"));
2281 }
2282
2283 #[test]
2284 fn render_steps_block() {
2285 let doc = SurfDoc {
2286 front_matter: None,
2287 blocks: vec![Block::Steps {
2288 steps: vec![
2289 StepItem { title: "Step 1".into(), time: None, body: "Do this".into() },
2290 StepItem { title: "Step 2".into(), time: Some("5 min".into()), body: "Then this".into() },
2291 ],
2292 span: Span::SYNTHETIC,
2293 }],
2294 source: String::new(),
2295 };
2296 let result = to_typst(&doc);
2297 assert!(result.contains("*Step 1*"));
2298 assert!(result.contains("*Step 2*"));
2299 assert!(result.contains("5 min"));
2300 }
2301
2302 #[test]
2303 fn render_comparison_block() {
2304 let doc = SurfDoc {
2305 front_matter: None,
2306 blocks: vec![Block::Comparison {
2307 headers: vec!["Feature".into(), "Us".into(), "Them".into()],
2308 rows: vec![vec!["Speed".into(), "yes".into(), "no".into()]],
2309 highlight: Some("Us".into()),
2310 span: Span::SYNTHETIC,
2311 }],
2312 source: String::new(),
2313 };
2314 let result = to_typst(&doc);
2315 assert!(result.contains("#table("));
2316 assert!(result.contains("surfdoc-blue"));
2317 assert!(result.contains("surfdoc-green")); }
2319
2320 #[test]
2321 fn render_pipeline_block() {
2322 let doc = SurfDoc {
2323 front_matter: None,
2324 blocks: vec![Block::Pipeline {
2325 steps: vec![
2326 PipelineStep { label: "Parse".into(), description: None },
2327 PipelineStep { label: "Compile".into(), description: Some("via Typst".into()) },
2328 PipelineStep { label: "Output".into(), description: None },
2329 ],
2330 span: Span::SYNTHETIC,
2331 }],
2332 source: String::new(),
2333 };
2334 let result = to_typst(&doc);
2335 assert!(result.contains("*Parse*"));
2336 assert!(result.contains("→"));
2337 assert!(result.contains("*Compile*"));
2338 assert!(result.contains("via Typst"));
2339 assert!(result.contains("*Output*"));
2340 }
2341
2342 #[test]
2343 fn render_unknown_block() {
2344 let doc = SurfDoc {
2345 front_matter: None,
2346 blocks: vec![Block::Unknown {
2347 name: "widget".to_string(),
2348 attrs: Default::default(),
2349 content: "some content".to_string(),
2350 span: Span::SYNTHETIC,
2351 }],
2352 source: String::new(),
2353 };
2354 let result = to_typst(&doc);
2355 assert!(result.contains("Unknown block: widget"));
2356 assert!(result.contains("some content"));
2357 }
2358
2359 #[test]
2360 fn render_before_after_block() {
2361 let doc = SurfDoc {
2362 front_matter: None,
2363 blocks: vec![Block::BeforeAfter {
2364 before_items: vec![BeforeAfterItem { label: "Old".into(), detail: "Slow".into() }],
2365 after_items: vec![BeforeAfterItem { label: "New".into(), detail: "Fast".into() }],
2366 transition: None,
2367 span: Span::SYNTHETIC,
2368 }],
2369 source: String::new(),
2370 };
2371 let result = to_typst(&doc);
2372 assert!(result.contains("*Before:*"));
2373 assert!(result.contains("*After:*"));
2374 assert!(result.contains("*Old*"));
2375 assert!(result.contains("*New*"));
2376 }
2377
2378 #[test]
2379 fn render_product_card_block() {
2380 let doc = SurfDoc {
2381 front_matter: None,
2382 blocks: vec![Block::ProductCard {
2383 title: "WaveSite".to_string(),
2384 subtitle: Some("Build your site".into()),
2385 badge: Some("NEW".into()),
2386 badge_color: None,
2387 body: "Description here".into(),
2388 features: vec!["Fast".into(), "Free".into()],
2389 cta_label: Some("Try it".into()),
2390 cta_href: Some("https://wave.site".into()),
2391 span: Span::SYNTHETIC,
2392 }],
2393 source: String::new(),
2394 };
2395 let result = to_typst(&doc);
2396 assert!(result.contains("WaveSite"));
2397 assert!(result.contains("NEW"));
2398 assert!(result.contains("Build your site"));
2399 assert!(result.contains("Fast"));
2400 assert!(result.contains("Try it"));
2401 }
2402
2403 #[test]
2404 fn render_faq_block() {
2405 let doc = SurfDoc {
2406 front_matter: None,
2407 blocks: vec![Block::Faq {
2408 items: vec![FaqItem {
2409 question: "What is SurfDoc?".into(),
2410 answer: "A typed document format.".into(),
2411 }],
2412 span: Span::SYNTHETIC,
2413 }],
2414 source: String::new(),
2415 };
2416 let result = to_typst(&doc);
2417 assert!(result.contains("*Q: What is SurfDoc?*"));
2418 assert!(result.contains("A typed document format."));
2419 }
2420
2421 #[test]
2422 fn render_stats_block() {
2423 let doc = SurfDoc {
2424 front_matter: None,
2425 blocks: vec![Block::Stats {
2426 items: vec![
2427 StatItem { value: "373".into(), label: "Tests".into(), color: None },
2428 StatItem { value: "37".into(), label: "Block Types".into(), color: None },
2429 ],
2430 span: Span::SYNTHETIC,
2431 }],
2432 source: String::new(),
2433 };
2434 let result = to_typst(&doc);
2435 assert!(result.contains("#grid("));
2436 assert!(result.contains("373"));
2437 assert!(result.contains("Tests"));
2438 }
2439
2440 #[test]
2441 fn render_cta_block() {
2442 let doc = SurfDoc {
2443 front_matter: None,
2444 blocks: vec![Block::Cta {
2445 label: "Sign Up".into(),
2446 href: "/signup".into(),
2447 primary: true,
2448 icon: None,
2449 span: Span::SYNTHETIC,
2450 }],
2451 source: String::new(),
2452 };
2453 let result = to_typst(&doc);
2454 assert!(result.contains("Sign Up"));
2455 assert!(result.contains("/signup"));
2456 assert!(result.contains("surfdoc-blue"));
2457 }
2458
2459 #[test]
2460 fn render_nav_block() {
2461 let doc = SurfDoc {
2462 front_matter: None,
2463 blocks: vec![Block::Nav {
2464 items: vec![
2465 NavItem { label: "Home".into(), href: "/".into(), icon: None, image: None, external: false },
2466 NavItem { label: "About".into(), href: "/about".into(), icon: None, image: None, external: false },
2467 ],
2468 logo: None,
2469 groups: vec![], brand: None, brand_reg: false, cta: None, drawer: false, minimal: false,
2470 span: Span::SYNTHETIC,
2471 }],
2472 source: String::new(),
2473 };
2474 let result = to_typst(&doc);
2475 assert!(result.contains("Home"));
2476 assert!(result.contains("About"));
2477 }
2478
2479 #[test]
2480 fn site_and_style_blocks_skipped() {
2481 let doc = SurfDoc {
2482 front_matter: None,
2483 blocks: vec![
2484 Block::Site {
2485 domain: Some("example.com".into()),
2486 properties: vec![],
2487 span: Span::SYNTHETIC,
2488 },
2489 Block::Style {
2490 properties: vec![StyleProperty { key: "accent".into(), value: "#f00".into() }],
2491 span: Span::SYNTHETIC,
2492 },
2493 Block::Markdown {
2494 content: "visible".to_string(),
2495 span: Span::SYNTHETIC,
2496 },
2497 ],
2498 source: String::new(),
2499 };
2500 let result = to_typst(&doc);
2501 assert!(!result.contains("example.com"));
2502 assert!(result.contains("visible"));
2503 }
2504
2505 fn parse_doc(src: &str) -> SurfDoc {
2508 crate::parse(src).doc
2509 }
2510
2511 #[test]
2512 fn ieee_paper_is_two_column() {
2513 let src = "---\ntitle: A Study of Things\ntype: paper\nformat: ieee\nauthor: Jane Doe; John Roe\naffiliation: Acme Labs\nabstract: We study things.\n---\n\n# Introduction\n\nText here.\n";
2514 let ts = to_typst(&parse_doc(src));
2515 assert!(ts.contains("columns: 2"), "IEEE paper must be two-column: {ts}");
2516 assert!(ts.contains("A Study of Things"));
2517 assert!(ts.contains("Jane Doe"));
2518 assert!(ts.contains("Acme Labs"));
2519 assert!(ts.contains("Abstract."));
2520 assert!(ts.contains("#set heading(numbering: \"1.\")"));
2522 }
2523
2524 #[test]
2525 fn article_paper_is_single_column() {
2526 let src = "---\ntitle: T\ntype: paper\nformat: article\nauthor: A\n---\n\n# H\n\nBody.\n";
2527 let ts = to_typst(&parse_doc(src));
2528 assert!(!ts.contains("columns: 2"));
2529 assert!(ts.contains("#set heading(numbering: \"1.\")"));
2530 }
2531
2532 #[test]
2533 fn mla_report_has_works_cited() {
2534 let src = "---\ntitle: My Essay\ntype: report\nformat: mla\nauthor: Sam Student\ninstructor: Dr. Smith\ncourse: ENG 101\ndate: 2026-06-27\n---\n\n# Body\n\nClaim [@a].\n\n::cite[key=a type=book]\nauthor = Author, One\ntitle = A Book\npublisher = Press\nyear = 2020\n::\n\n::bibliography\n::\n";
2535 let ts = to_typst(&parse_doc(src));
2536 assert!(ts.contains("Works Cited"), "MLA report must have Works Cited: {ts}");
2537 assert!(ts.contains("Dr. Smith"));
2538 assert!(ts.contains("ENG 101"));
2539 assert!(ts.contains("leading: 1.5em"));
2541 assert!(ts.contains("(Author)"));
2543 }
2544
2545 #[test]
2546 fn apa_report_has_references_and_title_page() {
2547 let src = "---\ntitle: My Report\ntype: report\nformat: apa\nauthor: A B\ninstitution: State University\n---\n\n# Body\n\nText [@a].\n\n::cite[key=a type=article]\nauthor = Q, R\ntitle = T\nyear = 2020\n::\n\n::bibliography\n::\n";
2548 let ts = to_typst(&parse_doc(src));
2549 assert!(ts.contains("References"), "APA report must have References: {ts}");
2550 assert!(ts.contains("#pagebreak()"));
2551 assert!(ts.contains("State University"));
2552 }
2553
2554 #[test]
2555 fn chicago_report_has_bibliography() {
2556 let src = "---\ntitle: R\ntype: report\nformat: chicago\nauthor: A B\n---\n\n# Body\n\nText [@a].\n\n::cite[key=a type=book]\nauthor = Q, R\ntitle = T\npublisher = P\nyear = 2020\n::\n\n::bibliography\n::\n";
2557 let ts = to_typst(&parse_doc(src));
2558 assert!(ts.contains("Bibliography"), "Chicago report must have Bibliography: {ts}");
2559 }
2560
2561 #[test]
2562 fn typst_academic_is_deterministic() {
2563 let src = "---\ntitle: P\ntype: paper\nformat: ieee\nauthor: Jane Doe\nabstract: A.\n---\n\n# Intro\n\nWork [@a].\n\n::cite[key=a type=article]\nauthor = Q, R\ntitle = T\njournal = J\nyear = 2020\n::\n\n::bibliography\n::\n";
2564 let doc = parse_doc(src);
2565 assert_eq!(to_typst(&doc), to_typst(&doc));
2566 }
2567
2568 #[test]
2571 fn every_format_renders_paper_and_report() {
2572 for f in ["ieee", "acm", "article", "mla", "apa", "chicago"] {
2573 for ty in ["paper", "report"] {
2574 let src = format!(
2575 "---\ntitle: T\ntype: {ty}\nformat: {f}\nauthor: A B\n---\n\n# Section\n\nBody text.\n"
2576 );
2577 let doc = parse_doc(&src);
2578 let ts = to_typst(&doc);
2579 let tex = crate::render_latex::to_latex(&doc);
2580 assert!(ts.len() > 100, "empty Typst for {ty}/{f}");
2581 assert!(tex.contains("\\begin{document}"), "no LaTeX doc for {ty}/{f}");
2582 }
2583 }
2584 }
2585}