prism-design 2.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +292 -0
- package/LICENSE +21 -0
- package/README.md +203 -0
- package/bin/clone-architect.mjs +476 -0
- package/bin/prism.mjs +467 -0
- package/catalog/index.json +1155 -0
- package/extractions/airbnb.com/DESIGN.md +1068 -0
- package/extractions/airbnb.com/tokens.json +507 -0
- package/extractions/attio.com/DESIGN.md +1295 -0
- package/extractions/attio.com/tokens.json +438 -0
- package/extractions/auroxdashboard.com/DESIGN.md +724 -0
- package/extractions/auroxdashboard.com/tokens.json +195 -0
- package/extractions/careerexplorer.com/DESIGN.md +1178 -0
- package/extractions/careerexplorer.com/tokens.json +141 -0
- package/extractions/chance.co/DESIGN.md +1209 -0
- package/extractions/chance.co/tokens.json +160 -0
- package/extractions/choisis-ton-avenir.com/DESIGN.md +1265 -0
- package/extractions/choisis-ton-avenir.com/tokens.json +227 -0
- package/extractions/example.com/DESIGN.md +436 -0
- package/extractions/example.com/tokens.json +91 -0
- package/extractions/getdesign.md/DESIGN.md +1009 -0
- package/extractions/getdesign.md/tokens.json +219 -0
- package/extractions/github.com/DESIGN.md +1130 -0
- package/extractions/github.com/tokens.json +2092 -0
- package/extractions/hello-charly.com/DESIGN.md +1146 -0
- package/extractions/hello-charly.com/tokens.json +322 -0
- package/extractions/hyperliquid.xyz/DESIGN.md +779 -0
- package/extractions/hyperliquid.xyz/tokens.json +598 -0
- package/extractions/instagram.com/DESIGN.md +996 -0
- package/extractions/instagram.com/tokens.json +1240 -0
- package/extractions/jobirl.com/DESIGN.md +1160 -0
- package/extractions/jobirl.com/tokens.json +139 -0
- package/extractions/life360.com/DESIGN.md +1133 -0
- package/extractions/life360.com/tokens.json +491 -0
- package/extractions/lifesum.com/DESIGN.md +965 -0
- package/extractions/lifesum.com/tokens.json +170 -0
- package/extractions/linear.app/DESIGN.md +1301 -0
- package/extractions/linear.app/tokens.json +732 -0
- package/extractions/mavoie.org/DESIGN.md +1148 -0
- package/extractions/mavoie.org/tokens.json +128 -0
- package/extractions/miro.com/DESIGN.md +1237 -0
- package/extractions/miro.com/tokens.json +401 -0
- package/extractions/notion.so/DESIGN.md +1319 -0
- package/extractions/notion.so/tokens.json +906 -0
- package/extractions/onetonline.org/DESIGN.md +909 -0
- package/extractions/onetonline.org/tokens.json +280 -0
- package/extractions/posthog.com/DESIGN.md +1024 -0
- package/extractions/posthog.com/tokens.json +197 -0
- package/extractions/revolut.com/DESIGN.md +1080 -0
- package/extractions/revolut.com/tokens.json +401 -0
- package/extractions/stripe.com/DESIGN.md +1272 -0
- package/extractions/stripe.com/tokens.json +794 -0
- package/extractions/switchcollective.com/DESIGN.md +1040 -0
- package/extractions/switchcollective.com/tokens.json +98 -0
- package/extractions/truity.com/DESIGN.md +970 -0
- package/extractions/truity.com/tokens.json +166 -0
- package/extractions/uniquekicks.be/DESIGN.md +1171 -0
- package/extractions/uniquekicks.be/tokens.json +237 -0
- package/package.json +122 -0
- package/scripts/analyze.ts +281 -0
- package/scripts/bank-register.ts +379 -0
- package/scripts/bank.ts +374 -0
- package/scripts/browser-stealth.ts +189 -0
- package/scripts/clone.ts +198 -0
- package/scripts/compare-vs-gd-final.ts +273 -0
- package/scripts/compare-vs-gd.ts +269 -0
- package/scripts/compare.ts +405 -0
- package/scripts/deploy-site.ts +181 -0
- package/scripts/diff-snapshots.ts +340 -0
- package/scripts/enrich-catalog.ts +212 -0
- package/scripts/extract.ts +2038 -0
- package/scripts/extractors/advanced.ts +524 -0
- package/scripts/extractors/widgets.ts +711 -0
- package/scripts/generate-design-md.ts +5775 -0
- package/scripts/generate-final-pdf.ts +274 -0
- package/scripts/generate-og-image.ts +87 -0
- package/scripts/generate-showcase.ts +1588 -0
- package/scripts/generate-site.ts +847 -0
- package/scripts/mass-extract.sh +91 -0
- package/scripts/post-process-all.sh +55 -0
- package/scripts/regen-catalog.ts +203 -0
- package/scripts/shared/cache.ts +149 -0
- package/scripts/shared/css-helpers.ts +263 -0
- package/scripts/shared/logger.ts +57 -0
- package/scripts/shared/named-colors.ts +355 -0
- package/scripts/shared/types.ts +220 -0
- package/scripts/sync-catalog.ts +105 -0
- package/scripts/tokenize.ts +988 -0
- package/templates/layout-template.md +52 -0
- package/templates/tokens-template.json +34 -0
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* extractors/widgets.ts — Phase 4.1 Widget & Structure Extractor
|
|
3
|
+
*
|
|
4
|
+
* Identifies structural UI patterns beyond raw CSS — gives LLM agents the
|
|
5
|
+
* "blueprint" they need to clone a page architecturally, not just visually.
|
|
6
|
+
*
|
|
7
|
+
* Detects:
|
|
8
|
+
* - Hero patterns (full-bleed, centered, split-screen)
|
|
9
|
+
* - Navigation (sticky topbar, sidebar, mega-menu signals)
|
|
10
|
+
* - Card grids (N-up, aspect ratio, content density)
|
|
11
|
+
* - Pricing tables (2-4 column comparison)
|
|
12
|
+
* - Testimonials (quote blocks)
|
|
13
|
+
* - FAQ accordions
|
|
14
|
+
* - CTA banners
|
|
15
|
+
* - Footer composition
|
|
16
|
+
*
|
|
17
|
+
* Strategy: heuristic DOM analysis using string-based page.evaluate (avoids
|
|
18
|
+
* tsx __name() compilation issues — same pattern as advanced.ts).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { Page } from 'playwright';
|
|
22
|
+
|
|
23
|
+
export interface WidgetExtraction {
|
|
24
|
+
hero: HeroPattern | null;
|
|
25
|
+
navigation: NavPattern | null;
|
|
26
|
+
cardGrid: CardGridPattern | null;
|
|
27
|
+
pricingTable: PricingPattern | null;
|
|
28
|
+
testimonials: TestimonialPattern | null;
|
|
29
|
+
faq: FaqPattern | null;
|
|
30
|
+
ctaBanner: CtaPattern | null;
|
|
31
|
+
footer: FooterPattern | null;
|
|
32
|
+
/** Total structural patterns detected (signal quality) */
|
|
33
|
+
detectedCount: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface HeroPattern {
|
|
37
|
+
composition: 'centered' | 'split-left-text' | 'split-right-text' | 'full-bleed-image' | 'background-pattern';
|
|
38
|
+
hasMedia: boolean;
|
|
39
|
+
mediaPosition: 'left' | 'right' | 'top' | 'bottom' | 'background' | 'none';
|
|
40
|
+
headingText: string;
|
|
41
|
+
headingFontSize: number;
|
|
42
|
+
subheadingText: string;
|
|
43
|
+
ctaCount: number;
|
|
44
|
+
ctaPrimaryText: string;
|
|
45
|
+
heightPx: number;
|
|
46
|
+
isFullViewport: boolean;
|
|
47
|
+
contentAlignment: 'left' | 'center' | 'right';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface NavPattern {
|
|
51
|
+
position: 'sticky' | 'fixed' | 'static';
|
|
52
|
+
hasLogo: boolean;
|
|
53
|
+
navItemCount: number;
|
|
54
|
+
ctaCount: number;
|
|
55
|
+
hasDarkMode: boolean; // toggle button detected?
|
|
56
|
+
hasSearchBar: boolean;
|
|
57
|
+
heightPx: number;
|
|
58
|
+
layout: 'logo-left-links-right' | 'logo-center-links-split' | 'logo-left-links-left' | 'unknown';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface CardGridPattern {
|
|
62
|
+
cardCount: number;
|
|
63
|
+
columnsDesktop: number;
|
|
64
|
+
cardAspectRatio: number; // width / height
|
|
65
|
+
cardHasImage: boolean;
|
|
66
|
+
cardHasIcon: boolean;
|
|
67
|
+
cardHasCta: boolean;
|
|
68
|
+
imagePosition: 'top' | 'left' | 'background' | 'none';
|
|
69
|
+
averageCardWidthPx: number;
|
|
70
|
+
averageCardHeightPx: number;
|
|
71
|
+
gapPx: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface PricingPattern {
|
|
75
|
+
tierCount: number;
|
|
76
|
+
hasPriceLabels: boolean;
|
|
77
|
+
hasFeatureList: boolean;
|
|
78
|
+
featuresPerTier: number;
|
|
79
|
+
highlightedTierIndex: number; // -1 if none
|
|
80
|
+
columnsDesktop: number;
|
|
81
|
+
layout: 'side-by-side' | 'vertical-stack';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface TestimonialPattern {
|
|
85
|
+
count: number;
|
|
86
|
+
hasAvatar: boolean;
|
|
87
|
+
hasCompanyLogo: boolean;
|
|
88
|
+
hasRating: boolean; // stars or score
|
|
89
|
+
layout: 'single-feature' | 'grid' | 'carousel' | 'list';
|
|
90
|
+
averageQuoteLength: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface FaqPattern {
|
|
94
|
+
itemCount: number;
|
|
95
|
+
isAccordion: boolean; // expand/collapse detected via <details> or aria-expanded
|
|
96
|
+
layout: 'single-column' | 'two-column';
|
|
97
|
+
averageQuestionLength: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface CtaPattern {
|
|
101
|
+
count: number;
|
|
102
|
+
hasContrastBg: boolean; // background distinct from body bg
|
|
103
|
+
hasGradient: boolean;
|
|
104
|
+
primaryButtonText: string;
|
|
105
|
+
position: 'mid-page' | 'bottom' | 'multiple';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface FooterPattern {
|
|
109
|
+
columnCount: number;
|
|
110
|
+
linkCount: number;
|
|
111
|
+
hasNewsletter: boolean;
|
|
112
|
+
hasSocialIcons: boolean;
|
|
113
|
+
hasLanguageSwitcher: boolean;
|
|
114
|
+
hasCopyrightLine: boolean;
|
|
115
|
+
heightPx: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
export async function extractWidgets(page: Page): Promise<WidgetExtraction> {
|
|
121
|
+
// String-based page.evaluate to avoid tsx __name() emission for named functions
|
|
122
|
+
return page.evaluate(`(function() {
|
|
123
|
+
// ── HELPERS ──────────────────────────────────────────────────────────
|
|
124
|
+
function visibleRect(el) {
|
|
125
|
+
var r = el.getBoundingClientRect();
|
|
126
|
+
if (r.width < 1 || r.height < 1) return null;
|
|
127
|
+
var cs = getComputedStyle(el);
|
|
128
|
+
if (cs.display === 'none' || cs.visibility === 'hidden' || parseFloat(cs.opacity) === 0) return null;
|
|
129
|
+
return { x: r.left, y: r.top + window.scrollY, width: r.width, height: r.height };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function getTextContent(el, maxLen) {
|
|
133
|
+
var txt = (el.textContent || '').replace(/\\s+/g, ' ').trim();
|
|
134
|
+
return maxLen ? txt.slice(0, maxLen) : txt;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function findCtaButtons(scope) {
|
|
138
|
+
var nodes = scope.querySelectorAll('a[href], button, [role="button"]');
|
|
139
|
+
var ctas = [];
|
|
140
|
+
for (var i = 0; i < nodes.length; i++) {
|
|
141
|
+
var n = nodes[i];
|
|
142
|
+
var rect = visibleRect(n);
|
|
143
|
+
if (!rect) continue;
|
|
144
|
+
var text = getTextContent(n, 60);
|
|
145
|
+
if (!text || text.length < 2) continue;
|
|
146
|
+
// Heuristic: CTAs have padding, are not tiny, are not pure icons
|
|
147
|
+
var cs = getComputedStyle(n);
|
|
148
|
+
var hasBg = cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent';
|
|
149
|
+
var hasPad = parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight) > 16;
|
|
150
|
+
var isLargeText = parseFloat(cs.fontSize) >= 12;
|
|
151
|
+
if (isLargeText && (hasBg || hasPad)) {
|
|
152
|
+
ctas.push({ text: text, rect: rect, hasBg: hasBg });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return ctas;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── HERO ─────────────────────────────────────────────────────────────
|
|
159
|
+
function detectHero() {
|
|
160
|
+
var viewportH = window.innerHeight;
|
|
161
|
+
// Look for largest h1 in top viewport
|
|
162
|
+
var headings = document.querySelectorAll('h1, [class*="hero"] h2, [class*="banner"] h2');
|
|
163
|
+
var bestH = null;
|
|
164
|
+
var bestSize = 0;
|
|
165
|
+
for (var i = 0; i < headings.length; i++) {
|
|
166
|
+
var h = headings[i];
|
|
167
|
+
var rect = visibleRect(h);
|
|
168
|
+
if (!rect) continue;
|
|
169
|
+
if (rect.y > viewportH * 1.5) continue;
|
|
170
|
+
var size = parseFloat(getComputedStyle(h).fontSize) || 0;
|
|
171
|
+
if (size > bestSize) {
|
|
172
|
+
bestSize = size;
|
|
173
|
+
bestH = h;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (!bestH) return null;
|
|
177
|
+
|
|
178
|
+
// Find the hero container (climb until we hit a section-like parent)
|
|
179
|
+
var hero = bestH.closest('header, section, [class*="hero"], [class*="banner"], main > div, main > section');
|
|
180
|
+
if (!hero) hero = bestH.parentElement;
|
|
181
|
+
if (!hero) return null;
|
|
182
|
+
var heroRect = visibleRect(hero);
|
|
183
|
+
if (!heroRect) return null;
|
|
184
|
+
|
|
185
|
+
// Subheading: largest text below h1 in same container, font-size 14-24px
|
|
186
|
+
var subTxt = '';
|
|
187
|
+
var paras = hero.querySelectorAll('p, h2, h3, [class*="subtitle"], [class*="lead"]');
|
|
188
|
+
for (var i = 0; i < paras.length; i++) {
|
|
189
|
+
var p = paras[i];
|
|
190
|
+
if (p === bestH) continue;
|
|
191
|
+
var pRect = visibleRect(p);
|
|
192
|
+
if (!pRect) continue;
|
|
193
|
+
if (pRect.y < heroRect.y) continue;
|
|
194
|
+
var pSize = parseFloat(getComputedStyle(p).fontSize) || 0;
|
|
195
|
+
if (pSize < 12 || pSize > 32) continue;
|
|
196
|
+
var t = getTextContent(p, 200);
|
|
197
|
+
if (t.length > 20 && t.length > subTxt.length) {
|
|
198
|
+
subTxt = t;
|
|
199
|
+
if (subTxt.length > 60) break;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// CTAs
|
|
204
|
+
var heroCtas = findCtaButtons(hero);
|
|
205
|
+
var primaryCta = heroCtas.length > 0 ? heroCtas[0].text : '';
|
|
206
|
+
|
|
207
|
+
// Media detection
|
|
208
|
+
var imgs = hero.querySelectorAll('img, video, picture, svg, canvas, [class*="illustration"], [class*="hero-image"]');
|
|
209
|
+
var hasMedia = false;
|
|
210
|
+
var mediaPos = 'none';
|
|
211
|
+
var mediaX = 0;
|
|
212
|
+
for (var i = 0; i < imgs.length; i++) {
|
|
213
|
+
var img = imgs[i];
|
|
214
|
+
var iRect = visibleRect(img);
|
|
215
|
+
if (!iRect) continue;
|
|
216
|
+
if (iRect.width < 80 || iRect.height < 80) continue;
|
|
217
|
+
hasMedia = true;
|
|
218
|
+
mediaX = iRect.x + iRect.width / 2;
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
var heroCenterX = heroRect.x + heroRect.width / 2;
|
|
223
|
+
var bgImage = getComputedStyle(hero).backgroundImage;
|
|
224
|
+
var hasBg = bgImage && bgImage !== 'none' && bgImage.indexOf('url(') !== -1;
|
|
225
|
+
|
|
226
|
+
var composition = 'centered';
|
|
227
|
+
if (hasMedia) {
|
|
228
|
+
if (mediaX < heroCenterX - 100) {
|
|
229
|
+
mediaPos = 'left';
|
|
230
|
+
composition = 'split-right-text';
|
|
231
|
+
} else if (mediaX > heroCenterX + 100) {
|
|
232
|
+
mediaPos = 'right';
|
|
233
|
+
composition = 'split-left-text';
|
|
234
|
+
} else {
|
|
235
|
+
mediaPos = 'top';
|
|
236
|
+
composition = 'centered';
|
|
237
|
+
}
|
|
238
|
+
} else if (hasBg) {
|
|
239
|
+
composition = 'background-pattern';
|
|
240
|
+
mediaPos = 'background';
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Content alignment from text-align
|
|
244
|
+
var headingCs = getComputedStyle(bestH);
|
|
245
|
+
var alignment = 'left';
|
|
246
|
+
if (headingCs.textAlign === 'center') alignment = 'center';
|
|
247
|
+
else if (headingCs.textAlign === 'right') alignment = 'right';
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
composition: composition,
|
|
251
|
+
hasMedia: hasMedia,
|
|
252
|
+
mediaPosition: mediaPos,
|
|
253
|
+
headingText: getTextContent(bestH, 200),
|
|
254
|
+
headingFontSize: bestSize,
|
|
255
|
+
subheadingText: subTxt,
|
|
256
|
+
ctaCount: heroCtas.length,
|
|
257
|
+
ctaPrimaryText: primaryCta,
|
|
258
|
+
heightPx: Math.round(heroRect.height),
|
|
259
|
+
isFullViewport: heroRect.height >= viewportH * 0.7,
|
|
260
|
+
contentAlignment: alignment,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── NAVIGATION ───────────────────────────────────────────────────────
|
|
265
|
+
function detectNav() {
|
|
266
|
+
var navEl = document.querySelector('header > nav, header nav, body > nav, [role="banner"] nav, nav[class*="header"], nav[class*="top"], nav');
|
|
267
|
+
if (!navEl) {
|
|
268
|
+
// Fallback: top header element
|
|
269
|
+
navEl = document.querySelector('header, [role="banner"]');
|
|
270
|
+
}
|
|
271
|
+
if (!navEl) return null;
|
|
272
|
+
var rect = visibleRect(navEl);
|
|
273
|
+
if (!rect) return null;
|
|
274
|
+
if (rect.y > 300) return null; // not at top
|
|
275
|
+
|
|
276
|
+
var cs = getComputedStyle(navEl);
|
|
277
|
+
var pos = cs.position;
|
|
278
|
+
var stickyOrFixed = (pos === 'sticky' || pos === 'fixed') ? pos : 'static';
|
|
279
|
+
|
|
280
|
+
// Logo: <a> at left containing img or text near brand
|
|
281
|
+
var logoEl = navEl.querySelector('a[href="/"], a[class*="logo"], a[aria-label*="ogo" i]');
|
|
282
|
+
var hasLogo = !!logoEl;
|
|
283
|
+
if (!hasLogo) {
|
|
284
|
+
// Heuristic: leftmost link with img child
|
|
285
|
+
var leftLinks = navEl.querySelectorAll('a img, a svg');
|
|
286
|
+
if (leftLinks.length > 0) hasLogo = true;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Nav items: links with text in nav
|
|
290
|
+
var navLinks = navEl.querySelectorAll('a');
|
|
291
|
+
var itemCount = 0;
|
|
292
|
+
var ctaCount = 0;
|
|
293
|
+
for (var i = 0; i < navLinks.length; i++) {
|
|
294
|
+
var l = navLinks[i];
|
|
295
|
+
var t = getTextContent(l, 50);
|
|
296
|
+
if (!t || t.length < 2) continue;
|
|
297
|
+
var lr = visibleRect(l);
|
|
298
|
+
if (!lr) continue;
|
|
299
|
+
// v2.9 A.6 — count ONLY links in the visible TOP BAR, not flattened mega-menu / dropdown
|
|
300
|
+
// items (which inflate the count to "50 items"). Header band ≈ nav top + ~72px.
|
|
301
|
+
if (lr.top > rect.y + 72) continue;
|
|
302
|
+
var lcs = getComputedStyle(l);
|
|
303
|
+
var hasBg = lcs.backgroundColor && lcs.backgroundColor !== 'rgba(0, 0, 0, 0)' && lcs.backgroundColor !== 'transparent';
|
|
304
|
+
var hasBorder = lcs.borderWidth && parseFloat(lcs.borderWidth) > 0;
|
|
305
|
+
if (hasBg || hasBorder) {
|
|
306
|
+
ctaCount++;
|
|
307
|
+
} else {
|
|
308
|
+
itemCount++;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
var hasSearch = !!navEl.querySelector('input[type="search"], [role="searchbox"], [class*="search"]');
|
|
313
|
+
var hasDarkMode = !!navEl.querySelector('button[aria-label*="theme" i], button[aria-label*="mode" i], [class*="theme-toggle"], [class*="dark-mode"]');
|
|
314
|
+
|
|
315
|
+
// Layout heuristic from logo + links positions
|
|
316
|
+
var layout = 'unknown';
|
|
317
|
+
if (hasLogo && itemCount > 0) {
|
|
318
|
+
// Look at first nav link position vs logo
|
|
319
|
+
var firstLink = null;
|
|
320
|
+
for (var j = 0; j < navLinks.length; j++) {
|
|
321
|
+
var t2 = getTextContent(navLinks[j], 50);
|
|
322
|
+
if (t2 && t2.length > 2) {
|
|
323
|
+
firstLink = navLinks[j];
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (firstLink && logoEl) {
|
|
328
|
+
var lRect = visibleRect(logoEl);
|
|
329
|
+
var fRect = visibleRect(firstLink);
|
|
330
|
+
if (lRect && fRect) {
|
|
331
|
+
var navCenterX = rect.x + rect.width / 2;
|
|
332
|
+
if (Math.abs(lRect.x + lRect.width / 2 - navCenterX) < 100) {
|
|
333
|
+
layout = 'logo-center-links-split';
|
|
334
|
+
} else if (fRect.x > rect.x + rect.width / 2) {
|
|
335
|
+
layout = 'logo-left-links-right';
|
|
336
|
+
} else {
|
|
337
|
+
layout = 'logo-left-links-left';
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
position: stickyOrFixed,
|
|
345
|
+
hasLogo: hasLogo,
|
|
346
|
+
navItemCount: itemCount,
|
|
347
|
+
ctaCount: ctaCount,
|
|
348
|
+
hasDarkMode: hasDarkMode,
|
|
349
|
+
hasSearchBar: hasSearch,
|
|
350
|
+
heightPx: Math.round(rect.height),
|
|
351
|
+
layout: layout,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ── CARD GRID ────────────────────────────────────────────────────────
|
|
356
|
+
function detectCardGrid() {
|
|
357
|
+
// Find containers with 3+ direct children of similar size
|
|
358
|
+
var containers = document.querySelectorAll('ul, ol, div, section, main > div');
|
|
359
|
+
var bestGrid = null;
|
|
360
|
+
var bestScore = 0;
|
|
361
|
+
for (var i = 0; i < containers.length; i++) {
|
|
362
|
+
var c = containers[i];
|
|
363
|
+
var children = c.children;
|
|
364
|
+
if (children.length < 3 || children.length > 30) continue;
|
|
365
|
+
// Sample first 4 children
|
|
366
|
+
var rects = [];
|
|
367
|
+
for (var j = 0; j < Math.min(children.length, 6); j++) {
|
|
368
|
+
var r = visibleRect(children[j]);
|
|
369
|
+
if (r) rects.push(r);
|
|
370
|
+
}
|
|
371
|
+
if (rects.length < 3) continue;
|
|
372
|
+
// Check homogeneity: similar widths AND similar heights
|
|
373
|
+
var widths = rects.map(function(r){ return r.width; });
|
|
374
|
+
var heights = rects.map(function(r){ return r.height; });
|
|
375
|
+
var avgW = widths.reduce(function(a,b){return a+b;}, 0) / widths.length;
|
|
376
|
+
var avgH = heights.reduce(function(a,b){return a+b;}, 0) / heights.length;
|
|
377
|
+
if (avgW < 120 || avgH < 80) continue;
|
|
378
|
+
var widthVar = widths.reduce(function(a,b){return a + Math.abs(b - avgW);}, 0) / widths.length;
|
|
379
|
+
var heightVar = heights.reduce(function(a,b){return a + Math.abs(b - avgH);}, 0) / heights.length;
|
|
380
|
+
if (widthVar / avgW > 0.2) continue; // not homogeneous widths
|
|
381
|
+
if (heightVar / avgH > 0.3) continue; // not homogeneous heights
|
|
382
|
+
|
|
383
|
+
var score = rects.length * (avgW * avgH / 10000);
|
|
384
|
+
if (score > bestScore) {
|
|
385
|
+
bestScore = score;
|
|
386
|
+
// Sample first card for content analysis
|
|
387
|
+
var firstChild = children[0];
|
|
388
|
+
var hasImg = !!firstChild.querySelector('img, picture, video');
|
|
389
|
+
var hasIcon = !hasImg && !!firstChild.querySelector('svg');
|
|
390
|
+
var hasCta = findCtaButtons(firstChild).length > 0;
|
|
391
|
+
|
|
392
|
+
var imgEl = firstChild.querySelector('img, picture, video');
|
|
393
|
+
var imgPos = 'none';
|
|
394
|
+
if (imgEl) {
|
|
395
|
+
var iRect = visibleRect(imgEl);
|
|
396
|
+
var cRect = visibleRect(firstChild);
|
|
397
|
+
if (iRect && cRect) {
|
|
398
|
+
if (iRect.y - cRect.y < 10) imgPos = 'top';
|
|
399
|
+
else if (iRect.x - cRect.x < 10 && iRect.width < cRect.width * 0.6) imgPos = 'left';
|
|
400
|
+
else imgPos = 'top';
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Columns: derived from how many cards fit in viewport width
|
|
405
|
+
var viewportW = window.innerWidth;
|
|
406
|
+
var colsDesktop = Math.max(1, Math.round(viewportW / avgW));
|
|
407
|
+
|
|
408
|
+
// Gap: distance between first 2 cards
|
|
409
|
+
var gap = 0;
|
|
410
|
+
if (rects.length >= 2) {
|
|
411
|
+
if (rects[0].y === rects[1].y) {
|
|
412
|
+
// horizontal row
|
|
413
|
+
gap = Math.max(0, rects[1].x - (rects[0].x + rects[0].width));
|
|
414
|
+
} else {
|
|
415
|
+
gap = Math.max(0, rects[1].y - (rects[0].y + rects[0].height));
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
bestGrid = {
|
|
420
|
+
cardCount: children.length,
|
|
421
|
+
columnsDesktop: colsDesktop,
|
|
422
|
+
cardAspectRatio: Math.round((avgW / avgH) * 100) / 100,
|
|
423
|
+
cardHasImage: hasImg,
|
|
424
|
+
cardHasIcon: hasIcon,
|
|
425
|
+
cardHasCta: hasCta,
|
|
426
|
+
imagePosition: imgPos,
|
|
427
|
+
averageCardWidthPx: Math.round(avgW),
|
|
428
|
+
averageCardHeightPx: Math.round(avgH),
|
|
429
|
+
gapPx: Math.round(gap),
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return bestGrid;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ── PRICING TABLE ────────────────────────────────────────────────────
|
|
437
|
+
function detectPricing() {
|
|
438
|
+
// Pricing = container with 2-5 children, each containing price-like text (currency + number)
|
|
439
|
+
var priceRegex = /[\\$€£¥][\\s]?\\d|\\d+[\\s]?\\/(month|mo|year|yr)|free/i;
|
|
440
|
+
var containers = document.querySelectorAll('section, div, ul');
|
|
441
|
+
for (var i = 0; i < containers.length; i++) {
|
|
442
|
+
var c = containers[i];
|
|
443
|
+
var children = c.children;
|
|
444
|
+
if (children.length < 2 || children.length > 5) continue;
|
|
445
|
+
var matches = 0;
|
|
446
|
+
var highlightIdx = -1;
|
|
447
|
+
var featuresPerTier = [];
|
|
448
|
+
for (var j = 0; j < children.length; j++) {
|
|
449
|
+
var ch = children[j];
|
|
450
|
+
var text = ch.textContent || '';
|
|
451
|
+
if (priceRegex.test(text)) {
|
|
452
|
+
matches++;
|
|
453
|
+
// Count features (li or check items)
|
|
454
|
+
var feats = ch.querySelectorAll('li, [class*="check"], [class*="feat"]');
|
|
455
|
+
featuresPerTier.push(feats.length);
|
|
456
|
+
// Highlighted: has unique border or background vs siblings
|
|
457
|
+
var cs = getComputedStyle(ch);
|
|
458
|
+
if (cs.borderWidth && parseFloat(cs.borderWidth) >= 2) highlightIdx = j;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (matches >= 2 && matches === children.length) {
|
|
462
|
+
var avgFeat = featuresPerTier.length > 0 ? featuresPerTier.reduce(function(a,b){return a+b;}, 0) / featuresPerTier.length : 0;
|
|
463
|
+
var rect = visibleRect(c);
|
|
464
|
+
if (!rect) continue;
|
|
465
|
+
var firstChildRect = visibleRect(children[0]);
|
|
466
|
+
var secondChildRect = children.length > 1 ? visibleRect(children[1]) : null;
|
|
467
|
+
var isSideBySide = firstChildRect && secondChildRect && Math.abs(firstChildRect.y - secondChildRect.y) < 20;
|
|
468
|
+
return {
|
|
469
|
+
tierCount: matches,
|
|
470
|
+
hasPriceLabels: true,
|
|
471
|
+
hasFeatureList: avgFeat > 0,
|
|
472
|
+
featuresPerTier: Math.round(avgFeat),
|
|
473
|
+
highlightedTierIndex: highlightIdx,
|
|
474
|
+
columnsDesktop: matches,
|
|
475
|
+
layout: isSideBySide ? 'side-by-side' : 'vertical-stack',
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ── TESTIMONIALS ─────────────────────────────────────────────────────
|
|
483
|
+
function detectTestimonials() {
|
|
484
|
+
// Heuristic: <blockquote>, or class includes testimonial/review/quote
|
|
485
|
+
var nodes = document.querySelectorAll('blockquote, [class*="testimonial" i], [class*="review" i], [class*="quote" i]');
|
|
486
|
+
var visible = [];
|
|
487
|
+
for (var i = 0; i < nodes.length; i++) {
|
|
488
|
+
var n = nodes[i];
|
|
489
|
+
var r = visibleRect(n);
|
|
490
|
+
if (!r) continue;
|
|
491
|
+
var t = getTextContent(n, 500);
|
|
492
|
+
if (t.length < 20) continue;
|
|
493
|
+
visible.push({ node: n, text: t, rect: r });
|
|
494
|
+
}
|
|
495
|
+
if (visible.length === 0) return null;
|
|
496
|
+
|
|
497
|
+
var hasAvatar = false;
|
|
498
|
+
var hasLogo = false;
|
|
499
|
+
var hasRating = false;
|
|
500
|
+
var totalLen = 0;
|
|
501
|
+
for (var j = 0; j < visible.length; j++) {
|
|
502
|
+
var v = visible[j];
|
|
503
|
+
if (v.node.querySelector('img[class*="avatar" i], img[class*="profile" i]') ||
|
|
504
|
+
(v.node.querySelector('img') && v.node.querySelector('img').width < 80)) hasAvatar = true;
|
|
505
|
+
if (v.node.querySelector('img[class*="logo" i], svg[class*="logo" i]')) hasLogo = true;
|
|
506
|
+
if (v.node.querySelector('[class*="star" i], [class*="rating" i], [aria-label*="rating" i]') ||
|
|
507
|
+
/★|⭐|☆/.test(v.text)) hasRating = true;
|
|
508
|
+
totalLen += v.text.length;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
var layout = 'single-feature';
|
|
512
|
+
if (visible.length >= 4) layout = 'grid';
|
|
513
|
+
else if (visible.length >= 2) layout = 'list';
|
|
514
|
+
|
|
515
|
+
return {
|
|
516
|
+
count: visible.length,
|
|
517
|
+
hasAvatar: hasAvatar,
|
|
518
|
+
hasCompanyLogo: hasLogo,
|
|
519
|
+
hasRating: hasRating,
|
|
520
|
+
layout: layout,
|
|
521
|
+
averageQuoteLength: Math.round(totalLen / visible.length),
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ── FAQ ──────────────────────────────────────────────────────────────
|
|
526
|
+
function detectFaq() {
|
|
527
|
+
// Look for <details>/<summary> or aria-expanded patterns or "FAQ" heading
|
|
528
|
+
var details = document.querySelectorAll('details > summary');
|
|
529
|
+
var expanded = document.querySelectorAll('[aria-expanded]');
|
|
530
|
+
var faqEls = [];
|
|
531
|
+
|
|
532
|
+
if (details.length >= 2) {
|
|
533
|
+
for (var i = 0; i < details.length; i++) {
|
|
534
|
+
var r = visibleRect(details[i]);
|
|
535
|
+
if (r) faqEls.push({ node: details[i], text: getTextContent(details[i], 200) });
|
|
536
|
+
}
|
|
537
|
+
} else if (expanded.length >= 3) {
|
|
538
|
+
// Filter to ones that look question-shaped (end with ? or have h-tag inside)
|
|
539
|
+
for (var i = 0; i < expanded.length; i++) {
|
|
540
|
+
var e = expanded[i];
|
|
541
|
+
var t = getTextContent(e, 200);
|
|
542
|
+
if (t.length > 10 && (t.indexOf('?') !== -1 || e.querySelector('h2, h3, h4'))) {
|
|
543
|
+
var r = visibleRect(e);
|
|
544
|
+
if (r) faqEls.push({ node: e, text: t });
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (faqEls.length < 2) return null;
|
|
550
|
+
|
|
551
|
+
var totalQuestionLen = 0;
|
|
552
|
+
for (var j = 0; j < faqEls.length; j++) {
|
|
553
|
+
totalQuestionLen += faqEls[j].text.length;
|
|
554
|
+
}
|
|
555
|
+
// Layout: 2-col if two columns of items detected
|
|
556
|
+
var firstY = visibleRect(faqEls[0].node);
|
|
557
|
+
var secondY = visibleRect(faqEls[1].node);
|
|
558
|
+
var twoCol = firstY && secondY && Math.abs(firstY.y - secondY.y) < 30;
|
|
559
|
+
|
|
560
|
+
return {
|
|
561
|
+
itemCount: faqEls.length,
|
|
562
|
+
isAccordion: details.length >= 2 || expanded.length >= 3,
|
|
563
|
+
layout: twoCol ? 'two-column' : 'single-column',
|
|
564
|
+
averageQuestionLength: Math.round(totalQuestionLen / faqEls.length),
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// ── CTA BANNER ───────────────────────────────────────────────────────
|
|
569
|
+
function detectCtaBanner() {
|
|
570
|
+
// Heuristic: section with a prominent button and contrasting background, not the hero
|
|
571
|
+
var sections = document.querySelectorAll('section, [class*="cta" i], [class*="banner" i]');
|
|
572
|
+
var viewportH = window.innerHeight;
|
|
573
|
+
var bodyBg = getComputedStyle(document.body).backgroundColor;
|
|
574
|
+
var found = 0;
|
|
575
|
+
var primary = '';
|
|
576
|
+
var hasContrast = false;
|
|
577
|
+
var hasGrad = false;
|
|
578
|
+
var positions = [];
|
|
579
|
+
|
|
580
|
+
for (var i = 0; i < sections.length; i++) {
|
|
581
|
+
var s = sections[i];
|
|
582
|
+
var rect = visibleRect(s);
|
|
583
|
+
if (!rect) continue;
|
|
584
|
+
if (rect.y < viewportH * 0.5) continue; // skip hero zone
|
|
585
|
+
var ctas = findCtaButtons(s);
|
|
586
|
+
if (ctas.length === 0) continue;
|
|
587
|
+
var cs = getComputedStyle(s);
|
|
588
|
+
var sBg = cs.backgroundColor;
|
|
589
|
+
var contrasted = sBg && sBg !== bodyBg && sBg !== 'rgba(0, 0, 0, 0)';
|
|
590
|
+
var bgImage = cs.backgroundImage;
|
|
591
|
+
var grad = bgImage && bgImage.indexOf('gradient') !== -1;
|
|
592
|
+
if (!contrasted && !grad) continue;
|
|
593
|
+
|
|
594
|
+
found++;
|
|
595
|
+
if (!primary && ctas[0].text) primary = ctas[0].text;
|
|
596
|
+
if (contrasted) hasContrast = true;
|
|
597
|
+
if (grad) hasGrad = true;
|
|
598
|
+
positions.push(rect.y);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (found === 0) return null;
|
|
602
|
+
|
|
603
|
+
var pos = 'mid-page';
|
|
604
|
+
if (found > 1) pos = 'multiple';
|
|
605
|
+
else if (positions[0] > (document.documentElement.scrollHeight - viewportH * 1.5)) pos = 'bottom';
|
|
606
|
+
|
|
607
|
+
return {
|
|
608
|
+
count: found,
|
|
609
|
+
hasContrastBg: hasContrast,
|
|
610
|
+
hasGradient: hasGrad,
|
|
611
|
+
primaryButtonText: primary,
|
|
612
|
+
position: pos,
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// ── FOOTER ───────────────────────────────────────────────────────────
|
|
617
|
+
function detectFooter() {
|
|
618
|
+
var footer = document.querySelector('footer, [role="contentinfo"]');
|
|
619
|
+
if (!footer) {
|
|
620
|
+
// Fallback: last large section near bottom
|
|
621
|
+
var totalH = document.documentElement.scrollHeight;
|
|
622
|
+
var sections = document.querySelectorAll('section, div');
|
|
623
|
+
for (var i = sections.length - 1; i >= 0; i--) {
|
|
624
|
+
var r = visibleRect(sections[i]);
|
|
625
|
+
if (r && r.y > totalH - 800 && r.width > window.innerWidth * 0.5) {
|
|
626
|
+
footer = sections[i];
|
|
627
|
+
break;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
if (!footer) return null;
|
|
632
|
+
var rect = visibleRect(footer);
|
|
633
|
+
if (!rect) return null;
|
|
634
|
+
|
|
635
|
+
// Columns: count direct flex/grid children, or count distinct X positions of headings
|
|
636
|
+
var directChildren = footer.children;
|
|
637
|
+
var xPositions = new Set();
|
|
638
|
+
for (var i = 0; i < directChildren.length; i++) {
|
|
639
|
+
var dr = visibleRect(directChildren[i]);
|
|
640
|
+
if (dr) xPositions.add(Math.round(dr.x / 50) * 50);
|
|
641
|
+
}
|
|
642
|
+
var columnCount = xPositions.size || 1;
|
|
643
|
+
if (columnCount === 1) {
|
|
644
|
+
// Try by header h3/h4 positions
|
|
645
|
+
var heads = footer.querySelectorAll('h3, h4, [class*="title"]');
|
|
646
|
+
var headX = new Set();
|
|
647
|
+
for (var i = 0; i < heads.length; i++) {
|
|
648
|
+
var hr = visibleRect(heads[i]);
|
|
649
|
+
if (hr) headX.add(Math.round(hr.x / 80) * 80);
|
|
650
|
+
}
|
|
651
|
+
if (headX.size > 1) columnCount = headX.size;
|
|
652
|
+
}
|
|
653
|
+
columnCount = Math.min(columnCount, 6);
|
|
654
|
+
|
|
655
|
+
var links = footer.querySelectorAll('a');
|
|
656
|
+
var visibleLinks = 0;
|
|
657
|
+
for (var i = 0; i < links.length; i++) {
|
|
658
|
+
if (visibleRect(links[i])) visibleLinks++;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
var newsletter = !!footer.querySelector('input[type="email"], form[class*="newsletter" i]');
|
|
662
|
+
var social = !!footer.querySelector('a[href*="twitter"], a[href*="x.com"], a[href*="facebook"], a[href*="instagram"], a[href*="linkedin"], a[href*="github"], a[aria-label*="ocial" i]');
|
|
663
|
+
var langSwitch = !!footer.querySelector('[class*="lang" i] select, [aria-label*="language" i]');
|
|
664
|
+
// Copyright: text containing ©, "all rights reserved", or year
|
|
665
|
+
var fText = footer.textContent || '';
|
|
666
|
+
var copyright = /©|all rights reserved|copyright/i.test(fText);
|
|
667
|
+
|
|
668
|
+
return {
|
|
669
|
+
columnCount: columnCount,
|
|
670
|
+
linkCount: visibleLinks,
|
|
671
|
+
hasNewsletter: newsletter,
|
|
672
|
+
hasSocialIcons: social,
|
|
673
|
+
hasLanguageSwitcher: langSwitch,
|
|
674
|
+
hasCopyrightLine: copyright,
|
|
675
|
+
heightPx: Math.round(rect.height),
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// ── ASSEMBLE ─────────────────────────────────────────────────────────
|
|
680
|
+
var hero = detectHero();
|
|
681
|
+
var nav = detectNav();
|
|
682
|
+
var cardGrid = detectCardGrid();
|
|
683
|
+
var pricing = detectPricing();
|
|
684
|
+
var testimonials = detectTestimonials();
|
|
685
|
+
var faq = detectFaq();
|
|
686
|
+
var cta = detectCtaBanner();
|
|
687
|
+
var footer = detectFooter();
|
|
688
|
+
|
|
689
|
+
var detectedCount = 0;
|
|
690
|
+
if (hero) detectedCount++;
|
|
691
|
+
if (nav) detectedCount++;
|
|
692
|
+
if (cardGrid) detectedCount++;
|
|
693
|
+
if (pricing) detectedCount++;
|
|
694
|
+
if (testimonials) detectedCount++;
|
|
695
|
+
if (faq) detectedCount++;
|
|
696
|
+
if (cta) detectedCount++;
|
|
697
|
+
if (footer) detectedCount++;
|
|
698
|
+
|
|
699
|
+
return {
|
|
700
|
+
hero: hero,
|
|
701
|
+
navigation: nav,
|
|
702
|
+
cardGrid: cardGrid,
|
|
703
|
+
pricingTable: pricing,
|
|
704
|
+
testimonials: testimonials,
|
|
705
|
+
faq: faq,
|
|
706
|
+
ctaBanner: cta,
|
|
707
|
+
footer: footer,
|
|
708
|
+
detectedCount: detectedCount,
|
|
709
|
+
};
|
|
710
|
+
})()`);
|
|
711
|
+
}
|