@ape-egg/vibe 1.0.0

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 ADDED
@@ -0,0 +1,42 @@
1
+ # Changelog
2
+
3
+ ## [1.0.0] - 2026-01-19
4
+
5
+ ### Added
6
+
7
+ - Initial public release
8
+ - Proxy-based reactive state (`window.$`)
9
+ - MutationObserver for automatic DOM tracking
10
+ - Fine-grained updates (no virtual DOM)
11
+
12
+ ### Core Features
13
+
14
+ - **Reactive bindings**: `@[property]` syntax in HTML and CSS
15
+ - **Iteration**: `<!-- each items as item, i -->` with efficient diffing
16
+ - **Nested iteration**: `<!-- each category.items as item -->`
17
+ - **Conditionals**: `<!-- if condition -->...<!-- else -->...<!-- /if -->`
18
+ - **Dehydrate**: `<div dehydrate>` to skip reactive processing
19
+ - **Expression evaluation**: `@[count * 2]`, `@[firstName + ' ' + lastName]`
20
+
21
+ ### Performance
22
+
23
+ - Surgical DOM updates - only affected elements re-render
24
+
25
+ ---
26
+
27
+ ## [0.0.5] - 2026-01-15
28
+
29
+ - Heavy optimizations for iteration rendering
30
+ - Added benchmark tooling
31
+ - Improved diffing algorithm for array changes
32
+
33
+ ## [0.0.4] - 2026-01-10
34
+
35
+ - Renamed from "Soulbound" to "Vibe"
36
+ - Published to npm as `@ape-egg/vibe`
37
+
38
+ ## [0.0.1-0.0.3] - 2026-01-06 to 2026-01-09
39
+
40
+ - Initial development and prototyping
41
+ - Core architecture: parse, link, hydrate, affected, state
42
+ - Basic iteration and conditional support
package/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # Vibe
2
+
3
+ A runtime-first reactive library.
4
+
5
+ No virtual DOM. No build step. Just modern JavaScript.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @ape-egg/vibe
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```html
16
+ <script type="module">
17
+ import state from "@ape-egg/vibe";
18
+ window.$ = state({ name: "World", count: 0 });
19
+ </script>
20
+
21
+ <h1>Hello, @[name]!</h1>
22
+ <button onclick="$.count++">Clicked @[count] times</button>
23
+ ```
24
+
25
+ ## Features
26
+
27
+ ### Reactive Bindings
28
+
29
+ Use `@[property]` anywhere in HTML or CSS:
30
+
31
+ ```html
32
+ <div>@[firstName] @[lastName]</div>
33
+ <div>@[firstName + ' ' + lastName]</div>
34
+
35
+ <style>
36
+ .box { background: @[themeColor]; }
37
+ </style>
38
+ ```
39
+
40
+ ### Iteration
41
+
42
+ ```html
43
+ <!-- each items as item, index -->
44
+ <li>@[index]: @[item]</li>
45
+ <!-- /each -->
46
+ ```
47
+
48
+ Nested iteration with dot paths:
49
+
50
+ ```html
51
+ <!-- each categories as category -->
52
+ <!-- each category.items as item -->
53
+ <span>@[item.name]</span>
54
+ <!-- /each -->
55
+ <!-- /each -->
56
+ ```
57
+
58
+ ### Conditionals
59
+
60
+ ```html
61
+ <!-- if user.isAdmin -->
62
+ <admin-badge>Admin</admin-badge>
63
+ <!-- else -->
64
+ <span>User</span>
65
+ <!-- /if -->
66
+ ```
67
+
68
+ ### Dehydrate
69
+
70
+ Skip reactive processing for an element:
71
+
72
+ ```html
73
+ <code dehydrate>@[this] displays literally</code>
74
+ ```
75
+
76
+ ### Dynamic Elements
77
+
78
+ Elements added via JavaScript are automatically hydrated through MutationObserver.
79
+
80
+ ## How It Works
81
+
82
+ 1. **Proxy-based state** — `window.$` intercepts property changes
83
+ 2. **DOM parsing** — Finds all `@[...]` bindings on load
84
+ 3. **Surgical updates** — Only affected elements re-render
85
+ 4. **MutationObserver** — Tracks dynamically added elements
86
+
87
+ ## Prevent FOUC
88
+
89
+ Hide content until hydration completes:
90
+
91
+ ```html
92
+ <body style="visibility: hidden;">
93
+ ```
94
+
95
+ Or use the included CSS:
96
+
97
+ ```html
98
+ <link rel="stylesheet" href="@ape-egg/vibe/vibe.css">
99
+ <body vibe>
100
+ ```
101
+
102
+ ## Browser Support
103
+
104
+ Modern browsers with Proxy and MutationObserver support.
105
+
106
+ ## License
107
+
108
+ ISC
@@ -0,0 +1,161 @@
1
+ /**
2
+ * _vibe-compiled-iteration-batch.js
3
+ *
4
+ * EXPERIMENTAL: Fast path for iteration rendering using compiled batch functions.
5
+ *
6
+ * This is a preview/prototype of what Vibe Compiled (Phase 2) will do automatically.
7
+ * Currently opt-in via: window.__VIBE_FAST_ITERATION__ = true
8
+ *
9
+ * HOW IT WORKS:
10
+ * Instead of cloning DOM nodes and hydrating each item individually (slow),
11
+ * this compiles the template to a JavaScript function that builds HTML strings
12
+ * in a loop, then parses once with innerHTML (fast).
13
+ *
14
+ * Template: <item-card><item-text>@[item.name]</item-text></item-card>
15
+ * Compiles to: (arr) => { let html=''; for(...) html += `<item-card>...${item.name}...`; return html; }
16
+ *
17
+ * LIMITATIONS:
18
+ * - Cannot handle nested <!-- each --> or <!-- if --> (those need DOM-based rendering)
19
+ * - Cannot do incremental updates for small changes (rebuilds entire list)
20
+ * - Template structure is "frozen" at compile time
21
+ *
22
+ * WHEN VIBE COMPILED EXISTS:
23
+ * This logic will move to build-time compilation, producing optimized JavaScript
24
+ * that gets shipped to the browser. The runtime will just execute the compiled code.
25
+ *
26
+ * See: .claude/phase-2-compiler.md for full compiler plans
27
+ */
28
+
29
+ import { BINDING_REGEX } from './constants.js';
30
+
31
+ // Reusable template element for HTML parsing
32
+ const parseTemplate = document.createElement('template');
33
+
34
+ /**
35
+ * Check if a template can use the fast path (no nested iterations/conditionals)
36
+ */
37
+ export const canUseFastPath = (template) => {
38
+ return !hasNestedStructures(template);
39
+ };
40
+
41
+ /**
42
+ * Recursively check if tree has nested iterations or conditionals
43
+ */
44
+ const hasNestedStructures = (tree) => {
45
+ if (!tree || !tree.children) return false;
46
+ for (const key in tree.children) {
47
+ const child = tree.children[key];
48
+ if (!child) continue;
49
+ if (child.type === 'iteration' || child.type === 'conditional') return true;
50
+ if (hasNestedStructures(child)) return true;
51
+ }
52
+ return false;
53
+ };
54
+
55
+ /**
56
+ * Compile a batch function for an iteration template.
57
+ * Returns a function: (array, ...stateValues) => htmlString
58
+ */
59
+ export const compileBatchFn = (template, itemAlias, indexAlias, stateKeys) => {
60
+ const templateHtml = template.element.innerHTML.trim();
61
+ const escaped = templateHtml
62
+ .replace(/\\/g, '\\\\')
63
+ .replace(/`/g, '\\`')
64
+ .replace(/\$\{/g, '\\${');
65
+ const code = escaped.replace(BINDING_REGEX, (_, expr) => '${' + expr + '}');
66
+
67
+ return new Function('arr', ...stateKeys, `
68
+ let html = '';
69
+ const len = arr.length;
70
+ for (let ${indexAlias} = 0; ${indexAlias} < len; ${indexAlias}++) {
71
+ const ${itemAlias} = arr[${indexAlias}];
72
+ html += \`${code}\`;
73
+ }
74
+ return html;
75
+ `);
76
+ };
77
+
78
+ /**
79
+ * Fast render: compile template to batch function and render via innerHTML
80
+ */
81
+ export const renderFast = (iterationNode, array, state, parent, endComment) => {
82
+ const { itemAlias, indexAlias, template } = iterationNode.meta;
83
+
84
+ // Compile batch function if not cached
85
+ if (!iterationNode.runtime.batchFn) {
86
+ const stateKeys = Object.keys(state);
87
+ iterationNode.runtime.batchFn = compileBatchFn(template, itemAlias, indexAlias, stateKeys);
88
+ iterationNode.runtime.stateKeys = stateKeys;
89
+ }
90
+
91
+ const batchFn = iterationNode.runtime.batchFn;
92
+ const stateKeys = iterationNode.runtime.stateKeys;
93
+
94
+ // Build HTML string using batch function
95
+ const stateValues = stateKeys.map(k => state[k]);
96
+ const html = batchFn(array, ...stateValues);
97
+
98
+ // Parse with reusable template element
99
+ parseTemplate.innerHTML = html;
100
+ const frag = parseTemplate.content;
101
+ const kids = frag.children;
102
+
103
+ // Track instances
104
+ const arrayLen = array.length;
105
+ const instances = new Array(arrayLen);
106
+ for (let i = 0; i < arrayLen; i++) {
107
+ instances[i] = { element: kids[i], item: array[i], index: i };
108
+ }
109
+
110
+ parent.insertBefore(frag, endComment);
111
+ iterationNode.runtime.instances = instances;
112
+ };
113
+
114
+ /**
115
+ * Fast update: clear and rebuild for bulk operations
116
+ */
117
+ export const updateFast = (iterationNode, newArray, state, startComment, endComment) => {
118
+ const { itemAlias, indexAlias, template } = iterationNode.meta;
119
+ const parent = startComment.parentNode;
120
+
121
+ // Fast clear using Range
122
+ if (iterationNode.runtime.instances.length > 0) {
123
+ const range = document.createRange();
124
+ range.setStartAfter(startComment);
125
+ range.setEndBefore(endComment);
126
+ range.deleteContents();
127
+ }
128
+
129
+ if (newArray.length === 0) {
130
+ iterationNode.runtime.instances = [];
131
+ return;
132
+ }
133
+
134
+ // Compile batch function if not cached (e.g., initial array was empty)
135
+ if (!iterationNode.runtime.batchFn) {
136
+ const stateKeys = Object.keys(state);
137
+ iterationNode.runtime.batchFn = compileBatchFn(template, itemAlias, indexAlias, stateKeys);
138
+ iterationNode.runtime.stateKeys = stateKeys;
139
+ }
140
+
141
+ // Build HTML string using cached batch function
142
+ const batchFn = iterationNode.runtime.batchFn;
143
+ const stateKeys = iterationNode.runtime.stateKeys;
144
+ const stateValues = stateKeys.map(k => state[k]);
145
+ const html = batchFn(newArray, ...stateValues);
146
+
147
+ // Parse and insert
148
+ parseTemplate.innerHTML = html;
149
+ const frag = parseTemplate.content;
150
+ const kids = frag.children;
151
+
152
+ // Build instance tracking
153
+ const arrayLen = newArray.length;
154
+ const instances = new Array(arrayLen);
155
+ for (let i = 0; i < arrayLen; i++) {
156
+ instances[i] = { element: kids[i], item: newArray[i], index: i };
157
+ }
158
+
159
+ parent.insertBefore(frag, endComment);
160
+ iterationNode.runtime.instances = instances;
161
+ };
package/affected.js ADDED
@@ -0,0 +1,149 @@
1
+ import { resolvePath, deepEqual } from './iteration-utils.js'
2
+ import { extractDependencies } from './conditionals.js'
3
+ import { BINDING_REGEX } from './constants.js'
4
+
5
+ // Evaluate conditional expression
6
+ const evaluateCondition = (expression, state) => {
7
+ try {
8
+ const keys = Object.keys(state);
9
+ const values = Object.values(state);
10
+ const result = new Function(...keys, `'use strict'; return !!(${expression})`)(...values);
11
+ return !!result;
12
+ } catch (e) {
13
+ return false;
14
+ }
15
+ };
16
+
17
+ // Helper function to check if a match references a specific key
18
+ const matchesKey = (matchStr, key) => matchStr === key || matchStr.startsWith(key + '.');
19
+
20
+ const recursive = (tree, state, newState, affected) => {
21
+ // Handle iteration nodes specially
22
+ if (tree.type === 'iteration') {
23
+ const oldArray = resolvePath(state, tree.meta.arrayPath);
24
+ const newArray = resolvePath(newState, tree.meta.arrayPath);
25
+
26
+ // Fast path: reference comparison (arrays are typically replaced, not mutated)
27
+ // This avoids expensive O(n) deepEqual for large arrays
28
+ if (oldArray !== newArray) {
29
+ affected.push({
30
+ type: 'iteration',
31
+ node: tree,
32
+ changeType: 'array'
33
+ });
34
+ }
35
+ return affected;
36
+ }
37
+
38
+ // Handle conditional nodes specially
39
+ if (tree.type === 'conditional') {
40
+ const oldValue = evaluateCondition(tree.meta.expression, state);
41
+ const newValue = evaluateCondition(tree.meta.expression, newState);
42
+
43
+ // Check if condition result changed
44
+ if (oldValue !== newValue) {
45
+ affected.push({
46
+ type: 'conditional',
47
+ node: tree,
48
+ changeType: 'expression'
49
+ });
50
+ return affected;
51
+ }
52
+
53
+ // Condition didn't change, check for affected elements inside active branch
54
+ if (tree.runtime.activeInstance && tree.runtime.activeInstance.parsedTree) {
55
+ return recursive(tree.runtime.activeInstance.parsedTree, state, newState, affected);
56
+ }
57
+
58
+ return affected;
59
+ }
60
+
61
+ // Reset regex state for reuse
62
+ BINDING_REGEX.lastIndex = 0;
63
+
64
+ const matches = [];
65
+ let match;
66
+ while ((match = BINDING_REGEX.exec(tree.parsed))) {
67
+ matches.push({ outer: match[0], inner: match[1], input: match.input });
68
+ }
69
+
70
+ if (matches.length) {
71
+ const shallowState = Object.keys(state);
72
+ const shallowNewState = Object.keys(newState);
73
+
74
+ let hasAffected = false;
75
+ const checkedMatches = [];
76
+
77
+ for (const m of matches) {
78
+ const noMatch = !shallowState.some(key => matchesKey(m.inner, key));
79
+ const newMatches = shallowNewState.filter(key => matchesKey(m.inner, key));
80
+
81
+ if (noMatch || newMatches.length) {
82
+ hasAffected = true;
83
+ }
84
+
85
+ checkedMatches.push({
86
+ ...m,
87
+ matches: newMatches.length ? newMatches : shallowState.filter(key => matchesKey(m.inner, key))
88
+ });
89
+ }
90
+
91
+ if (hasAffected) {
92
+ for (const m of checkedMatches) {
93
+ affected.push({
94
+ matchOuter: m.outer,
95
+ matchInner: m.inner,
96
+ input: m.input,
97
+ matches: m.matches,
98
+ element: tree.element
99
+ });
100
+ }
101
+ }
102
+ }
103
+
104
+ // Check attribute bindings
105
+ if (tree.attributes) {
106
+ const shallowState = Object.keys(state);
107
+ const shallowNewState = Object.keys(newState);
108
+
109
+ for (const [attrName, attrValue] of Object.entries(tree.attributes)) {
110
+ BINDING_REGEX.lastIndex = 0;
111
+ const attrMatches = [];
112
+ let attrMatch;
113
+ while ((attrMatch = BINDING_REGEX.exec(attrValue))) {
114
+ attrMatches.push({ outer: attrMatch[0], inner: attrMatch[1] });
115
+ }
116
+
117
+ for (const m of attrMatches) {
118
+ const noMatch = !shallowState.some(key => matchesKey(m.inner, key));
119
+ const newMatches = shallowNewState.filter(key => matchesKey(m.inner, key));
120
+
121
+ if (noMatch || newMatches.length) {
122
+ affected.push({
123
+ type: 'attribute',
124
+ attrName,
125
+ attrValue,
126
+ matchOuter: m.outer,
127
+ matchInner: m.inner,
128
+ element: tree.element
129
+ });
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ // Process children
136
+ const children = tree.children;
137
+ if (children) {
138
+ for (const key in children) {
139
+ const child = children[key];
140
+ if (child && typeof child === 'object') {
141
+ recursive(child, state, newState, affected);
142
+ }
143
+ }
144
+ }
145
+
146
+ return affected;
147
+ };
148
+
149
+ export default (tree, state, newState) => recursive(tree, state, newState, [])
@@ -0,0 +1,182 @@
1
+ import parse from './parse.js';
2
+ import affected from './affected.js';
3
+ import hydrate from './hydrate.js';
4
+ import { createScopedState, renderAllIterations, initializeBlock } from './iterate.js';
5
+
6
+ // Evaluate conditional expression in state context
7
+ const evaluateCondition = (expression, state) => {
8
+ try {
9
+ const keys = Object.keys(state);
10
+ const values = Object.values(state);
11
+ // Create a function with state keys as parameters and evaluate the expression
12
+ const result = new Function(...keys, `'use strict'; return !!(${expression})`)(...values);
13
+ return !!result; // Coerce to boolean
14
+ } catch (e) {
15
+ console.warn(`Error evaluating condition "${expression}":`, e);
16
+ return false; // Default to false on error
17
+ }
18
+ };
19
+
20
+ // Extract state dependencies from an expression
21
+ // e.g., "count > 5" → ["count"]
22
+ // e.g., "isLoggedIn && isAdmin" → ["isLoggedIn", "isAdmin"]
23
+ export const extractDependencies = (expression) => {
24
+ const regex = /\b([a-zA-Z_$][a-zA-Z0-9_$]*)\b/g;
25
+ const matches = [];
26
+ let match;
27
+ while ((match = regex.exec(expression))) {
28
+ const identifier = match[1];
29
+ // Filter out JavaScript keywords and common literals
30
+ if (!['true', 'false', 'null', 'undefined', 'this', 'return'].includes(identifier)) {
31
+ matches.push(identifier);
32
+ }
33
+ }
34
+ return [...new Set(matches)]; // Unique values
35
+ };
36
+
37
+ // Render all conditionals in the parsed tree
38
+ export const renderAllConditionals = (tree, state, linkList, parentScope = {}) => {
39
+ // If this is a conditional node, render it
40
+ if (tree.type === 'conditional') {
41
+ renderConditional(tree, state, linkList, parentScope);
42
+ return;
43
+ }
44
+
45
+ // Recursively render conditionals in child nodes
46
+ if (tree.children) {
47
+ Object.keys(tree.children).forEach(key => {
48
+ const child = tree.children[key];
49
+ if (typeof child === 'object' && child !== null) {
50
+ renderAllConditionals(child, state, linkList, parentScope);
51
+ }
52
+ });
53
+ }
54
+ };
55
+
56
+ // Initial render of a conditional block
57
+ export const renderConditional = (node, state, linkList, parentScope = {}) => {
58
+ const { expression, startComment, endComment, branches } = node.meta;
59
+
60
+ // Remove original template nodes from DOM (between start and end comments)
61
+ // Only do this on first render (when activeBranch is undefined)
62
+ if (node.runtime.activeBranch === undefined) {
63
+ let currentNode = startComment.nextSibling;
64
+ while (currentNode && currentNode !== endComment) {
65
+ const nextNode = currentNode.nextSibling;
66
+ if (currentNode.parentNode) {
67
+ currentNode.parentNode.removeChild(currentNode);
68
+ }
69
+ currentNode = nextNode;
70
+ }
71
+
72
+ // Mark that template has been removed
73
+ node.runtime.templateRemoved = true;
74
+ }
75
+
76
+ // Evaluate condition with current state
77
+ const conditionResult = evaluateCondition(expression, state);
78
+
79
+ // Determine which branch to mount
80
+ const branchToMount = conditionResult ? branches.if : branches.else;
81
+
82
+ // Mount the appropriate branch
83
+ mountBranch(node, branchToMount, state, linkList, parentScope);
84
+
85
+ // Store active branch reference
86
+ node.runtime.activeBranch = branchToMount;
87
+ };
88
+
89
+ // Mount a specific branch
90
+ const mountBranch = (node, branchData, state, linkList, parentScope) => {
91
+ const { startComment, endComment } = node.meta;
92
+
93
+ // If branch doesn't exist (no else clause), just unmount current
94
+ if (!branchData) {
95
+ unmountBranch(node);
96
+ return;
97
+ }
98
+
99
+ // Unmount current branch first (if any)
100
+ unmountBranch(node);
101
+
102
+ // Clone the template element
103
+ const templateContent = branchData.element.childNodes;
104
+ const parent = startComment.parentNode;
105
+
106
+ // Create scoped state (with parent scope if inside iteration)
107
+ const scopedState = Object.keys(parentScope).length > 0
108
+ ? createScopedState(state, parentScope)
109
+ : state;
110
+
111
+ // Initialize block (clone, parse, hydrate)
112
+ const { element: firstElement, tree: branchTree, clonedNodes } = initializeBlock(templateContent, scopedState);
113
+
114
+ // Insert cloned nodes into DOM
115
+ clonedNodes.forEach(clonedNode => parent.insertBefore(clonedNode, endComment));
116
+
117
+ // Recursively render any nested iterations and conditionals
118
+ if (branchTree) {
119
+ renderAllIterations(branchTree, scopedState, linkList);
120
+ renderAllConditionals(branchTree, scopedState, linkList, parentScope);
121
+ }
122
+
123
+ // Store active instance
124
+ node.runtime.activeInstance = {
125
+ branch: branchData,
126
+ nodes: clonedNodes,
127
+ parsedTree: branchTree
128
+ };
129
+ };
130
+
131
+ // Unmount currently active branch
132
+ const unmountBranch = (node) => {
133
+ const { activeInstance } = node.runtime;
134
+
135
+ if (!activeInstance) return;
136
+
137
+ // Remove all nodes from DOM
138
+ activeInstance.nodes.forEach(domNode => {
139
+ if (domNode.parentNode) {
140
+ domNode.parentNode.removeChild(domNode);
141
+ }
142
+ });
143
+
144
+ // Clear active instance
145
+ node.runtime.activeInstance = null;
146
+ };
147
+
148
+ // Update conditional when dependencies change
149
+ export const updateConditional = (node, newState, oldState, linkList, parentScope = {}) => {
150
+ const { expression, branches } = node.meta;
151
+
152
+ // If not yet rendered, skip (renderConditional handles initial render)
153
+ if (!node.runtime.templateRemoved) {
154
+ return;
155
+ }
156
+
157
+ // Evaluate expression with new state
158
+ const newConditionResult = evaluateCondition(expression, newState);
159
+ const newBranchData = newConditionResult ? branches.if : branches.else;
160
+
161
+ // Check if branch changed (compare references)
162
+ const branchChanged = node.runtime.activeBranch !== newBranchData;
163
+
164
+ if (branchChanged) {
165
+ // Switch branches
166
+ mountBranch(node, newBranchData, newState, linkList, parentScope);
167
+ node.runtime.activeBranch = newBranchData;
168
+ } else {
169
+ // Same branch, but state might have changed - rehydrate
170
+ const { activeInstance } = node.runtime;
171
+
172
+ if (activeInstance && activeInstance.parsedTree) {
173
+ const scopedState = Object.keys(parentScope).length > 0
174
+ ? createScopedState(newState, parentScope)
175
+ : newState;
176
+
177
+ const affectedElements = affected(activeInstance.parsedTree, oldState, scopedState);
178
+ hydrate(affectedElements, scopedState);
179
+ }
180
+ }
181
+ };
182
+