brandpull 0.1.2

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,860 @@
1
+ import type { RawBrandingData } from "./types"
2
+
3
+ export function extractBrandingFromPage(): RawBrandingData {
4
+ const constants = {
5
+ buttonMinWidth: 50,
6
+ buttonMinHeight: 25,
7
+ buttonMinPaddingVertical: 3,
8
+ buttonMinPaddingHorizontal: 6,
9
+ maxParentTraversal: 5,
10
+ maxBackgroundSamples: 100,
11
+ minSignificantArea: 1000,
12
+ minLargeContainerArea: 10000,
13
+ minLogoSize: 25,
14
+ minAlphaThreshold: 0.1,
15
+ maxTransparentAlpha: 0.01,
16
+ topPageThreshold: 500,
17
+ }
18
+
19
+ const errors: Array<{ context: string; message: string; timestamp: number }> = []
20
+ const recordError = (context: string, error: unknown) => {
21
+ errors.push({
22
+ context,
23
+ message: error && (error as Error).message ? (error as Error).message : String(error),
24
+ timestamp: Date.now(),
25
+ })
26
+ }
27
+
28
+ let nativeGetComputedStyle: (el: Element) => CSSStyleDeclaration
29
+ try {
30
+ const native = Window.prototype.getComputedStyle
31
+ nativeGetComputedStyle = native ? native.bind(window) : window.getComputedStyle.bind(window)
32
+ } catch {
33
+ nativeGetComputedStyle = () => ({}) as CSSStyleDeclaration
34
+ }
35
+
36
+ const styleCache = new WeakMap<Element, CSSStyleDeclaration>()
37
+ const getStyle = (el: Element): CSSStyleDeclaration => {
38
+ try {
39
+ if (!el || !(el instanceof Element)) return nativeGetComputedStyle(document.documentElement)
40
+ const cached = styleCache.get(el)
41
+ if (cached) return cached
42
+ const style = nativeGetComputedStyle(el)
43
+ styleCache.set(el, style)
44
+ return style
45
+ } catch (error) {
46
+ recordError("getComputedStyle", error)
47
+ return nativeGetComputedStyle(document.documentElement)
48
+ }
49
+ }
50
+
51
+ const classString = (el: Element | null): string => {
52
+ if (!el) return ""
53
+ try {
54
+ const cn = el.className as unknown
55
+ if (typeof cn === "string") return cn
56
+ if (cn && typeof cn === "object" && "baseVal" in cn) return String((cn as { baseVal: string }).baseVal || "")
57
+ return el.getAttribute("class") || String(cn || "")
58
+ } catch {
59
+ return ""
60
+ }
61
+ }
62
+
63
+ const toPx = (value: string | null | undefined): number | null => {
64
+ if (!value || value === "auto") return null
65
+ if (value.endsWith("px")) return Number.parseFloat(value)
66
+ if (value.endsWith("rem"))
67
+ return Number.parseFloat(value) * Number.parseFloat(getStyle(document.documentElement).fontSize || "16")
68
+ if (value.endsWith("em"))
69
+ return (
70
+ Number.parseFloat(value) *
71
+ Number.parseFloat(getStyle(document.body ?? document.documentElement).fontSize || "16")
72
+ )
73
+ if (value.endsWith("%")) return null
74
+ const parsed = Number.parseFloat(value)
75
+ return Number.isFinite(parsed) ? parsed : null
76
+ }
77
+
78
+ const textOf = (el: Element | null, limit = 100) =>
79
+ (el?.textContent || "").trim().replace(/\s+/g, " ").slice(0, limit)
80
+
81
+ const resolveUrl = (raw: string | null | undefined): string => {
82
+ if (!raw) return ""
83
+ if (raw.startsWith("data:")) return raw
84
+ try {
85
+ return new URL(raw, window.location.href).href
86
+ } catch {
87
+ return raw
88
+ }
89
+ }
90
+
91
+ const isSameBrandHost = (a: string, b: string): boolean => {
92
+ if (a === b) return true
93
+ const al = a.replace(/^www\./, "").split(".")[0] || ""
94
+ const bl = b.replace(/^www\./, "").split(".")[0] || ""
95
+ return al.length > 1 && al === bl
96
+ }
97
+
98
+ const isHomeHref = (href: string | null | undefined): boolean => {
99
+ if (!href) return false
100
+ const trimmed = href.trim()
101
+ if (["", "/", "./", "/home", "/index", "/index.html", "#"].includes(trimmed)) return true
102
+ if (trimmed.startsWith("#") || trimmed.startsWith("?")) return true
103
+ try {
104
+ const url = new URL(trimmed, window.location.origin)
105
+ if (!isSameBrandHost(window.location.hostname.toLowerCase(), url.hostname.toLowerCase())) return false
106
+ const path = url.pathname.replace(/\/$/, "") || "/"
107
+ return (
108
+ path === "/" ||
109
+ path === "/home" ||
110
+ path === "/index" ||
111
+ path === "/index.html" ||
112
+ path.split("/").filter(Boolean).length === 1
113
+ )
114
+ } catch {
115
+ const parts = trimmed.split("/").filter(Boolean)
116
+ return parts.length === 1 && !trimmed.includes(".")
117
+ }
118
+ }
119
+
120
+ const isExternalServiceHref = (href: string): boolean => {
121
+ const lower = href.toLowerCase()
122
+ if (!(lower.startsWith("http://") || lower.startsWith("https://") || lower.startsWith("//"))) return false
123
+ const services = [
124
+ "github.com",
125
+ "twitter.com",
126
+ "x.com",
127
+ "facebook.com",
128
+ "linkedin.com",
129
+ "instagram.com",
130
+ "youtube.com",
131
+ "discord.com",
132
+ "slack.com",
133
+ "npmjs.com",
134
+ "pypi.org",
135
+ "shields.io",
136
+ "vercel.com",
137
+ "netlify.com",
138
+ ]
139
+ try {
140
+ const url = new URL(href, window.location.origin)
141
+ if (isSameBrandHost(window.location.hostname.toLowerCase(), url.hostname.toLowerCase())) return false
142
+ return services.some((service) => lower.includes(service))
143
+ } catch {
144
+ return true
145
+ }
146
+ }
147
+
148
+ const queryAllDeep = (selector: string): Element[] => {
149
+ const results: Element[] = []
150
+ const seen = new Set<Document | ShadowRoot>()
151
+ const walk = (root: Document | ShadowRoot) => {
152
+ if (!root || seen.has(root)) return
153
+ seen.add(root)
154
+ try {
155
+ root.querySelectorAll(selector).forEach((el) => {
156
+ results.push(el)
157
+ })
158
+ root.querySelectorAll("*").forEach((el) => {
159
+ const shadowRoot = (el as Element & { shadowRoot?: ShadowRoot }).shadowRoot
160
+ if (shadowRoot) walk(shadowRoot)
161
+ })
162
+ } catch (error) {
163
+ recordError(`queryAllDeep:${selector}`, error)
164
+ }
165
+ }
166
+ walk(document)
167
+ return results
168
+ }
169
+
170
+ const isValidBackgroundColor = (color: string | null | undefined): boolean => {
171
+ if (!color) return false
172
+ const normalized = color.toLowerCase().trim()
173
+ if (normalized === "transparent" || normalized === "rgba(0, 0, 0, 0)") return false
174
+ const rgba = normalized.match(/rgba\(\s*0\s*,\s*0\s*,\s*0\s*,\s*([\d.]+)\s*\)/)
175
+ if (rgba) return Number.parseFloat(rgba[1]!) >= constants.maxTransparentAlpha
176
+ return normalized.length > 0
177
+ }
178
+
179
+ const normalizeColor = (color: string | null | undefined): string | null => {
180
+ if (!color) return null
181
+ const normalized = color.toLowerCase().trim()
182
+ if (!isValidBackgroundColor(normalized)) return null
183
+ return normalized.replace(/\s+/g, "")
184
+ }
185
+
186
+ const collectCSSData = () => {
187
+ const data = { colors: [] as string[], spacings: [] as number[], radii: [] as number[] }
188
+ for (const sheet of Array.from(document.styleSheets)) {
189
+ let rules: CSSRuleList | null = null
190
+ try {
191
+ rules = sheet.cssRules
192
+ } catch {
193
+ continue
194
+ }
195
+ for (const rule of Array.from(rules || [])) {
196
+ try {
197
+ if (rule.type !== CSSRule.STYLE_RULE) continue
198
+ const style = (rule as CSSStyleRule).style
199
+ for (const prop of ["color", "background-color", "border-color", "fill", "stroke"]) {
200
+ const value = style.getPropertyValue(prop)
201
+ if (value) data.colors.push(value)
202
+ }
203
+ for (const prop of [
204
+ "border-radius",
205
+ "border-top-left-radius",
206
+ "border-top-right-radius",
207
+ "border-bottom-left-radius",
208
+ "border-bottom-right-radius",
209
+ ]) {
210
+ const value = toPx(style.getPropertyValue(prop))
211
+ if (value) data.radii.push(value)
212
+ }
213
+ for (const prop of [
214
+ "margin",
215
+ "margin-top",
216
+ "margin-right",
217
+ "margin-bottom",
218
+ "margin-left",
219
+ "padding",
220
+ "padding-top",
221
+ "padding-right",
222
+ "padding-bottom",
223
+ "padding-left",
224
+ "gap",
225
+ "row-gap",
226
+ "column-gap",
227
+ ]) {
228
+ const value = toPx(style.getPropertyValue(prop))
229
+ if (value) data.spacings.push(value)
230
+ }
231
+ } catch (error) {
232
+ recordError("collectCSSData:rule", error)
233
+ }
234
+ }
235
+ }
236
+ return data
237
+ }
238
+
239
+ const isButtonElement = (el: Element | null): boolean => {
240
+ if (!el || typeof el.matches !== "function") return false
241
+ const selector =
242
+ 'button,input[type="submit"],input[type="button"],[role=button],[data-primary-button],[data-secondary-button],[data-cta],a.button,a.btn,[class*="btn"],[class*="button"],a[class*="bg-brand"],a[class*="bg-primary"],a[class*="bg-accent"]'
243
+ if (el.matches(selector)) return true
244
+ if (el.tagName.toLowerCase() !== "a") return false
245
+ const style = getStyle(el)
246
+ const rect = el.getBoundingClientRect()
247
+ const classes = classString(el).toLowerCase()
248
+ const hasButtonClasses =
249
+ /rounded(-md|-lg|-xl|-full)?|p[xy]?-\d+|border.*rounded|inline-flex.*items-center.*justify-center/.test(classes)
250
+ const hasPadding =
251
+ (Number.parseFloat(style.paddingTop) || 0) > constants.buttonMinPaddingVertical ||
252
+ (Number.parseFloat(style.paddingBottom) || 0) > constants.buttonMinPaddingVertical ||
253
+ (Number.parseFloat(style.paddingLeft) || 0) > constants.buttonMinPaddingHorizontal ||
254
+ (Number.parseFloat(style.paddingRight) || 0) > constants.buttonMinPaddingHorizontal
255
+ const hasSize = rect.width > constants.buttonMinWidth && rect.height > constants.buttonMinHeight
256
+ const hasShape =
257
+ (Number.parseFloat(style.borderRadius) || 0) > 0 || (Number.parseFloat(style.borderTopWidth) || 0) > 0
258
+ return (hasButtonClasses && hasSize) || (hasPadding && hasSize && hasShape)
259
+ }
260
+
261
+ const sampleElements = (): Element[] => {
262
+ const set = new Set<Element>()
263
+ const push = (selector: string, limit: number) => {
264
+ let count = 0
265
+ for (const el of Array.from(document.querySelectorAll(selector))) {
266
+ if (count >= limit) break
267
+ set.add(el)
268
+ count++
269
+ }
270
+ }
271
+ push('header img, header svg, nav img, nav svg, .site-logo img, img[alt*="logo" i], img[src*="logo" i]', 20)
272
+ push(
273
+ 'button, input[type="submit"], input[type="button"], [role=button], [data-primary-button], [data-secondary-button], [data-cta], a.button, a.btn, [class*="btn"], [class*="button"], a[class*="bg-brand"], a[class*="bg-primary"], a[class*="bg-accent"]',
274
+ 120,
275
+ )
276
+ for (const link of Array.from(document.querySelectorAll("a")).slice(0, 150)) {
277
+ if (!set.has(link) && isButtonElement(link)) set.add(link)
278
+ }
279
+ push('input, select, textarea, [class*="form-control"]', 35)
280
+ push("h1, h2, h3, p, a", 80)
281
+ return Array.from(set).filter(Boolean)
282
+ }
283
+
284
+ const getStyleSnapshot = (el: Element) => {
285
+ const style = getStyle(el)
286
+ const rect = el.getBoundingClientRect()
287
+ const tag = el.tagName.toLowerCase()
288
+ const classes = classString(el).toLowerCase()
289
+ const fontStack = (style.fontFamily || "")
290
+ .split(",")
291
+ .map((font) => font.replace(/["']/g, "").trim())
292
+ .filter(Boolean)
293
+
294
+ let background = style.getPropertyValue("background-color")
295
+ const transparent = background === "transparent" || background === "rgba(0, 0, 0, 0)"
296
+ const isInputElement = tag === "input" || tag === "select" || tag === "textarea"
297
+ if (transparent && !isInputElement) {
298
+ let parent = el.parentElement
299
+ let depth = 0
300
+ while (parent && depth < constants.maxParentTraversal) {
301
+ const parentBg = getStyle(parent).getPropertyValue("background-color")
302
+ if (isValidBackgroundColor(parentBg)) {
303
+ background = parentBg
304
+ break
305
+ }
306
+ parent = parent.parentElement
307
+ depth++
308
+ }
309
+ }
310
+
311
+ const hasCTAIndicator =
312
+ el.matches('[data-primary-button],[data-secondary-button],[data-cta],[class*="cta"],[class*="hero"]') ||
313
+ el.getAttribute("data-primary-button") === "true" ||
314
+ el.getAttribute("data-secondary-button") === "true"
315
+ const isNavigation =
316
+ !hasCTAIndicator &&
317
+ (/nav-|nav-|nav-link|sidebar|menu|toggle|trigger/.test(classes) ||
318
+ el.matches('[role="tab"],[role="menuitem"],[aria-haspopup],[aria-expanded]') ||
319
+ !!el.closest(
320
+ 'nav, [role="navigation"], [role="menu"], [role="menubar"], [class*="navigation"], [class*="dropdown"], [class*="sidebar"], aside',
321
+ ))
322
+ const isButton = isButtonElement(el) && !isNavigation
323
+ const isInput = el.matches(
324
+ 'input:not([type="submit"]):not([type="button"]),select,textarea,[class*="form-control"]',
325
+ )
326
+ const input = el as HTMLInputElement
327
+ const text =
328
+ tag === "input" && (input.type === "submit" || input.type === "button")
329
+ ? (input.value || "").trim().slice(0, 100)
330
+ : textOf(el, 100)
331
+
332
+ const inputMetadata = isInput
333
+ ? {
334
+ type: tag === "input" ? input.type || "text" : tag,
335
+ placeholder: input.placeholder || "",
336
+ value: tag === "input" ? input.value || "" : "",
337
+ required: input.required || false,
338
+ disabled: input.disabled || false,
339
+ name: input.name || "",
340
+ id: el.id || "",
341
+ label: (() => {
342
+ if (el.id) {
343
+ const label = document.querySelector(`label[for="${el.id.replace(/"/g, '\\"')}"]`)
344
+ if (label) return textOf(label, 100)
345
+ }
346
+ const parentLabel = el.closest("label")
347
+ if (!parentLabel) return ""
348
+ const clone = parentLabel.cloneNode(true) as HTMLElement
349
+ clone.querySelector("input,select,textarea")?.remove()
350
+ return textOf(clone, 100)
351
+ })(),
352
+ }
353
+ : null
354
+
355
+ const borderTop = style.getPropertyValue("border-top-color")
356
+ const borderRight = style.getPropertyValue("border-right-color")
357
+ const borderBottom = style.getPropertyValue("border-bottom-color")
358
+ const borderLeft = style.getPropertyValue("border-left-color")
359
+ const borderTopWidth = toPx(style.getPropertyValue("border-top-width"))
360
+ const borderRightWidth = toPx(style.getPropertyValue("border-right-width"))
361
+ const borderBottomWidth = toPx(style.getPropertyValue("border-bottom-width"))
362
+ const borderLeftWidth = toPx(style.getPropertyValue("border-left-width"))
363
+
364
+ return {
365
+ tag,
366
+ classes,
367
+ text,
368
+ rect: { w: rect.width, h: rect.height },
369
+ colors: {
370
+ text: style.getPropertyValue("color"),
371
+ background,
372
+ border:
373
+ borderTop === borderRight && borderTop === borderBottom && borderTop === borderLeft ? borderTop : borderTop,
374
+ borderWidth:
375
+ borderTopWidth === borderRightWidth &&
376
+ borderTopWidth === borderBottomWidth &&
377
+ borderTopWidth === borderLeftWidth
378
+ ? borderTopWidth
379
+ : borderTopWidth,
380
+ borderTop,
381
+ borderTopWidth,
382
+ borderRight,
383
+ borderRightWidth,
384
+ borderBottom,
385
+ borderBottomWidth,
386
+ borderLeft,
387
+ borderLeftWidth,
388
+ },
389
+ typography: {
390
+ fontStack,
391
+ size: style.getPropertyValue("font-size") || null,
392
+ weight: Number.parseInt(style.getPropertyValue("font-weight"), 10) || null,
393
+ },
394
+ radius: toPx(style.getPropertyValue("border-radius")),
395
+ borderRadius: {
396
+ topLeft: toPx(style.getPropertyValue("border-top-left-radius")),
397
+ topRight: toPx(style.getPropertyValue("border-top-right-radius")),
398
+ bottomRight: toPx(style.getPropertyValue("border-bottom-right-radius")),
399
+ bottomLeft: toPx(style.getPropertyValue("border-bottom-left-radius")),
400
+ },
401
+ shadow: style.getPropertyValue("box-shadow") || null,
402
+ isButton,
403
+ isNavigation,
404
+ hasCTAIndicator,
405
+ isInput,
406
+ inputMetadata,
407
+ isLink: el.matches("a"),
408
+ }
409
+ }
410
+
411
+ const backgroundImageUrl = (value: string | null): string | null => {
412
+ if (!value || value === "none") return null
413
+ const quoted = value.match(/url\((["'])(.*?)\1\)/)
414
+ const simple = quoted?.[2] ?? value.match(/url\(([^)]+?)\)/)?.[1]
415
+ if (!simple) return null
416
+ return simple
417
+ .trim()
418
+ .replace(/^["']|["']$/g, "")
419
+ .replace(/&quot;/g, '"')
420
+ .replace(/&lt;/g, "<")
421
+ .replace(/&gt;/g, ">")
422
+ }
423
+
424
+ const serializeSvg = (svg: SVGSVGElement): string => {
425
+ const clone = svg.cloneNode(true) as SVGSVGElement
426
+ const originals = [svg, ...Array.from(svg.querySelectorAll("*"))]
427
+ const clones = [clone, ...Array.from(clone.querySelectorAll("*"))]
428
+ const props = ["fill", "stroke", "color", "stop-color", "stroke-width", "opacity", "fill-opacity", "stroke-opacity"]
429
+ for (let i = 0; i < clones.length; i++) {
430
+ const original = originals[i]
431
+ const cloned = clones[i]
432
+ if (!original || !cloned) continue
433
+ const style = getStyle(original)
434
+ for (const prop of props) {
435
+ const value = style.getPropertyValue(prop)
436
+ if (value?.trim() && value !== "none") (cloned as HTMLElement).style.setProperty(prop, value)
437
+ }
438
+ }
439
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(new XMLSerializer().serializeToString(clone))}`
440
+ }
441
+
442
+ const findImages = () => {
443
+ const images: Array<{ type: string; src: string }> = []
444
+ const logoCandidates: RawBrandingData["logoCandidates"] = []
445
+ const pushImage = (src: string | null | undefined, type: string) => {
446
+ const resolved = resolveUrl(src)
447
+ if (resolved) images.push({ type, src: resolved })
448
+ }
449
+
450
+ pushImage((document.querySelector('link[rel*="icon" i]') as HTMLLinkElement | null)?.href, "favicon")
451
+ pushImage((document.querySelector('meta[property="og:image" i]') as HTMLMetaElement | null)?.content, "og")
452
+ pushImage((document.querySelector('meta[name="twitter:image" i]') as HTMLMetaElement | null)?.content, "twitter")
453
+
454
+ const collect = (el: Element, source: string) => {
455
+ try {
456
+ const rect = el.getBoundingClientRect()
457
+ const style = getStyle(el)
458
+ const parentLink = el.closest("a")
459
+ const href = parentLink?.getAttribute("href") || ""
460
+ if (href && isExternalServiceHref(href)) return
461
+
462
+ const inHeader = !!el.closest(
463
+ 'header, nav, [role="banner"], #navbar, [id*="navbar" i], [class*="navbar" i], [class*="globalnav" i], [role="menubar"]',
464
+ )
465
+ const inFooter = !!el.closest('footer, [class*="footer" i], [role="contentinfo"]')
466
+ const parentAria = parentLink?.getAttribute("aria-label") || ""
467
+ const ownAria = el.getAttribute("aria-label") || ""
468
+ const ownTitle = el.getAttribute("title") || el.querySelector("title")?.textContent || ""
469
+ const classes = classString(el).toLowerCase()
470
+ const id = (el.id || "").toLowerCase()
471
+ const tag = el.tagName.toLowerCase()
472
+ const bgUrl = backgroundImageUrl(style.getPropertyValue("background-image"))
473
+ const imgSrc = tag === "img" ? (el as HTMLImageElement).currentSrc || (el as HTMLImageElement).src : ""
474
+ const isSvg = tag === "svg"
475
+ const alt =
476
+ tag === "img"
477
+ ? ((el as HTMLImageElement).alt || parentAria || "").trim()
478
+ : (ownAria || ownTitle || textOf(el, 80) || id || "").trim()
479
+ const hasLogoData = /logo|brand|site-name|site-title/i.test(
480
+ `${classes} ${id} ${ownAria} ${ownTitle} ${parentAria}`,
481
+ )
482
+ const hasHome = isHomeHref(href)
483
+ const strongContext = inHeader || hasHome || hasLogoData
484
+ const isVisible =
485
+ rect.width > 0 &&
486
+ rect.height > 0 &&
487
+ style.display !== "none" &&
488
+ style.visibility !== "hidden" &&
489
+ style.opacity !== "0"
490
+
491
+ if (/flag|language|locale/i.test(`${classes} ${id} ${alt}`) && rect.width <= 32 && rect.height <= 32) return
492
+ if (
493
+ /search|magnif|hamburger|menu|cart|user|bell|chevron|arrow|caret|dropdown|close|settings/i.test(
494
+ `${classes} ${id} ${alt}`,
495
+ ) &&
496
+ !hasLogoData
497
+ )
498
+ return
499
+ if (!strongContext && !/logo|brand/i.test(`${imgSrc} ${bgUrl || ""} ${alt}`)) return
500
+
501
+ let src = ""
502
+ let finalSvg = false
503
+ let svgScore = 0
504
+ if (isSvg) {
505
+ finalSvg = true
506
+ const image = el.querySelector("image")
507
+ const imageHref = image?.getAttribute("href") || image?.getAttribute("xlink:href") || ""
508
+ src = imageHref ? resolveUrl(imageHref) : serializeSvg(el as SVGSVGElement)
509
+ const pathCount = el.querySelectorAll("path").length
510
+ const groupCount = el.querySelectorAll("g").length
511
+ svgScore += Math.min(pathCount * 2, 40) + Math.min(groupCount, 20)
512
+ if (el.querySelector("text")) svgScore -= 50
513
+ if (el.querySelector("animate, animateTransform, animateMotion")) svgScore += 30
514
+ const area = rect.width * rect.height
515
+ if (area > 10000) svgScore += 20
516
+ else if (area > 5000) svgScore += 10
517
+ else if (area < 1000) svgScore -= 20
518
+ } else if (imgSrc) {
519
+ src = resolveUrl(imgSrc)
520
+ } else if (bgUrl && strongContext) {
521
+ src = resolveUrl(bgUrl)
522
+ finalSvg = src.startsWith("data:image/svg+xml") || /\.svg(\?|#|$)/i.test(src)
523
+ }
524
+ if (!src) return
525
+
526
+ const srcMatch = /logo|brand/i.test(src) || /logo/i.test(id)
527
+ const altMatch = /logo|brand/i.test(alt)
528
+ const classMatch = hasLogoData || !!el.closest('[class*="logo" i], [id*="logo" i]')
529
+ if (!isVisible && !strongContext) return
530
+
531
+ const position = {
532
+ top: isVisible ? rect.top : 0,
533
+ left: isVisible ? rect.left : 0,
534
+ width: rect.width || Number.parseFloat(el.getAttribute("width") || "0") || 0,
535
+ height: rect.height || Number.parseFloat(el.getAttribute("height") || "0") || 0,
536
+ }
537
+ logoCandidates.push({
538
+ src,
539
+ alt,
540
+ ariaLabel: ownAria || parentAria || undefined,
541
+ title: ownTitle || undefined,
542
+ isSvg: finalSvg,
543
+ isVisible,
544
+ location: inHeader ? "header" : inFooter ? "footer" : "body",
545
+ position,
546
+ indicators: {
547
+ inHeader,
548
+ altMatch,
549
+ srcMatch,
550
+ classMatch,
551
+ hrefMatch: hasHome,
552
+ },
553
+ href: href || undefined,
554
+ source,
555
+ logoSvgScore: finalSvg ? (src.startsWith("data:image/svg+xml") ? Math.max(80, svgScore) : svgScore) : 100,
556
+ })
557
+ } catch (error) {
558
+ recordError("collectLogoCandidate", error)
559
+ }
560
+ }
561
+
562
+ const selectors = [
563
+ "header a img, header a svg, header img, header svg",
564
+ "nav a img, nav a svg, nav img, nav svg",
565
+ '[role="banner"] a img, [role="banner"] a svg, [role="banner"] img, [role="banner"] svg',
566
+ '[class*="header" i] a img, [class*="header" i] a svg, [class*="header" i] img, [class*="header" i] svg',
567
+ '[id*="header" i] a img, [id*="header" i] a svg, [id*="header" i] img, [id*="header" i] svg',
568
+ '[class*="navbar" i] a img, [class*="navbar" i] a svg, [class*="navbar" i] img, [class*="navbar" i] svg',
569
+ 'a[aria-label*="logo" i] img, a[aria-label*="logo" i] svg',
570
+ 'a[aria-label*="home" i] img, a[aria-label*="home" i] svg',
571
+ 'a[class*="logo" i] img, a[class*="logo" i] svg',
572
+ '[class*="logo" i] img, [class*="logo" i] svg',
573
+ '[id*="logo" i] img, [id*="logo" i] svg',
574
+ 'img[class*="logo" i], svg[class*="logo" i]',
575
+ 'img[src*="logo" i], img[alt*="logo" i]',
576
+ 'a[href="/"] img, a[href="/"] svg, a[href="./"] img, a[href="./"] svg',
577
+ ]
578
+ for (const selector of selectors) {
579
+ queryAllDeep(selector).forEach((el) => {
580
+ collect(el, selector)
581
+ })
582
+ }
583
+
584
+ const backgroundSelectors = [
585
+ '[class*="logo" i]',
586
+ '[id*="logo" i]',
587
+ "header a > div, header a > span, nav a > div, nav a > span",
588
+ 'a[aria-label*="logo" i] > div, a[aria-label*="home" i] > div',
589
+ 'div[data-framer-name*="logo" i], span[data-framer-name*="logo" i], div[data-name*="logo" i], span[data-name*="logo" i]',
590
+ ]
591
+ for (const selector of backgroundSelectors) {
592
+ queryAllDeep(selector).forEach((el) => {
593
+ if (backgroundImageUrl(getStyle(el).getPropertyValue("background-image"))) collect(el, `background:${selector}`)
594
+ })
595
+ }
596
+
597
+ queryAllDeep("img").forEach((img) => {
598
+ const alt = (img as HTMLImageElement).alt || ""
599
+ const src = (img as HTMLImageElement).src || ""
600
+ if (
601
+ /logo|brand/i.test(`${alt} ${src} ${classString(img)}`) &&
602
+ !img.closest('[class*="testimonial" i], [class*="client" i], [class*="partner" i], footer')
603
+ )
604
+ collect(img, "document.images")
605
+ })
606
+
607
+ queryAllDeep("svg").forEach((svg) => {
608
+ const rect = svg.getBoundingClientRect()
609
+ const existing = logoCandidates.some(
610
+ (c) =>
611
+ c.isSvg &&
612
+ Math.abs(c.position.top - rect.top) < 1 &&
613
+ Math.abs(c.position.left - rect.left) < 1 &&
614
+ Math.abs(c.position.width - rect.width) < 1,
615
+ )
616
+ if (!existing) {
617
+ const data = `${svg.id} ${classString(svg)} ${svg.getAttribute("aria-label") || ""} ${svg.querySelector("title")?.textContent || ""}`
618
+ if (
619
+ /logo|brand/i.test(data) ||
620
+ svg.closest('header, nav, [role="banner"], [class*="logo" i], [id*="logo" i]')
621
+ ) {
622
+ collect(svg, "document.querySelectorAll(svg)")
623
+ }
624
+ }
625
+ })
626
+
627
+ queryAllDeep("a[href]")
628
+ .filter((a) => isHomeHref(a.getAttribute("href")))
629
+ .flatMap((a) => Array.from(a.querySelectorAll("img, svg")))
630
+ .filter((el) => {
631
+ const rect = el.getBoundingClientRect()
632
+ return rect.top >= 0 && rect.top < constants.topPageThreshold && rect.width > 0 && rect.height > 0
633
+ })
634
+ .forEach((el) => {
635
+ collect(el, "fallback-top-home-link")
636
+ })
637
+
638
+ const bySrc = new Map<string, RawBrandingData["logoCandidates"][number]>()
639
+ for (const candidate of logoCandidates) {
640
+ const existing = bySrc.get(candidate.src)
641
+ if (!existing) {
642
+ bySrc.set(candidate.src, candidate)
643
+ continue
644
+ }
645
+ const area = candidate.position.width * candidate.position.height
646
+ const existingArea = existing.position.width * existing.position.height
647
+ if (
648
+ (candidate.isVisible && !existing.isVisible) ||
649
+ (candidate.indicators.hrefMatch && !existing.indicators.hrefMatch) ||
650
+ area > existingArea
651
+ ) {
652
+ bySrc.set(candidate.src, candidate)
653
+ }
654
+ }
655
+ const unique = Array.from(bySrc.values())
656
+ const pickable = unique.filter((c) => c.isVisible)
657
+ const best = (pickable.length ? pickable : unique).reduce<RawBrandingData["logoCandidates"][number] | null>(
658
+ (bestCandidate, candidate) => {
659
+ if (!bestCandidate) return candidate
660
+ if (
661
+ candidate.indicators.hrefMatch &&
662
+ candidate.indicators.inHeader &&
663
+ !(bestCandidate.indicators.hrefMatch && bestCandidate.indicators.inHeader)
664
+ )
665
+ return candidate
666
+ if (!candidate.isSvg && bestCandidate.isSvg) return candidate
667
+ if (candidate.indicators.inHeader && !bestCandidate.indicators.inHeader) return candidate
668
+ if (candidate.indicators.hrefMatch && !bestCandidate.indicators.hrefMatch) return candidate
669
+ const area = candidate.position.width * candidate.position.height
670
+ const bestArea = bestCandidate.position.width * bestCandidate.position.height
671
+ if (area >= constants.minSignificantArea && bestArea < constants.minSignificantArea) return candidate
672
+ return candidate.position.top < bestCandidate.position.top ? candidate : bestCandidate
673
+ },
674
+ null,
675
+ )
676
+
677
+ if (best) pushImage(best.src, best.isSvg ? "logo-svg" : "logo")
678
+ return { images, logoCandidates: unique }
679
+ }
680
+
681
+ const pickFontStack = (el: Element | null): string[] =>
682
+ (getStyle(el ?? document.documentElement).fontFamily || "")
683
+ .split(",")
684
+ .map((font) => font.replace(/["']/g, "").trim())
685
+ .filter(Boolean)
686
+
687
+ const getTypography = () => {
688
+ const body = document.body ?? document.documentElement
689
+ const h1 = document.querySelector("h1") ?? body
690
+ const h2 = document.querySelector("h2") ?? h1
691
+ const p = document.querySelector("p") ?? body
692
+ return {
693
+ stacks: {
694
+ body: pickFontStack(body),
695
+ heading: pickFontStack(h1),
696
+ paragraph: pickFontStack(p),
697
+ },
698
+ sizes: {
699
+ h1: getStyle(h1).fontSize || "32px",
700
+ h2: getStyle(h2).fontSize || "24px",
701
+ body: getStyle(p).fontSize || "16px",
702
+ },
703
+ }
704
+ }
705
+
706
+ const detectFrameworkHints = () => {
707
+ const hints = new Set<string>()
708
+ const generator = document.querySelector('meta[name="generator"]')?.getAttribute("content")
709
+ if (generator) hints.add(generator)
710
+ const scripts = Array.from(document.querySelectorAll("script[src]")).map((s) => s.getAttribute("src") || "")
711
+ if (scripts.some((s) => /tailwind|cdn\.tailwindcss/.test(s))) hints.add("tailwind")
712
+ if (scripts.some((s) => /bootstrap/.test(s))) hints.add("bootstrap")
713
+ if (scripts.some((s) => /mui|material-ui/.test(s))) hints.add("material-ui")
714
+ const classSample = Array.from(document.querySelectorAll("[class]")).slice(0, 250).map(classString).join(" ")
715
+ if (/\b(px|py|mx|my|bg|text|rounded|flex|grid|items-center|justify-)/.test(classSample)) hints.add("tailwind-like")
716
+ if (/\bbtn\b|\bcontainer\b|\brow\b|\bcol-/.test(classSample)) hints.add("bootstrap-like")
717
+ if (/Mui[A-Z]|chakra-|radix-|headlessui|react-aria/.test(classSample)) hints.add("component-library")
718
+ return Array.from(hints).filter(Boolean)
719
+ }
720
+
721
+ const detectColorScheme = (): "dark" | "light" => {
722
+ const html = document.documentElement
723
+ const body = document.body
724
+ const dark =
725
+ html.classList.contains("dark") ||
726
+ body?.classList.contains("dark") ||
727
+ html.getAttribute("data-theme") === "dark" ||
728
+ body?.getAttribute("data-theme") === "dark" ||
729
+ html.getAttribute("data-bs-theme") === "dark"
730
+ const light =
731
+ html.classList.contains("light") ||
732
+ body?.classList.contains("light") ||
733
+ html.getAttribute("data-theme") === "light" ||
734
+ body?.getAttribute("data-theme") === "light" ||
735
+ html.getAttribute("data-bs-theme") === "light"
736
+ if (dark) return "dark"
737
+ if (light) return "light"
738
+ const bg = getStyle(body ?? html).backgroundColor || getStyle(html).backgroundColor
739
+ const match = bg.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/)
740
+ if (match) {
741
+ const r = Number.parseInt(match[1]!, 10)
742
+ const g = Number.parseInt(match[2]!, 10)
743
+ const b = Number.parseInt(match[3]!, 10)
744
+ const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
745
+ if (luminance < 0.4) return "dark"
746
+ if (luminance > 0.6) return "light"
747
+ }
748
+ return window.matchMedia?.("(prefers-color-scheme: dark)")?.matches ? "dark" : "light"
749
+ }
750
+
751
+ const extractBrandName = () => {
752
+ const site = document.querySelector('meta[property="og:site_name"]')?.getAttribute("content")?.trim()
753
+ const app = document.querySelector('meta[name="application-name"]')?.getAttribute("content")?.trim()
754
+ const title = document.title || ""
755
+ const h1 = textOf(document.querySelector("h1"), 80)
756
+ let domain = ""
757
+ try {
758
+ domain = window.location.hostname.replace(/^www\./, "").split(".")[0] || ""
759
+ domain = domain.charAt(0).toUpperCase() + domain.slice(1)
760
+ } catch {}
761
+ const titleParts = title
762
+ .split(/\s(?:[|–—-]|::)\s/)
763
+ .map((part) => part.trim())
764
+ .filter(Boolean)
765
+ const titleBrand =
766
+ titleParts.length > 1 ? titleParts[titleParts.length - 1] : title.replace(/\s*[-|–—:].*$/, "").trim()
767
+ return site || app || titleBrand || h1 || domain || ""
768
+ }
769
+
770
+ const getBackgroundCandidates = () => {
771
+ const candidates: Array<{ color: string; source: string; priority: number; area?: number }> = []
772
+ const freq = new Map<string, number>()
773
+ for (const el of Array.from(
774
+ document.querySelectorAll("body, html, main, article, [role='main'], div, section"),
775
+ ).slice(0, constants.maxBackgroundSamples)) {
776
+ try {
777
+ const bg = getStyle(el).backgroundColor
778
+ if (!isValidBackgroundColor(bg)) continue
779
+ const rect = el.getBoundingClientRect()
780
+ const area = rect.width * rect.height
781
+ if (area <= constants.minSignificantArea) continue
782
+ const key = normalizeColor(bg)
783
+ if (key) freq.set(key, (freq.get(key) ?? 0) + area)
784
+ } catch (error) {
785
+ recordError("backgroundCandidates:element", error)
786
+ }
787
+ }
788
+ let mostCommon: string | null = null
789
+ let maxArea = 0
790
+ for (const [color, area] of freq.entries()) {
791
+ if (area > maxArea) {
792
+ maxArea = area
793
+ mostCommon = color
794
+ }
795
+ }
796
+ const add = (color: string | null | undefined, source: string, priority: number, area?: number) => {
797
+ const normalized = normalizeColor(color)
798
+ if (normalized) candidates.push({ color: normalized, source, priority, area })
799
+ }
800
+ const bodyBg = getStyle(document.body ?? document.documentElement).backgroundColor
801
+ const htmlBg = getStyle(document.documentElement).backgroundColor
802
+ add(bodyBg, "body", normalizeColor(bodyBg) === mostCommon ? 15 : 10)
803
+ add(htmlBg, "html", normalizeColor(htmlBg) === mostCommon ? 14 : 9)
804
+ if (mostCommon && mostCommon !== normalizeColor(bodyBg) && mostCommon !== normalizeColor(htmlBg)) {
805
+ add(mostCommon, "most-common-visible", 12, maxArea)
806
+ }
807
+ const root = getStyle(document.documentElement)
808
+ for (const name of [
809
+ "--background",
810
+ "--background-light",
811
+ "--background-dark",
812
+ "--bg-background",
813
+ "--color-background",
814
+ "--color-background-light",
815
+ "--color-background-dark",
816
+ ]) {
817
+ add(root.getPropertyValue(name).trim(), `css-var:${name}`, 8)
818
+ }
819
+ for (const el of Array.from(
820
+ document.querySelectorAll("main, article, [role='main'], header, .main, .container"),
821
+ ).slice(0, 5)) {
822
+ const rect = el.getBoundingClientRect()
823
+ if (rect.width * rect.height > constants.minLargeContainerArea) {
824
+ add(getStyle(el).backgroundColor, `${el.tagName.toLowerCase()}-container`, 5, rect.width * rect.height)
825
+ }
826
+ }
827
+ const seen = new Set<string>()
828
+ return candidates
829
+ .filter((candidate) => {
830
+ const key = normalizeColor(candidate.color)
831
+ if (!key || seen.has(key)) return false
832
+ seen.add(key)
833
+ return true
834
+ })
835
+ .sort((a, b) => b.priority - a.priority)
836
+ }
837
+
838
+ const cssData = collectCSSData()
839
+ const snapshots = sampleElements().map(getStyleSnapshot)
840
+ const imageData = findImages()
841
+ const typography = getTypography()
842
+ const backgroundCandidates = getBackgroundCandidates()
843
+ const pageBackground = backgroundCandidates[0]?.color ?? null
844
+
845
+ return {
846
+ cssData,
847
+ snapshots,
848
+ images: imageData.images,
849
+ logoCandidates: imageData.logoCandidates,
850
+ brandName: extractBrandName(),
851
+ pageTitle: document.title || "",
852
+ pageUrl: window.location.href,
853
+ typography,
854
+ frameworkHints: detectFrameworkHints(),
855
+ colorScheme: detectColorScheme(),
856
+ pageBackground,
857
+ backgroundCandidates,
858
+ errors: errors.length ? errors : undefined,
859
+ }
860
+ }