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
+2 -2
View File
@@ -834,8 +834,8 @@ mod tests {
#[test]
fn commented_prose_is_dropped_from_the_exported_body() {
// The whole point of the hotkey: preprocess must not see the hidden line.
let md = "### Rough Draft:\n\nKeep this.\n\n<!-- Cut this. -->\n";
let body = crate::preprocess::parse(md, "### Rough Draft:").body;
let md = "---\ntitle: Test\n---\n\nKeep this.\n\n<!-- Cut this. -->\n";
let body = crate::preprocess::parse(md, "").body;
assert!(body.contains("Keep this."));
assert!(!body.contains("Cut this."));
}
+9 -13
View File
@@ -813,10 +813,6 @@ order: {}
---
## Draft Content
### Rough Draft:
[Write your scene content here...]
---
@@ -2027,14 +2023,14 @@ mod tests {
#[test]
fn render_template_expands_every_placeholder() {
let out = render_template(
"# Title: {{name}}\n# Started: {{date}}\n\n{{marker}}\n",
"---\ntitle: {{name}}\n---\n",
"untitled-7",
"### Rough Draft:",
"",
"2026-08-22",
);
assert_eq!(
out,
"# Title: untitled-7\n# Started: 2026-08-22\n\n### Rough Draft:\n"
"---\ntitle: untitled-7\n---\n"
);
}
@@ -2074,7 +2070,7 @@ mod tests {
assert_eq!(h.slug, None);
assert_eq!(h.pov, None);
assert_eq!(h.goal, None);
// Nothing above the marker leaks into the exported prose.
// Nothing above the --- leaks into the exported prose.
assert_eq!(h.body.trim(), "");
}
@@ -2083,8 +2079,7 @@ mod tests {
fn filled_template_parses_into_header_fields() {
let marker = crate::config::default_marker();
let seeded = render_template(
"# Title: {{name}}\n# Slug: the door\n# POV: Ada\n\
# Word Count Target: 1500 - 2000\n\n{{marker}}\n\nReal prose here.\n",
"---\ntitle: {{name}}\nslug: the door\npov: Ada\nword_count_target: 1500 - 2000\n---\n\nReal prose here.\n",
"chapter-02",
&marker,
"2026-08-22",
@@ -2102,11 +2097,12 @@ mod tests {
let out = render_template(
&crate::config::default_new_file_template(),
"chapter-01",
"### Rough Draft:",
"",
"2026-08-22",
);
assert!(out.starts_with("# Title: chapter-01\n"), "got {out:?}");
assert!(out.contains("\n### Rough Draft:\n"), "got {out:?}");
assert!(out.starts_with("---\n"), "got {out:?}");
assert!(out.contains("title: chapter-01"), "got {out:?}");
assert!(out.contains("---\n"), "got {out:?}");
assert!(!out.contains("{{"), "placeholder left unexpanded in {out:?}");
}
+1 -1
View File
@@ -249,7 +249,7 @@ mod tests {
Character {
name: heading_name(text).unwrap_or_default(),
file: PathBuf::from("x.md"),
fields: crate::preprocess::fields(text, "### Rough Draft:"),
fields: crate::preprocess::fields(text, ""),
subtitle: subtitle(text),
}
}
+11 -15
View File
@@ -236,20 +236,16 @@ pub fn default_projects_dir() -> PathBuf {
})
}
/// Default new-file template: the editorial header fields this app already
/// understands, followed by the draft marker so the body starts below it.
///
/// Note: For YAML frontmatter support, create files manually with --- delimiters.
/// The legacy header format is kept for backward compatibility with the parser.
/// Default new-file template: Uses YAML frontmatter for metadata,
/// with everything after the closing --- being the draft content.
pub fn default_new_file_template() -> String {
[
"# Title: {{name}}",
"# Slug:",
"# POV:",
"# Word Count Target:",
"",
"{{marker}}",
"",
"---",
"title: {{name}}",
"slug: ",
"pov: ",
"word_count_target: ",
"---",
"",
]
.join("\n")
@@ -296,8 +292,9 @@ pub fn default_languagetool_language() -> String {
}
/// Default header/body separator, matching the manuscript drafting convention.
/// Now empty by default since we use YAML frontmatter.
pub fn default_marker() -> String {
"### Rough Draft:".to_string()
"".to_string()
}
impl Default for Config {
@@ -456,7 +453,7 @@ mod tests {
assert!(cfg.show_preview);
// The new field is filled in from its default rather than left blank.
assert_eq!(cfg.new_file_template, default_new_file_template());
assert!(cfg.new_file_template.contains("{{marker}}"));
assert!(cfg.new_file_template.contains("---"));
// Likewise the Gitea pair. `use_ssh` defaulting to *true* is the point:
// a `#[serde(default)]` bool would come back false and quietly attach
// every repository over HTTPS.
@@ -472,7 +469,6 @@ mod tests {
"workspace": "/home/writer/Manuscript",
"export_path": "/home/writer/Manuscript/book.odt",
"show_preview": true,
"draft_marker": "### Rough Draft:",
"editor_zoom": 10.0,
"languagetool_host": "lt.example.com",
"mistral_api_key": "secret",
+11 -9
View File
@@ -1,6 +1,6 @@
//! In-app **Markdown cheatsheet** window: a quick reference for the markdown
//! md-manuscript understands, plus the manuscript-specific conventions (chapter
//! metadata, the draft-marker header, and comments) applied at export time.
//! md-manuscript understands, plus the manuscript-specific conventions (YAML frontmatter
//! metadata and comments) applied at export time.
//!
//! Pure rendering — the caller owns the `open` flag so the window's ✕ works via
//! [`egui::Window::open`].
@@ -136,24 +136,26 @@ fn cheatsheet_body(ui: &mut egui::Ui) {
ui,
"cs-header",
&[
("### Rough Draft:", "The draft marker — everything above it is your working header."),
("---", "YAML frontmatter delimiter — metadata between --- lines is not exported."),
("title: ...", "Chapter title (used for export, not included in draft content)."),
("slug: ...", "Optional caption shown beneath the chapter title."),
("pov: ...", "Point of view character for this chapter."),
("word_count_target: ...", "Target word count (e.g., 2000 or 1500-2000)."),
("<!-- note -->", "A comment; dropped on export (may span multiple lines)."),
],
);
note(
ui,
"The draft marker text is configurable in the top bar. Everything above the \
marker line is treated as notes and left out of the export; leave the marker \
blank to export the whole file. Comments are removed everywhere.",
"Files use YAML frontmatter for metadata. Everything between --- delimiters is \
metadata and is not included in the export. Comments are removed everywhere. \
Chapter titles are automatically numbered based on file order in the file list.",
);
note(
ui,
"Typing a header field name above the marker (e.g. “## PO”) pops up an \
"Typing a YAML field name (e.g. \"title:\" or \"pov:\") in the frontmatter pops up an \
autocomplete — Tab/Enter to accept, ↑/↓ to choose, Esc to dismiss. It \
suggests common fields plus ones learned from your other files.",
);
section(ui, "Spelling & grammar");
body(
ui,
"Spelling is checked live and offline: misspellings are underlined in red \
+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)]
@@ -1,128 +1,13 @@
---
id: scene_01
title: "Chapter One: The Beginning"
slug: chapter-one-the-beginning
title: "Scene 1"
slug: "scene-1"
act: 1
chapter: 1
scene: 1
type: chapter
type: scene
status: draft
word_count_target: 2000
word_count_min: 1800
word_count_max: 2200
pov: ""
characters: []
setting: ""
date: ""
time: ""
conflict: ""
theme: ""
tone: ""
word_count: 0
word_count_updated: ""
session_word_count: 0
last_session: ""
total_sessions: 0
version: 1
created: ""
updated: ""
order: 1
---
# Chapter One: The Beginning
**Scene File** | **ID:** scene_01 | **Status:** Draft
---
## Scene Metadata
| Attribute | Value |
|-----------|-------|
| **Act** | 1 |
| **Chapter** | 1 |
| **Scene** | 1 |
| **Type** | Chapter |
| **POV** | Not set |
| **Version** | 1 |
| **Word Count Target** | 2000 |
---
## Word Count Tracking
| Metric | Value |
|--------|-------|
| **Target** | 2000 |
| **Minimum** | 1800 |
| **Maximum** | 2200 |
| **Current** | 0 |
| **This Session** | 0 |
| **Last Updated** | Never |
---
## Creative Elements
### POV
> Not specified
### Characters
### Setting
> Not specified
### Date & Time
> Not specified | Not specified
---
## Story Structure
### Conflict
> Not specified
### Theme
> Not specified
### Tone
> Not specified
---
## Draft Content
### Rough Draft:
[Write your scene content here...]
---
## Writing Notes
### Session Log
| Date | Words Added | Notes |
|------|--------------|-------|
### Revision Notes
-
-
---
## Status Checklist
- [ ] Planning complete
- [ ] First draft complete
- [ ] Word count target met
- [ ] POV consistent
- [ ] Character voices distinct
- [ ] Setting details accurate
- [ ] Conflict resolved/advanced
- [ ] Ready for revision
---
**Created:** | **Updated:**
@@ -1,129 +1,13 @@
---
id: scene_02_01
title: "Chapter Two: Development"
slug: chapter-two-development
title: "Scene 2"
slug: "scene-2"
act: 2
chapter: 1
scene: 1
type: chapter
chapter: 2
scene: 2
type: scene
status: draft
word_count_target: 2000
word_count_min: 1800
word_count_max: 2200
pov: ""
characters: []
setting: ""
date: ""
time: ""
conflict: ""
theme: ""
tone: ""
word_count: 0
word_count_updated: ""
session_word_count: 0
last_session: ""
total_sessions: 0
version: 1
created: ""
updated: ""
planning_template: ""
draft_file: ""
order: 1
---
# Chapter Two: Development
**Draft File** | **ID:** scene_02_01 | **Status:** Draft
---
## Scene Metadata
| Attribute | Value |
|-----------|-------|
| **Act** | 2 |
| **Chapter** | 1 |
| **Scene** | 1 |
| **Type** | Chapter |
| **POV** | Not set |
| **Version** | 1 |
| **Word Count Target** | 2000 |
---
## Word Count Tracking
| Metric | Value |
|--------|-------|
| **Target** | 2000 |
| **Minimum** | 1800 |
| **Maximum** | 2200 |
| **Current** | 0 |
| **This Session** | 0 |
| **Last Updated** | Never |
---
## Creative Elements
### POV
> Not specified
### Characters
### Setting
> Not specified
### Date & Time
> Not specified | Not specified
---
## Story Structure
### Conflict
> Not specified
### Theme
> Not specified
### Tone
> Not specified
---
## Draft Content
### Rough Draft:
[Write your scene content here...]
---
## Writing Notes
### Session Log
| Date | Words Added | Notes |
|------|--------------|-------|
### Revision Notes
-
-
---
## Status Checklist
- [ ] Planning complete
- [ ] First draft complete
- [ ] Word count target met
- [ ] POV consistent
- [ ] Character voices distinct
- [ ] Setting details accurate
- [ ] Conflict resolved/advanced
- [ ] Ready for revision
---
**Created:** | **Updated:**
@@ -1,129 +1,13 @@
---
id: scene_03_01
title: "Chapter Three: Climax"
slug: chapter-three-climax
title: "Scene 3"
slug: "scene-3"
act: 3
chapter: 1
scene: 1
type: chapter
chapter: 3
scene: 3
type: scene
status: draft
word_count_target: 2000
word_count_min: 1800
word_count_max: 2200
pov: ""
characters: []
setting: ""
date: ""
time: ""
conflict: ""
theme: ""
tone: ""
word_count: 0
word_count_updated: ""
session_word_count: 0
last_session: ""
total_sessions: 0
version: 1
created: ""
updated: ""
planning_template: ""
draft_file: ""
order: 1
---
# Chapter Three: Climax
**Draft File** | **ID:** scene_03_01 | **Status:** Draft
---
## Scene Metadata
| Attribute | Value |
|-----------|-------|
| **Act** | 3 |
| **Chapter** | 1 |
| **Scene** | 1 |
| **Type** | Chapter |
| **POV** | Not set |
| **Version** | 1 |
| **Word Count Target** | 2000 |
---
## Word Count Tracking
| Metric | Value |
|--------|-------|
| **Target** | 2000 |
| **Minimum** | 1800 |
| **Maximum** | 2200 |
| **Current** | 0 |
| **This Session** | 0 |
| **Last Updated** | Never |
---
## Creative Elements
### POV
> Not specified
### Characters
### Setting
> Not specified
### Date & Time
> Not specified | Not specified
---
## Story Structure
### Conflict
> Not specified
### Theme
> Not specified
### Tone
> Not specified
---
## Draft Content
### Rough Draft:
[Write your scene content here...]
---
## Writing Notes
### Session Log
| Date | Words Added | Notes |
|------|--------------|-------|
### Revision Notes
-
-
---
## Status Checklist
- [ ] Planning complete
- [ ] First draft complete
- [ ] Word count target met
- [ ] POV consistent
- [ ] Character voices distinct
- [ ] Setting details accurate
- [ ] Conflict resolved/advanced
- [ ] Ready for revision
---
**Created:** | **Updated:**
@@ -1,164 +1,21 @@
---
# Merged Planning + Draft Template
# YAML frontmatter contains all planning fields; body is the draft content
id: scene_new
# Unique identifier for this scene
# Basic metadata for your scene/chapter
# Everything after the closing --- is your draft content
id: ""
title: "New Scene Title"
# Display title for the scene
slug: new-scene-title
# URL-friendly slug
# === Core Metadata ===
slug: ""
act: 1
chapter: 1
scene: 99
scene: 1
type: scene
# Type: scene, chapter, interlude, etc.
# === Planning Fields ===
status: draft
# Status: planning, draft, revised, done
word_count_target: 2000
word_count_min: 1800
word_count_max: 2200
# === Creative Elements ===
pov: ""
# Point of view character
characters: []
# Characters appearing in this scene (array of names)
setting: ""
# Location/setting
date: ""
time: ""
# Date and time of scene
conflict: ""
# Central conflict in this scene
theme: ""
# Themes explored
tone: ""
# Mood/tone
# === Draft Tracking ===
word_count: 0
word_count_updated: ""
session_word_count: 0
last_session: ""
total_sessions: 0
version: 1
# === Timestamps ===
created: ""
updated: ""
# === Linking ===
# These are kept for backward compatibility but are no longer needed
# since planning and draft are merged
planning_template: ""
draft_file: ""
order: 99
---
# New Scene Title
**Scene File** | **ID:** scene_new | **Status:** Draft
---
## Scene Metadata
| Attribute | Value |
|-----------|-------|
| **Act** | 1 |
| **Chapter** | 1 |
| **Scene** | 99 |
| **Type** | Scene |
| **POV** | Not set |
| **Version** | 1 |
| **Word Count Target** | 2000 |
---
## Word Count Tracking
| Metric | Value |
|--------|-------|
| **Target** | 2000 |
| **Minimum** | 1800 |
| **Maximum** | 2200 |
| **Current** | 0 |
| **This Session** | 0 |
| **Last Updated** | Never |
---
## Creative Elements
### POV
> Not specified
### Characters
### Setting
> Not specified
### Date & Time
> Not specified | Not specified
---
## Story Structure
### Conflict
> Not specified
### Theme
> Not specified
### Tone
> Not specified
---
## Draft Content
### Rough Draft:
[Write your scene content here...]
---
## Writing Notes
### Session Log
| Date | Words Added | Notes |
|------|--------------|-------|
### Revision Notes
-
-
---
## Status Checklist
- [ ] Planning complete
- [ ] First draft complete
- [ ] Word count target met
- [ ] POV consistent
- [ ] Character voices distinct
- [ ] Setting details accurate
- [ ] Conflict resolved/advanced
- [ ] Ready for revision
---
**Created:** | **Updated:**