@ape-egg/vibe 1.9.0 → 1.9.5
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 +71 -0
- package/README.md +90 -97
- package/ROADMAP.md +6 -12
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/compiler/PRE-RENDERING-IMPLEMENTATION.md +1 -1
- package/compiler/src/compiler/compile.rs +66 -6
- package/compiler/src/parser/html.rs +7 -1
- package/llms.txt +175 -83
- package/package.json +2 -1
- package/runtime/affected.js +141 -52
- package/runtime/component.js +302 -148
- package/runtime/conditionals.js +45 -3
- package/runtime/constants.js +24 -5
- package/runtime/hydrate.js +35 -21
- package/runtime/index.js +50 -3
- package/runtime/iterate.js +598 -56
- package/runtime/iteration-utils.js +9 -2
- package/runtime/loop-scope.js +157 -0
- package/runtime/parse.js +95 -20
- package/runtime/pre-compiled-iterations.js +12 -0
- package/runtime/state.js +18 -1
- package/runtime/utils.js +39 -2
package/runtime/component.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { debugLog } from './debug.js';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
PHASE_FETCH,
|
|
4
|
+
DEHYDRATE_CLASS_OR_ATTR,
|
|
5
|
+
BINDING_REGEX,
|
|
6
|
+
THIS_PROP_REGEX,
|
|
7
|
+
STATE_THIS_PROP_REGEX,
|
|
8
|
+
} from './constants.js';
|
|
3
9
|
import { evalInScope } from './utils.js';
|
|
4
10
|
|
|
5
11
|
// Deterministic component counter
|
|
@@ -35,9 +41,49 @@ export const releaseOrphanedComponentState = (collectedIds) => {
|
|
|
35
41
|
delete window.__vibeComponents?.[id];
|
|
36
42
|
// CLEANUP OF CURRENT STATE
|
|
37
43
|
delete window.$[id];
|
|
44
|
+
runComponentCleanups(id);
|
|
38
45
|
}
|
|
39
46
|
};
|
|
40
47
|
|
|
48
|
+
// Listener registry: maps componentId → array of unsubscribe callbacks
|
|
49
|
+
// returned from `$.on(...)` calls inside the component's `<script>`.
|
|
50
|
+
// Re-running a script for the same id (HMR remount with reused ids) or
|
|
51
|
+
// unmounting the component fires the callbacks so the previous evaluation's
|
|
52
|
+
// listeners don't accumulate alongside fresh registrations.
|
|
53
|
+
export const runComponentCleanups = (componentId) => {
|
|
54
|
+
const cleanups = window.__vibeComponentCleanups?.[componentId];
|
|
55
|
+
if (!cleanups) return;
|
|
56
|
+
for (let i = 0; i < cleanups.length; i++) cleanups[i]();
|
|
57
|
+
delete window.__vibeComponentCleanups[componentId];
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Per-script `$` Proxy. Bare `$` references in a component's <script> resolve
|
|
61
|
+
// to this Proxy (the function parameter shadows the global), so every
|
|
62
|
+
// `$.on(...)` registration is automatically attributed to `componentId` via
|
|
63
|
+
// the closure — no global flag, async-safe across `await` boundaries because
|
|
64
|
+
// the closure binds the id, not a shared variable.
|
|
65
|
+
const createScopedDollar = (componentId) => {
|
|
66
|
+
const dollar = window.$;
|
|
67
|
+
if (!dollar) return dollar;
|
|
68
|
+
return new Proxy(dollar, {
|
|
69
|
+
get(target, prop, receiver) {
|
|
70
|
+
if (prop === 'on') {
|
|
71
|
+
return (event, callback) => {
|
|
72
|
+
const unsub = target.on(event, callback);
|
|
73
|
+
if (!window.__vibeComponentCleanups) window.__vibeComponentCleanups = {};
|
|
74
|
+
const slot = window.__vibeComponentCleanups[componentId] || (window.__vibeComponentCleanups[componentId] = []);
|
|
75
|
+
slot.push(unsub);
|
|
76
|
+
return unsub;
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return Reflect.get(target, prop, receiver);
|
|
80
|
+
},
|
|
81
|
+
set(target, prop, value, receiver) {
|
|
82
|
+
return Reflect.set(target, prop, value, receiver);
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
|
|
41
87
|
// Walk a node subtree (element or node list) and collect all
|
|
42
88
|
// `data-vibe-component-id` values found on the node and its descendants.
|
|
43
89
|
export const collectComponentIds = (node, into = new Set()) => {
|
|
@@ -79,6 +125,168 @@ const isNestedInUnprocessedComponent = (el, rootElement) => {
|
|
|
79
125
|
return false;
|
|
80
126
|
};
|
|
81
127
|
|
|
128
|
+
// Rewrite `@[this.x...]` and `$.this.x...` inside element's text/attrs to use
|
|
129
|
+
// componentId. Shared between script-execution path and pure-render path.
|
|
130
|
+
//
|
|
131
|
+
// Within `@[...]` bindings we rewrite every `this.X` reference (preserving
|
|
132
|
+
// any trailing `.Y.Z` chain), so `@[this.user.name]` and `@[this.x + 1]` both
|
|
133
|
+
// resolve correctly. Outside bindings — i.e. event handler attribute bodies
|
|
134
|
+
// like `onclick="$.this.mode = 'edit'"` — only `$.this.X` writes are
|
|
135
|
+
// rewritten; bare `this.X` reads in event handlers are handled later by
|
|
136
|
+
// parse.js (which preserves DOM properties like `this.value`).
|
|
137
|
+
const rewriteBindingsInString = (str, componentId) =>
|
|
138
|
+
str.replace(BINDING_REGEX, (match, expr) => {
|
|
139
|
+
const rewritten = expr.replace(THIS_PROP_REGEX, `${componentId}.$1`);
|
|
140
|
+
return rewritten === expr ? match : `@[${rewritten}]`;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const rewriteThisBindings = (element, componentId) => {
|
|
144
|
+
Array.from(element.childNodes).forEach((node) => {
|
|
145
|
+
if (node.nodeType === Node.TEXT_NODE && node.textContent.includes('@[this.')) {
|
|
146
|
+
node.textContent = rewriteBindingsInString(node.textContent, componentId);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
Array.from(element.attributes || []).forEach((attr) => {
|
|
151
|
+
if (attr.value.includes('@[this.')) {
|
|
152
|
+
attr.value = rewriteBindingsInString(attr.value, componentId);
|
|
153
|
+
}
|
|
154
|
+
if (attr.value.includes('$.this.')) {
|
|
155
|
+
attr.value = attr.value.replace(STATE_THIS_PROP_REGEX, `$.${componentId}.$1`);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
Array.from(element.children).forEach((child) => {
|
|
160
|
+
rewriteThisBindings(child, componentId);
|
|
161
|
+
});
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// Tag siblings of a <script> with componentId and rewrite `this.` bindings.
|
|
165
|
+
// Runs BEFORE script.remove() so nextElementSibling is valid. Shared by
|
|
166
|
+
// processSingle (executing path) and renderComponentTemplate (pure path).
|
|
167
|
+
const tagScriptSiblings = (script, componentId) => {
|
|
168
|
+
let sibling = script.nextElementSibling;
|
|
169
|
+
while (sibling) {
|
|
170
|
+
if (sibling.tagName === 'SCRIPT' && sibling.getAttribute('type') === 'module') {
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
sibling.setAttribute('data-vibe-component-id', componentId);
|
|
174
|
+
rewriteThisBindings(sibling, componentId);
|
|
175
|
+
sibling = sibling.nextElementSibling;
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// Substitute props + inline slot content into a pre-processed temp container.
|
|
180
|
+
// Returns the final processed HTML string. Shared by processSingle.finalize()
|
|
181
|
+
// (runtime mount) and renderComponentTemplate (surgical HMR).
|
|
182
|
+
const renderPropsAndSlot = (temp, props, slotHtml) => {
|
|
183
|
+
let transformedHtml = temp.innerHTML;
|
|
184
|
+
|
|
185
|
+
const DIRECTIVE_COMMENT_REGEX = /<!--\s*(if|else if|each)\s+([^]*?)\s*-->/g;
|
|
186
|
+
const EVENT_ATTR_REGEX = /\bon(\w+)="([^"]*)"/g;
|
|
187
|
+
|
|
188
|
+
// HTML's parser lowercases attribute names, so a consumer-written
|
|
189
|
+
// `<component dndDisabled>` arrives here with propName `dnddisabled` while
|
|
190
|
+
// the component template author wrote `dndDisabled`. Match identifiers and
|
|
191
|
+
// bindings case-insensitively so both sides line up. Word-boundary
|
|
192
|
+
// lookbehind/lookahead still hold (they're case-agnostic), so
|
|
193
|
+
// `dnddisabled` won't bleed into `dndDisabledAlt`.
|
|
194
|
+
Object.entries(props).forEach(([propName, propValue]) => {
|
|
195
|
+
const bindingMatch = propValue.match(/^@\[(.+)\]$/);
|
|
196
|
+
const exactPattern = new RegExp(`@\\[${escapeRegex(propName)}\\]`, 'gi');
|
|
197
|
+
const idRegex = new RegExp(
|
|
198
|
+
`(?<![a-zA-Z0-9_$\\.])${escapeRegex(propName)}(?![a-zA-Z0-9_$])`,
|
|
199
|
+
'gi'
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const substituteInExpr = (expr, replacement) => expr.replace(idRegex, replacement);
|
|
203
|
+
|
|
204
|
+
if (bindingMatch) {
|
|
205
|
+
const path = bindingMatch[1];
|
|
206
|
+
transformedHtml = transformedHtml.replace(exactPattern, `@[${path}]`);
|
|
207
|
+
transformedHtml = transformedHtml.replace(BINDING_REGEX, (match, expr) => {
|
|
208
|
+
const rewritten = substituteInExpr(expr, `(${path})`);
|
|
209
|
+
return rewritten === expr ? match : `@[${rewritten}]`;
|
|
210
|
+
});
|
|
211
|
+
transformedHtml = transformedHtml.replace(
|
|
212
|
+
DIRECTIVE_COMMENT_REGEX,
|
|
213
|
+
(match, kw, expr) => {
|
|
214
|
+
const replacement = kw === 'each' ? path : `(${path})`;
|
|
215
|
+
const rewritten = substituteInExpr(expr, replacement);
|
|
216
|
+
return rewritten === expr ? match : `<!-- ${kw} ${rewritten} -->`;
|
|
217
|
+
}
|
|
218
|
+
);
|
|
219
|
+
const stateRegex = new RegExp(`\\$\\.${escapeRegex(propName)}(?![a-zA-Z0-9_$])`, 'gi');
|
|
220
|
+
transformedHtml = transformedHtml.replace(EVENT_ATTR_REGEX, (match, evName, body) => {
|
|
221
|
+
const rewritten = body.replace(stateRegex, `$.${path}`);
|
|
222
|
+
return rewritten === body ? match : `on${evName}="${rewritten}"`;
|
|
223
|
+
});
|
|
224
|
+
} else {
|
|
225
|
+
transformedHtml = transformedHtml.replace(exactPattern, propValue);
|
|
226
|
+
const isNumeric =
|
|
227
|
+
typeof propValue === 'string' && /^-?\d+(\.\d+)?(e[+-]?\d+)?$/i.test(propValue);
|
|
228
|
+
const isBooleanAttr = propValue === '';
|
|
229
|
+
const literal = isBooleanAttr
|
|
230
|
+
? 'true'
|
|
231
|
+
: typeof propValue === 'string' && !isNumeric
|
|
232
|
+
? JSON.stringify(propValue)
|
|
233
|
+
: String(propValue);
|
|
234
|
+
transformedHtml = transformedHtml.replace(BINDING_REGEX, (match, expr) => {
|
|
235
|
+
const rewritten = substituteInExpr(expr, literal);
|
|
236
|
+
return rewritten === expr ? match : `@[${rewritten}]`;
|
|
237
|
+
});
|
|
238
|
+
transformedHtml = transformedHtml.replace(
|
|
239
|
+
DIRECTIVE_COMMENT_REGEX,
|
|
240
|
+
(match, kw, expr) => {
|
|
241
|
+
const rewritten = substituteInExpr(expr, literal);
|
|
242
|
+
return rewritten === expr ? match : `<!-- ${kw} ${rewritten} -->`;
|
|
243
|
+
}
|
|
244
|
+
);
|
|
245
|
+
const stateRegex = new RegExp(`\\$\\.${escapeRegex(propName)}(?![a-zA-Z0-9_$])`, 'gi');
|
|
246
|
+
transformedHtml = transformedHtml.replace(EVENT_ATTR_REGEX, (match, evName, body) => {
|
|
247
|
+
const rewritten = body.replace(stateRegex, `$.${literal}`);
|
|
248
|
+
return rewritten === body ? match : `on${evName}="${rewritten}"`;
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const children = (slotHtml || '').trim();
|
|
254
|
+
if (children) {
|
|
255
|
+
transformedHtml = transformedHtml.replace(
|
|
256
|
+
/<slot(\s[^>]*)?>\s*<\/slot>/g,
|
|
257
|
+
(_, attrs) => `<slot${attrs || ''}>${children}</slot>`
|
|
258
|
+
);
|
|
259
|
+
transformedHtml = transformedHtml.replace(
|
|
260
|
+
/<slot(\s[^>]*)?\/>/g,
|
|
261
|
+
(_, attrs) => `<slot${attrs || ''}>${children}</slot>`
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return transformedHtml;
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
// Pure-render path for surgical HMR. Takes a component's raw template HTML
|
|
269
|
+
// plus the live instance's props, slot, and existing componentIds. Returns
|
|
270
|
+
// the processed HTML string that $.reconcile can diff against the live
|
|
271
|
+
// wrapper's children. Scripts are NOT executed — callers are responsible
|
|
272
|
+
// for deciding whether to preserve the existing state (reuse componentIds)
|
|
273
|
+
// or trigger a full re-mount (new componentIds).
|
|
274
|
+
export const renderComponentTemplate = (rawHtml, props = {}, slotHtml = '', options = {}) => {
|
|
275
|
+
const { componentIds = [] } = options;
|
|
276
|
+
const temp = document.createElement('div');
|
|
277
|
+
temp.innerHTML = rawHtml;
|
|
278
|
+
|
|
279
|
+
const idsToReuse = [...componentIds];
|
|
280
|
+
const moduleScripts = temp.querySelectorAll('script[type="module"]');
|
|
281
|
+
for (const script of moduleScripts) {
|
|
282
|
+
const componentId = idsToReuse.shift() || generateComponentId();
|
|
283
|
+
tagScriptSiblings(script, componentId);
|
|
284
|
+
script.remove();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return renderPropsAndSlot(temp, props, slotHtml);
|
|
288
|
+
};
|
|
289
|
+
|
|
82
290
|
// Process a single component element: fetch HTML, execute scripts, replace DOM
|
|
83
291
|
const processSingle = (el, debug) => {
|
|
84
292
|
// Skip dehydrated components
|
|
@@ -119,6 +327,21 @@ const processSingle = (el, debug) => {
|
|
|
119
327
|
// Process each script — collect async tasks if any have imports
|
|
120
328
|
const asyncTasks = [];
|
|
121
329
|
|
|
330
|
+
// Track every componentId registered during this fetch. If the host
|
|
331
|
+
// element is detached before finalize runs (e.g. a conditional unmounted
|
|
332
|
+
// mid-fetch, or the user navigated away), we release these state
|
|
333
|
+
// buckets — otherwise component({...}) leaks state to `$` for DOM that
|
|
334
|
+
// never reaches the document.
|
|
335
|
+
const registeredComponentIds = [];
|
|
336
|
+
|
|
337
|
+
// First componentId encountered — applied to the wrapper itself so
|
|
338
|
+
// directives living between top-level sibling roots (e.g. a comment
|
|
339
|
+
// marker for `<!-- if this.X -->`) can resolve component scope via
|
|
340
|
+
// closest('[data-vibe-component-id]'). Without this, multi-root
|
|
341
|
+
// templates have scope-less wrappers and top-level `this.` references
|
|
342
|
+
// fall through to global state.
|
|
343
|
+
let firstComponentId = null;
|
|
344
|
+
|
|
122
345
|
for (const script of moduleScripts) {
|
|
123
346
|
let scriptContent = script.textContent?.trim() || '';
|
|
124
347
|
if (!scriptContent) continue;
|
|
@@ -135,7 +358,11 @@ const processSingle = (el, debug) => {
|
|
|
135
358
|
|
|
136
359
|
if (hasImports) {
|
|
137
360
|
// Rewrite remaining imports to dynamic await import()
|
|
138
|
-
// Order matters: default → named → namespace → side-effect (most specific first)
|
|
361
|
+
// Order matters: combined → default → named → namespace → side-effect (most specific first)
|
|
362
|
+
scriptContent = scriptContent.replace(
|
|
363
|
+
/import\s+(\w+)\s*,\s*\{([^}]+)\}\s+from\s+(['"][^'"]+['"])\s*;?/g,
|
|
364
|
+
'const __m_$1 = await import($3); const $1 = __m_$1.default; const {$2} = __m_$1;'
|
|
365
|
+
);
|
|
139
366
|
scriptContent = scriptContent.replace(
|
|
140
367
|
/import\s+(\w+)\s+from\s+(['"][^'"]+['"])\s*;?/g,
|
|
141
368
|
'const $1 = (await import($2)).default;'
|
|
@@ -158,6 +385,8 @@ const processSingle = (el, debug) => {
|
|
|
158
385
|
const reuseIds = el._vibeReuseComponentIds;
|
|
159
386
|
const componentId = reuseIds && reuseIds.length ? reuseIds.shift() : generateComponentId();
|
|
160
387
|
|
|
388
|
+
if (firstComponentId === null) firstComponentId = componentId;
|
|
389
|
+
|
|
161
390
|
// Provide a component() function that registers state for this component.
|
|
162
391
|
// Sibling tagging is handled below (before script.remove()) so it works
|
|
163
392
|
// for both sync and async scripts.
|
|
@@ -165,66 +394,43 @@ const processSingle = (el, debug) => {
|
|
|
165
394
|
if (!window.__vibeComponents) window.__vibeComponents = {};
|
|
166
395
|
window.__vibeComponents[componentId] = state;
|
|
167
396
|
if (window.$) window.$[componentId] = state;
|
|
397
|
+
if (!registeredComponentIds.includes(componentId)) {
|
|
398
|
+
registeredComponentIds.push(componentId);
|
|
399
|
+
}
|
|
168
400
|
};
|
|
169
401
|
|
|
402
|
+
// Re-running the script for a reused componentId (HMR remount) must
|
|
403
|
+
// tear down listeners from the previous evaluation before the fresh
|
|
404
|
+
// script registers new ones. Without this, every cycle stacks another
|
|
405
|
+
// listener on top of the stale closures and a single reactive tick
|
|
406
|
+
// fires N callbacks instead of one.
|
|
407
|
+
runComponentCleanups(componentId);
|
|
408
|
+
|
|
409
|
+
// Provide a per-script `$` whose `.on(...)` registers cleanups under
|
|
410
|
+
// this componentId. Bare `$` in the script body resolves to this
|
|
411
|
+
// Proxy (the function parameter shadows the global), so listener
|
|
412
|
+
// registrations are auto-tracked across `await` boundaries via the
|
|
413
|
+
// closure — no opt-in required.
|
|
414
|
+
const scopedDollar = createScopedDollar(componentId);
|
|
415
|
+
|
|
170
416
|
// Execute script with component() function in scope
|
|
171
417
|
try {
|
|
172
418
|
if (hasImports) {
|
|
173
419
|
// Async execution for scripts with imports
|
|
174
420
|
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
|
175
|
-
asyncTasks.push(new AsyncFunction('component', scriptContent)(componentFn));
|
|
421
|
+
asyncTasks.push(new AsyncFunction('$', 'component', scriptContent)(scopedDollar, componentFn));
|
|
176
422
|
} else {
|
|
177
423
|
// Synchronous execution for scripts without imports (preserves boot timing)
|
|
178
|
-
const executeFn = new Function('component', scriptContent);
|
|
179
|
-
executeFn(componentFn);
|
|
424
|
+
const executeFn = new Function('$', 'component', scriptContent);
|
|
425
|
+
executeFn(scopedDollar, componentFn);
|
|
180
426
|
}
|
|
181
427
|
} catch (e) {
|
|
182
428
|
console.warn('[vibe] Failed to execute component script:', e);
|
|
183
429
|
}
|
|
184
430
|
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
const rewriteThisBindings = (element) => {
|
|
189
|
-
const bindingRegex = /@\[this\.(\w+)\]/g;
|
|
190
|
-
const writeRegex = /\$\.this\.(\w+)/g;
|
|
191
|
-
|
|
192
|
-
// Rewrite in text nodes
|
|
193
|
-
Array.from(element.childNodes).forEach((node) => {
|
|
194
|
-
if (node.nodeType === Node.TEXT_NODE && node.textContent.includes('@[this.')) {
|
|
195
|
-
node.textContent = node.textContent.replace(bindingRegex, `@[${componentId}.$1]`);
|
|
196
|
-
}
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
// Rewrite in attributes — both @[this.x] bindings and $.this.x in event handlers
|
|
200
|
-
Array.from(element.attributes || []).forEach((attr) => {
|
|
201
|
-
if (attr.value.includes('@[this.')) {
|
|
202
|
-
attr.value = attr.value.replace(bindingRegex, `@[${componentId}.$1]`);
|
|
203
|
-
}
|
|
204
|
-
if (attr.value.includes('$.this.')) {
|
|
205
|
-
attr.value = attr.value.replace(writeRegex, `$.${componentId}.$1`);
|
|
206
|
-
}
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
// Recurse into children
|
|
210
|
-
Array.from(element.children).forEach((child) => {
|
|
211
|
-
rewriteThisBindings(child);
|
|
212
|
-
});
|
|
213
|
-
};
|
|
214
|
-
|
|
215
|
-
// Tag siblings with componentId and rewrite this. bindings.
|
|
216
|
-
// This runs BEFORE script.remove() so nextElementSibling is valid.
|
|
217
|
-
// For async scripts, componentFn runs later (after removal) and can't
|
|
218
|
-
// find siblings — so we tag here instead.
|
|
219
|
-
let sibling = script.nextElementSibling;
|
|
220
|
-
while (sibling) {
|
|
221
|
-
if (sibling.tagName === 'SCRIPT' && sibling.getAttribute('type') === 'module') {
|
|
222
|
-
break;
|
|
223
|
-
}
|
|
224
|
-
sibling.setAttribute('data-vibe-component-id', componentId);
|
|
225
|
-
rewriteThisBindings(sibling);
|
|
226
|
-
sibling = sibling.nextElementSibling;
|
|
227
|
-
}
|
|
431
|
+
// Tag siblings + rewrite this. bindings using shared helper. Runs
|
|
432
|
+
// BEFORE script.remove() so nextElementSibling is valid.
|
|
433
|
+
tagScriptSiblings(script, componentId);
|
|
228
434
|
|
|
229
435
|
// Remove script from temp (we executed it manually)
|
|
230
436
|
script.remove();
|
|
@@ -232,106 +438,8 @@ const processSingle = (el, debug) => {
|
|
|
232
438
|
|
|
233
439
|
// Finalize: props, slots, DOM replacement
|
|
234
440
|
const finalize = () => {
|
|
235
|
-
//
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
// Replace props — both exact @[propName] bindings and identifiers
|
|
239
|
-
// appearing inside larger expressions like @[Math.floor(propName / 100)]
|
|
240
|
-
// or conditional/iteration comment expressions like <!-- if propName -->.
|
|
241
|
-
// Matches any <!-- if ... -->, <!-- else if ... -->, or <!-- each ... -->
|
|
242
|
-
// so prop identifiers resolve there the same way they do inside @[...].
|
|
243
|
-
const DIRECTIVE_COMMENT_REGEX = /<!--\s*(if|else if|each)\s+([^]*?)\s*-->/g;
|
|
244
|
-
// Event handler attributes (onclick, oninput, onchange, …). Prop
|
|
245
|
-
// identifiers inside these are JS expressions that read/write state —
|
|
246
|
-
// substituting them gives components natural two-way binding:
|
|
247
|
-
// child writes `$.value = x`, parent passed `value="@[email]"`,
|
|
248
|
-
// substitution turns it into `$.email = x`.
|
|
249
|
-
const EVENT_ATTR_REGEX = /\bon(\w+)="([^"]*)"/g;
|
|
250
|
-
|
|
251
|
-
Object.entries(props).forEach(([propName, propValue]) => {
|
|
252
|
-
const bindingMatch = propValue.match(/^@\[(.+)\]$/);
|
|
253
|
-
const exactPattern = new RegExp(`@\\[${escapeRegex(propName)}\\]`, 'g');
|
|
254
|
-
// Word-boundary identifier match for substitution inside expressions.
|
|
255
|
-
// The lookbehind also excludes `.` so property accesses like `_c0.email`
|
|
256
|
-
// aren't double-substituted when the prop name is `email` — only
|
|
257
|
-
// standalone identifiers match, not property tails.
|
|
258
|
-
const idRegex = new RegExp(
|
|
259
|
-
`(?<![a-zA-Z0-9_$\\.])${escapeRegex(propName)}(?![a-zA-Z0-9_$])`,
|
|
260
|
-
'g'
|
|
261
|
-
);
|
|
262
|
-
|
|
263
|
-
const substituteInExpr = (expr, replacement) => expr.replace(idRegex, replacement);
|
|
264
|
-
|
|
265
|
-
if (bindingMatch) {
|
|
266
|
-
// Reactive prop: @[propName] → @[path], identifiers inside expressions → (path)
|
|
267
|
-
const path = bindingMatch[1];
|
|
268
|
-
transformedHtml = transformedHtml.replace(exactPattern, `@[${path}]`);
|
|
269
|
-
transformedHtml = transformedHtml.replace(BINDING_REGEX, (match, expr) => {
|
|
270
|
-
const rewritten = substituteInExpr(expr, `(${path})`);
|
|
271
|
-
return rewritten === expr ? match : `@[${rewritten}]`;
|
|
272
|
-
});
|
|
273
|
-
transformedHtml = transformedHtml.replace(
|
|
274
|
-
DIRECTIVE_COMMENT_REGEX,
|
|
275
|
-
(match, kw, expr) => {
|
|
276
|
-
const replacement = kw === 'each' ? path : `(${path})`;
|
|
277
|
-
const rewritten = substituteInExpr(expr, replacement);
|
|
278
|
-
return rewritten === expr ? match : `<!-- ${kw} ${rewritten} -->`;
|
|
279
|
-
}
|
|
280
|
-
);
|
|
281
|
-
// Event handlers: rewrite `$.propName` → `$.path` for two-way
|
|
282
|
-
// binding. Only targets state access (`$.xxx`) so DOM properties
|
|
283
|
-
// like `this.value` stay untouched.
|
|
284
|
-
const stateRegex = new RegExp(`\\$\\.${escapeRegex(propName)}(?![a-zA-Z0-9_$])`, 'g');
|
|
285
|
-
transformedHtml = transformedHtml.replace(EVENT_ATTR_REGEX, (match, evName, body) => {
|
|
286
|
-
const rewritten = body.replace(stateRegex, `$.${path}`);
|
|
287
|
-
return rewritten === body ? match : `on${evName}="${rewritten}"`;
|
|
288
|
-
});
|
|
289
|
-
} else {
|
|
290
|
-
// Static prop: @[propName] → literal value (raw),
|
|
291
|
-
// identifiers inside expressions → JS literal.
|
|
292
|
-
transformedHtml = transformedHtml.replace(exactPattern, propValue);
|
|
293
|
-
const isNumeric =
|
|
294
|
-
typeof propValue === 'string' && /^-?\d+(\.\d+)?(e[+-]?\d+)?$/i.test(propValue);
|
|
295
|
-
const isBooleanAttr = propValue === '';
|
|
296
|
-
const literal = isBooleanAttr
|
|
297
|
-
? 'true'
|
|
298
|
-
: typeof propValue === 'string' && !isNumeric
|
|
299
|
-
? JSON.stringify(propValue)
|
|
300
|
-
: String(propValue);
|
|
301
|
-
transformedHtml = transformedHtml.replace(BINDING_REGEX, (match, expr) => {
|
|
302
|
-
const rewritten = substituteInExpr(expr, literal);
|
|
303
|
-
return rewritten === expr ? match : `@[${rewritten}]`;
|
|
304
|
-
});
|
|
305
|
-
transformedHtml = transformedHtml.replace(
|
|
306
|
-
DIRECTIVE_COMMENT_REGEX,
|
|
307
|
-
(match, kw, expr) => {
|
|
308
|
-
const rewritten = substituteInExpr(expr, literal);
|
|
309
|
-
return rewritten === expr ? match : `<!-- ${kw} ${rewritten} -->`;
|
|
310
|
-
}
|
|
311
|
-
);
|
|
312
|
-
// Event handlers: rewrite `$.propName` → `$.literal` for static props
|
|
313
|
-
const stateRegex = new RegExp(`\\$\\.${escapeRegex(propName)}(?![a-zA-Z0-9_$])`, 'g');
|
|
314
|
-
transformedHtml = transformedHtml.replace(EVENT_ATTR_REGEX, (match, evName, body) => {
|
|
315
|
-
const rewritten = body.replace(stateRegex, `$.${literal}`);
|
|
316
|
-
return rewritten === body ? match : `on${evName}="${rewritten}"`;
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
// Replace <slot> with children wrapped in <slot> boundary.
|
|
322
|
-
// Allow attributes on the slot (e.g. data-vibe-component-id added by
|
|
323
|
-
// sibling tagging when a <slot> happens to be a direct sibling of the
|
|
324
|
-
// component's <script>).
|
|
325
|
-
if (children) {
|
|
326
|
-
transformedHtml = transformedHtml.replace(
|
|
327
|
-
/<slot(\s[^>]*)?>\s*<\/slot>/g,
|
|
328
|
-
(_, attrs) => `<slot${attrs || ''}>${children}</slot>`
|
|
329
|
-
);
|
|
330
|
-
transformedHtml = transformedHtml.replace(
|
|
331
|
-
/<slot(\s[^>]*)?\/>/g,
|
|
332
|
-
(_, attrs) => `<slot${attrs || ''}>${children}</slot>`
|
|
333
|
-
);
|
|
334
|
-
}
|
|
441
|
+
// Delegate prop substitution + slot inlining to shared helper.
|
|
442
|
+
const transformedHtml = renderPropsAndSlot(temp, props, children);
|
|
335
443
|
|
|
336
444
|
// Clean up pending fetch tracker
|
|
337
445
|
pendingFetches.delete(el);
|
|
@@ -350,12 +458,58 @@ const processSingle = (el, debug) => {
|
|
|
350
458
|
}
|
|
351
459
|
|
|
352
460
|
newWrapper.innerHTML = transformedHtml;
|
|
461
|
+
if (firstComponentId !== null) {
|
|
462
|
+
newWrapper.setAttribute('data-vibe-component-id', firstComponentId);
|
|
463
|
+
}
|
|
464
|
+
// Stash the raw source so the HMR plugin can establish a baseline
|
|
465
|
+
// script hash on the very first update — without this, the first
|
|
466
|
+
// save after page load would always fall back to re-mount (since
|
|
467
|
+
// the plugin would have nothing to compare against). Vibe itself
|
|
468
|
+
// never reads this; it's purely for the plugin spy.
|
|
469
|
+
newWrapper._vibeRawSource = html;
|
|
470
|
+
// Transfer iteration-prop registry ownership from the soon-to-be-
|
|
471
|
+
// detached `<component src>` to the new wrapper. The detached element
|
|
472
|
+
// would otherwise trigger releaseOrphanedIterationProps and free the
|
|
473
|
+
// registry slots that the inlined template's bindings still reference,
|
|
474
|
+
// causing every `@[window.__vibeIterProps._pN]` to resolve to undefined
|
|
475
|
+
// on the next hydrate.
|
|
476
|
+
if (el._vibeIterPropIds) {
|
|
477
|
+
newWrapper._vibeIterPropIds = el._vibeIterPropIds;
|
|
478
|
+
newWrapper.setAttribute('data-vibe-iter-prop', '');
|
|
479
|
+
el._vibeIterPropIds = null;
|
|
480
|
+
el.removeAttribute('data-vibe-iter-prop');
|
|
481
|
+
}
|
|
482
|
+
// Transfer the original prop expressions too, so the iteration's
|
|
483
|
+
// update path can re-evaluate them against the row's new scope and
|
|
484
|
+
// refresh the registry slots in place — letting the inlined
|
|
485
|
+
// component's bindings react without rebuilding the row's DOM.
|
|
486
|
+
if (el._vibeIterPropExprs) {
|
|
487
|
+
newWrapper._vibeIterPropExprs = el._vibeIterPropExprs;
|
|
488
|
+
el._vibeIterPropExprs = null;
|
|
489
|
+
}
|
|
490
|
+
// Back-pointer from the soon-to-be-detached `<component src>` to
|
|
491
|
+
// the new wrapper. The iteration's `instance.clonedNodes` still
|
|
492
|
+
// references the original element; follow this link to reach the
|
|
493
|
+
// live wrapper when refreshing registry slots / re-hydrating.
|
|
494
|
+
// The wrapper's `_vibeIterTree` (set later by processMutations after
|
|
495
|
+
// renderAllConditionals/Iterations populated runtime data) is what
|
|
496
|
+
// iterate.js's update path uses to re-evaluate inlined bindings on
|
|
497
|
+
// each row update.
|
|
498
|
+
el._vibeReplacedBy = newWrapper;
|
|
353
499
|
el.replaceWith(newWrapper);
|
|
354
500
|
debugLog(PHASE_FETCH, src, debug);
|
|
355
501
|
|
|
356
502
|
// MutationObserver handles parsing and hydrating the new content.
|
|
357
503
|
// Branch nodes are registered in the manifest by mountBranch,
|
|
358
504
|
// so the observer can find parents even inside conditional branches.
|
|
505
|
+
} else {
|
|
506
|
+
// Element was detached before finalize ran (conditional unmounted
|
|
507
|
+
// during fetch, parent removed, etc). Release any state component()
|
|
508
|
+
// calls registered — otherwise it leaks on `$` forever.
|
|
509
|
+
for (const id of registeredComponentIds) {
|
|
510
|
+
delete window.__vibeComponents?.[id];
|
|
511
|
+
if (window.$) delete window.$[id];
|
|
512
|
+
}
|
|
359
513
|
}
|
|
360
514
|
};
|
|
361
515
|
|
package/runtime/conditionals.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import parse from './parse.js';
|
|
2
2
|
import affected from './affected.js';
|
|
3
3
|
import hydrate from './hydrate.js';
|
|
4
|
-
import { createScopedState, renderAllIterations, initializeBlock } from './iterate.js';
|
|
4
|
+
import { createScopedState, renderAllIterations, initializeBlock, resolveIterationComponentProps } from './iterate.js';
|
|
5
5
|
import { evalInScope } from './utils.js';
|
|
6
6
|
import { collectComponentIds, releaseOrphanedComponentState } from './component.js';
|
|
7
7
|
|
|
@@ -98,6 +98,16 @@ export const renderAllConditionals = (tree, state, manifest, parentScope = {}) =
|
|
|
98
98
|
export const renderConditional = (node, state, manifest, parentScope = {}) => {
|
|
99
99
|
const { expression, startComment, endComment, branches } = node.meta;
|
|
100
100
|
|
|
101
|
+
// Mark conditionals that live inside an iteration row so update-time branch
|
|
102
|
+
// flips can route `<component src>` props through the iteration-prop
|
|
103
|
+
// registry. Skipping this for top-level conditionals keeps their props as
|
|
104
|
+
// live `@[stateKey]` bindings — which is what global state-change reactivity
|
|
105
|
+
// depends on (the registry path snapshots a value and doesn't react to
|
|
106
|
+
// global state changes on its own).
|
|
107
|
+
if (Object.keys(parentScope).length > 0) {
|
|
108
|
+
node.runtime.inIteration = true;
|
|
109
|
+
}
|
|
110
|
+
|
|
101
111
|
// Check if already rendered (using marker on comment node)
|
|
102
112
|
// @ts-ignore - adding custom property to comment node
|
|
103
113
|
if (startComment.__vibeRendered) {
|
|
@@ -158,12 +168,29 @@ const mountBranch = (node, branchData, state, manifest, parentScope) => {
|
|
|
158
168
|
const scopedState =
|
|
159
169
|
Object.keys(parentScope).length > 0 ? createScopedState(state, parentScope) : state;
|
|
160
170
|
|
|
161
|
-
// Initialize block (clone, parse, hydrate)
|
|
171
|
+
// Initialize block (clone, parse, hydrate). Pass the enclosing loop aliases so
|
|
172
|
+
// the re-parse rewrites loop-scoped handlers in this branch — including ones
|
|
173
|
+
// nested deeper in further conditionals, which the parser reaches by carrying
|
|
174
|
+
// the alias set down. `scopeAliases` is set at parse time and persists, so this
|
|
175
|
+
// works on both the initial-render and update (hydrate) mount paths.
|
|
176
|
+
const aliasSet = node.meta.scopeAliases?.length ? new Set(node.meta.scopeAliases) : undefined;
|
|
162
177
|
const {
|
|
163
178
|
element: firstElement,
|
|
164
179
|
tree: branchTree,
|
|
165
180
|
clonedNodes,
|
|
166
|
-
} = initializeBlock(templateContent, scopedState);
|
|
181
|
+
} = initializeBlock(templateContent, scopedState, null, null, aliasSet);
|
|
182
|
+
|
|
183
|
+
// For conditionals living inside an iteration, route any `<component src>`
|
|
184
|
+
// props through the iteration-prop registry against the active scopedState
|
|
185
|
+
// (which carries the iteration's local vars). Without this, processSingle
|
|
186
|
+
// would inline `@[item.x]` bindings into the component template, where
|
|
187
|
+
// `item` isn't reachable in global scope and props resolve to undefined.
|
|
188
|
+
// Skipped for top-level conditionals because their props reference live
|
|
189
|
+
// global-state bindings — going through the registry would snapshot the
|
|
190
|
+
// value and break reactivity.
|
|
191
|
+
if (node.runtime.inIteration) {
|
|
192
|
+
resolveIterationComponentProps(clonedNodes, scopedState);
|
|
193
|
+
}
|
|
167
194
|
|
|
168
195
|
// Insert cloned nodes into DOM and register in branch registry
|
|
169
196
|
clonedNodes.forEach((clonedNode, i) => {
|
|
@@ -172,6 +199,21 @@ const mountBranch = (node, branchData, state, manifest, parentScope) => {
|
|
|
172
199
|
if (clonedNode.nodeType === 1) managedNodes.add(clonedNode);
|
|
173
200
|
});
|
|
174
201
|
|
|
202
|
+
// Branch content is mounted outside the enclosing iteration instance's
|
|
203
|
+
// clonedNodes, so it doesn't inherit the instance's `__vibeScope` stamp by DOM
|
|
204
|
+
// ancestry. When this conditional lives inside a loop, stamp `scopedState` —
|
|
205
|
+
// the same scope that hydrates the branch's `@[alias.x]` bindings — onto the
|
|
206
|
+
// branch's root elements so loop-scoped `$scope(this,'alias')` handlers resolve.
|
|
207
|
+
// Gate on `scopeAliases` (set at parse time, persists) rather than the runtime
|
|
208
|
+
// parentScope/inIteration, because the update path (hydrate -> updateConditional)
|
|
209
|
+
// and deeper-nested conditionals mount with an empty parentScope yet still flow
|
|
210
|
+
// the iteration's scoped state in as `state`.
|
|
211
|
+
if (node.meta.scopeAliases?.length) {
|
|
212
|
+
for (let i = 0; i < clonedNodes.length; i++) {
|
|
213
|
+
if (clonedNodes[i].nodeType === 1) clonedNodes[i].__vibeScope = scopedState;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
175
217
|
// Integrate branch tree into the conditional node's children and manifest.
|
|
176
218
|
// This makes branch content visible to the main update loop (hydrate,
|
|
177
219
|
// renderAllConditionals, renderAllIterations) and to MutationObserver
|
package/runtime/constants.js
CHANGED
|
@@ -210,11 +210,18 @@ export const BINDING_REGEX = new RegExp(String.raw`\@\[(${BINDING_INNER})\]`, 'g
|
|
|
210
210
|
// Regex for detecting a pure binding (entire value is just @[expression])
|
|
211
211
|
export const PURE_BINDING_REGEX = new RegExp(String.raw`^\@\[(${BINDING_INNER})\]$`);
|
|
212
212
|
|
|
213
|
-
// Regex for parsing iteration comment syntax
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
|
|
213
|
+
// Regex for parsing iteration comment syntax. Supported forms:
|
|
214
|
+
// <!-- each items as item -->
|
|
215
|
+
// <!-- each items as item, index -->
|
|
216
|
+
// <!-- each items as item (item.id) --> // explicit key
|
|
217
|
+
// <!-- each items as item (item.id), index --> // key + index
|
|
218
|
+
// Capture groups: arrayPath, itemAlias, keyExpr (optional), indexAlias (optional).
|
|
219
|
+
// The array expression can be any JS: a state path, a window global, a method
|
|
220
|
+
// call, or an inline literal. The optional key expression is evaluated per
|
|
221
|
+
// item against scoped state to produce a stable identity for diffing — this
|
|
222
|
+
// keeps survivors stable when earlier items are removed (otherwise the
|
|
223
|
+
// fallback hash key embeds the index and triggers bulk re-render).
|
|
224
|
+
export const ITERATION_REGEX = /^each\s+(.+)\s+as\s+(\w+)(?:\s*\(\s*([^)]+?)\s*\))?(?:\s*,\s*(\w+))?\s*$/;
|
|
218
225
|
|
|
219
226
|
// Regex for detecting start of iteration comment
|
|
220
227
|
export const ITERATION_START_REGEX = /^each\s+/;
|
|
@@ -224,3 +231,15 @@ export const CONDITIONAL_REGEX = /^if\s+(.+)$/;
|
|
|
224
231
|
|
|
225
232
|
// Regex for detecting start of conditional comment
|
|
226
233
|
export const CONDITIONAL_START_REGEX = /^if\s+/;
|
|
234
|
+
|
|
235
|
+
// Regex for rewriting component-local `this.X` references to the component's
|
|
236
|
+
// state path. Captures the leading identifier only — any trailing `.Y.Z`
|
|
237
|
+
// chain stays attached after replacement, so `this.user.name` becomes
|
|
238
|
+
// `<componentId>.user.name`. Used in expression bodies (bindings, event
|
|
239
|
+
// handlers, conditional/iteration directives).
|
|
240
|
+
export const THIS_PROP_REGEX = /\bthis\.(\w+)/g;
|
|
241
|
+
|
|
242
|
+
// Regex for rewriting `$.this.X` writes (proxy assignment from event handlers)
|
|
243
|
+
// to the component's write path. Same prefix-only semantics as THIS_PROP_REGEX
|
|
244
|
+
// — `$.this.user.name = x` becomes `$.<componentId>.user.name = x`.
|
|
245
|
+
export const STATE_THIS_PROP_REGEX = /\$\.this\.(\w+)/g;
|