@ape-egg/vibe 1.9.7 → 1.9.8

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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.9.8] - 2026-06-01
4
+
5
+ ### Added
6
+
7
+ - **Raw-HTML rendering via `$.unsafe(expr)`** (`runtime/raw-html.js` (new), `runtime/hydrate.js`, `runtime/index.js`, `runtime/utils.js`, `compiler/src/compiler/value_stamper.rs`) — a binding that renders a trusted string as real markup instead of escaping it; Vibe's equivalent of Svelte `{@html}` / Vue `v-html` / React `dangerouslySetInnerHTML`. `$.unsafe(str)` wraps a string in a `RawHtml` marker. When the binding is the **sole content of its element** (`<p>@[$.unsafe(desc)]</p>`), the runtime sets `innerHTML` from the string and marks the injected subtree opaque (`managedNodes`) so the MutationObserver never re-walks it — injected markup is **inert**, matching Svelte. Mixed into surrounding text it falls back to escaped literal text (via the marker's `toString()`). Reactive in every scope (top-level, iterations, conditionals, components). Compiled mode paints the markup raw at stamp time (`value_stamper.rs` defines `$.unsafe` in the QuickJS context) while the manifest preserves the `@[$.unsafe(...)]` marker for runtime re-hydration. **Trusted input only — no sanitizing.**
8
+ - Test: `tests/unit/raw-html.test.js`, `tests/e2e/unsafe-html.spec.js` (pure render, escaped-text fallback, reactivity, in-iteration, in-conditional, inert injected markup), and `$.unsafe` stamping in `tests/compiler/runtime`.
9
+
10
+ ### Fixed
11
+
12
+ - **`$` inside expressions must read from the per-cycle state, not the live root** (`runtime/utils.js`) — surfaced while wiring `$.unsafe`. The reactivity engine detects change by evaluating each expression against an old snapshot and a new snapshot and comparing; binding `$` to the live root proxy made both reads identical, silently defeating change detection for any expression reading through `$` or `this.` (which compiles to `$['id']…`) — e.g. a computed iteration array or conditional gated on component state would stop re-rendering. `evalInScope` now resolves `$` to the same state object the diff cycle is evaluating, and reaches the root's non-enumerable helper methods (`unsafe`, `on`, `reconcile`) via a thin per-state fallback proxy so `$.unsafe` stays callable from plain snapshots on the update path. State reads stay on the snapshot (diffable); only missing method names fall through to root. Scoped states already delegate to root, so the hot path is unaffected.
13
+ - Test: `tests/unit/dollar-binding-scope.test.js` (a `$`-expression yields different values for two snapshots → diffable; `$.unsafe` resolves from a plain snapshot lacking it)
14
+
15
+ ---
16
+
3
17
  ## [1.9.7] - 2026-06-01
4
18
 
5
19
  ### Fixed
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Vibe
2
2
 
3
- **Version 1.9.2 (Beta)** — A runtime-first reactive framework with optional compilation.
3
+ **Version 1.9.8 (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
 
@@ -105,6 +105,26 @@ Vibe supports bindings in three positions:
105
105
  <!-- /each -->
106
106
  ```
107
107
 
108
+ The "array" position accepts any JS expression evaluated in scope, not just a state path:
109
+
110
+ ```html
111
+ <!-- each items.filter(i => i.active) as item -->...<!-- /each -->
112
+ <!-- each Array.from({ length: count }) as n, index -->...<!-- /each -->
113
+ ```
114
+
115
+ **Keyed iteration** — give each row a stable identity with `(keyExpr)` so survivors keep their DOM (and listeners / animation state) when the list reorders or items are removed. The key comes before the optional index. Without a key, Vibe falls back to an index-coupled hash and bulk-re-renders the tail on reorder.
116
+
117
+ ```html
118
+ <!-- each rows as row (row.id) -->
119
+ <li>@[row.label]</li>
120
+ <!-- /each -->
121
+
122
+ <!-- key + index -->
123
+ <!-- each rows as row (row.id), index -->
124
+ <li>@[index]: @[row.label]</li>
125
+ <!-- /each -->
126
+ ```
127
+
108
128
  Nested iteration with dot paths:
109
129
 
110
130
  ```html
@@ -218,6 +238,19 @@ Skip reactive processing for an element:
218
238
  <code vibe-dehydrate>@[this] displays literally</code>
219
239
  ```
220
240
 
241
+ ### Raw HTML (`$.unsafe`)
242
+
243
+ `@[expr]` escapes its output (`textContent`) — safe by default. To render **trusted** markup instead, wrap the value in `$.unsafe(...)`; the binding sets `innerHTML`. This is Vibe's equivalent of Svelte `{@html}` / Vue `v-html` / React `dangerouslySetInnerHTML`.
244
+
245
+ ```html
246
+ <p>@[$.unsafe(description)]</p>
247
+ ```
248
+
249
+ - The binding **must be the sole content of its element** (innerHTML semantics). Mixed into surrounding text, it falls back to escaped literal text.
250
+ - Injected markup is **inert** — `@[...]` / `<!-- if -->` / `<!-- each -->` inside it are not processed (matches Svelte `{@html}`); the subtree is opaque to the MutationObserver.
251
+ - Fully **reactive** — re-renders on value change; works in iterations, conditionals, and components. Compiled mode paints the markup at stamp time and re-hydrates at runtime.
252
+ - **Trusted input only** — no sanitizing. Don't pass user-supplied strings.
253
+
221
254
  ### Internal Names (don't collide)
222
255
 
223
256
  These are used by the runtime — don't repurpose them in your code:
@@ -64,7 +64,14 @@ impl<'a> ValueStamper<'a> {
64
64
  context.with(|ctx| -> Result<(), String> {
65
65
  let state_str = serde_json::to_string(state)
66
66
  .map_err(|e| format!("Failed to serialize state: {}", e))?;
67
- ctx.eval::<(), _>(format!("const $ = {}; Object.assign(globalThis, $)", state_str))
67
+ // Expose state both as bare globals (via Object.assign) and as a
68
+ // persistent `$` global carrying the runtime `unsafe` helper, so a
69
+ // `@[$.unsafe(expr)]` binding stamps its trusted string raw at build
70
+ // time — mirroring the runtime, where `$.unsafe` marks raw HTML.
71
+ ctx.eval::<(), _>(format!(
72
+ "const $ = {}; Object.assign(globalThis, $); $.unsafe = (s) => s; globalThis.$ = $",
73
+ state_str
74
+ ))
68
75
  .map_err(|e| format!("Failed to set up state in QuickJS: {:?}", e))?;
69
76
  Ok(())
70
77
  })?;
@@ -714,6 +721,25 @@ mod tests {
714
721
  assert_eq!(result, "<div>Hello @[missing]</div>");
715
722
  }
716
723
 
724
+ #[test]
725
+ fn stamp_unsafe_emits_raw_html() {
726
+ let state = json!({ "desc": "Gain <span data-green>+3%</span>." });
727
+ let stamper = ValueStamper::new(&state, false).unwrap();
728
+ let html = String::from("<p>@[$.unsafe(desc)]</p>");
729
+ let result = stamper.stamp_html(html).unwrap();
730
+ // Raw markup is painted directly — not escaped, not left as a marker.
731
+ assert_eq!(result, "<p>Gain <span data-green>+3%</span>.</p>");
732
+ }
733
+
734
+ #[test]
735
+ fn stamp_unsafe_nested_path() {
736
+ let state = json!({ "tooltip": { "props": { "description": "<em>x</em>" } } });
737
+ let stamper = ValueStamper::new(&state, false).unwrap();
738
+ let html = String::from("<p>@[$.unsafe(tooltip.props.description)]</p>");
739
+ let result = stamper.stamp_html(html).unwrap();
740
+ assert_eq!(result, "<p><em>x</em></p>");
741
+ }
742
+
717
743
  #[test]
718
744
  fn stamp_attribute_binding() {
719
745
  let state = json!({ "firstName": "John" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe",
3
- "version": "1.9.7",
3
+ "version": "1.9.8",
4
4
  "type": "module",
5
5
  "description": "Runtime-first reactivity with optional compiler",
6
6
  "main": "index.js",
@@ -38,6 +38,16 @@ const matchesKey = (matchStr, key) => {
38
38
  return false;
39
39
  };
40
40
 
41
+ // Identifier-followed-by-`(` — i.e. an attempted function or method call.
42
+ // Used to flag conditional expressions whose result can't be trusted to the
43
+ // snapshot-equality short-circuit because the called function may read live
44
+ // `$` (or other state-holding globals) via closure rather than the state
45
+ // snapshot affected.js passes in. False positives are acceptable: when the
46
+ // expression contains a call, we fall back to "if any state key changed,
47
+ // re-evaluate"; mountBranch's branchChanged guard suppresses the no-op DOM
48
+ // churn when the value didn't actually flip.
49
+ const CALL_EXPR_REGEX = /\b[A-Za-z_$][\w$]*\s*\(/;
50
+
41
51
  // Resolve a clone-list entry to its live counterpart. processComponent swaps
42
52
  // the original `<component src>` for a post-process `<component>` wrapper and
43
53
  // records the new node on the original via `_vibeReplacedBy`. Mirrors
@@ -235,6 +245,29 @@ const recursive = (
235
245
  return affected;
236
246
  }
237
247
 
248
+ // Function-call short-circuit: when the expression contains a `(`-style
249
+ // call, the snapshot eval can be blind — the called helper may read live
250
+ // `$` via closure rather than the passed state, so `oldValue` and
251
+ // `newValue` both run against the *current* values and the equality check
252
+ // never fires. Flag as affected whenever any state key actually changed
253
+ // this cycle so updateConditional re-runs against live state; if the
254
+ // result hasn't really flipped, mountBranch's branchChanged guard absorbs
255
+ // the call cheaply. Skip during initial hydration (state === newState).
256
+ if (state !== newState && CALL_EXPR_REGEX.test(tree.meta.expression)) {
257
+ const stateKeys = Object.keys(state);
258
+ const anyChanged = stateKeys.some((k) => state[k] !== newState[k]);
259
+ if (anyChanged) {
260
+ affected.push({
261
+ type: "conditional",
262
+ node: tree,
263
+ changeType: "expression-fn-call",
264
+ scopedState: newState,
265
+ oldScopedState: state,
266
+ });
267
+ return affected;
268
+ }
269
+ }
270
+
238
271
  // Condition didn't change, check for affected elements inside active branch
239
272
  if (tree.runtime.activeInstance && tree.runtime.activeInstance.parsedTree) {
240
273
  return recursive(
@@ -1,7 +1,8 @@
1
1
  import { updateIteration } from './iterate.js';
2
- import { updateConditional } from './conditionals.js';
2
+ import { updateConditional, managedNodes } from './conditionals.js';
3
3
  import { VALUE_ATTRS, DOM_PROPERTIES, BINDING_REGEX, PURE_BINDING_REGEX } from './constants.js';
4
4
  import { evalInScope, resolveCaseInsensitivePath } from './utils.js';
5
+ import { RawHtml } from './raw-html.js';
5
6
 
6
7
  export default (affected, state, manifest = {}, oldState = {}) => {
7
8
  affected.forEach((aff) => {
@@ -131,6 +132,27 @@ export default (affected, state, manifest = {}, oldState = {}) => {
131
132
  // Evaluate the expression with state as context
132
133
  const evaluated = evalInScope(matchInner, effectiveState, element);
133
134
 
135
+ // Raw-HTML render: `$.unsafe(str)` returns a RawHtml marker. When the
136
+ // binding is the sole content of its element (`<p>@[$.unsafe(x)]</p>`),
137
+ // set innerHTML from the trusted string instead of escaping via
138
+ // textContent. The injected subtree is marked opaque (managedNodes) so
139
+ // the MutationObserver never re-walks or hydrates it — raw HTML is inert,
140
+ // matching Svelte {@html}. Any non-pure use (marker spliced into
141
+ // surrounding text, or an element with other children) falls through to
142
+ // the normal path, where RawHtml.toString() degrades to escaped text.
143
+ const isPureBinding = input.trim() === matchOuter;
144
+ if (evaluated instanceof RawHtml && isPureBinding &&
145
+ (element._vibeRawHtml || element.childNodes.length === 1)) {
146
+ const html = evaluated.html;
147
+ if (element._vibeRawHtmlValue !== html) {
148
+ element.innerHTML = html;
149
+ for (const child of element.children) managedNodes.add(child);
150
+ element._vibeRawHtmlValue = html;
151
+ }
152
+ element._vibeRawHtml = true;
153
+ return;
154
+ }
155
+
134
156
  const toReplace = input.replaceAll(matchOuter, evaluated).trim();
135
157
 
136
158
  affected.forEach((innerAff) => {
package/runtime/index.js CHANGED
@@ -3,7 +3,8 @@ import parse from './parse.js';
3
3
  import createManifest from './manifest.js';
4
4
  import hydrate from './hydrate.js';
5
5
  import affected from './affected.js';
6
- import { deepMerge, hash } from './utils.js';
6
+ import { deepMerge, hash, setRootState } from './utils.js';
7
+ import { unsafe } from './raw-html.js';
7
8
  import { renderAllIterations, setRenderAllConditionals, releaseOrphanedIterationProps } from './iterate.js';
8
9
  import { renderAllConditionals, branchNodeRegistry, managedNodes } from './conditionals.js';
9
10
  import { installScopeResolver } from './loop-scope.js';
@@ -655,6 +656,16 @@ const main = (s, config = {}, stringSelector = '') => {
655
656
  enumerable: false,
656
657
  });
657
658
 
659
+ // Mark a trusted string as raw HTML. A binding that is the sole content of
660
+ // its element — `<p>@[$.unsafe(desc)]</p>` — sets innerHTML from the string
661
+ // instead of escaping it via textContent. Trusted input only (no sanitizing,
662
+ // like Svelte {@html}). Non-enumerable so it never leaks into state snapshots.
663
+ Object.defineProperty($, 'unsafe', {
664
+ value: unsafe,
665
+ enumerable: false,
666
+ configurable: true,
667
+ });
668
+
658
669
  // Pure-render path for surgical component HMR. Given raw component template
659
670
  // HTML, callsite props, slot HTML, and existing componentIds, returns the
660
671
  // processed HTML string the plugin's HMR handler can hand to $.reconcile.
@@ -672,6 +683,21 @@ const main = (s, config = {}, stringSelector = '') => {
672
683
  // manifest's node-path entry iteration.
673
684
  Object.defineProperty(manifest, '__live', { value: $, enumerable: false, configurable: true });
674
685
 
686
+ // Bind `$` inside every expression to this live root proxy (see setRootState
687
+ // in utils.js). Done before the first hydration pass so `$.unsafe` and the
688
+ // other reserved methods are reachable from the initial render onward.
689
+ setRootState($);
690
+
691
+ // Publish the real proxy on `window.$` BEFORE the first hydration pass.
692
+ // boot.js sets `window.$ = main(...)`, but until main returns it's the
693
+ // pre-boot placeholder (vibeInstance with no state keys). User helpers
694
+ // defined on `window` that close over `$` — e.g. a global `brawlerActivity`
695
+ // function reading `$.combat?.duration` — would then read the placeholder
696
+ // during initial render and treat the whole world as empty. Assigning the
697
+ // live proxy here lets those closures see the right `$` from the very
698
+ // first conditional/binding eval.
699
+ if (typeof window !== 'undefined') window.$ = $;
700
+
675
701
  // Initial hydration - pass plain values so iteration can do reference comparison
676
702
  const initialState = extractPlainValue($);
677
703
  const affectedElements = affected(parsedTree, initialState, initialState);
@@ -0,0 +1,15 @@
1
+ // Marker for trusted raw-HTML output. `$.unsafe(str)` wraps a string so the
2
+ // hydrate path knows to set innerHTML instead of escaping via textContent.
3
+ // toString() returns the raw string so any non-pure use (the marker spliced
4
+ // into surrounding text) degrades to escaped literal text automatically — the
5
+ // browser escapes it when it lands in a text node.
6
+ export class RawHtml {
7
+ constructor(html) {
8
+ this.html = html == null ? '' : String(html);
9
+ }
10
+ toString() {
11
+ return this.html;
12
+ }
13
+ }
14
+
15
+ export const unsafe = (html) => new RawHtml(html);
package/runtime/utils.js CHANGED
@@ -4,6 +4,40 @@ import { THIS_PROP_REGEX } from './constants.js';
4
4
  let hashCounter = 0;
5
5
  export const hash = () => `_${hashCounter++}`;
6
6
 
7
+ // The live root reactive proxy, wired once at boot by index.js. Used ONLY to
8
+ // reach the root's non-enumerable helper methods (`$.unsafe`, `$.on`, …) — NOT
9
+ // for state reads.
10
+ let rootProxy = null;
11
+ export const setRootState = (proxy) => { rootProxy = proxy; };
12
+
13
+ // Resolve the `$` identifier for an expression. `$` must read from the SAME
14
+ // state object the diff cycle is evaluating — affected.js / iterate.js compare
15
+ // an expression's value against an old snapshot and a new snapshot, so binding
16
+ // `$` to the live root instead would make both reads identical and defeat
17
+ // change detection (the whole reactivity engine). So state reads stay on the
18
+ // passed `state`.
19
+ //
20
+ // The catch: the root's reserved methods (`unsafe`, `on`, `reconcile`, …) are
21
+ // non-enumerable and don't survive the plain `{...state}` snapshots, so a
22
+ // plain snapshot can't reach `$.unsafe`. Wrap such a state in a thin proxy that
23
+ // serves its own keys (snapshot reads, fully diffable) and falls back to the
24
+ // live root only for keys it lacks (the helper methods). Scoped states already
25
+ // delegate to the live root (their target is `$`), so they need no wrapper.
26
+ const dollarCache = new WeakMap();
27
+ const RESERVED_PROBE = 'unsafe'; // reserved method present on root-backed states, absent on plain snapshots
28
+ const dollarFor = (state) => {
29
+ if (!rootProxy || state === rootProxy || RESERVED_PROBE in state) return state;
30
+ let wrapped = dollarCache.get(state);
31
+ if (!wrapped) {
32
+ wrapped = new Proxy(state, {
33
+ get: (t, k) => (k in t ? t[k] : rootProxy[k]),
34
+ has: (t, k) => k in t || k in rootProxy,
35
+ });
36
+ dollarCache.set(state, wrapped);
37
+ }
38
+ return wrapped;
39
+ };
40
+
7
41
  // Function compilation cache: avoids creating new Function() for repeated expressions
8
42
  // Key: normalized expression + '\0' + state keys joined by '\0'
9
43
  const fnCache = new Map();
@@ -113,10 +147,13 @@ export const evalInScope = (expr, state, element = null) => {
113
147
  fnCache.set(cacheKey, fn);
114
148
  }
115
149
 
116
- // Build values array matching the cached function's parameter order
150
+ // Build values array matching the cached function's parameter order. Bare
151
+ // identifiers resolve from the passed (possibly scoped) state; the `$`
152
+ // identifier resolves via dollarFor — the same state for reads (so old/new
153
+ // diffing works), with helper-method fallback to the live root.
117
154
  const values = new Array(keyCount + 1);
118
155
  for (let i = 0; i < keyCount; i++) values[i] = state[stateKeys[i]];
119
- values[keyCount] = state;
156
+ values[keyCount] = dollarFor(state);
120
157
 
121
158
  const result = fn(...values);
122
159