@ape-egg/vibe 2.0.0 → 2.1.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/CHANGELOG.md +97 -0
- package/README.md +22 -1
- package/ROADMAP.md +45 -0
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/Cargo.lock +1 -1
- package/compiler/src/Cargo.toml +1 -1
- package/compiler/src/compiler/compile.rs +100 -13
- package/compiler/src/compiler/iteration_optimizer.rs +188 -112
- package/compiler/src/compiler/manifest_builder.rs +5 -9
- package/compiler/src/parser/html.rs +194 -31
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/runtime/cleanup.js +12 -3
- package/runtime/component-cache.js +94 -0
- package/runtime/component.js +139 -46
- package/runtime/conditionals.js +16 -9
- package/runtime/constants.js +2 -1
- package/runtime/debug.js +2 -1
- package/runtime/hydrate.js +12 -3
- package/runtime/index.js +38 -2
- package/runtime/iterate.js +103 -22
- package/runtime/loop-scope.js +49 -3
- package/runtime/parse.js +8 -0
- package/runtime/pre-compiled-manifest.js +56 -155
- package/runtime/utils.js +12 -2
|
@@ -60,6 +60,16 @@ fn extract_and_compile_iterations(html: &str) -> HashMap<String, CompiledIterati
|
|
|
60
60
|
continue;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// Raw-HTML bindings can't ride a template literal: a batch function
|
|
64
|
+
// interpolates RawHtml via toString(), which escapes. Skip the batch
|
|
65
|
+
// function so the runtime renders these rows through clone+hydrate —
|
|
66
|
+
// the one place that implements $.unsafe semantics (innerHTML +
|
|
67
|
+
// inert subtree).
|
|
68
|
+
if template_html.contains("$.unsafe(") {
|
|
69
|
+
search_start = end_after;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
63
73
|
// Generate hash for this template
|
|
64
74
|
let hash = generate_template_hash(template_html);
|
|
65
75
|
|
|
@@ -113,6 +123,54 @@ fn generate_template_hash(template: &str) -> String {
|
|
|
113
123
|
format!("iter_{:x}", hash)
|
|
114
124
|
}
|
|
115
125
|
|
|
126
|
+
/// DOM element properties — set on the element, kept as value attributes in
|
|
127
|
+
/// batch output. Mirrors DOM_PROPERTIES in runtime/constants.js.
|
|
128
|
+
const DOM_PROPERTIES: &[&str] = &["value", "checked", "selected"];
|
|
129
|
+
|
|
130
|
+
/// Attributes that keep their string value verbatim (never boolean-coerced).
|
|
131
|
+
/// Mirrors VALUE_ATTRS in runtime/constants.js — the two lists must stay in
|
|
132
|
+
/// lockstep or batch and clone paths render different attributes.
|
|
133
|
+
const VALUE_ATTRS: &[&str] = &[
|
|
134
|
+
"class", "style", "id", "title", "lang", "dir", "tabindex", "accesskey",
|
|
135
|
+
"slot", "part", "is", "nonce", "popover", "anchor",
|
|
136
|
+
"contenteditable", "draggable", "spellcheck", "translate",
|
|
137
|
+
"autocapitalize", "inputmode", "enterkeyhint", "virtualkeyboardpolicy",
|
|
138
|
+
"href", "src", "action", "cite", "data", "poster", "srcset",
|
|
139
|
+
"imagesrcset", "formaction", "ping", "usemap", "manifest", "codebase",
|
|
140
|
+
"name", "type", "value", "placeholder", "pattern", "min", "max", "step",
|
|
141
|
+
"minlength", "maxlength", "size", "accept", "autocomplete", "list",
|
|
142
|
+
"form", "formmethod", "formtarget", "formenctype", "wrap", "method",
|
|
143
|
+
"enctype", "for", "dirname",
|
|
144
|
+
"alt", "label", "summary", "abbr",
|
|
145
|
+
"width", "height", "cols", "rows", "span", "rowspan", "colspan",
|
|
146
|
+
"low", "high", "optimum",
|
|
147
|
+
"target", "rel", "hreflang", "download", "as", "media", "charset",
|
|
148
|
+
"crossorigin", "integrity", "loading", "decoding", "fetchpriority",
|
|
149
|
+
"referrerpolicy", "blocking", "imagesizes", "sizes",
|
|
150
|
+
"preload", "kind", "srclang",
|
|
151
|
+
"content", "http-equiv",
|
|
152
|
+
"sandbox", "allow", "srcdoc", "credentialless",
|
|
153
|
+
"headers", "scope",
|
|
154
|
+
"datetime",
|
|
155
|
+
"coords", "shape",
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
/// Whether an attribute keeps its string value (vs boolean coercion).
|
|
159
|
+
/// Mirrors isValueStyleAttr in runtime/iterate.js.
|
|
160
|
+
fn is_value_style_attr(name: &str) -> bool {
|
|
161
|
+
VALUE_ATTRS.contains(&name)
|
|
162
|
+
|| name.starts_with("data-")
|
|
163
|
+
|| name.starts_with("aria-")
|
|
164
|
+
|| name.starts_with("on")
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/// Escape a static text chunk for a JS template-literal context.
|
|
168
|
+
fn escape_tpl_text(text: &str) -> String {
|
|
169
|
+
text.replace('\\', "\\\\")
|
|
170
|
+
.replace('`', "\\`")
|
|
171
|
+
.replace("${", "\\${")
|
|
172
|
+
}
|
|
173
|
+
|
|
116
174
|
/// Compile template HTML to a batch function string
|
|
117
175
|
/// Supports nested <!-- each --> blocks
|
|
118
176
|
fn compile_template_to_batch_fn(
|
|
@@ -120,134 +178,120 @@ fn compile_template_to_batch_fn(
|
|
|
120
178
|
item_alias: &str,
|
|
121
179
|
index_alias: &str,
|
|
122
180
|
) -> String {
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
//
|
|
127
|
-
let binding_re = Regex::new(r"@\[([^\]]+)\]").unwrap();
|
|
128
|
-
let with_bindings = binding_re.replace_all(&processed_template, |caps: ®ex::Captures| {
|
|
129
|
-
format!("${{{}}}", &caps[1])
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
// Fix boolean attributes: remove ="" from attributes to match CSS selectors
|
|
133
|
-
// Converts: <div attr=""> to <div attr>
|
|
181
|
+
// Normalize static boolean attributes: <div attr=""> → <div attr>
|
|
182
|
+
// (matches CSS attribute selectors). Runs on raw template text, before
|
|
183
|
+
// emission — emitted JS below also contains attr="" inside string
|
|
184
|
+
// literals, which must not be touched.
|
|
134
185
|
let boolean_attr_re = Regex::new(r#"(\w+)="""#).unwrap();
|
|
135
|
-
let
|
|
186
|
+
let cleaned = boolean_attr_re.replace_all(template, "$1");
|
|
136
187
|
|
|
137
|
-
|
|
138
|
-
let escaped = with_boolean_attrs
|
|
139
|
-
.replace('\\', "\\\\");
|
|
188
|
+
let body = emit_template_literal(&cleaned);
|
|
140
189
|
|
|
141
|
-
// Generate batch function
|
|
142
190
|
format!(
|
|
143
|
-
r#"(arr, $) => {{ let html = ''; const len = arr.length; for (let {index} = 0; {index} < len; {index}++) {{ const {item} = arr[{index}]; html += `{
|
|
191
|
+
r#"(arr, $) => {{ let html = ''; const len = arr.length; for (let {index} = 0; {index} < len; {index}++) {{ const {item} = arr[{index}]; html += `{body}`; }} return html; }}"#,
|
|
144
192
|
index = index_alias,
|
|
145
193
|
item = item_alias,
|
|
146
|
-
|
|
194
|
+
body = body
|
|
147
195
|
)
|
|
148
196
|
}
|
|
149
197
|
|
|
150
|
-
///
|
|
151
|
-
///
|
|
152
|
-
|
|
198
|
+
/// Emit the body of a JS template literal for a template chunk:
|
|
199
|
+
/// - static text escaped for template-literal context
|
|
200
|
+
/// - attribute pure bindings classified like hydrate.js / compileBatchFn:
|
|
201
|
+
/// DOM properties and value-style attributes keep `attr="${expr}"`,
|
|
202
|
+
/// boolean-coerced attributes become `${(expr) ? ' attr=""' : ''}` so the
|
|
203
|
+
/// attribute is ABSENT when falsy and present-empty when truthy
|
|
204
|
+
/// - remaining @[expr] bindings (text content, partial attribute values)
|
|
205
|
+
/// become `${expr}` interpolations
|
|
206
|
+
/// - nested <!-- each --> blocks become inline IIFE loops, recursively
|
|
207
|
+
///
|
|
208
|
+
/// Anything the runtime clone+hydrate path renders, this output has to render
|
|
209
|
+
/// identically — it is the compiled twin of compileBatchFn in runtime/iterate.js.
|
|
210
|
+
fn emit_template_literal(template: &str) -> String {
|
|
153
211
|
let each_re = Regex::new(r"<!--\s*each\s+([^\s]+)\s+as\s+([^\s,]+)(?:\s*,\s*([^\s]+))?\s*-->").unwrap();
|
|
154
212
|
let end_re = Regex::new(r"<!--\s*/each\s*-->").unwrap();
|
|
155
|
-
|
|
156
|
-
let mut result = template.to_string();
|
|
157
|
-
let mut replacements = Vec::new();
|
|
158
|
-
|
|
159
|
-
// Find all nested iterations
|
|
160
|
-
let mut search_start = 0;
|
|
161
|
-
while let Some(start_match) = each_re.find_at(&result, search_start) {
|
|
162
|
-
let captures = each_re.captures(&result[start_match.start()..]).unwrap();
|
|
163
|
-
let array_path = captures.get(1).unwrap().as_str();
|
|
164
|
-
let item_alias = captures.get(2).unwrap().as_str();
|
|
165
|
-
let index_alias = captures.get(3).map(|m| m.as_str()).unwrap_or("index");
|
|
166
|
-
|
|
167
|
-
let template_start = start_match.end();
|
|
168
|
-
|
|
169
|
-
// Find matching <!-- /each --> using depth counting
|
|
170
|
-
if let Some((end_pos, _)) = find_matching_each_end(&result, template_start) {
|
|
171
|
-
let inner_template = &result[template_start..end_pos];
|
|
172
|
-
|
|
173
|
-
// Recursively process inner template
|
|
174
|
-
let processed_inner = process_nested_iterations(inner_template);
|
|
175
|
-
|
|
176
|
-
// For nested iterations, convert to string concatenation
|
|
177
|
-
// Uses JSON string escaping which is valid JavaScript
|
|
178
|
-
let inner_concat_code = convert_to_string_concat(&processed_inner, item_alias, index_alias, array_path);
|
|
179
|
-
|
|
180
|
-
let nested_code = format!("${{(() => {{ let inner = ''; const len_{idx} = {arr}.length; for (let {idx} = 0; {idx} < len_{idx}; {idx}++) {{ const {item} = {arr}[{idx}]; {code} }} return inner; }})()}}",
|
|
181
|
-
idx = index_alias,
|
|
182
|
-
arr = array_path,
|
|
183
|
-
item = item_alias,
|
|
184
|
-
code = inner_concat_code
|
|
185
|
-
);
|
|
186
|
-
|
|
187
|
-
// Store replacement (from start to end including comments)
|
|
188
|
-
let end_match = end_re.find_at(&result, end_pos).unwrap();
|
|
189
|
-
replacements.push((start_match.start(), end_match.end(), nested_code));
|
|
190
|
-
|
|
191
|
-
search_start = end_match.end();
|
|
192
|
-
} else {
|
|
193
|
-
break;
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// Apply replacements in reverse order to maintain positions
|
|
198
|
-
for (start, end, replacement) in replacements.iter().rev() {
|
|
199
|
-
result.replace_range(*start..*end, replacement);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
result
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/// Convert template to string concatenation code using template literals
|
|
206
|
-
/// Parses @[expr] and converts to: inner += `text${expr}more text`;
|
|
207
|
-
/// Template literals properly handle newlines without escaping
|
|
208
|
-
/// NOTE: Backslashes and ${ need escaping, but backticks don't (they'll be in JSON)
|
|
209
|
-
fn convert_to_string_concat(template: &str, _item_alias: &str, _index_alias: &str, _array_path: &str) -> String {
|
|
213
|
+
let attr_binding_re = Regex::new(r#"(\s)([\w-]+)="@\[([^\]]+)\]""#).unwrap();
|
|
210
214
|
let binding_re = Regex::new(r"@\[([^\]]+)\]").unwrap();
|
|
211
215
|
|
|
212
|
-
|
|
213
|
-
let mut
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
let
|
|
218
|
-
let
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
216
|
+
let mut out = String::new();
|
|
217
|
+
let mut pos = 0;
|
|
218
|
+
|
|
219
|
+
while pos < template.len() {
|
|
220
|
+
let next_each = each_re.find_at(template, pos);
|
|
221
|
+
let next_attr = attr_binding_re.find_at(template, pos);
|
|
222
|
+
let next_binding = binding_re.find_at(template, pos);
|
|
223
|
+
|
|
224
|
+
// Earliest match wins; attr-binding outranks plain binding at the same
|
|
225
|
+
// region (the plain regex would match inside the attr form).
|
|
226
|
+
let candidates = [
|
|
227
|
+
next_each.map(|m| (m.start(), 0u8)),
|
|
228
|
+
next_attr.map(|m| (m.start(), 1u8)),
|
|
229
|
+
next_binding.map(|m| (m.start(), 2u8)),
|
|
230
|
+
];
|
|
231
|
+
let Some(&(start, kind)) = candidates
|
|
232
|
+
.iter()
|
|
233
|
+
.flatten()
|
|
234
|
+
.min_by_key(|(s, k)| (*s, *k))
|
|
235
|
+
else {
|
|
236
|
+
out.push_str(&escape_tpl_text(&template[pos..]));
|
|
237
|
+
break;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
out.push_str(&escape_tpl_text(&template[pos..start]));
|
|
241
|
+
|
|
242
|
+
match kind {
|
|
243
|
+
0 => {
|
|
244
|
+
// Nested <!-- each --> → inline IIFE loop
|
|
245
|
+
let m = next_each.unwrap();
|
|
246
|
+
let caps = each_re.captures(&template[m.start()..]).unwrap();
|
|
247
|
+
let arr = caps.get(1).unwrap().as_str();
|
|
248
|
+
let item = caps.get(2).unwrap().as_str();
|
|
249
|
+
let idx = caps.get(3).map(|c| c.as_str()).unwrap_or("index");
|
|
250
|
+
|
|
251
|
+
if let Some((end_pos, _)) = find_matching_each_end(template, m.end()) {
|
|
252
|
+
let inner = emit_template_literal(&template[m.end()..end_pos]);
|
|
253
|
+
out.push_str(&format!(
|
|
254
|
+
"${{(() => {{ let inner = ''; const len_{idx} = {arr}.length; for (let {idx} = 0; {idx} < len_{idx}; {idx}++) {{ const {item} = {arr}[{idx}]; inner += `{inner_body}`; }} return inner; }})()}}",
|
|
255
|
+
idx = idx,
|
|
256
|
+
arr = arr,
|
|
257
|
+
item = item,
|
|
258
|
+
inner_body = inner
|
|
259
|
+
));
|
|
260
|
+
let end_match = end_re.find_at(template, end_pos).unwrap();
|
|
261
|
+
pos = end_match.end();
|
|
262
|
+
} else {
|
|
263
|
+
// Unbalanced each — emit as text and move on
|
|
264
|
+
out.push_str(&escape_tpl_text(&template[m.start()..m.end()]));
|
|
265
|
+
pos = m.end();
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
1 => {
|
|
269
|
+
// Attribute pure binding: attr="@[expr]"
|
|
270
|
+
let m = next_attr.unwrap();
|
|
271
|
+
let caps = attr_binding_re.captures(&template[m.start()..]).unwrap();
|
|
272
|
+
let ws = caps.get(1).unwrap().as_str();
|
|
273
|
+
let name = caps.get(2).unwrap().as_str();
|
|
274
|
+
let expr = caps.get(3).unwrap().as_str();
|
|
275
|
+
let name_lc = name.to_lowercase();
|
|
276
|
+
|
|
277
|
+
if DOM_PROPERTIES.contains(&name_lc.as_str()) || is_value_style_attr(&name_lc) {
|
|
278
|
+
out.push_str(&format!("{ws}{name}=\"${{{expr}}}\""));
|
|
279
|
+
} else {
|
|
280
|
+
out.push_str(&format!("${{({expr}) ? ' {name}=\"\"' : ''}}"));
|
|
281
|
+
}
|
|
282
|
+
pos = m.end();
|
|
283
|
+
}
|
|
284
|
+
_ => {
|
|
285
|
+
// Plain binding: text content or partial attribute value
|
|
286
|
+
let m = next_binding.unwrap();
|
|
287
|
+
let caps = binding_re.captures(&template[m.start()..]).unwrap();
|
|
288
|
+
out.push_str(&format!("${{{}}}", caps.get(1).unwrap().as_str()));
|
|
289
|
+
pos = m.end();
|
|
290
|
+
}
|
|
230
291
|
}
|
|
231
|
-
|
|
232
|
-
// Add expression
|
|
233
|
-
result.push_str("${");
|
|
234
|
-
result.push_str(expr);
|
|
235
|
-
result.push_str("}");
|
|
236
|
-
|
|
237
|
-
last_end = match_end;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// Add remaining text
|
|
241
|
-
if last_end < template.len() {
|
|
242
|
-
let text = &template[last_end..];
|
|
243
|
-
let escaped = text
|
|
244
|
-
.replace('\\', "\\\\")
|
|
245
|
-
.replace("${", "\\${");
|
|
246
|
-
result.push_str(&escaped);
|
|
247
292
|
}
|
|
248
293
|
|
|
249
|
-
|
|
250
|
-
result
|
|
294
|
+
out
|
|
251
295
|
}
|
|
252
296
|
|
|
253
297
|
/// Find matching <!-- /each --> comment using depth counting
|
|
@@ -323,6 +367,38 @@ mod tests {
|
|
|
323
367
|
assert_eq!(compiled.index_alias, "idx");
|
|
324
368
|
}
|
|
325
369
|
|
|
370
|
+
#[test]
|
|
371
|
+
fn test_boolean_attr_binding_emits_conditional_presence() {
|
|
372
|
+
let html = r#"
|
|
373
|
+
<!-- each items as item -->
|
|
374
|
+
<attr-target open="@[flag]" href="@[item.url]" value="@[item.v]"></attr-target>
|
|
375
|
+
<!-- /each -->
|
|
376
|
+
"#;
|
|
377
|
+
|
|
378
|
+
let opts = build_iteration_optimizations(html);
|
|
379
|
+
let iterations = &opts.unwrap().iterations;
|
|
380
|
+
let (_, compiled) = iterations.iter().next().unwrap();
|
|
381
|
+
|
|
382
|
+
// Boolean-coerced attribute: absent when falsy, present-empty when truthy
|
|
383
|
+
assert!(compiled.batch_fn.contains(r#"${(flag) ? ' open=""' : ''}"#));
|
|
384
|
+
// Value-style attribute keeps its string value
|
|
385
|
+
assert!(compiled.batch_fn.contains(r#"href="${item.url}""#));
|
|
386
|
+
// DOM property keeps its value form
|
|
387
|
+
assert!(compiled.batch_fn.contains(r#"value="${item.v}""#));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
#[test]
|
|
391
|
+
fn test_unsafe_template_skips_batch_fn() {
|
|
392
|
+
let html = r#"
|
|
393
|
+
<!-- each rows as row -->
|
|
394
|
+
<li>@[$.unsafe(row.markup)]</li>
|
|
395
|
+
<!-- /each -->
|
|
396
|
+
"#;
|
|
397
|
+
|
|
398
|
+
// RawHtml semantics can't ride a template literal — no batch function
|
|
399
|
+
assert!(build_iteration_optimizations(html).is_none());
|
|
400
|
+
}
|
|
401
|
+
|
|
326
402
|
#[test]
|
|
327
403
|
fn test_nested_iteration_compiled() {
|
|
328
404
|
let html = r#"
|
|
@@ -395,19 +395,15 @@ impl ManifestBuilder {
|
|
|
395
395
|
}
|
|
396
396
|
}
|
|
397
397
|
|
|
398
|
-
// Extract template HTML between start and end
|
|
398
|
+
// Extract template HTML between start and end — VERBATIM, including
|
|
399
|
+
// whitespace-only text nodes. The manifest's child keys are childNodes
|
|
400
|
+
// indices computed on the pre-stamp DOM; restoration re-inserts this
|
|
401
|
+
// template, and only an exact node-count round-trip keeps those
|
|
402
|
+
// indices valid for every sibling that follows the restored region.
|
|
399
403
|
if let Some(end) = end_idx {
|
|
400
404
|
let template_nodes: Vec<_> = siblings.iter()
|
|
401
405
|
.skip(start_idx + 1)
|
|
402
406
|
.take(end - start_idx - 1)
|
|
403
|
-
.filter(|node| {
|
|
404
|
-
// Skip whitespace-only text nodes
|
|
405
|
-
if let NodeData::Text { contents } = &node.data {
|
|
406
|
-
!contents.borrow().trim().is_empty()
|
|
407
|
-
} else {
|
|
408
|
-
true // Keep all non-text nodes
|
|
409
|
-
}
|
|
410
|
-
})
|
|
411
407
|
.collect();
|
|
412
408
|
|
|
413
409
|
return self.serialize_nodes(&template_nodes);
|
|
@@ -195,7 +195,7 @@ impl HtmlParser {
|
|
|
195
195
|
let closing_pattern = format!(r"</{}>", regex::escape(tag_name));
|
|
196
196
|
|
|
197
197
|
// Find all occurrences
|
|
198
|
-
let mut matches: Vec<(usize, usize, String,
|
|
198
|
+
let mut matches: Vec<(usize, usize, String, Vec<(String, String)>)> = Vec::new();
|
|
199
199
|
|
|
200
200
|
// Find opening tags
|
|
201
201
|
for cap in tag_re.find_iter(&result) {
|
|
@@ -228,14 +228,9 @@ impl HtmlParser {
|
|
|
228
228
|
// Replace from end to start to maintain indices
|
|
229
229
|
matches.reverse();
|
|
230
230
|
for (start, end, slot_content, props) in matches {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
// replace @[headline] in content with @[pageTitle]
|
|
235
|
-
for (prop_name, prop_value) in props {
|
|
236
|
-
let prop_binding = format!("@[{}]", prop_name);
|
|
237
|
-
replacement = replacement.replace(&prop_binding, &prop_value);
|
|
238
|
-
}
|
|
231
|
+
// Substitute props with full runtime parity (bindings,
|
|
232
|
+
// expressions, directive comments, event handlers)
|
|
233
|
+
let mut replacement = substitute_props(&element.content, &props);
|
|
239
234
|
|
|
240
235
|
// Replace <slot> tags — wrap children in <slot> boundary, or remove if empty
|
|
241
236
|
let slot_wrapped = if slot_content.trim().is_empty() {
|
|
@@ -360,7 +355,10 @@ impl HtmlParser {
|
|
|
360
355
|
let slot_content = result[open_end..close_start].to_string();
|
|
361
356
|
|
|
362
357
|
let attrs_str = format!("{} {}", attrs_before, attrs_after);
|
|
363
|
-
let props = Self::parse_props(&attrs_str)
|
|
358
|
+
let props: Vec<(String, String)> = Self::parse_props(&attrs_str)
|
|
359
|
+
.into_iter()
|
|
360
|
+
.filter(|(n, _)| n != "src" && n != "class")
|
|
361
|
+
.collect();
|
|
364
362
|
|
|
365
363
|
Some((open_tag.start(), close_end, props, slot_content))
|
|
366
364
|
}).collect();
|
|
@@ -370,12 +368,8 @@ impl HtmlParser {
|
|
|
370
368
|
sorted.sort_by(|a, b| b.0.cmp(&a.0));
|
|
371
369
|
|
|
372
370
|
for (start, end, props, slot_content) in &sorted {
|
|
373
|
-
let mut replacement = component_content
|
|
374
|
-
|
|
375
|
-
for (prop_name, prop_value) in props {
|
|
376
|
-
let prop_binding = format!("@[{}]", prop_name);
|
|
377
|
-
replacement = replacement.replace(&prop_binding, prop_value);
|
|
378
|
-
}
|
|
371
|
+
let mut replacement = substitute_props(component_content, props);
|
|
372
|
+
replacement = neuter_component_scripts(&replacement);
|
|
379
373
|
|
|
380
374
|
let slot_wrapped = if slot_content.trim().is_empty() { String::new() } else { format!("<slot>{}</slot>", slot_content) };
|
|
381
375
|
replacement = replacement.replace("<slot></slot>", &slot_wrapped);
|
|
@@ -425,7 +419,10 @@ impl HtmlParser {
|
|
|
425
419
|
let slot_content = result[open_end..close_start].to_string();
|
|
426
420
|
|
|
427
421
|
let attrs_str = format!("{} {}", attrs_before, attrs_after);
|
|
428
|
-
let props = Self::parse_props(&attrs_str)
|
|
422
|
+
let props: Vec<(String, String)> = Self::parse_props(&attrs_str)
|
|
423
|
+
.into_iter()
|
|
424
|
+
.filter(|(n, _)| n != "src" && n != "class")
|
|
425
|
+
.collect();
|
|
429
426
|
|
|
430
427
|
Some((open_tag.start(), close_end, src.to_string(), props, slot_content))
|
|
431
428
|
}).collect();
|
|
@@ -447,11 +444,9 @@ impl HtmlParser {
|
|
|
447
444
|
}
|
|
448
445
|
};
|
|
449
446
|
|
|
450
|
-
if let Some(
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
replacement = replacement.replace(&prop_binding, prop_value);
|
|
454
|
-
}
|
|
447
|
+
if let Some(template) = external_cache.get(&normalized_src) {
|
|
448
|
+
let mut replacement = substitute_props(template, &props);
|
|
449
|
+
replacement = neuter_component_scripts(&replacement);
|
|
455
450
|
|
|
456
451
|
let slot_wrapped = if slot_content.trim().is_empty() { String::new() } else { format!("<slot>{}</slot>", slot_content) };
|
|
457
452
|
replacement = replacement.replace("<slot></slot>", &slot_wrapped);
|
|
@@ -473,24 +468,192 @@ impl HtmlParser {
|
|
|
473
468
|
result
|
|
474
469
|
}
|
|
475
470
|
|
|
476
|
-
/// Parse component props from attributes string
|
|
477
|
-
///
|
|
478
|
-
|
|
479
|
-
|
|
471
|
+
/// Parse component props from attributes string, in source order.
|
|
472
|
+
/// Supports valued (`headline="@[pageTitle]"`) and bare boolean
|
|
473
|
+
/// (`dndDisabled`) attributes — the runtime receives bare attributes from
|
|
474
|
+
/// the DOM with an empty-string value, so they're captured the same here.
|
|
475
|
+
fn parse_props(attrs_str: &str) -> Vec<(String, String)> {
|
|
476
|
+
let mut props = Vec::new();
|
|
480
477
|
|
|
481
|
-
|
|
482
|
-
let attr_re = regex::Regex::new(r#"(\w+)="([^"]*)""#).unwrap();
|
|
478
|
+
let attr_re = regex::Regex::new(r#"([\w-]+)(?:="([^"]*)")?"#).unwrap();
|
|
483
479
|
|
|
484
480
|
for cap in attr_re.captures_iter(attrs_str) {
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
481
|
+
let name = cap.get(1).unwrap().as_str().to_string();
|
|
482
|
+
let value = cap.get(2).map(|m| m.as_str().to_string()).unwrap_or_default();
|
|
483
|
+
props.push((name, value));
|
|
488
484
|
}
|
|
489
485
|
|
|
490
486
|
props
|
|
491
487
|
}
|
|
492
488
|
}
|
|
493
489
|
|
|
490
|
+
/// Neuter component scripts inlined from `<component src>` templates:
|
|
491
|
+
/// `<script type="module">` becomes `<script type="vibe-module">` so the
|
|
492
|
+
/// browser does NOT execute it as a native page module (wrong timing —
|
|
493
|
+
/// pre-boot, placeholder `$`, no live state merge). The runtime executes
|
|
494
|
+
/// vibe-module scripts through the same injected-component() path it uses
|
|
495
|
+
/// for fetched component scripts, giving compiled and runtime pages one
|
|
496
|
+
/// script-execution pipeline with identical semantics. Runs before slot
|
|
497
|
+
/// inlining so caller-authored slot scripts stay native.
|
|
498
|
+
fn neuter_component_scripts(html: &str) -> String {
|
|
499
|
+
html.replace("<script type=\"module\"", "<script type=\"vibe-module\"")
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
fn is_ident_byte(b: u8) -> bool {
|
|
503
|
+
b.is_ascii_alphanumeric() || b == b'_' || b == b'$'
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/// Case-insensitive free-identifier substitution, mirroring the runtime's
|
|
507
|
+
/// renderPropsAndSlot idRegex: the name must not be preceded by an identifier
|
|
508
|
+
/// character or `.` (property access) and not followed by an identifier
|
|
509
|
+
/// character. Case-insensitive because HTML lowercases attribute names while
|
|
510
|
+
/// component templates reference the author's camelCase identifiers.
|
|
511
|
+
fn substitute_identifier(expr: &str, name: &str, replacement: &str) -> String {
|
|
512
|
+
let bytes = expr.as_bytes();
|
|
513
|
+
let nb = name.as_bytes();
|
|
514
|
+
let nlen = nb.len();
|
|
515
|
+
if nlen == 0 {
|
|
516
|
+
return expr.to_string();
|
|
517
|
+
}
|
|
518
|
+
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
|
|
519
|
+
let mut i = 0;
|
|
520
|
+
while i < bytes.len() {
|
|
521
|
+
if i + nlen <= bytes.len()
|
|
522
|
+
&& bytes[i..i + nlen].eq_ignore_ascii_case(nb)
|
|
523
|
+
&& (i == 0 || (!is_ident_byte(bytes[i - 1]) && bytes[i - 1] != b'.'))
|
|
524
|
+
&& (i + nlen == bytes.len() || !is_ident_byte(bytes[i + nlen]))
|
|
525
|
+
{
|
|
526
|
+
out.extend_from_slice(replacement.as_bytes());
|
|
527
|
+
i += nlen;
|
|
528
|
+
} else {
|
|
529
|
+
out.push(bytes[i]);
|
|
530
|
+
i += 1;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
String::from_utf8(out).unwrap()
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/// `$.propName` rewrites inside event-handler bodies (the runtime's stateRegex
|
|
537
|
+
/// pass): for a binding prop `value="@[email]"`, `$.value = this.value`
|
|
538
|
+
/// becomes `$.email = this.value` — DOM property reads (`this.value`) stay.
|
|
539
|
+
fn substitute_state_ref(body: &str, name: &str, replacement: &str) -> String {
|
|
540
|
+
let bytes = body.as_bytes();
|
|
541
|
+
let nb = name.as_bytes();
|
|
542
|
+
let nlen = nb.len();
|
|
543
|
+
if nlen == 0 {
|
|
544
|
+
return body.to_string();
|
|
545
|
+
}
|
|
546
|
+
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
|
|
547
|
+
let mut i = 0;
|
|
548
|
+
while i < bytes.len() {
|
|
549
|
+
if bytes[i] == b'$'
|
|
550
|
+
&& i + 2 + nlen <= bytes.len()
|
|
551
|
+
&& bytes[i + 1] == b'.'
|
|
552
|
+
&& bytes[i + 2..i + 2 + nlen].eq_ignore_ascii_case(nb)
|
|
553
|
+
&& (i + 2 + nlen == bytes.len() || !is_ident_byte(bytes[i + 2 + nlen]))
|
|
554
|
+
{
|
|
555
|
+
out.extend_from_slice(b"$.");
|
|
556
|
+
out.extend_from_slice(replacement.as_bytes());
|
|
557
|
+
i += 2 + nlen;
|
|
558
|
+
} else {
|
|
559
|
+
out.push(bytes[i]);
|
|
560
|
+
i += 1;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
String::from_utf8(out).unwrap()
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/// Substitute component props into a template — the compiled twin of the
|
|
567
|
+
/// runtime's renderPropsAndSlot (runtime/component.js). Both sides must
|
|
568
|
+
/// transform identically:
|
|
569
|
+
/// - exact `@[propName]` bindings (case-insensitive)
|
|
570
|
+
/// - prop identifiers inside other `@[expr]` bindings
|
|
571
|
+
/// - prop identifiers inside directive comments (`if` / `else if` / `each`)
|
|
572
|
+
/// - `$.propName` references inside event-handler attribute bodies
|
|
573
|
+
/// Binding props (`prop="@[path]"`) rewrite identifiers to the bound path;
|
|
574
|
+
/// literal props inject the value (bare boolean attrs → true, strings
|
|
575
|
+
/// JSON-quoted, numerics raw).
|
|
576
|
+
fn substitute_props(template: &str, props: &[(String, String)]) -> String {
|
|
577
|
+
let binding_re = regex::Regex::new(r"@\[([^\]]+)\]").unwrap();
|
|
578
|
+
let directive_re = regex::Regex::new(r"(?s)<!--\s*(if|else if|each)\s+(.*?)\s*-->").unwrap();
|
|
579
|
+
let event_re = regex::Regex::new(r#"\bon(\w+)="([^"]*)""#).unwrap();
|
|
580
|
+
let numeric_re = regex::Regex::new(r"(?i)^-?\d+(\.\d+)?(e[+-]?\d+)?$").unwrap();
|
|
581
|
+
|
|
582
|
+
let mut html = template.to_string();
|
|
583
|
+
|
|
584
|
+
for (prop_name, prop_value) in props {
|
|
585
|
+
let exact_re =
|
|
586
|
+
regex::Regex::new(&format!(r"(?i)@\[{}\]", regex::escape(prop_name))).unwrap();
|
|
587
|
+
|
|
588
|
+
let binding_path = prop_value
|
|
589
|
+
.strip_prefix("@[")
|
|
590
|
+
.and_then(|v| v.strip_suffix(']'));
|
|
591
|
+
|
|
592
|
+
let (exact_repl, ident_repl, each_repl, state_repl) = if let Some(path) = binding_path {
|
|
593
|
+
(
|
|
594
|
+
format!("@[{}]", path),
|
|
595
|
+
format!("({})", path),
|
|
596
|
+
path.to_string(),
|
|
597
|
+
path.to_string(),
|
|
598
|
+
)
|
|
599
|
+
} else {
|
|
600
|
+
let literal = if prop_value.is_empty() {
|
|
601
|
+
"true".to_string()
|
|
602
|
+
} else if numeric_re.is_match(prop_value) {
|
|
603
|
+
prop_value.clone()
|
|
604
|
+
} else {
|
|
605
|
+
serde_json::to_string(prop_value).unwrap()
|
|
606
|
+
};
|
|
607
|
+
(prop_value.clone(), literal.clone(), literal.clone(), literal)
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
html = exact_re
|
|
611
|
+
.replace_all(&html, regex::NoExpand(exact_repl.as_str()))
|
|
612
|
+
.to_string();
|
|
613
|
+
|
|
614
|
+
html = binding_re
|
|
615
|
+
.replace_all(&html, |c: ®ex::Captures| {
|
|
616
|
+
let expr = c.get(1).unwrap().as_str();
|
|
617
|
+
let rewritten = substitute_identifier(expr, prop_name, &ident_repl);
|
|
618
|
+
if rewritten == expr {
|
|
619
|
+
c.get(0).unwrap().as_str().to_string()
|
|
620
|
+
} else {
|
|
621
|
+
format!("@[{}]", rewritten)
|
|
622
|
+
}
|
|
623
|
+
})
|
|
624
|
+
.to_string();
|
|
625
|
+
|
|
626
|
+
html = directive_re
|
|
627
|
+
.replace_all(&html, |c: ®ex::Captures| {
|
|
628
|
+
let kw = c.get(1).unwrap().as_str();
|
|
629
|
+
let expr = c.get(2).unwrap().as_str();
|
|
630
|
+
let repl = if kw == "each" { each_repl.as_str() } else { ident_repl.as_str() };
|
|
631
|
+
let rewritten = substitute_identifier(expr, prop_name, repl);
|
|
632
|
+
if rewritten == expr {
|
|
633
|
+
c.get(0).unwrap().as_str().to_string()
|
|
634
|
+
} else {
|
|
635
|
+
format!("<!-- {} {} -->", kw, rewritten)
|
|
636
|
+
}
|
|
637
|
+
})
|
|
638
|
+
.to_string();
|
|
639
|
+
|
|
640
|
+
html = event_re
|
|
641
|
+
.replace_all(&html, |c: ®ex::Captures| {
|
|
642
|
+
let ev = c.get(1).unwrap().as_str();
|
|
643
|
+
let body = c.get(2).unwrap().as_str();
|
|
644
|
+
let rewritten = substitute_state_ref(body, prop_name, &state_repl);
|
|
645
|
+
if rewritten == body {
|
|
646
|
+
c.get(0).unwrap().as_str().to_string()
|
|
647
|
+
} else {
|
|
648
|
+
format!(r#"on{}="{}""#, ev, rewritten)
|
|
649
|
+
}
|
|
650
|
+
})
|
|
651
|
+
.to_string();
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
html
|
|
655
|
+
}
|
|
656
|
+
|
|
494
657
|
/// Transform custom HTML elements to divs with classes
|
|
495
658
|
fn transform_custom_tags_to_divs(content: &str, reserved_elements: &[String]) -> String {
|
|
496
659
|
let mut result = content.to_string();
|
package/llms.txt
CHANGED
|
@@ -289,7 +289,7 @@ window.$ = vibe(
|
|
|
289
289
|
|
|
290
290
|
**Parameters:**
|
|
291
291
|
- `initialState` — object containing initial state values
|
|
292
|
-
- `config` — optional object.
|
|
292
|
+
- `config` — optional object. Supports `{ debug: boolean, noCache: boolean }` (`noCache` disables the component template cache — components are otherwise fetched once per `src` and reused across instances/navigation). A third positional argument can pass a target selector (defaults to `body`).
|
|
293
293
|
|
|
294
294
|
**Returns:** Reactive proxy. Assign it to `window.$` so inline event handlers and bindings can find it.
|
|
295
295
|
|