@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 +78 -0
- package/README.md +1 -1
- package/ROADMAP.md +45 -0
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/Cargo.lock +1 -1
- package/compiler/src/Cargo.toml +1 -1
- package/compiler/src/compiler/compile.rs +100 -13
- package/compiler/src/compiler/iteration_optimizer.rs +188 -112
- package/compiler/src/compiler/manifest_builder.rs +5 -9
- package/compiler/src/parser/html.rs +194 -31
- package/package.json +1 -1
- package/runtime/cleanup.js +12 -3
- package/runtime/component.js +130 -43
- package/runtime/conditionals.js +16 -9
- package/runtime/hydrate.js +12 -3
- package/runtime/index.js +23 -2
- package/runtime/iterate.js +103 -22
- package/runtime/loop-scope.js +49 -3
- package/runtime/parse.js +8 -0
- package/runtime/pre-compiled-manifest.js +56 -155
- package/runtime/utils.js +12 -2
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.
|
|
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*
|
|
Binary file
|
|
Binary file
|
package/compiler/src/Cargo.lock
CHANGED
package/compiler/src/Cargo.toml
CHANGED
|
@@ -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:
|
|
1591
|
-
|
|
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
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
//
|
|
127
|
-
let binding_re = Regex::new(r"@\[([^\]]+)\]").unwrap();
|
|
128
|
-
let with_bindings = binding_re.replace_all(&processed_template, |caps: ®ex::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
|
|
186
|
+
let cleaned = boolean_attr_re.replace_all(template, "$1");
|
|
136
187
|
|
|
137
|
-
|
|
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 += `{
|
|
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
|
-
|
|
194
|
+
body = body
|
|
147
195
|
)
|
|
148
196
|
}
|
|
149
197
|
|
|
150
|
-
///
|
|
151
|
-
///
|
|
152
|
-
|
|
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
|
-
|
|
213
|
-
let mut
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
let
|
|
218
|
-
let
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
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#"
|