1use std::collections::{HashMap, HashSet};
18
19use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
20
21pub fn to_typst(markdown: &str, sections: &[String]) -> String {
31 let slugs = section_slugs(sections);
34 let known_slugs: HashSet<&str> = slugs.iter().map(String::as_str).collect();
35 let link_map: HashMap<&str, &str> = sections
36 .iter()
37 .map(|s| basename(s))
38 .zip(slugs.iter().map(String::as_str))
39 .collect();
40 let mut emitted_anchors: HashSet<String> = HashSet::new();
43
44 let mut opts = Options::empty();
45 opts.insert(Options::ENABLE_TABLES);
46 opts.insert(Options::ENABLE_STRIKETHROUGH);
47 opts.insert(Options::ENABLE_FOOTNOTES);
48
49 let mut out = String::with_capacity(markdown.len() * 2);
50 let mut list_stack: Vec<Option<u64>> = Vec::new();
51
52 let mut row_cells: Vec<String> = Vec::new();
54 let mut cell_buf: Option<String> = None;
55 let mut table_rows: Vec<Vec<String>> = Vec::new();
56
57 let mut para_buf: Option<String> = None;
59 let mut strong_depth: u32 = 0;
60 let mut para_strong_runs: u32 = 0;
61 let mut para_has_plain: bool = false;
62
63 let mut in_code = false;
65
66 for event in Parser::new_ext(markdown, opts) {
67 match event {
68 Event::Start(Tag::Table(_)) => table_rows.clear(),
70 Event::End(TagEnd::Table) => {
71 ensure_blank(&mut out);
72 emit_table(&mut out, &table_rows);
73 table_rows.clear();
74 }
75 Event::Start(Tag::TableHead) | Event::Start(Tag::TableRow) => row_cells.clear(),
76 Event::End(TagEnd::TableHead) | Event::End(TagEnd::TableRow) => {
77 table_rows.push(std::mem::take(&mut row_cells))
78 }
79 Event::Start(Tag::TableCell) => cell_buf = Some(String::new()),
80 Event::End(TagEnd::TableCell) => row_cells.push(cell_buf.take().unwrap_or_default()),
81
82 Event::Start(Tag::Heading { level, .. }) => {
84 ensure_blank(&mut out);
85 for _ in 0..heading_depth(level) {
86 out.push('=');
87 }
88 out.push(' ');
89 }
90 Event::End(TagEnd::Heading(_)) => out.push_str("\n\n"),
91
92 Event::Start(Tag::Paragraph) if list_stack.is_empty() && cell_buf.is_none() => {
94 para_buf = Some(String::new());
95 para_strong_runs = 0;
96 para_has_plain = false;
97 }
98 Event::Start(Tag::Paragraph) => {}
99 Event::End(TagEnd::Paragraph) => {
100 if let Some(buf) = para_buf.take() {
101 let trimmed = buf.trim();
102 ensure_blank(&mut out);
103 if para_strong_runs == 1 && !para_has_plain && is_wrapped_bold(trimmed) {
104 out.push_str("==== ");
106 out.push_str(trimmed[1..trimmed.len() - 1].trim());
107 } else {
108 out.push_str(trimmed);
109 }
110 out.push_str("\n\n");
111 }
112 }
115
116 Event::Start(Tag::List(start)) => {
118 if list_stack.is_empty() {
119 ensure_blank(&mut out);
120 } else {
121 ensure_newline(&mut out);
122 }
123 list_stack.push(start);
124 }
125 Event::End(TagEnd::List(_)) => {
126 list_stack.pop();
127 if list_stack.is_empty() {
128 ensure_blank(&mut out);
129 }
130 }
131 Event::Start(Tag::Item) => {
132 ensure_newline(&mut out);
133 for _ in 0..list_stack.len().saturating_sub(1) {
134 out.push_str(" ");
135 }
136 match list_stack.last() {
137 Some(Some(_)) => out.push_str("+ "),
140 _ => out.push_str("- "),
141 }
142 }
143 Event::End(TagEnd::Item) => ensure_newline(&mut out),
144
145 Event::Start(Tag::BlockQuote(_)) => {
147 ensure_blank(&mut out);
148 out.push_str("#quote(block: true)[\n");
149 }
150 Event::End(TagEnd::BlockQuote(_)) => out.push_str("]\n\n"),
151 Event::Start(Tag::CodeBlock(kind)) => {
152 ensure_blank(&mut out);
153 let lang = match kind {
154 CodeBlockKind::Fenced(l) if !l.is_empty() => l.to_string(),
155 _ => String::new(),
156 };
157 out.push_str("```");
158 out.push_str(&lang);
159 out.push('\n');
160 in_code = true;
161 }
162 Event::End(TagEnd::CodeBlock) => {
163 in_code = false;
164 out.push_str("```\n\n");
165 }
166 Event::Rule => {
167 ensure_blank(&mut out);
168 out.push_str("#line(length: 100%)\n\n");
169 }
170
171 Event::Start(Tag::Emphasis) | Event::End(TagEnd::Emphasis) => {
173 sink(&mut out, &mut cell_buf, &mut para_buf).push('_')
174 }
175 Event::Start(Tag::Strong) => {
176 strong_depth += 1;
177 if para_buf.is_some() && strong_depth == 1 {
178 para_strong_runs += 1;
179 }
180 sink(&mut out, &mut cell_buf, &mut para_buf).push('*');
181 }
182 Event::End(TagEnd::Strong) => {
183 strong_depth = strong_depth.saturating_sub(1);
184 sink(&mut out, &mut cell_buf, &mut para_buf).push('*');
185 }
186 Event::Start(Tag::Strikethrough) => {
187 sink(&mut out, &mut cell_buf, &mut para_buf).push_str("#strike[")
188 }
189 Event::End(TagEnd::Strikethrough) => {
190 sink(&mut out, &mut cell_buf, &mut para_buf).push(']')
191 }
192 Event::Start(Tag::Link { dest_url, .. }) => {
193 let internal = link_target_slug(&dest_url, &link_map);
194 let s = sink(&mut out, &mut cell_buf, &mut para_buf);
195 match internal {
196 Some(slug) => {
198 s.push_str("#link(<");
199 s.push_str(slug);
200 s.push_str(">)[");
201 }
202 None => {
203 s.push_str("#link(\"");
204 s.push_str(&escape_typst_string(&dest_url));
205 s.push_str("\")[");
206 }
207 }
208 }
209 Event::End(TagEnd::Link) => sink(&mut out, &mut cell_buf, &mut para_buf).push(']'),
210 Event::Code(text) => {
211 let s = sink(&mut out, &mut cell_buf, &mut para_buf);
215 s.push_str("#raw(\"");
216 s.push_str(&escape_typst_string(&text));
217 s.push_str("\")");
218 }
219 Event::Text(text) => {
220 if in_code {
221 out.push_str(&text);
224 } else {
225 if para_buf.is_some() && strong_depth == 0 && !text.trim().is_empty() {
226 para_has_plain = true;
227 }
228 sink(&mut out, &mut cell_buf, &mut para_buf).push_str(&escape_markup(&text));
229 }
230 }
231 Event::SoftBreak => sink(&mut out, &mut cell_buf, &mut para_buf).push(' '),
232 Event::HardBreak => sink(&mut out, &mut cell_buf, &mut para_buf).push_str(" \\\n"),
233
234 Event::Html(text) | Event::InlineHtml(text) => {
235 if let Some(slug) = parse_anchor_marker(&text) {
236 if known_slugs.contains(slug.as_str()) && emitted_anchors.insert(slug.clone()) {
241 ensure_blank(&mut out);
242 out.push_str("#metadata(none) <");
243 out.push_str(&slug);
244 out.push_str(">\n\n");
245 }
246 }
247 }
248 _ => {}
249 }
250 }
251
252 normalize_blanks(&out)
254}
255
256fn sink<'a>(
259 out: &'a mut String,
260 cell: &'a mut Option<String>,
261 para: &'a mut Option<String>,
262) -> &'a mut String {
263 if let Some(c) = cell.as_mut() {
264 c
265 } else if let Some(p) = para.as_mut() {
266 p
267 } else {
268 out
269 }
270}
271
272fn heading_depth(level: HeadingLevel) -> usize {
273 match level {
274 HeadingLevel::H1 => 1,
275 HeadingLevel::H2 => 2,
276 HeadingLevel::H3 => 3,
277 HeadingLevel::H4 => 4,
278 HeadingLevel::H5 => 5,
279 HeadingLevel::H6 => 6,
280 }
281}
282
283fn is_wrapped_bold(s: &str) -> bool {
286 s.len() > 2 && s.starts_with('*') && s.ends_with('*') && !s[1..s.len() - 1].contains('*')
287}
288
289fn ensure_blank(s: &mut String) {
291 if s.is_empty() {
292 return;
293 }
294 while s.ends_with([' ', '\t', '\n']) {
295 s.pop();
296 }
297 if !s.is_empty() {
298 s.push_str("\n\n");
299 }
300}
301
302fn ensure_newline(s: &mut String) {
304 while s.ends_with([' ', '\t']) {
305 s.pop();
306 }
307 if !s.is_empty() && !s.ends_with('\n') {
308 s.push('\n');
309 }
310}
311
312fn normalize_blanks(s: &str) -> String {
314 let mut out = String::with_capacity(s.len());
315 let mut newlines = 0u32;
316 for ch in s.chars() {
317 if ch == '\n' {
318 newlines += 1;
319 if newlines <= 2 {
320 out.push('\n');
321 }
322 } else {
323 newlines = 0;
324 out.push(ch);
325 }
326 }
327 out.trim_start_matches('\n').to_string()
328}
329
330fn emit_table(out: &mut String, rows: &[Vec<String>]) {
332 if rows.is_empty() {
333 return;
334 }
335 let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0).max(1);
336 use std::fmt::Write as _;
337 out.push_str("#table(\n");
338 let _ = writeln!(out, " columns: {cols},");
339 out.push_str(" inset: 6pt,\n align: left + top,\n stroke: 0.5pt + luma(180),\n");
340 for (i, row) in rows.iter().enumerate() {
341 out.push_str(" ");
342 for c in 0..cols {
343 let cell = row.get(c).map(String::as_str).unwrap_or("").trim();
344 if i == 0 {
345 let _ = write!(out, "[*{cell}*], ");
346 } else {
347 let _ = write!(out, "[{cell}], ");
348 }
349 }
350 out.push('\n');
351 }
352 out.push_str(")\n\n");
353}
354
355fn escape_markup(s: &str) -> String {
358 let mut out = String::with_capacity(s.len() + 8);
359 for ch in s.chars() {
360 match ch {
361 '\\' | '#' | '$' | '*' | '_' | '`' | '<' | '>' | '@' | '=' | '[' | ']' => {
362 out.push('\\');
363 out.push(ch);
364 }
365 _ => out.push(ch),
366 }
367 }
368 out
369}
370
371pub(crate) fn escape_typst_string(s: &str) -> String {
374 s.replace('\\', "\\\\").replace('"', "\\\"")
375}
376
377fn basename(name: &str) -> &str {
380 name.rsplit(['/', '\\']).next().unwrap_or(name)
381}
382
383pub fn section_slugs(sections: &[String]) -> Vec<String> {
389 let mut used: HashSet<String> = HashSet::new();
390 let mut out = Vec::with_capacity(sections.len());
391 for name in sections {
392 let base = section_slug(name);
393 let mut slug = base.clone();
394 let mut n = 2u32;
395 while !used.insert(slug.clone()) {
396 slug = format!("{base}-{n}");
397 n += 1;
398 }
399 out.push(slug);
400 }
401 out
402}
403
404pub fn section_slug(name: &str) -> String {
409 let base = name.rsplit(['/', '\\']).next().unwrap_or(name);
410 let stem = base
411 .strip_suffix(".md")
412 .or_else(|| base.strip_suffix(".markdown"))
413 .unwrap_or(base);
414 let mut slug = String::from("wisp-");
415 let mut prev_dash = false;
416 for ch in stem.chars() {
417 if ch.is_ascii_alphanumeric() {
418 slug.push(ch.to_ascii_lowercase());
419 prev_dash = false;
420 } else if !prev_dash {
421 slug.push('-');
422 prev_dash = true;
423 }
424 }
425 slug.trim_end_matches('-').to_string()
426}
427
428fn link_target_slug<'a>(dest: &str, link_map: &HashMap<&str, &'a str>) -> Option<&'a str> {
432 if dest.contains("://") || dest.starts_with("mailto:") || dest.starts_with('#') {
433 return None;
434 }
435 let path = dest.split('#').next().unwrap_or(dest);
436 link_map.get(basename(path)).copied()
437}
438
439fn parse_anchor_marker(html: &str) -> Option<String> {
441 let inner = html
442 .trim()
443 .strip_prefix("<!--wisp:anchor ")?
444 .strip_suffix("-->")?;
445 let slug = inner.trim();
446 (!slug.is_empty()).then(|| slug.to_string())
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452
453 fn to_typst_default(markdown: &str) -> String {
455 to_typst(markdown, &[])
456 }
457
458 #[test]
459 fn converts_atx_headings() {
460 let typ = to_typst_default("# Title\n\nbody\n\n## Section\n");
461 assert!(typ.contains("= Title"));
462 assert!(typ.contains("== Section"));
463 assert!(typ.contains("body"));
464 }
465
466 #[test]
467 fn escapes_typst_metacharacters_in_prose() {
468 let typ = to_typst_default("Email me @ test or pay $5 to #1\n");
469 assert!(typ.contains("\\@"));
470 assert!(typ.contains("\\$5"));
471 assert!(typ.contains("\\#1"));
472 }
473
474 #[test]
475 fn list_is_separated_from_preceding_paragraph() {
476 let typ = to_typst_default("Some intro.\n- one\n- two\n");
479 assert!(
480 typ.contains("intro.\n\n- one"),
481 "list not blank-separated: {typ:?}"
482 );
483 }
484
485 #[test]
486 fn bold_only_paragraph_becomes_subheading() {
487 let typ = to_typst_default("**General Use and Ownership**\n\n- a\n- b\n");
488 assert!(
489 typ.contains("==== General Use and Ownership"),
490 "bold pseudo-heading not promoted: {typ:?}"
491 );
492 }
493
494 #[test]
495 fn inline_emphasis_is_not_promoted() {
496 let typ = to_typst_default("This is *important* text.\n");
499 assert!(
500 !typ.contains("===="),
501 "inline emphasis wrongly promoted: {typ:?}"
502 );
503 assert!(typ.contains("_important_"), "emphasis not mapped: {typ:?}");
504 }
505
506 #[test]
507 fn inline_strong_is_not_promoted() {
508 let typ = to_typst_default("This is **bold** text.\n");
511 assert!(
512 !typ.contains("===="),
513 "inline strong wrongly promoted: {typ:?}"
514 );
515 assert!(typ.contains("*bold*"), "strong not mapped: {typ:?}");
516 }
517
518 #[test]
519 fn renders_a_table() {
520 let md = "| Field | Value |\n| --- | --- |\n| Owner | CTO |\n";
521 let typ = to_typst_default(md);
522 assert!(typ.contains("#table("));
523 assert!(typ.contains("columns: 2"));
524 assert!(typ.contains("[*Field*]"));
525 assert!(typ.contains("[Owner]"));
526 }
527
528 #[test]
529 fn preserves_code_blocks() {
530 let typ = to_typst_default("```rust\nlet x = 1;\n```\n");
531 assert!(typ.contains("```rust"));
532 assert!(typ.contains("let x = 1;"));
533 }
534
535 #[test]
536 fn no_triple_newlines() {
537 let typ = to_typst_default("# H\n\n\n\npara\n");
538 assert!(!typ.contains("\n\n\n"), "blank runs not collapsed: {typ:?}");
539 }
540
541 #[test]
542 fn section_slug_is_namespaced_and_stable() {
543 assert_eq!(
544 section_slug("access-control-policy.md"),
545 "wisp-access-control-policy"
546 );
547 assert_eq!(section_slug("security/README.md"), "wisp-readme");
548 assert_eq!(section_slug("foo_bar.markdown"), "wisp-foo-bar");
549 }
550
551 #[test]
552 fn rewrites_md_links_to_internal_anchors() {
553 let sections = vec!["access-control-policy.md".to_string()];
557 let body = "<!--wisp:anchor wisp-access-control-policy-->\n\n\
558 # Access Control Policy\n\n\
559 See the [ACP](access-control-policy.md#scope) for details.\n";
560 let typ = to_typst(body, §ions);
561 assert!(
562 typ.contains("#metadata(none) <wisp-access-control-policy>"),
563 "section anchor not emitted: {typ}"
564 );
565 assert!(
566 typ.contains("#link(<wisp-access-control-policy>)[ACP]"),
567 "md link not rewired to internal ref: {typ}"
568 );
569 assert!(
570 !typ.contains("#link(\"access-control-policy"),
571 "internal link still emitted as a file URL: {typ}"
572 );
573 }
574
575 #[test]
576 fn anchor_is_emitted_even_without_a_heading() {
577 let sections = vec!["intro.md".to_string()];
580 let body = "<!--wisp:anchor wisp-intro-->\n\nJust a paragraph, no heading.\n";
581 let typ = to_typst(body, §ions);
582 assert!(
583 typ.contains("#metadata(none) <wisp-intro>"),
584 "heading-less section lost its anchor: {typ}"
585 );
586 }
587
588 #[test]
589 fn forged_or_unknown_anchor_markers_are_ignored() {
590 let sections = vec!["intro.md".to_string()];
593 let body = "<!--wisp:anchor wisp-intro-->\n\n# Intro\n\n\
594 <!--wisp:anchor wisp-not-a-section-->\n\nBody.\n";
595 let typ = to_typst(body, §ions);
596 assert!(typ.contains("#metadata(none) <wisp-intro>"));
597 assert!(
598 !typ.contains("wisp-not-a-section"),
599 "forged anchor leaked: {typ}"
600 );
601 }
602
603 #[test]
604 fn section_slugs_disambiguate_collisions() {
605 let s = section_slugs(&["access-control.md".into(), "access_control.md".into()]);
608 assert_eq!(s, vec!["wisp-access-control", "wisp-access-control-2"]);
609 }
610
611 #[test]
612 fn external_and_unknown_links_stay_literal() {
613 let sections = vec!["access-control-policy.md".to_string()];
614 let typ = to_typst(
617 "[site](https://example.com) and [rb](runbooks/restore.md)\n",
618 §ions,
619 );
620 assert!(
621 typ.contains("#link(\"https://example.com\")[site]"),
622 "{typ}"
623 );
624 assert!(typ.contains("#link(\"runbooks/restore.md\")[rb]"), "{typ}");
625 }
626}