@sprlab/wccompiler 0.13.0 → 0.15.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/README.md +998 -998
- package/adapters/angular-compiled/angular.d.ts +197 -197
- package/adapters/angular-compiled/angular.mjs +488 -488
- package/adapters/angular.js +54 -54
- package/adapters/angular.ts +630 -630
- package/adapters/react.js +114 -114
- package/adapters/vue.js +103 -103
- package/bin/wcc.js +412 -412
- package/bin/wcc.test.js +126 -126
- package/integrations/angular.js +73 -73
- package/integrations/react.js +859 -859
- package/integrations/vue.js +253 -253
- package/lib/codegen.js +2078 -2074
- package/lib/compiler-browser.js +545 -545
- package/lib/compiler.js +483 -479
- package/lib/config.js +71 -71
- package/lib/css-scoper.js +180 -180
- package/lib/dev-server.js +193 -193
- package/lib/import-resolver.js +160 -160
- package/lib/parser-extractors.js +1240 -1169
- package/lib/parser.js +273 -269
- package/lib/reactive-runtime.js +143 -143
- package/lib/sfc-parser.js +333 -333
- package/lib/template-normalizer.js +114 -114
- package/lib/tree-walker.js +1013 -1013
- package/lib/types.js +262 -262
- package/lib/wcc-runtime.js +68 -68
- package/package.json +85 -85
- package/types/wcc.d.ts +28 -28
- package/types/wcc.test.js +46 -46
package/lib/tree-walker.js
CHANGED
|
@@ -1,1013 +1,1013 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tree Walker for wcCompiler v2.
|
|
3
|
-
*
|
|
4
|
-
* Walks a jsdom DOM tree to discover:
|
|
5
|
-
* - Text bindings {{var}} with childNodes[n] paths
|
|
6
|
-
* - Event bindings @event="handler"
|
|
7
|
-
* - Show bindings show="expression"
|
|
8
|
-
* - Conditional chains (if / else-if / else)
|
|
9
|
-
*
|
|
10
|
-
* Produces { bindings, events, showBindings } arrays with path metadata.
|
|
11
|
-
* processIfChains() detects conditional chains, validates them,
|
|
12
|
-
* extracts branch templates, and replaces chains with comment anchors.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { parseHTML } from 'linkedom';
|
|
16
|
-
import { BOOLEAN_ATTRIBUTES } from './types.js';
|
|
17
|
-
|
|
18
|
-
/** @import { Binding, EventBinding, IfBlock, IfBranch, ShowBinding, AttrBinding, ForBlock, ModelBinding, ModelPropBinding, SlotBinding, SlotProp, RefBinding, ChildComponentBinding, ChildPropBinding, DynamicComponentBinding, DynPropBinding, DynEventBinding } from './types.js' */
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Walk a DOM tree rooted at rootEl, discovering bindings and events.
|
|
22
|
-
*
|
|
23
|
-
* @param {Element} rootEl — jsdom DOM element (parsed template root)
|
|
24
|
-
* @param {Set<string>} signalNames — Set of signal variable names
|
|
25
|
-
* @param {Set<string>} computedNames — Set of computed variable names
|
|
26
|
-
* @param {Set<string>} [propNames] — Set of prop names from defineProps
|
|
27
|
-
* @returns {{ bindings: Binding[], events: EventBinding[], showBindings: ShowBinding[], modelBindings: ModelBinding[], modelPropBindings: ModelPropBinding[], attrBindings: AttrBinding[], slots: SlotBinding[], childComponents: ChildComponentBinding[] }}
|
|
28
|
-
*/
|
|
29
|
-
export function walkTree(rootEl, signalNames, computedNames, propNames = new Set()) {
|
|
30
|
-
/** @type {Binding[]} */
|
|
31
|
-
const bindings = [];
|
|
32
|
-
/** @type {EventBinding[]} */
|
|
33
|
-
const events = [];
|
|
34
|
-
/** @type {ShowBinding[]} */
|
|
35
|
-
const showBindings = [];
|
|
36
|
-
/** @type {ModelBinding[]} */
|
|
37
|
-
const modelBindings = [];
|
|
38
|
-
/** @type {ModelPropBinding[]} */
|
|
39
|
-
const modelPropBindings = [];
|
|
40
|
-
/** @type {AttrBinding[]} */
|
|
41
|
-
const attrBindings = [];
|
|
42
|
-
/** @type {SlotBinding[]} */
|
|
43
|
-
const slots = [];
|
|
44
|
-
/** @type {ChildComponentBinding[]} */
|
|
45
|
-
const childComponents = [];
|
|
46
|
-
let bindIdx = 0;
|
|
47
|
-
let eventIdx = 0;
|
|
48
|
-
let showIdx = 0;
|
|
49
|
-
let modelIdx = 0;
|
|
50
|
-
let modelPropIdx = 0;
|
|
51
|
-
let attrIdx = 0;
|
|
52
|
-
let slotIdx = 0;
|
|
53
|
-
let childIdx = 0;
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Determine the binding type for a variable name.
|
|
57
|
-
* Priority: prop → signal → computed → method
|
|
58
|
-
*
|
|
59
|
-
* @param {string} name
|
|
60
|
-
* @returns {'prop' | 'signal' | 'computed' | 'method'}
|
|
61
|
-
*/
|
|
62
|
-
function bindingType(name) {
|
|
63
|
-
if (propNames.has(name)) return 'prop';
|
|
64
|
-
if (signalNames.has(name)) return 'signal';
|
|
65
|
-
if (computedNames.has(name)) return 'computed';
|
|
66
|
-
return 'method';
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Recursively walk a DOM node, collecting bindings and events.
|
|
71
|
-
*
|
|
72
|
-
* @param {Node} node — DOM node to walk
|
|
73
|
-
* @param {string[]} pathParts — Current path segments from root
|
|
74
|
-
*/
|
|
75
|
-
function walk(node, pathParts) {
|
|
76
|
-
// --- Element node ---
|
|
77
|
-
if (node.nodeType === 1) {
|
|
78
|
-
const el = /** @type {Element} */ (node);
|
|
79
|
-
|
|
80
|
-
// Skip <template #name> elements — they are slot content passed to child components
|
|
81
|
-
// Their interpolations are resolved by the provider, not the consumer
|
|
82
|
-
if (el.tagName === 'TEMPLATE') {
|
|
83
|
-
for (const attr of Array.from(el.attributes)) {
|
|
84
|
-
if (attr.name.startsWith('#')) return;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// Detect <slot> elements — replace with <span data-slot="..."> placeholder
|
|
89
|
-
if (el.tagName === 'SLOT') {
|
|
90
|
-
const slotName = el.getAttribute('name') || '';
|
|
91
|
-
const safeName = slotName ? slotName.replace(/[^a-zA-Z0-9_]/g, '_') : 'default';
|
|
92
|
-
const varName = `__slot_${safeName}_${slotIdx}`;
|
|
93
|
-
slotIdx++;
|
|
94
|
-
const defaultContent = el.innerHTML.trim();
|
|
95
|
-
|
|
96
|
-
// Collect :prop="expr" attributes (slot props for scoped slots)
|
|
97
|
-
/** @type {SlotProp[]} */
|
|
98
|
-
const slotProps = [];
|
|
99
|
-
for (const attr of Array.from(el.attributes)) {
|
|
100
|
-
if (attr.name.startsWith(':')) {
|
|
101
|
-
slotProps.push({ prop: attr.name.slice(1), source: attr.value });
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
slots.push({
|
|
106
|
-
varName,
|
|
107
|
-
name: slotName,
|
|
108
|
-
path: [...pathParts],
|
|
109
|
-
defaultContent,
|
|
110
|
-
slotProps,
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
// Replace <slot> with <span data-slot="name">
|
|
114
|
-
const doc = el.ownerDocument;
|
|
115
|
-
const placeholder = doc.createElement('span');
|
|
116
|
-
placeholder.setAttribute('data-slot', slotName || 'default');
|
|
117
|
-
if (defaultContent) placeholder.innerHTML = defaultContent;
|
|
118
|
-
el.parentNode.replaceChild(placeholder, el);
|
|
119
|
-
return; // Don't recurse into the replaced element
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Detect child custom elements (tag name contains a hyphen)
|
|
123
|
-
const tagLower = el.tagName.toLowerCase();
|
|
124
|
-
if (tagLower.includes('-') && tagLower !== rootEl.tagName?.toLowerCase()) {
|
|
125
|
-
/** @type {ChildPropBinding[]} */
|
|
126
|
-
const propBindings = [];
|
|
127
|
-
for (const attr of Array.from(el.attributes)) {
|
|
128
|
-
// Skip directive attributes (@event, :bind, show, model, etc.)
|
|
129
|
-
if (attr.name.startsWith('@') || attr.name.startsWith(':') || attr.name.startsWith('bind:') || attr.name.startsWith('model:')) continue;
|
|
130
|
-
if (['show', 'model', 'if', 'else-if', 'else', 'each', 'ref'].includes(attr.name)) continue;
|
|
131
|
-
|
|
132
|
-
// Check for {{interpolation}} in attribute value
|
|
133
|
-
const interpMatch = attr.value.match(/^\{\{([\w.()]+)\}\}$/);
|
|
134
|
-
if (interpMatch) {
|
|
135
|
-
const rawExpr = interpMatch[1];
|
|
136
|
-
const expr = rawExpr.endsWith('()') ? rawExpr.slice(0, -2) : rawExpr;
|
|
137
|
-
propBindings.push({
|
|
138
|
-
attr: attr.name,
|
|
139
|
-
expr,
|
|
140
|
-
type: propNames.has(expr) ? 'prop' : signalNames.has(expr) ? 'signal' : computedNames.has(expr) ? 'computed' : 'method',
|
|
141
|
-
});
|
|
142
|
-
// Clear the interpolation from the attribute — the effect sets it at runtime
|
|
143
|
-
el.setAttribute(attr.name, '');
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Always register child component for auto-import (even without prop bindings)
|
|
148
|
-
childComponents.push({
|
|
149
|
-
tag: tagLower,
|
|
150
|
-
varName: `__child${childIdx++}`,
|
|
151
|
-
path: [...pathParts],
|
|
152
|
-
propBindings,
|
|
153
|
-
});
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// Check for @event attributes
|
|
157
|
-
const attrsToRemove = [];
|
|
158
|
-
for (const attr of Array.from(el.attributes)) {
|
|
159
|
-
if (attr.name.startsWith('@')) {
|
|
160
|
-
const eventName = attr.name.slice(1);
|
|
161
|
-
const handlerName = attr.value.replace(/[^a-zA-Z0-9_]/g, '_').slice(0, 20);
|
|
162
|
-
const varName = `__evt_${eventName.replace(/-/g, '_')}_${handlerName}_${eventIdx}`;
|
|
163
|
-
eventIdx++;
|
|
164
|
-
events.push({
|
|
165
|
-
varName,
|
|
166
|
-
event: eventName,
|
|
167
|
-
handler: attr.value,
|
|
168
|
-
path: [...pathParts],
|
|
169
|
-
});
|
|
170
|
-
attrsToRemove.push(attr.name);
|
|
171
|
-
} else if (attr.name.startsWith(':') || attr.name.startsWith('bind:')) {
|
|
172
|
-
// Attribute binding: :attr="expr" or bind:attr="expr"
|
|
173
|
-
const attrName = attr.name.startsWith(':') ? attr.name.slice(1) : attr.name.slice(5);
|
|
174
|
-
const expression = attr.value;
|
|
175
|
-
|
|
176
|
-
// Classify binding kind
|
|
177
|
-
let kind;
|
|
178
|
-
if (attrName === 'class') {
|
|
179
|
-
kind = 'class';
|
|
180
|
-
} else if (attrName === 'style') {
|
|
181
|
-
kind = 'style';
|
|
182
|
-
} else if (BOOLEAN_ATTRIBUTES.has(attrName)) {
|
|
183
|
-
kind = 'bool';
|
|
184
|
-
} else {
|
|
185
|
-
kind = 'attr';
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const varName = `__attr_${attrName.replace(/-/g, '_')}_${attrIdx}`;
|
|
189
|
-
attrIdx++;
|
|
190
|
-
attrBindings.push({
|
|
191
|
-
varName,
|
|
192
|
-
attr: attrName,
|
|
193
|
-
expression,
|
|
194
|
-
kind,
|
|
195
|
-
path: [...pathParts],
|
|
196
|
-
});
|
|
197
|
-
attrsToRemove.push(attr.name);
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
attrsToRemove.forEach((a) => el.removeAttribute(a));
|
|
201
|
-
|
|
202
|
-
// Detect show attribute
|
|
203
|
-
if (el.hasAttribute('show')) {
|
|
204
|
-
const varName = `__show_${showIdx}`;
|
|
205
|
-
showIdx++;
|
|
206
|
-
showBindings.push({
|
|
207
|
-
varName,
|
|
208
|
-
expression: el.getAttribute('show'),
|
|
209
|
-
path: [...pathParts],
|
|
210
|
-
});
|
|
211
|
-
el.removeAttribute('show');
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// Detect model attribute
|
|
215
|
-
if (el.hasAttribute('model')) {
|
|
216
|
-
const signalName = el.getAttribute('model');
|
|
217
|
-
const tag = el.tagName.toLowerCase();
|
|
218
|
-
|
|
219
|
-
// Validate element is a form element
|
|
220
|
-
if (!['input', 'textarea', 'select'].includes(tag)) {
|
|
221
|
-
const error = new Error(`model is only valid on <input>, <textarea>, or <select>, not on <${tag}>`);
|
|
222
|
-
/** @ts-expect-error — custom error code */
|
|
223
|
-
error.code = 'INVALID_MODEL_ELEMENT';
|
|
224
|
-
throw error;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// Validate model value is a valid identifier
|
|
228
|
-
if (!signalName || !/^[a-zA-Z_$][\w$]*$/.test(signalName)) {
|
|
229
|
-
const error = new Error(`model requires a valid signal name, received: '${signalName || ''}'`);
|
|
230
|
-
/** @ts-expect-error — custom error code */
|
|
231
|
-
error.code = 'INVALID_MODEL_TARGET';
|
|
232
|
-
throw error;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// Determine prop, event, coerce, radioValue based on tag and type
|
|
236
|
-
const type = el.getAttribute('type') || 'text';
|
|
237
|
-
let prop, event, coerce = false, radioValue = null;
|
|
238
|
-
|
|
239
|
-
if (tag === 'select') {
|
|
240
|
-
prop = 'value'; event = 'change';
|
|
241
|
-
} else if (tag === 'textarea') {
|
|
242
|
-
prop = 'value'; event = 'input';
|
|
243
|
-
} else if (type === 'checkbox') {
|
|
244
|
-
prop = 'checked'; event = 'change';
|
|
245
|
-
} else if (type === 'radio') {
|
|
246
|
-
prop = 'checked'; event = 'change';
|
|
247
|
-
radioValue = el.getAttribute('value');
|
|
248
|
-
} else if (type === 'number') {
|
|
249
|
-
prop = 'value'; event = 'input'; coerce = true;
|
|
250
|
-
} else {
|
|
251
|
-
prop = 'value'; event = 'input';
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const varName = `__model_${signalName}_${modelIdx}`;
|
|
255
|
-
modelIdx++;
|
|
256
|
-
modelBindings.push({ varName, signal: signalName, prop, event, coerce, radioValue, path: [...pathParts] });
|
|
257
|
-
el.removeAttribute('model');
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// Detect model:propName="signalName" attributes (for custom element binding)
|
|
261
|
-
const modelPropAttrsToRemove = [];
|
|
262
|
-
for (const attr of Array.from(el.attributes)) {
|
|
263
|
-
if (attr.name.startsWith('model:')) {
|
|
264
|
-
const propName = attr.name.slice(6); // after 'model:'
|
|
265
|
-
const signal = attr.value;
|
|
266
|
-
const tag = el.tagName.toLowerCase();
|
|
267
|
-
|
|
268
|
-
// Validate the element is a custom element (tag contains a hyphen)
|
|
269
|
-
if (!tag.includes('-')) {
|
|
270
|
-
const error = new Error(`model:propName is only valid on custom elements (tag must contain a hyphen)`);
|
|
271
|
-
/** @ts-expect-error — custom error code */
|
|
272
|
-
error.code = 'MODEL_PROP_INVALID_TARGET';
|
|
273
|
-
throw error;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
const varName = `__modelProp_${propName}`;
|
|
277
|
-
modelPropIdx++;
|
|
278
|
-
modelPropBindings.push({ varName, propName, signal, path: [...pathParts] });
|
|
279
|
-
modelPropAttrsToRemove.push(attr.name);
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
modelPropAttrsToRemove.forEach((a) => el.removeAttribute(a));
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// --- Text node with interpolations ---
|
|
286
|
-
if (node.nodeType === 3 && /\{\{(?:[^}]|\}(?!\}))+\}\}/.test(node.textContent)) {
|
|
287
|
-
const text = node.textContent;
|
|
288
|
-
const trimmed = text.trim();
|
|
289
|
-
const soleMatch = trimmed.match(/^\{\{((?:[^}]|\}(?!\}))+)\}\}$/);
|
|
290
|
-
const parent = node.parentNode;
|
|
291
|
-
|
|
292
|
-
// Strip trailing () from expression to get the base name for type lookup
|
|
293
|
-
function baseName(expr) {
|
|
294
|
-
return expr.endsWith('()') ? expr.slice(0, -2) : expr;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// Case 1: {{var}} is the sole content of the parent element and parent has only one child text node
|
|
298
|
-
if (soleMatch && parent.childNodes.length === 1) {
|
|
299
|
-
const name = baseName(soleMatch[1]);
|
|
300
|
-
const safeName = name.replace(/[^a-zA-Z0-9_]/g, '_').slice(0, 30);
|
|
301
|
-
const varName = `__text_${safeName}_${bindIdx}`;
|
|
302
|
-
bindIdx++;
|
|
303
|
-
bindings.push({
|
|
304
|
-
varName,
|
|
305
|
-
name,
|
|
306
|
-
type: bindingType(name),
|
|
307
|
-
path: pathParts.slice(0, -1), // path to parent, not text node
|
|
308
|
-
});
|
|
309
|
-
parent.textContent = '';
|
|
310
|
-
return;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// Case 2: Mixed text and interpolations — split into spans
|
|
314
|
-
const doc = node.ownerDocument;
|
|
315
|
-
const fragment = doc.createDocumentFragment();
|
|
316
|
-
const parts = text.split(/(\{\{(?:[^}]|\}(?!\}))+\}\})/);
|
|
317
|
-
const parentPath = pathParts.slice(0, -1);
|
|
318
|
-
|
|
319
|
-
// Find the index of this text node among its siblings
|
|
320
|
-
let baseIndex = 0;
|
|
321
|
-
for (const child of parent.childNodes) {
|
|
322
|
-
if (child === node) break;
|
|
323
|
-
baseIndex++;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
let offset = 0;
|
|
327
|
-
for (const part of parts) {
|
|
328
|
-
const bm = part.match(/^\{\{((?:[^}]|\}(?!\}))+)\}\}$/);
|
|
329
|
-
if (bm) {
|
|
330
|
-
fragment.appendChild(doc.createElement('span'));
|
|
331
|
-
const name = baseName(bm[1]);
|
|
332
|
-
const safeName = name.replace(/[^a-zA-Z0-9_]/g, '_').slice(0, 30);
|
|
333
|
-
const varName = `__text_${safeName}_${bindIdx}`;
|
|
334
|
-
bindIdx++;
|
|
335
|
-
bindings.push({
|
|
336
|
-
varName,
|
|
337
|
-
name,
|
|
338
|
-
type: bindingType(name),
|
|
339
|
-
path: [...parentPath, `childNodes[${baseIndex + offset}]`],
|
|
340
|
-
});
|
|
341
|
-
offset++;
|
|
342
|
-
} else if (part) {
|
|
343
|
-
fragment.appendChild(doc.createTextNode(part));
|
|
344
|
-
offset++;
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
parent.replaceChild(fragment, node);
|
|
348
|
-
return;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
// --- Recurse into children ---
|
|
352
|
-
const children = Array.from(node.childNodes);
|
|
353
|
-
for (let i = 0; i < children.length; i++) {
|
|
354
|
-
walk(children[i], [...pathParts, `childNodes[${i}]`]);
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
walk(rootEl, []);
|
|
359
|
-
return { bindings, events, showBindings, modelBindings, modelPropBindings, attrBindings, slots, childComponents };
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// ── Conditional chain processing (if / else-if / else) ──────────────
|
|
363
|
-
|
|
364
|
-
/**
|
|
365
|
-
* Recompute the path from rootEl to a specific node after DOM normalization.
|
|
366
|
-
* Walks up from the node to rootEl, building the path segments.
|
|
367
|
-
*
|
|
368
|
-
* @param {Element} rootEl - The root element
|
|
369
|
-
* @param {Node} targetNode - The node to find the path to
|
|
370
|
-
* @returns {string[]} Path segments from rootEl to targetNode
|
|
371
|
-
*/
|
|
372
|
-
export function recomputeAnchorPath(rootEl, targetNode) {
|
|
373
|
-
const segments = [];
|
|
374
|
-
let current = targetNode;
|
|
375
|
-
while (current && current !== rootEl) {
|
|
376
|
-
const parent = current.parentNode;
|
|
377
|
-
if (!parent) break;
|
|
378
|
-
const children = Array.from(parent.childNodes);
|
|
379
|
-
const idx = children.indexOf(current);
|
|
380
|
-
segments.unshift(`childNodes[${idx}]`);
|
|
381
|
-
current = parent;
|
|
382
|
-
}
|
|
383
|
-
return segments;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
/**
|
|
387
|
-
* Check if an element is a valid predecessor in a conditional chain
|
|
388
|
-
* (has `if` or `else-if` attribute).
|
|
389
|
-
*
|
|
390
|
-
* @param {Element} el
|
|
391
|
-
* @returns {boolean}
|
|
392
|
-
*/
|
|
393
|
-
function isChainPredecessor(el) {
|
|
394
|
-
return el.hasAttribute('if') || el.hasAttribute('else-if');
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
/**
|
|
398
|
-
* Process a branch's HTML to extract internal bindings and events.
|
|
399
|
-
* Creates a temporary DOM and runs walkTree on it.
|
|
400
|
-
*
|
|
401
|
-
* @param {string} html - The branch HTML (outerHTML of the branch element)
|
|
402
|
-
* @param {Set<string>} signalNames
|
|
403
|
-
* @param {Set<string>} computedNames
|
|
404
|
-
* @param {Set<string>} propNames
|
|
405
|
-
* @returns {{ bindings: Binding[], events: EventBinding[], showBindings: ShowBinding[], attrBindings: AttrBinding[], modelBindings: ModelBinding[], modelPropBindings: ModelPropBinding[], slots: SlotBinding[], processedHtml: string }}
|
|
406
|
-
*/
|
|
407
|
-
export function walkBranch(html, signalNames, computedNames, propNames) {
|
|
408
|
-
const { document } = parseHTML(`<div id="__branchRoot">${html}</div>`);
|
|
409
|
-
const branchRoot = document.getElementById('__branchRoot');
|
|
410
|
-
|
|
411
|
-
// Process nested structural directives FIRST (before walkTree modifies the DOM).
|
|
412
|
-
// This is critical because walkTree clears textContent of elements with sole
|
|
413
|
-
// {{interpolation}} children, which would destroy content needed by
|
|
414
|
-
// processForBlocks/processIfChains when they clone nested elements for their
|
|
415
|
-
// own walkBranch calls.
|
|
416
|
-
const forBlocks = processForBlocks(branchRoot, [], signalNames, computedNames, propNames);
|
|
417
|
-
const ifBlocks = processIfChains(branchRoot, [], signalNames, computedNames, propNames);
|
|
418
|
-
|
|
419
|
-
// Now run walkTree on the remaining DOM (nested directive elements have been
|
|
420
|
-
// replaced with comment nodes, so walkTree won't process their contents).
|
|
421
|
-
const result = walkTree(branchRoot, signalNames, computedNames, propNames);
|
|
422
|
-
|
|
423
|
-
// Capture the processed HTML AFTER all processing
|
|
424
|
-
const processedHtml = branchRoot.innerHTML;
|
|
425
|
-
|
|
426
|
-
// Strip the first path segment from all paths since at runtime
|
|
427
|
-
// `node = clone.firstChild` is the element itself, not the wrapper div.
|
|
428
|
-
function stripFirstSegment(items) {
|
|
429
|
-
for (const item of items) {
|
|
430
|
-
if (item.path && item.path.length > 0 && item.path[0].startsWith('childNodes[')) {
|
|
431
|
-
item.path = item.path.slice(1);
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
stripFirstSegment(result.bindings);
|
|
436
|
-
stripFirstSegment(result.events);
|
|
437
|
-
stripFirstSegment(result.showBindings);
|
|
438
|
-
stripFirstSegment(result.attrBindings);
|
|
439
|
-
stripFirstSegment(result.modelBindings);
|
|
440
|
-
stripFirstSegment(result.modelPropBindings);
|
|
441
|
-
stripFirstSegment(result.slots);
|
|
442
|
-
stripFirstSegment(result.childComponents);
|
|
443
|
-
|
|
444
|
-
// Strip first path segment from nested forBlock/ifBlock anchor paths
|
|
445
|
-
function stripFirstAnchorSegment(items) {
|
|
446
|
-
for (const item of items) {
|
|
447
|
-
if (item.anchorPath && item.anchorPath.length > 0 && item.anchorPath[0].startsWith('childNodes[')) {
|
|
448
|
-
item.anchorPath = item.anchorPath.slice(1);
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
stripFirstAnchorSegment(forBlocks);
|
|
453
|
-
stripFirstAnchorSegment(ifBlocks);
|
|
454
|
-
|
|
455
|
-
return {
|
|
456
|
-
bindings: result.bindings,
|
|
457
|
-
events: result.events,
|
|
458
|
-
showBindings: result.showBindings,
|
|
459
|
-
attrBindings: result.attrBindings,
|
|
460
|
-
modelBindings: result.modelBindings,
|
|
461
|
-
modelPropBindings: result.modelPropBindings,
|
|
462
|
-
slots: result.slots,
|
|
463
|
-
childComponents: result.childComponents,
|
|
464
|
-
forBlocks,
|
|
465
|
-
ifBlocks,
|
|
466
|
-
processedHtml,
|
|
467
|
-
};
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
/**
|
|
471
|
-
* Build an IfBlock from a completed chain, replacing elements with a comment node.
|
|
472
|
-
*
|
|
473
|
-
* @param {{ elements: Element[], branches: { type: 'if' | 'else-if' | 'else', expression: string | null, element: Element }[] }} chain
|
|
474
|
-
* @param {Element} parent
|
|
475
|
-
* @param {string[]} parentPath
|
|
476
|
-
* @param {number} idx
|
|
477
|
-
* @param {Set<string>} signalNames
|
|
478
|
-
* @param {Set<string>} computedNames
|
|
479
|
-
* @param {Set<string>} propNames
|
|
480
|
-
* @returns {IfBlock}
|
|
481
|
-
*/
|
|
482
|
-
function buildIfBlock(chain, parent, parentPath, idx, signalNames, computedNames, propNames) {
|
|
483
|
-
const doc = parent.ownerDocument;
|
|
484
|
-
|
|
485
|
-
// Extract HTML for each branch (without the directive attribute)
|
|
486
|
-
/** @type {IfBranch[]} */
|
|
487
|
-
const branches = chain.branches.map((branch) => {
|
|
488
|
-
const el = branch.element;
|
|
489
|
-
// Clone the element to extract HTML without modifying the original yet
|
|
490
|
-
const clone = /** @type {Element} */ (el.cloneNode(true));
|
|
491
|
-
// Remove the directive attribute from the clone
|
|
492
|
-
clone.removeAttribute('if');
|
|
493
|
-
clone.removeAttribute('else-if');
|
|
494
|
-
clone.removeAttribute('else');
|
|
495
|
-
const templateHtml = clone.outerHTML;
|
|
496
|
-
|
|
497
|
-
// Process internal bindings/events via partial walk
|
|
498
|
-
const { bindings, events, showBindings, attrBindings, modelBindings, slots, childComponents, processedHtml } = walkBranch(templateHtml, signalNames, computedNames, propNames);
|
|
499
|
-
|
|
500
|
-
return {
|
|
501
|
-
type: branch.type,
|
|
502
|
-
expression: branch.expression,
|
|
503
|
-
templateHtml: processedHtml,
|
|
504
|
-
bindings,
|
|
505
|
-
events,
|
|
506
|
-
showBindings,
|
|
507
|
-
attrBindings,
|
|
508
|
-
modelBindings,
|
|
509
|
-
slots,
|
|
510
|
-
childComponents,
|
|
511
|
-
};
|
|
512
|
-
});
|
|
513
|
-
|
|
514
|
-
// Replace all chain elements with a single comment node
|
|
515
|
-
const comment = doc.createComment(' if ');
|
|
516
|
-
const firstEl = chain.elements[0];
|
|
517
|
-
parent.insertBefore(comment, firstEl);
|
|
518
|
-
|
|
519
|
-
// Remove all chain elements from the DOM
|
|
520
|
-
for (const el of chain.elements) {
|
|
521
|
-
parent.removeChild(el);
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
// Calculate anchorPath: find the index of the comment node among parent's childNodes
|
|
525
|
-
const childNodes = Array.from(parent.childNodes);
|
|
526
|
-
const commentIndex = childNodes.indexOf(comment);
|
|
527
|
-
const anchorPath = [...parentPath, `childNodes[${commentIndex}]`];
|
|
528
|
-
|
|
529
|
-
return {
|
|
530
|
-
varName: `__if${idx}`,
|
|
531
|
-
anchorPath,
|
|
532
|
-
_anchorNode: comment,
|
|
533
|
-
branches,
|
|
534
|
-
};
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
/**
|
|
538
|
-
* Process conditional chains (if/else-if/else) in a DOM tree.
|
|
539
|
-
* Recursively searches all descendants for chains.
|
|
540
|
-
*
|
|
541
|
-
* @param {Element} parent - Root element to search
|
|
542
|
-
* @param {string[]} parentPath - DOM path to parent from __root
|
|
543
|
-
* @param {Set<string>} signalNames
|
|
544
|
-
* @param {Set<string>} computedNames
|
|
545
|
-
* @param {Set<string>} propNames
|
|
546
|
-
* @returns {IfBlock[]}
|
|
547
|
-
*/
|
|
548
|
-
export function processIfChains(parent, parentPath, signalNames, computedNames, propNames) {
|
|
549
|
-
/** @type {IfBlock[]} */
|
|
550
|
-
const ifBlocks = [];
|
|
551
|
-
let ifIdx = 0;
|
|
552
|
-
|
|
553
|
-
/**
|
|
554
|
-
* Recursively search for if chains in the subtree.
|
|
555
|
-
* @param {Element} node
|
|
556
|
-
* @param {string[]} currentPath
|
|
557
|
-
*/
|
|
558
|
-
function findIfChains(node, currentPath) {
|
|
559
|
-
const children = Array.from(node.childNodes);
|
|
560
|
-
|
|
561
|
-
// First pass: validate all element children for conflicting directives
|
|
562
|
-
for (const child of children) {
|
|
563
|
-
if (child.nodeType !== 1) continue;
|
|
564
|
-
const el = /** @type {Element} */ (child);
|
|
565
|
-
|
|
566
|
-
const hasIf = el.hasAttribute('if');
|
|
567
|
-
const hasElseIf = el.hasAttribute('else-if');
|
|
568
|
-
const hasElse = el.hasAttribute('else');
|
|
569
|
-
const hasShow = el.hasAttribute('show');
|
|
570
|
-
|
|
571
|
-
// CONFLICTING_DIRECTIVES: if + else or if + else-if on same element
|
|
572
|
-
if (hasIf && (hasElse || hasElseIf)) {
|
|
573
|
-
const error = new Error('Las directivas condicionales son mutuamente excluyentes en un mismo elemento');
|
|
574
|
-
/** @ts-expect-error — custom error code */
|
|
575
|
-
error.code = 'CONFLICTING_DIRECTIVES';
|
|
576
|
-
throw error;
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
// CONFLICTING_DIRECTIVES: show + if on same element
|
|
580
|
-
if (hasShow && hasIf) {
|
|
581
|
-
const error = new Error('show y if no deben usarse en el mismo elemento');
|
|
582
|
-
/** @ts-expect-error — custom error code */
|
|
583
|
-
error.code = 'CONFLICTING_DIRECTIVES';
|
|
584
|
-
throw error;
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
// INVALID_V_ELSE: else with a non-empty value
|
|
588
|
-
if (hasElse && el.getAttribute('else') !== '') {
|
|
589
|
-
const error = new Error('else no acepta expresión');
|
|
590
|
-
/** @ts-expect-error — custom error code */
|
|
591
|
-
error.code = 'INVALID_V_ELSE';
|
|
592
|
-
throw error;
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
// Second pass: detect chains by iterating element nodes in order
|
|
597
|
-
/** @type {{ elements: Element[], branches: { type: 'if' | 'else-if' | 'else', expression: string | null, element: Element }[] } | null} */
|
|
598
|
-
let currentChain = null;
|
|
599
|
-
/** @type {Element | null} */
|
|
600
|
-
let prevElement = null;
|
|
601
|
-
|
|
602
|
-
for (const child of children) {
|
|
603
|
-
if (child.nodeType !== 1) continue;
|
|
604
|
-
const el = /** @type {Element} */ (child);
|
|
605
|
-
|
|
606
|
-
const hasIf = el.hasAttribute('if');
|
|
607
|
-
const hasElseIf = el.hasAttribute('else-if');
|
|
608
|
-
const hasElse = el.hasAttribute('else');
|
|
609
|
-
|
|
610
|
-
if (hasIf) {
|
|
611
|
-
// Close any open chain
|
|
612
|
-
if (currentChain) {
|
|
613
|
-
ifBlocks.push(buildIfBlock(currentChain, node, currentPath, ifIdx++, signalNames, computedNames, propNames));
|
|
614
|
-
currentChain = null;
|
|
615
|
-
}
|
|
616
|
-
// Start new chain
|
|
617
|
-
currentChain = {
|
|
618
|
-
elements: [el],
|
|
619
|
-
branches: [{ type: 'if', expression: el.getAttribute('if'), element: el }],
|
|
620
|
-
};
|
|
621
|
-
} else if (hasElseIf) {
|
|
622
|
-
// Validate: must follow an if or else-if
|
|
623
|
-
if (!currentChain || !prevElement || !isChainPredecessor(prevElement)) {
|
|
624
|
-
const error = new Error('else-if/else requiere un if previo en el mismo nivel');
|
|
625
|
-
/** @ts-expect-error — custom error code */
|
|
626
|
-
error.code = 'ORPHAN_ELSE';
|
|
627
|
-
throw error;
|
|
628
|
-
}
|
|
629
|
-
currentChain.elements.push(el);
|
|
630
|
-
currentChain.branches.push({ type: 'else-if', expression: el.getAttribute('else-if'), element: el });
|
|
631
|
-
} else if (hasElse) {
|
|
632
|
-
// Validate: must follow an if or else-if
|
|
633
|
-
if (!currentChain || !prevElement || !isChainPredecessor(prevElement)) {
|
|
634
|
-
const error = new Error('else-if/else requiere un if previo en el mismo nivel');
|
|
635
|
-
/** @ts-expect-error — custom error code */
|
|
636
|
-
error.code = 'ORPHAN_ELSE';
|
|
637
|
-
throw error;
|
|
638
|
-
}
|
|
639
|
-
currentChain.elements.push(el);
|
|
640
|
-
currentChain.branches.push({ type: 'else', expression: null, element: el });
|
|
641
|
-
// Close chain
|
|
642
|
-
ifBlocks.push(buildIfBlock(currentChain, node, currentPath, ifIdx++, signalNames, computedNames, propNames));
|
|
643
|
-
currentChain = null;
|
|
644
|
-
} else {
|
|
645
|
-
// Non-conditional element: close any open chain
|
|
646
|
-
if (currentChain) {
|
|
647
|
-
ifBlocks.push(buildIfBlock(currentChain, node, currentPath, ifIdx++, signalNames, computedNames, propNames));
|
|
648
|
-
currentChain = null;
|
|
649
|
-
}
|
|
650
|
-
// Recurse into non-conditional elements to find nested if chains
|
|
651
|
-
const childIdx = Array.from(node.childNodes).indexOf(el);
|
|
652
|
-
findIfChains(el, [...currentPath, `childNodes[${childIdx}]`]);
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
prevElement = el;
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
// Close any remaining open chain
|
|
659
|
-
if (currentChain) {
|
|
660
|
-
ifBlocks.push(buildIfBlock(currentChain, node, currentPath, ifIdx++, signalNames, computedNames, propNames));
|
|
661
|
-
currentChain = null;
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
findIfChains(parent, parentPath);
|
|
666
|
-
|
|
667
|
-
// Normalize the DOM to merge adjacent text nodes created by element removal
|
|
668
|
-
parent.normalize();
|
|
669
|
-
|
|
670
|
-
// Recompute anchor paths after normalization since text node merging
|
|
671
|
-
// may have changed childNode indices
|
|
672
|
-
for (const ib of ifBlocks) {
|
|
673
|
-
ib.anchorPath = recomputeAnchorPath(parent, ib._anchorNode);
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
return ifBlocks;
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
// ── each directive processing ───────────────────────────────────────
|
|
680
|
-
|
|
681
|
-
// Forma simple: "item in source"
|
|
682
|
-
const simpleRe = /^\s*(\w+)\s+in\s+(.+)\s*$/;
|
|
683
|
-
// Forma con índice: "(item, index) in source"
|
|
684
|
-
const destructuredRe = /^\s*\(\s*(\w+)\s*,\s*(\w+)\s*\)\s+in\s+(.+)\s*$/;
|
|
685
|
-
|
|
686
|
-
/**
|
|
687
|
-
* Parse an each expression.
|
|
688
|
-
* Supports:
|
|
689
|
-
* "item in source"
|
|
690
|
-
* "(item, index) in source"
|
|
691
|
-
*
|
|
692
|
-
* @param {string} expr - The each attribute value
|
|
693
|
-
* @returns {{ itemVar: string, indexVar: string | null, source: string }}
|
|
694
|
-
* @throws {Error} with code INVALID_V_FOR if syntax is invalid
|
|
695
|
-
*/
|
|
696
|
-
export function parseEachExpression(expr) {
|
|
697
|
-
// Check if expression contains "in" keyword
|
|
698
|
-
if (!/\bin\b/.test(expr)) {
|
|
699
|
-
const error = new Error('each requiere la sintaxis \'item in source\' o \'(item, index) in source\'');
|
|
700
|
-
/** @ts-expect-error — custom error code */
|
|
701
|
-
error.code = 'INVALID_V_FOR';
|
|
702
|
-
throw error;
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
// Try destructured form first (more specific)
|
|
706
|
-
const destructuredMatch = destructuredRe.exec(expr);
|
|
707
|
-
if (destructuredMatch) {
|
|
708
|
-
const itemVar = destructuredMatch[1];
|
|
709
|
-
const indexVar = destructuredMatch[2];
|
|
710
|
-
const source = destructuredMatch[3].trim();
|
|
711
|
-
|
|
712
|
-
if (!itemVar) {
|
|
713
|
-
const error = new Error('each requiere una variable de iteración');
|
|
714
|
-
/** @ts-expect-error — custom error code */
|
|
715
|
-
error.code = 'INVALID_V_FOR';
|
|
716
|
-
throw error;
|
|
717
|
-
}
|
|
718
|
-
if (!source) {
|
|
719
|
-
const error = new Error('each requiere una expresión fuente');
|
|
720
|
-
/** @ts-expect-error — custom error code */
|
|
721
|
-
error.code = 'INVALID_V_FOR';
|
|
722
|
-
throw error;
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
return { itemVar, indexVar, source };
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
// Try simple form
|
|
729
|
-
const simpleMatch = simpleRe.exec(expr);
|
|
730
|
-
if (simpleMatch) {
|
|
731
|
-
const itemVar = simpleMatch[1];
|
|
732
|
-
const source = simpleMatch[2].trim();
|
|
733
|
-
|
|
734
|
-
if (!itemVar) {
|
|
735
|
-
const error = new Error('each requiere una variable de iteración');
|
|
736
|
-
/** @ts-expect-error — custom error code */
|
|
737
|
-
error.code = 'INVALID_V_FOR';
|
|
738
|
-
throw error;
|
|
739
|
-
}
|
|
740
|
-
if (!source) {
|
|
741
|
-
const error = new Error('each requiere una expresión fuente');
|
|
742
|
-
/** @ts-expect-error — custom error code */
|
|
743
|
-
error.code = 'INVALID_V_FOR';
|
|
744
|
-
throw error;
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
return { itemVar, indexVar: null, source };
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
// If neither regex matched, check for specific error conditions
|
|
751
|
-
const inIndex = expr.indexOf(' in ');
|
|
752
|
-
if (inIndex !== -1) {
|
|
753
|
-
const left = expr.substring(0, inIndex).trim();
|
|
754
|
-
const right = expr.substring(inIndex + 4).trim();
|
|
755
|
-
|
|
756
|
-
if (!left) {
|
|
757
|
-
const error = new Error('each requiere una variable de iteración');
|
|
758
|
-
/** @ts-expect-error — custom error code */
|
|
759
|
-
error.code = 'INVALID_V_FOR';
|
|
760
|
-
throw error;
|
|
761
|
-
}
|
|
762
|
-
if (!right) {
|
|
763
|
-
const error = new Error('each requiere una expresión fuente');
|
|
764
|
-
/** @ts-expect-error — custom error code */
|
|
765
|
-
error.code = 'INVALID_V_FOR';
|
|
766
|
-
throw error;
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
// Fallback: invalid syntax
|
|
771
|
-
const error = new Error('each requiere la sintaxis \'item in source\' o \'(item, index) in source\'');
|
|
772
|
-
/** @ts-expect-error — custom error code */
|
|
773
|
-
error.code = 'INVALID_V_FOR';
|
|
774
|
-
throw error;
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
/**
|
|
778
|
-
* Process each directives in descendants of a parent element.
|
|
779
|
-
* Recursively detects elements with `each` attribute, validates them,
|
|
780
|
-
* extracts item templates, and replaces them with comment anchors.
|
|
781
|
-
*
|
|
782
|
-
* @param {Element} parent - Root element to search
|
|
783
|
-
* @param {string[]} parentPath - DOM path to parent from __root
|
|
784
|
-
* @param {Set<string>} signalNames
|
|
785
|
-
* @param {Set<string>} computedNames
|
|
786
|
-
* @param {Set<string>} propNames
|
|
787
|
-
* @returns {ForBlock[]}
|
|
788
|
-
*/
|
|
789
|
-
export function processForBlocks(parent, parentPath, signalNames, computedNames, propNames) {
|
|
790
|
-
/** @type {ForBlock[]} */
|
|
791
|
-
const forBlocks = [];
|
|
792
|
-
let forIdx = 0;
|
|
793
|
-
|
|
794
|
-
/**
|
|
795
|
-
* Recursively search for elements with each in the subtree.
|
|
796
|
-
* @param {Element} node
|
|
797
|
-
* @param {string[]} currentPath
|
|
798
|
-
*/
|
|
799
|
-
function findForElements(node, currentPath) {
|
|
800
|
-
const children = Array.from(node.childNodes);
|
|
801
|
-
for (let i = 0; i < children.length; i++) {
|
|
802
|
-
const child = children[i];
|
|
803
|
-
if (child.nodeType !== 1) continue;
|
|
804
|
-
const el = /** @type {Element} */ (child);
|
|
805
|
-
|
|
806
|
-
if (el.hasAttribute('each')) {
|
|
807
|
-
// Validate no conflicting if directive
|
|
808
|
-
if (el.hasAttribute('if')) {
|
|
809
|
-
const error = new Error('each y if no deben usarse en el mismo elemento');
|
|
810
|
-
/** @ts-expect-error — custom error code */
|
|
811
|
-
error.code = 'CONFLICTING_DIRECTIVES';
|
|
812
|
-
throw error;
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
// Parse the each expression
|
|
816
|
-
const expr = el.getAttribute('each');
|
|
817
|
-
const { itemVar, indexVar, source } = parseEachExpression(expr);
|
|
818
|
-
|
|
819
|
-
// Extract :key if present
|
|
820
|
-
const keyExpr = el.hasAttribute(':key') ? el.getAttribute(':key') : null;
|
|
821
|
-
|
|
822
|
-
// Clone the element and remove each and :key from the clone
|
|
823
|
-
const clone = /** @type {Element} */ (el.cloneNode(true));
|
|
824
|
-
clone.removeAttribute('each');
|
|
825
|
-
clone.removeAttribute(':key');
|
|
826
|
-
const templateHtml = clone.outerHTML;
|
|
827
|
-
|
|
828
|
-
// Process internal bindings/events via partial walk
|
|
829
|
-
const { bindings, events, showBindings, attrBindings, modelBindings, slots, childComponents: forChildComponents, forBlocks: nestedForBlocks, ifBlocks: nestedIfBlocks, processedHtml } = walkBranch(templateHtml, signalNames, computedNames, propNames);
|
|
830
|
-
|
|
831
|
-
// Replace the original element with a comment node <!-- each -->
|
|
832
|
-
const doc = node.ownerDocument;
|
|
833
|
-
const comment = doc.createComment(' each ');
|
|
834
|
-
node.replaceChild(comment, el);
|
|
835
|
-
|
|
836
|
-
// Calculate anchorPath
|
|
837
|
-
const updatedChildren = Array.from(node.childNodes);
|
|
838
|
-
const commentIndex = updatedChildren.indexOf(comment);
|
|
839
|
-
const anchorPath = [...currentPath, `childNodes[${commentIndex}]`];
|
|
840
|
-
|
|
841
|
-
// Create ForBlock
|
|
842
|
-
forBlocks.push({
|
|
843
|
-
varName: `__for${forIdx++}`,
|
|
844
|
-
itemVar,
|
|
845
|
-
indexVar,
|
|
846
|
-
source,
|
|
847
|
-
keyExpr,
|
|
848
|
-
templateHtml: processedHtml,
|
|
849
|
-
anchorPath,
|
|
850
|
-
_anchorNode: comment,
|
|
851
|
-
bindings,
|
|
852
|
-
events,
|
|
853
|
-
showBindings,
|
|
854
|
-
attrBindings,
|
|
855
|
-
modelBindings,
|
|
856
|
-
slots,
|
|
857
|
-
childComponents: forChildComponents,
|
|
858
|
-
forBlocks: nestedForBlocks,
|
|
859
|
-
ifBlocks: nestedIfBlocks,
|
|
860
|
-
});
|
|
861
|
-
} else {
|
|
862
|
-
// Recurse into non-each elements to find nested each
|
|
863
|
-
const childPath = [...currentPath, `childNodes[${i}]`];
|
|
864
|
-
findForElements(el, childPath);
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
findForElements(parent, parentPath);
|
|
870
|
-
return forBlocks;
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
// ── Dynamic component processing ────────────────────────────────────
|
|
875
|
-
|
|
876
|
-
/**
|
|
877
|
-
* Process dynamic component elements (`<component :is="expr">`) in descendants of a parent element.
|
|
878
|
-
* Recursively detects `<component>` elements, validates the `:is` attribute,
|
|
879
|
-
* extracts prop/event bindings, and replaces them with comment anchors.
|
|
880
|
-
*
|
|
881
|
-
* @param {Element} parent - Root element to search
|
|
882
|
-
* @param {string[]} parentPath - DOM path to parent from __root
|
|
883
|
-
* @returns {DynamicComponentBinding[]}
|
|
884
|
-
*/
|
|
885
|
-
export function processDynamicComponents(parent, parentPath) {
|
|
886
|
-
/** @type {DynamicComponentBinding[]} */
|
|
887
|
-
const dynamicComponents = [];
|
|
888
|
-
let dynIdx = 0;
|
|
889
|
-
|
|
890
|
-
/**
|
|
891
|
-
* Recursively search for <component> elements in the subtree.
|
|
892
|
-
* @param {Element} node
|
|
893
|
-
* @param {string[]} currentPath
|
|
894
|
-
*/
|
|
895
|
-
function findDynamicComponents(node, currentPath) {
|
|
896
|
-
const children = Array.from(node.childNodes);
|
|
897
|
-
for (let i = 0; i < children.length; i++) {
|
|
898
|
-
const child = children[i];
|
|
899
|
-
if (child.nodeType !== 1) continue;
|
|
900
|
-
const el = /** @type {Element} */ (child);
|
|
901
|
-
|
|
902
|
-
if (el.tagName === 'COMPONENT') {
|
|
903
|
-
// Validate :is attribute is present
|
|
904
|
-
const isExpr = el.getAttribute(':is');
|
|
905
|
-
if (!isExpr) {
|
|
906
|
-
const error = new Error(':is attribute is required on <component> elements');
|
|
907
|
-
/** @ts-expect-error — custom error code */
|
|
908
|
-
error.code = 'MISSING_IS_ATTRIBUTE';
|
|
909
|
-
throw error;
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
// Collect prop bindings (:attr="expr", excluding :is)
|
|
913
|
-
/** @type {DynPropBinding[]} */
|
|
914
|
-
const props = [];
|
|
915
|
-
// Collect event bindings (@event="handler")
|
|
916
|
-
/** @type {DynEventBinding[]} */
|
|
917
|
-
const events = [];
|
|
918
|
-
|
|
919
|
-
for (const attr of Array.from(el.attributes)) {
|
|
920
|
-
if (attr.name.startsWith(':') && attr.name !== ':is') {
|
|
921
|
-
props.push({
|
|
922
|
-
attr: attr.name.slice(1),
|
|
923
|
-
expression: attr.value,
|
|
924
|
-
});
|
|
925
|
-
} else if (attr.name.startsWith('@')) {
|
|
926
|
-
events.push({
|
|
927
|
-
event: attr.name.slice(1),
|
|
928
|
-
handler: attr.value,
|
|
929
|
-
});
|
|
930
|
-
}
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
// Replace <component> with a comment node <!-- dynamic -->
|
|
934
|
-
const doc = node.ownerDocument;
|
|
935
|
-
const comment = doc.createComment(' dynamic ');
|
|
936
|
-
node.replaceChild(comment, el);
|
|
937
|
-
|
|
938
|
-
// Calculate anchorPath
|
|
939
|
-
const updatedChildren = Array.from(node.childNodes);
|
|
940
|
-
const commentIndex = updatedChildren.indexOf(comment);
|
|
941
|
-
const anchorPath = [...currentPath, `childNodes[${commentIndex}]`];
|
|
942
|
-
|
|
943
|
-
// Create DynamicComponentBinding
|
|
944
|
-
dynamicComponents.push({
|
|
945
|
-
varName: `__dyn${dynIdx++}`,
|
|
946
|
-
isExpression: isExpr,
|
|
947
|
-
props,
|
|
948
|
-
events,
|
|
949
|
-
anchorPath,
|
|
950
|
-
_anchorNode: comment,
|
|
951
|
-
});
|
|
952
|
-
} else {
|
|
953
|
-
// Recurse into non-component elements to find nested dynamic components
|
|
954
|
-
const childPath = [...currentPath, `childNodes[${i}]`];
|
|
955
|
-
findDynamicComponents(el, childPath);
|
|
956
|
-
}
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
findDynamicComponents(parent, parentPath);
|
|
961
|
-
return dynamicComponents;
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
// ── Ref detection ───────────────────────────────────────────────────
|
|
965
|
-
|
|
966
|
-
/**
|
|
967
|
-
* Detect ref="name" attributes on elements in the DOM tree.
|
|
968
|
-
* Removes the ref attribute from each element after recording.
|
|
969
|
-
*
|
|
970
|
-
* @param {Element} rootEl — jsdom DOM element (parsed template root)
|
|
971
|
-
* @returns {RefBinding[]}
|
|
972
|
-
* @throws {Error} with code DUPLICATE_REF if same ref name appears on multiple elements
|
|
973
|
-
*/
|
|
974
|
-
export function detectRefs(rootEl) {
|
|
975
|
-
/** @type {RefBinding[]} */
|
|
976
|
-
const refBindings = [];
|
|
977
|
-
/** @type {Set<string>} */
|
|
978
|
-
const seen = new Set();
|
|
979
|
-
|
|
980
|
-
const elements = rootEl.querySelectorAll('[ref]');
|
|
981
|
-
|
|
982
|
-
for (const el of elements) {
|
|
983
|
-
const refName = el.getAttribute('ref');
|
|
984
|
-
|
|
985
|
-
// Check for duplicate ref names
|
|
986
|
-
if (seen.has(refName)) {
|
|
987
|
-
const error = new Error(`Duplicate ref name '${refName}' — each ref must be unique`);
|
|
988
|
-
/** @ts-expect-error — custom error code */
|
|
989
|
-
error.code = 'DUPLICATE_REF';
|
|
990
|
-
throw error;
|
|
991
|
-
}
|
|
992
|
-
seen.add(refName);
|
|
993
|
-
|
|
994
|
-
// Compute DOM path from rootEl to el
|
|
995
|
-
const path = [];
|
|
996
|
-
let current = el;
|
|
997
|
-
while (current && current !== rootEl) {
|
|
998
|
-
const parent = current.parentNode;
|
|
999
|
-
if (!parent) break;
|
|
1000
|
-
const children = Array.from(parent.childNodes);
|
|
1001
|
-
const idx = children.indexOf(current);
|
|
1002
|
-
path.unshift(`childNodes[${idx}]`);
|
|
1003
|
-
current = parent;
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
// Remove the ref attribute
|
|
1007
|
-
el.removeAttribute('ref');
|
|
1008
|
-
|
|
1009
|
-
refBindings.push({ refName, path });
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
return refBindings;
|
|
1013
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Tree Walker for wcCompiler v2.
|
|
3
|
+
*
|
|
4
|
+
* Walks a jsdom DOM tree to discover:
|
|
5
|
+
* - Text bindings {{var}} with childNodes[n] paths
|
|
6
|
+
* - Event bindings @event="handler"
|
|
7
|
+
* - Show bindings show="expression"
|
|
8
|
+
* - Conditional chains (if / else-if / else)
|
|
9
|
+
*
|
|
10
|
+
* Produces { bindings, events, showBindings } arrays with path metadata.
|
|
11
|
+
* processIfChains() detects conditional chains, validates them,
|
|
12
|
+
* extracts branch templates, and replaces chains with comment anchors.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { parseHTML } from 'linkedom';
|
|
16
|
+
import { BOOLEAN_ATTRIBUTES } from './types.js';
|
|
17
|
+
|
|
18
|
+
/** @import { Binding, EventBinding, IfBlock, IfBranch, ShowBinding, AttrBinding, ForBlock, ModelBinding, ModelPropBinding, SlotBinding, SlotProp, RefBinding, ChildComponentBinding, ChildPropBinding, DynamicComponentBinding, DynPropBinding, DynEventBinding } from './types.js' */
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Walk a DOM tree rooted at rootEl, discovering bindings and events.
|
|
22
|
+
*
|
|
23
|
+
* @param {Element} rootEl — jsdom DOM element (parsed template root)
|
|
24
|
+
* @param {Set<string>} signalNames — Set of signal variable names
|
|
25
|
+
* @param {Set<string>} computedNames — Set of computed variable names
|
|
26
|
+
* @param {Set<string>} [propNames] — Set of prop names from defineProps
|
|
27
|
+
* @returns {{ bindings: Binding[], events: EventBinding[], showBindings: ShowBinding[], modelBindings: ModelBinding[], modelPropBindings: ModelPropBinding[], attrBindings: AttrBinding[], slots: SlotBinding[], childComponents: ChildComponentBinding[] }}
|
|
28
|
+
*/
|
|
29
|
+
export function walkTree(rootEl, signalNames, computedNames, propNames = new Set()) {
|
|
30
|
+
/** @type {Binding[]} */
|
|
31
|
+
const bindings = [];
|
|
32
|
+
/** @type {EventBinding[]} */
|
|
33
|
+
const events = [];
|
|
34
|
+
/** @type {ShowBinding[]} */
|
|
35
|
+
const showBindings = [];
|
|
36
|
+
/** @type {ModelBinding[]} */
|
|
37
|
+
const modelBindings = [];
|
|
38
|
+
/** @type {ModelPropBinding[]} */
|
|
39
|
+
const modelPropBindings = [];
|
|
40
|
+
/** @type {AttrBinding[]} */
|
|
41
|
+
const attrBindings = [];
|
|
42
|
+
/** @type {SlotBinding[]} */
|
|
43
|
+
const slots = [];
|
|
44
|
+
/** @type {ChildComponentBinding[]} */
|
|
45
|
+
const childComponents = [];
|
|
46
|
+
let bindIdx = 0;
|
|
47
|
+
let eventIdx = 0;
|
|
48
|
+
let showIdx = 0;
|
|
49
|
+
let modelIdx = 0;
|
|
50
|
+
let modelPropIdx = 0;
|
|
51
|
+
let attrIdx = 0;
|
|
52
|
+
let slotIdx = 0;
|
|
53
|
+
let childIdx = 0;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Determine the binding type for a variable name.
|
|
57
|
+
* Priority: prop → signal → computed → method
|
|
58
|
+
*
|
|
59
|
+
* @param {string} name
|
|
60
|
+
* @returns {'prop' | 'signal' | 'computed' | 'method'}
|
|
61
|
+
*/
|
|
62
|
+
function bindingType(name) {
|
|
63
|
+
if (propNames.has(name)) return 'prop';
|
|
64
|
+
if (signalNames.has(name)) return 'signal';
|
|
65
|
+
if (computedNames.has(name)) return 'computed';
|
|
66
|
+
return 'method';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Recursively walk a DOM node, collecting bindings and events.
|
|
71
|
+
*
|
|
72
|
+
* @param {Node} node — DOM node to walk
|
|
73
|
+
* @param {string[]} pathParts — Current path segments from root
|
|
74
|
+
*/
|
|
75
|
+
function walk(node, pathParts) {
|
|
76
|
+
// --- Element node ---
|
|
77
|
+
if (node.nodeType === 1) {
|
|
78
|
+
const el = /** @type {Element} */ (node);
|
|
79
|
+
|
|
80
|
+
// Skip <template #name> elements — they are slot content passed to child components
|
|
81
|
+
// Their interpolations are resolved by the provider, not the consumer
|
|
82
|
+
if (el.tagName === 'TEMPLATE') {
|
|
83
|
+
for (const attr of Array.from(el.attributes)) {
|
|
84
|
+
if (attr.name.startsWith('#')) return;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Detect <slot> elements — replace with <span data-slot="..."> placeholder
|
|
89
|
+
if (el.tagName === 'SLOT') {
|
|
90
|
+
const slotName = el.getAttribute('name') || '';
|
|
91
|
+
const safeName = slotName ? slotName.replace(/[^a-zA-Z0-9_]/g, '_') : 'default';
|
|
92
|
+
const varName = `__slot_${safeName}_${slotIdx}`;
|
|
93
|
+
slotIdx++;
|
|
94
|
+
const defaultContent = el.innerHTML.trim();
|
|
95
|
+
|
|
96
|
+
// Collect :prop="expr" attributes (slot props for scoped slots)
|
|
97
|
+
/** @type {SlotProp[]} */
|
|
98
|
+
const slotProps = [];
|
|
99
|
+
for (const attr of Array.from(el.attributes)) {
|
|
100
|
+
if (attr.name.startsWith(':')) {
|
|
101
|
+
slotProps.push({ prop: attr.name.slice(1), source: attr.value });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
slots.push({
|
|
106
|
+
varName,
|
|
107
|
+
name: slotName,
|
|
108
|
+
path: [...pathParts],
|
|
109
|
+
defaultContent,
|
|
110
|
+
slotProps,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Replace <slot> with <span data-slot="name">
|
|
114
|
+
const doc = el.ownerDocument;
|
|
115
|
+
const placeholder = doc.createElement('span');
|
|
116
|
+
placeholder.setAttribute('data-slot', slotName || 'default');
|
|
117
|
+
if (defaultContent) placeholder.innerHTML = defaultContent;
|
|
118
|
+
el.parentNode.replaceChild(placeholder, el);
|
|
119
|
+
return; // Don't recurse into the replaced element
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Detect child custom elements (tag name contains a hyphen)
|
|
123
|
+
const tagLower = el.tagName.toLowerCase();
|
|
124
|
+
if (tagLower.includes('-') && tagLower !== rootEl.tagName?.toLowerCase()) {
|
|
125
|
+
/** @type {ChildPropBinding[]} */
|
|
126
|
+
const propBindings = [];
|
|
127
|
+
for (const attr of Array.from(el.attributes)) {
|
|
128
|
+
// Skip directive attributes (@event, :bind, show, model, etc.)
|
|
129
|
+
if (attr.name.startsWith('@') || attr.name.startsWith(':') || attr.name.startsWith('bind:') || attr.name.startsWith('model:')) continue;
|
|
130
|
+
if (['show', 'model', 'if', 'else-if', 'else', 'each', 'ref'].includes(attr.name)) continue;
|
|
131
|
+
|
|
132
|
+
// Check for {{interpolation}} in attribute value
|
|
133
|
+
const interpMatch = attr.value.match(/^\{\{([\w.()]+)\}\}$/);
|
|
134
|
+
if (interpMatch) {
|
|
135
|
+
const rawExpr = interpMatch[1];
|
|
136
|
+
const expr = rawExpr.endsWith('()') ? rawExpr.slice(0, -2) : rawExpr;
|
|
137
|
+
propBindings.push({
|
|
138
|
+
attr: attr.name,
|
|
139
|
+
expr,
|
|
140
|
+
type: propNames.has(expr) ? 'prop' : signalNames.has(expr) ? 'signal' : computedNames.has(expr) ? 'computed' : 'method',
|
|
141
|
+
});
|
|
142
|
+
// Clear the interpolation from the attribute — the effect sets it at runtime
|
|
143
|
+
el.setAttribute(attr.name, '');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Always register child component for auto-import (even without prop bindings)
|
|
148
|
+
childComponents.push({
|
|
149
|
+
tag: tagLower,
|
|
150
|
+
varName: `__child${childIdx++}`,
|
|
151
|
+
path: [...pathParts],
|
|
152
|
+
propBindings,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Check for @event attributes
|
|
157
|
+
const attrsToRemove = [];
|
|
158
|
+
for (const attr of Array.from(el.attributes)) {
|
|
159
|
+
if (attr.name.startsWith('@')) {
|
|
160
|
+
const eventName = attr.name.slice(1);
|
|
161
|
+
const handlerName = attr.value.replace(/[^a-zA-Z0-9_]/g, '_').slice(0, 20);
|
|
162
|
+
const varName = `__evt_${eventName.replace(/-/g, '_')}_${handlerName}_${eventIdx}`;
|
|
163
|
+
eventIdx++;
|
|
164
|
+
events.push({
|
|
165
|
+
varName,
|
|
166
|
+
event: eventName,
|
|
167
|
+
handler: attr.value,
|
|
168
|
+
path: [...pathParts],
|
|
169
|
+
});
|
|
170
|
+
attrsToRemove.push(attr.name);
|
|
171
|
+
} else if (attr.name.startsWith(':') || attr.name.startsWith('bind:')) {
|
|
172
|
+
// Attribute binding: :attr="expr" or bind:attr="expr"
|
|
173
|
+
const attrName = attr.name.startsWith(':') ? attr.name.slice(1) : attr.name.slice(5);
|
|
174
|
+
const expression = attr.value;
|
|
175
|
+
|
|
176
|
+
// Classify binding kind
|
|
177
|
+
let kind;
|
|
178
|
+
if (attrName === 'class') {
|
|
179
|
+
kind = 'class';
|
|
180
|
+
} else if (attrName === 'style') {
|
|
181
|
+
kind = 'style';
|
|
182
|
+
} else if (BOOLEAN_ATTRIBUTES.has(attrName)) {
|
|
183
|
+
kind = 'bool';
|
|
184
|
+
} else {
|
|
185
|
+
kind = 'attr';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const varName = `__attr_${attrName.replace(/-/g, '_')}_${attrIdx}`;
|
|
189
|
+
attrIdx++;
|
|
190
|
+
attrBindings.push({
|
|
191
|
+
varName,
|
|
192
|
+
attr: attrName,
|
|
193
|
+
expression,
|
|
194
|
+
kind,
|
|
195
|
+
path: [...pathParts],
|
|
196
|
+
});
|
|
197
|
+
attrsToRemove.push(attr.name);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
attrsToRemove.forEach((a) => el.removeAttribute(a));
|
|
201
|
+
|
|
202
|
+
// Detect show attribute
|
|
203
|
+
if (el.hasAttribute('show')) {
|
|
204
|
+
const varName = `__show_${showIdx}`;
|
|
205
|
+
showIdx++;
|
|
206
|
+
showBindings.push({
|
|
207
|
+
varName,
|
|
208
|
+
expression: el.getAttribute('show'),
|
|
209
|
+
path: [...pathParts],
|
|
210
|
+
});
|
|
211
|
+
el.removeAttribute('show');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Detect model attribute
|
|
215
|
+
if (el.hasAttribute('model')) {
|
|
216
|
+
const signalName = el.getAttribute('model');
|
|
217
|
+
const tag = el.tagName.toLowerCase();
|
|
218
|
+
|
|
219
|
+
// Validate element is a form element
|
|
220
|
+
if (!['input', 'textarea', 'select'].includes(tag)) {
|
|
221
|
+
const error = new Error(`model is only valid on <input>, <textarea>, or <select>, not on <${tag}>`);
|
|
222
|
+
/** @ts-expect-error — custom error code */
|
|
223
|
+
error.code = 'INVALID_MODEL_ELEMENT';
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Validate model value is a valid identifier
|
|
228
|
+
if (!signalName || !/^[a-zA-Z_$][\w$]*$/.test(signalName)) {
|
|
229
|
+
const error = new Error(`model requires a valid signal name, received: '${signalName || ''}'`);
|
|
230
|
+
/** @ts-expect-error — custom error code */
|
|
231
|
+
error.code = 'INVALID_MODEL_TARGET';
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Determine prop, event, coerce, radioValue based on tag and type
|
|
236
|
+
const type = el.getAttribute('type') || 'text';
|
|
237
|
+
let prop, event, coerce = false, radioValue = null;
|
|
238
|
+
|
|
239
|
+
if (tag === 'select') {
|
|
240
|
+
prop = 'value'; event = 'change';
|
|
241
|
+
} else if (tag === 'textarea') {
|
|
242
|
+
prop = 'value'; event = 'input';
|
|
243
|
+
} else if (type === 'checkbox') {
|
|
244
|
+
prop = 'checked'; event = 'change';
|
|
245
|
+
} else if (type === 'radio') {
|
|
246
|
+
prop = 'checked'; event = 'change';
|
|
247
|
+
radioValue = el.getAttribute('value');
|
|
248
|
+
} else if (type === 'number') {
|
|
249
|
+
prop = 'value'; event = 'input'; coerce = true;
|
|
250
|
+
} else {
|
|
251
|
+
prop = 'value'; event = 'input';
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const varName = `__model_${signalName}_${modelIdx}`;
|
|
255
|
+
modelIdx++;
|
|
256
|
+
modelBindings.push({ varName, signal: signalName, prop, event, coerce, radioValue, path: [...pathParts] });
|
|
257
|
+
el.removeAttribute('model');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Detect model:propName="signalName" attributes (for custom element binding)
|
|
261
|
+
const modelPropAttrsToRemove = [];
|
|
262
|
+
for (const attr of Array.from(el.attributes)) {
|
|
263
|
+
if (attr.name.startsWith('model:')) {
|
|
264
|
+
const propName = attr.name.slice(6); // after 'model:'
|
|
265
|
+
const signal = attr.value;
|
|
266
|
+
const tag = el.tagName.toLowerCase();
|
|
267
|
+
|
|
268
|
+
// Validate the element is a custom element (tag contains a hyphen)
|
|
269
|
+
if (!tag.includes('-')) {
|
|
270
|
+
const error = new Error(`model:propName is only valid on custom elements (tag must contain a hyphen)`);
|
|
271
|
+
/** @ts-expect-error — custom error code */
|
|
272
|
+
error.code = 'MODEL_PROP_INVALID_TARGET';
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const varName = `__modelProp_${propName}`;
|
|
277
|
+
modelPropIdx++;
|
|
278
|
+
modelPropBindings.push({ varName, propName, signal, path: [...pathParts] });
|
|
279
|
+
modelPropAttrsToRemove.push(attr.name);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
modelPropAttrsToRemove.forEach((a) => el.removeAttribute(a));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// --- Text node with interpolations ---
|
|
286
|
+
if (node.nodeType === 3 && /\{\{(?:[^}]|\}(?!\}))+\}\}/.test(node.textContent)) {
|
|
287
|
+
const text = node.textContent;
|
|
288
|
+
const trimmed = text.trim();
|
|
289
|
+
const soleMatch = trimmed.match(/^\{\{((?:[^}]|\}(?!\}))+)\}\}$/);
|
|
290
|
+
const parent = node.parentNode;
|
|
291
|
+
|
|
292
|
+
// Strip trailing () from expression to get the base name for type lookup
|
|
293
|
+
function baseName(expr) {
|
|
294
|
+
return expr.endsWith('()') ? expr.slice(0, -2) : expr;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Case 1: {{var}} is the sole content of the parent element and parent has only one child text node
|
|
298
|
+
if (soleMatch && parent.childNodes.length === 1) {
|
|
299
|
+
const name = baseName(soleMatch[1]);
|
|
300
|
+
const safeName = name.replace(/[^a-zA-Z0-9_]/g, '_').slice(0, 30);
|
|
301
|
+
const varName = `__text_${safeName}_${bindIdx}`;
|
|
302
|
+
bindIdx++;
|
|
303
|
+
bindings.push({
|
|
304
|
+
varName,
|
|
305
|
+
name,
|
|
306
|
+
type: bindingType(name),
|
|
307
|
+
path: pathParts.slice(0, -1), // path to parent, not text node
|
|
308
|
+
});
|
|
309
|
+
parent.textContent = '';
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Case 2: Mixed text and interpolations — split into spans
|
|
314
|
+
const doc = node.ownerDocument;
|
|
315
|
+
const fragment = doc.createDocumentFragment();
|
|
316
|
+
const parts = text.split(/(\{\{(?:[^}]|\}(?!\}))+\}\})/);
|
|
317
|
+
const parentPath = pathParts.slice(0, -1);
|
|
318
|
+
|
|
319
|
+
// Find the index of this text node among its siblings
|
|
320
|
+
let baseIndex = 0;
|
|
321
|
+
for (const child of parent.childNodes) {
|
|
322
|
+
if (child === node) break;
|
|
323
|
+
baseIndex++;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
let offset = 0;
|
|
327
|
+
for (const part of parts) {
|
|
328
|
+
const bm = part.match(/^\{\{((?:[^}]|\}(?!\}))+)\}\}$/);
|
|
329
|
+
if (bm) {
|
|
330
|
+
fragment.appendChild(doc.createElement('span'));
|
|
331
|
+
const name = baseName(bm[1]);
|
|
332
|
+
const safeName = name.replace(/[^a-zA-Z0-9_]/g, '_').slice(0, 30);
|
|
333
|
+
const varName = `__text_${safeName}_${bindIdx}`;
|
|
334
|
+
bindIdx++;
|
|
335
|
+
bindings.push({
|
|
336
|
+
varName,
|
|
337
|
+
name,
|
|
338
|
+
type: bindingType(name),
|
|
339
|
+
path: [...parentPath, `childNodes[${baseIndex + offset}]`],
|
|
340
|
+
});
|
|
341
|
+
offset++;
|
|
342
|
+
} else if (part) {
|
|
343
|
+
fragment.appendChild(doc.createTextNode(part));
|
|
344
|
+
offset++;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
parent.replaceChild(fragment, node);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// --- Recurse into children ---
|
|
352
|
+
const children = Array.from(node.childNodes);
|
|
353
|
+
for (let i = 0; i < children.length; i++) {
|
|
354
|
+
walk(children[i], [...pathParts, `childNodes[${i}]`]);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
walk(rootEl, []);
|
|
359
|
+
return { bindings, events, showBindings, modelBindings, modelPropBindings, attrBindings, slots, childComponents };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ── Conditional chain processing (if / else-if / else) ──────────────
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Recompute the path from rootEl to a specific node after DOM normalization.
|
|
366
|
+
* Walks up from the node to rootEl, building the path segments.
|
|
367
|
+
*
|
|
368
|
+
* @param {Element} rootEl - The root element
|
|
369
|
+
* @param {Node} targetNode - The node to find the path to
|
|
370
|
+
* @returns {string[]} Path segments from rootEl to targetNode
|
|
371
|
+
*/
|
|
372
|
+
export function recomputeAnchorPath(rootEl, targetNode) {
|
|
373
|
+
const segments = [];
|
|
374
|
+
let current = targetNode;
|
|
375
|
+
while (current && current !== rootEl) {
|
|
376
|
+
const parent = current.parentNode;
|
|
377
|
+
if (!parent) break;
|
|
378
|
+
const children = Array.from(parent.childNodes);
|
|
379
|
+
const idx = children.indexOf(current);
|
|
380
|
+
segments.unshift(`childNodes[${idx}]`);
|
|
381
|
+
current = parent;
|
|
382
|
+
}
|
|
383
|
+
return segments;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Check if an element is a valid predecessor in a conditional chain
|
|
388
|
+
* (has `if` or `else-if` attribute).
|
|
389
|
+
*
|
|
390
|
+
* @param {Element} el
|
|
391
|
+
* @returns {boolean}
|
|
392
|
+
*/
|
|
393
|
+
function isChainPredecessor(el) {
|
|
394
|
+
return el.hasAttribute('if') || el.hasAttribute('else-if');
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Process a branch's HTML to extract internal bindings and events.
|
|
399
|
+
* Creates a temporary DOM and runs walkTree on it.
|
|
400
|
+
*
|
|
401
|
+
* @param {string} html - The branch HTML (outerHTML of the branch element)
|
|
402
|
+
* @param {Set<string>} signalNames
|
|
403
|
+
* @param {Set<string>} computedNames
|
|
404
|
+
* @param {Set<string>} propNames
|
|
405
|
+
* @returns {{ bindings: Binding[], events: EventBinding[], showBindings: ShowBinding[], attrBindings: AttrBinding[], modelBindings: ModelBinding[], modelPropBindings: ModelPropBinding[], slots: SlotBinding[], processedHtml: string }}
|
|
406
|
+
*/
|
|
407
|
+
export function walkBranch(html, signalNames, computedNames, propNames) {
|
|
408
|
+
const { document } = parseHTML(`<div id="__branchRoot">${html}</div>`);
|
|
409
|
+
const branchRoot = document.getElementById('__branchRoot');
|
|
410
|
+
|
|
411
|
+
// Process nested structural directives FIRST (before walkTree modifies the DOM).
|
|
412
|
+
// This is critical because walkTree clears textContent of elements with sole
|
|
413
|
+
// {{interpolation}} children, which would destroy content needed by
|
|
414
|
+
// processForBlocks/processIfChains when they clone nested elements for their
|
|
415
|
+
// own walkBranch calls.
|
|
416
|
+
const forBlocks = processForBlocks(branchRoot, [], signalNames, computedNames, propNames);
|
|
417
|
+
const ifBlocks = processIfChains(branchRoot, [], signalNames, computedNames, propNames);
|
|
418
|
+
|
|
419
|
+
// Now run walkTree on the remaining DOM (nested directive elements have been
|
|
420
|
+
// replaced with comment nodes, so walkTree won't process their contents).
|
|
421
|
+
const result = walkTree(branchRoot, signalNames, computedNames, propNames);
|
|
422
|
+
|
|
423
|
+
// Capture the processed HTML AFTER all processing
|
|
424
|
+
const processedHtml = branchRoot.innerHTML;
|
|
425
|
+
|
|
426
|
+
// Strip the first path segment from all paths since at runtime
|
|
427
|
+
// `node = clone.firstChild` is the element itself, not the wrapper div.
|
|
428
|
+
function stripFirstSegment(items) {
|
|
429
|
+
for (const item of items) {
|
|
430
|
+
if (item.path && item.path.length > 0 && item.path[0].startsWith('childNodes[')) {
|
|
431
|
+
item.path = item.path.slice(1);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
stripFirstSegment(result.bindings);
|
|
436
|
+
stripFirstSegment(result.events);
|
|
437
|
+
stripFirstSegment(result.showBindings);
|
|
438
|
+
stripFirstSegment(result.attrBindings);
|
|
439
|
+
stripFirstSegment(result.modelBindings);
|
|
440
|
+
stripFirstSegment(result.modelPropBindings);
|
|
441
|
+
stripFirstSegment(result.slots);
|
|
442
|
+
stripFirstSegment(result.childComponents);
|
|
443
|
+
|
|
444
|
+
// Strip first path segment from nested forBlock/ifBlock anchor paths
|
|
445
|
+
function stripFirstAnchorSegment(items) {
|
|
446
|
+
for (const item of items) {
|
|
447
|
+
if (item.anchorPath && item.anchorPath.length > 0 && item.anchorPath[0].startsWith('childNodes[')) {
|
|
448
|
+
item.anchorPath = item.anchorPath.slice(1);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
stripFirstAnchorSegment(forBlocks);
|
|
453
|
+
stripFirstAnchorSegment(ifBlocks);
|
|
454
|
+
|
|
455
|
+
return {
|
|
456
|
+
bindings: result.bindings,
|
|
457
|
+
events: result.events,
|
|
458
|
+
showBindings: result.showBindings,
|
|
459
|
+
attrBindings: result.attrBindings,
|
|
460
|
+
modelBindings: result.modelBindings,
|
|
461
|
+
modelPropBindings: result.modelPropBindings,
|
|
462
|
+
slots: result.slots,
|
|
463
|
+
childComponents: result.childComponents,
|
|
464
|
+
forBlocks,
|
|
465
|
+
ifBlocks,
|
|
466
|
+
processedHtml,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Build an IfBlock from a completed chain, replacing elements with a comment node.
|
|
472
|
+
*
|
|
473
|
+
* @param {{ elements: Element[], branches: { type: 'if' | 'else-if' | 'else', expression: string | null, element: Element }[] }} chain
|
|
474
|
+
* @param {Element} parent
|
|
475
|
+
* @param {string[]} parentPath
|
|
476
|
+
* @param {number} idx
|
|
477
|
+
* @param {Set<string>} signalNames
|
|
478
|
+
* @param {Set<string>} computedNames
|
|
479
|
+
* @param {Set<string>} propNames
|
|
480
|
+
* @returns {IfBlock}
|
|
481
|
+
*/
|
|
482
|
+
function buildIfBlock(chain, parent, parentPath, idx, signalNames, computedNames, propNames) {
|
|
483
|
+
const doc = parent.ownerDocument;
|
|
484
|
+
|
|
485
|
+
// Extract HTML for each branch (without the directive attribute)
|
|
486
|
+
/** @type {IfBranch[]} */
|
|
487
|
+
const branches = chain.branches.map((branch) => {
|
|
488
|
+
const el = branch.element;
|
|
489
|
+
// Clone the element to extract HTML without modifying the original yet
|
|
490
|
+
const clone = /** @type {Element} */ (el.cloneNode(true));
|
|
491
|
+
// Remove the directive attribute from the clone
|
|
492
|
+
clone.removeAttribute('if');
|
|
493
|
+
clone.removeAttribute('else-if');
|
|
494
|
+
clone.removeAttribute('else');
|
|
495
|
+
const templateHtml = clone.outerHTML;
|
|
496
|
+
|
|
497
|
+
// Process internal bindings/events via partial walk
|
|
498
|
+
const { bindings, events, showBindings, attrBindings, modelBindings, slots, childComponents, processedHtml } = walkBranch(templateHtml, signalNames, computedNames, propNames);
|
|
499
|
+
|
|
500
|
+
return {
|
|
501
|
+
type: branch.type,
|
|
502
|
+
expression: branch.expression,
|
|
503
|
+
templateHtml: processedHtml,
|
|
504
|
+
bindings,
|
|
505
|
+
events,
|
|
506
|
+
showBindings,
|
|
507
|
+
attrBindings,
|
|
508
|
+
modelBindings,
|
|
509
|
+
slots,
|
|
510
|
+
childComponents,
|
|
511
|
+
};
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
// Replace all chain elements with a single comment node
|
|
515
|
+
const comment = doc.createComment(' if ');
|
|
516
|
+
const firstEl = chain.elements[0];
|
|
517
|
+
parent.insertBefore(comment, firstEl);
|
|
518
|
+
|
|
519
|
+
// Remove all chain elements from the DOM
|
|
520
|
+
for (const el of chain.elements) {
|
|
521
|
+
parent.removeChild(el);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Calculate anchorPath: find the index of the comment node among parent's childNodes
|
|
525
|
+
const childNodes = Array.from(parent.childNodes);
|
|
526
|
+
const commentIndex = childNodes.indexOf(comment);
|
|
527
|
+
const anchorPath = [...parentPath, `childNodes[${commentIndex}]`];
|
|
528
|
+
|
|
529
|
+
return {
|
|
530
|
+
varName: `__if${idx}`,
|
|
531
|
+
anchorPath,
|
|
532
|
+
_anchorNode: comment,
|
|
533
|
+
branches,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Process conditional chains (if/else-if/else) in a DOM tree.
|
|
539
|
+
* Recursively searches all descendants for chains.
|
|
540
|
+
*
|
|
541
|
+
* @param {Element} parent - Root element to search
|
|
542
|
+
* @param {string[]} parentPath - DOM path to parent from __root
|
|
543
|
+
* @param {Set<string>} signalNames
|
|
544
|
+
* @param {Set<string>} computedNames
|
|
545
|
+
* @param {Set<string>} propNames
|
|
546
|
+
* @returns {IfBlock[]}
|
|
547
|
+
*/
|
|
548
|
+
export function processIfChains(parent, parentPath, signalNames, computedNames, propNames) {
|
|
549
|
+
/** @type {IfBlock[]} */
|
|
550
|
+
const ifBlocks = [];
|
|
551
|
+
let ifIdx = 0;
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Recursively search for if chains in the subtree.
|
|
555
|
+
* @param {Element} node
|
|
556
|
+
* @param {string[]} currentPath
|
|
557
|
+
*/
|
|
558
|
+
function findIfChains(node, currentPath) {
|
|
559
|
+
const children = Array.from(node.childNodes);
|
|
560
|
+
|
|
561
|
+
// First pass: validate all element children for conflicting directives
|
|
562
|
+
for (const child of children) {
|
|
563
|
+
if (child.nodeType !== 1) continue;
|
|
564
|
+
const el = /** @type {Element} */ (child);
|
|
565
|
+
|
|
566
|
+
const hasIf = el.hasAttribute('if');
|
|
567
|
+
const hasElseIf = el.hasAttribute('else-if');
|
|
568
|
+
const hasElse = el.hasAttribute('else');
|
|
569
|
+
const hasShow = el.hasAttribute('show');
|
|
570
|
+
|
|
571
|
+
// CONFLICTING_DIRECTIVES: if + else or if + else-if on same element
|
|
572
|
+
if (hasIf && (hasElse || hasElseIf)) {
|
|
573
|
+
const error = new Error('Las directivas condicionales son mutuamente excluyentes en un mismo elemento');
|
|
574
|
+
/** @ts-expect-error — custom error code */
|
|
575
|
+
error.code = 'CONFLICTING_DIRECTIVES';
|
|
576
|
+
throw error;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// CONFLICTING_DIRECTIVES: show + if on same element
|
|
580
|
+
if (hasShow && hasIf) {
|
|
581
|
+
const error = new Error('show y if no deben usarse en el mismo elemento');
|
|
582
|
+
/** @ts-expect-error — custom error code */
|
|
583
|
+
error.code = 'CONFLICTING_DIRECTIVES';
|
|
584
|
+
throw error;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// INVALID_V_ELSE: else with a non-empty value
|
|
588
|
+
if (hasElse && el.getAttribute('else') !== '') {
|
|
589
|
+
const error = new Error('else no acepta expresión');
|
|
590
|
+
/** @ts-expect-error — custom error code */
|
|
591
|
+
error.code = 'INVALID_V_ELSE';
|
|
592
|
+
throw error;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Second pass: detect chains by iterating element nodes in order
|
|
597
|
+
/** @type {{ elements: Element[], branches: { type: 'if' | 'else-if' | 'else', expression: string | null, element: Element }[] } | null} */
|
|
598
|
+
let currentChain = null;
|
|
599
|
+
/** @type {Element | null} */
|
|
600
|
+
let prevElement = null;
|
|
601
|
+
|
|
602
|
+
for (const child of children) {
|
|
603
|
+
if (child.nodeType !== 1) continue;
|
|
604
|
+
const el = /** @type {Element} */ (child);
|
|
605
|
+
|
|
606
|
+
const hasIf = el.hasAttribute('if');
|
|
607
|
+
const hasElseIf = el.hasAttribute('else-if');
|
|
608
|
+
const hasElse = el.hasAttribute('else');
|
|
609
|
+
|
|
610
|
+
if (hasIf) {
|
|
611
|
+
// Close any open chain
|
|
612
|
+
if (currentChain) {
|
|
613
|
+
ifBlocks.push(buildIfBlock(currentChain, node, currentPath, ifIdx++, signalNames, computedNames, propNames));
|
|
614
|
+
currentChain = null;
|
|
615
|
+
}
|
|
616
|
+
// Start new chain
|
|
617
|
+
currentChain = {
|
|
618
|
+
elements: [el],
|
|
619
|
+
branches: [{ type: 'if', expression: el.getAttribute('if'), element: el }],
|
|
620
|
+
};
|
|
621
|
+
} else if (hasElseIf) {
|
|
622
|
+
// Validate: must follow an if or else-if
|
|
623
|
+
if (!currentChain || !prevElement || !isChainPredecessor(prevElement)) {
|
|
624
|
+
const error = new Error('else-if/else requiere un if previo en el mismo nivel');
|
|
625
|
+
/** @ts-expect-error — custom error code */
|
|
626
|
+
error.code = 'ORPHAN_ELSE';
|
|
627
|
+
throw error;
|
|
628
|
+
}
|
|
629
|
+
currentChain.elements.push(el);
|
|
630
|
+
currentChain.branches.push({ type: 'else-if', expression: el.getAttribute('else-if'), element: el });
|
|
631
|
+
} else if (hasElse) {
|
|
632
|
+
// Validate: must follow an if or else-if
|
|
633
|
+
if (!currentChain || !prevElement || !isChainPredecessor(prevElement)) {
|
|
634
|
+
const error = new Error('else-if/else requiere un if previo en el mismo nivel');
|
|
635
|
+
/** @ts-expect-error — custom error code */
|
|
636
|
+
error.code = 'ORPHAN_ELSE';
|
|
637
|
+
throw error;
|
|
638
|
+
}
|
|
639
|
+
currentChain.elements.push(el);
|
|
640
|
+
currentChain.branches.push({ type: 'else', expression: null, element: el });
|
|
641
|
+
// Close chain
|
|
642
|
+
ifBlocks.push(buildIfBlock(currentChain, node, currentPath, ifIdx++, signalNames, computedNames, propNames));
|
|
643
|
+
currentChain = null;
|
|
644
|
+
} else {
|
|
645
|
+
// Non-conditional element: close any open chain
|
|
646
|
+
if (currentChain) {
|
|
647
|
+
ifBlocks.push(buildIfBlock(currentChain, node, currentPath, ifIdx++, signalNames, computedNames, propNames));
|
|
648
|
+
currentChain = null;
|
|
649
|
+
}
|
|
650
|
+
// Recurse into non-conditional elements to find nested if chains
|
|
651
|
+
const childIdx = Array.from(node.childNodes).indexOf(el);
|
|
652
|
+
findIfChains(el, [...currentPath, `childNodes[${childIdx}]`]);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
prevElement = el;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Close any remaining open chain
|
|
659
|
+
if (currentChain) {
|
|
660
|
+
ifBlocks.push(buildIfBlock(currentChain, node, currentPath, ifIdx++, signalNames, computedNames, propNames));
|
|
661
|
+
currentChain = null;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
findIfChains(parent, parentPath);
|
|
666
|
+
|
|
667
|
+
// Normalize the DOM to merge adjacent text nodes created by element removal
|
|
668
|
+
parent.normalize();
|
|
669
|
+
|
|
670
|
+
// Recompute anchor paths after normalization since text node merging
|
|
671
|
+
// may have changed childNode indices
|
|
672
|
+
for (const ib of ifBlocks) {
|
|
673
|
+
ib.anchorPath = recomputeAnchorPath(parent, ib._anchorNode);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
return ifBlocks;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// ── each directive processing ───────────────────────────────────────
|
|
680
|
+
|
|
681
|
+
// Forma simple: "item in source"
|
|
682
|
+
const simpleRe = /^\s*(\w+)\s+in\s+(.+)\s*$/;
|
|
683
|
+
// Forma con índice: "(item, index) in source"
|
|
684
|
+
const destructuredRe = /^\s*\(\s*(\w+)\s*,\s*(\w+)\s*\)\s+in\s+(.+)\s*$/;
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Parse an each expression.
|
|
688
|
+
* Supports:
|
|
689
|
+
* "item in source"
|
|
690
|
+
* "(item, index) in source"
|
|
691
|
+
*
|
|
692
|
+
* @param {string} expr - The each attribute value
|
|
693
|
+
* @returns {{ itemVar: string, indexVar: string | null, source: string }}
|
|
694
|
+
* @throws {Error} with code INVALID_V_FOR if syntax is invalid
|
|
695
|
+
*/
|
|
696
|
+
export function parseEachExpression(expr) {
|
|
697
|
+
// Check if expression contains "in" keyword
|
|
698
|
+
if (!/\bin\b/.test(expr)) {
|
|
699
|
+
const error = new Error('each requiere la sintaxis \'item in source\' o \'(item, index) in source\'');
|
|
700
|
+
/** @ts-expect-error — custom error code */
|
|
701
|
+
error.code = 'INVALID_V_FOR';
|
|
702
|
+
throw error;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Try destructured form first (more specific)
|
|
706
|
+
const destructuredMatch = destructuredRe.exec(expr);
|
|
707
|
+
if (destructuredMatch) {
|
|
708
|
+
const itemVar = destructuredMatch[1];
|
|
709
|
+
const indexVar = destructuredMatch[2];
|
|
710
|
+
const source = destructuredMatch[3].trim();
|
|
711
|
+
|
|
712
|
+
if (!itemVar) {
|
|
713
|
+
const error = new Error('each requiere una variable de iteración');
|
|
714
|
+
/** @ts-expect-error — custom error code */
|
|
715
|
+
error.code = 'INVALID_V_FOR';
|
|
716
|
+
throw error;
|
|
717
|
+
}
|
|
718
|
+
if (!source) {
|
|
719
|
+
const error = new Error('each requiere una expresión fuente');
|
|
720
|
+
/** @ts-expect-error — custom error code */
|
|
721
|
+
error.code = 'INVALID_V_FOR';
|
|
722
|
+
throw error;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
return { itemVar, indexVar, source };
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Try simple form
|
|
729
|
+
const simpleMatch = simpleRe.exec(expr);
|
|
730
|
+
if (simpleMatch) {
|
|
731
|
+
const itemVar = simpleMatch[1];
|
|
732
|
+
const source = simpleMatch[2].trim();
|
|
733
|
+
|
|
734
|
+
if (!itemVar) {
|
|
735
|
+
const error = new Error('each requiere una variable de iteración');
|
|
736
|
+
/** @ts-expect-error — custom error code */
|
|
737
|
+
error.code = 'INVALID_V_FOR';
|
|
738
|
+
throw error;
|
|
739
|
+
}
|
|
740
|
+
if (!source) {
|
|
741
|
+
const error = new Error('each requiere una expresión fuente');
|
|
742
|
+
/** @ts-expect-error — custom error code */
|
|
743
|
+
error.code = 'INVALID_V_FOR';
|
|
744
|
+
throw error;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
return { itemVar, indexVar: null, source };
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// If neither regex matched, check for specific error conditions
|
|
751
|
+
const inIndex = expr.indexOf(' in ');
|
|
752
|
+
if (inIndex !== -1) {
|
|
753
|
+
const left = expr.substring(0, inIndex).trim();
|
|
754
|
+
const right = expr.substring(inIndex + 4).trim();
|
|
755
|
+
|
|
756
|
+
if (!left) {
|
|
757
|
+
const error = new Error('each requiere una variable de iteración');
|
|
758
|
+
/** @ts-expect-error — custom error code */
|
|
759
|
+
error.code = 'INVALID_V_FOR';
|
|
760
|
+
throw error;
|
|
761
|
+
}
|
|
762
|
+
if (!right) {
|
|
763
|
+
const error = new Error('each requiere una expresión fuente');
|
|
764
|
+
/** @ts-expect-error — custom error code */
|
|
765
|
+
error.code = 'INVALID_V_FOR';
|
|
766
|
+
throw error;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Fallback: invalid syntax
|
|
771
|
+
const error = new Error('each requiere la sintaxis \'item in source\' o \'(item, index) in source\'');
|
|
772
|
+
/** @ts-expect-error — custom error code */
|
|
773
|
+
error.code = 'INVALID_V_FOR';
|
|
774
|
+
throw error;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Process each directives in descendants of a parent element.
|
|
779
|
+
* Recursively detects elements with `each` attribute, validates them,
|
|
780
|
+
* extracts item templates, and replaces them with comment anchors.
|
|
781
|
+
*
|
|
782
|
+
* @param {Element} parent - Root element to search
|
|
783
|
+
* @param {string[]} parentPath - DOM path to parent from __root
|
|
784
|
+
* @param {Set<string>} signalNames
|
|
785
|
+
* @param {Set<string>} computedNames
|
|
786
|
+
* @param {Set<string>} propNames
|
|
787
|
+
* @returns {ForBlock[]}
|
|
788
|
+
*/
|
|
789
|
+
export function processForBlocks(parent, parentPath, signalNames, computedNames, propNames) {
|
|
790
|
+
/** @type {ForBlock[]} */
|
|
791
|
+
const forBlocks = [];
|
|
792
|
+
let forIdx = 0;
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Recursively search for elements with each in the subtree.
|
|
796
|
+
* @param {Element} node
|
|
797
|
+
* @param {string[]} currentPath
|
|
798
|
+
*/
|
|
799
|
+
function findForElements(node, currentPath) {
|
|
800
|
+
const children = Array.from(node.childNodes);
|
|
801
|
+
for (let i = 0; i < children.length; i++) {
|
|
802
|
+
const child = children[i];
|
|
803
|
+
if (child.nodeType !== 1) continue;
|
|
804
|
+
const el = /** @type {Element} */ (child);
|
|
805
|
+
|
|
806
|
+
if (el.hasAttribute('each')) {
|
|
807
|
+
// Validate no conflicting if directive
|
|
808
|
+
if (el.hasAttribute('if')) {
|
|
809
|
+
const error = new Error('each y if no deben usarse en el mismo elemento');
|
|
810
|
+
/** @ts-expect-error — custom error code */
|
|
811
|
+
error.code = 'CONFLICTING_DIRECTIVES';
|
|
812
|
+
throw error;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// Parse the each expression
|
|
816
|
+
const expr = el.getAttribute('each');
|
|
817
|
+
const { itemVar, indexVar, source } = parseEachExpression(expr);
|
|
818
|
+
|
|
819
|
+
// Extract :key if present
|
|
820
|
+
const keyExpr = el.hasAttribute(':key') ? el.getAttribute(':key') : null;
|
|
821
|
+
|
|
822
|
+
// Clone the element and remove each and :key from the clone
|
|
823
|
+
const clone = /** @type {Element} */ (el.cloneNode(true));
|
|
824
|
+
clone.removeAttribute('each');
|
|
825
|
+
clone.removeAttribute(':key');
|
|
826
|
+
const templateHtml = clone.outerHTML;
|
|
827
|
+
|
|
828
|
+
// Process internal bindings/events via partial walk
|
|
829
|
+
const { bindings, events, showBindings, attrBindings, modelBindings, slots, childComponents: forChildComponents, forBlocks: nestedForBlocks, ifBlocks: nestedIfBlocks, processedHtml } = walkBranch(templateHtml, signalNames, computedNames, propNames);
|
|
830
|
+
|
|
831
|
+
// Replace the original element with a comment node <!-- each -->
|
|
832
|
+
const doc = node.ownerDocument;
|
|
833
|
+
const comment = doc.createComment(' each ');
|
|
834
|
+
node.replaceChild(comment, el);
|
|
835
|
+
|
|
836
|
+
// Calculate anchorPath
|
|
837
|
+
const updatedChildren = Array.from(node.childNodes);
|
|
838
|
+
const commentIndex = updatedChildren.indexOf(comment);
|
|
839
|
+
const anchorPath = [...currentPath, `childNodes[${commentIndex}]`];
|
|
840
|
+
|
|
841
|
+
// Create ForBlock
|
|
842
|
+
forBlocks.push({
|
|
843
|
+
varName: `__for${forIdx++}`,
|
|
844
|
+
itemVar,
|
|
845
|
+
indexVar,
|
|
846
|
+
source,
|
|
847
|
+
keyExpr,
|
|
848
|
+
templateHtml: processedHtml,
|
|
849
|
+
anchorPath,
|
|
850
|
+
_anchorNode: comment,
|
|
851
|
+
bindings,
|
|
852
|
+
events,
|
|
853
|
+
showBindings,
|
|
854
|
+
attrBindings,
|
|
855
|
+
modelBindings,
|
|
856
|
+
slots,
|
|
857
|
+
childComponents: forChildComponents,
|
|
858
|
+
forBlocks: nestedForBlocks,
|
|
859
|
+
ifBlocks: nestedIfBlocks,
|
|
860
|
+
});
|
|
861
|
+
} else {
|
|
862
|
+
// Recurse into non-each elements to find nested each
|
|
863
|
+
const childPath = [...currentPath, `childNodes[${i}]`];
|
|
864
|
+
findForElements(el, childPath);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
findForElements(parent, parentPath);
|
|
870
|
+
return forBlocks;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
// ── Dynamic component processing ────────────────────────────────────
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Process dynamic component elements (`<component :is="expr">`) in descendants of a parent element.
|
|
878
|
+
* Recursively detects `<component>` elements, validates the `:is` attribute,
|
|
879
|
+
* extracts prop/event bindings, and replaces them with comment anchors.
|
|
880
|
+
*
|
|
881
|
+
* @param {Element} parent - Root element to search
|
|
882
|
+
* @param {string[]} parentPath - DOM path to parent from __root
|
|
883
|
+
* @returns {DynamicComponentBinding[]}
|
|
884
|
+
*/
|
|
885
|
+
export function processDynamicComponents(parent, parentPath) {
|
|
886
|
+
/** @type {DynamicComponentBinding[]} */
|
|
887
|
+
const dynamicComponents = [];
|
|
888
|
+
let dynIdx = 0;
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* Recursively search for <component> elements in the subtree.
|
|
892
|
+
* @param {Element} node
|
|
893
|
+
* @param {string[]} currentPath
|
|
894
|
+
*/
|
|
895
|
+
function findDynamicComponents(node, currentPath) {
|
|
896
|
+
const children = Array.from(node.childNodes);
|
|
897
|
+
for (let i = 0; i < children.length; i++) {
|
|
898
|
+
const child = children[i];
|
|
899
|
+
if (child.nodeType !== 1) continue;
|
|
900
|
+
const el = /** @type {Element} */ (child);
|
|
901
|
+
|
|
902
|
+
if (el.tagName === 'COMPONENT') {
|
|
903
|
+
// Validate :is attribute is present
|
|
904
|
+
const isExpr = el.getAttribute(':is');
|
|
905
|
+
if (!isExpr) {
|
|
906
|
+
const error = new Error(':is attribute is required on <component> elements');
|
|
907
|
+
/** @ts-expect-error — custom error code */
|
|
908
|
+
error.code = 'MISSING_IS_ATTRIBUTE';
|
|
909
|
+
throw error;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Collect prop bindings (:attr="expr", excluding :is)
|
|
913
|
+
/** @type {DynPropBinding[]} */
|
|
914
|
+
const props = [];
|
|
915
|
+
// Collect event bindings (@event="handler")
|
|
916
|
+
/** @type {DynEventBinding[]} */
|
|
917
|
+
const events = [];
|
|
918
|
+
|
|
919
|
+
for (const attr of Array.from(el.attributes)) {
|
|
920
|
+
if (attr.name.startsWith(':') && attr.name !== ':is') {
|
|
921
|
+
props.push({
|
|
922
|
+
attr: attr.name.slice(1),
|
|
923
|
+
expression: attr.value,
|
|
924
|
+
});
|
|
925
|
+
} else if (attr.name.startsWith('@')) {
|
|
926
|
+
events.push({
|
|
927
|
+
event: attr.name.slice(1),
|
|
928
|
+
handler: attr.value,
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// Replace <component> with a comment node <!-- dynamic -->
|
|
934
|
+
const doc = node.ownerDocument;
|
|
935
|
+
const comment = doc.createComment(' dynamic ');
|
|
936
|
+
node.replaceChild(comment, el);
|
|
937
|
+
|
|
938
|
+
// Calculate anchorPath
|
|
939
|
+
const updatedChildren = Array.from(node.childNodes);
|
|
940
|
+
const commentIndex = updatedChildren.indexOf(comment);
|
|
941
|
+
const anchorPath = [...currentPath, `childNodes[${commentIndex}]`];
|
|
942
|
+
|
|
943
|
+
// Create DynamicComponentBinding
|
|
944
|
+
dynamicComponents.push({
|
|
945
|
+
varName: `__dyn${dynIdx++}`,
|
|
946
|
+
isExpression: isExpr,
|
|
947
|
+
props,
|
|
948
|
+
events,
|
|
949
|
+
anchorPath,
|
|
950
|
+
_anchorNode: comment,
|
|
951
|
+
});
|
|
952
|
+
} else {
|
|
953
|
+
// Recurse into non-component elements to find nested dynamic components
|
|
954
|
+
const childPath = [...currentPath, `childNodes[${i}]`];
|
|
955
|
+
findDynamicComponents(el, childPath);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
findDynamicComponents(parent, parentPath);
|
|
961
|
+
return dynamicComponents;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// ── Ref detection ───────────────────────────────────────────────────
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* Detect ref="name" attributes on elements in the DOM tree.
|
|
968
|
+
* Removes the ref attribute from each element after recording.
|
|
969
|
+
*
|
|
970
|
+
* @param {Element} rootEl — jsdom DOM element (parsed template root)
|
|
971
|
+
* @returns {RefBinding[]}
|
|
972
|
+
* @throws {Error} with code DUPLICATE_REF if same ref name appears on multiple elements
|
|
973
|
+
*/
|
|
974
|
+
export function detectRefs(rootEl) {
|
|
975
|
+
/** @type {RefBinding[]} */
|
|
976
|
+
const refBindings = [];
|
|
977
|
+
/** @type {Set<string>} */
|
|
978
|
+
const seen = new Set();
|
|
979
|
+
|
|
980
|
+
const elements = rootEl.querySelectorAll('[ref]');
|
|
981
|
+
|
|
982
|
+
for (const el of elements) {
|
|
983
|
+
const refName = el.getAttribute('ref');
|
|
984
|
+
|
|
985
|
+
// Check for duplicate ref names
|
|
986
|
+
if (seen.has(refName)) {
|
|
987
|
+
const error = new Error(`Duplicate ref name '${refName}' — each ref must be unique`);
|
|
988
|
+
/** @ts-expect-error — custom error code */
|
|
989
|
+
error.code = 'DUPLICATE_REF';
|
|
990
|
+
throw error;
|
|
991
|
+
}
|
|
992
|
+
seen.add(refName);
|
|
993
|
+
|
|
994
|
+
// Compute DOM path from rootEl to el
|
|
995
|
+
const path = [];
|
|
996
|
+
let current = el;
|
|
997
|
+
while (current && current !== rootEl) {
|
|
998
|
+
const parent = current.parentNode;
|
|
999
|
+
if (!parent) break;
|
|
1000
|
+
const children = Array.from(parent.childNodes);
|
|
1001
|
+
const idx = children.indexOf(current);
|
|
1002
|
+
path.unshift(`childNodes[${idx}]`);
|
|
1003
|
+
current = parent;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// Remove the ref attribute
|
|
1007
|
+
el.removeAttribute('ref');
|
|
1008
|
+
|
|
1009
|
+
refBindings.push({ refName, path });
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
return refBindings;
|
|
1013
|
+
}
|