@ape-egg/vibe-compiler 4.1.4 → 4.2.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.
Binary file
Binary file
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe-compiler",
3
- "version": "4.1.4",
3
+ "version": "4.2.0",
4
4
  "type": "module",
5
- "description": "Optional Rust compiler for Vibe — pre-renders, inlines components, and optimizes iterations. The runtime never needs it.",
5
+ "description": "Optional Rust compiler for Vibe \u2014 pre-renders, inlines components, and optimizes iterations. The runtime never needs it.",
6
6
  "homepage": "https://vibe.korte.kim",
7
7
  "bin": {
8
8
  "vibe": "./bin/vibe-compile.js"
@@ -14,6 +14,8 @@
14
14
  "src/Cargo.lock",
15
15
  "src/main.rs",
16
16
  "src/config.rs",
17
+ "src/constants.rs",
18
+ "src/inert.rs",
17
19
  "src/compiler",
18
20
  "src/parser"
19
21
  ],
package/src/Cargo.lock CHANGED
@@ -1599,7 +1599,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
1599
1599
 
1600
1600
  [[package]]
1601
1601
  name = "vibe-compiler"
1602
- version = "4.1.4"
1602
+ version = "4.2.0"
1603
1603
  dependencies = [
1604
1604
  "clap",
1605
1605
  "colored",
package/src/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "vibe-compiler"
3
- version = "4.1.4"
3
+ version = "4.2.0"
4
4
  edition = "2021"
5
5
  description = "Vibe framework compiler - compiles Vibe source files into optimized output"
6
6
  authors = ["Kim Korte"]
@@ -59,8 +59,7 @@ pub(crate) fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
59
59
  // or a more sophisticated asset pipeline.
60
60
  // =============================================================================
61
61
  // FOUC prevention class/attribute name (matches runtime/constants.js)
62
- // Can be used as class (.vibe-fouc) or attribute ([vibe-fouc])
63
- const FOUC_CLASS_OR_ATTR: &str = "vibe-fouc";
62
+ use crate::constants::{FOUC_ATTR as FOUC_CLASS_OR_ATTR, CSS_COVER_ATTR, CSS_LAYOUT_ATTR, RECURSIVE_SRC_ATTR, HYPERSPEED_DIR, MANIFEST_SUFFIX, SPA_FRAGMENTS_DIR};
64
63
 
65
64
  const VIBE_CSS_FILE: &str = include_str!("../../../vibe/vibe.css");
66
65
  const VIBE_CSS_OPTIONAL_MARKER: &str =
@@ -234,7 +233,7 @@ fn extract_event_handlers(html: &str) -> Vec<String> {
234
233
  .collect()
235
234
  }
236
235
 
237
- /// Rename `src="{component_src}"` to `data-vibe-recursive-src="{component_src}"`
236
+ /// Rename `src="{component_src}"` to `data-vibe-internal-recursive-src="{component_src}"`
238
237
  /// on `<component>` / `<div class="component">` tags. Used during cache build
239
238
  /// to neutralize cyclic refs before they reach `inline_component_elements` —
240
239
  /// the inliner's `src=` regex won't match the renamed attribute, so it stops
@@ -246,7 +245,7 @@ fn escape_recursive_src(content: &str, component_src: &str) -> String {
246
245
  regex::escape(component_src),
247
246
  );
248
247
  let re = Regex::new(&pattern).unwrap();
249
- re.replace_all(content, format!(r#"$1data-vibe-recursive-src="{}""#, component_src))
248
+ re.replace_all(content, format!(r#"$1{}="{}""#, RECURSIVE_SRC_ATTR, component_src))
250
249
  .into_owned()
251
250
  }
252
251
 
@@ -279,7 +278,7 @@ fn remove_fouc_prevention(html: String) -> String {
279
278
  }
280
279
 
281
280
  pub fn inject_vibe_css(html: String, disable_vibe_css: bool) -> String {
282
- if html.contains("data-vibe-cover") {
281
+ if html.contains(CSS_COVER_ATTR) {
283
282
  return html;
284
283
  }
285
284
  let open_end = match html.find("<head") {
@@ -290,9 +289,9 @@ pub fn inject_vibe_css(html: String, disable_vibe_css: bool) -> String {
290
289
  None => return html,
291
290
  };
292
291
  let (cover_css, layout_css) = vibe_css_sheets();
293
- let mut style = format!("<style data-vibe-cover>{}</style>", cover_css);
292
+ let mut style = format!("<style {}>{}</style>", CSS_COVER_ATTR, cover_css);
294
293
  if !disable_vibe_css {
295
- style.push_str(&format!("<style data-vibe-css>{}</style>", layout_css));
294
+ style.push_str(&format!("<style {}>{}</style>", CSS_LAYOUT_ATTR, layout_css));
296
295
  }
297
296
  let mut result = String::with_capacity(html.len() + style.len());
298
297
  result.push_str(&html[..open_end]);
@@ -1084,7 +1083,7 @@ impl Compiler {
1084
1083
 
1085
1084
  // Tag components with deterministic IDs and structure state
1086
1085
  let tagged = ComponentTagger::tag_components(&protected, &source_root.to_path_buf())?;
1087
- let html = &tagged.html; // Use modified HTML with data-vibe-component-id attributes
1086
+ let html = &tagged.html; // Use modified HTML with data-vibe-internal-component-id attributes
1088
1087
  let state = tagged.state;
1089
1088
 
1090
1089
  // Build manifest from pre-stamp HTML (both conditional branches present, @[...] markers intact)
@@ -1101,8 +1100,8 @@ impl Compiler {
1101
1100
 
1102
1101
  // Write manifest
1103
1102
  let manifest_path = output_dir
1104
- .join("vibe-hyperspeed")
1105
- .join(format!("{}.manifest.js", manifest_rel));
1103
+ .join(HYPERSPEED_DIR)
1104
+ .join(format!("{}{}", manifest_rel, MANIFEST_SUFFIX));
1106
1105
 
1107
1106
  // Ensure directory exists
1108
1107
  if let Some(parent) = manifest_path.parent() {
@@ -1572,7 +1571,7 @@ impl Compiler {
1572
1571
  pages_dir.display()
1573
1572
  )));
1574
1573
  }
1575
- let reserved = self.config.components_path().join("vibe-spa");
1574
+ let reserved = self.config.components_path().join(SPA_FRAGMENTS_DIR);
1576
1575
  if reserved.exists() {
1577
1576
  return Err(CompileError::SpaError(format!(
1578
1577
  "{} is reserved for compiled page fragments — move or rename it",
@@ -1686,7 +1685,7 @@ impl Compiler {
1686
1685
  .config
1687
1686
  .output
1688
1687
  .join("components")
1689
- .join("vibe-spa")
1688
+ .join(SPA_FRAGMENTS_DIR)
1690
1689
  .join(&rel);
1691
1690
  if let Some(parent) = fragment_path.parent() {
1692
1691
  fs::create_dir_all(parent).map_err(|_| {
@@ -1733,7 +1732,7 @@ impl Compiler {
1733
1732
 
1734
1733
  // The fragments dir mirrors the pages tree: prune fragments whose
1735
1734
  // page no longer exists (watch-mode page removals, no-clean runs).
1736
- let fragments_root = self.config.output.join("components").join("vibe-spa");
1735
+ let fragments_root = self.config.output.join("components").join(SPA_FRAGMENTS_DIR);
1737
1736
  let live: HashSet<String> = pages.iter().map(|p| p.rel_path.clone()).collect();
1738
1737
  let mut existing = Vec::new();
1739
1738
  Self::collect_page_files(&fragments_root, &self.config.skip_files, &mut existing);
@@ -2040,7 +2039,7 @@ impl Compiler {
2040
2039
  /// `<component src>` that points back into the chain is a cycle (direct
2041
2040
  /// self-reference, or A→B→A) — those are valid runtime patterns (recursive
2042
2041
  /// tree components bounded by data depth) but would expand infinitely at
2043
- /// compile time. Cycle references are escaped to `data-vibe-recursive-src`
2042
+ /// compile time. Cycle references are escaped to `data-vibe-internal-recursive-src`
2044
2043
  /// in the cached content so the inliner's `src=` regex won't keep matching
2045
2044
  /// them; `process_html_with_cache` restores the attribute name at the end
2046
2045
  /// of compilation so the runtime sees a normal `<component src>` tag.
@@ -10,6 +10,7 @@ use std::path::PathBuf;
10
10
  use std::cell::RefCell;
11
11
  use std::sync::OnceLock;
12
12
  use crate::compiler::state_extractor::StateExtractor;
13
+ use crate::constants::{COMPONENT_ID_ATTR, COMPONENT_ID_PREFIX};
13
14
 
14
15
  /// Matches a `component(...)` state-registration call in a component's inline
15
16
  /// script. The `\b` keeps it from firing on `subcomponent(`; resolvable or not,
@@ -136,7 +137,7 @@ impl ComponentTagger {
136
137
  })
137
138
  }
138
139
 
139
- /// Recursively walk DOM, add data-vibe-component-id, and extract component state
140
+ /// Recursively walk DOM, add data-vibe-internal-component-id, and extract component state
140
141
  fn walk_tag_and_extract(
141
142
  node: &Handle,
142
143
  counter: &RefCell<usize>,
@@ -186,16 +187,16 @@ impl ComponentTagger {
186
187
  _ => serde_json::Map::new(),
187
188
  };
188
189
 
189
- let component_id = format!("_c{}", *counter.borrow());
190
+ let component_id = format!("{}{}", COMPONENT_ID_PREFIX, *counter.borrow());
190
191
  *counter.borrow_mut() += 1;
191
192
 
192
- // Add data-vibe-component-id attribute
193
+ // Add data-vibe-internal-component-id attribute
193
194
  let mut attrs_mut = attrs.borrow_mut();
194
195
  attrs_mut.push(markup5ever::Attribute {
195
196
  name: QualName::new(
196
197
  None,
197
198
  Namespace::from(""),
198
- LocalName::from("data-vibe-component-id"),
199
+ LocalName::from(COMPONENT_ID_ATTR),
199
200
  ),
200
201
  value: component_id.clone().into(),
201
202
  });
@@ -284,7 +285,7 @@ impl ComponentTagger {
284
285
  /// expressions) and inside if/each/else-if directive comments. Resolving the
285
286
  /// directives at build time is what lets a component-local `<!-- if this.x -->`
286
287
  /// work on compiled (manifest-restored) pages, where the runtime can't fall
287
- /// back to a `data-vibe-component-id` ancestor lookup.
288
+ /// back to a `data-vibe-internal-component-id` ancestor lookup.
288
289
  fn rewrite_this_to_component_id(node: &Handle, component_id: &str) {
289
290
  use regex::Regex;
290
291
  // The whole `@[...]` binding; `this.` is resolved within it so nested
@@ -381,8 +382,8 @@ mod tests {
381
382
 
382
383
  let result = ComponentTagger::tag_components(html, &PathBuf::from(".")).unwrap();
383
384
 
384
- // Should have data-vibe-component-id in output
385
- assert!(result.html.contains("data-vibe-component-id=\"_c0\""));
385
+ // Should have data-vibe-internal-component-id in output
386
+ assert!(result.html.contains("data-vibe-internal-component-id=\"_c0\""));
386
387
  }
387
388
 
388
389
  #[test]
@@ -413,7 +414,7 @@ mod tests {
413
414
  let result = ComponentTagger::tag_components(html, &PathBuf::from(".")).unwrap();
414
415
 
415
416
  assert!(
416
- result.html.contains("data-vibe-component-id=\"_c0\""),
417
+ result.html.contains("data-vibe-internal-component-id=\"_c0\""),
417
418
  "wrapper not tagged; html: {}",
418
419
  result.html
419
420
  );
@@ -431,7 +432,7 @@ mod tests {
431
432
  let result = ComponentTagger::tag_components(html, &PathBuf::from(".")).unwrap();
432
433
 
433
434
  assert!(
434
- result.html.contains("data-vibe-component-id=\"_c0\""),
435
+ result.html.contains("data-vibe-internal-component-id=\"_c0\""),
435
436
  "runtime-built-state component not tagged: {}",
436
437
  result.html
437
438
  );
@@ -475,8 +476,8 @@ mod tests {
475
476
  let result = ComponentTagger::tag_components(html, &PathBuf::from(".")).unwrap();
476
477
 
477
478
  // Should have _c0, _c1, _c2
478
- assert!(result.html.contains("data-vibe-component-id=\"_c0\""));
479
- assert!(result.html.contains("data-vibe-component-id=\"_c1\""));
480
- assert!(result.html.contains("data-vibe-component-id=\"_c2\""));
479
+ assert!(result.html.contains("data-vibe-internal-component-id=\"_c0\""));
480
+ assert!(result.html.contains("data-vibe-internal-component-id=\"_c1\""));
481
+ assert!(result.html.contains("data-vibe-internal-component-id=\"_c2\""));
481
482
  }
482
483
  }
@@ -1,6 +1,7 @@
1
1
  use regex::Regex;
2
2
  use serde::{Serialize, Deserialize};
3
3
  use std::collections::HashMap;
4
+ use crate::constants::NAMEBIND_ATTR;
4
5
 
5
6
  #[derive(Debug, Clone, Serialize, Deserialize)]
6
7
  #[serde(rename_all = "camelCase")]
@@ -157,12 +158,12 @@ const VALUE_ATTRS: &[&str] = &[
157
158
 
158
159
  /// Whether an attribute keeps its string value (vs boolean coercion).
159
160
  /// Mirrors isValueStyleAttr in runtime/iterate.js.
160
- /// Turn ` data-vibe-namebind="@[A]@[B]"` back into ` @[A]="" @[B]=""` so each relocated
161
+ /// Turn ` data-vibe-internal-namebind="@[A]@[B]"` back into ` @[A]="" @[B]=""` so each relocated
161
162
  /// binding is rendered as a name-binding (` resolvedName=""`) rather than having its
162
163
  /// resolved value written into the transport attribute. See name_binding_protect.
163
164
  fn restore_relocated_name_bindings(template: &str) -> String {
164
165
  let wrapper = Regex::new(
165
- r#"\s+data-vibe-namebind="((?:@\[(?:[^\[\]'"]|\[[^\]]*\]|'[^']*'|"[^"]*")+\])+)""#,
166
+ &format!(r#"\s+{}="((?:@\[(?:[^\[\]'"]|\[[^\]]*\]|'[^']*'|"[^"]*")+\])+)""#, NAMEBIND_ATTR),
166
167
  )
167
168
  .unwrap();
168
169
  let one = Regex::new(r#"@\[(?:[^\[\]'"]|\[[^\]]*\]|'[^']*'|"[^"]*")+\]"#).unwrap();
@@ -230,7 +231,7 @@ fn compile_template_to_batch_fn(
230
231
  /// Anything the runtime clone+hydrate path renders, this output has to render
231
232
  /// identically — it is the compiled twin of compileBatchFn in runtime/iterate.js.
232
233
  fn emit_template_literal(template: &str) -> String {
233
- // Restore name-bindings the compiler relocated into `data-vibe-namebind` (their
234
+ // Restore name-bindings the compiler relocated into `data-vibe-internal-namebind` (their
234
235
  // expression has whitespace, so it could not be an HTML attribute name) back to the
235
236
  // `@[expr]=""` form the binding pass below renders as ` resolvedName=""`. The compiled
236
237
  // twin of the same step in runtime/iterate.js compileBatchFn.
@@ -139,7 +139,7 @@ impl ManifestBuilder {
139
139
  let attr_value = attr.value.to_string();
140
140
 
141
141
  // A relocated name-binding: its expression(s) live verbatim in the
142
- // VALUE of data-vibe-namebind because whitespace barred them from
142
+ // VALUE of data-vibe-internal-namebind because whitespace barred them from
143
143
  // attribute-name position. Treat the value as the name-binding — no
144
144
  // case restoration needed (value attrs keep their case).
145
145
  if attr_name == crate::compiler::name_binding_protect::NAME_BIND_ATTR {
@@ -4,7 +4,7 @@ use std::sync::OnceLock;
4
4
  /// The attribute a relocated name-binding is parked in. Mirrors the runtime
5
5
  /// constant of the same name (runtime/constants.js) — the manifest builder reads
6
6
  /// the binding back out of it, and the runtime consumes + strips it on hydrate.
7
- pub const NAME_BIND_ATTR: &str = "data-vibe-namebind";
7
+ pub const NAME_BIND_ATTR: &str = crate::constants::NAMEBIND_ATTR;
8
8
 
9
9
  /// A name-binding lives in attribute-NAME position: `<icon @[expr]>`. HTML parsers
10
10
  /// split an attribute token on whitespace, so once a prop is inlined into one and the
@@ -12,7 +12,7 @@ pub const NAME_BIND_ATTR: &str = "data-vibe-namebind";
12
12
  /// — html5ever (and the browser) tear it into broken attributes and the binding is lost.
13
13
  ///
14
14
  /// We relocate every whitespace-bearing name-binding into a single verbatim
15
- /// value-attribute `data-vibe-namebind="@[expr]@[expr2]…"`. Value attributes are kept
15
+ /// value-attribute `data-vibe-internal-namebind="@[expr]@[expr2]…"`. Value attributes are kept
16
16
  /// byte-for-byte through parsing, so the manifest builder recovers the exact expression
17
17
  /// and the runtime evaluates it through the same path it uses in non-compiled mode —
18
18
  /// identical behaviour, no expression rewriting. Whitespace-free name-bindings already
@@ -131,7 +131,7 @@ mod tests {
131
131
  let html = r#"<icon @[EQUIPMENT(item, true).element]></icon>"#;
132
132
  let out = protect(html);
133
133
  assert!(
134
- out.contains(r#"data-vibe-namebind="@[EQUIPMENT(item, true).element]""#),
134
+ out.contains(r#"data-vibe-internal-namebind="@[EQUIPMENT(item, true).element]""#),
135
135
  "got: {}",
136
136
  out
137
137
  );
@@ -162,7 +162,7 @@ mod tests {
162
162
  let html = r#"<script>const x = "@[fn(a, b)]";</script><icon @[fn(a, b)]></icon>"#;
163
163
  let out = protect(html);
164
164
  assert!(out.contains(r#"const x = "@[fn(a, b)]";"#), "script mangled: {}", out);
165
- assert!(out.contains(r#"data-vibe-namebind="@[fn(a, b)]""#), "got: {}", out);
165
+ assert!(out.contains(r#"data-vibe-internal-namebind="@[fn(a, b)]""#), "got: {}", out);
166
166
  }
167
167
 
168
168
  #[test]
@@ -170,7 +170,7 @@ mod tests {
170
170
  let html = r#"<el @[fn(a, b)] @[gn(c, d)]></el>"#;
171
171
  let out = protect(html);
172
172
  assert!(
173
- out.contains(r#"data-vibe-namebind="@[fn(a, b)]@[gn(c, d)]""#),
173
+ out.contains(r#"data-vibe-internal-namebind="@[fn(a, b)]@[gn(c, d)]""#),
174
174
  "got: {}",
175
175
  out
176
176
  );
@@ -9,6 +9,7 @@
9
9
  // filter over pages, not a rework.
10
10
 
11
11
  use regex::Regex;
12
+ use crate::constants::{SPA_FRAGMENTS_DIR, FOUC_ATTR};
12
13
 
13
14
  /// One page of the pages tree, dissected for SPA output.
14
15
  pub struct SpaPage {
@@ -247,7 +248,7 @@ pub fn dissect_page(rel_path: &str, html: &str) -> Result<SpaPage, String> {
247
248
  Ok(SpaPage {
248
249
  rel_path: rel_path.to_string(),
249
250
  route: route_for(rel_path),
250
- src: format!("/components/vibe-spa/{}", rel_path),
251
+ src: format!("/components/{}/{}", SPA_FRAGMENTS_DIR, rel_path),
251
252
  title,
252
253
  fragment,
253
254
  head_items,
@@ -470,7 +471,7 @@ pub fn compose_shell(
470
471
  // hyperspeed manifest loading on it). The shell is compiled output:
471
472
  // strip the attribute from the union — there is nothing to flash, the
472
473
  // outlet is empty and fragments hydrate off-DOM before insertion.
473
- body_attrs.retain(|(n, _)| n != "vibe-fouc");
474
+ body_attrs.retain(|(n, _)| n != FOUC_ATTR);
474
475
 
475
476
  let body_attr_str = body_attrs
476
477
  .iter()
@@ -16,49 +16,8 @@ fn primitive_to_text(value: &Value) -> String {
16
16
  }
17
17
  }
18
18
 
19
- // Elements that should not have reactive bindings processed
20
- // Matches runtime/constants.js NON_REACTIVE_ELEMENTS
21
- const NON_REACTIVE_ELEMENTS: &[&str] = &["script", "head", "pre"];
22
-
23
- // Attributes where the string value is meaningful (should NOT be removed when falsy)
24
- // All other attributes are treated as boolean-like (removed when falsy)
25
- // Matches runtime/constants.js VALUE_ATTRS
26
- const VALUE_ATTRS: &[&str] = &[
27
- // Global attributes
28
- "class", "style", "id", "title", "lang", "dir", "tabindex", "accesskey",
29
- "slot", "part", "is", "nonce", "popover", "anchor",
30
- // Enumerated attributes
31
- "contenteditable", "draggable", "spellcheck", "translate", "autocapitalize",
32
- "inputmode", "enterkeyhint", "virtualkeyboardpolicy",
33
- // URLs and sources
34
- "href", "src", "action", "cite", "data", "poster", "srcset", "imagesrcset",
35
- "formaction", "ping", "usemap", "manifest", "codebase",
36
- // Form attributes
37
- "name", "type", "value", "placeholder", "pattern", "min", "max", "step",
38
- "minlength", "maxlength", "size", "accept", "autocomplete", "list", "form",
39
- "formmethod", "formtarget", "formenctype", "wrap", "method", "enctype", "for", "dirname",
40
- // Text/accessibility
41
- "alt", "label", "summary", "abbr",
42
- // Dimensions and layout
43
- "width", "height", "cols", "rows", "span", "rowspan", "colspan",
44
- "low", "high", "optimum",
45
- // Link/resource hints
46
- "target", "rel", "hreflang", "download", "as", "media", "charset",
47
- "crossorigin", "integrity", "loading", "decoding", "fetchpriority",
48
- "referrerpolicy", "blocking", "imagesizes", "sizes",
49
- // Media
50
- "preload", "kind", "srclang",
51
- // Meta
52
- "content", "http-equiv",
53
- // iframe/embed
54
- "sandbox", "allow", "srcdoc", "credentialless",
55
- // Table
56
- "headers", "scope",
57
- // Datetime
58
- "datetime",
59
- // Object/embed legacy
60
- "coords", "shape",
61
- ];
19
+ use crate::constants::{NON_REACTIVE_ELEMENTS, VALUE_ATTRS, DEHYDRATE_ATTR, NAMEBIND_ATTR, DEFER_ATTR_PREFIX};
20
+
62
21
 
63
22
  pub struct ValueStamper<'a> {
64
23
  state: &'a Value,
@@ -131,7 +90,7 @@ impl<'a> ValueStamper<'a> {
131
90
  pub fn stamp_html(&self, html: String) -> Result<String, String> {
132
91
  // Extract vibe-dehydrate content to protect it from processing
133
92
  // This prevents @[...] markers and iterations/conditionals from being processed
134
- let dehydrate_regex = Regex::new(r"(?s)<template[^>]*\svibe-dehydrate[^>]*>.*?</template>").unwrap();
93
+ let dehydrate_regex = Regex::new(&format!(r"(?s)<template[^>]*\s{}[^>]*>.*?</template>", DEHYDRATE_ATTR)).unwrap();
135
94
  let mut dehydrate_placeholders: Vec<String> = Vec::new();
136
95
  let mut html_with_placeholders = html.clone();
137
96
 
@@ -160,7 +119,7 @@ impl<'a> ValueStamper<'a> {
160
119
 
161
120
  // Skip elements with vibe-dehydrate attribute (content should not be processed)
162
121
  // This is a secondary protection in case placeholders aren't used
163
- let dehydrate_regex = Regex::new(r"(?s)<template[^>]*\svibe-dehydrate[^>]*>.*?</template>").unwrap();
122
+ let dehydrate_regex = Regex::new(&format!(r"(?s)<template[^>]*\s{}[^>]*>.*?</template>", DEHYDRATE_ATTR)).unwrap();
164
123
  for mat in dehydrate_regex.find_iter(&html) {
165
124
  skip_regions.push((mat.start(), mat.end()));
166
125
  }
@@ -185,52 +144,13 @@ impl<'a> ValueStamper<'a> {
185
144
  skip_regions.sort_by_key(|r| r.0);
186
145
 
187
146
  // Stamp bindings only in regions that are NOT in skip_regions
188
- let mut result = String::new();
189
- let mut last_pos = 0;
190
-
191
- for binding_match in self.binding_regex.find_iter(&html) {
192
- let match_start = binding_match.start();
193
- let match_end = binding_match.end();
194
-
195
- // Check if this binding is inside a skip region
196
- let should_skip = skip_regions.iter().any(|(start, end)| {
197
- match_start >= *start && match_end <= *end
198
- });
199
-
200
- // Add text before this match
201
- result.push_str(&html[last_pos..match_start]);
202
-
203
- if should_skip {
204
- // Keep the binding marker as-is
205
- result.push_str(binding_match.as_str());
206
- } else {
207
- // Stamp the binding
208
- let caps = self.binding_regex.captures(binding_match.as_str()).unwrap();
209
- let expr = &caps[1];
210
- // A bare reference to a proven-constant global key (`@[version]`)
211
- // stamps its build-time value. Any compound expression falls
212
- // through to normal evaluation, where dynamic globals stay absent
213
- // and the binding is left live for the runtime.
214
- if let Some(text) = self.constant_texts.get(expr.trim()) {
215
- result.push_str(text);
216
- } else {
217
- let stamped = self.eval_expression(expr)
218
- .unwrap_or_else(|| binding_match.as_str().to_string());
219
- result.push_str(&stamped);
220
- }
221
- }
222
-
223
- last_pos = match_end;
224
- }
225
-
226
- // Add remaining text
227
- result.push_str(&html[last_pos..]);
147
+ let (mut result, unresolved) = self.stamp_bindings_tracked(&html, &skip_regions);
228
148
 
229
149
  // Clean up attributes that still contain unresolved markers. Selective
230
150
  // mode keeps them: an unresolved marker there is runtime-owned state
231
151
  // (the outlet's `src="@[page.src]"`), not an absent iteration property.
232
152
  if !self.selective {
233
- result = self.cleanup_unresolved_attributes(result);
153
+ result = self.cleanup_unresolved_attributes(result, &unresolved);
234
154
  }
235
155
 
236
156
  // Restore vibe-dehydrate content from placeholders
@@ -337,14 +257,10 @@ impl<'a> ValueStamper<'a> {
337
257
  item_html = temp_stamper.render_conditionals(item_html)?;
338
258
 
339
259
  // Finally stamp all bindings in this item's context
340
- item_html = temp_stamper.binding_regex.replace_all(&item_html, |caps: &Captures| {
341
- let expr = &caps[1];
342
- temp_stamper.eval_expression(expr)
343
- .unwrap_or_else(|| caps[0].to_string())
344
- }).to_string();
260
+ let (stamped_item, item_unresolved) = temp_stamper.stamp_bindings_tracked(&item_html, &[]);
345
261
 
346
262
  // Clean up attributes with unresolved markers (e.g., data-tutorial="@[item.id]" when item.id is undefined)
347
- item_html = temp_stamper.cleanup_unresolved_attributes(item_html);
263
+ item_html = temp_stamper.cleanup_unresolved_attributes(stamped_item, &item_unresolved);
348
264
 
349
265
  rendered_items.push(item_html);
350
266
  }
@@ -744,14 +660,74 @@ impl<'a> ValueStamper<'a> {
744
660
  /// - Present with empty value when truthy (HTML5 boolean attribute syntax)
745
661
  /// Remove attributes that still contain unresolved markers (e.g., data-tutorial="@[item.id]")
746
662
  /// These occur when iterating over items where some don't have the referenced property
747
- fn cleanup_unresolved_attributes(&self, html: String) -> String {
748
- // Match attributes with marker values: attr="@[...]"
749
- // Use non-greedy match and look for closing "]" at end of attribute value
750
- let attr_with_marker = Regex::new(r#"\s+([\w-]+)="@\[.+?\]""#).unwrap();
751
-
752
- attr_with_marker.replace_all(&html, |_caps: &Captures| {
753
- // Remove the entire attribute when it has an unresolved marker
754
- String::new()
663
+ /// Stamp every binding the state can resolve, keeping the rest as markers,
664
+ /// and record where each kept marker landed in the output so cleanup can
665
+ /// tell an unresolved binding apart from resolved data that happens to
666
+ /// contain literal binding syntax.
667
+ fn stamp_bindings_tracked(&self, html: &str, skip_regions: &[(usize, usize)]) -> (String, Vec<usize>) {
668
+ let mut result = String::new();
669
+ let mut unresolved: Vec<usize> = Vec::new();
670
+ let mut last_pos = 0;
671
+
672
+ for binding_match in self.binding_regex.find_iter(html) {
673
+ let match_start = binding_match.start();
674
+ let match_end = binding_match.end();
675
+
676
+ let should_skip = skip_regions.iter().any(|(start, end)| {
677
+ match_start >= *start && match_end <= *end
678
+ });
679
+
680
+ result.push_str(&html[last_pos..match_start]);
681
+
682
+ if should_skip {
683
+ result.push_str(binding_match.as_str());
684
+ } else {
685
+ let caps = self.binding_regex.captures(binding_match.as_str()).unwrap();
686
+ let expr = &caps[1];
687
+ // A bare reference to a proven-constant global key (`@[version]`)
688
+ // stamps its build-time value. Any compound expression falls
689
+ // through to normal evaluation, where dynamic globals stay absent
690
+ // and the binding is left live for the runtime.
691
+ if let Some(text) = self.constant_texts.get(expr.trim()) {
692
+ result.push_str(text);
693
+ } else if let Some(stamped) = self.eval_expression(expr) {
694
+ result.push_str(&stamped);
695
+ } else {
696
+ unresolved.push(result.len());
697
+ result.push_str(binding_match.as_str());
698
+ }
699
+ }
700
+
701
+ last_pos = match_end;
702
+ }
703
+
704
+ result.push_str(&html[last_pos..]);
705
+ (result, unresolved)
706
+ }
707
+
708
+ fn cleanup_unresolved_attributes(&self, html: String, unresolved: &[usize]) -> String {
709
+ // An attribute holding a genuinely unresolved binding is rewritten to
710
+ // its deferred data-vibe- form, so the browser never acts on the
711
+ // literal and the runtime folds it back at hydration. Attributes whose
712
+ // markers are resolved data are left verbatim.
713
+ let attr_with_marker = Regex::new(r#"\s([\w-]+)="([^"]*@\[[^"]*)""#).unwrap();
714
+
715
+ attr_with_marker.replace_all(&html, |caps: &Captures| {
716
+ let whole = caps.get(0).unwrap();
717
+ let carries_unresolved = unresolved
718
+ .iter()
719
+ .any(|pos| *pos >= whole.start() && *pos < whole.end());
720
+ if !carries_unresolved {
721
+ return caps[0].to_string();
722
+ }
723
+ let name = &caps[1];
724
+ if name == NAMEBIND_ATTR {
725
+ return String::new();
726
+ }
727
+ if name.starts_with(DEFER_ATTR_PREFIX) {
728
+ return caps[0].to_string();
729
+ }
730
+ format!(" {}{}=\"{}\"", DEFER_ATTR_PREFIX, name, &caps[2])
755
731
  }).to_string()
756
732
  }
757
733
 
@@ -8,6 +8,7 @@ use regex::Regex;
8
8
 
9
9
  use super::{Compiler, compile::should_skip_path};
10
10
  use crate::config::Config;
11
+ use crate::constants::{HYPERSPEED_DIR, MANIFEST_SUFFIX};
11
12
 
12
13
  /// Tracks which pages and components depend on which components
13
14
  pub struct DependencyGraph {
@@ -803,7 +804,7 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
803
804
  }
804
805
 
805
806
  // Delete corresponding manifest
806
- let manifest_path = canonical_output.join("vibe-hyperspeed").join(format!("{}.manifest.js", relative_path.display()));
807
+ let manifest_path = canonical_output.join(HYPERSPEED_DIR).join(format!("{}{}", relative_path.display(), MANIFEST_SUFFIX));
807
808
  if manifest_path.exists() {
808
809
  if let Err(e) = std::fs::remove_file(&manifest_path) {
809
810
  eprintln!("{}: Failed to delete manifest {}: {}", "Warning".yellow(), manifest_path.display(), e);
@@ -0,0 +1,46 @@
1
+ pub const DEFER_ATTR_PREFIX: &str = "data-vibe-";
2
+ // Declared for the JS↔Rust drift check; the compiler only ever names the
3
+ // derived attributes below, and narrowing to internal is DEFER_ATTR_PREFIX's job.
4
+ #[allow(dead_code)]
5
+ pub const INTERNAL_ATTR_PREFIX: &str = "data-vibe-internal-";
6
+ pub const COMPONENT_ID_ATTR: &str = "data-vibe-internal-component-id";
7
+ pub const NAMEBIND_ATTR: &str = "data-vibe-internal-namebind";
8
+ pub const RECURSIVE_SRC_ATTR: &str = "data-vibe-internal-recursive-src";
9
+ pub const CSS_COVER_ATTR: &str = "data-vibe-internal-cover";
10
+ pub const CSS_LAYOUT_ATTR: &str = "data-vibe-internal-css";
11
+
12
+ pub const FOUC_ATTR: &str = "vibe-fouc";
13
+ pub const DEHYDRATE_ATTR: &str = "vibe-dehydrate";
14
+ pub const MODULE_SCRIPT_TYPE: &str = "vibe-module";
15
+
16
+ pub const HYPERSPEED_DIR: &str = "vibe-hyperspeed";
17
+ pub const MANIFEST_SUFFIX: &str = ".manifest.js";
18
+ pub const SPA_FRAGMENTS_DIR: &str = "vibe-spa";
19
+
20
+ pub const COMPONENT_ID_PREFIX: &str = "_c";
21
+
22
+ pub const NON_REACTIVE_ELEMENTS: &[&str] = &["script", "head", "pre"];
23
+
24
+ pub const VALUE_ATTRS: &[&str] = &[
25
+ "class", "style", "id", "title", "lang", "dir", "tabindex", "accesskey",
26
+ "slot", "part", "is", "nonce", "popover", "anchor",
27
+ "contenteditable", "draggable", "spellcheck", "translate", "autocapitalize",
28
+ "inputmode", "enterkeyhint", "virtualkeyboardpolicy",
29
+ "href", "src", "action", "cite", "data", "poster", "srcset", "imagesrcset",
30
+ "formaction", "ping", "usemap", "manifest", "codebase",
31
+ "name", "type", "value", "placeholder", "pattern", "min", "max", "step",
32
+ "minlength", "maxlength", "size", "accept", "autocomplete", "list", "form",
33
+ "formmethod", "formtarget", "formenctype", "wrap", "method", "enctype", "for", "dirname",
34
+ "alt", "label", "summary", "abbr",
35
+ "width", "height", "cols", "rows", "span", "rowspan", "colspan",
36
+ "low", "high", "optimum",
37
+ "target", "rel", "hreflang", "download", "as", "media", "charset",
38
+ "crossorigin", "integrity", "loading", "decoding", "fetchpriority",
39
+ "referrerpolicy", "blocking", "imagesizes", "sizes",
40
+ "preload", "kind", "srclang",
41
+ "content", "http-equiv",
42
+ "sandbox", "allow", "srcdoc", "credentialless",
43
+ "headers", "scope",
44
+ "datetime",
45
+ "coords", "shape",
46
+ ];
package/src/inert.rs ADDED
@@ -0,0 +1,171 @@
1
+ use regex::Regex;
2
+ use std::sync::OnceLock;
3
+
4
+ use crate::parser::html::HtmlParser;
5
+
6
+ pub const DEHYDRATE_ATTR: &str = crate::constants::DEHYDRATE_ATTR;
7
+
8
+ const RAW_TEXT_ELEMENTS: [&str; 2] = ["script", "style"];
9
+ const NON_REACTIVE_ELEMENTS: [&str; 1] = ["pre"];
10
+
11
+ #[derive(Default)]
12
+ pub struct Inert {
13
+ blocks: Vec<String>,
14
+ }
15
+
16
+ impl Inert {
17
+ pub fn new() -> Self {
18
+ Self::default()
19
+ }
20
+
21
+ pub fn mask(&mut self, html: &str) -> String {
22
+ let mut out = String::with_capacity(html.len());
23
+ let mut cursor = 0;
24
+
25
+ while let Some(caps) = open_tag_regex().captures(&html[cursor..]) {
26
+ let open_tag = caps.get(0).unwrap();
27
+ let start = cursor + open_tag.start();
28
+ let after_open = cursor + open_tag.end();
29
+ let name = caps[1].to_ascii_lowercase();
30
+ let attrs = caps.get(2).map(|m| m.as_str()).unwrap_or("");
31
+
32
+ let region_end = if RAW_TEXT_ELEMENTS.contains(&name.as_str()) {
33
+ raw_text_end(html, after_open, &name)
34
+ } else if NON_REACTIVE_ELEMENTS.contains(&name.as_str()) || is_dehydrated(attrs) {
35
+ HtmlParser::find_matching_close(html, after_open, &name).map(|(_, end)| end)
36
+ } else {
37
+ None
38
+ };
39
+
40
+ match region_end {
41
+ Some(end) => {
42
+ out.push_str(&html[cursor..start]);
43
+ out.push_str(&placeholder(self.blocks.len()));
44
+ self.blocks.push(html[start..end].to_string());
45
+ cursor = end;
46
+ }
47
+ None => {
48
+ out.push_str(&html[cursor..after_open]);
49
+ cursor = after_open;
50
+ }
51
+ }
52
+ }
53
+
54
+ out.push_str(&html[cursor..]);
55
+ out
56
+ }
57
+
58
+ pub fn restore(&self, html: &str) -> String {
59
+ let mut out = html.to_string();
60
+ for (index, block) in self.blocks.iter().enumerate().rev() {
61
+ out = out.replace(&placeholder(index), block);
62
+ }
63
+ out
64
+ }
65
+ }
66
+
67
+ pub fn protect(html: &str, rewrite: impl FnOnce(&str) -> String) -> String {
68
+ let mut inert = Inert::new();
69
+ let masked = inert.mask(html);
70
+ inert.restore(&rewrite(&masked))
71
+ }
72
+
73
+ fn placeholder(index: usize) -> String {
74
+ format!("<!--vibe-inert-{}-->", index)
75
+ }
76
+
77
+ fn open_tag_regex() -> &'static Regex {
78
+ static RE: OnceLock<Regex> = OnceLock::new();
79
+ RE.get_or_init(|| {
80
+ Regex::new(r#"<([a-zA-Z][a-zA-Z0-9-]*)((?:"[^"]*"|'[^']*'|[^<>"'])*)>"#).unwrap()
81
+ })
82
+ }
83
+
84
+ fn close_tag_regex() -> &'static Regex {
85
+ static RE: OnceLock<Regex> = OnceLock::new();
86
+ RE.get_or_init(|| Regex::new(r"(?i)</([a-zA-Z][a-zA-Z0-9-]*)\s*>").unwrap())
87
+ }
88
+
89
+ fn dehydrate_regex() -> &'static Regex {
90
+ static RE: OnceLock<Regex> = OnceLock::new();
91
+ RE.get_or_init(|| {
92
+ Regex::new(&format!(
93
+ r#"(?i)(?:^|[\s"']){}(?:[\s="'/]|$)"#,
94
+ regex::escape(DEHYDRATE_ATTR)
95
+ ))
96
+ .unwrap()
97
+ })
98
+ }
99
+
100
+ fn is_dehydrated(attrs: &str) -> bool {
101
+ dehydrate_regex().is_match(attrs)
102
+ }
103
+
104
+ fn raw_text_end(html: &str, after_open: usize, name: &str) -> Option<usize> {
105
+ close_tag_regex()
106
+ .captures_iter(&html[after_open..])
107
+ .find(|caps| caps[1].eq_ignore_ascii_case(name))
108
+ .map(|caps| after_open + caps.get(0).unwrap().end())
109
+ }
110
+
111
+ #[cfg(test)]
112
+ mod tests {
113
+ use super::*;
114
+
115
+ #[test]
116
+ fn masks_script_body_and_restores_it_verbatim() {
117
+ let html = r#"<a></a><script>const t = '<component src="/c.html"></component>';</script><b></b>"#;
118
+ let mut inert = Inert::new();
119
+ let masked = inert.mask(html);
120
+
121
+ assert!(!masked.contains("<component"), "script body still exposed: {masked}");
122
+ assert!(masked.contains("<a></a>") && masked.contains("<b></b>"));
123
+ assert_eq!(inert.restore(&masked), html);
124
+ }
125
+
126
+ #[test]
127
+ fn masks_dehydrated_subtree_by_attribute_and_by_class() {
128
+ let html = r#"<code vibe-dehydrate><badge></badge></code><code class="vibe-dehydrate"><badge></badge></code>"#;
129
+ let mut inert = Inert::new();
130
+ let masked = inert.mask(html);
131
+
132
+ assert!(!masked.contains("<badge>"), "dehydrated markup still exposed: {masked}");
133
+ assert_eq!(inert.restore(&masked), html);
134
+ }
135
+
136
+ #[test]
137
+ fn leaves_ordinary_markup_untouched() {
138
+ let html = r#"<page><component src="/c.html"></component><data-vibe-dehydrate-note></data-vibe-dehydrate-note></page>"#;
139
+ let mut inert = Inert::new();
140
+
141
+ assert_eq!(inert.mask(html), html);
142
+ }
143
+
144
+ #[test]
145
+ fn masks_nested_same_tag_to_the_matching_close() {
146
+ let html = r#"<pre><pre><badge></badge></pre></pre><badge></badge>"#;
147
+ let mut inert = Inert::new();
148
+ let masked = inert.mask(html);
149
+
150
+ assert_eq!(masked.matches("<badge>").count(), 1, "nesting mis-counted: {masked}");
151
+ assert_eq!(inert.restore(&masked), html);
152
+ }
153
+
154
+ #[test]
155
+ fn restores_regions_masked_inside_earlier_placeholders() {
156
+ let mut inert = Inert::new();
157
+ let outer = inert.mask("<pre>kept</pre>");
158
+ let nested = inert.mask(&format!("<pre>{}</pre>", outer));
159
+
160
+ assert!(!nested.contains("kept"));
161
+ assert_eq!(inert.restore(&nested), "<pre><pre>kept</pre></pre>");
162
+ }
163
+
164
+ #[test]
165
+ fn leaves_unterminated_region_unmasked() {
166
+ let html = r#"<page><pre><badge></badge></page>"#;
167
+ let mut inert = Inert::new();
168
+
169
+ assert_eq!(inert.mask(html), html);
170
+ }
171
+ }
package/src/main.rs CHANGED
@@ -1,4 +1,5 @@
1
1
  mod config;
2
+ mod constants;
2
3
  mod inert;
3
4
  mod parser;
4
5
  mod compiler;
@@ -5,6 +5,7 @@ use thiserror::Error;
5
5
 
6
6
  use super::element::{Element, ElementCache};
7
7
  use crate::inert::Inert;
8
+ use crate::constants::{RECURSIVE_SRC_ATTR, MODULE_SCRIPT_TYPE};
8
9
 
9
10
  #[derive(Error, Debug)]
10
11
  pub enum ParseError {
@@ -159,7 +160,7 @@ impl HtmlParser {
159
160
  // escaped during cache build (see compile.rs::escape_recursive_src).
160
161
  // These are runtime-handled component references that the inliner
161
162
  // had to skip; the runtime needs them as real `src=` attributes.
162
- result = result.replace("data-vibe-recursive-src=", "src=");
163
+ result = result.replace(&format!("{}=", RECURSIVE_SRC_ATTR), "src=");
163
164
 
164
165
  // Step 5: Restore DOCTYPE if it was present
165
166
  if let Some(dt) = doctype {
@@ -518,7 +519,7 @@ impl HtmlParser {
518
519
  /// script-execution pipeline with identical semantics. Runs before slot
519
520
  /// inlining so caller-authored slot scripts stay native.
520
521
  fn neuter_component_scripts(html: &str) -> String {
521
- html.replace("<script type=\"module\"", "<script type=\"vibe-module\"")
522
+ html.replace("<script type=\"module\"", &format!("<script type=\"{}\"", MODULE_SCRIPT_TYPE))
522
523
  }
523
524
 
524
525
  /// A run of HTML attributes where `>` is permitted only inside a quoted value.