lego-dom 1.0.0 → 1.3.4

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.
Files changed (74) hide show
  1. package/.legodom +87 -0
  2. package/CHANGELOG.md +87 -3
  3. package/cdn.html +10 -5
  4. package/docs/.vitepress/config.js +23 -7
  5. package/docs/api/config.md +95 -0
  6. package/docs/api/define.md +29 -2
  7. package/docs/api/directives.md +10 -2
  8. package/docs/api/index.md +1 -0
  9. package/docs/contributing/01-welcome.md +2 -0
  10. package/docs/contributing/02-registry.md +37 -3
  11. package/docs/contributing/06-init.md +13 -2
  12. package/docs/contributing/07-observer.md +3 -0
  13. package/docs/contributing/08-snap.md +15 -1
  14. package/docs/contributing/10-studs.md +3 -1
  15. package/docs/contributing/11-scanner.md +13 -0
  16. package/docs/contributing/12-render.md +32 -10
  17. package/docs/contributing/13-directives.md +19 -1
  18. package/docs/contributing/14-events.md +1 -1
  19. package/docs/contributing/15-router.md +49 -1
  20. package/docs/contributing/16-state.md +9 -10
  21. package/docs/contributing/17-legodom.md +1 -8
  22. package/docs/contributing/index.md +23 -4
  23. package/docs/examples/form.md +1 -1
  24. package/docs/examples/index.md +3 -3
  25. package/docs/examples/routing.md +10 -10
  26. package/docs/examples/sfc-showcase.md +1 -1
  27. package/docs/examples/todo-app.md +7 -7
  28. package/docs/guide/cdn-usage.md +44 -18
  29. package/docs/guide/components.md +18 -12
  30. package/docs/guide/directives.md +131 -22
  31. package/docs/guide/directory-structure.md +248 -0
  32. package/docs/guide/faq.md +210 -0
  33. package/docs/guide/getting-started.md +14 -10
  34. package/docs/guide/index.md +1 -1
  35. package/docs/guide/lifecycle.md +32 -0
  36. package/docs/guide/quick-start.md +4 -4
  37. package/docs/guide/reactivity.md +2 -2
  38. package/docs/guide/routing.md +69 -8
  39. package/docs/guide/server-side.md +134 -0
  40. package/docs/guide/sfc.md +96 -13
  41. package/docs/guide/templating.md +62 -57
  42. package/docs/index.md +9 -9
  43. package/docs/router/basic-routing.md +8 -8
  44. package/docs/router/cold-entry.md +2 -2
  45. package/docs/router/history.md +7 -7
  46. package/docs/router/index.md +1 -1
  47. package/docs/router/resolver.md +5 -5
  48. package/docs/router/surgical-swaps.md +5 -5
  49. package/docs/tutorial/01-project-setup.md +152 -0
  50. package/docs/tutorial/02-your-first-component.md +226 -0
  51. package/docs/tutorial/03-adding-routes.md +279 -0
  52. package/docs/tutorial/04-multi-page-app.md +329 -0
  53. package/docs/tutorial/05-state-and-globals.md +285 -0
  54. package/docs/tutorial/index.md +40 -0
  55. package/examples/vite-app/index.html +1 -0
  56. package/examples/vite-app/src/app.js +2 -2
  57. package/examples/vite-app/src/components/side-menu.lego +46 -0
  58. package/examples/vite-app/vite.config.js +2 -1
  59. package/main.js +261 -72
  60. package/main.min.js +7 -0
  61. package/monitoring-plugin.js +111 -0
  62. package/package.json +4 -2
  63. package/parse-lego.js +49 -22
  64. package/tests/error.test.js +74 -0
  65. package/tests/main.test.js +2 -2
  66. package/tests/memory.test.js +68 -0
  67. package/tests/monitoring.test.js +74 -0
  68. package/tests/naming.test.js +74 -0
  69. package/tests/parse-lego.test.js +2 -2
  70. package/tests/security.test.js +67 -0
  71. package/tests/server.test.js +114 -0
  72. package/tests/syntax.test.js +67 -0
  73. package/vite-plugin.js +3 -2
  74. package/docs/guide/contributing.md +0 -32
package/main.js CHANGED
@@ -5,10 +5,31 @@ const Lego = (() => {
5
5
 
6
6
  const sfcLogic = new Map();
7
7
  const sharedStates = new Map();
8
+ const expressionCache = new Map(); // Cache for compiled expressions
8
9
 
9
10
  const styleRegistry = new Map();
10
11
  let styleConfig = {};
11
12
 
13
+ const config = {
14
+ onError: (err, type, el) => {
15
+ console.error(`[Lego Error] [${type}]`, err, el);
16
+ },
17
+ metrics: {}, // Performance hooks
18
+ syntax: 'brackets' // 'brackets' ([[ ]]) or 'mustache' ({{ }})
19
+ };
20
+
21
+ const getDelimiters = () => {
22
+ return config.syntax === 'brackets' ? ['[[', ']]'] : ['{{', '}}'];
23
+ };
24
+
25
+ const createRegex = () => {
26
+ const [start, end] = getDelimiters();
27
+ // Escape special regex characters
28
+ const s = start.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
29
+ const e = end.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
30
+ return new RegExp(`${s}(.*?)${e}`, 'g');
31
+ };
32
+
12
33
  const routes = [];
13
34
 
14
35
  const escapeHTML = (str) => {
@@ -18,6 +39,21 @@ const Lego = (() => {
18
39
  }[m]));
19
40
  };
20
41
 
42
+ const deriveComponentName = (filename) => {
43
+ const basename = filename.split('/').pop().replace(/\.lego$/, '');
44
+ // 1. Convert snake_case to kebab-case
45
+ // 2. Convert PascalCase/camelCase to kebab-case
46
+ const name = basename
47
+ .replace(/_/g, '-')
48
+ .replace(/([a-z])([A-Z])/g, '$1-$2')
49
+ .toLowerCase();
50
+
51
+ if (!name.includes('-')) {
52
+ throw new Error(`[Lego] Invalid component definition: "${filename}". Component names must contain a hyphen (e.g. user-card.lego or UserCard.lego).`);
53
+ }
54
+ return name;
55
+ };
56
+
21
57
  /**
22
58
  * Enterprise Target Resolver
23
59
  * Resolves strings (#id, tag-name) or functions into DOM Elements.
@@ -168,20 +204,37 @@ const Lego = (() => {
168
204
  return undefined;
169
205
  };
170
206
 
171
- const safeEval = (expr, context) => {
207
+ const safeEval = (expr, context, throwError = false) => {
208
+ // 1. Security: Block dangerous patterns
209
+ if (/\b(function|eval|import|class|module|deploy|constructor|__proto__)\b/.test(expr)) {
210
+ console.warn(`[Lego] Security Warning: Blocked dangerous expression "${expr}"`);
211
+ return undefined;
212
+ }
213
+
172
214
  try {
173
215
  const scope = context.state || {};
174
216
 
217
+ // 2. Performance: Check cache
218
+ // We purposefully cache based on the expression string alone.
219
+ // The 'helpers' and 'state' are passed dynamically to the cached function.
220
+ let func = expressionCache.get(expr);
221
+ if (!func) {
222
+ // Removed inner try-catch to allow error propagation to the caller
223
+ func = new Function('global', 'self', 'event', 'helpers', `
224
+ with(helpers) {
225
+ with(this) {
226
+ return ${expr}
227
+ }
228
+ }
229
+ `);
230
+ expressionCache.set(expr, func);
231
+ }
232
+
175
233
  const helpers = {
176
234
  $ancestors: (tag) => findAncestorState(context.self, tag),
177
235
  $registry: (tag) => sharedStates.get(tag.toLowerCase()),
178
236
  $element: context.self,
179
237
  $route: Lego.globals.$route,
180
- /**
181
- * The $go helper for surgical routing
182
- * @param {string} path - URL to navigate to
183
- * @param {...(string|function)} targets - Specific components or IDs to swap
184
- */
185
238
  $go: (path, ...targets) => _go(path, ...targets)(context.self),
186
239
  $emit: (name, detail) => {
187
240
  context.self.dispatchEvent(new CustomEvent(name, {
@@ -192,18 +245,12 @@ const Lego = (() => {
192
245
  }
193
246
  };
194
247
 
195
- const func = new Function('global', 'self', 'event', 'helpers', `
196
- with(helpers) {
197
- with(this) {
198
- try { return ${expr} } catch(e) { return undefined; }
199
- }
200
- }
201
- `);
202
-
203
248
  const result = func.call(scope, context.global, context.self, context.event, helpers);
204
249
  if (typeof result === 'function') return result.call(scope, context.event);
205
250
  return result;
206
251
  } catch (e) {
252
+ if (throwError) throw e;
253
+ config.onError(e, 'render-error', context.self);
207
254
  return undefined;
208
255
  }
209
256
  };
@@ -219,51 +266,76 @@ const Lego = (() => {
219
266
 
220
267
  const bind = (container, componentRoot, loopCtx = null) => {
221
268
  const state = componentRoot._studs;
222
- const elements = container instanceof Element ? [container, ...container.querySelectorAll('*')] : container.querySelectorAll('*');
223
269
 
224
- elements.forEach(child => {
270
+ const bindNode = (child) => {
225
271
  const childData = getPrivateData(child);
226
272
  if (childData.bound) return;
227
273
 
228
- [...child.attributes].forEach(attr => {
229
- if (attr.name.startsWith('@')) {
230
- const eventName = attr.name.slice(1);
231
- child.addEventListener(eventName, (event) => {
232
- let evalScope = state;
233
- if (loopCtx) {
234
- const list = safeEval(loopCtx.listName, { state, global: Lego.globals, self: componentRoot });
235
- const item = list[loopCtx.index];
236
- evalScope = Object.assign(Object.create(state), { [loopCtx.name]: item });
274
+ if (child.hasAttributes()) {
275
+ const attrs = child.attributes;
276
+ for (let i = 0; i < attrs.length; i++) {
277
+ const attr = attrs[i];
278
+ if (attr.name.startsWith('@')) {
279
+ const eventName = attr.name.slice(1);
280
+ child.addEventListener(eventName, (event) => {
281
+ try {
282
+ let evalScope = state;
283
+ if (loopCtx) {
284
+ const list = safeEval(loopCtx.listName, { state, global: Lego.globals, self: componentRoot });
285
+ const item = list[loopCtx.index];
286
+ evalScope = Object.assign(Object.create(state), { [loopCtx.name]: item });
287
+ }
288
+ safeEval(attr.value, { state: evalScope, global: Lego.globals, self: child, event }, true);
289
+ } catch (err) {
290
+ config.onError(err, 'event-handler', child);
291
+ }
292
+ });
293
+ }
294
+ }
295
+
296
+ if (child.hasAttribute('b-sync')) {
297
+ const prop = child.getAttribute('b-sync');
298
+ const updateState = () => {
299
+ try {
300
+ let target, last;
301
+ if (loopCtx && prop.startsWith(loopCtx.name + '.')) {
302
+ const list = safeEval(loopCtx.listName, { state, global: Lego.globals, self: componentRoot });
303
+ const item = list[loopCtx.index];
304
+ if (!item) return;
305
+ const subPath = prop.split('.').slice(1);
306
+ last = subPath.pop();
307
+ target = subPath.reduce((o, k) => o[k], item);
308
+ } else {
309
+ const keys = prop.split('.');
310
+ last = keys.pop();
311
+ target = keys.reduce((o, k) => o[k], state);
312
+ }
313
+ const newVal = child.type === 'checkbox' ? child.checked : child.value;
314
+ if (target && target[last] !== newVal) target[last] = newVal;
315
+ } catch (err) {
316
+ config.onError(err, 'sync-update', child);
237
317
  }
238
- safeEval(attr.value, { state: evalScope, global: Lego.globals, self: child, event });
239
- });
318
+ };
319
+ child.addEventListener('input', updateState);
320
+ child.addEventListener('change', updateState);
240
321
  }
241
- });
242
322
 
243
- if (child.hasAttribute('b-sync')) {
244
- const prop = child.getAttribute('b-sync');
245
- const updateState = () => {
246
- let target, last;
247
- if (loopCtx && prop.startsWith(loopCtx.name + '.')) {
248
- const list = safeEval(loopCtx.listName, { state, global: Lego.globals, self: componentRoot });
249
- const item = list[loopCtx.index];
250
- if (!item) return;
251
- const subPath = prop.split('.').slice(1);
252
- last = subPath.pop();
253
- target = subPath.reduce((o, k) => o[k], item);
254
- } else {
255
- const keys = prop.split('.');
256
- last = keys.pop();
257
- target = keys.reduce((o, k) => o[k], state);
323
+ // b-var: Register element reference
324
+ if (child.hasAttribute('b-var')) {
325
+ const varName = child.getAttribute('b-var');
326
+ if (state.$vars) {
327
+ state.$vars[varName] = child;
258
328
  }
259
- const newVal = child.type === 'checkbox' ? child.checked : child.value;
260
- if (target && target[last] !== newVal) target[last] = newVal;
261
- };
262
- child.addEventListener('input', updateState);
263
- child.addEventListener('change', updateState);
329
+ }
264
330
  }
265
331
  childData.bound = true;
266
- });
332
+ };
333
+
334
+ if (container instanceof Element) bindNode(container);
335
+
336
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT);
337
+ let child;
338
+ while (child = walker.nextNode()) bindNode(child);
267
339
  };
268
340
 
269
341
  const scanForBindings = (container) => {
@@ -307,7 +379,7 @@ const Lego = (() => {
307
379
  bindings.push({ type: 'b-show', node, expr });
308
380
  }
309
381
  if (node.hasAttribute('b-for')) {
310
- const match = node.getAttribute('b-for').match(/^\s*(\w+)\s+in\s+(.+)\s*$/);
382
+ const match = node.getAttribute('b-for').match(/^\s*(\w+)\s+in\s+([\s\S]+?)\s*$/);
311
383
  if (match) {
312
384
  checkGlobal(match[2]);
313
385
  bindings.push({
@@ -315,22 +387,31 @@ const Lego = (() => {
315
387
  node,
316
388
  itemName: match[1],
317
389
  listName: match[2].trim(),
318
- template: node.innerHTML
390
+ template: node.cloneNode(true) // Store the whole node as template
319
391
  });
320
392
  node.innerHTML = '';
321
393
  }
322
394
  }
323
395
  if (node.hasAttribute('b-text')) bindings.push({ type: 'b-text', node, path: node.getAttribute('b-text') });
396
+ if (node.hasAttribute('b-html')) {
397
+ const expr = node.getAttribute('b-html');
398
+ checkGlobal(expr);
399
+ bindings.push({ type: 'b-html', node, expr });
400
+ }
324
401
  if (node.hasAttribute('b-sync')) bindings.push({ type: 'b-sync', node });
402
+ const [start] = getDelimiters();
325
403
  [...node.attributes].forEach(attr => {
326
- if (attr.value.includes('{{')) {
404
+ if (attr.value.includes(start)) {
327
405
  checkGlobal(attr.value);
328
406
  bindings.push({ type: 'attr', node, attrName: attr.name, template: attr.value });
329
407
  }
330
408
  });
331
- } else if (node.nodeType === Node.TEXT_NODE && node.textContent.includes('{{')) {
332
- checkGlobal(node.textContent);
333
- bindings.push({ type: 'text', node, template: node.textContent });
409
+ } else if (node.nodeType === Node.TEXT_NODE) {
410
+ const [start] = getDelimiters();
411
+ if (node.textContent.includes(start)) {
412
+ checkGlobal(node.textContent);
413
+ bindings.push({ type: 'text', node, template: node.textContent });
414
+ }
334
415
  }
335
416
  }
336
417
  return bindings;
@@ -340,13 +421,14 @@ const Lego = (() => {
340
421
  const processNode = (node) => {
341
422
  if (node.nodeType === Node.TEXT_NODE) {
342
423
  if (node._tpl === undefined) node._tpl = node.textContent;
343
- const out = node._tpl.replace(/{{(.*?)}}/g, (_, k) => safeEval(k.trim(), { state: scope, global: Lego.globals, self: node }) ?? '');
424
+ const out = node._tpl.replace(createRegex(), (_, k) => safeEval(k.trim(), { state: scope, global: Lego.globals, self: node }) ?? '');
344
425
  if (node.textContent !== out) node.textContent = out;
345
426
  } else if (node.nodeType === Node.ELEMENT_NODE) {
427
+ const [start] = getDelimiters();
346
428
  [...node.attributes].forEach(attr => {
347
429
  if (attr._tpl === undefined) attr._tpl = attr.value;
348
- if (attr._tpl.includes('{{')) {
349
- const out = attr._tpl.replace(/{{(.*?)}}/g, (_, k) => safeEval(k.trim(), { state: scope, global: Lego.globals, self: node }) ?? '');
430
+ if (attr._tpl.includes(start)) {
431
+ const out = attr._tpl.replace(createRegex(), (_, k) => safeEval(k.trim(), { state: scope, global: Lego.globals, self: node }) ?? '');
350
432
  if (attr.value !== out) {
351
433
  attr.value = out;
352
434
  if (attr.name === 'class') node.className = out;
@@ -367,6 +449,7 @@ const Lego = (() => {
367
449
  const data = getPrivateData(el);
368
450
  if (data.rendering) return;
369
451
  data.rendering = true;
452
+ if (config.metrics && config.metrics.onRenderStart) config.metrics.onRenderStart(el);
370
453
 
371
454
  try {
372
455
  // Use shadowRoot if it's a component, otherwise render the element itself (light DOM)
@@ -386,13 +469,14 @@ const Lego = (() => {
386
469
  }
387
470
  if (b.type === 'b-show') b.node.style.display = safeEval(b.expr, { state, global: Lego.globals, self: b.node }) ? '' : 'none';
388
471
  if (b.type === 'b-text') b.node.textContent = resolve(b.path, state);
472
+ if (b.type === 'b-html') b.node.innerHTML = safeEval(b.expr, { state, global: Lego.globals, self: b.node }) || '';
389
473
  if (b.type === 'b-sync') syncModelValue(b.node, resolve(b.node.getAttribute('b-sync'), state));
390
474
  if (b.type === 'text') {
391
- const out = b.template.replace(/{{(.*?)}}/g, (_, k) => safeEval(k.trim(), { state, global: Lego.globals, self: b.node }) ?? '');
475
+ const out = b.template.replace(createRegex(), (_, k) => safeEval(k.trim(), { state, global: Lego.globals, self: b.node }) ?? '');
392
476
  if (b.node.textContent !== out) b.node.textContent = out;
393
477
  }
394
478
  if (b.type === 'attr') {
395
- const out = b.template.replace(/{{(.*?)}}/g, (_, k) => safeEval(k.trim(), { state, global: Lego.globals, self: b.node }) ?? '');
479
+ const out = b.template.replace(createRegex(), (_, k) => safeEval(k.trim(), { state, global: Lego.globals, self: b.node }) ?? '');
396
480
  if (b.node.getAttribute(b.attrName) !== out) {
397
481
  b.node.setAttribute(b.attrName, out);
398
482
  if (b.attrName === 'class') b.node.className = out;
@@ -408,9 +492,8 @@ const Lego = (() => {
408
492
  currentKeys.add(key);
409
493
  let child = pool.get(key);
410
494
  if (!child) {
411
- const temp = document.createElement('div');
412
- temp.innerHTML = b.template;
413
- child = temp.firstElementChild;
495
+ child = b.template.cloneNode(true);
496
+ child.removeAttribute('b-for');
414
497
  pool.set(key, child);
415
498
  bind(child, el, { name: b.itemName, listName: b.listName, index: i });
416
499
  }
@@ -438,7 +521,10 @@ const Lego = (() => {
438
521
  if (getPrivateData(comp).hasGlobalDependency) render(comp);
439
522
  });
440
523
  }
524
+ } catch (err) {
525
+ config.onError(err, 'render', el);
441
526
  } finally {
527
+ if (config.metrics && config.metrics.onRenderEnd) config.metrics.onRenderEnd(el);
442
528
  data.rendering = false;
443
529
  }
444
530
  };
@@ -469,11 +555,15 @@ const Lego = (() => {
469
555
  const templateLogic = parseJSObject(templateNode.getAttribute('b-data') || '{}');
470
556
  const instanceLogic = parseJSObject(el.getAttribute('b-data') || '{}');
471
557
 
472
- // Priority: Script < Template < Instance
473
558
  el._studs = reactive({
474
559
  ...scriptLogic,
475
560
  ...templateLogic,
476
561
  ...instanceLogic,
562
+ $vars: {},
563
+ $element: el,
564
+ $emit: (name, detail) => {
565
+ el.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
566
+ },
477
567
  get $route() { return Lego.globals.$route },
478
568
  get $go() { return Lego.globals.$go }
479
569
  }, el);
@@ -489,8 +579,10 @@ const Lego = (() => {
489
579
  activeComponents.add(el);
490
580
  render(el);
491
581
 
582
+ [...shadow.children].forEach(snap);
583
+
492
584
  if (typeof el._studs.mounted === 'function') {
493
- try { el._studs.mounted.call(el._studs); } catch (e) { console.error(`[Lego] Error in mounted <${name}>:`, e); }
585
+ try { el._studs.mounted.call(el._studs); } catch (e) { config.onError(e, 'mounted', el); }
494
586
  }
495
587
  }
496
588
 
@@ -505,6 +597,11 @@ const Lego = (() => {
505
597
  if (el._studs && typeof el._studs.unmounted === 'function') {
506
598
  try { el._studs.unmounted.call(el._studs); } catch (e) { console.error(`[Lego] Error in unmounted:`, e); }
507
599
  }
600
+
601
+ if (el.shadowRoot) {
602
+ [...el.shadowRoot.children].forEach(unsnap);
603
+ }
604
+
508
605
  activeComponents.delete(el);
509
606
  [...el.children].forEach(unsnap);
510
607
  };
@@ -551,14 +648,14 @@ const Lego = (() => {
551
648
  });
552
649
  };
553
650
 
554
- return {
555
- init: async (root = document.body, styles = {}) => {
556
- // If called as an event listener or with invalid root, fail over to document.body
651
+ const publicAPI = {
652
+ init: async (root = document.body, options = {}) => {
557
653
  if (!root || typeof root.nodeType !== 'number') root = document.body;
558
- styleConfig = styles;
654
+ styleConfig = options.styles || {};
655
+ config.loader = options.loader; // Register loader hook
559
656
 
560
657
  // Pre-load all defined style sets into Constructable Stylesheets
561
- const loadPromises = Object.entries(styles).map(async ([key, urls]) => {
658
+ const loadPromises = Object.entries(styleConfig).map(async ([key, urls]) => {
562
659
  const sheets = await Promise.all(urls.map(async (url) => {
563
660
  try {
564
661
  const response = await fetch(url);
@@ -580,7 +677,26 @@ const Lego = (() => {
580
677
  });
581
678
 
582
679
  const observer = new MutationObserver(m => m.forEach(r => {
583
- r.addedNodes.forEach(n => n.nodeType === Node.ELEMENT_NODE && snap(n));
680
+ r.addedNodes.forEach(n => {
681
+ if (n.nodeType === Node.ELEMENT_NODE) {
682
+ snap(n);
683
+ // Auto-Discovery: Check if tag is unknown and loader is configured
684
+ const tagName = n.tagName.toLowerCase();
685
+ if (tagName.includes('-') && !registry[tagName] && config.loader && !activeComponents.has(n)) {
686
+ const result = config.loader(tagName);
687
+ if (result) {
688
+ // Handle Promise (user does custom fetch) vs String (we fetch)
689
+ const promise = (typeof result === 'string')
690
+ ? fetch(result).then(r => r.text())
691
+ : result;
692
+
693
+ Promise.resolve(promise)
694
+ .then(sfc => publicAPI.defineSFC(sfc, tagName + '.lego'))
695
+ .catch(e => console.error(`[Lego] Failed to load ${tagName}:`, e));
696
+ }
697
+ }
698
+ }
699
+ });
584
700
  r.removedNodes.forEach(n => n.nodeType === Node.ELEMENT_NODE && unsnap(n));
585
701
  }));
586
702
  observer.observe(root, { childList: true, subtree: true });
@@ -628,6 +744,69 @@ const Lego = (() => {
628
744
  },
629
745
  $go: (path, ...targets) => _go(path, ...targets)(document.body)
630
746
  }, document.body),
747
+ defineSFC: (content, filename = 'component.lego') => {
748
+ let template = '';
749
+ let script = '{}';
750
+ let stylesAttr = '';
751
+ let style = '';
752
+
753
+ let remaining = content;
754
+ // Robust regex from parse-lego.js
755
+ const startTagRegex = /<(template|script|style)\b((?:\s+(?:[^>"']|"[^"]*"|'[^']*')*)*)>/i;
756
+
757
+ while (remaining) {
758
+ const match = remaining.match(startTagRegex);
759
+ if (!match) break;
760
+
761
+ const tagName = match[1].toLowerCase();
762
+ const attrs = match[2];
763
+ const fullMatch = match[0];
764
+ const startIndex = match.index;
765
+
766
+ const closeTag = `</${tagName}>`;
767
+ const contentStart = startIndex + fullMatch.length;
768
+ const contentEnd = remaining.indexOf(closeTag, contentStart);
769
+
770
+ if (contentEnd === -1) {
771
+ console.warn(`[Lego] Unclosed <${tagName}> tag in ${filename}`);
772
+ break;
773
+ }
774
+
775
+ const innerContent = remaining.slice(contentStart, contentEnd);
776
+
777
+ if (tagName === 'template') {
778
+ template = innerContent.trim();
779
+ const bStylesMatch = attrs.match(/b-styles=["']([^"']+)["']/);
780
+ if (bStylesMatch) stylesAttr = bStylesMatch[1];
781
+ } else if (tagName === 'script') {
782
+ // Extract logic from script
783
+ const logic = innerContent.trim();
784
+ const defaultExport = logic.match(/export\s+default\s+({[\s\S]*})/);
785
+ script = defaultExport ? defaultExport[1] : logic;
786
+ } else if (tagName === 'style') {
787
+ style = innerContent.trim();
788
+ }
789
+
790
+ remaining = remaining.slice(contentEnd + closeTag.length);
791
+ }
792
+
793
+ const name = deriveComponentName(filename);
794
+ // We must eval the script to get the object.
795
+ // Safe-ish because it's coming from the "Server" (trusted source in this architecture)
796
+ const logicObj = new Function(`return ${script}`)();
797
+
798
+ if (style) {
799
+ template = `<style>${style}</style>` + template;
800
+ }
801
+
802
+ registry[name] = document.createElement('template');
803
+ registry[name].innerHTML = template;
804
+ registry[name].setAttribute('b-styles', stylesAttr);
805
+ sfcLogic.set(name, logicObj);
806
+
807
+ // Upgrade existing elements
808
+ document.querySelectorAll(name).forEach(el => !getPrivateData(el).snapped && snap(el));
809
+ },
631
810
  define: (tagName, templateHTML, logic = {}, styles = "") => {
632
811
  const t = document.createElement('template');
633
812
  t.setAttribute('b-id', tagName);
@@ -636,10 +815,18 @@ const Lego = (() => {
636
815
  registry[tagName] = t;
637
816
  sfcLogic.set(tagName, logic);
638
817
 
639
- sharedStates.set(tagName.toLowerCase(), reactive({ ...logic }, document.body));
818
+ // Initialize shared state with try-catch safety
819
+ try {
820
+ sharedStates.set(tagName.toLowerCase(), reactive({ ...logic }, document.body));
821
+ } catch (e) {
822
+ config.onError(e, 'define', tagName);
823
+ }
640
824
 
641
825
  document.querySelectorAll(tagName).forEach(snap);
642
826
  },
827
+ // For specific test validation
828
+ getActiveComponentsCount: () => activeComponents.size,
829
+ config, // Expose config for customization
643
830
  route: (path, tagName, middleware = null) => {
644
831
  const paramNames = [];
645
832
  const regexPath = path.replace(/:([^\/]+)/g, (_, name) => {
@@ -649,6 +836,8 @@ const Lego = (() => {
649
836
  routes.push({ path, regex: new RegExp(`^${regexPath}$`), tagName, paramNames, middleware });
650
837
  }
651
838
  };
839
+
840
+ return publicAPI;
652
841
  })();
653
842
 
654
843
  if (typeof window !== 'undefined') {
package/main.min.js ADDED
@@ -0,0 +1,7 @@
1
+ const Lego=(()=>{const w={},M=new WeakMap,k=new WeakMap,O=new WeakMap,$=new Set,P=new Map,B=new Map,H=new Map,q=new Map;let G={};const p={onError:(e,s,c)=>{console.error(`[Lego Error] [${s}]`,e,c)},metrics:{},syntax:"brackets"},_=()=>p.syntax==="brackets"?["[[","]]"]:["{{","}}"],L=()=>{const[e,s]=_(),c=e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),o=s.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");return new RegExp(`${c}(.*?)${o}`,"g")},R=[],ee=e=>typeof e!="string"?e:e.replace(/[&<>"']/g,s=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[s]),K=e=>{const c=e.split("/").pop().replace(/\.lego$/,"").replace(/_/g,"-").replace(/([a-z])([A-Z])/g,"$1-$2").toLowerCase();if(!c.includes("-"))throw new Error(`[Lego] Invalid component definition: "${e}". Component names must contain a hyphen (e.g. user-card.lego or UserCard.lego).`);return c},V=(e,s)=>{if(typeof e=="function"){const o=Array.from(document.querySelectorAll("*")).filter(t=>t.tagName.includes("-"));return[].concat(e(o))}if(e.startsWith("#")){const o=document.getElementById(e.slice(1));return o?[o]:[]}const c=s?.querySelectorAll(e)||[];return c.length>0?[...c]:[...document.querySelectorAll(e)]},j=(e,...s)=>c=>{const o=async(t,a=null,r=!0,n={})=>{if(r){const d={legoTargets:s.filter(i=>typeof i=="string"),method:t,body:a};history.pushState(d,"",e)}await F(s.length?s:null,c)};return{get:(t=!0,a={})=>o("GET",null,t,a),post:(t,a=!0,r={})=>o("POST",t,a,r),put:(t,a=!0,r={})=>o("PUT",t,a,r),patch:(t,a=!0,r={})=>o("PATCH",t,a,r),delete:(t=!0,a={})=>o("DELETE",null,t,a)}},J=(()=>{let e=!1;const s=new Set;let c=!1;return{add:o=>{!o||c||(s.add(o),!e&&(e=!0,requestAnimationFrame(()=>{c=!0;const t=Array.from(s);s.clear(),e=!1,t.forEach(a=>C(a)),setTimeout(()=>{t.forEach(a=>{const r=a._studs;if(r&&typeof r.updated=="function")try{r.updated.call(r)}catch(n){console.error("[Lego] Error in updated hook:",n)}}),c=!1},0)})))}}})(),S=(e,s,c=J)=>{if(e===null||typeof e!="object"||e instanceof Node)return e;if(M.has(e))return M.get(e);const o={get:(a,r)=>{const n=Reflect.get(a,r);return n!==null&&typeof n=="object"&&!(n instanceof Node)?S(n,s,c):n},set:(a,r,n)=>{const l=a[r],d=Reflect.set(a,r,n);return l!==n&&c.add(s),d},deleteProperty:(a,r)=>{const n=Reflect.deleteProperty(a,r);return c.add(s),n}},t=new Proxy(e,o);return M.set(e,t),t},I=e=>{try{return new Function(`return (${e})`)()}catch(s){return console.error("[Lego] Failed to parse b-data:",e,s),{}}},N=e=>(k.has(e)||k.set(e,{snapped:!1,bindings:null,bound:!1,rendering:!1,anchor:null,hasGlobalDependency:!1}),k.get(e)),D=(e,s)=>{if(!e)return"";const c=e.trim().split(".");let o=s;for(const t of c){if(o==null)return"";o=o[t]}return o??""},Z=(e,s)=>{let c=e.parentElement||e.getRootNode().host;for(;c;){if(c.tagName&&c.tagName.toLowerCase()===s.toLowerCase())return c._studs;c=c.parentElement||c.getRootNode&&c.getRootNode().host}},h=(e,s,c=!1)=>{if(/\b(function|eval|import|class|module|deploy|constructor|__proto__)\b/.test(e)){console.warn(`[Lego] Security Warning: Blocked dangerous expression "${e}"`);return}try{const o=s.state||{};let t=H.get(e);t||(t=new Function("global","self","event","helpers",`
2
+ with(helpers) {
3
+ with(this) {
4
+ return ${e}
5
+ }
6
+ }
7
+ `),H.set(e,t));const a={$ancestors:n=>Z(s.self,n),$registry:n=>B.get(n.toLowerCase()),$element:s.self,$route:Lego.globals.$route,$go:(n,...l)=>j(n,...l)(s.self),$emit:(n,l)=>{s.self.dispatchEvent(new CustomEvent(n,{detail:l,bubbles:!0,composed:!0}))}},r=t.call(o,s.global,s.self,s.event,a);return typeof r=="function"?r.call(o,s.event):r}catch(o){if(c)throw o;p.onError(o,"render-error",s.self);return}},z=(e,s)=>{if(e.type==="checkbox")e.checked!==!!s&&(e.checked=!!s);else{const c=s==null?"":String(s);e.value!==c&&(e.value=c)}},x=(e,s,c=null)=>{const o=s._studs,t=n=>{const l=N(n);if(!l.bound){if(n.hasAttributes()){const d=n.attributes;for(let i=0;i<d.length;i++){const u=d[i];if(u.name.startsWith("@")){const f=u.name.slice(1);n.addEventListener(f,g=>{try{let m=o;if(c){const y=h(c.listName,{state:o,global:Lego.globals,self:s})[c.index];m=Object.assign(Object.create(o),{[c.name]:y})}h(u.value,{state:m,global:Lego.globals,self:n,event:g},!0)}catch(m){p.onError(m,"event-handler",n)}})}}if(n.hasAttribute("b-sync")){const i=n.getAttribute("b-sync"),u=()=>{try{let f,g;if(c&&i.startsWith(c.name+".")){const y=h(c.listName,{state:o,global:Lego.globals,self:s})[c.index];if(!y)return;const E=i.split(".").slice(1);g=E.pop(),f=E.reduce((T,A)=>T[A],y)}else{const b=i.split(".");g=b.pop(),f=b.reduce((y,E)=>y[E],o)}const m=n.type==="checkbox"?n.checked:n.value;f&&f[g]!==m&&(f[g]=m)}catch(f){p.onError(f,"sync-update",n)}};n.addEventListener("input",u),n.addEventListener("change",u)}if(n.hasAttribute("b-var")){const i=n.getAttribute("b-var");o.$vars&&(o.$vars[i]=n)}}l.bound=!0}};e instanceof Element&&t(e);const a=document.createTreeWalker(e,NodeFilter.SHOW_ELEMENT);let r;for(;r=a.nextNode();)t(r)},Y=e=>{const s=[],c=document.createTreeWalker(e,NodeFilter.SHOW_ELEMENT|NodeFilter.SHOW_TEXT);let o;for(;o=c.nextNode();){if((r=>{let n=r.parentNode;for(;n&&n!==e;){if(n.hasAttribute&&n.hasAttribute("b-for"))return!0;n=n.parentNode}return!1})(o))continue;const a=r=>{if(/\bglobal\b/.test(r)){const n=e.host||e;N(n).hasGlobalDependency=!0}};if(o.nodeType===Node.ELEMENT_NODE){if(o.hasAttribute("b-if")){const n=o.getAttribute("b-if");a(n);const l=document.createComment(`b-if: ${n}`),d=N(o);d.anchor=l,s.push({type:"b-if",node:o,anchor:l,expr:n})}if(o.hasAttribute("b-show")){const n=o.getAttribute("b-show");a(n),s.push({type:"b-show",node:o,expr:n})}if(o.hasAttribute("b-for")){const n=o.getAttribute("b-for").match(/^\s*(\w+)\s+in\s+([\s\S]+?)\s*$/);n&&(a(n[2]),s.push({type:"b-for",node:o,itemName:n[1],listName:n[2].trim(),template:o.cloneNode(!0)}),o.innerHTML="")}if(o.hasAttribute("b-text")&&s.push({type:"b-text",node:o,path:o.getAttribute("b-text")}),o.hasAttribute("b-html")){const n=o.getAttribute("b-html");a(n),s.push({type:"b-html",node:o,expr:n})}o.hasAttribute("b-sync")&&s.push({type:"b-sync",node:o});const[r]=_();[...o.attributes].forEach(n=>{n.value.includes(r)&&(a(n.value),s.push({type:"attr",node:o,attrName:n.name,template:n.value}))})}else if(o.nodeType===Node.TEXT_NODE){const[r]=_();o.textContent.includes(r)&&(a(o.textContent),s.push({type:"text",node:o,template:o.textContent}))}}return s},Q=(e,s)=>{const c=a=>{if(a.nodeType===Node.TEXT_NODE){a._tpl===void 0&&(a._tpl=a.textContent);const r=a._tpl.replace(L(),(n,l)=>h(l.trim(),{state:s,global:Lego.globals,self:a})??"");a.textContent!==r&&(a.textContent=r)}else if(a.nodeType===Node.ELEMENT_NODE){const[r]=_();[...a.attributes].forEach(n=>{if(n._tpl===void 0&&(n._tpl=n.value),n._tpl.includes(r)){const l=n._tpl.replace(L(),(d,i)=>h(i.trim(),{state:s,global:Lego.globals,self:a})??"");n.value!==l&&(n.value=l,n.name==="class"&&(a.className=l))}})}};c(e);const o=document.createTreeWalker(e,NodeFilter.SHOW_ELEMENT|NodeFilter.SHOW_TEXT);let t;for(;t=o.nextNode();)c(t)},C=e=>{const s=e._studs;if(!s)return;const c=N(e);if(!c.rendering){c.rendering=!0,p.metrics&&p.metrics.onRenderStart&&p.metrics.onRenderStart(e);try{const o=e.shadowRoot||e;c.bindings||(c.bindings=Y(o)),c.bindings.forEach(t=>{if(t.type==="b-if"){const a=!!h(t.expr,{state:s,global:Lego.globals,self:t.node}),r=!!t.node.parentNode;a&&!r?t.anchor.parentNode&&t.anchor.parentNode.replaceChild(t.node,t.anchor):!a&&r&&t.node.parentNode.replaceChild(t.anchor,t.node)}if(t.type==="b-show"&&(t.node.style.display=h(t.expr,{state:s,global:Lego.globals,self:t.node})?"":"none"),t.type==="b-text"&&(t.node.textContent=D(t.path,s)),t.type==="b-html"&&(t.node.innerHTML=h(t.expr,{state:s,global:Lego.globals,self:t.node})||""),t.type==="b-sync"&&z(t.node,D(t.node.getAttribute("b-sync"),s)),t.type==="text"){const a=t.template.replace(L(),(r,n)=>h(n.trim(),{state:s,global:Lego.globals,self:t.node})??"");t.node.textContent!==a&&(t.node.textContent=a)}if(t.type==="attr"){const a=t.template.replace(L(),(r,n)=>h(n.trim(),{state:s,global:Lego.globals,self:t.node})??"");t.node.getAttribute(t.attrName)!==a&&(t.node.setAttribute(t.attrName,a),t.attrName==="class"&&(t.node.className=a))}if(t.type==="b-for"){const a=h(t.listName,{state:s,global:Lego.globals,self:e})||[];O.has(t.node)||O.set(t.node,new Map);const r=O.get(t.node),n=new Set;a.forEach((l,d)=>{const i=l&&typeof l=="object"?l.__id||(l.__id=Math.random()):`${d}-${l}`;n.add(i);let u=r.get(i);u||(u=t.template.cloneNode(!0),u.removeAttribute("b-for"),r.set(i,u),x(u,e,{name:t.itemName,listName:t.listName,index:d}));const f=Object.assign(Object.create(s),{[t.itemName]:l});Q(u,f),u.querySelectorAll("[b-sync]").forEach(g=>{const m=g.getAttribute("b-sync");if(m.startsWith(t.itemName+".")){const b=h(t.listName,{state:s,global:Lego.globals,self:e});z(g,D(m.split(".").slice(1).join("."),b[d]))}}),t.node.children[d]!==u&&t.node.insertBefore(u,t.node.children[d]||null)});for(const[l,d]of r.entries())n.has(l)||(d.remove(),r.delete(l))}}),s===Lego.globals&&$.forEach(t=>{N(t).hasGlobalDependency&&C(t)})}catch(o){p.onError(o,"render",e)}finally{p.metrics&&p.metrics.onRenderEnd&&p.metrics.onRenderEnd(e),c.rendering=!1}}},v=e=>{if(!e||e.nodeType!==Node.ELEMENT_NODE)return;const s=N(e),c=e.tagName.toLowerCase(),o=w[c];if(o&&!s.snapped){s.snapped=!0;const a=o.content.cloneNode(!0),r=e.attachShadow({mode:"open"}),n=(o.getAttribute("b-styles")||"").split(/\s+/).filter(Boolean);if(n.length>0){const f=n.flatMap(g=>q.get(g)||[]);f.length>0&&(r.adoptedStyleSheets=[...f])}const l=P.get(c)||{},d=I(o.getAttribute("b-data")||"{}"),i=I(e.getAttribute("b-data")||"{}");e._studs=S({...l,...d,...i,$vars:{},$element:e,$emit:(f,g)=>{e.dispatchEvent(new CustomEvent(f,{detail:g,bubbles:!0,composed:!0}))},get $route(){return Lego.globals.$route},get $go(){return Lego.globals.$go}},e),r.appendChild(a);const u=r.querySelector("style");if(u&&(u.textContent=u.textContent.replace(/\bself\b/g,":host")),x(r,e),$.add(e),C(e),[...r.children].forEach(v),typeof e._studs.mounted=="function")try{e._studs.mounted.call(e._studs)}catch(f){p.onError(f,"mounted",e)}}let t=e.parentElement;for(;t&&!t._studs;)t=t.parentElement;t&&t._studs&&x(e,t),[...e.children].forEach(v)},W=e=>{if(e._studs&&typeof e._studs.unmounted=="function")try{e._studs.unmounted.call(e._studs)}catch(s){console.error("[Lego] Error in unmounted:",s)}e.shadowRoot&&[...e.shadowRoot.children].forEach(W),$.delete(e),[...e.children].forEach(W)},F=async(e=null,s=null)=>{const c=window.location.pathname,o=window.location.search,t=R.find(d=>d.regex.test(c));if(!t)return;let a=[];if(e)a=e.flatMap(d=>V(d,s));else{const d=document.querySelector("lego-router");d&&(a=[d])}if(a.length===0)return;const r=c.match(t.regex).slice(1),n=Object.fromEntries(t.paramNames.map((d,i)=>[d,r[i]])),l=Object.fromEntries(new URLSearchParams(o));t.middleware&&!await t.middleware(n,Lego.globals)||(Lego.globals.$route.url=c+o,Lego.globals.$route.route=t.path,Lego.globals.$route.params=n,Lego.globals.$route.query=l,Lego.globals.$route.method=history.state?.method||"GET",Lego.globals.$route.body=history.state?.body||null,a.forEach(d=>{if(d){const i=document.createElement(t.tagName);d.replaceChildren(i)}}))},U={init:async(e=document.body,s={})=>{(!e||typeof e.nodeType!="number")&&(e=document.body),G=s.styles||{},p.loader=s.loader;const c=Object.entries(G).map(async([t,a])=>{const r=await Promise.all(a.map(async n=>{try{const d=await(await fetch(n)).text(),i=new CSSStyleSheet;return await i.replace(d),i}catch(l){return console.error(`[Lego] Failed to load stylesheet: ${n}`,l),null}}));q.set(t,r.filter(n=>n!==null))});await Promise.all(c),document.querySelectorAll("template[b-id]").forEach(t=>{w[t.getAttribute("b-id")]=t}),new MutationObserver(t=>t.forEach(a=>{a.addedNodes.forEach(r=>{if(r.nodeType===Node.ELEMENT_NODE){v(r);const n=r.tagName.toLowerCase();if(n.includes("-")&&!w[n]&&p.loader&&!$.has(r)){const l=p.loader(n);if(l){const d=typeof l=="string"?fetch(l).then(i=>i.text()):l;Promise.resolve(d).then(i=>U.defineSFC(i,n+".lego")).catch(i=>console.error(`[Lego] Failed to load ${n}:`,i))}}}}),a.removedNodes.forEach(r=>r.nodeType===Node.ELEMENT_NODE&&W(r))})).observe(e,{childList:!0,subtree:!0}),e._studs=Lego.globals,v(e),x(e,e),C(e),R.length>0&&(window.addEventListener("popstate",t=>{const a=t.state?.legoTargets||null;F(a)}),document.addEventListener("submit",t=>{t.preventDefault()}),document.addEventListener("click",t=>{const r=t.composedPath().find(n=>n.tagName==="A"&&(n.hasAttribute("b-target")||n.hasAttribute("b-link")));if(r){t.preventDefault();const n=r.getAttribute("href"),l=r.getAttribute("b-target"),d=l?l.split(/\s+/).filter(Boolean):[],i=r.getAttribute("b-link")!=="false";Lego.globals.$go(n,...d).get(i)}}),F())},globals:S({$route:{url:window.location.pathname,route:"",params:{},query:{},method:"GET",body:null},$go:(e,...s)=>j(e,...s)(document.body)},document.body),defineSFC:(e,s="component.lego")=>{let c="",o="{}",t="",a="",r=e;const n=/<(template|script|style)\b((?:\s+(?:[^>"']|"[^"]*"|'[^']*')*)*)>/i;for(;r;){const i=r.match(n);if(!i)break;const u=i[1].toLowerCase(),f=i[2],g=i[0],m=i.index,b=`</${u}>`,y=m+g.length,E=r.indexOf(b,y);if(E===-1){console.warn(`[Lego] Unclosed <${u}> tag in ${s}`);break}const T=r.slice(y,E);if(u==="template"){c=T.trim();const A=f.match(/b-styles=["']([^"']+)["']/);A&&(t=A[1])}else if(u==="script"){const A=T.trim(),X=A.match(/export\s+default\s+({[\s\S]*})/);o=X?X[1]:A}else u==="style"&&(a=T.trim());r=r.slice(E+b.length)}const l=K(s),d=new Function(`return ${o}`)();a&&(c=`<style>${a}</style>`+c),w[l]=document.createElement("template"),w[l].innerHTML=c,w[l].setAttribute("b-styles",t),P.set(l,d),document.querySelectorAll(l).forEach(i=>!N(i).snapped&&v(i))},define:(e,s,c={},o="")=>{const t=document.createElement("template");t.setAttribute("b-id",e),t.setAttribute("b-styles",o),t.innerHTML=s,w[e]=t,P.set(e,c);try{B.set(e.toLowerCase(),S({...c},document.body))}catch(a){p.onError(a,"define",e)}document.querySelectorAll(e).forEach(v)},getActiveComponentsCount:()=>$.size,config:p,route:(e,s,c=null)=>{const o=[],t=e.replace(/:([^\/]+)/g,(a,r)=>(o.push(r),"([^/]+)"));R.push({path:e,regex:new RegExp(`^${t}$`),tagName:s,paramNames:o,middleware:c})}};return U})();typeof window<"u"&&(window.Lego=Lego);
@@ -0,0 +1,111 @@
1
+ /**
2
+ * LegoDOM Monitoring Plugin
3
+ * Tracks render performance and errors.
4
+ */
5
+
6
+ export const Monitoring = {
7
+ // Metrics storage
8
+ metrics: {
9
+ renders: 0,
10
+ errors: 0,
11
+ slowRenders: 0,
12
+ components: new Map(), // tagName -> { count, avgTime }
13
+ },
14
+
15
+ // Configuration options
16
+ options: {
17
+ slowRenderThreshold: 16, // ms (target 60fps)
18
+ reportToConsole: false,
19
+ reportUrl: null // Endpoint to send metrics to
20
+ },
21
+
22
+ /**
23
+ * Install the plugin
24
+ * @param {object} Lego - The global Lego object
25
+ * @param {object} opts - Configuration options
26
+ */
27
+ install(Lego, opts = {}) {
28
+ Object.assign(this.options, opts);
29
+
30
+ // Prevent double installation
31
+ if (Lego.metrics) {
32
+ // Just reset metrics if re-installing
33
+ if (Lego.metrics.reset) Lego.metrics.reset();
34
+ return;
35
+ }
36
+
37
+ // Ensure metrics object exists on config if not already
38
+ if (!Lego.config.metrics) Lego.config.metrics = {};
39
+
40
+ // 1. Hook into Render Cycle
41
+ Lego.config.metrics.onRenderStart = (el) => {
42
+ if (!el.__perfId) el.__perfId = Math.random().toString(36).substr(2, 9);
43
+ performance.mark(`lego-render-start-${el.__perfId}`);
44
+ };
45
+
46
+ Lego.config.metrics.onRenderEnd = (el) => {
47
+ const id = el.__perfId;
48
+ const startMark = `lego-render-start-${id}`;
49
+ const measureName = `lego-render-${el.tagName}-${id}`;
50
+
51
+ try {
52
+ performance.measure(measureName, startMark);
53
+ const entry = performance.getEntriesByName(measureName).pop();
54
+ if (entry) {
55
+ this.recordMetric(el.tagName.toLowerCase(), entry.duration);
56
+ performance.clearMarks(startMark);
57
+ performance.clearMeasures(measureName);
58
+ }
59
+ } catch (e) {
60
+ // Fallback or ignore if performance API fails
61
+ }
62
+ };
63
+
64
+ // 2. Hook into Error Handler
65
+ const originalOnError = Lego.config.onError;
66
+ Lego.config.onError = (err, type, context) => {
67
+ this.metrics.errors++;
68
+ if (this.options.reportToConsole) {
69
+ console.groupCollapsed(`[Lego Monitoring] Error in ${type}`);
70
+ console.error(err);
71
+ console.log('Context:', context);
72
+ console.groupEnd();
73
+ }
74
+
75
+ // Call original handler if it exists
76
+ if (originalOnError) originalOnError(err, type, context);
77
+ };
78
+
79
+ // Expose metrics API
80
+ Lego.metrics = {
81
+ get: () => this.metrics,
82
+ reset: () => {
83
+ this.metrics.renders = 0;
84
+ this.metrics.errors = 0;
85
+ this.metrics.slowRenders = 0;
86
+ this.metrics.components.clear();
87
+ }
88
+ };
89
+
90
+ console.log('[Lego] Monitoring Plugin Installed');
91
+ },
92
+
93
+ recordMetric(tagName, duration) {
94
+ this.metrics.renders++;
95
+ if (duration > this.options.slowRenderThreshold) {
96
+ this.metrics.slowRenders++;
97
+ if (this.options.reportToConsole) {
98
+ console.warn(`[Lego Slow Render] <${tagName}> took ${duration.toFixed(2)}ms`);
99
+ }
100
+ }
101
+
102
+ if (!this.metrics.components.has(tagName)) {
103
+ this.metrics.components.set(tagName, { count: 0, totalTime: 0, avg: 0 });
104
+ }
105
+
106
+ const stats = this.metrics.components.get(tagName);
107
+ stats.count++;
108
+ stats.totalTime += duration;
109
+ stats.avg = stats.totalTime / stats.count;
110
+ }
111
+ };