@ape-egg/vibe 4.0.1 → 4.1.3

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/runtime/parse.js CHANGED
@@ -18,17 +18,12 @@ import {
18
18
  import { rewriteHandlerAliases } from './loop-scope.js';
19
19
  import './this-scope.js';
20
20
 
21
- // Walks up the DOM for the nearest component wrapper tagged by component.js.
22
- // Used to rewrite `this.property` in event handlers to the component's state path.
23
21
  const findComponentIdForElement = (element) => {
24
22
  if (!element?.closest) return null;
25
23
  const wrapper = element.closest('[data-vibe-component-id]');
26
24
  return wrapper ? wrapper.getAttribute('data-vibe-component-id') : null;
27
25
  };
28
26
 
29
- // `key` is reserved on fetched-component wrappers and remounts on CHANGE — a
30
- // literal key can never change, so it can never fire. Warn once per value
31
- // instead of dying silently (reparses would otherwise spam).
32
27
  const warnedStaticKeys = new Set();
33
28
  const warnStaticKey = (key) => {
34
29
  if (warnedStaticKeys.has(key)) return;
@@ -38,18 +33,6 @@ const warnStaticKey = (key) => {
38
33
  );
39
34
  };
40
35
 
41
- // Single source of truth for reading attribute/name bindings off an element.
42
- // Called from both the root handler and recursive() so they can't drift. Any
43
- // element classified as a fetched component (`<component src>` or
44
- // `<div class="component" src>`) captures ONLY a bound src (`src="@[page.src]"`
45
- // — resolved by hydration before the fetch, re-mounted on change) and a bound
46
- // key (`key="@[page.path]"` — a key change remounts the same src); every other
47
- // attribute is a prop owned by processComponent and must stay raw — hydrating
48
- // them would coerce objects to "[object Object]" or strip boolean-like attrs
49
- // to empty. A mounted wrapper carries the authored bindings in data-vibe-src /
50
- // data-vibe-key (stamped by finalize — the src attribute was consumed by the
51
- // fetch), so the knowledge survives every wrapper replacement: reparsing the
52
- // live DOM alone rebuilds it.
53
36
  const captureAttributeBindings = (element, aliasSet) => {
54
37
  const nodeName = element.nodeName;
55
38
  const isFetchedComponent =
@@ -84,17 +67,11 @@ const captureAttributeBindings = (element, aliasSet) => {
84
67
  const attr = element.attributes[j];
85
68
  BINDING_REGEX.lastIndex = 0;
86
69
 
87
- // A name-binding the compiler relocated into data-vibe-namebind because its
88
- // expression has whitespace (barred from attribute-name position): the value
89
- // holds the verbatim @[expr](s). Treat it as a name binding, not a value binding.
90
70
  if (attr.name === 'data-vibe-namebind') {
91
71
  nameBindings.push(attr.value);
92
72
  continue;
93
73
  }
94
74
 
95
- // A src-family binding parked on data-vibe-<attr> (by an earlier parse of
96
- // this template, or by component finalize neutralizing fetched HTML):
97
- // bind it to the real attribute — hydration writes the evaluated URL there.
98
75
  if (isFetchableElement && attr.name.startsWith('data-vibe-')) {
99
76
  const realName = attr.name.slice(10);
100
77
  if (FETCH_SRC_ATTRS.includes(realName)) {
@@ -103,11 +80,6 @@ const captureAttributeBindings = (element, aliasSet) => {
103
80
  }
104
81
  }
105
82
 
106
- // A raw binding-valued src reaching a fetchable element fires a request
107
- // for the literal `@[...]` text (innerHTML parse and cloneNode both
108
- // trigger it). Park it on data-vibe-<attr> — after the loop, since
109
- // removeAttribute would shift the live NamedNodeMap being iterated —
110
- // so the template and every clone of it are inert until hydration.
111
83
  if (isFetchableElement && FETCH_SRC_ATTRS.includes(attr.name)) {
112
84
  BINDING_REGEX.lastIndex = 0;
113
85
  if (BINDING_REGEX.test(attr.value)) {
@@ -117,32 +89,16 @@ const captureAttributeBindings = (element, aliasSet) => {
117
89
  }
118
90
  }
119
91
 
120
- // Attribute name itself contains a binding (e.g. <icon @[section.icon]>).
121
92
  if (BINDING_REGEX.test(attr.name)) {
122
93
  nameBindings.push(attr.name);
123
94
  continue;
124
95
  }
125
96
 
126
- // Event handlers get two compile-time rewrites, computed off the original
127
- // value and written once:
128
- // 1. `this.` inside a component scope → `$this(this).` — resolved at
129
- // FIRE time by this-scope.js: declared state keys hit the bucket,
130
- // everything else stays the native element. Fire-time resolution is
131
- // load-bearing: compiled pages register component state after the
132
- // initial parse, so no key check is possible here.
133
- // 2. bare loop-variable aliases → `$scope(this,'alias')` (loop-scoped
134
- // handlers — only when an enclosing <!-- each --> alias is in scope).
135
97
  if (attr.name.startsWith('on')) {
136
98
  let v = attr.value;
137
99
  if (v.includes('this.')) {
138
100
  const componentId = findComponentIdForElement(element);
139
101
  if (componentId) {
140
- // `$.this.X` (component-state write through the root) must be
141
- // consumed as one reference BEFORE the bare `this.X` pass — that
142
- // pass alone would leave the `$.` prefix behind and produce
143
- // `$.$this(this).X`. Runtime-fetched components arrive with this
144
- // form already rewritten by component.js; compiled pages inline the
145
- // authored form, so parse meets it raw.
146
102
  v = v.replace(STATE_THIS_PROP_REGEX, (_, prop) => `$['${componentId}'].${prop}`);
147
103
  v = v.replace(THIS_PROP_REGEX, (_, prop) => `$this(this).${prop}`);
148
104
  }
@@ -172,10 +128,6 @@ const captureAttributeBindings = (element, aliasSet) => {
172
128
  };
173
129
  };
174
130
 
175
- // Walk a parsed children map for any iteration node carrying scoped handlers.
176
- // Each iteration node's own flag already aggregates its descendants, so we take
177
- // the flag without re-descending into its template; we still recurse through
178
- // elements and conditional branches to reach nested iteration nodes.
179
131
  const subtreeHasScopedHandlers = (nodes) => {
180
132
  for (const key in nodes) {
181
133
  const node = nodes[key];
@@ -211,30 +163,21 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
211
163
  let result = {};
212
164
 
213
165
  for (let i = 0; i < children.length; i++) {
214
- // Skip if this index was part of an iteration template
215
166
  if (skipIndices.has(i)) continue;
216
167
 
217
168
  const element = children[i];
218
169
  const { nodeName, childNodes, outerHTML, innerHTML, textContent } = element;
219
170
 
220
- // Skip non-reactive elements and dehydrated elements
221
171
  if (NON_REACTIVE_ELEMENTS.includes(nodeName)) continue;
222
172
  if (element.hasAttribute?.(DEHYDRATE_CLASS_OR_ATTR) || element.classList?.contains(DEHYDRATE_CLASS_OR_ATTR)) {
223
173
  stats.skipped++;
224
174
  continue;
225
175
  }
226
176
 
227
- // Handle iteration comments
228
177
  if (nodeName === '#comment') {
229
178
  const trimmedComment = textContent.trim();
230
179
  const iterationMatch = parseIterationHeader(trimmedComment);
231
180
 
232
- // An each-shaped comment that fails the grammar, WITH a matching /each,
233
- // is an authored directive with a syntax error — code, not prose. It
234
- // used to render its body once, silently: the framework's worst
235
- // footgun. Directives scream like a SyntaxError in every mode. (A
236
- // prose comment merely starting with "each", no /each below, stays an
237
- // inert comment.)
238
181
  if (
239
182
  !iterationMatch &&
240
183
  ITERATION_START_REGEX.test(trimmedComment) &&
@@ -246,8 +189,6 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
246
189
  );
247
190
  }
248
191
 
249
- // A bare <!-- if --> / <!-- else if --> (no expression) with a
250
- // matching /if ahead is the same class of authored error.
251
192
  if (/^if$/.test(trimmedComment) || /^else\s+if$/.test(trimmedComment)) {
252
193
  try {
253
194
  findConditionalEnd(children, i + 1);
@@ -256,7 +197,6 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
256
197
  'Expected <!-- if condition --> ... <!-- else if condition --> ... <!-- /if -->',
257
198
  );
258
199
  } catch {
259
- // No /if below — an inert comment.
260
200
  }
261
201
  }
262
202
 
@@ -264,49 +204,27 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
264
204
  const { arrayPath, itemAlias, keyExpr, indexAlias } = iterationMatch;
265
205
 
266
206
  try {
267
- // Find matching end comment
268
207
  const endIndex = findEndComment(children, i + 1);
269
208
 
270
- // Extract template nodes (between start and end comments)
271
209
  const templateNodes = Array.from(children).slice(i + 1, endIndex);
272
210
 
273
- // Create a temporary container for the template
274
211
  const templateContainer = document.createElement('div');
275
212
  templateNodes.forEach((node) => {
276
213
  templateContainer.appendChild(node.cloneNode(true));
277
214
  });
278
215
 
279
- // Aliases in scope inside this loop's template = enclosing aliases plus
280
- // this loop's item alias and (only when explicitly declared) its index
281
- // alias. The implicit default index name is never auto-rewritten.
282
216
  const childAliases = new Set(aliasSet);
283
217
  childAliases.add(itemAlias);
284
218
  if (indexAlias) childAliases.add(indexAlias);
285
219
 
286
- // Parse the template recursively
287
220
  const templateParsed = recursive([...templateContainer.childNodes], undefined, new Set(), stats, childAliases);
288
221
 
289
- // A handler rewritten to `$scope(this,'alias')` only exists in this
290
- // freshly-parsed runtime template — the manifest's compiled batchFn was
291
- // generated from the original (unrewritten) template, so it must be
292
- // bypassed for this iteration (see canUseCompiled). True when a direct
293
- // handler in this template was rewritten, OR a nested iteration carries
294
- // scoped handlers (its inlined batchFn would be wrong too). The runtime
295
- // clone / batch / diff paths all read the rewritten template correctly.
296
222
  const hasScopedHandlers =
297
223
  (childAliases.size > 0 && templateContainer.innerHTML.includes("$scope(this,")) ||
298
224
  subtreeHasScopedHandlers(templateParsed);
299
225
 
300
- // All aliases in scope inside this loop (enclosing + this loop's own) —
301
- // passed back to parse() by iterate.js when it re-parses a cloned
302
- // instance. Must be the ACCUMULATED set, not just this loop's own: a
303
- // handler nested in a further if/each inside the loop can reference an
304
- // outer alias that wasn't rewritten in this loop's own template (the
305
- // inner structure's branch was extracted to a separate container), so
306
- // the re-parse needs every enclosing alias to rewrite it.
307
226
  const scopeAliases = [...childAliases];
308
227
 
309
- // Store iteration metadata (use index for deterministic keys)
310
228
  const iterationKey = `iteration_${i}`;
311
229
  const iterationNode = {
312
230
  type: 'iteration',
@@ -325,9 +243,6 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
325
243
  children: templateParsed,
326
244
  },
327
245
  },
328
- // Preserve runtime data from previous parse if it exists (stored on startComment by iterate.js)
329
- // Only restore if the comment node is still connected to the DOM (not replaced by component loading)
330
- // @ts-ignore - custom property added by iterate.js
331
246
  runtime: (element.isConnected && element.__vibeIterationRuntime) || {
332
247
  instances: [],
333
248
  templateRemoved: false,
@@ -336,13 +251,11 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
336
251
  };
337
252
  result[iterationKey] = iterationNode;
338
253
 
339
- // Mark template indices as processed
340
254
  for (let j = i + 1; j < endIndex; j++) {
341
255
  skipIndices.add(j);
342
256
  }
343
- skipIndices.add(endIndex); // Also skip the end comment
257
+ skipIndices.add(endIndex);
344
258
 
345
- // Skip past this iteration block
346
259
  i = endIndex;
347
260
  continue;
348
261
  } catch (e) {
@@ -351,37 +264,24 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
351
264
  }
352
265
  }
353
266
 
354
- // Handle conditional comments
355
267
  const conditionalMatch = textContent.trim().match(CONDITIONAL_REGEX);
356
268
 
357
269
  if (conditionalMatch) {
358
270
  const [_, expression] = conditionalMatch;
359
271
 
360
272
  try {
361
- // Find matching end comment and optional else / else-if boundary
362
273
  const { elseIndex, endIndex, elseText } = findConditionalEnd(children, i + 1);
363
274
 
364
- // Extract true branch nodes (between if and else/endif)
365
275
  const trueBranchEnd = elseIndex !== null ? elseIndex : endIndex;
366
276
  const trueBranchNodes = Array.from(children).slice(i + 1, trueBranchEnd);
367
277
 
368
- // Create temporary container for true branch
369
278
  const trueBranchContainer = document.createElement('div');
370
279
  trueBranchNodes.forEach((node) => {
371
280
  trueBranchContainer.appendChild(node.cloneNode(true));
372
281
  });
373
282
 
374
- // Parse the true branch recursively (loop aliases stay in scope
375
- // inside a conditional nested within an iteration).
376
283
  const trueBranchParsed = recursive([...trueBranchContainer.childNodes], undefined, new Set(), stats, aliasSet);
377
284
 
378
- // Extract false branch nodes if else exists. An `else if` boundary
379
- // DESUGARS: the false branch becomes a synthesized nested
380
- // conditional (`<!-- if rest -->…<!-- /if -->` wrapping the chain
381
- // tail), so every mount/patch/manifest path only ever sees binary
382
- // conditionals — else-if is grammar, not machinery. The chain tail
383
- // may itself contain further else-if boundaries; the recursion
384
- // desugars them the same way.
385
285
  let falseBranchParsed = null;
386
286
  let falseBranchContainer = null;
387
287
  if (elseIndex !== null) {
@@ -400,16 +300,12 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
400
300
  falseBranchParsed = recursive([...falseBranchContainer.childNodes], undefined, new Set(), stats, aliasSet);
401
301
  }
402
302
 
403
- // Store conditional metadata (use index for deterministic keys)
404
303
  const conditionalKey = `conditional_${i}`;
405
304
  result[conditionalKey] = {
406
305
  _key: conditionalKey,
407
306
  type: 'conditional',
408
307
  meta: {
409
308
  expression,
410
- // Enclosing loop aliases — threaded back into parse() when
411
- // mountBranch re-parses this branch, so loop-scoped handlers
412
- // (including those nested deeper in further conditionals) rewrite.
413
309
  scopeAliases: [...aliasSet],
414
310
  startComment: element,
415
311
  elseComment: elseIndex !== null ? children[elseIndex] : null,
@@ -437,12 +333,10 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
437
333
  children: {},
438
334
  };
439
335
 
440
- // Mark template indices as processed
441
336
  for (let j = i + 1; j <= endIndex; j++) {
442
337
  skipIndices.add(j);
443
338
  }
444
339
 
445
- // Skip past this conditional block
446
340
  i = endIndex;
447
341
  continue;
448
342
  } catch (e) {
@@ -451,17 +345,15 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
451
345
  }
452
346
  }
453
347
 
454
- // Skip other comments
455
348
  continue;
456
349
  }
457
350
 
458
351
  const name = nodeName.startsWith('#') ? nodeName.slice(1) : nodeName;
459
352
  const nodeIdentifier = `${name}_${i}`.toLowerCase();
460
353
 
461
- // For text nodes, use parent element (text nodes can't have attributes)
462
354
  const isTextNode = nodeName === '#text';
463
355
  const elementForBindings = isTextNode ? element.parentElement : element;
464
- const textNodeRef = isTextNode ? element : null; // Store reference to actual text node
356
+ const textNodeRef = isTextNode ? element : null;
465
357
 
466
358
  const { attributes, nameBindings } = captureAttributeBindings(element, aliasSet);
467
359
  const hasChildren = childNodes.length;
@@ -493,11 +385,6 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
493
385
  return result;
494
386
  };
495
387
 
496
- // `aliasSet` carries enclosing `<!-- each -->` aliases into the parse. The root
497
- // page parse passes none; iterate.js passes a loop's own aliases when it
498
- // re-parses a cloned instance subtree, so loop-scoped `on*` handlers (including
499
- // those in nested loops, via child-alias accumulation in recursive) rewrite to
500
- // `$scope(this,'alias')`.
501
388
  export default (root, rootKey = undefined, aliasSet = new Set()) => {
502
389
  const { childNodes } = root;
503
390
  const stats = { skipped: 0 };
@@ -505,7 +392,6 @@ export default (root, rootKey = undefined, aliasSet = new Set()) => {
505
392
  const { attributes, nameBindings } = captureAttributeBindings(root, aliasSet);
506
393
 
507
394
  return {
508
- // html: root.outerHTML,
509
395
  parsed: parseHTML([...childNodes], rootKey),
510
396
  element: root,
511
397
  children: recursive(Array.from(childNodes), rootKey, new Set(), stats, aliasSet),
@@ -1,20 +1,8 @@
1
- /**
2
- * pre-compiled-iterations.js
3
- *
4
- * Pre-compiled iteration rendering - production version of iteration optimization.
5
- * Uses pre-compiled batch functions from manifest generated by the compiler.
6
- *
7
- * This is the production implementation that runs compiled code generated at build time.
8
- */
9
-
10
1
  import { stampInstanceScopes } from './loop-scope.js';
2
+ import { markBoundValues } from './iteration-utils.js';
11
3
 
12
- // Reusable template element for parsing compiled HTML
13
4
  const parseTemplate = typeof document !== 'undefined' ? document.createElement('template') : null;
14
5
 
15
- /**
16
- * Check if template has nested iterations or conditionals
17
- */
18
6
  const hasNestedStructures = (template) => {
19
7
  if (!template || !template.children) return false;
20
8
  for (const key in template.children) {
@@ -26,37 +14,23 @@ const hasNestedStructures = (template) => {
26
14
  return false;
27
15
  };
28
16
 
29
- /**
30
- * Check if iteration node has compiled batch function
31
- */
32
17
  export const canUseCompiled = (iterationNode) => {
33
18
  if (!iterationNode || !iterationNode.meta) {
34
19
  return false;
35
20
  }
36
21
 
37
- // A template with a loop-scoped `$scope(this,'alias')` handler was rewritten at
38
- // parse time; the manifest's compiled batchFn predates that rewrite and would
39
- // emit the bare, unresolvable alias. Fall back to the runtime path, which reads
40
- // the rewritten template.
41
22
  if (iterationNode.meta.hasScopedHandlers) {
42
23
  return false;
43
24
  }
44
25
 
45
- // Check compiled data from manifest merge
46
26
  const compiled = iterationNode.compiled;
47
27
  if (!compiled || !compiled.iterations || !compiled.iterations.batchFn) {
48
28
  return false;
49
29
  }
50
30
 
51
- // If we have a compiled batch function, we can use it even if the template
52
- // has nested structures, because the compiler has already inlined them
53
- // into the batch function
54
31
  return true;
55
32
  };
56
33
 
57
- /**
58
- * Get compiled function metadata from iteration node
59
- */
60
34
  export const getCompiledMeta = (iterationNode) => {
61
35
  const compiled = iterationNode.compiled;
62
36
  if (!compiled || !compiled.iterations) return null;
@@ -68,12 +42,6 @@ export const getCompiledMeta = (iterationNode) => {
68
42
  };
69
43
  };
70
44
 
71
- /**
72
- * Build a scoped wrapper that puts state keys in scope for the batch function.
73
- * The compiler generates (arr, $) => { ... } with bare variable names like `selectedCategory`,
74
- * but those aren't parameters of the arrow function. We create a wrapper that defines
75
- * state keys as parameters, then evaluates the batch function in that scope.
76
- */
77
45
  const buildScopedFn = (batchFnStr, stateKeys) => {
78
46
  return new Function(
79
47
  ...stateKeys, 'arr',
@@ -83,9 +51,6 @@ const buildScopedFn = (batchFnStr, stateKeys) => {
83
51
  );
84
52
  };
85
53
 
86
- /**
87
- * Call a compiled batch function with state keys spread into scope
88
- */
89
54
  const callCompiled = (iterationNode, array, state, compiledMeta) => {
90
55
  if (!iterationNode.runtime.compiledFn) {
91
56
  try {
@@ -103,20 +68,16 @@ const callCompiled = (iterationNode, array, state, compiledMeta) => {
103
68
  return iterationNode.runtime.compiledFn(...values, array);
104
69
  };
105
70
 
106
- /**
107
- * Render iteration using pre-compiled batch function from manifest
108
- */
109
71
  export const renderCompiled = (iterationNode, array, state, compiledMeta, parent, endComment) => {
110
72
  const html = callCompiled(iterationNode, array, state, compiledMeta);
111
73
  if (html === null) return false;
112
74
 
113
- // Parse and insert
114
75
  if (parseTemplate) {
115
76
  parseTemplate.innerHTML = html;
116
77
  const frag = parseTemplate.content;
78
+ markBoundValues(frag, html);
117
79
  const kids = frag.children;
118
80
 
119
- // Track instances - pre-allocate array for performance
120
81
  const arrayLen = array.length;
121
82
  const instances = new Array(arrayLen);
122
83
  for (let i = 0; i < arrayLen; i++) {
@@ -133,9 +94,6 @@ export const renderCompiled = (iterationNode, array, state, compiledMeta, parent
133
94
  return false;
134
95
  };
135
96
 
136
- /**
137
- * Update iteration using pre-compiled batch function (bulk rebuild)
138
- */
139
97
  export const updateCompiled = (iterationNode, newArray, state, compiledMeta, startComment, endComment) => {
140
98
  const parent = startComment.parentNode;
141
99
  const rt = iterationNode.runtime;
@@ -143,16 +101,11 @@ export const updateCompiled = (iterationNode, newArray, state, compiledMeta, sta
143
101
  const html = newArray.length === 0 ? '' : callCompiled(iterationNode, newArray, state, compiledMeta);
144
102
  if (html === null) return false;
145
103
 
146
- // Keep-DOM guard, same contract as the runtime batch renderer: compiled
147
- // iterations sit in the always-dirty bucket, so every flush of ANY key
148
- // lands here — identical output must keep the existing rows (imperative
149
- // listeners, focus, markers die with deleteContents), not rebuild them.
150
104
  if (html === rt.lastCompiledHtml && rt.instances.length === newArray.length) {
151
105
  for (let i = 0; i < newArray.length; i++) rt.instances[i].item = newArray[i];
152
106
  return true;
153
107
  }
154
108
 
155
- // Clear existing instances
156
109
  if (rt.instances.length > 0) {
157
110
  const range = document.createRange();
158
111
  range.setStartAfter(startComment);
@@ -166,13 +119,12 @@ export const updateCompiled = (iterationNode, newArray, state, compiledMeta, sta
166
119
  return true;
167
120
  }
168
121
 
169
- // Parse and insert
170
122
  if (parseTemplate) {
171
123
  parseTemplate.innerHTML = html;
172
124
  const frag = parseTemplate.content;
125
+ markBoundValues(frag, html);
173
126
  const kids = frag.children;
174
127
 
175
- // Track instances - pre-allocate array for performance
176
128
  const arrayLen = newArray.length;
177
129
  const instances = new Array(arrayLen);
178
130
  for (let i = 0; i < arrayLen; i++) {