@ape-egg/vibe 2.1.18 → 2.1.20

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.1.20] - 2026-06-25
4
+
5
+ ### Added
6
+
7
+ - **`@ape-egg/vibe/hot-module-refresh` — transport-agnostic browser HMR client** (`hot-module-refresh.js` (new), exported from `package.json`) — the surgical-HMR "brain" that reconciles a code edit into the live DOM instead of reloading the page is extracted out of `vite-plugin-vibe` into a standalone flat module the vibe package now exports. It's a *soft dependency*: nothing in the runtime imports it and it imports nothing from the runtime, depending only on the public `window.$` surface (`reconcile` / `renderComponent` / `clearComponentCache`), so a plain static server can serve it as-is — no bundler, no Vite. A transport adapter wires its channel to the brain through a single seam, `setupHotModuleRefresh({ debug, subscribe })`, where `subscribe` delivers two callbacks: `componentUpdate(path)` — re-fetch a changed component template and, for each live instance, reconcile surgically when its scripts are unchanged or re-mount otherwise (runtime mode only, since compiled output inlines components into pages) — and `pageUpdate(payload)` — re-fetch the current page and reconcile the `[vibe]` root (mode-agnostic; raw and compiled pages reconcile the same way). This lets the Vite plugin and any other dev transport share one HMR implementation. Tests: `e2e-runtime/hot-module-refresh.html`, `tests/e2e/hot-module-refresh.spec.js`.
8
+
9
+ ## [2.1.19] - 2026-06-25
10
+
11
+ ### Fixed
12
+
13
+ - **`<!-- each list as item, i (item.id) -->` (index alias before the key) silently failed to iterate** (`runtime/constants.js`, `runtime/iteration-utils.js`, `runtime/parse.js`, `compiler/src/compiler/manifest_builder.rs`) — the iteration header grammar only accepted the key before the index (`as item (item.id), i`). When the index came first, the trailing `(item.id)` made `ITERATION_REGEX` fail to match, so `parse.js` skipped the comment entirely and the body rendered once with an undefined alias instead of iterating. `ITERATION_REGEX` now accepts the `(key)` expression in either position (a second optional key group), and a new `parseIterationHeader` helper coalesces the two and is the single source of truth used by `parse.js`. Compiled pages re-parse the preserved each comment through the same helper, so they were fixed by the runtime change; additionally the Rust `manifest_builder` (compiler 2.0.0 → 2.0.1) now strips the key before the item/index split so the emitted manifest carries a clean `indexAlias` (was `"i (item.id)"`) for both orderings. Tests: `tests/unit/iteration-utils.test.js` (parseIterationHeader), `tests/compiler/iterations-index-key/`, `e2e-runtime/iteration-index-key.html` + `tests/e2e/iteration-index-key.spec.js` (runtime + compiled).
14
+
3
15
  ## [2.1.18] - 2026-06-22
4
16
 
5
17
  ### Fixed
@@ -1599,7 +1599,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
1599
1599
 
1600
1600
  [[package]]
1601
1601
  name = "vibe-compiler"
1602
- version = "2.0.0"
1602
+ version = "2.0.1"
1603
1603
  dependencies = [
1604
1604
  "clap",
1605
1605
  "colored",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "vibe-compiler"
3
- version = "2.0.0"
3
+ version = "2.0.1"
4
4
  edition = "2021"
5
5
  description = "Vibe framework compiler - compiles Vibe source files into optimized output"
6
6
  authors = ["Kim Korte"]
@@ -202,16 +202,26 @@ impl ManifestBuilder {
202
202
  // Extract expression: "each items as item" -> "items as item"
203
203
  let expression = trimmed.strip_prefix("each ").unwrap_or("").to_string();
204
204
 
205
- // Parse expression to extract parts: "items as item, index" or "items as item"
205
+ // Parse expression to extract parts: "items as item, index" or "items as item".
206
+ // The optional (key) expression may sit before or after the index — strip it
207
+ // first so the item/index split isn't polluted by it. The key itself isn't
208
+ // stored here; the runtime re-derives it from the preserved comment.
206
209
  let parts: Vec<&str> = expression.split(" as ").collect();
207
210
  let array_path = parts.get(0).unwrap_or(&"").trim().to_string();
208
211
  let alias_part = parts.get(1).unwrap_or(&"").trim();
209
- let (item_alias, index_alias) = if let Some(comma_pos) = alias_part.find(',') {
210
- let item = alias_part[..comma_pos].trim().to_string();
211
- let index = alias_part[comma_pos + 1..].trim().to_string();
212
+ let alias_without_key = match (alias_part.find('('), alias_part.rfind(')')) {
213
+ (Some(open), Some(close)) if close > open => {
214
+ format!("{}{}", &alias_part[..open], &alias_part[close + 1..])
215
+ }
216
+ _ => alias_part.to_string(),
217
+ };
218
+ let alias_without_key = alias_without_key.trim();
219
+ let (item_alias, index_alias) = if let Some(comma_pos) = alias_without_key.find(',') {
220
+ let item = alias_without_key[..comma_pos].trim().to_string();
221
+ let index = alias_without_key[comma_pos + 1..].trim().to_string();
212
222
  (item, index)
213
223
  } else {
214
- (alias_part.to_string(), "index".to_string())
224
+ (alias_without_key.to_string(), "index".to_string())
215
225
  };
216
226
 
217
227
  // Find node index in siblings
@@ -0,0 +1,384 @@
1
+ // @ape-egg/vibe/hot-module-refresh
2
+ //
3
+ // Transport-agnostic browser HMR client — the "brain" that reconciles a code
4
+ // edit into the live DOM instead of reloading the page. Extracted out of
5
+ // vite-plugin-vibe so both dev modes share one implementation.
6
+ //
7
+ // SOFT DEPENDENCY: nothing in Vibe's runtime imports this, it imports nothing
8
+ // from Vibe, and deleting it leaves a working framework. It depends solely on
9
+ // the runtime global `window.$` (reconcile / renderComponent /
10
+ // clearComponentCache), so it ships as a flat module a plain static server can
11
+ // serve as-is — no bundler, no Vite.
12
+ //
13
+ // A transport adapter wires its channel to the brain through one seam:
14
+ //
15
+ // setupHotModuleRefresh({ debug, subscribe })
16
+ // subscribe({ componentUpdate, pageUpdate })
17
+ //
18
+ // • componentUpdate(path) — a component template changed. Fetch the raw
19
+ // template; for every live instance, reconcile surgically when scripts
20
+ // are unchanged, else re-mount. Runtime-mode only — compiled output
21
+ // inlines components into pages, so its adapter never calls this.
22
+ // • pageUpdate(payload) — the current page changed. Fetch it and reconcile
23
+ // the [vibe] root. Mode-agnostic: a raw page (runtime) and a compiled
24
+ // page (compiled) reconcile the same way.
25
+
26
+ export const setupHotModuleRefresh = ({ debug = false, subscribe }) => {
27
+ const dbg = {
28
+ info: (...a) => { if (debug) console.info(...a); },
29
+ group: (...a) => { if (debug) console.group(...a); },
30
+ groupEnd: () => { if (debug) console.groupEnd(); },
31
+ };
32
+
33
+ // Track which live <component> wrappers correspond to which source file.
34
+ // Vibe's component.js replaces <component src='X'> with a fresh <component>
35
+ // (no src). The spy below tags the new wrapper with _vibeSrc + the slot
36
+ // content, so on a component-update we can find every live instance and
37
+ // hand the slot back to Vibe verbatim during re-mount.
38
+ const liveBySrc = new Map();
39
+
40
+ const isComponentSrc = (n) =>
41
+ n?.nodeType === 1 && n.hasAttribute?.('src') &&
42
+ (n.nodeName === 'COMPONENT' || (n.nodeName === 'DIV' && n.classList?.contains?.('component')));
43
+ const isProcessedComponent = (n) =>
44
+ n?.nodeType === 1 && !n.hasAttribute?.('src') &&
45
+ (n.nodeName === 'COMPONENT' || (n.nodeName === 'DIV' && n.classList?.contains?.('component')));
46
+
47
+ // Snapshot slot innerHTML on a <component src> BEFORE vibe strips iteration/
48
+ // conditional templates from its subtree. vibe's renderAllIterations removes
49
+ // nodes between <!-- each -->/<!-- /each --> comments synchronously after
50
+ // hydrate; if we wait until the component is swapped out (spy's removedNodes
51
+ // path) and read innerHTML then, the templates are gone and HMR re-mounts
52
+ // receive an empty slot. setup runs before the page's boot script calls into
53
+ // vibe — the initial pass captures everything present at parse time.
54
+ const captureSlot = (el) => {
55
+ if (el._vibePluginSlot === undefined) el._vibePluginSlot = el.innerHTML;
56
+ };
57
+ document.querySelectorAll('component[src], div.component[src]').forEach(captureSlot);
58
+
59
+ // Mirrors vibe/runtime/cleanup.js#shouldCleanup: a subtree is 'done' once
60
+ // no <component[src]> remains pending and no literal @[...] sits in a
61
+ // text node. Used to release vibe-fouc once Vibe finishes processing the
62
+ // re-mounted component subtree.
63
+ const waitForVibeReady = (target, timeout = 2000) => new Promise((done) => {
64
+ const deadline = performance.now() + timeout;
65
+ const tick = () => {
66
+ if (!target.isConnected) return done();
67
+ if (!target.querySelector('component[src], div.component[src]')) {
68
+ const walker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT);
69
+ let pending = false;
70
+ let n;
71
+ while ((n = walker.nextNode())) {
72
+ if (/@\[.+?\]/.test(n.textContent)) { pending = true; break; }
73
+ }
74
+ if (!pending) return done();
75
+ }
76
+ if (performance.now() > deadline) return done();
77
+ requestAnimationFrame(tick);
78
+ };
79
+ requestAnimationFrame(tick);
80
+ });
81
+
82
+ new MutationObserver((mutations) => {
83
+ // First pass: capture slot content on any <component src> that gets
84
+ // added dynamically (conditional/iteration branches mounting nested
85
+ // components). Must happen before the removal/addition logic runs, so
86
+ // that if a subsequent mutation re-mounts or strips this element, the
87
+ // snapshot is already in place.
88
+ for (const { addedNodes } of mutations) {
89
+ for (const n of addedNodes) {
90
+ if (isComponentSrc(n)) captureSlot(n);
91
+ if (n?.nodeType === 1 && n.querySelectorAll) {
92
+ n.querySelectorAll('component[src], div.component[src]').forEach(captureSlot);
93
+ }
94
+ }
95
+ }
96
+ for (const { addedNodes, removedNodes } of mutations) {
97
+ let src = null;
98
+ let slotContent = '';
99
+ let hadFouc = false;
100
+ let props = null;
101
+ let scriptHash;
102
+ for (const n of removedNodes) {
103
+ if (isComponentSrc(n)) {
104
+ src = n.getAttribute('src');
105
+ // vibe's component.js deletes _vibeSlotContent synchronously after
106
+ // reading it, so on re-mount transitions the prop is already gone
107
+ // by the time we see the removal. _vibePluginSlot is our own
108
+ // mirror that vibe never touches — read it preferentially, fall
109
+ // back to vibe's prop (initial page load), then innerHTML (also
110
+ // initial, when source was authored inline).
111
+ slotContent = (
112
+ n._vibePluginSlot !== undefined ? n._vibePluginSlot :
113
+ n._vibeSlotContent !== undefined ? n._vibeSlotContent :
114
+ n.innerHTML
115
+ ).trim();
116
+ hadFouc = n.hasAttribute('vibe-fouc');
117
+ // Script hash is set by remount() on the <component src> before it
118
+ // hits the DOM, OR carried from a previous wrapper via this same
119
+ // forward path. Either way, stash it so the processed wrapper can
120
+ // use it as baseline for future surgical reconciles.
121
+ scriptHash = n._vibeScriptHash;
122
+ // Capture all authored attrs so reconcile can detect prop changes
123
+ // on the callsite and re-mount when they differ. vibe-fouc is a
124
+ // transient HMR marker — in either attribute or class form — and
125
+ // must never end up in the authored set or every FOUC flip would
126
+ // falsely look like a prop change.
127
+ props = {};
128
+ for (const a of n.attributes) {
129
+ if (a.name === 'vibe-fouc') continue;
130
+ if (a.name === 'class') {
131
+ const kept = a.value.split(/\s+/).filter((t) => t && t !== 'vibe-fouc');
132
+ if (kept.length) props.class = kept.join(' ');
133
+ continue;
134
+ }
135
+ props[a.name] = a.value;
136
+ }
137
+ break;
138
+ }
139
+ }
140
+ if (!src) continue;
141
+ for (const n of addedNodes) {
142
+ if (isProcessedComponent(n)) {
143
+ n._vibeSrc = src;
144
+ n._vibeSlotContent = slotContent;
145
+ // Brain-owned mirror so we can recover slot content after vibe's
146
+ // processSingle deletes _vibeSlotContent on the next re-mount.
147
+ n._vibePluginSlot = slotContent;
148
+ n._vibeProps = props;
149
+ if (scriptHash !== undefined) n._vibeScriptHash = scriptHash;
150
+ if (!liveBySrc.has(src)) liveBySrc.set(src, new Set());
151
+ liveBySrc.get(src).add(n);
152
+ if (hadFouc) {
153
+ n.setAttribute('vibe-fouc', '');
154
+ waitForVibeReady(n).then(() => n.removeAttribute('vibe-fouc'));
155
+ }
156
+ break;
157
+ }
158
+ }
159
+ }
160
+ }).observe(document.body, { childList: true, subtree: true });
161
+
162
+ // Cheap string hash (djb2). Used to detect <script type="module"> changes
163
+ // between HMR fetches — unchanged scripts mean registered component state
164
+ // is still valid, so we can reconcile in place instead of re-mounting.
165
+ const hashString = (s) => {
166
+ let h = 5381;
167
+ for (let i = 0; i < s.length; i++) h = (((h << 5) + h) + s.charCodeAt(i)) | 0;
168
+ return h;
169
+ };
170
+ const hashScripts = (rawHtml) => {
171
+ const temp = document.createElement('div');
172
+ temp.innerHTML = rawHtml;
173
+ const scripts = temp.querySelectorAll('script[type="module"]');
174
+ return hashString([...scripts].map((s) => s.textContent || '').join(''));
175
+ };
176
+
177
+ // Return the baseline script hash for a live wrapper. On initial mount,
178
+ // vibe's component.js stashes the raw fetched HTML on the wrapper as
179
+ // _vibeRawSource. We hash it lazily on first HMR check and cache the
180
+ // result. This makes the very first save after page load surgical
181
+ // (assuming the script body hasn't changed) rather than always falling
182
+ // back to re-mount.
183
+ const getBaselineScriptHash = (el) => {
184
+ if (el._vibeScriptHash !== undefined) return el._vibeScriptHash;
185
+ if (el._vibeRawSource) {
186
+ el._vibeScriptHash = hashScripts(el._vibeRawSource);
187
+ return el._vibeScriptHash;
188
+ }
189
+ return undefined;
190
+ };
191
+
192
+ // Collect data-vibe-component-id values from the live wrapper's subtree in
193
+ // DOM order. Script processing inside the component assigns ids in script
194
+ // order → DOM order (each script's sibling group follows it), so reusing
195
+ // ids in DOM order keeps state bindings aligned across HMR renders.
196
+ const collectIdsInOrder = (el) => {
197
+ const ids = [];
198
+ const seen = new Set();
199
+ el.querySelectorAll('[data-vibe-component-id]').forEach((node) => {
200
+ const id = node.getAttribute('data-vibe-component-id');
201
+ if (id && !seen.has(id)) { seen.add(id); ids.push(id); }
202
+ });
203
+ return ids;
204
+ };
205
+
206
+ // True when an element lives inside a vibe iteration region (between
207
+ // <!-- each ... --> and <!-- /each -->). Iterations materialize the
208
+ // template N times with pre-resolved props; iterate.js caches the template
209
+ // at mount time, so surgical changes to one instance would be clobbered on
210
+ // the next array mutation. Fall back to re-mount in that case.
211
+ const isInsideIteration = (el) => {
212
+ let cur = el;
213
+ while (cur && cur.parentNode) {
214
+ let depth = 0;
215
+ let sib = cur.previousSibling;
216
+ while (sib) {
217
+ if (sib.nodeType === 8) {
218
+ const t = sib.textContent.trim();
219
+ if (t === '/each') depth++;
220
+ else if (t.startsWith('each ')) {
221
+ if (depth === 0) return true;
222
+ depth--;
223
+ }
224
+ }
225
+ sib = sib.previousSibling;
226
+ }
227
+ cur = cur.parentNode;
228
+ if (!cur || cur === document.body) return false;
229
+ }
230
+ return false;
231
+ };
232
+
233
+ // Re-mount a single live instance by swapping its wrapper for a fresh
234
+ // <component src>. Vibe re-fetches, re-executes scripts, and re-inlines
235
+ // the template. Used for first-HMR (no baseline hash), script changes,
236
+ // iteration-nested instances, and as a failure fallback.
237
+ const remount = (el, path, scriptHash) => {
238
+ const oldComponentIds = collectIdsInOrder(el);
239
+ const fresh = document.createElement(el.tagName);
240
+ if (el.tagName === 'DIV') fresh.className = 'component';
241
+ const props = el._vibeProps || { src: path };
242
+ for (const [name, value] of Object.entries(props)) {
243
+ fresh.setAttribute(name, value);
244
+ }
245
+ fresh.setAttribute('vibe-fouc', '');
246
+ const slot = el._vibePluginSlot !== undefined
247
+ ? el._vibePluginSlot
248
+ : (el._vibeSlotContent || '');
249
+ fresh._vibeSlotContent = slot;
250
+ fresh._vibePluginSlot = slot;
251
+ fresh._vibeScriptHash = scriptHash;
252
+ if (oldComponentIds.length) fresh._vibeReuseComponentIds = oldComponentIds;
253
+ // Transfer iteration-prop registry ownership from the soon-to-be-detached
254
+ // wrapper to the fresh one. The detach would otherwise trigger Vibe's
255
+ // releaseOrphanedIterationProps and free the registry slots that the
256
+ // copied prop attributes (e.g. node='@[window.__vibeIterProps._pN]') still
257
+ // reference, leaving every binding to render undefined after the HMR swap.
258
+ if (el._vibeIterPropIds) {
259
+ fresh._vibeIterPropIds = el._vibeIterPropIds;
260
+ fresh.setAttribute('data-vibe-iter-prop', '');
261
+ el._vibeIterPropIds = null;
262
+ el.removeAttribute('data-vibe-iter-prop');
263
+ }
264
+ el.replaceWith(fresh);
265
+ };
266
+
267
+ // Component file changed. Strategy:
268
+ // 1. Fetch the raw template once per update; hash its <script type="module">
269
+ // contents. Script hash unchanged + not inside an iteration → surgical
270
+ // path: $.renderComponent produces the processed HTML (props + slot
271
+ // substituted, componentIds reused) and $.reconcile diffs it against
272
+ // the live wrapper's children. DOM identity, focus, and component
273
+ // state are preserved.
274
+ // 2. First HMR for any instance (no baseline hash stored), script changes,
275
+ // or iteration-scoped callsites → full re-mount (same path as before
276
+ // the surgical rewrite). vibe-fouc hides the subtree until ready.
277
+ //
278
+ // Props and slot content always come from the spy's _vibeProps /
279
+ // _vibePluginSlot snapshot.
280
+ let componentUpdateCount = 0;
281
+ const componentUpdate = async (path) => {
282
+ const n = ++componentUpdateCount;
283
+ // Drop the runtime's cached template for this file so any component
284
+ // mounted *after* this edit fetches the fresh version. Already-mounted
285
+ // instances are refreshed surgically below; this covers future mounts.
286
+ window.$?.clearComponentCache?.(path);
287
+ const instances = liveBySrc.get(path);
288
+ dbg.group('[vibe-hmr] component-update #' + n, path,
289
+ '— instances:', instances?.size || 0);
290
+ try {
291
+ if (!instances || !instances.size) {
292
+ dbg.info('[vibe-hmr] no live instances; nothing to do');
293
+ return;
294
+ }
295
+ const rawUrl = path + (path.includes('?') ? '&' : '?') + '_t=' + Date.now();
296
+ const rawHtml = await fetch(rawUrl, { cache: 'no-store' }).then((r) => r.text());
297
+ const scriptHash = hashScripts(rawHtml);
298
+ const canSurgical = typeof window.$?.renderComponent === 'function'
299
+ && typeof window.$?.reconcile === 'function';
300
+
301
+ let surgical = 0;
302
+ let remounted = 0;
303
+ for (const el of [...instances]) {
304
+ instances.delete(el);
305
+ if (!el.parentNode) continue;
306
+
307
+ const oldHash = getBaselineScriptHash(el);
308
+ const inIter = isInsideIteration(el);
309
+
310
+ if (canSurgical && oldHash !== undefined && oldHash === scriptHash && !inIter) {
311
+ try {
312
+ const componentIds = collectIdsInOrder(el);
313
+ const slot = el._vibePluginSlot !== undefined
314
+ ? el._vibePluginSlot
315
+ : (el._vibeSlotContent || '');
316
+ const props = el._vibeProps || {};
317
+ const processedHtml = window.$.renderComponent(rawHtml, props, slot, { componentIds });
318
+ const summary = await window.$.reconcile(el, processedHtml);
319
+ el._vibeScriptHash = scriptHash;
320
+ el._vibeRawSource = rawHtml;
321
+ if (!liveBySrc.has(path)) liveBySrc.set(path, new Set());
322
+ liveBySrc.get(path).add(el);
323
+ surgical++;
324
+ dbg.info('[vibe-hmr] surgical reconcile:', summary);
325
+ continue;
326
+ } catch (err) {
327
+ console.warn('[vibe-hmr] surgical failed, falling back to re-mount:', err);
328
+ }
329
+ }
330
+
331
+ remount(el, path, scriptHash);
332
+ remounted++;
333
+ }
334
+ dbg.info('[vibe-hmr] surgical:', surgical, 'remounted:', remounted);
335
+ } catch (err) {
336
+ console.error('[vibe-hmr] component-update failed:', err);
337
+ } finally {
338
+ dbg.groupEnd();
339
+ }
340
+ };
341
+
342
+ // Page file changed — fetch new HTML and hand the [vibe] root + new inner
343
+ // content to $.reconcile. Vibe walks live vs. source and applies the
344
+ // minimal mutation; iteration / conditional / component regions are
345
+ // opaque (their interiors are state-driven). Page-level JS state on $
346
+ // is preserved (no full reload).
347
+ let pageUpdateCount = 0;
348
+ const pageUpdate = async (payload) => {
349
+ const n = ++pageUpdateCount;
350
+ dbg.group('[vibe-hmr] page-update #' + n, payload?.path || '(no path)');
351
+ try {
352
+ if (!window.$ || typeof window.$.reconcile !== 'function') {
353
+ console.warn('[vibe-hmr] $.reconcile not available — skipped');
354
+ return;
355
+ }
356
+ const url = location.href + (location.href.includes('?') ? '&' : '?') + '_t=' + Date.now();
357
+ const t0 = performance.now();
358
+ const html = await fetch(url).then((r) => r.text());
359
+ dbg.info('[vibe-hmr] fetched', html.length, 'bytes in', (performance.now() - t0).toFixed(1) + 'ms');
360
+ const doc = new DOMParser().parseFromString(html, 'text/html');
361
+ const newRoot = doc.querySelector('[vibe]') || doc.body;
362
+ const liveRoot = document.querySelector('[vibe]') || document.body;
363
+ dbg.info('[vibe-hmr] target [vibe] root:', liveRoot, 'isConnected=', liveRoot.isConnected);
364
+ // Note: we no longer strip <script type='module'> from source. Stripping
365
+ // misaligned source vs. live and caused cascading replaces. Reconcile's
366
+ // tag-aligned walk now matches scripts at their position; updating a
367
+ // script's textContent doesn't re-execute it (a known limitation —
368
+ // editing inline page scripts requires a real reload to take effect).
369
+ const summary = await window.$.reconcile(liveRoot, newRoot.innerHTML);
370
+ dbg.info('[vibe-hmr] summary:', summary);
371
+ if (summary?.changes?.length) {
372
+ for (const c of summary.changes) dbg.info('[vibe-hmr] ·', c);
373
+ } else {
374
+ dbg.info('[vibe-hmr] no changes — source matches live');
375
+ }
376
+ } catch (err) {
377
+ console.error('[vibe-hmr] page-update failed:', err);
378
+ } finally {
379
+ dbg.groupEnd();
380
+ }
381
+ };
382
+
383
+ subscribe({ componentUpdate, pageUpdate });
384
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe",
3
- "version": "2.1.18",
3
+ "version": "2.1.20",
4
4
  "type": "module",
5
5
  "description": "Runtime-first reactivity with optional compiler",
6
6
  "main": "index.js",
@@ -9,6 +9,7 @@
9
9
  ".": "./index.js",
10
10
  "./boot": "./boot.js",
11
11
  "./component": "./component.js",
12
+ "./hot-module-refresh": "./hot-module-refresh.js",
12
13
  "./runtime": "./runtime/index.js",
13
14
  "./compiler": "./compiler/bin/vibe-compile.js"
14
15
  },
@@ -211,18 +211,23 @@ export const BINDING_REGEX = new RegExp(String.raw`\@\[(${BINDING_INNER})\]`, 'g
211
211
  // Regex for detecting a pure binding (entire value is just @[expression])
212
212
  export const PURE_BINDING_REGEX = new RegExp(String.raw`^\@\[(${BINDING_INNER})\]$`);
213
213
 
214
- // Regex for parsing iteration comment syntax. Supported forms:
214
+ // Regex for parsing iteration comment syntax. Supported forms — the index and
215
+ // the key are each optional and may appear in EITHER order:
215
216
  // <!-- each items as item -->
216
217
  // <!-- each items as item, index -->
217
218
  // <!-- each items as item (item.id) --> // explicit key
218
- // <!-- each items as item (item.id), index --> // key + index
219
- // Capture groups: arrayPath, itemAlias, keyExpr (optional), indexAlias (optional).
219
+ // <!-- each items as item (item.id), index --> // key, then index
220
+ // <!-- each items as item, index (item.id) --> // index, then key
221
+ // Capture groups: arrayPath, itemAlias, keyBeforeIndex (optional), indexAlias
222
+ // (optional), keyAfterIndex (optional). The key lands in group 3 when written
223
+ // before the index and in group 5 when written after; parseIterationHeader
224
+ // coalesces the two — prefer that helper over destructuring the raw match.
220
225
  // The array expression can be any JS: a state path, a window global, a method
221
226
  // call, or an inline literal. The optional key expression is evaluated per
222
227
  // item against scoped state to produce a stable identity for diffing — this
223
228
  // keeps survivors stable when earlier items are removed (otherwise the
224
229
  // fallback hash key embeds the index and triggers bulk re-render).
225
- export const ITERATION_REGEX = /^each\s+(.+)\s+as\s+(\w+)(?:\s*\(\s*([^)]+?)\s*\))?(?:\s*,\s*(\w+))?\s*$/;
230
+ export const ITERATION_REGEX = /^each\s+(.+)\s+as\s+(\w+)(?:\s*\(\s*([^)]+?)\s*\))?(?:\s*,\s*(\w+))?(?:\s*\(\s*([^)]+?)\s*\))?\s*$/;
226
231
 
227
232
  // Regex for detecting start of iteration comment
228
233
  export const ITERATION_START_REGEX = /^each\s+/;
@@ -1,5 +1,24 @@
1
1
  // Utility functions for array iteration
2
- import { ITERATION_START_REGEX, CONDITIONAL_START_REGEX } from './constants.js';
2
+ import { ITERATION_REGEX, ITERATION_START_REGEX, CONDITIONAL_START_REGEX } from './constants.js';
3
+
4
+ // Parse an `each` directive body (the text inside `<!-- ... -->`, markers
5
+ // stripped) into its parts, or null when it isn't a valid each. The index alias
6
+ // and the (key) expression are both optional and may be written in either order
7
+ // — `as item, i (item.id)` and `as item (item.id), i` are equivalent. Single
8
+ // source of truth for the grammar, used by the runtime parser and (via DOM
9
+ // re-parse of the restored markers) by compiled pages.
10
+ export const parseIterationHeader = (text) => {
11
+ const match = text.match(ITERATION_REGEX);
12
+ if (!match) return null;
13
+ const [, arrayPath, itemAlias, keyBeforeIndex, indexAlias, keyAfterIndex] = match;
14
+ const keyExpr = keyBeforeIndex ?? keyAfterIndex;
15
+ return {
16
+ arrayPath,
17
+ itemAlias,
18
+ keyExpr: keyExpr != null ? keyExpr.trim() : null,
19
+ indexAlias: indexAlias ?? null,
20
+ };
21
+ };
3
22
 
4
23
  // Resolve nested paths in state (e.g., "user.items" -> state.user.items)
5
24
  // Supports bracket notation: "teams[0].combatants" -> state.teams[0].combatants
package/runtime/parse.js CHANGED
@@ -1,8 +1,7 @@
1
- import { findEndComment, findConditionalEnd } from './iteration-utils.js';
1
+ import { findEndComment, findConditionalEnd, parseIterationHeader } from './iteration-utils.js';
2
2
  import {
3
3
  NON_REACTIVE_ELEMENTS,
4
4
  BINDING_REGEX,
5
- ITERATION_REGEX,
6
5
  CONDITIONAL_REGEX,
7
6
  DOM_ELEMENT_PROPERTIES,
8
7
  DEHYDRATE_CLASS_OR_ATTR,
@@ -150,10 +149,10 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
150
149
 
151
150
  // Handle iteration comments
152
151
  if (nodeName === '#comment') {
153
- const iterationMatch = textContent.trim().match(ITERATION_REGEX);
152
+ const iterationMatch = parseIterationHeader(textContent.trim());
154
153
 
155
154
  if (iterationMatch) {
156
- const [_, arrayPath, itemAlias, keyExpr, indexAlias] = iterationMatch;
155
+ const { arrayPath, itemAlias, keyExpr, indexAlias } = iterationMatch;
157
156
 
158
157
  try {
159
158
  // Find matching end comment