@ape-egg/vibe 2.1.21 → 2.3.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.
- package/CHANGELOG.md +49 -0
- package/README.md +98 -1
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/Cargo.lock +1 -1
- package/compiler/src/Cargo.toml +1 -1
- package/compiler/src/compiler/compile.rs +459 -9
- package/compiler/src/compiler/mod.rs +1 -0
- package/compiler/src/compiler/spa.rs +477 -0
- package/compiler/src/compiler/watcher.rs +182 -20
- package/compiler/src/config.rs +41 -1
- package/compiler/src/main.rs +12 -1
- package/index.js +17 -3
- package/llms.txt +29 -0
- package/package.json +2 -1
- package/runtime/component.js +145 -14
- package/runtime/hydrate.js +46 -0
- package/runtime/index.js +27 -0
- package/runtime/parse.js +25 -5
- package/runtime/pre-compiled-manifest.js +18 -1
- package/spa.js +143 -0
|
@@ -0,0 +1,477 @@
|
|
|
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
|
+
let mut segments: Vec<&str> = no_ext.split('/').filter(|s| !s.is_empty()).collect();
|
|
61
|
+
if segments.last() == Some(&"index") {
|
|
62
|
+
segments.pop();
|
|
63
|
+
}
|
|
64
|
+
let catch_all = segments
|
|
65
|
+
.last()
|
|
66
|
+
.map_or(false, |s| s.starts_with("$$"));
|
|
67
|
+
let tail = if catch_all { segments.pop() } else { None };
|
|
68
|
+
let mut parts: Vec<String> = segments
|
|
69
|
+
.iter()
|
|
70
|
+
.map(|s| match s.strip_prefix('$') {
|
|
71
|
+
Some(param) => format!(":{}", param),
|
|
72
|
+
None => s.to_string(),
|
|
73
|
+
})
|
|
74
|
+
.collect();
|
|
75
|
+
if let Some(tail) = tail {
|
|
76
|
+
parts.push(format!(":{}*", &tail[2..]));
|
|
77
|
+
}
|
|
78
|
+
format!("/{}", parts.join("/"))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// Route-table order, most-specific-first for a first-match-wins resolver:
|
|
82
|
+
/// non-catch-alls before catch-alls, deeper before shallower, statics before
|
|
83
|
+
/// params, then lexicographic for determinism. (game-stack's scanRoutes sorts
|
|
84
|
+
/// deepest-first over dynamic routes only; a full table additionally needs
|
|
85
|
+
/// the exact-beats-param and everything-beats-catch-all rules.) Returns
|
|
86
|
+
/// indices so `pages` keeps its stable path order for shell composition.
|
|
87
|
+
pub fn route_order(pages: &[SpaPage]) -> Vec<usize> {
|
|
88
|
+
let depth = |route: &str| route.split('/').filter(|s| !s.is_empty()).count();
|
|
89
|
+
let params = |route: &str| route.split('/').filter(|s| s.starts_with(':')).count();
|
|
90
|
+
let mut order: Vec<usize> = (0..pages.len()).collect();
|
|
91
|
+
order.sort_by(|&x, &y| {
|
|
92
|
+
let a = &pages[x].route;
|
|
93
|
+
let b = &pages[y].route;
|
|
94
|
+
a.ends_with('*')
|
|
95
|
+
.cmp(&b.ends_with('*'))
|
|
96
|
+
.then(depth(b).cmp(&depth(a)))
|
|
97
|
+
.then(params(a).cmp(¶ms(b)))
|
|
98
|
+
.then(a.cmp(b))
|
|
99
|
+
});
|
|
100
|
+
order
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/// The specifier a page uses to import vibe — the shell reuses the project's
|
|
104
|
+
/// own working import style instead of inventing one. Fragment scripts are
|
|
105
|
+
/// carried byte-identical: vibe() itself applies defaults semantics once
|
|
106
|
+
/// booted (state is app-lifetime; re-mounts seed missing keys only), so no
|
|
107
|
+
/// import rewrite is needed.
|
|
108
|
+
pub fn harvest_vibe_import(script: &str) -> Option<String> {
|
|
109
|
+
Regex::new(r#"from\s*['"]([^'"]*@ape-egg/vibe(?:/index\.js)?)['"]"#)
|
|
110
|
+
.unwrap()
|
|
111
|
+
.captures(script)
|
|
112
|
+
.map(|caps| caps[1].to_string())
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/// Derive the @ape-egg/vibe/spa specifier from the harvested vibe specifier.
|
|
116
|
+
pub fn spa_import_for(vibe_spec: &str) -> String {
|
|
117
|
+
match vibe_spec.strip_suffix("/index.js") {
|
|
118
|
+
Some(base) => format!("{}/spa.js", base),
|
|
119
|
+
None => format!("{}/spa", vibe_spec),
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/// Grep-level unmount hygiene (a teaching aid, not a guarantee): a page
|
|
124
|
+
/// script that starts side effects and never references `$.on('unmount'`
|
|
125
|
+
/// will leak them across SPA navigations.
|
|
126
|
+
pub fn hygiene_offender(script: &str) -> Option<&'static str> {
|
|
127
|
+
if script.contains("$.on('unmount'") || script.contains("$.on(\"unmount\"") {
|
|
128
|
+
return None;
|
|
129
|
+
}
|
|
130
|
+
["setInterval", "setTimeout", "addEventListener", "new WebSocket"]
|
|
131
|
+
.iter()
|
|
132
|
+
.find(|api| script.contains(*api))
|
|
133
|
+
.copied()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
fn collapse_whitespace(html: &str) -> String {
|
|
137
|
+
let mut out = String::with_capacity(html.len());
|
|
138
|
+
let mut in_space = false;
|
|
139
|
+
for ch in html.trim().chars() {
|
|
140
|
+
if ch.is_whitespace() {
|
|
141
|
+
if !in_space {
|
|
142
|
+
out.push(' ');
|
|
143
|
+
in_space = true;
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
out.push(ch);
|
|
147
|
+
in_space = false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
out
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/// Dissect a compiled page into its SPA parts. `html` is the page AFTER the
|
|
154
|
+
/// normal compile transforms (component handling, element transform) so the
|
|
155
|
+
/// fragment is exactly what the MPA output would have carried.
|
|
156
|
+
pub fn dissect_page(rel_path: &str, html: &str) -> Result<SpaPage, String> {
|
|
157
|
+
let head_re = Regex::new(r"(?is)<head[^>]*>(.*?)</head>").unwrap();
|
|
158
|
+
let body_re = Regex::new(r"(?is)<body([^>]*)>(.*)</body>").unwrap();
|
|
159
|
+
|
|
160
|
+
let head = head_re
|
|
161
|
+
.captures(html)
|
|
162
|
+
.map(|caps| caps[1].to_string())
|
|
163
|
+
.unwrap_or_default();
|
|
164
|
+
let body_caps = body_re
|
|
165
|
+
.captures(html)
|
|
166
|
+
.ok_or_else(|| format!("pages/{} has no <body> — SPA pages need one", rel_path))?;
|
|
167
|
+
let body_attrs_raw = body_caps[1].to_string();
|
|
168
|
+
let body_inner = body_caps[2].trim_matches('\n').to_string();
|
|
169
|
+
|
|
170
|
+
// Comments never travel to the shell head.
|
|
171
|
+
let head = Regex::new(r"(?s)<!--.*?-->").unwrap().replace_all(&head, "");
|
|
172
|
+
|
|
173
|
+
let style_re = Regex::new(r"(?is)<style[^>]*>.*?</style>").unwrap();
|
|
174
|
+
let styles: Vec<String> = style_re
|
|
175
|
+
.find_iter(&head)
|
|
176
|
+
.map(|m| m.as_str().trim().to_string())
|
|
177
|
+
.collect();
|
|
178
|
+
let head = style_re.replace_all(&head, "");
|
|
179
|
+
|
|
180
|
+
let script_re = Regex::new(r#"(?is)<script[^>]*type=["']module["'][^>]*>.*?</script>"#).unwrap();
|
|
181
|
+
let scripts: Vec<String> = script_re
|
|
182
|
+
.find_iter(&head)
|
|
183
|
+
.map(|m| m.as_str().trim().to_string())
|
|
184
|
+
.collect();
|
|
185
|
+
let head = script_re.replace_all(&head, "");
|
|
186
|
+
|
|
187
|
+
let title_re = Regex::new(r"(?is)<title[^>]*>(.*?)</title>").unwrap();
|
|
188
|
+
let title = title_re
|
|
189
|
+
.captures(&head)
|
|
190
|
+
.map(|caps| collapse_whitespace(&caps[1]))
|
|
191
|
+
.filter(|t| !t.is_empty());
|
|
192
|
+
let head = title_re.replace_all(&head, "");
|
|
193
|
+
|
|
194
|
+
let item_re = Regex::new(
|
|
195
|
+
r"(?is)<(?:meta|link|base)\b[^>]*/?>|<script\b[^>]*>.*?</script>|<noscript\b[^>]*>.*?</noscript>",
|
|
196
|
+
)
|
|
197
|
+
.unwrap();
|
|
198
|
+
let head_items: Vec<HeadItem> = item_re
|
|
199
|
+
.find_iter(&head)
|
|
200
|
+
.map(|m| {
|
|
201
|
+
let normalized = collapse_whitespace(m.as_str());
|
|
202
|
+
let kind = if normalized.to_lowercase().starts_with("<meta") {
|
|
203
|
+
HeadKind::Meta
|
|
204
|
+
} else {
|
|
205
|
+
HeadKind::Other
|
|
206
|
+
};
|
|
207
|
+
HeadItem { kind, normalized }
|
|
208
|
+
})
|
|
209
|
+
.collect();
|
|
210
|
+
|
|
211
|
+
let attr_re = Regex::new(r#"([^\s"'=]+)(?:="([^"]*)")?"#).unwrap();
|
|
212
|
+
let body_attrs: Vec<(String, Option<String>)> = attr_re
|
|
213
|
+
.captures_iter(&body_attrs_raw)
|
|
214
|
+
.map(|caps| (caps[1].to_string(), caps.get(2).map(|v| v.as_str().to_string())))
|
|
215
|
+
.collect();
|
|
216
|
+
|
|
217
|
+
let vibe_import = scripts.iter().find_map(|s| harvest_vibe_import(s));
|
|
218
|
+
let offender = scripts.iter().find_map(|s| hygiene_offender(s));
|
|
219
|
+
|
|
220
|
+
let mut fragment = String::new();
|
|
221
|
+
for style in &styles {
|
|
222
|
+
fragment.push_str(style);
|
|
223
|
+
fragment.push('\n');
|
|
224
|
+
}
|
|
225
|
+
if !styles.is_empty() && !scripts.is_empty() {
|
|
226
|
+
fragment.push('\n');
|
|
227
|
+
}
|
|
228
|
+
for script in &scripts {
|
|
229
|
+
fragment.push_str(script);
|
|
230
|
+
fragment.push('\n');
|
|
231
|
+
}
|
|
232
|
+
if !(styles.is_empty() && scripts.is_empty()) {
|
|
233
|
+
fragment.push('\n');
|
|
234
|
+
}
|
|
235
|
+
fragment.push_str(&body_inner);
|
|
236
|
+
if !fragment.ends_with('\n') {
|
|
237
|
+
fragment.push('\n');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
Ok(SpaPage {
|
|
241
|
+
rel_path: rel_path.to_string(),
|
|
242
|
+
route: route_for(rel_path),
|
|
243
|
+
src: format!("/components/vibe-spa/{}", rel_path),
|
|
244
|
+
title,
|
|
245
|
+
fragment,
|
|
246
|
+
head_items,
|
|
247
|
+
body_attrs,
|
|
248
|
+
vibe_import,
|
|
249
|
+
hygiene_offender: offender,
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/// Compose the shell: deduped head union + '/' route's title, union body
|
|
254
|
+
/// attrs minus vibe-fouc, one generated boot script, the route outlet. Meta tags
|
|
255
|
+
/// keep only the set common to every page (per-route meta is future work);
|
|
256
|
+
/// links and shared scripts stack as a deduped union.
|
|
257
|
+
pub fn compose_shell(pages: &[SpaPage], route_order: &[usize]) -> ShellComposition {
|
|
258
|
+
let mut notes = Vec::new();
|
|
259
|
+
|
|
260
|
+
let vibe_spec = pages
|
|
261
|
+
.iter()
|
|
262
|
+
.enumerate()
|
|
263
|
+
.find(|(_, p)| p.route == "/")
|
|
264
|
+
.and_then(|(_, p)| p.vibe_import.clone())
|
|
265
|
+
.or_else(|| pages.iter().find_map(|p| p.vibe_import.clone()))
|
|
266
|
+
// Nothing harvestable (vibe imported only transitively through the
|
|
267
|
+
// project's own bundled modules): fall back to the conventional
|
|
268
|
+
// browser-resolvable path — a bare specifier would need an import map.
|
|
269
|
+
.unwrap_or_else(|| "/node_modules/@ape-egg/vibe/index.js".to_string());
|
|
270
|
+
let spa_spec = spa_import_for(&vibe_spec);
|
|
271
|
+
|
|
272
|
+
let title = pages
|
|
273
|
+
.iter()
|
|
274
|
+
.find(|p| p.route == "/")
|
|
275
|
+
.and_then(|p| p.title.clone())
|
|
276
|
+
.or_else(|| pages.iter().find_map(|p| p.title.clone()));
|
|
277
|
+
|
|
278
|
+
// Meta intersection: normalized form present in every page survives.
|
|
279
|
+
let meta_kept: Vec<String> = pages
|
|
280
|
+
.first()
|
|
281
|
+
.map(|first| {
|
|
282
|
+
first
|
|
283
|
+
.head_items
|
|
284
|
+
.iter()
|
|
285
|
+
.filter(|item| item.kind == HeadKind::Meta)
|
|
286
|
+
.filter(|item| {
|
|
287
|
+
pages.iter().all(|p| {
|
|
288
|
+
p.head_items
|
|
289
|
+
.iter()
|
|
290
|
+
.any(|other| other.normalized == item.normalized)
|
|
291
|
+
})
|
|
292
|
+
})
|
|
293
|
+
.map(|item| item.normalized.clone())
|
|
294
|
+
.collect()
|
|
295
|
+
})
|
|
296
|
+
.unwrap_or_default();
|
|
297
|
+
|
|
298
|
+
for page in pages {
|
|
299
|
+
for item in &page.head_items {
|
|
300
|
+
if item.kind == HeadKind::Meta && !meta_kept.contains(&item.normalized) {
|
|
301
|
+
notes.push(format!(
|
|
302
|
+
"pages/{}: page-specific {} dropped from the shell head (per-route meta is future work)",
|
|
303
|
+
page.rel_path, item.normalized
|
|
304
|
+
));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Union of everything else, deduped, first-seen order (metas keep their
|
|
310
|
+
// intersection set, also first-seen).
|
|
311
|
+
let mut head_lines: Vec<String> = Vec::new();
|
|
312
|
+
for page in pages {
|
|
313
|
+
for item in &page.head_items {
|
|
314
|
+
let keep = match item.kind {
|
|
315
|
+
HeadKind::Meta => meta_kept.contains(&item.normalized),
|
|
316
|
+
HeadKind::Other => true,
|
|
317
|
+
};
|
|
318
|
+
if keep && !head_lines.contains(&item.normalized) {
|
|
319
|
+
head_lines.push(item.normalized.clone());
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Body attribute union, first-seen order and value; disagreements noted.
|
|
325
|
+
let mut body_attrs: Vec<(String, Option<String>)> = Vec::new();
|
|
326
|
+
for page in pages {
|
|
327
|
+
for (name, value) in &page.body_attrs {
|
|
328
|
+
match body_attrs.iter().find(|(n, _)| n == name) {
|
|
329
|
+
None => body_attrs.push((name.clone(), value.clone())),
|
|
330
|
+
Some((_, kept)) if kept != value => notes.push(format!(
|
|
331
|
+
"body attribute \"{}\" differs across pages — shell keeps the first-seen value",
|
|
332
|
+
name
|
|
333
|
+
)),
|
|
334
|
+
Some(_) => {}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
for page in pages {
|
|
339
|
+
for (name, _) in &page.body_attrs {
|
|
340
|
+
if !pages.iter().all(|p| p.body_attrs.iter().any(|(n, _)| n == name)) {
|
|
341
|
+
let note = format!(
|
|
342
|
+
"body attribute \"{}\" is not on every page (e.g. pages/{}) — shell uses the union",
|
|
343
|
+
name, page.rel_path
|
|
344
|
+
);
|
|
345
|
+
if !notes.contains(¬e) {
|
|
346
|
+
notes.push(note);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
// vibe-fouc absence is the compiled-mode marker (the runtime gates
|
|
352
|
+
// hyperspeed manifest loading on it). The shell is compiled output:
|
|
353
|
+
// strip the attribute from the union — there is nothing to flash, the
|
|
354
|
+
// outlet is empty and fragments hydrate off-DOM before insertion.
|
|
355
|
+
body_attrs.retain(|(n, _)| n != "vibe-fouc");
|
|
356
|
+
|
|
357
|
+
let body_attr_str = body_attrs
|
|
358
|
+
.iter()
|
|
359
|
+
.map(|(name, value)| match value {
|
|
360
|
+
Some(value) => format!("{}=\"{}\"", name, value),
|
|
361
|
+
None => name.clone(),
|
|
362
|
+
})
|
|
363
|
+
.collect::<Vec<_>>()
|
|
364
|
+
.join(" ");
|
|
365
|
+
|
|
366
|
+
let mut routes_js = String::new();
|
|
367
|
+
for &i in route_order {
|
|
368
|
+
let page = &pages[i];
|
|
369
|
+
routes_js.push_str(" { \"route\": ");
|
|
370
|
+
routes_js.push_str(&serde_json::to_string(&page.route).unwrap());
|
|
371
|
+
routes_js.push_str(", \"src\": ");
|
|
372
|
+
routes_js.push_str(&serde_json::to_string(&page.src).unwrap());
|
|
373
|
+
if let Some(title) = &page.title {
|
|
374
|
+
routes_js.push_str(", \"title\": ");
|
|
375
|
+
routes_js.push_str(&serde_json::to_string(title).unwrap());
|
|
376
|
+
}
|
|
377
|
+
routes_js.push_str(" },\n");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
let mut html = String::from("<!DOCTYPE html>\n<html>\n <head>\n");
|
|
381
|
+
if let Some(title) = &title {
|
|
382
|
+
html.push_str(" <title>");
|
|
383
|
+
html.push_str(title);
|
|
384
|
+
html.push_str("</title>\n");
|
|
385
|
+
}
|
|
386
|
+
for line in &head_lines {
|
|
387
|
+
html.push_str(" ");
|
|
388
|
+
html.push_str(line);
|
|
389
|
+
html.push('\n');
|
|
390
|
+
}
|
|
391
|
+
html.push_str("\n <script type=\"module\">\n");
|
|
392
|
+
html.push_str(&format!(" import vibe from \"{}\";\n", vibe_spec));
|
|
393
|
+
html.push_str(&format!(
|
|
394
|
+
" import {{ setupSpa, resolve }} from \"{}\";\n\n",
|
|
395
|
+
spa_spec
|
|
396
|
+
));
|
|
397
|
+
html.push_str(" const routes = [\n");
|
|
398
|
+
html.push_str(&routes_js);
|
|
399
|
+
html.push_str(" ];\n\n");
|
|
400
|
+
html.push_str(" window.$ = vibe({ page: resolve(location, routes) ?? {} });\n");
|
|
401
|
+
html.push_str(" setupSpa({ routes });\n");
|
|
402
|
+
html.push_str(" </script>\n </head>\n\n");
|
|
403
|
+
html.push_str(&format!(" <body {}>\n", body_attr_str));
|
|
404
|
+
// Keyed outlet: a key (page.path) change remounts even when the src is
|
|
405
|
+
// unchanged, so param→param navigation on the same route mounts fresh.
|
|
406
|
+
html.push_str(" <component src=\"@[page.src]\" key=\"@[page.path]\"></component>\n");
|
|
407
|
+
html.push_str(" </body>\n</html>\n");
|
|
408
|
+
|
|
409
|
+
ShellComposition { html, notes }
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
#[cfg(test)]
|
|
413
|
+
mod tests {
|
|
414
|
+
use super::*;
|
|
415
|
+
|
|
416
|
+
#[test]
|
|
417
|
+
fn route_grammar_matches_the_scanners() {
|
|
418
|
+
assert_eq!(route_for("index.html"), "/");
|
|
419
|
+
assert_eq!(route_for("about.html"), "/about");
|
|
420
|
+
assert_eq!(route_for("docs/index.html"), "/docs");
|
|
421
|
+
assert_eq!(route_for("brawlers/$index.html"), "/brawlers/:index");
|
|
422
|
+
assert_eq!(route_for("docs/$$rest.html"), "/docs/:rest*");
|
|
423
|
+
assert_eq!(route_for("$$rest.html"), "/:rest*");
|
|
424
|
+
assert_eq!(route_for("shop/$id/cart.html"), "/shop/:id/cart");
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
fn page(route: &str) -> SpaPage {
|
|
428
|
+
SpaPage {
|
|
429
|
+
rel_path: String::new(),
|
|
430
|
+
route: route.to_string(),
|
|
431
|
+
src: String::new(),
|
|
432
|
+
title: None,
|
|
433
|
+
fragment: String::new(),
|
|
434
|
+
head_items: vec![],
|
|
435
|
+
body_attrs: vec![],
|
|
436
|
+
vibe_import: None,
|
|
437
|
+
hygiene_offender: None,
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
#[test]
|
|
442
|
+
fn orders_most_specific_first() {
|
|
443
|
+
let pages = vec![
|
|
444
|
+
page("/"),
|
|
445
|
+
page("/:all*"),
|
|
446
|
+
page("/brawlers/:index"),
|
|
447
|
+
page("/brawlers/new"),
|
|
448
|
+
page("/docs/:rest*"),
|
|
449
|
+
page("/about"),
|
|
450
|
+
];
|
|
451
|
+
let routes: Vec<&str> = route_order(&pages)
|
|
452
|
+
.into_iter()
|
|
453
|
+
.map(|i| pages[i].route.as_str())
|
|
454
|
+
.collect();
|
|
455
|
+
assert_eq!(
|
|
456
|
+
routes,
|
|
457
|
+
vec!["/brawlers/new", "/brawlers/:index", "/about", "/", "/docs/:rest*", "/:all*"]
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
#[test]
|
|
462
|
+
fn harvests_and_derives_shell_imports() {
|
|
463
|
+
assert_eq!(
|
|
464
|
+
harvest_vibe_import("import vibe from '/nodemodules/@ape-egg/vibe/index.js';"),
|
|
465
|
+
Some("/nodemodules/@ape-egg/vibe/index.js".to_string())
|
|
466
|
+
);
|
|
467
|
+
assert_eq!(spa_import_for("/nodemodules/@ape-egg/vibe/index.js"), "/nodemodules/@ape-egg/vibe/spa.js");
|
|
468
|
+
assert_eq!(spa_import_for("@ape-egg/vibe"), "@ape-egg/vibe/spa");
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
#[test]
|
|
472
|
+
fn hygiene_flags_side_effects_without_teardown() {
|
|
473
|
+
assert_eq!(hygiene_offender("setInterval(() => {}, 100)"), Some("setInterval"));
|
|
474
|
+
assert_eq!(hygiene_offender("setInterval(x); $.on('unmount', stop)"), None);
|
|
475
|
+
assert_eq!(hygiene_offender("const x = 1;"), None);
|
|
476
|
+
}
|
|
477
|
+
}
|