dembrandt 0.12.9 → 0.12.10
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 +5 -5
- package/index.js +1 -0
- package/lib/extractors/index.js +1 -1
- package/lib/merger.js +52 -0
- package/mcp-server.js +150 -21
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -67,9 +67,9 @@ dembrandt example.com --mobile # Use mobile viewport (390x844) for respo
|
|
|
67
67
|
dembrandt example.com --slow # 3x longer timeouts (24s hydration) for JavaScript-heavy sites
|
|
68
68
|
dembrandt example.com --brand-guide # Generate a brand guide PDF
|
|
69
69
|
dembrandt example.com --design-md # Generate a DESIGN.md file for AI agents
|
|
70
|
-
dembrandt example.com --
|
|
70
|
+
dembrandt example.com --crawl 5 # Analyze 5 pages (homepage + 4 discovered pages), merges results
|
|
71
71
|
dembrandt example.com --sitemap # Discover pages from sitemap.xml instead of DOM links
|
|
72
|
-
dembrandt example.com --
|
|
72
|
+
dembrandt example.com --crawl 10 --sitemap # Combine: up to 10 pages discovered via sitemap
|
|
73
73
|
dembrandt example.com --no-sandbox # Disable Chromium sandbox (required for Docker/CI)
|
|
74
74
|
dembrandt example.com --browser=firefox # Use Firefox instead of Chromium (better for Cloudflare bypass)
|
|
75
75
|
dembrandt example.com --wcag # WCAG 2.1 contrast analysis — real DOM pairs, AA/AAA grades
|
|
@@ -83,13 +83,13 @@ Analyze multiple pages to get a more complete picture of a site's design system.
|
|
|
83
83
|
|
|
84
84
|
```bash
|
|
85
85
|
# Analyze homepage + 4 auto-discovered pages (default: 5 total)
|
|
86
|
-
dembrandt example.com --
|
|
86
|
+
dembrandt example.com --crawl 5
|
|
87
87
|
|
|
88
88
|
# Use sitemap.xml for page discovery instead of DOM link scraping
|
|
89
89
|
dembrandt example.com --sitemap
|
|
90
90
|
|
|
91
91
|
# Combine both: up to 10 pages from sitemap
|
|
92
|
-
dembrandt example.com --
|
|
92
|
+
dembrandt example.com --crawl 10 --sitemap
|
|
93
93
|
```
|
|
94
94
|
|
|
95
95
|
**Page discovery** works two ways:
|
|
@@ -197,7 +197,7 @@ dembrandt braintree.com --save-output
|
|
|
197
197
|
|
|
198
198
|
**Multi-page audit** — get a fuller picture across the whole site
|
|
199
199
|
```bash
|
|
200
|
-
dembrandt stripe.com --
|
|
200
|
+
dembrandt stripe.com --crawl 10 --sitemap --save-output
|
|
201
201
|
```
|
|
202
202
|
|
|
203
203
|
**Spot-check a value** — verify a specific token fast
|
package/index.js
CHANGED
package/lib/extractors/index.js
CHANGED
|
@@ -168,7 +168,7 @@ export async function extractBranding(url, spinner, browser, options = {}) {
|
|
|
168
168
|
spinner.stop();
|
|
169
169
|
console.log(color.info("\n Extracting design tokens...\n"));
|
|
170
170
|
|
|
171
|
-
spinner.start("Analyzing design system (
|
|
171
|
+
spinner.start("Analyzing design system (17 parallel tasks)...");
|
|
172
172
|
const [
|
|
173
173
|
{ logo, instances: logoInstances, favicons },
|
|
174
174
|
colors,
|
package/lib/merger.js
CHANGED
|
@@ -275,6 +275,56 @@ function mergeByName(results, getter) {
|
|
|
275
275
|
return out;
|
|
276
276
|
}
|
|
277
277
|
|
|
278
|
+
function mergeGradients(results) {
|
|
279
|
+
const map = new Map();
|
|
280
|
+
results.forEach(r => {
|
|
281
|
+
(r.gradients || []).forEach(g => {
|
|
282
|
+
const key = g.gradient;
|
|
283
|
+
if (!map.has(key)) {
|
|
284
|
+
map.set(key, { ...g });
|
|
285
|
+
} else {
|
|
286
|
+
map.get(key).count += (g.count || 1);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
return [...map.values()].sort((a, b) => b.count - a.count);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function mergeMotion(results) {
|
|
294
|
+
const base = results[0].motion || { durations: [], easings: [], animations: [], contexts: {}, interactiveDeltas: [] };
|
|
295
|
+
|
|
296
|
+
const durationMap = new Map();
|
|
297
|
+
results.forEach(r => {
|
|
298
|
+
(r.motion?.durations || []).forEach(d => {
|
|
299
|
+
if (!durationMap.has(d.value)) durationMap.set(d.value, { ...d });
|
|
300
|
+
else durationMap.get(d.value).count += (d.count || 1);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const easingMap = new Map();
|
|
305
|
+
results.forEach(r => {
|
|
306
|
+
(r.motion?.easings || []).forEach(e => {
|
|
307
|
+
if (!easingMap.has(e.value)) easingMap.set(e.value, { ...e });
|
|
308
|
+
else easingMap.get(e.value).count += (e.count || 1);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const animMap = new Map();
|
|
313
|
+
results.forEach(r => {
|
|
314
|
+
(r.motion?.animations || []).forEach(a => {
|
|
315
|
+
if (!animMap.has(a.name || a.value)) animMap.set(a.name || a.value, { ...a });
|
|
316
|
+
else animMap.get(a.name || a.value).count += (a.count || 1);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
...base,
|
|
322
|
+
durations: [...durationMap.values()].sort((a, b) => a.ms - b.ms),
|
|
323
|
+
easings: [...easingMap.values()].sort((a, b) => b.count - a.count).slice(0, 8),
|
|
324
|
+
animations: [...animMap.values()].sort((a, b) => b.count - a.count).slice(0, 8),
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
278
328
|
/**
|
|
279
329
|
* Merge an array of per-page result objects into a single unified result.
|
|
280
330
|
* @param {Object[]} results - Array of extractBranding() result objects
|
|
@@ -298,6 +348,8 @@ export function mergeResults(results) {
|
|
|
298
348
|
borderRadius: mergeBorderRadius(results),
|
|
299
349
|
borders: mergeBorders(results),
|
|
300
350
|
shadows: mergeShadows(results),
|
|
351
|
+
gradients: mergeGradients(results),
|
|
352
|
+
motion: mergeMotion(results),
|
|
301
353
|
components: mergeComponents(results),
|
|
302
354
|
breakpoints: [
|
|
303
355
|
...new Map(
|
package/mcp-server.js
CHANGED
|
@@ -99,16 +99,119 @@ function errorResult(message) {
|
|
|
99
99
|
return { content: [{ type: "text", text: message }], isError: true };
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
// ── Job Queue ──────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
class JobQueue {
|
|
105
|
+
#jobs = new Map();
|
|
106
|
+
#queue = [];
|
|
107
|
+
#running = new Set();
|
|
108
|
+
#maxConcurrent = 2;
|
|
109
|
+
|
|
110
|
+
enqueue(url, opts, pick) {
|
|
111
|
+
const id = `job_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
112
|
+
this.#jobs.set(id, {
|
|
113
|
+
status: "queued",
|
|
114
|
+
url,
|
|
115
|
+
opts,
|
|
116
|
+
pick,
|
|
117
|
+
createdAt: Date.now(),
|
|
118
|
+
startedAt: undefined,
|
|
119
|
+
completedAt: undefined,
|
|
120
|
+
result: undefined,
|
|
121
|
+
error: undefined,
|
|
122
|
+
});
|
|
123
|
+
this.#queue.push(id);
|
|
124
|
+
void this.#drain();
|
|
125
|
+
return id;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
get(id) {
|
|
129
|
+
return this.#jobs.get(id) ?? null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
cancel(id) {
|
|
133
|
+
const job = this.#jobs.get(id);
|
|
134
|
+
if (!job || job.status !== "queued") return false;
|
|
135
|
+
job.status = "cancelled";
|
|
136
|
+
job.completedAt = Date.now();
|
|
137
|
+
const idx = this.#queue.indexOf(id);
|
|
138
|
+
if (idx !== -1) this.#queue.splice(idx, 1);
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async #drain() {
|
|
143
|
+
while (this.#queue.length > 0 && this.#running.size < this.#maxConcurrent) {
|
|
144
|
+
const id = this.#queue.shift();
|
|
145
|
+
const job = this.#jobs.get(id);
|
|
146
|
+
if (!job || job.status === "cancelled") continue;
|
|
147
|
+
|
|
148
|
+
job.status = "running";
|
|
149
|
+
job.startedAt = Date.now();
|
|
150
|
+
this.#running.add(id);
|
|
151
|
+
|
|
152
|
+
runExtraction(job.url, job.opts)
|
|
153
|
+
.then((result) => {
|
|
154
|
+
if (job.status === "cancelled") return;
|
|
155
|
+
if (result.ok) {
|
|
156
|
+
job.status = "completed";
|
|
157
|
+
job.result = job.pick(result.data);
|
|
158
|
+
} else {
|
|
159
|
+
job.status = "failed";
|
|
160
|
+
job.error = result.error;
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
.catch((err) => {
|
|
164
|
+
if (job.status !== "cancelled") {
|
|
165
|
+
job.status = "failed";
|
|
166
|
+
job.error = err.message || String(err);
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
.finally(() => {
|
|
170
|
+
job.completedAt = Date.now();
|
|
171
|
+
this.#running.delete(id);
|
|
172
|
+
void this.#drain();
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Remove completed/failed/cancelled jobs older than 1 hour
|
|
178
|
+
cleanup() {
|
|
179
|
+
const cutoff = Date.now() - 3_600_000;
|
|
180
|
+
for (const [id, job] of this.#jobs) {
|
|
181
|
+
if (
|
|
182
|
+
["completed", "failed", "cancelled"].includes(job.status) &&
|
|
183
|
+
job.completedAt !== undefined &&
|
|
184
|
+
job.completedAt < cutoff
|
|
185
|
+
) {
|
|
186
|
+
this.#jobs.delete(id);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const jobQueue = new JobQueue();
|
|
193
|
+
setInterval(() => jobQueue.cleanup(), 600_000);
|
|
194
|
+
|
|
195
|
+
// ── Helpers ────────────────────────────────────────────────────────────
|
|
196
|
+
|
|
102
197
|
/**
|
|
103
|
-
* Wrapper
|
|
104
|
-
*
|
|
198
|
+
* Wrapper for extraction tools.
|
|
199
|
+
* Async by default: enqueues and returns a job_id immediately.
|
|
200
|
+
* Pass sync: true to block and return the result directly.
|
|
105
201
|
*/
|
|
106
202
|
function toolHandler(pick, extraOptions = {}) {
|
|
107
203
|
return async (params) => {
|
|
108
|
-
const { url, slow, darkMode } = params;
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
204
|
+
const { url, slow, darkMode, sync } = params;
|
|
205
|
+
const opts = { slow, darkMode, ...extraOptions };
|
|
206
|
+
|
|
207
|
+
if (sync) {
|
|
208
|
+
const result = await runExtraction(url, opts);
|
|
209
|
+
if (!result.ok) return errorResult(result.error);
|
|
210
|
+
return jsonResult(pick(result.data));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const jobId = jobQueue.enqueue(url, opts, pick);
|
|
214
|
+
return jsonResult({ job_id: jobId, status: "queued" });
|
|
112
215
|
};
|
|
113
216
|
}
|
|
114
217
|
|
|
@@ -116,21 +219,22 @@ function toolHandler(pick, extraOptions = {}) {
|
|
|
116
219
|
|
|
117
220
|
const url = z.string().describe("Website URL (e.g. example.com)");
|
|
118
221
|
const slow = z.boolean().optional().default(false).describe("3x timeouts for heavy SPAs");
|
|
222
|
+
const sync = z.boolean().optional().default(false).describe("Wait for result directly instead of returning a job_id (blocks 15-40s)");
|
|
119
223
|
|
|
120
|
-
// ──
|
|
224
|
+
// ── Extraction tools ───────────────────────────────────────────────────
|
|
121
225
|
|
|
122
226
|
server.tool(
|
|
123
227
|
"get_design_tokens",
|
|
124
|
-
"Extract the full design system from a live website. Launches a real browser, navigates to the site, and returns production-ready design tokens: color palette (hex, RGB, LCH, OKLCH) with semantic roles and CSS custom properties, typography scale (families, fallbacks, sizes, weights, line heights, letter spacing by context), spacing system with grid detection, border radii, border patterns, box shadows for elevation, component styles (buttons with hover/focus states, inputs, links, badges), responsive breakpoints, logo and favicons, site name, detected CSS frameworks, and icon systems.
|
|
125
|
-
{ url, slow },
|
|
228
|
+
"Extract the full design system from a live website. Launches a real browser, navigates to the site, and returns production-ready design tokens: color palette (hex, RGB, LCH, OKLCH) with semantic roles and CSS custom properties, typography scale (families, fallbacks, sizes, weights, line heights, letter spacing by context), spacing system with grid detection, border radii, border patterns, box shadows for elevation, component styles (buttons with hover/focus states, inputs, links, badges), responsive breakpoints, logo and favicons, site name, detected CSS frameworks, and icon systems. Returns a job_id by default — use get_job_status to poll for the result.",
|
|
229
|
+
{ url, slow, sync },
|
|
126
230
|
toolHandler((d) => d),
|
|
127
231
|
);
|
|
128
232
|
|
|
129
233
|
server.tool(
|
|
130
234
|
"get_color_palette",
|
|
131
|
-
"Extract brand colors from a live website. Returns semantic colors (primary, secondary, accent), full palette ranked by usage frequency and confidence (high/medium/low), CSS custom properties with their design-system names, and hover/focus state colors discovered by simulating real user interactions. Each color in hex, RGB, LCH, and OKLCH.",
|
|
235
|
+
"Extract brand colors from a live website. Returns semantic colors (primary, secondary, accent), full palette ranked by usage frequency and confidence (high/medium/low), CSS custom properties with their design-system names, and hover/focus state colors discovered by simulating real user interactions. Each color in hex, RGB, LCH, and OKLCH. Returns a job_id by default — use get_job_status to poll for the result.",
|
|
132
236
|
{
|
|
133
|
-
url, slow,
|
|
237
|
+
url, slow, sync,
|
|
134
238
|
darkMode: z.boolean().optional().default(false).describe("Also extract dark mode palette"),
|
|
135
239
|
},
|
|
136
240
|
toolHandler((d) => ({ url: d.url, colors: d.colors })),
|
|
@@ -138,22 +242,22 @@ server.tool(
|
|
|
138
242
|
|
|
139
243
|
server.tool(
|
|
140
244
|
"get_typography",
|
|
141
|
-
"Extract typography from a live website. Returns every font family with its fallback stack, the complete type scale grouped by context (heading, body, button, link, caption) with pixel and rem sizes, weights, line heights, letter spacing, and text transforms. Also reports font sources: Google Fonts URLs, Adobe Fonts usage, and variable font detection.",
|
|
142
|
-
{ url, slow },
|
|
245
|
+
"Extract typography from a live website. Returns every font family with its fallback stack, the complete type scale grouped by context (heading, body, button, link, caption) with pixel and rem sizes, weights, line heights, letter spacing, and text transforms. Also reports font sources: Google Fonts URLs, Adobe Fonts usage, and variable font detection. Returns a job_id by default — use get_job_status to poll for the result.",
|
|
246
|
+
{ url, slow, sync },
|
|
143
247
|
toolHandler((d) => ({ url: d.url, typography: d.typography })),
|
|
144
248
|
);
|
|
145
249
|
|
|
146
250
|
server.tool(
|
|
147
251
|
"get_component_styles",
|
|
148
|
-
"Extract UI component styles from a live website. Returns button variants with default, hover, active, and focus states (background, text color, padding, border radius, border, shadow, outline, opacity), input field styles (border, focus ring, padding, placeholder), link styles (color, text decoration, hover changes), and badge/tag styles.",
|
|
149
|
-
{ url, slow },
|
|
252
|
+
"Extract UI component styles from a live website. Returns button variants with default, hover, active, and focus states (background, text color, padding, border radius, border, shadow, outline, opacity), input field styles (border, focus ring, padding, placeholder), link styles (color, text decoration, hover changes), and badge/tag styles. Returns a job_id by default — use get_job_status to poll for the result.",
|
|
253
|
+
{ url, slow, sync },
|
|
150
254
|
toolHandler((d) => ({ url: d.url, components: d.components })),
|
|
151
255
|
);
|
|
152
256
|
|
|
153
257
|
server.tool(
|
|
154
258
|
"get_surfaces",
|
|
155
|
-
"Extract surface treatment tokens from a live website: border radii with element context (which radii are used on buttons vs cards vs inputs vs modals), border patterns (width + style + color combinations), and box shadow elevation levels.",
|
|
156
|
-
{ url, slow },
|
|
259
|
+
"Extract surface treatment tokens from a live website: border radii with element context (which radii are used on buttons vs cards vs inputs vs modals), border patterns (width + style + color combinations), and box shadow elevation levels. Returns a job_id by default — use get_job_status to poll for the result.",
|
|
260
|
+
{ url, slow, sync },
|
|
157
261
|
toolHandler((d) => ({
|
|
158
262
|
url: d.url,
|
|
159
263
|
borderRadius: d.borderRadius,
|
|
@@ -164,15 +268,15 @@ server.tool(
|
|
|
164
268
|
|
|
165
269
|
server.tool(
|
|
166
270
|
"get_spacing",
|
|
167
|
-
"Extract the spacing system from a live website: common margin and padding values sorted by frequency, pixel and rem values, and grid system detection (4px, 8px, or custom scale).",
|
|
168
|
-
{ url, slow },
|
|
271
|
+
"Extract the spacing system from a live website: common margin and padding values sorted by frequency, pixel and rem values, and grid system detection (4px, 8px, or custom scale). Returns a job_id by default — use get_job_status to poll for the result.",
|
|
272
|
+
{ url, slow, sync },
|
|
169
273
|
toolHandler((d) => ({ url: d.url, spacing: d.spacing })),
|
|
170
274
|
);
|
|
171
275
|
|
|
172
276
|
server.tool(
|
|
173
277
|
"get_brand_identity",
|
|
174
|
-
"Extract brand identity from a live website: site name, logo (source, dimensions, safe zone), all favicon variants (icon, apple-touch-icon, og:image, twitter:image with sizes and URLs), detected CSS frameworks (Tailwind, Bootstrap, MUI, etc.), icon systems (Font Awesome, Material Icons, SVG), and responsive breakpoints.",
|
|
175
|
-
{ url, slow },
|
|
278
|
+
"Extract brand identity from a live website: site name, logo (source, dimensions, safe zone), all favicon variants (icon, apple-touch-icon, og:image, twitter:image with sizes and URLs), detected CSS frameworks (Tailwind, Bootstrap, MUI, etc.), icon systems (Font Awesome, Material Icons, SVG), and responsive breakpoints. Returns a job_id by default — use get_job_status to poll for the result.",
|
|
279
|
+
{ url, slow, sync },
|
|
176
280
|
toolHandler((d) => ({
|
|
177
281
|
url: d.url,
|
|
178
282
|
siteName: d.siteName,
|
|
@@ -184,6 +288,31 @@ server.tool(
|
|
|
184
288
|
})),
|
|
185
289
|
);
|
|
186
290
|
|
|
291
|
+
// ── Job management tools ───────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
server.tool(
|
|
294
|
+
"get_job_status",
|
|
295
|
+
"Poll for the result of an async extraction job. Returns status (queued/running/completed/failed/cancelled) and the full result once completed. Call this after any extraction tool that returned a job_id.",
|
|
296
|
+
{ job_id: z.string().describe("The job_id returned by an extraction tool") },
|
|
297
|
+
({ job_id }) => {
|
|
298
|
+
const job = jobQueue.get(job_id);
|
|
299
|
+
if (!job) return errorResult(`No job found with id: ${job_id}`);
|
|
300
|
+
if (job.status === "completed") return jsonResult({ job_id, status: "completed", result: job.result });
|
|
301
|
+
if (job.status === "failed") return errorResult(`Job failed: ${job.error}`);
|
|
302
|
+
return jsonResult({ job_id, status: job.status });
|
|
303
|
+
},
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
server.tool(
|
|
307
|
+
"cancel_job",
|
|
308
|
+
"Cancel a queued extraction job. Has no effect on jobs that are already running.",
|
|
309
|
+
{ job_id: z.string().describe("The job_id to cancel") },
|
|
310
|
+
({ job_id }) => {
|
|
311
|
+
const cancelled = jobQueue.cancel(job_id);
|
|
312
|
+
return jsonResult({ job_id, cancelled });
|
|
313
|
+
},
|
|
314
|
+
);
|
|
315
|
+
|
|
187
316
|
// ── Start ──────────────────────────────────────────────────────────────
|
|
188
317
|
|
|
189
318
|
const transport = new StdioServerTransport();
|