feat: Remove ### Rough Draft: marker, use YAML frontmatter only for draft files

- Everything after YAML frontmatter closing --- is now considered the draft content
- No ### Rough Draft: marker needed
- Chapter titles are handled by the program on export based on file order
- Draft content should not include chapter titles
- Updated default_new_file_template to use YAML frontmatter
- Updated preprocess.rs to parse YAML frontmatter for metadata
- Updated all relevant tests
- Updated documentation in help.rs
- Updated template files

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
landon
2026-09-16 13:33:23 -05:00
parent aafe1786b5
commit a0d1c3c206
11 changed files with 243 additions and 550 deletions
+191 -2
View File
@@ -281,13 +281,55 @@ fn meta_line(line: &str) -> Option<(Meta, String)> {
}
/// 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.
/// 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 {
@@ -345,6 +387,115 @@ pub fn parse(markdown: &str, marker: &str) -> Header {
}
}
/// 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<usize> {
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::*;
@@ -443,6 +594,44 @@ mod tests {
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)]