Skip to main content

sofie_cli/
islands.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use serde_json::json;
5use sofie_model::{App, island_bundles};
6
7use crate::cargo::Diagnostics;
8use crate::clients::{absolute, check_toolchain, run_node};
9use crate::gen_command::{GenError, io_error};
10
11/// Shipped inside this crate, so an installed CLI finds it beside its own sources.
12pub(crate) const TOOLCHAIN: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/web");
13
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum IslandMode {
16    Release,
17    Development,
18}
19
20impl IslandMode {
21    pub(crate) fn name(self) -> &'static str {
22        match self {
23            Self::Release => "release",
24            Self::Development => "development",
25        }
26    }
27}
28
29#[must_use]
30pub fn has_islands(app: &App) -> bool {
31    app.modules.iter().any(|module| !module.islands.is_empty())
32}
33
34pub(crate) fn build_islands(
35    app: &App,
36    out: &Path,
37    mode: IslandMode,
38    diagnostics: Diagnostics,
39) -> Result<Vec<String>, GenError> {
40    let bundles = island_bundles(&app.modules);
41    if bundles.is_empty() {
42        return Ok(Vec::new());
43    }
44    check_toolchain()?;
45    fs::create_dir_all(out).map_err(|source| io_error(out, source))?;
46    let plan = out.join("plan.json");
47    let text = plan_json(out, mode, &bundles);
48    fs::write(&plan, text).map_err(|source| io_error(&plan, source))?;
49    run_node("build.mjs", &plan, "the island build", diagnostics)?;
50    Ok(bundles.into_iter().map(|bundle| bundle.area).collect())
51}
52
53#[cfg(feature = "typescript")]
54pub(crate) fn types_file(app: &App) -> (PathBuf, Option<String>) {
55    let text = has_islands(app).then(|| crate::typescript(app));
56    (app.root.join("web").join("gen").join("types.ts"), text)
57}
58
59#[must_use]
60pub fn island_sources(app: &App) -> Vec<PathBuf> {
61    let toolchain = Path::new(TOOLCHAIN);
62    let mut sources: Vec<PathBuf> = ["build.mjs", "shared.mjs", "runtime.js", "package-lock.json"]
63        .into_iter()
64        .map(|file| toolchain.join(file))
65        .collect();
66    sources.extend(
67        app.modules
68            .iter()
69            .flat_map(|module| &module.islands)
70            .map(|island| island.file.clone()),
71    );
72    sources
73}
74
75fn plan_json(out: &Path, mode: IslandMode, bundles: &[sofie_model::IslandBundle<'_>]) -> String {
76    let bundles: Vec<serde_json::Value> = bundles
77        .iter()
78        .map(|bundle| {
79            let islands: Vec<serde_json::Value> = bundle
80                .islands
81                .iter()
82                .map(|island| json!({ "tag": island.name, "file": absolute(&island.file) }))
83                .collect();
84            json!({ "area": bundle.area, "islands": islands })
85        })
86        .collect();
87    json!({ "mode": mode.name(), "out": out.display().to_string(), "bundles": bundles }).to_string()
88}
89
90#[cfg(test)]
91mod tests {
92    use std::path::PathBuf;
93
94    use sofie_model::Island;
95    use sofie_model::testing::{TemporaryDirectory, app, area, module};
96
97    use super::*;
98
99    fn island(name: &str, area: &str) -> Island {
100        Island {
101            name: name.to_owned(),
102            file: PathBuf::from(format!(
103                "/shop/modules/order/src/areas/{area}/islands/{name}.svelte"
104            )),
105            area: Some(area.to_owned()),
106            props: None,
107        }
108    }
109
110    #[test]
111    fn the_plan_names_each_areas_islands() {
112        let mut order = module("order", "/shop/modules/order");
113        order.islands = vec![island("order-chart", "member")];
114        let shop = app("/shop", vec![order]);
115        let bundles = island_bundles(&shop.modules);
116
117        let plan = plan_json(Path::new("/out \"x\""), IslandMode::Release, &bundles);
118
119        assert_eq!(
120            serde_json::from_str::<serde_json::Value>(&plan).unwrap(),
121            json!({
122                "mode": "release",
123                "out": "/out \"x\"",
124                "bundles": [{
125                    "area": "member",
126                    "islands": [{
127                        "tag": "order-chart",
128                        "file": "/shop/modules/order/src/areas/member/islands/order-chart.svelte",
129                    }],
130                }],
131            })
132        );
133        assert!(has_islands(&shop));
134    }
135
136    #[test]
137    fn an_app_without_islands_builds_nothing() {
138        let shop = app("/shop", vec![module("order", "/shop/modules/order")]);
139
140        let built = build_islands(
141            &shop,
142            Path::new("/nonexistent"),
143            IslandMode::Release,
144            Diagnostics::Terminal,
145        )
146        .unwrap();
147
148        assert_eq!(built, Vec::<String>::new());
149        assert!(!has_islands(&shop));
150    }
151
152    const ISLAND: &str = r#"<script lang="ts">
153  let { count }: { count: number } = $props();
154</script>
155
156<p class="chart">{count} orders</p>
157
158<style>
159  .chart { color: rebeccapurple; }
160</style>
161"#;
162
163    #[test]
164    fn each_area_gets_one_bundle_with_its_styles_apart() {
165        let directory = TemporaryDirectory::new("sofie-islands");
166        let file = directory.write("order/src/areas/member/islands/order-chart.svelte", ISLAND);
167        let mut order = module("order", directory.path("order"));
168        order.islands = vec![Island {
169            name: "order-chart".to_owned(),
170            file,
171            area: Some("member".to_owned()),
172            props: None,
173        }];
174        let shop = app(directory.root(), vec![order]);
175        let out = directory.path("out");
176
177        let built = build_islands(&shop, &out, IslandMode::Release, Diagnostics::Terminal).unwrap();
178
179        assert_eq!(built, ["member"]);
180        let script = fs::read_to_string(out.join("member.js")).unwrap();
181        assert!(script.contains("\"order-chart\""), "{script}");
182        assert!(!script.contains("<style"), "{script}");
183        let stylesheet = fs::read_to_string(out.join("member.css")).unwrap();
184        assert!(
185            stylesheet.contains(".chart.svelte-") && stylesheet.contains("color"),
186            "{stylesheet}"
187        );
188        for file in ["member.js", "member.css"] {
189            let gzip = fs::read(out.join(format!("{file}.gz"))).unwrap();
190            assert_eq!(gzip[..2], [0x1f, 0x8b], "{file}.gz is gzip");
191            assert!(fs::metadata(out.join(format!("{file}.br"))).unwrap().len() > 0);
192        }
193    }
194
195    #[test]
196    fn a_broken_island_fails_the_build_naming_its_file() {
197        let directory = TemporaryDirectory::new("sofie-islands");
198        let file = directory.write("order/web/broken-chart.svelte", "<p>{</p>");
199        let mut order = module("order", directory.path("order"));
200        order.islands = vec![Island {
201            name: "broken-chart".to_owned(),
202            file,
203            area: None,
204            props: None,
205        }];
206        order.areas.push(area(&order, "web", Vec::new()));
207        let shop = app(directory.root(), vec![order]);
208
209        let error = build_islands(
210            &shop,
211            &directory.path("out"),
212            IslandMode::Development,
213            Diagnostics::Terminal,
214        )
215        .unwrap_err()
216        .to_string();
217
218        assert!(
219            error.starts_with("the island build failed") && error.contains("broken-chart.svelte"),
220            "{error}"
221        );
222    }
223
224    #[cfg(all(unix, feature = "mcp"))]
225    #[test]
226    fn node_runs_in_a_process_group_of_its_own_under_sofie_mcp() {
227        let directory = TemporaryDirectory::new("sofie-node");
228        let script = directory.write(
229            "group.mjs",
230            "import { execSync } from 'node:child_process';\n\
231             import { writeFileSync } from 'node:fs';\n\
232             const group = execSync(`ps -o pgid= -p ${process.pid}`).toString().trim();\n\
233             writeFileSync(process.argv[2], `${group} ${process.pid}`);\n",
234        );
235        let written = directory.path("group.txt");
236
237        run_node(
238            &script.display().to_string(),
239            &written,
240            "the probe",
241            Diagnostics::Captured,
242        )
243        .unwrap();
244
245        let text = fs::read_to_string(&written).unwrap();
246        let (group, pid) = text.split_once(' ').unwrap();
247        assert_eq!(group, pid);
248    }
249}