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 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("--pages <n>", "Alias for --crawl (deprecated)", (v) => {
56
- const n = parseInt(v, 10);
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 ?? opts.pages ?? null;
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
 
@@ -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
- const isCanvasOnly = await page.evaluate(() => {
996
- const canvases = document.querySelectorAll("canvas");
997
- const hasRealContent = document.body.textContent.trim().length > 200;
998
- const hasManyCanvases = canvases.length > 3;
999
- const hasWebGL = Array.from(canvases).some((c) => {
1000
- const ctx = c.getContext("webgl") || c.getContext("webgl2");
1001
- return !!ctx;
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
- return hasManyCanvases && hasWebGL && !hasRealContent;
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
- await page.screenshot({ path: options.screenshotPath, fullPage: false });
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");
@@ -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
- if (!favicons.some(f => f.url.endsWith('/favicon.ico'))) {
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
 
@@ -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.url) {
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. Is Playwright installed? Run: npx playwright install chromium\n\n${err.message}`,
55
+ error: `Browser launch failed. Install the matching browser: npx playwright@${pwVersion} install chromium\n\n${err.message}`,
54
56
  };
55
57
  }
56
58
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dembrandt",
3
- "version": "0.14.1",
3
+ "version": "0.16.0",
4
4
  "description": "Extract design tokens and publicly visible CSS information from any website",
5
5
  "mcpName": "io.github.dembrandt/dembrandt",
6
6
  "main": "index.js",