@ape-egg/vibe 2.3.0 → 3.0.1
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/README.md +14 -4
- package/boot.js +4 -4
- package/component.js +27 -29
- package/hot-module-refresh.js +4 -4
- package/index.js +10 -15
- package/llms.txt +8 -6
- package/package.json +19 -14
- package/runtime/affected.js +159 -36
- package/runtime/cleanup.js +45 -1
- package/runtime/component.js +312 -99
- package/runtime/conditionals.js +111 -14
- package/runtime/debug.js +24 -0
- package/runtime/dispatch.js +172 -0
- package/runtime/hydrate.js +251 -111
- package/runtime/index.js +180 -71
- package/runtime/iterate.js +125 -50
- package/runtime/iteration-utils.js +59 -8
- package/runtime/manifest.js +77 -2
- package/runtime/parse.js +69 -5
- package/runtime/pre-compiled-iterations.js +19 -6
- package/runtime/pre-compiled-manifest.js +13 -4
- package/runtime/staging.js +153 -0
- package/runtime/state.js +31 -0
- package/runtime/tracking.js +173 -0
- package/runtime/utils.js +155 -78
- package/spa.js +77 -14
- package/vibe.css +8 -4
- package/CHANGELOG.md +0 -1196
- package/ROADMAP.md +0 -397
- package/compiler/bin/vibe-compile.js +0 -121
- package/compiler/native/.gitkeep +0 -0
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/Cargo.lock +0 -2023
- package/compiler/src/Cargo.toml +0 -38
- package/compiler/src/compiler/PRE-RENDERING-IMPLEMENTATION.md +0 -241
- package/compiler/src/compiler/binding_case.rs +0 -88
- package/compiler/src/compiler/compile.rs +0 -2880
- package/compiler/src/compiler/component_tagger.rs +0 -469
- package/compiler/src/compiler/iteration_optimizer.rs +0 -455
- package/compiler/src/compiler/js_analyzer.rs +0 -715
- package/compiler/src/compiler/manifest_builder.rs +0 -693
- package/compiler/src/compiler/mod.rs +0 -16
- package/compiler/src/compiler/name_binding_protect.rs +0 -207
- package/compiler/src/compiler/reassignment_analyzer.rs +0 -456
- package/compiler/src/compiler/spa.rs +0 -477
- package/compiler/src/compiler/state_extractor.rs +0 -263
- package/compiler/src/compiler/value_stamper.rs +0 -921
- package/compiler/src/compiler/watcher.rs +0 -1278
- package/compiler/src/config.rs +0 -279
- package/compiler/src/main.rs +0 -358
- package/compiler/src/parser/element.rs +0 -96
- package/compiler/src/parser/html.rs +0 -1004
- package/compiler/src/parser/mod.rs +0 -8
- package/runtime/pre-compiled-manifest.test.mjs +0 -58
- package/runtime/scope.js +0 -50
- 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: ®ex::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: ®ex::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: ®ex::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: ®ex::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
|
-
}
|