dembrandt 0.14.1 → 0.16.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/README.md +4 -0
- package/index.js +9 -14
- package/lib/extractors/index.js +28 -12
- package/lib/extractors/logo.js +31 -1
- package/lib/formatters/pdf.js +5 -0
- package/lib/formatters/terminal.js +7 -1
- package/mcp-server.js +3 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -59,6 +59,9 @@ Load extractions, track token drift, and compare snapshots. **[dembrandt.com/app
|
|
|
59
59
|
* **Copy tokens.** Paste values straight into Copilot, Claude, or Cursor.
|
|
60
60
|
* **No login.** Your data stays in the browser, nothing is sent to any server.
|
|
61
61
|
|
|
62
|
+
## Recipes
|
|
63
|
+
|
|
64
|
+
**[dembrandt.com/recipes](https://www.dembrandt.com/recipes)** — 38 ready-to-run workflows. Copy a command, paste a prompt, get a result. Covers competitor benchmarking, WCAG audits, CI/CD drift detection, Figma token push, and agentic design system builds. Filterable by role.
|
|
62
65
|
|
|
63
66
|
## What to expect from extraction?
|
|
64
67
|
|
|
@@ -84,6 +87,7 @@ dembrandt example.com --mobile # Use mobile viewport (390x844) for respo
|
|
|
84
87
|
dembrandt example.com --slow # 3x longer timeouts (24s hydration) for JavaScript-heavy sites
|
|
85
88
|
dembrandt example.com --brand-guide # Generate a brand guide PDF
|
|
86
89
|
dembrandt example.com --design-md # Generate a DESIGN.md file for AI agents
|
|
90
|
+
dembrandt example.com /pricing /docs # Extract specific paths and merge results into one output
|
|
87
91
|
dembrandt example.com --crawl 5 # Analyze 5 pages (homepage + 4 discovered pages), merges results
|
|
88
92
|
dembrandt example.com --sitemap # Discover pages from sitemap.xml instead of DOM links
|
|
89
93
|
dembrandt example.com --crawl 10 --sitemap # Combine: up to 10 pages discovered via sitemap
|
package/index.js
CHANGED
|
@@ -32,7 +32,7 @@ program
|
|
|
32
32
|
.description("Extract design tokens from any website")
|
|
33
33
|
.version(version)
|
|
34
34
|
.argument("<url>")
|
|
35
|
-
.argument("[paths...]", "Additional paths to extract and merge, e.g. /pricing /docs")
|
|
35
|
+
.argument("[paths...]", "Additional paths on the same domain to extract and merge, e.g. /pricing /docs")
|
|
36
36
|
.option("--browser <type>", "Browser to use (chromium|firefox); set BROWSER_CDP_ENDPOINT env var to connect to an existing Chromium instance via CDP", "chromium")
|
|
37
37
|
.option("--json-only", "Output raw JSON")
|
|
38
38
|
.option("--save-output", "Save JSON file to output folder")
|
|
@@ -44,24 +44,19 @@ program
|
|
|
44
44
|
.option("--design-md", "Export a DESIGN.md file")
|
|
45
45
|
.option("--no-sandbox", "Disable browser sandbox (needed for Docker/CI)")
|
|
46
46
|
.option("--raw-colors", "Include pre-filter raw colors in JSON output")
|
|
47
|
-
.option("--screenshot <path>", "Save a screenshot of the page")
|
|
47
|
+
.option("--screenshot <path>", "Save a viewport screenshot of the page (not full-page)")
|
|
48
48
|
.option("--wcag", "Analyze WCAG contrast ratios between palette colors")
|
|
49
|
-
.option("--crawl [n]", "Auto-discover and extract up to N pages via DOM links (default: 5)", (v) => {
|
|
49
|
+
.option("--crawl [n]", "Auto-discover and extract up to N pages via DOM links (default: 5); combine with --sitemap to use sitemap discovery instead", (v) => {
|
|
50
50
|
if (v === undefined || v === true) return 5;
|
|
51
51
|
const n = parseInt(v, 10);
|
|
52
52
|
if (isNaN(n) || n < 1) throw new Error(`--crawl must be a positive integer, got: ${v}`);
|
|
53
53
|
return n;
|
|
54
54
|
})
|
|
55
|
-
.option("--
|
|
56
|
-
|
|
57
|
-
if (isNaN(n) || n < 1) throw new Error(`--pages must be a positive integer, got: ${v}`);
|
|
58
|
-
return n;
|
|
59
|
-
})
|
|
60
|
-
.option("--sitemap", "Discover pages from sitemap.xml instead of DOM links")
|
|
61
|
-
.option("--stealth", "Enable anti-detection scripts to bypass bot protection (use only when authorized)")
|
|
55
|
+
.option("--sitemap", "Discover pages from sitemap.xml instead of DOM links; use alone or combine with --crawl to set page limit")
|
|
56
|
+
.option("--stealth", "Enable anti-detection: navigator spoofing, human mouse simulation, randomized fingerprint (use only when authorized)")
|
|
62
57
|
.option("--user-agent <string>", "Custom user agent string")
|
|
63
|
-
.option("--locale <string>", "Browser locale, e.g. en-GB, fi-FI (default: en-US)")
|
|
64
|
-
.option("--timezone <string>", "Browser timezone, e.g. Europe/Helsinki (default: America/New_York)")
|
|
58
|
+
.option("--locale <string>", "Browser locale for fingerprint, e.g. en-GB, fi-FI; affects content only if the site reacts to Accept-Language (default: en-US)")
|
|
59
|
+
.option("--timezone <string>", "Browser timezone for fingerprint, e.g. Europe/Helsinki; affects content only if the site reacts to timezone (default: America/New_York)")
|
|
65
60
|
.option("--accept-language <string>", "Custom Accept-Language header value")
|
|
66
61
|
.option("--screen-size <WxH>", "Physical screen resolution to report, e.g. 1920x1080 (default: 1920x1080)")
|
|
67
62
|
.action(async (input, paths, opts) => {
|
|
@@ -126,7 +121,7 @@ program
|
|
|
126
121
|
}
|
|
127
122
|
|
|
128
123
|
try {
|
|
129
|
-
const crawlN = opts.crawl ??
|
|
124
|
+
const crawlN = opts.crawl ?? null;
|
|
130
125
|
const isAutoCrawl = crawlN && !opts.sitemap && (!paths || paths.length === 0);
|
|
131
126
|
const hasExplicitPaths = paths && paths.length > 0;
|
|
132
127
|
|
|
@@ -265,7 +260,7 @@ program
|
|
|
265
260
|
mkdirSync(outputDir, { recursive: true });
|
|
266
261
|
|
|
267
262
|
const suffix = opts.dtcg ? '.tokens' : '';
|
|
268
|
-
const filename = `${timestamp}${suffix}.json`;
|
|
263
|
+
const filename = `${timestamp}_v${version}${suffix}.json`;
|
|
269
264
|
const filepath = join(outputDir, filename);
|
|
270
265
|
writeFileSync(filepath, JSON.stringify(outputData, null, 2));
|
|
271
266
|
|
package/lib/extractors/index.js
CHANGED
|
@@ -241,6 +241,7 @@ async function simulateHumanMouse(page) {
|
|
|
241
241
|
export async function extractBranding(url, spinner, browser, options = {}) {
|
|
242
242
|
const timeoutMultiplier = options.slow ? 3 : 1;
|
|
243
243
|
const timeouts = [];
|
|
244
|
+
const degraded = []; // post-extraction stages that failed but did not abort the run
|
|
244
245
|
|
|
245
246
|
spinner.text = "Creating browser context...";
|
|
246
247
|
|
|
@@ -556,6 +557,7 @@ export async function extractBranding(url, spinner, browser, options = {}) {
|
|
|
556
557
|
spinner.stop();
|
|
557
558
|
|
|
558
559
|
// Inject manifest theme_color / background_color as high-confidence palette entries
|
|
560
|
+
try {
|
|
559
561
|
if (manifest) {
|
|
560
562
|
const manifestColorEntries = [
|
|
561
563
|
manifest.themeColor && { color: manifest.themeColor, label: 'manifest:theme_color' },
|
|
@@ -615,6 +617,7 @@ export async function extractBranding(url, spinner, browser, options = {}) {
|
|
|
615
617
|
].filter(Boolean);
|
|
616
618
|
console.log(color.success(` ✓ Manifest: ${parts.join(', ')}`));
|
|
617
619
|
}
|
|
620
|
+
} catch (e) { degraded.push('manifest'); console.log(color.warning(' ! Manifest injection: failed (continuing)')); }
|
|
618
621
|
console.log(colors.palette.length > 0 ? color.success(` ✓ Colors: ${colors.palette.length} found`) : color.warning(` ! Colors: 0 found`));
|
|
619
622
|
console.log(typography.styles.length > 0 ? color.success(` ✓ Typography: ${typography.styles.length} styles`) : color.warning(` ! Typography: 0 styles`));
|
|
620
623
|
console.log(spacing.commonValues.length > 0 ? color.success(` ✓ Spacing: ${spacing.commonValues.length} values`) : color.warning(` ! Spacing: 0 values`));
|
|
@@ -634,8 +637,10 @@ export async function extractBranding(url, spinner, browser, options = {}) {
|
|
|
634
637
|
console.log();
|
|
635
638
|
|
|
636
639
|
// Hover/focus state extraction
|
|
637
|
-
spinner.start("Extracting hover/focus state colors...");
|
|
638
640
|
const hoverFocusColors = [];
|
|
641
|
+
const interactiveStatePairs = []; // { fg, bg, state, tag } — raw rgb strings, normalized later (consumed by WCAG stage)
|
|
642
|
+
try {
|
|
643
|
+
spinner.start("Extracting hover/focus state colors...");
|
|
639
644
|
|
|
640
645
|
function splitMultiValueColors(colorValue) {
|
|
641
646
|
if (!colorValue) return [];
|
|
@@ -655,7 +660,6 @@ export async function extractBranding(url, spinner, browser, options = {}) {
|
|
|
655
660
|
`);
|
|
656
661
|
|
|
657
662
|
const sampled = interactiveElements.slice(0, 20);
|
|
658
|
-
const interactiveStatePairs = []; // { fg, bg, state, tag } — raw rgb strings, normalized later
|
|
659
663
|
|
|
660
664
|
for (const element of sampled) {
|
|
661
665
|
try {
|
|
@@ -837,9 +841,11 @@ export async function extractBranding(url, spinner, browser, options = {}) {
|
|
|
837
841
|
console.log(hoverFocusColors.length > 0 ?
|
|
838
842
|
color.success(` ✓ Hover/focus: ${hoverFocusColors.length} state colors found`) :
|
|
839
843
|
color.warning(` ! Hover/focus: 0 state colors found`));
|
|
844
|
+
} catch (e) { spinner.stop(); degraded.push('hover-focus'); console.log(color.warning(' ! Hover/focus: failed (continuing)')); }
|
|
840
845
|
|
|
841
846
|
// Dark mode
|
|
842
847
|
if (options.darkMode) {
|
|
848
|
+
try {
|
|
843
849
|
spinner.start("Extracting dark mode colors...");
|
|
844
850
|
await page.evaluate(() => {
|
|
845
851
|
document.documentElement.setAttribute("data-theme", "dark");
|
|
@@ -867,10 +873,12 @@ export async function extractBranding(url, spinner, browser, options = {}) {
|
|
|
867
873
|
|
|
868
874
|
spinner.stop();
|
|
869
875
|
console.log(color.success(` ✓ Dark mode: +${darkModeColors.palette.length} colors`));
|
|
876
|
+
} catch (e) { spinner.stop(); degraded.push('dark-mode'); console.log(color.warning(' ! Dark mode: failed (continuing)')); }
|
|
870
877
|
}
|
|
871
878
|
|
|
872
879
|
// Mobile viewport
|
|
873
880
|
if (options.mobile) {
|
|
881
|
+
try {
|
|
874
882
|
spinner.start("Extracting mobile viewport colors...");
|
|
875
883
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
876
884
|
await page.waitForTimeout(500 * timeoutMultiplier);
|
|
@@ -885,6 +893,7 @@ export async function extractBranding(url, spinner, browser, options = {}) {
|
|
|
885
893
|
|
|
886
894
|
spinner.stop();
|
|
887
895
|
console.log(color.success(` ✓ Mobile: +${mobileColors.palette.length} colors`));
|
|
896
|
+
} catch (e) { spinner.stop(); degraded.push('mobile'); console.log(color.warning(' ! Mobile: failed (continuing)')); }
|
|
888
897
|
}
|
|
889
898
|
|
|
890
899
|
spinner.stop();
|
|
@@ -992,16 +1001,19 @@ export async function extractBranding(url, spinner, browser, options = {}) {
|
|
|
992
1001
|
...(options.wcag ? { wcag } : {}),
|
|
993
1002
|
};
|
|
994
1003
|
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
const
|
|
1001
|
-
|
|
1004
|
+
let isCanvasOnly = false;
|
|
1005
|
+
try {
|
|
1006
|
+
isCanvasOnly = await page.evaluate(() => {
|
|
1007
|
+
const canvases = document.querySelectorAll("canvas");
|
|
1008
|
+
const hasRealContent = document.body.textContent.trim().length > 200;
|
|
1009
|
+
const hasManyCanvases = canvases.length > 3;
|
|
1010
|
+
const hasWebGL = Array.from(canvases).some((c) => {
|
|
1011
|
+
const ctx = c.getContext("webgl") || c.getContext("webgl2");
|
|
1012
|
+
return !!ctx;
|
|
1013
|
+
});
|
|
1014
|
+
return hasManyCanvases && hasWebGL && !hasRealContent;
|
|
1002
1015
|
});
|
|
1003
|
-
|
|
1004
|
-
});
|
|
1016
|
+
} catch { isCanvasOnly = false; }
|
|
1005
1017
|
|
|
1006
1018
|
if (isCanvasOnly) {
|
|
1007
1019
|
result.note = "This website uses canvas/WebGL rendering. Design system cannot be extracted from DOM.";
|
|
@@ -1009,7 +1021,9 @@ export async function extractBranding(url, spinner, browser, options = {}) {
|
|
|
1009
1021
|
}
|
|
1010
1022
|
|
|
1011
1023
|
if (options.screenshotPath) {
|
|
1012
|
-
|
|
1024
|
+
try {
|
|
1025
|
+
await page.screenshot({ path: options.screenshotPath, fullPage: false });
|
|
1026
|
+
} catch (e) { degraded.push('screenshot'); }
|
|
1013
1027
|
}
|
|
1014
1028
|
|
|
1015
1029
|
if (options.includeRawColors) {
|
|
@@ -1024,6 +1038,8 @@ export async function extractBranding(url, spinner, browser, options = {}) {
|
|
|
1024
1038
|
}
|
|
1025
1039
|
}
|
|
1026
1040
|
|
|
1041
|
+
if (degraded.length) result.meta.degraded = degraded;
|
|
1042
|
+
|
|
1027
1043
|
return result;
|
|
1028
1044
|
} catch (error) {
|
|
1029
1045
|
spinner.fail("Extraction failed");
|
package/lib/extractors/logo.js
CHANGED
|
@@ -262,10 +262,37 @@ export async function extractLogo(page, url) {
|
|
|
262
262
|
position: { top: rect.top, left: rect.left },
|
|
263
263
|
};
|
|
264
264
|
} else if (el.tagName === 'SVG' || el.tagName === 'svg') {
|
|
265
|
+
// Inline SVG: the logo lives in the DOM, not at a URL. Capture the
|
|
266
|
+
// resolved color (currentColor → the element's `color`), serialize the
|
|
267
|
+
// markup, and emit a self-contained data URI so the logo is usable.
|
|
268
|
+
const color = toHex(computed.color);
|
|
269
|
+
const ariaLabel = el.getAttribute('aria-label') || null;
|
|
270
|
+
|
|
271
|
+
let markup = null;
|
|
272
|
+
let dataUri = null;
|
|
273
|
+
try {
|
|
274
|
+
const clone = el.cloneNode(true);
|
|
275
|
+
// Bake the resolved color in so `currentColor` fills render standalone.
|
|
276
|
+
if (color) clone.style.color = color;
|
|
277
|
+
if (!clone.getAttribute('xmlns')) clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
278
|
+
const serialized = clone.outerHTML;
|
|
279
|
+
// Skip pathological inline SVGs (sprite sheets, embedded rasters).
|
|
280
|
+
if (serialized && serialized.length <= 50000) {
|
|
281
|
+
markup = serialized;
|
|
282
|
+
dataUri = 'data:image/svg+xml;utf8,' + encodeURIComponent(serialized);
|
|
283
|
+
}
|
|
284
|
+
} catch {}
|
|
285
|
+
|
|
265
286
|
return {
|
|
266
287
|
source: 'svg',
|
|
267
288
|
context,
|
|
289
|
+
inline: true,
|
|
290
|
+
// Link target the logo points at; the logo itself is `markup`/`dataUri`.
|
|
268
291
|
url: parentLink ? parentLink.href : baseUrl,
|
|
292
|
+
color,
|
|
293
|
+
ariaLabel,
|
|
294
|
+
markup,
|
|
295
|
+
dataUri,
|
|
269
296
|
width: el.width?.baseVal?.value || rect.width,
|
|
270
297
|
height: el.height?.baseVal?.value || rect.height,
|
|
271
298
|
type: logoType,
|
|
@@ -557,7 +584,10 @@ export async function extractLogo(page, url) {
|
|
|
557
584
|
try { favicons.push({ type: 'twitter:image', url: new URL(twitterImage.getAttribute('content'), baseUrl).href, sizes: null }); } catch {}
|
|
558
585
|
}
|
|
559
586
|
|
|
560
|
-
|
|
587
|
+
// Only synthesize the /favicon.ico fallback when the page declares no icon at
|
|
588
|
+
// all. Sites that ship their icon under another name (e.g. favicon-purple.ico)
|
|
589
|
+
// often 404 on the bare /favicon.ico, which would surface as a broken entry.
|
|
590
|
+
if (!favicons.some(f => /icon/i.test(f.type || ''))) {
|
|
561
591
|
favicons.push({ type: 'favicon.ico', url: new URL('/favicon.ico', baseUrl).href, sizes: null });
|
|
562
592
|
}
|
|
563
593
|
|
package/lib/formatters/pdf.js
CHANGED
|
@@ -846,6 +846,11 @@ ${(() => {
|
|
|
846
846
|
function getLogoImageUrl(data) {
|
|
847
847
|
const isImageUrl = (url) => /\.(svg|png|jpg|jpeg|webp|gif)(\?.*)?$/i.test(url);
|
|
848
848
|
|
|
849
|
+
// Inline SVG logos carry their own self-contained data URI.
|
|
850
|
+
if (data.logo?.inline && data.logo.dataUri) {
|
|
851
|
+
return data.logo.dataUri;
|
|
852
|
+
}
|
|
853
|
+
|
|
849
854
|
if (data.logo?.url && isImageUrl(data.logo.url)) {
|
|
850
855
|
return data.logo.url;
|
|
851
856
|
}
|
|
@@ -69,7 +69,13 @@ function displayLogo(logo) {
|
|
|
69
69
|
|
|
70
70
|
console.log(chalk.dim('├─') + ' ' + chalk.bold('Logo'));
|
|
71
71
|
|
|
72
|
-
if (logo.
|
|
72
|
+
if (logo.inline) {
|
|
73
|
+
const colorInfo = logo.color ? ` · ${logo.color}` : '';
|
|
74
|
+
console.log(chalk.dim('│ ├─') + ' ' + chalk.dim(`inline ${logo.source || 'svg'}${colorInfo}`));
|
|
75
|
+
if (logo.url && logo.url !== '/') {
|
|
76
|
+
console.log(chalk.dim('│ ├─') + ' ' + chalk.blue(terminalLink(logo.url)));
|
|
77
|
+
}
|
|
78
|
+
} else if (logo.url) {
|
|
73
79
|
console.log(chalk.dim('│ ├─') + ' ' + chalk.blue(terminalLink(logo.url)));
|
|
74
80
|
}
|
|
75
81
|
|
package/mcp-server.js
CHANGED
|
@@ -15,9 +15,11 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
15
15
|
import { z } from "zod";
|
|
16
16
|
import { chromium } from "playwright-core";
|
|
17
17
|
import { readFileSync } from "node:fs";
|
|
18
|
+
import { createRequire } from "node:module";
|
|
18
19
|
import { extractBranding } from "./lib/extractors/index.js";
|
|
19
20
|
|
|
20
21
|
const { version } = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8"));
|
|
22
|
+
const pwVersion = createRequire(import.meta.url)("playwright-core/package.json").version;
|
|
21
23
|
|
|
22
24
|
const server = new McpServer({
|
|
23
25
|
name: "dembrandt",
|
|
@@ -50,7 +52,7 @@ async function runExtraction(url, options = {}) {
|
|
|
50
52
|
} catch (err) {
|
|
51
53
|
return {
|
|
52
54
|
ok: false,
|
|
53
|
-
error: `Browser launch failed.
|
|
55
|
+
error: `Browser launch failed. Install the matching browser: npx playwright@${pwVersion} install chromium\n\n${err.message}`,
|
|
54
56
|
};
|
|
55
57
|
}
|
|
56
58
|
|