bee-ladybug 0.5.4 → 0.6.1

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.
@@ -0,0 +1,695 @@
1
+ /**
2
+ * ReportExporter — turns a core snapshot into HTML, JSON, or CSV.
3
+ *
4
+ * No adapter imports. Attached modules are asked through the snapshot
5
+ * hook stored by the core. A missing hook is skipped.
6
+ */
7
+
8
+ const LATEST_TYPES = Object.freeze([
9
+ 'fps',
10
+ 'metric',
11
+ 'coord',
12
+ 'log',
13
+ 'warn',
14
+ 'error',
15
+ 'state',
16
+ 'sys',
17
+ 'telemetry'
18
+ ]);
19
+
20
+ /**
21
+ * @typedef {'html' | 'json' | 'csv'} ReportFormat
22
+ * @typedef {'error' | 'warn' | 'ok'} ReportSeverity
23
+ * @typedef {{ severity: ReportSeverity, selector: string, rule: string, value: string, source: string }} ReportItem
24
+ */
25
+
26
+ /**
27
+ * Build a report and, when the overlay is mounted, download it.
28
+ * Headless cores (`ui: false`, or no document) return the value and do not download.
29
+ *
30
+ * JSON returns the plain object. HTML and CSV return strings.
31
+ *
32
+ * @param {object} core
33
+ * @param {{ format?: ReportFormat }} [options]
34
+ * @returns {object | string}
35
+ */
36
+ export function exportReport(core, options = {}) {
37
+ if (!core || typeof core.getState !== 'function' || typeof core.collectSnapshots !== 'function') {
38
+ throw new TypeError('exportReport() needs a BeeLadybug core.');
39
+ }
40
+
41
+ const format = normalizeFormat(options.format);
42
+ const report = buildReport(core);
43
+
44
+ if (format === 'json') {
45
+ saveIfMounted(core, report, 'json', 'application/json', JSON.stringify(report, null, 2));
46
+ return report;
47
+ }
48
+
49
+ if (format === 'csv') {
50
+ const text = renderCsv(report);
51
+ saveIfMounted(core, report, 'csv', 'text/csv', text);
52
+ return text;
53
+ }
54
+
55
+ const html = renderHtml(report);
56
+ saveIfMounted(core, report, 'html', 'text/html', html);
57
+ return html;
58
+ }
59
+
60
+ /**
61
+ * Skip hidden boxes, the WordPress admin bar, and BeeLadybug's own chrome.
62
+ * Adapters call this while they still have the element. The exported row does not.
63
+ *
64
+ * @param {object | null | undefined} node
65
+ * @returns {boolean}
66
+ */
67
+ export function isReportNoise(node) {
68
+ if (!node || typeof node !== 'object') return false;
69
+ const el = node.nodeType === 1 ? node : (node.parentElement || node);
70
+ if (!el || typeof el !== 'object') return false;
71
+
72
+ const seen = new Set();
73
+ let current = el;
74
+ while (current && typeof current === 'object') {
75
+ if (seen.has(current)) break;
76
+ seen.add(current);
77
+ if (current.id === 'wpadminbar' || elementId(current) === 'wpadminbar') return true;
78
+ if (hasAbClass(classText(current))) return true;
79
+ if (current.tagName === 'BEE-LADYBUG-OVERLAY') return true;
80
+ if (hasBeeAttribute(current)) return true;
81
+ if (typeof current.closest === 'function') {
82
+ try {
83
+ const bar = current.closest('#wpadminbar');
84
+ if (bar) return true;
85
+ } catch {
86
+ /* closest is optional */
87
+ }
88
+ }
89
+ current = current.parentElement || (current.parentNode && current.parentNode.nodeType === 1 ? current.parentNode : null);
90
+ }
91
+
92
+ const style = readStyle(el);
93
+ if (style && (style.display === 'none' || style.visibility === 'hidden')) return true;
94
+
95
+ if (typeof el.getBoundingClientRect === 'function') {
96
+ const rect = el.getBoundingClientRect();
97
+ if (rect && (Number(rect.width) === 0 || Number(rect.height) === 0)) return true;
98
+ }
99
+
100
+ return false;
101
+ }
102
+
103
+ /**
104
+ * @param {object} core
105
+ */
106
+ function buildReport(core) {
107
+ const state = core.getState();
108
+ const generatedAt = new Date();
109
+ const env = readEnvironment();
110
+ const logs = readLogs(core);
111
+ const series = readSeries(core);
112
+ const latest = readLatest(core);
113
+ const adapters = readAdapters(core);
114
+ const summary = countFindings(adapters, logs);
115
+
116
+ return {
117
+ version: state.version,
118
+ generatedAt: generatedAt.toISOString(),
119
+ fileStamp: fileStamp(generatedAt),
120
+ url: env.url,
121
+ viewport: env.viewport,
122
+ clock: {
123
+ wallTime: generatedAt.getTime(),
124
+ simTime: state.simTime,
125
+ frame: state.frameCount,
126
+ frozen: state.frozen,
127
+ timeScale: state.timeScale,
128
+ coreFps: state.coreFps
129
+ },
130
+ summary,
131
+ logs,
132
+ series,
133
+ latest,
134
+ adapters
135
+ };
136
+ }
137
+
138
+ /**
139
+ * @param {object} core
140
+ */
141
+ function readLogs(core) {
142
+ const logs = typeof core.getLogs === 'function' ? core.getLogs() : [];
143
+ return logs.map((packet) => ({
144
+ type: packet.type,
145
+ level: packet.level ?? null,
146
+ source: packet.source,
147
+ message: packetMessage(packet),
148
+ wallTime: packet.wallTime,
149
+ simTime: packet.simTime,
150
+ frame: packet.frame
151
+ }));
152
+ }
153
+
154
+ /**
155
+ * FPS plus every metric name currently on the HUD.
156
+ * @param {object} core
157
+ */
158
+ function readSeries(core) {
159
+ const names = new Set(['fps']);
160
+ const hud = typeof core.getHud === 'function' ? core.getHud() : {};
161
+ for (const [name, entry] of Object.entries(hud)) {
162
+ if (entry && entry.type === 'metric') names.add(name);
163
+ }
164
+
165
+ const series = [];
166
+ for (const name of names) {
167
+ const history = core.getHistory(name);
168
+ if (!history || !history.values || !history.values.length) continue;
169
+ series.push({
170
+ name: history.name,
171
+ min: history.min,
172
+ max: history.max,
173
+ avg: history.avg,
174
+ last: history.last,
175
+ samples: history.values.slice()
176
+ });
177
+ }
178
+ return series;
179
+ }
180
+
181
+ /**
182
+ * @param {object} core
183
+ */
184
+ function readLatest(core) {
185
+ if (typeof core.getLatest !== 'function') return [];
186
+ const packets = [];
187
+ for (const type of LATEST_TYPES) {
188
+ const packet = core.getLatest(type);
189
+ if (!packet) continue;
190
+ packets.push({
191
+ id: packet.id,
192
+ type: packet.type,
193
+ payload: packet.payload,
194
+ wallTime: packet.wallTime,
195
+ simTime: packet.simTime,
196
+ frame: packet.frame,
197
+ source: packet.source,
198
+ level: packet.level ?? null
199
+ });
200
+ }
201
+ return packets;
202
+ }
203
+
204
+ /**
205
+ * @param {object} core
206
+ */
207
+ function readAdapters(core) {
208
+ const raw = core.collectSnapshots();
209
+ const sections = [];
210
+ for (const entry of raw) {
211
+ if (entry.error) {
212
+ sections.push({
213
+ name: entry.name,
214
+ items: [makeItem({
215
+ severity: 'warn',
216
+ rule: 'snapshot',
217
+ value: entry.error,
218
+ source: entry.name
219
+ })]
220
+ });
221
+ continue;
222
+ }
223
+ const result = entry.result;
224
+ const name = result && typeof result.name === 'string' && result.name
225
+ ? result.name
226
+ : entry.name;
227
+ const list = result && Array.isArray(result.items) ? result.items : [];
228
+ sections.push({
229
+ name,
230
+ items: groupIdentical(list.filter((row) => row && typeof row === 'object').map((row) => makeItem(row, name)))
231
+ });
232
+ }
233
+ return sections;
234
+ }
235
+
236
+ /**
237
+ * @param {object} row
238
+ * @param {string} [fallbackSource]
239
+ * @returns {ReportItem}
240
+ */
241
+ function makeItem(row, fallbackSource = '') {
242
+ return {
243
+ severity: normalizeSeverity(row.severity),
244
+ selector: asText(row.selector),
245
+ rule: asText(row.rule),
246
+ value: asText(row.value),
247
+ source: asText(row.source) || fallbackSource
248
+ };
249
+ }
250
+
251
+ /**
252
+ * Identical selector + rule + value collapse to one row. The count is part of the value.
253
+ * @param {ReportItem[]} items
254
+ * @returns {ReportItem[]}
255
+ */
256
+ function groupIdentical(items) {
257
+ const order = [];
258
+ const map = new Map();
259
+ for (let i = 0; i < items.length; i++) {
260
+ const item = items[i];
261
+ const key = `${item.selector}\0${item.rule}\0${item.value}`;
262
+ const prev = map.get(key);
263
+ if (!prev) {
264
+ const row = { ...item, count: 1 };
265
+ map.set(key, row);
266
+ order.push(row);
267
+ continue;
268
+ }
269
+ prev.count += 1;
270
+ }
271
+ return order.map((row) => ({
272
+ severity: row.severity,
273
+ selector: row.selector,
274
+ rule: row.rule,
275
+ value: row.count > 1 ? `${row.value} ×${row.count}` : row.value,
276
+ source: row.source
277
+ }));
278
+ }
279
+
280
+ /**
281
+ * @param {Array<{ items: ReportItem[] }>} adapters
282
+ * @param {Array<{ type: string, level: string | null }>} logs
283
+ */
284
+ function countFindings(adapters, logs) {
285
+ const summary = { error: 0, warn: 0, ok: 0 };
286
+ for (const section of adapters) {
287
+ for (const item of section.items) summary[item.severity] += 1;
288
+ }
289
+ for (let i = 0; i < logs.length; i++) summary[logSeverity(logs[i])] += 1;
290
+ return summary;
291
+ }
292
+
293
+ /**
294
+ * @param {string | undefined} format
295
+ * @returns {ReportFormat}
296
+ */
297
+ function normalizeFormat(format) {
298
+ if (format === 'json' || format === 'csv' || format === 'html') return format;
299
+ return 'html';
300
+ }
301
+
302
+ /**
303
+ * @param {unknown} value
304
+ * @returns {ReportSeverity}
305
+ */
306
+ function normalizeSeverity(value) {
307
+ const text = String(value || '').toLowerCase();
308
+ if (text === 'error') return 'error';
309
+ if (text === 'warn' || text === 'warning' || text === 'warm') return 'warn';
310
+ return 'ok';
311
+ }
312
+
313
+ /**
314
+ * @param {object} report
315
+ */
316
+ function renderCsv(report) {
317
+ const lines = ['adapter,severity,selector,rule,value,source'];
318
+ for (const log of report.logs) {
319
+ lines.push(csvRow([
320
+ 'core',
321
+ logSeverity(log),
322
+ '',
323
+ log.type,
324
+ log.message,
325
+ log.source
326
+ ]));
327
+ }
328
+ for (const series of report.series) {
329
+ lines.push(csvRow([
330
+ 'core',
331
+ 'ok',
332
+ '',
333
+ series.name,
334
+ `min ${formatNum(series.min)} / avg ${formatNum(series.avg)} / max ${formatNum(series.max)}`,
335
+ 'core'
336
+ ]));
337
+ }
338
+ for (const section of report.adapters) {
339
+ for (const item of section.items) {
340
+ lines.push(csvRow([
341
+ section.name,
342
+ item.severity,
343
+ item.selector,
344
+ item.rule,
345
+ item.value,
346
+ item.source
347
+ ]));
348
+ }
349
+ }
350
+ return `${lines.join('\n')}\n`;
351
+ }
352
+
353
+ /**
354
+ * @param {unknown[]} cells
355
+ */
356
+ function csvRow(cells) {
357
+ return cells.map(csvCell).join(',');
358
+ }
359
+
360
+ /**
361
+ * @param {unknown} value
362
+ */
363
+ function csvCell(value) {
364
+ const text = value == null ? '' : String(value);
365
+ if (/[",\n\r]/.test(text)) return `"${text.replace(/"/g, '""')}"`;
366
+ return text;
367
+ }
368
+
369
+ /**
370
+ * @param {{ type: string, level: string | null }} log
371
+ * @returns {ReportSeverity}
372
+ */
373
+ function logSeverity(log) {
374
+ const level = String(log.level || '').toLowerCase();
375
+ if (level === 'warn' || level === 'warning' || level === 'warm') return 'warn';
376
+ if (level === 'error' || log.type === 'error') return 'error';
377
+ if (log.type === 'warn') return 'warn';
378
+ return 'ok';
379
+ }
380
+
381
+ /**
382
+ * @param {object} report
383
+ */
384
+ function renderHtml(report) {
385
+ const title = `BeeLadybug report ${report.fileStamp}`;
386
+ const viewport = report.viewport
387
+ ? `${report.viewport.width}×${report.viewport.height}`
388
+ : 'n/a';
389
+ const sections = report.adapters.map((section) => renderSection(section)).join('\n');
390
+
391
+ return `<!DOCTYPE html>
392
+ <html lang="en">
393
+ <head>
394
+ <meta charset="utf-8">
395
+ <meta name="viewport" content="width=device-width, initial-scale=1">
396
+ <title>${escapeHtml(title)}</title>
397
+ <style>
398
+ :root { color-scheme: dark; }
399
+ * { box-sizing: border-box; }
400
+ body {
401
+ margin: 0;
402
+ background: #07080c;
403
+ color: #f1f2f6;
404
+ font: 14px/1.45 ui-monospace, Consolas, monospace;
405
+ }
406
+ main { max-width: 1100px; margin: 0 auto; padding: 28px 22px 64px; }
407
+ h1 { margin: 0 0 8px; color: #ff3b4e; font-size: 22px; letter-spacing: 0.04em; }
408
+ h2 { margin: 28px 0 10px; font-size: 13px; letter-spacing: 0.14em; color: #8b91a3; }
409
+ .meta { color: #8b91a3; margin: 0 0 18px; }
410
+ .counts { display: flex; gap: 10px; flex-wrap: wrap; margin: 0 0 8px; }
411
+ .count { padding: 8px 12px; background: #101218; border: 1px solid #2a2e3a; }
412
+ .count b { display: block; font-size: 20px; }
413
+ .count.error b { color: #ff3b4e; }
414
+ .count.warn b { color: #ffd36a; }
415
+ .count.ok b { color: #3dd68c; }
416
+ table { width: 100%; border-collapse: collapse; margin: 0 0 8px; }
417
+ th, td { text-align: left; padding: 7px 8px; border-bottom: 1px solid #2a2e3a; vertical-align: top; }
418
+ th { color: #8b91a3; font-weight: 600; cursor: pointer; background: #101218; }
419
+ tr.sev-error td { color: #ff8b96; }
420
+ tr.sev-warn td { color: #ffd36a; }
421
+ tr.sev-ok td { color: #d5d8e2; }
422
+ td.sev { font-weight: 700; }
423
+ tr.sev-error td.sev { color: #ff3b4e; }
424
+ tr.sev-warn td.sev { color: #ffd36a; }
425
+ tr.sev-ok td.sev { color: #3dd68c; }
426
+ .empty { color: #8b91a3; }
427
+ @media print {
428
+ body { background: #07080c; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
429
+ main { max-width: none; padding: 0; }
430
+ h2, table, .count { break-inside: avoid; }
431
+ thead { display: table-header-group; }
432
+ th { cursor: default; }
433
+ }
434
+ </style>
435
+ </head>
436
+ <body>
437
+ <main>
438
+ <h1>${escapeHtml(title)}</h1>
439
+ <p class="meta">v${escapeHtml(report.version)} · ${escapeHtml(report.generatedAt)} · sim ${escapeHtml(formatSim(report.clock.simTime))} · frame ${escapeHtml(report.clock.frame)} · ${escapeHtml(report.clock.frozen ? 'FREEZE' : `${formatNum(report.clock.timeScale)}x`)}<br>
440
+ ${escapeHtml(report.url || 'n/a')} · viewport ${escapeHtml(viewport)}</p>
441
+ <div class="counts">
442
+ <div class="count error"><b>${report.summary.error}</b>errors</div>
443
+ <div class="count warn"><b>${report.summary.warn}</b>warnings</div>
444
+ <div class="count ok"><b>${report.summary.ok}</b>ok</div>
445
+ </div>
446
+ <h2>CORE · LOG</h2>
447
+ ${renderLogTable(report.logs)}
448
+ <h2>CORE · SERIES</h2>
449
+ ${renderSeriesTable(report.series)}
450
+ ${sections || '<h2>ADAPTERS</h2><p class="empty">No attached module exposed a snapshot.</p>'}
451
+ </main>
452
+ <script>
453
+ document.querySelectorAll('table').forEach((table) => {
454
+ table.querySelectorAll('thead th').forEach((th, index) => {
455
+ th.addEventListener('click', () => {
456
+ const body = table.tBodies[0];
457
+ if (!body) return;
458
+ const dir = th.dataset.dir === 'asc' ? 'desc' : 'asc';
459
+ table.querySelectorAll('thead th').forEach((other) => { delete other.dataset.dir; });
460
+ th.dataset.dir = dir;
461
+ const mul = dir === 'asc' ? 1 : -1;
462
+ [...body.rows].sort((a, b) => {
463
+ const av = a.cells[index] ? a.cells[index].textContent : '';
464
+ const bv = b.cells[index] ? b.cells[index].textContent : '';
465
+ const an = Number(av);
466
+ const bn = Number(bv);
467
+ if (av !== '' && bv !== '' && Number.isFinite(an) && Number.isFinite(bn)) return (an - bn) * mul;
468
+ return av.localeCompare(bv) * mul;
469
+ }).forEach((row) => body.appendChild(row));
470
+ });
471
+ });
472
+ });
473
+ </script>
474
+ </body>
475
+ </html>
476
+ `;
477
+ }
478
+
479
+ /**
480
+ * @param {Array<{ type: string, level: string | null, source: string, message: string, simTime: number, frame: number }>} logs
481
+ */
482
+ function renderLogTable(logs) {
483
+ if (!logs.length) return '<p class="empty">No log lines.</p>';
484
+ const rows = logs.map((log) => {
485
+ const severity = logSeverity(log);
486
+ return `<tr class="sev-${severity}"><td class="sev">${escapeHtml(severity)}</td><td>${escapeHtml(log.type)}</td><td>${escapeHtml(log.source)}</td><td>${escapeHtml(log.message)}</td><td>${escapeHtml(formatSim(log.simTime))}</td><td>${escapeHtml(log.frame)}</td></tr>`;
487
+ }).join('');
488
+ return `<table>
489
+ <thead><tr><th>severity</th><th>type</th><th>source</th><th>message</th><th>sim</th><th>frame</th></tr></thead>
490
+ <tbody>${rows}</tbody>
491
+ </table>`;
492
+ }
493
+
494
+ /**
495
+ * @param {Array<{ name: string, min: number, avg: number, max: number, last: number, samples: number[] }>} series
496
+ */
497
+ function renderSeriesTable(series) {
498
+ if (!series.length) return '<p class="empty">No fps or metric samples.</p>';
499
+ const rows = series.map((entry) => `<tr class="sev-ok"><td>${escapeHtml(entry.name)}</td><td>${escapeHtml(formatNum(entry.min))}</td><td>${escapeHtml(formatNum(entry.avg))}</td><td>${escapeHtml(formatNum(entry.max))}</td><td>${escapeHtml(formatNum(entry.last))}</td><td>${entry.samples.length}</td></tr>`).join('');
500
+ return `<table>
501
+ <thead><tr><th>name</th><th>min</th><th>avg</th><th>max</th><th>last</th><th>samples</th></tr></thead>
502
+ <tbody>${rows}</tbody>
503
+ </table>`;
504
+ }
505
+
506
+ /**
507
+ * @param {{ name: string, items: ReportItem[] }} section
508
+ */
509
+ function renderSection(section) {
510
+ const title = escapeHtml(section.name);
511
+ if (!section.items.length) {
512
+ return `<h2>${title}</h2><p class="empty">No rows.</p>`;
513
+ }
514
+ const rows = section.items.map((item) => `<tr class="sev-${item.severity}"><td class="sev">${escapeHtml(item.severity)}</td><td>${escapeHtml(item.selector)}</td><td>${escapeHtml(item.rule)}</td><td>${escapeHtml(item.value)}</td><td>${escapeHtml(item.source)}</td></tr>`).join('');
515
+ return `<h2>${title}</h2>
516
+ <table>
517
+ <thead><tr><th>severity</th><th>selector</th><th>rule</th><th>value</th><th>source</th></tr></thead>
518
+ <tbody>${rows}</tbody>
519
+ </table>`;
520
+ }
521
+
522
+ /**
523
+ * @param {object} core
524
+ * @param {{ fileStamp: string }} report
525
+ * @param {string} ext
526
+ * @param {string} mime
527
+ * @param {string} text
528
+ */
529
+ function saveIfMounted(core, report, ext, mime, text) {
530
+ if (!core.ui) return;
531
+ if (typeof document === 'undefined' || !document.body) return;
532
+ if (typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function' || typeof Blob === 'undefined') return;
533
+
534
+ const filename = `report-${report.fileStamp}.${ext}`;
535
+ const type = `${mime};charset=utf-8`;
536
+ let blob;
537
+ try {
538
+ blob = typeof File === 'function'
539
+ ? new File([text], filename, { type })
540
+ : new Blob([text], { type });
541
+ } catch {
542
+ if (typeof Blob !== 'function') return;
543
+ blob = new Blob([text], { type });
544
+ }
545
+ const url = URL.createObjectURL(blob);
546
+ const link = document.createElement('a');
547
+ link.href = url;
548
+ link.download = filename;
549
+ link.setAttribute('download', filename);
550
+ link.rel = 'noopener';
551
+ document.body.appendChild(link);
552
+ link.click();
553
+ link.remove();
554
+ setTimeout(() => URL.revokeObjectURL(url), 1500);
555
+ }
556
+
557
+ function readEnvironment() {
558
+ if (typeof window === 'undefined' || typeof location === 'undefined') {
559
+ return { url: '', viewport: null };
560
+ }
561
+ const width = Number(window.innerWidth);
562
+ const height = Number(window.innerHeight);
563
+ return {
564
+ url: String(location.href || ''),
565
+ viewport: Number.isFinite(width) && Number.isFinite(height)
566
+ ? { width, height }
567
+ : null
568
+ };
569
+ }
570
+
571
+ /**
572
+ * @param {object} packet
573
+ */
574
+ function packetMessage(packet) {
575
+ const payload = packet && packet.payload;
576
+ if (!payload || typeof payload !== 'object') return '';
577
+ if (typeof payload.message === 'string') return payload.message;
578
+ if (payload.value != null) return String(payload.value);
579
+ return '';
580
+ }
581
+
582
+ /**
583
+ * @param {unknown} value
584
+ */
585
+ function asText(value) {
586
+ if (value == null) return '';
587
+ if (typeof value === 'string') return value;
588
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
589
+ try {
590
+ return JSON.stringify(value);
591
+ } catch {
592
+ return String(value);
593
+ }
594
+ }
595
+
596
+ /**
597
+ * @param {number} value
598
+ */
599
+ function formatNum(value) {
600
+ const n = Number(value);
601
+ if (!Number.isFinite(n)) return '';
602
+ if (Number.isInteger(n)) return String(n);
603
+ return String(Math.round(n * 100) / 100);
604
+ }
605
+
606
+ /**
607
+ * @param {number} ms
608
+ */
609
+ function formatSim(ms) {
610
+ const total = Math.max(0, Number(ms) || 0) / 1000;
611
+ const minutes = Math.floor(total / 60);
612
+ const seconds = total - minutes * 60;
613
+ return `${String(minutes).padStart(2, '0')}:${seconds.toFixed(1).padStart(4, '0')}`;
614
+ }
615
+
616
+ /**
617
+ * @param {Date} date
618
+ */
619
+ function fileStamp(date) {
620
+ const pad = (n) => String(n).padStart(2, '0');
621
+ return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}`;
622
+ }
623
+
624
+ /**
625
+ * @param {unknown} value
626
+ */
627
+ function escapeHtml(value) {
628
+ return String(value ?? '')
629
+ .replace(/&/g, '&amp;')
630
+ .replace(/</g, '&lt;')
631
+ .replace(/>/g, '&gt;')
632
+ .replace(/"/g, '&quot;');
633
+ }
634
+
635
+ function classText(node) {
636
+ if (!node || typeof node !== 'object') return '';
637
+ const parts = [];
638
+ if (typeof node.className === 'string' && node.className) parts.push(node.className);
639
+ else if (node.className && typeof node.className === 'object' && 'baseVal' in node.className && node.className.baseVal) {
640
+ parts.push(String(node.className.baseVal));
641
+ }
642
+ if (node.classList && typeof node.classList.length === 'number') {
643
+ try {
644
+ parts.push([...node.classList].join(' '));
645
+ } catch {
646
+ /* classList may not be iterable */
647
+ }
648
+ }
649
+ if (typeof node.getAttribute === 'function') {
650
+ const attr = node.getAttribute('class');
651
+ if (attr) parts.push(attr);
652
+ }
653
+ return parts.join(' ');
654
+ }
655
+
656
+ function elementId(node) {
657
+ if (!node || typeof node !== 'object') return '';
658
+ if (typeof node.id === 'string' && node.id) return node.id;
659
+ if (typeof node.getAttribute === 'function') return node.getAttribute('id') || '';
660
+ return '';
661
+ }
662
+
663
+ /**
664
+ * @param {unknown} className
665
+ */
666
+ function hasAbClass(className) {
667
+ const raw = typeof className === 'string'
668
+ ? className
669
+ : (className && typeof className === 'object' && 'baseVal' in className
670
+ ? String(className.baseVal)
671
+ : '');
672
+ return raw.split(/\s+/).some((token) => token.startsWith('ab-'));
673
+ }
674
+
675
+ /**
676
+ * @param {object} el
677
+ */
678
+ function hasBeeAttribute(el) {
679
+ if (typeof el.hasAttribute !== 'function') return false;
680
+ return el.hasAttribute('data-bee-ladybug-dom')
681
+ || el.hasAttribute('data-bee-ladybug-canvas')
682
+ || el.hasAttribute('data-bee-ladybug-css');
683
+ }
684
+
685
+ /**
686
+ * @param {object} el
687
+ */
688
+ function readStyle(el) {
689
+ if (typeof getComputedStyle !== 'function') return null;
690
+ try {
691
+ return getComputedStyle(el);
692
+ } catch {
693
+ return null;
694
+ }
695
+ }