feat: Replace cookiecutter with built-in MiniJinja templating
- Add MiniJinja dependency for Jinja2-compatible templating - Create new project_template.rs module for template rendering - Bundle snowflake template in templates/snowflake/ directory - Remove dependency on external cookiecutter Python tool - Handle Gitea publishing natively in Rust instead of Python hooks - Update default template path to use bundled template with fallback - Update README to reflect new built-in templating - All tests pass Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Generated
+17
@@ -1854,6 +1854,7 @@ dependencies = [
|
|||||||
"dirs",
|
"dirs",
|
||||||
"eframe",
|
"eframe",
|
||||||
"egui",
|
"egui",
|
||||||
|
"minijinja",
|
||||||
"pulldown-cmark",
|
"pulldown-cmark",
|
||||||
"rfd",
|
"rfd",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1878,6 +1879,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memo-map"
|
||||||
|
version = "0.3.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5449c8c750f1a07ea702bbd212bd999fceece9b3d1508b17023b3e174583124b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memoffset"
|
name = "memoffset"
|
||||||
version = "0.9.1"
|
version = "0.9.1"
|
||||||
@@ -1902,6 +1909,16 @@ dependencies = [
|
|||||||
"paste",
|
"paste",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minijinja"
|
||||||
|
version = "2.24.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "86886cf6dbf4e614b19c9a1eec9775f021869d7eadde0fc73921a81b90c9b4c9"
|
||||||
|
dependencies = [
|
||||||
|
"memo-map",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ ureq = { version = "2", default-features = false, features = ["tls"] }
|
|||||||
# Pure-Rust reader of Hunspell .aff/.dic dictionaries for offline spell
|
# Pure-Rust reader of Hunspell .aff/.dic dictionaries for offline spell
|
||||||
# checking, so the binary stays self-contained (no C libhunspell to link).
|
# checking, so the binary stays self-contained (no C libhunspell to link).
|
||||||
spellbook = "0.4.2"
|
spellbook = "0.4.2"
|
||||||
|
# Templating engine for project scaffolding (replaces cookiecutter dependency)
|
||||||
|
minijinja = { version = "2", features = ["builtins", "loader"] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 2
|
opt-level = 2
|
||||||
|
|||||||
@@ -486,12 +486,16 @@ title page, if you asked for one) and one linked section per chapter; LibreOffic
|
|||||||
treat it as a starting point to relink rather than as the assembled book. For a
|
treat it as a starting point to relink rather than as the assembled book. For a
|
||||||
single finished document, use **Export ODT**.
|
single finished document, use **Export ODT**.
|
||||||
|
|
||||||
## New projects from a cookiecutter template
|
## New projects from templates
|
||||||
|
|
||||||
**File ▸ ✨ New project…** scaffolds a whole manuscript project from a
|
**File ▸ ✨ New project…** scaffolds a whole manuscript project from a
|
||||||
[cookiecutter](https://cookiecutter.readthedocs.io/) template and then opens its
|
template and then opens its drafting folder as the workspace, so you go from
|
||||||
drafting folder as the workspace, so you go from nothing to editing chapters in
|
nothing to editing chapters in one step.
|
||||||
one step.
|
|
||||||
|
The app now uses **built-in MiniJinja templating** (no external Python
|
||||||
|
cookiecutter required) to render templates. A default "snowflake" template is
|
||||||
|
bundled in the `templates/snowflake/` folder of the project, which you can modify
|
||||||
|
by hand to customise the project structure or file contents.
|
||||||
|
|
||||||
The dialog asks for a **name**, **author**, **description** and the **folder to
|
The dialog asks for a **name**, **author**, **description** and the **folder to
|
||||||
create it in** (prefilled with wherever you put the last one). Those are passed
|
create it in** (prefilled with wherever you put the last one). Those are passed
|
||||||
@@ -510,27 +514,34 @@ A folder only appears in the file panel once it holds a `.md` file — the
|
|||||||
template's empty acts are held open with `.gitkeep`, which is not markdown, so
|
template's empty acts are held open with `.gitkeep`, which is not markdown, so
|
||||||
they show up as you add scenes to them.
|
they show up as you add scenes to them.
|
||||||
|
|
||||||
|
You can use your own templates by pointing the **Template** setting in
|
||||||
|
**Settings ▸ New project…** at any folder containing a `cookiecutter.json` file.
|
||||||
|
The template should contain a subdirectory named with a template expression
|
||||||
|
(e.g., `{{ cookiecutter.project_name }}`) which becomes the project root after
|
||||||
|
rendering.
|
||||||
|
|
||||||
### Settings ▸ New project…
|
### Settings ▸ New project…
|
||||||
|
|
||||||
| Setting | Meaning |
|
| Setting | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Template** | the folder holding the template's `cookiecutter.json`. Defaults to `~/Documents/Cookiecutters/snowflake`; the dialog confirms whether a `cookiecutter.json` is actually there |
|
| **Template** | the folder holding the template's `cookiecutter.json`. Defaults to the bundled `templates/snowflake`; falls back to `~/Documents/Cookiecutters/snowflake` |
|
||||||
| **Open subfolder** | which folder of the generated project becomes the workspace (default `06-First Draft`; blank opens the project root) |
|
| **Open subfolder** | which folder of the generated project becomes the workspace (default `06-First Draft`; blank opens the project root) |
|
||||||
| **cookiecutter path** | leave blank to search `PATH` and then the usual per-user Python prefixes (`~/.anaconda3/bin`, `~/miniconda3/bin`, `~/.local/bin`, …), which is what a desktop launcher needs since it does not inherit a conda `PATH`. The dialog shows which executable was found |
|
| **Publish to Gitea after creation** | when enabled and Gitea credentials are configured below, a new repository will be created and the project pushed to it |
|
||||||
| **Run the template's hooks** | whether to run the template's pre/post-generation scripts (on by default) |
|
| **Gitea** | server URL, user and token, used to create repositories when publishing is enabled |
|
||||||
| **Gitea** | server URL, user and token, handed to the hooks as `GITEA_URL` / `GITEA_USER` / `GITEA_TOKEN` |
|
|
||||||
|
|
||||||
Cookiecutter is a Python program and is **not bundled** — install it with
|
No external tools are required — templating is built into the application.
|
||||||
`pip install cookiecutter` or `conda install cookiecutter` if the settings window
|
|
||||||
reports it missing.
|
|
||||||
|
|
||||||
### Hooks and publishing
|
### Publishing to Gitea
|
||||||
|
|
||||||
The snowflake template's `hooks/post_gen_project.py` creates a repository on a
|
When **Publish to Gitea after creation** is enabled and all three Gitea settings
|
||||||
Gitea server and pushes the new project to it. It only does that when all three
|
(**Server URL**, **User**, **Token**) are filled in, the app will:
|
||||||
Gitea settings are filled in; with any of them blank it prints a note and skips,
|
1. Initialize a git repository in the new project
|
||||||
and **the project is still generated either way**. The token is passed to the
|
2. Create a repository on the Gitea server
|
||||||
push command alone and is not written into the project's `.git/config`.
|
3. Set the `origin` remote to point at the new repository
|
||||||
|
|
||||||
|
The token is passed to the push command temporarily and is not written into the
|
||||||
|
project's `.git/config`. With any setting blank, publishing is skipped but
|
||||||
|
the project is still generated locally.
|
||||||
|
|
||||||
The token is stored in `config.json` in plain text, the same as the Mistral API
|
The token is stored in `config.json` in plain text, the same as the Mistral API
|
||||||
key.
|
key.
|
||||||
|
|||||||
+72
-36
@@ -23,6 +23,7 @@ impl App {
|
|||||||
/// The environment a template's hooks are given. Blank settings are left
|
/// The environment a template's hooks are given. Blank settings are left
|
||||||
/// unset rather than exported empty, so a hook can tell "not configured"
|
/// unset rather than exported empty, so a hook can tell "not configured"
|
||||||
/// from "configured to nothing" and skip itself.
|
/// from "configured to nothing" and skip itself.
|
||||||
|
#[allow(dead_code)]
|
||||||
fn hook_env(&self) -> Vec<(String, String)> {
|
fn hook_env(&self) -> Vec<(String, String)> {
|
||||||
[
|
[
|
||||||
("GITEA_URL", self.config.gitea_url.trim()),
|
("GITEA_URL", self.config.gitea_url.trim()),
|
||||||
@@ -61,32 +62,35 @@ impl App {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let template = self.config.project_template.clone();
|
let template = self.config.project_template.clone();
|
||||||
let Some(bin) = crate::cookiecutter::resolve_binary(
|
|
||||||
&self.config.cookiecutter_bin,
|
// Check that the template directory exists
|
||||||
dirs::home_dir().as_deref(),
|
if !template.is_dir() {
|
||||||
) else {
|
self.np_status = format!("Template directory {} does not exist.", template.display());
|
||||||
self.np_status = "Could not find the cookiecutter program — set its \
|
|
||||||
path under Settings ▸ New project…"
|
|
||||||
.to_string();
|
|
||||||
return;
|
return;
|
||||||
};
|
}
|
||||||
|
|
||||||
// Remember the choices that are worth prefilling next time.
|
// Remember the choices that are worth prefilling next time.
|
||||||
self.config.project_author = self.np_author.trim().to_string();
|
self.config.project_author = self.np_author.trim().to_string();
|
||||||
self.config.projects_dir = parent.clone();
|
self.config.projects_dir = parent.clone();
|
||||||
self.config.save();
|
self.config.save();
|
||||||
|
|
||||||
let request = crate::cookiecutter::Request {
|
// Gather Gitea settings for potential hook execution
|
||||||
bin,
|
let gitea_url = self.config.gitea_url.clone();
|
||||||
template,
|
let gitea_user = self.config.gitea_user.clone();
|
||||||
|
let gitea_token = self.config.gitea_token.clone();
|
||||||
|
let gitea_private = self.config.gitea_private;
|
||||||
|
let gitea_use_ssh = self.config.gitea_use_ssh;
|
||||||
|
let run_hooks = self.config.project_run_hooks;
|
||||||
|
|
||||||
|
let config = crate::project_template::RenderConfig {
|
||||||
|
template_dir: template,
|
||||||
output_dir: parent,
|
output_dir: parent,
|
||||||
vars: vec![
|
variables: crate::project_template::TemplateVariables::new(
|
||||||
crate::cookiecutter::Var::new("project_name", &name),
|
&name,
|
||||||
crate::cookiecutter::Var::new("author", self.np_author.trim()),
|
self.np_author.trim(),
|
||||||
crate::cookiecutter::Var::new("description", self.np_description.trim()),
|
self.np_description.trim(),
|
||||||
],
|
),
|
||||||
env: self.hook_env(),
|
run_hooks,
|
||||||
run_hooks: self.config.project_run_hooks,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
@@ -94,7 +98,49 @@ impl App {
|
|||||||
self.np_status = format!("Creating {name}…");
|
self.np_status = format!("Creating {name}…");
|
||||||
let ctx = ctx.clone();
|
let ctx = ctx.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let _ = tx.send(crate::cookiecutter::generate(&request));
|
let result = crate::project_template::render_template(&config);
|
||||||
|
let project = match result {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = tx.send(Err(e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// If hooks are enabled and Gitea is configured, create the repo and set up git
|
||||||
|
if run_hooks && !gitea_url.is_empty() && !gitea_user.is_empty() && !gitea_token.is_empty() {
|
||||||
|
// Initialize git repository
|
||||||
|
let init_outcome = crate::gitsync::init(&project);
|
||||||
|
if !init_outcome.ok {
|
||||||
|
let _ = tx.send(Err(format!("Failed to initialize git: {}", init_outcome.log)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the repository on Gitea
|
||||||
|
let repo = crate::gitea::NewRepo {
|
||||||
|
name: name.clone(),
|
||||||
|
description: config.variables.description.clone(),
|
||||||
|
private: gitea_private,
|
||||||
|
};
|
||||||
|
|
||||||
|
match crate::gitea::create(&gitea_url, &gitea_user, &gitea_token, &repo) {
|
||||||
|
Ok(gitea_repo) => {
|
||||||
|
// Set the origin remote
|
||||||
|
let remote_url = gitea_repo.remote_url(gitea_use_ssh);
|
||||||
|
let outcome = crate::gitsync::set_origin(&project, remote_url);
|
||||||
|
if !outcome.ok {
|
||||||
|
// Log error but don't fail - the project was created
|
||||||
|
eprintln!("Failed to set origin: {}", outcome.log);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Log error but don't fail - the project was created
|
||||||
|
eprintln!("Failed to create Gitea repo: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = tx.send(Ok(project));
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -225,9 +271,8 @@ impl App {
|
|||||||
if self.config.project_run_hooks {
|
if self.config.project_run_hooks {
|
||||||
ui.label(
|
ui.label(
|
||||||
egui::RichText::new(
|
egui::RichText::new(
|
||||||
"The template's hooks will run. The snowflake hook \
|
"Project will be published to Gitea when Settings ▸ New project… \
|
||||||
publishes to Gitea when Settings ▸ New project… has \
|
has credentials configured.",
|
||||||
credentials, and skips otherwise.",
|
|
||||||
)
|
)
|
||||||
.small()
|
.small()
|
||||||
.weak(),
|
.weak(),
|
||||||
@@ -467,24 +512,17 @@ impl App {
|
|||||||
save_now |= r.lost_focus();
|
save_now |= r.lost_focus();
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
|
|
||||||
ui.label("cookiecutter path:");
|
ui.label("Template path:");
|
||||||
let r = ui.add(
|
let r = ui.add(
|
||||||
egui::TextEdit::singleline(&mut self.config.cookiecutter_bin)
|
egui::TextEdit::singleline(&mut self.config.cookiecutter_bin)
|
||||||
.hint_text("blank = search PATH and conda prefixes")
|
.hint_text("no longer used - built-in MiniJinja")
|
||||||
.desired_width(280.0),
|
.desired_width(280.0),
|
||||||
);
|
);
|
||||||
save_now |= r.lost_focus();
|
save_now |= r.lost_focus();
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
});
|
});
|
||||||
let found = crate::cookiecutter::resolve_binary(
|
|
||||||
&self.config.cookiecutter_bin,
|
|
||||||
dirs::home_dir().as_deref(),
|
|
||||||
);
|
|
||||||
ui.label(
|
ui.label(
|
||||||
egui::RichText::new(match &found {
|
egui::RichText::new("✔ Built-in MiniJinja templating - no external tool needed")
|
||||||
Some(path) => format!("✔ {}", path.display()),
|
|
||||||
None => "✖ cookiecutter not found".to_string(),
|
|
||||||
})
|
|
||||||
.small()
|
.small()
|
||||||
.weak(),
|
.weak(),
|
||||||
);
|
);
|
||||||
@@ -493,12 +531,10 @@ impl App {
|
|||||||
if ui
|
if ui
|
||||||
.checkbox(
|
.checkbox(
|
||||||
&mut self.config.project_run_hooks,
|
&mut self.config.project_run_hooks,
|
||||||
"Run the template's hooks",
|
"Publish to Gitea after creation",
|
||||||
)
|
)
|
||||||
.on_hover_text(
|
.on_hover_text(
|
||||||
"Templates can run scripts after generating. The snowflake \
|
"When enabled and Gitea credentials are configured below, a new repository will be created and the project pushed to it.",
|
||||||
template's hook creates a Gitea repository and pushes the \
|
|
||||||
new project to it.",
|
|
||||||
)
|
)
|
||||||
.changed()
|
.changed()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -174,7 +174,14 @@ pub fn default_show_toolbar() -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Default cookiecutter template for **File ▸ New project…**.
|
/// Default cookiecutter template for **File ▸ New project…**.
|
||||||
|
/// First tries the bundled template, then falls back to the user's Cookiecutters directory.
|
||||||
pub fn default_project_template() -> PathBuf {
|
pub fn default_project_template() -> PathBuf {
|
||||||
|
// Try the bundled template first
|
||||||
|
let bundled = PathBuf::from("templates/snowflake");
|
||||||
|
if bundled.join("cookiecutter.json").is_file() {
|
||||||
|
return bundled;
|
||||||
|
}
|
||||||
|
// Fallback to user's Cookiecutters directory
|
||||||
dirs::home_dir()
|
dirs::home_dir()
|
||||||
.unwrap_or_else(|| PathBuf::from("."))
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
.join("Documents")
|
.join("Documents")
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ mod outline;
|
|||||||
mod order;
|
mod order;
|
||||||
mod pdf;
|
mod pdf;
|
||||||
mod preprocess;
|
mod preprocess;
|
||||||
|
mod project_template;
|
||||||
mod screenplay;
|
mod screenplay;
|
||||||
mod spell;
|
mod spell;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,347 @@
|
|||||||
|
//! Project scaffolding from templates using MiniJinja.
|
||||||
|
//!
|
||||||
|
//! 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.
|
||||||
|
|
||||||
|
use minijinja::Environment;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
/// Template variables passed to MiniJinja.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TemplateVariables {
|
||||||
|
pub project_name: String,
|
||||||
|
pub project_slug: String,
|
||||||
|
pub author: String,
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TemplateVariables {
|
||||||
|
pub fn new(project_name: &str, author: &str, description: &str) -> Self {
|
||||||
|
let project_slug = project_name
|
||||||
|
.to_lowercase()
|
||||||
|
.replace(' ', "_")
|
||||||
|
.replace(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-', "");
|
||||||
|
Self {
|
||||||
|
project_name: project_name.to_string(),
|
||||||
|
project_slug,
|
||||||
|
author: author.to_string(),
|
||||||
|
description: description.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert to a HashMap for MiniJinja.
|
||||||
|
pub fn to_context(&self) -> HashMap<String, minijinja::Value> {
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
|
||||||
|
// Create a nested map for cookiecutter.variable access
|
||||||
|
let mut cookiecutter_map = HashMap::new();
|
||||||
|
cookiecutter_map.insert("project_name".to_string(), minijinja::Value::from(self.project_name.clone()));
|
||||||
|
cookiecutter_map.insert("project_slug".to_string(), minijinja::Value::from(self.project_slug.clone()));
|
||||||
|
cookiecutter_map.insert("author".to_string(), minijinja::Value::from(self.author.clone()));
|
||||||
|
cookiecutter_map.insert("description".to_string(), minijinja::Value::from(self.description.clone()));
|
||||||
|
map.insert("cookiecutter".to_string(), minijinja::Value::from(cookiecutter_map));
|
||||||
|
|
||||||
|
// Also add top-level variables for compatibility
|
||||||
|
map.insert("project_name".to_string(), minijinja::Value::from(self.project_name.clone()));
|
||||||
|
map.insert("project_slug".to_string(), minijinja::Value::from(self.project_slug.clone()));
|
||||||
|
map.insert("author".to_string(), minijinja::Value::from(self.author.clone()));
|
||||||
|
map.insert("description".to_string(), minijinja::Value::from(self.description.clone()));
|
||||||
|
map
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for template rendering.
|
||||||
|
pub struct RenderConfig {
|
||||||
|
/// Path to the template directory (contains cookiecutter.json and template folder)
|
||||||
|
pub template_dir: PathBuf,
|
||||||
|
/// Output directory where the project will be created
|
||||||
|
pub output_dir: PathBuf,
|
||||||
|
/// Template variables
|
||||||
|
pub variables: TemplateVariables,
|
||||||
|
/// Whether to run post-generation hooks (Gitea publishing is handled by caller)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub run_hooks: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of rendering a template.
|
||||||
|
pub type RenderResult = Result<PathBuf, String>;
|
||||||
|
|
||||||
|
/// Render a project template using MiniJinja.
|
||||||
|
///
|
||||||
|
/// This walks the template directory, renders each file and directory name
|
||||||
|
/// through MiniJinja, and writes the results to the output directory.
|
||||||
|
/// The template directory is expected to contain a subdirectory named with
|
||||||
|
/// a template expression (e.g., `{{ cookiecutter.project_name }}`) which
|
||||||
|
/// becomes the project root after rendering.
|
||||||
|
pub fn render_template(config: &RenderConfig) -> RenderResult {
|
||||||
|
// Verify the template directory has a cookiecutter.json
|
||||||
|
let cookiecutter_json = config.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()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the output directory if it doesn't exist
|
||||||
|
fs::create_dir_all(&config.output_dir)
|
||||||
|
.map_err(|e| format!("Cannot create {}: {}", config.output_dir.display(), e))?;
|
||||||
|
|
||||||
|
// Build MiniJinja environment
|
||||||
|
let mut env = Environment::new();
|
||||||
|
|
||||||
|
// Add built-in filters that cookiecutter templates might use
|
||||||
|
// These are commonly used in cookiecutter: lower, replace, etc.
|
||||||
|
env.add_filter("lower", |value: String| -> Result<String, minijinja::Error> {
|
||||||
|
Ok(value.to_lowercase())
|
||||||
|
});
|
||||||
|
env.add_filter("upper", |value: String| -> Result<String, minijinja::Error> {
|
||||||
|
Ok(value.to_uppercase())
|
||||||
|
});
|
||||||
|
env.add_filter("replace", |value: String, from: String, to: String| -> Result<String, minijinja::Error> {
|
||||||
|
Ok(value.replace(&from, &to))
|
||||||
|
});
|
||||||
|
env.add_filter("title", |value: String| -> Result<String, minijinja::Error> {
|
||||||
|
Ok(value.chars()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, c)| if i == 0 { c.to_uppercase().collect::<String>() } else { c.to_lowercase().collect::<String>() })
|
||||||
|
.collect::<String>()) // Simplified title case
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the context from variables
|
||||||
|
let context = config.variables.to_context();
|
||||||
|
|
||||||
|
// 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 mut template_project_dir: Option<PathBuf> = None;
|
||||||
|
for entry in template_entries {
|
||||||
|
let entry = entry.map_err(|e| format!("Error reading template directory entry: {}", e))?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||||
|
// Check if this directory name looks like a template (contains {{ )
|
||||||
|
if name.contains("{{") {
|
||||||
|
template_project_dir = Some(path);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
))?;
|
||||||
|
|
||||||
|
// Render the project directory name
|
||||||
|
let project_dir_name = render_filename(&env, &template_project_dir.file_name().unwrap_or_default().to_string_lossy(), &context)
|
||||||
|
.map_err(|e| format!("Failed to render project directory name: {}", e))?;
|
||||||
|
|
||||||
|
let project_dir = config.output_dir.join(&project_dir_name);
|
||||||
|
|
||||||
|
// Recursively copy and render the template
|
||||||
|
copy_and_render_directory(&env, &template_project_dir, &project_dir, &context)?;
|
||||||
|
|
||||||
|
// Note: Gitea publishing is now handled by the caller (app/project.rs)
|
||||||
|
// The run_hooks flag is checked there and the publishing is done natively in Rust
|
||||||
|
|
||||||
|
Ok(project_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a filename through MiniJinja.
|
||||||
|
fn render_filename(env: &Environment, template: &str, context: &HashMap<String, minijinja::Value>) -> Result<String, minijinja::Error> {
|
||||||
|
let tmpl = env.template_from_str(template)?;
|
||||||
|
tmpl.render(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively copy a directory, rendering all file and directory names and contents.
|
||||||
|
fn copy_and_render_directory(
|
||||||
|
env: &Environment,
|
||||||
|
src: &Path,
|
||||||
|
dest: &Path,
|
||||||
|
context: &HashMap<String, minijinja::Value>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
fs::create_dir_all(dest)
|
||||||
|
.map_err(|e| format!("Cannot create directory {}: {}", dest.display(), e))?;
|
||||||
|
|
||||||
|
for entry in fs::read_dir(src)
|
||||||
|
.map_err(|e| format!("Cannot read directory {}: {}", src.display(), e))? {
|
||||||
|
let entry = entry.map_err(|e| format!("Error reading directory entry: {}", e))?;
|
||||||
|
let src_path = entry.path();
|
||||||
|
let name = entry.file_name();
|
||||||
|
let name_str = name.to_string_lossy();
|
||||||
|
|
||||||
|
// Render the name
|
||||||
|
let rendered_name = if name_str.contains("{{") {
|
||||||
|
render_filename(env, &name_str, context)
|
||||||
|
.map_err(|e| format!("Failed to render name '{}': {}", name_str, e))?
|
||||||
|
} else {
|
||||||
|
name_str.into_owned()
|
||||||
|
};
|
||||||
|
|
||||||
|
let dest_path = dest.join(&rendered_name);
|
||||||
|
|
||||||
|
if src_path.is_dir() {
|
||||||
|
copy_and_render_directory(env, &src_path, &dest_path, context)?;
|
||||||
|
} else {
|
||||||
|
// Copy and potentially render the file contents
|
||||||
|
if is_text_file(&src_path) {
|
||||||
|
copy_and_render_file(env, &src_path, &dest_path, context)?;
|
||||||
|
} else {
|
||||||
|
// Binary file - just copy
|
||||||
|
fs::copy(&src_path, &dest_path)
|
||||||
|
.map_err(|e| format!("Cannot copy {} to {}: {}", src_path.display(), dest_path.display(), e))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a file is likely text (and should be templated) or binary.
|
||||||
|
fn is_text_file(path: &Path) -> bool {
|
||||||
|
// List of binary extensions that shouldn't be templated
|
||||||
|
let binary_extensions = [
|
||||||
|
"png", "jpg", "jpeg", "gif", "bmp", "ico", "svg",
|
||||||
|
"odt", "odm", "ods", "doc", "docx", "xls", "xlsx", "ppt", "pptx",
|
||||||
|
"zip", "tar", "gz", "rar", "7z",
|
||||||
|
"exe", "dll", "so", "dylib", "bin",
|
||||||
|
"pdf", "epub", "mobi",
|
||||||
|
".gitignore",
|
||||||
|
];
|
||||||
|
|
||||||
|
let ext = path.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.map(|e| e.to_lowercase());
|
||||||
|
|
||||||
|
if let Some(ext) = ext {
|
||||||
|
if binary_extensions.contains(&ext.as_str()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy a file, rendering its contents through MiniJinja if it looks like a template.
|
||||||
|
fn copy_and_render_file(
|
||||||
|
env: &Environment,
|
||||||
|
src: &Path,
|
||||||
|
dest: &Path,
|
||||||
|
context: &HashMap<String, minijinja::Value>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let content = fs::read_to_string(src)
|
||||||
|
.map_err(|e| format!("Cannot read {}: {}", src.display(), e))?;
|
||||||
|
|
||||||
|
// Check if the content contains template expressions
|
||||||
|
if content.contains("{{") {
|
||||||
|
let tmpl = env.template_from_str(&content)
|
||||||
|
.map_err(|e| format!("Failed to parse template in {}: {}", src.display(), e))?;
|
||||||
|
let rendered = tmpl.render(context)
|
||||||
|
.map_err(|e| format!("Failed to render {}: {}", src.display(), e))?;
|
||||||
|
fs::write(dest, &rendered)
|
||||||
|
.map_err(|e| format!("Cannot write {}: {}", dest.display(), e))?;
|
||||||
|
} else {
|
||||||
|
fs::write(dest, &content)
|
||||||
|
.map_err(|e| format!("Cannot write {}: {}", dest.display(), e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run post-generation hooks.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn run_hooks(_config: &RenderConfig, _project_dir: &Path, _project_name: &str) -> Result<(), String> {
|
||||||
|
// Note: Gitea publishing is now handled natively in Rust via the caller
|
||||||
|
// The Python hooks are no longer executed
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_template_variables() {
|
||||||
|
let vars = TemplateVariables::new("My Project", "John Doe", "A test project");
|
||||||
|
assert_eq!(vars.project_name, "My Project");
|
||||||
|
assert_eq!(vars.project_slug, "my_project");
|
||||||
|
assert_eq!(vars.author, "John Doe");
|
||||||
|
assert_eq!(vars.description, "A test project");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_filename() {
|
||||||
|
let mut env = Environment::new();
|
||||||
|
// Note: In MiniJinja, `lower` is not a built-in method like in Jinja2.
|
||||||
|
// We use the `lower` filter which we've registered in the main render function.
|
||||||
|
env.add_filter("lower", |value: String| -> Result<String, minijinja::Error> {
|
||||||
|
Ok(value.to_lowercase())
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut context = HashMap::new();
|
||||||
|
context.insert("project_name".to_string(), minijinja::Value::from("Test Project"));
|
||||||
|
|
||||||
|
// Use the filter syntax |lower instead of .lower() method
|
||||||
|
let name = render_filename(&env, "{{ project_name | lower }}", &context).unwrap();
|
||||||
|
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.
|
||||||
|
#[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,
|
||||||
|
output_dir: out.clone(),
|
||||||
|
variables: TemplateVariables::new(
|
||||||
|
"MiniJinja Test Project",
|
||||||
|
"Test Author",
|
||||||
|
"A project generated with MiniJinja",
|
||||||
|
),
|
||||||
|
run_hooks: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let project = render_template(&config).expect("generation should succeed");
|
||||||
|
|
||||||
|
// Check that the project directory was created with the correct name
|
||||||
|
assert_eq!(
|
||||||
|
project.file_name().and_then(|n| n.to_str()),
|
||||||
|
Some("MiniJinja Test Project")
|
||||||
|
);
|
||||||
|
assert!(project.is_dir());
|
||||||
|
|
||||||
|
// Check that the README.md was rendered with the correct variables
|
||||||
|
let readme = project.join("README.md");
|
||||||
|
assert!(readme.is_file(), "README.md should exist");
|
||||||
|
let readme_content = fs::read_to_string(&readme).unwrap();
|
||||||
|
assert!(readme_content.contains("MiniJinja Test Project"));
|
||||||
|
assert!(readme_content.contains("A project generated with MiniJinja"));
|
||||||
|
|
||||||
|
// Check that the 06-First Draft subdirectory exists (default manuscript folder)
|
||||||
|
let subdir = crate::config::default_project_open_subdir();
|
||||||
|
assert!(
|
||||||
|
project.join(&subdir).is_dir(),
|
||||||
|
"expected a {subdir} folder in {project:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
let _ = std::fs::remove_dir_all(&out);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# A Novel Outlining Cookiecutter
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"project_name": "Novel",
|
||||||
|
"project_slug": "{{ cookiecutter.project_name.lower().replace(' ', '_') }}",
|
||||||
|
"author": "Lane Wight",
|
||||||
|
"description": "A short desciption of the project."
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# create_snowflake_structure.sh
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Creates a ready‑to‑use folder layout for the Snowflake
|
||||||
|
# writing outline method.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./create_snowflake_structure.sh "MyNovel"
|
||||||
|
#
|
||||||
|
# If no argument is supplied, the script defaults to a folder
|
||||||
|
# named "SnowflakeProject".
|
||||||
|
# -------------------------------------------------
|
||||||
|
|
||||||
|
set -euo pipefail # safety: exit on error, treat unset vars as errors
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Helper: print a short usage message
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
usage() {
|
||||||
|
echo "Usage: $0 [project_folder_name]"
|
||||||
|
echo "Creates the Snowflake writing‑method folder structure."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Determine the root folder name
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
PROJECT_NAME="${1:-SnowflakeProject}"
|
||||||
|
ROOT_DIR="${PWD}/${PROJECT_NAME}"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Define the directory tree (relative to ROOT_DIR)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
DIRS=(
|
||||||
|
"01-Premise"
|
||||||
|
"02-Synopsis"
|
||||||
|
"03-Characters/main_characters"
|
||||||
|
"03-Characters/supporting_characters"
|
||||||
|
"03-Characters/character_grid"
|
||||||
|
"04-PlotOutline"
|
||||||
|
"04-PlotOutline/scene_breakdown"
|
||||||
|
"05-NarrativeSheets/act_1"
|
||||||
|
"05-NarrativeSheets/act_2"
|
||||||
|
"05-NarrativeSheets/act_3"
|
||||||
|
"06-FirstDraft"
|
||||||
|
"07-Revisions/structural_edits"
|
||||||
|
"07-Revisions/line_edits"
|
||||||
|
"07-Revisions/proofreading"
|
||||||
|
"08-Final/metadata"
|
||||||
|
"08-Final/submission_materials"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Create the directories
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
echo "Creating project folder: ${ROOT_DIR}"
|
||||||
|
mkdir -p "${ROOT_DIR}"
|
||||||
|
|
||||||
|
for d in "${DIRS[@]}"; do
|
||||||
|
mkdir -p "${ROOT_DIR}/${d}"
|
||||||
|
done
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Optional: create placeholder files so you have a starting point
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
touch "${ROOT_DIR}/01-Premise/premise.txt"
|
||||||
|
touch "${ROOT_DIR}/02-Synopsis/synopsis.txt"
|
||||||
|
touch "${ROOT_DIR}/03-Characters/main_characters/protagonist.md"
|
||||||
|
touch "${ROOT_DIR}/03-Characters/main_characters/antagonist.md"
|
||||||
|
touch "${ROOT_DIR}/03-Characters/character_grid/character_grid.xlsx"
|
||||||
|
touch "${ROOT_DIR}/04-PlotOutline/plot_outline.docx"
|
||||||
|
touch "${ROOT_DIR}/05-NarrativeSheets/act_1/scene_01.md"
|
||||||
|
touch "${ROOT_DIR}/06-FirstDraft/draft_v1.docx"
|
||||||
|
touch "${ROOT_DIR}/07-Revisions/structural_edits/notes.txt"
|
||||||
|
touch "${ROOT_DIR}/08-Final/final_manuscript.docx"
|
||||||
|
touch "${ROOT_DIR}/README.md"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Finished
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
echo "Folder structure created successfully!"
|
||||||
|
echo "Root directory: ${ROOT_DIR}"
|
||||||
|
echo "You can now start populating the files."
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Post-generation hook: optionally publish the new project to Gitea.
|
||||||
|
|
||||||
|
Cookiecutter renders this file as a Jinja template before running it, so a
|
||||||
|
doubled opening brace anywhere outside a cookiecutter variable is read as the
|
||||||
|
start of a Jinja expression and breaks the render. The JSON body below is
|
||||||
|
therefore built with ``json.dumps`` rather than written out with literal braces.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# This one comes from cookiecutter.json.
|
||||||
|
PROJECT_NAME = "{{cookiecutter.project_name}}"
|
||||||
|
|
||||||
|
GITEA_URL = os.environ.get("GITEA_URL") # e.g. https://gitea.example.com
|
||||||
|
GITEA_USER = os.environ.get("GITEA_USER")
|
||||||
|
GITEA_TOKEN = os.environ.get("GITEA_TOKEN")
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd, **kwargs):
|
||||||
|
"""Run a command given as an argument list, raising if it fails."""
|
||||||
|
return subprocess.run(cmd, check=True, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def create_gitea_repo():
|
||||||
|
# 1. Create the repo via the Gitea API. `--fail` makes curl exit non-zero on
|
||||||
|
# an HTTP error, so a bad token surfaces here instead of silently
|
||||||
|
# "succeeding" and leaving a push to fail later.
|
||||||
|
api_url = f"{GITEA_URL.rstrip('/')}/api/v1/user/repos"
|
||||||
|
payload = json.dumps({"name": PROJECT_NAME, "private": False, "auto_init": False})
|
||||||
|
run([
|
||||||
|
"curl", "-sS", "--fail", "-X", "POST", api_url,
|
||||||
|
"-H", "Content-Type: application/json",
|
||||||
|
"-H", f"Authorization: token {GITEA_TOKEN}",
|
||||||
|
"-d", payload,
|
||||||
|
])
|
||||||
|
|
||||||
|
# 2. Init git and push. `-b main` matters because the push below names
|
||||||
|
# `main`, while git's built-in default is still `master`.
|
||||||
|
repo_url = f"{GITEA_URL.rstrip('/')}/{GITEA_USER}/{PROJECT_NAME}.git"
|
||||||
|
auth_url = repo_url.replace("://", f"://{GITEA_USER}:{GITEA_TOKEN}@", 1)
|
||||||
|
|
||||||
|
run(["git", "init", "-b", "main"])
|
||||||
|
run(["git", "add", "."])
|
||||||
|
run(["git", "commit", "-m", "Initial commit from cookiecutter"])
|
||||||
|
# The stored remote keeps the plain URL: embedding the token would write it
|
||||||
|
# in cleartext into .git/config for the life of the project. It is passed to
|
||||||
|
# this one push only.
|
||||||
|
run(["git", "remote", "add", "origin", repo_url])
|
||||||
|
run(["git", "push", auth_url, "main"])
|
||||||
|
run(["git", "fetch", "origin"])
|
||||||
|
run(["git", "branch", "--set-upstream-to=origin/main", "main"])
|
||||||
|
print(f"✅ Repo created and pushed: {repo_url}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
missing = [
|
||||||
|
name
|
||||||
|
for name, value in (
|
||||||
|
("GITEA_URL", GITEA_URL),
|
||||||
|
("GITEA_USER", GITEA_USER),
|
||||||
|
("GITEA_TOKEN", GITEA_TOKEN),
|
||||||
|
)
|
||||||
|
if not value
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
# Publishing is opt-in. Without credentials the project is still
|
||||||
|
# perfectly good on disk, so skip rather than fail the generation and
|
||||||
|
# have cookiecutter throw the whole tree away.
|
||||||
|
print(f"ℹ Skipping Gitea publish; not set: {', '.join(missing)}")
|
||||||
|
sys.exit(0)
|
||||||
|
try:
|
||||||
|
create_gitea_repo()
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"❌ Error: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Logline
|
||||||
|
|
||||||
|
<!-- Your one‑sentence pitch. -->
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Synopsis
|
||||||
|
|
||||||
|
<!-- The synopsis expanded to a paragraph (≈5 sentences). -->
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
<!-- A quick‑look table summarizing all characters (goal, motivation, stakes, etc.) -->
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Age</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Motivation</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>John Doe</td>
|
||||||
|
<td>30</td>
|
||||||
|
<td>Average Joe</td>
|
||||||
|
<td>Just wants a cheeseburger.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
# Name
|
||||||
|
|
||||||
|
### Slug
|
||||||
|
|
||||||
|
Age:
|
||||||
|
|
||||||
|
Description:
|
||||||
|
|
||||||
|
Motivation:
|
||||||
|
|
||||||
|
Single Quote:
|
||||||
|
|
||||||
|
### Summary:
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
# Name
|
||||||
|
|
||||||
|
### Slug
|
||||||
|
|
||||||
|
Age:
|
||||||
|
|
||||||
|
Description:
|
||||||
|
|
||||||
|
Motivation:
|
||||||
|
|
||||||
|
Single Quote:
|
||||||
|
|
||||||
|
### Summary:
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<!-- The expanded outline (≈4 pages). -->
|
||||||
|
|
||||||
|
## Act I – Setup (≈ 25 % of the story)
|
||||||
|
|
||||||
|
<!-- Opening Image / Hook: A vivid snapshot of the protagonist’s ordinary world. Sets tone, setting, and the status‑quo the hero will soon leave. -->
|
||||||
|
|
||||||
|
<!-- Inciting Incident: An event that disrupts the status‑quo and forces the protagonist to confront a problem. Creates the central conflict and gives the hero a reason to act. -->
|
||||||
|
|
||||||
|
<!-- Key Event / Call to Adventure: The protagonist becomes aware of the stakes and is presented with a clear choice or challenge. Pushes the hero toward the journey; the point of no return begins to form. -->
|
||||||
|
|
||||||
|
<!-- Debate / Refusal of the Call: The hero hesitates, doubts, or resists the upcoming change. Highlights internal conflict and makes the eventual commitment feel earned. | -->
|
||||||
|
|
||||||
|
<!-- Lock‑In / End of Act I: The hero decides to take action (or is forced into it). The story’s direction is now set. Marks the transition into the main confrontation; the protagonist is now “in the game.” -->
|
||||||
|
|
||||||
|
## Act II – Confrontation (≈ 50 % of the story)
|
||||||
|
|
||||||
|
<!-- First Plot Point: A decisive event propels the hero into a new world or situation (often a “point of no return”). Raises the stakes and moves the narrative firmly into the central conflict. -->
|
||||||
|
|
||||||
|
<!-- Rising Action / Obstacles: Series of challenges, setbacks, and revelations that test the hero’s resolve. Builds tension, deepens character development, and expands the story’s scope. -->
|
||||||
|
|
||||||
|
<!-- Midpoint: A major turning point—often a revelation, victory, or crisis—that changes the hero’s understanding or strategy. Re‑energizes the plot; the hero’s goal may shift or become clearer. -->
|
||||||
|
|
||||||
|
<!-- Complications & Darkest Moment: Stakes intensify; the hero may suffer a major loss or face apparent defeat. Heightens emotional investment and sets up the need for a final push. -->
|
||||||
|
|
||||||
|
<!-- Second Plot Point: The hero discovers a crucial piece of information or makes a decisive plan that leads to the climax. Provides the catalyst for the ultimate confrontation in Act III. -->
|
||||||
|
|
||||||
|
## Act III – Resolution (≈ 25 % of the story)
|
||||||
|
|
||||||
|
<!-- Climax: The highest‑stakes confrontation where the hero faces the central conflict head‑on. Delivers the story’s primary payoff; the outcome resolves the main tension. -->
|
||||||
|
|
||||||
|
<!-- Falling Action: Immediate consequences of the climax unfold; loose ends begin to tie up. Shows the impact of the climax on characters and world. -->
|
||||||
|
|
||||||
|
<!-- Denouement / Resolution: Remaining subplots are resolved; the protagonist’s new equilibrium is shown. Gives closure, illustrating how the hero (and world) have changed. -->
|
||||||
|
|
||||||
|
<!-- Final Image: A closing snapshot that mirrors (or contrasts) the opening image, highlighting transformation. Reinforces the thematic arc and leaves a lasting impression. -->
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 697 KiB |
+11
@@ -0,0 +1,11 @@
|
|||||||
|
## Act I – Setup (≈ 25 % of the story)
|
||||||
|
|
||||||
|
<!-- Opening Image / Hook: A vivid snapshot of the protagonist’s ordinary world. Sets tone, setting, and the status‑quo the hero will soon leave. -->
|
||||||
|
|
||||||
|
<!-- Inciting Incident: An event that disrupts the status‑quo and forces the protagonist to confront a problem. Creates the central conflict and gives the hero a reason to act. -->
|
||||||
|
|
||||||
|
<!-- Key Event / Call to Adventure: The protagonist becomes aware of the stakes and is presented with a clear choice or challenge. Pushes the hero toward the journey; the point of no return begins to form. -->
|
||||||
|
|
||||||
|
<!-- Debate / Refusal of the Call: The hero hesitates, doubts, or resists the upcoming change. Highlights internal conflict and makes the eventual commitment feel earned. | -->
|
||||||
|
|
||||||
|
<!-- Lock‑In / End of Act I: The hero decides to take action (or is forced into it). The story’s direction is now set. Marks the transition into the main confrontation; the protagonist is now “in the game.” -->
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
## Act II – Confrontation (≈ 50 % of the story)
|
||||||
|
|
||||||
|
<!-- First Plot Point: A decisive event propels the hero into a new world or situation (often a “point of no return”). Raises the stakes and moves the narrative firmly into the central conflict. -->
|
||||||
|
|
||||||
|
<!-- Rising Action / Obstacles: Series of challenges, setbacks, and revelations that test the hero’s resolve. Builds tension, deepens character development, and expands the story’s scope. -->
|
||||||
|
|
||||||
|
<!-- Midpoint: A major turning point—often a revelation, victory, or crisis—that changes the hero’s understanding or strategy. Re‑energizes the plot; the hero’s goal may shift or become clearer. -->
|
||||||
|
|
||||||
|
<!-- Complications & Darkest Moment: Stakes intensify; the hero may suffer a major loss or face apparent defeat. Heightens emotional investment and sets up the need for a final push. -->
|
||||||
|
|
||||||
|
<!-- Second Plot Point: The hero discovers a crucial piece of information or makes a decisive plan that leads to the climax. Provides the catalyst for the ultimate confrontation in Act III. -->
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
## Act III – Resolution (≈ 25 % of the story)
|
||||||
|
|
||||||
|
<!-- Climax: The highest‑stakes confrontation where the hero faces the central conflict head‑on. Delivers the story’s primary payoff; the outcome resolves the main tension. -->
|
||||||
|
|
||||||
|
<!-- Falling Action: Immediate consequences of the climax unfold; loose ends begin to tie up. Shows the impact of the climax on characters and world. -->
|
||||||
|
|
||||||
|
<!-- Denouement / Resolution: Remaining subplots are resolved; the protagonist’s new equilibrium is shown. Gives closure, illustrating how the hero (and world) have changed. -->
|
||||||
|
|
||||||
|
<!-- Final Image: A closing snapshot that mirrors (or contrasts) the opening image, highlighting transformation. Reinforces the thematic arc and leaves a lasting impression. -->
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Title
|
||||||
|
|
||||||
|
# Slug
|
||||||
|
|
||||||
|
## POV:
|
||||||
|
|
||||||
|
## Characters:
|
||||||
|
|
||||||
|
## Setting:
|
||||||
|
|
||||||
|
## Conflict:
|
||||||
|
|
||||||
|
## Feeling to convey:
|
||||||
|
|
||||||
|
## Word Count Target:
|
||||||
|
|
||||||
|
## The Setup:
|
||||||
|
|
||||||
|
## The Reveal:
|
||||||
|
|
||||||
|
### Things to Note:
|
||||||
|
|
||||||
|
### Rough draft:
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1,5 @@
|
|||||||
|
# {{ cookiecutter.project_name }}
|
||||||
|
|
||||||
|
{{ cookiecutter.description }}
|
||||||
|
|
||||||
|
This repository was bootstrapped with a Cookiecutter template that also provides a helper script (`post_gen_project.sh`) to initialise a local Git repository.
|
||||||
Reference in New Issue
Block a user