@ape-egg/vibe 2.1.22 → 3.0.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.
Files changed (56) hide show
  1. package/README.md +112 -5
  2. package/boot.js +4 -4
  3. package/component.js +27 -29
  4. package/hot-module-refresh.js +4 -4
  5. package/index.js +26 -17
  6. package/llms.txt +36 -5
  7. package/package.json +20 -14
  8. package/runtime/affected.js +159 -36
  9. package/runtime/cleanup.js +45 -1
  10. package/runtime/component.js +360 -98
  11. package/runtime/conditionals.js +111 -14
  12. package/runtime/debug.js +24 -0
  13. package/runtime/dispatch.js +172 -0
  14. package/runtime/hydrate.js +277 -110
  15. package/runtime/index.js +189 -65
  16. package/runtime/iterate.js +125 -50
  17. package/runtime/iteration-utils.js +59 -8
  18. package/runtime/manifest.js +77 -2
  19. package/runtime/parse.js +81 -11
  20. package/runtime/pre-compiled-iterations.js +19 -6
  21. package/runtime/pre-compiled-manifest.js +13 -4
  22. package/runtime/staging.js +153 -0
  23. package/runtime/state.js +31 -0
  24. package/runtime/tracking.js +173 -0
  25. package/runtime/utils.js +155 -78
  26. package/spa.js +206 -0
  27. package/vibe.css +8 -4
  28. package/CHANGELOG.md +0 -1159
  29. package/ROADMAP.md +0 -397
  30. package/compiler/bin/vibe-compile.js +0 -121
  31. package/compiler/native/.gitkeep +0 -0
  32. package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
  33. package/compiler/native/vibe-compiler-linux-x64 +0 -0
  34. package/compiler/src/Cargo.lock +0 -2023
  35. package/compiler/src/Cargo.toml +0 -38
  36. package/compiler/src/compiler/PRE-RENDERING-IMPLEMENTATION.md +0 -241
  37. package/compiler/src/compiler/binding_case.rs +0 -88
  38. package/compiler/src/compiler/compile.rs +0 -2522
  39. package/compiler/src/compiler/component_tagger.rs +0 -469
  40. package/compiler/src/compiler/iteration_optimizer.rs +0 -455
  41. package/compiler/src/compiler/js_analyzer.rs +0 -715
  42. package/compiler/src/compiler/manifest_builder.rs +0 -693
  43. package/compiler/src/compiler/mod.rs +0 -15
  44. package/compiler/src/compiler/name_binding_protect.rs +0 -207
  45. package/compiler/src/compiler/reassignment_analyzer.rs +0 -456
  46. package/compiler/src/compiler/state_extractor.rs +0 -263
  47. package/compiler/src/compiler/value_stamper.rs +0 -921
  48. package/compiler/src/compiler/watcher.rs +0 -1147
  49. package/compiler/src/config.rs +0 -239
  50. package/compiler/src/main.rs +0 -347
  51. package/compiler/src/parser/element.rs +0 -96
  52. package/compiler/src/parser/html.rs +0 -1004
  53. package/compiler/src/parser/mod.rs +0 -8
  54. package/runtime/pre-compiled-manifest.test.mjs +0 -58
  55. package/runtime/scope.js +0 -50
  56. package/test-results/.last-run.json +0 -4
@@ -1,1004 +0,0 @@
1
- use std::fs;
2
- use std::path::Path;
3
- use std::collections::{HashMap, HashSet};
4
- use thiserror::Error;
5
-
6
- use super::element::{Element, ElementCache};
7
-
8
- #[derive(Error, Debug)]
9
- pub enum ParseError {
10
- #[error("Failed to read file {path}: {source}")]
11
- ReadError {
12
- path: String,
13
- #[source]
14
- source: std::io::Error,
15
- },
16
- #[error("Element not found: {0}")]
17
- #[allow(dead_code)]
18
- ElementNotFound(String),
19
- }
20
-
21
- pub struct HtmlParser {
22
- components_dir: std::path::PathBuf,
23
- cache: ElementCache,
24
- }
25
-
26
- impl HtmlParser {
27
- pub fn new(components_dir: std::path::PathBuf) -> Self {
28
- Self {
29
- components_dir,
30
- cache: ElementCache::new(),
31
- }
32
- }
33
-
34
- /// Load all elements from the elements directory
35
- pub fn load_elements(&mut self) -> Result<(), ParseError> {
36
- if !self.components_dir.exists() {
37
- return Ok(()); // No elements directory is fine
38
- }
39
-
40
- self.load_elements_recursive(&self.components_dir.clone())
41
- }
42
-
43
- fn load_elements_recursive(&mut self, dir: &Path) -> Result<(), ParseError> {
44
- let entries = fs::read_dir(dir).map_err(|e| ParseError::ReadError {
45
- path: dir.display().to_string(),
46
- source: e,
47
- })?;
48
-
49
- for entry in entries.flatten() {
50
- let path = entry.path();
51
-
52
- if path.is_dir() {
53
- self.load_elements_recursive(&path)?;
54
- } else if path.extension().map_or(false, |ext| ext == "html") {
55
- let tag_name = path
56
- .file_stem()
57
- .and_then(|s| s.to_str())
58
- .unwrap_or("")
59
- .to_string();
60
-
61
- let content = fs::read_to_string(&path).map_err(|e| ParseError::ReadError {
62
- path: path.display().to_string(),
63
- source: e,
64
- })?;
65
-
66
- let element = Element::new(tag_name.clone(), path.clone(), content);
67
- self.cache.insert(tag_name, element);
68
- }
69
- }
70
-
71
- Ok(())
72
- }
73
-
74
- /// Reload a single element from disk (when the file changes in watch mode)
75
- pub fn reload_element(&mut self, path: &Path) -> Result<(), ParseError> {
76
- if path.extension().map_or(false, |ext| ext == "html") {
77
- if let Some(tag_name) = path.file_stem().and_then(|s| s.to_str()) {
78
- let content = fs::read_to_string(path).map_err(|e| ParseError::ReadError {
79
- path: path.display().to_string(),
80
- source: e,
81
- })?;
82
- let element = Element::new(tag_name.to_string(), path.to_path_buf(), content);
83
- self.cache.insert(tag_name.to_string(), element);
84
- }
85
- }
86
- Ok(())
87
- }
88
-
89
- /// Get an element by tag name
90
- pub fn _get_element(&self, tag_name: &str) -> Option<&Element> {
91
- self.cache.get(tag_name)
92
- }
93
-
94
- /// Get all loaded elements
95
- #[allow(dead_code)]
96
- pub fn elements(&self) -> &ElementCache {
97
- &self.cache
98
- }
99
-
100
- /// Process HTML content: transform custom tags to <component>, optionally inline, transform custom elements
101
- pub fn process_html(
102
- &self,
103
- content: &str,
104
- elements_as_is: bool,
105
- reserved_elements: &[String],
106
- components_as_is: bool,
107
- components_dir: &str,
108
- ) -> String {
109
- self.process_html_with_cache(content, elements_as_is, reserved_elements, components_as_is, components_dir, &HashMap::new())
110
- }
111
-
112
- pub fn process_html_with_cache(
113
- &self,
114
- content: &str,
115
- elements_as_is: bool,
116
- reserved_elements: &[String],
117
- components_as_is: bool,
118
- _components_dir: &str,
119
- external_cache: &HashMap<String, String>,
120
- ) -> String {
121
- // Extract and preserve DOCTYPE declaration if present
122
- let doctype_re = regex::Regex::new(r"(?i)^\s*<!DOCTYPE[^>]*>\s*").unwrap();
123
- let doctype = doctype_re.find(content).map(|m| m.as_str().to_string());
124
-
125
- let mut result = content.to_string();
126
-
127
- // Step 1: Inline custom elements directly (ALWAYS, even with components_as_is)
128
- // Custom elements like <card> are always inlined because runtime doesn't know about /components directory
129
- result = self.inline_custom_elements(&result);
130
-
131
- // Step 2: Handle explicit <component src="..."> elements
132
- // If components_as_is is false, recursively inline all <component> elements
133
- // Keep running until no more components are found (handles nested components in slots)
134
- if !components_as_is {
135
- let mut iterations = 0;
136
- let max_iterations = 50; // Prevent infinite loops
137
- loop {
138
- let before = result.clone();
139
- result = self.inline_component_elements(&result, external_cache);
140
- iterations += 1;
141
-
142
- // Stop if no changes or max iterations reached
143
- if result == before || iterations >= max_iterations {
144
- break;
145
- }
146
- }
147
-
148
- // NOTE: Don't run inline_custom_elements again here - it causes infinite recursion
149
- // Custom elements inside components are already processed when the component was cached
150
- }
151
-
152
- // Step 3: Transform custom elements to divs if elements_as_is is false (accessible by default)
153
- if !elements_as_is {
154
- result = transform_custom_tags_to_divs(&result, reserved_elements);
155
- }
156
-
157
- // Step 4: Restore cyclic `<component src>` references that were
158
- // escaped during cache build (see compile.rs::escape_recursive_src).
159
- // These are runtime-handled component references that the inliner
160
- // had to skip; the runtime needs them as real `src=` attributes.
161
- result = result.replace("data-vibe-recursive-src=", "src=");
162
-
163
- // Step 5: Restore DOCTYPE if it was present
164
- if let Some(dt) = doctype {
165
- // Remove any existing DOCTYPE that might have been left behind
166
- result = doctype_re.replace(&result, "").to_string();
167
- // Prepend the original DOCTYPE
168
- result = format!("{}{}", dt, result);
169
- }
170
-
171
- result
172
- }
173
-
174
- /// Inline custom elements directly with their HTML content (ALWAYS, even with components_as_is)
175
- /// Custom elements like <card> are always inlined because runtime doesn't know about /components directory
176
- fn inline_custom_elements(&self, content: &str) -> String {
177
- if self.cache.is_empty() {
178
- return content.to_string(); // No custom elements to inline
179
- }
180
-
181
- let mut result = content.to_string();
182
- let mut changed = true;
183
- let mut iterations = 0;
184
- const MAX_ITERATIONS: usize = 10; // Reasonable limit for deeply nested custom elements
185
-
186
- while changed && iterations < MAX_ITERATIONS {
187
- changed = false;
188
- iterations += 1;
189
-
190
- // Find all tags that match loaded elements
191
- for (tag_name, element) in &self.cache {
192
- // Match opening and closing tags with any attributes and children
193
- let tag_pattern = format!(r"<{}(\s{})?>", regex::escape(tag_name), ATTR_RUN);
194
- let tag_re = regex::Regex::new(&tag_pattern).unwrap();
195
- let closing_pattern = format!(r"</{}>", regex::escape(tag_name));
196
-
197
- // Find all occurrences
198
- let mut matches: Vec<(usize, usize, String, Vec<(String, String)>)> = Vec::new();
199
-
200
- // Find opening tags
201
- for cap in tag_re.find_iter(&result) {
202
- let start = cap.start();
203
- let tag_with_attrs = cap.as_str();
204
-
205
- // Extract attributes and parse as props
206
- let attrs_str = if tag_with_attrs.ends_with('>') {
207
- let inner = &tag_with_attrs[tag_name.len() + 1..tag_with_attrs.len() - 1];
208
- inner.to_string()
209
- } else {
210
- String::new()
211
- };
212
- let props = Self::parse_props(&attrs_str);
213
-
214
- // Find corresponding closing tag
215
- if let Some(closing_pos) = result[cap.end()..].find(&closing_pattern) {
216
- let closing_start = cap.end() + closing_pos;
217
- let closing_end = closing_start + closing_pattern.len();
218
- let slot_content = result[cap.end()..closing_start].to_string();
219
-
220
- matches.push((start, closing_end, slot_content, props));
221
- }
222
- }
223
-
224
- if !matches.is_empty() {
225
- changed = true;
226
- }
227
-
228
- // Replace from end to start to maintain indices
229
- matches.reverse();
230
- for (start, end, slot_content, props) in matches {
231
- // Substitute props with full runtime parity (bindings,
232
- // expressions, directive comments, event handlers)
233
- let mut replacement = substitute_props(&element.content, &props);
234
-
235
- // Replace <slot> tags — wrap children in <slot> boundary, or remove if empty
236
- let slot_wrapped = if slot_content.trim().is_empty() {
237
- String::new()
238
- } else {
239
- format!("<slot>{}</slot>", slot_content)
240
- };
241
- replacement = replacement.replace("<slot></slot>", &slot_wrapped);
242
- replacement = replacement.replace("<slot/>", &slot_wrapped);
243
- replacement = replacement.replace("<slot />", &slot_wrapped);
244
-
245
- // Keep the wrapper for consistency with runtime (using generic <component> wrapper)
246
- // No src attribute = wrapper won't be re-processed
247
- let wrapper = format!("<component>{}</component>", replacement);
248
- result.replace_range(start..end, &wrapper);
249
- }
250
- }
251
- }
252
-
253
- result
254
- }
255
-
256
- /// Recursively inline all <component> elements with their HTML content
257
- /// Inline a single component (one pass, no looping)
258
- /// Used during recursive component fetching to resolve nested components
259
- /// Find the start position of the matching close tag, handling nesting of the same tag.
260
- /// `after_open`: byte position immediately after the `>` of the opening tag.
261
- /// Returns `(start, end)` byte offsets of the matching close tag: `start` is
262
- /// the `<` of `</tag…>` (the slot-content boundary), `end` is just past its
263
- /// `>`. Because the end tag may carry whitespace before `>` (`</tag\n>`), the
264
- /// caller must use this `end` rather than assuming a `</tag>`-length tag.
265
- fn find_matching_close(content: &str, after_open: usize, tag_name: &str) -> Option<(usize, usize)> {
266
- // (?:\s|>) ensures <component> matches but not <component-foo> (hyphen is not \s or >)
267
- let open_re = regex::Regex::new(&format!(r"<{}(?:\s|>|/>)", regex::escape(tag_name))).unwrap();
268
- // `\s*>` tolerates whitespace before the `>` of an end tag — HTML allows
269
- // it, and whitespace-controlled markup splits end tags across lines
270
- // (`</component\n>`). `\s*` can't bridge into `</component-foo>` (the `-`
271
- // is neither whitespace nor `>`), so this stays exact on the tag name.
272
- let close_re = regex::Regex::new(&format!(r"</{}\s*>", regex::escape(tag_name))).unwrap();
273
-
274
- let mut depth = 1i32;
275
- let mut cursor = after_open;
276
-
277
- while cursor < content.len() {
278
- let slice = &content[cursor..];
279
- let next_open = open_re.find(slice).map(|m| (m.start(), m.end()));
280
- let next_close = close_re.find(slice).map(|m| (m.start(), m.end()));
281
-
282
- match (next_open, next_close) {
283
- (None, None) => return None,
284
- (None, Some((cs, ce))) => {
285
- depth -= 1;
286
- if depth == 0 { return Some((cursor + cs, cursor + ce)); }
287
- cursor += ce;
288
- }
289
- (Some((os, oe)), None) => {
290
- // Check if self-closing by finding the end of this tag
291
- if let Some(end) = slice[os..].find('>') {
292
- if slice[os..os + end + 1].ends_with("/>") {
293
- cursor += os + end + 1;
294
- } else {
295
- depth += 1;
296
- cursor += oe;
297
- }
298
- } else {
299
- return None;
300
- }
301
- }
302
- (Some((os, oe)), Some((cs, ce))) => {
303
- if os < cs {
304
- if let Some(end) = slice[os..].find('>') {
305
- if slice[os..os + end + 1].ends_with("/>") {
306
- cursor += os + end + 1;
307
- } else {
308
- depth += 1;
309
- cursor += oe;
310
- }
311
- } else {
312
- return None;
313
- }
314
- } else {
315
- depth -= 1;
316
- if depth == 0 { return Some((cursor + cs, cursor + ce)); }
317
- cursor += ce;
318
- }
319
- }
320
- }
321
- }
322
-
323
- None
324
- }
325
-
326
- pub fn inline_single_component(&self, content: &str, component_src: &str, component_content: &str) -> String {
327
- let mut result = content.to_string();
328
-
329
- // Normalize the src for matching
330
- let normalized_src = if component_src.starts_with("http://") || component_src.starts_with("https://") {
331
- component_src.to_string()
332
- } else {
333
- let without_prefix = component_src.trim_start_matches("./");
334
- if without_prefix.starts_with('/') {
335
- without_prefix.to_string()
336
- } else {
337
- format!("/{}", without_prefix)
338
- }
339
- };
340
-
341
- // Match only the opening tag — slot content is extracted via depth-counting close search
342
- // to correctly handle slot content that contains </div> or nested <component> elements.
343
- let pattern = format!(r#"<(component|div)\s+({attr})\bsrc="{src}"\s*({attr})>"#, attr = ATTR_RUN, src = regex::escape(&normalized_src));
344
- let open_re = regex::Regex::new(&pattern).unwrap();
345
-
346
- let matches: Vec<_> = open_re.captures_iter(&result).filter_map(|cap| {
347
- let open_tag = cap.get(0).unwrap();
348
- let tag_name = cap.get(1).unwrap().as_str();
349
- let attrs_before = cap.get(2).map(|m| m.as_str()).unwrap_or("");
350
- let attrs_after = cap.get(3).map(|m| m.as_str()).unwrap_or("");
351
-
352
- // For <div>, verify it has class="component"
353
- if tag_name == "div" {
354
- let combined_attrs = format!("{} {}", attrs_before, attrs_after);
355
- if !combined_attrs.contains("class=") || !combined_attrs.contains("component") {
356
- return None;
357
- }
358
- }
359
-
360
- let open_end = open_tag.end();
361
- let (close_start, close_end) = Self::find_matching_close(&result, open_end, tag_name)?;
362
- let slot_content = result[open_end..close_start].to_string();
363
-
364
- let attrs_str = format!("{} {}", attrs_before, attrs_after);
365
- let props: Vec<(String, String)> = Self::parse_props(&attrs_str)
366
- .into_iter()
367
- .filter(|(n, _)| n != "src" && n != "class")
368
- .collect();
369
-
370
- Some((open_tag.start(), close_end, props, slot_content))
371
- }).collect();
372
-
373
- // Iter-prop components stay as runtime `<component src>` tags (see
374
- // is_iter_prop_root). The content is the same for every match, so skip all.
375
- if is_iter_prop_root(component_content) {
376
- return result;
377
- }
378
-
379
- // Replace from end to start (sorted descending by start position)
380
- let mut sorted = matches;
381
- sorted.sort_by(|a, b| b.0.cmp(&a.0));
382
-
383
- for (start, end, props, slot_content) in &sorted {
384
- let mut replacement = substitute_props(component_content, props);
385
- replacement = neuter_component_scripts(&replacement);
386
-
387
- let slot_wrapped = if slot_content.trim().is_empty() { String::new() } else { format!("<slot>{}</slot>", slot_content) };
388
- replacement = replacement.replace("<slot></slot>", &slot_wrapped);
389
- replacement = replacement.replace("<slot/>", &slot_wrapped);
390
- replacement = replacement.replace("<slot />", &slot_wrapped);
391
-
392
- let wrapper = format!("<component>{}</component>", replacement);
393
- result.replace_range(*start..*end, &wrapper);
394
- }
395
-
396
- result
397
- }
398
-
399
- fn inline_component_elements(&self, content: &str, external_cache: &HashMap<String, String>) -> String {
400
- let mut result = content.to_string();
401
-
402
- // Match only the opening component tag — slot content is extracted via depth-counting
403
- // to correctly handle slot content containing </div> or nested <component> elements.
404
- let open_re = regex::Regex::new(
405
- &format!(r#"<(component|div)\s+({attr})\bsrc="([^"]+)"({attr})>"#, attr = ATTR_RUN)
406
- ).unwrap();
407
-
408
- // Process one match at a time with re-scanning after each replacement.
409
- // This is required for nested components (e.g. Layout.html wrapping inner components):
410
- // collecting all byte positions at once and applying them in reverse fails because
411
- // replacing an inner component changes the string length, invalidating the outer
412
- // component's end byte position and causing a char boundary panic.
413
- loop {
414
- let matches: Vec<_> = open_re.captures_iter(&result).filter_map(|cap| {
415
- let open_tag = cap.get(0).unwrap();
416
- let tag_name = cap.get(1).unwrap().as_str();
417
- let attrs_before = cap.get(2).map(|m| m.as_str()).unwrap_or("");
418
- let src = cap.get(3).unwrap().as_str();
419
- let attrs_after = cap.get(4).map(|m| m.as_str()).unwrap_or("");
420
-
421
- // For <div>, verify it has class="component"
422
- if tag_name == "div" {
423
- let combined_attrs = format!("{} {}", attrs_before, attrs_after);
424
- if !combined_attrs.contains("class=") || !combined_attrs.contains("component") {
425
- return None;
426
- }
427
- }
428
-
429
- let open_end = open_tag.end();
430
- let (close_start, close_end) = Self::find_matching_close(&result, open_end, tag_name)?;
431
- let slot_content = result[open_end..close_start].to_string();
432
-
433
- let attrs_str = format!("{} {}", attrs_before, attrs_after);
434
- let props: Vec<(String, String)> = Self::parse_props(&attrs_str)
435
- .into_iter()
436
- .filter(|(n, _)| n != "src" && n != "class")
437
- .collect();
438
-
439
- Some((open_tag.start(), close_end, src.to_string(), props, slot_content))
440
- }).collect();
441
-
442
- // Pick the rightmost match that has a cached component and apply it, then re-scan.
443
- let mut sorted = matches;
444
- sorted.sort_by(|a, b| b.0.cmp(&a.0));
445
-
446
- let mut applied = false;
447
- for (start, end, src, props, slot_content) in sorted {
448
- let normalized_src = if src.starts_with("http://") || src.starts_with("https://") {
449
- src.to_string()
450
- } else {
451
- let without_prefix = src.trim_start_matches("./");
452
- if without_prefix.starts_with('/') {
453
- without_prefix.to_string()
454
- } else {
455
- format!("/{}", without_prefix)
456
- }
457
- };
458
-
459
- if let Some(template) = external_cache.get(&normalized_src) {
460
- // Iter-prop components stay as runtime `<component src>` tags so the
461
- // runtime renders them via its __vibeiterprops path — inlining
462
- // breaks the enclosing loop's scope (see is_iter_prop_root).
463
- if is_iter_prop_root(template) {
464
- continue;
465
- }
466
- let mut replacement = substitute_props(template, &props);
467
- replacement = neuter_component_scripts(&replacement);
468
-
469
- let slot_wrapped = if slot_content.trim().is_empty() { String::new() } else { format!("<slot>{}</slot>", slot_content) };
470
- replacement = replacement.replace("<slot></slot>", &slot_wrapped);
471
- replacement = replacement.replace("<slot/>", &slot_wrapped);
472
- replacement = replacement.replace("<slot />", &slot_wrapped);
473
-
474
- let wrapper = format!("<component>{}</component>", replacement);
475
- result.replace_range(start..end, &wrapper);
476
- applied = true;
477
- break;
478
- }
479
- }
480
-
481
- if !applied {
482
- break;
483
- }
484
- }
485
-
486
- result
487
- }
488
-
489
- /// Parse component props from attributes string, in source order.
490
- /// Supports valued (`headline="@[pageTitle]"`) and bare boolean
491
- /// (`dndDisabled`) attributes — the runtime receives bare attributes from
492
- /// the DOM with an empty-string value, so they're captured the same here.
493
- fn parse_props(attrs_str: &str) -> Vec<(String, String)> {
494
- let mut props = Vec::new();
495
-
496
- let attr_re = regex::Regex::new(r#"([\w-]+)(?:="([^"]*)")?"#).unwrap();
497
-
498
- for cap in attr_re.captures_iter(attrs_str) {
499
- let name = cap.get(1).unwrap().as_str().to_string();
500
- let value = cap.get(2).map(|m| m.as_str().to_string()).unwrap_or_default();
501
- props.push((name, value));
502
- }
503
-
504
- props
505
- }
506
- }
507
-
508
- /// Neuter component scripts inlined from `<component src>` templates:
509
- /// `<script type="module">` becomes `<script type="vibe-module">` so the
510
- /// browser does NOT execute it as a native page module (wrong timing —
511
- /// pre-boot, placeholder `$`, no live state merge). The runtime executes
512
- /// vibe-module scripts through the same injected-component() path it uses
513
- /// for fetched component scripts, giving compiled and runtime pages one
514
- /// script-execution pipeline with identical semantics. Runs before slot
515
- /// inlining so caller-authored slot scripts stay native.
516
- fn neuter_component_scripts(html: &str) -> String {
517
- html.replace("<script type=\"module\"", "<script type=\"vibe-module\"")
518
- }
519
-
520
- /// A run of HTML attributes where `>` is permitted only inside a quoted value.
521
- /// Mirrors a real HTML tokenizer: a tag ends on an *unquoted* `>` only. Without
522
- /// this, a binding prop like `flipped="@[a >= b]"` truncates the open tag at the
523
- /// `>` inside its value, so the trailing attributes get mis-parsed (empty
524
- /// boolean props → literal `true`) and corrupt the inlined template.
525
- const ATTR_RUN: &str = r#"(?:"[^"]*"|'[^']*'|[^>"'])*"#;
526
-
527
- fn is_ident_byte(b: u8) -> bool {
528
- b.is_ascii_alphanumeric() || b == b'_' || b == b'$'
529
- }
530
-
531
- /// Case-insensitive free-identifier substitution, mirroring the runtime's
532
- /// renderPropsAndSlot idRegex: the name must not be preceded by an identifier
533
- /// character or `.` (property access) and not followed by an identifier
534
- /// character. Case-insensitive because HTML lowercases attribute names while
535
- /// component templates reference the author's camelCase identifiers.
536
- fn substitute_identifier(expr: &str, name: &str, replacement: &str) -> String {
537
- let bytes = expr.as_bytes();
538
- let nb = name.as_bytes();
539
- let nlen = nb.len();
540
- if nlen == 0 {
541
- return expr.to_string();
542
- }
543
- let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
544
- let mut i = 0;
545
- // Delimiter of the string literal we're currently inside, or 0 when in code.
546
- // A prop identifier that appears inside a quoted string (e.g. the selector in
547
- // `closest('brawler-slot')`) must be left intact, mirroring the runtime's
548
- // substituteInExpr. Template `${…}` counts as part of the literal here.
549
- let mut quote: u8 = 0;
550
- while i < bytes.len() {
551
- let b = bytes[i];
552
- if quote != 0 {
553
- out.push(b);
554
- if b == b'\\' && i + 1 < bytes.len() {
555
- out.push(bytes[i + 1]);
556
- i += 2;
557
- continue;
558
- }
559
- if b == quote {
560
- quote = 0;
561
- }
562
- i += 1;
563
- continue;
564
- }
565
- if b == b'\'' || b == b'"' || b == b'`' {
566
- quote = b;
567
- out.push(b);
568
- i += 1;
569
- continue;
570
- }
571
- if i + nlen <= bytes.len()
572
- && bytes[i..i + nlen].eq_ignore_ascii_case(nb)
573
- && (i == 0 || (!is_ident_byte(bytes[i - 1]) && bytes[i - 1] != b'.'))
574
- && (i + nlen == bytes.len() || !is_ident_byte(bytes[i + nlen]))
575
- {
576
- out.extend_from_slice(replacement.as_bytes());
577
- i += nlen;
578
- } else {
579
- out.push(b);
580
- i += 1;
581
- }
582
- }
583
- String::from_utf8(out).unwrap()
584
- }
585
-
586
- /// `$.propName` rewrites inside event-handler bodies (the runtime's stateRegex
587
- /// pass): for a binding prop `value="@[email]"`, `$.value = this.value`
588
- /// becomes `$.email = this.value` — DOM property reads (`this.value`) stay.
589
- fn substitute_state_ref(body: &str, name: &str, replacement: &str) -> String {
590
- let bytes = body.as_bytes();
591
- let nb = name.as_bytes();
592
- let nlen = nb.len();
593
- if nlen == 0 {
594
- return body.to_string();
595
- }
596
- let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
597
- let mut i = 0;
598
- while i < bytes.len() {
599
- if bytes[i] == b'$'
600
- && i + 2 + nlen <= bytes.len()
601
- && bytes[i + 1] == b'.'
602
- && bytes[i + 2..i + 2 + nlen].eq_ignore_ascii_case(nb)
603
- && (i + 2 + nlen == bytes.len() || !is_ident_byte(bytes[i + 2 + nlen]))
604
- {
605
- out.extend_from_slice(b"$.");
606
- out.extend_from_slice(replacement.as_bytes());
607
- i += 2 + nlen;
608
- } else {
609
- out.push(bytes[i]);
610
- i += 1;
611
- }
612
- }
613
- String::from_utf8(out).unwrap()
614
- }
615
-
616
- /// Substitute component props into a template — the compiled twin of the
617
- /// runtime's renderPropsAndSlot (runtime/component.js). Both sides must
618
- /// transform identically:
619
- /// - exact `@[propName]` bindings (case-insensitive)
620
- /// Whether a component template's ROOT is an array-literal each
621
- /// (`<!-- each [expr] as a -->`). Such a component receives its iterable as a prop
622
- /// and depends on the runtime's `__vibeiterprops` indirection: the runtime
623
- /// evaluates the prop binding in the *enclosing* loop scope, stores the value in a
624
- /// global registry slot, and rewrites the each to iterate that slot. Inlining
625
- /// instead bakes the call-site's parent-loop alias straight into the each
626
- /// (`[card.signatureAbility]`); the runtime evaluates an array-literal iterable in
627
- /// GLOBAL scope (that's the registry-slot pattern), where the alias is undefined →
628
- /// the loop yields zero items (the empty AbilityCell / status-chip bug in compiled
629
- /// mode). So such components are NOT inlined — they stay as runtime
630
- /// `<component src>` tags, and the compiler ships their source so the runtime can
631
- /// fetch and instantiate them exactly as it does in non-compiled mode.
632
- fn is_iter_prop_root(template: &str) -> bool {
633
- let t = template.trim_start();
634
- t.strip_prefix("<!--")
635
- .map(str::trim_start)
636
- .and_then(|r| r.strip_prefix("each"))
637
- .map(str::trim_start)
638
- .is_some_and(|after| after.starts_with('['))
639
- }
640
-
641
- /// Substitute component props into a template.
642
- ///
643
- /// Rewrites:
644
- /// - prop identifiers inside other `@[expr]` bindings
645
- /// - prop identifiers inside directive comments (`if` / `else if` / `each`)
646
- /// - `$.propName` references inside event-handler attribute bodies
647
- /// Binding props (`prop="@[path]"`) rewrite identifiers to the bound path;
648
- /// literal props inject the value (bare boolean attrs → true, strings
649
- /// JSON-quoted, numerics raw).
650
- fn substitute_props(template: &str, props: &[(String, String)]) -> String {
651
- let binding_re = regex::Regex::new(r"@\[([^\]]+)\]").unwrap();
652
- let directive_re = regex::Regex::new(r"(?s)<!--\s*(if|else if|each)\s+(.*?)\s*-->").unwrap();
653
- let event_re = regex::Regex::new(r#"\bon(\w+)="([^"]*)""#).unwrap();
654
- let numeric_re = regex::Regex::new(r"(?i)^-?\d+(\.\d+)?(e[+-]?\d+)?$").unwrap();
655
-
656
- let mut html = template.to_string();
657
-
658
- for (prop_name, prop_value) in props {
659
- let exact_re =
660
- regex::Regex::new(&format!(r"(?i)@\[{}\]", regex::escape(prop_name))).unwrap();
661
-
662
- let binding_path = prop_value
663
- .strip_prefix("@[")
664
- .and_then(|v| v.strip_suffix(']'));
665
-
666
- let (exact_repl, ident_repl, each_repl, state_repl) = if let Some(path) = binding_path {
667
- (
668
- format!("@[{}]", path),
669
- format!("({})", path),
670
- path.to_string(),
671
- path.to_string(),
672
- )
673
- } else {
674
- let literal = if prop_value.is_empty() {
675
- "true".to_string()
676
- } else if numeric_re.is_match(prop_value) {
677
- prop_value.clone()
678
- } else {
679
- serde_json::to_string(prop_value).unwrap()
680
- };
681
- (prop_value.clone(), literal.clone(), literal.clone(), literal)
682
- };
683
-
684
- html = exact_re
685
- .replace_all(&html, regex::NoExpand(exact_repl.as_str()))
686
- .to_string();
687
-
688
- html = binding_re
689
- .replace_all(&html, |c: &regex::Captures| {
690
- let expr = c.get(1).unwrap().as_str();
691
- let rewritten = substitute_identifier(expr, prop_name, &ident_repl);
692
- if rewritten == expr {
693
- c.get(0).unwrap().as_str().to_string()
694
- } else {
695
- format!("@[{}]", rewritten)
696
- }
697
- })
698
- .to_string();
699
-
700
- html = directive_re
701
- .replace_all(&html, |c: &regex::Captures| {
702
- let kw = c.get(1).unwrap().as_str();
703
- let expr = c.get(2).unwrap().as_str();
704
- let repl = if kw == "each" { each_repl.as_str() } else { ident_repl.as_str() };
705
- let rewritten = substitute_identifier(expr, prop_name, repl);
706
- if rewritten == expr {
707
- c.get(0).unwrap().as_str().to_string()
708
- } else {
709
- format!("<!-- {} {} -->", kw, rewritten)
710
- }
711
- })
712
- .to_string();
713
-
714
- html = event_re
715
- .replace_all(&html, |c: &regex::Captures| {
716
- let ev = c.get(1).unwrap().as_str();
717
- let body = c.get(2).unwrap().as_str();
718
- // First the `$.prop` form, then the bare prop identifier — mirroring
719
- // the runtime's two-step rewrite so `onclick="pick(item)"` resolves
720
- // the live prop instead of throwing ReferenceError in global scope.
721
- // The bare pass skips `$.prop` (its `.`-lookbehind guard) so the two
722
- // don't collide.
723
- let after_state = substitute_state_ref(body, prop_name, &state_repl);
724
- let rewritten = substitute_identifier(&after_state, prop_name, &ident_repl);
725
- if rewritten == body {
726
- c.get(0).unwrap().as_str().to_string()
727
- } else {
728
- format!(r#"on{}="{}""#, ev, rewritten)
729
- }
730
- })
731
- .to_string();
732
- }
733
-
734
- html
735
- }
736
-
737
- /// Transform custom HTML elements to divs with classes
738
- fn transform_custom_tags_to_divs(content: &str, reserved_elements: &[String]) -> String {
739
- let mut result = content.to_string();
740
-
741
- // Standard HTML5 elements (should not be transformed)
742
- let standard_tags: HashSet<&str> = [
743
- "a", "abbr", "address", "area", "article", "aside", "audio",
744
- "b", "base", "bdi", "bdo", "blockquote", "body", "br", "button",
745
- "canvas", "caption", "cite", "code", "col", "colgroup",
746
- "data", "datalist", "dd", "del", "details", "dfn", "dialog", "div", "dl", "dt",
747
- "em", "embed",
748
- "fieldset", "figcaption", "figure", "footer", "form",
749
- "h1", "h2", "h3", "h4", "h5", "h6", "head", "header", "hgroup", "hr", "html",
750
- "i", "iframe", "img", "input", "ins",
751
- "kbd",
752
- "label", "legend", "li", "link",
753
- "main", "map", "mark", "menu", "meta", "meter",
754
- "nav", "noscript",
755
- "object", "ol", "optgroup", "option", "output",
756
- "p", "param", "picture", "pre", "progress",
757
- "q",
758
- "rp", "rt", "ruby",
759
- "s", "samp", "script", "search", "section", "select", "slot", "small", "source", "span", "strong", "style", "sub", "summary", "sup", "svg",
760
- "table", "tbody", "td", "template", "textarea", "tfoot", "th", "thead", "time", "title", "tr", "track",
761
- "u", "ul",
762
- "var", "video",
763
- "wbr",
764
- ].into_iter().collect();
765
-
766
- // Find all custom tags and transform them
767
- let tag_pattern = regex::Regex::new(r"<([a-z][a-z0-9-]*)([^>]*)>").unwrap();
768
- let _closing_pattern = regex::Regex::new(r"</([a-z][a-z0-9-]*)>").unwrap();
769
-
770
- // Collect unique custom tags (excluding standard HTML tags and reserved elements)
771
- let mut custom_tags: Vec<String> = Vec::new();
772
- for cap in tag_pattern.captures_iter(&result.clone()) {
773
- if let Some(m) = cap.get(1) {
774
- let tag = m.as_str().to_string();
775
- if !standard_tags.contains(tag.as_str())
776
- && !reserved_elements.contains(&tag)
777
- && !custom_tags.contains(&tag)
778
- {
779
- custom_tags.push(tag);
780
- }
781
- }
782
- }
783
-
784
- // Sort by descending length so more specific tags (e.g. "accordion-content")
785
- // are processed before shorter prefixes (e.g. "accordion"), preventing
786
- // partial tag-name matches like <accordion([^>]*)> matching <accordion-content>
787
- custom_tags.sort_by(|a, b| b.len().cmp(&a.len()));
788
-
789
- // Pre-compile class attribute regex for merging existing class values
790
- let class_attr_re = regex::Regex::new(r#"\bclass="([^"]*)""#).unwrap();
791
-
792
- // Transform each custom tag
793
- for tag in &custom_tags {
794
- // Opening tag: require whitespace or end-of-tag after the tag name so that
795
- // <accordion> does not accidentally match <accordion-content>
796
- let open_re = regex::Regex::new(
797
- &format!(r"<{}([\s][^>]*|)>", regex::escape(tag))
798
- ).unwrap();
799
-
800
- result = open_re.replace_all(&result, |caps: &regex::Captures| -> String {
801
- let attrs = caps.get(1).map(|m| m.as_str()).unwrap_or("");
802
-
803
- // If the element already has class="...", merge tag name with existing value
804
- if let Some(class_cap) = class_attr_re.captures(attrs) {
805
- let existing = class_cap.get(1).unwrap().as_str();
806
- let merged = format!("{} {}", tag, existing);
807
- let new_attrs = class_attr_re.replace(
808
- attrs,
809
- format!(r#"class="{}""#, merged.trim()).as_str(),
810
- );
811
- format!("<div{}>", new_attrs)
812
- } else {
813
- format!("<div class=\"{}\"{}>", tag, attrs)
814
- }
815
- }).to_string();
816
-
817
- // Closing tag: </custom-tag> -> </div>
818
- let close_re = regex::Regex::new(&format!(r"</{}>", regex::escape(tag))).unwrap();
819
- result = close_re.replace_all(&result, "</div>").to_string();
820
- }
821
-
822
- // Note: <component> tags are NOT transformed - they are a framework element, not a custom element
823
- // Only user-defined custom elements are transformed to divs
824
-
825
- result
826
- }
827
-
828
- #[cfg(test)]
829
- mod tests {
830
- use super::*;
831
-
832
- #[test]
833
- fn find_matching_close_tolerates_whitespace_in_end_tag() {
834
- // Whitespace-controlled markup splits an end tag across lines, e.g.
835
- // <icon-cell
836
- // ><component src="x"></component
837
- // ></icon-cell>
838
- // which reaches the inliner as `</component\n>`. HTML permits
839
- // whitespace before the `>` of an end tag, so the depth counter must
840
- // treat `</component\n>` as the nested close. If it doesn't, the nested
841
- // open is counted but never closed and the OUTER close search overshoots,
842
- // swallowing every following sibling into the component.
843
- let content = "<component><a><component src=\"x\"></component\n></a></component><sibling></sibling>";
844
- let after_open = "<component>".len();
845
-
846
- let (close, close_end) = HtmlParser::find_matching_close(content, after_open, "component")
847
- .expect("outer </component> must be found");
848
-
849
- // The match must be the OUTER close (immediately before <sibling>), not
850
- // an overshoot past it.
851
- assert!(content[close..].starts_with("</component>"), "matched wrong close: {:?}", &content[close..close + 14]);
852
- assert!(content[close..].contains("<sibling>"), "sibling was swallowed into the component");
853
- // The reported end lands just past the close tag's `>`.
854
- assert!(content[..close_end].ends_with('>'), "close_end must sit right after the `>`");
855
- }
856
-
857
- #[test]
858
- fn inline_component_with_split_end_tag_leaves_no_stray_gt() {
859
- // Prettier's whitespace-controlled wrapping splits an end tag across
860
- // lines (`</component\n>`). find_matching_close tolerates that whitespace
861
- // when locating the close, but the caller computed the replacement end as
862
- // `close_start + len("</component>")` — too short by the whitespace before
863
- // the `>`. The final `>` was therefore left behind as a stray text node
864
- // (the literal ">" that leaked next to inlined components in the app).
865
- let parser = HtmlParser::new(std::path::PathBuf::from("."));
866
- let page = "<wrap><component src=\"/components/Potion.html\"></component\n></wrap>";
867
- let template = "<icon potion></icon>";
868
- let mut cache = HashMap::new();
869
- cache.insert("/components/Potion.html".to_string(), template.to_string());
870
-
871
- let out = parser.inline_component_elements(page, &cache);
872
-
873
- assert_eq!(
874
- out, "<wrap><component><icon potion></icon></component></wrap>",
875
- "split end tag left a stray `>` (or otherwise mis-sliced the close): {out}"
876
- );
877
- }
878
-
879
- #[test]
880
- fn inline_component_tolerates_gt_in_prop_binding() {
881
- // A component prop whose `@[...]` value contains a comparison operator
882
- // (`>=`) must not truncate the open tag at the `>` *inside the value*.
883
- // The flawed `[^>]*` attribute run cut the tag mid-attribute, so the
884
- // trailing identifiers (`selectedBrawlers`, `length`) were mis-parsed as
885
- // empty boolean props (→ literal `true`), rewriting the loop expression
886
- // to `brawlHandCards(characters, true)` — which throws `true.includes is
887
- // not a function` at runtime, so the card discs never render.
888
- let parser = HtmlParser::new(std::path::PathBuf::from("."));
889
- let page = concat!(
890
- r#"<page><component src="/components/remote/CardHand.html" "#,
891
- r#"cards="@[brawlHandCards(characters, selectedBrawlers)]" "#,
892
- r#"flipped="@[selectedBrawlers.length >= maxBrawlers]" "#,
893
- r#"angle="16"></component></page>"#,
894
- );
895
- let template = concat!(
896
- r#"<card-hand flipped="@[flipped]"><card-fan style="--angle: @[angle]deg">"#,
897
- r#"<!-- each cards as card (card.id), i --><card-slot data-id="@[card.id]"></card-slot>"#,
898
- r#"<!-- /each --></card-fan></card-hand>"#,
899
- );
900
- let mut cache = HashMap::new();
901
- cache.insert("/components/remote/CardHand.html".to_string(), template.to_string());
902
-
903
- let out = parser.inline_component_elements(page, &cache);
904
-
905
- // The loop expression must carry `selectedBrawlers` through untouched.
906
- assert!(
907
- out.contains("brawlHandCards(characters, selectedBrawlers)"),
908
- "loop expression corrupted: {out}"
909
- );
910
- assert!(
911
- !out.contains("brawlHandCards(characters, true)"),
912
- "selectedBrawlers wrongly replaced with `true`: {out}"
913
- );
914
- // The binding prop keeps its full comparison expression.
915
- assert!(
916
- out.contains(r#"flipped="@[selectedBrawlers.length >= maxBrawlers]""#),
917
- "flipped binding lost: {out}"
918
- );
919
- // A literal prop *after* the `>=` prop must still substitute — proof the
920
- // tag was parsed to the real `>`, not the one inside the value.
921
- assert!(out.contains("--angle: 16deg"), "angle not substituted: {out}");
922
- }
923
-
924
- #[test]
925
- fn inline_custom_element_tolerates_gt_in_prop_binding() {
926
- // Same defect in the custom-element inliner's `<tag(\s[^>]*)?>` regex.
927
- // (Cached element tags are filename-derived single words — see element.rs.)
928
- let mut parser = HtmlParser::new(std::path::PathBuf::from("."));
929
- parser.cache.insert(
930
- "gauge".to_string(),
931
- Element::new(
932
- "gauge".to_string(),
933
- std::path::PathBuf::from("gauge.html"),
934
- r#"<gauge-inner show="@[show]" n="@[count]"></gauge-inner>"#.to_string(),
935
- ),
936
- );
937
-
938
- let page = r#"<page><gauge show="@[items.length >= max]" count="7"></gauge></page>"#;
939
- let out = parser.inline_custom_elements(page);
940
-
941
- // The `>=` inside the binding must not have truncated the open tag.
942
- assert!(
943
- out.contains(r#"show="@[items.length >= max]""#),
944
- "show binding lost: {out}"
945
- );
946
- // A literal prop after the `>=` prop still substitutes → tag parsed fully.
947
- assert!(out.contains(r#"n="7""#), "count not substituted: {out}");
948
- }
949
-
950
- #[test]
951
- fn each_root_component_is_left_for_runtime() {
952
- // A component whose ROOT is an array-literal each (`<!-- each [prop] as a -->`)
953
- // depends on the runtime's __vibeiterprops indirection (the runtime evaluates
954
- // the prop in the enclosing loop scope and stashes it in a global registry
955
- // slot the each iterates). Inlining bakes the parent-loop alias into the each
956
- // (`[card.signatureAbility]`); the runtime evaluates array-literal iterables
957
- // in GLOBAL scope, where that alias is undefined → zero items (the empty
958
- // AbilityCell / status-chip bug). So such a component must NOT be inlined:
959
- // leave the `<component src=...>` tag for the runtime to fetch and instantiate.
960
- let parser = HtmlParser::new(std::path::PathBuf::from("."));
961
- let page = concat!(
962
- r#"<!-- each cards as card --><card-section>"#,
963
- r#"<component src="/components/AbilityCell.html" ability="@[card.signatureAbility]"></component>"#,
964
- r#"</card-section><!-- /each -->"#,
965
- );
966
- // AbilityCell's root is an each over the `[ability]` array-literal prop.
967
- let template = r#"<!-- each [ability] as a --><ability-tile><icon @[a.icon]></icon></ability-tile><!-- /each -->"#;
968
- let mut cache = HashMap::new();
969
- cache.insert("/components/AbilityCell.html".to_string(), template.to_string());
970
-
971
- let out = parser.inline_component_elements(page, &cache);
972
-
973
- // Left un-inlined for the runtime, with its prop binding intact…
974
- assert!(
975
- out.contains(r#"<component src="/components/AbilityCell.html""#),
976
- "each-root component must be left un-inlined for the runtime: {out}"
977
- );
978
- assert!(
979
- out.contains(r#"ability="@[card.signatureAbility]""#),
980
- "prop binding lost on un-inlined component: {out}"
981
- );
982
- // …and the parent-loop alias must NOT have been baked into an each iterable.
983
- assert!(
984
- !out.contains("each [card.signatureAbility]"),
985
- "parent-loop alias was inlined into the component each (the bug): {out}"
986
- );
987
- }
988
-
989
- #[test]
990
- fn ordinary_component_still_inlines() {
991
- // Guard: a normal component (root is NOT an array-literal each) must still
992
- // inline as before — the skip is scoped to the iter-prop pattern only.
993
- let parser = HtmlParser::new(std::path::PathBuf::from("."));
994
- let page = r#"<page><component src="/components/Badge.html" label="@[title]"></component></page>"#;
995
- let template = r#"<badge-pill>@[label]</badge-pill>"#;
996
- let mut cache = HashMap::new();
997
- cache.insert("/components/Badge.html".to_string(), template.to_string());
998
-
999
- let out = parser.inline_component_elements(page, &cache);
1000
-
1001
- assert!(out.contains("<badge-pill>"), "ordinary component should inline: {out}");
1002
- assert!(!out.contains(r#"src="/components/Badge.html""#), "ordinary component src should be gone: {out}");
1003
- }
1004
- }