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
//! CSS build: materialize the (optional) basecoat layer, build the shared
//! `base.css`, and generate + compile a per-page bundle for each page.
//!
//! PostCSS is driven via `npx postcss` from the workspace root, so the caller's
//! `postcss.config.*` (wiring `@tailwindcss/postcss`) and `node_modules` apply.

use std::fs;
use std::io::Write as _;
use std::path::Path;
use std::process::Command;

use anyhow::{bail, Context, Result};
use rayon::prelude::*;

use crate::config::{BuildPlan, CssConfig, PageBuild};

/// Ensure `node_modules/` exists at `workspace_root`, running `npm install` if
/// absent. The PostCSS pipeline resolves `@tailwindcss/postcss` + `postcss-cli`
/// through npm.
pub fn ensure_node_modules(workspace_root: &Path) -> Result<()> {
    if workspace_root.join("node_modules").exists() {
        return Ok(());
    }
    eprintln!("[islands-build] node_modules missing; running `npm install`");
    let status = Command::new("npm")
        .arg("install")
        .current_dir(workspace_root)
        .status()
        .context("spawn npm install")?;
    if !status.success() {
        bail!("npm install failed (exit {:?})", status.code());
    }
    Ok(())
}

/// Run `npx postcss <input> -o <output> --no-map` from `workspace_root`.
fn run_postcss(workspace_root: &Path, input: &Path, output: &Path) -> Result<()> {
    let input_arg = input.strip_prefix(workspace_root).unwrap_or(input);
    let output_arg = output.strip_prefix(workspace_root).unwrap_or(output);
    let result = Command::new("npx")
        .arg("postcss")
        .arg(input_arg)
        .arg("-o")
        .arg(output_arg)
        .arg("--no-map")
        .current_dir(workspace_root)
        .output()
        .with_context(|| format!("spawn npx postcss for {}", input.display()))?;
    if !result.status.success() {
        bail!(
            "postcss failed (exit {:?}) for {}:\nstderr:\n{}\nstdout:\n{}",
            result.status.code(),
            input.display(),
            String::from_utf8_lossy(&result.stderr),
            String::from_utf8_lossy(&result.stdout)
        );
    }
    Ok(())
}

/// Materialize `basecoat.css` from the `basecoat-css` crate (full when `enabled`,
/// an empty stub otherwise). The stub keeps a `@import "./basecoat.css";` in a
/// checked-in `base.css` resolving even when basecoat is off, so a feature flip
/// never leaves the file stale.
///
/// Requires the `islands-build` crate's `basecoat` feature.
#[cfg(feature = "basecoat")]
pub fn materialize_basecoat_css(target: &Path, enabled: bool) -> Result<()> {
    if let Some(parent) = target.parent() {
        fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
    }
    if enabled {
        basecoat_css::write_to(target)
            .with_context(|| format!("write basecoat-css source to {}", target.display()))?;
    } else {
        fs::write(target, "/* basecoat feature disabled */\n")
            .with_context(|| format!("write empty basecoat.css stub to {}", target.display()))?;
    }
    Ok(())
}

/// Build the shared `base.css` bundle.
fn build_base(workspace_root: &Path, out_dir: &Path, css: &CssConfig) -> Result<()> {
    let output_dir = out_dir.join("css");
    fs::create_dir_all(&output_dir).with_context(|| format!("create {}", output_dir.display()))?;
    let output = output_dir.join("base.css");
    eprintln!("[islands-build] building base.css → {}", output.display());
    run_postcss(workspace_root, &css.base_css, &output)
}

/// Write the per-page CSS input at `<assets_css_dir>/page-<short>.css`.
///
/// The input carries the `@theme reference` token block, the unified
/// `@import "tailwindcss";` (so the utility extractor runs), a re-applied
/// border-color override (Tailwind's preflight resets it per bundle), and one
/// `@source` line per entry in [`PageBuild::css_sources`].
fn generate_page_input(css: &CssConfig, page: &PageBuild) -> Result<()> {
    fs::create_dir_all(&css.assets_css_dir)
        .with_context(|| format!("create {}", css.assets_css_dir.display()))?;
    let output_path = css.assets_css_dir.join(format!("page-{}.css", page.short_name()));

    let mut content = String::new();
    content.push_str(&css.theme_reference);
    content.push_str("\n\n@import \"tailwindcss\";\n\n");
    // Tailwind v4's per-bundle preflight resets border-color to currentColor;
    // re-apply the token so card/input borders render as --color-border.
    content.push_str(
        "@layer base {\n  *, ::before, ::after, ::backdrop { border-color: var(--color-border); }\n}\n\n",
    );
    for source in &page.css_sources {
        content.push_str(&format!("@source \"{source}\";\n"));
    }

    let mut file =
        fs::File::create(&output_path).with_context(|| format!("create {}", output_path.display()))?;
    file.write_all(content.as_bytes())
        .with_context(|| format!("write {}", output_path.display()))?;
    Ok(())
}

/// Generate + compile one page's CSS bundle into `<out_dir>/<bundle_key>/<underscore>.css`.
fn build_page(workspace_root: &Path, out_dir: &Path, css: &CssConfig, page: &PageBuild) -> Result<()> {
    generate_page_input(css, page)?;
    let input = css.assets_css_dir.join(format!("page-{}.css", page.short_name()));
    let output_dir = out_dir.join(&page.bundle_key);
    fs::create_dir_all(&output_dir).with_context(|| format!("create {}", output_dir.display()))?;
    let output = output_dir.join(format!("{}.css", page.underscore_name()));
    eprintln!(
        "[islands-build] building page CSS {}{}",
        page.bundle_key,
        output.display()
    );
    run_postcss(workspace_root, &input, &output)
}

/// Build the shared `base.css` bundle (materializing `basecoat.css` first when a
/// CSS config is present). A `None` `plan.css` is a no-op.
pub fn build_base_css(plan: &BuildPlan) -> Result<()> {
    let Some(css) = &plan.css else {
        return Ok(());
    };
    ensure_node_modules(&plan.workspace_root)?;

    // basecoat.css is always (re)written when a `basecoat` build is requested, so
    // a checked-in `@import "./basecoat.css";` resolves and a feature flip never
    // leaves the file stale. Requires islands-build's `basecoat` feature.
    #[cfg(feature = "basecoat")]
    materialize_basecoat_css(&css.assets_css_dir.join("basecoat.css"), css.basecoat)?;
    #[cfg(not(feature = "basecoat"))]
    if css.basecoat {
        bail!(
            "CssConfig.basecoat=true but islands-build was compiled without its `basecoat` feature"
        );
    }

    build_base(&plan.workspace_root, &plan.out_dir, css)
}

/// Generate + compile one page's CSS bundle. A `None` `plan.css` is a no-op.
pub fn build_page_css(plan: &BuildPlan, page: &PageBuild) -> Result<()> {
    let Some(css) = &plan.css else {
        return Ok(());
    };
    ensure_node_modules(&plan.workspace_root)?;
    build_page(&plan.workspace_root, &plan.out_dir, css, page)
}

/// Build all CSS for a plan: base + every page bundle (pages in parallel).
/// A `None` `plan.css` skips CSS entirely.
pub fn build_css(plan: &BuildPlan) -> Result<()> {
    if plan.css.is_none() {
        return Ok(());
    }
    build_base_css(plan)?;
    plan.pages
        .par_iter()
        .try_for_each(|page| build_page_css(plan, page))?;
    eprintln!("[islands-build] build-css complete");
    Ok(())
}