@ape-egg/vibe 2.1.4 → 2.1.6

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.
@@ -190,7 +190,7 @@ impl HtmlParser {
190
190
  // Find all tags that match loaded elements
191
191
  for (tag_name, element) in &self.cache {
192
192
  // Match opening and closing tags with any attributes and children
193
- let tag_pattern = format!(r"<{}(\s[^>]*)?>", regex::escape(tag_name));
193
+ let tag_pattern = format!(r"<{}(\s{})?>", regex::escape(tag_name), ATTR_RUN);
194
194
  let tag_re = regex::Regex::new(&tag_pattern).unwrap();
195
195
  let closing_pattern = format!(r"</{}>", regex::escape(tag_name));
196
196
 
@@ -336,7 +336,7 @@ impl HtmlParser {
336
336
 
337
337
  // Match only the opening tag — slot content is extracted via depth-counting close search
338
338
  // to correctly handle slot content that contains </div> or nested <component> elements.
339
- let pattern = format!(r#"<(component|div)\s+([^>]*)\bsrc="{}"\s*([^>]*)>"#, regex::escape(&normalized_src));
339
+ let pattern = format!(r#"<(component|div)\s+({attr})\bsrc="{src}"\s*({attr})>"#, attr = ATTR_RUN, src = regex::escape(&normalized_src));
340
340
  let open_re = regex::Regex::new(&pattern).unwrap();
341
341
 
342
342
  let matches: Vec<_> = open_re.captures_iter(&result).filter_map(|cap| {
@@ -393,7 +393,7 @@ impl HtmlParser {
393
393
  // Match only the opening component tag — slot content is extracted via depth-counting
394
394
  // to correctly handle slot content containing </div> or nested <component> elements.
395
395
  let open_re = regex::Regex::new(
396
- r#"<(component|div)\s+([^>]*)\bsrc="([^"]+)"([^>]*)>"#
396
+ &format!(r#"<(component|div)\s+({attr})\bsrc="([^"]+)"({attr})>"#, attr = ATTR_RUN)
397
397
  ).unwrap();
398
398
 
399
399
  // Process one match at a time with re-scanning after each replacement.
@@ -503,6 +503,13 @@ fn neuter_component_scripts(html: &str) -> String {
503
503
  html.replace("<script type=\"module\"", "<script type=\"vibe-module\"")
504
504
  }
505
505
 
506
+ /// A run of HTML attributes where `>` is permitted only inside a quoted value.
507
+ /// Mirrors a real HTML tokenizer: a tag ends on an *unquoted* `>` only. Without
508
+ /// this, a binding prop like `flipped="@[a >= b]"` truncates the open tag at the
509
+ /// `>` inside its value, so the trailing attributes get mis-parsed (empty
510
+ /// boolean props → literal `true`) and corrupt the inlined template.
511
+ const ATTR_RUN: &str = r#"(?:"[^"]*"|'[^']*'|[^>"'])*"#;
512
+
506
513
  fn is_ident_byte(b: u8) -> bool {
507
514
  b.is_ascii_alphanumeric() || b == b'_' || b == b'$'
508
515
  }
@@ -775,4 +782,75 @@ mod tests {
775
782
  assert!(content[close..].starts_with("</component>"), "matched wrong close: {:?}", &content[close..close + 14]);
776
783
  assert!(content[close..].contains("<sibling>"), "sibling was swallowed into the component");
777
784
  }
785
+
786
+ #[test]
787
+ fn inline_component_tolerates_gt_in_prop_binding() {
788
+ // A component prop whose `@[...]` value contains a comparison operator
789
+ // (`>=`) must not truncate the open tag at the `>` *inside the value*.
790
+ // The flawed `[^>]*` attribute run cut the tag mid-attribute, so the
791
+ // trailing identifiers (`selectedBrawlers`, `length`) were mis-parsed as
792
+ // empty boolean props (→ literal `true`), rewriting the loop expression
793
+ // to `brawlHandCards(characters, true)` — which throws `true.includes is
794
+ // not a function` at runtime, so the card discs never render.
795
+ let parser = HtmlParser::new(std::path::PathBuf::from("."));
796
+ let page = concat!(
797
+ r#"<page><component src="/components/remote/CardHand.html" "#,
798
+ r#"cards="@[brawlHandCards(characters, selectedBrawlers)]" "#,
799
+ r#"flipped="@[selectedBrawlers.length >= maxBrawlers]" "#,
800
+ r#"angle="16"></component></page>"#,
801
+ );
802
+ let template = concat!(
803
+ r#"<card-hand flipped="@[flipped]"><card-fan style="--angle: @[angle]deg">"#,
804
+ r#"<!-- each cards as card (card.id), i --><card-slot data-id="@[card.id]"></card-slot>"#,
805
+ r#"<!-- /each --></card-fan></card-hand>"#,
806
+ );
807
+ let mut cache = HashMap::new();
808
+ cache.insert("/components/remote/CardHand.html".to_string(), template.to_string());
809
+
810
+ let out = parser.inline_component_elements(page, &cache);
811
+
812
+ // The loop expression must carry `selectedBrawlers` through untouched.
813
+ assert!(
814
+ out.contains("brawlHandCards(characters, selectedBrawlers)"),
815
+ "loop expression corrupted: {out}"
816
+ );
817
+ assert!(
818
+ !out.contains("brawlHandCards(characters, true)"),
819
+ "selectedBrawlers wrongly replaced with `true`: {out}"
820
+ );
821
+ // The binding prop keeps its full comparison expression.
822
+ assert!(
823
+ out.contains(r#"flipped="@[selectedBrawlers.length >= maxBrawlers]""#),
824
+ "flipped binding lost: {out}"
825
+ );
826
+ // A literal prop *after* the `>=` prop must still substitute — proof the
827
+ // tag was parsed to the real `>`, not the one inside the value.
828
+ assert!(out.contains("--angle: 16deg"), "angle not substituted: {out}");
829
+ }
830
+
831
+ #[test]
832
+ fn inline_custom_element_tolerates_gt_in_prop_binding() {
833
+ // Same defect in the custom-element inliner's `<tag(\s[^>]*)?>` regex.
834
+ // (Cached element tags are filename-derived single words — see element.rs.)
835
+ let mut parser = HtmlParser::new(std::path::PathBuf::from("."));
836
+ parser.cache.insert(
837
+ "gauge".to_string(),
838
+ Element::new(
839
+ "gauge".to_string(),
840
+ std::path::PathBuf::from("gauge.html"),
841
+ r#"<gauge-inner show="@[show]" n="@[count]"></gauge-inner>"#.to_string(),
842
+ ),
843
+ );
844
+
845
+ let page = r#"<page><gauge show="@[items.length >= max]" count="7"></gauge></page>"#;
846
+ let out = parser.inline_custom_elements(page);
847
+
848
+ // The `>=` inside the binding must not have truncated the open tag.
849
+ assert!(
850
+ out.contains(r#"show="@[items.length >= max]""#),
851
+ "show binding lost: {out}"
852
+ );
853
+ // A literal prop after the `>=` prop still substitutes → tag parsed fully.
854
+ assert!(out.contains(r#"n="7""#), "count not substituted: {out}");
855
+ }
778
856
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe",
3
- "version": "2.1.4",
3
+ "version": "2.1.6",
4
4
  "type": "module",
5
5
  "description": "Runtime-first reactivity with optional compiler",
6
6
  "main": "index.js",