@ape-egg/vibe 1.2.0 → 1.3.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.
@@ -1,5 +1,5 @@
1
1
  import { debugLog } from './debug.js';
2
- import { PHASE_COMPLETE } from './constants.js';
2
+ import { PHASE_READY, FOUC_CLASS_OR_ATTR } from './constants.js';
3
3
 
4
4
  /**
5
5
  * Check if all Vibe processing is complete and cleanup can run
@@ -7,30 +7,27 @@ import { PHASE_COMPLETE } from './constants.js';
7
7
  * @returns {Boolean} - true if cleanup should run
8
8
  */
9
9
  export const shouldCleanup = (rootElement) => {
10
- // 1. Check for pending <component> elements
11
- const componentElements = rootElement.querySelectorAll('component');
10
+ // 1. Check for pending <component> elements (only those with src - fetched components)
11
+ // Inline component wrappers (<component> without src) are fine to remain
12
+ const componentElements = rootElement.querySelectorAll('component[src], div.component[src]');
12
13
  if (componentElements.length > 0) {
13
14
  return false;
14
15
  }
15
16
 
16
17
  // 2. Check for unhydrated bindings (literal @[...] in DOM, excluding dehydrated elements)
17
- const walker = document.createTreeWalker(
18
- rootElement,
19
- NodeFilter.SHOW_TEXT,
20
- {
21
- acceptNode(node) {
22
- // Check if this text node is inside a dehydrated element
23
- let parent = node.parentElement;
24
- while (parent && parent !== rootElement) {
25
- if (parent.hasAttribute('dehydrate')) {
26
- return NodeFilter.FILTER_REJECT; // Skip dehydrated content
27
- }
28
- parent = parent.parentElement;
18
+ const walker = document.createTreeWalker(rootElement, NodeFilter.SHOW_TEXT, {
19
+ acceptNode(node) {
20
+ // Check if this text node is inside a dehydrated element
21
+ let parent = node.parentElement;
22
+ while (parent && parent !== rootElement) {
23
+ if (parent.hasAttribute('dehydrate')) {
24
+ return NodeFilter.FILTER_REJECT; // Skip dehydrated content
29
25
  }
30
- return NodeFilter.FILTER_ACCEPT;
26
+ parent = parent.parentElement;
31
27
  }
32
- }
33
- );
28
+ return NodeFilter.FILTER_ACCEPT;
29
+ },
30
+ });
34
31
 
35
32
  let node;
36
33
  while ((node = walker.nextNode())) {
@@ -44,16 +41,40 @@ export const shouldCleanup = (rootElement) => {
44
41
  };
45
42
 
46
43
  /**
47
- * Perform cleanup - remove vibe attribute to reveal content
44
+ * Perform cleanup - remove attribute or class from all matching elements
48
45
  * @param {Element} rootElement - Root element
49
- * @param {String} attrName - Attribute name to remove (default: 'vibe')
50
46
  * @param {Boolean} debug - Debug mode
51
47
  */
52
- export const cleanup = (rootElement, attrName = 'vibe', debug = false) => {
48
+ export const cleanup = (rootElement, debug = false) => {
53
49
  // Force reflow
54
50
  rootElement.offsetHeight;
55
51
 
56
- // Remove vibe attribute
57
- debugLog(PHASE_COMPLETE, `removing [${attrName}] attribute`, debug);
58
- rootElement.removeAttribute(attrName);
52
+ // Determine if selector is a class (starts with .) or attribute (default)
53
+ const isClass = FOUC_CLASS_OR_ATTR.startsWith('.');
54
+ const cleanName = FOUC_CLASS_OR_ATTR.replace(/^\./, '').replace(/^\[/, '').replace(/\]$/, '');
55
+
56
+ if (isClass) {
57
+ // Remove class from all elements in document that have it
58
+ const elements = document.querySelectorAll(`.${cleanName}`);
59
+ elements.forEach((el) => el.classList.remove(cleanName));
60
+ debugLog(PHASE_READY, `removing .${cleanName} class from ${elements.length} elements`, debug);
61
+ } else {
62
+ // Remove attribute from all elements in document that have it
63
+ const elements = document.querySelectorAll(`[${cleanName}]`);
64
+ elements.forEach((el) => el.removeAttribute(cleanName));
65
+ debugLog(
66
+ PHASE_READY,
67
+ `removing [${cleanName}] attribute from ${elements.length} elements`,
68
+ debug,
69
+ );
70
+ }
71
+
72
+ // Dispatch ready event to signal that Vibe has completed all initial processing
73
+ if (typeof document !== 'undefined') {
74
+ document.dispatchEvent(
75
+ new CustomEvent('vibe:ready', {
76
+ detail: { rootElement, cleanName, isClass },
77
+ }),
78
+ );
79
+ }
59
80
  };
@@ -1,4 +1,4 @@
1
- // Shared utility for processing component state from <script type="component">
1
+ // Shared utility for processing component state
2
2
 
3
3
  /**
4
4
  * Generate unique component ID
@@ -20,7 +20,10 @@ export const abortComponentFetch = (element) => {
20
20
 
21
21
  export const processComponent = (rootElement, onComplete, config = {}) => {
22
22
  const debug = !!config?.debug;
23
- const componentElements = rootElement.querySelectorAll('component');
23
+ // Only process component elements with src attribute (fetched components)
24
+ // Supports: <component src="..."> and <div class="component" src="...">
25
+ // Ignores: <component> and <div class="component"> (inline component wrappers)
26
+ const componentElements = rootElement.querySelectorAll('component[src], div.component[src]');
24
27
 
25
28
  if (componentElements.length === 0) {
26
29
  if (onComplete) onComplete();
@@ -31,12 +34,6 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
31
34
  const el = componentElements[0];
32
35
  const src = el.getAttribute('src');
33
36
 
34
- if (!src) {
35
- el.remove();
36
- // Don't recursively call - let MutationObserver handle it
37
- return;
38
- }
39
-
40
37
  // Capture children and props before fetching
41
38
  const children = el.innerHTML.trim();
42
39
  const props = {};
@@ -97,10 +94,10 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
97
94
  const bindingMatch = propValue.match(/^@\[(.+)\]$/);
98
95
 
99
96
  if (bindingMatch) {
100
- // Reactive prop: replace propName as word boundary
97
+ // Reactive prop: replace @[propName] with @[path]
101
98
  const path = bindingMatch[1];
102
- const propPattern = new RegExp(`\\b${escapeRegex(propName)}\\b`, 'g');
103
- transformedHtml = transformedHtml.replace(propPattern, path);
99
+ const propPattern = new RegExp(`@\\[${escapeRegex(propName)}\\]`, 'g');
100
+ transformedHtml = transformedHtml.replace(propPattern, `@[${path}]`);
104
101
  } else {
105
102
  // Static prop: replace @[propName] with literal value
106
103
  const propPattern = new RegExp(`@\\[${escapeRegex(propName)}\\]`, 'g');
@@ -117,14 +114,24 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
117
114
  // Clean up pending fetch tracker
118
115
  pendingFetches.delete(el);
119
116
 
120
- // Set outerHTML - this triggers MutationObserver
117
+ // Replace with clean component wrapper (no src, no props)
121
118
  // Check if element still has a parent (might have been removed during fetch)
122
119
  if (el.parentNode) {
123
- el.outerHTML = transformedHtml;
120
+ // Create clean wrapper element (preserve tag type: component or div.component)
121
+ const newWrapper = el.tagName === 'DIV'
122
+ ? document.createElement('div')
123
+ : document.createElement('component');
124
+
125
+ if (el.tagName === 'DIV') {
126
+ newWrapper.className = 'component';
127
+ }
128
+
129
+ newWrapper.innerHTML = transformedHtml;
130
+ el.replaceWith(newWrapper);
124
131
  debugLog(PHASE_FETCH, src, debug);
125
132
 
126
133
  // Force immediate processing of the mutation (MutationObserver is async, but we need sync)
127
- // Use microtask to process right after outerHTML completes
134
+ // Use microtask to process right after replaceWith completes
128
135
  if (config._forceSync && config._processMutations && config._observer) {
129
136
  Promise.resolve().then(() => {
130
137
  const pending = config._observer.takeRecords();
@@ -1,12 +1,13 @@
1
1
  // Debug logger name
2
2
  export const DEBUGGER_NAME = '[vibe-debug]:';
3
+ export const FOUC_CLASS_OR_ATTR = 'vibe-fouc'; // Class or attribute used to prevent FOUC (default: [vibe])
3
4
 
4
5
  // Lifecycle phase names for debug logging
5
6
  // ONE-OFF operations (run once during initialization)
6
7
  export const PHASE_ATTACH = 'Attached'; // Latches onto DOM element (index.js)
7
8
  export const PHASE_MANIFEST = 'Manifested'; // Creates DOM manifest (manifest.js)
8
9
  export const PHASE_OBSERVE = 'Observer'; // Starts MutationObserver (index.js)
9
- export const PHASE_COMPLETE = 'Cleanup'; // Removes [vibe] attribute (index.js)
10
+ export const PHASE_READY = 'Ready'; // Removes [vibe-fouc] attribute (or .vibe-fouc class) and dispatches vibe:ready event (cleanup.js)
10
11
 
11
12
  // REPEATED operations (run during init + can repeat during runtime)
12
13
  export const PHASE_PARSE = 'Parsed'; // Reads DOM structure (parse.js)
@@ -16,9 +17,12 @@ export const PHASE_CONDITION = 'Evaluated'; // Renders <!-- if --> blocks (condi
16
17
  export const PHASE_FETCH = 'Fetched'; // Loads <component> content (component.js)
17
18
  export const PHASE_UPDATE = 'Proxy'; // State changes trigger re-hydration (index.js)
18
19
  export const PHASE_MUTATE = 'Mutation'; // DOM mutations detected by observer (index.js)
20
+ export const PHASE_HYPERSPEED = 'Hyperspeed'; // Restores @[...] markers from pre-compiled manifest (hyperspeed.js)
19
21
 
20
22
  // Elements that should not have reactive bindings
21
- export const NON_REACTIVE_ELEMENTS = ['SCRIPT', 'HEAD', 'PRE', 'COMPONENT'];
23
+ // Note: COMPONENT is NOT in this list - inline component wrappers need to be parsed
24
+ // Fetched components (<component src="">) are handled separately by processComponent()
25
+ export const NON_REACTIVE_ELEMENTS = ['SCRIPT', 'HEAD', 'PRE'];
22
26
 
23
27
  // Attributes where the string value is meaningful (should NOT be removed when falsy)
24
28
  // All other attributes are treated as boolean-like (removed when falsy, present when truthy)
@@ -159,14 +163,41 @@ export const DOM_PROPERTIES = ['value', 'checked', 'selected'];
159
163
  // DOM element properties that should NOT be rewritten in event handlers
160
164
  // When parsing onclick="this.value = this.inputValue", preserve this.value (DOM) but rewrite this.inputValue (component state)
161
165
  export const DOM_ELEMENT_PROPERTIES = new Set([
162
- 'value', 'checked', 'selected', 'disabled', 'readOnly', 'files',
163
- 'tagName', 'nodeName', 'nodeType', 'classList', 'className', 'id',
164
- 'innerHTML', 'outerHTML', 'textContent', 'innerText',
165
- 'parentElement', 'parentNode', 'nextSibling', 'previousSibling',
166
- 'firstChild', 'lastChild', 'children', 'childNodes',
167
- 'offsetWidth', 'offsetHeight', 'clientWidth', 'clientHeight',
168
- 'scrollTop', 'scrollLeft', 'scrollWidth', 'scrollHeight',
169
- 'style', 'dataset', 'attributes'
166
+ 'value',
167
+ 'checked',
168
+ 'selected',
169
+ 'disabled',
170
+ 'readOnly',
171
+ 'files',
172
+ 'tagName',
173
+ 'nodeName',
174
+ 'nodeType',
175
+ 'classList',
176
+ 'className',
177
+ 'id',
178
+ 'innerHTML',
179
+ 'outerHTML',
180
+ 'textContent',
181
+ 'innerText',
182
+ 'parentElement',
183
+ 'parentNode',
184
+ 'nextSibling',
185
+ 'previousSibling',
186
+ 'firstChild',
187
+ 'lastChild',
188
+ 'children',
189
+ 'childNodes',
190
+ 'offsetWidth',
191
+ 'offsetHeight',
192
+ 'clientWidth',
193
+ 'clientHeight',
194
+ 'scrollTop',
195
+ 'scrollLeft',
196
+ 'scrollWidth',
197
+ 'scrollHeight',
198
+ 'style',
199
+ 'dataset',
200
+ 'attributes',
170
201
  ]);
171
202
 
172
203
  // Regex for matching reactive bindings (@[expression])
package/runtime/debug.js CHANGED
@@ -14,6 +14,7 @@ const PHASE_COLORS = {
14
14
  Fetched: 'oklch(0.59 0.12 307)', // muted purple
15
15
  Mutation: 'oklch(0.60 0.11 240)', // muted blue
16
16
  Proxy: 'oklch(0.60 0.11 240)', // muted blue
17
+ Hyperspeed: 'oklch(0.60 0.11 180)', // muted cyan
17
18
  };
18
19
 
19
20
  // Special colors for styled segments (Tailwind 500)
@@ -82,10 +82,15 @@ export default (affected, state, manifest = {}, oldState = {}) => {
82
82
  attrName.startsWith('on');
83
83
 
84
84
  if (isDomProperty && isPureBinding) {
85
- // For DOM properties like value, set the property directly
85
+ // For DOM properties (value, checked, selected), set BOTH property AND attribute
86
+ // Property: Fast runtime updates, what the user sees
87
+ // Attribute: Truthful DOM representation, enables compiler snapshots via outerHTML
86
88
  const expr = isPureBinding[1];
87
89
  const value = evalInScope(expr, effectiveState, element);
88
90
  element[attrName] = value;
91
+ if (value !== undefined && value !== null) {
92
+ element.setAttribute(attrName, String(value));
93
+ }
89
94
  } else if (!isValueAttr && isPureBinding) {
90
95
  // Boolean-like attributes: add or remove based on truthiness
91
96
  const expr = isPureBinding[1];