use std::collections::{HashMap, HashSet};
use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
pub fn to_typst(markdown: &str, sections: &[String]) -> String {
let slugs = section_slugs(sections);
let known_slugs: HashSet<&str> = slugs.iter().map(String::as_str).collect();
let link_map: HashMap<&str, &str> = sections
.iter()
.map(|s| basename(s))
.zip(slugs.iter().map(String::as_str))
.collect();
let mut emitted_anchors: HashSet<String> = HashSet::new();
let mut opts = Options::empty();
opts.insert(Options::ENABLE_TABLES);
opts.insert(Options::ENABLE_STRIKETHROUGH);
opts.insert(Options::ENABLE_FOOTNOTES);
let mut out = String::with_capacity(markdown.len() * 2);
let mut list_stack: Vec<Option<u64>> = Vec::new();
let mut row_cells: Vec<String> = Vec::new();
let mut cell_buf: Option<String> = None;
let mut table_rows: Vec<Vec<String>> = Vec::new();
let mut para_buf: Option<String> = None;
let mut strong_depth: u32 = 0;
let mut para_strong_runs: u32 = 0;
let mut para_has_plain: bool = false;
let mut in_code = false;
for event in Parser::new_ext(markdown, opts) {
match event {
Event::Start(Tag::Table(_)) => table_rows.clear(),
Event::End(TagEnd::Table) => {
ensure_blank(&mut out);
emit_table(&mut out, &table_rows);
table_rows.clear();
}
Event::Start(Tag::TableHead) | Event::Start(Tag::TableRow) => row_cells.clear(),
Event::End(TagEnd::TableHead) | Event::End(TagEnd::TableRow) => {
table_rows.push(std::mem::take(&mut row_cells))
}
Event::Start(Tag::TableCell) => cell_buf = Some(String::new()),
Event::End(TagEnd::TableCell) => row_cells.push(cell_buf.take().unwrap_or_default()),
Event::Start(Tag::Heading { level, .. }) => {
ensure_blank(&mut out);
for _ in 0..heading_depth(level) {
out.push('=');
}
out.push(' ');
}
Event::End(TagEnd::Heading(_)) => out.push_str("\n\n"),
Event::Start(Tag::Paragraph) if list_stack.is_empty() && cell_buf.is_none() => {
para_buf = Some(String::new());
para_strong_runs = 0;
para_has_plain = false;
}
Event::Start(Tag::Paragraph) => {}
Event::End(TagEnd::Paragraph) => {
if let Some(buf) = para_buf.take() {
let trimmed = buf.trim();
ensure_blank(&mut out);
if para_strong_runs == 1 && !para_has_plain && is_wrapped_bold(trimmed) {
out.push_str("==== ");
out.push_str(trimmed[1..trimmed.len() - 1].trim());
} else {
out.push_str(trimmed);
}
out.push_str("\n\n");
}
}
Event::Start(Tag::List(start)) => {
if list_stack.is_empty() {
ensure_blank(&mut out);
} else {
ensure_newline(&mut out);
}
list_stack.push(start);
}
Event::End(TagEnd::List(_)) => {
list_stack.pop();
if list_stack.is_empty() {
ensure_blank(&mut out);
}
}
Event::Start(Tag::Item) => {
ensure_newline(&mut out);
for _ in 0..list_stack.len().saturating_sub(1) {
out.push_str(" ");
}
match list_stack.last() {
Some(Some(_)) => out.push_str("+ "),
_ => out.push_str("- "),
}
}
Event::End(TagEnd::Item) => ensure_newline(&mut out),
Event::Start(Tag::BlockQuote(_)) => {
ensure_blank(&mut out);
out.push_str("#quote(block: true)[\n");
}
Event::End(TagEnd::BlockQuote(_)) => out.push_str("]\n\n"),
Event::Start(Tag::CodeBlock(kind)) => {
ensure_blank(&mut out);
let lang = match kind {
CodeBlockKind::Fenced(l) if !l.is_empty() => l.to_string(),
_ => String::new(),
};
out.push_str("```");
out.push_str(&lang);
out.push('\n');
in_code = true;
}
Event::End(TagEnd::CodeBlock) => {
in_code = false;
out.push_str("```\n\n");
}
Event::Rule => {
ensure_blank(&mut out);
out.push_str("#line(length: 100%)\n\n");
}
Event::Start(Tag::Emphasis) | Event::End(TagEnd::Emphasis) => {
sink(&mut out, &mut cell_buf, &mut para_buf).push('_')
}
Event::Start(Tag::Strong) => {
strong_depth += 1;
if para_buf.is_some() && strong_depth == 1 {
para_strong_runs += 1;
}
sink(&mut out, &mut cell_buf, &mut para_buf).push('*');
}
Event::End(TagEnd::Strong) => {
strong_depth = strong_depth.saturating_sub(1);
sink(&mut out, &mut cell_buf, &mut para_buf).push('*');
}
Event::Start(Tag::Strikethrough) => {
sink(&mut out, &mut cell_buf, &mut para_buf).push_str("#strike[")
}
Event::End(TagEnd::Strikethrough) => {
sink(&mut out, &mut cell_buf, &mut para_buf).push(']')
}
Event::Start(Tag::Link { dest_url, .. }) => {
let internal = link_target_slug(&dest_url, &link_map);
let s = sink(&mut out, &mut cell_buf, &mut para_buf);
match internal {
Some(slug) => {
s.push_str("#link(<");
s.push_str(slug);
s.push_str(">)[");
}
None => {
s.push_str("#link(\"");
s.push_str(&escape_typst_string(&dest_url));
s.push_str("\")[");
}
}
}
Event::End(TagEnd::Link) => sink(&mut out, &mut cell_buf, &mut para_buf).push(']'),
Event::Code(text) => {
let s = sink(&mut out, &mut cell_buf, &mut para_buf);
s.push_str("#raw(\"");
s.push_str(&escape_typst_string(&text));
s.push_str("\")");
}
Event::Text(text) => {
if in_code {
out.push_str(&text);
} else {
if para_buf.is_some() && strong_depth == 0 && !text.trim().is_empty() {
para_has_plain = true;
}
sink(&mut out, &mut cell_buf, &mut para_buf).push_str(&escape_markup(&text));
}
}
Event::SoftBreak => sink(&mut out, &mut cell_buf, &mut para_buf).push(' '),
Event::HardBreak => sink(&mut out, &mut cell_buf, &mut para_buf).push_str(" \\\n"),
Event::Html(text) | Event::InlineHtml(text) => {
if let Some(slug) = parse_anchor_marker(&text) {
if known_slugs.contains(slug.as_str()) && emitted_anchors.insert(slug.clone()) {
ensure_blank(&mut out);
out.push_str("#metadata(none) <");
out.push_str(&slug);
out.push_str(">\n\n");
}
}
}
_ => {}
}
}
normalize_blanks(&out)
}
fn sink<'a>(
out: &'a mut String,
cell: &'a mut Option<String>,
para: &'a mut Option<String>,
) -> &'a mut String {
if let Some(c) = cell.as_mut() {
c
} else if let Some(p) = para.as_mut() {
p
} else {
out
}
}
fn heading_depth(level: HeadingLevel) -> usize {
match level {
HeadingLevel::H1 => 1,
HeadingLevel::H2 => 2,
HeadingLevel::H3 => 3,
HeadingLevel::H4 => 4,
HeadingLevel::H5 => 5,
HeadingLevel::H6 => 6,
}
}
fn is_wrapped_bold(s: &str) -> bool {
s.len() > 2 && s.starts_with('*') && s.ends_with('*') && !s[1..s.len() - 1].contains('*')
}
fn ensure_blank(s: &mut String) {
if s.is_empty() {
return;
}
while s.ends_with([' ', '\t', '\n']) {
s.pop();
}
if !s.is_empty() {
s.push_str("\n\n");
}
}
fn ensure_newline(s: &mut String) {
while s.ends_with([' ', '\t']) {
s.pop();
}
if !s.is_empty() && !s.ends_with('\n') {
s.push('\n');
}
}
fn normalize_blanks(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut newlines = 0u32;
for ch in s.chars() {
if ch == '\n' {
newlines += 1;
if newlines <= 2 {
out.push('\n');
}
} else {
newlines = 0;
out.push(ch);
}
}
out.trim_start_matches('\n').to_string()
}
fn emit_table(out: &mut String, rows: &[Vec<String>]) {
if rows.is_empty() {
return;
}
let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0).max(1);
use std::fmt::Write as _;
out.push_str("#table(\n");
let _ = writeln!(out, " columns: {cols},");
out.push_str(" inset: 6pt,\n align: left + top,\n stroke: 0.5pt + luma(180),\n");
for (i, row) in rows.iter().enumerate() {
out.push_str(" ");
for c in 0..cols {
let cell = row.get(c).map(String::as_str).unwrap_or("").trim();
if i == 0 {
let _ = write!(out, "[*{cell}*], ");
} else {
let _ = write!(out, "[{cell}], ");
}
}
out.push('\n');
}
out.push_str(")\n\n");
}
fn escape_markup(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 8);
for ch in s.chars() {
match ch {
'\\' | '#' | '$' | '*' | '_' | '`' | '<' | '>' | '@' | '=' | '[' | ']' => {
out.push('\\');
out.push(ch);
}
_ => out.push(ch),
}
}
out
}
pub(crate) fn escape_typst_string(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
fn basename(name: &str) -> &str {
name.rsplit(['/', '\\']).next().unwrap_or(name)
}
pub fn section_slugs(sections: &[String]) -> Vec<String> {
let mut used: HashSet<String> = HashSet::new();
let mut out = Vec::with_capacity(sections.len());
for name in sections {
let base = section_slug(name);
let mut slug = base.clone();
let mut n = 2u32;
while !used.insert(slug.clone()) {
slug = format!("{base}-{n}");
n += 1;
}
out.push(slug);
}
out
}
pub fn section_slug(name: &str) -> String {
let base = name.rsplit(['/', '\\']).next().unwrap_or(name);
let stem = base
.strip_suffix(".md")
.or_else(|| base.strip_suffix(".markdown"))
.unwrap_or(base);
let mut slug = String::from("wisp-");
let mut prev_dash = false;
for ch in stem.chars() {
if ch.is_ascii_alphanumeric() {
slug.push(ch.to_ascii_lowercase());
prev_dash = false;
} else if !prev_dash {
slug.push('-');
prev_dash = true;
}
}
slug.trim_end_matches('-').to_string()
}
fn link_target_slug<'a>(dest: &str, link_map: &HashMap<&str, &'a str>) -> Option<&'a str> {
if dest.contains("://") || dest.starts_with("mailto:") || dest.starts_with('#') {
return None;
}
let path = dest.split('#').next().unwrap_or(dest);
link_map.get(basename(path)).copied()
}
fn parse_anchor_marker(html: &str) -> Option<String> {
let inner = html
.trim()
.strip_prefix("<!--wisp:anchor ")?
.strip_suffix("-->")?;
let slug = inner.trim();
(!slug.is_empty()).then(|| slug.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn to_typst_default(markdown: &str) -> String {
to_typst(markdown, &[])
}
#[test]
fn converts_atx_headings() {
let typ = to_typst_default("# Title\n\nbody\n\n## Section\n");
assert!(typ.contains("= Title"));
assert!(typ.contains("== Section"));
assert!(typ.contains("body"));
}
#[test]
fn escapes_typst_metacharacters_in_prose() {
let typ = to_typst_default("Email me @ test or pay $5 to #1\n");
assert!(typ.contains("\\@"));
assert!(typ.contains("\\$5"));
assert!(typ.contains("\\#1"));
}
#[test]
fn list_is_separated_from_preceding_paragraph() {
let typ = to_typst_default("Some intro.\n- one\n- two\n");
assert!(
typ.contains("intro.\n\n- one"),
"list not blank-separated: {typ:?}"
);
}
#[test]
fn bold_only_paragraph_becomes_subheading() {
let typ = to_typst_default("**General Use and Ownership**\n\n- a\n- b\n");
assert!(
typ.contains("==== General Use and Ownership"),
"bold pseudo-heading not promoted: {typ:?}"
);
}
#[test]
fn inline_emphasis_is_not_promoted() {
let typ = to_typst_default("This is *important* text.\n");
assert!(
!typ.contains("===="),
"inline emphasis wrongly promoted: {typ:?}"
);
assert!(typ.contains("_important_"), "emphasis not mapped: {typ:?}");
}
#[test]
fn inline_strong_is_not_promoted() {
let typ = to_typst_default("This is **bold** text.\n");
assert!(
!typ.contains("===="),
"inline strong wrongly promoted: {typ:?}"
);
assert!(typ.contains("*bold*"), "strong not mapped: {typ:?}");
}
#[test]
fn renders_a_table() {
let md = "| Field | Value |\n| --- | --- |\n| Owner | CTO |\n";
let typ = to_typst_default(md);
assert!(typ.contains("#table("));
assert!(typ.contains("columns: 2"));
assert!(typ.contains("[*Field*]"));
assert!(typ.contains("[Owner]"));
}
#[test]
fn preserves_code_blocks() {
let typ = to_typst_default("```rust\nlet x = 1;\n```\n");
assert!(typ.contains("```rust"));
assert!(typ.contains("let x = 1;"));
}
#[test]
fn no_triple_newlines() {
let typ = to_typst_default("# H\n\n\n\npara\n");
assert!(!typ.contains("\n\n\n"), "blank runs not collapsed: {typ:?}");
}
#[test]
fn section_slug_is_namespaced_and_stable() {
assert_eq!(
section_slug("access-control-policy.md"),
"wisp-access-control-policy"
);
assert_eq!(section_slug("security/README.md"), "wisp-readme");
assert_eq!(section_slug("foo_bar.markdown"), "wisp-foo-bar");
}
#[test]
fn rewrites_md_links_to_internal_anchors() {
let sections = vec!["access-control-policy.md".to_string()];
let body = "<!--wisp:anchor wisp-access-control-policy-->\n\n\
# Access Control Policy\n\n\
See the [ACP](access-control-policy.md#scope) for details.\n";
let typ = to_typst(body, §ions);
assert!(
typ.contains("#metadata(none) <wisp-access-control-policy>"),
"section anchor not emitted: {typ}"
);
assert!(
typ.contains("#link(<wisp-access-control-policy>)[ACP]"),
"md link not rewired to internal ref: {typ}"
);
assert!(
!typ.contains("#link(\"access-control-policy"),
"internal link still emitted as a file URL: {typ}"
);
}
#[test]
fn anchor_is_emitted_even_without_a_heading() {
let sections = vec!["intro.md".to_string()];
let body = "<!--wisp:anchor wisp-intro-->\n\nJust a paragraph, no heading.\n";
let typ = to_typst(body, §ions);
assert!(
typ.contains("#metadata(none) <wisp-intro>"),
"heading-less section lost its anchor: {typ}"
);
}
#[test]
fn forged_or_unknown_anchor_markers_are_ignored() {
let sections = vec!["intro.md".to_string()];
let body = "<!--wisp:anchor wisp-intro-->\n\n# Intro\n\n\
<!--wisp:anchor wisp-not-a-section-->\n\nBody.\n";
let typ = to_typst(body, §ions);
assert!(typ.contains("#metadata(none) <wisp-intro>"));
assert!(
!typ.contains("wisp-not-a-section"),
"forged anchor leaked: {typ}"
);
}
#[test]
fn section_slugs_disambiguate_collisions() {
let s = section_slugs(&["access-control.md".into(), "access_control.md".into()]);
assert_eq!(s, vec!["wisp-access-control", "wisp-access-control-2"]);
}
#[test]
fn external_and_unknown_links_stay_literal() {
let sections = vec!["access-control-policy.md".to_string()];
let typ = to_typst(
"[site](https://example.com) and [rb](runbooks/restore.md)\n",
§ions,
);
assert!(
typ.contains("#link(\"https://example.com\")[site]"),
"{typ}"
);
assert!(typ.contains("#link(\"runbooks/restore.md\")[rb]"), "{typ}");
}
}