@ape-egg/vibe-compiler 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,612 @@
1
+ // SPA mode (fetched): compiles the MPA pages tree into page-component
2
+ // fragments under /components/vibe-spa/, a generated route table, and a
3
+ // composed index.html shell that boots @ape-egg/vibe/spa with a reactive
4
+ // `<component src="@[page.src]">` outlet. The composed shell is plain tier-2
5
+ // runtime-Vibe code — the invariant that compiled output is always runtime
6
+ // Vibe code holds.
7
+ //
8
+ // Everything is per-page on purpose: future per-page SPA selection becomes a
9
+ // filter over pages, not a rework.
10
+
11
+ use regex::Regex;
12
+
13
+ /// One page of the pages tree, dissected for SPA output.
14
+ pub struct SpaPage {
15
+ /// Pages-relative source path, e.g. "brawlers/$index.html"
16
+ pub rel_path: String,
17
+ /// Route template, e.g. "/brawlers/:index"
18
+ pub route: String,
19
+ /// Fragment URL, e.g. "/components/vibe-spa/brawlers/$index.html"
20
+ pub src: String,
21
+ /// Harvested <title> text
22
+ pub title: Option<String>,
23
+ /// Fragment markup: page styles + rewritten page scripts + body content
24
+ pub fragment: String,
25
+ /// Head resources for shell composition (styles/scripts/title excluded)
26
+ pub head_items: Vec<HeadItem>,
27
+ /// Body attributes in authored order: (name, Some(value) | None)
28
+ pub body_attrs: Vec<(String, Option<String>)>,
29
+ /// The page's vibe import specifier, pre-rewrite (shell import harvest)
30
+ pub vibe_import: Option<String>,
31
+ /// First side-effect API found without an `$.on('unmount'` teardown
32
+ pub hygiene_offender: Option<&'static str>,
33
+ }
34
+
35
+ #[derive(Clone, PartialEq)]
36
+ pub enum HeadKind {
37
+ Meta,
38
+ Other,
39
+ }
40
+
41
+ #[derive(Clone)]
42
+ pub struct HeadItem {
43
+ pub kind: HeadKind,
44
+ /// Whitespace-collapsed form — both the dedup key and the emitted markup
45
+ pub normalized: String,
46
+ }
47
+
48
+ pub struct ShellComposition {
49
+ pub html: String,
50
+ /// Verbose-mode notes: dropped page-specific meta, body attr divergence
51
+ pub notes: Vec<String>,
52
+ }
53
+
54
+ /// Map a pages-relative file path to its route template. Grammar shared with
55
+ /// the runtime route scanners: `$param` captures one segment, a terminal
56
+ /// `$$name` file is an optional catch-all (`:name*`), a literal terminal
57
+ /// `index` serves its directory path.
58
+ pub fn route_for(rel_path: &str) -> String {
59
+ let no_ext = rel_path.strip_suffix(".html").unwrap_or(rel_path);
60
+ // The 404 convention: a `404.html` in the pages tree compiles to the
61
+ // declared '*' fallback — no-match deep links mount it instead of a
62
+ // permanently blank outlet. The runtime resolves it with
63
+ // name: 'not-found' and it never claims a click.
64
+ if no_ext == "404" || no_ext.ends_with("/404") {
65
+ return "*".to_string();
66
+ }
67
+ let mut segments: Vec<&str> = no_ext.split('/').filter(|s| !s.is_empty()).collect();
68
+ if segments.last() == Some(&"index") {
69
+ segments.pop();
70
+ }
71
+ let catch_all = segments
72
+ .last()
73
+ .map_or(false, |s| s.starts_with("$$"));
74
+ let tail = if catch_all { segments.pop() } else { None };
75
+ let mut parts: Vec<String> = segments
76
+ .iter()
77
+ .map(|s| match s.strip_prefix('$') {
78
+ Some(param) => format!(":{}", param),
79
+ None => s.to_string(),
80
+ })
81
+ .collect();
82
+ if let Some(tail) = tail {
83
+ parts.push(format!(":{}*", &tail[2..]));
84
+ }
85
+ format!("/{}", parts.join("/"))
86
+ }
87
+
88
+ /// Route-table order, most-specific-first for a first-match-wins resolver:
89
+ /// non-catch-alls before catch-alls, deeper before shallower, statics before
90
+ /// params, then lexicographic for determinism. (game-stack's scanRoutes sorts
91
+ /// deepest-first over dynamic routes only; a full table additionally needs
92
+ /// the exact-beats-param and everything-beats-catch-all rules.) Returns
93
+ /// indices so `pages` keeps its stable path order for shell composition.
94
+ pub fn route_order(pages: &[SpaPage]) -> Vec<usize> {
95
+ let depth = |route: &str| route.split('/').filter(|s| !s.is_empty()).count();
96
+ let params = |route: &str| route.split('/').filter(|s| s.starts_with(':')).count();
97
+ let mut order: Vec<usize> = (0..pages.len()).collect();
98
+ order.sort_by(|&x, &y| {
99
+ let a = &pages[x].route;
100
+ let b = &pages[y].route;
101
+ a.ends_with('*')
102
+ .cmp(&b.ends_with('*'))
103
+ .then(depth(b).cmp(&depth(a)))
104
+ .then(params(a).cmp(&params(b)))
105
+ .then(a.cmp(b))
106
+ });
107
+ order
108
+ }
109
+
110
+ /// The specifier a page uses to import vibe — the shell reuses the project's
111
+ /// own working import style instead of inventing one. Fragment scripts are
112
+ /// carried byte-identical: vibe() itself applies defaults semantics once
113
+ /// booted (state is app-lifetime; re-mounts seed missing keys only), so no
114
+ /// import rewrite is needed.
115
+ pub fn harvest_vibe_import(script: &str) -> Option<String> {
116
+ Regex::new(r#"from\s*['"]([^'"]*@ape-egg/vibe(?:/index\.js)?)['"]"#)
117
+ .unwrap()
118
+ .captures(script)
119
+ .map(|caps| caps[1].to_string())
120
+ }
121
+
122
+ /// Derive the @ape-egg/vibe/spa specifier from the harvested vibe specifier.
123
+ pub fn spa_import_for(vibe_spec: &str) -> String {
124
+ match vibe_spec.strip_suffix("/index.js") {
125
+ Some(base) => format!("{}/spa.js", base),
126
+ None => format!("{}/spa", vibe_spec),
127
+ }
128
+ }
129
+
130
+ /// Grep-level unmount hygiene (a teaching aid, not a guarantee): a page
131
+ /// script that starts side effects and never references `$.on('unmount'`
132
+ /// will leak them across SPA navigations.
133
+ pub fn hygiene_offender(script: &str) -> Option<&'static str> {
134
+ if script.contains("$.on('unmount'") || script.contains("$.on(\"unmount\"") {
135
+ return None;
136
+ }
137
+ ["setInterval", "setTimeout", "addEventListener", "new WebSocket"]
138
+ .iter()
139
+ .find(|api| script.contains(*api))
140
+ .copied()
141
+ }
142
+
143
+ fn collapse_whitespace(html: &str) -> String {
144
+ let mut out = String::with_capacity(html.len());
145
+ let mut in_space = false;
146
+ for ch in html.trim().chars() {
147
+ if ch.is_whitespace() {
148
+ if !in_space {
149
+ out.push(' ');
150
+ in_space = true;
151
+ }
152
+ } else {
153
+ out.push(ch);
154
+ in_space = false;
155
+ }
156
+ }
157
+ out
158
+ }
159
+
160
+ /// Dissect a compiled page into its SPA parts. `html` is the page AFTER the
161
+ /// normal compile transforms (component handling, element transform) so the
162
+ /// fragment is exactly what the MPA output would have carried.
163
+ pub fn dissect_page(rel_path: &str, html: &str) -> Result<SpaPage, String> {
164
+ let head_re = Regex::new(r"(?is)<head[^>]*>(.*?)</head>").unwrap();
165
+ let body_re = Regex::new(r"(?is)<body([^>]*)>(.*)</body>").unwrap();
166
+
167
+ let head = head_re
168
+ .captures(html)
169
+ .map(|caps| caps[1].to_string())
170
+ .unwrap_or_default();
171
+ let body_caps = body_re
172
+ .captures(html)
173
+ .ok_or_else(|| format!("pages/{} has no <body> — SPA pages need one", rel_path))?;
174
+ let body_attrs_raw = body_caps[1].to_string();
175
+ let body_inner = body_caps[2].trim_matches('\n').to_string();
176
+
177
+ // Comments never travel to the shell head.
178
+ let head = Regex::new(r"(?s)<!--.*?-->").unwrap().replace_all(&head, "");
179
+
180
+ let style_re = Regex::new(r"(?is)<style[^>]*>.*?</style>").unwrap();
181
+ let styles: Vec<String> = style_re
182
+ .find_iter(&head)
183
+ .map(|m| m.as_str().trim().to_string())
184
+ .collect();
185
+ let head = style_re.replace_all(&head, "");
186
+
187
+ let script_re = Regex::new(r#"(?is)<script[^>]*type=["']module["'][^>]*>.*?</script>"#).unwrap();
188
+ let scripts: Vec<String> = script_re
189
+ .find_iter(&head)
190
+ .map(|m| m.as_str().trim().to_string())
191
+ .collect();
192
+ let head = script_re.replace_all(&head, "");
193
+
194
+ let title_re = Regex::new(r"(?is)<title[^>]*>(.*?)</title>").unwrap();
195
+ let title = title_re
196
+ .captures(&head)
197
+ .map(|caps| collapse_whitespace(&caps[1]))
198
+ .filter(|t| !t.is_empty());
199
+ let head = title_re.replace_all(&head, "");
200
+
201
+ let item_re = Regex::new(
202
+ r"(?is)<(?:meta|link|base)\b[^>]*/?>|<script\b[^>]*>.*?</script>|<noscript\b[^>]*>.*?</noscript>",
203
+ )
204
+ .unwrap();
205
+ let head_items: Vec<HeadItem> = item_re
206
+ .find_iter(&head)
207
+ .map(|m| {
208
+ let normalized = collapse_whitespace(m.as_str());
209
+ let kind = if normalized.to_lowercase().starts_with("<meta") {
210
+ HeadKind::Meta
211
+ } else {
212
+ HeadKind::Other
213
+ };
214
+ HeadItem { kind, normalized }
215
+ })
216
+ .collect();
217
+
218
+ let attr_re = Regex::new(r#"([^\s"'=]+)(?:="([^"]*)")?"#).unwrap();
219
+ let body_attrs: Vec<(String, Option<String>)> = attr_re
220
+ .captures_iter(&body_attrs_raw)
221
+ .map(|caps| (caps[1].to_string(), caps.get(2).map(|v| v.as_str().to_string())))
222
+ .collect();
223
+
224
+ let vibe_import = scripts.iter().find_map(|s| harvest_vibe_import(s));
225
+ let offender = scripts.iter().find_map(|s| hygiene_offender(s));
226
+
227
+ let mut fragment = String::new();
228
+ for style in &styles {
229
+ fragment.push_str(style);
230
+ fragment.push('\n');
231
+ }
232
+ if !styles.is_empty() && !scripts.is_empty() {
233
+ fragment.push('\n');
234
+ }
235
+ for script in &scripts {
236
+ fragment.push_str(script);
237
+ fragment.push('\n');
238
+ }
239
+ if !(styles.is_empty() && scripts.is_empty()) {
240
+ fragment.push('\n');
241
+ }
242
+ fragment.push_str(&body_inner);
243
+ if !fragment.ends_with('\n') {
244
+ fragment.push('\n');
245
+ }
246
+
247
+ Ok(SpaPage {
248
+ rel_path: rel_path.to_string(),
249
+ route: route_for(rel_path),
250
+ src: format!("/components/vibe-spa/{}", rel_path),
251
+ title,
252
+ fragment,
253
+ head_items,
254
+ body_attrs,
255
+ vibe_import,
256
+ hygiene_offender: offender,
257
+ })
258
+ }
259
+
260
+ /// Scan a page body for the hoistable-layout shape: ignoring whitespace,
261
+ /// comments and top-level <script> blocks, the body must be EXACTLY ONE
262
+ /// `<component src="…">` element. Returns (open_tag, src, inner_html).
263
+ /// In-body scripts stay page code (they ride the fragment prelude), so they
264
+ /// don't break the shape.
265
+ pub fn detect_layout_root(raw_source: &str) -> Option<(String, String, String)> {
266
+ let body_re = Regex::new(r"(?is)<body[^>]*>(.*)</body>").unwrap();
267
+ let body_caps = body_re.captures(raw_source)?;
268
+ let body_match = body_caps.get(1)?;
269
+ let body_inner = body_match.as_str();
270
+
271
+ let comment_re = Regex::new(r"(?s)<!--.*?-->").unwrap();
272
+ let cleaned = comment_re.replace_all(body_inner, "");
273
+ let script_re = Regex::new(r"(?is)<script\b[^>]*>.*?</script>").unwrap();
274
+ // Top-level <style> blocks are page assets that ride the fragment, same
275
+ // as scripts — the page-styles-at-body-bottom convention must not break
276
+ // the "layout + slot content" shape.
277
+ let page_style_re = Regex::new(r"(?is)<style\b[^>]*>.*?</style>").unwrap();
278
+ let cleaned = script_re.replace_all(&cleaned, "");
279
+ let cleaned = page_style_re.replace_all(&cleaned, "");
280
+ let trimmed = cleaned.trim();
281
+
282
+ let open_re = Regex::new(r#"(?is)^<component\b((?:"[^"]*"|'[^']*'|[^>"'])*)>"#).unwrap();
283
+ let caps = open_re.captures(trimmed)?;
284
+ let open_tag = caps.get(0).unwrap().as_str().to_string();
285
+ let attrs = caps.get(1).unwrap().as_str();
286
+ let src_re = Regex::new(r#"(?i)src\s*=\s*"([^"]+)""#).unwrap();
287
+ let src = src_re.captures(attrs)?.get(1).unwrap().as_str().to_string();
288
+
289
+ // Find the matching </component> by depth over a MASKED copy of the body:
290
+ // comments, scripts and styles are space-filled at identical byte offsets,
291
+ // so a textual '<component' mention inside them can't corrupt the count —
292
+ // while every index still slices the ORIGINAL body (the slot content keeps
293
+ // its scripts/comments verbatim in the splices below).
294
+ let mask = |mut masked: String, re: &Regex| {
295
+ let spans: Vec<(usize, usize)> =
296
+ re.find_iter(&masked).map(|m| (m.start(), m.end())).collect();
297
+ for (s, e) in spans {
298
+ masked.replace_range(s..e, &" ".repeat(e - s));
299
+ }
300
+ masked
301
+ };
302
+ let masked_body = mask(
303
+ mask(mask(body_inner.to_string(), &comment_re), &script_re),
304
+ &page_style_re,
305
+ );
306
+ let start = masked_body.find(open_tag.as_str())?;
307
+ let after_open = start + open_tag.len();
308
+ let tag_re = Regex::new(r"(?i)<component\b|</component\s*>").unwrap();
309
+ let mut depth = 1usize;
310
+ let mut inner_end = None;
311
+ for m in tag_re.find_iter(&masked_body[after_open..]) {
312
+ if m.as_str().starts_with("</") {
313
+ depth -= 1;
314
+ if depth == 0 {
315
+ inner_end = Some(after_open + m.start());
316
+ break;
317
+ }
318
+ } else {
319
+ depth += 1;
320
+ }
321
+ }
322
+ let inner_end = inner_end?;
323
+ let close_end = body_inner[inner_end..].find('>').map(|i| inner_end + i + 1)?;
324
+
325
+ // Everything outside the root element must be only whitespace, comments
326
+ // or scripts — a second top-level sibling means the page isn't "layout +
327
+ // slot content" and the app falls back to whole-page fragments.
328
+ let outside = format!("{}{}", &body_inner[..start], &body_inner[close_end..]);
329
+ let outside = comment_re.replace_all(&outside, "");
330
+ let outside = script_re.replace_all(&outside, "");
331
+ let outside = page_style_re.replace_all(&outside, "");
332
+ if !outside.trim().is_empty() {
333
+ return None;
334
+ }
335
+
336
+ let reduced_body = format!(
337
+ "{}{}{}",
338
+ &body_inner[..start],
339
+ &body_inner[after_open..inner_end],
340
+ &body_inner[close_end..]
341
+ );
342
+ let reduced_source = format!(
343
+ "{}{}{}",
344
+ &raw_source[..body_match.start()],
345
+ reduced_body,
346
+ &raw_source[body_match.end()..]
347
+ );
348
+
349
+ Some((open_tag, src, reduced_source))
350
+ }
351
+
352
+ /// The hoist decision: every page must present the SAME root component —
353
+ /// identical src and identical open tag (attributes parameterize a layout
354
+ /// instance; divergence means the pages don't share one). Returns the shared
355
+ /// (open_tag, src) when hoisting applies.
356
+ pub fn hoistable_layout(candidates: &[Option<(String, String, String)>]) -> Option<(String, String)> {
357
+ let first = candidates.first()?.as_ref()?;
358
+ for candidate in candidates {
359
+ let c = candidate.as_ref()?;
360
+ if c.0 != first.0 || c.1 != first.1 {
361
+ return None;
362
+ }
363
+ }
364
+ Some((first.0.clone(), first.1.clone()))
365
+ }
366
+
367
+ /// Compose the shell: deduped head union + '/' route's title, union body
368
+ /// attrs minus vibe-fouc, one generated boot script, the route outlet. Meta tags
369
+ /// keep only the set common to every page (per-route meta is future work);
370
+ /// links and shared scripts stack as a deduped union.
371
+ pub fn compose_shell(
372
+ pages: &[SpaPage],
373
+ route_order: &[usize],
374
+ hoisted_layout: Option<&(String, String)>,
375
+ ) -> ShellComposition {
376
+ let mut notes = Vec::new();
377
+
378
+ let vibe_spec = pages
379
+ .iter()
380
+ .enumerate()
381
+ .find(|(_, p)| p.route == "/")
382
+ .and_then(|(_, p)| p.vibe_import.clone())
383
+ .or_else(|| pages.iter().find_map(|p| p.vibe_import.clone()))
384
+ // Nothing harvestable (vibe imported only transitively through the
385
+ // project's own bundled modules): fall back to the conventional
386
+ // browser-resolvable path — a bare specifier would need an import map.
387
+ .unwrap_or_else(|| "/node_modules/@ape-egg/vibe/index.js".to_string());
388
+ let spa_spec = spa_import_for(&vibe_spec);
389
+
390
+ let title = pages
391
+ .iter()
392
+ .find(|p| p.route == "/")
393
+ .and_then(|p| p.title.clone())
394
+ .or_else(|| pages.iter().find_map(|p| p.title.clone()));
395
+
396
+ // Meta intersection: normalized form present in every page survives.
397
+ let meta_kept: Vec<String> = pages
398
+ .first()
399
+ .map(|first| {
400
+ first
401
+ .head_items
402
+ .iter()
403
+ .filter(|item| item.kind == HeadKind::Meta)
404
+ .filter(|item| {
405
+ pages.iter().all(|p| {
406
+ p.head_items
407
+ .iter()
408
+ .any(|other| other.normalized == item.normalized)
409
+ })
410
+ })
411
+ .map(|item| item.normalized.clone())
412
+ .collect()
413
+ })
414
+ .unwrap_or_default();
415
+
416
+ for page in pages {
417
+ for item in &page.head_items {
418
+ if item.kind == HeadKind::Meta && !meta_kept.contains(&item.normalized) {
419
+ notes.push(format!(
420
+ "pages/{}: page-specific {} dropped from the shell head (per-route meta is future work)",
421
+ page.rel_path, item.normalized
422
+ ));
423
+ }
424
+ }
425
+ }
426
+
427
+ // Union of everything else, deduped, first-seen order (metas keep their
428
+ // intersection set, also first-seen).
429
+ let mut head_lines: Vec<String> = Vec::new();
430
+ for page in pages {
431
+ for item in &page.head_items {
432
+ let keep = match item.kind {
433
+ HeadKind::Meta => meta_kept.contains(&item.normalized),
434
+ HeadKind::Other => true,
435
+ };
436
+ if keep && !head_lines.contains(&item.normalized) {
437
+ head_lines.push(item.normalized.clone());
438
+ }
439
+ }
440
+ }
441
+
442
+ // Body attribute union, first-seen order and value; disagreements noted.
443
+ let mut body_attrs: Vec<(String, Option<String>)> = Vec::new();
444
+ for page in pages {
445
+ for (name, value) in &page.body_attrs {
446
+ match body_attrs.iter().find(|(n, _)| n == name) {
447
+ None => body_attrs.push((name.clone(), value.clone())),
448
+ Some((_, kept)) if kept != value => notes.push(format!(
449
+ "body attribute \"{}\" differs across pages — shell keeps the first-seen value",
450
+ name
451
+ )),
452
+ Some(_) => {}
453
+ }
454
+ }
455
+ }
456
+ for page in pages {
457
+ for (name, _) in &page.body_attrs {
458
+ if !pages.iter().all(|p| p.body_attrs.iter().any(|(n, _)| n == name)) {
459
+ let note = format!(
460
+ "body attribute \"{}\" is not on every page (e.g. pages/{}) — shell uses the union",
461
+ name, page.rel_path
462
+ );
463
+ if !notes.contains(&note) {
464
+ notes.push(note);
465
+ }
466
+ }
467
+ }
468
+ }
469
+ // vibe-fouc absence is the compiled-mode marker (the runtime gates
470
+ // hyperspeed manifest loading on it). The shell is compiled output:
471
+ // strip the attribute from the union — there is nothing to flash, the
472
+ // outlet is empty and fragments hydrate off-DOM before insertion.
473
+ body_attrs.retain(|(n, _)| n != "vibe-fouc");
474
+
475
+ let body_attr_str = body_attrs
476
+ .iter()
477
+ .map(|(name, value)| match value {
478
+ Some(value) => format!("{}=\"{}\"", name, value),
479
+ None => name.clone(),
480
+ })
481
+ .collect::<Vec<_>>()
482
+ .join(" ");
483
+
484
+ let mut routes_js = String::new();
485
+ for &i in route_order {
486
+ let page = &pages[i];
487
+ routes_js.push_str(" { \"route\": ");
488
+ routes_js.push_str(&serde_json::to_string(&page.route).unwrap());
489
+ routes_js.push_str(", \"src\": ");
490
+ routes_js.push_str(&serde_json::to_string(&page.src).unwrap());
491
+ if let Some(title) = &page.title {
492
+ routes_js.push_str(", \"title\": ");
493
+ routes_js.push_str(&serde_json::to_string(title).unwrap());
494
+ }
495
+ routes_js.push_str(" },\n");
496
+ }
497
+
498
+ let mut html = String::from("<!DOCTYPE html>\n<html>\n <head>\n");
499
+ if let Some(title) = &title {
500
+ html.push_str(" <title>");
501
+ html.push_str(title);
502
+ html.push_str("</title>\n");
503
+ }
504
+ for line in &head_lines {
505
+ html.push_str(" ");
506
+ html.push_str(line);
507
+ html.push('\n');
508
+ }
509
+ html.push_str("\n <script type=\"module\">\n");
510
+ html.push_str(&format!(" import vibe from \"{}\";\n", vibe_spec));
511
+ html.push_str(&format!(
512
+ " import {{ setupSpa, resolve }} from \"{}\";\n\n",
513
+ spa_spec
514
+ ));
515
+ html.push_str(" const routes = [\n");
516
+ html.push_str(&routes_js);
517
+ html.push_str(" ];\n\n");
518
+ html.push_str(" window.$ = vibe({ page: resolve(location, routes) ?? {} });\n");
519
+ html.push_str(" setupSpa({ routes });\n");
520
+ html.push_str(" </script>\n </head>\n\n");
521
+ html.push_str(&format!(" <body {}>\n", body_attr_str));
522
+ // Keyed outlet: a key (page.path) change remounts even when the src is
523
+ // unchanged, so param→param navigation on the same route mounts fresh.
524
+ // With a hoisted layout the outlet nests inside the layout's slot: the
525
+ // layout mounts ONCE in the shell (build-inlined like any page chrome)
526
+ // and navigation swaps only the slot content — layout DOM, component
527
+ // state and timers persist, matching what a hand-written SPA shell does.
528
+ match hoisted_layout {
529
+ Some((open_tag, _src)) => {
530
+ html.push_str(" ");
531
+ html.push_str(open_tag);
532
+ html.push('\n');
533
+ html.push_str(" <component src=\"@[page.src]\" key=\"@[page.path]\"></component>\n");
534
+ html.push_str(" </component>\n");
535
+ }
536
+ None => {
537
+ html.push_str(" <component src=\"@[page.src]\" key=\"@[page.path]\"></component>\n");
538
+ }
539
+ }
540
+ html.push_str(" </body>\n</html>\n");
541
+
542
+ ShellComposition { html, notes }
543
+ }
544
+
545
+ #[cfg(test)]
546
+ mod tests {
547
+ use super::*;
548
+
549
+ #[test]
550
+ fn route_grammar_matches_the_scanners() {
551
+ assert_eq!(route_for("index.html"), "/");
552
+ assert_eq!(route_for("about.html"), "/about");
553
+ assert_eq!(route_for("docs/index.html"), "/docs");
554
+ assert_eq!(route_for("brawlers/$index.html"), "/brawlers/:index");
555
+ assert_eq!(route_for("404.html"), "*");
556
+ assert_eq!(route_for("errors/404.html"), "*");
557
+ assert_eq!(route_for("docs/$$rest.html"), "/docs/:rest*");
558
+ assert_eq!(route_for("$$rest.html"), "/:rest*");
559
+ assert_eq!(route_for("shop/$id/cart.html"), "/shop/:id/cart");
560
+ }
561
+
562
+ fn page(route: &str) -> SpaPage {
563
+ SpaPage {
564
+ rel_path: String::new(),
565
+ route: route.to_string(),
566
+ src: String::new(),
567
+ title: None,
568
+ fragment: String::new(),
569
+ head_items: vec![],
570
+ body_attrs: vec![],
571
+ vibe_import: None,
572
+ hygiene_offender: None,
573
+ }
574
+ }
575
+
576
+ #[test]
577
+ fn orders_most_specific_first() {
578
+ let pages = vec![
579
+ page("/"),
580
+ page("/:all*"),
581
+ page("/brawlers/:index"),
582
+ page("/brawlers/new"),
583
+ page("/docs/:rest*"),
584
+ page("/about"),
585
+ ];
586
+ let routes: Vec<&str> = route_order(&pages)
587
+ .into_iter()
588
+ .map(|i| pages[i].route.as_str())
589
+ .collect();
590
+ assert_eq!(
591
+ routes,
592
+ vec!["/brawlers/new", "/brawlers/:index", "/about", "/", "/docs/:rest*", "/:all*"]
593
+ );
594
+ }
595
+
596
+ #[test]
597
+ fn harvests_and_derives_shell_imports() {
598
+ assert_eq!(
599
+ harvest_vibe_import("import vibe from '/nodemodules/@ape-egg/vibe/index.js';"),
600
+ Some("/nodemodules/@ape-egg/vibe/index.js".to_string())
601
+ );
602
+ assert_eq!(spa_import_for("/nodemodules/@ape-egg/vibe/index.js"), "/nodemodules/@ape-egg/vibe/spa.js");
603
+ assert_eq!(spa_import_for("@ape-egg/vibe"), "@ape-egg/vibe/spa");
604
+ }
605
+
606
+ #[test]
607
+ fn hygiene_flags_side_effects_without_teardown() {
608
+ assert_eq!(hygiene_offender("setInterval(() => {}, 100)"), Some("setInterval"));
609
+ assert_eq!(hygiene_offender("setInterval(x); $.on('unmount', stop)"), None);
610
+ assert_eq!(hygiene_offender("const x = 1;"), None);
611
+ }
612
+ }