@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/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;