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:
+191
-2
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user