lego-dom 0.0.3 → 0.0.5
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/.ignore/auto.html +135 -0
- package/.ignore/test.html +73 -0
- package/README.md +448 -102
- package/index.js +26 -0
- package/main.js +459 -0
- package/main.test.js +86 -0
- package/package.json +14 -8
- package/dist/dom/index.js +0 -1
- package/dist/index.js +0 -1
- package/dist/utils/index.js +0 -1
- package/dist/utils/traverser.js +0 -1
- package/dist/veyors/basket.js +0 -2
- package/dist/veyors/brick.js +0 -1
- package/dist/veyors/index.js +0 -1
- package/dist/veyors/router.js +0 -1
- package/example/blocks/banner.lego +0 -0
- package/example/blocks/card.lego +0 -40
- package/example/blocks/form.lego +0 -31
- package/example/bricks/index.html +0 -50
- package/src/dom/index.ts +0 -0
- package/src/index.ts +0 -0
- package/src/utils/index.ts +0 -0
- package/src/utils/traverser.ts +0 -0
- package/src/veyors/basket.ts +0 -5
- package/src/veyors/brick.ts +0 -0
- package/src/veyors/index.ts +0 -0
- package/src/veyors/router.ts +0 -0
- package/tsconfig.json +0 -69
package/main.js
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
const Lego = (() => {
|
|
2
|
+
const registry = {}, proxyCache = new WeakMap(), privateData = new WeakMap();
|
|
3
|
+
const forPools = new WeakMap();
|
|
4
|
+
|
|
5
|
+
const sfcLogic = new Map();
|
|
6
|
+
const sharedStates = new Map(); // Track singleton states for $registry
|
|
7
|
+
const routes = [];
|
|
8
|
+
|
|
9
|
+
const escapeHTML = (str) => {
|
|
10
|
+
if (typeof str !== 'string') return str;
|
|
11
|
+
return str.replace(/[&<>"']/g, m => ({
|
|
12
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
13
|
+
}[m]));
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const createBatcher = () => {
|
|
17
|
+
let queued = false;
|
|
18
|
+
const componentsToUpdate = new Set();
|
|
19
|
+
let isProcessing = false;
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
add: (el) => {
|
|
23
|
+
if (!el || isProcessing) return;
|
|
24
|
+
componentsToUpdate.add(el);
|
|
25
|
+
if (queued) return;
|
|
26
|
+
queued = true;
|
|
27
|
+
|
|
28
|
+
requestAnimationFrame(() => {
|
|
29
|
+
isProcessing = true;
|
|
30
|
+
const batch = Array.from(componentsToUpdate);
|
|
31
|
+
componentsToUpdate.clear();
|
|
32
|
+
queued = false;
|
|
33
|
+
|
|
34
|
+
batch.forEach(el => render(el));
|
|
35
|
+
|
|
36
|
+
setTimeout(() => {
|
|
37
|
+
batch.forEach(el => {
|
|
38
|
+
const state = el._studs;
|
|
39
|
+
if (state && typeof state.updated === 'function') {
|
|
40
|
+
try {
|
|
41
|
+
state.updated.call(state);
|
|
42
|
+
} catch (e) {
|
|
43
|
+
console.error(`[Lego] Error in updated hook:`, e);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
isProcessing = false;
|
|
48
|
+
}, 0);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const globalBatcher = createBatcher();
|
|
55
|
+
|
|
56
|
+
const reactive = (obj, el, batcher = globalBatcher) => {
|
|
57
|
+
if (obj === null || typeof obj !== 'object' || obj instanceof Node) return obj;
|
|
58
|
+
if (proxyCache.has(obj)) return proxyCache.get(obj);
|
|
59
|
+
|
|
60
|
+
const handler = {
|
|
61
|
+
get: (t, k) => {
|
|
62
|
+
const val = Reflect.get(t, k);
|
|
63
|
+
if (val !== null && typeof val === 'object' && !(val instanceof Node)) {
|
|
64
|
+
return reactive(val, el, batcher);
|
|
65
|
+
}
|
|
66
|
+
return val;
|
|
67
|
+
},
|
|
68
|
+
set: (t, k, v) => {
|
|
69
|
+
const old = t[k];
|
|
70
|
+
const r = Reflect.set(t, k, v);
|
|
71
|
+
if (old !== v) batcher.add(el);
|
|
72
|
+
return r;
|
|
73
|
+
},
|
|
74
|
+
deleteProperty: (t, k) => {
|
|
75
|
+
const r = Reflect.deleteProperty(t, k);
|
|
76
|
+
batcher.add(el);
|
|
77
|
+
return r;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const p = new Proxy(obj, handler);
|
|
82
|
+
proxyCache.set(obj, p);
|
|
83
|
+
return p;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const parseJSObject = (raw) => {
|
|
87
|
+
try {
|
|
88
|
+
return (new Function(`return (${raw})`))();
|
|
89
|
+
} catch (e) {
|
|
90
|
+
console.error(`[Lego] Failed to parse b-data:`, raw, e);
|
|
91
|
+
return {};
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const getPrivateData = (el) => {
|
|
96
|
+
if (!privateData.has(el)) {
|
|
97
|
+
privateData.set(el, { snapped: false, bindings: null, bound: false, rendering: false });
|
|
98
|
+
}
|
|
99
|
+
return privateData.get(el);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const resolve = (path, obj) => {
|
|
103
|
+
if (!path) return '';
|
|
104
|
+
const parts = path.trim().split('.');
|
|
105
|
+
let current = obj;
|
|
106
|
+
for (const part of parts) {
|
|
107
|
+
if (current == null) return '';
|
|
108
|
+
current = current[part];
|
|
109
|
+
}
|
|
110
|
+
return current ?? '';
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const findAncestorState = (el, tagName) => {
|
|
114
|
+
let parent = el.parentElement || el.getRootNode().host;
|
|
115
|
+
while (parent) {
|
|
116
|
+
if (parent.tagName && parent.tagName.toLowerCase() === tagName.toLowerCase()) {
|
|
117
|
+
return parent._studs;
|
|
118
|
+
}
|
|
119
|
+
parent = parent.parentElement || (parent.getRootNode && parent.getRootNode().host);
|
|
120
|
+
}
|
|
121
|
+
return undefined;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const safeEval = (expr, context) => {
|
|
125
|
+
try {
|
|
126
|
+
const scope = context.state || {};
|
|
127
|
+
|
|
128
|
+
const helpers = {
|
|
129
|
+
$ancestors: (tag) => findAncestorState(context.self, tag),
|
|
130
|
+
// Helper to access shared state by tag name
|
|
131
|
+
$registry: (tag) => sharedStates.get(tag.toLowerCase()),
|
|
132
|
+
$element: context.self,
|
|
133
|
+
$emit: (name, detail) => {
|
|
134
|
+
context.self.dispatchEvent(new CustomEvent(name, {
|
|
135
|
+
detail,
|
|
136
|
+
bubbles: true,
|
|
137
|
+
composed: true
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const func = new Function('global', 'self', 'event', 'helpers', `
|
|
143
|
+
with(helpers) {
|
|
144
|
+
with(this) {
|
|
145
|
+
try { return ${expr} } catch(e) { return undefined; }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
`);
|
|
149
|
+
|
|
150
|
+
const result = func.call(scope, context.global, context.self, context.event, helpers);
|
|
151
|
+
if (typeof result === 'function') return result.call(scope, context.event);
|
|
152
|
+
return result;
|
|
153
|
+
} catch (e) {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const syncModelValue = (el, val) => {
|
|
159
|
+
if (el.type === 'checkbox') {
|
|
160
|
+
if (el.checked !== !!val) el.checked = !!val;
|
|
161
|
+
} else {
|
|
162
|
+
const normalized = (val === undefined || val === null) ? '' : String(val);
|
|
163
|
+
if (el.value !== normalized) el.value = normalized;
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const bind = (container, componentRoot, loopCtx = null) => {
|
|
168
|
+
const state = componentRoot._studs;
|
|
169
|
+
const elements = container instanceof Element ? [container, ...container.querySelectorAll('*')] : container.querySelectorAll('*');
|
|
170
|
+
|
|
171
|
+
elements.forEach(child => {
|
|
172
|
+
const childData = getPrivateData(child);
|
|
173
|
+
if (childData.bound) return;
|
|
174
|
+
|
|
175
|
+
[...child.attributes].forEach(attr => {
|
|
176
|
+
if (attr.name.startsWith('@')) {
|
|
177
|
+
const eventName = attr.name.slice(1);
|
|
178
|
+
child.addEventListener(eventName, (event) => {
|
|
179
|
+
let evalScope = state;
|
|
180
|
+
if (loopCtx) {
|
|
181
|
+
const list = resolve(loopCtx.listName, state);
|
|
182
|
+
const item = list[loopCtx.index];
|
|
183
|
+
evalScope = Object.assign(Object.create(state), { [loopCtx.name]: item });
|
|
184
|
+
}
|
|
185
|
+
safeEval(attr.value, { state: evalScope, global: Lego.globals, self: child, event });
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
if (child.hasAttribute('b-sync')) {
|
|
191
|
+
const prop = child.getAttribute('b-sync');
|
|
192
|
+
const updateState = () => {
|
|
193
|
+
let target, last;
|
|
194
|
+
if (loopCtx && prop.startsWith(loopCtx.name + '.')) {
|
|
195
|
+
const list = resolve(loopCtx.listName, state);
|
|
196
|
+
const item = list[loopCtx.index];
|
|
197
|
+
if (!item) return;
|
|
198
|
+
const subPath = prop.split('.').slice(1);
|
|
199
|
+
last = subPath.pop();
|
|
200
|
+
target = subPath.reduce((o, k) => o[k], item);
|
|
201
|
+
} else {
|
|
202
|
+
const keys = prop.split('.');
|
|
203
|
+
last = keys.pop();
|
|
204
|
+
target = keys.reduce((o, k) => o[k], state);
|
|
205
|
+
}
|
|
206
|
+
const newVal = child.type === 'checkbox' ? child.checked : child.value;
|
|
207
|
+
if (target && target[last] !== newVal) target[last] = newVal;
|
|
208
|
+
};
|
|
209
|
+
child.addEventListener('input', updateState);
|
|
210
|
+
child.addEventListener('change', updateState);
|
|
211
|
+
}
|
|
212
|
+
childData.bound = true;
|
|
213
|
+
});
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const scanForBindings = (container) => {
|
|
217
|
+
const bindings = [];
|
|
218
|
+
const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT);
|
|
219
|
+
let node;
|
|
220
|
+
while (node = walker.nextNode()) {
|
|
221
|
+
const isInsideBFor = (n) => {
|
|
222
|
+
let curr = n.parentNode;
|
|
223
|
+
while (curr && curr !== container) {
|
|
224
|
+
if (curr.hasAttribute && curr.hasAttribute('b-for')) return true;
|
|
225
|
+
if (curr.tagName && curr.tagName.includes('-') && registry[curr.tagName.toLowerCase()]) return true;
|
|
226
|
+
curr = curr.parentNode;
|
|
227
|
+
}
|
|
228
|
+
return false;
|
|
229
|
+
};
|
|
230
|
+
if (isInsideBFor(node)) continue;
|
|
231
|
+
|
|
232
|
+
if (node.nodeType === 1) {
|
|
233
|
+
if (node.hasAttribute('b-if')) bindings.push({ type: 'b-if', node, expr: node.getAttribute('b-if') });
|
|
234
|
+
if (node.hasAttribute('b-for')) {
|
|
235
|
+
const match = node.getAttribute('b-for').match(/^\s*(\w+)\s+in\s+(.+)\s*$/);
|
|
236
|
+
if (match) {
|
|
237
|
+
bindings.push({
|
|
238
|
+
type: 'b-for',
|
|
239
|
+
node,
|
|
240
|
+
itemName: match[1],
|
|
241
|
+
listName: match[2].trim(),
|
|
242
|
+
template: node.innerHTML
|
|
243
|
+
});
|
|
244
|
+
node.innerHTML = '';
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (node.hasAttribute('b-text')) bindings.push({ type: 'b-text', node, path: node.getAttribute('b-text') });
|
|
248
|
+
if (node.hasAttribute('b-sync')) bindings.push({ type: 'b-sync', node });
|
|
249
|
+
[...node.attributes].forEach(attr => {
|
|
250
|
+
if (attr.value.includes('{{')) bindings.push({ type: 'attr', node, attrName: attr.name, template: attr.value });
|
|
251
|
+
});
|
|
252
|
+
} else if (node.nodeType === 3 && node.textContent.includes('{{')) {
|
|
253
|
+
bindings.push({ type: 'text', node, template: node.textContent });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return bindings;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const updateNodeBindings = (root, scope) => {
|
|
260
|
+
const processNode = (node) => {
|
|
261
|
+
if (node.nodeType === 3) {
|
|
262
|
+
if (node._tpl === undefined) node._tpl = node.textContent;
|
|
263
|
+
const out = node._tpl.replace(/{{(.*?)}}/g, (_, k) => escapeHTML(safeEval(k.trim(), { state: scope, self: node }) ?? ''));
|
|
264
|
+
if (node.textContent !== out) node.textContent = out;
|
|
265
|
+
} else if (node.nodeType === 1) {
|
|
266
|
+
[...node.attributes].forEach(attr => {
|
|
267
|
+
if (attr._tpl === undefined) attr._tpl = attr.value;
|
|
268
|
+
if (attr._tpl.includes('{{')) {
|
|
269
|
+
const out = attr._tpl.replace(/{{(.*?)}}/g, (_, k) => escapeHTML(safeEval(k.trim(), { state: scope, self: node }) ?? ''));
|
|
270
|
+
if (attr.value !== out) {
|
|
271
|
+
attr.value = out;
|
|
272
|
+
if (attr.name === 'class') node.className = out;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
processNode(root);
|
|
279
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT);
|
|
280
|
+
let n;
|
|
281
|
+
while (n = walker.nextNode()) processNode(n);
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const render = (el) => {
|
|
285
|
+
const state = el._studs;
|
|
286
|
+
if (!state) return;
|
|
287
|
+
const data = getPrivateData(el);
|
|
288
|
+
if (data.rendering) return;
|
|
289
|
+
data.rendering = true;
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
const shadow = el.shadowRoot;
|
|
293
|
+
if (!shadow) return;
|
|
294
|
+
if (!data.bindings) data.bindings = scanForBindings(shadow);
|
|
295
|
+
|
|
296
|
+
data.bindings.forEach(b => {
|
|
297
|
+
if (b.type === 'b-if') b.node.style.display = safeEval(b.expr, { state, self: b.node }) ? '' : 'none';
|
|
298
|
+
if (b.type === 'b-text') b.node.textContent = escapeHTML(resolve(b.path, state));
|
|
299
|
+
if (b.type === 'b-sync') syncModelValue(b.node, resolve(b.node.getAttribute('b-sync'), state));
|
|
300
|
+
if (b.type === 'text') {
|
|
301
|
+
const out = b.template.replace(/{{(.*?)}}/g, (_, k) => escapeHTML(safeEval(k.trim(), { state, self: b.node }) ?? ''));
|
|
302
|
+
if (b.node.textContent !== out) b.node.textContent = out;
|
|
303
|
+
}
|
|
304
|
+
if (b.type === 'attr') {
|
|
305
|
+
const out = b.template.replace(/{{(.*?)}}/g, (_, k) => escapeHTML(safeEval(k.trim(), { state, self: b.node }) ?? ''));
|
|
306
|
+
if (b.node.getAttribute(b.attrName) !== out) {
|
|
307
|
+
b.node.setAttribute(b.attrName, out);
|
|
308
|
+
if (b.attrName === 'class') b.node.className = out;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (b.type === 'b-for') {
|
|
312
|
+
const list = resolve(b.listName, state) || [];
|
|
313
|
+
if (!forPools.has(b.node)) forPools.set(b.node, new Map());
|
|
314
|
+
const pool = forPools.get(b.node);
|
|
315
|
+
const currentKeys = new Set();
|
|
316
|
+
list.forEach((item, i) => {
|
|
317
|
+
const key = (item && typeof item === 'object') ? (item.__id || (item.__id = Math.random())) : `${i}-${item}`;
|
|
318
|
+
currentKeys.add(key);
|
|
319
|
+
let child = pool.get(key);
|
|
320
|
+
if (!child) {
|
|
321
|
+
const temp = document.createElement('div');
|
|
322
|
+
temp.innerHTML = b.template;
|
|
323
|
+
child = temp.firstElementChild;
|
|
324
|
+
pool.set(key, child);
|
|
325
|
+
bind(child, el, { name: b.itemName, listName: b.listName, index: i });
|
|
326
|
+
}
|
|
327
|
+
const localScope = Object.assign(Object.create(state), { [b.itemName]: item });
|
|
328
|
+
updateNodeBindings(child, localScope);
|
|
329
|
+
|
|
330
|
+
child.querySelectorAll('[b-sync]').forEach(input => {
|
|
331
|
+
const path = input.getAttribute('b-sync');
|
|
332
|
+
if (path.startsWith(b.itemName + '.')) {
|
|
333
|
+
syncModelValue(input, resolve(path.split('.').slice(1).join('.'), item));
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
if (b.node.children[i] !== child) b.node.insertBefore(child, b.node.children[i] || null);
|
|
337
|
+
});
|
|
338
|
+
for (const [key, node] of pool.entries()) {
|
|
339
|
+
if (!currentKeys.has(key)) { node.remove(); pool.delete(key); }
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
} finally {
|
|
344
|
+
data.rendering = false;
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const snap = (el) => {
|
|
349
|
+
if (!el || el.nodeType !== 1) return;
|
|
350
|
+
const data = getPrivateData(el);
|
|
351
|
+
const name = el.tagName.toLowerCase();
|
|
352
|
+
|
|
353
|
+
if (registry[name] && !data.snapped) {
|
|
354
|
+
data.snapped = true;
|
|
355
|
+
const tpl = registry[name].content.cloneNode(true);
|
|
356
|
+
const shadow = el.attachShadow({ mode: 'open' });
|
|
357
|
+
|
|
358
|
+
const defaultLogic = sfcLogic.get(name) || {};
|
|
359
|
+
const attrLogic = parseJSObject(el.getAttribute('b-data') || '{}');
|
|
360
|
+
el._studs = reactive({ ...defaultLogic, ...attrLogic }, el);
|
|
361
|
+
|
|
362
|
+
shadow.appendChild(tpl);
|
|
363
|
+
|
|
364
|
+
const style = shadow.querySelector('style');
|
|
365
|
+
if (style) {
|
|
366
|
+
style.textContent = style.textContent.replace(/\bself\b/g, ':host');
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
bind(shadow, el);
|
|
370
|
+
render(el);
|
|
371
|
+
|
|
372
|
+
if (typeof el._studs.mounted === 'function') {
|
|
373
|
+
try { el._studs.mounted.call(el._studs); } catch (e) { console.error(`[Lego] Error in mounted <${name}>:`, e); }
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
let provider = el.parentElement;
|
|
378
|
+
while(provider && !provider._studs) provider = provider.parentElement;
|
|
379
|
+
if (provider && provider._studs) bind(el, provider);
|
|
380
|
+
|
|
381
|
+
[...el.children].forEach(snap);
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const unsnap = (el) => {
|
|
385
|
+
if (el._studs && typeof el._studs.unmounted === 'function') {
|
|
386
|
+
try { el._studs.unmounted.call(el._studs); } catch (e) { console.error(`[Lego] Error in unmounted:`, e); }
|
|
387
|
+
}
|
|
388
|
+
[...el.children].forEach(unsnap);
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const _matchRoute = async () => {
|
|
392
|
+
const path = window.location.pathname;
|
|
393
|
+
const match = routes.find(r => r.regex.test(path));
|
|
394
|
+
const outlet = document.querySelector('lego-router');
|
|
395
|
+
if (!outlet || !match) return;
|
|
396
|
+
|
|
397
|
+
const values = path.match(match.regex).slice(1);
|
|
398
|
+
const params = Object.fromEntries(match.paramNames.map((n, i) => [n, values[i]]));
|
|
399
|
+
|
|
400
|
+
if (match.middleware) {
|
|
401
|
+
const allowed = await match.middleware(params, Lego.globals);
|
|
402
|
+
if (!allowed) return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
Lego.globals.params = params;
|
|
406
|
+
outlet.innerHTML = `<${match.tagName}></${match.tagName}>`;
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
return {
|
|
410
|
+
init: () => {
|
|
411
|
+
document.querySelectorAll('template[b-id]').forEach(t => registry[t.getAttribute('b-id')] = t);
|
|
412
|
+
const observer = new MutationObserver(m => m.forEach(r => {
|
|
413
|
+
r.addedNodes.forEach(n => n.nodeType === 1 && snap(n));
|
|
414
|
+
r.removedNodes.forEach(n => n.nodeType === 1 && unsnap(n));
|
|
415
|
+
}));
|
|
416
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
417
|
+
snap(document.body);
|
|
418
|
+
|
|
419
|
+
if (routes.length > 0) {
|
|
420
|
+
window.addEventListener('popstate', _matchRoute);
|
|
421
|
+
document.addEventListener('click', e => {
|
|
422
|
+
const link = e.target.closest('a[b-link]');
|
|
423
|
+
if (link) {
|
|
424
|
+
e.preventDefault();
|
|
425
|
+
history.pushState({}, '', link.getAttribute('href'));
|
|
426
|
+
_matchRoute();
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
_matchRoute();
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
globals: reactive({}, document.body),
|
|
433
|
+
define: (tagName, templateHTML, logic = {}) => {
|
|
434
|
+
const t = document.createElement('template');
|
|
435
|
+
t.setAttribute('b-id', tagName);
|
|
436
|
+
t.innerHTML = templateHTML;
|
|
437
|
+
registry[tagName] = t;
|
|
438
|
+
sfcLogic.set(tagName, logic);
|
|
439
|
+
|
|
440
|
+
// Initialize shared state for $registry singleton
|
|
441
|
+
sharedStates.set(tagName.toLowerCase(), reactive({ ...logic }, document.body));
|
|
442
|
+
|
|
443
|
+
document.querySelectorAll(tagName).forEach(snap);
|
|
444
|
+
},
|
|
445
|
+
route: (path, tagName, middleware = null) => {
|
|
446
|
+
const paramNames = [];
|
|
447
|
+
const regexPath = path.replace(/:([^\/]+)/g, (_, name) => {
|
|
448
|
+
paramNames.push(name);
|
|
449
|
+
return '([^/]+)';
|
|
450
|
+
});
|
|
451
|
+
routes.push({ regex: new RegExp(`^${regexPath}$`), tagName, paramNames, middleware });
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
})();
|
|
455
|
+
|
|
456
|
+
if (typeof window !== 'undefined') {
|
|
457
|
+
document.addEventListener('DOMContentLoaded', Lego.init);
|
|
458
|
+
window.Lego = Lego;
|
|
459
|
+
}
|
package/main.test.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { JSDOM } from 'jsdom';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
// 1. Setup the DOM environment
|
|
7
|
+
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
|
8
|
+
runScripts: "dangerously",
|
|
9
|
+
resources: "usable"
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
global.window = dom.window;
|
|
13
|
+
global.document = dom.window.document;
|
|
14
|
+
global.navigator = dom.window.navigator;
|
|
15
|
+
global.HTMLElement = dom.window.HTMLElement;
|
|
16
|
+
global.customElements = dom.window.customElements;
|
|
17
|
+
global.MutationObserver = dom.window.MutationObserver;
|
|
18
|
+
global.Node = dom.window.Node;
|
|
19
|
+
global.NodeFilter = dom.window.NodeFilter;
|
|
20
|
+
global.requestAnimationFrame = (cb) => setTimeout(cb, 0);
|
|
21
|
+
|
|
22
|
+
// 2. Load the library code
|
|
23
|
+
// We read it as a string to execute it in our shimmed global environment
|
|
24
|
+
const libCode = fs.readFileSync(path.resolve(__dirname, './main.js'), 'utf8');
|
|
25
|
+
eval(libCode);
|
|
26
|
+
|
|
27
|
+
describe('Lego JS Node Environment Tests', () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
document.body.innerHTML = '';
|
|
30
|
+
// Reset registry if necessary between tests
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should initialize the Lego global object', () => {
|
|
34
|
+
expect(window.Lego).toBeDefined();
|
|
35
|
+
expect(typeof window.Lego.define).toBe('function');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should reactively update text content', async () => {
|
|
39
|
+
window.Lego.define('test-comp', '<span>{{msg}}</span>');
|
|
40
|
+
const el = document.createElement('test-comp');
|
|
41
|
+
el.setAttribute('l-studs', "{ msg: 'hello' }");
|
|
42
|
+
document.body.appendChild(el);
|
|
43
|
+
|
|
44
|
+
// Wait for Lego.init / MutationObserver to fire
|
|
45
|
+
await new Promise(r => setTimeout(r, 50));
|
|
46
|
+
|
|
47
|
+
const span = el.shadowRoot.querySelector('span');
|
|
48
|
+
expect(span.textContent).toBe('hello');
|
|
49
|
+
|
|
50
|
+
// Test reactivity
|
|
51
|
+
el._studs.msg = 'world';
|
|
52
|
+
|
|
53
|
+
// Wait for batcher (requestAnimationFrame shim)
|
|
54
|
+
await new Promise(r => setTimeout(r, 50));
|
|
55
|
+
expect(span.textContent).toBe('world');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should prevent XSS via auto-escaping', async () => {
|
|
59
|
+
window.Lego.define('xss-comp', '<div>{{code}}</div>');
|
|
60
|
+
const el = document.createElement('xss-comp');
|
|
61
|
+
el.setAttribute('l-studs', "{ code: '<script>alert(1)</script>' }");
|
|
62
|
+
document.body.appendChild(el);
|
|
63
|
+
|
|
64
|
+
await new Promise(r => setTimeout(r, 50));
|
|
65
|
+
|
|
66
|
+
const div = el.shadowRoot.querySelector('div');
|
|
67
|
+
// It should be escaped, not raw HTML
|
|
68
|
+
expect(div.innerHTML).toContain('<script>');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should handle @events using the universal binder', async () => {
|
|
72
|
+
const clickSpy = vi.fn();
|
|
73
|
+
window.Lego.define('event-comp', '<button @click="handleClick">Click Me</button>');
|
|
74
|
+
|
|
75
|
+
const el = document.createElement('event-comp');
|
|
76
|
+
el.setAttribute('l-studs', `{ handleClick: () => { window.clicked = true; } }`);
|
|
77
|
+
document.body.appendChild(el);
|
|
78
|
+
|
|
79
|
+
await new Promise(r => setTimeout(r, 50));
|
|
80
|
+
|
|
81
|
+
const btn = el.shadowRoot.querySelector('button');
|
|
82
|
+
btn.click();
|
|
83
|
+
|
|
84
|
+
expect(window.clicked).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
});
|
package/package.json
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lego-dom",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "
|
|
5
|
-
"main": "
|
|
6
|
-
"
|
|
7
|
-
"
|
|
3
|
+
"version": "0.0.5",
|
|
4
|
+
"description": "A feature-rich web components + SFC frontend framework",
|
|
5
|
+
"main": "main.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"keywords": ["framework", "sfc", "components", "lego", "legokit"],
|
|
8
|
+
"author": "Tersoo <ortserga@gmail.com>",
|
|
8
9
|
"scripts": {
|
|
9
|
-
"
|
|
10
|
-
}
|
|
11
|
-
|
|
10
|
+
"test": "vitest run"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"vitest": "^1.0.0",
|
|
14
|
+
"jsdom": "^22.0.0"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT"
|
|
17
|
+
}
|
package/dist/dom/index.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
"use strict";
|
package/dist/index.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
"use strict";
|
package/dist/utils/index.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
"use strict";
|
package/dist/utils/traverser.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
"use strict";
|
package/dist/veyors/basket.js
DELETED
package/dist/veyors/brick.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
"use strict";
|
package/dist/veyors/index.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
"use strict";
|
package/dist/veyors/router.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
File without changes
|
package/example/blocks/card.lego
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
<div id="containerDivOrSomething">
|
|
4
|
-
<div class="card">
|
|
5
|
-
<input name="" onsubmit="doSomething" onchange="doAnotherThing"/>
|
|
6
|
-
|
|
7
|
-
<a href="/banner" id="escapeMe">Escape this link</a>
|
|
8
|
-
|
|
9
|
-
<!-- You can mix and match JS and server rendered code-->
|
|
10
|
-
<p onload="username" >{{username}}</p>
|
|
11
|
-
</div>
|
|
12
|
-
</div>
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
<script>
|
|
16
|
-
const doSomething = function(){};
|
|
17
|
-
const doAnotherThing = function(evt){evt.target.value};
|
|
18
|
-
|
|
19
|
-
Basket.register("containerDivOrSomething").as("SomeFormWizard");
|
|
20
|
-
|
|
21
|
-
const username = Interface.set((element) => {
|
|
22
|
-
element.innerHTML = "some value";
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
const lastName = async (evt) => {
|
|
26
|
-
evt.target.innerHTML = await fetchData()['last_name'];
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
Router.escape("escapeMe");
|
|
30
|
-
Interface.strip("containerDivOrSomething"); //if you don't want the container div to show up in the rendered output
|
|
31
|
-
|
|
32
|
-
let basket = lego.Basket.get('');
|
|
33
|
-
let router = lego.Router.goto('/');
|
|
34
|
-
|
|
35
|
-
let interfaces = lego.Interface.update();
|
|
36
|
-
let connection = lego.Connection.post();
|
|
37
|
-
let keep = lego.Keep.store();
|
|
38
|
-
|
|
39
|
-
container.replace()
|
|
40
|
-
</script>
|
package/example/blocks/form.lego
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
<form method="/lego">
|
|
4
|
-
<div class="control">
|
|
5
|
-
<input name="someInputName" onchange="updateObject"></div>
|
|
6
|
-
</div>
|
|
7
|
-
</form>
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
<script src="unpkg.com/lego/1.0"></script>
|
|
11
|
-
<script>
|
|
12
|
-
const keep = lego.use("keep");
|
|
13
|
-
const basket = lego.use("basket");
|
|
14
|
-
|
|
15
|
-
//putting basket.brick in a block transforms it into a brick i.e. it is no longer naive/fluid and knows where it is expected to render
|
|
16
|
-
//so it is no longer fluid/naive but concrete/inflexible. It is advisable to separate bricks into different pages and load
|
|
17
|
-
//blocks into them but Lego will not get in your way if you
|
|
18
|
-
//prefer to limit blocks with the brick() call.
|
|
19
|
-
//
|
|
20
|
-
//i.e. a brick that loads this block would have been better and can be created by moving this call to a separate file
|
|
21
|
-
basket.brick("/", this);
|
|
22
|
-
|
|
23
|
-
// --> basket will look at /localStorage to get link to lego and load UI otherwise it will clear the screen and show Lego Error...
|
|
24
|
-
|
|
25
|
-
let user = keep.get("User");
|
|
26
|
-
// can also be scoped -> let enterpriseUser = keep.get("User.Type2");
|
|
27
|
-
|
|
28
|
-
let updateObject = (evt) => {
|
|
29
|
-
user.firstName(evt.target.value);
|
|
30
|
-
}
|
|
31
|
-
</script>
|