makeover-build 0.3.1

Build-script support for the make-family design system: materialise makeover's themes and makeover-webview's stylesheet into a Tauri app's frontend, once, instead of copying the same twenty lines into every consumer's build.rs.
Documentation
//! Build-script support for the make-family design system.
//!
//! <!-- wiki: makeover-geometry -->
//!
//! Every consumer materialises the same generated files from a `build.rs`, and
//! until now every consumer wrote that code itself. GoingsOn and Balanced
//! Breakfast grew byte-identical copies of the theme materialiser during the
//! makeover-geometry adoption, and the layout stylesheet would have been the
//! third and fourth copies. This is that code, once.
//!
//! # What is deliberately not here
//!
//! The geometry emitter. GoingsOn and Balanced Breakfast do *not* agree on it:
//! GO scopes the touch preset to a `ui-mode-mobile` class set by a bootstrap
//! script, BB hangs it off `@media (hover: none)`, and audiofiles has no
//! switch at all. Extracting it would mean picking one of those policies by
//! accident, inside a shared crate, without anyone deciding. Density selection
//! is unowned and has its own task; the geometry half lands after it.
//!
//! Taking the identical half now and leaving the contested half alone is the
//! whole point: a helper crate should record agreement, not manufacture it.
//!
//! # Why these files are generated rather than checked in
//!
//! Tauri's resource globs are read by its CLI against the crate directory, so
//! they cannot point into a registry checkout or `OUT_DIR`. Materialising into
//! the crate keeps the source crate authoritative without vendoring a second
//! copy that drifts. Every path written here is expected to be gitignored.

#![forbid(unsafe_code)]

use std::path::Path;

/// Re-exported so a consumer's `build.rs` needs one dependency rather than
/// three. Nothing here wraps it; the emitter's options are the emitter's.
pub use makeover_webview::Emit;

/// Write the themes `makeover` ships into `dir`, as `<id>.toml`.
///
/// Clears stale `.toml` files first, so a theme removed or renamed upstream
/// does not linger in the bundle from an earlier build. That detail is the
/// reason this is worth sharing rather than retyping: it is easy to omit and
/// its absence shows up as a theme that will not go away.
///
/// # Panics
///
/// If the directory cannot be created, read, or written. A build script has
/// nowhere useful to return an error to, and a half-materialised theme set is
/// worse than a failed build.
pub fn themes(dir: impl AsRef<Path>) {
    let dir = dir.as_ref();
    std::fs::create_dir_all(dir).expect("create themes dir");

    for entry in std::fs::read_dir(dir).expect("read themes dir").flatten() {
        let path = entry.path();
        if path.extension().is_some_and(|e| e == "toml") {
            std::fs::remove_file(&path).expect("remove stale theme");
        }
    }

    for (id, source) in makeover::embedded_themes() {
        std::fs::write(dir.join(format!("{id}.toml")), source).expect("write theme");
    }
}

/// Write `makeover-webview`'s component stylesheet to `path`.
///
/// Baked at build time rather than applied from JS the way the intent layer
/// is, because composition never changes at runtime: no theme may reach it, so
/// there is nothing to re-apply and no second pass over `:root` to pay for on
/// load.
///
/// # Panics
///
/// If the file cannot be written.
pub fn layout_css(path: impl AsRef<Path>, opts: &makeover_webview::Emit) {
    std::fs::write(path, makeover_webview::stylesheet(opts)).expect("write layout css");
}

/// Write `makeover-geometry`'s spacing layer, with its canonical density
/// selection, to `path`.
///
/// The policy is the crate's, not this one's: touch hangs off
/// `(hover: none), (pointer: coarse)` because density is a capability rather
/// than a device or a width, and `explicit_touch` names a selector an app sets
/// when the user has chosen. See [`makeover_geometry::density_css`]. All this
/// adds is the generated-file banner and the write.
///
/// # Panics
///
/// If the file cannot be written.
pub fn geometry_css(path: impl AsRef<Path>, explicit_touch: Option<&str>) {
    let mut css = String::from(
        "/* Generated by makeover-build from makeover-geometry. Do not edit.\n   \
         Spacing is named by relationship, not by size. Touch density is a\n   \
         capability question: a narrow desktop window still has a pointer, a\n   \
         full-width tablet still has a finger. */\n",
    );
    css.push_str(&makeover_geometry::density_css(explicit_touch));
    std::fs::write(path, css).expect("write geometry css");
}

/// All three generated files at the layout every Tauri consumer already uses:
/// `themes/` beside the manifest, and `frontend/css/{geometry,layout}.css`
/// under it.
///
/// Pass `env!("CARGO_MANIFEST_DIR")`. Consumers that want different paths call
/// [`themes`] and [`layout_css`] directly.
///
/// # Panics
///
/// If either file cannot be written.
pub fn tauri_frontend(
    manifest_dir: impl AsRef<Path>,
    opts: &makeover_webview::Emit,
    explicit_touch: Option<&str>,
) {
    let root = manifest_dir.as_ref();
    let css = root.join("frontend").join("css");
    themes(root.join("themes"));
    geometry_css(css.join("geometry.css"), explicit_touch);
    layout_css(css.join("layout.css"), opts);
}

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

    /// A scratch directory keyed by process id, so a parallel test run does
    /// not collide. No timestamp: the pid is enough and is deterministic
    /// within a run.
    fn scratch(name: &str) -> std::path::PathBuf {
        let dir =
            std::env::temp_dir().join(format!("makeover-build-{}-{name}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).expect("create scratch");
        dir
    }

    #[test]
    fn themes_are_written_one_file_per_id() {
        let dir = scratch("themes");
        themes(&dir);
        let count = std::fs::read_dir(&dir).unwrap().count();
        assert_eq!(count, makeover::embedded_themes().count());
        assert!(count > 0, "makeover ships no themes?");
    }

    #[test]
    fn a_theme_removed_upstream_does_not_linger() {
        // The detail that makes this worth sharing rather than retyping.
        let dir = scratch("stale");
        std::fs::write(dir.join("gone-upstream.toml"), "# stale").unwrap();
        themes(&dir);
        assert!(!dir.join("gone-upstream.toml").exists());
    }

    #[test]
    fn a_non_theme_file_is_left_alone() {
        // Only .toml is cleared, so a README or a .gitignore in the bundle
        // directory survives a rebuild.
        let dir = scratch("keep");
        std::fs::write(dir.join("README.md"), "not a theme").unwrap();
        themes(&dir);
        assert!(dir.join("README.md").exists());
    }

    #[test]
    fn the_stylesheet_lands_and_names_no_colour() {
        let dir = scratch("css");
        let path = dir.join("layout.css");
        layout_css(&path, &makeover_webview::Emit::default());
        let css = std::fs::read_to_string(&path).unwrap();
        assert!(css.contains("--bevel-raised"));
        assert!(
            !css.contains('#'),
            "a colour literal reached a build output"
        );
    }

    #[test]
    fn the_geometry_file_carries_the_crates_policy_and_a_banner() {
        // The policy itself is tested in makeover-geometry. What is this
        // crate's job is that the banner is there and the policy reached the
        // file at all.
        let dir = scratch("geometry");
        let path = dir.join("geometry.css");
        geometry_css(&path, Some(".ui-mode-mobile"));
        let css = std::fs::read_to_string(&path).unwrap();
        assert!(css.starts_with("/* Generated by makeover-build"));
        assert!(css.contains("@media (hover: none), (pointer: coarse)"));
        assert!(css.contains(".ui-mode-mobile"));
    }

    #[test]
    fn the_tauri_layout_puts_all_three_where_the_apps_look() {
        let root = scratch("tauri");
        std::fs::create_dir_all(root.join("frontend").join("css")).unwrap();
        tauri_frontend(&root, &makeover_webview::Emit::default(), None);
        assert!(
            root.join("frontend")
                .join("css")
                .join("geometry.css")
                .exists()
        );
        assert!(
            root.join("frontend")
                .join("css")
                .join("layout.css")
                .exists()
        );
        assert!(root.join("themes").is_dir());
    }
}