@ape-egg/vibe 2.0.0 → 2.0.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 CHANGED
@@ -1,5 +1,83 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.0.5] - 2026-06-16
4
+
5
+ ### Fixed
6
+
7
+ - **Name bindings routed through a component prop inside an iteration were silently dropped** (`runtime/iterate.js`, `runtime/component.js`) — `resolveIterationComponentProps` injects `@[window.__vibeIterProps._pN]` into a component's bindings, and prop substitution carries that accessor into the template's own bindings, including name-bindings (`<icon @[props.element]>`). HTML lowercases attribute names, so the camelCase global arrived at hydrate as `window.__vibeiterprops` and resolved to `undefined`, dropping the attribute. The iteration-prop registry global is now all-lowercase (`__vibeiterprops`) so it survives attribute-name normalization. Adds the `name-binding-prop` e2e fixture and spec.
8
+
9
+ ## [2.0.4] - 2026-06-12
10
+
11
+ Companion-package and website release; no runtime changes.
12
+
13
+ ### Fixed
14
+
15
+ - **Codie treated vibe name bindings as broken attributes** (`@ape-egg/codie`) — `cleanupBooleanAttrs` now recognizes `@[expr]` as an attribute name (so `<icon @[icon]=""></icon>` cleans up to `<icon @[icon]></icon>`), and the HTML highlighter tokenizes opening tags containing name-binding attributes instead of leaving them unhighlighted.
16
+ - **Codie's editing textarea inherited host design-system styling** (`@ape-egg/codie`) — the textarea reset now also clears `box-shadow`, `border-radius` and `background`, so CSS libraries that style `textarea` globally (e.g. stylecheat's inset ring) can't bleed into the editor.
17
+
18
+ ### Changed
19
+
20
+ - Website: the fantasy tutorial is replaced by the interactive **Try & learn** lessons (live Codie editor on every page), a **Roadmap** page is added, toast notifications land site-wide, dark mode is the default, and the compiler status is promoted from alpha to **beta**.
21
+
22
+ ## [2.0.3] - 2026-06-11
23
+
24
+ Compiler parity with the 2.0.x runtime: all six findings from
25
+ `get-compiler-up-to-date-with-vibe-runtime-2.0.0.md` are fixed and the doc is
26
+ retired. Every former parity-gap opt-out in the e2e suite now runs dual-mode
27
+ (644 e2e tests, both runtime and compiled). Compiler bumped 1.7.2 → 1.8.0.
28
+
29
+ ### Fixed
30
+
31
+ - **Compiled component props only substituted exact `@[prop]` bindings** (`compiler/src/parser/html.rs`) — the inliner now mirrors the runtime's `renderPropsAndSlot` exactly: case-insensitive `@[propName]` replacement, prop identifiers inside larger `@[expr]` expressions and directive comments (`if` / `else if` / `each`), `$.propName` references in event-handler bodies, bare boolean attributes (→ `true`), JSON-quoted string literals, raw numerics. Binding props (`prop="@[path]"`) rewrite identifiers to the bound path, so prop reactivity flows in both directions on compiled pages (parent→child re-render, child→parent handler writes). One shared `substitute_props` serves all three inline sites; `src`/`class` are excluded from props.
32
+ - Tests: `component-props.spec.js` (all four suites), `components-in-iterations.spec.js` (all five suites) — now `testBothModes`
33
+
34
+ - **Compiled iteration batch functions stamped boolean attribute bindings as strings** (`compiler/src/compiler/iteration_optimizer.rs`) — `open="@[flag]"` rendered `open="false"`, which is "open" to HTML and `[open]`-style CSS. The generator now classifies attribute bindings like the runtime batch path: DOM properties and value-style attributes keep `attr="${expr}"`, boolean-coerced attributes emit conditional presence (`${(expr) ? ' attr=""' : ''}`). Rust-side `VALUE_ATTRS`/`DOM_PROPERTIES` mirror `runtime/constants.js`; the template emitter was unified into one recursive pass that also escapes backticks and `${` in static text.
35
+ - Test: `directive-nesting.spec.js` → "Outer-scope reactivity inside `<!-- each -->`" — now `testBothModes`
36
+
37
+ - **`@[$.unsafe(...)]` inside a compiled iteration crashed boot** (`compiler/src/compiler/iteration_optimizer.rs`) — the generated batch function interpolated `$.unsafe(...)` against a plain `$` (uncaught TypeError; `$.ready` never resolved), and a template-literal interpolation can never honor raw-HTML semantics anyway (`RawHtml.toString()` escapes by design). Templates containing `$.unsafe(` no longer get a batch function — those iterations render through clone+hydrate, the one place that implements `$.unsafe` (innerHTML + inert subtree).
38
+ - Test: `hydration-optouts.spec.js` → "Raw HTML rendering via $.unsafe()" — now `testBothModes`
39
+
40
+ - **Manifest restoration templates dropped whitespace text nodes** (`compiler/src/compiler/manifest_builder.rs`) — the manifest's child keys are childNodes indices computed on the pre-stamp DOM, but restoration re-inserted templates with whitespace-only text nodes filtered out, shifting every later sibling's index. Iterations/conditionals after a restored region never got wired to their DOM markers, crashing boot with "Cannot read properties of undefined (reading '__vibeRendered')" on prop-driven compiled pages. Templates are now captured verbatim — an exact node-count round-trip.
41
+
42
+ - **Restoration located directives by expression text with a subtree-wide scan** (`runtime/pre-compiled-manifest.js`) — two conditionals sharing an expression cross-matched: the outer's template landed inside the inner's markers (multi-root component scope broke this way). Directive regions are now located by their manifest key's childNodes index and processed in ascending order, so each restoration revalidates the next index. Same-expression siblings, conditionals inside iterations, and nested regions all disambiguate structurally; the `insideIteration`/`_vibeProcessed` scanning heuristics are gone.
43
+
44
+ - **Compiled pages executed inlined component scripts as native page modules** (`compiler/src/parser/html.rs`, `runtime/component.js`, `runtime/index.js`) — native timing is wrong on every axis: scripts ran pre-boot so `const id = component(state); $[id]` captured the placeholder `$`, scripts with imports registered state after boot with nothing merging it into the live proxy, and `$.ready` raced async registration. The compiler now neuters src-inlined component scripts to `<script type="vibe-module">`; the runtime executes them at boot through the same injected-`component()` path fetched scripts use (shared `transformScriptContent`): live `$`, componentId claimed from the build-tagged wrapper, listener cleanup on re-execution, and async scripts gate `$.ready`. Scripts stay in the DOM (inert) so manifest indices keep matching.
45
+ - Tests: `component-state.spec.js` → "Async component() with child prop binding", "component() returns componentId", "Multi-root component scope" — now `testBothModes`
46
+
47
+ - **`$.this.X` in event handlers double-rewrote on compiled pages** (`runtime/parse.js`) — runtime-fetched components arrive with `$.this.` already rewritten by component.js, but compiled pages reach parse with the authored form, and the bare-`this.X` pass alone produced `$.$['id'].X` (TypeError on click). parse now consumes `$.this.X` as one reference before the bare-`this.X` pass.
48
+
49
+ - **`$` snapshot fallback leaked state reads to the live root** (`runtime/utils.js`) — `dollarFor`'s helper fallback served any key missing from a snapshot, so old/new diff evaluations both read the live value for a component state bucket registered mid-cycle and change detection saw "no change" (a `<!-- if this.x -->` in an async compiled component never mounted). The fallback now serves only the root's non-enumerable helper methods (`unsafe`, `on`, `reconcile`, …); a state key missing from a snapshot reads `undefined`, as the diff requires.
50
+
51
+ - **Batch path rewrote a text binding on its own line as a name binding** (`runtime/iterate.js`) — `BATCH_NAME_BINDING_REGEX` matched any whitespace-bounded `@[expr]`, so a prettier-formatted `<option>` with `@[item]` on its own line rendered ` item=""` as its text. The name-binding pass now runs only over tag spans (quote-aware `mapTagSpans` — name bindings can only exist inside a tag), and binding-bearing text nodes are trimmed in the batch template to match the clone path's trimmed writes.
52
+ - Test: `batch-vs-clone-equivalence.spec.js` → dom-prop-selected region (and all other regions still byte-identical)
53
+
54
+ ### Changed
55
+
56
+ - **Compiler 1.7.2 → 1.8.0** — prop-substitution parity, component-script neutering (`vibe-module`), verbatim restoration templates, boolean-attribute classification in batch functions, `$.unsafe` batch opt-out.
57
+
58
+ ---
59
+
60
+ ## [2.0.2] - 2026-06-11
61
+
62
+ ### Fixed
63
+
64
+ - **Unmounting a conditional left nested-directive content behind** (`runtime/conditionals.js`) — `unmountBranch` removed only `activeInstance.nodes`, the branch's originally cloned top-level nodes. Content rendered by nested directives at the branch's top level — `<!-- each -->` rows, deeper `<!-- if -->` branches — is inserted between the *nested* directive's own comment markers after the branch mounts, so it never appears in that list and survived the unmount. Every off/on toggle of the outer conditional then accumulated another copy of the nested content. `unmountBranch` now sweeps the live range between the conditional's start/end comments (same pattern as iteration teardown), still collecting componentIds before detaching so orphaned component state is released.
65
+ - Test: `tests/e2e/each-inside-if-toggle.spec.js` (nested each rows + nested if content removed on toggle-off; repeated off/on toggles don't duplicate)
66
+
67
+ - **`checked`/`selected` bindings stringified into the attribute** (`runtime/hydrate.js`, `runtime/iterate.js`) — DOM-property bindings mirrored every value into the attribute as a string, so `checked="@[on]"` with falsy state produced `checked="false"` — which is *checked* to HTML and `[checked]`-style CSS, and lied in `outerHTML` snapshots. `checked`/`selected` now keep the canonical boolean attribute form: present (empty) when truthy, removed when falsy; the property still follows the state. Only `value` keeps the stringified attribute mirror. Applied in both the clone path (`hydrate.js`) and the batch path (`applyDomPropertyWrites` in `iterate.js`) so runtime and batch-rendered iterations agree.
68
+ - Test: `tests/e2e/checked-attribute-sync.spec.js` (absent when falsy, empty-present when truthy, removed on truthy→falsy, property always synced)
69
+
70
+ ---
71
+
72
+ ## [2.0.1] - 2026-06-10
73
+
74
+ ### Fixed
75
+
76
+ - **Loop-alias rewriter mangled object-literal keys in inline handlers** (`runtime/loop-scope.js`) — `rewriteHandlerAliases` rewrote every standalone alias identifier, including ones in object-key position, so `onclick="f({ pack: pack.name })"` inside `<!-- each soundPacks as pack -->` hydrated to `f({ $scope(this,'pack'): $scope(this,'pack').name })` — a syntax error, and the click silently did nothing. The rewriter now tracks bracket frames with a pending-ternary count per nesting level, which is what tells an object key's `:` apart from a ternary's: an alias followed by `:` inside `{ }` with no open ternary is a key and stays put, and a shorthand property (`{ pack }`) expands to `{ pack: $scope(this,'pack') }`. Ternary branches (`go ? pack : null`), computed keys (`{ [pack]: 1 }`), and array elements keep rewriting as values.
77
+ - Test: `tests/unit/loop-scope.test.js` (key position, shorthand expansion, computed keys, ternary colons inside object values, arrays, optional chaining/nullish coalescing)
78
+
79
+ ---
80
+
3
81
  ## [2.0.0] - 2026-06-06
4
82
 
5
83
  Version rolled to 2.0.0. **Still Beta — no behavior changes since 1.9.9.** The major bump consolidates the 1.9.x line (runtime reactive core, components, optional compiler, `$.unsafe` raw-HTML, plus the recent reactivity-correctness, iteration teardown-leak, and scoped-state performance fixes); it does not signal a stability promotion or any breaking change.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Vibe
2
2
 
3
- **Version 2.0.0 (Beta)** — A runtime-first reactive framework with optional compilation.
3
+ **Version 2.0.2 (Beta)** — A runtime-first reactive framework with optional compilation.
4
4
 
5
5
  No virtual DOM. No build step required. Just modern JavaScript. When you need production optimizations, add the optional Rust-based compiler.
6
6
 
package/ROADMAP.md CHANGED
@@ -278,6 +278,51 @@ This is acceptable technical debt since:
278
278
 
279
279
  ---
280
280
 
281
+ ## Proposed: `settled` Lifecycle Event (app quiescence)
282
+
283
+ **Status**: Proposal
284
+ **Priority**: Medium
285
+ **Category**: Core Runtime / Lifecycle
286
+ **Discovered**: 2026-06-16 (battle-brawlers: boss-unlock toast on the arena page)
287
+
288
+ ### Problem
289
+
290
+ There is no event for *"the app has finished its initial boot/render churn and is now idle — run post-boot side-effects here."* The existing hooks don't cover it:
291
+
292
+ - **`ready`** (`PHASE_READY`, removes `[vibe-fouc]` + dispatches `vibe:ready`) fires after the **first** render — but that first render is the *dataless/initial* state. Async data (e.g. a socket-loaded game state) arrives *after* `ready` and triggers a second, often **multi-second** data-driven re-render. So `ready` is "first paint," not "settled."
293
+ - **`afterUpdate`** fires on **every** flush — it's a per-change signal, not a one-shot "we've quiesced" signal, and it fires *during* the churn.
294
+
295
+ Consequently, state set imperatively right after boot is unreliable. Concrete case: pushing a toast (`$.notifications.push(...)`) when game state loads — the toast is added before the heavy first data render paints, the main thread is blocked through its 4s auto-dismiss window, and it's removed unseen. "The heavy boot churn eats it."
296
+
297
+ ### What it would accomplish
298
+
299
+ A one-time, post-quiescence hook so consumers can reliably run boot-time side-effects: toasts/announcements, focus management, scroll restoration, analytics "app interactive", third-party widget init — anything that must happen *after* the initial render storm rather than during it.
300
+
301
+ ```js
302
+ $.on('settled', () => notify.success('…')); // lands; paints; lives its full duration
303
+ ```
304
+
305
+ ### Proposed shape
306
+
307
+ `settled` fires **once per page load**, after: (a) Vibe's reactive flush queue has drained, and (b) the browser reports a free frame. Implementation sketch: when a flush completes with no further flush queued, schedule `requestIdleCallback(cb, { timeout })` (with a `setTimeout` fallback for Safari < 17); if still no flush was queued when it fires, dispatch `settled`.
308
+
309
+ **Honest caveat — it's a heuristic, not a guarantee.** Perpetual tickers make true quiescence undecidable (battle-brawlers runs a 1s `setInterval` clock that flushes forever). This is the same class of fuzziness as Playwright's `networkidle` ("no requests for 500ms" — which Playwright now discourages as flaky). `settled` should mean "drained + one idle frame," documented as best-effort. Vibe *can* reliably know its own flush queue is empty; only external async (sockets/timers) is fuzzy, and the idle check bounds that.
310
+
311
+ ### Current workaround
312
+
313
+ Hand-roll the idle wait at the call site with `requestIdleCallback`. See `webdev/webdev-game-stack/pages/the-arena.html` (boss-unlock toast): defer the toast to `requestIdleCallback({ timeout })` so it lands after the arena's heavy first render. Works, but every consumer re-implements it and must know it's needed.
314
+
315
+ ### Related (accurate precedents)
316
+
317
+ - **Angular** — `ApplicationRef.isStable` (Observable) / `NgZone.onStable`: emits when the app has no pending micro/macro tasks. This is the genuine *app-level quiescence* analog to `settled`.
318
+ - **Vue** — `nextTick()`: resolves after the current reactive flush → DOM updated. *Flush-level* (one flush), not full idle.
319
+ - **Svelte** — `tick()`: resolves after pending state is applied to the DOM. Also flush-level.
320
+ - **React** — no first-class app-settled signal; composed from `useEffect` + `requestIdleCallback`, or inferred via concurrent features (`useTransition` `isPending`, Suspense).
321
+
322
+ **Not this:** `onMount` (Svelte) / `onMounted` (Vue) / `useEffect(fn, [])` (React) are **component-level "this component finished mounting"** hooks. They are *not* an app-wide quiescence signal and are not what `settled` proposes — don't model `settled` on them.
323
+
324
+ ---
325
+
281
326
  ## Future Proposals
282
327
 
283
328
  *This section reserved for additional feature proposals*
@@ -1599,7 +1599,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
1599
1599
 
1600
1600
  [[package]]
1601
1601
  name = "vibe-compiler"
1602
- version = "1.7.2"
1602
+ version = "1.8.0"
1603
1603
  dependencies = [
1604
1604
  "clap",
1605
1605
  "colored",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "vibe-compiler"
3
- version = "1.7.2"
3
+ version = "1.8.0"
4
4
  edition = "2021"
5
5
  description = "Vibe framework compiler - compiles Vibe source files into optimized output"
6
6
  authors = ["Kim Korte"]
@@ -1587,24 +1587,99 @@ impl Compiler {
1587
1587
  }
1588
1588
 
1589
1589
  fn validate_html(&self, content: &str, path: &Path) -> Result<(), CompileError> {
1590
- // Check 1: Quote balance per line
1591
- let mut line_num = 1;
1592
- for line in content.lines() {
1593
- let quote_count = line.matches('"').count();
1594
- if quote_count % 2 != 0 {
1595
- return Err(CompileError::ValidationError {
1596
- file: path.display().to_string(),
1597
- line: line_num,
1598
- message: "Unclosed quote".to_string(),
1599
- });
1600
- }
1601
- line_num += 1;
1602
- }
1590
+ // Check 1: Unclosed attribute quotes
1591
+ self.validate_quote_balance(content, path)?;
1603
1592
 
1604
1593
  // Check 2: Tag balance (unclosed elements break slot extraction)
1605
1594
  self.validate_tag_balance(content, path)
1606
1595
  }
1607
1596
 
1597
+ /// Quotes only matter inside tags and attribute values may span lines;
1598
+ /// comments and raw content (script, style, pre) are skipped entirely.
1599
+ fn validate_quote_balance(&self, content: &str, path: &Path) -> Result<(), CompileError> {
1600
+ const RAW_CONTENT: &[&str] = &["script", "style", "pre"];
1601
+
1602
+ let bytes = content.as_bytes();
1603
+ let mut i = 0;
1604
+ let mut line = 1;
1605
+
1606
+ while i < bytes.len() {
1607
+ if bytes[i] == b'\n' {
1608
+ line += 1;
1609
+ i += 1;
1610
+ } else if bytes[i..].starts_with(b"<!--") {
1611
+ match find_bytes(bytes, b"-->", i + 4) {
1612
+ Some(end) => {
1613
+ line += count_newlines(&bytes[i..end + 3]);
1614
+ i = end + 3;
1615
+ }
1616
+ None => break,
1617
+ }
1618
+ } else if bytes[i] == b'<'
1619
+ && i + 1 < bytes.len()
1620
+ && (bytes[i + 1].is_ascii_alphabetic() || bytes[i + 1] == b'/' || bytes[i + 1] == b'!')
1621
+ {
1622
+ let is_closing = bytes[i + 1] == b'/';
1623
+ let name_start = i + if is_closing { 2 } else { 1 };
1624
+ let name_end = bytes[name_start..]
1625
+ .iter()
1626
+ .position(|b| !(b.is_ascii_alphanumeric() || *b == b'-'))
1627
+ .map_or(bytes.len(), |p| name_start + p);
1628
+ let name = content[name_start..name_end].to_ascii_lowercase();
1629
+ i = name_end;
1630
+
1631
+ let mut quote: Option<(u8, usize)> = None;
1632
+ let mut self_closing = false;
1633
+ while i < bytes.len() {
1634
+ let b = bytes[i];
1635
+ if b == b'\n' {
1636
+ line += 1;
1637
+ }
1638
+ match quote {
1639
+ Some((q, _)) if b == q => quote = None,
1640
+ Some(_) => {}
1641
+ None => match b {
1642
+ b'"' | b'\'' => quote = Some((b, line)),
1643
+ b'>' => {
1644
+ self_closing = bytes[i - 1] == b'/';
1645
+ break;
1646
+ }
1647
+ _ => {}
1648
+ },
1649
+ }
1650
+ i += 1;
1651
+ }
1652
+
1653
+ if let Some((_, quote_line)) = quote {
1654
+ return Err(CompileError::ValidationError {
1655
+ file: path.display().to_string(),
1656
+ line: quote_line,
1657
+ message: "Unclosed quote".to_string(),
1658
+ });
1659
+ }
1660
+ if i >= bytes.len() {
1661
+ break;
1662
+ }
1663
+ i += 1;
1664
+
1665
+ if !is_closing && !self_closing && RAW_CONTENT.contains(&name.as_str()) {
1666
+ let close = format!("</{}", name);
1667
+ match find_bytes_ci(bytes, close.as_bytes(), i) {
1668
+ Some(pos) => {
1669
+ line += count_newlines(&bytes[i..pos]);
1670
+ i = pos;
1671
+ }
1672
+ None => break, // unterminated raw block — tag balance reports it
1673
+ }
1674
+ }
1675
+ } else {
1676
+ i += 1;
1677
+ }
1678
+ }
1679
+
1680
+ Ok(())
1681
+ }
1682
+
1608
1683
  fn validate_tag_balance(&self, content: &str, path: &Path) -> Result<(), CompileError> {
1609
1684
  const VOID_ELEMENTS: &[&str] = &[
1610
1685
  "area", "base", "br", "col", "embed", "hr", "img", "input",
@@ -1892,3 +1967,15 @@ fn minify_html(html: &str) -> String {
1892
1967
 
1893
1968
  result.trim().to_string()
1894
1969
  }
1970
+
1971
+ fn find_bytes(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
1972
+ haystack[from..].windows(needle.len()).position(|w| w == needle).map(|p| p + from)
1973
+ }
1974
+
1975
+ fn find_bytes_ci(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
1976
+ haystack[from..].windows(needle.len()).position(|w| w.eq_ignore_ascii_case(needle)).map(|p| p + from)
1977
+ }
1978
+
1979
+ fn count_newlines(bytes: &[u8]) -> usize {
1980
+ bytes.iter().filter(|&&b| b == b'\n').count()
1981
+ }
@@ -60,6 +60,16 @@ fn extract_and_compile_iterations(html: &str) -> HashMap<String, CompiledIterati
60
60
  continue;
61
61
  }
62
62
 
63
+ // Raw-HTML bindings can't ride a template literal: a batch function
64
+ // interpolates RawHtml via toString(), which escapes. Skip the batch
65
+ // function so the runtime renders these rows through clone+hydrate —
66
+ // the one place that implements $.unsafe semantics (innerHTML +
67
+ // inert subtree).
68
+ if template_html.contains("$.unsafe(") {
69
+ search_start = end_after;
70
+ continue;
71
+ }
72
+
63
73
  // Generate hash for this template
64
74
  let hash = generate_template_hash(template_html);
65
75
 
@@ -113,6 +123,54 @@ fn generate_template_hash(template: &str) -> String {
113
123
  format!("iter_{:x}", hash)
114
124
  }
115
125
 
126
+ /// DOM element properties — set on the element, kept as value attributes in
127
+ /// batch output. Mirrors DOM_PROPERTIES in runtime/constants.js.
128
+ const DOM_PROPERTIES: &[&str] = &["value", "checked", "selected"];
129
+
130
+ /// Attributes that keep their string value verbatim (never boolean-coerced).
131
+ /// Mirrors VALUE_ATTRS in runtime/constants.js — the two lists must stay in
132
+ /// lockstep or batch and clone paths render different attributes.
133
+ const VALUE_ATTRS: &[&str] = &[
134
+ "class", "style", "id", "title", "lang", "dir", "tabindex", "accesskey",
135
+ "slot", "part", "is", "nonce", "popover", "anchor",
136
+ "contenteditable", "draggable", "spellcheck", "translate",
137
+ "autocapitalize", "inputmode", "enterkeyhint", "virtualkeyboardpolicy",
138
+ "href", "src", "action", "cite", "data", "poster", "srcset",
139
+ "imagesrcset", "formaction", "ping", "usemap", "manifest", "codebase",
140
+ "name", "type", "value", "placeholder", "pattern", "min", "max", "step",
141
+ "minlength", "maxlength", "size", "accept", "autocomplete", "list",
142
+ "form", "formmethod", "formtarget", "formenctype", "wrap", "method",
143
+ "enctype", "for", "dirname",
144
+ "alt", "label", "summary", "abbr",
145
+ "width", "height", "cols", "rows", "span", "rowspan", "colspan",
146
+ "low", "high", "optimum",
147
+ "target", "rel", "hreflang", "download", "as", "media", "charset",
148
+ "crossorigin", "integrity", "loading", "decoding", "fetchpriority",
149
+ "referrerpolicy", "blocking", "imagesizes", "sizes",
150
+ "preload", "kind", "srclang",
151
+ "content", "http-equiv",
152
+ "sandbox", "allow", "srcdoc", "credentialless",
153
+ "headers", "scope",
154
+ "datetime",
155
+ "coords", "shape",
156
+ ];
157
+
158
+ /// Whether an attribute keeps its string value (vs boolean coercion).
159
+ /// Mirrors isValueStyleAttr in runtime/iterate.js.
160
+ fn is_value_style_attr(name: &str) -> bool {
161
+ VALUE_ATTRS.contains(&name)
162
+ || name.starts_with("data-")
163
+ || name.starts_with("aria-")
164
+ || name.starts_with("on")
165
+ }
166
+
167
+ /// Escape a static text chunk for a JS template-literal context.
168
+ fn escape_tpl_text(text: &str) -> String {
169
+ text.replace('\\', "\\\\")
170
+ .replace('`', "\\`")
171
+ .replace("${", "\\${")
172
+ }
173
+
116
174
  /// Compile template HTML to a batch function string
117
175
  /// Supports nested <!-- each --> blocks
118
176
  fn compile_template_to_batch_fn(
@@ -120,134 +178,120 @@ fn compile_template_to_batch_fn(
120
178
  item_alias: &str,
121
179
  index_alias: &str,
122
180
  ) -> String {
123
- // Process nested iterations first
124
- let processed_template = process_nested_iterations(template);
125
-
126
- // Replace @[expr] with ${expr}
127
- let binding_re = Regex::new(r"@\[([^\]]+)\]").unwrap();
128
- let with_bindings = binding_re.replace_all(&processed_template, |caps: &regex::Captures| {
129
- format!("${{{}}}", &caps[1])
130
- });
131
-
132
- // Fix boolean attributes: remove ="" from attributes to match CSS selectors
133
- // Converts: <div attr=""> to <div attr>
181
+ // Normalize static boolean attributes: <div attr=""> → <div attr>
182
+ // (matches CSS attribute selectors). Runs on raw template text, before
183
+ // emission — emitted JS below also contains attr="" inside string
184
+ // literals, which must not be touched.
134
185
  let boolean_attr_re = Regex::new(r#"(\w+)="""#).unwrap();
135
- let with_boolean_attrs = boolean_attr_re.replace_all(&with_bindings, "$1");
186
+ let cleaned = boolean_attr_re.replace_all(template, "$1");
136
187
 
137
- // Escape backslashes only (backticks are fine in JSON strings)
138
- let escaped = with_boolean_attrs
139
- .replace('\\', "\\\\");
188
+ let body = emit_template_literal(&cleaned);
140
189
 
141
- // Generate batch function
142
190
  format!(
143
- r#"(arr, $) => {{ let html = ''; const len = arr.length; for (let {index} = 0; {index} < len; {index}++) {{ const {item} = arr[{index}]; html += `{template}`; }} return html; }}"#,
191
+ r#"(arr, $) => {{ let html = ''; const len = arr.length; for (let {index} = 0; {index} < len; {index}++) {{ const {item} = arr[{index}]; html += `{body}`; }} return html; }}"#,
144
192
  index = index_alias,
145
193
  item = item_alias,
146
- template = escaped
194
+ body = body
147
195
  )
148
196
  }
149
197
 
150
- /// Process nested <!-- each --> blocks recursively
151
- /// Replaces nested iterations with inline loop code
152
- fn process_nested_iterations(template: &str) -> String {
198
+ /// Emit the body of a JS template literal for a template chunk:
199
+ /// - static text escaped for template-literal context
200
+ /// - attribute pure bindings classified like hydrate.js / compileBatchFn:
201
+ /// DOM properties and value-style attributes keep `attr="${expr}"`,
202
+ /// boolean-coerced attributes become `${(expr) ? ' attr=""' : ''}` so the
203
+ /// attribute is ABSENT when falsy and present-empty when truthy
204
+ /// - remaining @[expr] bindings (text content, partial attribute values)
205
+ /// become `${expr}` interpolations
206
+ /// - nested <!-- each --> blocks become inline IIFE loops, recursively
207
+ ///
208
+ /// Anything the runtime clone+hydrate path renders, this output has to render
209
+ /// identically — it is the compiled twin of compileBatchFn in runtime/iterate.js.
210
+ fn emit_template_literal(template: &str) -> String {
153
211
  let each_re = Regex::new(r"<!--\s*each\s+([^\s]+)\s+as\s+([^\s,]+)(?:\s*,\s*([^\s]+))?\s*-->").unwrap();
154
212
  let end_re = Regex::new(r"<!--\s*/each\s*-->").unwrap();
155
-
156
- let mut result = template.to_string();
157
- let mut replacements = Vec::new();
158
-
159
- // Find all nested iterations
160
- let mut search_start = 0;
161
- while let Some(start_match) = each_re.find_at(&result, search_start) {
162
- let captures = each_re.captures(&result[start_match.start()..]).unwrap();
163
- let array_path = captures.get(1).unwrap().as_str();
164
- let item_alias = captures.get(2).unwrap().as_str();
165
- let index_alias = captures.get(3).map(|m| m.as_str()).unwrap_or("index");
166
-
167
- let template_start = start_match.end();
168
-
169
- // Find matching <!-- /each --> using depth counting
170
- if let Some((end_pos, _)) = find_matching_each_end(&result, template_start) {
171
- let inner_template = &result[template_start..end_pos];
172
-
173
- // Recursively process inner template
174
- let processed_inner = process_nested_iterations(inner_template);
175
-
176
- // For nested iterations, convert to string concatenation
177
- // Uses JSON string escaping which is valid JavaScript
178
- let inner_concat_code = convert_to_string_concat(&processed_inner, item_alias, index_alias, array_path);
179
-
180
- let nested_code = format!("${{(() => {{ let inner = ''; const len_{idx} = {arr}.length; for (let {idx} = 0; {idx} < len_{idx}; {idx}++) {{ const {item} = {arr}[{idx}]; {code} }} return inner; }})()}}",
181
- idx = index_alias,
182
- arr = array_path,
183
- item = item_alias,
184
- code = inner_concat_code
185
- );
186
-
187
- // Store replacement (from start to end including comments)
188
- let end_match = end_re.find_at(&result, end_pos).unwrap();
189
- replacements.push((start_match.start(), end_match.end(), nested_code));
190
-
191
- search_start = end_match.end();
192
- } else {
193
- break;
194
- }
195
- }
196
-
197
- // Apply replacements in reverse order to maintain positions
198
- for (start, end, replacement) in replacements.iter().rev() {
199
- result.replace_range(*start..*end, replacement);
200
- }
201
-
202
- result
203
- }
204
-
205
- /// Convert template to string concatenation code using template literals
206
- /// Parses @[expr] and converts to: inner += `text${expr}more text`;
207
- /// Template literals properly handle newlines without escaping
208
- /// NOTE: Backslashes and ${ need escaping, but backticks don't (they'll be in JSON)
209
- fn convert_to_string_concat(template: &str, _item_alias: &str, _index_alias: &str, _array_path: &str) -> String {
213
+ let attr_binding_re = Regex::new(r#"(\s)([\w-]+)="@\[([^\]]+)\]""#).unwrap();
210
214
  let binding_re = Regex::new(r"@\[([^\]]+)\]").unwrap();
211
215
 
212
- // Build template literal with ${} expressions
213
- let mut result = String::from("inner += `");
214
- let mut last_end = 0;
215
-
216
- for cap in binding_re.captures_iter(template) {
217
- let match_start = cap.get(0).unwrap().start();
218
- let match_end = cap.get(0).unwrap().end();
219
- let expr = &cap[1];
220
-
221
- // Add text before this binding
222
- if match_start > last_end {
223
- let text = &template[last_end..match_start];
224
- // For template literals: only escape backslashes and ${ sequences
225
- // Don't escape backticks - they're fine in JSON and we want them as-is
226
- let escaped = text
227
- .replace('\\', "\\\\") // Escape backslashes
228
- .replace("${", "\\${"); // Escape template literal expression markers
229
- result.push_str(&escaped);
216
+ let mut out = String::new();
217
+ let mut pos = 0;
218
+
219
+ while pos < template.len() {
220
+ let next_each = each_re.find_at(template, pos);
221
+ let next_attr = attr_binding_re.find_at(template, pos);
222
+ let next_binding = binding_re.find_at(template, pos);
223
+
224
+ // Earliest match wins; attr-binding outranks plain binding at the same
225
+ // region (the plain regex would match inside the attr form).
226
+ let candidates = [
227
+ next_each.map(|m| (m.start(), 0u8)),
228
+ next_attr.map(|m| (m.start(), 1u8)),
229
+ next_binding.map(|m| (m.start(), 2u8)),
230
+ ];
231
+ let Some(&(start, kind)) = candidates
232
+ .iter()
233
+ .flatten()
234
+ .min_by_key(|(s, k)| (*s, *k))
235
+ else {
236
+ out.push_str(&escape_tpl_text(&template[pos..]));
237
+ break;
238
+ };
239
+
240
+ out.push_str(&escape_tpl_text(&template[pos..start]));
241
+
242
+ match kind {
243
+ 0 => {
244
+ // Nested <!-- each --> → inline IIFE loop
245
+ let m = next_each.unwrap();
246
+ let caps = each_re.captures(&template[m.start()..]).unwrap();
247
+ let arr = caps.get(1).unwrap().as_str();
248
+ let item = caps.get(2).unwrap().as_str();
249
+ let idx = caps.get(3).map(|c| c.as_str()).unwrap_or("index");
250
+
251
+ if let Some((end_pos, _)) = find_matching_each_end(template, m.end()) {
252
+ let inner = emit_template_literal(&template[m.end()..end_pos]);
253
+ out.push_str(&format!(
254
+ "${{(() => {{ let inner = ''; const len_{idx} = {arr}.length; for (let {idx} = 0; {idx} < len_{idx}; {idx}++) {{ const {item} = {arr}[{idx}]; inner += `{inner_body}`; }} return inner; }})()}}",
255
+ idx = idx,
256
+ arr = arr,
257
+ item = item,
258
+ inner_body = inner
259
+ ));
260
+ let end_match = end_re.find_at(template, end_pos).unwrap();
261
+ pos = end_match.end();
262
+ } else {
263
+ // Unbalanced each — emit as text and move on
264
+ out.push_str(&escape_tpl_text(&template[m.start()..m.end()]));
265
+ pos = m.end();
266
+ }
267
+ }
268
+ 1 => {
269
+ // Attribute pure binding: attr="@[expr]"
270
+ let m = next_attr.unwrap();
271
+ let caps = attr_binding_re.captures(&template[m.start()..]).unwrap();
272
+ let ws = caps.get(1).unwrap().as_str();
273
+ let name = caps.get(2).unwrap().as_str();
274
+ let expr = caps.get(3).unwrap().as_str();
275
+ let name_lc = name.to_lowercase();
276
+
277
+ if DOM_PROPERTIES.contains(&name_lc.as_str()) || is_value_style_attr(&name_lc) {
278
+ out.push_str(&format!("{ws}{name}=\"${{{expr}}}\""));
279
+ } else {
280
+ out.push_str(&format!("${{({expr}) ? ' {name}=\"\"' : ''}}"));
281
+ }
282
+ pos = m.end();
283
+ }
284
+ _ => {
285
+ // Plain binding: text content or partial attribute value
286
+ let m = next_binding.unwrap();
287
+ let caps = binding_re.captures(&template[m.start()..]).unwrap();
288
+ out.push_str(&format!("${{{}}}", caps.get(1).unwrap().as_str()));
289
+ pos = m.end();
290
+ }
230
291
  }
231
-
232
- // Add expression
233
- result.push_str("${");
234
- result.push_str(expr);
235
- result.push_str("}");
236
-
237
- last_end = match_end;
238
- }
239
-
240
- // Add remaining text
241
- if last_end < template.len() {
242
- let text = &template[last_end..];
243
- let escaped = text
244
- .replace('\\', "\\\\")
245
- .replace("${", "\\${");
246
- result.push_str(&escaped);
247
292
  }
248
293
 
249
- result.push_str("`;");
250
- result
294
+ out
251
295
  }
252
296
 
253
297
  /// Find matching <!-- /each --> comment using depth counting
@@ -323,6 +367,38 @@ mod tests {
323
367
  assert_eq!(compiled.index_alias, "idx");
324
368
  }
325
369
 
370
+ #[test]
371
+ fn test_boolean_attr_binding_emits_conditional_presence() {
372
+ let html = r#"
373
+ <!-- each items as item -->
374
+ <attr-target open="@[flag]" href="@[item.url]" value="@[item.v]"></attr-target>
375
+ <!-- /each -->
376
+ "#;
377
+
378
+ let opts = build_iteration_optimizations(html);
379
+ let iterations = &opts.unwrap().iterations;
380
+ let (_, compiled) = iterations.iter().next().unwrap();
381
+
382
+ // Boolean-coerced attribute: absent when falsy, present-empty when truthy
383
+ assert!(compiled.batch_fn.contains(r#"${(flag) ? ' open=""' : ''}"#));
384
+ // Value-style attribute keeps its string value
385
+ assert!(compiled.batch_fn.contains(r#"href="${item.url}""#));
386
+ // DOM property keeps its value form
387
+ assert!(compiled.batch_fn.contains(r#"value="${item.v}""#));
388
+ }
389
+
390
+ #[test]
391
+ fn test_unsafe_template_skips_batch_fn() {
392
+ let html = r#"
393
+ <!-- each rows as row -->
394
+ <li>@[$.unsafe(row.markup)]</li>
395
+ <!-- /each -->
396
+ "#;
397
+
398
+ // RawHtml semantics can't ride a template literal — no batch function
399
+ assert!(build_iteration_optimizations(html).is_none());
400
+ }
401
+
326
402
  #[test]
327
403
  fn test_nested_iteration_compiled() {
328
404
  let html = r#"