| name | marmite-development |
| description | Guidelines and workflows for contributing to the marmite codebase - covers code quality, testing, architecture patterns, and contribution checklists |
Marmite Development Guide
This skill is for developers contributing to the marmite codebase. For building sites with marmite, use the marmite skill instead.
Tools to call during development
Show urls
marmite input_folder --show-urls
{
"archives": [
"/archive-2027.html",
"/archive.html"
],
"authors": [
"/author-jao.html",
"/authors.html"
],
"feeds": [
"/index.rss",
"/internet.rss",
"/tag-python.rss",
"/author-jao.rss",
"/archive-2026.rss"
],
"file_mappings": [],
"misc": [
"/index.html"
],
"pages": [
"/about.html",
"/pages.html"
],
"pagination": [
"/pages-1.html",
"/tag-dev-1.html",
"/tag-python-1.html",
"/author-jao-1.html",
"/archive-2026-1.html",
"/index-1.html",
],
"posts": [
"/code-django.html",
"/code-python.html",
],
"redirects": [],
"series": [],
"streams": [
"/code.html",
"/streams.html"
],
"summary": {
"archives": 1,
"authors": 1,
"feeds": 5,
"file_mappings": 0,
"meta": {
"absolute_urls": false,
"url": ""
},
"misc": 1,
"pages": 1,
"pagination": 1,
"posts": 2,
"redirects": 0,
"series": 0,
"streams": 2,
"tags": 1,
},
"tags": [
"/tag-python.html",
"/tags.html"
]
}
This tool allows the agent to:
- compare urls before and after a refactor
- check generated content slug
- count number of contents
init site
marmite input_folder --init-site
Initialize a new site structure from scaffolding.
New post
marmite input_folder --new "title" -t tag
Create a new post.
More
Use marmite --help to check what else is available.
Code Quality Requirements
Formatting and Linting
Every change must pass formatting and linting before it can be committed.
mask fmt
mask check
mask pedantic
mask check runs cargo fmt -- --check followed by cargo clippy. Both must pass clean. mask pedantic adds -W clippy::pedantic for stricter analysis - fix pedantic issues only after confirming with the project maintainer.
Do not suppress clippy warnings with #[allow(...)] unless there is a justified reason. If a suppression is needed, use the narrowest possible scope (on the item, not the module).
Safe Rust Practices
- No
unwrap() in non-test code. Use Result and Option with proper error propagation (? operator), unwrap_or, unwrap_or_else, unwrap_or_default, or pattern matching. Reserve unwrap() for test code and cases where the value is guaranteed at compile time (e.g., embedded assets loaded via rust_embed).
- No
panic! in library code. Return Result or Option instead. Use error! from the log crate to report problems, then return an error or a sensible default.
- No unsafe code unless absolutely necessary and approved by the maintainer.
- Handle all
Result types. Do not silently discard errors with let _ = ... unless the discard is intentional and documented.
- Prefer
&str over String in function parameters when ownership is not needed.
- Use
Arc for shared ownership across threads. The project uses Arc<PathBuf> and Arc<Cli> for passing shared state.
- Avoid
.clone() when a borrow suffices. Cloning is acceptable when the borrow checker requires it or when the data needs to outlive the current scope, but do not clone as a reflex.
- Use
LazyLock for static initialization instead of lazy_static! in new code. The project is transitioning to std::sync::LazyLock.
Error Handling Patterns
The project uses the log crate for user-facing messages:
use log::{info, warn, error, debug, trace};
info!("Processing {} files", count);
warn!("Skipping file with invalid frontmatter: {}", path.display());
error!("Failed to write output: {}", err);
debug!("Resolved slug: {}", slug);
trace!("Template context: {:?}", context);
Functions that can fail should return Result<T, E> or Option<T>. Use process::exit(1) only in main.rs for top-level CLI errors.
Naming Conventions
snake_case for functions, methods, variables, and modules
PascalCase for types, structs, enums, and traits
SCREAMING_SNAKE_CASE for constants and static variables
- CLI flags: use
#[arg(long)] with descriptive names (e.g., --skip-image-resize, --init-templates). Clap automatically converts underscores to hyphens.
- Config fields: use
snake_case matching the YAML key name. Always add #[serde(default)] with a default function.
Testing
Every change needs tests. The project has two testing layers:
Unit Tests (src/tests/)
Unit tests call marmite code directly. Each module has a corresponding test file.
src/tests/
content.rs # Content parsing, slug generation, streams
shortcodes.rs # Shortcode regex matching and expansion
parser.rs # Markdown to HTML conversion
tera_functions.rs # Custom Tera template functions
tera_filter.rs # Custom Tera filters
feed.rs # RSS feed generation
gallery.rs # Image gallery processing
site.rs # Site generation logic
templates.rs # Template initialization
embedded.rs # Embedded asset loading
server.rs # HTTP server
image_resize.rs # Image resizing
image_provider.rs # Image download
theme_manager.rs # Theme installation
Unit test pattern:
use super::*;
use crate::config::Marmite;
#[test]
fn test_descriptive_name_of_what_is_tested() {
let config = Marmite::default();
let result = function_under_test(&config);
assert_eq!(result, expected_value);
}
Run unit tests:
mask test_unit
cargo test --bin marmite test_name
Integration Tests (tests/)
Integration tests run marmite as a subprocess via std::process::Command and assert on the output files. They test end-to-end behavior.
tests/
basic_functionality.rs # CLI help, version, minimal site generation
content_generation.rs # Content processing end-to-end
features.rs # Feature-specific integration tests
streams.rs # Stream content type
wikilinks_integration.rs # Wikilink resolution
highlight.rs # Syntax highlighting
image_resize_integration.rs # Image resizing pipeline
themes.rs # Theme loading and application
Integration test pattern:
use std::fs;
use std::process::Command;
use tempfile::TempDir;
#[test]
fn test_feature_name_end_to_end() {
let temp_dir = TempDir::new().unwrap();
let input_dir = temp_dir.path().join("input");
let output_dir = temp_dir.path().join("output");
fs::create_dir_all(input_dir.join("content")).unwrap();
fs::write(
input_dir.join("marmite.yaml"),
"name: Test Site\ntagline: Test",
).unwrap();
fs::write(
input_dir.join("content").join("test.md"),
"# Test Page\n\nContent here.",
).unwrap();
let output = Command::new("cargo")
.args(["run", "--quiet", "--",
input_dir.to_str().unwrap(),
output_dir.().()])
.()
.();
(output.status.());
= fs::(output_dir.()).();
(html.());
}
Run integration tests:
mask test_integration
cargo test --test basic_functionality
Run all tests:
mask test
What to Test
- New features: Both unit tests for the logic and integration tests for CLI/end-to-end behavior.
- Bug fixes: A test that reproduces the bug and verifies the fix. The test should fail without the fix applied.
- Config options: Test default values, custom values, and edge cases.
- CLI flags: Integration test that passes the flag and checks the result.
Avoiding Breaking Changes
Marmite is used by people who depend on its current behavior. Follow these principles:
- Prefer feature flags over behavior changes. If a change alters existing behavior, add a config option to opt in. Make the current behavior the default so existing sites are unaffected.
- New config fields must have sensible defaults. Use
#[serde(default = "default_function")] so that existing marmite.yaml files without the new field continue to work.
- Do not remove or rename existing config fields. If a field needs to be superseded, deprecate it (log a warning when it is used) and support both the old and new field.
- Do not remove or rename existing CLI flags. Use
#[arg(hide = true)] to hide deprecated flags from --help while still accepting them.
- Do not change the default output structure (file names, directory layout) without a migration path or a feature flag.
- Template variable changes must be backward-compatible. Adding new variables is fine; removing or renaming existing ones breaks custom templates.
Documentation Requirements
Every new feature must be documented in multiple places. The marmite website is generated from example/content/, so documentation and the site are the same thing.
Blog post for new features
Create a new markdown file in example/content/ documenting the feature. Follow existing patterns:
- Use a dated filename:
YYYY-MM-DD-feature-name.md
- Include frontmatter with title, description, and relevant tags
- Write a practical guide showing how to use the feature, with examples
- If the feature adds CLI flags, also update
example/content/2024-11-26-marmite-command-line-interface.md
LLM documentation (example/ai/llms.txt)
Add an entry to example/ai/llms.txt under the appropriate section for any significant change: new CLI flag, new config option, new content capability, new template variable, or new shortcode. This file is served at marmite.blog/llms.txt and is used by AI agents to understand marmite's capabilities.
Agent skill references (.agents/skills/marmite/references/)
Update the corresponding reference file when the feature changes:
| What changed | File to update |
|---|
| Config options | references/config-reference.md |
| CLI flags | references/cli-reference.md |
| Frontmatter fields | references/frontmatter.md |
| Template variables or functions | references/tera-templates.md |
| Shortcodes | references/shortcodes.md |
| Content organization (streams, series, fragments) | references/content-organization.md |
| Deployment or hosting | references/deployment-guide.md |
| Comment systems | references/comment-system.md |
These files are compiled into the binary via rust_embed and installed with --skill-install. They must stay accurate.
When adding a new feature, add to the top level SKILL.md
Release notes
Every new feature and bug fix must be added to the current draft release notes. Find the latest release notes file with stream: draft in frontmatter under example/content/release-notes/. The filename pattern is marmite-X-Y-Z-release-notes.md.
- For new features, add a section describing what the feature does and how to use it.
- For bug fixes, describe what was broken and how it is fixed.
- If no draft release notes file exists, create one following the same pattern, using the next expected version number.
Template Changes
Marmite has two sets of templates that must be kept in sync:
Main theme (example/templates/ and example/static/)
This is the default theme used by marmite when no custom theme is set. Changes to templates, CSS, JavaScript, or static assets go here first.
example/templates/ - Tera HTML templates (base.html, content.html, list.html, group.html)
example/static/ - CSS, JavaScript, fonts, colorschemes
Alternative theme - theme_template (example/theme_template/)
This is the scaffolding theme used when users run marmite <folder> --start-theme <name>. It provides a starting point for new custom themes. It has its own copies of templates and static assets:
example/theme_template/templates/ - Template files
example/theme_template/static/ - Static assets (CSS, JS, fonts, colorschemes)
When you change any template or static asset in the main theme, you must also apply the corresponding change to theme_template. The two themes can differ in styling and layout details, but they must both support the same template variables, blocks, and structural features. If a new template block or variable is added in the main theme, theme_template must also include it so that users who scaffold a new theme get a working starting point.
After making template changes, verify both themes work:
mask serve
mask serve_theme
Adding New Features
Every config parameter needs a CLI argument
When you add a new field to marmite.yaml, it must also be settable via a CLI flag. This allows users to override config without editing files, and is essential for CI/CD and scripting.
The full process:
- Add the config field in
config.rs:
#[serde(default = "default_my_feature")]
pub my_feature: bool,
Add the default function:
fn default_my_feature() -> bool {
false
}
- Add the CLI flag in
cli.rs inside the Configuration struct:
#[arg(long)]
pub my_feature: bool,
- Wire the override in
config.rs inside Marmite::override_from_cli_args():
if cli_args.configuration.my_feature {
self.my_feature = true;
}
- Document it:
- Add a commented-out example in
example/marmite.yaml
- Update
.agents/skills/marmite/references/config-reference.md
- Update
example/content/2024-11-26-marmite-command-line-interface.md if it is a CLI flag
Adding a new CLI command
- Add the flag to
Cli in cli.rs with #[arg(long)] and a doc comment (becomes --help text).
- Add the handler in
run_cli() in main.rs at the correct position in the command chain.
- If the command does not need
input_folder, handle it before input_folder resolution.
- Update
determine_verbosity() if the command should auto-set verbosity.
- Update the CLI docs.
Adding a new template function
- Implement the function struct in
tera_functions.rs (implement tera::Function).
- Register it in
site.rs where other functions are registered (search for tera.register_function).
- Update
.agents/skills/marmite/references/tera-templates.md.
- Add the function name to
write_template_context_files in site.rs (the functions vec).
- Add it to the
TERA_BUILTIN_FILTERS or function completion list in assets/toolbar/editor.js.
Adding a new template filter
- Implement the filter function in
tera_filter.rs (implement tera::Filter).
- Register it in
site.rs where other filters are registered (search for tera.register_filter).
- Update
.agents/skills/marmite/references/tera-templates.md.
- Add the filter name to
write_template_context_files in site.rs (the filters vec).
- Add it to the editor's filter completion list in
assets/toolbar/editor.js.
Adding a new template variable
- Insert the variable into the Tera context in
site.rs (search for context.insert).
- Determine which templates receive it:
- Global context (
render_templates function) - available to all templates
- Content context (
handle_content_pages function) - available to content.html
- List context (
render_list_page function) - available to list.html
- Group context (
render_group_page function) - available to group.html
- Update
.agents/skills/marmite/references/tera-templates.md.
- Add the variable to the relevant template list in
write_template_context_files in site.rs.
Template rendering architecture
Marmite uses Tera 2.0. Key points for contributors:
- Template loading (
initialize_tera in site.rs): loads embedded default templates first, then user templates from templates_path which override the defaults. The preprocess_template function in embedded.rs auto-converts Tera 1.x syntax (dot-notation indexing, positional test args, ignore missing includes) to Tera 2.0 syntax.
- Template context is built in layers: global context (site config, fragments, menu) is shared by all templates, then each template type adds its own variables.
- Fragment rendering: files prefixed with
_ in the content directory (e.g., _hero.md, _sidebar.md) are rendered through Tera with the global context, then converted to HTML, and injected as variables into all templates.
- Template fallback: list pages use a
custom_{filename},list.html pattern - Tera tries custom_index.html first, falls back to list.html.
- Error handling during --serve: template rendering errors are logged but do not crash the server. The previous good build remains served.
- Template context files: during
--serve, template.{name}.context.json files are written to the output folder listing available variables, functions, and filters per template type. These are consumed by the editor for autocomplete.
Tera 2.0 notes for contributors
Marmite uses Tera 2.0. When writing or modifying templates:
- No macros: macros were removed in Tera 2.0. Shortcodes use their own
{% shortcode name() %} syntax which is processed by marmite's shortcode processor before Tera.
- Array indexing: use
items[0] not items.0 (the preprocessor auto-converts old syntax).
- Optional chaining:
{{ content?.extra?.key }} is safe for deeply nested access.
- Renamed filters:
escape is now escape_html, linebreaksbr is newlines_to_br, as_str is str. Marmite provides compatibility shims for the old names.
- Removed filters:
addslashes, spaceless, get_env, concat are gone from Tera. striptags, slice, trim_start_matches, and date are provided by marmite as custom filters.
- Tests use keyword args:
value is divisible_by(n=3) not value is divisibleby(3).
Adding embedded assets
New embedded assets follow the rust_embed pattern in embedded.rs:
#[derive(Embed, Debug)]
#[folder = "$CARGO_MANIFEST_DIR/path/to/folder/"]
pub struct MyAssets;
pub static EMBEDDED_MY_ASSETS: LazyLock<Vec<(String, Vec<u8>)>> = LazyLock::new(|| {
let mut files: Vec<(String, Vec<u8>)> = Vec::new();
for name in MyAssets::iter() {
let file = MyAssets::get(name.as_ref())
.expect("Failed to get embedded asset - this is a build-time error");
files.push((name.clone().to_string(), file.data.clone().to_vec()));
}
files
});
Dependency Management
- Minimize new dependencies. Marmite ships as a single binary. Every dependency adds compile time and binary size. Prefer the standard library when possible.
- Pin major versions in
Cargo.toml (e.g., serde = "1.0"). Dependabot handles patch and minor updates.
- Check for security advisories with
cargo audit before adding new crates.
- Feature-gate heavy optional dependencies. If a feature requires a large crate, consider making it optional behind a Cargo feature flag.
Project Architecture
Content processing pipeline
- Walk the content directory (
walkdir)
- For each
.md file, call Content::from_markdown():
- Extract frontmatter (
frontmatter_gen - supports YAML/TOML/JSON)
- Determine post vs page (date presence)
- Detect stream from frontmatter or filename prefix
- Generate slug from frontmatter, title, or filename
- Convert markdown to HTML via comrak
- Post-process the HTML (fix internal links, replace
@/ media refs)
- Process shortcodes if enabled
- Build taxonomy indexes (tags, authors, archive, streams, series)
- Resolve backlinks and related content
- Render Tera templates with content and site data
- Write HTML output, copy static/media, resize images
HTML post-processing over raw markdown rewriting
When a feature needs to rewrite URLs, paths, or other content in the output, always operate on the rendered HTML rather than on the raw markdown before comrak processes it.
Comrak turns markdown into HTML and applies its own transformations (wrapping images in <figure>, generating anchor links, rendering code fences into <code> blocks, etc.). A naive find-and-replace on the raw markdown cannot distinguish between content the user intends to render (an image src, a link href) and content that should be left literal (code blocks, inline code, plain prose). By the time HTML is produced, that distinction is already made - real attributes live in tags, and literal text is inside <code> or plain text nodes.
Existing functions that follow this pattern:
fix_internal_links() in parser.rs - rewrites <a href="..."> attributes to turn .md references into .html slugs. Runs at the end of get_html_with_options().
fix_wikilinks() in parser.rs - rewrites <a data-wikilink="true"> attributes to resolve wikilink targets. Called in site.rs after content is collected.
fix_at_media_refs() in content.rs - rewrites src="@/..." and href="@/..." to src="media/{slug}/...". Runs after get_html_with_options() returns in from_markdown().
When implementing a new feature that needs to transform paths, URLs, or text in content output, follow the same approach:
- Let comrak convert markdown to HTML first.
- Write a regex that targets the specific HTML attribute or tag (e.g.,
src="...", href="...", <a ... >).
- Define the regex pattern as a constant in
re.rs with a doc comment.
- Apply the replacement on the HTML string after
get_html_with_options() returns.
- Test that the replacement does not affect code blocks or plain text containing the same pattern.
CLI command routing
main() parses args with cli::Cli::parse(), then run_cli() checks flags in priority order. Each handler returns early after completing its task. Commands that do not need input_folder are handled before the folder resolution.
Key modules
| Module | Responsibility |
|---|
cli.rs | Clap argument definitions |
config.rs | Marmite struct, YAML deserialization, CLI overrides |
content.rs | Content struct, frontmatter parsing, slug generation |
site.rs | Core site generation (~2000 lines), template rendering, taxonomy building |
templates.rs | Template initialization, theme loading |
tera_functions.rs | Custom Tera functions (url_for, group, get_posts, etc.) |
tera_filter.rs | Custom Tera filters (default_date_format, remove_draft) |
shortcodes.rs | Shortcode processing with regex pattern matching |
parser.rs | Markdown to HTML conversion with comrak |
feed.rs | RSS feed generation |
embedded.rs | Embedded assets via rust_embed |
server.rs | Built-in HTTP server with WebSocket live reload |
image_resize.rs | Parallel image resizing with rayon |
Contribution Checklist
After any code change
mask fmt
mask check
mask test
After implementing a new feature
- Format and lint:
mask fmt and mask check
- Add tests: unit tests in
src/tests/ for module logic, integration tests in tests/ for CLI/end-to-end behavior
- Write a new blog post in
example/content/ documenting the feature (see "Documentation Requirements")
- Update CLI docs in
example/content/2024-11-26-marmite-command-line-interface.md if CLI flags changed
- Add an entry to
example/ai/llms.txt under the appropriate section
- Update the draft release notes - find the file with
stream: draft in example/content/ and add the feature
- Update the relevant reference files in
.agents/skills/marmite/references/ (see table in "Documentation Requirements")
- If templates or static assets changed, apply the same changes to
example/theme_template/ (see "Template Changes")
After implementing a bug fix
mask fmt and mask check
- Add or update tests that cover the fixed behavior
- Update the draft release notes - find the file with
stream: draft in example/content/ and describe what was broken and how it is fixed
Before opening a PR
mask fmt and mask check - must pass clean
mask test - all tests must pass
mask pedantic - run and discuss pedantic issues with the maintainer before fixing
- Verify the example site builds correctly:
mask serve, check in a browser
- If templates changed, also verify with
mask serve_theme to test the theme_template variant
- Ensure documentation is complete: blog post, llms.txt entry, skill references, draft release notes
- Ensure no unrelated changes are staged
Development Environment
First-time setup
cargo install mask
mask install_hook
mask serve
The site runs at http://localhost:8000 with auto-rebuild on file changes.
Pre-commit hook: The hook runs mask pedantic (format check + clippy with pedantic warnings) before every commit. If a check fails, the commit is blocked. To bypass for work-in-progress commits, use git commit --no-verify.
Agent note: Before starting development work, verify the hook is in place. Check if .git/hooks/pre-commit exists and contains mask pedantic. If not, run mask install_hook.
Useful commands
| Command | What it does |
|---|
mask serve | Build and serve example site with live reload and trace logging |
mask watch | Watch for changes, rebuild without serving |
mask fmt | Format code with cargo fmt |
mask check | Check formatting + run clippy |
mask test | Run all tests (unit + integration) |
mask test_unit | Run unit tests only |
mask test_integration | Run integration tests only |
mask build | Build release binary |
mask pedantic | Run clippy with pedantic warnings |
mask install_hook | Install pre-commit hook that runs mask pedantic |
mask fix | Auto-apply clippy fixes |
mask serve_theme | Serve with the theme_template theme |
mask serve_site | Serve the actual marmite.blog site locally |
mask coverage_llvm | Generate HTML coverage report (requires cargo-llvm-cov) |
Running specific tests
cargo test --bin marmite test_name_here
cargo test --test basic_functionality
cargo test -- --nocapture