@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.
@@ -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(&params(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(&note) {
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
+ }