@ape-egg/vibe 2.1.1 → 2.1.3

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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.1.3] - 2026-06-18
4
+
5
+ ### Added
6
+
7
+ - **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`.
8
+ - 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.
9
+
10
+ ## [2.1.2] - 2026-06-18
11
+
12
+ ### Fixed
13
+
14
+ - **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`.
15
+
3
16
  ## [2.1.1] - 2026-06-18
4
17
 
5
18
  ### 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.0"
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.0"
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
 
@@ -1945,7 +1985,16 @@ fn minify_html(html: &str) -> String {
1945
1985
  if in_pre {
1946
1986
  result.push_str(line);
1947
1987
  result.push('\n');
1988
+ last_was_space = false;
1948
1989
  } else {
1990
+ // The line break preceding this line is whitespace: collapse it to a
1991
+ // single space so attributes/text split across lines don't glue
1992
+ // together (e.g. a multi-line <meta name=... content=...> tag).
1993
+ // The >\s+< pass below re-tightens genuine tag boundaries.
1994
+ if !last_was_space && !result.is_empty() {
1995
+ result.push(' ');
1996
+ last_was_space = true;
1997
+ }
1949
1998
  for c in trimmed.chars() {
1950
1999
  if c.is_whitespace() {
1951
2000
  if !last_was_space {
@@ -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!();
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.3",
4
4
  "type": "module",
5
5
  "description": "Runtime-first reactivity with optional compiler",
6
6
  "main": "index.js",
@@ -193,6 +193,17 @@ const detectHyperspeed = async () => {
193
193
  );
194
194
  }
195
195
 
196
+ // Strategy 4: dynamic routes. The compiler collapses a `$param` segment to a
197
+ // single `$` token (the-arena/$id.html -> the-arena/$.html.manifest.js), so a
198
+ // concrete URL only matches once its trailing segment is tokenized. Tried
199
+ // after the literal strategies, so static pages still win on an exact hit.
200
+ const dot = fileName.indexOf(".");
201
+ const tokenized = dot >= 0 ? "$" + fileName.slice(dot) : "$";
202
+ if (tokenized !== fileName) {
203
+ const dirPrefix = dirSegments.length ? `/${dirSegments.join("/")}` : "";
204
+ possiblePaths.push(`/vibe-hyperspeed${dirPrefix}/${tokenized}.manifest.js`);
205
+ }
206
+
196
207
  if (!skipNetwork) {
197
208
  // Fully-runtime dynamic import. Hidden behind `new Function` so any
198
209
  // bundler's static-analysis can't read into it — there's nothing we