hauchiwa 0.19.0

Flexible static website generator library with incremental rebuilds and cached image optimization
Documentation
//! Loaders are tasks that ingest data from the filesystem or external sources.
//!
//! A "Loader" is typically a task with **zero dependencies** that reads files
//! matching a glob pattern, processes them (e.g., parsing frontmatter, resizing
//! images), and stores them in [`Tracker`](crate::Tracker) accessible at runtime.
//!
//! Loaders that require JavaScript execution (like Svelte) do not embed V8.
//! Instead, they act as orchestrators, spawning `deno` subprocesses to handle
//! the compilation. This keeps the Rust binary small and compilation times
//! fast, leveraging Deno's existing toolchain for transpilation.

pub mod generic;
pub use generic::Document;

#[cfg(feature = "image")]
pub mod image;
#[cfg(feature = "image")]
pub use image::Image;

#[cfg(feature = "grass")]
pub mod css;
#[cfg(feature = "grass")]
pub use css::Stylesheet;

#[cfg(feature = "minijinja")]
pub mod jinja;
#[cfg(feature = "minijinja")]
pub use jinja::TemplateEnv;

#[cfg(feature = "rolldown")]
pub mod rolldown;

pub mod esbuild;

pub mod svelte;
pub use svelte::Svelte;
use tracing_indicatif::span_ext::IndicatifSpanExt;

#[cfg(feature = "tokio")]
pub mod tokio;

#[cfg(feature = "pagefind")]
pub mod pagefind;

#[cfg(feature = "sitemap")]
pub mod sitemap;

use std::collections::{BTreeMap, HashSet};
use std::fs;

use camino::{Utf8Path, Utf8PathBuf};
use glob::{Pattern, glob};
use gray_matter::engine::YAML;
use petgraph::graph::NodeIndex;
use rayon::iter::{IntoParallelIterator, ParallelIterator};

use crate::core::{Dynamic, Hash32, Store, TaskContext};
use crate::engine::{Map, Provenance, Tracking, TypedFine};

/// Represents a compiled JavaScript module.
#[derive(Clone)]
pub struct Script {
    /// The path to the compiled JavaScript file (e.g., hashed path).
    pub path: Utf8PathBuf,
}

/// A raw file read from the filesystem.
///
/// This struct is passed to the callback of custom loaders.
pub struct Input {
    /// The path to the source file.
    pub path: Utf8PathBuf,
    /// The hash of the file content.
    pub(crate) hash: Hash32,
}

impl Input {
    /// Reads the file content from the filesystem.
    pub fn read(&self) -> std::io::Result<Box<[u8]>> {
        fs::read(&self.path).map(Into::into)
    }
}

type GlobFilesCallback<G, R> = Box<
    dyn Fn(&TaskContext<G>, &mut Store, Input) -> anyhow::Result<(Utf8PathBuf, R)> + Send + Sync,
>;

/// A task that finds files matching a glob pattern and processes them in parallel.
///
/// This is the implementation behind helper methods like `load_frontmatter` and `load_images`.
/// It is generic over the global context `G` and the result type `R`.
pub(crate) struct GlobFiles<G, R>
where
    G: Send + Sync + 'static,
    R: Send + Sync + 'static,
{
    glob_entry: Vec<String>,
    glob_watch: Vec<Pattern>,
    callback: GlobFilesCallback<G, R>,
}

impl<G, R> GlobFiles<G, R>
where
    G: Send + Sync + 'static,
    R: Send + Sync + 'static,
{
    /// Creates a new `GlobFiles` task with pre-compiled watch patterns.
    ///
    /// Use this when patterns have already been validated at the call site.
    pub fn new<F>(glob_entry: Vec<String>, glob_watch: Vec<Pattern>, callback: F) -> Self
    where
        F: Fn(&TaskContext<G>, &mut Store, Input) -> anyhow::Result<(Utf8PathBuf, R)>
            + Send
            + Sync
            + 'static,
    {
        Self {
            glob_entry,
            glob_watch,
            callback: Box::new(callback),
        }
    }
}

impl<G, R> TypedFine<G> for GlobFiles<G, R>
where
    G: Send + Sync + 'static,
    R: Send + Sync + 'static,
{
    type Output = R;

    fn get_name(&self) -> String {
        self.glob_entry.join(", ")
    }

    fn dependencies(&self) -> Vec<NodeIndex> {
        vec![]
    }

    fn get_watched(&self) -> Vec<camino::Utf8PathBuf> {
        self.glob_watch
            .iter()
            .map(|pat| Utf8PathBuf::from(pat.as_str()))
            .collect()
    }

    fn execute(
        &self,
        context: &TaskContext<G>,
        runtime: &mut Store,
        _: &[Dynamic],
        _: Option<&Dynamic>,
        _: &HashSet<NodeIndex>,
    ) -> anyhow::Result<(Tracking, Map<Self::Output>)> {
        let mut paths = Vec::new();
        for glob_entry in &self.glob_entry {
            for path in glob(glob_entry)? {
                // Handle glob errors immediately here
                paths.push(Utf8PathBuf::try_from(path?)?);
            }
        }

        // we can override the style to have progress
        let style = crate::utils::get_style_task_progress()?;
        context.span.pb_set_style(&style);
        context.span.pb_set_length(paths.len() as u64);

        let results: anyhow::Result<Vec<_>> = paths
            .into_par_iter()
            .map(|path| {
                let hash = Hash32::hash_file(&path)?;
                let file = Input { path, hash };

                let mut rt = runtime.fork();

                // call the user callback
                let (path, res) = (self.callback)(context, &mut rt, file)?;

                // next iteration
                context.span.pb_inc(1);

                Ok((Provenance(hash), path, res, rt.imports, rt.store_paths))
            })
            .collect();

        let mut map = BTreeMap::new();
        for (provenance, path, res, imports, store_paths) in results? {
            map.insert(path.as_str().into(), (res, provenance));
            runtime.imports.merge(imports);
            runtime.store_paths.extend(store_paths);
        }

        Ok((Tracking::default(), Map { map, dirty: false }))
    }

    fn is_dirty(&self, path: &Utf8Path) -> bool {
        self.glob_watch.iter().any(|p| p.matches(path.as_str()))
    }
}

type GlobBundleCallback<G, R> = Box<
    dyn Fn(&TaskContext<G>, &mut Store, Input) -> anyhow::Result<(Hash32, Utf8PathBuf, R)>
        + Send
        + Sync,
>;

type PreRunCallback<G> =
    Box<dyn Fn(&TaskContext<G>, &mut Store) -> anyhow::Result<()> + Send + Sync>;

/// A task that finds files matching a glob pattern and processes them in parallel.
///
/// This is the implementation behind helper methods like `load_frontmatter` and `load_images`.
/// It is generic over the global context `G` and the result type `R`.
pub(crate) struct GlobBundle<G, R>
where
    G: Send + Sync + 'static,
    R: Send + Sync + 'static,
{
    glob_entry: Vec<String>,
    glob_watch: Vec<Pattern>,
    callback: GlobBundleCallback<G, R>,
    pre_run: Option<PreRunCallback<G>>,
    requirements: Vec<crate::preflight::Requirement>,
}

impl<G, R> GlobBundle<G, R>
where
    G: Send + Sync + 'static,
    R: Send + Sync + 'static,
{
    /// Creates a new `GlobBundle` task with pre-compiled watch patterns.
    ///
    /// Use this when patterns have already been validated at the call site.
    pub(crate) fn new<F>(glob_entry: Vec<String>, glob_watch: Vec<Pattern>, callback: F) -> Self
    where
        F: Fn(&TaskContext<G>, &mut Store, Input) -> anyhow::Result<(Hash32, Utf8PathBuf, R)>
            + Send
            + Sync
            + 'static,
    {
        Self {
            glob_entry,
            glob_watch,
            callback: Box::new(callback),
            pre_run: None,
            requirements: Vec::new(),
        }
    }

    /// Declares a pre_run hook to run before path processing.
    pub(crate) fn pre_run<F>(mut self, f: F) -> Self
    where
        F: Fn(&TaskContext<G>, &mut Store) -> anyhow::Result<()> + Send + Sync + 'static,
    {
        self.pre_run = Some(Box::new(f));
        self
    }

    /// Declares a preflight requirement for this task.
    pub(crate) fn require(mut self, req: crate::preflight::Requirement) -> Self {
        self.requirements.push(req);
        self
    }
}

impl<G, R> TypedFine<G> for GlobBundle<G, R>
where
    G: Send + Sync + 'static,
    R: Send + Sync + 'static,
{
    type Output = R;

    fn get_name(&self) -> String {
        self.glob_entry.join(", ")
    }

    fn dependencies(&self) -> Vec<NodeIndex> {
        vec![]
    }

    fn get_watched(&self) -> Vec<camino::Utf8PathBuf> {
        self.glob_watch
            .iter()
            .map(|pat| Utf8PathBuf::from(pat.as_str()))
            .collect()
    }

    fn execute(
        &self,
        context: &TaskContext<G>,
        runtime: &mut Store,
        _: &[Dynamic],
        _: Option<&Dynamic>,
        _: &HashSet<NodeIndex>,
    ) -> anyhow::Result<(Tracking, Map<Self::Output>)> {
        if let Some(ref pre_run) = self.pre_run {
            pre_run(context, runtime)?;
        }

        let mut paths = Vec::new();
        for glob_entry in &self.glob_entry {
            for path in glob(glob_entry)? {
                // Handle glob errors immediately here
                paths.push(Utf8PathBuf::try_from(path?)?);
            }
        }

        // we can override the style to have progress
        let style = crate::utils::get_style_task_progress()?;
        context.span.pb_set_style(&style);
        context.span.pb_set_length(paths.len() as u64);

        let results: anyhow::Result<Vec<_>> = paths
            .into_par_iter()
            .map(|path| {
                let hash = Hash32::hash_file(&path)?;
                let file = Input { path, hash };

                let mut rt = runtime.fork();

                // call the user callback
                let (hash, path, res) = (self.callback)(context, &mut rt, file)?;

                // next iteration
                context.span.pb_inc(1);

                Ok((Provenance(hash), path, res, rt.imports, rt.store_paths))
            })
            .collect();

        let mut map = BTreeMap::new();
        for (provenance, path, res, imports, store_paths) in results? {
            map.insert(path.as_str().into(), (res, provenance));
            runtime.imports.merge(imports);
            runtime.store_paths.extend(store_paths);
        }

        Ok((Tracking::default(), Map { map, dirty: false }))
    }

    fn is_dirty(&self, path: &Utf8Path) -> bool {
        self.glob_watch.iter().any(|p| p.matches(path.as_str()))
    }

    fn requirements(&self) -> Vec<crate::preflight::Requirement> {
        self.requirements.clone()
    }
}

/// Generate the functions used to initialize content files. These functions can
/// be used to parse the front matter using engines from crate `gray_matter`.
macro_rules! matter_parser {
	($name:ident, $engine:path) => {
		#[doc = concat!(
			"This function can be used to extract metadata from a document with `D` as the frontmatter shape.\n",
			"Configured to use [`", stringify!($engine), "`] as the engine of the parser."
		)]
		fn $name<D>(content: &str) -> Result<(D, String), anyhow::Error>
		where
			D: for<'de> serde::Deserialize<'de> + Send + Sync + 'static,
		{
		    use gray_matter::{Matter, Pod};

			// We can cache the creation of the parser
			static PARSER: std::sync::LazyLock<Matter<$engine>> = std::sync::LazyLock::new(Matter::<$engine>::new);

			let entity = PARSER.parse(content)?;
            let object = entity
                .data
                .unwrap_or_else(Pod::new_hash)
                .deserialize::<D>()
                .map_err(|e| anyhow::anyhow!("Malformed frontmatter:\n{e}"))?;

			Ok((
				// Just the front matter
				object,
				// The rest of the content
				entity.content,
			))
		}
	};
}

matter_parser!(parse_yaml, YAML);
// matter_parser!(parse_json, JSON);