//! Markdown pre-processing applied at export time. //! //! Manuscript source files may carry an editorial "header" above the prose and //! HTML comments inline. Neither belongs in the exported document, so before a //! file is converted to ODT we: //! //! * strip HTML comments (``, including multi-line ones), and //! * split off the header: everything above a marker line (default //! `### Rough Draft:`). From the header we pull out `# Title:` and //! `# Slug:` metadata; the title becomes the chapter heading and the slug a //! caption beneath it. All other header text is discarded. //! //! If a file has no marker line, nothing is treated as a header block, but any //! stray `# Title:` / `# Slug:` lines are still lifted out and removed. /// The parsed result of pre-processing one markdown document. pub struct Header { /// Value of the `# Title:` line, if present and non-empty. pub title: Option, /// Value of the `# Slug:` line, if present and non-empty. pub slug: Option, /// The prose body, with comments, the header block, and metadata lines removed. pub body: String, } #[derive(Clone, Copy)] enum Meta { Title, Slug, } /// Remove `` comments (matching the non-greedy ``), /// including comments that span multiple lines. An unterminated comment drops /// everything from `") { Some(end) => rest = &rest[start + 4 + end + 3..], None => { rest = ""; break; } } } out.push_str(rest); out } /// If `line` is a `# Title:` / `# Slug:` metadata line, return which one and its /// value. Matches a single leading `#` followed by whitespace, case-insensitively. fn meta_line(line: &str) -> Option<(Meta, String)> { let after_hash = line.trim_start().strip_prefix('#')?; if !after_hash.starts_with(char::is_whitespace) { return None; } let rest = after_hash.trim_start(); let lower = rest.to_ascii_lowercase(); for (key, meta) in [("title:", Meta::Title), ("slug:", Meta::Slug)] { if lower.starts_with(key) { return Some((meta, rest[key.len()..].trim().to_string())); } } None } /// Pre-process one markdown document, splitting header metadata from the body. /// `marker` is the header/body separator line (e.g. `### Rough Draft:`); an empty /// marker disables header-block splitting. pub fn parse(markdown: &str, marker: &str) -> Header { let cleaned = strip_comments(markdown); let lines: Vec<&str> = cleaned.lines().collect(); let marker_norm = marker.trim().to_ascii_lowercase(); let marker_idx = if marker_norm.is_empty() { None } else { lines .iter() .position(|l| l.trim().to_ascii_lowercase().starts_with(&marker_norm)) }; let mut title = None; let mut slug = None; let mut set = |meta: Meta, value: String| match meta { Meta::Title => title = Some(value), Meta::Slug => slug = Some(value), }; let body_lines: Vec<&str> = match marker_idx { Some(idx) => { // Header is everything before the marker; parse metadata from it. for l in &lines[..idx] { if let Some((m, v)) = meta_line(l) { set(m, v); } } // Body is everything after the marker, minus any stray metadata lines. lines[idx + 1..] .iter() .copied() .filter(|l| meta_line(l).is_none()) .collect() } None => { // No marker: keep all prose, but lift out metadata lines anywhere. let mut kept = Vec::new(); for l in &lines { if let Some((m, v)) = meta_line(l) { set(m, v); } else { kept.push(*l); } } kept } }; Header { title: title.filter(|s| !s.is_empty()), slug: slug.filter(|s| !s.is_empty()), body: body_lines.join("\n").trim().to_string(), } } #[cfg(test)] mod tests { use super::*; const MARKER: &str = "### Rough Draft:"; #[test] fn strips_inline_and_multiline_comments() { let s = "before after\n\nend"; let out = strip_comments(s); assert!(!out.contains("note")); assert!(!out.contains("block")); assert!(out.contains("before after")); assert!(out.contains("end")); } #[test] fn unterminated_comment_drops_tail() { let out = strip_comments("keep \nnotes to self\n### Rough Draft:\n\nThe prose begins here.\n\n## A scene"; let h = parse(md, MARKER); assert_eq!(h.title.as_deref(), Some("The Gate")); assert_eq!(h.slug.as_deref(), Some("an opening")); assert!(h.body.starts_with("The prose begins here.")); assert!(h.body.contains("## A scene")); assert!(!h.body.contains("notes to self")); assert!(!h.body.contains("outline")); assert!(!h.body.contains("Title:")); } #[test] fn without_marker_keeps_body_but_lifts_metadata() { let md = "# Title: Solo\n\nJust prose, no marker."; let h = parse(md, MARKER); assert_eq!(h.title.as_deref(), Some("Solo")); assert_eq!(h.slug, None); assert_eq!(h.body, "Just prose, no marker."); } #[test] fn real_heading_is_not_mistaken_for_metadata() { let md = "### Rough Draft:\n\n# Chapter One\n\nText."; let h = parse(md, MARKER); assert!(h.body.contains("# Chapter One")); } }