designlang 10.1.0 → 10.3.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 +27 -0
- package/bin/design-extract.js +45 -1
- package/package.json +1 -1
- package/src/crawler.js +11 -0
- package/src/extractors/dark-mode-pair.js +96 -0
- package/src/extractors/perf.js +140 -0
- package/src/extractors/responsive-screenshots.js +55 -0
- package/src/extractors/seo.js +69 -0
- package/src/index.js +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [10.3.0] — 2026-04-22
|
|
4
|
+
|
|
5
|
+
**Perf + SEO.** designlang now doubles as a lightweight auditor.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`src/extractors/perf.js`** — `captureCoreWebVitals(url)` opens a fresh Playwright context, measures LCP / CLS / INP via PerformanceObserver, categorises every network response into JS / CSS / font / image / document / other, counts third-party requests against a known-host list, and synthesises an interaction so INP reports. Returns grade buckets (good / needs-improvement / poor) per vital.
|
|
10
|
+
- **`src/extractors/seo.js`** — pure extractor for Open Graph, Twitter cards, canonical, manifest, theme-color, viewport, every favicon, and inline JSON-LD blocks (schema.org structured data).
|
|
11
|
+
- Crawler now captures `favicons`, `manifest`, and `<script type="application/ld+json">` content.
|
|
12
|
+
- New flag `--perf`. Auto-on with `--full`.
|
|
13
|
+
- New outputs: `*-seo.json`, `*-perf.json`.
|
|
14
|
+
|
|
15
|
+
## [10.2.0] — 2026-04-22
|
|
16
|
+
|
|
17
|
+
**Dark mode pairing + responsive screenshots.** Joins the light & dark extractor passes into semantic pairs, and adds full-page captures at 4 breakpoints × (light, dark).
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **`src/extractors/dark-mode-pair.js`** — pure function that maps light ↔ dark pairs for primary/secondary/accent/background/text roles and every CSS variable that actually differs between themes. Emits a drop-in Tailwind `darkMode: 'class'` config plus an audit (tokens missing from either pass).
|
|
22
|
+
- **`src/extractors/responsive-screenshots.js`** — full-page PNGs at mobile / tablet / desktop / wide × (light, dark). Writes to `screenshots/responsive/<breakpoint>-<scheme>.png` with an index.
|
|
23
|
+
- New flag `--responsive-shots`. Auto-on with `--full`.
|
|
24
|
+
- New outputs: `*-dark-mode.json`, `*-responsive.json`.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- CLI version test now reads from `package.json` instead of a hardcoded string — no per-release test churn going forward.
|
|
29
|
+
|
|
3
30
|
## [10.1.0] — 2026-04-22
|
|
4
31
|
|
|
5
32
|
**Component screenshots.** The existing `--screenshots` flag now emits cluster-aware, retina (2×), multi-variant PNGs instead of five hardcoded selectors and a full-page image.
|
package/bin/design-extract.js
CHANGED
|
@@ -10,6 +10,9 @@ import { refineWithSmart } from '../src/classifiers/smart.js';
|
|
|
10
10
|
import { crawlCanonicalPages } from '../src/multipage.js';
|
|
11
11
|
import { extractLogo } from '../src/extractors/logo.js';
|
|
12
12
|
import { captureComponentScreenshotsV10 } from '../src/extractors/component-screenshots.js';
|
|
13
|
+
import { pairDarkMode } from '../src/extractors/dark-mode-pair.js';
|
|
14
|
+
import { captureResponsiveScreenshots } from '../src/extractors/responsive-screenshots.js';
|
|
15
|
+
import { captureCoreWebVitals, extractFontLoading } from '../src/extractors/perf.js';
|
|
13
16
|
import { buildPromptPack } from '../src/formatters/prompt-pack.js';
|
|
14
17
|
import { formatMarkdown } from '../src/formatters/markdown.js';
|
|
15
18
|
import { formatTokens } from '../src/formatters/tokens.js';
|
|
@@ -53,7 +56,7 @@ const program = new Command();
|
|
|
53
56
|
program
|
|
54
57
|
.name('designlang')
|
|
55
58
|
.description('Extract the complete design language from any website')
|
|
56
|
-
.version('10.
|
|
59
|
+
.version('10.3.0');
|
|
57
60
|
|
|
58
61
|
// ── Main command: extract ──────────────────────────────────────
|
|
59
62
|
program
|
|
@@ -85,6 +88,8 @@ program
|
|
|
85
88
|
.option('--smart', 'use optional LLM fallback when heuristic classifiers have low confidence (needs OPENAI_API_KEY or ANTHROPIC_API_KEY)')
|
|
86
89
|
.option('--pages <n>', 'crawl N canonical pages (pricing/docs/blog/about/product) in addition to the homepage', parseInt)
|
|
87
90
|
.option('--no-prompts', 'skip writing the prompt-pack directory')
|
|
91
|
+
.option('--responsive-shots', 'capture full-page PNGs at 4 breakpoints × (light,dark)')
|
|
92
|
+
.option('--perf', 'measure Core Web Vitals + bundle profile (LCP/CLS/INP, JS/CSS/font/img bytes, third-party count)')
|
|
88
93
|
.option('--json', 'output raw JSON to stdout (for CI/CD)')
|
|
89
94
|
.option('--json-pretty', 'output formatted JSON to stdout')
|
|
90
95
|
.option('--no-history', 'skip saving to history')
|
|
@@ -233,6 +238,33 @@ program
|
|
|
233
238
|
} catch (e) { design.componentScreenshots = { error: e.message }; }
|
|
234
239
|
}
|
|
235
240
|
|
|
241
|
+
// v10.2: dark-mode pairing (pure, based on already-extracted data).
|
|
242
|
+
design.darkModePaired = pairDarkMode(design);
|
|
243
|
+
|
|
244
|
+
// v10.3: Core Web Vitals + bundle profile.
|
|
245
|
+
if (merged.full || merged.perf) {
|
|
246
|
+
spinner.text = 'Measuring Core Web Vitals...';
|
|
247
|
+
try {
|
|
248
|
+
design.perf = await captureCoreWebVitals(url, {
|
|
249
|
+
width: merged.width,
|
|
250
|
+
height: parseInt(merged.height) || 800,
|
|
251
|
+
channel: merged.systemChrome ? 'chrome' : undefined,
|
|
252
|
+
});
|
|
253
|
+
design.perf.fontLoading = extractFontLoading(design._raw?.light?.stack || {});
|
|
254
|
+
} catch (e) { design.perf = { error: e.message }; }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// v10.2: responsive screenshots at 4 breakpoints × (light, dark).
|
|
258
|
+
if (merged.full || merged.responsiveShots) {
|
|
259
|
+
spinner.text = 'Capturing responsive screenshots...';
|
|
260
|
+
try {
|
|
261
|
+
design.responsiveShots = await captureResponsiveScreenshots(url, outDir, {
|
|
262
|
+
includeDark: merged.dark || merged.full,
|
|
263
|
+
channel: merged.systemChrome ? 'chrome' : undefined,
|
|
264
|
+
});
|
|
265
|
+
} catch (e) { design.responsiveShots = { error: e.message }; }
|
|
266
|
+
}
|
|
267
|
+
|
|
236
268
|
// v10: multi-page canonical crawl (pricing/docs/blog/about/product).
|
|
237
269
|
const pagesArg = merged.pages != null ? merged.pages : (merged.full ? 5 : 0);
|
|
238
270
|
if (pagesArg > 0) {
|
|
@@ -322,6 +354,18 @@ program
|
|
|
322
354
|
if (design.componentScreenshots && (design.componentScreenshots.components || []).length) {
|
|
323
355
|
files.push({ name: `${prefix}-screenshots.json`, content: JSON.stringify(design.componentScreenshots, null, 2), label: 'Component Screenshots index' });
|
|
324
356
|
}
|
|
357
|
+
if (design.darkModePaired && design.darkModePaired.available) {
|
|
358
|
+
files.push({ name: `${prefix}-dark-mode.json`, content: JSON.stringify(design.darkModePaired, null, 2), label: 'Dark Mode Pairing' });
|
|
359
|
+
}
|
|
360
|
+
if (design.responsiveShots && Array.isArray(design.responsiveShots.shots) && design.responsiveShots.shots.length) {
|
|
361
|
+
files.push({ name: `${prefix}-responsive.json`, content: JSON.stringify(design.responsiveShots, null, 2), label: 'Responsive Screenshots index' });
|
|
362
|
+
}
|
|
363
|
+
if (design.seo) {
|
|
364
|
+
files.push({ name: `${prefix}-seo.json`, content: JSON.stringify(design.seo, null, 2), label: 'SEO + Structured Data' });
|
|
365
|
+
}
|
|
366
|
+
if (design.perf && !design.perf.error) {
|
|
367
|
+
files.push({ name: `${prefix}-perf.json`, content: JSON.stringify(design.perf, null, 2), label: 'Perf + Bundle' });
|
|
368
|
+
}
|
|
325
369
|
if (merged.prompts !== false) {
|
|
326
370
|
const pack = buildPromptPack(design);
|
|
327
371
|
const promptsDir = join(outDir, `${prefix}-prompts`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "designlang",
|
|
3
|
-
"version": "10.
|
|
3
|
+
"version": "10.3.0",
|
|
4
4
|
"description": "Extract the complete design language from any website — colors, typography, spacing, shadows, motion, component anatomy, brand voice, page intent, section roles, material language, component library, imagery style, and logo. Outputs AI-optimized markdown, W3C design tokens, motion tokens, typed component stubs, Tailwind config, and ready-to-paste v0 / Lovable / Cursor / Claude-Artifacts prompts.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/crawler.js
CHANGED
|
@@ -867,6 +867,17 @@ async function extractPageData(page, ignoreSelectors, scopeSelector) {
|
|
|
867
867
|
results.fontData.documentFonts.push({ family: font.family.replace(/['"]/g, ''), style: font.style, weight: font.weight, status: font.status });
|
|
868
868
|
}
|
|
869
869
|
|
|
870
|
+
// v10.3 — favicons, manifest, JSON-LD.
|
|
871
|
+
results.favicons = Array.from(document.querySelectorAll('link[rel~="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]'))
|
|
872
|
+
.slice(0, 10)
|
|
873
|
+
.map(l => ({ rel: l.getAttribute('rel'), href: l.href, sizes: l.getAttribute('sizes') || '', type: l.getAttribute('type') || '' }));
|
|
874
|
+
const manifestLink = document.querySelector('link[rel="manifest"]');
|
|
875
|
+
results.manifest = manifestLink ? manifestLink.href : null;
|
|
876
|
+
results.jsonLd = Array.from(document.querySelectorAll('script[type="application/ld+json"]'))
|
|
877
|
+
.slice(0, 12)
|
|
878
|
+
.map(s => s.textContent || '')
|
|
879
|
+
.filter(Boolean);
|
|
880
|
+
|
|
870
881
|
// Image data
|
|
871
882
|
results.images = [];
|
|
872
883
|
for (const img of document.querySelectorAll('img, picture img, [role="img"]')) {
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// v10.2 — Dark Mode Pairing
|
|
2
|
+
//
|
|
3
|
+
// When the user passes --dark the crawler runs a second pass under
|
|
4
|
+
// `color-scheme: dark` and the main extractor emits `design.darkMode` with
|
|
5
|
+
// parallel colors + CSS variable maps. This module joins the two halves into
|
|
6
|
+
// *semantic pairs* — same role in light mode ↔ same role in dark mode — so
|
|
7
|
+
// downstream consumers (prompt pack, Tailwind darkMode config, agents) can
|
|
8
|
+
// author a single `[data-theme="dark"]` override block without guessing.
|
|
9
|
+
//
|
|
10
|
+
// Pure function. No Page handle, no side effects — takes the finished design
|
|
11
|
+
// object and returns a plain JSON structure.
|
|
12
|
+
|
|
13
|
+
function hexOf(c) {
|
|
14
|
+
if (!c) return null;
|
|
15
|
+
if (typeof c === 'string') return c.toLowerCase();
|
|
16
|
+
return (c.hex || '').toLowerCase();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function pairRoleColors(light = {}, dark = {}) {
|
|
20
|
+
const pairs = {};
|
|
21
|
+
for (const role of ['primary', 'secondary', 'accent']) {
|
|
22
|
+
const l = hexOf(light[role]);
|
|
23
|
+
const d = hexOf(dark[role]);
|
|
24
|
+
if (l || d) pairs[role] = { light: l, dark: d };
|
|
25
|
+
}
|
|
26
|
+
const bgPair = {
|
|
27
|
+
light: (light.backgrounds?.[0]?.hex || null),
|
|
28
|
+
dark: (dark.backgrounds?.[0]?.hex || null),
|
|
29
|
+
};
|
|
30
|
+
if (bgPair.light || bgPair.dark) pairs.background = bgPair;
|
|
31
|
+
const textPair = {
|
|
32
|
+
light: (light.text?.[0]?.hex || null),
|
|
33
|
+
dark: (dark.text?.[0]?.hex || null),
|
|
34
|
+
};
|
|
35
|
+
if (textPair.light || textPair.dark) pairs.text = textPair;
|
|
36
|
+
return pairs;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function pairVariables(lightVars = {}, darkVars = {}) {
|
|
40
|
+
// Walk the union of keys; emit light/dark only when values differ.
|
|
41
|
+
const keys = new Set([...Object.keys(lightVars || {}), ...Object.keys(darkVars || {})]);
|
|
42
|
+
const out = {};
|
|
43
|
+
for (const k of keys) {
|
|
44
|
+
const l = lightVars[k];
|
|
45
|
+
const d = darkVars[k];
|
|
46
|
+
if (l == null && d == null) continue;
|
|
47
|
+
if (typeof l === 'string' && typeof d === 'string' && l === d) continue;
|
|
48
|
+
out[k] = { light: l ?? null, dark: d ?? null };
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function pairDarkMode(design = {}) {
|
|
54
|
+
if (!design.darkMode) {
|
|
55
|
+
return { available: false, reason: 'no --dark pass captured' };
|
|
56
|
+
}
|
|
57
|
+
const lightColors = design.colors || {};
|
|
58
|
+
const darkColors = design.darkMode.colors || {};
|
|
59
|
+
const roles = pairRoleColors(lightColors, darkColors);
|
|
60
|
+
const variables = pairVariables(design.variables || {}, design.darkMode.variables || {});
|
|
61
|
+
const pairedVarCount = Object.keys(variables).length;
|
|
62
|
+
|
|
63
|
+
// Light colors that don't appear in dark (and vice versa) signal tokens the
|
|
64
|
+
// site forgot to theme — useful for an audit.
|
|
65
|
+
const lightSet = new Set((lightColors.all || []).map(c => (c.hex || '').toLowerCase()).filter(Boolean));
|
|
66
|
+
const darkSet = new Set((darkColors.all || []).map(c => (c.hex || '').toLowerCase()).filter(Boolean));
|
|
67
|
+
const missingInDark = [...lightSet].filter(x => !darkSet.has(x)).slice(0, 20);
|
|
68
|
+
const missingInLight = [...darkSet].filter(x => !lightSet.has(x)).slice(0, 20);
|
|
69
|
+
|
|
70
|
+
// Tailwind-ready config snippet.
|
|
71
|
+
const tailwind = {
|
|
72
|
+
darkMode: 'class',
|
|
73
|
+
theme: {
|
|
74
|
+
extend: {
|
|
75
|
+
colors: Object.fromEntries(
|
|
76
|
+
Object.entries(roles)
|
|
77
|
+
.filter(([, v]) => v.light && v.dark)
|
|
78
|
+
.map(([role, v]) => [role, { DEFAULT: v.light, dark: v.dark }]),
|
|
79
|
+
),
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
available: true,
|
|
86
|
+
roles,
|
|
87
|
+
variables,
|
|
88
|
+
pairedVarCount,
|
|
89
|
+
audit: {
|
|
90
|
+
missingInDark,
|
|
91
|
+
missingInLight,
|
|
92
|
+
coverage: pairedVarCount > 0 ? 'paired' : 'light-only-vars',
|
|
93
|
+
},
|
|
94
|
+
tailwind,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// v10.3 — Perf & Bundle Profile
|
|
2
|
+
//
|
|
3
|
+
// Opens a fresh Playwright context, captures every network response, and
|
|
4
|
+
// measures Core Web Vitals via PerformanceObserver. Returns a single JSON
|
|
5
|
+
// payload a bin-level consumer can write as `*-perf.json`.
|
|
6
|
+
//
|
|
7
|
+
// No Lighthouse dependency — everything is pure `playwright` + the page's own
|
|
8
|
+
// `PerformanceObserver` API, which keeps the package size flat.
|
|
9
|
+
|
|
10
|
+
import { chromium } from 'playwright';
|
|
11
|
+
|
|
12
|
+
const THIRD_PARTY_HOSTS = [
|
|
13
|
+
'google-analytics', 'googletagmanager', 'analytics.google', 'segment.', 'mixpanel',
|
|
14
|
+
'amplitude', 'posthog', 'intercom', 'hotjar', 'fullstory', 'sentry', 'datadog',
|
|
15
|
+
'cloudflare', 'fastly', 'doubleclick', 'facebook.net', 'adservice.google', 'hs-analytics',
|
|
16
|
+
'stripe.com', 'recaptcha', 'hcaptcha', 'sentry-cdn', 'optimizely', 'statsig',
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
function categorize(url) {
|
|
20
|
+
if (!url) return 'other';
|
|
21
|
+
if (/\.(js|mjs)(?:\?|$)/i.test(url)) return 'js';
|
|
22
|
+
if (/\.(css)(?:\?|$)/i.test(url)) return 'css';
|
|
23
|
+
if (/\.(woff2?|ttf|otf|eot)(?:\?|$)/i.test(url)) return 'font';
|
|
24
|
+
if (/\.(png|jpe?g|webp|avif|gif|svg|ico)(?:\?|$)/i.test(url)) return 'image';
|
|
25
|
+
if (/fonts\.gstatic|fonts\.googleapis/.test(url)) return 'font';
|
|
26
|
+
if (/\.(html?)(?:\?|$)/i.test(url)) return 'document';
|
|
27
|
+
return 'other';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isThirdParty(resUrl, pageHost) {
|
|
31
|
+
try {
|
|
32
|
+
const u = new URL(resUrl);
|
|
33
|
+
if (u.hostname === pageHost) return false;
|
|
34
|
+
if (THIRD_PARTY_HOSTS.some(h => u.hostname.includes(h))) return true;
|
|
35
|
+
return u.hostname !== pageHost;
|
|
36
|
+
} catch { return false; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function fontLoadingStrategy(stack) {
|
|
40
|
+
const classes = (stack.classNameSample || []).join(' ');
|
|
41
|
+
const metas = (stack.metas || []).map(m => `${m.name || ''}=${m.content || ''}`).join(' ');
|
|
42
|
+
const preloadCount = ((metas + classes).match(/preload|rel=["']preload/g) || []).length;
|
|
43
|
+
return { preloadCount };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function captureCoreWebVitals(url, { width = 1280, height = 800, channel, timeout = 30000 } = {}) {
|
|
47
|
+
const browser = await chromium.launch({ headless: true, ...(channel && { channel }) });
|
|
48
|
+
try {
|
|
49
|
+
const ctx = await browser.newContext({ viewport: { width, height }, colorScheme: 'light' });
|
|
50
|
+
const page = await ctx.newPage();
|
|
51
|
+
|
|
52
|
+
const requests = [];
|
|
53
|
+
page.on('response', async (res) => {
|
|
54
|
+
try {
|
|
55
|
+
const req = res.request();
|
|
56
|
+
const headers = res.headers();
|
|
57
|
+
const contentLength = Number(headers['content-length'] || 0);
|
|
58
|
+
requests.push({
|
|
59
|
+
url: res.url(),
|
|
60
|
+
method: req.method(),
|
|
61
|
+
status: res.status(),
|
|
62
|
+
type: categorize(res.url()),
|
|
63
|
+
bytes: contentLength,
|
|
64
|
+
fromCache: res.fromServiceWorker() || /hit/i.test(headers['x-cache'] || ''),
|
|
65
|
+
});
|
|
66
|
+
} catch { /* ignore */ }
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await page.addInitScript(() => {
|
|
70
|
+
window.__dlVitals = { lcp: 0, cls: 0, inp: 0 };
|
|
71
|
+
try {
|
|
72
|
+
new PerformanceObserver((list) => {
|
|
73
|
+
for (const e of list.getEntries()) window.__dlVitals.lcp = e.startTime;
|
|
74
|
+
}).observe({ type: 'largest-contentful-paint', buffered: true });
|
|
75
|
+
} catch {}
|
|
76
|
+
try {
|
|
77
|
+
let cls = 0;
|
|
78
|
+
new PerformanceObserver((list) => {
|
|
79
|
+
for (const e of list.getEntries()) {
|
|
80
|
+
if (!e.hadRecentInput) cls += e.value;
|
|
81
|
+
}
|
|
82
|
+
window.__dlVitals.cls = cls;
|
|
83
|
+
}).observe({ type: 'layout-shift', buffered: true });
|
|
84
|
+
} catch {}
|
|
85
|
+
try {
|
|
86
|
+
new PerformanceObserver((list) => {
|
|
87
|
+
for (const e of list.getEntries()) {
|
|
88
|
+
if ((e.duration || 0) > window.__dlVitals.inp) window.__dlVitals.inp = e.duration;
|
|
89
|
+
}
|
|
90
|
+
}).observe({ type: 'event', buffered: true, durationThreshold: 16 });
|
|
91
|
+
} catch {}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const start = Date.now();
|
|
95
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout }).catch(() => {});
|
|
96
|
+
await page.waitForLoadState('networkidle').catch(() => {});
|
|
97
|
+
// Give the observers a moment; synthesize an interaction so INP reports.
|
|
98
|
+
await page.mouse.move(100, 100);
|
|
99
|
+
await page.mouse.click(100, 100).catch(() => {});
|
|
100
|
+
await page.waitForTimeout(1200);
|
|
101
|
+
|
|
102
|
+
const ttfbish = Date.now() - start;
|
|
103
|
+
const vitals = await page.evaluate(() => ({ ...(window.__dlVitals || {}) }));
|
|
104
|
+
const pageHost = new URL(url).hostname;
|
|
105
|
+
|
|
106
|
+
const totals = { js: 0, css: 0, font: 0, image: 0, document: 0, other: 0 };
|
|
107
|
+
const counts = { js: 0, css: 0, font: 0, image: 0, document: 0, other: 0 };
|
|
108
|
+
let thirdPartyCount = 0, thirdPartyBytes = 0;
|
|
109
|
+
for (const r of requests) {
|
|
110
|
+
totals[r.type] = (totals[r.type] || 0) + (r.bytes || 0);
|
|
111
|
+
counts[r.type] = (counts[r.type] || 0) + 1;
|
|
112
|
+
if (isThirdParty(r.url, pageHost)) {
|
|
113
|
+
thirdPartyCount++;
|
|
114
|
+
thirdPartyBytes += r.bytes || 0;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
vitals: {
|
|
120
|
+
lcp: Math.round(vitals.lcp || 0),
|
|
121
|
+
cls: Number((vitals.cls || 0).toFixed(4)),
|
|
122
|
+
inp: Math.round(vitals.inp || 0),
|
|
123
|
+
// Rough classification vs Google's good/needs-improvement thresholds.
|
|
124
|
+
lcpGrade: vitals.lcp < 2500 ? 'good' : vitals.lcp < 4000 ? 'needs-improvement' : 'poor',
|
|
125
|
+
clsGrade: (vitals.cls || 0) < 0.1 ? 'good' : (vitals.cls || 0) < 0.25 ? 'needs-improvement' : 'poor',
|
|
126
|
+
},
|
|
127
|
+
ttfbApprox: ttfbish,
|
|
128
|
+
bytes: totals,
|
|
129
|
+
counts,
|
|
130
|
+
thirdParty: { count: thirdPartyCount, bytes: thirdPartyBytes },
|
|
131
|
+
requestsTotal: requests.length,
|
|
132
|
+
};
|
|
133
|
+
} finally {
|
|
134
|
+
await browser.close();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function extractFontLoading(stack = {}) {
|
|
139
|
+
return fontLoadingStrategy(stack);
|
|
140
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// v10.2 — Responsive Screenshots
|
|
2
|
+
//
|
|
3
|
+
// Full-page PNGs at each breakpoint × (light, dark). Lives alongside the
|
|
4
|
+
// component screenshots dir so output stays organised. Writes to
|
|
5
|
+
// `screenshots/responsive/<breakpoint>-<scheme>.png` and returns an index.
|
|
6
|
+
|
|
7
|
+
import { chromium } from 'playwright';
|
|
8
|
+
import { mkdirSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
|
|
11
|
+
const BREAKPOINTS = [
|
|
12
|
+
{ slug: 'mobile', width: 375, height: 812 },
|
|
13
|
+
{ slug: 'tablet', width: 768, height: 1024 },
|
|
14
|
+
{ slug: 'desktop', width: 1280, height: 800 },
|
|
15
|
+
{ slug: 'wide', width: 1920, height: 1080 },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
async function captureAt(url, dir, bp, scheme, channel) {
|
|
19
|
+
const browser = await chromium.launch({ headless: true, ...(channel && { channel }) });
|
|
20
|
+
try {
|
|
21
|
+
const ctx = await browser.newContext({
|
|
22
|
+
viewport: { width: bp.width, height: bp.height },
|
|
23
|
+
deviceScaleFactor: bp.slug === 'mobile' ? 2 : 1,
|
|
24
|
+
colorScheme: scheme,
|
|
25
|
+
});
|
|
26
|
+
const page = await ctx.newPage();
|
|
27
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {});
|
|
28
|
+
await page.waitForLoadState('networkidle').catch(() => {});
|
|
29
|
+
await page.evaluate(() => document.fonts.ready).catch(() => {});
|
|
30
|
+
const file = `${bp.slug}-${scheme}.png`;
|
|
31
|
+
const path = join(dir, file);
|
|
32
|
+
await page.screenshot({ path, fullPage: true });
|
|
33
|
+
return { breakpoint: bp.slug, scheme, width: bp.width, path: `screenshots/responsive/${file}` };
|
|
34
|
+
} finally {
|
|
35
|
+
await browser.close();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function captureResponsiveScreenshots(url, outDir, { includeDark = true, channel } = {}) {
|
|
40
|
+
const dir = join(outDir, 'screenshots', 'responsive');
|
|
41
|
+
mkdirSync(dir, { recursive: true });
|
|
42
|
+
const out = [];
|
|
43
|
+
const schemes = includeDark ? ['light', 'dark'] : ['light'];
|
|
44
|
+
for (const bp of BREAKPOINTS) {
|
|
45
|
+
for (const scheme of schemes) {
|
|
46
|
+
try {
|
|
47
|
+
const row = await captureAt(url, dir, bp, scheme, channel);
|
|
48
|
+
out.push(row);
|
|
49
|
+
} catch (e) {
|
|
50
|
+
out.push({ breakpoint: bp.slug, scheme, error: e.message });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { count: out.filter(r => !r.error).length, shots: out };
|
|
55
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// v10.3 — SEO & Structured Data
|
|
2
|
+
//
|
|
3
|
+
// Pure extractor — operates on the metas + scripts the crawler already
|
|
4
|
+
// collected. Captures Open Graph, Twitter cards, canonical, manifest, theme
|
|
5
|
+
// color, and every inline JSON-LD block (schema.org structured data).
|
|
6
|
+
|
|
7
|
+
function pickMeta(metas, name) {
|
|
8
|
+
const m = metas.find(m => (m.name || '').toLowerCase() === name.toLowerCase());
|
|
9
|
+
return m ? m.content : null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function jsonLdFromScripts(rawScripts = []) {
|
|
13
|
+
// rawScripts here may be just URLs. For JSON-LD we need inline script text,
|
|
14
|
+
// which the crawler doesn't currently capture. Accept an optional `inline`
|
|
15
|
+
// parameter from a richer payload where available.
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function extractSeo(rawData = {}) {
|
|
20
|
+
const stack = rawData.light?.stack || {};
|
|
21
|
+
const metas = stack.metas || [];
|
|
22
|
+
const openGraph = {};
|
|
23
|
+
const twitter = {};
|
|
24
|
+
for (const m of metas) {
|
|
25
|
+
const name = (m.name || '').toLowerCase();
|
|
26
|
+
if (name.startsWith('og:')) openGraph[name.slice(3)] = m.content;
|
|
27
|
+
else if (name.startsWith('twitter:')) twitter[name.slice(8)] = m.content;
|
|
28
|
+
}
|
|
29
|
+
const description = pickMeta(metas, 'description');
|
|
30
|
+
const canonical = pickMeta(metas, 'canonical');
|
|
31
|
+
const themeColor = pickMeta(metas, 'theme-color');
|
|
32
|
+
const viewport = pickMeta(metas, 'viewport');
|
|
33
|
+
|
|
34
|
+
const inlineJsonLd = Array.isArray(rawData.light?.jsonLd) ? rawData.light.jsonLd : [];
|
|
35
|
+
const favicons = rawData.light?.favicons || [];
|
|
36
|
+
const manifest = rawData.light?.manifest || null;
|
|
37
|
+
|
|
38
|
+
const structured = [];
|
|
39
|
+
for (const block of inlineJsonLd) {
|
|
40
|
+
try {
|
|
41
|
+
const parsed = typeof block === 'string' ? JSON.parse(block) : block;
|
|
42
|
+
const entries = Array.isArray(parsed) ? parsed : [parsed];
|
|
43
|
+
for (const e of entries) {
|
|
44
|
+
structured.push({ type: e['@type'] || 'Thing', name: e.name || e.headline || null, sample: JSON.stringify(e).slice(0, 400) });
|
|
45
|
+
}
|
|
46
|
+
} catch { /* skip bad JSON-LD */ }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
openGraph,
|
|
51
|
+
twitter,
|
|
52
|
+
description,
|
|
53
|
+
canonical,
|
|
54
|
+
themeColor,
|
|
55
|
+
viewport,
|
|
56
|
+
favicons,
|
|
57
|
+
manifest,
|
|
58
|
+
structuredData: structured,
|
|
59
|
+
score: {
|
|
60
|
+
hasOg: Object.keys(openGraph).length > 0,
|
|
61
|
+
hasTwitter: Object.keys(twitter).length > 0,
|
|
62
|
+
hasDescription: !!description,
|
|
63
|
+
hasCanonical: !!canonical,
|
|
64
|
+
hasStructuredData: structured.length > 0,
|
|
65
|
+
hasFavicon: favicons.length > 0,
|
|
66
|
+
hasThemeColor: !!themeColor,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
package/src/index.js
CHANGED
|
@@ -33,6 +33,7 @@ import { extractSectionRoles } from './extractors/section-roles.js';
|
|
|
33
33
|
import { extractComponentLibrary } from './extractors/component-library.js';
|
|
34
34
|
import { extractMaterialLanguage } from './extractors/material-language.js';
|
|
35
35
|
import { extractImageryStyle } from './extractors/imagery-style.js';
|
|
36
|
+
import { extractSeo } from './extractors/seo.js';
|
|
36
37
|
import { formatDtcgTokens } from './formatters/dtcg-tokens.js';
|
|
37
38
|
import { formatMotionTokens } from './formatters/motion-tokens.js';
|
|
38
39
|
|
|
@@ -137,6 +138,7 @@ export async function extractDesignLanguage(url, options = {}) {
|
|
|
137
138
|
design.componentLibrary = safeExtract(extractComponentLibrary, rawData.light?.stack || {}) || { library: 'unknown', confidence: 0, evidence: [], alternates: [] };
|
|
138
139
|
design.materialLanguage = safeExtract(extractMaterialLanguage, design) || { label: 'flat', confidence: 0, signals: [], metrics: {} };
|
|
139
140
|
design.imageryStyle = safeExtract(extractImageryStyle, rawData.light?.images || []) || { label: 'none', confidence: 0, counts: {}, signals: [] };
|
|
141
|
+
design.seo = safeExtract(extractSeo, rawData) || { openGraph: {}, twitter: {}, structuredData: [], score: {} };
|
|
140
142
|
// Stash raw crawler output so downstream orchestration (multipage, smart)
|
|
141
143
|
// can rebuild the digest without re-crawling.
|
|
142
144
|
design._raw = rawData;
|
|
@@ -203,6 +205,10 @@ export { extractMaterialLanguage } from './extractors/material-language.js';
|
|
|
203
205
|
export { extractImageryStyle } from './extractors/imagery-style.js';
|
|
204
206
|
export { extractLogo } from './extractors/logo.js';
|
|
205
207
|
export { captureComponentScreenshotsV10 } from './extractors/component-screenshots.js';
|
|
208
|
+
export { pairDarkMode } from './extractors/dark-mode-pair.js';
|
|
209
|
+
export { captureResponsiveScreenshots } from './extractors/responsive-screenshots.js';
|
|
210
|
+
export { captureCoreWebVitals, extractFontLoading } from './extractors/perf.js';
|
|
211
|
+
export { extractSeo } from './extractors/seo.js';
|
|
206
212
|
export { refineWithSmart } from './classifiers/smart.js';
|
|
207
213
|
export { crawlCanonicalPages, computeCrossPageConsistency, discoverCanonicalPages } from './multipage.js';
|
|
208
214
|
export { buildPromptPack, formatV0Prompt, formatLovablePrompt, formatCursorPrompt, formatClaudeArtifactPrompt } from './formatters/prompt-pack.js';
|