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#[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
68pub(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
139const 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_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}