Skip to main content

sofie_model/
islands.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use syn::ExprMethodCall;
5
6use crate::load::{files_with_extension, subdirectories};
7use crate::naming::file_stem;
8use crate::syntax::string_literal;
9use crate::{AreaContribution, ModelError, Module, Route};
10
11const EXTENSION: &str = "svelte";
12const PROPS_CALL: &str = "$props()";
13
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct Island {
16    pub name: String,
17    pub file: PathBuf,
18    pub area: Option<String>,
19    pub props: Option<PropsDeclaration>,
20}
21
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct PropsDeclaration {
24    pub names: Vec<String>,
25    pub type_name: Option<String>,
26    pub line: usize,
27}
28
29#[derive(Debug)]
30pub struct IslandBundle<'m> {
31    pub area: String,
32    pub islands: Vec<&'m Island>,
33}
34
35/// A module's `web/` islands belong to every area a module routes in.
36#[must_use]
37pub fn island_bundles(modules: &[Module]) -> Vec<IslandBundle<'_>> {
38    let islands: Vec<&Island> = modules.iter().flat_map(|module| &module.islands).collect();
39    let shared = islands.iter().any(|island| island.area.is_none());
40    let mut areas: Vec<&str> = islands
41        .iter()
42        .filter_map(|island| island.area.as_deref())
43        .chain(
44            modules
45                .iter()
46                .flat_map(|module| &module.areas)
47                .filter(|_| shared)
48                .map(|area| area.area.as_str()),
49        )
50        .collect();
51    areas.sort_unstable();
52    areas.dedup();
53    areas
54        .into_iter()
55        .map(|area| {
56            let own = islands
57                .iter()
58                .filter(|island| island.area.as_deref() == Some(area));
59            let everywhere = islands.iter().filter(|island| island.area.is_none());
60            IslandBundle {
61                area: area.to_owned(),
62                islands: own.chain(everywhere).copied().collect(),
63            }
64        })
65        .collect()
66}
67
68/// Keyed by the area's name, which a `#[path]` may set apart from its folder's.
69pub(crate) fn read_islands(
70    directory: &Path,
71    areas: &[AreaContribution],
72) -> Result<Vec<Island>, ModelError> {
73    let mut islands = Vec::new();
74    for area_dir in subdirectories(&directory.join("src").join("areas"))? {
75        let contribution = areas
76            .iter()
77            .find(|area| area.routes_file.parent() == Some(area_dir.as_path()));
78        let area = contribution
79            .map(|area| area.area.as_str())
80            .or_else(|| area_dir.file_name().and_then(|name| name.to_str()));
81        if let Some(area) = area {
82            islands.extend(islands_in(&area_dir.join("islands"), Some(area))?);
83        }
84    }
85    islands.extend(islands_in(&directory.join("web"), None)?);
86    Ok(islands)
87}
88
89fn islands_in(directory: &Path, area: Option<&str>) -> Result<Vec<Island>, ModelError> {
90    files_with_extension(directory, EXTENSION)?
91        .into_iter()
92        .map(|file| {
93            let source = fs::read_to_string(&file).map_err(|error| {
94                ModelError::in_file(&file, format!("cannot read the island: {error}"))
95            })?;
96            Ok(Island {
97                name: file_stem(&file),
98                area: area.map(str::to_owned),
99                props: props_declaration(&source),
100                file,
101            })
102        })
103        .collect()
104}
105
106fn props_declaration(source: &str) -> Option<PropsDeclaration> {
107    let call = source.find(PROPS_CALL)?;
108    let before = &source[..call];
109    let declaration = before.rfind("let ")?;
110    let written = &before[declaration + "let ".len()..];
111    let open = written.find('{')?;
112    let close = written.rfind('}')?;
113    let names = written
114        .get(open + 1..close)?
115        .split(',')
116        .filter_map(prop_name)
117        .collect();
118    let type_name = written[close + 1..]
119        .trim()
120        .strip_prefix(':')
121        .and_then(|annotation| annotation.trim().strip_suffix('='))
122        .map(|annotation| annotation.trim().to_owned());
123    Some(PropsDeclaration {
124        names,
125        type_name,
126        line: source[..declaration].matches('\n').count() + 1,
127    })
128}
129
130fn prop_name(binding: &str) -> Option<String> {
131    let binding = binding.trim();
132    if binding.is_empty() || binding.starts_with("...") {
133        return None;
134    }
135    let name = binding.split([':', '=']).next()?.trim();
136    Some(name.to_owned())
137}
138
139/// Names the HTML standard reserves although they contain a hyphen.
140const RESERVED_NAMES: [&str; 8] = [
141    "annotation-xml",
142    "color-profile",
143    "font-face",
144    "font-face-src",
145    "font-face-uri",
146    "font-face-format",
147    "font-face-name",
148    "missing-glyph",
149];
150
151/// Must match sofie-engine's `is_island_name`, which refuses the island at boot;
152/// sofie-check's tests compare the two.
153#[must_use]
154pub fn is_island_name(name: &str) -> bool {
155    name.starts_with(|first: char| first.is_ascii_lowercase())
156        && name.contains('-')
157        && name
158            .bytes()
159            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
160        && !RESERVED_NAMES.contains(&name)
161}
162
163pub(crate) fn island_of(call: &ExprMethodCall, line: usize) -> Result<Route, String> {
164    let shape = || {
165        "write the island as `.island(\"/path\", \"name\", loader)`: a literal path, the literal \
166         name of `islands/<name>.svelte` beside this file and a loader, nothing else"
167            .to_owned()
168    };
169    if call.turbofish.is_some() || call.args.len() != 3 {
170        return Err(shape());
171    }
172    let name = string_literal(&call.args[1])
173        .filter(|name| is_island_name(name))
174        .ok_or_else(|| {
175            "the island name must be a string literal in lowercase with a hyphen, such as \
176             `order-chart`, naming `islands/<name>.svelte` beside this file"
177                .to_owned()
178        })?;
179    let mut route = crate::routes::page_route(call, line)?;
180    route.island = Some(name);
181    Ok(route)
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn reads_the_props_an_island_destructures_and_their_type() {
190        let source = "<script lang=\"ts\">\n  import type { OrderSummary } from \"./props\";\n  \
191                      let { total, count: orders = 0, ...rest }: OrderSummary = $props();\n</script>\n";
192
193        let declaration = props_declaration(source).unwrap();
194
195        assert_eq!(declaration.names, ["total", "count"]);
196        assert_eq!(declaration.type_name.as_deref(), Some("OrderSummary"));
197        assert_eq!(declaration.line, 3);
198    }
199
200    #[test]
201    fn an_island_without_props_or_type_says_so() {
202        assert_eq!(props_declaration("<p>static</p>"), None);
203        let untyped = props_declaration("<script>let { a } = $props();</script>").unwrap();
204        assert_eq!(untyped.names, ["a"]);
205        assert_eq!(untyped.type_name, None);
206    }
207
208    fn island(name: &str, area: Option<&str>) -> Island {
209        Island {
210            name: name.to_owned(),
211            file: PathBuf::from(format!("{name}.svelte")),
212            area: area.map(str::to_owned),
213            props: None,
214        }
215    }
216
217    #[test]
218    fn a_web_island_is_in_every_areas_bundle() {
219        let mut order = crate::testing::module("order", "modules/order");
220        order.islands = vec![
221            island("order-chart", Some("member")),
222            island("price-tag", None),
223        ];
224        let console = crate::testing::area(&order, "console", Vec::new());
225        order.areas.push(console);
226        let modules = [order];
227
228        let bundles = island_bundles(&modules);
229
230        let shown: Vec<(&str, Vec<&str>)> = bundles
231            .iter()
232            .map(|bundle| {
233                let names = bundle
234                    .islands
235                    .iter()
236                    .map(|island| island.name.as_str())
237                    .collect();
238                (bundle.area.as_str(), names)
239            })
240            .collect();
241        assert_eq!(
242            shown,
243            [
244                ("console", vec!["price-tag"]),
245                ("member", vec!["order-chart", "price-tag"]),
246            ]
247        );
248    }
249
250    #[test]
251    fn island_names_are_custom_element_names() {
252        assert!(is_island_name("order-chart"));
253        assert!(!is_island_name("chart"));
254        assert!(!is_island_name("Order-chart"));
255        assert!(!is_island_name("order_chart"));
256        assert!(!is_island_name("font-face"));
257    }
258}