diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..8c0e653 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,60 @@ +name: Build and Deploy Docs + +on: + push: + branches: [ "main" ] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + components: rustfmt, clippy + + - name: Cache Cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Build documentation site + run: cargo run -p docs --release + env: + CARGO_NET_GIT_FETCH_WITH_CLI: true + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs/dist + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/Cargo.lock b/Cargo.lock index 4e5a8bf..83eaee6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -135,6 +135,13 @@ version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" +[[package]] +name = "docs" +version = "0.1.0" +dependencies = [ + "librawssg", +] + [[package]] name = "equivalent" version = "1.0.2" diff --git a/Cargo.toml b/Cargo.toml index b4fa58a..743fecc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["librawssg","librawssg_compiler", "librawssg_config", "librawssg_demo", "librawssg_error", "librawssg_fs","librawssg_handler", "librawssg_templates"] +members = ["librawssg","librawssg_compiler", "librawssg_config", "librawssg_demo", "librawssg_error", "librawssg_fs","librawssg_handler", "librawssg_templates", "docs"] [workspace.lints.clippy] all = { level = "deny", priority = -1 } diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..3e22129 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1 @@ +/dist \ No newline at end of file diff --git a/docs/Cargo.toml b/docs/Cargo.toml new file mode 100644 index 0000000..fa076fb --- /dev/null +++ b/docs/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "docs" +version = "0.1.0" +edition = "2024" + +[dependencies] +librawssg = {version = "1.0.0", path = "../librawssg" } + +[lints] +workspace = true diff --git a/docs/src/content/api/compiler.raw b/docs/src/content/api/compiler.raw new file mode 100644 index 0000000..0f76215 --- /dev/null +++ b/docs/src/content/api/compiler.raw @@ -0,0 +1,1139 @@ +

librawssg_compiler

+

+ Version: 1.0.0 (implied)
Crate name: + librawssg_compiler
Description: Provides + the core compilation pipeline for the librawssg static site + generator. This crate orchestrates the entire build process: reading content + files, processing them via pluggable processors, rendering templates, copying + static assets, running custom generators, and outputting the final site + atomically. +

+
+

Table of Contents

+
    +
  1. Overview
  2. +
  3. Modules and Re‑exports
  4. +
  5. + PipelineBuilder + +
  6. +
  7. + ContextBuilder Trait + +
  8. +
  9. + Generator Trait + +
  10. +
  11. + Pattern Matching Function + +
  12. +
  13. + Pipeline Struct + +
  14. +
  15. Error Handling
  16. +
  17. + Complete Example from Tests + +
  18. +
  19. Testing Suite Overview
  20. +
  21. Conclusion
  22. +
+
+

Overview

+

+ librawssg_compiler is the orchestration layer that ties together + all other components of the static site generator: +

+ +

+ The main entry point is PipelineBuilder, which constructs a + Pipeline after validating the configuration and ensuring + mandatory components are present. Running the pipeline performs the full site + generation in an atomic fashion, producing output in the configured output + directory. +

+
+

Modules and Re‑exports

+

+ The crate root (lib.rs) declares the following public modules: +

+ +

Re‑exported types at the crate root:

+
pub use builder::PipelineBuilder;
+pub use context::ContextBuilder;
+pub use context::TeraContextBuilder;
+pub use generator::Generator;
+pub use pipeline::Pipeline;
+
+

+ The match_pattern function is also re‑exported? Actually it is + in pattern module and not re‑exported at root, so users must use + librawssg_compiler::pattern::match_pattern. However, in + pipeline.rs it is imported via + crate::pattern::match_pattern, but for external users they need + to access it via module path. +

+
+

Struct PipelineBuilder

+

+ PipelineBuilder is a builder‑style struct that collects all + components needed to run the compilation pipeline and then builds a + Pipeline. +

+
pub struct PipelineBuilder {
+    config: Config,
+    content_dir: PathBuf,
+    output_dir: PathBuf,
+    fs: Box<dyn FileSystem>,
+    renderer: Option<Box<dyn Renderer>>,
+    processors: Vec<Box<dyn Processor>>,
+    context_builder: Option<Box<dyn ContextBuilder>>,
+    generators: Vec<Box<dyn Generator>>,
+}
+
+

+ Note: Fields are private; use the builder methods to + configure. +

+

PipelineBuilder::new

+
#[must_use]
+pub fn new() -> Self
+
+

Purpose: Creates a new builder with default values:

+ +

Returns: A fresh PipelineBuilder.

+

Example:

+
let builder = PipelineBuilder::new();
+
+
+

PipelineBuilder Builder Methods

+

+ All builder methods consume self and return Self, + allowing method chaining. +

+

config

+
#[must_use]
+pub fn config(mut self, config: Config) -> Self
+
+

Purpose: Sets the configuration object.

+

Parameters:

+ +

Returns: The builder with the config set.

+

load_config

+
pub fn load_config<P: AsRef<Path> + Send + Sync>(mut self, path: P) -> Result<Self>
+
+

+ Purpose: Reads a YAML config file from disk and parses it + into a Config. Errors are converted to + Error::Config. +

+

Parameters:

+ +

+ Returns: Ok(Self) if parsing succeeds, + otherwise Err(Error::Config). +

+

+ Note: The file is read using standard + std::fs::read_to_string; the error is wrapped in + Error::Config. +

+

content_dir

+
#[must_use]
+pub fn content_dir(mut self, dir: impl Into<PathBuf>) -> Self
+
+

Purpose: Overrides the content directory.

+

Parameters:

+ +

+ Default: "content" (but may be + overridden by config if not explicitly set; see build()). +

+

output_dir

+
#[must_use]
+pub fn output_dir(mut self, dir: impl Into<PathBuf>) -> Self
+
+

Purpose: Overrides the output directory.

+

Default: "dist".

+

with_fs

+
#[must_use]
+pub fn with_fs(mut self, fs: Box<dyn FileSystem>) -> Self
+
+

+ Purpose: Sets a custom filesystem implementation. Useful for + testing or non‑standard backends. +

+

Default: RealFs.

+

with_renderer

+
#[must_use]
+pub fn with_renderer(mut self, renderer: Box<dyn Renderer>) -> Self
+
+

+ Purpose: Sets the template renderer. + Mandatory; build() will fail if not set. +

+

add_processor

+
#[must_use]
+pub fn add_processor(mut self, processor: Box<dyn Processor>) -> Self
+
+

+ Purpose: Adds a content processor to the pipeline. Multiple + processors can be added; they are tried in order for each source file. +

+

with_context_builder

+
#[must_use]
+pub fn with_context_builder(mut self, builder: Box<dyn ContextBuilder>) -> Self
+
+

+ Purpose: Sets the context builder. + Mandatory; build() will fail if not set. +

+

add_generator

+
#[must_use]
+pub fn add_generator(mut self, generator: Box<dyn Generator>) -> Self
+
+

+ Purpose: Adds a post‑processing generator. Generators run + after all documents are rendered and static assets copied. +

+
+

PipelineBuilder::build

+
pub fn build(mut self) -> Result<Pipeline>
+
+

+ Purpose: Validates the configuration, ensures required + components are present, and constructs a Pipeline. +

+

Behavior:

+
    +
  1. + Calls self.config.validate()? (see + librawssg_config::Config::validate). +
  2. +
  3. + If content_dir is still the default + "content" (i.e., not changed by + content_dir()), it is replaced with + self.config.build.content_dir. +
  4. +
  5. + Similarly, if output_dir is still + "dist", it is replaced with + self.config.build.output_dir. +
  6. +
  7. + Takes the renderer from self.renderer (using + take()). If None, returns + Error::Config("template renderer not set"). +
  8. +
  9. + Takes the context builder from self.context_builder. If + None, returns + Error::Config("context builder not set"). +
  10. +
  11. + Moves all remaining fields into a new Pipeline and returns + Ok. +
  12. +
+

Returns:

+ +
+

PipelineBuilder Default

+
impl Default for PipelineBuilder {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+

Allows creating with PipelineBuilder::default().

+
+

PipelineBuilder Example

+
use librawssg_compiler::{PipelineBuilder, TeraContextBuilder};
+use librawssg_config::Config;
+use librawssg_fs::RealFs;
+use librawssg_handler::Processor;
+use librawssg_templates::{Renderer, TeraRenderer};
+
+// Assuming custom processor and renderer exist
+let config = Config::new().with_site_name("My Site");
+let processor = Box::new(MyProcessor);
+let renderer = Box::new(TeraRenderer::new()); // TeraRenderer must be configured with templates beforehand
+let context_builder = Box::new(TeraContextBuilder);
+
+let pipeline = PipelineBuilder::new()
+    .config(config)
+    .content_dir("src")
+    .output_dir("public")
+    .with_fs(Box::new(RealFs))
+    .with_renderer(renderer)
+    .with_context_builder(context_builder)
+    .add_processor(processor)
+    .build()?;
+
+
+

Trait ContextBuilder

+
pub trait ContextBuilder: Send + Sync {
+    fn build_context(&self, config: &Config, doc: &Document) -> Result<Box<dyn RenderContext>>;
+}
+
+

+ Purpose: Abstract factory that creates a + RenderContext from the global Config and a specific + Document. The renderer then uses this context to render the + document's template. +

+

+ Requirements: Implementors must be Send + Sync. +

+

build_context

+

Parameters:

+ +

Returns:

+ +
+

Struct TeraContextBuilder

+
#[derive(Debug, Default, Clone, Copy)]
+pub struct TeraContextBuilder;
+
+

+ A concrete implementation of ContextBuilder that produces a + tera::Context populated with common page and site data. +

+

Implementation Details

+

+ TeraContextBuilder creates a new tera::Context and + inserts the following keys: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyValue SourceDescription
site&config.siteThe full SiteConfig object.
page_title&doc.metadata.titleDocument title.
page_description&doc.metadata.descriptionDocument description.
page_author&doc.metadata.authorOptional author.
page_date&doc.metadata.dateOptional publication date.
page_tags&doc.metadata.tagsVector of tags.
page_content&doc.bodyThe rendered body content of the document.
page_url&doc.urlRelative URL of the document.
page_depth&doc.depthDepth in the site hierarchy.
page_type&doc.content_typeContent type identifier (e.g., "blog").
page_is_list&doc.is_listBoolean indicating list page.
page_list_items&doc.list_itemsOptional vector of child documents for lists.
+

+ Note: The page_list_items field is inserted as + &doc.list_items which is + Option<Vec<Document>>. In Tera templates, this will + be None or an array. +

+

Example:

+
use librawssg_compiler::TeraContextBuilder;
+let builder = TeraContextBuilder;
+let ctx = builder.build_context(&config, &doc)?;
+// Pass `ctx` to renderer.render(...)
+
+
+

Trait Generator

+
pub trait Generator: Send + Sync {
+    fn generate(&self, pipeline: &Pipeline, output_base: &Path) -> Result<()>;
+}
+
+

+ Purpose: Allows custom post‑processing steps after the main + site generation. Generators can write additional files to the output + directory (e.g., RSS feed, sitemap, search index). +

+

+ Requirements: Implementors must be Send + Sync. +

+

generate

+

Parameters:

+ +

Returns:

+ +

Example (from tests):

+
struct DummyGenerator;
+
+impl Generator for DummyGenerator {
+    fn generate(&self, _pipeline: &Pipeline, output_base: &Path) -> Result<()> {
+        let path = output_base.join("generated.txt");
+        std::fs::write(&path, b"generated")?;
+        Ok(())
+    }
+}
+
+
+

Function match_pattern

+
#[must_use]
+pub fn match_pattern(pattern: &str, path: &Path) -> bool
+
+

Location: librawssg_compiler::pattern

+

+ Purpose: Checks whether a file path matches a glob pattern + with support for * (within a segment) and + ** (across segments). Used by the pipeline to assign content + types based on ContentRule patterns. +

+

Parameters:

+ +

+ Returns: true if the path matches the pattern; + false otherwise. +

+

Supported Glob Syntax

+ +

Limitations:

+ +

Algorithm Overview

+

+ The function first converts the path to a string (lossy) and splits both + pattern and path by /. It then calls an internal recursive + match_pattern_slice. The logic: +

+ +

Examples

+
use std::path::Path;
+use librawssg_compiler::pattern::match_pattern;
+
+assert!(match_pattern("**/*.html", Path::new("blog/post.html")));
+assert!(match_pattern("blog/**/*.html", Path::new("blog/2024/post.html")));
+assert!(match_pattern("*.html", Path::new("index.html")));
+assert!(!match_pattern("*.html", Path::new("blog/post.html")));
+assert!(match_pattern("**", Path::new("anything/at/all")));
+assert!(match_pattern("**/*.md", Path::new("readme.md"))); // zero segments before .md
+
+
+

Struct Pipeline

+

+ The Pipeline is the core execution engine. It is created by + PipelineBuilder::build() and holds all necessary components. +

+
pub struct Pipeline {
+    config: Config,
+    fs: Box<dyn FileSystem>,
+    renderer: Box<dyn Renderer>,
+    processors: Vec<Box<dyn Processor>>,
+    context_builder: Box<dyn ContextBuilder>,
+    generators: Vec<Box<dyn Generator>>,
+    content_dir: PathBuf,
+    output_dir: PathBuf,
+}
+
+

+ All fields are private; access to configuration is provided via the + config() method. +

+

Pipeline::config

+
#[must_use]
+pub const fn config(&self) -> &Config
+
+

+ Purpose: Returns a reference to the configuration used by + this pipeline. +

+

Returns: &Config.

+
+

Pipeline::run

+
pub fn run(&self) -> Result<()>
+
+

+ Purpose: Executes the full site generation process + atomically. +

+

Behavior:

+
    +
  1. + Determines a temporary output directory: + output_dir.with_extension("tmp"). For example, if + output_dir is "dist", the temp dir is + "dist.tmp". +
  2. +
  3. If the temp dir already exists, it is removed.
  4. +
  5. Creates the temp dir.
  6. +
  7. + Calls internal generate_to(&tmp_dir) to perform the + actual generation into the temporary location. +
  8. +
  9. If the final output directory exists, it is removed.
  10. +
  11. + Attempts to rename the temp dir to the final output dir. + +
  12. +
+

+ Returns: Ok(()) on success, or + Err(Error). +

+

+ Note: This atomic approach ensures that the final output + directory is never left in a partially generated state; either the old output + remains untouched (if generation fails) or the new output replaces it + atomically (or near‑atomically). +

+
+

Pipeline Internal Workflow

+

+ The internal method + generate_to(output_base: &Path) orchestrates the entire + generation. The following steps are performed (not public, but described for + understanding): +

+
    +
  1. + Create output directory – + fs.create_dir_all(output_base). +
  2. +
  3. + Process documents – Calls + process_documents() to get a vector of Document. +
  4. +
  5. + Group documents by content type – Builds a + HashMap<String, Vec<Document>>. +
  6. +
  7. + Render non‑list documents – For each + Document where is_list == false, calls + render_document(output_base, doc). +
  8. +
  9. + Generate list pages – For each content type that has a + matching ContentRule with list_enabled == true, + list_template set, and at least one document, creates a + synthetic list document (is_list = true) and renders it using + the list template. The list document’s list_items are all + documents of that content type. The output path is + "{content_type}/index.html". +
  10. +
  11. + Copy static assets – If the directory specified by + config.build.static_dir exists, its entire contents are + copied to output_base/static_dir_name (using + copy_dir_all). +
  12. +
  13. + Run generators – Iterates over all + generators and calls generate() for each, + passing the output base. +
  14. +
+

Document Processing

+

+ process_documents() walks the content directory (using + fs.walk_dir). For each file, it: +

+ +

+ If no processor matches, the file is ignored. If a processor returns + Ok(None), the file is skipped. If any processor returns an + error, the whole processing fails. +

+

Content Type Determination

+

+ determine_content_type(rel) iterates through the + config.content_rules in reverse order (so later + rules take precedence) and returns the name of the first rule + whose pattern matches the relative path. If no rule matches, the + content type defaults to "page". +

+

Rendering

+

+ render_document calls template_for_document(doc) to + get the template name: +

+ +

The actual rendering uses:

+ +

List Generation

+

+ When generating list pages, the pipeline creates a Metadata with + title = content_type and empty description. Then constructs a + Document with: +

+ +

+ Then sets list_items to the cloned vector of documents of that + type, and renders using the list template. +

+

Static Assets

+

+ The static directory from config.build.static_dir is copied + verbatim. The destination is output_base joined with the static + directory’s base name (e.g., if static_dir = "static", + files are copied to output_base/static/). Directories are + recursively copied. +

+

Generators

+

+ After all documents and static files are in place, each registered + Generator is invoked with &self and + output_base. This allows adding custom files like RSS feeds, + sitemaps, etc. +

+
+

Error Handling

+

+ librawssg_compiler uses librawssg_error::Error for + all fallible operations. The common error variants encountered: +

+ +

+ Methods return Result<T> (alias for + std::result::Result<T, librawssg_error::Error>). +

+
+

Complete Example from Tests

+

+ The test file full_compiler_test.rs demonstrates a full working + pipeline with mock components. Below is a simplified but complete example. +

+

Example Setup

+

Define mock renderer, context, context builder, and processor:

+
use librawssg_compiler::{PipelineBuilder, TeraContextBuilder};
+use librawssg_config::{Config, ContentRule};
+use librawssg_fs::{FileSystem, RealFs};
+use librawssg_handler::{Document, Metadata, Processor};
+use librawssg_templates::{RenderContext, Renderer};
+use std::path::{Path, PathBuf};
+
+// Mock renderer: returns "rendered:{template_name}"
+struct MockRenderer;
+impl Renderer for MockRenderer {
+    fn render(&self, template_name: &str, _ctx: &dyn RenderContext) -> Result<String> {
+        Ok(format!("rendered:{template_name}"))
+    }
+}
+
+// Mock context
+struct MockContext;
+impl RenderContext for MockContext {
+    fn as_any(&self) -> &dyn Any { self }
+    fn as_mut_any(&mut self) -> &mut dyn Any { self }
+}
+
+// Mock context builder
+struct MockContextBuilder;
+impl ContextBuilder for MockContextBuilder {
+    fn build_context(&self, _config: &Config, _doc: &Document) -> Result<Box<dyn RenderContext>> {
+        Ok(Box::new(MockContext))
+    }
+}
+
+// Processor that handles .html files
+struct RawHtmlProcessor;
+impl Processor for RawHtmlProcessor {
+    fn name(&self) -> &'static str { "raw-html" }
+    fn can_process(&self, relative_path: &Path, _original_path: &Path) -> bool {
+        relative_path.extension().is_some_and(|ext| ext == "html")
+    }
+    fn process(&self, fs: &dyn FileSystem, relative_path: &Path, content_dir: &Path) -> Result<Option<Document>> {
+        let full_path = content_dir.join(relative_path);
+        let content = fs.read_to_string(&full_path)?;
+        let url = relative_path.with_extension("html").to_string_lossy().to_string();
+        let output_path = PathBuf::from(&url);
+        let metadata = Metadata::new("Test", "Description")?;
+        let doc = Document::new(
+            metadata,
+            content,
+            url,
+            output_path,
+            relative_path.to_path_buf(),
+            0,
+            "page".to_string(),
+            false,
+        )?;
+        Ok(Some(doc))
+    }
+}
+
+// Config with one rule
+fn setup_config() -> Config {
+    let mut config = Config::new().with_site_name("Compiler Test");
+    config.add_content_rule(ContentRule::new("page", "**/*.html", "base"));
+    config
+}
+
+// Build pipeline
+fn build_pipeline(content_dir: &Path, output_dir: &Path) -> Result<Pipeline> {
+    PipelineBuilder::new()
+        .config(setup_config())
+        .content_dir(content_dir)
+        .output_dir(output_dir)
+        .with_fs(Box::new(RealFs))
+        .with_renderer(Box::new(MockRenderer))
+        .with_context_builder(Box::new(MockContextBuilder))
+        .add_processor(Box::new(RawHtmlProcessor))
+        .build()
+}
+
+

Running the Pipeline

+
let tmp = tempfile::TempDir::new()?;
+let content_dir = tmp.path().join("content");
+let output_dir = tmp.path().join("dist");
+std::fs::create_dir_all(&content_dir)?;
+std::fs::write(content_dir.join("index.html"), "<h1>Home</h1>")?;
+
+let pipeline = build_pipeline(&content_dir, &output_dir)?;
+pipeline.run()?;
+
+

Verifying Output

+
let output_file = output_dir.join("index.html");
+assert!(output_file.exists());
+let content = std::fs::read_to_string(output_file)?;
+assert_eq!(content, "rendered:base");
+
+
+

Testing Suite Overview

+

+ The test file tests/full_compiler_test.rs contains comprehensive + integration tests covering: +

+ +

+ Each test uses temporary directories (tempfile) and mock + implementations to isolate components. The tests serve as executable examples + of how to configure and run the pipeline. +

+
+

Conclusion

+

+ librawssg_compiler is the central execution engine of the static + site generator. It provides a flexible builder to assemble the necessary + components, a robust Pipeline that orchestrates all steps, and + extension points via Processor, Renderer, + ContextBuilder, and Generator. The pattern matching + function and atomic output generation ensure correctness and safety. The + comprehensive test suite demonstrates practical usage and edge cases. +

+

For further details, refer to the source code and the test file.

diff --git a/docs/src/content/api/config.raw b/docs/src/content/api/config.raw new file mode 100644 index 0000000..c9b0b22 --- /dev/null +++ b/docs/src/content/api/config.raw @@ -0,0 +1,1071 @@ +

librawssg_config

+

+ Version: 1.0.0 (implied)
Crate name: + librawssg_config
Description: Defines + configuration data structures for the librawssg static site + generator. Provides Config, SiteConfig, + BuildConfig, ContentRule, and + NavItem types, along with serialization/deserialization support + (YAML and JSON) and validation logic. +

+
+

Table of Contents

+
    +
  1. Overview
  2. +
  3. Modules
  4. +
  5. + BuildConfig + +
  6. +
  7. + ContentRule + +
  8. +
  9. + NavItem + +
  10. +
  11. + SiteConfig + +
  12. +
  13. + Config + +
  14. +
  15. Error Handling
  16. +
  17. Serialization Details
  18. +
  19. Examples from Tests
  20. +
  21. Testing Suite Overview
  22. +
  23. Conclusion
  24. +
+
+

Overview

+

+ librawssg_config provides the central configuration types used + by the static site generator. The main Config struct combines + site settings (SiteConfig), build paths + (BuildConfig), content processing rules + (ContentRule), and arbitrary extra data. All types are + serializable/deserializable via serde, enabling configuration to + be read from and written to YAML or JSON files. +

+

+ The types are designed with sensible defaults and include a + validate() method to ensure the configuration is internally + consistent and safe (e.g., preventing path traversal in patterns). +

+
+

Modules

+

+ The crate root (lib.rs) declares the following public modules: +

+ +

All public types are re‑exported at the crate root for convenience:

+
pub use build::BuildConfig;
+pub use config::Config;
+pub use content_rule::ContentRule;
+pub use nav::NavItem;
+pub use site::SiteConfig;
+
+
+

Struct BuildConfig

+

Represents filesystem path configuration for the build process.

+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[non_exhaustive]
+pub struct BuildConfig {
+    #[serde(default = "default_content_dir")]
+    pub content_dir: String,
+    #[serde(default = "default_output_dir")]
+    pub output_dir: String,
+    #[serde(default = "default_templates_dir")]
+    pub templates_dir: String,
+    #[serde(default = "default_static_dir")]
+    pub static_dir: String,
+}
+
+

BuildConfig Fields

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDefault ValueDescription
content_dirString"content"Directory containing source content files.
output_dirString"dist"Directory where generated site output will be written.
templates_dirString"templates"Directory containing template files.
static_dirString"static"Directory containing static assets (copied as-is).
+

+ Note: #[non_exhaustive] prevents external + crates from exhaustively matching or constructing with a struct literal. Use + the provided constructors or update syntax. +

+

BuildConfig::new

+
#[must_use]
+pub fn new() -> Self
+
+

+ Purpose: Creates a BuildConfig with default + values (identical to BuildConfig::default()). +

+

+ Returns: A new BuildConfig with all fields set + to their defaults. +

+

Example:

+
let build = BuildConfig::new();
+assert_eq!(build.content_dir, "content");
+
+

BuildConfig Default

+

The Default trait is implemented with the following values:

+ +

+ These defaults can be overridden during deserialization; missing fields in + serialized data will fall back to these defaults (thanks to + #[serde(default = "...")]). +

+

BuildConfig Serialization

+

+ BuildConfig derives Serialize and + Deserialize. When deserializing from YAML/JSON, any omitted + fields will use the specified default functions. This allows partial + configuration. +

+

Example (from tests):

+
let yaml = "content_dir: custom_content\noutput_dir: public\n";
+let build: BuildConfig = serde_yaml::from_str(yaml)?;
+assert_eq!(build.content_dir, "custom_content");
+assert_eq!(build.templates_dir, "templates"); // default
+
+
+

Struct ContentRule

+

Defines how a certain group of content files should be processed.

+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
+#[non_exhaustive]
+pub struct ContentRule {
+    pub name: String,
+    pub pattern: String,
+    pub template: String,
+    #[serde(default)]
+    pub list_template: Option<String>,
+    #[serde(default)]
+    pub list_enabled: bool,
+    #[serde(default)]
+    pub extra: HashMap<String, serde_json::Value>,
+}
+
+

ContentRule Fields

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDefaultDescription
nameString"" + Unique identifier for the rule (e.g., "blog", + "page"). +
patternString"" + Glob pattern matching content files (e.g., + "**/*.md"). Must not contain ... +
templateString""Name of the template to use for rendering each matched file.
list_templateOption<String>None + Optional template name for rendering list pages (e.g., index pages). +
list_enabledboolfalseWhether list generation is enabled for this rule.
extraHashMap<String, serde_json::Value>empty mapArbitrary extra data associated with the rule.
+

ContentRule::new

+
#[must_use]
+pub fn new(
+    name: impl Into<String>,
+    pattern: impl Into<String>,
+    template: impl Into<String>,
+) -> Self
+
+

+ Purpose: Creates a ContentRule with the + required fields (name, pattern, + template). All other fields are set to their defaults. +

+

Parameters:

+ +

Returns: A new ContentRule instance.

+

Example:

+
let rule = ContentRule::new("blog", "**/*.md", "post");
+assert_eq!(rule.name, "blog");
+assert!(!rule.list_enabled);
+
+

ContentRule Default

+

The Default implementation (derived) sets:

+ +

ContentRule Serialization

+

+ Serializes/deserializes with serde. Missing optional fields + default as specified. The extra map can hold any JSON‑compatible + values. +

+

Example:

+
let mut rule = ContentRule::new("page", "**/*.html", "base");
+rule.list_enabled = true;
+rule.list_template = Some("list".into());
+rule.extra.insert("key".into(), json!("value"));
+let yaml = serde_yaml::to_string(&rule)?;
+let parsed: ContentRule = serde_yaml::from_str(&yaml)?;
+assert_eq!(rule, parsed);
+
+
+

Struct NavItem

+

Represents an item in a navigation menu (navbar or sidebar).

+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
+#[non_exhaustive]
+pub struct NavItem {
+    pub label: String,
+    pub url: String,
+    pub children: Vec<Self>,
+}
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDefaultDescription
labelString""Display text for the navigation link.
urlString""URL the link points to (relative or absolute).
childrenVec<NavItem>emptyNested sub‑items, enabling hierarchical menus.
+ +
#[must_use]
+pub fn new(label: impl Into<String>, url: impl Into<String>) -> Self
+
+

+ Purpose: Creates a NavItem with a label and + URL. The children vector starts empty. +

+

Parameters:

+ +

Returns: A new NavItem.

+

Example:

+
let item = NavItem::new("Home", "/");
+assert_eq!(item.label, "Home");
+assert!(item.children.is_empty());
+
+ +

+ NavItem::default() creates an item with empty label, empty URL, + and no children. +

+ +

+ Supports serialization and deserialization via serde. Nested + children are handled recursively. +

+

Example:

+
let parent = NavItem::new("Docs", "/docs");
+parent.children.push(NavItem::new("API", "/docs/api"));
+let json = serde_json::to_string(&parent)?;
+let parsed: NavItem = serde_json::from_str(&json)?;
+assert_eq!(parent, parsed);
+
+
+

Struct SiteConfig

+

Holds global site metadata and navigation structures.

+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[non_exhaustive]
+pub struct SiteConfig {
+    #[serde(default)]
+    pub navbar: Vec<super::NavItem>,
+    #[serde(default)]
+    pub sidebar: Vec<super::NavItem>,
+    #[serde(default = "default_site_name")]
+    pub site_name: String,
+    #[serde(default)]
+    pub description: Option<String>,
+    #[serde(default = "default_language")]
+    pub language: Option<String>,
+    #[serde(default)]
+    pub base_url: Option<String>,
+    #[serde(default)]
+    pub author: Option<String>,
+    #[serde(default)]
+    pub repo_url: Option<String>,
+    #[serde(default)]
+    pub license: Option<String>,
+    #[serde(default)]
+    pub extra: HashMap<String, serde_json::Value>,
+}
+
+

SiteConfig Fields

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDefaultDescription
navbarVec<NavItem>emptyNavigation items for the top bar.
sidebarVec<NavItem>emptyNavigation items for the sidebar.
site_nameString"librawssg"The name of the website.
descriptionOption<String>NoneShort site description.
languageOption<String>Some("en") + Site language code (e.g., "en", + "id"). +
base_urlOption<String>None + Base URL for the site; must start with http:// or + https:// if set. +
authorOption<String>NoneDefault author name.
repo_urlOption<String>NoneURL to the source repository.
licenseOption<String>NoneLicense identifier (e.g., "MIT").
extraHashMap<String, serde_json::Value>empty mapArbitrary extra site‑wide metadata.
+

SiteConfig::new

+
#[must_use]
+pub fn new(site_name: impl Into<String>) -> Self
+
+

+ Purpose: Creates a SiteConfig with a custom + site name. All other fields are set to their defaults (navbar/sidebar empty, + language Some("en"), etc.). +

+

Parameters:

+ +

Returns: A new SiteConfig.

+

Example:

+
let site = SiteConfig::new("My Site");
+assert_eq!(site.site_name, "My Site");
+assert_eq!(site.language.as_deref(), Some("en"));
+
+

SiteConfig Default

+

SiteConfig::default() sets:

+ +

SiteConfig Serialization

+

+ Supports YAML/JSON. Missing fields during deserialization use defaults. The + language default is provided by a custom function. +

+

Example:

+
let mut site = SiteConfig::new("Test");
+site.extra.insert("foo".into(), json!("bar"));
+let yaml = serde_yaml::to_string(&site)?;
+let parsed: SiteConfig = serde_yaml::from_str(&yaml)?;
+assert_eq!(site, parsed);
+
+
+

Struct Config

+

The top‑level configuration combining all other components.

+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
+#[non_exhaustive]
+pub struct Config {
+    pub site: SiteConfig,
+    pub build: BuildConfig,
+    pub content_rules: Vec<ContentRule>,
+    #[serde(default)]
+    pub extra: HashMap<String, serde_json::Value>,
+}
+
+

Config Fields

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDefaultDescription
siteSiteConfig + SiteConfig::default() (site name "librawssg") + Global site configuration.
buildBuildConfigBuildConfig::default()Build path settings.
content_rulesVec<ContentRule>emptyList of content processing rules.
extraHashMap<String, serde_json::Value>empty mapArbitrary top‑level extra data.
+

Config::new

+
#[must_use]
+pub fn new() -> Self
+
+

+ Purpose: Creates a Config with all fields + defaulted. Equivalent to Config::default(). +

+

Returns: A new Config.

+

Example:

+
let config = Config::new();
+assert!(config.content_rules.is_empty());
+
+

Config::with_site_name

+
#[must_use]
+pub fn with_site_name(mut self, name: impl Into<String>) -> Self
+
+

+ Purpose: Builder‑style method that sets the + site.site_name and returns the modified Config. +

+

Parameters:

+ +

+ Returns: The same Config with updated site + name. +

+

Example:

+
let config = Config::new().with_site_name("My Awesome Site");
+assert_eq!(config.site.site_name, "My Awesome Site");
+
+

Config Rule Management

+

add_content_rule

+
pub fn add_content_rule(&mut self, rule: ContentRule)
+
+

+ Purpose: Appends a ContentRule to the + content_rules vector. +

+

Parameters:

+ +

Example:

+
config.add_content_rule(ContentRule::new("blog", "**/*.md", "post"));
+
+

find_rule_by_name

+
#[must_use]
+pub fn find_rule_by_name(&self, name: &str) -> Option<&ContentRule>
+
+

+ Purpose: Searches for a content rule by its + name field. +

+

Parameters:

+ +

+ Returns: Some(&ContentRule) if found, + otherwise None. +

+

Example:

+
if let Some(rule) = config.find_rule_by_name("blog") {
+    // ...
+}
+
+

remove_rule_by_name

+
pub fn remove_rule_by_name(&mut self, name: &str) -> Option<ContentRule>
+
+

+ Purpose: Removes and returns the first content rule whose + name matches the given string. +

+

Parameters:

+ +

+ Returns: Some(ContentRule) if found and + removed, otherwise None. +

+

Example:

+
let removed = config.remove_rule_by_name("blog");
+
+

has_duplicate_rule_names

+
#[must_use]
+pub fn has_duplicate_rule_names(&self) -> bool
+
+

+ Purpose: Checks whether any two content rules share the same + name. +

+

+ Returns: true if duplicates exist, + false otherwise. +

+

+ Implementation: Uses a HashSet to detect + duplicates; O(n) time. +

+

Example:

+
if config.has_duplicate_rule_names() {
+    // handle error
+}
+
+

Config::validate

+
pub fn validate(&self) -> Result<()>
+
+

+ Purpose: Performs comprehensive validation of the + configuration. Returns Ok(()) if the configuration is valid, + otherwise an Err(Error::Validation(...)) with a descriptive + message. +

+

Validation Rules:

+
    +
  1. site.site_name must not be empty or whitespace‑only.
  2. +
  3. At least one content rule must be defined.
  4. +
  5. No duplicate content rule names.
  6. +
  7. + For each content rule (indexed from 0): + +
  8. +
  9. + If site.base_url is Some, it must start with + "http://" or "https://". +
  10. +
+

Returns:

+ +

Example (from tests):

+
let config = valid_config(); // has one rule
+assert!(config.validate().is_ok());
+
+

Config Serialization

+

+ The Config struct can be serialized to and deserialized from + YAML and JSON via convenience methods. +

+

from_yaml_str

+
pub fn from_yaml_str(yaml: &str) -> Result<Self>
+
+

+ Purpose: Parses a YAML string into a Config. +

+

Parameters:

+ +

Returns:

+ +

Example:

+
let config = Config::from_yaml_str("site:\n  site_name: Test\n")?;
+
+

to_yaml_string

+
pub fn to_yaml_string(&self) -> Result<String>
+
+

+ Purpose: Serializes the Config to a YAML + string. +

+

Returns:

+ +

from_json_str

+
pub fn from_json_str(json: &str) -> Result<Self>
+
+

+ Purpose: Parses a JSON string into a Config. +

+

Parameters:

+ +

Returns:

+ +

to_json_string

+
pub fn to_json_string(&self) -> Result<String>
+
+

+ Purpose: Serializes the Config to a JSON + string. +

+

Returns:

+ +

Example roundtrip:

+
let yaml = config.to_yaml_string()?;
+let parsed = Config::from_yaml_str(&yaml)?;
+assert_eq!(config, parsed);
+
+
+

Error Handling

+

+ The Config methods and validate use + librawssg_error::Result<T> (alias for + std::result::Result<T, librawssg_error::Error>). The + relevant error variants used in this crate are: +

+ +

+ All error messages are descriptive and include context (e.g., which rule is + invalid, what condition was violated). +

+
+

Serialization Details

+

+ All configuration structs derive Serialize and + Deserialize from serde. Default values are applied + during deserialization for missing fields via + #[serde(default = "function")] or + #[serde(default)] (which uses + Default::default() for the field type). +

+ +
+

Examples from Tests

+

+ The test suite provides extensive examples for each type. Below are selected + snippets. +

+

BuildConfig

+
let build = BuildConfig::default();
+assert_eq!(build.output_dir, "dist");
+
+let yaml = "content_dir: custom_content\noutput_dir: public\n";
+let build: BuildConfig = serde_yaml::from_str(yaml)?;
+assert_eq!(build.content_dir, "custom_content");
+assert_eq!(build.templates_dir, "templates");
+
+

ContentRule

+
let mut rule = ContentRule::new("page", "**/*.html", "base");
+rule.list_enabled = true;
+rule.list_template = Some("list".into());
+rule.extra.insert("key".into(), json!("value"));
+
+ +
let mut parent = NavItem::new("Docs", "/docs");
+parent.children.push(NavItem::new("API", "/docs/api"));
+
+

SiteConfig

+
let site = SiteConfig::new("My Site");
+assert_eq!(site.language.as_deref(), Some("en"));
+
+

Config Validation

+
let mut config = Config::new().with_site_name("My Site");
+config.add_content_rule(ContentRule::new("page", "**/*.html", "base"));
+assert!(config.validate().is_ok());
+
+config.site.base_url = Some("ftp://example.com".to_string());
+assert!(config.validate().is_err());
+
+
+

Testing Suite Overview

+

The crate includes five test files:

+ +

+ All tests are self‑contained and use the must! macro to unwrap + results with a helpful message on failure. They serve as executable examples + of the API usage. +

+
+

Conclusion

+

+ librawssg_config provides a clean and extensible configuration + system for a static site generator. With sensible defaults, comprehensive + validation, and full serde support, it covers the needs of both simple and + complex site configurations. The types are designed for ergonomic use and can + be easily loaded from YAML or JSON files, making it straightforward to define + site‑wide settings, build paths, navigation, and content processing rules. +

+

For further details, refer to the source code and test files.

diff --git a/docs/src/content/api/error.raw b/docs/src/content/api/error.raw new file mode 100644 index 0000000..f21a2ab --- /dev/null +++ b/docs/src/content/api/error.raw @@ -0,0 +1,893 @@ +

librawssg_error

+

+ Version: 1.0.0 (implied)
Crate name: + librawssg_error
Description: Defines a + comprehensive error enum and Result alias for use across the + librawssg static site generator ecosystem. The error type is + built with thiserror for ergonomic Display and + Error implementations, supports source chaining, and is designed + to cover all common failure modes in SSG operations. +

+
+

Table of Contents

+
    +
  1. Overview
  2. +
  3. Dependencies
  4. +
  5. + The Error Enum + +
  6. +
  7. + Variants + +
  8. +
  9. + Result<T> Type Alias +
  10. +
  11. + Error Sources and std::error::Error +
  12. +
  13. + Conversion from std::io::Error +
  14. +
  15. + Usage Examples from Tests + +
  16. +
  17. Guidelines for Error Usage
  18. +
  19. Testing Suite Overview
  20. +
  21. Conclusion
  22. +
+
+

Overview

+

+ The librawssg_error crate provides a single, unified error type + for the entire static site generator ecosystem. Instead of having each module + define its own error types, they all share this Error enum, + which categorizes failures into well‑defined variants. The enum is derived + with thiserror::Error, giving each variant an automatic + Display implementation based on a custom message pattern, and an + automatic std::error::Error implementation that preserves source + chains when applicable. +

+

+ The crate also exports a Result<T> type alias, simplifying + function signatures throughout the codebase. +

+

Key features:

+ +
+

Dependencies

+ +

No other external crates are required.

+
+

The Error Enum

+

Enum Definition

+
use core::error::Error as CoreError;
+use std::path::PathBuf;
+use thiserror::Error;
+
+#[derive(Debug, Error)]
+#[non_exhaustive]
+pub enum Error {
+    #[error("I/O error: {0}")]
+    Io(#[from] std::io::Error),
+
+    #[error("Configuration error: {0}")]
+    Config(String),
+
+    #[error("Failed to parse metadata in {path}")]
+    Metadata {
+        path: PathBuf,
+        #[source]
+        source: Box<dyn CoreError + Send + Sync>,
+    },
+
+    #[error("Template rendering error: {0}")]
+    Render(String),
+
+    #[error("Content processor error: {0}")]
+    Processor(String),
+
+    #[error("Generator error: {0}")]
+    Generator(String),
+
+    #[error("Path traversal attempt detected: {0}")]
+    PathTraversal(String),
+
+    #[error("Missing configuration key: {0}")]
+    MissingConfig(String),
+
+    #[error("Site generation error: {0}")]
+    Generation(String),
+
+    #[error("Resource not found: {0}")]
+    NotFound(String),
+
+    #[error("Serialization error: {0}")]
+    Serialization(String),
+
+    #[error("Validation error: {0}")]
+    Validation(String),
+
+    #[error("Duplicate value: {0}")]
+    Duplicate(String),
+
+    #[error("Invalid state: {0}")]
+    InvalidState(String),
+
+    #[error("Internal error: {0}")]
+    Internal(String),
+}
+
+

Attributes and Derives

+ +

Non‑Exhaustive

+

Because the enum is non‑exhaustive, external code cannot write:

+
match err {
+    Error::Io(_) => ...,
+    Error::Config(_) => ...,
+    // all variants...
+}
+
+

without including a catch‑all arm:

+
match err {
+    Error::Io(_) => ...,
+    Error::Config(_) => ...,
+    // ...
+    _ => { /* handle unknown future variants */ }
+}
+
+

This ensures forward compatibility.

+
+

Variants

+

Io

+
#[error("I/O error: {0}")]
+Io(#[from] std::io::Error),
+
+ +

Example:

+
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
+let err = Error::Io(io_err);
+assert_eq!(err.to_string(), "I/O error: file missing");
+
+
+

Config

+
#[error("Configuration error: {0}")]
+Config(String),
+
+ +

Example:

+
let err = Error::Config("invalid YAML".to_string());
+assert_eq!(err.to_string(), "Configuration error: invalid YAML");
+
+
+

Metadata

+
#[error("Failed to parse metadata in {path}")]
+Metadata {
+    path: PathBuf,
+    #[source]
+    source: Box<dyn CoreError + Send + Sync>,
+},
+
+ +

Example:

+
use std::path::PathBuf;
+
+let source: Box<dyn core::error::Error + Send + Sync> =
+    Box::new(std::io::Error::other("bad yaml"));
+let err = Error::Metadata {
+    path: PathBuf::from("content/post.md"),
+    source,
+};
+
+assert_eq!(err.to_string(), "Failed to parse metadata in content/post.md");
+let as_core_error: &dyn core::error::Error = &err;
+let source_ref = as_core_error.source().unwrap();
+assert_eq!(source_ref.to_string(), "bad yaml");
+
+
+

Render

+
#[error("Template rendering error: {0}")]
+Render(String),
+
+ +

Example:

+
let err = Error::Render("template not found".to_string());
+assert_eq!(err.to_string(), "Template rendering error: template not found");
+
+
+

Processor

+
#[error("Content processor error: {0}")]
+Processor(String),
+
+ +

Example:

+
let err = Error::Processor("custom processor failed".to_string());
+assert_eq!(err.to_string(), "Content processor error: custom processor failed");
+
+
+

Generator

+
#[error("Generator error: {0}")]
+Generator(String),
+
+ +

Example:

+
let err = Error::Generator("RSS generation failed".to_string());
+assert_eq!(err.to_string(), "Generator error: RSS generation failed");
+
+
+

PathTraversal

+
#[error("Path traversal attempt detected: {0}")]
+PathTraversal(String),
+
+ +

Example:

+
let err = Error::PathTraversal("../escape".to_string());
+assert_eq!(err.to_string(), "Path traversal attempt detected: ../escape");
+
+
+

MissingConfig

+
#[error("Missing configuration key: {0}")]
+MissingConfig(String),
+
+ +

Example:

+
let err = Error::MissingConfig("base_url".to_string());
+assert_eq!(err.to_string(), "Missing configuration key: base_url");
+
+
+

Generation

+
#[error("Site generation error: {0}")]
+Generation(String),
+
+ +

Example:

+
let err = Error::Generation("output write failed".to_string());
+assert_eq!(err.to_string(), "Site generation error: output write failed");
+
+
+

NotFound

+
#[error("Resource not found: {0}")]
+NotFound(String),
+
+ +

Example:

+
let err = Error::NotFound("asset.css".to_string());
+assert_eq!(err.to_string(), "Resource not found: asset.css");
+
+
+

Serialization

+
#[error("Serialization error: {0}")]
+Serialization(String),
+
+ +

Example:

+
let err = Error::Serialization("invalid JSON".to_string());
+assert_eq!(err.to_string(), "Serialization error: invalid JSON");
+
+
+

Validation

+
#[error("Validation error: {0}")]
+Validation(String),
+
+ +

Example:

+
let err = Error::Validation("name too long".to_string());
+assert_eq!(err.to_string(), "Validation error: name too long");
+
+
+

Duplicate

+
#[error("Duplicate value: {0}")]
+Duplicate(String),
+
+ +

Example:

+
let err = Error::Duplicate("duplicate key".to_string());
+assert_eq!(err.to_string(), "Duplicate value: duplicate key");
+
+
+

InvalidState

+
#[error("Invalid state: {0}")]
+InvalidState(String),
+
+ +

Example:

+
let err = Error::InvalidState("unexpected null".to_string());
+assert_eq!(err.to_string(), "Invalid state: unexpected null");
+
+
+

Internal

+
#[error("Internal error: {0}")]
+Internal(String),
+
+ +

Example:

+
let err = Error::Internal("bug in code".to_string());
+assert_eq!(err.to_string(), "Internal error: bug in code");
+
+
+

Result<T> Type Alias

+
pub type Result<T> = core::result::Result<T, Error>;
+
+ +

Example:

+
fn read_config(path: &str) -> librawssg_error::Result<String> {
+    let content = std::fs::read_to_string(path)?; // `?` converts io::Error into Error::Io
+    Ok(content)
+}
+
+
+

+ Error Sources and std::error::Error +

+

+ All variants of Error implement + std::error::Error (via thiserror). The + source() method returns: +

+ +

+ This allows error chains to be inspected using + std::error::Error::source(). +

+

Example (from integration tests):

+
let source: Box<dyn core::error::Error + Send + Sync> =
+    Box::new(std::io::Error::other("bad yaml"));
+let err = Error::Metadata {
+    path: PathBuf::from("content/post.md"),
+    source,
+};
+
+let as_core_error: &dyn core::error::Error = &err;
+let source_ref = as_core_error.source().unwrap();
+assert_eq!(source_ref.to_string(), "bad yaml");
+
+
+

+ Conversion from std::io::Error +

+

+ The Io variant has the #[from] attribute, which + automatically generates: +

+
impl From<std::io::Error> for Error {
+    fn from(err: std::io::Error) -> Error {
+        Error::Io(err)
+    }
+}
+
+

+ This enables the ? operator to convert + std::io::Error into Error in any function returning + Result<T, Error> (or + librawssg_error::Result<T>). +

+

Example:

+
fn read_file(path: &str) -> Result<String> {
+    let content = std::fs::read_to_string(path)?; // io::Error becomes Error::Io
+    Ok(content)
+}
+
+
+

Usage Examples from Tests

+

+ The test suite provides excellent examples of how to construct and use the + error type. +

+

Display Messages

+

+ Each variant has a test asserting its exact Display output. For + example: +

+
#[test]
+fn display_for_config_error() {
+    let err = Error::Config("invalid YAML".to_string());
+    assert_eq!(err.to_string(), "Configuration error: invalid YAML");
+}
+
+

All 15 variants have similar tests in unit_tests.rs.

+

Using the ? Operator

+

+ The integration test + io_error_propagates_via_question_mark demonstrates how + ? works: +

+
fn read_file(path: &str) -> Result<String> {
+    let content = std::fs::read_to_string(path)?;
+    Ok(content)
+}
+
+#[test]
+fn io_error_propagates_via_question_mark() {
+    let result = read_file("definitely_not_exists.txt");
+    assert!(result.is_err());
+    assert!(matches!(result, Err(Error::Io(_))));
+}
+
+

Source Chain

+

+ The metadata_error_can_hold_boxed_dyn_error test shows how to + store an arbitrary error and retrieve it via source(): +

+
let source: Box<dyn core::error::Error + Send + Sync> =
+    Box::new(std::io::Error::other("bad yaml"));
+let err = Error::Metadata {
+    path: PathBuf::from("content/post.md"),
+    source,
+};
+
+let as_core_error: &dyn core::error::Error = &err;
+let source_ref = as_core_error.source();
+assert!(source_ref.is_some());
+
+

Property Tests

+

+ Property tests verify that the error message always preserves the input + string exactly, regardless of content (including empty strings, newlines, + special characters): +

+
#[test]
+fn config_error_message_preserves_input() {
+    let samples = [
+        "",
+        "short",
+        "a very long error message with symbols !@#$%^&*()",
+        "line1\nline2",
+    ];
+
+    for sample in samples {
+        let err = Error::Config(sample.to_string());
+        assert_eq!(err.to_string(), format!("Configuration error: {sample}"));
+    }
+}
+
+

+ Similar tests exist for PathTraversal and Render. +

+
+

Guidelines for Error Usage

+

+ When writing code in the librawssg ecosystem, follow these + recommendations: +

+ +
+

Testing Suite Overview

+

The crate includes three test files:

+ +

+ Together, these tests ensure the error type is robust, easy to use, and + consistent. +

+
+

Conclusion

+

+ librawssg_error provides a centralized, well‑structured error + type for the entire static site generator project. With 15 descriptive + variants, automatic Display and + Error implementations, convenient conversion from + io::Error, and support for error sources, it simplifies error + handling across all modules. The non‑exhaustive design guarantees future + extensibility without breaking downstream code. +

+

For further details, refer to the source code and test files.

diff --git a/docs/src/content/api/fecade.raw b/docs/src/content/api/fecade.raw new file mode 100644 index 0000000..7e4a6ac --- /dev/null +++ b/docs/src/content/api/fecade.raw @@ -0,0 +1,1198 @@ +

librawssg

+

A modular static site generator library for Rust.

+

+ This facade crate re‑exports the essential building blocks from the + librawssg ecosystem, providing a single convenient entry point + for building static site generators. It aggregates configuration management, + filesystem abstraction, content processing, template rendering (with Tera + built‑in), and build pipeline orchestration. +

+
+

Table of Contents

+
    +
  1. Overview
  2. +
  3. Installation
  4. +
  5. Modules
  6. +
  7. + Core Types and Traits + +
  8. +
  9. Usage Example
  10. +
  11. + Full API Reference + +
  12. +
  13. Feature Flags
  14. +
  15. License
  16. +
+
+

Overview

+

+ librawssg is the top‑level crate that brings together six + specialized crates: +

+ +

+ By depending on librawssg, you get all these components without + needing to specify each one individually. The facade also re‑exports the most + commonly used types at the crate root for ergonomic access. +

+
+

Installation

+

Add librawssg to your Cargo.toml:

+
[dependencies]
+librawssg = "1.0.0"
+
+

+ If you are working in the same workspace as the + librawssg source, you can use a path dependency: +

+
[dependencies]
+librawssg = { path = "../librawssg" }
+
+

The crate is compatible with Rust edition 2024 and later.

+
+

Modules

+

The crate organises its re‑exports into submodules for clarity:

+ +

+ Additionally, the most important types are also re‑exported directly at the + crate root for convenience. +

+
+

Core Types and Traits

+

Configuration

+

+ The configuration system revolves around the Config struct, + which contains site settings, build paths, and content processing rules. +

+ +

Filesystem

+ +

Content Handling

+ +

Template Rendering

+ +

Compiler Pipeline

+ +

Error Handling

+ +
+

Usage Example

+

+ Here’s a minimal but complete example that builds a site from raw HTML + fragments using a custom processor, Tera templates, and the default context + builder: +

+
use librawssg::{
+    Config, ContentRule, Document, FileSystem, Metadata, PipelineBuilder, Processor,
+    RealFs, RenderContext, Renderer, TeraContextBuilder, TeraRenderer,
+};
+use std::path::{Path, PathBuf};
+
+// 1. Define a simple processor for .raw files
+struct RawProcessor;
+impl Processor for RawProcessor {
+    fn name(&self) -> &'static str { "raw" }
+    fn can_process(&self, rel: &Path, _orig: &Path) -> bool {
+        rel.extension().and_then(|e| e.to_str()) == Some("raw")
+    }
+    fn process(
+        &self,
+        fs: &dyn FileSystem,
+        rel: &Path,
+        content_dir: &Path,
+    ) -> librawssg::Result<Option<Document>> {
+        let full_path = content_dir.join(rel);
+        let body = fs.read_to_string(&full_path)?;
+        let title = rel.file_stem().unwrap_or_default().to_string_lossy().to_string();
+        let meta = Metadata::new(title, String::new())?;
+        let url = rel.with_extension("html").to_string_lossy().to_string();
+        let output = PathBuf::from(&url);
+        let doc = Document::new(meta, body, url, output, rel.to_path_buf(), 0, "page".to_string(), false)?;
+        Ok(Some(doc))
+    }
+}
+
+fn main() -> Result<(), Box<dyn std::error::Error>> {
+    // 2. Configure the site
+    let mut config = Config::new().with_site_name("My Site");
+    config.add_content_rule(ContentRule::new("page", "**/*.raw", "base.tera"));
+    config.build.content_dir = "content".to_string();
+    config.build.output_dir = "dist".to_string();
+    config.build.static_dir = "static".to_string();
+
+    // 3. Set up the renderer and load templates
+    let mut renderer = TeraRenderer::new();
+    renderer.load_templates_dir(Path::new("templates"))?;
+
+    // 4. Build the pipeline
+    let pipeline = PipelineBuilder::new()
+        .config(config)
+        .content_dir("content")
+        .output_dir("dist")
+        .with_fs(Box::new(RealFs))
+        .with_renderer(Box::new(renderer))
+        .with_context_builder(Box::new(TeraContextBuilder))
+        .add_processor(Box::new(RawProcessor))
+        .build()?;
+
+    // 5. Run the generation
+    pipeline.run()?;
+    println!("Site generated successfully!");
+    Ok(())
+}
+
+

+ For more detailed examples, see the librawssg_demo crate in the + repository. +

+
+

Full API Reference

+

+ This section provides a concise reference for every public item re‑exported + by librawssg. For deeper details, consult the respective + sub‑crate documentation (e.g., librawssg_config, + librawssg_compiler). +

+

Configuration Types

+

+ All configuration types are in librawssg::config (and + re‑exported at root). +

+ +

Filesystem Types

+ +

Handler Types

+ +

Template Types

+ +

Compiler Types

+ +

Error Types

+ +
+

Feature Flags

+

+ The tera feature is enabled by default and provides the + TeraRenderer implementation. To disable it (e.g., if you use a + different template engine), set default-features = false in your + Cargo.toml: +

+
[dependencies]
+librawssg = { version = "1.0.0", default-features = false }
+
+

+ Without this feature, the crate still exports the core traits + (Renderer, RenderContext) and all other + functionality, but TeraRenderer and + TeraContextBuilder are unavailable. +

+
+

License

+

+ This project is licensed under the MIT License. See the + LICENSE file for details. +

+
+

+ This documentation is generated from the source code of the + librawssg facade crate and its sub‑crates. +

diff --git a/docs/src/content/api/fs.raw b/docs/src/content/api/fs.raw new file mode 100644 index 0000000..a7e4786 --- /dev/null +++ b/docs/src/content/api/fs.raw @@ -0,0 +1,1102 @@ +

librawssg_fs

+

+ Version: 1.0.0 (implied)
Crate name: + librawssg_fs
Description: A filesystem + abstraction layer for static site generators. Defines the + FileSystem trait with a comprehensive set of file and directory + operations, and provides a concrete implementation RealFs that + delegates to std::fs and walkdir. The trait + includes built‑in path traversal protection and convenience methods for + atomic operations. +

+
+

Table of Contents

+
    +
  1. Overview
  2. +
  3. Modules
  4. +
  5. + Trait FileSystem + +
  6. +
  7. + Struct RealFs + +
  8. +
  9. Error Handling
  10. +
  11. + Implementing a Custom FileSystem +
  12. +
  13. Security Considerations
  14. +
  15. Testing Suite Overview
  16. +
  17. + Complete Code Examples from Tests +
  18. +
+
+

Overview

+

+ librawssg_fs provides a trait‑based abstraction over filesystem + operations. This allows static site generator components to interact with the + filesystem without being tightly coupled to std::fs. It enables: +

+ +

The crate exports:

+ +
+

Modules

+

+ The crate root (lib.rs) defines the + FileSystem trait and re‑exports RealFs from the + real module. +

+
pub mod real;
+pub use real::RealFs;
+
+

+ There is also an internal module real.rs containing the + RealFs implementation. +

+
+

Trait FileSystem

+

+ The FileSystem trait is the core of this crate. It is + object‑safe and requires implementors to be Send + Sync (safe to + share across threads). The trait provides many required methods and several + methods with default implementations. +

+
pub trait FileSystem: Send + Sync {
+    // Required methods (see below)
+    // Provided methods with default implementations
+}
+
+

Required Methods

+

+ These methods must be implemented by any type that + implements FileSystem. They map closely to + std::fs functions and walkdir functionality. +

+

read_to_string

+
fn read_to_string(&self, path: &Path) -> io::Result<String>;
+
+

+ Purpose: Reads the entire contents of a file into a + String. +

+

Parameters:

+ +

+ Returns: Ok(String) containing the file + contents, or an Err(io::Error) if the file cannot be read (e.g., + not found, permission denied, invalid UTF‑8). +

+

Example:

+
let content = fs.read_to_string(Path::new("hello.txt"))?;
+
+

read_bytes

+
fn read_bytes(&self, path: &Path) -> io::Result<Vec<u8>>;
+
+

+ Purpose: Reads the entire contents of a file as raw bytes. +

+

Parameters:

+ +

+ Returns: Ok(Vec<u8>) with the file bytes, + or an Err(io::Error). +

+

Example:

+
let data = fs.read_bytes(Path::new("image.png"))?;
+
+

write

+
fn write(&self, path: &Path, content: &[u8]) -> io::Result<()>;
+
+

+ Purpose: Writes the given bytes to a file, creating any + necessary parent directories. +

+

Parameters:

+ +

+ Returns: Ok(()) on success, or + Err(io::Error) on failure (e.g., permission denied, disk full). +

+

+ Behavior: The default RealFs implementation + creates parent directories before writing (via create_dir_all on + the parent). This is convenient for writing deeply nested outputs. +

+

Example:

+
fs.write(Path::new("a/b/c.txt"), b"hello")?;
+
+

create_dir_all

+
fn create_dir_all(&self, path: &Path) -> io::Result<()>;
+
+

+ Purpose: Creates a directory and all its missing parents. +

+

Parameters:

+ +

+ Returns: Ok(()) or Err(io::Error). +

+

+ Note: Unlike create_dir, this does + not error if the directory already exists. +

+

Example:

+
fs.create_dir_all(Path::new("a/b/c"))?;
+
+

remove_dir_all

+
fn remove_dir_all(&self, path: &Path) -> io::Result<()>;
+
+

+ Purpose: Removes a directory and all its contents + recursively. +

+

Parameters:

+ +

+ Returns: Ok(()) or + Err(io::Error) (e.g., directory does not exist, permission + denied). +

+

Warning: This is destructive and cannot be undone.

+

remove_file

+
fn remove_file(&self, path: &Path) -> io::Result<()>;
+
+

Purpose: Deletes a single file.

+

Parameters:

+ +

+ Returns: Ok(()) or Err(io::Error). +

+

create_dir

+
fn create_dir(&self, path: &Path) -> io::Result<()>;
+
+

+ Purpose: Creates a single directory. Fails if the parent + directory does not exist or if the directory already exists. +

+

Parameters:

+ +

+ Returns: Ok(()) or + Err(io::Error) (e.g., already exists, parent missing). +

+

exists

+
fn exists(&self, path: &Path) -> bool;
+
+

+ Purpose: Checks whether a path exists (as a file, directory, + symlink, etc.). +

+

Parameters:

+ +

+ Returns: true if the path exists, + false otherwise. +

+

+ Note: This method does not follow symlinks for broken + symlinks; it returns false for a broken symlink. +

+

is_dir

+
fn is_dir(&self, path: &Path) -> bool;
+
+

Purpose: Checks whether the path points to a directory.

+

+ Returns: true if it is a directory, + false otherwise (including if it does not exist). +

+

is_file

+
fn is_file(&self, path: &Path) -> bool;
+
+

+ Purpose: Checks whether the path points to a regular file. +

+

+ Returns: true if it is a regular file, + false otherwise. +

+

read_dir

+
fn read_dir(&self, path: &Path) -> io::Result<Vec<PathBuf>>;
+
+

+ Purpose: Lists all entries (files and directories) directly + inside a directory. +

+

Parameters:

+ +

+ Returns: Ok(Vec<PathBuf>) containing the + full paths of all entries, or Err(io::Error). +

+

+ Note: The order is not guaranteed. It does not recurse into + subdirectories. +

+

copy_file

+
fn copy_file(&self, from: &Path, to: &Path) -> io::Result<u64>;
+
+

+ Purpose: Copies a file from from to + to. If to already exists, it will be overwritten. +

+

Parameters:

+ +

+ Returns: Ok(u64) with the number of bytes + copied, or Err(io::Error). +

+

+ Note: Does not create parent directories of + to in the default RealFs; use copy or + copy_dir_all for that. +

+

copy_dir_all

+
fn copy_dir_all(&self, from: &Path, to: &Path) -> io::Result<()>;
+
+

+ Purpose: Recursively copies a directory tree from + from to to. Creates the destination directory and + all parent directories as needed. +

+

Parameters:

+ +

+ Returns: Ok(()) or Err(io::Error). +

+

Behavior:

+
    +
  1. Creates to directory.
  2. +
  3. Walks all files in from (using walk_dir).
  4. +
  5. + For each file, computes relative path and creates parent directories in + to, then copies the file. +
  6. +
+

walk_dir

+
fn walk_dir(&self, root: &Path) -> io::Result<Vec<PathBuf>>;
+
+

+ Purpose: Recursively collects all + files under root. Does not include directories + or symlinks to directories. +

+

Parameters:

+ +

+ Returns: Ok(Vec<PathBuf>) with the full + paths of all files, or Err(io::Error). +

+

+ Note: The default RealFs uses the + walkdir crate to handle traversal. It follows symlinks? (The + WalkDir::new default does not follow symlinks; it will include + symlinks but not traverse into them unless + .follow_links(true) is set. Here symlinks to files will be + included? entry.file_type().is_file() will be true for a symlink + to a file? Actually file_type() returns the type of the symlink + itself, not the target, unless follow_links is used. So symlinks + are not considered files and are skipped.) +

+

canonicalize

+
fn canonicalize(&self, path: &Path) -> io::Result<PathBuf>;
+
+

+ Purpose: Returns the canonical, absolute form of a path, + resolving all symbolic links and normalizing . and + .. components. +

+

Parameters:

+ +

+ Returns: Ok(PathBuf) with the canonical path, + or Err(io::Error) (e.g., path does not exist). +

+

rename

+
fn rename(&self, from: &Path, to: &Path) -> io::Result<()>;
+
+

+ Purpose: Renames (moves) a file or directory from + from to to. On most filesystems this is an atomic + operation when source and destination are on the same filesystem. +

+

Parameters:

+ +

+ Returns: Ok(()) or Err(io::Error). +

+

+ Note: If to exists, it may be overwritten + (platform‑dependent). Does not work across different mount points (returns + CrossesDevices error). +

+

atomic_write

+
fn atomic_write(&self, path: &Path, content: &[u8]) -> io::Result<()>;
+
+

+ Purpose: Writes data to a file atomically by first writing + to a temporary file and then renaming it over the target path. +

+

Parameters:

+ +

+ Returns: Ok(()) or Err(io::Error). +

+

Behavior:

+
    +
  1. + Creates a temporary file with extension .tmp (by calling + with_extension("tmp") on the target path). +
  2. +
  3. + Writes the content to the temporary file (using write, which + creates parent directories). +
  4. +
  5. + Renames the temporary file to the target path (using rename). +
  6. +
  7. + If the rename fails, attempts to remove the temporary file and returns the + error. +
  8. +
+

+ Note: The temporary file name is derived from the target; it + is not a hidden file and may collide if multiple writes happen concurrently + to the same path. This is a best‑effort atomic write suitable for many use + cases. +

+

touch

+
fn touch(&self, path: &Path) -> io::Result<()>;
+
+

+ Purpose: Creates an empty file at path or + updates its access/modification timestamp if it already exists. +

+

Parameters:

+ +

+ Returns: Ok(()) or Err(io::Error). +

+

Behavior:

+
    +
  1. Creates parent directories (like write).
  2. +
  3. Opens the file in append/create mode, which creates it if missing.
  4. +
  5. + Calls sync_all() to flush to disk (optional, but ensures + metadata is updated). +
  6. +
+

Note: Existing file content is preserved.

+

metadata

+
fn metadata(&self, path: &Path) -> io::Result<std::fs::Metadata>;
+
+

+ Purpose: Returns metadata for a file or directory, following + symlinks. +

+

Parameters:

+ +

+ Returns: Ok(fs::Metadata) or + Err(io::Error). +

+ +
fn symlink_metadata(&self, path: &Path) -> io::Result<std::fs::Metadata>;
+
+

+ Purpose: Returns metadata for a path + without following symlinks (i.e., metadata of the symlink + itself). +

+

Parameters:

+ +

+ Returns: Ok(fs::Metadata) or + Err(io::Error). +

+

permissions

+
fn permissions(&self, path: &Path) -> io::Result<std::fs::Permissions>;
+
+

Purpose: Reads the permissions of a file or directory.

+

Parameters:

+ +

+ Returns: Ok(fs::Permissions) or + Err(io::Error). +

+

+ Note: The default RealFs obtains permissions + from metadata, which follows symlinks. +

+

set_permissions

+
fn set_permissions(&self, path: &Path, permissions: std::fs::Permissions) -> io::Result<()>;
+
+

Purpose: Sets the permissions of a file or directory.

+

Parameters:

+ +

+ Returns: Ok(()) or Err(io::Error). +

+ +
fn read_link(&self, path: &Path) -> io::Result<PathBuf>;
+
+

Purpose: Reads the target of a symbolic link.

+

Parameters:

+ +

+ Returns: Ok(PathBuf) containing the link + target, or Err(io::Error) if the path is not a symlink or does + not exist. +

+ +
fn hard_link(&self, from: &Path, to: &Path) -> io::Result<()>;
+
+

+ Purpose: Creates a hard link from from to + to. +

+

Parameters:

+ +

+ Returns: Ok(()) or Err(io::Error). +

+

Note: Both paths must be on the same filesystem.

+
+

Provided (Default) Methods

+

+ These methods have default implementations that rely on the required methods. + Implementors may override them for performance or platform‑specific behavior. +

+ +
fn is_symlink(&self, path: &Path) -> bool {
+    self.symlink_metadata(path)
+        .is_ok_and(|meta| meta.file_type().is_symlink())
+}
+
+

+ Purpose: Checks whether the given path is a symbolic link. +

+

+ Returns: true if the path is a symlink (even if + broken), false otherwise. +

+

+ Implementation: Uses symlink_metadata (which + does not follow symlinks) and checks the file type. +

+

Example:

+
if fs.is_symlink(Path::new("link")) { ... }
+
+

canonicalize_or_join

+
fn canonicalize_or_join(&self, base: &Path, candidate: &Path) -> io::Result<PathBuf>
+
+

+ Purpose: Safely resolves a possibly non‑existent path + relative to base. If the joined path exists, it is + canonicalized; otherwise it returns the canonical parent joined with the file + name, after normalizing . and .. components. +

+

Parameters:

+ +

+ Returns: Ok(PathBuf) with the resolved path, or + Err(io::Error) if path traversal is detected or other errors + occur. +

+

Detailed Behavior:

+
    +
  1. + Normalizes the candidate path by iterating over its + components: + +
  2. +
  3. Joins the normalized candidate with base.
  4. +
  5. + If the joined path exists, canonicalizes it (resolving symlinks, etc.). +
  6. +
  7. + If it does not exist: + +
  8. +
+

+ Security: This method prevents .. from escaping + the base directory (unless there are symlinks that point outside; + canonicalization of existing paths can still lead outside base, which is why + safe_join adds an extra check). For non‑existent paths, the + parent canonicalization ensures that the final path is within the canonical + base. +

+

Example (from tests):

+
let existing = base.join("existing.txt");
+fs.write(&existing, b"data")?;
+
+let canon_existing = fs.canonicalize_or_join(base, Path::new("existing.txt"))?;
+let canon_direct = fs.canonicalize(&existing)?;
+assert_eq!(canon_existing, canon_direct);
+
+let missing = Path::new("missing.txt");
+let canon_missing = fs.canonicalize_or_join(base, missing)?;
+let canon_base = fs.canonicalize(base)?;
+assert_eq!(canon_missing, canon_base.join(missing));
+
+

safe_join

+
fn safe_join(&self, base: &Path, candidate: &Path) -> io::Result<PathBuf>
+
+

+ Purpose: Safely joins a candidate path to a base directory, + ensuring the result is within the base directory (no path + traversal). This is the recommended way to compute destination paths for + user‑provided or untrusted relative paths. +

+

Parameters:

+ +

+ Returns: Ok(PathBuf) with the resolved path, + guaranteed to start with the canonical base. Returns + Err(io::Error) with PermissionDenied if the + resolved path escapes the base (e.g., + candidate = "../secret"). +

+

Implementation Details:

+
    +
  1. Canonicalizes base.
  2. +
  3. + Calls canonicalize_or_join with the canonical base and + candidate. +
  4. +
  5. + Checks that the resulting path starts with the canonical base. If not, + returns PermissionDenied. +
  6. +
+

+ Why needed: Although + canonicalize_or_join prevents simple .. traversal, + symlinks inside the base directory could cause a resolved path to point + outside the base even after normalization. The starts_with check + enforces containment. +

+

Example:

+
let base = tmp.path().join("base");
+fs.create_dir_all(&base)?;
+
+let safe = fs.safe_join(&base, Path::new("inside.txt"))?;
+assert!(safe.starts_with(&base));
+
+let traversal = Path::new("../escape.txt");
+let result = fs.safe_join(&base, traversal);
+assert!(result.is_err());
+
+

copy

+
fn copy(&self, from: &Path, to: &Path) -> io::Result<()>
+
+

+ Purpose: Copies a file or directory from + from to to. If from is a directory, it + recursively copies the whole tree; if it is a file, it performs a single file + copy. +

+

Parameters:

+ +

+ Returns: Ok(()) or Err(io::Error). +

+

Implementation:

+
if self.is_dir(from) {
+    self.copy_dir_all(from, to)
+} else {
+    self.copy_file(from, to).map(|_| ())
+}
+
+

+ Note: Does not create parent directories of + to for file copies (unless copy_file implementation + does; the default RealFs::copy_file does not). For directories, + copy_dir_all does create to and parents as needed. +

+

Example:

+
fs.copy(&src_file, &dst_file)?;
+fs.copy(&src_dir, &dst_dir)?;
+
+

rename_or_copy

+
fn rename_or_copy(&self, from: &Path, to: &Path) -> io::Result<()>
+
+

+ Purpose: Attempts to rename from to + to. If the rename fails with + ErrorKind::CrossesDevices (i.e., source and destination are on + different filesystems), it falls back to copying the directory tree and then + removing the source. +

+

Parameters:

+ +

+ Returns: Ok(()) or Err(io::Error). +

+

Behavior:

+
    +
  1. Try rename(from, to).
  2. +
  3. If success, return Ok(()).
  4. +
  5. + If error kind is CrossesDevices: + +
  6. +
  7. Otherwise, return the original error.
  8. +
+

+ Note: The fallback only works for directories (as the code + uses copy_dir_all and remove_dir_all). For a file + across devices, this will likely fail. This method is useful for moving + directories across mount points. +

+

Example:

+
fs.rename_or_copy(&src_dir, &dst_dir)?;
+
+
+

Struct RealFs

+

+ RealFs is a zero‑sized struct that implements + FileSystem by delegating directly to the operating system’s + filesystem APIs. +

+
#[derive(Debug, Default, Clone, Copy)]
+pub struct RealFs;
+
+

+ It has no fields and can be instantiated with RealFs or + RealFs::default(). +

+

Implementation Details

+

RealFs uses:

+ +

+ All methods follow the behavior described in the trait definitions. The + write method creates parent directories before writing, and + atomic_write uses a temporary .tmp file. +

+

Example Usage

+
use librawssg_fs::{FileSystem, RealFs};
+use std::path::Path;
+
+let fs = RealFs;
+
+// Write a file
+fs.write(Path::new("output/file.txt"), b"Hello")?;
+
+// Read it back
+let content = fs.read_to_string(Path::new("output/file.txt"))?;
+assert_eq!(content, "Hello");
+
+// Create directory
+fs.create_dir_all(Path::new("output/sub"))?;
+
+// Copy directory
+fs.copy_dir_all(Path::new("output"), Path::new("backup"))?;
+
+
+

Error Handling

+

+ All methods that can fail return io::Result<T> (i.e., + Result<T, std::io::Error>). This is the standard error + type from the standard library, so no custom error enum is defined in this + crate. Consumers can inspect the error kind (e.g., + ErrorKind::NotFound, PermissionDenied, + CrossesDevices) to handle specific failures. +

+

+ The provided security methods (canonicalize_or_join and + safe_join) return io::Error with + ErrorKind::PermissionDenied when path traversal is detected, + along with the message "path traversal detected". +

+
+

+ Implementing a Custom FileSystem +

+

+ To create a mock filesystem or an alternative backend, implement the + FileSystem trait. You must provide implementations for all 24 + required methods. The provided methods can be left as default unless you need + custom behavior. +

+

Example of a minimal mock (from tests, adapted):

+
use librawssg_fs::FileSystem;
+use std::io;
+use std::path::{Path, PathBuf};
+
+struct DummyFs;
+
+impl FileSystem for DummyFs {
+    fn read_to_string(&self, _path: &Path) -> io::Result<String> {
+        Err(io::Error::other("not implemented"))
+    }
+    // ... implement all other required methods similarly
+    // (returning Err or trivial values)
+}
+
+

+ Because FileSystem is Send + Sync, your mock must + also be thread‑safe. In practice, you can use Arc or interior + mutability if state is needed. +

+
+

Security Considerations

+

+ The library includes two methods specifically designed to prevent path + traversal attacks: +

+ +

+ Recommendation: Always use safe_join when + constructing output paths from untrusted input (e.g., user‑supplied relative + URLs). Avoid using join directly followed by canonicalization + without containment checks. +

+
+

Testing Suite Overview

+

+ The test file tests/filesystem.rs contains comprehensive tests + for RealFs and the provided methods. It uses + tempfile::TempDir to create isolated temporary directories. The + tests cover: +

+ +

All tests can be run with cargo test.

+
+

+ Complete Code Examples from Tests +

+

+ Below are selected examples from the test suite that illustrate common usage + patterns. They can be copied and adapted. +

+

Writing and Reading a String

+
use librawssg_fs::{FileSystem, RealFs};
+use std::path::Path;
+use tempfile::TempDir;
+
+let tmp = TempDir::new().unwrap();
+let fs = RealFs;
+let file_path = tmp.path().join("hello.txt");
+
+fs.write(&file_path, b"world").unwrap();
+let content = fs.read_to_string(&file_path).unwrap();
+assert_eq!(content, "world");
+
+

Atomic Write Overwriting

+
let file = tmp.path().join("atomic.txt");
+fs.atomic_write(&file, b"first").unwrap();
+fs.atomic_write(&file, b"second").unwrap();
+let content = fs.read_to_string(&file).unwrap();
+assert_eq!(content, "second");
+
+

Safe Join Blocking Traversal

+
let base = tmp.path().join("base");
+fs.create_dir_all(&base).unwrap();
+
+let safe = fs.safe_join(&base, Path::new("inside.txt")).unwrap();
+assert!(safe.starts_with(&base));
+
+let traversal = Path::new("../escape.txt");
+assert!(fs.safe_join(&base, traversal).is_err());
+
+

Copying a Directory Recursively

+
let src = tmp.path().join("src_dir");
+let dst = tmp.path().join("dst_dir");
+fs.create_dir_all(&src.join("nested")).unwrap();
+fs.write(&src.join("file1.txt"), b"one").unwrap();
+fs.write(&src.join("nested").join("file2.txt"), b"two").unwrap();
+
+fs.copy_dir_all(&src, &dst).unwrap();
+assert!(fs.exists(&dst.join("file1.txt")));
+assert!(fs.exists(&dst.join("nested").join("file2.txt")));
+
+

+ Using walk_dir to Gather All Files +

+
let root = tmp.path().join("root");
+fs.create_dir_all(&root.join("sub")).unwrap();
+fs.write(&root.join("root.txt"), b"root").unwrap();
+fs.write(&root.join("sub").join("sub.txt"), b"sub").unwrap();
+
+let files = fs.walk_dir(&root).unwrap();
+assert_eq!(files.len(), 2);
+
+
+

Summary

+

+ librawssg_fs provides a robust, thread‑safe filesystem + abstraction with built‑in path traversal protection and convenience methods + for atomic operations and cross‑device moves. The + RealFs implementation is ready to use, and the trait enables + easy mocking for unit tests. The extensive test suite validates all features + and serves as living documentation. +

+

For any additional details, refer to the source code and inline comments.

diff --git a/docs/src/content/api/handler.raw b/docs/src/content/api/handler.raw new file mode 100644 index 0000000..552dc07 --- /dev/null +++ b/docs/src/content/api/handler.raw @@ -0,0 +1,1007 @@ +

librawssg_handler

+

+ Version: 1.0.0 (implied)
Crate name: + librawssg_handler
Description: Core data + structures and traits for building a static site generator (SSG) handler. + Provides Document, Metadata, and a + Processor trait for processing content files. +

+
+

Table of Contents

+
    +
  1. Overview
  2. +
  3. Modules
  4. +
  5. + Struct Document + +
  6. +
  7. + Struct Metadata + +
  8. +
  9. + Trait Processor + +
  10. +
  11. Error Handling
  12. +
  13. External Traits & Types
  14. +
  15. Examples from Tests
  16. +
  17. Validation Rules Summary
  18. +
  19. Testing Suite Overview
  20. +
+
+

Overview

+

+ librawssg_handler is the core library for a static site + generator. It defines the essential data structures used to represent a + processed document (Document) and its front matter + (Metadata). Additionally, it provides a pluggable + Processor trait that allows different file types to be processed + into Document instances. +

+

The crate is intended to be used in conjunction with:

+ +
+

Modules

+

The library is organized into three public modules:

+ +

All public types are re‑exported at the crate root for convenience:

+
pub use document::Document;
+pub use metadata::Metadata;
+pub use processor::Processor;
+
+
+

Struct Document

+

+ Represents a fully processed content item ready for rendering or further + processing. +

+
#[derive(Debug, Clone, PartialEq, Serialize)]
+#[non_exhaustive]
+pub struct Document {
+    pub metadata: Metadata,
+    pub body: String,
+    pub url: String,
+    pub output_path: PathBuf,
+    pub source_path: PathBuf,
+    pub depth: usize,
+    pub content_type: String,
+    pub is_list: bool,
+    pub list_items: Option<Vec<Self>>,
+    pub taxonomies: HashMap<String, Vec<String>>,
+}
+
+

Document Fields

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
metadataMetadataFront matter metadata associated with the document.
bodyString + The processed content body (e.g., rendered HTML, Markdown text, + etc.). +
urlString + The relative URL where the document will be accessible (e.g., + "blog/my-post.html"). +
output_pathPathBuf + Filesystem path where the final output file should be written (e.g., + "blog/my-post/index.html"). +
source_pathPathBuf + Path to the original source file (e.g., + "content/blog/my-post.md"). +
depthusize + Depth of the document in the site hierarchy (0 for top‑level). Used + for sorting or navigation. +
content_typeString + Identifier for the kind of content (e.g., + "blog", "page", + "article"). +
is_listbool + Indicates whether this document represents a list of other documents + (e.g., an index page). +
list_itemsOption<Vec<Document>> + If is_list is true, may contain the child documents. + None otherwise or when not set. +
taxonomiesHashMap<String, Vec<String>> + A map of taxonomy names (e.g., "categories", + "tags") to lists of terms. +
+
+

+ Note: The #[non_exhaustive] attribute means + that external crates cannot exhaustively match on Document or + construct it with a struct literal; they must use the provided constructor + or update syntax. This allows adding fields in the future without breaking + downstream code. +

+
+

Document::new

+
pub fn new(
+    metadata: Metadata,
+    body: impl Into<String>,
+    url: impl Into<String>,
+    output_path: impl Into<PathBuf>,
+    source_path: impl Into<PathBuf>,
+    depth: usize,
+    content_type: impl Into<String>,
+    is_list: bool,
+) -> Result<Self>
+
+

+ Purpose: Creates a new Document after + validating the provided arguments. +

+

Parameters:

+ +

Returns:

+ +

Validation Rules:

+
    +
  1. url must not be empty or contain only whitespace.
  2. +
  3. output_path must not be empty (as an OS string).
  4. +
  5. + source_path must have a file name (i.e., its last component + is not .. or empty). +
  6. +
  7. depth must not exceed 1000.
  8. +
+

Example:

+
use librawssg_handler::{Document, Metadata};
+use std::path::PathBuf;
+
+let metadata = Metadata::new("My Post", "A short description")?;
+
+let document = Document::new(
+    metadata,
+    "<h1>Hello</h1><p>World</p>",
+    "blog/my-post.html",
+    "blog/my-post/index.html",
+    "content/blog/my-post.md",
+    1,
+    "blog",
+    false,
+)?;
+
+
+

Document::relative_url

+
#[must_use]
+pub fn relative_url(&self) -> &str
+
+

Purpose: Returns the relative URL of the document.

+

+ Returns: A string slice referencing the + url field. +

+

Example:

+
let doc = /* ... */;
+assert_eq!(doc.relative_url(), "blog/my-post.html");
+
+
+

Document::add_taxonomy

+
pub fn add_taxonomy(&mut self, name: impl Into<String>, items: Vec<String>)
+
+

+ Purpose: Inserts or replaces a taxonomy entry in the + document’s taxonomies map. +

+

Parameters:

+ +

+ Behavior: If a taxonomy with the same name already exists, + its value is replaced. +

+

Example:

+
let mut doc = /* ... */;
+doc.add_taxonomy("categories", vec!["rust".to_string(), "ssg".to_string()]);
+assert_eq!(doc.taxonomies["categories"], vec!["rust", "ssg"]);
+
+
+

Document::depth

+
#[must_use]
+pub const fn depth(&self) -> usize
+
+

Purpose: Returns the depth field.

+

Returns: The document’s depth as a usize.

+

Example:

+
let doc = /* ... */;
+assert_eq!(doc.depth(), 1);
+
+
+

Document::with_list_items

+
#[must_use]
+pub fn with_list_items(mut self, items: Vec<Self>) -> Self
+
+

+ Purpose: Consumes the document, sets its + list_items field to Some(items), and returns the + modified document. +

+

Parameters:

+ +

+ Returns: The same document with list_items set. +

+

Example:

+
let parent = Document::new(/* ... */)?;
+let child1 = Document::new(/* ... */)?;
+let child2 = Document::new(/* ... */)?;
+let list_doc = parent.with_list_items(vec![child1, child2]);
+assert!(list_doc.list_items.is_some());
+
+
+

Struct Metadata

+

+ Represents front matter (metadata) for a document. The struct is serializable + and deserializable, making it suitable for parsing from formats like YAML or + TOML front matter. +

+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
+#[non_exhaustive]
+pub struct Metadata {
+    pub title: String,
+    pub description: String,
+    pub author: Option<String>,
+    pub repo_url: Option<String>,
+    pub license: Option<String>,
+    pub date: Option<NaiveDate>,
+    pub updated: Option<NaiveDate>,
+    pub tags: Vec<String>,
+    pub draft: bool,
+    pub extra: HashMap<String, serde_json::Value>,
+}
+
+

Metadata Fields

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
titleStringThe document title (required, cannot be empty).
descriptionStringA short description of the content.
authorOption<String>The author’s name, if known.
repo_urlOption<String>URL to the source repository.
licenseOption<String> + License identifier (e.g., "MIT", + "Apache-2.0"). +
dateOption<NaiveDate> + Publication date (ISO 8601 date, e.g., 2026-09-08). +
updatedOption<NaiveDate>Last modification date.
tagsVec<String>List of tags (keywords) associated with the content.
draftbool + If true, the document is considered a draft and may be excluded from + builds. +
extraHashMap<String, serde_json::Value>Arbitrary extra key–value pairs for custom metadata.
+
+

+ Note: #[non_exhaustive] prevents exhaustive + struct literals outside the crate; use the provided constructors or update + syntax. +

+
+

Metadata::new

+
pub fn new(
+    title: impl Into<String>,
+    description: impl Into<String>,
+) -> librawssg_error::Result<Self>
+
+

+ Purpose: Creates a Metadata instance with a + required title and description. All other fields + are set to their default values. +

+

Parameters:

+ +

Returns:

+ +

Example:

+
use librawssg_handler::Metadata;
+
+let meta = Metadata::new("My Title", "My Description")?;
+assert_eq!(meta.title, "My Title");
+assert!(!meta.draft);
+assert!(meta.tags.is_empty());
+
+
+

Metadata::is_draft

+
#[must_use]
+pub const fn is_draft(&self) -> bool
+
+

Purpose: Returns the draft field.

+

+ Returns: true if the document is marked as a + draft, otherwise false. +

+

Example:

+
let mut meta = Metadata::new("Title", "Desc")?;
+assert!(!meta.is_draft());
+meta.draft = true;
+assert!(meta.is_draft());
+
+
+

Metadata::insert_extra

+
pub fn insert_extra(&mut self, key: impl Into<String>, value: impl Into<serde_json::Value>)
+
+

+ Purpose: Inserts or updates an entry in the + extra map. +

+

Parameters:

+ +

+ Behavior: If the key already exists, its value is + overwritten. +

+

Example:

+
use serde_json::json;
+
+let mut meta = Metadata::new("Title", "Desc")?;
+meta.insert_extra("key", "value");
+meta.insert_extra("number", 42);
+meta.insert_extra("flag", true);
+meta.insert_extra("nested", json!({"foo": "bar"}));
+
+
+

Metadata::get_extra

+
#[must_use]
+pub fn get_extra(&self, key: &str) -> Option<&serde_json::Value>
+
+

+ Purpose: Retrieves a reference to the value stored under + key in the extra map. +

+

Parameters:

+ +

Returns:

+ +

Example:

+
let meta = /* ... */;
+if let Some(v) = meta.get_extra("key") {
+    assert_eq!(v, &json!("value"));
+}
+
+
+

Metadata Serialization

+

+ Metadata derives both Serialize and + Deserialize, so it can be converted to/from JSON, YAML, etc. + This is particularly useful for reading front matter from source files. +

+

Serialization Example:

+
use serde_json;
+
+let meta = Metadata::new("Hello", "World")?;
+let json_str = serde_json::to_string(&meta)?;
+// {"title":"Hello","description":"World","author":null,...}
+
+

Deserialization Example:

+
let json_str = r#"{
+    "title": "Hello",
+    "description": "World",
+    "author": "Alice",
+    "date": "2026-09-08",
+    "tags": ["rust", "ssg"],
+    "draft": false,
+    "extra": {"foo": "bar"}
+}"#;
+
+let meta: Metadata = serde_json::from_str(json_str)?;
+assert_eq!(meta.author.as_deref(), Some("Alice"));
+
+
+

Metadata Default

+

+ The Default trait is implemented. All fields are set to sensible + empty values: +

+ +

Example:

+
let meta = Metadata::default();
+assert_eq!(meta.title, "");
+assert!(!meta.draft);
+assert!(meta.tags.is_empty());
+
+
+

Trait Processor

+

+ The Processor trait defines an interface for components that can + transform a source file into a Document. Multiple processors may + be registered and invoked based on their ability to handle a given file. +

+
pub trait Processor: Send + Sync {
+    fn name(&self) -> &str;
+
+    fn priority(&self) -> i32 {
+        0
+    }
+
+    fn can_process(&self, relative_path: &Path, original_path: &Path) -> bool;
+
+    fn process(
+        &self,
+        fs: &dyn FileSystem,
+        relative_path: &Path,
+        content_dir: &Path,
+    ) -> Result<Option<Document>>;
+}
+
+

Processor Required Methods

+

name()

+
fn name(&self) -> &str
+
+

+ Purpose: Returns a human‑readable identifier for the + processor (e.g., "markdown", + "sass"). +

+

can_process()

+
fn can_process(&self, relative_path: &Path, original_path: &Path) -> bool
+
+

+ Purpose: Determines whether this processor should handle the + given file. +

+

Parameters:

+ +

+ Returns: true if the processor can process this + file; false otherwise. +

+

+ Typical Implementation: Check file extension or other + attributes. +

+

Example:

+
fn can_process(&self, relative_path: &Path, _original_path: &Path) -> bool {
+    relative_path.extension().and_then(|e| e.to_str()) == Some("md")
+}
+
+

process()

+
fn process(
+    &self,
+    fs: &dyn FileSystem,
+    relative_path: &Path,
+    content_dir: &Path,
+) -> Result<Option<Document>>
+
+

+ Purpose: Reads the source file, processes it, and returns an + optional Document. +

+

Parameters:

+ +

Returns:

+ +

+ Note: The FileSystem trait is defined in the + librawssg_fs crate. It abstracts many common file operations, + allowing processors to be tested with mock filesystems. +

+
+

Processor Provided Methods

+

priority()

+
fn priority(&self) -> i32 {
+    0
+}
+
+

+ Purpose: Returns the priority of this processor. Processors + with higher priority are invoked before those with lower priority. The + default is 0. +

+

+ Usage: Allows ordering of processors when multiple might + handle the same file. +

+

Example:

+
fn priority(&self) -> i32 {
+    10
+}
+
+
+

Processor Implementation

+

+ To create a custom processor, implement the Processor trait. + Below is a complete example based on the test suite: +

+
use librawssg_error::Result;
+use librawssg_fs::FileSystem;
+use librawssg_handler::{Document, Metadata, Processor};
+use std::path::Path;
+
+struct MarkdownProcessor;
+
+impl Processor for MarkdownProcessor {
+    fn name(&self) -> &str {
+        "markdown"
+    }
+
+    fn can_process(&self, relative_path: &Path, _original_path: &Path) -> bool {
+        relative_path.extension().and_then(|e| e.to_str()) == Some("md")
+    }
+
+    fn process(
+        &self,
+        fs: &dyn FileSystem,
+        relative_path: &Path,
+        content_dir: &Path,
+    ) -> Result<Option<Document>> {
+        // Read the source file
+        let source_path = content_dir.join(relative_path);
+        let content = fs.read_to_string(&source_path)?;
+
+        // Parse front matter and body (simplified here)
+        let metadata = Metadata::new("Untitled", "")?;
+        let body = content; // In reality, you would render Markdown to HTML
+
+        // Construct Document
+        let doc = Document::new(
+            metadata,
+            body,
+            relative_path.with_extension("html").to_string_lossy().to_string(),
+            relative_path.with_extension("index.html").to_string_lossy().into(),
+            source_path,
+            1,
+            "page",
+            false,
+        )?;
+
+        Ok(Some(doc))
+    }
+}
+
+
+

Error Handling

+

+ The library uses the librawssg_error::Error enum for all + fallible operations. Relevant variants: +

+ +

+ The return type Result<T> is an alias for + std::result::Result<T, librawssg_error::Error>. +

+

Example of Validation Error:

+
let result = Document::new(meta, "body", "", "out", "src.md", 0, "page", false);
+assert!(matches!(
+    result,
+    Err(librawssg_error::Error::Validation(ref msg)) if msg == "document url cannot be empty"
+));
+
+
+

External Traits & Types

+

FileSystem Trait

+

+ The Processor::process method takes a + &dyn FileSystem. This trait is defined in + librawssg_fs and provides a comprehensive set of file operations + (read, write, create directories, walk, etc.). A typical implementation wraps + std::fs, but for testing, mock implementations are often used. +

+

+ A minimal FileSystem implementation (used in tests) might + implement all methods returning + io::Error::other("not implemented") for those not + needed. +

+
+

Examples from Tests

+

+ The test suite contains numerous examples that demonstrate correct usage and + error conditions. Below are selected examples. +

+

Creating a Valid Document

+
use librawssg_handler::{Document, Metadata};
+
+let metadata = Metadata::new("Title", "Description")?;
+let doc = Document::new(
+    metadata,
+    "<p>Body</p>",
+    "blog/my-post.html",
+    "blog/my-post/index.html",
+    "content/blog/my-post.md",
+    1,
+    "blog",
+    false,
+)?;
+
+assert_eq!(doc.metadata.title, "Title");
+assert_eq!(doc.body, "<p>Body</p>");
+
+

Handling Invalid URL

+
let result = Document::new(
+    Metadata::new("Title", "Desc")?,
+    "body",
+    "", // empty URL
+    "out",
+    "src.md",
+    0,
+    "page",
+    false,
+);
+assert!(result.is_err());
+
+

Adding Taxonomy Terms

+
let mut doc = /* ... */;
+doc.add_taxonomy("categories", vec!["rust".to_string(), "ssg".to_string()]);
+
+

+ Working with extra Metadata +

+
use serde_json::json;
+
+let mut meta = Metadata::new("Title", "Desc")?;
+meta.insert_extra("key", "value");
+if let Some(v) = meta.get_extra("key") {
+    assert_eq!(v, &json!("value"));
+}
+
+

Processor Mock Example

+
struct MockProcessor { /* ... */ }
+
+impl Processor for MockProcessor {
+    fn name(&self) -> &str { "mock" }
+    fn can_process(&self, _: &Path, _: &Path) -> bool { true }
+    fn process(&self, _fs: &dyn FileSystem, _rel: &Path, _cd: &Path) -> Result<Option<Document>> {
+        Ok(Some(document))
+    }
+}
+
+
+

Validation Rules Summary

+

Metadata::new

+ +

Document::new

+
    +
  1. url must not be empty or contain only whitespace.
  2. +
  3. output_path must not be empty (as an OS string).
  4. +
  5. source_path must have a file name component.
  6. +
  7. depth must be ≤ 1000.
  8. +
+

Any violation results in an Err(Error::Validation(...)).

+
+

Testing Suite Overview

+

The tests are organized into four files:

+
    +
  1. + document_tests.rs – Validates + Document construction, field defaults, error cases, and + methods (relative_url, add_taxonomy, + depth, with_list_items). +
  2. +
  3. + metadata_tests.rs – Tests + Metadata creation, validation, is_draft, + insert_extra/get_extra, default values, and JSON + serialization/deserialization round‑trip. +
  4. +
  5. + processor_tests.rs – Tests the + Processor trait using mock implementations: + name, priority, can_process, + process returning Some, None, and + error. +
  6. +
  7. + unit_tests.rs – Verifies that all public + items are re‑exported at the crate root. +
  8. +
+

All tests can serve as executable examples of the API usage.

+
+

Conclusion

+

+ This documentation covers the public API of librawssg_handler in + detail. The crate provides a flexible foundation for building static site + generators by separating metadata handling (Metadata), document + representation (Document), and pluggable processing logic + (Processor). The validation rules ensure data integrity, and the + use of traits like FileSystem enables testability. +

+

+ For further details, refer to the source code and the accompanying test + suite. +

diff --git a/docs/src/content/api/index.raw b/docs/src/content/api/index.raw new file mode 100644 index 0000000..460bf45 --- /dev/null +++ b/docs/src/content/api/index.raw @@ -0,0 +1,14 @@ +

API Reference

+ +

Welcome to the API documentation for the librawssg workspace. Each crate is documented individually, covering its public types, traits, functions, and usage examples.

+ + + +

Select a crate from the list above to view its complete API documentation, including methods, fields, examples, and testing notes.

diff --git a/docs/src/content/api/templates.raw b/docs/src/content/api/templates.raw new file mode 100644 index 0000000..1c67df2 --- /dev/null +++ b/docs/src/content/api/templates.raw @@ -0,0 +1,947 @@ +

librawssg_templates

+ +

+ Version: 1.0.0 (implied)
+ Crate name: librawssg_templates
+ Description: Defines rendering abstractions for static site + generators. Provides the Renderer and + RenderContext traits, and an optional + TeraRenderer implementation (when the tera feature + is enabled) that integrates the Tera template engine. +

+ +
+ +

Table of Contents

+
    +
  1. Overview
  2. +
  3. Modules and Features
  4. +
  5. + Core Traits + +
  6. +
  7. + TeraRenderer + +
  8. +
  9. Internal Helper Function
  10. +
  11. Error Handling
  12. +
  13. Feature Gating
  14. +
  15. + Examples from Tests + +
  16. +
  17. Testing Suite Overview
  18. +
  19. Conclusion
  20. +
+ +
+ +

Overview

+

+ librawssg_templates provides a pluggable template rendering + system. It abstracts the rendering process with two traits: +

+ +

+ The crate optionally includes a + TeraRenderer implementation for the + Tera template engine. This + implementation is gated behind the tera feature flag. +

+ +
+ +

Modules and Features

+

The crate root (lib.rs) declares:

+
pub mod renderer;
+#[cfg(feature = "tera")]
+pub mod tera_renderer;
+
+pub use renderer::{RenderContext, Renderer};
+#[cfg(feature = "tera")]
+pub use tera_renderer::TeraRenderer;
+
+ +

+ The tera feature must be explicitly enabled in + Cargo.toml to use TeraRenderer. Without it, the + crate still provides the traits for custom renderer implementations. +

+ +
+ +

Core Traits

+

Trait RenderContext

+
pub trait RenderContext: Send + Sync {
+    fn as_any(&self) -> &dyn Any;
+    fn as_mut_any(&mut self) -> &mut dyn Any;
+}
+
+

+ Purpose: Allows arbitrary context types to be passed to a + Renderer as a trait object. The renderer can then downcast the + &dyn RenderContext to the concrete context type it expects + (e.g., tera::Context). This provides flexibility without + requiring all renderers to accept a single concrete type. +

+

Requirements:

+ +

Typical Implementation:

+

For any type T, you can implement:

+
impl RenderContext for T {
+    fn as_any(&self) -> &dyn Any {
+        self
+    }
+    fn as_mut_any(&mut self) -> &mut dyn Any {
+        self
+    }
+}
+
+

Example (from tests):

+
struct MockContext;
+
+impl RenderContext for MockContext {
+    fn as_any(&self) -> &dyn Any {
+        self
+    }
+    fn as_mut_any(&mut self) -> &mut dyn Any {
+        self
+    }
+}
+
+ +
+ +

Trait Renderer

+
pub trait Renderer: Send + Sync {
+    fn render(&self, template_name: &str, context: &dyn RenderContext) -> Result<String>;
+}
+
+

+ Purpose: Defines the rendering interface. A + Renderer takes a template identifier (name) and a context + object, and returns the rendered output as a String. +

+

Parameters:

+ +

Returns:

+ +

Note: Implementors must be Send + Sync.

+

Example (custom mock renderer):

+
struct MockRenderer { output: String }
+
+impl Renderer for MockRenderer {
+    fn render(&self, _template_name: &str, _context: &dyn RenderContext) -> Result<String> {
+        Ok(self.output.clone())
+    }
+}
+
+ +
+ +

Struct TeraRenderer

+

+ TeraRenderer is a wrapper around tera::Tera, + providing convenient methods to load templates and render them using the + Renderer trait. It is only available when the + tera feature is enabled. +

+ +

Struct Definition

+
#[derive(Debug)]
+pub struct TeraRenderer {
+    tera: tera::Tera,
+}
+
+

+ The tera field is private; access is provided via + as_tera and as_tera_mut. +

+ +
+ +

TeraRenderer::new

+
#[must_use]
+pub fn new() -> Self
+
+

+ Purpose: Creates a new TeraRenderer with an + empty Tera instance (tera::Tera::default()). +

+

Returns: A new TeraRenderer.

+

Example:

+
let renderer = TeraRenderer::new();
+
+ +
+ +

+ TeraRenderer::add_raw_template +

+
pub fn add_raw_template(&mut self, name: &str, content: &str) -> Result<()>
+
+

+ Purpose: Adds a template from a string, associating it with + the given name. The template is parsed and stored internally. +

+

Parameters:

+ +

Returns:

+ +

+ Behavior: Calls + tera.add_raw_template(name, content). Template names must be + unique; adding a duplicate name will replace the existing template. +

+

Example:

+
renderer.add_raw_template("hello", "Hello {{ name }}")?;
+
+ +
+ +

+ TeraRenderer::add_template_file +

+
pub fn add_template_file(&mut self, path: &Path) -> Result<()>
+
+

+ Purpose: Reads a template file from disk and adds it to the + renderer. The template name is derived from the file name (including + extension). +

+

Parameters:

+ +

Returns:

+ +

Behavior:

+
    +
  1. + Reads the file content using std::fs::read_to_string. On + failure, maps to + Error::Io(std::io::Error::other(format!("{e}"))). +
  2. +
  3. + Extracts the file name (the last component of the path) and converts it + to a &str. If missing or non‑UTF‑8, returns + Error::Render("template file has no valid file name"). +
  4. +
  5. + Calls add_raw_template with that file name as the template + name. +
  6. +
+

Example:

+
renderer.add_template_file(Path::new("templates/index.html"))?;
+// Template is registered as "index.html"
+
+ +
+ +

+ TeraRenderer::add_template_files_from_dir +

+
pub fn add_template_files_from_dir(&mut self, dir: &Path) -> Result<()>
+
+

+ Purpose: Adds all files directly inside a directory as + templates. This method is not recursive; it only considers + files in the immediate directory. +

+

Parameters:

+ +

Returns:

+ +

Behavior:

+
    +
  1. Reads the directory entries using std::fs::read_dir.
  2. +
  3. + For each entry: + +
  4. +
  5. Non‑file entries (subdirectories, symlinks) are ignored.
  6. +
+

+ Note: Template names are the file names (including + extensions). +

+

Example:

+
renderer.add_template_files_from_dir(Path::new("templates/"))?;
+// Adds all files in templates/ as templates with names like "base.tera", "index.html", etc.
+
+ +
+ +

+ TeraRenderer::load_templates_dir +

+
pub fn load_templates_dir(&mut self, dir: &Path) -> Result<()>
+
+

+ Purpose: Recursively loads all template files from a + directory tree. Template names are derived from the relative path (using + forward slashes as separators), allowing nested template structures (e.g., + "sub/nested.tera"). +

+

Parameters:

+ +

Returns:

+ +

Behavior:

+
    +
  1. + Canonicalizes the input directory (using + dir.canonicalize()) to ensure a stable base. +
  2. +
  3. + Walks the directory recursively using walkdir::WalkDir. + Only files are processed. +
  4. +
  5. + For each file: + +
  6. +
+

+ Note: This method is similar to + add_template_files_from_dir but recursive and with + namespace‑like template names. +

+

Example:

+
renderer.load_templates_dir(Path::new("templates"))?;
+// If templates contains sub/child.tera, it can be referenced as "sub/child.tera"
+
+ +
+ +

+ TeraRenderer::enable_autoescape +

+
pub fn enable_autoescape(&mut self)
+
+

+ Purpose: Turns on automatic escaping for HTML, HTM, and XML + file extensions. This is a convenience method that calls + tera.autoescape_on(vec!["html", "htm", "xml"]). +

+

Parameters: None.

+

Returns: Nothing.

+

+ Behavior: After calling this, templates with names ending + in .html, .htm, or .xml will + automatically escape variable output (HTML escaping). For other file + extensions, autoescaping remains off. +

+

Example:

+
renderer.enable_autoescape();
+renderer.add_raw_template("page.html", "{{ user_input }}")?;
+// Rendering will escape HTML special characters in user_input
+
+ +
+ +

TeraRenderer::render_str

+
pub fn render_str(&self, template_str: &str, context: &dyn RenderContext) -> Result<String>
+
+

+ Purpose: Renders a one‑off template string without + registering it. This is useful for small, inline templates. +

+

Parameters:

+ +

Returns:

+ +

Behavior:

+
    +
  1. + Attempts to downcast context.as_any() to + &tera::Context. If the cast fails, returns + Error::Render("invalid context type for Tera"). +
  2. +
  3. + Calls tera::Tera::one_off(template_str, tera_ctx, true). + The third argument true enables autoescaping for the + one‑off render by default, regardless of the renderer's + autoescape settings. +
  4. +
+

+ Note: render_str always autoescapes (the + true parameter forces autoescape). This may differ from + render which respects the renderer's autoescape configuration. +

+

Example:

+
let renderer = TeraRenderer::new();
+let mut ctx = tera::Context::new();
+ctx.insert("content", "<b>bold</b>");
+let output = renderer.render_str("{{ content }}", &ctx)?;
+// Output is escaped: "&lt;b&gt;bold&lt;&#x2F;b&gt;"
+
+ +
+ +

TeraRenderer::as_tera

+
#[must_use]
+pub const fn as_tera(&self) -> &tera::Tera
+
+

+ Purpose: Returns an immutable reference to the underlying + tera::Tera instance. This allows advanced operations not + directly exposed by TeraRenderer. +

+

Returns: &tera::Tera.

+

Example:

+
let tera = renderer.as_tera();
+// e.g., inspect registered templates
+
+ +
+ +

TeraRenderer::as_tera_mut

+
#[must_use]
+pub const fn as_tera_mut(&mut self) -> &mut tera::Tera
+
+

+ Purpose: Returns a mutable reference to the underlying + tera::Tera instance. Useful for direct manipulation, such as + adding templates or changing settings. +

+

Returns: &mut tera::Tera.

+

Example:

+
let tera_mut = renderer.as_tera_mut();
+tera_mut.add_raw_template("direct", "Hello")?;
+
+ +
+ +

Trait Implementations

+ +

TeraRenderer::Default

+
impl Default for TeraRenderer {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+

+ Allows creating a TeraRenderer with + TeraRenderer::default(), equivalent to new(). +

+ +

+ Renderer for TeraRenderer +

+
impl Renderer for TeraRenderer {
+    fn render(&self, template_name: &str, context: &dyn RenderContext) -> Result<String> {
+        let tera_ctx = context
+            .as_any()
+            .downcast_ref::<tera::Context>()
+            .ok_or_else(|| Error::Render("invalid context type for Tera".into()))?;
+        self.tera
+            .render(template_name, tera_ctx)
+            .map_err(|e| Error::Render(e.to_string()))
+    }
+}
+
+ + +

+ RenderContext for tera::Context +

+
impl RenderContext for tera::Context {
+    fn as_any(&self) -> &dyn core::any::Any {
+        self
+    }
+    fn as_mut_any(&mut self) -> &mut dyn core::any::Any {
+        self
+    }
+}
+
+

+ This implementation allows tera::Context to be used directly as + a RenderContext when calling render. Users + typically create a tera::Context, populate it, and pass + &ctx to render. +

+ +
+ +

Internal Helper Function

+

rel_path_to_template_name (private)

+
fn rel_path_to_template_name(rel_path: &Path) -> Result<String>
+
+

+ Purpose: Converts a relative path (from + strip_prefix) into a template name string using forward slashes + as separators. It rejects paths with unusual components (prefixes, root, + parent, or current directory). +

+

Parameters:

+ +

Returns:

+ +

+ Note: This function is not public but is essential for + load_templates_dir. +

+ +
+ +

Error Handling

+

+ All fallible methods in TeraRenderer return + librawssg_error::Result<T>. The errors originate from: +

+ +

+ The Renderer trait method also returns + Result<String>, allowing custom renderers to use the same + error type. +

+ +
+ +

Feature Gating

+ +

To enable the feature, add to Cargo.toml:

+
[dependencies]
+librawssg_templates = { version = "...", features = ["tera"] }
+
+ +
+ +

Examples from Tests

+

+ The test suite (tera_tests.rs and unit_tests.rs) + provides extensive examples. Below are selected snippets with explanations. +

+ +

Basic Rendering

+
let mut renderer = TeraRenderer::new();
+renderer.add_raw_template("simple", "{{ title }}")?;
+
+let mut ctx = tera::Context::new();
+ctx.insert("title", "Hello World");
+let output = renderer.render("simple", &ctx)?;
+assert_eq!(output, "Hello World");
+
+ +

Loops, Filters, Conditions

+
renderer.add_raw_template("loop", "{% for item in items %}{{ item }}{% if not loop.last %},{% endif %}{% endfor %}")?;
+// context: items = ["a","b","c"] -> output "a,b,c"
+
+renderer.add_raw_template("filter", "{{ title | upper }}")?;
+// context: title = "Hello" -> "HELLO"
+
+renderer.add_raw_template("condition", "{% if number > 40 %}high{% else %}low{% endif %}")?;
+// context: number = 42 -> "high"
+
+ +

File Loading

+
// Add a single file
+let file_path = dir.path().join("hello.tera");
+std::fs::write(&file_path, "{{ name }}")?;
+renderer.add_template_file(&file_path)?;
+// Template name is "hello.tera"
+
+// Add all files from a directory (non-recursive)
+renderer.add_template_files_from_dir(dir.path())?;
+
+// Recursive loading with namespaced names
+renderer.load_templates_dir(dir.path())?;
+// If sub/nested.tera exists, use renderer.render("sub/nested.tera", &ctx)
+
+ +

Autoescaping

+
renderer.enable_autoescape();
+renderer.add_raw_template("esc.html", "{{ content }}")?;
+let mut ctx = tera::Context::new();
+ctx.insert("content", "<script>alert(1)</script>");
+let output = renderer.render("esc.html", &ctx)?;
+// Output: &lt;script&gt;alert(1)&lt;&#x2F;script&gt;
+
+

Note: render_str always autoescapes:

+
let output = renderer.render_str("{{ content }}", &ctx)?;
+// Also escaped
+
+ +

Template Inheritance and Macros

+
renderer.add_raw_template("base", "<html>{% block content %}Default{% endblock %}</html>")?;
+renderer.add_raw_template("child", "{% extends \"base\" %}{% block content %}Child content{% endblock %}")?;
+// Rendering "child" yields "<html>Child content</html>"
+
+renderer.add_raw_template("macro", "{% macro hello(name) %}Hello, {{ name }}{% endmacro hello %}{{ self::hello(name=\"World\") }}")?;
+// Rendering "macro" yields "Hello, World"
+
+ +

Context Downcasting

+
let ctx = sample_context();
+let dyn_ctx: &dyn RenderContext = &ctx;
+assert!(dyn_ctx.as_any().is::<tera::Context>());
+
+

+ This shows how the RenderContext trait enables type erasure and + safe downcasting. +

+ +
+ +

Testing Suite Overview

+

The crate contains two test files:

+ +

+ The tests use tempfile for temporary directories and + walkdir for directory traversal validation. +

+ +
+ +

Conclusion

+

+ librawssg_templates offers a flexible and extensible template + rendering abstraction. The core Renderer and + RenderContext traits allow any template engine to be + integrated, while the built‑in TeraRenderer provides a + powerful, full‑featured implementation for the Tera engine. With methods for + loading templates from files or directories, autoescaping control, and + direct access to the underlying engine, it covers the needs of most static + site generators. +

+

+ The crate is designed with testability and thread‑safety in mind, and the + comprehensive test suite serves as both documentation and validation. By + enabling the tera feature, developers can immediately start + rendering templates with minimal setup. +

diff --git a/docs/src/content/code_of_conduct.raw b/docs/src/content/code_of_conduct.raw new file mode 100644 index 0000000..0da5442 --- /dev/null +++ b/docs/src/content/code_of_conduct.raw @@ -0,0 +1,108 @@ +

Contributor Covenant Code of Conduct

+ +

Our Pledge

+

+ We as members, contributors, and leaders pledge to make participation in our + community a harassment-free experience for everyone, regardless of age, body + size, visible or invisible disability, ethnicity, sex characteristics, gender + identity and expression, level of experience, education, socio-economic status, + nationality, personal appearance, race, religion, or sexual identity and + orientation. +

+

+ We pledge to act and interact in ways that contribute to an open, welcoming, + diverse, inclusive, and healthy community. +

+ +

Our Standards

+

Examples of behavior that contributes to a positive environment for our community include:

+ +

Examples of unacceptable behavior include:

+ + +

Enforcement Responsibilities

+

+ Community leaders are responsible for clarifying and enforcing our standards of + acceptable behavior and will take appropriate and fair corrective action in + response to any behavior that they deem inappropriate, threatening, offensive, + or harmful. +

+

+ Community leaders have the right and responsibility to remove, edit, or reject + comments, commits, code, wiki edits, issues, and other contributions that are + not aligned to this Code of Conduct, and will communicate reasons for moderation + decisions when appropriate. +

+ +

Scope

+

+ This Code of Conduct applies within all community spaces, and also applies when + an individual is officially representing the community in public spaces. + Examples of representing our community include using an official e-mail address, + posting via an official social media account, or acting as an appointed + representative at an online or offline event. +

+ +

Enforcement

+

+ Instances of abusive, harassing, or otherwise unacceptable behavior may be + reported to the community leaders responsible for enforcement at + mroczect@proton.me. + All complaints will be reviewed and investigated promptly and fairly. +

+

+ All community leaders are obligated to respect the privacy and security of the + reporter of any incident. +

+ +

Enforcement Guidelines

+

+ Community leaders will follow these Community Impact Guidelines in determining + the consequences for any action they deem in violation of this Code of Conduct: +

+ +

1. Correction

+

Community Impact: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.

+

Consequence: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.

+ +

2. Warning

+

Community Impact: A violation through a single incident or series of actions.

+

Consequence: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.

+ +

3. Temporary Ban

+

Community Impact: A serious violation of community standards, including sustained inappropriate behavior.

+

Consequence: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.

+ +

4. Permanent Ban

+

Community Impact: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.

+

Consequence: A permanent ban from any sort of public interaction within the community.

+ +

Attribution

+

+ This Code of Conduct is adapted from the + Contributor Covenant, + version 2.0, available at + https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. +

+

+ Community Impact Guidelines were inspired by + Mozilla's code of conduct enforcement ladder. +

+

+ For answers to common questions about this code of conduct, see the FAQ at + https://www.contributor-covenant.org/faq. + Translations are available at + https://www.contributor-covenant.org/translations. +

\ No newline at end of file diff --git a/docs/src/content/configuration.raw b/docs/src/content/configuration.raw new file mode 100644 index 0000000..00e326c --- /dev/null +++ b/docs/src/content/configuration.raw @@ -0,0 +1,402 @@ +

Configuration

+ +

+ librawssg uses a single configuration file to control the entire + build process. This file can be written in YAML or JSON format and contains + site settings, directory locations, content processing rules, and custom + extra data. +

+ +

Configuration File Format

+ +

+ The configuration can be stored as config.yaml, + config.yml, or config.json. By default, the + PipelineBuilder supports reading YAML via its + load_config() method. For JSON, you can manually call + Config::from_json_str(). +

+ +
# config.yaml
+site:
+  site_name: "My Documentation"
+  description: "A site built with librawssg"
+  language: "en"
+  base_url: "https://example.com"
+  author: "Your Name"
+  repo_url: "https://github.com/username/repo"
+  license: "MIT"
+
+build:
+  content_dir: "content"
+  output_dir: "dist"
+  templates_dir: "templates"
+  static_dir: "static"
+
+content_rules:
+  - name: "page"
+    pattern: "**/*.raw"
+    template: "base.tera"
+    list_enabled: false
+
+ +

Configuration Sections

+ +

site

+ +

+ Contains site metadata and navigation structures. All fields except + site_name are optional and have sensible defaults. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDefaultDescription
site_namestring"librawssg"The name of the website. Must not be empty or whitespace-only.
descriptionstring | nullnullA short description of the site.
languagestring | null"en"Site language code (e.g., "en", "id").
base_urlstring | nullnullThe base URL of the site. Must start with http:// or https:// if set.
authorstring | nullnullDefault author name.
repo_urlstring | nullnullURL to the source repository.
licensestring | nullnullLicense identifier (e.g., "MIT").
navbararray<NavItem>[]List of navigation items for the top bar.
sidebararray<NavItem>[]List of navigation items for the sidebar.
extraobject{}Arbitrary extra site-wide metadata.
+ +

NavItem Structure

+ +

+ Each NavItem has the following fields: +

+ +
{
+  "label": "Home",
+  "url": "/",
+  "children": []
+}
+ + + +

build

+ +

+ Specifies the directory locations used during the build. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDefaultDescription
content_dirstring"content"Directory containing source content files.
output_dirstring"dist"Directory where generated site output will be written.
templates_dirstring"templates"Directory containing template files.
static_dirstring"static"Directory containing static assets (copied as-is).
+ +

content_rules

+ +

+ A list of rules that map file patterns to templates. Each rule has the + following fields: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDefaultDescription
namestringrequiredA unique identifier for the rule (e.g., "blog", "page").
patternstringrequiredGlob pattern matching content files (e.g., "**/*.md"). Must not contain ...
templatestringrequiredName of the template to use for rendering each matched file.
list_templatestring | nullnullOptional template name for rendering list pages (index pages).
list_enabledbooleanfalseWhether list generation is enabled for this rule.
extraobject{}Arbitrary extra data associated with the rule.
+ +

Example with List Generation

+ +
content_rules:
+  - name: "blog"
+    pattern: "blog/**/*.md"
+    template: "post.tera"
+    list_template: "blog_list.tera"
+    list_enabled: true
+
+ +

extra

+ +

+ A free-form object for storing custom top-level data. This data can be + accessed from templates via the context. +

+ +

Validation Rules

+ +

+ The configuration is validated when the pipeline is built. The following + checks are performed: +

+ + + +

+ If any validation fails, an error of type + Error::Validation is returned with a descriptive message. +

+ +

Loading Configuration

+ +

From YAML

+ +
use librawssg_compiler::PipelineBuilder;
+
+let pipeline = PipelineBuilder::new()
+    .load_config("config.yaml")?
+    // ... other builder methods
+    .build()?;
+
+ +

From JSON

+ +
use librawssg_config::Config;
+
+let json_str = r#"{
+    "site": {
+        "site_name": "My Site"
+    },
+    "build": {},
+    "content_rules": [
+        {
+            "name": "page",
+            "pattern": "**/*.html",
+            "template": "base.tera"
+        }
+    ]
+}"#;
+
+let config = Config::from_json_str(json_str)?;
+ +

Programmatic Construction

+ +
use librawssg_config::{Config, ContentRule};
+
+let mut config = Config::new()
+    .with_site_name("My Site");
+
+config.add_content_rule(
+    ContentRule::new("page", "**/*.raw", "base.tera")
+);
+ +

Serialization

+ +

+ The Config struct supports serialization to YAML and JSON: +

+ +
let yaml = config.to_yaml_string()?;
+let json = config.to_json_string()?;
+ +

+ This allows saving the configuration back to a file for later use. +

+ +

Common Patterns

+ +

Multiple Content Types

+ +
content_rules:
+  - name: "page"
+    pattern: "**/*.raw"
+    template: "base.tera"
+  - name: "blog"
+    pattern: "blog/**/*.md"
+    template: "post.tera"
+    list_enabled: true
+    list_template: "blog_list.tera"
+
+ +

Custom Navigation

+ +
site:
+  navbar:
+    - label: "Home"
+      url: "/"
+    - label: "API"
+      url: "/api/index.html"
+      children:
+        - label: "Compiler"
+          url: "/api/compiler.html"
+        - label: "Config"
+          url: "/api/config.html"
+  sidebar:
+    - label: "Getting Started"
+      url: "/"
+    - label: "Installation"
+      url: "/installation.html"
+
+ +

Custom Extra Data

+ +
site:
+  extra:
+    analytics_id: "UA-123456-7"
+    social:
+      twitter: "handle"
+
+ +

+ This extra data is accessible in templates via {{ site.extra }}. +

+ +

Tips

+ + diff --git a/docs/src/content/contributing.raw b/docs/src/content/contributing.raw new file mode 100644 index 0000000..4413ee0 --- /dev/null +++ b/docs/src/content/contributing.raw @@ -0,0 +1,185 @@ +

Contributing to librawssg

+ +

+ Thank you for your interest in contributing to librawssg. This document + outlines the process for reporting issues, proposing changes, and submitting + code contributions. Following these guidelines helps maintain the quality and + consistency of the project. +

+ +

Table of Contents

+ + +

Code of Conduct

+

+ This project adheres to a minimal set of social rules: be respectful, + constructive, and inclusive. Harassment, discrimination, or hostile behaviour + is not tolerated. If you experience or witness such conduct, please contact + the maintainers. +

+ +

Getting Started

+
    +
  1. Fork the repository on GitHub.
  2. +
  3. + Clone your fork locally: +
    git clone https://github.com/YOUR_USERNAME/librawssg.git
    +cd librawssg
    +
  4. +
  5. + Add the upstream remote to keep your fork in sync: +
    git remote add upstream https://github.com/mroczect/librawssg.git
    +
  6. +
  7. + Create a branch for your work: +
    git checkout -b feat/my-feature
    +
  8. +
+ +

Development Environment

+ + +

Building and Testing

+

All commands below are run from the repository root.

+ +

Build

+
cargo build
+

To build with all features enabled:

+
cargo build --all-features
+ +

Run tests

+
cargo test
+cargo test --features tera,pulldown
+cargo test --all-features
+

+ This runs unit tests, integration tests (located in tests/), and + doc-tests. All tests must pass before a pull request is accepted. +

+ +

Lint and format

+
cargo fmt --all -- --check
+cargo clippy --all-targets --all-features -- -D warnings
+

These are enforced in CI. Run them locally to avoid surprises.

+ +

Coding Style

+ + +

Commit Messages

+

+ Use + conventional commit format: +

+
type(scope): short description
+
+Optional longer explanation.
+

+ Types: feat, fix, docs, + test, ci, chore, refactor, + style. +

+

+ Scope: librawssg (for core library), ci, + docs, etc. +

+

Examples:

+ +

This format enables automatic changelog generation and clear history.

+ +

Pull Request Process

+
    +
  1. Ensure your branch is based on an up-to-date master.
  2. +
  3. Run cargo test, cargo fmt --all -- --check, and cargo clippy --all-targets --all-features -- -D warnings to verify there are no issues.
  4. +
  5. If you added or modified public API, update the README and any relevant documentation comments.
  6. +
  7. Push your branch and open a pull request against the master branch of the main repository.
  8. +
  9. In the PR description: + +
  10. +
  11. The CI will run automatically. All checks must be green.
  12. +
  13. A maintainer will review your code. Please respond to feedback and make requested changes.
  14. +
  15. Once approved, the PR will be merged via squash merge to keep the history linear.
  16. +
+ +

Reporting Bugs

+

Open an issue on GitHub and include:

+ + +

Feature Requests

+

Feature requests are welcome. When opening an issue:

+ +

For large features, consider opening an issue first to gather feedback before writing code.

+ +

Documentation

+ + +

Community

+ + +

Thank you for contributing to librawssg. Your effort helps make the project better for everyone.

\ No newline at end of file diff --git a/docs/src/content/index.raw b/docs/src/content/index.raw new file mode 100644 index 0000000..e17075f --- /dev/null +++ b/docs/src/content/index.raw @@ -0,0 +1,99 @@ +

librawssg

+ +

+ A modular static site generator library for Rust. Build your own static site + generator with composable, testable, and safe components. +

+ +

What is librawssg?

+ +

+ librawssg is a collection of Rust crates that provide the core + building blocks for creating a static site generator. It is not a + ready‑to‑use CLI tool, but a framework that gives you full control over your + site’s behaviour. +

+ + + +

Quick Start

+ +

Add the facade crate to your Cargo.toml:

+ +
[dependencies]
+librawssg = "1.0.0"
+ +

+ Then, in your main.rs, assemble a pipeline: +

+ +
use librawssg::{Config, ContentRule, PipelineBuilder, RealFs, TeraContextBuilder, TeraRenderer};
+
+fn main() -> Result<(), Box<dyn std::error::Error>> {
+    let mut config = Config::new().with_site_name("My Site");
+    config.add_content_rule(ContentRule::new("page", "**/*.raw", "base.tera"));
+
+    let mut renderer = TeraRenderer::new();
+    renderer.load_templates_dir("templates")?;
+
+    let pipeline = PipelineBuilder::new()
+        .config(config)
+        .content_dir("content")
+        .output_dir("dist")
+        .with_fs(Box::new(RealFs))
+        .with_renderer(Box::new(renderer))
+        .with_context_builder(Box::new(TeraContextBuilder))
+        .build()?;
+
+    pipeline.run()?;
+    Ok(())
+}
+ +

+ For a complete example, see the Installation + page. +

+ +

Explore the Documentation

+ + + +

Repository

+ +

+ The source code is available on + GitHub. +

diff --git a/docs/src/content/installation.raw b/docs/src/content/installation.raw new file mode 100644 index 0000000..977ee7d --- /dev/null +++ b/docs/src/content/installation.raw @@ -0,0 +1,191 @@ +

Installation

+ +

+ This guide covers how to install and set up librawssg for building + your own static site generator, or for integrating it into an existing Rust + project. +

+ +

Prerequisites

+ + + +

Adding librawssg as a Dependency

+ +

+ librawssg is a workspace of several crates. The easiest way is to + use the facade crate librawssg that re‑exports the essential + components. +

+ +

Add the following to your Cargo.toml:

+ +
[dependencies]
+librawssg = "1.0.0"
+ +

+ If you are working within the same workspace as the librawssg + source, use a path dependency instead: +

+ +
[dependencies]
+librawssg = { path = "../librawssg" }
+ +

Feature Flags

+ +

+ The facade crate re‑exports librawssg_templates with the + tera feature enabled by default. This gives you + TeraRenderer and TeraContextBuilder. If you want to + disable the Tera integration (for a custom renderer), set + default-features = false: +

+ +
[dependencies]
+librawssg = { version = "1.0.0", default-features = false }
+ +

Basic Project Setup

+ +

+ To create a minimal static site generator using librawssg, follow + these steps: +

+ +
    +
  1. +

    Create a new binary crate:

    +
    cargo new my-site-generator
    +cd my-site-generator
    +
  2. +
  3. +

    Add dependencies to Cargo.toml:

    +
    [dependencies]
    +librawssg = "1.0.0"
    +
  4. +
  5. +

    Create the necessary directories and files:

    +
    mkdir -p content templates static
    +
  6. +
  7. +

    Place your content, templates, and static assets in the respective folders.

    +
  8. +
  9. +

    Write a main.rs that builds and runs the pipeline (see example below).

    +
  10. +
+ +

Minimal Example

+ +

+ The following example uses a simple raw HTML processor and the Tera renderer. + It processes .raw files, renders them with a base template, and + copies static files. +

+ +
use librawssg::{
+    Config, ContentRule, Document, FileSystem, Metadata, PipelineBuilder, Processor,
+    RealFs, TeraContextBuilder, TeraRenderer,
+};
+use std::path::{Path, PathBuf};
+
+struct RawProcessor;
+
+impl Processor for RawProcessor {
+    fn name(&self) -> &'static str {
+        "raw"
+    }
+
+    fn can_process(&self, rel: &Path, _orig: &Path) -> bool {
+        rel.extension().and_then(|e| e.to_str()) == Some("raw")
+    }
+
+    fn process(
+        &self,
+        fs: &dyn FileSystem,
+        rel: &Path,
+        content_dir: &Path,
+    ) -> librawssg::Result<Option<Document>> {
+        let full_path = content_dir.join(rel);
+        let body = fs.read_to_string(&full_path)?;
+        let title = rel.file_stem().unwrap_or_default().to_string_lossy().to_string();
+        let meta = Metadata::new(title, String::new())?;
+        let url = rel.with_extension("html").to_string_lossy().to_string();
+        let output = PathBuf::from(&url);
+        let doc = Document::new(meta, body, url, output, rel.to_path_buf(), 0, "page".to_string(), false)?;
+        Ok(Some(doc))
+    }
+}
+
+fn main() -> Result<(), Box<dyn std::error::Error>> {
+    // 1. Configure the site
+    let mut config = Config::new().with_site_name("My Site");
+    config.add_content_rule(ContentRule::new("page", "**/*.raw", "base.tera"));
+    config.build.content_dir = "content".into();
+    config.build.output_dir = "dist".into();
+    config.build.static_dir = "static".into();
+
+    // 2. Set up the renderer and load templates
+    let mut renderer = TeraRenderer::new();
+    renderer.load_templates_dir(Path::new("templates"))?;
+
+    // 3. Build the pipeline
+    let pipeline = PipelineBuilder::new()
+        .config(config)
+        .content_dir("content")
+        .output_dir("dist")
+        .with_fs(Box::new(RealFs))
+        .with_renderer(Box::new(renderer))
+        .with_context_builder(Box::new(TeraContextBuilder))
+        .add_processor(Box::new(RawProcessor))
+        .build()?;
+
+    // 4. Run the generation
+    pipeline.run()?;
+    println!("Site generated in 'dist'");
+    Ok(())
+}
+ +

Building the Documentation Site Itself

+ +

+ The librawssg repository includes a docs crate that + generates this documentation website. To build it locally: +

+ +
cargo run -p docs
+ +

+ The output will be written to docs/dist/. You can open + index.html to view the site. +

+ +

Directory Structure

+ +

+ A typical project using librawssg has the following layout: +

+ +
my-site-generator/
+├── Cargo.toml
+├── content/           # Source content files (.raw, .md, etc.)
+├── templates/         # Tera templates
+├── static/            # Static assets (CSS, JS, images)
+└── src/
+    └── main.rs        # Entry point
+ +

Next Steps

+ + diff --git a/docs/src/content/license.raw b/docs/src/content/license.raw new file mode 100644 index 0000000..ce50c6d --- /dev/null +++ b/docs/src/content/license.raw @@ -0,0 +1,29 @@ +

License

+ +

The MIT License (MIT)

+ +

Copyright (c) 2026 mroczect

+ +

+ Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: +

+ +

+ The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. +

+ +

+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +

\ No newline at end of file diff --git a/docs/src/main.rs b/docs/src/main.rs new file mode 100644 index 0000000..e94213d --- /dev/null +++ b/docs/src/main.rs @@ -0,0 +1,126 @@ +#![allow(clippy::multiple_crate_versions)] + +use librawssg::{ + Config, ContentRule, Document, FileSystem, Metadata, NavItem, PipelineBuilder, Processor, + RealFs, TeraContextBuilder, TeraRenderer, +}; +use std::path::{Path, PathBuf}; + +struct RawFileProcessor; + +impl Processor for RawFileProcessor { + fn name(&self) -> &'static str { + "raw-file" + } + + fn can_process(&self, relative_path: &Path, _original_path: &Path) -> bool { + relative_path.extension().is_some_and(|ext| ext == "raw") + } + + fn process( + &self, + fs: &dyn FileSystem, + relative_path: &Path, + content_dir: &Path, + ) -> librawssg::Result> { + let full_path = content_dir.join(relative_path); + let body = fs.read_to_string(&full_path)?; + + let stem = relative_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("untitled"); + let title = stem + .split('-') + .map(|word| { + let mut c = word.chars(); + c.next().map_or_else(String::new, |first| { + first.to_uppercase().collect::() + c.as_str() + }) + }) + .collect::>() + .join(" "); + + let metadata = Metadata::new(title, String::new())?; + let url = relative_path + .with_extension("html") + .to_string_lossy() + .to_string(); + let output_path = PathBuf::from(&url); + + let doc = Document::new( + metadata, + body, + url, + output_path, + relative_path.to_path_buf(), + relative_path.components().count().saturating_sub(1), + "page".to_string(), + false, + )?; + Ok(Some(doc)) + } +} + +fn main() -> Result<(), Box> { + let base = Path::new(env!("CARGO_MANIFEST_DIR")); + let content_dir = base.join("src/content"); + let templates_dir = base.join("src/templates"); + let static_dir = base.join("src/static"); + let output_dir = base.join("dist"); + + let mut renderer = TeraRenderer::new(); + renderer.add_template_file(&templates_dir.join("macros.tera"))?; + renderer.load_templates_dir(&templates_dir)?; + + let mut config = Config::new().with_site_name("librawssg Docs"); + + config.add_content_rule(ContentRule::new("page", "**/*.raw", "base.tera")); + + config.build.content_dir = content_dir.to_string_lossy().to_string(); + config.build.output_dir = output_dir.to_string_lossy().to_string(); + config.build.static_dir = static_dir.to_string_lossy().to_string(); + + config.site.navbar = vec![ + NavItem::new("Home", "/"), + NavItem::new("Installation", "/installation.html"), + NavItem::new("Configuration", "/configuration.html"), + NavItem::new("API", "/api/index.html"), + NavItem::new("Contributing", "/contributing.html"), + ]; + + let mut api_item = NavItem::new("API Reference", "/api/index.html"); + api_item.children = vec![ + NavItem::new("Compiler", "/api/compiler.html"), + NavItem::new("Config", "/api/config.html"), + NavItem::new("Error", "/api/error.html"), + NavItem::new("Filesystem", "/api/fs.html"), + NavItem::new("Handler", "/api/handler.html"), + NavItem::new("Templates", "/api/templates.html"), + ]; + + config.site.sidebar = vec![ + NavItem::new("Getting Started", "/"), + NavItem::new("Installation", "/installation.html"), + NavItem::new("Configuration", "/configuration.html"), + api_item, + NavItem::new("Contributing", "/contributing.html"), + NavItem::new("Code of Conduct", "/code_of_conduct.html"), + NavItem::new("License", "/license.html"), + ]; + + let pipeline = PipelineBuilder::new() + .config(config) + .content_dir(&content_dir) + .output_dir(&output_dir) + .with_fs(Box::new(RealFs)) + .with_renderer(Box::new(renderer)) + .with_context_builder(Box::new(TeraContextBuilder)) + .add_processor(Box::new(RawFileProcessor)) + .build()?; + + pipeline.run()?; + + println!("✅ Docs generated in '{}'", output_dir.display()); + Ok(()) +} diff --git a/docs/src/static/css/_base.css b/docs/src/static/css/_base.css new file mode 100644 index 0000000..b711beb --- /dev/null +++ b/docs/src/static/css/_base.css @@ -0,0 +1,46 @@ +body { + font-family: var(--font-sans); + font-size: 15px; + line-height: 1.6; + color: var(--color-text); + background-color: var(--color-bg); + display: flex; + flex-direction: column; + min-height: 100vh; + margin: 0; + transition: + background-color 0.3s, + color 0.3s; +} + +a { + color: var(--color-primary); + text-decoration: none; + transition: + color 0.2s, + text-decoration-color 0.2s; +} + +a:hover { + color: var(--color-primary-hover); + text-decoration: underline; +} + +/* Fokus yang jelas untuk keyboard */ +a:focus-visible, +button:focus-visible, +input:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; + border-radius: 2px; +} + +button { + cursor: pointer; + border: none; + background: none; + font: inherit; + transition: + background-color 0.2s, + color 0.2s; +} diff --git a/docs/src/static/css/_content.css b/docs/src/static/css/_content.css new file mode 100644 index 0000000..0167ed5 --- /dev/null +++ b/docs/src/static/css/_content.css @@ -0,0 +1,81 @@ +.markdown-body { + font-size: 0.95rem; + line-height: 1.7; + word-wrap: break-word; +} + +.markdown-body > *:first-child { + margin-top: 0 !important; +} +.markdown-body > *:last-child { + margin-bottom: 0 !important; +} + +.markdown-body table { + display: block; + width: 100%; + overflow: auto; + margin: 1.25rem 0; + border-collapse: collapse; + font-size: 0.9rem; + border-radius: var(--radius-sm); + box-shadow: var(--shadow-sm); +} + +.markdown-body th, +.markdown-body td { + padding: 0.5rem 0.8rem; + border: 1px solid var(--color-border); + text-align: left; +} + +.markdown-body th { + background-color: var(--color-sidebar-bg); + font-weight: 600; + color: var(--color-heading); +} + +.markdown-body tr:nth-child(2n) { + background-color: var(--color-accent); +} + +.markdown-body img { + max-width: 100%; + height: auto; + border-radius: var(--radius-sm); +} + +.breadcrumb { + margin-bottom: 1rem; + font-size: 0.85rem; + color: var(--color-text-secondary); +} + +.breadcrumb ol { + list-style: none; + display: flex; + flex-wrap: wrap; + padding: 0; + margin: 0; + gap: 0.25rem; +} + +.breadcrumb li:not(:last-child)::after { + content: "/"; + margin: 0 0.4rem; + color: var(--color-border); +} + +.breadcrumb a { + color: var(--color-text-secondary); +} + +.breadcrumb a:hover { + color: var(--color-primary); +} + +.breadcrumb span { + color: var(--color-text); + font-weight: 500; +} + diff --git a/docs/src/static/css/_footer.css b/docs/src/static/css/_footer.css new file mode 100644 index 0000000..05c2858 --- /dev/null +++ b/docs/src/static/css/_footer.css @@ -0,0 +1,18 @@ +.footer { + background-color: var(--color-bg); + color: var(--color-text-secondary); + padding: 0.75rem 1rem; + text-align: center; + border-top: 1px solid var(--color-border); + font-size: 0.85rem; +} + +.footer-inner { + max-width: 1200px; + margin: 0 auto; + display: flex; + justify-content: center; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; +} diff --git a/docs/src/static/css/_layout.css b/docs/src/static/css/_layout.css new file mode 100644 index 0000000..5ec33a5 --- /dev/null +++ b/docs/src/static/css/_layout.css @@ -0,0 +1,24 @@ +/* ========================================================================== + _layout.css + ========================================================================== */ + +.wrapper { + display: flex; + flex: 1; + min-height: calc(100vh - var(--navbar-height)); +} + +.container { + width: 100%; + max-width: 1200px; + margin-right: auto; + margin-left: auto; + padding: 0 1rem; +} + +.content { + flex: 1; + padding: 1.5rem 2.5rem; + max-width: var(--content-max-width); + min-width: 0; +} diff --git a/docs/src/static/css/_navbar.css b/docs/src/static/css/_navbar.css new file mode 100644 index 0000000..bc75eb2 --- /dev/null +++ b/docs/src/static/css/_navbar.css @@ -0,0 +1,117 @@ +.navbar { + background-color: var(--color-navbar-bg); + color: var(--color-navbar-text); + height: var(--navbar-height); + position: sticky; + top: 0; + z-index: 1000; + display: flex; + align-items: center; + border-bottom: 1px solid var(--color-border); + box-shadow: var(--shadow-sm); +} + +.navbar-inner { + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: 0 1rem; + display: flex; + align-items: center; + gap: 1rem; +} + +.brand { + font-size: 1.2rem; + font-weight: 700; + color: var(--color-navbar-text); + text-decoration: none; + white-space: nowrap; + letter-spacing: -0.01em; +} + +.brand:hover { + color: var(--color-primary); + text-decoration: none; +} + +.nav-links { + display: flex; + align-items: center; + margin-left: auto; +} + +.nav-links ul { + list-style: none; + display: flex; + gap: 0.25rem; + margin: 0; + padding: 0; +} + +.nav-links a { + display: block; + padding: 0.4rem 0.75rem; + color: var(--color-text-secondary); + text-decoration: none; + border-radius: var(--radius-sm); + font-size: 0.9rem; + transition: + color 0.2s, + background-color 0.2s; +} + +.nav-links a:hover { + color: var(--color-primary); + background-color: var(--color-accent); + text-decoration: none; +} + +.nav-links a.active { + color: var(--color-primary); + font-weight: 600; + background-color: rgba(13, 110, 253, 0.08); +} + +/* Hamburger button dengan animasi 3 garis */ +.navbar-toggle { + display: none; + flex-direction: column; + cursor: pointer; + padding: 0.5rem; + background: none; + border: none; + margin-left: auto; + gap: 5px; +} + +.navbar-toggle .bar { + width: 22px; + height: 2px; + background-color: var(--color-navbar-text); + transition: 0.3s; + border-radius: 2px; +} + +.navbar-toggle[aria-expanded="true"] .bar:nth-child(1) { + transform: translateY(7px) rotate(45deg); +} +.navbar-toggle[aria-expanded="true"] .bar:nth-child(2) { + opacity: 0; +} +.navbar-toggle[aria-expanded="true"] .bar:nth-child(3) { + transform: translateY(-7px) rotate(-45deg); +} + +.theme-toggle { + color: var(--color-navbar-text); + font-size: 1.1rem; + cursor: pointer; + padding: 0.35rem 0.5rem; + border-radius: var(--radius-sm); + transition: background-color 0.2s; +} + +.theme-toggle:hover { + background-color: var(--color-accent); +} diff --git a/docs/src/static/css/_reset.css b/docs/src/static/css/_reset.css new file mode 100644 index 0000000..788be98 --- /dev/null +++ b/docs/src/static/css/_reset.css @@ -0,0 +1,187 @@ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + line-height: 1.15; + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; +} + +main { + display: block; +} + +h1 { + font-size: 2em; + margin: 0.67em 0; +} + +hr { + box-sizing: content-box; + height: 0; + overflow: visible; +} + +pre { + font-family: monospace, monospace; + font-size: 1em; +} + +a { + background-color: transparent; +} + +abbr[title] { + border-bottom: none; + text-decoration: underline; + text-decoration: underline dotted; +} + +b, +strong { + font-weight: bolder; +} + +code, +kbd, +samp { + font-family: monospace, monospace; + font-size: 1em; +} + +small { + font-size: 80%; +} + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +img { + border-style: none; +} + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; + font-size: 100%; + line-height: 1.15; + margin: 0; +} + +button, +input { + overflow: visible; +} + +button, +select { + text-transform: none; +} + +button, +[type="button"], +[type="reset"], +[type="submit"] { + -webkit-appearance: button; +} + +button::-moz-focus-inner, +[type="button"]::-moz-focus-inner, +[type="reset"]::-moz-focus-inner, +[type="submit"]::-moz-focus-inner { + border-style: none; + padding: 0; +} + +button:-moz-focusring, +[type="button"]:-moz-focusring, +[type="reset"]:-moz-focusring, +[type="submit"]:-moz-focusring { + outline: 1px dotted ButtonText; +} + +fieldset { + padding: 0.35em 0.75em 0.625em; +} + +legend { + box-sizing: border-box; + color: inherit; + display: table; + max-width: 100%; + padding: 0; + white-space: normal; +} + +progress { + vertical-align: baseline; +} + +textarea { + overflow: auto; +} + +[type="checkbox"], +[type="radio"] { + box-sizing: border-box; + padding: 0; +} + +[type="number"]::-webkit-inner-spin-button, +[type="number"]::-webkit-outer-spin-button { + height: auto; +} + +[type="search"] { + -webkit-appearance: textfield; + outline-offset: -2px; +} + +[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +::-webkit-file-upload-button { + -webkit-appearance: button; + font: inherit; +} + +details { + display: block; +} + +summary { + display: list-item; +} + +template { + display: none; +} + +[hidden] { + display: none; +} diff --git a/docs/src/static/css/_responsive.css b/docs/src/static/css/_responsive.css new file mode 100644 index 0000000..306d8a7 --- /dev/null +++ b/docs/src/static/css/_responsive.css @@ -0,0 +1,67 @@ +@media (max-width: 768px) { + .navbar-toggle { + display: flex; + } + + .nav-links { + display: none; + position: absolute; + top: var(--navbar-height); + left: 0; + right: 0; + background-color: var(--color-navbar-bg); + padding: 1rem; + box-shadow: var(--shadow-md); + flex-direction: column; + border-bottom: 1px solid var(--color-border); + } + + .nav-links.active { + display: flex; + } + + .nav-links ul { + flex-direction: column; + gap: 0; + width: 100%; + } + + .nav-links li { + margin-bottom: 0.25rem; + } + + .wrapper { + flex-direction: column; + } + + .sidebar { + width: 85%; + max-width: 320px; + height: 100vh; + position: fixed; + top: var(--navbar-height); + left: 0; + bottom: 0; + z-index: 999; + transform: translateX(-100%); + transition: transform 0.3s ease; + box-shadow: var(--shadow-md); + } + + .sidebar.open { + transform: translateX(0); + } + + .sidebar-close { + display: block; + } + + .content { + padding: 1.25rem; + } + + .footer-inner { + flex-direction: column; + text-align: center; + } +} diff --git a/docs/src/static/css/_sidebar.css b/docs/src/static/css/_sidebar.css new file mode 100644 index 0000000..eecd05e --- /dev/null +++ b/docs/src/static/css/_sidebar.css @@ -0,0 +1,118 @@ +.sidebar { + width: var(--sidebar-width); + flex-shrink: 0; + background-color: var(--color-sidebar-bg); + border-right: 1px solid var(--color-border); + padding: 1rem 0.5rem; + overflow-y: auto; + position: sticky; + top: var(--navbar-height); + height: calc(100vh - var(--navbar-height)); + transition: transform 0.3s ease; +} + +.sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; + padding: 0 0.5rem; +} + +.sidebar-header h2 { + font-size: 0.8rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-secondary); + margin: 0; +} + +.sidebar-header h2 a { + color: inherit; + text-decoration: none; +} + +.sidebar-header h2 a:hover { + color: var(--color-primary); +} + +.sidebar ul { + list-style: none; + padding: 0; + margin: 0; +} + +.sidebar li { + margin-bottom: 0.05rem; +} + +.sidebar a { + display: block; + padding: 0.35rem 0.6rem; + color: var(--color-text); + text-decoration: none; + font-size: 0.9rem; + line-height: 1.4; + border-left: 2px solid transparent; + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; + transition: + background-color 0.15s, + border-color 0.15s, + color 0.15s; +} + +.sidebar a:hover { + background-color: var(--color-accent); + text-decoration: none; + color: var(--color-primary); +} + +.sidebar a.active { + font-weight: 600; + color: var(--color-primary); + border-left-color: var(--color-primary); + background-color: rgba(13, 110, 253, 0.08); +} + +/* Nested list lebih rapi */ +.sidebar li > ul { + margin-left: 0.5rem; + border-left: 1px solid var(--color-border-light); + padding-left: 0.25rem; +} + +.sidebar li > ul > li > a { + padding-left: 1.2rem; + font-size: 0.85rem; +} + +.sidebar-close { + display: none; + font-size: 1.3rem; + cursor: pointer; + color: var(--color-text-secondary); + background: none; + border: none; + padding: 0.25rem; + border-radius: var(--radius-sm); +} + +.sidebar-close:hover { + background-color: var(--color-accent); +} + +.sidebar-overlay { + display: none; + position: fixed; + inset: 0; + background: var(--color-overlay); + z-index: 998; + transition: opacity 0.3s; + opacity: 0; +} + +.sidebar-overlay.active { + display: block; + opacity: 1; +} diff --git a/docs/src/static/css/_typography.css b/docs/src/static/css/_typography.css new file mode 100644 index 0000000..fd9a1c4 --- /dev/null +++ b/docs/src/static/css/_typography.css @@ -0,0 +1,90 @@ +h1, +h2, +h3, +h4, +h5, +h6 { + margin-top: 1.5em; + margin-bottom: 0.5em; + font-weight: 600; + line-height: 1.3; + color: var(--color-heading); +} + +h1 { + font-size: 2em; + padding-bottom: 0.25em; + border-bottom: 1px solid var(--color-border-light); + letter-spacing: -0.01em; +} + +h2 { + font-size: 1.5em; + padding-bottom: 0.2em; + border-bottom: 1px solid var(--color-border-light); +} + +h3 { + font-size: 1.15em; +} + +h4 { + font-size: 1em; +} + +h5 { + font-size: 0.9em; +} + +h6 { + font-size: 0.85em; + color: var(--color-text-secondary); +} + +p { + margin-bottom: 1rem; +} + +small { + font-size: 85%; +} + +blockquote { + margin: 0 0 1rem; + padding: 0.25rem 1rem; + color: var(--color-text-secondary); + border-left: 3px solid var(--color-border); + background-color: var(--color-accent); + border-radius: var(--radius-sm); + font-size: 0.95em; +} + +code, +tt { + font-family: var(--font-mono); + font-size: 0.85em; + padding: 0.15em 0.3em; + background-color: var(--color-code-bg); + border: 1px solid var(--color-border-light); + border-radius: 3px; +} + +pre { + margin-bottom: 1rem; + padding: 0.75rem 1rem; + overflow: auto; + font-family: var(--font-mono); + font-size: 0.85em; + line-height: 1.5; + background-color: var(--color-code-bg); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-md); + box-shadow: none; +} + +pre code { + padding: 0; + background: none; + border: none; + font-size: 100%; +} diff --git a/docs/src/static/css/_variables.css b/docs/src/static/css/_variables.css new file mode 100644 index 0000000..5fda29f --- /dev/null +++ b/docs/src/static/css/_variables.css @@ -0,0 +1,51 @@ +:root { + /* Light theme */ + --color-bg: #ffffff; + --color-text: #1a1a1a; + --color-text-secondary: #555555; + --color-heading: #000000; + --color-border: #d0d7de; + --color-border-light: #eaeef2; + --color-primary: #0d6efd; + --color-primary-hover: #0b5ed7; + --color-accent: #f8f9fa; + --color-code-bg: #f6f8fa; + --color-sidebar-bg: #f8f9fa; + --color-navbar-bg: #ffffff; + --color-navbar-text: #1a1a1a; + --color-overlay: rgba(0, 0, 0, 0.5); + --color-focus: rgba(13, 110, 253, 0.25); + + --sidebar-width: 280px; + --navbar-height: 56px; /* sedikit lebih tinggi */ + --content-max-width: 880px; + --radius-sm: 4px; + --radius-md: 6px; + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08); + + --font-sans: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, + sans-serif; + --font-mono: + ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", + monospace; +} + +[data-theme="dark"] { + --color-bg: #0d1117; + --color-text: #e6edf3; + --color-text-secondary: #8b949e; + --color-heading: #f0f6fc; + --color-border: #30363d; + --color-border-light: #21262d; + --color-primary: #58a6ff; + --color-primary-hover: #79c0ff; + --color-accent: #161b22; + --color-code-bg: #161b22; + --color-sidebar-bg: #010409; + --color-navbar-bg: #010409; + --color-navbar-text: #e6edf3; + --color-overlay: rgba(0, 0, 0, 0.7); + --color-focus: rgba(88, 166, 255, 0.35); +} diff --git a/docs/src/static/css/style.css b/docs/src/static/css/style.css new file mode 100644 index 0000000..ad35aaa --- /dev/null +++ b/docs/src/static/css/style.css @@ -0,0 +1,10 @@ +@import url("_variables.css"); +@import url("_reset.css"); +@import url("_base.css"); +@import url("_typography.css"); +@import url("_layout.css"); +@import url("_navbar.css"); +@import url("_sidebar.css"); +@import url("_content.css"); +@import url("_footer.css"); +@import url("_responsive.css"); diff --git a/docs/src/static/js/main.js b/docs/src/static/js/main.js new file mode 100644 index 0000000..dc6c77c --- /dev/null +++ b/docs/src/static/js/main.js @@ -0,0 +1,7 @@ +import { initNavbar } from "./modules/navbar.js"; +import { initSidebar } from "./modules/sidebar.js"; + +document.addEventListener("DOMContentLoaded", () => { + initNavbar(); + initSidebar(); +}); diff --git a/docs/src/static/js/modules/navbar.js b/docs/src/static/js/modules/navbar.js new file mode 100644 index 0000000..d7dd9a8 --- /dev/null +++ b/docs/src/static/js/modules/navbar.js @@ -0,0 +1,45 @@ +export function initNavbar() { + const toggleButton = document.getElementById("navbar-toggle"); + const navLinks = document.getElementById("nav-links"); + const themeToggle = document.getElementById("theme-toggle"); + + function setHighlightTheme(theme) { + const lightLink = document.getElementById("hljs-light"); + const darkLink = document.getElementById("hljs-dark"); + if (!lightLink || !darkLink) return; + + if (theme === "dark") { + lightLink.disabled = true; + darkLink.disabled = false; + } else { + lightLink.disabled = false; + darkLink.disabled = true; + } + } + + if (toggleButton && navLinks) { + toggleButton.addEventListener("click", () => { + const expanded = toggleButton.getAttribute("aria-expanded") === "true"; + navLinks.classList.toggle("active"); + toggleButton.setAttribute("aria-expanded", !expanded); + }); + } + + if (themeToggle) { + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + const storedTheme = localStorage.getItem("theme"); + const currentTheme = storedTheme || (prefersDark ? "dark" : "light"); + + // Terapkan tema awal + document.documentElement.setAttribute("data-theme", currentTheme); + setHighlightTheme(currentTheme); + + themeToggle.addEventListener("click", () => { + const current = document.documentElement.getAttribute("data-theme"); + const next = current === "dark" ? "light" : "dark"; + document.documentElement.setAttribute("data-theme", next); + localStorage.setItem("theme", next); + setHighlightTheme(next); + }); + } +} diff --git a/docs/src/static/js/modules/sidebar.js b/docs/src/static/js/modules/sidebar.js new file mode 100644 index 0000000..ff4bf4b --- /dev/null +++ b/docs/src/static/js/modules/sidebar.js @@ -0,0 +1,56 @@ +export function initSidebar() { + const currentPath = window.location.pathname; + const sidebarLinks = document.querySelectorAll(".sidebar-nav a"); + const sidebar = document.querySelector(".sidebar"); + const overlay = document.getElementById("sidebar-overlay"); + const closeBtn = document.getElementById("sidebar-close"); + + sidebarLinks.forEach((link) => { + const linkPath = link.getAttribute("href"); + if (linkPath && currentPath.endsWith(linkPath)) { + link.classList.add("active"); + let parent = link.closest("li"); + while (parent) { + const parentUl = parent.parentElement; + if (parentUl && parentUl.tagName === "UL") { + parentUl.style.display = "block"; + } + parent = parentUl ? parentUl.closest("li") : null; + } + } + }); + + const navbarToggle = document.getElementById("navbar-toggle"); + + function openSidebar() { + sidebar.classList.add("open"); + overlay.classList.add("active"); + navbarToggle?.setAttribute("aria-expanded", "true"); + } + + function closeSidebar() { + sidebar.classList.remove("open"); + overlay.classList.remove("active"); + navbarToggle?.setAttribute("aria-expanded", "false"); + } + + if (navbarToggle && sidebar) { + navbarToggle.addEventListener("click", () => { + if (sidebar.classList.contains("open")) { + closeSidebar(); + } else { + openSidebar(); + } + }); + } + + if (closeBtn) closeBtn.addEventListener("click", closeSidebar); + if (overlay) overlay.addEventListener("click", closeSidebar); + + // Tutup sidebar jika layar di-resize ke desktop + window.addEventListener("resize", () => { + if (window.innerWidth > 768 && sidebar.classList.contains("open")) { + closeSidebar(); + } + }); +} diff --git a/docs/src/templates/base.tera b/docs/src/templates/base.tera new file mode 100644 index 0000000..5bd9a2f --- /dev/null +++ b/docs/src/templates/base.tera @@ -0,0 +1,21 @@ +{% import "macros.tera" as nav %} +{% include "partials/head.tera" %} + + {% include "partials/navbar.tera" %} + +
+ {% include "partials/sidebar.tera" %} +
+ {% if page_breadcrumb %} + {{ nav::render_breadcrumb(items=page_breadcrumb) }} + {% endif %} +
+ {{ page_content | safe }} +
+
+
+ + {% include "partials/footer.tera" %} + {% include "partials/scripts.tera" %} + + diff --git a/docs/src/templates/macros.tera b/docs/src/templates/macros.tera new file mode 100644 index 0000000..06e1aa6 --- /dev/null +++ b/docs/src/templates/macros.tera @@ -0,0 +1,31 @@ +{% macro render_nav(items, active_url='') %} +
    + {% for item in items %} +
  • + + {{ item.label }} + + {% if item.children %} + {{ self::render_nav(items=item.children, active_url=active_url) }} + {% endif %} +
  • + {% endfor %} +
+{% endmacro %} + +{% macro render_breadcrumb(items) %} + +{% endmacro %} diff --git a/docs/src/templates/partials/footer.tera b/docs/src/templates/partials/footer.tera new file mode 100644 index 0000000..bb25fc7 --- /dev/null +++ b/docs/src/templates/partials/footer.tera @@ -0,0 +1,6 @@ +
+ +
diff --git a/docs/src/templates/partials/head.tera b/docs/src/templates/partials/head.tera new file mode 100644 index 0000000..e503001 --- /dev/null +++ b/docs/src/templates/partials/head.tera @@ -0,0 +1,20 @@ + + + + + + {% if page_title %}{{ page_title }} | {% endif %}{{ site.site_name }} + + + {# Set tema awal sebelum CSS dimuat untuk mencegah flash #} + + + {% include "partials/stylesheets.tera" %} + diff --git a/docs/src/templates/partials/navbar.tera b/docs/src/templates/partials/navbar.tera new file mode 100644 index 0000000..e6b9cb8 --- /dev/null +++ b/docs/src/templates/partials/navbar.tera @@ -0,0 +1,17 @@ +{% import "macros.tera" as nav %} + diff --git a/docs/src/templates/partials/scripts.tera b/docs/src/templates/partials/scripts.tera new file mode 100644 index 0000000..89b762c --- /dev/null +++ b/docs/src/templates/partials/scripts.tera @@ -0,0 +1,7 @@ + + + diff --git a/docs/src/templates/partials/sidebar.tera b/docs/src/templates/partials/sidebar.tera new file mode 100644 index 0000000..c4d447a --- /dev/null +++ b/docs/src/templates/partials/sidebar.tera @@ -0,0 +1,11 @@ +{% import "macros.tera" as nav %} + + \ No newline at end of file diff --git a/docs/src/templates/partials/stylesheets.tera b/docs/src/templates/partials/stylesheets.tera new file mode 100644 index 0000000..6aec86b --- /dev/null +++ b/docs/src/templates/partials/stylesheets.tera @@ -0,0 +1,5 @@ + + + + +