designlang 7.0.0 → 7.1.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/.vercel/README.txt +11 -0
- package/.vercel/project.json +1 -0
- package/CHANGELOG.md +15 -0
- package/CONTRIBUTING.md +25 -0
- package/README.md +9 -7
- package/bin/design-extract.js +18 -1
- package/chrome-extension/README.md +41 -0
- package/chrome-extension/icons/favicon.svg +7 -0
- package/chrome-extension/icons/icon-128.png +0 -0
- package/chrome-extension/icons/icon-16.png +0 -0
- package/chrome-extension/icons/icon-32.png +0 -0
- package/chrome-extension/icons/icon-48.png +0 -0
- package/chrome-extension/manifest.json +26 -0
- package/chrome-extension/popup.html +167 -0
- package/chrome-extension/popup.js +59 -0
- package/docs/superpowers/specs/2026-04-18-website-redesign-design.md +120 -0
- package/docs/superpowers/specs/2026-04-19-designlang-v7-1-design.md +111 -0
- package/package.json +1 -1
- package/src/config.js +3 -0
- package/src/crawler.js +20 -2
- package/src/utils-cookies.js +73 -0
- package/tests/cookies.test.js +98 -0
- package/website/app/api/extract/route.js +216 -56
- package/website/app/components/A11ySlider.js +369 -0
- package/website/app/components/Comparison.js +286 -0
- package/website/app/components/CssHealth.js +243 -0
- package/website/app/components/HeroExtractor.js +455 -0
- package/website/app/components/Marginalia.js +3 -0
- package/website/app/components/McpSection.js +223 -0
- package/website/app/components/PlatformTabs.js +250 -0
- package/website/app/components/RegionsComponents.js +429 -0
- package/website/app/components/Rule.js +13 -0
- package/website/app/components/Specimens.js +237 -0
- package/website/app/components/StructuredData.js +144 -0
- package/website/app/components/TokenBrowser.js +344 -0
- package/website/app/components/token-browser-sample.js +65 -0
- package/website/app/globals.css +415 -633
- package/website/app/icon.svg +7 -0
- package/website/app/layout.js +113 -6
- package/website/app/opengraph-image.js +170 -0
- package/website/app/page.js +325 -148
- package/website/app/robots.js +15 -0
- package/website/app/seo-config.js +82 -0
- package/website/app/sitemap.js +18 -0
- package/website/lib/cache.js +73 -0
- package/website/lib/rate-limit.js +30 -0
- package/website/lib/rate-limit.test.js +55 -0
- package/website/lib/specimens.json +86 -0
- package/website/lib/token-helpers.js +70 -0
- package/website/lib/url-safety.js +103 -0
- package/website/lib/url-safety.test.js +116 -0
- package/website/lib/zip-files.js +15 -0
- package/website/package-lock.json +85 -0
- package/website/package.json +1 -0
- package/website/public/favicon.svg +7 -0
- package/website/public/logo-specimen.svg +76 -0
- package/website/public/mark.svg +12 -0
- package/website/public/site.webmanifest +13 -0
- package/website/app/favicon.ico +0 -0
- package/website/public/file.svg +0 -1
- package/website/public/globe.svg +0 -1
- package/website/public/next.svg +0 -1
- package/website/public/vercel.svg +0 -1
- package/website/public/window.svg +0 -1
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# designlang v7.1 — Designer Toolkit
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-04-19
|
|
4
|
+
**Status:** Approved for implementation
|
|
5
|
+
|
|
6
|
+
## Theme
|
|
7
|
+
|
|
8
|
+
*From extractor to designer's toolkit.* v7.0 shipped the full extraction surface. v7.1 makes the output composable, auditable, and complete — pull one component at a time, get every brand asset alongside the tokens, drop into any stack (Tailwind v4, Emotion, Stitches, Vanilla Extract, Panda), and fail your CI when production drifts from your design system.
|
|
9
|
+
|
|
10
|
+
## Scope (7 features)
|
|
11
|
+
|
|
12
|
+
### 1. Single-component extraction
|
|
13
|
+
New flag: `designlang <url> --component <css-selector>`.
|
|
14
|
+
- Playwright walks to the matched element, captures its own computed styles + its children's styles + its bounding box + its text content.
|
|
15
|
+
- Emits a tiny extraction: `*-component.md` (a specimen-style card), `*-component.json` (the DTCG subset actually used by this component), `*-component.tsx` / `*-component.vue` / `*-component.svelte` based on `--framework` (or all three by default).
|
|
16
|
+
- `--component` can be combined with `--platforms all` and `--emit-agent-rules`.
|
|
17
|
+
|
|
18
|
+
### 2. Asset / brand-kit extraction
|
|
19
|
+
New flag: `designlang <url> --assets` (also implied by `--full`).
|
|
20
|
+
- Crawler enumerates every `<img>`, `<picture>`, `<source>`, `<svg>` (reuse existing icons work), `<link rel="icon">`, `<meta property="og:image">`, and CSS `background-image` URLs.
|
|
21
|
+
- Downloads each asset with a timeout and size cap (50MB total per extraction).
|
|
22
|
+
- Classifies: `logo`, `icon`, `illustration`, `photo`, `hero`, `og`, `favicon`, `avatar`, `screenshot`, `other`.
|
|
23
|
+
- Emits to `<out>/assets/<kind>/<original-filename-or-hash.ext>` plus `assets-manifest.json` with the type, source URL, dimensions, byte size, and an `srcset`-style variant list for `<picture>` elements.
|
|
24
|
+
- Size-aware: don't download a 20MB hero if it exceeds a per-asset cap (default 5MB). Log skipped assets in the manifest with a `skipped: reason` field.
|
|
25
|
+
|
|
26
|
+
### 3. Illustration detection (folded into #2)
|
|
27
|
+
- SVGs ≥ 200px or with >30 path elements → classified `illustration`, not `icon`.
|
|
28
|
+
- Inline-SVG illustrations captured alongside external file references.
|
|
29
|
+
- Raster illustrations (transparent PNG with flat-color regions) classified separately from photos via dominant-color entropy heuristic.
|
|
30
|
+
|
|
31
|
+
### 4. Tailwind v4 `@theme` formatter
|
|
32
|
+
New output file `<prefix>-tailwind.css` (separate from the existing `*-tailwind.config.js`, which stays for v3 users).
|
|
33
|
+
- Uses the Tailwind v4 `@theme { … }` block syntax inside a `@import "tailwindcss"` wrapper.
|
|
34
|
+
- Maps DTCG semantic tokens → Tailwind v4 CSS custom properties.
|
|
35
|
+
- Respects `--tokens-legacy` (v4 file still emitted but from primitive-only JSON).
|
|
36
|
+
|
|
37
|
+
### 5. CSS-in-JS emitters
|
|
38
|
+
Four new formatters under `src/formatters/css-in-js/`:
|
|
39
|
+
- `emotion.js` → `<prefix>-emotion.theme.ts`
|
|
40
|
+
- `stitches.js` → `<prefix>-stitches.config.ts`
|
|
41
|
+
- `vanilla-extract.js` → `<prefix>-vanilla.css.ts`
|
|
42
|
+
- `panda.js` → `<prefix>-panda.config.ts`
|
|
43
|
+
|
|
44
|
+
Selectable via new flag: `--framework emotion|stitches|vanilla-extract|panda|tailwind4`. Multi-select: `--framework emotion,stitches`. Default behavior unchanged: emit React theme + shadcn theme.
|
|
45
|
+
|
|
46
|
+
### 6. Drift detection + GitHub Action
|
|
47
|
+
New command: `designlang drift <url> <tokens-path> [--out <report-path>] [--fail-on <category>] [--tolerance <n>]`.
|
|
48
|
+
- Extracts from `<url>`, loads DTCG JSON at `<tokens-path>`, walks both trees, and produces a report:
|
|
49
|
+
- `added` tokens (in extraction, not in file)
|
|
50
|
+
- `removed` (in file, not in extraction)
|
|
51
|
+
- `changed` (path matches, `$value` differs beyond tolerance)
|
|
52
|
+
- Tolerance: colors use ΔE threshold (default 3), dimensions use px delta (default 1), others exact match.
|
|
53
|
+
- `--fail-on color|dimension|typography|all` controls exit code (default exits 0 for a report-only run; exit 1 when any matching drift found with `--fail-on`).
|
|
54
|
+
- Emits `drift-report.md` + `drift-report.json`.
|
|
55
|
+
- **GitHub Action:** new `.github/workflows/designlang-drift.yml` template committed to this repo under `templates/github-action/designlang-drift.yml` with a README snippet. Runs on PR + daily cron, posts the report as a PR comment when drift is detected.
|
|
56
|
+
|
|
57
|
+
### 7. Release
|
|
58
|
+
- Bump to `7.1.0`, CHANGELOG entry, README updates, npm publish, git tag.
|
|
59
|
+
|
|
60
|
+
## Non-goals (deferred to v7.2+)
|
|
61
|
+
|
|
62
|
+
- **Remix Mode** / interactive web editor — substantial Next.js work; needs its own spec.
|
|
63
|
+
- **Figma plugin**, **Chrome extension**, **VS Code extension** — each is a separate package with its own store/review cycle.
|
|
64
|
+
- **Brand-voice extraction** (tone, reading level) — out of scope for a token release.
|
|
65
|
+
- **Token marketplace / shared workspaces** — hosted SaaS territory.
|
|
66
|
+
|
|
67
|
+
## Architecture
|
|
68
|
+
|
|
69
|
+
### New files
|
|
70
|
+
- `src/extractors/component.js` — selector-scoped extraction
|
|
71
|
+
- `src/extractors/assets.js` — classifier + downloader for images and illustrations
|
|
72
|
+
- `src/formatters/tailwind-v4.js`
|
|
73
|
+
- `src/formatters/css-in-js/emotion.js`
|
|
74
|
+
- `src/formatters/css-in-js/stitches.js`
|
|
75
|
+
- `src/formatters/css-in-js/vanilla-extract.js`
|
|
76
|
+
- `src/formatters/css-in-js/panda.js`
|
|
77
|
+
- `src/formatters/component-snippet.js` — emits JSX / Vue / Svelte / HTML snippet from a component extraction
|
|
78
|
+
- `src/formatters/drift.js` — produces drift-report.md + drift-report.json
|
|
79
|
+
- `src/drift.js` — orchestrator for the `drift` subcommand
|
|
80
|
+
- `templates/github-action/designlang-drift.yml`
|
|
81
|
+
- `templates/github-action/README.md`
|
|
82
|
+
|
|
83
|
+
### Modified files
|
|
84
|
+
- `src/crawler.js` — add `fetchAssets()` with per-asset timeout + cap; add `page.$(selector).then(el => el.evaluate(...))` path for `--component`
|
|
85
|
+
- `src/index.js` — wire new extractors + output files
|
|
86
|
+
- `bin/design-extract.js` — new flags + `drift` subcommand
|
|
87
|
+
- `src/config.js` — thread new options (`component`, `assets`, `framework`, driftTolerance)
|
|
88
|
+
- `tests/extractors.test.js` + `tests/formatters.test.js` — new tests per extractor/formatter
|
|
89
|
+
- `package.json` — version bump, potentially add `sharp` for optional raster resize (deferred decision — only if it stays zero-runtime-cost)
|
|
90
|
+
- `README.md`
|
|
91
|
+
- `CHANGELOG.md`
|
|
92
|
+
|
|
93
|
+
### Dependencies
|
|
94
|
+
- No new runtime deps. Image downloads use Node 20's native `fetch`. Classifier is heuristic only (no ML).
|
|
95
|
+
|
|
96
|
+
### Security / safety
|
|
97
|
+
- Asset downloader respects the same URL-safety layer as the website API (no private IPs, http(s) only, byte cap).
|
|
98
|
+
- Same-origin preferred; cross-origin assets downloaded with an `Accept` header and a 10s timeout each.
|
|
99
|
+
|
|
100
|
+
## Tests
|
|
101
|
+
|
|
102
|
+
- Unit tests per extractor and formatter, snapshot-style for golden outputs.
|
|
103
|
+
- Integration smoke: `designlang <url> --component "header nav" --assets --framework emotion,stitches,vanilla-extract,panda,tailwind4` produces all expected files.
|
|
104
|
+
- Drift integration test: run against a fixture tokens file and a live URL, confirm report contents.
|
|
105
|
+
|
|
106
|
+
## Release plan
|
|
107
|
+
|
|
108
|
+
1. Implement via subagents in 3 parallel chunks (component+assets, emitters, drift+action).
|
|
109
|
+
2. Tests green.
|
|
110
|
+
3. README + CHANGELOG.
|
|
111
|
+
4. Version bump → PR → merge → npm publish → tag.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "designlang",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.1.0",
|
|
4
4
|
"description": "Extract the complete design language from any website — colors, typography, spacing, shadows, and more. Outputs AI-optimized markdown, W3C design tokens, Tailwind config, and CSS variables.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/config.js
CHANGED
|
@@ -35,6 +35,9 @@ export function mergeConfig(cliOpts, config) {
|
|
|
35
35
|
tokensLegacy: cliOpts.tokensLegacy || config.tokensLegacy || false,
|
|
36
36
|
platforms: parsePlatforms(cliOpts.platforms ?? config.platforms ?? 'web'),
|
|
37
37
|
emitAgentRules: cliOpts.emitAgentRules || config.emitAgentRules || false,
|
|
38
|
+
cookieFile: cliOpts.cookieFile || config.cookieFile,
|
|
39
|
+
insecure: cliOpts.insecure || config.insecure || false,
|
|
40
|
+
userAgent: cliOpts.userAgent || config.userAgent,
|
|
38
41
|
};
|
|
39
42
|
}
|
|
40
43
|
|
package/src/crawler.js
CHANGED
|
@@ -17,17 +17,35 @@ async function gotoWithRetry(page, url, opts, retries = 3) {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export async function crawlPage(url, options = {}) {
|
|
20
|
-
const {
|
|
20
|
+
const {
|
|
21
|
+
width = 1280, height = 800, wait = 0, dark = false, depth = 0,
|
|
22
|
+
screenshots = false, outDir = '', executablePath, browserArgs,
|
|
23
|
+
cookies, headers, ignore,
|
|
24
|
+
insecure = false,
|
|
25
|
+
userAgent,
|
|
26
|
+
} = options;
|
|
27
|
+
|
|
28
|
+
const launchArgs = [
|
|
29
|
+
...(browserArgs || []),
|
|
30
|
+
// Common flags that help with dev environments and CI. Insecure-only flags
|
|
31
|
+
// are added below when the user opts in.
|
|
32
|
+
'--disable-dev-shm-usage',
|
|
33
|
+
];
|
|
34
|
+
if (insecure) {
|
|
35
|
+
launchArgs.push('--ignore-certificate-errors', '--ignore-ssl-errors');
|
|
36
|
+
}
|
|
21
37
|
|
|
22
38
|
const browser = await chromium.launch({
|
|
23
39
|
headless: true,
|
|
24
40
|
...(executablePath && { executablePath }),
|
|
25
|
-
|
|
41
|
+
args: launchArgs,
|
|
26
42
|
});
|
|
27
43
|
try {
|
|
28
44
|
const context = await browser.newContext({
|
|
29
45
|
viewport: { width, height },
|
|
30
46
|
colorScheme: 'light',
|
|
47
|
+
ignoreHTTPSErrors: insecure,
|
|
48
|
+
...(userAgent && { userAgent }),
|
|
31
49
|
...(headers && { extraHTTPHeaders: headers }),
|
|
32
50
|
});
|
|
33
51
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Cookie file loaders. Supports three formats so users can paste whatever
|
|
2
|
+
// their existing tooling exports:
|
|
3
|
+
// - JSON array of Playwright cookie objects: [{name, value, domain, path, …}]
|
|
4
|
+
// - Playwright storageState JSON: { cookies: [...], origins: [...] }
|
|
5
|
+
// - Netscape cookies.txt: tab-separated lines (curl / wget / browser extensions)
|
|
6
|
+
//
|
|
7
|
+
// Returned shape is always the Playwright cookie array.
|
|
8
|
+
|
|
9
|
+
import { readFileSync } from 'fs';
|
|
10
|
+
|
|
11
|
+
function parseNetscape(text, targetUrl) {
|
|
12
|
+
const cookies = [];
|
|
13
|
+
const lines = text.split(/\r?\n/);
|
|
14
|
+
for (const raw of lines) {
|
|
15
|
+
const line = raw.trim();
|
|
16
|
+
if (!line) continue;
|
|
17
|
+
// Skip comment lines, but keep the Netscape `#HttpOnly_<domain>` prefix
|
|
18
|
+
// that browsers use to mark HttpOnly cookies — those are real entries.
|
|
19
|
+
if (line.startsWith('#') && !/^#HttpOnly_/i.test(line)) continue;
|
|
20
|
+
const parts = raw.split('\t');
|
|
21
|
+
if (parts.length < 7) continue;
|
|
22
|
+
const [domain, , path, secure, expires, name, value] = parts;
|
|
23
|
+
if (!name) continue;
|
|
24
|
+
const cookie = {
|
|
25
|
+
name,
|
|
26
|
+
value: value ?? '',
|
|
27
|
+
domain: domain.replace(/^#HttpOnly_/i, ''),
|
|
28
|
+
path: path || '/',
|
|
29
|
+
secure: secure === 'TRUE',
|
|
30
|
+
httpOnly: /^#HttpOnly_/i.test(domain),
|
|
31
|
+
};
|
|
32
|
+
const exp = Number(expires);
|
|
33
|
+
if (Number.isFinite(exp) && exp > 0) cookie.expires = exp;
|
|
34
|
+
if (!cookie.domain && targetUrl) cookie.url = targetUrl;
|
|
35
|
+
cookies.push(cookie);
|
|
36
|
+
}
|
|
37
|
+
return cookies;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseJson(text) {
|
|
41
|
+
const parsed = JSON.parse(text);
|
|
42
|
+
// Playwright storageState: { cookies: [...], origins: [...] }
|
|
43
|
+
if (parsed && Array.isArray(parsed.cookies)) return parsed.cookies;
|
|
44
|
+
// Raw array
|
|
45
|
+
if (Array.isArray(parsed)) return parsed;
|
|
46
|
+
throw new Error('cookie file: JSON must be a cookie array or Playwright storageState');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function loadCookiesFromFile(filePath, targetUrl) {
|
|
50
|
+
const text = readFileSync(filePath, 'utf-8');
|
|
51
|
+
const trimmed = text.trimStart();
|
|
52
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
53
|
+
return parseJson(text);
|
|
54
|
+
}
|
|
55
|
+
return parseNetscape(text, targetUrl);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Merge CLI-provided cookies (name=value strings) with file cookies.
|
|
59
|
+
// Later entries (file) override earlier entries (CLI) at the same name+domain.
|
|
60
|
+
export function mergeCookies(cliCookies = [], fileCookies = [], targetUrl) {
|
|
61
|
+
const seen = new Map();
|
|
62
|
+
const parseCli = (c) => {
|
|
63
|
+
if (typeof c !== 'string') return c;
|
|
64
|
+
const [name, ...rest] = c.split('=');
|
|
65
|
+
return { name, value: rest.join('='), url: targetUrl };
|
|
66
|
+
};
|
|
67
|
+
for (const c of [...cliCookies.map(parseCli), ...fileCookies]) {
|
|
68
|
+
if (!c || !c.name) continue;
|
|
69
|
+
const key = `${c.name}|${c.domain || c.url || ''}`;
|
|
70
|
+
seen.set(key, c);
|
|
71
|
+
}
|
|
72
|
+
return [...seen.values()];
|
|
73
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { writeFileSync, mkdtempSync } from 'fs';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { loadCookiesFromFile, mergeCookies } from '../src/utils-cookies.js';
|
|
7
|
+
|
|
8
|
+
const tmp = mkdtempSync(join(tmpdir(), 'dl-cookies-'));
|
|
9
|
+
|
|
10
|
+
function tmpFile(name, text) {
|
|
11
|
+
const p = join(tmp, name);
|
|
12
|
+
writeFileSync(p, text, 'utf-8');
|
|
13
|
+
return p;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('loadCookiesFromFile', () => {
|
|
17
|
+
it('loads JSON array', () => {
|
|
18
|
+
const f = tmpFile('c.json', JSON.stringify([
|
|
19
|
+
{ name: 'session', value: 'abc', domain: '.example.com', path: '/' },
|
|
20
|
+
]));
|
|
21
|
+
const out = loadCookiesFromFile(f, 'https://example.com');
|
|
22
|
+
assert.equal(out.length, 1);
|
|
23
|
+
assert.equal(out[0].name, 'session');
|
|
24
|
+
assert.equal(out[0].domain, '.example.com');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('loads Playwright storageState', () => {
|
|
28
|
+
const f = tmpFile('state.json', JSON.stringify({
|
|
29
|
+
cookies: [{ name: 'csrf', value: 'xyz', domain: 'example.com', path: '/' }],
|
|
30
|
+
origins: [],
|
|
31
|
+
}));
|
|
32
|
+
const out = loadCookiesFromFile(f, 'https://example.com');
|
|
33
|
+
assert.equal(out.length, 1);
|
|
34
|
+
assert.equal(out[0].name, 'csrf');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('loads Netscape cookies.txt with tab-separated lines and comment', () => {
|
|
38
|
+
const netscape = [
|
|
39
|
+
'# Netscape HTTP Cookie File',
|
|
40
|
+
'# comment',
|
|
41
|
+
'.example.com\tTRUE\t/\tFALSE\t1765000000\tsid\tabc123',
|
|
42
|
+
'#HttpOnly_.example.com\tTRUE\t/\tTRUE\t0\tauth\ttoken-value',
|
|
43
|
+
].join('\n');
|
|
44
|
+
const f = tmpFile('c.txt', netscape);
|
|
45
|
+
const out = loadCookiesFromFile(f, 'https://example.com');
|
|
46
|
+
assert.equal(out.length, 2);
|
|
47
|
+
const sid = out.find((c) => c.name === 'sid');
|
|
48
|
+
assert.equal(sid.value, 'abc123');
|
|
49
|
+
assert.equal(sid.domain, '.example.com');
|
|
50
|
+
assert.equal(sid.secure, false);
|
|
51
|
+
assert.equal(sid.expires, 1765000000);
|
|
52
|
+
const auth = out.find((c) => c.name === 'auth');
|
|
53
|
+
assert.equal(auth.httpOnly, true);
|
|
54
|
+
assert.equal(auth.secure, true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('throws on invalid JSON shape', () => {
|
|
58
|
+
const f = tmpFile('bad.json', '{"not":"array"}');
|
|
59
|
+
assert.throws(() => loadCookiesFromFile(f, 'https://example.com'));
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('mergeCookies', () => {
|
|
64
|
+
it('parses CLI name=value strings into cookie objects', () => {
|
|
65
|
+
const out = mergeCookies(['session=abc'], [], 'https://example.com');
|
|
66
|
+
assert.equal(out.length, 1);
|
|
67
|
+
assert.equal(out[0].name, 'session');
|
|
68
|
+
assert.equal(out[0].value, 'abc');
|
|
69
|
+
assert.equal(out[0].url, 'https://example.com');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('lets file cookies override CLI cookies with the same name+domain', () => {
|
|
73
|
+
const out = mergeCookies(
|
|
74
|
+
[{ name: 'session', value: 'cli', domain: '.example.com' }],
|
|
75
|
+
[{ name: 'session', value: 'file', domain: '.example.com' }],
|
|
76
|
+
'https://example.com',
|
|
77
|
+
);
|
|
78
|
+
assert.equal(out.length, 1);
|
|
79
|
+
assert.equal(out[0].value, 'file');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('keeps cookies with different domains separately', () => {
|
|
83
|
+
const out = mergeCookies(
|
|
84
|
+
[],
|
|
85
|
+
[
|
|
86
|
+
{ name: 'x', value: '1', domain: 'a.example.com' },
|
|
87
|
+
{ name: 'x', value: '2', domain: 'b.example.com' },
|
|
88
|
+
],
|
|
89
|
+
'https://example.com',
|
|
90
|
+
);
|
|
91
|
+
assert.equal(out.length, 2);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('strips entries without a name', () => {
|
|
95
|
+
const out = mergeCookies([], [{ value: 'orphan' }], 'https://example.com');
|
|
96
|
+
assert.equal(out.length, 0);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -1,18 +1,50 @@
|
|
|
1
|
+
// POST /api/extract
|
|
2
|
+
// Streaming NDJSON: one JSON event per line.
|
|
3
|
+
// Events:
|
|
4
|
+
// { type:'cache', cached:true } — if served from Blob
|
|
5
|
+
// { type:'stage', name:'crawl'|... } — progress markers
|
|
6
|
+
// { type:'token', category, path, value, $type } — one per semantic token
|
|
7
|
+
// { type:'summary', summary }
|
|
8
|
+
// { type:'files', files } — final, full file map
|
|
9
|
+
// { type:'error', error } — terminal failure
|
|
10
|
+
|
|
1
11
|
import { extractDesignLanguage } from '../../../../src/index.js';
|
|
2
12
|
import { formatMarkdown } from '../../../../src/formatters/markdown.js';
|
|
3
|
-
import { formatTokens } from '../../../../src/formatters/tokens.js';
|
|
4
13
|
import { formatTailwind } from '../../../../src/formatters/tailwind.js';
|
|
5
14
|
import { formatCssVars } from '../../../../src/formatters/css-vars.js';
|
|
6
15
|
import { formatPreview } from '../../../../src/formatters/preview.js';
|
|
7
16
|
import { formatFigma } from '../../../../src/formatters/figma.js';
|
|
8
17
|
import { formatReactTheme, formatShadcnTheme } from '../../../../src/formatters/theme.js';
|
|
18
|
+
import { formatWordPress, formatWordPressTheme } from '../../../../src/formatters/wordpress.js';
|
|
19
|
+
import { formatDtcgTokens } from '../../../../src/formatters/dtcg-tokens.js';
|
|
20
|
+
import { formatIosSwiftUI } from '../../../../src/formatters/ios-swiftui.js';
|
|
21
|
+
import { formatAndroidCompose } from '../../../../src/formatters/android-compose.js';
|
|
22
|
+
import { formatFlutterDart } from '../../../../src/formatters/flutter-dart.js';
|
|
23
|
+
import { formatAgentRules } from '../../../../src/formatters/agent-rules.js';
|
|
9
24
|
import { nameFromUrl } from '../../../../src/utils.js';
|
|
10
25
|
|
|
11
|
-
|
|
26
|
+
import { validateTargetUrl } from '../../../../website/lib/url-safety.js';
|
|
27
|
+
import { checkRate } from '../../../../website/lib/rate-limit.js';
|
|
28
|
+
import { cacheKey, getCached, putCached } from '../../../../website/lib/cache.js';
|
|
29
|
+
|
|
30
|
+
export const runtime = 'nodejs';
|
|
12
31
|
export const dynamic = 'force-dynamic';
|
|
32
|
+
export const maxDuration = 60;
|
|
33
|
+
|
|
34
|
+
const STAGES = [
|
|
35
|
+
'crawl',
|
|
36
|
+
'colors',
|
|
37
|
+
'typography',
|
|
38
|
+
'spacing',
|
|
39
|
+
'shadows',
|
|
40
|
+
'borders',
|
|
41
|
+
'components',
|
|
42
|
+
'regions',
|
|
43
|
+
'a11y',
|
|
44
|
+
'score',
|
|
45
|
+
];
|
|
13
46
|
|
|
14
47
|
async function getBrowserOptions() {
|
|
15
|
-
// On Vercel/Lambda, use @sparticuz/chromium; locally, use playwright's bundled browser
|
|
16
48
|
if (process.env.VERCEL || process.env.AWS_LAMBDA_FUNCTION_NAME) {
|
|
17
49
|
const chromium = (await import('@sparticuz/chromium')).default;
|
|
18
50
|
return {
|
|
@@ -23,63 +55,191 @@ async function getBrowserOptions() {
|
|
|
23
55
|
return {};
|
|
24
56
|
}
|
|
25
57
|
|
|
58
|
+
function ndjson(obj) {
|
|
59
|
+
return new TextEncoder().encode(JSON.stringify(obj) + '\n');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Walk a DTCG token tree and yield every leaf ({ $value, $type }).
|
|
63
|
+
function* walkDtcgTokens(tree, path = []) {
|
|
64
|
+
if (!tree || typeof tree !== 'object') return;
|
|
65
|
+
if (tree.$value !== undefined && tree.$type !== undefined) {
|
|
66
|
+
yield { path: path.join('.'), value: tree.$value, $type: tree.$type };
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
for (const key of Object.keys(tree)) {
|
|
70
|
+
if (key.startsWith('$')) continue;
|
|
71
|
+
yield* walkDtcgTokens(tree[key], [...path, key]);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function buildSummary(design) {
|
|
76
|
+
return {
|
|
77
|
+
url: design.meta?.url,
|
|
78
|
+
title: design.meta?.title,
|
|
79
|
+
colors: design.colors?.all?.length ?? 0,
|
|
80
|
+
colorList: (design.colors?.all || []).slice(0, 20).map((c) => c.hex),
|
|
81
|
+
fonts: design.typography?.families?.map((f) => f.name).join(', ') || 'none detected',
|
|
82
|
+
spacingCount: design.spacing?.scale?.length ?? 0,
|
|
83
|
+
spacingBase: design.spacing?.base ?? null,
|
|
84
|
+
shadowCount: design.shadows?.values?.length ?? 0,
|
|
85
|
+
radiiCount: design.borders?.radii?.length ?? 0,
|
|
86
|
+
componentCount: Object.keys(design.components || {}).length,
|
|
87
|
+
cssVarCount: Object.values(design.variables || {}).reduce((s, v) => s + Object.keys(v).length, 0),
|
|
88
|
+
a11yScore: design.accessibility?.score ?? null,
|
|
89
|
+
a11yFailCount: design.accessibility?.failCount ?? 0,
|
|
90
|
+
score: design.score,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function buildFiles(design, targetUrl) {
|
|
95
|
+
const prefix = nameFromUrl(targetUrl);
|
|
96
|
+
const dtcg = formatDtcgTokens(design);
|
|
97
|
+
const dtcgJson = JSON.stringify(dtcg, null, 2);
|
|
98
|
+
|
|
99
|
+
const files = {
|
|
100
|
+
[`${prefix}-design-language.md`]: formatMarkdown(design),
|
|
101
|
+
[`${prefix}-design-tokens.json`]: dtcgJson,
|
|
102
|
+
[`${prefix}-tailwind.config.js`]: formatTailwind(design),
|
|
103
|
+
[`${prefix}-variables.css`]: formatCssVars(design),
|
|
104
|
+
[`${prefix}-preview.html`]: formatPreview(design),
|
|
105
|
+
[`${prefix}-figma-variables.json`]: formatFigma(design),
|
|
106
|
+
[`${prefix}-theme.js`]: formatReactTheme(design),
|
|
107
|
+
[`${prefix}-shadcn-theme.css`]: formatShadcnTheme(design),
|
|
108
|
+
[`${prefix}-wordpress-theme.json`]: formatWordPress(design),
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// MCP companion JSON — same subset the CLI writes.
|
|
112
|
+
files[`${prefix}-mcp.json`] = JSON.stringify({
|
|
113
|
+
colors: { all: design.colors?.all || [] },
|
|
114
|
+
regions: design.regions || [],
|
|
115
|
+
componentClusters: design.componentClusters || [],
|
|
116
|
+
accessibility: { remediation: design.accessibility?.remediation || [] },
|
|
117
|
+
cssHealth: design.cssHealth || null,
|
|
118
|
+
}, null, 2);
|
|
119
|
+
|
|
120
|
+
// iOS
|
|
121
|
+
files['ios/DesignTokens.swift'] = formatIosSwiftUI(dtcg);
|
|
122
|
+
|
|
123
|
+
// Android (returns { filename: content })
|
|
124
|
+
const android = formatAndroidCompose(dtcg);
|
|
125
|
+
for (const name of Object.keys(android)) {
|
|
126
|
+
files[`android/${name}`] = android[name];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Flutter
|
|
130
|
+
files['flutter/design_tokens.dart'] = formatFlutterDart(dtcg);
|
|
131
|
+
|
|
132
|
+
// WordPress block theme (5 files)
|
|
133
|
+
const wpTheme = formatWordPressTheme(dtcg, design);
|
|
134
|
+
for (const name of Object.keys(wpTheme)) {
|
|
135
|
+
files[`wordpress-theme/${name}`] = wpTheme[name];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Agent rules
|
|
139
|
+
const agentFiles = formatAgentRules({ design, tokens: dtcg, url: targetUrl });
|
|
140
|
+
for (const name of Object.keys(agentFiles)) {
|
|
141
|
+
files[name] = agentFiles[name];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { files, dtcg };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function extractIp(request) {
|
|
148
|
+
const xff = request.headers.get('x-forwarded-for');
|
|
149
|
+
if (xff) return xff.split(',')[0].trim();
|
|
150
|
+
const real = request.headers.get('x-real-ip');
|
|
151
|
+
if (real) return real.trim();
|
|
152
|
+
return 'unknown';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Emit cached payload as a simulated stream so the hero paints consistently.
|
|
156
|
+
async function streamCached(controller, cached, targetUrl) {
|
|
157
|
+
controller.enqueue(ndjson({ type: 'cache', cached: true }));
|
|
158
|
+
for (const stage of STAGES) {
|
|
159
|
+
controller.enqueue(ndjson({ type: 'stage', name: stage }));
|
|
160
|
+
await new Promise((r) => setTimeout(r, 40));
|
|
161
|
+
}
|
|
162
|
+
// Re-derive DTCG tokens from the cached design so the token-by-token paint
|
|
163
|
+
// still happens on a cache hit.
|
|
164
|
+
const { files, dtcg } = buildFiles(cached.design, targetUrl);
|
|
165
|
+
for (const { path, value, $type } of walkDtcgTokens(dtcg)) {
|
|
166
|
+
const category = path.split('.')[1] || 'misc';
|
|
167
|
+
controller.enqueue(ndjson({ type: 'token', category, path, value, $type }));
|
|
168
|
+
}
|
|
169
|
+
controller.enqueue(ndjson({ type: 'summary', summary: buildSummary(cached.design) }));
|
|
170
|
+
controller.enqueue(ndjson({ type: 'files', files }));
|
|
171
|
+
}
|
|
172
|
+
|
|
26
173
|
export async function POST(request) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
return Response.json({ error: 'URL is required' }, { status: 400 });
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
let targetUrl = url;
|
|
35
|
-
if (!targetUrl.startsWith('http')) targetUrl = `https://${targetUrl}`;
|
|
36
|
-
|
|
37
|
-
// Validate URL
|
|
38
|
-
try {
|
|
39
|
-
new URL(targetUrl);
|
|
40
|
-
} catch {
|
|
41
|
-
return Response.json({ error: 'Invalid URL' }, { status: 400 });
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const browserOpts = await getBrowserOptions();
|
|
45
|
-
const design = await extractDesignLanguage(targetUrl, browserOpts);
|
|
46
|
-
|
|
47
|
-
const prefix = nameFromUrl(targetUrl);
|
|
48
|
-
|
|
49
|
-
const files = {
|
|
50
|
-
[`${prefix}-design-language.md`]: formatMarkdown(design),
|
|
51
|
-
[`${prefix}-design-tokens.json`]: formatTokens(design),
|
|
52
|
-
[`${prefix}-tailwind.config.js`]: formatTailwind(design),
|
|
53
|
-
[`${prefix}-variables.css`]: formatCssVars(design),
|
|
54
|
-
[`${prefix}-preview.html`]: formatPreview(design),
|
|
55
|
-
[`${prefix}-figma-variables.json`]: formatFigma(design),
|
|
56
|
-
[`${prefix}-theme.js`]: formatReactTheme(design),
|
|
57
|
-
[`${prefix}-shadcn-theme.css`]: formatShadcnTheme(design),
|
|
58
|
-
};
|
|
174
|
+
let body;
|
|
175
|
+
try { body = await request.json(); } catch {
|
|
176
|
+
return Response.json({ error: 'Invalid JSON body' }, { status: 400 });
|
|
177
|
+
}
|
|
59
178
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
fonts: design.typography.families.map(f => f.name).join(', ') || 'none detected',
|
|
66
|
-
spacingCount: design.spacing.scale.length,
|
|
67
|
-
spacingBase: design.spacing.base,
|
|
68
|
-
shadowCount: design.shadows.values.length,
|
|
69
|
-
radiiCount: design.borders.radii.length,
|
|
70
|
-
componentCount: Object.keys(design.components).length,
|
|
71
|
-
cssVarCount: Object.values(design.variables).reduce((s, v) => s + Object.keys(v).length, 0),
|
|
72
|
-
a11yScore: design.accessibility?.score ?? null,
|
|
73
|
-
a11yFailCount: design.accessibility?.failCount ?? 0,
|
|
74
|
-
score: design.score,
|
|
75
|
-
};
|
|
179
|
+
const validation = validateTargetUrl(body?.url);
|
|
180
|
+
if (!validation.ok) {
|
|
181
|
+
return Response.json({ error: validation.reason }, { status: validation.status });
|
|
182
|
+
}
|
|
183
|
+
const targetUrl = validation.url;
|
|
76
184
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
185
|
+
const ip = extractIp(request);
|
|
186
|
+
const rate = checkRate(`extract:${ip}`);
|
|
187
|
+
if (!rate.allowed) {
|
|
80
188
|
return Response.json(
|
|
81
|
-
{ error:
|
|
82
|
-
{ status:
|
|
189
|
+
{ error: 'Rate limit — 3 extractions per day. Try again later.', resetAt: rate.resetAt },
|
|
190
|
+
{ status: 429 }
|
|
83
191
|
);
|
|
84
192
|
}
|
|
193
|
+
|
|
194
|
+
const stream = new ReadableStream({
|
|
195
|
+
async start(controller) {
|
|
196
|
+
try {
|
|
197
|
+
const key = cacheKey(targetUrl);
|
|
198
|
+
const cached = await getCached(key);
|
|
199
|
+
if (cached) {
|
|
200
|
+
await streamCached(controller, cached, targetUrl);
|
|
201
|
+
controller.close();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Pre-stage markers — best-effort progress since extraction is atomic.
|
|
206
|
+
controller.enqueue(ndjson({ type: 'stage', name: 'crawl' }));
|
|
207
|
+
|
|
208
|
+
const browserOpts = await getBrowserOptions();
|
|
209
|
+
const design = await extractDesignLanguage(targetUrl, browserOpts);
|
|
210
|
+
|
|
211
|
+
// Post-stage markers once extraction resolves.
|
|
212
|
+
for (const stage of STAGES.slice(1)) {
|
|
213
|
+
controller.enqueue(ndjson({ type: 'stage', name: stage }));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const { files, dtcg } = buildFiles(design, targetUrl);
|
|
217
|
+
|
|
218
|
+
// Token-by-token paint — every DTCG leaf becomes its own event.
|
|
219
|
+
for (const { path, value, $type } of walkDtcgTokens(dtcg)) {
|
|
220
|
+
const category = path.split('.')[1] || 'misc';
|
|
221
|
+
controller.enqueue(ndjson({ type: 'token', category, path, value, $type }));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
controller.enqueue(ndjson({ type: 'summary', summary: buildSummary(design) }));
|
|
225
|
+
controller.enqueue(ndjson({ type: 'files', files }));
|
|
226
|
+
|
|
227
|
+
// Fire-and-forget cache write.
|
|
228
|
+
putCached(key, { design }).catch(() => {});
|
|
229
|
+
} catch (err) {
|
|
230
|
+
console.error('[extract] failed', { url: targetUrl, ip, message: err?.message });
|
|
231
|
+
controller.enqueue(ndjson({ type: 'error', error: err?.message || 'Extraction failed' }));
|
|
232
|
+
} finally {
|
|
233
|
+
controller.close();
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
return new Response(stream, {
|
|
239
|
+
headers: {
|
|
240
|
+
'content-type': 'application/x-ndjson; charset=utf-8',
|
|
241
|
+
'cache-control': 'no-store',
|
|
242
|
+
'x-accel-buffering': 'no',
|
|
243
|
+
},
|
|
244
|
+
});
|
|
85
245
|
}
|