//! 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. /// A per-file word-count target parsed from a `Word Count Target:` header line. /// A single number gives `min == max`; a range like `1500 - 2000` gives both. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct WordGoal { /// Lower bound of the target (the point "in range" begins). pub min: usize, /// Upper bound of the target (for a single number, equal to `min`). pub max: usize, } /// 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, /// Value of the `POV:` line, if present and non-empty. pub pov: Option, /// Word-count target from a `Word Count Target:` line, if present and parsable. pub goal: Option, /// The prose body, with comments, the header block, and metadata lines removed. pub body: String, } #[derive(Clone, Copy)] enum Meta { Title, Slug, Pov, WordCount, } /// Parse a word-count target value such as `1500 - 2000`, `1500-2000`, /// `1,500 to 2,000`, or a single `2000`. Any two numbers are taken as the /// bounds (ordered); a lone number becomes both bounds. Returns `None` if the /// value contains no digits. fn parse_goal(value: &str) -> Option { // Remove digit-grouping characters so "1,500" stays one number, then split // the remainder on any non-digit (dashes, "to", spaces) into number tokens. let ungrouped: String = value.chars().filter(|c| *c != ',' && *c != '_').collect(); let nums: Vec = ungrouped .split(|c: char| !c.is_ascii_digit()) .filter(|tok| !tok.is_empty()) .filter_map(|tok| tok.parse::().ok()) .collect(); match nums.as_slice() { [] => None, [n] => Some(WordGoal { min: *n, max: *n }), [a, b, ..] => Some(WordGoal { min: (*a).min(*b), max: (*a).max(*b), }), } } /// 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 } /// Split a document at its draft marker into `(header, body)`, where the header /// includes the marker line itself and its newline. /// /// The split is exact: `header + body` is the original text, byte for byte, so /// a caller can lift the header out and put it back without touching what the /// author wrote. `None` when the document has no marker to split at. pub fn split_at_marker<'a>(text: &'a str, marker: &str) -> Option<(&'a str, &'a str)> { let marker_norm = marker.trim().to_ascii_lowercase(); if marker_norm.is_empty() { return None; } let mut offset = 0usize; for line in text.split_inclusive('\n') { if line.trim().to_ascii_lowercase().starts_with(&marker_norm) { let end = offset + line.len(); return Some((&text[..end], &text[end..])); } offset += line.len(); } None } /// Split a document at its YAML frontmatter boundaries (`---` delimiters). /// Returns `(frontmatter, body)` if frontmatter exists, or `None` if no frontmatter. /// The frontmatter includes the opening and closing `---` lines. pub fn split_yaml_frontmatter<'a>(text: &'a str) -> Option<(&'a str, &'a str)> { let mut lines = text.lines(); // Check for opening --- let first_line = lines.next()?.trim(); if first_line != "---" { return None; } // Find closing --- let mut frontmatter_end = first_line.len() + 1; // +1 for newline let mut found_closing = false; for line in lines { frontmatter_end += line.len() + 1; // +1 for newline if line.trim() == "---" { found_closing = true; break; } } if !found_closing { return None; } // Return frontmatter (including delimiters) and body let frontmatter = &text[..frontmatter_end]; let body = &text[frontmatter_end..]; Some((frontmatter, body)) } /// One `#* Name: value` line from a document's editorial header. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Field { /// The field name as written, e.g. `Word Count Target`. pub name: String, /// Everything after the colon, trimmed. May be empty for an unfilled field. pub value: String, } /// Every `#* Name: value` line in a document's header, in the order written. /// /// This is the generic form of what [`parse`] pulls out for the few fields the /// exporter cares about: the scene card, the status column and the character /// index all read fields the exporter has no opinion about, so they read them /// from here rather than each re-implementing the line format. /// /// Only the header is considered — lines above `marker`, or the whole document /// when there is none — so a `## Setting:` written inside the prose is not /// mistaken for card metadata. pub fn fields(markdown: &str, marker: &str) -> Vec { let text = strip_comments(markdown); let marker_norm = marker.trim().to_ascii_lowercase(); let mut out = Vec::new(); for line in text.lines() { if !marker_norm.is_empty() && line.trim().to_ascii_lowercase().starts_with(&marker_norm) { break; } if let Some(field) = field_line(line) { out.push(field); } } out } /// Look up one field by name, case-insensitively. Empty values give `None`, so /// an unfilled `## POV:` reads the same as an absent one. pub fn field(markdown: &str, marker: &str, name: &str) -> Option { fields(markdown, marker) .into_iter() .find(|f| f.name.eq_ignore_ascii_case(name.trim())) .map(|f| f.value) .filter(|v| !v.is_empty()) } /// Split a comma-separated field value into its items, dropping blanks — the /// form `## Characters: Bixby, Mom, Kayla` uses. pub fn value_items(value: &str) -> Vec { value .split(',') .map(str::trim) .filter(|item| !item.is_empty()) .map(str::to_string) .collect() } /// If `line` is a header field, split it into name and value. /// /// Two forms are in use and both are accepted, because real files mix them: /// scene cards write `## POV: Bixby`, while character sheets often write a bare /// `Age: 37`. The bare form is the looser one, so it carries a stricter test — /// its name may only be letters, spaces, hyphens and apostrophes — which keeps /// ordinary prose out. A line of dialogue like `Bixby: run` would still be read /// as a field; only the header is scanned, so that matters just for documents /// with no draft marker, where the header is the whole file. fn field_line(line: &str) -> Option { let trimmed = line.trim_start(); let hashes = trimmed.len() - trimmed.trim_start_matches('#').len(); let (rest, headed) = if hashes > 0 { let after_hash = &trimmed[hashes..]; if !after_hash.starts_with(char::is_whitespace) { return None; } (after_hash.trim(), true) } else { // A bare field must start the line, so an indented or wrapped line of // prose is never mistaken for one. if line.starts_with(char::is_whitespace) { return None; } (trimmed, false) }; let (name, value) = rest.split_once(':')?; let name = name.trim(); // A field name is a short label, not a sentence. The longest in real use is // three words ("Word Count Target"), so four leaves headroom while rejecting // most prose headings that happen to contain a colon. if name.is_empty() || name.split_whitespace().count() > 4 || name.ends_with('.') { return None; } if !headed && !name .chars() .all(|c| c.is_alphabetic() || c == ' ' || c == '-' || c == '\'') { return None; } Some(Field { name: name.to_string(), value: value.trim().to_string(), }) } /// If `line` is a recognised metadata line — `# Title:`, `# Slug:`, or /// `## Word Count Target:` — return which one and its value. Matches one or more /// leading `#` followed by whitespace, case-insensitively, so the field can sit /// at any heading level (`#`, `##`, …). fn meta_line(line: &str) -> Option<(Meta, String)> { let trimmed = line.trim_start(); let hashes = trimmed.len() - trimmed.trim_start_matches('#').len(); if hashes == 0 { return None; } let after_hash = &trimmed[hashes..]; if !after_hash.starts_with(char::is_whitespace) { return None; } let rest = after_hash.trim_start(); let lower = rest.to_ascii_lowercase(); // Longer keys first so "word count target:" wins over "word count:". for (key, meta) in [ ("title:", Meta::Title), ("slug:", Meta::Slug), ("pov:", Meta::Pov), ("word count target:", Meta::WordCount), ("word count goal:", Meta::WordCount), ("word count:", Meta::WordCount), ] { 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. /// Tries YAML frontmatter first, then falls back to the legacy marker-based header. /// `marker` is the legacy header/body separator line (e.g. `### Rough Draft:`); an empty /// marker disables marker-based splitting, but YAML frontmatter is still parsed. 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(); // Try YAML frontmatter first if let Some(body_start_line) = find_yaml_frontmatter_end(&lines) { // Parse metadata from YAML frontmatter let mut title = None; let mut slug = None; let mut pov = None; let mut goal = None; // Extract YAML fields from the frontmatter lines for line in &lines[..body_start_line] { // Skip the --- delimiters if line.trim() == "---" { continue; } // Parse YAML-style key: value lines if let Some((key, value)) = parse_yaml_field(line) { match key.to_ascii_lowercase().as_str() { "title" => title = Some(value), "slug" => slug = Some(value), "pov" => pov = Some(value), "word_count_target" | "word count target" | "wordcounttarget" => { goal = parse_goal(&value); } _ => {} } } } // Body is everything after the closing --- let body_lines: Vec<&str> = lines[body_start_line..].to_vec(); return Header { title: title.filter(|s| !s.is_empty()), slug: slug.filter(|s| !s.is_empty()), pov: pov.filter(|s| !s.is_empty()), goal, body: body_lines.join("\n").trim().to_string(), }; } // Fall back to legacy marker-based parsing 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 pov = None; let mut goal = None; let mut set = |meta: Meta, value: String| match meta { Meta::Title => title = Some(value), Meta::Slug => slug = Some(value), Meta::Pov => pov = Some(value), Meta::WordCount => goal = parse_goal(&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()), pov: pov.filter(|s| !s.is_empty()), goal, body: body_lines.join("\n").trim().to_string(), } } /// Find the line index where YAML frontmatter ends (after the closing `---`). /// Returns Some(body_start_line) where body_start_line is the index after the closing ---. fn find_yaml_frontmatter_end(lines: &[&str]) -> Option { if lines.is_empty() || lines[0].trim() != "---" { return None; } for (i, line) in lines.iter().enumerate().skip(1) { if line.trim() == "---" { return Some(i + 1); } } None } /// Parse a YAML field line like `key: value` or `key: "value"`. /// Returns (key, value) if the line looks like a YAML field. /// Strips inline comments (everything after # that's not in quotes). fn parse_yaml_field(line: &str) -> Option<(String, String)> { let trimmed = line.trim(); // Skip empty lines and full-line comments if trimmed.is_empty() || trimmed.starts_with('#') { return None; } // Strip inline comments first (everything after # that's not in quotes) let mut in_quote = false; let mut quote_char = '\0'; let mut comment_pos = None; for (i, c) in trimmed.char_indices() { match c { '"' | '\'' => { if in_quote && c == quote_char { in_quote = false; quote_char = '\0'; } else if !in_quote { in_quote = true; quote_char = c; } } '#' => { if !in_quote && comment_pos.is_none() { comment_pos = Some(i); } } _ => {} } } // Trim at the comment position if found let trimmed_no_comment = if let Some(pos) = comment_pos { trimmed[..pos].trim() } else { trimmed }; if trimmed_no_comment.is_empty() { return None; } // Find the first colon that's not part of a quoted string let mut in_quote = false; let mut quote_char = '\0'; let mut colon_pos = None; for (i, c) in trimmed_no_comment.char_indices() { match c { '"' | '\'' => { if in_quote && c == quote_char { in_quote = false; quote_char = '\0'; } else if !in_quote { in_quote = true; quote_char = c; } } ':' => { if !in_quote && colon_pos.is_none() { colon_pos = Some(i); } } _ => {} } } let colon_pos = colon_pos?; let key = trimmed_no_comment[..colon_pos].trim().to_string(); let value = trimmed_no_comment[colon_pos + 1..].trim(); // Remove surrounding quotes if present let value = if value.starts_with('"') && value.ends_with('"') { value[1..value.len() - 1].to_string() } else if value.starts_with('\'') && value.ends_with('\'') { value[1..value.len() - 1].to_string() } else { value.to_string() }; if key.is_empty() { None } else { Some((key, value)) } } #[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")); } #[test] fn parses_word_count_target_range_at_heading_level_two() { let md = "# Title: Bixby\n## Word Count Target: 1500 - 2000\n\ ## POV: Bixby\n### Rough Draft:\n\nThe prose."; let h = parse(md, MARKER); assert_eq!(h.goal, Some(WordGoal { min: 1500, max: 2000 })); // The metadata line is lifted out of the body; POV (unrecognised) is // above the marker so it's dropped too. assert_eq!(h.body, "The prose."); } #[test] fn word_count_target_accepts_various_forms() { assert_eq!(parse_goal("1500 - 2000"), Some(WordGoal { min: 1500, max: 2000 })); assert_eq!(parse_goal("1500-2000"), Some(WordGoal { min: 1500, max: 2000 })); assert_eq!(parse_goal("1,500 to 2,000"), Some(WordGoal { min: 1500, max: 2000 })); assert_eq!(parse_goal("2000 – 1500"), Some(WordGoal { min: 1500, max: 2000 })); assert_eq!(parse_goal("about 2000"), Some(WordGoal { min: 2000, max: 2000 })); assert_eq!(parse_goal(" "), None); assert_eq!(parse_goal("none"), None); } #[test] fn word_count_goal_alias_and_no_marker() { // The alias spelling and no draft marker still yield a goal. let md = "## Word Count Goal: 800\n\nJust prose."; let h = parse(md, MARKER); assert_eq!(h.goal, Some(WordGoal { min: 800, max: 800 })); assert_eq!(h.body, "Just prose."); } #[test] fn no_goal_when_absent() { let md = "# Title: Plain\n### Rough Draft:\n\nText."; assert_eq!(parse(md, MARKER).goal, None); } #[test] fn parses_pov_at_heading_level_two() { let md = "# Title: Bixby\n## POV: Bixby\n### Rough Draft:\n\nThe prose."; let h = parse(md, MARKER); assert_eq!(h.pov.as_deref(), Some("Bixby")); assert_eq!(h.body, "The prose."); // A prose line that merely starts with "POV" is not metadata. let md2 = "### Rough Draft:\n\nPOV of the crowd was tense."; assert_eq!(parse(md2, MARKER).pov, None); } #[test] fn parses_yaml_frontmatter() { let md = "---\ntitle: The Gate\nslug: an opening\npov: Bixby\nword_count_target: 1500 - 2000\n---\n\nThe prose begins here.\n\n## A scene"; let h = parse(md, ""); assert_eq!(h.title.as_deref(), Some("The Gate")); assert_eq!(h.slug.as_deref(), Some("an opening")); assert_eq!(h.pov.as_deref(), Some("Bixby")); assert_eq!(h.goal, Some(WordGoal { min: 1500, max: 2000 })); assert!(h.body.starts_with("The prose begins here.")); assert!(h.body.contains("## A scene")); } #[test] fn parses_yaml_frontmatter_with_quoted_values() { let md = "---\ntitle: \"The Gate\"\nslug: \"an opening\"\n---\n\nProse here."; let h = parse(md, ""); assert_eq!(h.title.as_deref(), Some("The Gate")); assert_eq!(h.slug.as_deref(), Some("an opening")); } #[test] fn parses_yaml_frontmatter_with_comments() { let md = "---\n# This is a comment title: The Gate # inline comment\nslug: an opening\n---\n\nProse here."; let h = parse(md, ""); assert_eq!(h.title.as_deref(), Some("The Gate")); assert_eq!(h.slug.as_deref(), Some("an opening")); } #[test] fn falls_back_to_marker_if_no_yaml_frontmatter() { let md = "# Title: The Gate\n# Slug: an opening\n### Rough Draft:\n\nThe prose begins here."; let h = parse(md, "### Rough Draft:"); 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.")); } } #[cfg(test)] mod field_tests { use super::*; /// A real scene card from the snowflake plot folder. const CARD: &str = "\ # Title: # Slug: 2026 - Bixby ## POV: Bixby ## Characters: Bixby, Mom, Kayla ## Setting: The Old Reynold's House ## Word Count Target: 1500 - 2000 ### Rough Draft: She stood there. The setting: a house. Not a field: prose. "; #[test] fn every_header_field_is_read_in_order() { let names: Vec = fields(CARD, "### Rough Draft:") .into_iter() .map(|f| f.name) .collect(); assert_eq!( names, ["Title", "Slug", "POV", "Characters", "Setting", "Word Count Target"] ); } #[test] fn a_field_is_found_by_name_whatever_its_case() { assert_eq!(field(CARD, "### Rough Draft:", "POV").as_deref(), Some("Bixby")); assert_eq!(field(CARD, "### Rough Draft:", "pov").as_deref(), Some("Bixby")); assert_eq!( field(CARD, "### Rough Draft:", "Setting").as_deref(), Some("The Old Reynold's House") ); // An unfilled field reads as absent rather than as an empty string. assert_eq!(field(CARD, "### Rough Draft:", "Title"), None); assert_eq!(field(CARD, "### Rough Draft:", "Nope"), None); } /// The body is where prose lives; a colon in it is not card metadata. #[test] fn prose_below_the_marker_is_not_scanned() { assert_eq!(field(CARD, "### Rough Draft:", "The setting"), None); let names: Vec = fields(CARD, "### Rough Draft:") .into_iter() .map(|f| f.name) .collect(); assert!(!names.iter().any(|n| n.eq_ignore_ascii_case("Not a field"))); } #[test] fn a_comma_list_splits_into_items() { assert_eq!(value_items("Bixby, Mom, Kayla"), ["Bixby", "Mom", "Kayla"]); assert_eq!(value_items(" Bixby ,, Kayla "), ["Bixby", "Kayla"]); assert!(value_items("").is_empty()); assert_eq!(value_items("Bixby"), ["Bixby"]); } #[test] fn a_prose_heading_is_not_a_field() { // A heading long enough to be a sentence is a heading, not metadata. assert!(field_line("## And then she said to her: run").is_none()); assert!(field_line("## A very long heading indeed that goes on: yes").is_none()); // The longest field name actually in use still parses. assert_eq!( field_line("## Word Count Target: 1500").map(|f| f.name), Some("Word Count Target".to_string()) ); assert!(field_line("## No colon here").is_none()); // A bare field line is accepted — character sheets are written that way. assert_eq!( field_line("Age: 37"), Some(Field { name: "Age".into(), value: "37".into() }) ); // But not one whose label could not be a label. assert!(field_line("At 17, Bixby was hurt: badly").is_none()); assert!(field_line(" indented: value").is_none()); assert!(field_line("2026 - Bixby: something").is_none()); assert!(field_line("#NoSpace: value").is_none()); // But a short label is. assert_eq!( field_line("## POV: Bixby"), Some(Field { name: "POV".into(), value: "Bixby".into() }) ); } #[test] fn comments_are_stripped_before_fields_are_read() { let text = "\n## POV: Bixby\n"; assert_eq!(field(text, "", "POV").as_deref(), Some("Bixby")); } #[test] fn without_a_marker_the_whole_document_is_scanned() { assert_eq!(field("## POV: Ada\n\nprose\n", "", "POV").as_deref(), Some("Ada")); } } #[cfg(test)] mod split_tests { use super::*; const DOC: &str = "# Title: A\n\n## POV: Ada\n\n### Rough Draft:\n\nShe waited.\n"; #[test] fn the_split_is_lossless() { let (header, body) = split_at_marker(DOC, "### Rough Draft:").unwrap(); assert_eq!(format!("{header}{body}"), DOC, "rejoining must be exact"); assert!(header.ends_with("### Rough Draft:\n")); assert_eq!(body, "\nShe waited.\n"); } #[test] fn the_marker_is_matched_loosely_like_everywhere_else() { // Case and trailing text on the marker line are tolerated. let doc = "# A\n### rough draft: \n\nprose\n"; let (header, body) = split_at_marker(doc, "### Rough Draft:").unwrap(); assert!(header.ends_with("### rough draft: \n")); assert_eq!(body, "\nprose\n"); } #[test] fn a_document_without_a_marker_does_not_split() { assert!(split_at_marker("# A\n\nprose\n", "### Rough Draft:").is_none()); // An empty marker disables splitting, as it disables header parsing. assert!(split_at_marker(DOC, "").is_none()); assert!(split_at_marker(DOC, " ").is_none()); } #[test] fn only_the_first_marker_splits() { let doc = "# A\n### Rough Draft:\nfirst\n### Rough Draft:\nsecond\n"; let (header, body) = split_at_marker(doc, "### Rough Draft:").unwrap(); assert_eq!(header, "# A\n### Rough Draft:\n"); assert!(body.starts_with("first\n")); } #[test] fn a_marker_on_the_last_line_leaves_an_empty_body() { let doc = "# A\n### Rough Draft:"; let (header, body) = split_at_marker(doc, "### Rough Draft:").unwrap(); assert_eq!(header, doc); assert_eq!(body, ""); } #[test] fn multibyte_text_splits_on_a_character_boundary() { let doc = "# Título: Café\n### Rough Draft:\nprosa — sí\n"; let (header, body) = split_at_marker(doc, "### Rough Draft:").unwrap(); assert_eq!(format!("{header}{body}"), doc); assert_eq!(body, "prosa — sí\n"); } }