Files
md-manuscript/src/preprocess.rs
T
landon 507e0ab724 Strip comments and header metadata on ODT export
Each file is now cleaned before conversion: HTML comments are removed
and everything above the configurable Draft marker (default
"### Rough Draft:") is dropped. A "# Title:" line becomes the chapter
heading and a "# Slug:" line becomes an italic caption beneath it.

- new preprocess module (strip_comments + header parse) with tests
- Chapter gains a slug rendered via a Chapter_20_Caption style, italic
  at both paragraph-style and character-run level
- draft_marker config setting + top-bar field
- export title priority: override > # Title: > first # heading > filename

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 19:01:45 -05:00

179 lines
6.0 KiB
Rust

//! 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<String>,
/// Value of the `# Slug:` line, if present and non-empty.
pub slug: Option<String>,
/// 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 `<!--[\s\S]*?-->`),
/// including comments that span multiple lines. An unterminated comment drops
/// everything from `<!--` onward.
pub fn strip_comments(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(start) = rest.find("<!--") {
out.push_str(&rest[..start]);
match rest[start + 4..].find("-->") {
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 <!-- note --> after\n<!--\nblock\ncomment\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 <!-- oops never closed");
assert_eq!(out, "keep ");
}
#[test]
fn splits_header_at_marker() {
let md = "# Title: The Gate\n# Slug: an opening\n<!-- outline -->\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"));
}
}