@mseep/dembrandt 0.19.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.
Files changed (139) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +408 -0
  3. package/dist/index.d.ts +8 -0
  4. package/dist/index.js +532 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/lib/browser.d.ts +16 -0
  7. package/dist/lib/browser.js +27 -0
  8. package/dist/lib/browser.js.map +1 -0
  9. package/dist/lib/colors.d.ts +101 -0
  10. package/dist/lib/colors.js +405 -0
  11. package/dist/lib/colors.js.map +1 -0
  12. package/dist/lib/compare.d.ts +31 -0
  13. package/dist/lib/compare.js +46 -0
  14. package/dist/lib/compare.js.map +1 -0
  15. package/dist/lib/discovery.d.ts +31 -0
  16. package/dist/lib/discovery.js +243 -0
  17. package/dist/lib/discovery.js.map +1 -0
  18. package/dist/lib/drift.d.ts +64 -0
  19. package/dist/lib/drift.js +383 -0
  20. package/dist/lib/drift.js.map +1 -0
  21. package/dist/lib/dtcg/validate.d.ts +51 -0
  22. package/dist/lib/dtcg/validate.js +1403 -0
  23. package/dist/lib/dtcg/validate.js.map +1 -0
  24. package/dist/lib/exit-codes.d.ts +29 -0
  25. package/dist/lib/exit-codes.js +26 -0
  26. package/dist/lib/exit-codes.js.map +1 -0
  27. package/dist/lib/extractors/breakpoints.d.ts +5 -0
  28. package/dist/lib/extractors/breakpoints.js +450 -0
  29. package/dist/lib/extractors/breakpoints.js.map +1 -0
  30. package/dist/lib/extractors/colors.d.ts +2 -0
  31. package/dist/lib/extractors/colors.js +657 -0
  32. package/dist/lib/extractors/colors.js.map +1 -0
  33. package/dist/lib/extractors/components.d.ts +4 -0
  34. package/dist/lib/extractors/components.js +370 -0
  35. package/dist/lib/extractors/components.js.map +1 -0
  36. package/dist/lib/extractors/index.d.ts +9 -0
  37. package/dist/lib/extractors/index.js +1257 -0
  38. package/dist/lib/extractors/index.js.map +1 -0
  39. package/dist/lib/extractors/logo.d.ts +2 -0
  40. package/dist/lib/extractors/logo.js +626 -0
  41. package/dist/lib/extractors/logo.js.map +1 -0
  42. package/dist/lib/extractors/spacing.d.ts +4 -0
  43. package/dist/lib/extractors/spacing.js +163 -0
  44. package/dist/lib/extractors/spacing.js.map +1 -0
  45. package/dist/lib/extractors/teach.d.ts +1 -0
  46. package/dist/lib/extractors/teach.js +66 -0
  47. package/dist/lib/extractors/teach.js.map +1 -0
  48. package/dist/lib/extractors/typography.d.ts +1 -0
  49. package/dist/lib/extractors/typography.js +163 -0
  50. package/dist/lib/extractors/typography.js.map +1 -0
  51. package/dist/lib/findings.d.ts +34 -0
  52. package/dist/lib/findings.js +166 -0
  53. package/dist/lib/findings.js.map +1 -0
  54. package/dist/lib/formatters/dtcg.d.ts +10 -0
  55. package/dist/lib/formatters/dtcg.js +416 -0
  56. package/dist/lib/formatters/dtcg.js.map +1 -0
  57. package/dist/lib/formatters/html.d.ts +25 -0
  58. package/dist/lib/formatters/html.js +479 -0
  59. package/dist/lib/formatters/html.js.map +1 -0
  60. package/dist/lib/formatters/markdown.d.ts +5 -0
  61. package/dist/lib/formatters/markdown.js +568 -0
  62. package/dist/lib/formatters/markdown.js.map +1 -0
  63. package/dist/lib/formatters/pdf.d.ts +12 -0
  64. package/dist/lib/formatters/pdf.js +1121 -0
  65. package/dist/lib/formatters/pdf.js.map +1 -0
  66. package/dist/lib/formatters/terminal.d.ts +6 -0
  67. package/dist/lib/formatters/terminal.js +954 -0
  68. package/dist/lib/formatters/terminal.js.map +1 -0
  69. package/dist/lib/formatters/theme.d.ts +35 -0
  70. package/dist/lib/formatters/theme.js +37 -0
  71. package/dist/lib/formatters/theme.js.map +1 -0
  72. package/dist/lib/merger.d.ts +14 -0
  73. package/dist/lib/merger.js +362 -0
  74. package/dist/lib/merger.js.map +1 -0
  75. package/dist/lib/normalize.d.ts +29 -0
  76. package/dist/lib/normalize.js +59 -0
  77. package/dist/lib/normalize.js.map +1 -0
  78. package/dist/lib/robots.d.ts +12 -0
  79. package/dist/lib/robots.js +110 -0
  80. package/dist/lib/robots.js.map +1 -0
  81. package/dist/lib/run-summary.d.ts +40 -0
  82. package/dist/lib/run-summary.js +64 -0
  83. package/dist/lib/run-summary.js.map +1 -0
  84. package/dist/lib/types.d.ts +329 -0
  85. package/dist/lib/types.js +7 -0
  86. package/dist/lib/types.js.map +1 -0
  87. package/dist/lib/version.d.ts +134 -0
  88. package/dist/lib/version.js +153 -0
  89. package/dist/lib/version.js.map +1 -0
  90. package/dist/mcp-server.d.ts +11 -0
  91. package/dist/mcp-server.js +311 -0
  92. package/dist/mcp-server.js.map +1 -0
  93. package/dist/package.json +106 -0
  94. package/dist/test/_vitest-shim.d.ts +13 -0
  95. package/dist/test/_vitest-shim.js +23 -0
  96. package/dist/test/_vitest-shim.js.map +1 -0
  97. package/dist/test/cli.test.d.ts +1 -0
  98. package/dist/test/cli.test.js +24 -0
  99. package/dist/test/cli.test.js.map +1 -0
  100. package/dist/test/colors.test.d.ts +1 -0
  101. package/dist/test/colors.test.js +64 -0
  102. package/dist/test/colors.test.js.map +1 -0
  103. package/dist/test/compare.test.d.ts +1 -0
  104. package/dist/test/compare.test.js +57 -0
  105. package/dist/test/compare.test.js.map +1 -0
  106. package/dist/test/drift.test.d.ts +1 -0
  107. package/dist/test/drift.test.js +53 -0
  108. package/dist/test/drift.test.js.map +1 -0
  109. package/dist/test/dtcg-formatter.test.d.ts +1 -0
  110. package/dist/test/dtcg-formatter.test.js +48 -0
  111. package/dist/test/dtcg-formatter.test.js.map +1 -0
  112. package/dist/test/dtcg-validate.test.d.ts +1 -0
  113. package/dist/test/dtcg-validate.test.js +2129 -0
  114. package/dist/test/dtcg-validate.test.js.map +1 -0
  115. package/dist/test/exit-codes.test.d.ts +1 -0
  116. package/dist/test/exit-codes.test.js +53 -0
  117. package/dist/test/exit-codes.test.js.map +1 -0
  118. package/dist/test/findings.test.d.ts +1 -0
  119. package/dist/test/findings.test.js +77 -0
  120. package/dist/test/findings.test.js.map +1 -0
  121. package/dist/test/html.test.d.ts +1 -0
  122. package/dist/test/html.test.js +95 -0
  123. package/dist/test/html.test.js.map +1 -0
  124. package/dist/test/markdown.test.d.ts +1 -0
  125. package/dist/test/markdown.test.js +145 -0
  126. package/dist/test/markdown.test.js.map +1 -0
  127. package/dist/test/merger.test.d.ts +1 -0
  128. package/dist/test/merger.test.js +98 -0
  129. package/dist/test/merger.test.js.map +1 -0
  130. package/dist/test/normalize.test.d.ts +1 -0
  131. package/dist/test/normalize.test.js +47 -0
  132. package/dist/test/normalize.test.js.map +1 -0
  133. package/dist/test/run-summary.test.d.ts +1 -0
  134. package/dist/test/run-summary.test.js +45 -0
  135. package/dist/test/run-summary.test.js.map +1 -0
  136. package/dist/test/version.test.d.ts +1 -0
  137. package/dist/test/version.test.js +73 -0
  138. package/dist/test/version.test.js.map +1 -0
  139. package/package.json +106 -0
@@ -0,0 +1,626 @@
1
+ export async function extractSiteName(page) {
2
+ return await page.evaluate(() => {
3
+ const ogSiteName = document.querySelector('meta[property="og:site_name"]');
4
+ if (ogSiteName?.content?.trim())
5
+ return ogSiteName.content.trim();
6
+ const appName = document.querySelector('meta[name="application-name"]');
7
+ if (appName?.content?.trim())
8
+ return appName.content.trim();
9
+ const ldScripts = document.querySelectorAll('script[type="application/ld+json"]');
10
+ for (const s of ldScripts) {
11
+ try {
12
+ const data = JSON.parse(s.textContent);
13
+ const items = Array.isArray(data) ? data : data?.['@graph'] || [data];
14
+ for (const obj of items) {
15
+ if (obj?.name && typeof obj.name === 'string')
16
+ return obj.name;
17
+ if (obj?.organization?.name)
18
+ return obj.organization.name;
19
+ }
20
+ }
21
+ catch { }
22
+ }
23
+ const title = document.title?.trim();
24
+ if (title) {
25
+ const sep = title.match(/(.+?)\s*[|\-–—:]\s*/);
26
+ if (sep && sep[1].length > 1 && sep[1].length < 40)
27
+ return sep[1].trim();
28
+ }
29
+ const logoImg = document.querySelector('img[class*="logo"], img[id*="logo"], a[class*="logo"] img');
30
+ if (logoImg?.alt?.trim() && logoImg.alt.length < 40)
31
+ return logoImg.alt.trim();
32
+ return null;
33
+ });
34
+ }
35
+ export async function extractLogo(page, url) {
36
+ // Extract manifest.json for PWA icons
37
+ const manifestIcons = await page.evaluate((baseUrl) => {
38
+ try {
39
+ const manifestLink = document.querySelector('link[rel="manifest"]');
40
+ if (!manifestLink)
41
+ return [];
42
+ return [{ manifestUrl: new URL(manifestLink.getAttribute('href'), baseUrl).href }];
43
+ }
44
+ catch {
45
+ return [];
46
+ }
47
+ }, url);
48
+ let pwaIcons = [];
49
+ const manifestMeta = {};
50
+ if (manifestIcons.length > 0) {
51
+ try {
52
+ const manifestUrl = manifestIcons[0].manifestUrl;
53
+ const response = await page.evaluate(async (mUrl) => {
54
+ try {
55
+ const r = await fetch(mUrl);
56
+ if (!r.ok)
57
+ return null;
58
+ return await r.json();
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }, manifestUrl);
64
+ if (response?.icons) {
65
+ pwaIcons = response.icons.map(icon => ({
66
+ type: 'pwa',
67
+ url: new URL(icon.src, url).href,
68
+ sizes: icon.sizes || null,
69
+ purpose: icon.purpose || 'any',
70
+ }));
71
+ }
72
+ if (response) {
73
+ if (response.theme_color)
74
+ manifestMeta.themeColor = response.theme_color;
75
+ if (response.background_color)
76
+ manifestMeta.backgroundColor = response.background_color;
77
+ if (response.name)
78
+ manifestMeta.name = response.name;
79
+ if (response.short_name)
80
+ manifestMeta.shortName = response.short_name;
81
+ }
82
+ }
83
+ catch { }
84
+ }
85
+ const result = await page.evaluate((baseUrl) => {
86
+ const siteDomain = new URL(baseUrl).hostname.replace('www.', '').split('.')[0].toLowerCase();
87
+ // Canvas for background color detection
88
+ const canvas = document.createElement('canvas');
89
+ canvas.width = canvas.height = 1;
90
+ const ctx = canvas.getContext('2d');
91
+ function toHex(color) {
92
+ if (!color || color === 'transparent')
93
+ return null;
94
+ try {
95
+ const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
96
+ if (m) {
97
+ if (m[4] !== undefined && parseFloat(m[4]) < 0.1)
98
+ return null;
99
+ return `#${parseInt(m[1]).toString(16).padStart(2, '0')}${parseInt(m[2]).toString(16).padStart(2, '0')}${parseInt(m[3]).toString(16).padStart(2, '0')}`;
100
+ }
101
+ if (/^#[0-9a-f]{6}$/i.test(color))
102
+ return color.toLowerCase();
103
+ if (!ctx)
104
+ return null;
105
+ ctx.clearRect(0, 0, 1, 1);
106
+ ctx.fillStyle = 'rgba(0,0,0,0)';
107
+ ctx.fillStyle = color;
108
+ ctx.fillRect(0, 0, 1, 1);
109
+ const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
110
+ if (a < 25)
111
+ return null;
112
+ return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ }
118
+ function findBgColor(el) {
119
+ let node = el;
120
+ while (node && node.tagName !== 'HTML') {
121
+ try {
122
+ const bg = toHex(getComputedStyle(node).backgroundColor);
123
+ if (bg)
124
+ return bg;
125
+ }
126
+ catch { }
127
+ node = node.parentElement;
128
+ }
129
+ return null;
130
+ }
131
+ function isLight(hex) {
132
+ if (!hex)
133
+ return true;
134
+ const r = parseInt(hex.slice(1, 3), 16);
135
+ const g = parseInt(hex.slice(3, 5), 16);
136
+ const b = parseInt(hex.slice(5, 7), 16);
137
+ return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.5;
138
+ }
139
+ function detectLogoType(el, altText) {
140
+ const text = (altText || '').toLowerCase().trim();
141
+ const hasText = text.length > 0 && !/^logo$|^brand$|^icon$/i.test(text);
142
+ const rect = el.getBoundingClientRect();
143
+ const ratio = rect.width / (rect.height || 1);
144
+ // Wide element with text in alt = wordmark
145
+ if (hasText && ratio > 3)
146
+ return 'wordmark';
147
+ // Squarish = logomark/icon
148
+ if (ratio < 1.5 && ratio > 0.5)
149
+ return 'logomark';
150
+ // Wide with no meaningful alt = likely combination or wordmark
151
+ if (ratio > 2)
152
+ return 'wordmark';
153
+ return 'combination';
154
+ }
155
+ function scoreLogo(el, context) {
156
+ let score = 0;
157
+ const rect = el.getBoundingClientRect();
158
+ const parentLink = el.closest('a');
159
+ const linkHref = parentLink?.getAttribute('href') || '';
160
+ const imgSrc = el.tagName === 'IMG' ? (el.getAttribute('src') || '') : '';
161
+ const altText = (el.getAttribute('alt') || '').toLowerCase();
162
+ const className = (typeof el.className === 'string' ? el.className : el.className.baseVal || '').toLowerCase();
163
+ if (context === 'header')
164
+ score += 50;
165
+ if (context === 'footer')
166
+ score += 20;
167
+ if (context === 'hero')
168
+ score += 15;
169
+ if (imgSrc.toLowerCase().includes(siteDomain) || altText.includes(siteDomain) || className.includes(siteDomain))
170
+ score += 40;
171
+ if (className.includes('logo') || el.id?.toLowerCase().includes('logo'))
172
+ score += 30;
173
+ // SVG with aria-label="Homepage" directly in a home anchor — strong signal
174
+ if (el.tagName === 'svg' || el.tagName === 'SVG') {
175
+ const ariaLabel = (el.getAttribute('aria-label') || '').toLowerCase();
176
+ if (ariaLabel.includes('home') || ariaLabel.includes('logo'))
177
+ score += 40;
178
+ }
179
+ if (parentLink) {
180
+ const href = linkHref.toLowerCase();
181
+ if (href === '/' || href === baseUrl || href.endsWith('://' + new URL(baseUrl).hostname + '/') || href.endsWith('://' + new URL(baseUrl).hostname)) {
182
+ score += 30;
183
+ }
184
+ }
185
+ if (rect.top < 200)
186
+ score += 10;
187
+ if (rect.left < 400)
188
+ score += 10;
189
+ if (rect.top > 500)
190
+ score -= 50; // anything below fold is not a primary logo
191
+ // Penalize images hosted on a different domain (CDN paths on same domain are fine)
192
+ if (el.tagName === 'IMG') {
193
+ try {
194
+ const srcHost = new URL(el.src).hostname.replace('www.', '');
195
+ const pageHost = new URL(baseUrl).hostname.replace('www.', '');
196
+ const apexOf = h => h.split('.').slice(-2).join('.');
197
+ // Allow same apex domain (e.g. digitalhub.fifa.com and fifa.com share apex "fifa.com")
198
+ if (!srcHost.endsWith(pageHost) && !pageHost.endsWith(srcHost) && apexOf(srcHost) !== apexOf(pageHost))
199
+ score -= 60;
200
+ }
201
+ catch { }
202
+ }
203
+ // For SVGs use rendered rect — baseVal reflects viewBox coordinates, not display size
204
+ const width = el.tagName === 'IMG' ? (el.naturalWidth || rect.width) : rect.width;
205
+ const height = el.tagName === 'IMG' ? (el.naturalHeight || rect.height) : rect.height;
206
+ if (width < 20 || height < 20)
207
+ score -= 30;
208
+ if (width > 800 || height > 500)
209
+ score -= 80; // large editorial/hero images are never logos
210
+ else if (width > 600 || height > 400)
211
+ score -= 40;
212
+ if (altText.length > 50)
213
+ score -= 40; // long alt text = content image, not logo
214
+ if (width > height && width < 400 && width > 40 && height > 10 && height < 120)
215
+ score += 15;
216
+ return score;
217
+ }
218
+ function extractLogoFromEl(el, context, baseUrl) {
219
+ const rect = el.getBoundingClientRect();
220
+ const computed = getComputedStyle(el);
221
+ const parent = el.parentElement;
222
+ const parentComputed = parent ? getComputedStyle(parent) : null;
223
+ const parentLink = el.closest('a');
224
+ const bg = findBgColor(el);
225
+ const altText = el.getAttribute('alt') || '';
226
+ const safeZone = {
227
+ top: parseFloat(computed.marginTop) + (parentComputed ? parseFloat(parentComputed.paddingTop) : 0),
228
+ right: parseFloat(computed.marginRight) + (parentComputed ? parseFloat(parentComputed.paddingRight) : 0),
229
+ bottom: parseFloat(computed.marginBottom) + (parentComputed ? parseFloat(parentComputed.paddingBottom) : 0),
230
+ left: parseFloat(computed.marginLeft) + (parentComputed ? parseFloat(parentComputed.paddingLeft) : 0),
231
+ };
232
+ const logoType = detectLogoType(el, altText);
233
+ const reversed = bg ? !isLight(bg) : false;
234
+ if (el.tagName === 'IMG') {
235
+ // Handle picture element -- prefer highest-res source
236
+ const picture = el.closest('picture');
237
+ // Prefer currentSrc (browser-resolved after srcset/lazy), fallback to src attr, then parse srcset manually
238
+ let src = el.currentSrc || el.src;
239
+ const srcAttr = el.getAttribute('src') || '';
240
+ const srcsetAttr = el.getAttribute('srcset') || '';
241
+ // If src looks broken (just params, or resolves to baseUrl), parse srcset manually
242
+ if (!src || src === baseUrl || src === window.location.href || srcAttr.startsWith('width:') || srcAttr.startsWith('height:')) {
243
+ if (srcsetAttr) {
244
+ const entries = srcsetAttr.split(',').map(s => {
245
+ const parts = s.trim().split(/\s+/);
246
+ return { url: parts[0], w: parseFloat(parts[1]) || 0 };
247
+ }).filter(e => e.url && !e.url.startsWith('width:'));
248
+ entries.sort((a, b) => b.w - a.w);
249
+ if (entries[0])
250
+ src = new URL(entries[0].url, baseUrl).href;
251
+ }
252
+ }
253
+ if (picture) {
254
+ const sources = picture.querySelectorAll('source');
255
+ for (const source of sources) {
256
+ const srcset = source.getAttribute('srcset');
257
+ if (srcset) {
258
+ const best = srcset.split(',').map(s => s.trim().split(/\s+/)).sort((a, b) => {
259
+ const wa = parseFloat(a[1]) || 0;
260
+ const wb = parseFloat(b[1]) || 0;
261
+ return wb - wa;
262
+ })[0];
263
+ if (best?.[0]) {
264
+ src = new URL(best[0], baseUrl).href;
265
+ break;
266
+ }
267
+ }
268
+ }
269
+ }
270
+ return {
271
+ source: 'img',
272
+ context,
273
+ url: new URL(src, baseUrl).href,
274
+ width: el.naturalWidth || rect.width,
275
+ height: el.naturalHeight || rect.height,
276
+ alt: altText,
277
+ type: logoType,
278
+ reversed,
279
+ background: bg,
280
+ safeZone,
281
+ position: { top: rect.top, left: rect.left },
282
+ };
283
+ }
284
+ else if (el.tagName === 'SVG' || el.tagName === 'svg') {
285
+ // Inline SVG: the logo lives in the DOM, not at a URL. Capture the
286
+ // resolved color (currentColor → the element's `color`), serialize the
287
+ // markup, and emit a self-contained data URI so the logo is usable.
288
+ const color = toHex(computed.color);
289
+ const ariaLabel = el.getAttribute('aria-label') || null;
290
+ let markup = null;
291
+ let dataUri = null;
292
+ try {
293
+ const clone = el.cloneNode(true);
294
+ // Bake the resolved color in so `currentColor` fills render standalone.
295
+ if (color)
296
+ clone.style.color = color;
297
+ if (!clone.getAttribute('xmlns'))
298
+ clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
299
+ const serialized = clone.outerHTML;
300
+ // Skip pathological inline SVGs (sprite sheets, embedded rasters).
301
+ if (serialized && serialized.length <= 50000) {
302
+ markup = serialized;
303
+ dataUri = 'data:image/svg+xml;utf8,' + encodeURIComponent(serialized);
304
+ }
305
+ }
306
+ catch { }
307
+ return {
308
+ source: 'svg',
309
+ context,
310
+ inline: true,
311
+ // Link target the logo points at; the logo itself is `markup`/`dataUri`.
312
+ url: parentLink ? parentLink.href : baseUrl,
313
+ color,
314
+ ariaLabel,
315
+ markup,
316
+ dataUri,
317
+ width: el.width?.baseVal?.value || rect.width,
318
+ height: el.height?.baseVal?.value || rect.height,
319
+ type: logoType,
320
+ reversed,
321
+ background: bg,
322
+ safeZone,
323
+ position: { top: rect.top, left: rect.left },
324
+ };
325
+ }
326
+ return null;
327
+ }
328
+ function findLogosInZone(container, context) {
329
+ if (!container)
330
+ return [];
331
+ const candidates = [];
332
+ // img and svg
333
+ container.querySelectorAll('img, svg').forEach(el => {
334
+ try {
335
+ const s = getComputedStyle(el);
336
+ if (s.display === 'none' || s.visibility === 'hidden')
337
+ return;
338
+ const rect = el.getBoundingClientRect();
339
+ if (rect.width === 0 || rect.height === 0)
340
+ return;
341
+ const className = (typeof el.className === 'string' ? el.className : el.className.baseVal || '').toLowerCase();
342
+ const altText = (el.getAttribute('alt') || '').toLowerCase();
343
+ const attrs = (className + ' ' + (el.id || '') + ' ' + altText).toLowerCase();
344
+ // Disqualify third-party brand logos: alt like "Notion logo" / "Perplexity logo" / "Figma" where the brand isn't our site
345
+ // These appear in customer/integration/testimonial sections on marketing pages.
346
+ const altBrandMatch = altText.match(/^([a-z][a-z0-9.-]{1,30}?)(?:\s+(?:logo|icon|brand|wordmark))?$/i);
347
+ if (altBrandMatch) {
348
+ const altBrand = altBrandMatch[1].replace(/[\s.-]/g, '').toLowerCase();
349
+ if (altBrand && altBrand !== 'logo' && altBrand !== 'brand' && altBrand !== 'icon' && altBrand !== siteDomain && !altBrand.includes(siteDomain) && !siteDomain.includes(altBrand)) {
350
+ return; // third-party brand, skip entirely
351
+ }
352
+ }
353
+ let qualifies = attrs.includes('logo') || attrs.includes('brand');
354
+ if (!qualifies && el.tagName === 'svg') {
355
+ const useEls = el.querySelectorAll('use');
356
+ for (const use of useEls) {
357
+ const href = use.getAttribute('href') || use.getAttribute('xlink:href') || '';
358
+ if (href.toLowerCase().includes('logo') || href.toLowerCase().includes('brand')) {
359
+ qualifies = true;
360
+ break;
361
+ }
362
+ }
363
+ // aria-label="Homepage" or similar on the SVG itself
364
+ const ariaLabel = (el.getAttribute('aria-label') || '').toLowerCase();
365
+ if (ariaLabel.includes('home') || ariaLabel.includes('logo'))
366
+ qualifies = true;
367
+ }
368
+ if (!qualifies) {
369
+ const parentLink = el.closest('a');
370
+ if (parentLink) {
371
+ const href = (parentLink.getAttribute('href') || '').toLowerCase();
372
+ const ariaLabel = (parentLink.getAttribute('aria-label') || '').toLowerCase();
373
+ if (href === '/' || href.match(/^https?:\/\/[^/]+\/?$/) || ariaLabel.includes('home'))
374
+ qualifies = true;
375
+ }
376
+ }
377
+ if (qualifies) {
378
+ const score = scoreLogo(el, context);
379
+ candidates.push({ el, score, context });
380
+ }
381
+ }
382
+ catch { }
383
+ });
384
+ // CSS background-image logos
385
+ container.querySelectorAll('a, [class*="logo"], [id*="logo"], header > *, nav > *').forEach(el => {
386
+ try {
387
+ const s = getComputedStyle(el);
388
+ const bg = s.backgroundImage;
389
+ if (!bg || bg === 'none')
390
+ return;
391
+ const urlMatch = bg.match(/url\(["']?([^"')]+)["']?\)/);
392
+ if (!urlMatch)
393
+ return;
394
+ const imgUrl = urlMatch[1];
395
+ if (!/\.(svg|png|webp|gif)(\?|$)/i.test(imgUrl) && !imgUrl.includes('logo') && !imgUrl.includes('brand'))
396
+ return;
397
+ const rect = el.getBoundingClientRect();
398
+ if (rect.width === 0 || rect.height === 0)
399
+ return;
400
+ candidates.push({
401
+ el: null,
402
+ score: scoreLogo(el, context) + 10,
403
+ context,
404
+ cssBackground: {
405
+ source: 'css-background',
406
+ context,
407
+ url: new URL(imgUrl, window.location.href).href,
408
+ width: rect.width,
409
+ height: rect.height,
410
+ type: rect.width / rect.height > 2 ? 'wordmark' : 'logomark',
411
+ reversed: !isLight(toHex(s.backgroundColor)),
412
+ background: toHex(s.backgroundColor),
413
+ safeZone: { top: parseFloat(s.paddingTop), right: parseFloat(s.paddingRight), bottom: parseFloat(s.paddingBottom), left: parseFloat(s.paddingLeft) },
414
+ position: { top: rect.top, left: rect.left },
415
+ }
416
+ });
417
+ }
418
+ catch { }
419
+ });
420
+ return candidates;
421
+ }
422
+ // SPA/PWA apps often render into a root div — treat it as transparent wrapper
423
+ const spaRoot = document.querySelector('#app, #root, #__next, #__nuxt, [data-reactroot]');
424
+ const headerEl = document.querySelector('header, [role="banner"], [class*="header"], [id*="header"]') || (() => {
425
+ // Fallback: find first visually top element in SPA root or body that spans full width
426
+ const root = spaRoot || document.body;
427
+ const children = Array.from(root.children);
428
+ for (const child of children) {
429
+ const rect = child.getBoundingClientRect();
430
+ const s = getComputedStyle(child);
431
+ if (rect.top < 100 && rect.width > window.innerWidth * 0.5 && s.display !== 'none') {
432
+ return child;
433
+ }
434
+ }
435
+ return null;
436
+ })();
437
+ const navEl = document.querySelector('nav, [role="navigation"]');
438
+ const footerEl = document.querySelector('footer, [role="contentinfo"], [class*="footer"], [id*="footer"]');
439
+ // Hero: first large section that's not header/footer
440
+ const heroEl = (() => {
441
+ const sections = document.querySelectorAll('main > *:first-child, [class*="hero"], [class*="Hero"], [class*="banner"]:not([role="banner"])');
442
+ for (const s of sections) {
443
+ const rect = s.getBoundingClientRect();
444
+ if (rect.height > 200)
445
+ return s;
446
+ }
447
+ return null;
448
+ })();
449
+ // Sidebar: fixed/sticky left-edge containers and semantic sidebar elements
450
+ // Logo is typically in the first 1-3 children of a sidebar
451
+ const sidebarEl = (() => {
452
+ // Semantic sidebar selectors
453
+ const candidates = document.querySelectorAll('aside, [role="complementary"], [class*="sidebar"], [class*="side-nav"], [class*="sidenav"], [class*="side-bar"], [id*="sidebar"]');
454
+ for (const el of candidates) {
455
+ const rect = el.getBoundingClientRect();
456
+ if (rect.width > 0 && rect.height > 100)
457
+ return el;
458
+ }
459
+ // Fallback: fixed/sticky element pinned to left edge
460
+ const all = document.querySelectorAll('*');
461
+ for (const el of all) {
462
+ try {
463
+ const s = getComputedStyle(el);
464
+ if ((s.position === 'fixed' || s.position === 'sticky') && s.display !== 'none') {
465
+ const rect = el.getBoundingClientRect();
466
+ if (rect.left < 100 && rect.height > 200 && rect.width < 400)
467
+ return el;
468
+ }
469
+ }
470
+ catch { }
471
+ }
472
+ return null;
473
+ })();
474
+ // For header: scan first 3 children and their direct children only
475
+ function firstChildrenZone(container, context) {
476
+ if (!container)
477
+ return [];
478
+ const candidates = [];
479
+ const children = Array.from(container.children).slice(0, 3);
480
+ for (const child of children) {
481
+ // Scan the child itself + its direct children
482
+ const els = [child, ...Array.from(child.children)];
483
+ for (const el of els) {
484
+ if (el.matches('img, svg')) {
485
+ const found = findLogosInZone(el.parentElement || container, context);
486
+ candidates.push(...found.filter(c => c.el === el || c.cssBackground));
487
+ }
488
+ }
489
+ // Also run full zone scan on the first child subtree (logo is often nested 1-2 levels)
490
+ candidates.push(...findLogosInZone(child, context));
491
+ }
492
+ return candidates;
493
+ }
494
+ // For sidebar: scan first 3 children and their direct children
495
+ function sidebarZone(container) {
496
+ if (!container)
497
+ return [];
498
+ const children = Array.from(container.children).slice(0, 3);
499
+ const candidates = [];
500
+ for (const child of children) {
501
+ candidates.push(...findLogosInZone(child, 'header'));
502
+ }
503
+ return candidates;
504
+ }
505
+ // Global pre-scan: strong semantic signals that identify a logo anywhere in the document
506
+ const globalLogoCandidates = (() => {
507
+ const results = [];
508
+ const selectors = [
509
+ 'img[class*="logo"]',
510
+ 'img[id*="logo"]',
511
+ 'a[class*="logo"] img',
512
+ 'a[id*="logo"] img',
513
+ '[class*="logo-link"] img',
514
+ '[class*="logo-wrap"] img',
515
+ '[class*="logo-container"] img',
516
+ 'a[href="/"] img',
517
+ 'a[href="./"] img',
518
+ ];
519
+ const seen = new Set();
520
+ for (const sel of selectors) {
521
+ try {
522
+ document.querySelectorAll(sel).forEach(el => {
523
+ if (seen.has(el))
524
+ return;
525
+ seen.add(el);
526
+ const rect = el.getBoundingClientRect();
527
+ if (rect.width === 0 || rect.height === 0)
528
+ return;
529
+ if (rect.top > 500)
530
+ return; // must be near top
531
+ if (rect.width > 600 || rect.height > 200)
532
+ return; // too large
533
+ const score = scoreLogo(el, 'header') + 20; // bonus for semantic match
534
+ results.push({ el, score, context: 'header' });
535
+ });
536
+ }
537
+ catch { }
538
+ }
539
+ return results;
540
+ })();
541
+ // Deduplicate zones — nav might be inside header, avoid double-scanning
542
+ const headerCandidates = firstChildrenZone(headerEl, 'header');
543
+ const navCandidates = (navEl && !headerEl?.contains(navEl))
544
+ ? firstChildrenZone(navEl, 'header')
545
+ : [];
546
+ const sidebarCandidates = (sidebarEl && !headerEl?.contains(sidebarEl))
547
+ ? sidebarZone(sidebarEl)
548
+ : [];
549
+ const allCandidates = [
550
+ ...globalLogoCandidates,
551
+ ...headerCandidates,
552
+ ...navCandidates,
553
+ ...sidebarCandidates,
554
+ ...findLogosInZone(footerEl, 'footer'),
555
+ ...findLogosInZone(heroEl, 'hero'),
556
+ ];
557
+ allCandidates.sort((a, b) => b.score - a.score);
558
+ // Primary logo = highest scoring
559
+ const primary = allCandidates[0];
560
+ let primaryLogo = null;
561
+ if (primary) {
562
+ if (primary.cssBackground) {
563
+ primaryLogo = primary.cssBackground;
564
+ }
565
+ else if (primary.el) {
566
+ primaryLogo = extractLogoFromEl(primary.el, primary.context, baseUrl);
567
+ }
568
+ }
569
+ // Collect all unique instances (header, footer, hero)
570
+ const instances = [];
571
+ const seenContexts = new Set();
572
+ for (const c of allCandidates) {
573
+ if (seenContexts.has(c.context))
574
+ continue;
575
+ seenContexts.add(c.context);
576
+ let inst = null;
577
+ if (c.cssBackground)
578
+ inst = c.cssBackground;
579
+ else if (c.el)
580
+ inst = extractLogoFromEl(c.el, c.context, baseUrl);
581
+ if (inst)
582
+ instances.push(inst);
583
+ }
584
+ // Favicons
585
+ const favicons = [];
586
+ document.querySelectorAll('link[rel*="icon"], link[rel="apple-touch-icon"]').forEach(link => {
587
+ const href = link.getAttribute('href');
588
+ if (href) {
589
+ try {
590
+ favicons.push({
591
+ type: link.getAttribute('rel'),
592
+ url: new URL(href, baseUrl).href,
593
+ sizes: link.getAttribute('sizes') || null,
594
+ });
595
+ }
596
+ catch { }
597
+ }
598
+ });
599
+ const ogImage = document.querySelector('meta[property="og:image"]');
600
+ if (ogImage?.getAttribute('content')) {
601
+ try {
602
+ favicons.push({ type: 'og:image', url: new URL(ogImage.getAttribute('content'), baseUrl).href, sizes: null });
603
+ }
604
+ catch { }
605
+ }
606
+ const twitterImage = document.querySelector('meta[name="twitter:image"]');
607
+ if (twitterImage?.getAttribute('content')) {
608
+ try {
609
+ favicons.push({ type: 'twitter:image', url: new URL(twitterImage.getAttribute('content'), baseUrl).href, sizes: null });
610
+ }
611
+ catch { }
612
+ }
613
+ // Only synthesize the /favicon.ico fallback when the page declares no icon at
614
+ // all. Sites that ship their icon under another name (e.g. favicon-purple.ico)
615
+ // often 404 on the bare /favicon.ico, which would surface as a broken entry.
616
+ if (!favicons.some(f => /icon/i.test(f.type || ''))) {
617
+ favicons.push({ type: 'favicon.ico', url: new URL('/favicon.ico', baseUrl).href, sizes: null });
618
+ }
619
+ return { logo: primaryLogo, instances, favicons };
620
+ }, url);
621
+ // Merge PWA icons into favicons
622
+ result.favicons = [...result.favicons, ...pwaIcons];
623
+ result.manifest = Object.keys(manifestMeta).length > 0 ? manifestMeta : null;
624
+ return result;
625
+ }
626
+ //# sourceMappingURL=logo.js.map