@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 +42 -0
- package/README.md +108 -0
- package/_vibe-compiled-iteration-batch.js +161 -0
- package/affected.js +149 -0
- package/conditionals.js +182 -0
- package/constants.js +76 -0
- package/hydrate.js +100 -0
- package/index.js +250 -0
- package/iterate.js +451 -0
- package/iteration-utils.js +261 -0
- package/link.js +13 -0
- package/llms.txt +279 -0
- package/package.json +26 -0
- package/parse.js +225 -0
- package/state.js +26 -0
- package/utils.js +45 -0
- package/vibe.css +11 -0
package/constants.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Elements that should not have reactive bindings
|
|
2
|
+
export const NON_REACTIVE_ELEMENTS = ["SCRIPT", "HEAD", "PRE"];
|
|
3
|
+
|
|
4
|
+
// Attributes where the string value is meaningful (should NOT be removed when falsy)
|
|
5
|
+
// All other attributes are treated as boolean-like (removed when falsy, present when truthy)
|
|
6
|
+
export const VALUE_ATTRS = [
|
|
7
|
+
// Global attributes
|
|
8
|
+
'class', 'style', 'id', 'title', 'lang', 'dir', 'tabindex', 'accesskey',
|
|
9
|
+
'slot', 'part', 'is', 'nonce', 'popover', 'anchor',
|
|
10
|
+
|
|
11
|
+
// Enumerated (take specific string values, not truly boolean)
|
|
12
|
+
'contenteditable', 'draggable', 'spellcheck', 'translate', 'autocapitalize',
|
|
13
|
+
'inputmode', 'enterkeyhint', 'virtualkeyboardpolicy',
|
|
14
|
+
|
|
15
|
+
// URLs and sources
|
|
16
|
+
'href', 'src', 'action', 'cite', 'data', 'poster', 'srcset', 'imagesrcset',
|
|
17
|
+
'formaction', 'ping', 'usemap', 'manifest', 'codebase',
|
|
18
|
+
|
|
19
|
+
// Form attributes
|
|
20
|
+
'name', 'type', 'value', 'placeholder', 'pattern', 'min', 'max', 'step',
|
|
21
|
+
'minlength', 'maxlength', 'size', 'accept', 'autocomplete', 'list', 'form',
|
|
22
|
+
'formmethod', 'formtarget', 'formenctype', 'wrap', 'method', 'enctype',
|
|
23
|
+
'for', 'dirname',
|
|
24
|
+
|
|
25
|
+
// Text/accessibility
|
|
26
|
+
'alt', 'label', 'summary', 'abbr',
|
|
27
|
+
|
|
28
|
+
// Dimensions and layout
|
|
29
|
+
'width', 'height', 'cols', 'rows', 'span', 'rowspan', 'colspan',
|
|
30
|
+
'low', 'high', 'optimum',
|
|
31
|
+
|
|
32
|
+
// Link/resource hints
|
|
33
|
+
'target', 'rel', 'hreflang', 'download', 'as', 'media', 'type', 'charset',
|
|
34
|
+
'crossorigin', 'integrity', 'loading', 'decoding', 'fetchpriority',
|
|
35
|
+
'referrerpolicy', 'blocking', 'imagesizes', 'sizes',
|
|
36
|
+
|
|
37
|
+
// Media
|
|
38
|
+
'preload', 'kind', 'srclang',
|
|
39
|
+
|
|
40
|
+
// Meta
|
|
41
|
+
'content', 'http-equiv',
|
|
42
|
+
|
|
43
|
+
// iframe/embed
|
|
44
|
+
'sandbox', 'allow', 'srcdoc', 'credentialless',
|
|
45
|
+
|
|
46
|
+
// Table
|
|
47
|
+
'headers', 'scope',
|
|
48
|
+
|
|
49
|
+
// Datetime
|
|
50
|
+
'datetime',
|
|
51
|
+
|
|
52
|
+
// Object/embed legacy
|
|
53
|
+
'coords', 'shape',
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
// Properties that should be set directly on the DOM element (not as attributes)
|
|
57
|
+
export const DOM_PROPERTIES = ['value', 'checked', 'selected'];
|
|
58
|
+
|
|
59
|
+
// Regex for matching reactive bindings (@[expression])
|
|
60
|
+
export const BINDING_REGEX = /\@\[([^\]]+)\]/g;
|
|
61
|
+
|
|
62
|
+
// Regex for detecting a pure binding (entire value is just @[expression])
|
|
63
|
+
export const PURE_BINDING_REGEX = /^\@\[([^\]]+)\]$/;
|
|
64
|
+
|
|
65
|
+
// Regex for parsing iteration comment syntax (<!-- each items as item, index -->)
|
|
66
|
+
// Supports nested paths like category.items
|
|
67
|
+
export const ITERATION_REGEX = /^each\s+([\w.]+)\s+as\s+(\w+)(?:\s*,\s*(\w+))?\s*$/;
|
|
68
|
+
|
|
69
|
+
// Regex for detecting start of iteration comment
|
|
70
|
+
export const ITERATION_START_REGEX = /^each\s+/;
|
|
71
|
+
|
|
72
|
+
// Regex for parsing conditional comment syntax (<!-- if expression -->)
|
|
73
|
+
export const CONDITIONAL_REGEX = /^if\s+(.+)$/;
|
|
74
|
+
|
|
75
|
+
// Regex for detecting start of conditional comment
|
|
76
|
+
export const CONDITIONAL_START_REGEX = /^if\s+/;
|
package/hydrate.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { updateIteration } from './iterate.js'
|
|
2
|
+
import { updateConditional } from './conditionals.js'
|
|
3
|
+
import { VALUE_ATTRS, DOM_PROPERTIES, BINDING_REGEX, PURE_BINDING_REGEX } from './constants.js'
|
|
4
|
+
|
|
5
|
+
// Keep track of old state for diffing
|
|
6
|
+
let previousState = {};
|
|
7
|
+
|
|
8
|
+
export const setPreviousState = (state) => {
|
|
9
|
+
previousState = { ...state };
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// Evaluate expression in the context of state
|
|
13
|
+
const evalInScope = (expr, state) => {
|
|
14
|
+
try {
|
|
15
|
+
const keys = Object.keys(state);
|
|
16
|
+
const values = Object.values(state);
|
|
17
|
+
// Create a function with state keys as parameters and evaluate the expression
|
|
18
|
+
return new Function(...keys, `'use strict'; return (${expr})`)(...values);
|
|
19
|
+
} catch (e) {
|
|
20
|
+
// Fallback to undefined if evaluation fails
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export default (affected, state, linkList = {}) => {
|
|
26
|
+
affected.forEach((aff) => {
|
|
27
|
+
// Handle iteration updates
|
|
28
|
+
if (aff.type === 'iteration') {
|
|
29
|
+
updateIteration(aff.node, state, previousState, linkList);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Handle conditional updates
|
|
34
|
+
if (aff.type === 'conditional') {
|
|
35
|
+
updateConditional(aff.node, state, previousState, linkList);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Handle attribute updates
|
|
40
|
+
if (aff.type === 'attribute') {
|
|
41
|
+
const { attrName, attrValue, element } = aff;
|
|
42
|
+
try {
|
|
43
|
+
|
|
44
|
+
// Check if this is a pure binding (e.g., value="@[inputValue]")
|
|
45
|
+
const isPureBinding = attrValue.match(PURE_BINDING_REGEX);
|
|
46
|
+
const isDomProperty = DOM_PROPERTIES.includes(attrName);
|
|
47
|
+
// Value attrs keep their string value; everything else is boolean-like (removed when falsy)
|
|
48
|
+
const isValueAttr = VALUE_ATTRS.includes(attrName) ||
|
|
49
|
+
attrName.startsWith('data-') ||
|
|
50
|
+
attrName.startsWith('aria-') ||
|
|
51
|
+
attrName.startsWith('on');
|
|
52
|
+
|
|
53
|
+
if (isDomProperty && isPureBinding) {
|
|
54
|
+
// For DOM properties like value, set the property directly
|
|
55
|
+
const expr = isPureBinding[1];
|
|
56
|
+
const value = evalInScope(expr, state);
|
|
57
|
+
element[attrName] = value;
|
|
58
|
+
} else if (!isValueAttr && isPureBinding) {
|
|
59
|
+
// Boolean-like attributes: add or remove based on truthiness
|
|
60
|
+
const expr = isPureBinding[1];
|
|
61
|
+
const value = evalInScope(expr, state);
|
|
62
|
+
if (value) {
|
|
63
|
+
element.setAttribute(attrName, '');
|
|
64
|
+
} else {
|
|
65
|
+
element.removeAttribute(attrName);
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
// Value attribute - replace bindings with values
|
|
69
|
+
const newValue = attrValue.replace(BINDING_REGEX, (_, expr) => {
|
|
70
|
+
return evalInScope(expr, state);
|
|
71
|
+
});
|
|
72
|
+
element.setAttribute(attrName, newValue);
|
|
73
|
+
}
|
|
74
|
+
} catch (e) {}
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Handle regular element updates
|
|
79
|
+
const { matches, matchOuter, matchInner, input, element } = aff;
|
|
80
|
+
|
|
81
|
+
// This prevents undefined store properties to throw an error
|
|
82
|
+
try {
|
|
83
|
+
// Evaluate the expression with state as context
|
|
84
|
+
const evaluated = evalInScope(matchInner, state);
|
|
85
|
+
|
|
86
|
+
const toReplace = input.replaceAll(matchOuter, evaluated).trim();
|
|
87
|
+
|
|
88
|
+
affected.forEach((innerAff) => {
|
|
89
|
+
if (innerAff.element === element) {
|
|
90
|
+
innerAff.input = toReplace;
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
element.textContent = toReplace;
|
|
95
|
+
} catch (e) {}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Update previous state for next diff
|
|
99
|
+
setPreviousState(state);
|
|
100
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import state from "./state.js";
|
|
2
|
+
import parse from "./parse.js";
|
|
3
|
+
import link from "./link.js";
|
|
4
|
+
import hydrate, { setPreviousState } from "./hydrate.js";
|
|
5
|
+
import affected from "./affected.js";
|
|
6
|
+
import { deepMerge, hash } from "./utils.js";
|
|
7
|
+
import { renderAllIterations, setRenderAllConditionals } from "./iterate.js";
|
|
8
|
+
import { renderAllConditionals } from "./conditionals.js";
|
|
9
|
+
import { NON_REACTIVE_ELEMENTS } from "./constants.js";
|
|
10
|
+
|
|
11
|
+
// Wire up cross-module dependency after all modules are loaded
|
|
12
|
+
setRenderAllConditionals(renderAllConditionals);
|
|
13
|
+
|
|
14
|
+
// Check if node itself or any ancestor is non-reactive or dehydrated
|
|
15
|
+
const isNonReactiveOrInside = (node) => {
|
|
16
|
+
let current = node;
|
|
17
|
+
while (current && current !== document.body) {
|
|
18
|
+
if (NON_REACTIVE_ELEMENTS.includes(current.nodeName)) {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
if (current.hasAttribute?.('dehydrate')) {
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
current = current.parentElement;
|
|
25
|
+
}
|
|
26
|
+
return false;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Navigate tree using dot notation (handles .children at each level)
|
|
30
|
+
const navigateTree = (tree, path) => {
|
|
31
|
+
if (!path) return tree;
|
|
32
|
+
return path.split(".").reduce((node, key) => node?.children?.[key], tree);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Get or create a node in the tree at the given path
|
|
36
|
+
const ensureNode = (tree, path) => {
|
|
37
|
+
const keys = path.split(".");
|
|
38
|
+
return keys.reduce((node, key) => {
|
|
39
|
+
if (!node.children[key]) {
|
|
40
|
+
node.children[key] = { children: {} };
|
|
41
|
+
}
|
|
42
|
+
return node.children[key];
|
|
43
|
+
}, tree);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const main = (s, attrName = "vibe") => {
|
|
47
|
+
// Find element(s) with the specified attribute
|
|
48
|
+
const elements = document.querySelectorAll(`[${attrName}]`);
|
|
49
|
+
|
|
50
|
+
if (elements.length === 0) {
|
|
51
|
+
console.info(
|
|
52
|
+
`[vibe] No element found with attribute "${attrName}". Falling back to body.`,
|
|
53
|
+
);
|
|
54
|
+
} else if (elements.length > 1) {
|
|
55
|
+
console.info(
|
|
56
|
+
`[vibe] Multiple elements (${elements.length}) found with attribute "${attrName}". Hydrating the first one.`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const rootElement = elements[0] || document.body;
|
|
61
|
+
let parsedTree = parse(rootElement);
|
|
62
|
+
let linkList = link(parsedTree);
|
|
63
|
+
|
|
64
|
+
// Store previous state for comparison
|
|
65
|
+
let previousState = {};
|
|
66
|
+
|
|
67
|
+
// Lifecycle hooks that users can subscribe to
|
|
68
|
+
const hooks = {
|
|
69
|
+
afterUpdate: [],
|
|
70
|
+
afterDomMutation: [],
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// Observer reference - set after observer is created
|
|
74
|
+
let observer = null;
|
|
75
|
+
|
|
76
|
+
const $ = state(s, (newState) => {
|
|
77
|
+
const mergedState = deepMerge($, newState);
|
|
78
|
+
const affectedElements = affected(parsedTree, previousState, mergedState);
|
|
79
|
+
|
|
80
|
+
if (observer) observer.disconnect();
|
|
81
|
+
hydrate(affectedElements, mergedState, linkList);
|
|
82
|
+
if (observer) {
|
|
83
|
+
observer.observe(rootElement, {
|
|
84
|
+
attributes: false,
|
|
85
|
+
characterData: false,
|
|
86
|
+
childList: true,
|
|
87
|
+
subtree: true,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const prev = structuredClone(previousState);
|
|
92
|
+
previousState = { ...$, ...newState };
|
|
93
|
+
hooks.afterUpdate.forEach((callback) =>
|
|
94
|
+
callback(structuredClone({ ...$ }), prev),
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Add hook subscription method (non-enumerable so it won't be spread/cloned with state)
|
|
99
|
+
Object.defineProperty($, 'on', {
|
|
100
|
+
value: (event, callback) => {
|
|
101
|
+
if (hooks[event]) {
|
|
102
|
+
hooks[event].push(callback);
|
|
103
|
+
}
|
|
104
|
+
return () => hooks[event] = hooks[event].filter((cb) => cb !== callback);
|
|
105
|
+
},
|
|
106
|
+
enumerable: false,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Batch DOM changes: disconnects observer, runs fn, re-parses everything
|
|
110
|
+
Object.defineProperty($, 'batch', {
|
|
111
|
+
value: (fn) => {
|
|
112
|
+
if (observer) observer.disconnect();
|
|
113
|
+
try {
|
|
114
|
+
fn();
|
|
115
|
+
} finally {
|
|
116
|
+
parsedTree = parse(rootElement);
|
|
117
|
+
linkList = link(parsedTree);
|
|
118
|
+
const affectedElements = affected(parsedTree, $, $);
|
|
119
|
+
hydrate(affectedElements, $, linkList);
|
|
120
|
+
setPreviousState($);
|
|
121
|
+
renderAllIterations(parsedTree, $, linkList);
|
|
122
|
+
renderAllConditionals(parsedTree, $, linkList);
|
|
123
|
+
if (observer) {
|
|
124
|
+
observer.observe(rootElement, {
|
|
125
|
+
attributes: false,
|
|
126
|
+
characterData: false,
|
|
127
|
+
childList: true,
|
|
128
|
+
subtree: true,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
enumerable: false,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Initial hydration
|
|
137
|
+
const affectedElements = affected(parsedTree, $, $);
|
|
138
|
+
hydrate(affectedElements, $, linkList);
|
|
139
|
+
|
|
140
|
+
// Render all iterations and conditionals after initial hydration
|
|
141
|
+
setPreviousState($);
|
|
142
|
+
previousState = { ...$ };
|
|
143
|
+
renderAllIterations(parsedTree, $, linkList);
|
|
144
|
+
renderAllConditionals(parsedTree, $, linkList);
|
|
145
|
+
|
|
146
|
+
observer = new MutationObserver((mutations) => {
|
|
147
|
+
mutations.forEach(({ addedNodes, removedNodes, target }) => {
|
|
148
|
+
addedNodes.forEach((node) => {
|
|
149
|
+
// Skip if node itself or any ancestor is non-reactive
|
|
150
|
+
if (isNonReactiveOrInside(node)) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const index = [...target.childNodes].findIndex(
|
|
155
|
+
(childNode) => childNode === node,
|
|
156
|
+
);
|
|
157
|
+
const entry = Object.entries(linkList).find(
|
|
158
|
+
([_, element]) => element === target,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// If parent isn't tracked, this node is outside the reactive scope
|
|
162
|
+
if (!entry) return;
|
|
163
|
+
|
|
164
|
+
const [dotAnnotation] = entry;
|
|
165
|
+
const picked = navigateTree(parsedTree, dotAnnotation);
|
|
166
|
+
|
|
167
|
+
// If we can't navigate to the parent in the tree, skip
|
|
168
|
+
if (!picked) return;
|
|
169
|
+
|
|
170
|
+
// If parent has no element reference, re-parse from the actual DOM element
|
|
171
|
+
if (!picked.element) {
|
|
172
|
+
picked.element = target;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Parse the newly added node
|
|
176
|
+
const name = `${node.nodeName.toLowerCase()}_${hash()}`;
|
|
177
|
+
const parsedNode = parse(node);
|
|
178
|
+
|
|
179
|
+
// Update parent's parsed HTML
|
|
180
|
+
const { parsed } = parse(picked.element);
|
|
181
|
+
picked.parsed = parsed;
|
|
182
|
+
|
|
183
|
+
// Add the parsed node to parent's children
|
|
184
|
+
picked.children[name] = parsedNode;
|
|
185
|
+
|
|
186
|
+
linkList[`${dotAnnotation}.${name}`] = node;
|
|
187
|
+
|
|
188
|
+
const affectedElements = affected(parsedTree, $, $);
|
|
189
|
+
|
|
190
|
+
hydrate(affectedElements, $, linkList);
|
|
191
|
+
|
|
192
|
+
// Trigger afterDomMutation hook for added nodes
|
|
193
|
+
hooks.afterDomMutation.forEach((callback) => callback());
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
removedNodes.forEach((node) => {
|
|
197
|
+
const entry = Object.entries(linkList).find(
|
|
198
|
+
([_, element]) => element === node,
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
// Skip nodes that aren't tracked (e.g., iteration-generated nodes or nodes outside reactive scope)
|
|
202
|
+
if (!entry) return;
|
|
203
|
+
|
|
204
|
+
const [dotAnnotation] = entry;
|
|
205
|
+
delete linkList[dotAnnotation];
|
|
206
|
+
|
|
207
|
+
const dotPath = dotAnnotation.split(".");
|
|
208
|
+
const name = dotPath.pop();
|
|
209
|
+
const parentDotAnnotation = dotPath.join(".");
|
|
210
|
+
|
|
211
|
+
const picked = navigateTree(parsedTree, parentDotAnnotation);
|
|
212
|
+
|
|
213
|
+
// If we can't navigate to the parent, skip
|
|
214
|
+
if (!picked || !picked.element) return;
|
|
215
|
+
|
|
216
|
+
// Update parent's parsed HTML
|
|
217
|
+
const { parsed } = parse(picked.element);
|
|
218
|
+
picked.parsed = parsed;
|
|
219
|
+
|
|
220
|
+
// Remove the node from parent's children
|
|
221
|
+
delete picked.children[name];
|
|
222
|
+
|
|
223
|
+
const affectedElements = affected(parsedTree, $, $);
|
|
224
|
+
hydrate(affectedElements, $, linkList);
|
|
225
|
+
|
|
226
|
+
// Trigger afterDomMutation hook for removed nodes
|
|
227
|
+
hooks.afterDomMutation.forEach((callback) => callback());
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
observer.observe(rootElement, {
|
|
233
|
+
attributes: false,
|
|
234
|
+
characterData: false,
|
|
235
|
+
childList: true,
|
|
236
|
+
subtree: true,
|
|
237
|
+
attributeOldValue: false,
|
|
238
|
+
characterDataOldValue: false,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// Force reflow - ensures layout is applied before transitions re-enable
|
|
242
|
+
rootElement.offsetHeight;
|
|
243
|
+
|
|
244
|
+
// Remove the vibe attribute to reveal content and enable transitions
|
|
245
|
+
rootElement.removeAttribute(attrName);
|
|
246
|
+
|
|
247
|
+
return $;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
export default main;
|