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
11pub(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}