@ape-egg/vibe 4.0.1 → 4.1.1

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.
@@ -1,4 +1,3 @@
1
- // Utility functions for array iteration
2
1
  import {
3
2
  ITERATION_REGEX,
4
3
  ITERATION_START_REGEX,
@@ -8,12 +7,6 @@ import {
8
7
  FETCH_SRC_SELECTOR,
9
8
  } from './constants.js';
10
9
 
11
- // Move binding-valued src/srcset/poster on browser-fetchable elements to
12
- // data-vibe-<attr> so the literal `@[...]` text never becomes a fetchable URL.
13
- // Called on subtrees that are still in the inert document (component finalize)
14
- // — parse.js recaptures the parked binding under the real attribute name and
15
- // hydration writes the evaluated URL, which is the first value the browser
16
- // ever sees.
17
10
  export const parkFetchableSrc = (root) => {
18
11
  const elements = root.querySelectorAll(FETCH_SRC_SELECTOR);
19
12
  for (let i = 0; i < elements.length; i++) {
@@ -28,12 +21,6 @@ export const parkFetchableSrc = (root) => {
28
21
  }
29
22
  };
30
23
 
31
- // Parse an `each` directive body (the text inside `<!-- ... -->`, markers
32
- // stripped) into its parts, or null when it isn't a valid each. The index alias
33
- // and the (key) expression are both optional and may be written in either order
34
- // — `as item, i (item.id)` and `as item (item.id), i` are equivalent. Single
35
- // source of truth for the grammar, used by the runtime parser and (via DOM
36
- // re-parse of the restored markers) by compiled pages.
37
24
  export const parseIterationHeader = (text) => {
38
25
  const match = text.match(ITERATION_REGEX);
39
26
  if (!match) return null;
@@ -47,25 +34,34 @@ export const parseIterationHeader = (text) => {
47
34
  };
48
35
  };
49
36
 
50
- // Resolve nested paths in state (e.g., "user.items" -> state.user.items)
51
- // Supports bracket notation: "teams[0].combatants" -> state.teams[0].combatants
52
37
  export const resolvePath = (obj, path) => {
53
38
  if (!path || !obj) return undefined;
54
- // Split on dots and brackets: "a[0].b[1].c" → ["a", "0", "b", "1", "c"]
55
39
  const parts = path.match(/[^.\[\]]+/g);
56
40
  if (!parts) return undefined;
57
41
  return parts.reduce((acc, part) => acc?.[part], obj);
58
42
  };
59
43
 
60
- // Clone template element preserving structure
61
44
  export const cloneTemplate = (templateElement) => {
62
45
  return templateElement.cloneNode(true);
63
46
  };
64
47
 
65
- // Find matching <!-- /each --> comment with depth tracking. The index form
66
- // returns -1 when unmatched — parse.js probes with it to tell an authored
67
- // (but malformed) directive from a prose comment that merely starts with
68
- // "each".
48
+ export const markBoundValues = (root, html) => {
49
+ if (!html.includes('@[')) return;
50
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT);
51
+ let node;
52
+ while ((node = walker.nextNode())) {
53
+ if (node.nodeType === 3) {
54
+ if (node.textContent.includes('@[')) node._vibeBoundValue = true;
55
+ continue;
56
+ }
57
+ for (const attr of node.attributes) {
58
+ if (!attr.name.startsWith('data-vibe-') && attr.value.includes('@[')) {
59
+ (node._vibeBoundAttrs ??= new Set()).add(attr.name);
60
+ }
61
+ }
62
+ }
63
+ };
64
+
69
65
  export const findEndCommentIndex = (nodes, startIndex) => {
70
66
  let depth = 1;
71
67
  for (let i = startIndex; i < nodes.length; i++) {
@@ -90,12 +86,6 @@ export const findEndComment = (nodes, startIndex) => {
90
86
  return i;
91
87
  };
92
88
 
93
- // Find matching <!-- /if --> and optional <!-- else --> / <!-- else if -->
94
- // with depth tracking. Returns { elseIndex, endIndex, elseText } — elseText
95
- // is the boundary comment's trimmed text ('else' or 'else if <expr>'), so
96
- // parse.js can desugar a chain: only the FIRST boundary at depth 1 is
97
- // captured; the rest of the chain lives inside the false-branch span and
98
- // desugars recursively into nested conditionals.
99
89
  export const findConditionalEnd = (nodes, startIndex) => {
100
90
  let depth = 1;
101
91
  let elseIndex = null;
@@ -105,11 +95,9 @@ export const findConditionalEnd = (nodes, startIndex) => {
105
95
  if (nodes[i].nodeName === '#comment') {
106
96
  const text = nodes[i].textContent.trim();
107
97
 
108
- // Check for nested if
109
98
  if (CONDITIONAL_START_REGEX.test(text)) {
110
99
  depth++;
111
100
  }
112
- // Check for else / else-if at current depth
113
101
  else if (
114
102
  depth === 1 &&
115
103
  elseIndex === null &&
@@ -118,7 +106,6 @@ export const findConditionalEnd = (nodes, startIndex) => {
118
106
  elseIndex = i;
119
107
  elseText = text;
120
108
  }
121
- // Check for /if
122
109
  else if (text === '/if') {
123
110
  depth--;
124
111
  if (depth === 0) {
@@ -128,7 +115,6 @@ export const findConditionalEnd = (nodes, startIndex) => {
128
115
  }
129
116
  }
130
117
 
131
- // Build error message with context
132
118
  const nodeTypes = nodes.slice(startIndex - 1, Math.min(startIndex + 10, nodes.length)).map((n, idx) => {
133
119
  const actualIdx = startIndex - 1 + idx;
134
120
  const prefix = actualIdx === startIndex - 1 ? '→ ' : ' ';
@@ -141,33 +127,28 @@ export const findConditionalEnd = (nodes, startIndex) => {
141
127
  throw new Error(`Unmatched <!-- if --> comment: missing <!-- /if -->\nSearching from index ${startIndex} in ${nodes.length} nodes\nContext:\n${nodeTypes.join('\n')}`);
142
128
  };
143
129
 
144
- // Generate stable hash for objects
145
130
  export const stableHash = (obj) => {
146
131
  if (obj === null || obj === undefined) return 'null';
147
132
  if (typeof obj !== 'object') return String(obj);
148
133
 
149
134
  try {
150
- // Sort keys for stable hashing
151
135
  const str = JSON.stringify(obj, Object.keys(obj).sort());
152
136
  return simpleHash(str);
153
137
  } catch (e) {
154
- // Fallback for circular references
155
138
  return simpleHash(String(obj));
156
139
  }
157
140
  };
158
141
 
159
- // Simple hash function
160
142
  const simpleHash = (str) => {
161
143
  let hash = 0;
162
144
  for (let i = 0; i < str.length; i++) {
163
145
  const char = str.charCodeAt(i);
164
146
  hash = (hash << 5) - hash + char;
165
- hash = hash & hash; // Convert to 32-bit integer
147
+ hash = hash & hash;
166
148
  }
167
149
  return Math.abs(hash).toString(36);
168
150
  };
169
151
 
170
- // Deep equality check
171
152
  export const deepEqual = (a, b) => {
172
153
  if (a === b) return true;
173
154
 
@@ -204,7 +185,6 @@ export const deepEqual = (a, b) => {
204
185
  return true;
205
186
  };
206
187
 
207
- // Longest Common Subsequence algorithm
208
188
  export const longestCommonSubsequence = (arr1, arr2) => {
209
189
  const m = arr1.length;
210
190
  const n = arr2.length;
@@ -212,7 +192,6 @@ export const longestCommonSubsequence = (arr1, arr2) => {
212
192
  .fill(null)
213
193
  .map(() => Array(n + 1).fill(0));
214
194
 
215
- // Build LCS table
216
195
  for (let i = 1; i <= m; i++) {
217
196
  for (let j = 1; j <= n; j++) {
218
197
  if (arr1[i - 1] === arr2[j - 1]) {
@@ -223,7 +202,6 @@ export const longestCommonSubsequence = (arr1, arr2) => {
223
202
  }
224
203
  }
225
204
 
226
- // Backtrack to find LCS
227
205
  const lcs = [];
228
206
  let i = m,
229
207
  j = n;
@@ -242,16 +220,6 @@ export const longestCommonSubsequence = (arr1, arr2) => {
242
220
  return lcs;
243
221
  };
244
222
 
245
- // The affected walk and the iteration update path both ask "which
246
- // [data-vibe-iter-prop] wrappers live under this row node?" for every row on
247
- // every flush — re-querying each time dominated flush cost on iteration-heavy
248
- // pages (~24k querySelectorAll calls per flush on the game's armory, nearly all
249
- // returning nothing). The wrapper set only changes when a mount path stamps the
250
- // attribute, so each stamp site bumps a global generation and the query result
251
- // is cached per node against it. A cache hit still prunes wrappers whose
252
- // subtree was torn down since (nested each/if removals keep the row node but
253
- // drop descendants) — additions always arrive through a stamp ⇒ a bump ⇒ a
254
- // fresh query.
255
223
  let iterPropGeneration = 0;
256
224
 
257
225
  export const bumpIterPropGeneration = () => {
@@ -274,52 +242,39 @@ export const iterPropWrappersOf = (node) => {
274
242
  return list;
275
243
  };
276
244
 
277
- // Generate unique key for array items.
278
- // `customKey` (when defined and not null) wins over every default heuristic —
279
- // the developer has declared identity explicitly via `<!-- each xs as x (expr) -->`.
280
- // The default heuristic only runs when no custom key was provided.
281
245
  export const getItemKey = (item, index, customKey) => {
282
246
  if (customKey !== undefined && customKey !== null) {
283
247
  return `key_${customKey}`;
284
248
  }
285
249
 
286
- // 1. If item has 'id' property, use it
287
250
  if (item && typeof item === 'object' && 'id' in item) {
288
251
  return `id_${item.id}`;
289
252
  }
290
253
 
291
- // 2. If item is primitive, use value + index
292
254
  if (typeof item !== 'object' || item === null) {
293
255
  return `val_${item}_${index}`;
294
256
  }
295
257
 
296
- // 3. For objects without id, use stable hash
297
258
  return `hash_${stableHash(item)}_${index}`;
298
259
  };
299
260
 
300
- // Compute diff operations between old and new arrays
301
261
  export const computeDiff = (oldKeys, newKeys, oldArray, newArray) => {
302
262
  const operations = [];
303
263
 
304
- // Use LCS to find common elements
305
264
  const common = longestCommonSubsequence(oldKeys, newKeys);
306
265
 
307
- // Build index maps for quick lookup
308
266
  const newKeyMap = new Map(newKeys.map((key, idx) => [key, idx]));
309
267
  const oldKeyMap = new Map(oldKeys.map((key, idx) => [key, idx]));
310
268
 
311
- // Track which keys we've processed
312
269
  const processedOld = new Set();
313
270
  const processedNew = new Set();
314
271
 
315
- // First pass: identify items to keep and check for updates
316
272
  common.forEach((key) => {
317
273
  const oldIdx = oldKeyMap.get(key);
318
274
  const newIdx = newKeyMap.get(key);
319
275
  processedOld.add(oldIdx);
320
276
  processedNew.add(newIdx);
321
277
 
322
- // Check if item content changed
323
278
  if (!deepEqual(oldArray[oldIdx], newArray[newIdx])) {
324
279
  operations.push({
325
280
  type: 'UPDATE',
@@ -329,12 +284,8 @@ export const computeDiff = (oldKeys, newKeys, oldArray, newArray) => {
329
284
  });
330
285
  }
331
286
 
332
- // Note: We don't generate MOVE for index changes here.
333
- // Items in LCS maintain relative order - after REMOVEs and ADDs,
334
- // they'll naturally be in correct positions.
335
287
  });
336
288
 
337
- // Second pass: identify removals
338
289
  oldKeys.forEach((key, idx) => {
339
290
  if (!processedOld.has(idx)) {
340
291
  operations.push({
@@ -345,7 +296,6 @@ export const computeDiff = (oldKeys, newKeys, oldArray, newArray) => {
345
296
  }
346
297
  });
347
298
 
348
- // Third pass: identify additions
349
299
  newKeys.forEach((key, idx) => {
350
300
  if (!processedNew.has(idx)) {
351
301
  operations.push({
@@ -357,17 +307,14 @@ export const computeDiff = (oldKeys, newKeys, oldArray, newArray) => {
357
307
  }
358
308
  });
359
309
 
360
- // Sort operations: REMOVE first (from end), then MOVE, then ADD, then UPDATE
361
310
  return operations.sort((a, b) => {
362
311
  const priority = { REMOVE: 0, MOVE: 1, ADD: 2, UPDATE: 3 };
363
312
  if (priority[a.type] !== priority[b.type]) {
364
313
  return priority[a.type] - priority[b.type];
365
314
  }
366
- // For REMOVE, process from end to beginning
367
315
  if (a.type === 'REMOVE') {
368
316
  return b.index - a.index;
369
317
  }
370
- // For others, process in order
371
318
  return a.index - b.index;
372
319
  });
373
320
  };
@@ -1,20 +1,6 @@
1
- // Loop-scoped event handlers.
2
- //
3
- // An `on*` handler written inside a `<!-- each X as alias -->` loop can reference
4
- // the bare loop variable directly, e.g. `onclick="pick(ability)"`. At parse time
5
- // the alias token is rewritten to `$scope(this,'alias')`; at fire time that
6
- // global resolver walks up the DOM to the nearest instance root stamped with the
7
- // live item/index and returns it. This passes the *live object* (identity, not a
8
- // stringified copy), works for derived-source loops, and survives reorders — all
9
- // while keeping the handler a visible native `on*` attribute.
10
- //
11
- // See implement-loop-scoped-event-handlers.md for the full rationale.
12
-
13
1
  const IDENT_START = /[A-Za-z_$]/;
14
2
  const IDENT_PART = /[A-Za-z0-9_$]/;
15
3
 
16
- // Last non-whitespace character already emitted — lets us tell a standalone
17
- // identifier (rewrite) from a member access like `foo.alias` (leave alone).
18
4
  const lastNonSpace = (s) => {
19
5
  for (let i = s.length - 1; i >= 0; i--) {
20
6
  const c = s[i];
@@ -23,8 +9,6 @@ const lastNonSpace = (s) => {
23
9
  return '';
24
10
  };
25
11
 
26
- // Copy a quoted string literal beginning at `i` (value[i] is the opening quote)
27
- // verbatim, honoring backslash escapes. Returns the index just past the closer.
28
12
  const copyString = (value, i, push) => {
29
13
  const quote = value[i];
30
14
  push(quote);
@@ -48,14 +32,6 @@ const copyString = (value, i, push) => {
48
32
  return i;
49
33
  };
50
34
 
51
- // Rewrite standalone references to loop-variable aliases inside an event-handler
52
- // expression into `$scope(this,'alias')` calls. Skips `@[...]` binding spans
53
- // (they keep their existing hydrate-time stringifying behavior), string literals,
54
- // member accesses, and object-literal keys (`{ alias: x }` keeps its key; the
55
- // shorthand `{ alias }` expands to `{ alias: $scope(this,'alias') }`), so only
56
- // identifiers that genuinely name a loop alias are touched. Bracket frames carry
57
- // a pending-ternary count per nesting level, which is what tells an object key's
58
- // `:` apart from a ternary's.
59
35
  export const rewriteHandlerAliases = (value, aliasSet) => {
60
36
  if (!aliasSet || aliasSet.size === 0 || typeof value !== 'string') return value;
61
37
 
@@ -71,8 +47,6 @@ export const rewriteHandlerAliases = (value, aliasSet) => {
71
47
  while (i < n) {
72
48
  const ch = value[i];
73
49
 
74
- // @[...] binding span — copy verbatim. Track bracket depth and skip inner
75
- // strings so a `]` inside a quoted expression doesn't close the span early.
76
50
  if (ch === '@' && value[i + 1] === '[') {
77
51
  push('@[');
78
52
  i += 2;
@@ -91,7 +65,6 @@ export const rewriteHandlerAliases = (value, aliasSet) => {
91
65
  continue;
92
66
  }
93
67
 
94
- // String literal — copy verbatim.
95
68
  if (ch === "'" || ch === '"' || ch === '`') {
96
69
  i = copyString(value, i, push);
97
70
  continue;
@@ -128,7 +101,6 @@ export const rewriteHandlerAliases = (value, aliasSet) => {
128
101
  continue;
129
102
  }
130
103
 
131
- // Identifier — rewrite when it's a standalone alias reference.
132
104
  if (IDENT_START.test(ch)) {
133
105
  let j = i + 1;
134
106
  while (j < n && IDENT_PART.test(value[j])) j++;
@@ -160,10 +132,6 @@ export const rewriteHandlerAliases = (value, aliasSet) => {
160
132
  return out;
161
133
  };
162
134
 
163
- // Walk up from `el` to the nearest ancestor stamped with a scope that defines
164
- // `name`, returning the live value. Returns undefined if no enclosing loop
165
- // defines the alias. Nested loops resolve naturally: the innermost stamp is hit
166
- // first; an outer alias is found by continuing up past inner stamps.
167
135
  export const resolveScope = (el, name) => {
168
136
  let node = el;
169
137
  while (node) {
@@ -174,16 +142,6 @@ export const resolveScope = (el, name) => {
174
142
  return undefined;
175
143
  };
176
144
 
177
- // Re-stamp the instance scope onto conditional-branch roots mounted inside a
178
- // row. A `<!-- if -->` within a loop mounts its branch content separately from
179
- // the iteration's own clonedNodes and stamps it once, at mount time
180
- // (conditionals.js). When the row later updates in place — its DOM node reused
181
- // for a new item — the iteration refreshes its own root stamp, but the branch
182
- // root keeps the stale mount-time stamp, and `resolveScope` hits that first on
183
- // the walk up (so a handler inside the conditional resolves the previous item).
184
- // Walking the instance's parsed tree and re-stamping every active conditional
185
- // branch root with the same fresh `scope` object closes that gap. Nested loops
186
- // are skipped: each iteration node manages its own instances' scopes.
187
145
  const restampConditionalBranches = (tree, scope) => {
188
146
  if (!tree || typeof tree !== 'object') return;
189
147
  if (tree.type === 'iteration') return;
@@ -203,22 +161,11 @@ const restampConditionalBranches = (tree, scope) => {
203
161
  }
204
162
  };
205
163
 
206
- // Stamp the in-scope loop vars onto every iteration instance's root element
207
- // node(s). The stamp accumulates the enclosing loop vars (`parentScope`) plus
208
- // this loop's item/index, so a single innermost stamp resolves every alias in
209
- // scope — which is what makes arbitrarily nested `each`/`if` combinations work
210
- // even when a loop's template is purely another loop (no wrapper element to walk
211
- // up to). Re-applied after each render and update so a stamp always reflects the
212
- // current item/index — including after keyed reorders, where instance objects
213
- // keep current `.item`/`.index`.
214
164
  export const stampInstanceScopes = (iterationNode, parentScope = {}) => {
215
165
  const { itemAlias, indexAlias } = iterationNode.meta;
216
166
  const instances = iterationNode.runtime.instances;
217
167
  for (let k = 0; k < instances.length; k++) {
218
168
  const inst = instances[k];
219
- // `liveItem` (set by stampScopes in iterate.js) is the live `$`-proxy
220
- // element so handlers get the identity the app sees; `item` is the plain
221
- // diff-snapshot clone used for rendering. Prefer live when available.
222
169
  const item = inst.liveItem !== undefined ? inst.liveItem : inst.item;
223
170
  const scope = { ...parentScope, [itemAlias]: item, [indexAlias]: inst.index };
224
171
  const roots = inst.clonedNodes || (inst.element ? [inst.element] : []);
@@ -226,15 +173,10 @@ export const stampInstanceScopes = (iterationNode, parentScope = {}) => {
226
173
  const node = roots[r];
227
174
  if (node && node.nodeType === 1) node.__vibeScope = scope;
228
175
  }
229
- // Conditional branches inside the row carry their own stamp from mount time;
230
- // refresh them with the same fresh scope so in-place row updates don't leave
231
- // a handler inside an `<!-- if -->` resolving the previous item.
232
176
  if (inst.tree) restampConditionalBranches(inst.tree, scope);
233
177
  }
234
178
  };
235
179
 
236
- // Install the global `$scope` resolver so native inline handlers (which run in
237
- // global scope at fire time) can call it. Idempotent across boots.
238
180
  export const installScopeResolver = () => {
239
181
  globalThis.$scope = resolveScope;
240
182
  };
@@ -1,11 +1,3 @@
1
- // The manifest is the one-way dotPath→element map the runtime hangs DOM
2
- // bookkeeping on. Three hot paths need the REVERSE direction (element→path):
3
- // conditional branch mounts and the mutation observer's add/remove tracking.
4
- // Scanning Object.entries per lookup is O(manifest) per node — quadratic as
5
- // pages grow, and the dominant cost of mounting conditional-heavy pages. A
6
- // WeakMap rides on the manifest object (non-enumerable, like __live/__tree)
7
- // and is maintained by every write; lookups validate against the forward map
8
- // so a deleted entry can never resolve stale.
9
1
  const indexOf = (manifest) => {
10
2
  if (!manifest.__paths) {
11
3
  Object.defineProperty(manifest, '__paths', {
@@ -17,13 +9,6 @@ const indexOf = (manifest) => {
17
9
  return manifest.__paths;
18
10
  };
19
11
 
20
- // Single write point: forward map + reverse index together. Non-object
21
- // "elements" (text nodes stored as strings, null placeholders) can't be
22
- // WeakMap keys and are never reverse-looked-up — forward map only. An
23
- // element can legitimately hold MORE than one path (its own node plus an
24
- // inlined-slot alias, branch-graft re-registrations); the index keeps every
25
- // registered path shortest-first, so lookups answer with the subtree ROOT —
26
- // removal-time pruning must sweep the whole region, not a deeper alias.
27
12
  export const setManifestEntry = (manifest, path, element) => {
28
13
  manifest[path] = element;
29
14
  if (element !== null && typeof element === 'object') {
@@ -39,10 +24,6 @@ export const setManifestEntry = (manifest, path, element) => {
39
24
  }
40
25
  };
41
26
 
42
- // O(paths-per-element) reverse lookup, validated: entries whose forward
43
- // mapping was deleted (branch unmounts, subtree pruning) compact away; the
44
- // first path the forward map still agrees with — the shortest live one —
45
- // wins. Null when none survive.
46
27
  export const manifestPathOf = (manifest, element) => {
47
28
  if (element === null || typeof element !== 'object') return null;
48
29
  const paths = manifest.__paths?.get(element);
@@ -57,14 +38,6 @@ export const manifestPathOf = (manifest, element) => {
57
38
  return null;
58
39
  };
59
40
 
60
- // Removal counterpart of addToManifest (index.js): drop the root entry and
61
- // every entry under its dot-scope. A PREFIX SWEEP over the manifest keys, not
62
- // a tree walk — branch mounts register alias paths a tree walk can't reach,
63
- // and the sweep stays correct through any tree/manifest drift. Deleting only
64
- // the root entry leaked the rest: an SPA outlet swap left ~4,400
65
- // detached-element entries behind per navigation, pinning the DOM of every
66
- // page ever visited. The manifest stays bounded, so O(manifest) per removal
67
- // batch is cheap. An empty root path is never a legitimate removal target.
68
41
  export const removeManifestSubtree = (manifest, dotPath) => {
69
42
  if (!dotPath) return;
70
43
  delete manifest[dotPath];