@ape-egg/vibe 2.1.1 → 2.1.4

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 CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.1.4] - 2026-06-18
4
+
5
+ ### Fixed
6
+
7
+ - **Compiler 1.9.0 → 1.9.1 — `--minify` broke `<script>` and `<style>` blocks** (`compiler/src/compiler/compile.rs`) — `minify_html` only treated `<pre>` as whitespace-significant. Collapsing newlines inside a `<script>` turned a `//` line comment (or any ASI-dependent break) into a single line, so the comment swallowed the rest of the script and `new Function` threw a `SyntaxError` at runtime; `<style>` blocks were likewise flattened. Both tags now join `<pre>` as raw blocks emitted line-for-line, while the surrounding HTML still minifies normally. Repro: `minify_preserves_script_newlines_so_line_comments_dont_swallow_code`, `minify_preserves_style_newlines` (`compile.rs` unit tests).
8
+ - **Compiler 1.9.0 → 1.9.1 — component inliner overshot on end tags split across lines** (`compiler/src/parser/html.rs`) — `find_matching_close` matched `</tag>` exactly, but whitespace-controlled markup can split an end tag (`</component\n>`), which HTML permits. The depth counter then counted the nested open without ever seeing its close, so the outer-close search ran past the real boundary and swallowed every following sibling into the component. The close-tag regex now tolerates whitespace before `>` (`</tag\s*>`); `\s*` can't bridge into `</tag-foo>`, so matching stays exact on the tag name. Repro: `find_matching_close_tolerates_whitespace_in_end_tag` (`html.rs` unit test).
9
+
10
+ ## [2.1.3] - 2026-06-18
11
+
12
+ ### Added
13
+
14
+ - **Compiler 1.8.2 → 1.9.0 — `skipFiles` config option** (`compiler/src/config.rs`, `compiler/src/compiler/compile.rs`, `compiler/src/compiler/watcher.rs`, `compiler/src/main.rs`) — projects can now add their own exclude patterns on top of the compiler's built-in skip list (test files, `*.config.js`, `node_modules`, dotfiles, build dirs). Like `reservedElements`, user values **append** to the built-ins rather than replacing them, so the sensible defaults always hold. `Config::load` merges the built-in `SKIP_FILES` const with the user array into one effective `config.skip_files`, which is now threaded into `should_skip_path` at every call site (compile pass + watch mode) instead of the function reading a hard-coded const. Matching: a bare name (`server`) matches any file or directory with that name; a pattern containing `*` is a glob (`**/*.bak`) matched against both filename and full path; dotfiles are always skipped. This removes the need for app-side post-build pruning of directories the deploy never serves (a backend `server/`, build `scripts/`, a stale `dist/`). Config-only (no CLI flag, like `reservedElements`); surfaced in `--verbose` output. Repro: `tests/compiler/skip-files`.
15
+ - Docs: the compiler **Configuration** page documents `skipFiles` (example config + full section) and adds two collapsible panels revealing the built-in `reservedElements` and `skipFiles` default lists.
16
+
17
+ ## [2.1.2] - 2026-06-18
18
+
19
+ ### Fixed
20
+
21
+ - **Compiler 1.8.0 → 1.8.1 — `--minify` mangled tags whose attributes span multiple lines** (`compiler/src/compiler/compile.rs`) — `minify_html` joined trimmed source lines with no separator, so a newline *inside* a tag vanished instead of collapsing to a space. A multi-line `<meta name="viewport" content="...">` became `<metaname="viewport"content="...">`, which the downstream stamping stage then re-parsed into deeper garbage (`initial-scale="1.0,"`, `&quot;`, a bogus `</metaname...>` close). The inter-line break is now collapsed to a single space like any other whitespace run; the existing `>\s+<` → `><` pass re-tightens genuine tag boundaries. Generic fix — applies to any multi-line tag or text node, not just `<meta>`. Repro: `tests/compiler/minify-meta`.
22
+
3
23
  ## [2.1.1] - 2026-06-18
4
24
 
5
25
  ### Added
@@ -1599,7 +1599,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
1599
1599
 
1600
1600
  [[package]]
1601
1601
  name = "vibe-compiler"
1602
- version = "1.8.0"
1602
+ version = "1.9.1"
1603
1603
  dependencies = [
1604
1604
  "clap",
1605
1605
  "colored",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "vibe-compiler"
3
- version = "1.8.0"
3
+ version = "1.9.1"
4
4
  edition = "2021"
5
5
  description = "Vibe framework compiler - compiles Vibe source files into optimized output"
6
6
  authors = ["Kim Korte"]
@@ -12,6 +12,32 @@ use rayon::prelude::*;
12
12
  use crate::config::Config;
13
13
  use crate::parser::HtmlParser;
14
14
 
15
+ /// Map an output-relative HTML path to the URL path the runtime resolves a
16
+ /// manifest from. Strips the `root` dir (served at /) and collapses dynamic
17
+ /// `$param` segments to a `$` token, e.g. with root `pages`:
18
+ /// pages/armory.html -> armory.html
19
+ /// pages/the-arena/$id.html -> the-arena/$.html
20
+ fn manifest_url_path(relative_path: &str, root: Option<&str>) -> String {
21
+ let stripped = match root {
22
+ Some(r) if relative_path == r => "",
23
+ Some(r) if relative_path.starts_with(&format!("{}/", r)) => &relative_path[r.len() + 1..],
24
+ _ => relative_path,
25
+ };
26
+
27
+ stripped
28
+ .split('/')
29
+ .map(|seg| match seg.strip_prefix('$') {
30
+ // $id.html -> $.html ; $id -> $
31
+ Some(rest) => match rest.find('.') {
32
+ Some(dot) => format!("${}", &rest[dot..]),
33
+ None => "$".to_string(),
34
+ },
35
+ None => seg.to_string(),
36
+ })
37
+ .collect::<Vec<_>>()
38
+ .join("/")
39
+ }
40
+
15
41
  // =============================================================================
16
42
  // MIRROR_MODE: Copy asset files from source to output as-is, preserving
17
43
  // directory structure. HTML files are compiled separately.
@@ -38,7 +64,10 @@ const MIRROR_EXTENSIONS: &[&str] = &[
38
64
  "json", "xml", "csv",
39
65
  ];
40
66
 
41
- // Files and directories to skip when walking source (supports glob patterns)
67
+ // Built-in files and directories to skip when walking source (supports glob
68
+ // patterns). User-supplied `skipFiles` are appended to these in Config::load,
69
+ // so the effective list lives on `config.skip_files` and is passed into
70
+ // should_skip_path() at every call site.
42
71
  // Note: Output directory is checked dynamically (not hardcoded here)
43
72
  // Note: Dotfiles are handled by starts_with('.') check in should_skip_path()
44
73
  pub const SKIP_FILES: &[&str] = &[
@@ -53,20 +82,21 @@ pub const SKIP_FILES: &[&str] = &[
53
82
  "**/*.config.ts", // Config files
54
83
  ];
55
84
 
56
- /// Check if a path should be skipped based on SKIP_FILES patterns
57
- pub fn should_skip_path(path: &Path, name: &str) -> bool {
85
+ /// Check if a path should be skipped based on the effective skip patterns
86
+ /// (built-in SKIP_FILES + user `skipFiles`, merged in Config::load).
87
+ pub fn should_skip_path(path: &Path, name: &str, patterns: &[String]) -> bool {
58
88
  // Check if name starts with dot (dotfiles/directories)
59
89
  if name.starts_with('.') {
60
90
  return true;
61
91
  }
62
92
 
63
93
  // Check exact name match (for directories and simple filenames)
64
- if SKIP_FILES.contains(&name) {
94
+ if patterns.iter().any(|p| p == name) {
65
95
  return true;
66
96
  }
67
97
 
68
98
  // Check glob patterns (e.g., **/*.test.js)
69
- for pattern_str in SKIP_FILES {
99
+ for pattern_str in patterns {
70
100
  if pattern_str.contains('*') {
71
101
  if let Ok(pattern) = Pattern::new(pattern_str) {
72
102
  // Try matching against just the filename
@@ -794,6 +824,7 @@ impl Compiler {
794
824
  self.config.iterations_as_is,
795
825
  self.config.components_as_is,
796
826
  &self.config.source,
827
+ self.config.root.as_deref(),
797
828
  ) {
798
829
  Ok(()) => {
799
830
  pages_processed += 1;
@@ -826,6 +857,7 @@ impl Compiler {
826
857
  iterations_as_is: bool,
827
858
  components_as_is: bool,
828
859
  source_root: &Path,
860
+ manifest_root: Option<&str>,
829
861
  ) -> Result<(), String> {
830
862
  use crate::compiler::manifest_builder::ManifestBuilder;
831
863
  use crate::compiler::component_tagger::ComponentTagger;
@@ -841,10 +873,17 @@ impl Compiler {
841
873
  let manifest_builder = ManifestBuilder::new();
842
874
  let manifest = manifest_builder.build_from_html(html, &state, iterations_as_is)?;
843
875
 
876
+ // Map the output-relative path to the served-URL path: drop the `root`
877
+ // dir (the folder served at /, e.g. `pages`) and collapse dynamic
878
+ // `$param` segments to a single `$` token. This lets the runtime resolve
879
+ // a manifest from a clean URL — `/armory` and `/the-arena/123` find
880
+ // `armory.html.manifest.js` and `the-arena/$.html.manifest.js`.
881
+ let manifest_rel = manifest_url_path(relative_path, manifest_root);
882
+
844
883
  // Write manifest
845
884
  let manifest_path = output_dir
846
885
  .join("vibe-hyperspeed")
847
- .join(format!("{}.manifest.js", relative_path));
886
+ .join(format!("{}.manifest.js", manifest_rel));
848
887
 
849
888
  // Ensure directory exists
850
889
  if let Some(parent) = manifest_path.parent() {
@@ -857,7 +896,7 @@ impl Compiler {
857
896
 
858
897
  let manifest_js = format!(
859
898
  "// Pre-compiled manifest for /{}\n// Generated by Vibe compiler\n\nexport default {};\n",
860
- relative_path,
899
+ manifest_rel,
861
900
  manifest_json
862
901
  );
863
902
 
@@ -901,6 +940,7 @@ impl Compiler {
901
940
  let verbose = self.verbose;
902
941
  let iterations_as_is = self.config.iterations_as_is;
903
942
  let components_as_is = self.config.components_as_is;
943
+ let manifest_root = self.config.root.clone();
904
944
 
905
945
  let results: Vec<_> = html_files
906
946
  .par_iter()
@@ -917,7 +957,7 @@ impl Compiler {
917
957
  };
918
958
 
919
959
  // Try to generate manifest for this file (skip on error)
920
- match Self::generate_file_manifest(&html, html_path, &output_dir, relative_path, verbose, iterations_as_is, components_as_is, &source_root) {
960
+ match Self::generate_file_manifest(&html, html_path, &output_dir, relative_path, verbose, iterations_as_is, components_as_is, &source_root, manifest_root.as_deref()) {
921
961
  Ok(()) => (true, None),
922
962
  Err(e) => {
923
963
  if verbose {
@@ -974,7 +1014,7 @@ impl Compiler {
974
1014
  }
975
1015
 
976
1016
  // Skip special directories
977
- if should_skip_path(&path, file_name) {
1017
+ if should_skip_path(&path, file_name, &self.config.skip_files) {
978
1018
  continue;
979
1019
  }
980
1020
 
@@ -1059,7 +1099,7 @@ impl Compiler {
1059
1099
  }
1060
1100
 
1061
1101
  // Skip special directories
1062
- if should_skip_path(&path, file_name) {
1102
+ if should_skip_path(&path, file_name, &self.config.skip_files) {
1063
1103
  continue;
1064
1104
  }
1065
1105
 
@@ -1093,7 +1133,7 @@ impl Compiler {
1093
1133
  self.process_directory_assets_only(&path, &new_relative, canonical_output, canonical_source, stats)?;
1094
1134
  } else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
1095
1135
  // Skip files matching skip patterns
1096
- if should_skip_path(&path, file_name) {
1136
+ if should_skip_path(&path, file_name, &self.config.skip_files) {
1097
1137
  continue;
1098
1138
  }
1099
1139
 
@@ -1546,7 +1586,7 @@ impl Compiler {
1546
1586
  let file_name_str = file_name.to_string_lossy();
1547
1587
 
1548
1588
  // Skip specific directories/patterns (includes dotfiles via SKIP_FILES)
1549
- if should_skip_path(&path, &file_name_str) {
1589
+ if should_skip_path(&path, &file_name_str, &self.config.skip_files) {
1550
1590
  continue;
1551
1591
  }
1552
1592
 
@@ -1765,7 +1805,7 @@ impl Compiler {
1765
1805
  let file_name = path.file_name().unwrap().to_str().unwrap();
1766
1806
 
1767
1807
  // Skip files/directories matching skip patterns
1768
- if should_skip_path(&path, file_name) {
1808
+ if should_skip_path(&path, file_name, &self.config.skip_files) {
1769
1809
  continue;
1770
1810
  }
1771
1811
 
@@ -1929,23 +1969,40 @@ fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<(), CompileError> {
1929
1969
  /// Basic HTML minification
1930
1970
  fn minify_html(html: &str) -> String {
1931
1971
  let mut result = String::with_capacity(html.len());
1932
- let mut in_pre = false;
1972
+ let mut in_raw = false;
1933
1973
  let mut last_was_space = false;
1934
1974
 
1935
1975
  for line in html.lines() {
1936
1976
  let trimmed = line.trim();
1937
1977
 
1938
- if trimmed.contains("<pre") {
1939
- in_pre = true;
1978
+ // <pre>, <script> and <style> carry significant whitespace and must
1979
+ // survive minification verbatim: <pre> is literal text, while a JS
1980
+ // `//` line comment or ASI in <script> breaks the moment its trailing
1981
+ // newline is collapsed into a space (the comment swallows the rest of
1982
+ // the script). Keep these blocks line-for-line, exactly as <pre> always
1983
+ // did. The opening-tag line enters the block before we emit it; the
1984
+ // closing-tag line leaves it (and collapses, which only tightens the
1985
+ // bare `</pre>` / `</script>` / `</style>`).
1986
+ if trimmed.contains("<pre") || trimmed.contains("<script") || trimmed.contains("<style") {
1987
+ in_raw = true;
1940
1988
  }
1941
- if trimmed.contains("</pre>") {
1942
- in_pre = false;
1989
+ if trimmed.contains("</pre>") || trimmed.contains("</script>") || trimmed.contains("</style>") {
1990
+ in_raw = false;
1943
1991
  }
1944
1992
 
1945
- if in_pre {
1993
+ if in_raw {
1946
1994
  result.push_str(line);
1947
1995
  result.push('\n');
1996
+ last_was_space = false;
1948
1997
  } else {
1998
+ // The line break preceding this line is whitespace: collapse it to a
1999
+ // single space so attributes/text split across lines don't glue
2000
+ // together (e.g. a multi-line <meta name=... content=...> tag).
2001
+ // The >\s+< pass below re-tightens genuine tag boundaries.
2002
+ if !last_was_space && !result.is_empty() {
2003
+ result.push(' ');
2004
+ last_was_space = true;
2005
+ }
1949
2006
  for c in trimmed.chars() {
1950
2007
  if c.is_whitespace() {
1951
2008
  if !last_was_space {
@@ -1979,3 +2036,50 @@ fn find_bytes_ci(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
1979
2036
  fn count_newlines(bytes: &[u8]) -> usize {
1980
2037
  bytes.iter().filter(|&&b| b == b'\n').count()
1981
2038
  }
2039
+
2040
+ #[cfg(test)]
2041
+ mod tests {
2042
+ use super::*;
2043
+
2044
+ #[test]
2045
+ fn minify_preserves_script_newlines_so_line_comments_dont_swallow_code() {
2046
+ // A `//` line comment inside a component script relies on its trailing
2047
+ // newline. If minify collapses newlines into spaces, the comment eats
2048
+ // the rest of the script → SyntaxError at runtime (new Function).
2049
+ let html = "<page>\n\
2050
+ <script type=\"module\">\n\
2051
+ \x20 const a = 1; // explain a\n\
2052
+ \x20 // keep explaining\n\
2053
+ \x20 const b = 2;\n\
2054
+ </script>\n\
2055
+ </page>";
2056
+
2057
+ let out = minify_html(html);
2058
+
2059
+ // The code after the comments must still be reachable, i.e. on its own
2060
+ // line rather than glued behind the `//`.
2061
+ let script = &out[out.find("<script").unwrap()..out.find("</script>").unwrap()];
2062
+ assert!(
2063
+ script.contains('\n'),
2064
+ "script newlines were collapsed, // comment swallows following code: {script:?}"
2065
+ );
2066
+ assert!(out.contains("const b = 2"), "code after // comment lost: {out:?}");
2067
+
2068
+ // Surrounding HTML must still be minified (tag boundaries tightened).
2069
+ assert!(out.contains("<page><script"), "non-script HTML not minified: {out:?}");
2070
+ }
2071
+
2072
+ #[test]
2073
+ fn minify_preserves_style_newlines() {
2074
+ let html = "<page>\n\
2075
+ <style>\n\
2076
+ \x20 a { color: red; }\n\
2077
+ \x20 b { color: blue; }\n\
2078
+ </style>\n\
2079
+ </page>";
2080
+
2081
+ let out = minify_html(html);
2082
+ let style = &out[out.find("<style").unwrap()..out.find("</style>").unwrap()];
2083
+ assert!(style.contains('\n'), "style newlines collapsed: {style:?}");
2084
+ }
2085
+ }
@@ -1,7 +1,7 @@
1
1
  use html5ever::parse_document;
2
2
  use html5ever::tendril::TendrilSink;
3
3
  use html5ever::serialize::{serialize, SerializeOpts};
4
- use markup5ever_rcdom::{RcDom, NodeData, Handle, SerializableHandle};
4
+ use markup5ever_rcdom::{RcDom, NodeData, Handle, Node, SerializableHandle};
5
5
  use markup5ever::{QualName, LocalName, Namespace};
6
6
  use regex::Regex;
7
7
  use serde_json::Value;
@@ -137,15 +137,26 @@ impl ComponentTagger {
137
137
  for child in node.children.borrow().iter() {
138
138
  if let NodeData::Element { name: ref child_name, .. } = child.data {
139
139
  if child_name.local.as_ref() == "script" {
140
- let mut script_bytes = Vec::new();
141
- let _ = serialize(
142
- &mut script_bytes,
143
- &SerializableHandle::from(child.clone()),
144
- SerializeOpts::default()
145
- );
146
- if let Ok(script_str) = String::from_utf8(script_bytes) {
147
- direct_scripts_html.push_str(&script_str);
140
+ // Read the script's raw text directly. Serializing the
141
+ // element would HTML-escape JS operators (`>` -> `&gt;`,
142
+ // `&&` -> `&amp;&amp;`) because the serializer loses the
143
+ // rawtext context when it starts at the script's children;
144
+ // the escaped source then fails to parse, dropping a
145
+ // `component(stateVar)` component to the regex fallback
146
+ // (which only matches `component({` literals) and leaving
147
+ // it untagged.
148
+ let mut script_text = String::new();
149
+ for grandchild in child.children.borrow().iter() {
150
+ if let NodeData::Text { ref contents } = grandchild.data {
151
+ script_text.push_str(&contents.borrow());
152
+ }
148
153
  }
154
+ // Wrap in <script> so extract_from_html routes it through
155
+ // the JS AST analyzer, which resolves `component(stateVar)`
156
+ // via its declaration (the regex fallback can't).
157
+ direct_scripts_html.push_str("<script>");
158
+ direct_scripts_html.push_str(&script_text);
159
+ direct_scripts_html.push_str("</script>");
149
160
  }
150
161
  }
151
162
  }
@@ -200,36 +211,84 @@ impl ComponentTagger {
200
211
  })
201
212
  }
202
213
 
203
- /// Rewrite this.property to componentId.property in a node's subtree
214
+ /// Rewrite `this.X` to `componentId.X` throughout a component's subtree:
215
+ /// inside `@[...]` bindings (text + attributes, nested paths and multi-ref
216
+ /// expressions) and inside if/each/else-if directive comments. Resolving the
217
+ /// directives at build time is what lets a component-local `<!-- if this.x -->`
218
+ /// work on compiled (manifest-restored) pages, where the runtime can't fall
219
+ /// back to a `data-vibe-component-id` ancestor lookup.
204
220
  fn rewrite_this_to_component_id(node: &Handle, component_id: &str) {
205
221
  use regex::Regex;
206
- let this_regex = Regex::new(r"@\[this\.(\w+)\]").unwrap();
222
+ // The whole `@[...]` binding; `this.` is resolved within it so nested
223
+ // paths (this.x.y) and expressions (this.a + this.b) are all covered.
224
+ let binding_regex = Regex::new(r"@\[[^\]]*\]").unwrap();
225
+ let this_prop = Regex::new(r"\bthis\.").unwrap();
226
+ // `$.this.X` writes that live in event-handler bodies (onclick="$.this.mode
227
+ // = 'edit'"), outside any `@[...]`. The runtime lowers these via its
228
+ // STATE_THIS_PROP_REGEX pass (runtime/component.js); the compiler must do
229
+ // the same or compiled pages ship a literal `$.this.X` that resolves to
230
+ // undefined when the native handler fires.
231
+ let state_this_prop = Regex::new(r"\$\.this\.(\w+)").unwrap();
232
+ Self::rewrite_node_recursive(node, &binding_regex, &this_prop, &state_this_prop, component_id);
233
+ }
207
234
 
208
- // Recursively walk the node and all descendants
209
- Self::rewrite_node_recursive(node, &this_regex, component_id);
235
+ /// Resolve `this.` to `componentId.` inside every `@[...]` binding in a string.
236
+ fn rewrite_bindings(s: &str, binding_regex: &Regex, this_prop: &Regex, component_id: &str) -> String {
237
+ let replacement = format!("{}.", component_id);
238
+ binding_regex
239
+ .replace_all(s, |caps: &regex::Captures| {
240
+ this_prop.replace_all(&caps[0], replacement.as_str()).to_string()
241
+ })
242
+ .to_string()
210
243
  }
211
244
 
212
- /// Recursively rewrite this.property in text nodes and attributes
213
- fn rewrite_node_recursive(node: &Handle, regex: &Regex, component_id: &str) {
214
- // Rewrite text content
245
+ /// Recursively rewrite this.property in text, attributes, and directive comments
246
+ fn rewrite_node_recursive(node: &Handle, binding_regex: &Regex, this_prop: &Regex, state_this_prop: &Regex, component_id: &str) {
247
+ // Rewrite text content bindings
215
248
  if let NodeData::Text { ref contents } = node.data {
216
249
  let mut text = contents.borrow_mut();
217
- let new_text = regex.replace_all(&text, format!("@[{}.$1]", component_id));
218
- *text = new_text.to_string().into();
250
+ let new_text = Self::rewrite_bindings(&text, binding_regex, this_prop, component_id);
251
+ *text = new_text.into();
219
252
  }
220
253
 
221
- // Rewrite attributes
254
+ // Rewrite attribute bindings (`@[...]`) and event-handler `$.this.X` writes.
222
255
  if let NodeData::Element { ref attrs, .. } = node.data {
223
256
  let mut attrs_mut = attrs.borrow_mut();
224
257
  for attr in attrs_mut.iter_mut() {
225
- let new_value = regex.replace_all(&attr.value, format!("@[{}.$1]", component_id));
226
- attr.value = new_value.to_string().into();
258
+ let mut new_value = Self::rewrite_bindings(&attr.value, binding_regex, this_prop, component_id);
259
+ if new_value.contains("$.this.") {
260
+ new_value = state_this_prop
261
+ .replace_all(&new_value, |c: &regex::Captures| format!("$.{}.{}", component_id, &c[1]))
262
+ .to_string();
263
+ }
264
+ attr.value = new_value.into();
265
+ }
266
+ }
267
+
268
+ // Directive comments are immutable in the RcDom, so swap any if/each/else-if
269
+ // comment carrying a `this.` expression for a freshly-built comment node.
270
+ {
271
+ let mut children = node.children.borrow_mut();
272
+ for child in children.iter_mut() {
273
+ if let NodeData::Comment { ref contents } = child.data {
274
+ let text = contents.to_string();
275
+ let trimmed = text.trim_start();
276
+ let is_directive = trimmed.starts_with("if ")
277
+ || trimmed.starts_with("each ")
278
+ || trimmed.starts_with("else if ");
279
+ if is_directive && text.contains("this.") {
280
+ let new_text = this_prop
281
+ .replace_all(&text, format!("{}.", component_id).as_str())
282
+ .to_string();
283
+ *child = Node::new(NodeData::Comment { contents: new_text.into() });
284
+ }
285
+ }
227
286
  }
228
287
  }
229
288
 
230
289
  // Recurse into children
231
290
  for child in node.children.borrow().iter() {
232
- Self::rewrite_node_recursive(child, regex, component_id);
291
+ Self::rewrite_node_recursive(child, binding_regex, this_prop, state_this_prop, component_id);
233
292
  }
234
293
  }
235
294
  }
@@ -238,6 +297,10 @@ impl ComponentTagger {
238
297
  mod tests {
239
298
  use super::*;
240
299
 
300
+
301
+
302
+
303
+
241
304
  #[test]
242
305
  fn tag_single_component() {
243
306
  let html = r#"<!DOCTYPE html><html><body><component><script>component({ count: 0 })</script><div>@[this.count]</div></component></body></html>"#;
@@ -248,6 +311,40 @@ mod tests {
248
311
  assert!(result.html.contains("data-vibe-component-id=\"_c0\""));
249
312
  }
250
313
 
314
+ #[test]
315
+ fn rewrites_this_in_directive_comments_and_nested_bindings() {
316
+ // Conditionals/iterations keep `this.` in their directive comments and
317
+ // bindings can be nested (this.x.y). Compiled (manifest-restored) pages
318
+ // can't resolve `this.` via a runtime wrapper-tag lookup, so the compiler
319
+ // must resolve it to the component id at build time.
320
+ let html = r#"<!DOCTYPE html><html><body><component><script>const s = { x: null }; component(s);</script><page-content><!-- if this.x --><span>@[this.x.name]</span><!-- /if --><!-- each this.items as it --><b>@[it]</b><!-- /each --></page-content></component></body></html>"#;
321
+
322
+ let result = ComponentTagger::tag_components(html, &PathBuf::from(".")).unwrap();
323
+
324
+ assert!(result.html.contains("if _c0.x"), "if comment not rewritten: {}", result.html);
325
+ assert!(result.html.contains("@[_c0.x.name]"), "nested binding not rewritten: {}", result.html);
326
+ assert!(result.html.contains("each _c0.items as it"), "each comment not rewritten: {}", result.html);
327
+ // A non-this global expression must be left alone.
328
+ assert!(!result.html.contains("_c0.items as _c0"), "over-rewrote loop alias: {}", result.html);
329
+ }
330
+
331
+ #[test]
332
+ fn tag_component_called_with_variable() {
333
+ // BrawlerDetailContent shape: state held in a `const`, passed to
334
+ // component(state) as a bare identifier, with a top-level
335
+ // `<!-- if this.X -->`. The wrapper must still be tagged so the
336
+ // conditional can resolve `this.` at runtime.
337
+ let html = r#"<!DOCTYPE html><html><body><component><script>const state = { currentCharacter: null }; component(state);</script><page-content><!-- if this.currentCharacter --><span>@[this.currentCharacter]</span><!-- /if --></page-content></component></body></html>"#;
338
+
339
+ let result = ComponentTagger::tag_components(html, &PathBuf::from(".")).unwrap();
340
+
341
+ assert!(
342
+ result.html.contains("data-vibe-component-id=\"_c0\""),
343
+ "wrapper not tagged; html: {}",
344
+ result.html
345
+ );
346
+ }
347
+
251
348
  #[test]
252
349
  fn tag_multiple_components() {
253
350
  let html = r#"<!DOCTYPE html><html><body><component></component><component></component><component></component></body></html>"#;
@@ -554,6 +554,63 @@ mod tests {
554
554
  assert_eq!(state["c"].as_f64().unwrap(), 3.0);
555
555
  }
556
556
 
557
+ #[test]
558
+ fn test_component_called_with_variable() {
559
+ // component(state) — the whole argument is a variable bound to an object
560
+ // literal above (BrawlerDetailContent does exactly this). The extractor
561
+ // must resolve the variable; otherwise the component wrapper is never
562
+ // tagged and its `this.` conditionals can't resolve at runtime.
563
+ let mut analyzer = JsAnalyzer::new(PathBuf::from("."));
564
+ let script = r#"
565
+ const state = {
566
+ currentCharacter: null,
567
+ currentSlots: [],
568
+ currentLastTickEnd: 0,
569
+ };
570
+ setupCharacter(state);
571
+ const id = component(state);
572
+ "#;
573
+
574
+ let state = analyzer.extract_state(script).expect("state should be extracted");
575
+ assert!(state.get("currentCharacter").is_some(), "got: {state}");
576
+ assert_eq!(state["currentLastTickEnd"].as_f64().unwrap(), 0.0);
577
+ }
578
+
579
+ #[test]
580
+ fn test_component_var_amid_realistic_script() {
581
+ // Mirrors BrawlerDetailContent's script shape: top-level imports, a
582
+ // dynamic import().then(), an empty `catch {}`, optional chaining, and
583
+ // `component(state)`. A parse failure on any of these makes extract_state
584
+ // return None and the component goes untagged.
585
+ let mut analyzer = JsAnalyzer::new(PathBuf::from("."));
586
+ let script = r#"
587
+ import CHARACTERS from '/js/constants/CHARACTERS.js';
588
+ import { buildEquipmentSlots, unequip } from '/js/equipment.js';
589
+
590
+ const backParam = new URLSearchParams(location.search).get('back') || '';
591
+ const state = {
592
+ currentCharacter: null,
593
+ currentSlots: [],
594
+ currentLastTickEnd: 0,
595
+ backUrl: backParam.startsWith('/') ? backParam : '',
596
+ };
597
+
598
+ const setupCharacter = (target) => {
599
+ const ref = $.characters?.[0];
600
+ try { ref.foo(); } catch {}
601
+ target.currentCharacter = ref;
602
+ };
603
+
604
+ setupCharacter(state);
605
+ const id = component(state);
606
+
607
+ import('/js/dnd.js').then(({ default: dnd }) => { dnd.init(); });
608
+ "#;
609
+
610
+ let state = analyzer.extract_state(script).expect("state should be extracted");
611
+ assert!(state.get("currentCharacter").is_some(), "got: {state}");
612
+ }
613
+
557
614
  #[test]
558
615
  fn test_absolute_path_import() {
559
616
  let mut analyzer = JsAnalyzer::new(PathBuf::from("/tmp"));
@@ -128,11 +128,11 @@ fn to_kebab_case(s: &str) -> String {
128
128
 
129
129
  /// Check if a path should be blacklisted based on SKIP_FILES patterns
130
130
  /// This checks both the filename and all path components relative to source root
131
- fn is_path_blacklisted(path: &Path, source_root: &Path) -> bool {
131
+ fn is_path_blacklisted(path: &Path, source_root: &Path, skip_files: &[String]) -> bool {
132
132
  let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
133
133
 
134
134
  // Check filename against blacklist
135
- if should_skip_path(path, file_name) {
135
+ if should_skip_path(path, file_name, skip_files) {
136
136
  return true;
137
137
  }
138
138
 
@@ -140,7 +140,7 @@ fn is_path_blacklisted(path: &Path, source_root: &Path) -> bool {
140
140
  if let Ok(relative) = path.strip_prefix(source_root) {
141
141
  for component in relative.components() {
142
142
  if let Some(component_str) = component.as_os_str().to_str() {
143
- if should_skip_path(path, component_str) {
143
+ if should_skip_path(path, component_str, skip_files) {
144
144
  return true;
145
145
  }
146
146
  }
@@ -159,7 +159,7 @@ pub fn build_dependency_graph(config: &Config) -> std::result::Result<Dependency
159
159
  .unwrap_or_else(|_| config.output.clone());
160
160
 
161
161
  // Scan all HTML files in source directory
162
- scan_directory(&config.source, &config.source, &config.components, &canonical_output, &mut graph)?;
162
+ scan_directory(&config.source, &config.source, &config.components, &canonical_output, &config.skip_files, &mut graph)?;
163
163
 
164
164
  Ok(graph)
165
165
  }
@@ -169,6 +169,7 @@ fn scan_directory(
169
169
  source_root: &Path,
170
170
  components_dir: &str,
171
171
  output_dir: &Path,
172
+ skip_files: &[String],
172
173
  graph: &mut DependencyGraph,
173
174
  ) -> std::result::Result<(), std::io::Error> {
174
175
  if !dir.is_dir() {
@@ -190,11 +191,11 @@ fn scan_directory(
190
191
  }
191
192
 
192
193
  // Skip directories using shared skip logic
193
- if should_skip_path(&path, file_name) {
194
+ if should_skip_path(&path, file_name, skip_files) {
194
195
  continue;
195
196
  }
196
197
 
197
- scan_directory(&path, source_root, components_dir, output_dir, graph)?;
198
+ scan_directory(&path, source_root, components_dir, output_dir, skip_files, graph)?;
198
199
  } else if let Some(ext) = path.extension() {
199
200
  if ext == "html" {
200
201
  let html = std::fs::read_to_string(&path)?;
@@ -354,7 +355,7 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
354
355
  for event in events {
355
356
  for path in &event.paths {
356
357
  // Skip blacklisted files/directories (check entire path, not just filename)
357
- if is_path_blacklisted(path, &config.source) {
358
+ if is_path_blacklisted(path, &config.source, &config.skip_files) {
358
359
  continue;
359
360
  }
360
361
 
@@ -381,7 +382,7 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
381
382
  }
382
383
 
383
384
  // Skip blacklisted files/directories
384
- if is_path_blacklisted(path, &config.source) {
385
+ if is_path_blacklisted(path, &config.source, &config.skip_files) {
385
386
  continue;
386
387
  }
387
388
 
@@ -487,7 +488,7 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
487
488
  }
488
489
 
489
490
  // Skip blacklisted files/directories
490
- if is_path_blacklisted(path, &config.source) {
491
+ if is_path_blacklisted(path, &config.source, &config.skip_files) {
491
492
  continue;
492
493
  }
493
494
 
@@ -35,6 +35,8 @@ pub struct VibeCompilerConfig {
35
35
  #[serde(default)]
36
36
  pub reserved_elements: Vec<String>,
37
37
  #[serde(default)]
38
+ pub skip_files: Vec<String>,
39
+ #[serde(default)]
38
40
  pub node_modules_as_is: bool,
39
41
  #[serde(default)]
40
42
  pub components_as_is: bool,
@@ -96,6 +98,7 @@ impl Default for VibeCompilerConfig {
96
98
  elements_as_is: false,
97
99
  source_maps: false,
98
100
  reserved_elements: vec![],
101
+ skip_files: vec![],
99
102
  node_modules_as_is: false,
100
103
  components_as_is: false,
101
104
  runtime_as_is: false,
@@ -115,11 +118,12 @@ pub struct Config {
115
118
  pub components: String,
116
119
  pub pages: String,
117
120
  pub _assets: String,
118
- pub _root: Option<String>,
121
+ pub root: Option<String>,
119
122
  pub minify: bool,
120
123
  pub elements_as_is: bool,
121
124
  pub source_maps: bool,
122
125
  pub reserved_elements: Vec<String>,
126
+ pub skip_files: Vec<String>,
123
127
  pub node_modules_as_is: bool,
124
128
  pub components_as_is: bool,
125
129
  pub runtime_as_is: bool,
@@ -154,6 +158,13 @@ impl Config {
154
158
  let mut reserved_elements = get_default_reserved_elements();
155
159
  reserved_elements.extend(config.reserved_elements);
156
160
 
161
+ // Combine built-in skip patterns with user-provided ones (append, not replace)
162
+ let mut skip_files: Vec<String> = crate::compiler::compile::SKIP_FILES
163
+ .iter()
164
+ .map(|s| s.to_string())
165
+ .collect();
166
+ skip_files.extend(config.skip_files);
167
+
157
168
  Self {
158
169
  source,
159
170
  output,
@@ -162,11 +173,12 @@ impl Config {
162
173
  components: config.components,
163
174
  pages: config.pages,
164
175
  _assets: config.assets,
165
- _root: config.root,
176
+ root: config.root,
166
177
  minify: config.minify,
167
178
  elements_as_is: config.elements_as_is,
168
179
  source_maps: config.source_maps,
169
180
  reserved_elements,
181
+ skip_files,
170
182
  node_modules_as_is: config.node_modules_as_is,
171
183
  components_as_is: config.components_as_is,
172
184
  runtime_as_is: config.runtime_as_is,
@@ -204,6 +204,14 @@ fn main() {
204
204
  }
205
205
  }
206
206
 
207
+ fn format_list_preview(items: &[String]) -> String {
208
+ if items.len() <= 2 {
209
+ format!("{:?}", items)
210
+ } else {
211
+ format!("[{:?}, {:?}, ... + {} more]", items[0], items[1], items.len() - 2)
212
+ }
213
+ }
214
+
207
215
  // Alphabetically ordered with padding (longest key is "reservedElements" = 16 chars)
208
216
  println!(" {}: {}", format!("{:<16}", "assets").cyan(), format_value_no_flag(&config._assets));
209
217
  println!(" {}: {}", format!("{:<16}", "components").cyan(), format_value_no_flag(&config.components));
@@ -215,8 +223,9 @@ fn main() {
215
223
  println!(" {}: {}", format!("{:<16}", "output").cyan(), format_value_no_flag(&config._output_str));
216
224
  println!(" {}: {}", format!("{:<16}", "pages").cyan(), format_value_no_flag(&config.pages));
217
225
  println!(" {}: {}", format!("{:<16}", "reservedElements").cyan(), format_value_no_flag(format_reserved_elements(&config.reserved_elements)));
218
- println!(" {}: {}", format!("{:<16}", "root").cyan(), format_value_no_flag(config._root.as_ref().map(|s| s.as_str()).unwrap_or("null")));
226
+ println!(" {}: {}", format!("{:<16}", "root").cyan(), format_value_no_flag(config.root.as_ref().map(|s| s.as_str()).unwrap_or("null")));
219
227
  println!(" {}: {}", format!("{:<16}", "runtimeAsIs").cyan(), format_bool_with_flag(config.runtime_as_is, overrides.runtime_as_is, original_runtime_as_is));
228
+ println!(" {}: {}", format!("{:<16}", "skipFiles").cyan(), format_value_no_flag(format_list_preview(&config.skip_files)));
220
229
  println!(" {}: {}", format!("{:<16}", "source").cyan(), format_value_no_flag(&config._source_str));
221
230
  println!(" {}: {}", format!("{:<16}", "sourceMaps").cyan(), format_bool_with_flag(config.source_maps, overrides.source_maps, original_source_maps));
222
231
  println!();
@@ -261,7 +261,11 @@ impl HtmlParser {
261
261
  fn find_matching_close(content: &str, after_open: usize, tag_name: &str) -> Option<usize> {
262
262
  // (?:\s|>) ensures <component> matches but not <component-foo> (hyphen is not \s or >)
263
263
  let open_re = regex::Regex::new(&format!(r"<{}(?:\s|>|/>)", regex::escape(tag_name))).unwrap();
264
- let close_re = regex::Regex::new(&format!(r"</{}>", regex::escape(tag_name))).unwrap();
264
+ // `\s*>` tolerates whitespace before the `>` of an end tag — HTML allows
265
+ // it, and whitespace-controlled markup splits end tags across lines
266
+ // (`</component\n>`). `\s*` can't bridge into `</component-foo>` (the `-`
267
+ // is neither whitespace nor `>`), so this stays exact on the tag name.
268
+ let close_re = regex::Regex::new(&format!(r"</{}\s*>", regex::escape(tag_name))).unwrap();
265
269
 
266
270
  let mut depth = 1i32;
267
271
  let mut cursor = after_open;
@@ -744,3 +748,31 @@ fn transform_custom_tags_to_divs(content: &str, reserved_elements: &[String]) ->
744
748
 
745
749
  result
746
750
  }
751
+
752
+ #[cfg(test)]
753
+ mod tests {
754
+ use super::*;
755
+
756
+ #[test]
757
+ fn find_matching_close_tolerates_whitespace_in_end_tag() {
758
+ // Whitespace-controlled markup splits an end tag across lines, e.g.
759
+ // <icon-cell
760
+ // ><component src="x"></component
761
+ // ></icon-cell>
762
+ // which reaches the inliner as `</component\n>`. HTML permits
763
+ // whitespace before the `>` of an end tag, so the depth counter must
764
+ // treat `</component\n>` as the nested close. If it doesn't, the nested
765
+ // open is counted but never closed and the OUTER close search overshoots,
766
+ // swallowing every following sibling into the component.
767
+ let content = "<component><a><component src=\"x\"></component\n></a></component><sibling></sibling>";
768
+ let after_open = "<component>".len();
769
+
770
+ let close = HtmlParser::find_matching_close(content, after_open, "component")
771
+ .expect("outer </component> must be found");
772
+
773
+ // The match must be the OUTER close (immediately before <sibling>), not
774
+ // an overshoot past it.
775
+ assert!(content[close..].starts_with("</component>"), "matched wrong close: {:?}", &content[close..close + 14]);
776
+ assert!(content[close..].contains("<sibling>"), "sibling was swallowed into the component");
777
+ }
778
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe",
3
- "version": "2.1.1",
3
+ "version": "2.1.4",
4
4
  "type": "module",
5
5
  "description": "Runtime-first reactivity with optional compiler",
6
6
  "main": "index.js",
@@ -126,6 +126,98 @@ export const buildHyperspeedManifest = (parsedTree) => {
126
126
  let hyperspeedData = null;
127
127
  let hyperspeedDetectionAttempted = false;
128
128
 
129
+ /**
130
+ * Build the ordered list of manifest URLs to try for a page, most-likely first.
131
+ *
132
+ * `pathname` is window.location.pathname; `route` is the optional route template
133
+ * the page declares (window.__ROUTE__, e.g. "/brawlers/:index"). When the route
134
+ * marks a segment dynamic with `:param`, the compiler has collapsed that segment
135
+ * to `$` in the manifest path — so we point straight at the tokenized manifest
136
+ * instead of probing literal paths (`/brawlers/0.html.manifest.js`) that are
137
+ * guaranteed to 404. Without the route hint the original literal-first strategies
138
+ * apply unchanged. The result is de-duplicated (subdirectory pages otherwise
139
+ * produce the same candidate twice).
140
+ *
141
+ * @param {string} pathname
142
+ * @param {string|null|undefined} route
143
+ * @returns {string[]}
144
+ */
145
+ export const buildManifestCandidatePaths = (pathname, route) => {
146
+ let pagePath = pathname;
147
+
148
+ // Normalize path: handle directory URLs and missing extensions
149
+ if (pagePath.endsWith("/")) {
150
+ pagePath = pagePath + "index.html";
151
+ } else if (!pagePath.includes(".")) {
152
+ const lastSlash = pagePath.lastIndexOf("/");
153
+ const lastSegment = pagePath.substring(lastSlash + 1);
154
+ if (lastSegment && !lastSegment.includes(".")) {
155
+ pagePath = pagePath + ".html";
156
+ }
157
+ }
158
+
159
+ const pathSegments = pagePath.split("/").filter((s) => s);
160
+ if (pathSegments.length === 0) return [];
161
+
162
+ const fileName = pathSegments[pathSegments.length - 1];
163
+ const dirSegments = pathSegments.slice(0, -1);
164
+
165
+ const possiblePaths = [];
166
+
167
+ // Route-aware fast path: a `:segment` in the declared route is a dynamic param
168
+ // the compiler tokenized to `$`. Tokenize exactly those positions (params can
169
+ // sit mid-path, e.g. /a/:id/b) and try that manifest first — a direct hit, no
170
+ // 404 probing. Skipped entirely when no route is declared.
171
+ const routeSegments = route ? route.split("/").filter((s) => s) : null;
172
+ if (routeSegments && routeSegments.some((s) => s.startsWith(":"))) {
173
+ const tokenized = pathSegments.map((seg, i) => {
174
+ if (!routeSegments[i]?.startsWith(":")) return seg;
175
+ const dot = seg.indexOf(".");
176
+ return dot >= 0 ? "$" + seg.slice(dot) : "$";
177
+ });
178
+ possiblePaths.push(`/vibe-hyperspeed/${tokenized.join("/")}.manifest.js`);
179
+ }
180
+
181
+ // Strategy 1: vibe-hyperspeed at the same level as parent directory
182
+ // /compiled/playground/test.html -> /compiled/vibe-hyperspeed/playground/test.html.manifest.js
183
+ if (dirSegments.length >= 1) {
184
+ const subPath = dirSegments.slice(1).join("/"); // Everything after first dir
185
+ const baseDir = "/" + dirSegments[0]; // First directory segment
186
+ possiblePaths.push(
187
+ `${baseDir}/vibe-hyperspeed/${subPath ? subPath + "/" : ""}${fileName}.manifest.js`,
188
+ );
189
+ }
190
+
191
+ // Strategy 2: vibe-hyperspeed at web root (original behavior)
192
+ // /compiled/playground/test.html -> /vibe-hyperspeed/compiled/playground/test.html.manifest.js
193
+ possiblePaths.push(`/vibe-hyperspeed${pagePath}.manifest.js`);
194
+
195
+ // Strategy 3: vibe-hyperspeed relative to immediate parent
196
+ // /playground/test.html -> /vibe-hyperspeed/playground/test.html.manifest.js
197
+ if (dirSegments.length > 0) {
198
+ const relativePath = dirSegments.join("/");
199
+ possiblePaths.push(
200
+ `/vibe-hyperspeed/${relativePath}/${fileName}.manifest.js`,
201
+ );
202
+ }
203
+
204
+ // Strategy 4: dynamic routes without a declared route. The compiler collapses a
205
+ // `$param` segment to a single `$` token (the-arena/$id.html ->
206
+ // the-arena/$.html.manifest.js), so a concrete URL only matches once its
207
+ // trailing segment is tokenized. Tried after the literal strategies, so static
208
+ // pages still win on an exact hit.
209
+ const dot = fileName.indexOf(".");
210
+ const tokenized = dot >= 0 ? "$" + fileName.slice(dot) : "$";
211
+ if (tokenized !== fileName) {
212
+ const dirPrefix = dirSegments.length ? `/${dirSegments.join("/")}` : "";
213
+ possiblePaths.push(`/vibe-hyperspeed${dirPrefix}/${tokenized}.manifest.js`);
214
+ }
215
+
216
+ // Subdirectory pages make strategies 2 and 3 collapse to the same URL — probe
217
+ // each candidate once.
218
+ return [...new Set(possiblePaths)];
219
+ };
220
+
129
221
  /**
130
222
  * Detect page-specific manifest (async, cached after first call)
131
223
  * Returns { manifest, path } or null
@@ -143,55 +235,15 @@ const detectHyperspeed = async () => {
143
235
  const skipNetwork = !!document.querySelector("[vibe-fouc], .vibe-fouc");
144
236
 
145
237
  try {
146
- let pagePath = window.location.pathname;
147
-
148
- // Normalize path: handle directory URLs and missing extensions
149
- if (pagePath.endsWith("/")) {
150
- // /compiled/ -> /compiled/index.html
151
- pagePath = pagePath + "index.html";
152
- } else if (!pagePath.includes(".")) {
153
- // /compiled/mypage -> /compiled/mypage.html
154
- const lastSlash = pagePath.lastIndexOf("/");
155
- const lastSegment = pagePath.substring(lastSlash + 1);
156
- if (lastSegment && !lastSegment.includes(".")) {
157
- pagePath = pagePath + ".html";
158
- }
159
- }
160
-
161
- const pathSegments = pagePath.split("/").filter((s) => s);
162
-
163
- if (pathSegments.length === 0) return null;
164
-
165
- // Extract file name and directory parts
166
- // For /compiled/playground/test.html -> ['compiled', 'playground', 'test.html']
167
- const fileName = pathSegments[pathSegments.length - 1];
168
- const dirSegments = pathSegments.slice(0, -1); // All parts except filename
169
-
170
- // Build possible manifest paths
171
- const possiblePaths = [];
172
-
173
- // Strategy 1: vibe-hyperspeed at the same level as parent directory
174
- // /compiled/playground/test.html -> /compiled/vibe-hyperspeed/playground/test.html.manifest.js
175
- if (dirSegments.length >= 1) {
176
- const subPath = dirSegments.slice(1).join("/"); // Everything after first dir
177
- const baseDir = "/" + dirSegments[0]; // First directory segment
178
- possiblePaths.push(
179
- `${baseDir}/vibe-hyperspeed/${subPath ? subPath + "/" : ""}${fileName}.manifest.js`,
180
- );
181
- }
182
-
183
- // Strategy 2: vibe-hyperspeed at web root (original behavior)
184
- // /compiled/playground/test.html -> /vibe-hyperspeed/compiled/playground/test.html.manifest.js
185
- possiblePaths.push(`/vibe-hyperspeed${pagePath}.manifest.js`);
186
-
187
- // Strategy 3: vibe-hyperspeed relative to immediate parent
188
- // /playground/test.html -> /vibe-hyperspeed/playground/test.html.manifest.js
189
- if (dirSegments.length > 0) {
190
- const relativePath = dirSegments.join("/");
191
- possiblePaths.push(
192
- `/vibe-hyperspeed/${relativePath}/${fileName}.manifest.js`,
193
- );
194
- }
238
+ // window.__ROUTE__ is the page's route template (e.g. "/brawlers/:index"),
239
+ // injected by the compiler/dev server for dynamic pages. It lets us resolve
240
+ // the tokenized `$` manifest directly instead of probing literal 404s.
241
+ const possiblePaths = buildManifestCandidatePaths(
242
+ window.location.pathname,
243
+ typeof window !== "undefined" ? window.__ROUTE__ : null,
244
+ );
245
+
246
+ if (possiblePaths.length === 0) return null;
195
247
 
196
248
  if (!skipNetwork) {
197
249
  // Fully-runtime dynamic import. Hidden behind `new Function` so any
@@ -0,0 +1,58 @@
1
+ import assert from 'node:assert';
2
+
3
+ // The module runs a top-level `await detectHyperspeed()` that touches the DOM,
4
+ // so stub the globals before importing it. querySelector returns truthy →
5
+ // skipNetwork → no import() probing during module init.
6
+ globalThis.window = { location: { pathname: '/' }, __ROUTE__: undefined };
7
+ globalThis.document = { querySelector: () => ({}) };
8
+
9
+ const { buildManifestCandidatePaths } = await import('./pre-compiled-manifest.js');
10
+
11
+ let passed = 0;
12
+ const test = (name, fn) => {
13
+ fn();
14
+ passed++;
15
+ console.log(` ok - ${name}`);
16
+ };
17
+
18
+ // Dynamic route: window.__ROUTE__ tells us the trailing segment is a param, so
19
+ // the very first candidate must be the tokenized `$` manifest — no literal
20
+ // `0.html.manifest.js` probes that are guaranteed to 404.
21
+ test('dynamic route resolves the $ manifest first (no literal probing)', () => {
22
+ const paths = buildManifestCandidatePaths('/brawlers/0', '/brawlers/:index');
23
+ assert.strictEqual(paths[0], '/vibe-hyperspeed/brawlers/$.html.manifest.js');
24
+ assert.ok(
25
+ !paths.includes('/vibe-hyperspeed/brawlers/0.html.manifest.js') ||
26
+ paths.indexOf('/vibe-hyperspeed/brawlers/$.html.manifest.js') <
27
+ paths.indexOf('/vibe-hyperspeed/brawlers/0.html.manifest.js'),
28
+ 'tokenized path must come before any literal path',
29
+ );
30
+ });
31
+
32
+ // Mid-path params tokenize by position, not just the filename.
33
+ test('tokenizes only the param segments named by the route', () => {
34
+ const paths = buildManifestCandidatePaths(
35
+ '/_internal/characters/troll',
36
+ '/_internal/characters/:key',
37
+ );
38
+ assert.strictEqual(
39
+ paths[0],
40
+ '/vibe-hyperspeed/_internal/characters/$.html.manifest.js',
41
+ );
42
+ });
43
+
44
+ // Static page: no route hint, behaviour unchanged, no duplicate candidates.
45
+ test('static top-level page resolves cleanly with no duplicates', () => {
46
+ const paths = buildManifestCandidatePaths('/armory', null);
47
+ assert.ok(paths.includes('/vibe-hyperspeed/armory.html.manifest.js'));
48
+ assert.strictEqual(paths.length, new Set(paths).size, 'no duplicate candidates');
49
+ });
50
+
51
+ // Subdirectory static page: strategies 2 and 3 collapse to the same URL — it
52
+ // must be probed once, not twice.
53
+ test('subdirectory page dedupes identical candidates', () => {
54
+ const paths = buildManifestCandidatePaths('/foo/bar', null);
55
+ assert.strictEqual(paths.length, new Set(paths).size, 'no duplicate candidates');
56
+ });
57
+
58
+ console.log(`\n${passed} passed`);