librawssg is the engine‑agnostic, safety‑first kernel for building static site generators in Rust.
It gives you all the primitives you need: filesystem abstraction, frontmatter parsing, Markdown rendering, template rendering, content processing pipelines, feed & sitemap generation, and a secure development server.
The library does not include a CLI – you write your own main.rs and compose the parts you need.
Optional built‑in implementations for Tera and pulldown‑cmark are available behind feature flags.
- What's New in v0.5.0
- Installation
- Quick Start
- Architecture
- API Reference
- Feature Flags
- Security
- Full Customisation
- Testing
- Contributing
- License
- Robust path security – Symlink‑safe output writing via canonicalised parent directories.
- Correct glob matching –
**patterns now match exactly as expected (e.g.,blog/**/*.htmlno longer matches.md). - Completely trait‑based – Added
renametoFileSystem; all I/O goes through the trait for full mockability. - Better defaults – A default
pagecontent type (**/*.md→base.html) is included out‑of‑the‑box. - Optional context builders – Feed and sitemap context builders are now only required when the respective generator is enabled.
- Clearer error messages – Missing closing
---in frontmatter is reported explicitly; config loading failures are logged. - Improved testing – Property‑based tests, dynamic server ports, and a complete mock filesystem.
cargo add --git https://github.com/mroczect/librawssg.git --tag v0.5.0 librawssg
cargo add --git https://github.com/mroczect/librawssg.git --tag v0.5.0 librawssg --features tera,pulldown[dependencies]
librawssg = { git = "https://github.com/mroczect/librawssg.git", tag = "v0.5.0" }
librawssg = { git = "https://github.com/mroczect/librawssg.git", tag = "v0.5.0", features = ["tera", "pulldown"] }git clone https://github.com/mroczect/librawssg.git
cd librawssg
# in your project's Cargo.toml:
librawssg = { path = "../librawssg", features = ["tera", "pulldown"] }use librawssg::SiteBuilder;
use librawssg::site::TeraRenderer;
use librawssg::markdown::PulldownMarkdown;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut tera = TeraRenderer::new();
tera.add_raw_template("base.html", "<html><body>{{ page_content }}</body></html>")?;
let md = PulldownMarkdown;
let mut config = librawssg::RawssgConfig::default();
config.build.content_dir = "content".into();
config.build.output_dir = "dist".into();
let site = SiteBuilder::new()
.config(config)
.with_template_renderer(Box::new(tera))
.with_markdown_renderer(Box::new(md))
.build()?;
site.generate()?;
Ok(())
}src/
config/ ConfigLoader trait, YamlConfigLoader, DefaultConfig
error.rs RawssgError (miette + thiserror)
frontmatter.rs YAML frontmatter extraction and Markdown rendering
fs/ FileSystem trait, RealFs
markdown.rs MarkdownRenderer trait, optional PulldownMarkdown
serve/ Dev server and file watcher (feature "serve")
site/
builders/ Site and SiteBuilder structs
context.rs FeedContextBuilder, SitemapContextBuilder traits
feed.rs generate_feed
mod.rs Core traits: TemplateRenderer, Context, ContentHandler
page.rs build_page_context
sitemap.rs generate_sitemap
types.rs All configuration and page context types
util.rs safe_path, slugify, relative_prefix, match_pattern
site:
site_name: "My Site"
description: "A blog about Rust"
base_url: "https://example.com"
language: "en"
author: "Alice"
build:
content_dir: content
output_dir: dist
templates_dir: templates
static_dir: static
content_types:
- name: blog
pattern: blog/**/*.md
template: post.html
list_template: blog_list.html
list_enabled: true
- name: page
pattern: **/*.md
template: page.html
generators:
rss:
enabled: true
path: feed.xml
template: rss.xml
sitemap:
enabled: true
path: sitemap.xml
template: sitemap.xmlpub trait ConfigLoader: Send + Sync {
fn load(&self) -> Result<RawssgConfig, RawssgError>;
fn load_or_default(&self) -> RawssgConfig;
}YamlConfigLoader<P: AsRef<Path>>– reads a YAML file.DefaultConfig– returnsRawssgConfig::default().
- Checks that
site_nameis non‑empty. - At least one
content_typesentry must exist. - Each content type must have a valid glob pattern and a template name.
- If RSS or sitemap is enabled, their
pathandtemplatemust be set.
Since v0.5.0, the default configuration includes a single content type:
ContentTypeDef {
name: "page".into(),
pattern: "**/*.md".into(),
template: "base.html".into(),
list_template: None,
list_enabled: false,
}You can remove it with config.content_types.clear() and define your own.
RawssgError implements std::error::Error, Display, and miette::Diagnostic.
match err {
RawssgError::Frontmatter { path, source } => { /* ... */ }
RawssgError::PathTraversal(msg) => { /* ... */ }
// ...
}pub trait FileSystem: Send + Sync {
fn read_to_string(&self, path: &Path) -> io::Result<String>;
fn read_bytes(&self, path: &Path) -> io::Result<Vec<u8>>;
fn write(&self, path: &Path, content: &[u8]) -> io::Result<()>;
fn create_dir_all(&self, path: &Path) -> io::Result<()>;
fn remove_dir_all(&self, path: &Path) -> io::Result<()>;
fn exists(&self, path: &Path) -> bool;
fn is_dir(&self, path: &Path) -> bool;
fn is_file(&self, path: &Path) -> bool;
fn read_dir(&self, path: &Path) -> io::Result<Vec<PathBuf>>;
fn copy_file(&self, from: &Path, to: &Path) -> io::Result<u64>;
fn walk_dir(&self, root: &Path) -> io::Result<Vec<PathBuf>>;
fn canonicalize(&self, path: &Path) -> io::Result<PathBuf>;
fn rename(&self, from: &Path, to: &Path) -> io::Result<()>; // new in v0.5.0
}Default implementation delegating to std::fs and walkdir.
struct MyFs;
impl FileSystem for MyFs {
// implement all methods; e.g., read from database or network
fn read_to_string(&self, path: &Path) -> io::Result<String> { /* ... */ }
// ... etc.
}
let site = SiteBuilder::new().with_fs(Box::new(MyFs)).build()?;pub trait MarkdownRenderer: Send + Sync {
fn render(&self, markdown: &str) -> String;
}Available with pulldown feature. Enables tables, strikethrough, task lists.
struct MyMd;
impl MarkdownRenderer for MyMd {
fn render(&self, md: &str) -> String { my_parser(md) }
}pub trait TemplateRenderer: Send + Sync {
fn render(&self, template_name: &str, context: &dyn Context) -> Result<String, RawssgError>;
}pub trait Context: Send + Sync {
fn as_any(&self) -> &dyn Any;
fn as_mut_any(&mut self) -> &mut dyn Any;
}new()– creates an empty Tera instance.add_raw_template(name, content)– registers an inline template.
Implement TemplateRenderer and a Context wrapper.
Example: MiniJinja.
struct MiniJinjaRenderer { env: mini_jinja::Environment<'static> }
impl TemplateRenderer for MiniJinjaRenderer {
fn render(&self, name: &str, ctx: &dyn Context) -> Result<String, RawssgError> {
let tmpl = self.env.get_template(name).map_err(|e| RawssgError::Template(e.to_string()))?;
let data = ctx.as_any().downcast_ref::<serde_json::Value>().unwrap();
tmpl.render(data).map_err(|e| RawssgError::Template(e.to_string()))
}
}
impl Context for serde_json::Value { /* as_any downcast */ }pub trait ContentHandler: Send + Sync {
fn can_handle(&self, relative_path: &Path, original_path: &Path) -> bool;
fn process(&self, fs: &dyn FileSystem, md_renderer: &dyn MarkdownRenderer,
file_path: &Path, content_dir: &Path) -> Result<Option<PageContext>, RawssgError>;
}Return None to skip a file.
Handles .md files; extracts frontmatter, renders Markdown.
Always returns None (catch‑all, non‑Markdown files become static assets).
pub fn build_page_context(fs: &dyn FileSystem, md_renderer: &dyn MarkdownRenderer,
file_path: &Path, content_dir: &Path) -> Result<Option<PageContext>, RawssgError>;Skips drafts. Returns a PageContext with URL, depth, date formatting.
struct AsciiDocHandler;
impl ContentHandler for AsciiDocHandler {
fn can_handle(&self, _rel: &Path, orig: &Path) -> bool {
orig.extension().map_or(false, |e| e == "adoc")
}
fn process(&self, fs: &dyn FileSystem, _md: &dyn MarkdownRenderer, ...) -> Result<Option<PageContext>, RawssgError> {
let content = fs.read_to_string(file_path)?;
let html = asciidoc_render(&content);
Ok(Some(PageContext { content_html: html, .. }))
}
}
let builder = SiteBuilder::new().add_handler(Box::new(AsciiDocHandler));SiteBuilder::new()
.config(config)
.load_config("config.yml")? // alternative to .config()
.content_dir("my_content")
.output_dir("public")
.with_fs(Box::new(RealFs))
.with_markdown_renderer(Box::new(PulldownMarkdown))
.with_template_renderer(Box::new(TeraRenderer::new()))
.with_feed_context_builder(Box::new(TeraFeedContextBuilder))
.with_sitemap_context_builder(Box::new(TeraSitemapContextBuilder))
.add_handler(Box::new(MyHandler))
.build()?;content_dir,output_dircan be overridden by config values if left as default ("content","dist").feed_context_builderandsitemap_context_builderare only required when the corresponding generator is enabled.
let pages: &[PageContext] = site.pages();
site.generate()?; // atomic write to output_dirgenerate():
- Writes all pages (HTML) to
output_dir. - Copies static assets from
static_dir. - Copies non‑Markdown files from
content_dir. - Optionally generates RSS and sitemap (if
terafeature + enabled). - Uses atomic write: temp dir → rename (with cross‑device fallback).
If rename fails with CrossesDevices, the library performs a recursive copy and then deletes the temporary directory.
pub fn generate_feed(renderer: &dyn TemplateRenderer, config: &RawssgConfig,
posts: &[&PageContext], base_url: &str, context_builder: &dyn FeedContextBuilder) -> Result<String, RawssgError>;
pub fn generate_sitemap(renderer: &dyn TemplateRenderer, config: &RawssgConfig,
pages: &[PageContext], base_url: &str, context_builder: &dyn SitemapContextBuilder) -> Result<String, RawssgError>;Callers are responsible for writing the returned string to the output file; Site::generate does this automatically.
pub trait FeedContextBuilder: Send + Sync {
fn build_feed_context(&self, config: &RawssgConfig, posts: &[&PageContext], base_url: &str)
-> Result<Box<dyn Context>, RawssgError>;
}Default Tera implementations insert site, posts/pages, and base_url. Custom builders can add extra variables (e.g., ctx.insert("custom", &"value")).
pub fn safe_path(fs: &dyn FileSystem, base: &Path, candidate: &Path) -> Result<PathBuf, RawssgError>;- Canonicalises
base. - For existing files: canonicalises the candidate, checks it stays inside
base. - For non‑existent files (output): canonicalises the parent directory and verifies confinement.
- Returns an absolute, safe path.
Converts a string to lowercase, alphanumeric + hyphens. Example: "Hello World!" → "hello-world".
Returns "./" for depth 0, "../" repeated for deeper paths.
Glob matching with * (single segment) and ** (multi‑segment). Correctly handles patterns like blog/**/*.html.
pub struct RawssgConfig {
pub site: GlobalConfig,
pub build: BuildConfig,
pub content_types: Vec<ContentTypeDef>,
pub generators: GeneratorsConfig,
}validate()ensures invariants.Defaultnow includes one content type.
pub struct GlobalConfig {
pub site_name: String, // default "rawssg"
pub description: Option<String>,
pub language: Option<String>, // default Some("en")
pub base_url: Option<String>,
pub author: Option<String>,
pub repo_url: Option<String>,
pub license: Option<String>,
pub navbar: Vec<NavItem>,
pub sidebar: Vec<NavItem>,
}pub struct BuildConfig {
pub content_dir: String, // default "content"
pub output_dir: String, // default "dist"
pub templates_dir: String, // default "templates"
pub static_dir: String, // default "static"
}pub struct ContentTypeDef {
pub name: String,
pub pattern: String, // glob
pub template: String,
pub list_template: Option<String>,
pub list_enabled: bool,
}pub struct GeneratorsConfig { pub rss: GeneratorDef, pub sitemap: GeneratorDef }
pub struct GeneratorDef {
pub enabled: bool, // default false
pub path: String,
pub template: String,
}pub struct NavItem { pub label: String, pub url: String }pub struct PageFrontMatter {
pub title: String,
pub desc: String,
pub author: Option<String>,
pub date: Option<NaiveDate>,
pub tags: Vec<String>,
pub draft: bool,
// ...
}pub struct PageContext {
pub frontmatter: PageFrontMatter,
pub content_html: String,
pub url: String,
pub file_path: String,
pub depth: usize,
pub pub_date: Option<String>,
pub content_type: String,
pub is_list: bool,
pub list_items: Option<Vec<PageContext>>,
}use librawssg::serve::start_dev_server;
start_dev_server(Path::new("dist"), 8080)?;- Serves files with correct MIME types (including
text/plainfor.txt,.md,.yaml). - 404 for missing, 500 for internal errors.
use librawssg::serve::watch_dirs;
let _watcher = watch_dirs(&[content_path, templates_path], || { rebuild(); })?;Uses notify to trigger on Modify, Create, Remove.
| Feature | Deps | Description |
|---|---|---|
tera |
tera |
TeraRenderer, context builders |
pulldown |
pulldown-cmark |
PulldownMarkdown |
serve |
tiny_http,notify |
Dev server + file watcher |
All disabled by default.
- Path confinement:
safe_pathprevents directory traversal for both existing and new files. - Atomic output: Temp directory → rename; fallback copy‑and‑delete ensures atomicity across devices.
- Configuration validation: All YAML keys are known; glob patterns are validated.
- Trait‑based I/O: Every disk access goes through
FileSystem, allowing sandboxing and auditing.
Every core component is a trait. You can:
- Filesystem: Implement
FileSystemto read from database, in‑memory store, or network. - Markdown: Any parser via
MarkdownRenderer. - Templates: Any engine via
TemplateRenderer+Context. - Content handlers: Add new file processors (e.g., AsciiDoc, reStructuredText).
- Feed/Sitemap: Custom context builders inject arbitrary variables.
- Site generation: Use
SiteBuilderto buildSite, then replacegenerate()with your own logic.
- Define your custom types – implement the required traits.
- Load configuration – use
YamlConfigLoaderor buildRawssgConfigprogrammatically. - Instantiate
SiteBuilderwith your custom implementations. - Call
.build()to obtain aSite. - Generate using
site.generate(), or iterate oversite.pages()for custom output.
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --workspace --all-featuresThe test suite includes:
MockFs– full mock filesystem (withrename).- Mock renderers.
- Property‑based tests (
proptest). - Integration tests: full generation, drafts, blog lists, RSS/sitemap errors.
- Dynamic port allocation for server tests.
Please read CONTRIBUTING.md for guidelines.
All contributions are welcome – issues, PRs, documentation improvements.
MIT. See LICENSE.