islands-build 0.1.3

Layout-agnostic build pipeline for islands.rs apps: WASM bundling, the V8 module-namespace patch, per-page CSS, and content-hash manifests. Composed by a thin xtask in any workspace.
Documentation
//! Configurable page discovery (Rec 5): read an `islands.toml` describing the
//! pages, output layout, and runtime feature set, instead of hard-coding any one
//! repository's directory shape.
//!
//! ```toml
//! [islands]
//! out_dir = "static"                 # default "static"
//! bundle_prefix = "counter"          # bundle_key = "<prefix>/<crate>" (omit ⇒ just "<crate>")
//! runtime_package = "islands-runtime"
//! runtime_nav = true
//! pages = ["page-home", "page-about", "page-random"]
//! # Per-page Tailwind @source globs, templated with {prefix} {crate} {short}.
//! # Paths are relative to the generated per-page CSS input (assets/css/page-<short>.css).
//! css_source_templates = [
//!     "../../examples/{prefix}/{crate}/src/**/*.rs",
//!     "../../examples/{prefix}/src/pages/{short}.rs",
//! ]
//! ```
//!
//! The same `[islands]` table also works under `[package.metadata.islands]` in a
//! `Cargo.toml`; pass that file to [`IslandsConfig::from_cargo_metadata`].

use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use serde::Deserialize;

use crate::config::{PageBuild, RuntimeBuild};

fn default_out_dir() -> String {
    "static".to_owned()
}
fn default_runtime_package() -> String {
    "islands-runtime".to_owned()
}
fn default_true() -> bool {
    true
}

/// The `[islands]` configuration table.
#[derive(Debug, Clone, Deserialize)]
pub struct IslandsConfig {
    /// Output root, relative to the workspace root. Default `"static"`.
    #[serde(default = "default_out_dir")]
    pub out_dir: String,
    /// Optional bundle-key prefix. With `"counter"`, `page-home`'s bundle lands
    /// at `static/counter/page-home/` and is keyed `"counter/page-home"`.
    #[serde(default)]
    pub bundle_prefix: Option<String>,
    /// Runtime crate package name. Default `"islands-runtime"`.
    #[serde(default = "default_runtime_package")]
    pub runtime_package: String,
    /// Build the runtime with its `nav` feature. Default `true`.
    #[serde(default = "default_true")]
    pub runtime_nav: bool,
    /// `@source` glob templates (with `{prefix}` `{crate}` `{short}`), shared by
    /// every page. Relative to the generated per-page CSS input file.
    #[serde(default)]
    pub css_source_templates: Vec<String>,
    /// Page crate names (e.g. `["page-home", "page-about"]`).
    #[serde(default)]
    pub pages: Vec<String>,
}

#[derive(Deserialize)]
struct IslandsConfigFile {
    islands: IslandsConfig,
}

#[derive(Deserialize)]
struct CargoMetadataFile {
    package: Option<CargoPackage>,
}
#[derive(Deserialize)]
struct CargoPackage {
    metadata: Option<CargoMetadata>,
}
#[derive(Deserialize)]
struct CargoMetadata {
    islands: Option<IslandsConfig>,
}

impl IslandsConfig {
    /// Read an `islands.toml`. Returns `Ok(None)` if the file does not exist so a
    /// caller can fall back to its own discovery.
    pub fn from_toml_file(path: &Path) -> Result<Option<Self>> {
        if !path.exists() {
            return Ok(None);
        }
        let text =
            std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
        let parsed: IslandsConfigFile =
            toml::from_str(&text).with_context(|| format!("parse [islands] in {}", path.display()))?;
        Ok(Some(parsed.islands))
    }

    /// Read `[package.metadata.islands]` from a `Cargo.toml`. Returns `Ok(None)`
    /// if the file or the table is absent.
    pub fn from_cargo_metadata(cargo_toml: &Path) -> Result<Option<Self>> {
        if !cargo_toml.exists() {
            return Ok(None);
        }
        let text = std::fs::read_to_string(cargo_toml)
            .with_context(|| format!("read {}", cargo_toml.display()))?;
        let parsed: CargoMetadataFile = toml::from_str(&text)
            .with_context(|| format!("parse {}", cargo_toml.display()))?;
        Ok(parsed.package.and_then(|package| package.metadata).and_then(|metadata| metadata.islands))
    }

    /// Resolve the output directory against `workspace_root`.
    pub fn resolve_out_dir(&self, workspace_root: &Path) -> PathBuf {
        workspace_root.join(&self.out_dir)
    }

    /// The runtime build descriptor.
    pub fn runtime(&self) -> RuntimeBuild {
        RuntimeBuild {
            package: self.runtime_package.clone(),
            nav: self.runtime_nav,
        }
    }

    /// Expand the configured pages into [`PageBuild`]s, applying `bundle_prefix`
    /// to each bundle key and the `css_source_templates` to each page.
    pub fn page_builds(&self) -> Vec<PageBuild> {
        let prefix = self.bundle_prefix.as_deref().unwrap_or("");
        self.pages
            .iter()
            .map(|crate_name| {
                let short = crate_name.strip_prefix("page-").unwrap_or(crate_name);
                let bundle_key = if prefix.is_empty() {
                    crate_name.clone()
                } else {
                    format!("{prefix}/{crate_name}")
                };
                let css_sources = self
                    .css_source_templates
                    .iter()
                    .map(|template| {
                        template
                            .replace("{prefix}", prefix)
                            .replace("{crate}", crate_name)
                            .replace("{short}", short)
                    })
                    .collect();
                PageBuild {
                    crate_name: crate_name.clone(),
                    bundle_key,
                    css_sources,
                }
            })
            .collect()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    const SAMPLE: &str = r#"
[islands]
bundle_prefix = "counter"
pages = ["page-home", "page-random"]
css_source_templates = [
    "../../examples/{prefix}/{crate}/src/**/*.rs",
    "../../examples/{prefix}/src/pages/{short}.rs",
]
"#;

    fn parse(text: &str) -> IslandsConfig {
        toml::from_str::<IslandsConfigFile>(text).unwrap().islands
    }

    #[test]
    fn defaults_apply_when_omitted() {
        let config = parse("[islands]\n");
        assert_eq!(config.out_dir, "static");
        assert_eq!(config.runtime_package, "islands-runtime");
        assert!(config.runtime_nav);
        assert!(config.pages.is_empty());
    }

    #[test]
    fn page_builds_apply_prefix_and_templates() {
        let config = parse(SAMPLE);
        let pages = config.page_builds();
        assert_eq!(pages.len(), 2);
        let home = &pages[0];
        assert_eq!(home.crate_name, "page-home");
        assert_eq!(home.bundle_key, "counter/page-home");
        assert_eq!(home.short_name(), "home");
        assert_eq!(
            home.css_sources,
            vec![
                "../../examples/counter/page-home/src/**/*.rs".to_owned(),
                "../../examples/counter/src/pages/home.rs".to_owned(),
            ]
        );
    }

    #[test]
    fn no_prefix_yields_bare_bundle_key() {
        let config = parse("[islands]\npages = [\"page-home\"]\n");
        assert_eq!(config.page_builds()[0].bundle_key, "page-home");
    }
}