@ape-egg/vibe 1.7.1 → 1.8.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/CHANGELOG.md +62 -0
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/Cargo.lock +86 -497
- package/compiler/src/Cargo.toml +2 -2
- package/compiler/src/compiler/compile.rs +3 -11
- package/compiler/src/parser/html.rs +2 -2
- package/package.json +1 -1
- package/runtime/affected.js +18 -0
- package/runtime/component.js +147 -79
- package/runtime/conditionals.js +88 -7
- package/runtime/constants.js +7 -5
- package/runtime/index.js +65 -14
- package/runtime/iterate.js +119 -59
- package/runtime/iteration-utils.js +5 -1
- package/runtime/parse.js +1 -0
- package/runtime/pre-compiled-iterations.js +34 -21
- package/runtime/state.js +21 -5
- package/runtime/utils.js +24 -5
package/compiler/src/Cargo.toml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "vibe-compiler"
|
|
3
|
-
version = "1.7.
|
|
3
|
+
version = "1.7.2"
|
|
4
4
|
edition = "2021"
|
|
5
5
|
description = "Vibe framework compiler - compiles Vibe source files into optimized output"
|
|
6
6
|
authors = ["Kim Korte"]
|
|
@@ -20,7 +20,7 @@ markup5ever = "0.12"
|
|
|
20
20
|
thiserror = "1.0"
|
|
21
21
|
regex = "1.10"
|
|
22
22
|
colored = "2.1"
|
|
23
|
-
|
|
23
|
+
ureq = { version = "2", features = ["tls"] }
|
|
24
24
|
glob = "0.3"
|
|
25
25
|
rquickjs = "0.6"
|
|
26
26
|
swc_common = "=0.40.1"
|
|
@@ -1422,17 +1422,9 @@ impl Compiler {
|
|
|
1422
1422
|
|
|
1423
1423
|
/// Fetch external component without caching (returns raw content)
|
|
1424
1424
|
fn fetch_external_component_raw(&self, url: &str) -> Result<String, String> {
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
if !response.status().is_success() {
|
|
1429
|
-
return Err(format!("HTTP {} - {}", response.status().as_u16(), response.status().canonical_reason().unwrap_or("Unknown")));
|
|
1430
|
-
}
|
|
1431
|
-
match response.text() {
|
|
1432
|
-
Ok(content) => Ok(content),
|
|
1433
|
-
Err(e) => Err(format!("Failed to read response body: {}", e)),
|
|
1434
|
-
}
|
|
1435
|
-
}
|
|
1425
|
+
match ureq::get(url).call() {
|
|
1426
|
+
Ok(response) => response.into_string().map_err(|e| format!("Failed to read response body: {}", e)),
|
|
1427
|
+
Err(ureq::Error::Status(code, response)) => Err(format!("HTTP {} - {}", code, response.status_text().to_string())),
|
|
1436
1428
|
Err(e) => Err(format!("Failed to fetch: {}", e)),
|
|
1437
1429
|
}
|
|
1438
1430
|
}
|
|
@@ -258,8 +258,8 @@ impl HtmlParser {
|
|
|
258
258
|
/// Find the start position of the matching close tag, handling nesting of the same tag.
|
|
259
259
|
/// `after_open`: byte position immediately after the `>` of the opening tag.
|
|
260
260
|
fn find_matching_close(content: &str, after_open: usize, tag_name: &str) -> Option<usize> {
|
|
261
|
-
//
|
|
262
|
-
let open_re = regex::Regex::new(&format!(r"<{}
|
|
261
|
+
// (?:\s|>) ensures <component> matches but not <component-foo> (hyphen is not \s or >)
|
|
262
|
+
let open_re = regex::Regex::new(&format!(r"<{}(?:\s|>|/>)", regex::escape(tag_name))).unwrap();
|
|
263
263
|
let close_re = regex::Regex::new(&format!(r"</{}>", regex::escape(tag_name))).unwrap();
|
|
264
264
|
|
|
265
265
|
let mut depth = 1i32;
|
package/package.json
CHANGED
package/runtime/affected.js
CHANGED
|
@@ -35,6 +35,24 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
|
|
|
35
35
|
// Array didn't change, but check for affected elements inside iteration instances
|
|
36
36
|
// (e.g., when tutorialProgress changes, need to update checkmarks in menu items)
|
|
37
37
|
if (tree.runtime.instances) {
|
|
38
|
+
// Compiled iterations have instances without tree/scopedState — the batch function
|
|
39
|
+
// may reference global state keys (e.g., `selectedCategory` in a button binding).
|
|
40
|
+
// If any state key changed, trigger a rebuild so the batch function re-evaluates.
|
|
41
|
+
const hasCompiledInstances = tree.compiled && tree.runtime.instances.length > 0
|
|
42
|
+
&& !tree.runtime.instances[0].tree;
|
|
43
|
+
|
|
44
|
+
if (hasCompiledInstances) {
|
|
45
|
+
const isInitialHydration = state === newState;
|
|
46
|
+
if (!isInitialHydration) {
|
|
47
|
+
const stateKeys = Object.keys(state);
|
|
48
|
+
const hasChangedKey = stateKeys.some(k => state[k] !== newState[k]);
|
|
49
|
+
if (hasChangedKey) {
|
|
50
|
+
affected.push({ type: 'iteration', node: tree, changeType: 'array' });
|
|
51
|
+
return affected;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
38
56
|
for (const instance of tree.runtime.instances) {
|
|
39
57
|
if (instance.tree && instance.scopedState) {
|
|
40
58
|
// Use the instance's scoped state (includes item, index, etc.)
|
package/runtime/component.js
CHANGED
|
@@ -28,36 +28,31 @@ export const abortComponentFetch = (element) => {
|
|
|
28
28
|
}
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
return;
|
|
31
|
+
// Check if an element is nested inside another unprocessed component[src]
|
|
32
|
+
const isNestedInUnprocessedComponent = (el, rootElement) => {
|
|
33
|
+
let parent = el.parentElement;
|
|
34
|
+
while (parent && parent !== rootElement) {
|
|
35
|
+
if (
|
|
36
|
+
(parent.tagName === 'COMPONENT' || parent.classList?.contains('component')) &&
|
|
37
|
+
parent.hasAttribute('src')
|
|
38
|
+
) {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
parent = parent.parentElement;
|
|
43
42
|
}
|
|
43
|
+
return false;
|
|
44
|
+
};
|
|
44
45
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
// Skip if component is dehydrated (vibe-dehydrate attribute or class)
|
|
46
|
+
// Process a single component element: fetch HTML, execute scripts, replace DOM
|
|
47
|
+
const processSingle = (el, debug) => {
|
|
48
|
+
// Skip dehydrated components
|
|
49
49
|
if (el.hasAttribute(DEHYDRATE_CLASS_OR_ATTR) || el.classList.contains(DEHYDRATE_CLASS_OR_ATTR)) {
|
|
50
|
-
|
|
51
|
-
if (componentElements.length > 1) {
|
|
52
|
-
// Process next component
|
|
53
|
-
processComponent(rootElement, onComplete, config);
|
|
54
|
-
} else {
|
|
55
|
-
// Defer onComplete to give user code a chance to register listeners
|
|
56
|
-
if (onComplete) queueMicrotask(() => onComplete());
|
|
57
|
-
}
|
|
58
|
-
return;
|
|
50
|
+
return Promise.resolve();
|
|
59
51
|
}
|
|
60
52
|
|
|
53
|
+
// Skip if fetch already in flight for this element
|
|
54
|
+
if (pendingFetches.has(el)) return Promise.resolve();
|
|
55
|
+
|
|
61
56
|
const src = el.getAttribute('src');
|
|
62
57
|
|
|
63
58
|
// Use pre-hydration slot content if available (saved by index.js before hydration ran),
|
|
@@ -75,7 +70,7 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
|
|
|
75
70
|
const controller = new AbortController();
|
|
76
71
|
pendingFetches.set(el, controller);
|
|
77
72
|
|
|
78
|
-
fetch(src, { signal: controller.signal })
|
|
73
|
+
return fetch(src, { signal: controller.signal })
|
|
79
74
|
.then((r) => r.text())
|
|
80
75
|
.then((html) => {
|
|
81
76
|
// Parse HTML in temporary container to process component scripts
|
|
@@ -84,13 +79,41 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
|
|
|
84
79
|
|
|
85
80
|
// Process any <script type="module"> elements
|
|
86
81
|
const moduleScripts = temp.querySelectorAll('script[type="module"]');
|
|
87
|
-
moduleScripts.forEach((script) => {
|
|
88
|
-
let scriptContent = script.textContent?.trim() || '';
|
|
89
|
-
if (!scriptContent) return;
|
|
90
82
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
83
|
+
// Process each script — collect async tasks if any have imports
|
|
84
|
+
const asyncTasks = [];
|
|
85
|
+
|
|
86
|
+
for (const script of moduleScripts) {
|
|
87
|
+
let scriptContent = script.textContent?.trim() || '';
|
|
88
|
+
if (!scriptContent) continue;
|
|
89
|
+
|
|
90
|
+
// Strip `import component from '...'` — Vibe injects the contextual
|
|
91
|
+
// component() function as a parameter (it needs access to the temp DOM)
|
|
92
|
+
scriptContent = scriptContent.replace(/import\s+component\s+from\s+['"][^'"]+['"];?\s*/g, '');
|
|
93
|
+
|
|
94
|
+
// Check for remaining imports that need rewriting
|
|
95
|
+
const hasImports = /import\s/.test(scriptContent);
|
|
96
|
+
|
|
97
|
+
if (hasImports) {
|
|
98
|
+
// Rewrite remaining imports to dynamic await import()
|
|
99
|
+
// Order matters: default → named → namespace → side-effect (most specific first)
|
|
100
|
+
scriptContent = scriptContent.replace(
|
|
101
|
+
/import\s+(\w+)\s+from\s+(['"][^'"]+['"])\s*;?/g,
|
|
102
|
+
'const $1 = (await import($2)).default;'
|
|
103
|
+
);
|
|
104
|
+
scriptContent = scriptContent.replace(
|
|
105
|
+
/import\s+\{([^}]+)\}\s+from\s+(['"][^'"]+['"])\s*;?/g,
|
|
106
|
+
'const {$1} = await import($2);'
|
|
107
|
+
);
|
|
108
|
+
scriptContent = scriptContent.replace(
|
|
109
|
+
/import\s+\*\s+as\s+(\w+)\s+from\s+(['"][^'"]+['"])\s*;?/g,
|
|
110
|
+
'const $1 = await import($2);'
|
|
111
|
+
);
|
|
112
|
+
scriptContent = scriptContent.replace(
|
|
113
|
+
/import\s+(['"][^'"]+['"])\s*;?/g,
|
|
114
|
+
'await import($1);'
|
|
115
|
+
);
|
|
116
|
+
}
|
|
94
117
|
|
|
95
118
|
// Generate component ID for this instance
|
|
96
119
|
const componentId = generateComponentId();
|
|
@@ -122,10 +145,16 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
|
|
|
122
145
|
};
|
|
123
146
|
|
|
124
147
|
// Execute script with component() function in scope
|
|
125
|
-
// Use Function constructor to provide 'component' as a parameter
|
|
126
148
|
try {
|
|
127
|
-
|
|
128
|
-
|
|
149
|
+
if (hasImports) {
|
|
150
|
+
// Async execution for scripts with imports
|
|
151
|
+
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
|
|
152
|
+
asyncTasks.push(new AsyncFunction('component', scriptContent)(componentFn));
|
|
153
|
+
} else {
|
|
154
|
+
// Synchronous execution for scripts without imports (preserves boot timing)
|
|
155
|
+
const executeFn = new Function('component', scriptContent);
|
|
156
|
+
executeFn(componentFn);
|
|
157
|
+
}
|
|
129
158
|
} catch (e) {
|
|
130
159
|
console.warn('[vibe] Failed to execute component script:', e);
|
|
131
160
|
}
|
|
@@ -166,56 +195,67 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
|
|
|
166
195
|
|
|
167
196
|
// Remove script from temp (we executed it manually)
|
|
168
197
|
script.remove();
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
// Get transformed HTML from temp container (scripts removed)
|
|
172
|
-
let transformedHtml = temp.innerHTML;
|
|
173
|
-
|
|
174
|
-
// Replace props
|
|
175
|
-
Object.entries(props).forEach(([propName, propValue]) => {
|
|
176
|
-
const bindingMatch = propValue.match(/^@\[(.+)\]$/);
|
|
177
|
-
|
|
178
|
-
if (bindingMatch) {
|
|
179
|
-
// Reactive prop: replace @[propName] with @[path]
|
|
180
|
-
const path = bindingMatch[1];
|
|
181
|
-
const propPattern = new RegExp(`@\\[${escapeRegex(propName)}\\]`, 'g');
|
|
182
|
-
transformedHtml = transformedHtml.replace(propPattern, `@[${path}]`);
|
|
183
|
-
} else {
|
|
184
|
-
// Static prop: replace @[propName] with literal value
|
|
185
|
-
const propPattern = new RegExp(`@\\[${escapeRegex(propName)}\\]`, 'g');
|
|
186
|
-
transformedHtml = transformedHtml.replace(propPattern, propValue);
|
|
187
|
-
}
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
// Replace <slot></slot> with children
|
|
191
|
-
if (children) {
|
|
192
|
-
transformedHtml = transformedHtml.replace(/<slot><\/slot>/g, children);
|
|
193
|
-
transformedHtml = transformedHtml.replace(/<slot\s*\/>/g, children);
|
|
194
198
|
}
|
|
195
199
|
|
|
196
|
-
//
|
|
197
|
-
|
|
200
|
+
// Finalize: props, slots, DOM replacement
|
|
201
|
+
const finalize = () => {
|
|
202
|
+
// Get transformed HTML from temp container (scripts removed)
|
|
203
|
+
let transformedHtml = temp.innerHTML;
|
|
204
|
+
|
|
205
|
+
// Replace props
|
|
206
|
+
Object.entries(props).forEach(([propName, propValue]) => {
|
|
207
|
+
const bindingMatch = propValue.match(/^@\[(.+)\]$/);
|
|
208
|
+
|
|
209
|
+
if (bindingMatch) {
|
|
210
|
+
// Reactive prop: replace @[propName] with @[path]
|
|
211
|
+
const path = bindingMatch[1];
|
|
212
|
+
const propPattern = new RegExp(`@\\[${escapeRegex(propName)}\\]`, 'g');
|
|
213
|
+
transformedHtml = transformedHtml.replace(propPattern, `@[${path}]`);
|
|
214
|
+
} else {
|
|
215
|
+
// Static prop: replace @[propName] with literal value
|
|
216
|
+
const propPattern = new RegExp(`@\\[${escapeRegex(propName)}\\]`, 'g');
|
|
217
|
+
transformedHtml = transformedHtml.replace(propPattern, propValue);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
198
220
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const newWrapper =
|
|
204
|
-
el.tagName === 'DIV'
|
|
205
|
-
? document.createElement('div')
|
|
206
|
-
: document.createElement('component');
|
|
207
|
-
|
|
208
|
-
if (el.tagName === 'DIV') {
|
|
209
|
-
newWrapper.className = 'component';
|
|
221
|
+
// Replace <slot></slot> with children
|
|
222
|
+
if (children) {
|
|
223
|
+
transformedHtml = transformedHtml.replace(/<slot><\/slot>/g, children);
|
|
224
|
+
transformedHtml = transformedHtml.replace(/<slot\s*\/>/g, children);
|
|
210
225
|
}
|
|
211
226
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
debugLog(PHASE_FETCH, src, debug);
|
|
227
|
+
// Clean up pending fetch tracker
|
|
228
|
+
pendingFetches.delete(el);
|
|
215
229
|
|
|
216
|
-
//
|
|
217
|
-
//
|
|
230
|
+
// Replace with clean component wrapper (no src, no props)
|
|
231
|
+
// Check if element still has a parent (might have been removed during fetch)
|
|
232
|
+
if (el.parentNode) {
|
|
233
|
+
// Create clean wrapper element (preserve tag type: component or div.component)
|
|
234
|
+
const newWrapper =
|
|
235
|
+
el.tagName === 'DIV'
|
|
236
|
+
? document.createElement('div')
|
|
237
|
+
: document.createElement('component');
|
|
238
|
+
|
|
239
|
+
if (el.tagName === 'DIV') {
|
|
240
|
+
newWrapper.className = 'component';
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
newWrapper.innerHTML = transformedHtml;
|
|
244
|
+
el.replaceWith(newWrapper);
|
|
245
|
+
debugLog(PHASE_FETCH, src, debug);
|
|
246
|
+
|
|
247
|
+
// MutationObserver handles parsing and hydrating the new content.
|
|
248
|
+
// Branch nodes are registered in the manifest by mountBranch,
|
|
249
|
+
// so the observer can find parents even inside conditional branches.
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
// If any scripts had async imports, wait for them before finalizing.
|
|
254
|
+
// Otherwise finalize synchronously (preserves original boot timing).
|
|
255
|
+
if (asyncTasks.length > 0) {
|
|
256
|
+
return Promise.all(asyncTasks).then(finalize);
|
|
218
257
|
}
|
|
258
|
+
finalize();
|
|
219
259
|
})
|
|
220
260
|
.catch((error) => {
|
|
221
261
|
// Clean up pending fetch tracker
|
|
@@ -230,6 +270,34 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
|
|
|
230
270
|
if (el.parentNode) {
|
|
231
271
|
el.remove();
|
|
232
272
|
}
|
|
233
|
-
// Don't recursively call - let MutationObserver handle it
|
|
234
273
|
});
|
|
235
274
|
};
|
|
275
|
+
|
|
276
|
+
export const processComponent = (rootElement, onComplete, config = {}) => {
|
|
277
|
+
const debug = !!config?.debug;
|
|
278
|
+
const allComponents = rootElement.querySelectorAll('component[src], div.component[src]');
|
|
279
|
+
|
|
280
|
+
if (allComponents.length === 0) {
|
|
281
|
+
if (onComplete) queueMicrotask(() => onComplete());
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Only process top-level components — skip those nested inside other
|
|
286
|
+
// unprocessed component[src] elements (they're slot content that will
|
|
287
|
+
// be revealed when the parent component finalizes).
|
|
288
|
+
const topLevel = Array.from(allComponents).filter(
|
|
289
|
+
el => !isNestedInUnprocessedComponent(el, rootElement)
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
if (topLevel.length === 0) {
|
|
293
|
+
if (onComplete) queueMicrotask(() => onComplete());
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Fetch and process all top-level components in parallel.
|
|
298
|
+
// Nested components (inside finalized content) are discovered and
|
|
299
|
+
// processed by the MutationObserver → processComponent chain.
|
|
300
|
+
// onComplete is handled by checkCleanup (which fires when no
|
|
301
|
+
// component[src] elements remain).
|
|
302
|
+
topLevel.forEach(el => processSingle(el, debug));
|
|
303
|
+
};
|
package/runtime/conditionals.js
CHANGED
|
@@ -4,6 +4,50 @@ import hydrate from './hydrate.js';
|
|
|
4
4
|
import { createScopedState, renderAllIterations, initializeBlock } from './iterate.js';
|
|
5
5
|
import { evalInScope } from './utils.js';
|
|
6
6
|
|
|
7
|
+
// Registry of DOM nodes owned by conditional branches.
|
|
8
|
+
// Maps a DOM node to { nodes: array_ref, index: number } so that
|
|
9
|
+
// processMutations can update the reference when processComponent
|
|
10
|
+
// replaces the node (el.replaceWith). Keeps conditional state in
|
|
11
|
+
// sync with actual DOM without sweeps or special properties.
|
|
12
|
+
export const branchNodeRegistry = new WeakMap();
|
|
13
|
+
|
|
14
|
+
// Nodes that have been processed by mountBranch or renderIteration.
|
|
15
|
+
// processMutations checks this to avoid re-processing already-handled content.
|
|
16
|
+
export const managedNodes = new WeakSet();
|
|
17
|
+
|
|
18
|
+
// Find the dot path of a conditional node in the manifest.
|
|
19
|
+
// Searches for the parent element, then appends the conditional's key.
|
|
20
|
+
const findConditionalPath = (node, manifest) => {
|
|
21
|
+
const parent = node.meta.startComment.parentNode;
|
|
22
|
+
const parentEntry = Object.entries(manifest).find(([_, el]) => el === parent);
|
|
23
|
+
if (!parentEntry) return null;
|
|
24
|
+
|
|
25
|
+
// Find which key this conditional has in the parent's children
|
|
26
|
+
// by matching the startComment reference
|
|
27
|
+
const [parentPath] = parentEntry;
|
|
28
|
+
return `${parentPath}.${node._key}`;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Register branch tree nodes in the manifest (recursive)
|
|
32
|
+
const addBranchToManifest = (tree, manifest, basePath) => {
|
|
33
|
+
if (tree.element) manifest[basePath] = tree.element;
|
|
34
|
+
if (tree.children) {
|
|
35
|
+
for (const key in tree.children) {
|
|
36
|
+
addBranchToManifest(tree.children[key], manifest, `${basePath}.${key}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Remove branch tree nodes from the manifest (recursive)
|
|
42
|
+
const removeBranchFromManifest = (tree, manifest, basePath) => {
|
|
43
|
+
delete manifest[basePath];
|
|
44
|
+
if (tree.children) {
|
|
45
|
+
for (const key in tree.children) {
|
|
46
|
+
removeBranchFromManifest(tree.children[key], manifest, `${basePath}.${key}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
7
51
|
// Evaluate conditional expression in state context
|
|
8
52
|
const evaluateCondition = (expression, state, element = null) => !!evalInScope(expression, state, element);
|
|
9
53
|
|
|
@@ -96,12 +140,12 @@ const mountBranch = (node, branchData, state, manifest, parentScope) => {
|
|
|
96
140
|
|
|
97
141
|
// If branch doesn't exist (no else clause), just unmount current
|
|
98
142
|
if (!branchData) {
|
|
99
|
-
unmountBranch(node);
|
|
143
|
+
unmountBranch(node, manifest);
|
|
100
144
|
return;
|
|
101
145
|
}
|
|
102
146
|
|
|
103
147
|
// Unmount current branch first (if any)
|
|
104
|
-
unmountBranch(node);
|
|
148
|
+
unmountBranch(node, manifest);
|
|
105
149
|
|
|
106
150
|
// Clone the template element
|
|
107
151
|
const templateContent = branchData.element.childNodes;
|
|
@@ -118,8 +162,29 @@ const mountBranch = (node, branchData, state, manifest, parentScope) => {
|
|
|
118
162
|
clonedNodes,
|
|
119
163
|
} = initializeBlock(templateContent, scopedState);
|
|
120
164
|
|
|
121
|
-
// Insert cloned nodes into DOM
|
|
122
|
-
clonedNodes.forEach((clonedNode) =>
|
|
165
|
+
// Insert cloned nodes into DOM and register in branch registry
|
|
166
|
+
clonedNodes.forEach((clonedNode, i) => {
|
|
167
|
+
parent.insertBefore(clonedNode, endComment);
|
|
168
|
+
branchNodeRegistry.set(clonedNode, { nodes: clonedNodes, index: i });
|
|
169
|
+
if (clonedNode.nodeType === 1) managedNodes.add(clonedNode);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Integrate branch tree into the conditional node's children and manifest.
|
|
173
|
+
// This makes branch content visible to the main update loop (hydrate,
|
|
174
|
+
// renderAllConditionals, renderAllIterations) and to MutationObserver
|
|
175
|
+
// (which looks up parents in the manifest).
|
|
176
|
+
if (branchTree?.children) {
|
|
177
|
+
for (const key in branchTree.children) {
|
|
178
|
+
node.children[key] = branchTree.children[key];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const condPath = findConditionalPath(node, manifest);
|
|
182
|
+
if (condPath) {
|
|
183
|
+
for (const key in branchTree.children) {
|
|
184
|
+
addBranchToManifest(branchTree.children[key], manifest, `${condPath}.${key}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
123
188
|
|
|
124
189
|
// Recursively render any nested iterations and conditionals
|
|
125
190
|
if (branchTree) {
|
|
@@ -136,13 +201,26 @@ const mountBranch = (node, branchData, state, manifest, parentScope) => {
|
|
|
136
201
|
};
|
|
137
202
|
|
|
138
203
|
// Unmount currently active branch
|
|
139
|
-
const unmountBranch = (node) => {
|
|
204
|
+
const unmountBranch = (node, manifest) => {
|
|
140
205
|
const { activeInstance } = node.runtime;
|
|
141
206
|
|
|
142
207
|
if (!activeInstance) return;
|
|
143
208
|
|
|
144
|
-
// Remove
|
|
209
|
+
// Remove branch children from the conditional node's tree and manifest
|
|
210
|
+
if (activeInstance.parsedTree?.children) {
|
|
211
|
+
const condPath = manifest ? findConditionalPath(node, manifest) : null;
|
|
212
|
+
|
|
213
|
+
for (const key in activeInstance.parsedTree.children) {
|
|
214
|
+
delete node.children[key];
|
|
215
|
+
if (condPath) {
|
|
216
|
+
removeBranchFromManifest(activeInstance.parsedTree.children[key], manifest, `${condPath}.${key}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Remove all nodes from DOM and deregister from branch registry
|
|
145
222
|
activeInstance.nodes.forEach((domNode) => {
|
|
223
|
+
branchNodeRegistry.delete(domNode);
|
|
146
224
|
if (domNode.parentNode) {
|
|
147
225
|
domNode.parentNode.removeChild(domNode);
|
|
148
226
|
}
|
|
@@ -173,7 +251,8 @@ export const updateConditional = (node, newState, oldState, manifest, parentScop
|
|
|
173
251
|
mountBranch(node, newBranchData, newState, manifest, parentScope);
|
|
174
252
|
node.runtime.activeBranch = newBranchData;
|
|
175
253
|
} else {
|
|
176
|
-
// Same branch, but state might have changed
|
|
254
|
+
// Same branch, but state might have changed — rehydrate bindings
|
|
255
|
+
// and update nested conditionals/iterations
|
|
177
256
|
const { activeInstance } = node.runtime;
|
|
178
257
|
|
|
179
258
|
if (activeInstance && activeInstance.parsedTree) {
|
|
@@ -182,6 +261,8 @@ export const updateConditional = (node, newState, oldState, manifest, parentScop
|
|
|
182
261
|
|
|
183
262
|
const affectedElements = affected(activeInstance.parsedTree, oldState, scopedState);
|
|
184
263
|
hydrate(affectedElements, scopedState);
|
|
264
|
+
renderAllConditionals(activeInstance.parsedTree, scopedState, manifest, parentScope);
|
|
265
|
+
renderAllIterations(activeInstance.parsedTree, scopedState, manifest, parentScope);
|
|
185
266
|
}
|
|
186
267
|
}
|
|
187
268
|
};
|
package/runtime/constants.js
CHANGED
|
@@ -202,15 +202,17 @@ export const DOM_ELEMENT_PROPERTIES = new Set([
|
|
|
202
202
|
]);
|
|
203
203
|
|
|
204
204
|
// Regex for matching reactive bindings (@[expression])
|
|
205
|
-
// Supports
|
|
206
|
-
|
|
205
|
+
// Supports nested brackets, single-quoted and double-quoted strings inside expressions:
|
|
206
|
+
// @[items[0]], @[obj[key]], @[x.replace('.png', '-mugshot.png')]
|
|
207
|
+
const BINDING_INNER = String.raw`(?:[^\[\]'"]|\[[^\]]*\]|'[^']*'|"[^"]*")+`;
|
|
208
|
+
export const BINDING_REGEX = new RegExp(String.raw`\@\[(${BINDING_INNER})\]`, 'g');
|
|
207
209
|
|
|
208
210
|
// Regex for detecting a pure binding (entire value is just @[expression])
|
|
209
|
-
export const PURE_BINDING_REGEX =
|
|
211
|
+
export const PURE_BINDING_REGEX = new RegExp(String.raw`^\@\[(${BINDING_INNER})\]$`);
|
|
210
212
|
|
|
211
213
|
// Regex for parsing iteration comment syntax (<!-- each items as item, index -->)
|
|
212
|
-
// Supports nested paths like category.items
|
|
213
|
-
export const ITERATION_REGEX = /^each\s+([\w
|
|
214
|
+
// Supports nested paths like category.items and bracket notation like teams[0].combatants
|
|
215
|
+
export const ITERATION_REGEX = /^each\s+([\w.\[\]]+)\s+as\s+(\w+)(?:\s*,\s*(\w+))?\s*$/;
|
|
214
216
|
|
|
215
217
|
// Regex for detecting start of iteration comment
|
|
216
218
|
export const ITERATION_START_REGEX = /^each\s+/;
|