pulse-js-framework 1.7.5 → 1.7.6
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/README.md +78 -392
- package/cli/dev.js +14 -0
- package/cli/docs-test.js +633 -0
- package/cli/index.js +313 -31
- package/cli/lint.js +13 -4
- package/cli/logger.js +32 -4
- package/cli/release.js +50 -20
- package/core/errors.js +2 -572
- package/package.json +11 -4
- package/runtime/dom-advanced.js +357 -0
- package/runtime/dom-binding.js +230 -0
- package/runtime/dom-conditional.js +133 -0
- package/runtime/dom-element.js +142 -0
- package/runtime/dom-lifecycle.js +178 -0
- package/runtime/dom-list.js +267 -0
- package/runtime/dom-selector.js +267 -0
- package/runtime/dom.js +119 -1279
- package/runtime/errors.js +575 -0
- package/runtime/form.js +417 -22
- package/runtime/native.js +398 -52
- package/runtime/pulse.js +1 -1
- package/runtime/router.js +6 -5
- package/runtime/store.js +81 -6
- package/types/async.d.ts +310 -0
- package/types/form.d.ts +378 -0
- package/types/index.d.ts +44 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse DOM List Module
|
|
3
|
+
* Reactive list rendering with efficient keyed diffing using LIS algorithm
|
|
4
|
+
*
|
|
5
|
+
* @module dom-list
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { effect } from './pulse.js';
|
|
9
|
+
import { getAdapter } from './dom-adapter.js';
|
|
10
|
+
|
|
11
|
+
// =============================================================================
|
|
12
|
+
// LIS ALGORITHM
|
|
13
|
+
// =============================================================================
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Compute Longest Increasing Subsequence indices
|
|
17
|
+
* Used to minimize DOM moves during list reconciliation
|
|
18
|
+
*
|
|
19
|
+
* @private
|
|
20
|
+
* @param {number[]} arr - Array of indices
|
|
21
|
+
* @returns {number[]} Indices of elements in the LIS
|
|
22
|
+
*/
|
|
23
|
+
export function computeLIS(arr) {
|
|
24
|
+
const n = arr.length;
|
|
25
|
+
if (n === 0) return [];
|
|
26
|
+
|
|
27
|
+
// dp[i] = smallest tail of LIS of length i+1
|
|
28
|
+
const dp = [];
|
|
29
|
+
// parent[i] = index of previous element in LIS ending at i
|
|
30
|
+
const parent = new Array(n).fill(-1);
|
|
31
|
+
// indices[i] = index in original array of dp[i]
|
|
32
|
+
const indices = [];
|
|
33
|
+
|
|
34
|
+
for (let i = 0; i < n; i++) {
|
|
35
|
+
const val = arr[i];
|
|
36
|
+
|
|
37
|
+
// Binary search for position
|
|
38
|
+
let lo = 0, hi = dp.length;
|
|
39
|
+
while (lo < hi) {
|
|
40
|
+
const mid = (lo + hi) >> 1;
|
|
41
|
+
if (dp[mid] < val) lo = mid + 1;
|
|
42
|
+
else hi = mid;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (lo === dp.length) {
|
|
46
|
+
dp.push(val);
|
|
47
|
+
indices.push(i);
|
|
48
|
+
} else {
|
|
49
|
+
dp[lo] = val;
|
|
50
|
+
indices[lo] = i;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
parent[i] = lo > 0 ? indices[lo - 1] : -1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Reconstruct LIS indices
|
|
57
|
+
const lis = [];
|
|
58
|
+
let idx = indices[dp.length - 1];
|
|
59
|
+
while (idx !== -1) {
|
|
60
|
+
lis.push(idx);
|
|
61
|
+
idx = parent[idx];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return lis.reverse();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// =============================================================================
|
|
68
|
+
// LIST RENDERING
|
|
69
|
+
// =============================================================================
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Create a reactive list with efficient keyed diffing
|
|
73
|
+
*
|
|
74
|
+
* LIST DIFFING ALGORITHM:
|
|
75
|
+
* -----------------------
|
|
76
|
+
* This uses a keyed reconciliation strategy to minimize DOM operations:
|
|
77
|
+
*
|
|
78
|
+
* 1. KEY EXTRACTION: Each item gets a unique key via keyFn (defaults to index)
|
|
79
|
+
* Good keys: item.id, item.uuid (stable across re-renders)
|
|
80
|
+
* Bad keys: array index (causes unnecessary re-renders on reorder)
|
|
81
|
+
*
|
|
82
|
+
* 2. RECONCILIATION PHASES:
|
|
83
|
+
* a) Build a map of new items by key
|
|
84
|
+
* b) For existing keys: reuse the DOM nodes (no re-creation)
|
|
85
|
+
* c) For removed keys: remove DOM nodes and run cleanup
|
|
86
|
+
* d) For new keys: batch create via DocumentFragment
|
|
87
|
+
*
|
|
88
|
+
* 3. REORDERING: Uses LIS (Longest Increasing Subsequence):
|
|
89
|
+
* - Computes which nodes are already in correct relative order
|
|
90
|
+
* - Only moves nodes NOT in the LIS (minimizes DOM operations)
|
|
91
|
+
* - New items are batched into DocumentFragment before insertion
|
|
92
|
+
*
|
|
93
|
+
* 4. BOUNDARY MARKERS: Uses comment nodes to track list boundaries:
|
|
94
|
+
* - startMarker: Insertion point for first item
|
|
95
|
+
* - endMarker: End boundary (not currently used but reserved)
|
|
96
|
+
*
|
|
97
|
+
* COMPLEXITY: O(n log n) for LIS + O(m) DOM moves where m = n - LIS length
|
|
98
|
+
* Best case (append only): O(n) with single DocumentFragment insert
|
|
99
|
+
*
|
|
100
|
+
* @param {Function|Pulse} getItems - Items source (reactive)
|
|
101
|
+
* @param {Function} template - (item, index) => Node | Node[]
|
|
102
|
+
* @param {Function} keyFn - (item, index) => key (default: index)
|
|
103
|
+
* @returns {DocumentFragment} Container fragment with reactive list
|
|
104
|
+
*/
|
|
105
|
+
export function list(getItems, template, keyFn = (item, i) => i) {
|
|
106
|
+
const dom = getAdapter();
|
|
107
|
+
const container = dom.createDocumentFragment();
|
|
108
|
+
const startMarker = dom.createComment('list-start');
|
|
109
|
+
const endMarker = dom.createComment('list-end');
|
|
110
|
+
|
|
111
|
+
dom.appendChild(container, startMarker);
|
|
112
|
+
dom.appendChild(container, endMarker);
|
|
113
|
+
|
|
114
|
+
// Map: key -> { nodes: Node[], cleanup: Function, item: any }
|
|
115
|
+
let itemNodes = new Map();
|
|
116
|
+
let keyOrder = []; // Track order of keys for diffing
|
|
117
|
+
|
|
118
|
+
effect(() => {
|
|
119
|
+
const items = typeof getItems === 'function' ? getItems() : getItems.get();
|
|
120
|
+
const itemsArray = Array.isArray(items) ? items : Array.from(items);
|
|
121
|
+
|
|
122
|
+
const newKeys = [];
|
|
123
|
+
const newItemNodes = new Map();
|
|
124
|
+
const newItems = []; // Track new items for batched insertion
|
|
125
|
+
|
|
126
|
+
// Phase 1: Build map of new items by key
|
|
127
|
+
itemsArray.forEach((item, index) => {
|
|
128
|
+
const key = keyFn(item, index);
|
|
129
|
+
newKeys.push(key);
|
|
130
|
+
|
|
131
|
+
if (itemNodes.has(key)) {
|
|
132
|
+
// Reuse existing entry
|
|
133
|
+
newItemNodes.set(key, itemNodes.get(key));
|
|
134
|
+
} else {
|
|
135
|
+
// Mark as new (will batch create later)
|
|
136
|
+
newItems.push({ key, item, index });
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Phase 2: Batch create new nodes using DocumentFragment
|
|
141
|
+
if (newItems.length > 0) {
|
|
142
|
+
for (const { key, item, index } of newItems) {
|
|
143
|
+
const result = template(item, index);
|
|
144
|
+
const nodes = Array.isArray(result) ? result : [result];
|
|
145
|
+
newItemNodes.set(key, { nodes, cleanup: null, item });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Phase 3: Remove items that are no longer present
|
|
150
|
+
for (const [key, entry] of itemNodes) {
|
|
151
|
+
if (!newItemNodes.has(key)) {
|
|
152
|
+
for (const node of entry.nodes) {
|
|
153
|
+
dom.removeNode(node);
|
|
154
|
+
}
|
|
155
|
+
if (entry.cleanup) entry.cleanup();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Phase 4: Efficient reordering using LIS algorithm
|
|
160
|
+
// Build old position map for existing keys
|
|
161
|
+
const oldKeyIndex = new Map();
|
|
162
|
+
keyOrder.forEach((key, i) => oldKeyIndex.set(key, i));
|
|
163
|
+
|
|
164
|
+
// Get indices of existing items in old order
|
|
165
|
+
const existingIndices = [];
|
|
166
|
+
const existingKeys = [];
|
|
167
|
+
for (let i = 0; i < newKeys.length; i++) {
|
|
168
|
+
const key = newKeys[i];
|
|
169
|
+
if (oldKeyIndex.has(key)) {
|
|
170
|
+
existingIndices.push(oldKeyIndex.get(key));
|
|
171
|
+
existingKeys.push(key);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Compute LIS - these nodes don't need to move
|
|
176
|
+
const lisIndices = new Set(computeLIS(existingIndices));
|
|
177
|
+
const stableKeys = new Set();
|
|
178
|
+
existingKeys.forEach((key, i) => {
|
|
179
|
+
if (lisIndices.has(i)) {
|
|
180
|
+
stableKeys.add(key);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Phase 5: Position nodes with minimal DOM operations
|
|
185
|
+
const parent = dom.getParentNode(startMarker);
|
|
186
|
+
if (!parent) {
|
|
187
|
+
// Not yet in DOM, use simple append with DocumentFragment batch
|
|
188
|
+
const fragment = dom.createDocumentFragment();
|
|
189
|
+
for (const key of newKeys) {
|
|
190
|
+
const entry = newItemNodes.get(key);
|
|
191
|
+
for (const node of entry.nodes) {
|
|
192
|
+
dom.appendChild(fragment, node);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
dom.insertBefore(container, fragment, endMarker);
|
|
196
|
+
} else {
|
|
197
|
+
// Optimized reordering: batch consecutive inserts using DocumentFragment
|
|
198
|
+
let prevNode = startMarker;
|
|
199
|
+
|
|
200
|
+
// Process items in new order
|
|
201
|
+
for (let i = 0; i < newKeys.length; i++) {
|
|
202
|
+
const key = newKeys[i];
|
|
203
|
+
const entry = newItemNodes.get(key);
|
|
204
|
+
const firstNode = entry.nodes[0];
|
|
205
|
+
const isNew = !oldKeyIndex.has(key);
|
|
206
|
+
const isStable = stableKeys.has(key);
|
|
207
|
+
|
|
208
|
+
// Check if node is already in correct position
|
|
209
|
+
const inPosition = dom.getNextSibling(prevNode) === firstNode;
|
|
210
|
+
|
|
211
|
+
if (inPosition && (isStable || isNew)) {
|
|
212
|
+
// Already in correct position, just advance
|
|
213
|
+
prevNode = entry.nodes[entry.nodes.length - 1];
|
|
214
|
+
} else {
|
|
215
|
+
// Collect consecutive items that need to be inserted at this position
|
|
216
|
+
const fragment = dom.createDocumentFragment();
|
|
217
|
+
let j = i;
|
|
218
|
+
|
|
219
|
+
while (j < newKeys.length) {
|
|
220
|
+
const k = newKeys[j];
|
|
221
|
+
const e = newItemNodes.get(k);
|
|
222
|
+
const f = e.nodes[0];
|
|
223
|
+
const n = !oldKeyIndex.has(k);
|
|
224
|
+
const s = stableKeys.has(k);
|
|
225
|
+
|
|
226
|
+
// If this item is already in position after prevNode, stop batching
|
|
227
|
+
if (j > i && dom.getNextSibling(prevNode) === f) {
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Add to batch if it's new or needs to move
|
|
232
|
+
if (n || !s || dom.getNextSibling(prevNode) !== f) {
|
|
233
|
+
for (const node of e.nodes) {
|
|
234
|
+
dom.appendChild(fragment, node);
|
|
235
|
+
}
|
|
236
|
+
j++;
|
|
237
|
+
} else {
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Insert the batch
|
|
243
|
+
if (dom.getFirstChild(fragment)) {
|
|
244
|
+
dom.insertBefore(parent, fragment, dom.getNextSibling(prevNode));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Update prevNode to last inserted node
|
|
248
|
+
if (j > i) {
|
|
249
|
+
const lastEntry = newItemNodes.get(newKeys[j - 1]);
|
|
250
|
+
prevNode = lastEntry.nodes[lastEntry.nodes.length - 1];
|
|
251
|
+
i = j - 1; // Continue from where we left off
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
itemNodes = newItemNodes;
|
|
258
|
+
keyOrder = newKeys;
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
return container;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export default {
|
|
265
|
+
list,
|
|
266
|
+
computeLIS
|
|
267
|
+
};
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse DOM Selector Module
|
|
3
|
+
* CSS selector parsing with LRU caching
|
|
4
|
+
*
|
|
5
|
+
* @module dom-selector
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { loggers } from './logger.js';
|
|
9
|
+
import { LRUCache } from './lru-cache.js';
|
|
10
|
+
import { getAdapter } from './dom-adapter.js';
|
|
11
|
+
|
|
12
|
+
const log = loggers.dom;
|
|
13
|
+
|
|
14
|
+
// =============================================================================
|
|
15
|
+
// CONFIGURATION
|
|
16
|
+
// =============================================================================
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {Object} DomConfig
|
|
20
|
+
* @property {number} [selectorCacheCapacity=500] - Max selectors to cache (0 = disabled)
|
|
21
|
+
* @property {boolean} [trackCacheMetrics=true] - Enable cache hit/miss tracking
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** @type {DomConfig} */
|
|
25
|
+
const config = {
|
|
26
|
+
selectorCacheCapacity: 500,
|
|
27
|
+
trackCacheMetrics: true
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// =============================================================================
|
|
31
|
+
// SELECTOR CACHE
|
|
32
|
+
// =============================================================================
|
|
33
|
+
// LRU (Least Recently Used) cache for parseSelector results.
|
|
34
|
+
//
|
|
35
|
+
// Why LRU instead of Map?
|
|
36
|
+
// - Apps typically reuse the same selectors (e.g., 'div.container', 'button.primary')
|
|
37
|
+
// - Without eviction, memory grows unbounded in long-running apps
|
|
38
|
+
// - LRU keeps frequently-used selectors hot while evicting rare ones
|
|
39
|
+
//
|
|
40
|
+
// Default capacity: 500 selectors (configurable via configureDom())
|
|
41
|
+
// - Most apps use 50-200 unique selectors
|
|
42
|
+
// - 500 provides headroom for dynamic selectors without excessive memory
|
|
43
|
+
//
|
|
44
|
+
// Cache hit returns a shallow copy to prevent mutation of cached config
|
|
45
|
+
// Metrics tracking enabled for performance monitoring
|
|
46
|
+
let selectorCache = new LRUCache(config.selectorCacheCapacity, { trackMetrics: config.trackCacheMetrics });
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Configure DOM module settings.
|
|
50
|
+
*
|
|
51
|
+
* Call this before using any DOM functions if you need non-default settings.
|
|
52
|
+
* Changing configuration clears the selector cache.
|
|
53
|
+
*
|
|
54
|
+
* @param {Partial<DomConfig>} options - Configuration options
|
|
55
|
+
* @returns {DomConfig} Current configuration after applying changes
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* // Increase cache for large apps
|
|
59
|
+
* configureDom({ selectorCacheCapacity: 1000 });
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* // Disable caching for debugging
|
|
63
|
+
* configureDom({ selectorCacheCapacity: 0 });
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* // Disable metrics tracking in production
|
|
67
|
+
* configureDom({ trackCacheMetrics: false });
|
|
68
|
+
*/
|
|
69
|
+
export function configureDom(options = {}) {
|
|
70
|
+
let cacheChanged = false;
|
|
71
|
+
|
|
72
|
+
if (options.selectorCacheCapacity !== undefined) {
|
|
73
|
+
config.selectorCacheCapacity = options.selectorCacheCapacity;
|
|
74
|
+
cacheChanged = true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (options.trackCacheMetrics !== undefined) {
|
|
78
|
+
config.trackCacheMetrics = options.trackCacheMetrics;
|
|
79
|
+
cacheChanged = true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Recreate cache if settings changed
|
|
83
|
+
if (cacheChanged) {
|
|
84
|
+
if (config.selectorCacheCapacity > 0) {
|
|
85
|
+
selectorCache = new LRUCache(config.selectorCacheCapacity, {
|
|
86
|
+
trackMetrics: config.trackCacheMetrics
|
|
87
|
+
});
|
|
88
|
+
} else {
|
|
89
|
+
// Disable caching with a null-like cache
|
|
90
|
+
selectorCache = {
|
|
91
|
+
get: () => undefined,
|
|
92
|
+
set: () => {},
|
|
93
|
+
clear: () => {},
|
|
94
|
+
getMetrics: () => ({ hits: 0, misses: 0, evictions: 0, hitRate: 0, size: 0, capacity: 0 }),
|
|
95
|
+
resetMetrics: () => {}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { ...config };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Get current DOM configuration.
|
|
105
|
+
* @returns {DomConfig} Current configuration (copy)
|
|
106
|
+
*/
|
|
107
|
+
export function getDomConfig() {
|
|
108
|
+
return { ...config };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Clear the selector cache.
|
|
113
|
+
* Useful for memory management or after significant DOM structure changes.
|
|
114
|
+
*/
|
|
115
|
+
export function clearSelectorCache() {
|
|
116
|
+
selectorCache.clear();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get selector cache performance metrics.
|
|
121
|
+
* Useful for debugging and performance tuning.
|
|
122
|
+
*
|
|
123
|
+
* @returns {{hits: number, misses: number, evictions: number, hitRate: number, size: number, capacity: number}}
|
|
124
|
+
* @example
|
|
125
|
+
* const stats = getCacheMetrics();
|
|
126
|
+
* console.log(`Cache hit rate: ${(stats.hitRate * 100).toFixed(1)}%`);
|
|
127
|
+
* console.log(`Cache size: ${stats.size}/${stats.capacity}`);
|
|
128
|
+
*/
|
|
129
|
+
export function getCacheMetrics() {
|
|
130
|
+
return selectorCache.getMetrics();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Reset cache metrics counters.
|
|
135
|
+
* Useful for measuring performance over specific time periods.
|
|
136
|
+
*/
|
|
137
|
+
export function resetCacheMetrics() {
|
|
138
|
+
selectorCache.resetMetrics();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Resolve a selector string or element to a DOM element
|
|
143
|
+
* @param {string|HTMLElement} target - CSS selector or DOM element
|
|
144
|
+
* @param {string} context - Context name for error messages
|
|
145
|
+
* @returns {{element: HTMLElement|null, selector: string}} Resolved element and original selector
|
|
146
|
+
*/
|
|
147
|
+
export function resolveSelector(target, context = 'target') {
|
|
148
|
+
if (typeof target === 'string') {
|
|
149
|
+
const dom = getAdapter();
|
|
150
|
+
const element = dom.querySelector(target);
|
|
151
|
+
return { element, selector: target };
|
|
152
|
+
}
|
|
153
|
+
return { element: target, selector: '(element)' };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Parse a CSS selector-like string into element configuration
|
|
158
|
+
* Results are cached for performance using LRU cache.
|
|
159
|
+
*
|
|
160
|
+
* Supported syntax:
|
|
161
|
+
* - Tag: `div`, `span`, `custom-element`
|
|
162
|
+
* - ID: `#app`, `#my-id`, `#_private`
|
|
163
|
+
* - Classes: `.class`, `.my-class`, `.-modifier`
|
|
164
|
+
* - Attributes: `[attr]`, `[attr=value]`, `[attr="quoted value"]`, `[attr='single quoted']`
|
|
165
|
+
*
|
|
166
|
+
* Examples:
|
|
167
|
+
* "div" -> { tag: "div" }
|
|
168
|
+
* "#app" -> { tag: "div", id: "app" }
|
|
169
|
+
* ".container" -> { tag: "div", classes: ["container"] }
|
|
170
|
+
* "button.primary.large" -> { tag: "button", classes: ["primary", "large"] }
|
|
171
|
+
* "input[type=text][placeholder=Name]" -> { tag: "input", attrs: { type: "text", placeholder: "Name" } }
|
|
172
|
+
* "div[data-id=\"complex-123\"]" -> { tag: "div", attrs: { "data-id": "complex-123" } }
|
|
173
|
+
*
|
|
174
|
+
* @param {string} selector - CSS selector-like string
|
|
175
|
+
* @returns {{tag: string, id: string|null, classes: string[], attrs: Object}} Parsed configuration
|
|
176
|
+
*/
|
|
177
|
+
export function parseSelector(selector) {
|
|
178
|
+
if (!selector || selector === '') {
|
|
179
|
+
return { tag: 'div', id: null, classes: [], attrs: {} };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Check cache first
|
|
183
|
+
const cached = selectorCache.get(selector);
|
|
184
|
+
if (cached) {
|
|
185
|
+
// Return a shallow copy to prevent mutation
|
|
186
|
+
return {
|
|
187
|
+
tag: cached.tag,
|
|
188
|
+
id: cached.id,
|
|
189
|
+
classes: [...cached.classes],
|
|
190
|
+
attrs: { ...cached.attrs }
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const config = {
|
|
195
|
+
tag: 'div',
|
|
196
|
+
id: null,
|
|
197
|
+
classes: [],
|
|
198
|
+
attrs: {}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
let remaining = selector;
|
|
202
|
+
|
|
203
|
+
// Match tag name at the start
|
|
204
|
+
const tagMatch = remaining.match(/^([a-zA-Z][a-zA-Z0-9-]*)/);
|
|
205
|
+
if (tagMatch) {
|
|
206
|
+
config.tag = tagMatch[1];
|
|
207
|
+
remaining = remaining.slice(tagMatch[0].length);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Match ID (supports starting with letter, underscore, or hyphen followed by valid chars)
|
|
211
|
+
const idMatch = remaining.match(/#([a-zA-Z_-][a-zA-Z0-9-_]*)/);
|
|
212
|
+
if (idMatch) {
|
|
213
|
+
config.id = idMatch[1];
|
|
214
|
+
remaining = remaining.replace(idMatch[0], '');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Match classes (supports starting with letter, underscore, or hyphen)
|
|
218
|
+
const classMatches = remaining.matchAll(/\.([a-zA-Z_-][a-zA-Z0-9-_]*)/g);
|
|
219
|
+
for (const match of classMatches) {
|
|
220
|
+
config.classes.push(match[1]);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Match attributes - improved regex handles quoted values with special characters
|
|
224
|
+
// Matches: [attr], [attr=value], [attr="quoted value"], [attr='quoted value']
|
|
225
|
+
const attrRegex = /\[([a-zA-Z_][a-zA-Z0-9-_]*)(?:=(?:"([^"]*)"|'([^']*)'|([^\]]*)))?\]/g;
|
|
226
|
+
const attrMatches = remaining.matchAll(attrRegex);
|
|
227
|
+
for (const match of attrMatches) {
|
|
228
|
+
const key = match[1];
|
|
229
|
+
// Value can be in match[2] (double-quoted), match[3] (single-quoted), or match[4] (unquoted)
|
|
230
|
+
const value = match[2] ?? match[3] ?? match[4] ?? '';
|
|
231
|
+
config.attrs[key] = value;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Validate: check for unparsed content (malformed selector parts)
|
|
235
|
+
// Remove all parsed parts to see if anything remains
|
|
236
|
+
let unparsed = remaining
|
|
237
|
+
.replace(/#[a-zA-Z_-][a-zA-Z0-9-_]*/g, '') // Remove IDs
|
|
238
|
+
.replace(/\.[a-zA-Z_-][a-zA-Z0-9-_]*/g, '') // Remove classes
|
|
239
|
+
.replace(/\[([a-zA-Z_][a-zA-Z0-9-_]*)(?:=(?:"[^"]*"|'[^']*'|[^\]]*))?\]/g, '') // Remove attrs
|
|
240
|
+
.trim();
|
|
241
|
+
|
|
242
|
+
if (unparsed) {
|
|
243
|
+
log.warn(`Selector "${selector}" contains unrecognized parts: "${unparsed}". ` +
|
|
244
|
+
'Supported syntax: tag#id.class[attr=value]');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Cache the result (LRU cache handles eviction automatically)
|
|
248
|
+
selectorCache.set(selector, config);
|
|
249
|
+
|
|
250
|
+
// Return a copy
|
|
251
|
+
return {
|
|
252
|
+
tag: config.tag,
|
|
253
|
+
id: config.id,
|
|
254
|
+
classes: [...config.classes],
|
|
255
|
+
attrs: { ...config.attrs }
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export default {
|
|
260
|
+
parseSelector,
|
|
261
|
+
resolveSelector,
|
|
262
|
+
configureDom,
|
|
263
|
+
getDomConfig,
|
|
264
|
+
clearSelectorCache,
|
|
265
|
+
getCacheMetrics,
|
|
266
|
+
resetCacheMetrics
|
|
267
|
+
};
|