507e0ab724
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>
179 lines
6.0 KiB
Rust
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"));
|
|
}
|
|
}
|