@ape-egg/vibe 1.0.5 → 1.1.2
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 +112 -0
- package/README.md +228 -23
- package/compiler/bin/vibe-compile.js +109 -0
- package/compiler/native/.gitkeep +0 -0
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/src/Cargo.lock +1885 -0
- package/compiler/src/Cargo.toml +29 -0
- package/compiler/src/compiler/compile.rs +1209 -0
- package/compiler/src/compiler/mod.rs +5 -0
- package/compiler/src/config.rs +184 -0
- package/compiler/src/main.rs +284 -0
- package/compiler/src/parser/element.rs +96 -0
- package/compiler/src/parser/html.rs +335 -0
- package/compiler/src/parser/mod.rs +8 -0
- package/index.js +2 -248
- package/package.json +26 -3
- package/{affected.js → runtime/affected.js} +64 -4
- package/runtime/cleanup.js +59 -0
- package/runtime/component.js +116 -0
- package/{conditionals.js → runtime/conditionals.js} +25 -11
- package/{constants.js → runtime/constants.js} +23 -3
- package/runtime/debug.js +91 -0
- package/{hydrate.js → runtime/hydrate.js} +57 -7
- package/runtime/index.js +614 -0
- package/{iterate.js → runtime/iterate.js} +53 -45
- package/{iteration-utils.js → runtime/iteration-utils.js} +11 -1
- package/{parse.js → runtime/parse.js} +37 -7
- package/runtime/state.js +52 -0
- package/ROADMAP.md +0 -289
- package/llms.txt +0 -279
- package/state.js +0 -26
- /package/{_vibe-compiled-iteration-batch.js → runtime/_vibe-compiled-iteration-batch.js} +0 -0
- /package/{link.js → runtime/manifest.js} +0 -0
- /package/{utils.js → runtime/utils.js} +0 -0
- /package/{vibe.css → runtime/vibe.css} +0 -0
|
@@ -0,0 +1,335 @@
|
|
|
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
|
+
/// Get an element by tag name
|
|
75
|
+
pub fn _get_element(&self, tag_name: &str) -> Option<&Element> {
|
|
76
|
+
self.cache.get(tag_name)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/// Get all loaded elements
|
|
80
|
+
#[allow(dead_code)]
|
|
81
|
+
pub fn elements(&self) -> &ElementCache {
|
|
82
|
+
&self.cache
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/// Process HTML content: transform custom tags to <component>, optionally inline, apply accessibility
|
|
86
|
+
pub fn process_html(
|
|
87
|
+
&self,
|
|
88
|
+
content: &str,
|
|
89
|
+
accessibility: bool,
|
|
90
|
+
exclude_tags: &[String],
|
|
91
|
+
elements_as_is: bool,
|
|
92
|
+
components_dir: &str,
|
|
93
|
+
) -> String {
|
|
94
|
+
self.process_html_with_cache(content, accessibility, exclude_tags, elements_as_is, components_dir, &HashMap::new())
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
pub fn process_html_with_cache(
|
|
98
|
+
&self,
|
|
99
|
+
content: &str,
|
|
100
|
+
accessibility: bool,
|
|
101
|
+
exclude_tags: &[String],
|
|
102
|
+
elements_as_is: bool,
|
|
103
|
+
components_dir: &str,
|
|
104
|
+
external_cache: &HashMap<String, String>,
|
|
105
|
+
) -> String {
|
|
106
|
+
let mut result = content.to_string();
|
|
107
|
+
|
|
108
|
+
// Step 1: Transform custom tags matching element files to <component> tags
|
|
109
|
+
result = self.transform_custom_tags_to_component(&result, components_dir);
|
|
110
|
+
|
|
111
|
+
// Step 2: If elements_as_is is false, recursively inline all <component> elements
|
|
112
|
+
if !elements_as_is {
|
|
113
|
+
result = self.inline_component_elements(&result, external_cache);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Step 3: Accessibility transform if requested
|
|
117
|
+
if accessibility {
|
|
118
|
+
result = transform_custom_tags_to_divs(&result, exclude_tags);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
result
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/// Transform custom tags that match element files to <component src="..."> tags
|
|
125
|
+
fn transform_custom_tags_to_component(&self, content: &str, components_dir: &str) -> String {
|
|
126
|
+
let mut result = content.to_string();
|
|
127
|
+
|
|
128
|
+
// Find all tags that match loaded elements
|
|
129
|
+
for tag_name in self.cache.keys() {
|
|
130
|
+
// Match opening and closing tags with any attributes and children
|
|
131
|
+
let tag_pattern = format!(r"<{}(\s[^>]*)?>", regex::escape(tag_name));
|
|
132
|
+
let tag_re = regex::Regex::new(&tag_pattern).unwrap();
|
|
133
|
+
let closing_pattern = format!(r"</{}>", regex::escape(tag_name));
|
|
134
|
+
|
|
135
|
+
// Find all occurrences and transform them
|
|
136
|
+
let mut matches: Vec<(usize, usize, String, Option<String>)> = Vec::new();
|
|
137
|
+
|
|
138
|
+
// Find opening tags
|
|
139
|
+
for cap in tag_re.find_iter(&result) {
|
|
140
|
+
let start = cap.start();
|
|
141
|
+
let tag_with_attrs = cap.as_str();
|
|
142
|
+
|
|
143
|
+
// Extract attributes (everything between tag name and >)
|
|
144
|
+
let attrs = if tag_with_attrs.ends_with('>') {
|
|
145
|
+
let inner = &tag_with_attrs[tag_name.len() + 1..tag_with_attrs.len() - 1];
|
|
146
|
+
if inner.trim().is_empty() {
|
|
147
|
+
None
|
|
148
|
+
} else {
|
|
149
|
+
Some(inner.to_string())
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
None
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// Find corresponding closing tag
|
|
156
|
+
if let Some(closing_pos) = result[cap.end()..].find(&closing_pattern) {
|
|
157
|
+
let closing_start = cap.end() + closing_pos;
|
|
158
|
+
let closing_end = closing_start + closing_pattern.len();
|
|
159
|
+
let children = result[cap.end()..closing_start].to_string();
|
|
160
|
+
|
|
161
|
+
matches.push((start, closing_end, children, attrs));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Replace from end to start to maintain indices
|
|
166
|
+
matches.reverse();
|
|
167
|
+
for (start, end, children, attrs) in matches {
|
|
168
|
+
let attrs_str = attrs.map(|a| format!(" {}", a)).unwrap_or_default();
|
|
169
|
+
let replacement = format!("<component src=\"/{}/{}.html\"{}>{}</component>", components_dir, tag_name, attrs_str, children);
|
|
170
|
+
result.replace_range(start..end, &replacement);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
result
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/// Recursively inline all <component> elements with their HTML content
|
|
178
|
+
fn inline_component_elements(&self, content: &str, external_cache: &HashMap<String, String>) -> String {
|
|
179
|
+
let mut result = content.to_string();
|
|
180
|
+
let mut changed = true;
|
|
181
|
+
let mut iterations = 0;
|
|
182
|
+
const MAX_ITERATIONS: usize = 100; // Prevent infinite loops
|
|
183
|
+
|
|
184
|
+
while changed && iterations < MAX_ITERATIONS {
|
|
185
|
+
changed = false;
|
|
186
|
+
iterations += 1;
|
|
187
|
+
|
|
188
|
+
// Match <component src="..." attrs...>children</component>
|
|
189
|
+
// Handles internal (/components/file.html) and external (http://... or https://...)
|
|
190
|
+
// Use (?s) flag to make . match newlines
|
|
191
|
+
let component_re = regex::Regex::new(
|
|
192
|
+
r#"(?s)<component\s+src="([^"]+)"([^>]*)>(.*?)</component>"#
|
|
193
|
+
).unwrap();
|
|
194
|
+
|
|
195
|
+
let matches: Vec<_> = component_re.captures_iter(&result).map(|cap| {
|
|
196
|
+
let full_match = cap.get(0).unwrap();
|
|
197
|
+
let src = cap.get(1).unwrap().as_str();
|
|
198
|
+
let attrs_str = cap.get(2).map(|m| m.as_str().to_string()).unwrap_or_default();
|
|
199
|
+
let slot_content = cap.get(3).map(|m| m.as_str().to_string());
|
|
200
|
+
|
|
201
|
+
// Parse attributes into a map (prop_name -> prop_value)
|
|
202
|
+
let props = Self::parse_props(&attrs_str);
|
|
203
|
+
|
|
204
|
+
(full_match.start(), full_match.end(), src.to_string(), props, slot_content)
|
|
205
|
+
}).collect();
|
|
206
|
+
|
|
207
|
+
if !matches.is_empty() {
|
|
208
|
+
changed = true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Replace from end to start
|
|
212
|
+
for (start, end, src, props, slot_content) in matches.iter().rev() {
|
|
213
|
+
// Check if it's an external URL
|
|
214
|
+
let replacement_content = if src.starts_with("http://") || src.starts_with("https://") {
|
|
215
|
+
// External component - get from cache
|
|
216
|
+
external_cache.get(src).cloned()
|
|
217
|
+
} else {
|
|
218
|
+
// Internal component - get element name from filename (e.g., "card.html" -> "card")
|
|
219
|
+
let element_name = src
|
|
220
|
+
.trim_start_matches("./")
|
|
221
|
+
.trim_start_matches('/')
|
|
222
|
+
.split('/')
|
|
223
|
+
.last()
|
|
224
|
+
.unwrap_or(src)
|
|
225
|
+
.trim_end_matches(".html");
|
|
226
|
+
|
|
227
|
+
self.cache.get(element_name).map(|e| e.content.clone())
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
if let Some(mut replacement) = replacement_content {
|
|
231
|
+
// Replace props: for each prop like headline="@[pageTitle]",
|
|
232
|
+
// replace @[headline] in content with @[pageTitle]
|
|
233
|
+
for (prop_name, prop_value) in props {
|
|
234
|
+
let prop_binding = format!("@[{}]", prop_name);
|
|
235
|
+
replacement = replacement.replace(&prop_binding, prop_value);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Replace <slot></slot> with slot content if present
|
|
239
|
+
if let Some(ref slot) = slot_content {
|
|
240
|
+
if !slot.trim().is_empty() {
|
|
241
|
+
replacement = replacement.replace("<slot></slot>", slot);
|
|
242
|
+
replacement = replacement.replace("<slot/>", slot);
|
|
243
|
+
replacement = replacement.replace("<slot />", slot);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
result.replace_range(*start..*end, &replacement);
|
|
248
|
+
}
|
|
249
|
+
// If not found in cache or internal elements, leave as-is
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
result
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/// Parse component props from attributes string
|
|
257
|
+
/// Example: ` headline="@[pageTitle]" theme="dark"` -> {"headline": "@[pageTitle]", "theme": "dark"}
|
|
258
|
+
fn parse_props(attrs_str: &str) -> HashMap<String, String> {
|
|
259
|
+
let mut props = HashMap::new();
|
|
260
|
+
|
|
261
|
+
// Match attribute="value" pairs
|
|
262
|
+
let attr_re = regex::Regex::new(r#"(\w+)="([^"]*)""#).unwrap();
|
|
263
|
+
|
|
264
|
+
for cap in attr_re.captures_iter(attrs_str) {
|
|
265
|
+
if let (Some(name), Some(value)) = (cap.get(1), cap.get(2)) {
|
|
266
|
+
props.insert(name.as_str().to_string(), value.as_str().to_string());
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
props
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/// Transform custom HTML elements to divs with classes
|
|
275
|
+
fn transform_custom_tags_to_divs(content: &str, exclude_tags: &[String]) -> String {
|
|
276
|
+
let mut result = content.to_string();
|
|
277
|
+
|
|
278
|
+
// Standard HTML5 elements (should not be transformed)
|
|
279
|
+
let standard_tags: HashSet<&str> = [
|
|
280
|
+
"a", "abbr", "address", "area", "article", "aside", "audio",
|
|
281
|
+
"b", "base", "bdi", "bdo", "blockquote", "body", "br", "button",
|
|
282
|
+
"canvas", "caption", "cite", "code", "col", "colgroup",
|
|
283
|
+
"data", "datalist", "dd", "del", "details", "dfn", "dialog", "div", "dl", "dt",
|
|
284
|
+
"em", "embed",
|
|
285
|
+
"fieldset", "figcaption", "figure", "footer", "form",
|
|
286
|
+
"h1", "h2", "h3", "h4", "h5", "h6", "head", "header", "hgroup", "hr", "html",
|
|
287
|
+
"i", "iframe", "img", "input", "ins",
|
|
288
|
+
"kbd",
|
|
289
|
+
"label", "legend", "li", "link",
|
|
290
|
+
"main", "map", "mark", "menu", "meta", "meter",
|
|
291
|
+
"nav", "noscript",
|
|
292
|
+
"object", "ol", "optgroup", "option", "output",
|
|
293
|
+
"p", "param", "picture", "pre", "progress",
|
|
294
|
+
"q",
|
|
295
|
+
"rp", "rt", "ruby",
|
|
296
|
+
"s", "samp", "script", "search", "section", "select", "slot", "small", "source", "span", "strong", "style", "sub", "summary", "sup", "svg",
|
|
297
|
+
"table", "tbody", "td", "template", "textarea", "tfoot", "th", "thead", "time", "title", "tr", "track",
|
|
298
|
+
"u", "ul",
|
|
299
|
+
"var", "video",
|
|
300
|
+
"wbr",
|
|
301
|
+
].into_iter().collect();
|
|
302
|
+
|
|
303
|
+
// Find all custom tags and transform them
|
|
304
|
+
let tag_pattern = regex::Regex::new(r"<([a-z][a-z0-9-]*)([^>]*)>").unwrap();
|
|
305
|
+
let _closing_pattern = regex::Regex::new(r"</([a-z][a-z0-9-]*)>").unwrap();
|
|
306
|
+
|
|
307
|
+
// Collect unique custom tags first
|
|
308
|
+
let mut custom_tags: Vec<String> = Vec::new();
|
|
309
|
+
for cap in tag_pattern.captures_iter(&result.clone()) {
|
|
310
|
+
if let Some(m) = cap.get(1) {
|
|
311
|
+
let tag = m.as_str().to_string();
|
|
312
|
+
if !standard_tags.contains(tag.as_str())
|
|
313
|
+
&& !exclude_tags.contains(&tag)
|
|
314
|
+
&& !custom_tags.contains(&tag)
|
|
315
|
+
{
|
|
316
|
+
custom_tags.push(tag);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Transform each custom tag
|
|
322
|
+
for tag in custom_tags {
|
|
323
|
+
// Opening tag: <custom-tag attrs> -> <div class="custom-tag" attrs>
|
|
324
|
+
let open_re = regex::Regex::new(&format!(r"<{}([^>]*)>", regex::escape(&tag))).unwrap();
|
|
325
|
+
result = open_re
|
|
326
|
+
.replace_all(&result, format!("<div class=\"{}\"$1>", tag).as_str())
|
|
327
|
+
.to_string();
|
|
328
|
+
|
|
329
|
+
// Closing tag: </custom-tag> -> </div>
|
|
330
|
+
let close_re = regex::Regex::new(&format!(r"</{}>", regex::escape(&tag))).unwrap();
|
|
331
|
+
result = close_re.replace_all(&result, "</div>").to_string();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
result
|
|
335
|
+
}
|
package/index.js
CHANGED
|
@@ -1,248 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import link from './link.js';
|
|
4
|
-
import hydrate, { setPreviousState } from './hydrate.js';
|
|
5
|
-
import affected from './affected.js';
|
|
6
|
-
import { deepMerge, hash } from './utils.js';
|
|
7
|
-
import { renderAllIterations, setRenderAllConditionals } from './iterate.js';
|
|
8
|
-
import { renderAllConditionals } from './conditionals.js';
|
|
9
|
-
import { NON_REACTIVE_ELEMENTS } from './constants.js';
|
|
10
|
-
|
|
11
|
-
// Wire up cross-module dependency after all modules are loaded
|
|
12
|
-
setRenderAllConditionals(renderAllConditionals);
|
|
13
|
-
|
|
14
|
-
// Check if node itself or any ancestor is non-reactive or dehydrated
|
|
15
|
-
const isNonReactiveOrInside = (node) => {
|
|
16
|
-
let current = node;
|
|
17
|
-
while (current && current !== document.body) {
|
|
18
|
-
if (NON_REACTIVE_ELEMENTS.includes(current.nodeName)) {
|
|
19
|
-
return true;
|
|
20
|
-
}
|
|
21
|
-
if (current.hasAttribute?.('dehydrate')) {
|
|
22
|
-
return true;
|
|
23
|
-
}
|
|
24
|
-
current = current.parentElement;
|
|
25
|
-
}
|
|
26
|
-
return false;
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
// Navigate tree using dot notation (handles .children at each level)
|
|
30
|
-
const navigateTree = (tree, path) => {
|
|
31
|
-
if (!path) return tree;
|
|
32
|
-
return path.split('.').reduce((node, key) => node?.children?.[key], tree);
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
// Get or create a node in the tree at the given path
|
|
36
|
-
const ensureNode = (tree, path) => {
|
|
37
|
-
const keys = path.split('.');
|
|
38
|
-
return keys.reduce((node, key) => {
|
|
39
|
-
if (!node.children[key]) {
|
|
40
|
-
node.children[key] = { children: {} };
|
|
41
|
-
}
|
|
42
|
-
return node.children[key];
|
|
43
|
-
}, tree);
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
const main = (s, attrName = 'vibe') => {
|
|
47
|
-
// Find element(s) with the specified attribute
|
|
48
|
-
const elements = document.querySelectorAll(`[${attrName}]`);
|
|
49
|
-
|
|
50
|
-
if (elements.length === 0) {
|
|
51
|
-
console.info(`[vibe] No element found with attribute "${attrName}". Falling back to body.`);
|
|
52
|
-
} else if (elements.length > 1) {
|
|
53
|
-
console.info(
|
|
54
|
-
`[vibe] Multiple elements (${elements.length}) found with attribute "${attrName}". Hydrating the first one.`,
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const rootElement = elements[0] || document.body;
|
|
59
|
-
let parsedTree = parse(rootElement);
|
|
60
|
-
let linkList = link(parsedTree);
|
|
61
|
-
|
|
62
|
-
// Store previous state for comparison
|
|
63
|
-
let previousState = {};
|
|
64
|
-
|
|
65
|
-
// Lifecycle hooks that users can subscribe to
|
|
66
|
-
const hooks = {
|
|
67
|
-
afterUpdate: [],
|
|
68
|
-
afterDomMutation: [],
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
// Observer reference and callback - defined here so state handler can access processMutations
|
|
72
|
-
let observer = null;
|
|
73
|
-
let processMutations = null;
|
|
74
|
-
|
|
75
|
-
const $ = state(s, (newState) => {
|
|
76
|
-
const mergedState = deepMerge($, newState);
|
|
77
|
-
const affectedElements = affected(parsedTree, previousState, mergedState);
|
|
78
|
-
|
|
79
|
-
// Capture pending mutations before disconnecting (takeRecords clears the queue)
|
|
80
|
-
let pendingMutations = [];
|
|
81
|
-
if (observer) {
|
|
82
|
-
pendingMutations = observer.takeRecords();
|
|
83
|
-
observer.disconnect();
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
hydrate(affectedElements, mergedState, linkList);
|
|
87
|
-
|
|
88
|
-
if (observer) {
|
|
89
|
-
observer.observe(rootElement, {
|
|
90
|
-
attributes: false,
|
|
91
|
-
characterData: false,
|
|
92
|
-
childList: true,
|
|
93
|
-
subtree: true,
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
// Process mutations that were pending before we disconnected
|
|
97
|
-
if (pendingMutations.length > 0 && processMutations) {
|
|
98
|
-
processMutations(pendingMutations);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const prev = structuredClone(previousState);
|
|
103
|
-
previousState = { ...$, ...newState };
|
|
104
|
-
hooks.afterUpdate.forEach((callback) => callback(structuredClone({ ...$ }), prev));
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
// Add hook subscription method (non-enumerable so it won't be spread/cloned with state)
|
|
108
|
-
Object.defineProperty($, 'on', {
|
|
109
|
-
value: (event, callback) => {
|
|
110
|
-
if (hooks[event]) {
|
|
111
|
-
hooks[event].push(callback);
|
|
112
|
-
}
|
|
113
|
-
return () => (hooks[event] = hooks[event].filter((cb) => cb !== callback));
|
|
114
|
-
},
|
|
115
|
-
enumerable: false,
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
// Initial hydration
|
|
119
|
-
const affectedElements = affected(parsedTree, $, $);
|
|
120
|
-
hydrate(affectedElements, $, linkList);
|
|
121
|
-
|
|
122
|
-
// Render all iterations and conditionals after initial hydration
|
|
123
|
-
setPreviousState($);
|
|
124
|
-
previousState = { ...$ };
|
|
125
|
-
renderAllIterations(parsedTree, $, linkList);
|
|
126
|
-
renderAllConditionals(parsedTree, $, linkList);
|
|
127
|
-
|
|
128
|
-
// Define observer callback as named function so we can call it manually for pending mutations
|
|
129
|
-
processMutations = (mutations) => {
|
|
130
|
-
// Early exit if no mutations to process (common case)
|
|
131
|
-
if (mutations.length === 0) return;
|
|
132
|
-
|
|
133
|
-
let hadChanges = false;
|
|
134
|
-
let parsedParents = null; // Lazy init - only create Set when needed
|
|
135
|
-
|
|
136
|
-
mutations.forEach(({ addedNodes, removedNodes, target }) => {
|
|
137
|
-
removedNodes.forEach((node) => {
|
|
138
|
-
const entry = Object.entries(linkList).find(([_, element]) => element === node);
|
|
139
|
-
|
|
140
|
-
// Skip nodes that aren't tracked (e.g., iteration-generated nodes or nodes outside reactive scope)
|
|
141
|
-
if (!entry) return;
|
|
142
|
-
|
|
143
|
-
const [dotAnnotation] = entry;
|
|
144
|
-
delete linkList[dotAnnotation];
|
|
145
|
-
|
|
146
|
-
const dotPath = dotAnnotation.split('.');
|
|
147
|
-
const name = dotPath.pop();
|
|
148
|
-
const parentDotAnnotation = dotPath.join('.');
|
|
149
|
-
|
|
150
|
-
const picked = navigateTree(parsedTree, parentDotAnnotation);
|
|
151
|
-
|
|
152
|
-
// If we can't navigate to the parent, skip
|
|
153
|
-
if (!picked || !picked.element) return;
|
|
154
|
-
|
|
155
|
-
// Update parent's parsed HTML (only once per parent)
|
|
156
|
-
if (!parsedParents) parsedParents = new Set();
|
|
157
|
-
if (!parsedParents.has(picked)) {
|
|
158
|
-
const { parsed } = parse(picked.element);
|
|
159
|
-
picked.parsed = parsed;
|
|
160
|
-
parsedParents.add(picked);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// Remove the node from parent's children
|
|
164
|
-
delete picked.children[name];
|
|
165
|
-
|
|
166
|
-
hadChanges = true;
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
addedNodes.forEach((node) => {
|
|
170
|
-
// Skip if node itself or any ancestor is non-reactive
|
|
171
|
-
if (isNonReactiveOrInside(node)) {
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const entry = Object.entries(linkList).find(([_, element]) => element === target);
|
|
176
|
-
|
|
177
|
-
// If parent isn't tracked, this node is outside the reactive scope
|
|
178
|
-
if (!entry) return;
|
|
179
|
-
|
|
180
|
-
const [dotAnnotation] = entry;
|
|
181
|
-
const picked = navigateTree(parsedTree, dotAnnotation);
|
|
182
|
-
|
|
183
|
-
// If we can't navigate to the parent in the tree, skip
|
|
184
|
-
if (!picked) return;
|
|
185
|
-
|
|
186
|
-
// If parent has no element reference, re-parse from the actual DOM element
|
|
187
|
-
if (!picked.element) {
|
|
188
|
-
picked.element = target;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// Parse the newly added node
|
|
192
|
-
const name = `${node.nodeName.toLowerCase()}_${hash()}`;
|
|
193
|
-
const parsedNode = parse(node);
|
|
194
|
-
|
|
195
|
-
// Update parent's parsed HTML (only once per parent)
|
|
196
|
-
if (!parsedParents) parsedParents = new Set();
|
|
197
|
-
if (!parsedParents.has(picked)) {
|
|
198
|
-
const { parsed } = parse(picked.element);
|
|
199
|
-
picked.parsed = parsed;
|
|
200
|
-
parsedParents.add(picked);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// Add the parsed node to parent's children
|
|
204
|
-
picked.children[name] = parsedNode;
|
|
205
|
-
|
|
206
|
-
linkList[`${dotAnnotation}.${name}`] = node;
|
|
207
|
-
|
|
208
|
-
// Only hydrate the newly added node and its descendants, not the entire tree
|
|
209
|
-
// Use empty object as "previous state" so all bindings in new node are considered affected
|
|
210
|
-
const affectedElements = affected(parsedNode, {}, $);
|
|
211
|
-
|
|
212
|
-
hydrate(affectedElements, $, linkList);
|
|
213
|
-
|
|
214
|
-
// Process iterations and conditionals in the newly added node
|
|
215
|
-
renderAllIterations(parsedNode, $, linkList);
|
|
216
|
-
renderAllConditionals(parsedNode, $, linkList);
|
|
217
|
-
|
|
218
|
-
hadChanges = true;
|
|
219
|
-
});
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
// Fire hooks once after all mutations are processed (not per-node)
|
|
223
|
-
if (hadChanges) {
|
|
224
|
-
hooks.afterDomMutation.forEach((callback) => callback());
|
|
225
|
-
}
|
|
226
|
-
};
|
|
227
|
-
|
|
228
|
-
observer = new MutationObserver(processMutations);
|
|
229
|
-
|
|
230
|
-
observer.observe(rootElement, {
|
|
231
|
-
attributes: false,
|
|
232
|
-
characterData: false,
|
|
233
|
-
childList: true,
|
|
234
|
-
subtree: true,
|
|
235
|
-
attributeOldValue: false,
|
|
236
|
-
characterDataOldValue: false,
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
// Force reflow - ensures layout is applied before transitions re-enable
|
|
240
|
-
rootElement.offsetHeight;
|
|
241
|
-
|
|
242
|
-
// Remove the vibe attribute to reveal content and enable transitions
|
|
243
|
-
rootElement.removeAttribute(attrName);
|
|
244
|
-
|
|
245
|
-
return $;
|
|
246
|
-
};
|
|
247
|
-
|
|
248
|
-
export default main;
|
|
1
|
+
// Re-export from runtime for backwards compatibility
|
|
2
|
+
export { default } from './runtime/index.js';
|
package/package.json
CHANGED
|
@@ -1,10 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ape-egg/vibe",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Runtime-first reactivity",
|
|
5
|
+
"description": "Runtime-first reactivity with optional compiler",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"homepage": "https://vibe.korte.kim",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./index.js",
|
|
10
|
+
"./runtime": "./runtime/index.js",
|
|
11
|
+
"./compiler": "./compiler/bin/vibe-compile.js"
|
|
12
|
+
},
|
|
13
|
+
"bin": {
|
|
14
|
+
"vibe": "./compiler/bin/vibe-compile.js"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"index.js",
|
|
18
|
+
"runtime/",
|
|
19
|
+
"compiler/bin/",
|
|
20
|
+
"compiler/native/",
|
|
21
|
+
"compiler/src/Cargo.lock",
|
|
22
|
+
"compiler/src/Cargo.toml",
|
|
23
|
+
"compiler/src/compiler/",
|
|
24
|
+
"compiler/src/config.rs",
|
|
25
|
+
"compiler/src/main.rs",
|
|
26
|
+
"compiler/src/parser/",
|
|
27
|
+
"README.md",
|
|
28
|
+
"CHANGELOG.md"
|
|
29
|
+
],
|
|
8
30
|
"keywords": [
|
|
9
31
|
"reactive",
|
|
10
32
|
"framework",
|
|
@@ -12,7 +34,8 @@
|
|
|
12
34
|
"ui",
|
|
13
35
|
"mutation-observer",
|
|
14
36
|
"proxy",
|
|
15
|
-
"minimalistic"
|
|
37
|
+
"minimalistic",
|
|
38
|
+
"compiler"
|
|
16
39
|
],
|
|
17
40
|
"scripts": {
|
|
18
41
|
"test": "echo \"Error: no test specified\" && exit 1"
|