@ape-egg/vibe 2.3.0 → 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.
- 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,693 +0,0 @@
|
|
|
1
|
-
use html5ever::parse_document;
|
|
2
|
-
use html5ever::tendril::TendrilSink;
|
|
3
|
-
use markup5ever_rcdom::{RcDom, NodeData, Handle};
|
|
4
|
-
use regex::Regex;
|
|
5
|
-
use serde::{Serialize, Serializer};
|
|
6
|
-
use serde_json::Value;
|
|
7
|
-
use std::cell::RefCell;
|
|
8
|
-
use std::collections::{HashMap, BTreeMap};
|
|
9
|
-
use crate::compiler::iteration_optimizer::build_iteration_optimizations;
|
|
10
|
-
|
|
11
|
-
pub struct ManifestBuilder {
|
|
12
|
-
binding_regex: Regex,
|
|
13
|
-
name_binding_fix_regex: Regex,
|
|
14
|
-
/// Original-cased `@[...]` bindings captured from the pre-parse HTML, keyed by
|
|
15
|
-
/// their lowercased form — used to undo html5ever's attribute-name lowercasing
|
|
16
|
-
/// of name bindings on every string this builder serializes back out.
|
|
17
|
-
binding_cases: RefCell<HashMap<String, String>>,
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
impl ManifestBuilder {
|
|
21
|
-
pub fn new() -> Self {
|
|
22
|
-
Self {
|
|
23
|
-
binding_regex: Regex::new(r"@\[((?:[^\[\]]|\[[^\]]*\])+)\]").unwrap(),
|
|
24
|
-
name_binding_fix_regex: Regex::new(r#"(@\[[^\]]+\])="+"#).unwrap(),
|
|
25
|
-
binding_cases: RefCell::new(HashMap::new()),
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
pub fn build_from_html(&self, html: &str, _state: &Value, iterations_as_is: bool) -> Result<ManifestNode, String> {
|
|
30
|
-
// Snapshot original-cased bindings before html5ever lowercases name
|
|
31
|
-
// bindings (attr-name position). Restored on serialized templates and on
|
|
32
|
-
// the captured name_bindings array.
|
|
33
|
-
*self.binding_cases.borrow_mut() = crate::compiler::binding_case::capture(html);
|
|
34
|
-
|
|
35
|
-
// Parse HTML
|
|
36
|
-
let dom = parse_document(RcDom::default(), Default::default())
|
|
37
|
-
.from_utf8()
|
|
38
|
-
.read_from(&mut html.as_bytes())
|
|
39
|
-
.map_err(|e| format!("Failed to parse HTML: {:?}", e))?;
|
|
40
|
-
|
|
41
|
-
// Walk tree starting from document root
|
|
42
|
-
let mut root_node = self.walk_node(&dom.document, &mut 0);
|
|
43
|
-
|
|
44
|
-
// Inject compiled batch functions directly into iteration nodes (unless disabled)
|
|
45
|
-
if !iterations_as_is {
|
|
46
|
-
let compiled_functions = build_iteration_optimizations(html);
|
|
47
|
-
if let Some(optimizations) = compiled_functions {
|
|
48
|
-
self.inject_compiled_functions(&mut root_node, &optimizations.iterations);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
Ok(root_node)
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/// Recursively inject compiled batch functions into iteration nodes
|
|
56
|
-
fn inject_compiled_functions(&self, node: &mut ManifestNode, compiled_map: &HashMap<String, crate::compiler::iteration_optimizer::CompiledIteration>) {
|
|
57
|
-
// Check if this is an iteration node with a template
|
|
58
|
-
if node.node_type.as_deref() == Some("iteration") {
|
|
59
|
-
if let Some(ref mut compiled_data) = node.compiled {
|
|
60
|
-
if let Some(ref restoration) = compiled_data.restoration {
|
|
61
|
-
if let Some(ref template) = restoration.template {
|
|
62
|
-
// Generate hash for this template (same logic as optimizer)
|
|
63
|
-
let template_hash = self.generate_template_hash(template);
|
|
64
|
-
|
|
65
|
-
// Look up compiled function
|
|
66
|
-
if let Some(compiled) = compiled_map.get(&template_hash) {
|
|
67
|
-
compiled_data.iterations = Some(IterationData {
|
|
68
|
-
batch_fn: Some(compiled.batch_fn.clone()),
|
|
69
|
-
item_alias: Some(compiled.item_alias.clone()),
|
|
70
|
-
index_alias: Some(compiled.index_alias.clone()),
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// Recursively process children
|
|
79
|
-
for child in node.children.values_mut() {
|
|
80
|
-
self.inject_compiled_functions(child, compiled_map);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/// Generate stable hash for template (matches iteration_optimizer logic)
|
|
85
|
-
fn generate_template_hash(&self, template: &str) -> String {
|
|
86
|
-
use std::collections::hash_map::DefaultHasher;
|
|
87
|
-
use std::hash::{Hash, Hasher};
|
|
88
|
-
|
|
89
|
-
let mut hasher = DefaultHasher::new();
|
|
90
|
-
template.trim().hash(&mut hasher);
|
|
91
|
-
let hash = hasher.finish();
|
|
92
|
-
format!("iter_{:x}", hash)
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
fn walk_node(&self, node: &Handle, _counter: &mut usize) -> ManifestNode {
|
|
96
|
-
self.walk_node_impl(node, &node.children.borrow())
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
fn walk_node_impl(&self, node: &Handle, siblings: &[Handle]) -> ManifestNode {
|
|
100
|
-
let mut manifest_node = ManifestNode {
|
|
101
|
-
element: None,
|
|
102
|
-
parsed: vec![],
|
|
103
|
-
children: BTreeMap::new(),
|
|
104
|
-
compiled: None,
|
|
105
|
-
node_type: None,
|
|
106
|
-
attributes: None,
|
|
107
|
-
name_bindings: None,
|
|
108
|
-
meta: None,
|
|
109
|
-
runtime: None,
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
match &node.data {
|
|
113
|
-
NodeData::Text { contents } => {
|
|
114
|
-
let text = contents.borrow().to_string();
|
|
115
|
-
|
|
116
|
-
// Check for @[...] bindings
|
|
117
|
-
if self.binding_regex.is_match(&text) {
|
|
118
|
-
let parsed = self.split_by_bindings(&text);
|
|
119
|
-
manifest_node.compiled = Some(CompiledData {
|
|
120
|
-
restoration: Some(RestorationData {
|
|
121
|
-
parsed: Some(parsed),
|
|
122
|
-
..Default::default()
|
|
123
|
-
}),
|
|
124
|
-
..Default::default()
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
NodeData::Element { name, attrs, .. } => {
|
|
130
|
-
let _tag_name = name.local.to_string();
|
|
131
|
-
|
|
132
|
-
// Check attribute bindings (both value and name bindings)
|
|
133
|
-
let mut attr_bindings = BTreeMap::new();
|
|
134
|
-
let mut name_bindings: Vec<Option<String>> = Vec::new();
|
|
135
|
-
let borrowed_attrs = attrs.borrow();
|
|
136
|
-
|
|
137
|
-
for (idx, attr) in borrowed_attrs.iter().enumerate() {
|
|
138
|
-
let attr_name = attr.name.local.to_string();
|
|
139
|
-
let attr_value = attr.value.to_string();
|
|
140
|
-
|
|
141
|
-
// A relocated name-binding: its expression(s) live verbatim in the
|
|
142
|
-
// VALUE of data-vibe-namebind because whitespace barred them from
|
|
143
|
-
// attribute-name position. Treat the value as the name-binding — no
|
|
144
|
-
// case restoration needed (value attrs keep their case).
|
|
145
|
-
if attr_name == crate::compiler::name_binding_protect::NAME_BIND_ATTR {
|
|
146
|
-
while name_bindings.len() <= idx {
|
|
147
|
-
name_bindings.push(None);
|
|
148
|
-
}
|
|
149
|
-
name_bindings[idx] = Some(attr_value);
|
|
150
|
-
}
|
|
151
|
-
// Check for name bindings (binding in attribute NAME)
|
|
152
|
-
else if self.binding_regex.is_match(&attr_name) {
|
|
153
|
-
// Extend vec to include this index
|
|
154
|
-
while name_bindings.len() <= idx {
|
|
155
|
-
name_bindings.push(None);
|
|
156
|
-
}
|
|
157
|
-
// html5ever lowercased the attr name; restore original casing.
|
|
158
|
-
let restored = crate::compiler::binding_case::restore(
|
|
159
|
-
&attr_name,
|
|
160
|
-
&self.binding_cases.borrow(),
|
|
161
|
-
);
|
|
162
|
-
name_bindings[idx] = Some(restored);
|
|
163
|
-
}
|
|
164
|
-
// Check for attribute value bindings
|
|
165
|
-
else if self.binding_regex.is_match(&attr_value) {
|
|
166
|
-
attr_bindings.insert(attr_name, attr_value);
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// Store if we have any bindings
|
|
171
|
-
let has_name_bindings = name_bindings.iter().any(|b| b.is_some());
|
|
172
|
-
|
|
173
|
-
// Set runtime-compatible fields at node level
|
|
174
|
-
if !attr_bindings.is_empty() {
|
|
175
|
-
manifest_node.attributes = Some(attr_bindings.clone());
|
|
176
|
-
}
|
|
177
|
-
if has_name_bindings {
|
|
178
|
-
manifest_node.name_bindings = Some(name_bindings.clone());
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// Also store in compiled.restoration for restoration purposes
|
|
182
|
-
if !attr_bindings.is_empty() || has_name_bindings {
|
|
183
|
-
manifest_node.compiled = Some(CompiledData {
|
|
184
|
-
restoration: Some(RestorationData {
|
|
185
|
-
attributes: if !attr_bindings.is_empty() { Some(attr_bindings) } else { None },
|
|
186
|
-
name_bindings: if has_name_bindings { Some(name_bindings) } else { None },
|
|
187
|
-
..Default::default()
|
|
188
|
-
}),
|
|
189
|
-
..Default::default()
|
|
190
|
-
});
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
NodeData::Comment { contents } => {
|
|
195
|
-
let comment = contents.to_string();
|
|
196
|
-
let trimmed = comment.trim();
|
|
197
|
-
|
|
198
|
-
// Check for iteration: <!-- each items as item -->
|
|
199
|
-
if trimmed.starts_with("each ") {
|
|
200
|
-
manifest_node.node_type = Some("iteration".to_string());
|
|
201
|
-
|
|
202
|
-
// Extract expression: "each items as item" -> "items as item"
|
|
203
|
-
let expression = trimmed.strip_prefix("each ").unwrap_or("").to_string();
|
|
204
|
-
|
|
205
|
-
// Parse expression to extract parts: "items as item, index" or "items as item".
|
|
206
|
-
// The optional (key) expression may sit before or after the index — strip it
|
|
207
|
-
// first so the item/index split isn't polluted by it. The key itself isn't
|
|
208
|
-
// stored here; the runtime re-derives it from the preserved comment.
|
|
209
|
-
let parts: Vec<&str> = expression.split(" as ").collect();
|
|
210
|
-
let array_path = parts.get(0).unwrap_or(&"").trim().to_string();
|
|
211
|
-
let alias_part = parts.get(1).unwrap_or(&"").trim();
|
|
212
|
-
let alias_without_key = match (alias_part.find('('), alias_part.rfind(')')) {
|
|
213
|
-
(Some(open), Some(close)) if close > open => {
|
|
214
|
-
format!("{}{}", &alias_part[..open], &alias_part[close + 1..])
|
|
215
|
-
}
|
|
216
|
-
_ => alias_part.to_string(),
|
|
217
|
-
};
|
|
218
|
-
let alias_without_key = alias_without_key.trim();
|
|
219
|
-
let (item_alias, index_alias) = if let Some(comma_pos) = alias_without_key.find(',') {
|
|
220
|
-
let item = alias_without_key[..comma_pos].trim().to_string();
|
|
221
|
-
let index = alias_without_key[comma_pos + 1..].trim().to_string();
|
|
222
|
-
(item, index)
|
|
223
|
-
} else {
|
|
224
|
-
(alias_without_key.to_string(), "index".to_string())
|
|
225
|
-
};
|
|
226
|
-
|
|
227
|
-
// Find node index in siblings
|
|
228
|
-
let node_idx = siblings.iter().position(|n| std::ptr::eq(n as *const _, node as *const _));
|
|
229
|
-
|
|
230
|
-
// Extract template if we can find this node in siblings
|
|
231
|
-
let template = if let Some(idx) = node_idx {
|
|
232
|
-
self.extract_template_from_siblings(siblings, idx, "each ", "/each")
|
|
233
|
-
} else {
|
|
234
|
-
String::new()
|
|
235
|
-
};
|
|
236
|
-
|
|
237
|
-
// Create minimal meta structure - runtime will populate template after parsing restored DOM
|
|
238
|
-
manifest_node.meta = Some(serde_json::json!({
|
|
239
|
-
"arrayPath": array_path,
|
|
240
|
-
"itemAlias": item_alias,
|
|
241
|
-
"indexAlias": index_alias
|
|
242
|
-
}));
|
|
243
|
-
|
|
244
|
-
// Runtime state
|
|
245
|
-
manifest_node.runtime = Some(serde_json::json!({
|
|
246
|
-
"instances": [],
|
|
247
|
-
"templateRemoved": false
|
|
248
|
-
}));
|
|
249
|
-
|
|
250
|
-
// Compiled restoration data
|
|
251
|
-
manifest_node.compiled = Some(CompiledData {
|
|
252
|
-
restoration: Some(RestorationData {
|
|
253
|
-
template: Some(template),
|
|
254
|
-
expression: Some(expression),
|
|
255
|
-
..Default::default()
|
|
256
|
-
}),
|
|
257
|
-
..Default::default()
|
|
258
|
-
});
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// Check for conditionals: <!-- if condition -->
|
|
262
|
-
if trimmed.starts_with("if ") {
|
|
263
|
-
manifest_node.node_type = Some("conditional".to_string());
|
|
264
|
-
|
|
265
|
-
let expression = trimmed.strip_prefix("if ").unwrap_or("").to_string();
|
|
266
|
-
let node_idx = siblings.iter().position(|n| std::ptr::eq(n as *const _, node as *const _));
|
|
267
|
-
|
|
268
|
-
let template = if let Some(idx) = node_idx {
|
|
269
|
-
self.extract_template_from_siblings(siblings, idx, "if ", "/if")
|
|
270
|
-
} else {
|
|
271
|
-
String::new()
|
|
272
|
-
};
|
|
273
|
-
|
|
274
|
-
// Create minimal meta structure - runtime will populate branches after parsing restored DOM
|
|
275
|
-
manifest_node.meta = Some(serde_json::json!({
|
|
276
|
-
"expression": expression
|
|
277
|
-
}));
|
|
278
|
-
|
|
279
|
-
// Runtime state
|
|
280
|
-
manifest_node.runtime = Some(serde_json::json!({
|
|
281
|
-
"activeBranch": null,
|
|
282
|
-
"activeInstance": null,
|
|
283
|
-
"templateRemoved": false
|
|
284
|
-
}));
|
|
285
|
-
|
|
286
|
-
// Compiled restoration data
|
|
287
|
-
manifest_node.compiled = Some(CompiledData {
|
|
288
|
-
restoration: Some(RestorationData {
|
|
289
|
-
template: Some(template),
|
|
290
|
-
..Default::default()
|
|
291
|
-
}),
|
|
292
|
-
..Default::default()
|
|
293
|
-
});
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
_ => {}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// Recursively process children using local indices (to match runtime key generation)
|
|
301
|
-
let children = node.children.borrow();
|
|
302
|
-
let mut skip_indices = std::collections::HashSet::new();
|
|
303
|
-
|
|
304
|
-
// First pass: identify indices to skip (iteration/conditional template content)
|
|
305
|
-
for (i, child) in children.iter().enumerate() {
|
|
306
|
-
if let NodeData::Comment { contents } = &child.data {
|
|
307
|
-
let trimmed = contents.trim();
|
|
308
|
-
|
|
309
|
-
// Find iteration blocks and mark template indices to skip
|
|
310
|
-
if trimmed.starts_with("each ") {
|
|
311
|
-
if let Some(end_idx) = self.find_matching_end(&children, i, "each ", "/each") {
|
|
312
|
-
// Skip all nodes between start and end comments (including end comment)
|
|
313
|
-
for j in (i + 1)..=end_idx {
|
|
314
|
-
skip_indices.insert(j);
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// Find conditional blocks and mark template indices to skip
|
|
320
|
-
if trimmed.starts_with("if ") {
|
|
321
|
-
if let Some(end_idx) = self.find_matching_end(&children, i, "if ", "/if") {
|
|
322
|
-
for j in (i + 1)..=end_idx {
|
|
323
|
-
skip_indices.insert(j);
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// Second pass: process children that aren't skipped
|
|
331
|
-
for (local_index, child) in children.iter().enumerate() {
|
|
332
|
-
if skip_indices.contains(&local_index) {
|
|
333
|
-
continue;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
let child_key = self.generate_child_key(child, &local_index);
|
|
337
|
-
manifest_node.children.insert(
|
|
338
|
-
child_key.clone(),
|
|
339
|
-
self.walk_node_impl(child, &children)
|
|
340
|
-
);
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
manifest_node
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
/// Split text by @[...] bindings: "Hello @[name]!" -> ["Hello ", "@[name]", "!"]
|
|
347
|
-
fn split_by_bindings(&self, text: &str) -> Vec<String> {
|
|
348
|
-
let mut result = vec![];
|
|
349
|
-
let mut last_end = 0;
|
|
350
|
-
|
|
351
|
-
for mat in self.binding_regex.find_iter(text) {
|
|
352
|
-
// Add text before match
|
|
353
|
-
if mat.start() > last_end {
|
|
354
|
-
result.push(text[last_end..mat.start()].to_string());
|
|
355
|
-
}
|
|
356
|
-
// Add binding
|
|
357
|
-
result.push(mat.as_str().to_string());
|
|
358
|
-
last_end = mat.end();
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
// Add remaining text
|
|
362
|
-
if last_end < text.len() {
|
|
363
|
-
result.push(text[last_end..].to_string());
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
result
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
/// Generate child key: "text_0", "div_1", etc.
|
|
370
|
-
fn generate_child_key(&self, node: &Handle, index: &usize) -> String {
|
|
371
|
-
match &node.data {
|
|
372
|
-
NodeData::Text { .. } => format!("text_{}", index),
|
|
373
|
-
NodeData::Element { name, .. } => {
|
|
374
|
-
format!("{}_{}", name.local.to_lowercase(), index)
|
|
375
|
-
}
|
|
376
|
-
NodeData::Comment { contents } => {
|
|
377
|
-
let trimmed = contents.trim();
|
|
378
|
-
if trimmed.starts_with("each ") {
|
|
379
|
-
format!("iteration_{}", index)
|
|
380
|
-
} else if trimmed.starts_with("if ") {
|
|
381
|
-
format!("conditional_{}", index)
|
|
382
|
-
} else {
|
|
383
|
-
format!("comment_{}", index)
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
_ => format!("node_{}", index),
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
/// Find the index of matching end comment
|
|
391
|
-
fn find_matching_end(&self, siblings: &[Handle], start_idx: usize, start_marker: &str, end_marker: &str) -> Option<usize> {
|
|
392
|
-
let mut depth = 0;
|
|
393
|
-
|
|
394
|
-
for (idx, sibling) in siblings.iter().enumerate().skip(start_idx + 1) {
|
|
395
|
-
if let NodeData::Comment { contents } = &sibling.data {
|
|
396
|
-
let trimmed = contents.trim();
|
|
397
|
-
if trimmed.starts_with(start_marker) {
|
|
398
|
-
depth += 1;
|
|
399
|
-
} else if trimmed == end_marker {
|
|
400
|
-
if depth == 0 {
|
|
401
|
-
return Some(idx);
|
|
402
|
-
} else {
|
|
403
|
-
depth -= 1;
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
None
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
/// Extract template between start and end comments (generic for iterations and conditionals)
|
|
413
|
-
fn extract_template_from_siblings(&self, siblings: &[Handle], start_idx: usize, start_marker: &str, end_marker: &str) -> String {
|
|
414
|
-
// Find matching end comment
|
|
415
|
-
let mut depth = 0;
|
|
416
|
-
let mut end_idx = None;
|
|
417
|
-
|
|
418
|
-
for (idx, sibling) in siblings.iter().enumerate().skip(start_idx + 1) {
|
|
419
|
-
if let NodeData::Comment { contents } = &sibling.data {
|
|
420
|
-
let trimmed = contents.trim();
|
|
421
|
-
if trimmed.starts_with(start_marker) {
|
|
422
|
-
depth += 1;
|
|
423
|
-
} else if trimmed == end_marker {
|
|
424
|
-
if depth == 0 {
|
|
425
|
-
end_idx = Some(idx);
|
|
426
|
-
break;
|
|
427
|
-
} else {
|
|
428
|
-
depth -= 1;
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
// Extract template HTML between start and end — VERBATIM, including
|
|
435
|
-
// whitespace-only text nodes. The manifest's child keys are childNodes
|
|
436
|
-
// indices computed on the pre-stamp DOM; restoration re-inserts this
|
|
437
|
-
// template, and only an exact node-count round-trip keeps those
|
|
438
|
-
// indices valid for every sibling that follows the restored region.
|
|
439
|
-
if let Some(end) = end_idx {
|
|
440
|
-
let template_nodes: Vec<_> = siblings.iter()
|
|
441
|
-
.skip(start_idx + 1)
|
|
442
|
-
.take(end - start_idx - 1)
|
|
443
|
-
.collect();
|
|
444
|
-
|
|
445
|
-
return self.serialize_nodes(&template_nodes);
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
String::new()
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
/// Serialize nodes to HTML string
|
|
452
|
-
fn serialize_nodes(&self, nodes: &[&Handle]) -> String {
|
|
453
|
-
use html5ever::serialize::{serialize, SerializeOpts, TraversalScope};
|
|
454
|
-
use markup5ever_rcdom::SerializableHandle;
|
|
455
|
-
|
|
456
|
-
let mut html = String::new();
|
|
457
|
-
|
|
458
|
-
for node in nodes.iter() {
|
|
459
|
-
let mut bytes = Vec::new();
|
|
460
|
-
|
|
461
|
-
// Serialize with IncludeNode to include the element tags
|
|
462
|
-
let opts = SerializeOpts {
|
|
463
|
-
traversal_scope: TraversalScope::IncludeNode,
|
|
464
|
-
..Default::default()
|
|
465
|
-
};
|
|
466
|
-
|
|
467
|
-
let serializable = SerializableHandle::from((*node).clone());
|
|
468
|
-
serialize(&mut bytes, &serializable, opts).ok();
|
|
469
|
-
|
|
470
|
-
if let Ok(node_html) = String::from_utf8(bytes) {
|
|
471
|
-
html.push_str(&node_html);
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
// Fix name bindings: Remove ="" added by html5ever serializer
|
|
476
|
-
// Name bindings like <icon @[section.icon]> get serialized as <icon @[section.icon]="">
|
|
477
|
-
// which is invalid HTML that browsers reject
|
|
478
|
-
html = self.name_binding_fix_regex.replace_all(&html, "$1").to_string();
|
|
479
|
-
|
|
480
|
-
// Restore original casing of any name-binding expressions html5ever
|
|
481
|
-
// lowercased while parsing (attr-name position).
|
|
482
|
-
html = crate::compiler::binding_case::restore(&html, &self.binding_cases.borrow());
|
|
483
|
-
|
|
484
|
-
html
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
#[derive(Debug)]
|
|
489
|
-
pub struct ManifestNode {
|
|
490
|
-
element: Option<()>, // Always null in static manifest
|
|
491
|
-
parsed: Vec<String>,
|
|
492
|
-
children: BTreeMap<String, ManifestNode>,
|
|
493
|
-
|
|
494
|
-
#[allow(dead_code)]
|
|
495
|
-
compiled: Option<CompiledData>,
|
|
496
|
-
|
|
497
|
-
#[allow(dead_code)]
|
|
498
|
-
node_type: Option<String>,
|
|
499
|
-
|
|
500
|
-
// Runtime-compatible fields
|
|
501
|
-
#[allow(dead_code)]
|
|
502
|
-
attributes: Option<BTreeMap<String, String>>,
|
|
503
|
-
|
|
504
|
-
#[allow(dead_code)]
|
|
505
|
-
name_bindings: Option<Vec<Option<String>>>,
|
|
506
|
-
|
|
507
|
-
#[allow(dead_code)]
|
|
508
|
-
meta: Option<Value>, // For conditionals and iterations
|
|
509
|
-
|
|
510
|
-
#[allow(dead_code)]
|
|
511
|
-
runtime: Option<Value>, // Runtime state
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
// Custom Serialize implementation to control output format
|
|
515
|
-
impl Serialize for ManifestNode {
|
|
516
|
-
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
|
517
|
-
where
|
|
518
|
-
S: Serializer,
|
|
519
|
-
{
|
|
520
|
-
use serde::ser::SerializeMap;
|
|
521
|
-
|
|
522
|
-
let mut map = serializer.serialize_map(None)?;
|
|
523
|
-
|
|
524
|
-
// Always include element (null)
|
|
525
|
-
map.serialize_entry("element", &self.element)?;
|
|
526
|
-
|
|
527
|
-
// Always include parsed (empty array if no data)
|
|
528
|
-
map.serialize_entry("parsed", &self.parsed)?;
|
|
529
|
-
|
|
530
|
-
// Always include children
|
|
531
|
-
map.serialize_entry("children", &self.children)?;
|
|
532
|
-
|
|
533
|
-
// Optional: attributes (runtime field)
|
|
534
|
-
if let Some(ref attributes) = self.attributes {
|
|
535
|
-
map.serialize_entry("attributes", attributes)?;
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
// Optional: nameBindings (runtime field)
|
|
539
|
-
if let Some(ref name_bindings) = self.name_bindings {
|
|
540
|
-
map.serialize_entry("nameBindings", name_bindings)?;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
// Optional: meta (runtime field for conditionals/iterations)
|
|
544
|
-
if let Some(ref meta) = self.meta {
|
|
545
|
-
map.serialize_entry("meta", meta)?;
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
// Optional: runtime (runtime state)
|
|
549
|
-
if let Some(ref runtime) = self.runtime {
|
|
550
|
-
map.serialize_entry("runtime", runtime)?;
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
// Optional: compiled (contains restoration and iterations data)
|
|
554
|
-
if let Some(ref compiled) = self.compiled {
|
|
555
|
-
map.serialize_entry("compiled", compiled)?;
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
// Optional: type
|
|
559
|
-
if let Some(ref node_type) = self.node_type {
|
|
560
|
-
map.serialize_entry("type", node_type)?;
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
map.end()
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
#[derive(Debug, Serialize, Default)]
|
|
568
|
-
pub struct RestorationData {
|
|
569
|
-
#[serde(skip_serializing_if = "Option::is_none")]
|
|
570
|
-
pub parsed: Option<Vec<String>>,
|
|
571
|
-
|
|
572
|
-
#[serde(skip_serializing_if = "Option::is_none")]
|
|
573
|
-
pub attributes: Option<BTreeMap<String, String>>,
|
|
574
|
-
|
|
575
|
-
#[serde(skip_serializing_if = "Option::is_none")]
|
|
576
|
-
pub template: Option<String>,
|
|
577
|
-
|
|
578
|
-
#[serde(skip_serializing_if = "Option::is_none")]
|
|
579
|
-
pub expression: Option<String>,
|
|
580
|
-
|
|
581
|
-
#[serde(skip_serializing_if = "Option::is_none", rename = "nameBindings")]
|
|
582
|
-
pub name_bindings: Option<Vec<Option<String>>>,
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
#[derive(Debug, Serialize, Default)]
|
|
586
|
-
pub struct IterationData {
|
|
587
|
-
#[serde(skip_serializing_if = "Option::is_none", rename = "batchFn")]
|
|
588
|
-
pub batch_fn: Option<String>,
|
|
589
|
-
|
|
590
|
-
#[serde(skip_serializing_if = "Option::is_none", rename = "itemAlias")]
|
|
591
|
-
pub item_alias: Option<String>,
|
|
592
|
-
|
|
593
|
-
#[serde(skip_serializing_if = "Option::is_none", rename = "indexAlias")]
|
|
594
|
-
pub index_alias: Option<String>,
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
#[derive(Debug, Serialize, Default)]
|
|
598
|
-
pub struct CompiledData {
|
|
599
|
-
#[serde(skip_serializing_if = "Option::is_none")]
|
|
600
|
-
pub restoration: Option<RestorationData>,
|
|
601
|
-
|
|
602
|
-
#[serde(skip_serializing_if = "Option::is_none")]
|
|
603
|
-
pub iterations: Option<IterationData>,
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
#[cfg(test)]
|
|
607
|
-
mod tests {
|
|
608
|
-
use super::*;
|
|
609
|
-
use serde_json::json;
|
|
610
|
-
|
|
611
|
-
#[test]
|
|
612
|
-
fn build_simple_text_binding() {
|
|
613
|
-
let html = r#"<div>Hello @[name]</div>"#;
|
|
614
|
-
let state = json!({ "name": "World" });
|
|
615
|
-
let builder = ManifestBuilder::new();
|
|
616
|
-
let manifest = builder.build_from_html(html, &state, false).unwrap();
|
|
617
|
-
|
|
618
|
-
// Should have children
|
|
619
|
-
assert!(!manifest.children.is_empty());
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
#[test]
|
|
623
|
-
fn build_attribute_binding() {
|
|
624
|
-
let html = r#"<input value="@[firstName]">"#;
|
|
625
|
-
let state = json!({ "firstName": "John" });
|
|
626
|
-
let builder = ManifestBuilder::new();
|
|
627
|
-
let manifest = builder.build_from_html(html, &state, false).unwrap();
|
|
628
|
-
|
|
629
|
-
// Should have children
|
|
630
|
-
assert!(!manifest.children.is_empty());
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
#[test]
|
|
634
|
-
fn build_iteration_with_template() {
|
|
635
|
-
let html = r#"<body><!-- each items as item --><div>@[item]</div><!-- /each --></body>"#;
|
|
636
|
-
let state = json!({ "items": [1, 2, 3] });
|
|
637
|
-
let builder = ManifestBuilder::new();
|
|
638
|
-
let manifest = builder.build_from_html(html, &state, false).unwrap();
|
|
639
|
-
|
|
640
|
-
// Serialize to JSON to inspect
|
|
641
|
-
let json = serde_json::to_string_pretty(&manifest).unwrap();
|
|
642
|
-
eprintln!("Manifest JSON:\n{}", json);
|
|
643
|
-
|
|
644
|
-
// Find iteration node
|
|
645
|
-
// Note: might be nested under body
|
|
646
|
-
assert!(!manifest.children.is_empty());
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
#[test]
|
|
650
|
-
fn split_bindings_simple() {
|
|
651
|
-
let builder = ManifestBuilder::new();
|
|
652
|
-
let result = builder.split_by_bindings("Hello @[name]!");
|
|
653
|
-
assert_eq!(result, vec!["Hello ", "@[name]", "!"]);
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
#[test]
|
|
657
|
-
fn split_bindings_multiple() {
|
|
658
|
-
let builder = ManifestBuilder::new();
|
|
659
|
-
let result = builder.split_by_bindings("@[firstName] @[lastName]");
|
|
660
|
-
assert_eq!(result, vec!["@[firstName]", " ", "@[lastName]"]);
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
#[test]
|
|
664
|
-
fn split_bindings_no_match() {
|
|
665
|
-
let builder = ManifestBuilder::new();
|
|
666
|
-
let result = builder.split_by_bindings("Hello World");
|
|
667
|
-
assert_eq!(result, vec!["Hello World"]);
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
#[test]
|
|
671
|
-
fn build_multiple_name_bindings() {
|
|
672
|
-
let html = r#"<div test @[theme] @[size]></div>"#;
|
|
673
|
-
let state = json!({ "theme": "light", "size": "large" });
|
|
674
|
-
let builder = ManifestBuilder::new();
|
|
675
|
-
let manifest = builder.build_from_html(html, &state, false).unwrap();
|
|
676
|
-
|
|
677
|
-
// Navigate to div
|
|
678
|
-
let html_node = manifest.children.get("html_0").unwrap();
|
|
679
|
-
let body_node = html_node.children.get("body_1").unwrap();
|
|
680
|
-
let div_node = body_node.children.get("div_0").unwrap();
|
|
681
|
-
|
|
682
|
-
// Check name bindings (sparse array indexed by position)
|
|
683
|
-
let compiled = div_node.compiled.as_ref().unwrap();
|
|
684
|
-
let restoration = compiled.restoration.as_ref().unwrap();
|
|
685
|
-
let name_bindings = restoration.name_bindings.as_ref().unwrap();
|
|
686
|
-
|
|
687
|
-
// Attributes: [0] test, [1] @[theme], [2] @[size]
|
|
688
|
-
assert_eq!(name_bindings.len(), 3);
|
|
689
|
-
assert_eq!(name_bindings[0], None); // "test" is not a binding
|
|
690
|
-
assert_eq!(name_bindings[1], Some("@[theme]".to_string()));
|
|
691
|
-
assert_eq!(name_bindings[2], Some("@[size]".to_string()));
|
|
692
|
-
}
|
|
693
|
-
}
|