crepuscularity-cli 0.7.9

crepus CLI — scaffolding and builds for Crepuscularity (UNSTABLE; in active development).
use crepuscularity_core::ast::Node;
use crepuscularity_core::parser::{parse_component_file, parse_template};
use serde::Serialize;
use std::collections::{BTreeMap, HashMap};
use std::path::{Component, Path, PathBuf};
use std::process::Command;

#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Serialize)]
pub(crate) struct WebIslandRef {
    pub(crate) src: String,
    pub(crate) adapter: String,
}

#[derive(Debug, Serialize)]
struct WebIslandManifest {
    version: u8,
    islands: BTreeMap<String, WebIslandManifestEntry>,
}

#[derive(Debug, Serialize)]
struct WebIslandManifestEntry {
    adapter: String,
    module: String,
}

pub(crate) fn collect_web_islands(
    files: &HashMap<String, String>,
) -> Result<Vec<WebIslandRef>, String> {
    let mut out = BTreeMap::new();
    for content in files.values() {
        let components = parse_component_file(content)?;
        if components.components.is_empty() {
            let nodes = parse_template(content)?;
            collect_nodes(&nodes, &mut out);
        } else {
            for component in components.components.values() {
                collect_nodes(&component.nodes, &mut out);
            }
        }
    }
    Ok(out.into_values().collect())
}

pub(crate) fn build_web_islands(
    site_dir: &Path,
    out_dir: &Path,
    files: &HashMap<String, String>,
) -> Result<(), String> {
    let refs = collect_web_islands(files)?;
    let manifest_path = out_dir.join("crepus-islands.json");
    if refs.is_empty() {
        if manifest_path.is_file() {
            let _ = std::fs::remove_file(&manifest_path);
        }
        return Ok(());
    }

    let island_out_dir = out_dir.join("islands");
    std::fs::create_dir_all(&island_out_dir).map_err(|e| format!("mkdir islands: {e}"))?;

    let mut manifest = WebIslandManifest {
        version: 1,
        islands: BTreeMap::new(),
    };

    for island in refs {
        let entry = resolve_island_entry(site_dir, &island.src)?;
        let file_name = island_output_file_name(&island.src);
        let out_file = island_out_dir.join(&file_name);
        run_bun_build(&entry, &out_file)?;
        manifest.islands.insert(
            island.src.clone(),
            WebIslandManifestEntry {
                adapter: island.adapter,
                module: format!("./islands/{file_name}"),
            },
        );
    }

    let manifest_json =
        serde_json::to_string_pretty(&manifest).map_err(|e| format!("island manifest: {e}"))?;
    std::fs::write(&manifest_path, manifest_json)
        .map_err(|e| format!("write {}: {e}", manifest_path.display()))?;
    Ok(())
}

fn collect_nodes(nodes: &[Node], out: &mut BTreeMap<String, WebIslandRef>) {
    for node in nodes {
        match node {
            Node::Element(el) => collect_nodes(&el.children, out),
            Node::If(block) => {
                collect_nodes(&block.then_children, out);
                if let Some(else_children) = &block.else_children {
                    collect_nodes(else_children, out);
                }
            }
            Node::For(block) => collect_nodes(&block.body, out),
            Node::Match(block) => {
                for arm in &block.arms {
                    collect_nodes(&arm.body, out);
                }
            }
            Node::Include(include) => collect_nodes(&include.slot, out),
            Node::Embed(embed) => {
                let adapter = embed
                    .adapter
                    .clone()
                    .unwrap_or_else(|| "module".to_string());
                out.entry(embed.src.clone())
                    .or_insert_with(|| WebIslandRef {
                        src: embed.src.clone(),
                        adapter,
                    });
            }
            Node::Text(_) | Node::LetDecl(_) | Node::RawText(_) => {}
        }
    }
}

fn resolve_island_entry(site_dir: &Path, src: &str) -> Result<PathBuf, String> {
    let path = Path::new(src);
    if path.is_absolute()
        || path
            .components()
            .any(|component| matches!(component, Component::ParentDir))
    {
        return Err(format!("island path outside site root: {src}"));
    }
    let full = site_dir.join(path);
    if !full.is_file() {
        return Err(format!("island entry not found: {}", full.display()));
    }
    Ok(full)
}

fn island_output_file_name(src: &str) -> String {
    let path = Path::new(src);
    let stem = path
        .file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or("island")
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
                c
            } else {
                '-'
            }
        })
        .collect::<String>();
    format!("{stem}-{:016x}.js", stable_hash(src.as_bytes()))
}

fn stable_hash(bytes: &[u8]) -> u64 {
    let mut hash = 0xcbf29ce484222325u64;
    for byte in bytes {
        hash ^= u64::from(*byte);
        hash = hash.wrapping_mul(0x100000001b3);
    }
    hash
}

fn run_bun_build(entry: &Path, out_file: &Path) -> Result<(), String> {
    let output = Command::new("bun")
        .arg("build")
        .arg("--target")
        .arg("browser")
        .arg("--format")
        .arg("esm")
        .arg("--packages")
        .arg("bundle")
        .arg("--env")
        .arg("disable")
        .arg("--outfile")
        .arg(out_file)
        .arg(entry)
        .output()
        .map_err(|e| format!("bun build: {e}"))?;
    if output.status.success() {
        return Ok(());
    }
    let stderr = String::from_utf8_lossy(&output.stderr);
    Err(format!("bun build {} failed:\n{stderr}", entry.display()))
}

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

    #[test]
    fn collect_web_islands_finds_indent_and_component_islands() {
        let mut files = HashMap::new();
        files.insert(
            "index.crepus".to_string(),
            r#"div
  embed ./islands/a.ts adapter="module" title="A""#
                .to_string(),
        );
        files.insert(
            "ui.crepus".to_string(),
            r#"--- Card
div
  embed ./islands/b.tsx adapter="react" count={n}"#
                .to_string(),
        );

        let islands = collect_web_islands(&files).unwrap();
        assert_eq!(islands.len(), 2);
        assert!(islands.iter().any(|i| i.src == "./islands/a.ts"));
        assert!(islands.iter().any(|i| i.src == "./islands/b.tsx"));
    }

    #[test]
    fn resolve_island_entry_rejects_parent_dir() {
        let err = resolve_island_entry(Path::new("."), "../outside.ts").unwrap_err();
        assert!(err.contains("outside site root"));
    }
}