Bundle the project template into the binary; add outline-to-draft
links, a sprint timer, placeholder scanning, corkboard, and a quick-capture inbox - Compile templates/snowflake into the binary via include_dir, so New project… never depends on files installed beside the executable; Settings ▸ Template becomes an optional external override, and a broken saved override now falls back to the bundled template instead of failing project generation. - Outline beats now link to a matching chapter for a one-click jump. - Sprint window: a daily whole-manuscript word goal plus a Pomodoro-style writing sprint timer, with a compact countdown in the status bar while in Distraction-free mode. - Placeholders window: find [TODO]/[TK]/[FIXME]/[XXX]/[NOTE]/[???] markers left in the draft and jump straight to any of them. - Snowflake progress strip in the file panel: a nudge-only readout of which snowflake stage folders actually have content. - Corkboard window: drag-reorderable synopsis cards, one per chapter. - Quick-capture popup (Ctrl+Shift+N): jot a stray idea straight into the project's Scratch Pad without losing your place in the draft. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+128
-16
@@ -3,12 +3,81 @@
|
||||
//! This module replaces the cookiecutter dependency by providing native Rust
|
||||
//! templating for project generation. It uses MiniJinja which is Jinja2-compatible,
|
||||
//! so existing cookiecutter templates (like snowflake) work with minimal changes.
|
||||
//!
|
||||
//! The default (bundled) template's files are compiled into the binary via
|
||||
//! [`BUNDLED_SNOWFLAKE`], so **File ▸ New project…** works out of the box —
|
||||
//! no `templates/` folder has to be installed beside the executable. A writer
|
||||
//! can still point at a folder of their own (`Settings ▸ New project… ▸
|
||||
//! Template`) to use a customized copy instead; see [`TemplateSource`].
|
||||
|
||||
use include_dir::{include_dir, Dir};
|
||||
use minijinja::Environment;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::fs;
|
||||
|
||||
/// The bundled "snowflake" project template, compiled into the binary so
|
||||
/// **File ▸ New project…** never depends on a `templates/` folder installed
|
||||
/// beside the executable.
|
||||
static BUNDLED_SNOWFLAKE: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/templates/snowflake");
|
||||
|
||||
/// Where a project template comes from: the one compiled into the binary, or
|
||||
/// a folder on disk the writer has pointed **Settings ▸ New project… ▸
|
||||
/// Template** at (to use a customized copy of their own).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TemplateSource {
|
||||
Bundled,
|
||||
External(PathBuf),
|
||||
}
|
||||
|
||||
impl TemplateSource {
|
||||
/// Whether this source is actually usable. `Bundled` always is — it's
|
||||
/// compiled in; `External` needs a real `cookiecutter.json` at the path.
|
||||
pub fn is_available(&self) -> bool {
|
||||
match self {
|
||||
TemplateSource::Bundled => true,
|
||||
TemplateSource::External(path) => path.join("cookiecutter.json").is_file(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes its directory on drop, so the bundled template's temp extraction
|
||||
/// is cleaned up whether `render_template` returns `Ok` or `Err`.
|
||||
struct TempExtraction {
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Drop for TempExtraction {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the bundled template out to a fresh temp directory, so the rest of
|
||||
/// the render pipeline can treat it exactly like an on-disk template.
|
||||
fn extract_bundled() -> Result<(PathBuf, TempExtraction), String> {
|
||||
let unique = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"md-manuscript-template-{}-{unique}",
|
||||
std::process::id()
|
||||
));
|
||||
// `Dir::extract` fails if any file already exists, so clear any stale
|
||||
// leftovers (e.g. from a previous crash) first.
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
// `extract` creates *sub*directories as it walks in, but never the root
|
||||
// path itself — with top-level files in the template (cookiecutter.json,
|
||||
// README.md), it would otherwise try to write into a directory that
|
||||
// doesn't exist yet.
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("Cannot create {}: {e}", dir.display()))?;
|
||||
BUNDLED_SNOWFLAKE
|
||||
.extract(&dir)
|
||||
.map_err(|e| format!("Failed to extract the bundled template: {e}"))?;
|
||||
Ok((dir.clone(), TempExtraction { dir }))
|
||||
}
|
||||
|
||||
/// Template variables passed to MiniJinja.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TemplateVariables {
|
||||
@@ -55,8 +124,9 @@ impl TemplateVariables {
|
||||
|
||||
/// Configuration for template rendering.
|
||||
pub struct RenderConfig {
|
||||
/// Path to the template directory (contains cookiecutter.json and template folder)
|
||||
pub template_dir: PathBuf,
|
||||
/// Where the template (with its cookiecutter.json and template folder)
|
||||
/// comes from.
|
||||
pub template: TemplateSource,
|
||||
/// Output directory where the project will be created
|
||||
pub output_dir: PathBuf,
|
||||
/// Template variables
|
||||
@@ -77,12 +147,24 @@ pub type RenderResult = Result<PathBuf, String>;
|
||||
/// a template expression (e.g., `{{ cookiecutter.project_name }}`) which
|
||||
/// becomes the project root after rendering.
|
||||
pub fn render_template(config: &RenderConfig) -> RenderResult {
|
||||
// Resolve the template to a real directory on disk: an external override
|
||||
// is used as-is, while the bundled template is extracted to a fresh temp
|
||||
// directory first. `_guard` cleans that temp directory up when this
|
||||
// function returns, however it returns.
|
||||
let (template_dir, _guard): (PathBuf, Option<TempExtraction>) = match &config.template {
|
||||
TemplateSource::External(path) => (path.clone(), None),
|
||||
TemplateSource::Bundled => {
|
||||
let (dir, guard) = extract_bundled()?;
|
||||
(dir, Some(guard))
|
||||
}
|
||||
};
|
||||
|
||||
// Verify the template directory has a cookiecutter.json
|
||||
let cookiecutter_json = config.template_dir.join("cookiecutter.json");
|
||||
let cookiecutter_json = template_dir.join("cookiecutter.json");
|
||||
if !cookiecutter_json.is_file() {
|
||||
return Err(format!(
|
||||
"No cookiecutter.json in {} — that folder is not a template.",
|
||||
config.template_dir.display()
|
||||
template_dir.display()
|
||||
));
|
||||
}
|
||||
|
||||
@@ -130,8 +212,8 @@ pub fn render_template(config: &RenderConfig) -> RenderResult {
|
||||
|
||||
// Find the template project directory (the one with the templated name)
|
||||
// This is typically named something like "{{ cookiecutter.project_name }}"
|
||||
let template_entries = fs::read_dir(&config.template_dir)
|
||||
.map_err(|e| format!("Cannot read template directory {}: {}", config.template_dir.display(), e))?;
|
||||
let template_entries = fs::read_dir(&template_dir)
|
||||
.map_err(|e| format!("Cannot read template directory {}: {}", template_dir.display(), e))?;
|
||||
|
||||
let mut template_project_dir: Option<PathBuf> = None;
|
||||
for entry in template_entries {
|
||||
@@ -149,7 +231,7 @@ pub fn render_template(config: &RenderConfig) -> RenderResult {
|
||||
|
||||
let template_project_dir = template_project_dir.ok_or(format!(
|
||||
"No template project directory found in {}. Expected a directory with a templated name like '{{{{ cookiecutter.project_name }}}}'.",
|
||||
config.template_dir.display()
|
||||
template_dir.display()
|
||||
))?;
|
||||
|
||||
// Render the project directory name
|
||||
@@ -307,22 +389,19 @@ mod tests {
|
||||
assert_eq!(name, "test project");
|
||||
}
|
||||
|
||||
/// Integration test: render the snowflake template using MiniJinja.
|
||||
/// This tests that the templating works end-to-end with a real template.
|
||||
/// Integration test: render the bundled snowflake template using
|
||||
/// MiniJinja. This tests that the templating works end-to-end, and that
|
||||
/// the compiled-in template is actually extracted and used correctly —
|
||||
/// there's no "skip if the template isn't installed" escape hatch
|
||||
/// anymore, since it's always available now.
|
||||
#[test]
|
||||
fn test_render_snowflake_template() {
|
||||
let template_dir = crate::config::default_project_template();
|
||||
if !template_dir.join("cookiecutter.json").is_file() {
|
||||
eprintln!("skipping: no snowflake template at {}", template_dir.display());
|
||||
return;
|
||||
}
|
||||
|
||||
let out = std::env::temp_dir().join("md_manuscript_minijinja_test");
|
||||
let _ = std::fs::remove_dir_all(&out);
|
||||
std::fs::create_dir_all(&out).unwrap();
|
||||
|
||||
let config = RenderConfig {
|
||||
template_dir,
|
||||
template: TemplateSource::Bundled,
|
||||
output_dir: out.clone(),
|
||||
variables: TemplateVariables::new(
|
||||
"MiniJinja Test Project",
|
||||
@@ -369,4 +448,37 @@ mod tests {
|
||||
// Clean up
|
||||
let _ = std::fs::remove_dir_all(&out);
|
||||
}
|
||||
|
||||
/// The embed itself has to actually hold the template — a hermetic check
|
||||
/// (no filesystem, no temp directory) that what's compiled into the
|
||||
/// binary is a real, usable snowflake template.
|
||||
#[test]
|
||||
fn bundled_template_contains_the_expected_files() {
|
||||
assert!(
|
||||
BUNDLED_SNOWFLAKE.get_file("cookiecutter.json").is_some(),
|
||||
"bundled template is missing cookiecutter.json"
|
||||
);
|
||||
let project_dir = BUNDLED_SNOWFLAKE
|
||||
.dirs()
|
||||
.find(|d| {
|
||||
d.path()
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|n| n.contains("{{"))
|
||||
})
|
||||
.expect("bundled template is missing its `{{ cookiecutter.project_name }}` folder");
|
||||
let subdir = crate::config::default_project_open_subdir();
|
||||
assert!(
|
||||
project_dir.get_dir(project_dir.path().join(&subdir)).is_some(),
|
||||
"bundled template is missing its {subdir} folder"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracting_the_bundled_template_cleans_up_after_itself() {
|
||||
let (dir, guard) = extract_bundled().expect("extraction should succeed");
|
||||
assert!(dir.join("cookiecutter.json").is_file());
|
||||
drop(guard);
|
||||
assert!(!dir.exists(), "temp extraction should be removed on drop");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user