designlang 7.0.0 → 7.2.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/.github/og-preview.png +0 -0
- package/.github/workflows/manavarya-bot.yml +17 -0
- package/.vercel/README.txt +11 -0
- package/.vercel/project.json +1 -0
- package/CHANGELOG.md +29 -0
- package/CONTRIBUTING.md +25 -0
- package/README.md +38 -11
- package/bin/design-extract.js +41 -2
- 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 +5 -1
- package/src/crawler.js +361 -2
- package/src/extractors/interaction-states.js +57 -0
- package/src/extractors/modern-css.js +100 -0
- package/src/extractors/token-sources.js +65 -0
- package/src/extractors/wide-gamut.js +47 -0
- package/src/formatters/routes-reconciliation.js +160 -0
- package/src/index.js +29 -0
- package/src/utils/color-gamut.js +82 -0
- package/src/utils-cookies.js +73 -0
- package/tests/cookies.test.js +98 -0
- package/tests/interaction-states.test.js +62 -0
- package/tests/modern-css.test.js +104 -0
- package/tests/routes-reconciliation.test.js +120 -0
- package/tests/wide-gamut.test.js +90 -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 +372 -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,120 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { reconcileRoutes, slugForPath, formatRoutesReport } from '../src/formatters/routes-reconciliation.js';
|
|
4
|
+
|
|
5
|
+
function leaf(v) { return { $value: v, $type: 'color' }; }
|
|
6
|
+
|
|
7
|
+
const baseTokens = {
|
|
8
|
+
primitive: {
|
|
9
|
+
color: {
|
|
10
|
+
brand: { primary: leaf('#ff0000'), secondary: leaf('#00ff00') },
|
|
11
|
+
neutral: { 100: leaf('#ffffff') },
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
semantic: {},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
describe('slugForPath', () => {
|
|
18
|
+
it('treats / and empty as index', () => {
|
|
19
|
+
assert.equal(slugForPath('/'), 'index');
|
|
20
|
+
assert.equal(slugForPath(''), 'index');
|
|
21
|
+
assert.equal(slugForPath(null), 'index');
|
|
22
|
+
});
|
|
23
|
+
it('slugifies standard paths', () => {
|
|
24
|
+
assert.equal(slugForPath('/pricing'), 'pricing');
|
|
25
|
+
assert.equal(slugForPath('/docs/getting-started'), 'docs-getting-started');
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('reconcileRoutes', () => {
|
|
30
|
+
it('returns empty-safe output for no routes', () => {
|
|
31
|
+
const r = reconcileRoutes([]);
|
|
32
|
+
assert.equal(r.summary.routeCount, 0);
|
|
33
|
+
assert.equal(r.summary.sharedTokenCount, 0);
|
|
34
|
+
assert.deepEqual(r.perRoute, {});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('intersects identical tokens across routes into shared', () => {
|
|
38
|
+
const routes = [
|
|
39
|
+
{ url: 'https://x.com/', path: '/', tokens: baseTokens },
|
|
40
|
+
{ url: 'https://x.com/about', path: '/about', tokens: baseTokens },
|
|
41
|
+
];
|
|
42
|
+
const r = reconcileRoutes(routes);
|
|
43
|
+
assert.equal(r.summary.routeCount, 2);
|
|
44
|
+
assert.ok(r.summary.sharedTokenCount >= 3);
|
|
45
|
+
// shared tree has the primitive.color.brand.primary leaf
|
|
46
|
+
assert.equal(r.shared.primitive.color.brand.primary.$value, '#ff0000');
|
|
47
|
+
// neither route should have "added" or "changed" entries
|
|
48
|
+
assert.deepEqual(r.perRoute.index.added, {});
|
|
49
|
+
assert.deepEqual(r.perRoute.index.changed, {});
|
|
50
|
+
assert.deepEqual(r.perRoute.about.added, {});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('emits `added` tokens for a route that has unique tokens', () => {
|
|
54
|
+
const priceTokens = {
|
|
55
|
+
primitive: {
|
|
56
|
+
color: {
|
|
57
|
+
brand: { primary: leaf('#ff0000'), secondary: leaf('#00ff00') },
|
|
58
|
+
neutral: { 100: leaf('#ffffff') },
|
|
59
|
+
accent: { gold: leaf('#ffd700') }, // unique to /pricing
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
semantic: {},
|
|
63
|
+
};
|
|
64
|
+
const routes = [
|
|
65
|
+
{ url: 'https://x.com/', path: '/', tokens: baseTokens },
|
|
66
|
+
{ url: 'https://x.com/pricing', path: '/pricing', tokens: priceTokens },
|
|
67
|
+
];
|
|
68
|
+
const r = reconcileRoutes(routes);
|
|
69
|
+
assert.equal(r.perRoute.pricing.added.primitive.color.accent.gold.$value, '#ffd700');
|
|
70
|
+
// The base route has no "added" since all its tokens are shared or absent.
|
|
71
|
+
assert.deepEqual(r.perRoute.index.added, {});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('emits `changed` when a route overrides an otherwise-shared token value', () => {
|
|
75
|
+
const darkTokens = {
|
|
76
|
+
primitive: {
|
|
77
|
+
color: {
|
|
78
|
+
brand: { primary: leaf('#aa0000'), secondary: leaf('#00ff00') }, // primary differs
|
|
79
|
+
neutral: { 100: leaf('#ffffff') },
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
semantic: {},
|
|
83
|
+
};
|
|
84
|
+
const routes = [
|
|
85
|
+
{ url: 'https://x.com/', path: '/', tokens: baseTokens },
|
|
86
|
+
{ url: 'https://x.com/dark', path: '/dark', tokens: darkTokens },
|
|
87
|
+
];
|
|
88
|
+
const r = reconcileRoutes(routes);
|
|
89
|
+
// primary shouldn't be shared because values disagree
|
|
90
|
+
assert.ok(!('primary' in (r.shared.primitive?.color?.brand || {})),
|
|
91
|
+
'disagreeing primary should not be shared');
|
|
92
|
+
// both routes should have primary in their `changed` bucket
|
|
93
|
+
assert.equal(r.perRoute.index.changed.primitive.color.brand.primary.$value, '#ff0000');
|
|
94
|
+
assert.equal(r.perRoute.dark.changed.primitive.color.brand.primary.$value, '#aa0000');
|
|
95
|
+
assert.ok(r.summary.drift.length >= 2);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('resolves slug collisions by appending a numeric suffix', () => {
|
|
99
|
+
const routes = [
|
|
100
|
+
{ url: 'https://x.com/docs/a', path: '/docs-a', tokens: baseTokens },
|
|
101
|
+
{ url: 'https://x.com/docs-a/', path: '/docs-a', tokens: baseTokens },
|
|
102
|
+
];
|
|
103
|
+
const r = reconcileRoutes(routes);
|
|
104
|
+
const slugs = Object.keys(r.perRoute);
|
|
105
|
+
assert.equal(slugs.length, 2);
|
|
106
|
+
assert.ok(slugs.includes('docs-a'));
|
|
107
|
+
assert.ok(slugs.some(s => s.startsWith('docs-a-')));
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('formatRoutesReport produces readable markdown', () => {
|
|
111
|
+
const routes = [
|
|
112
|
+
{ url: 'https://x.com/', path: '/', tokens: baseTokens },
|
|
113
|
+
{ url: 'https://x.com/pricing', path: '/pricing', tokens: baseTokens },
|
|
114
|
+
];
|
|
115
|
+
const md = formatRoutesReport(reconcileRoutes(routes));
|
|
116
|
+
assert.match(md, /Routes crawled.*2/);
|
|
117
|
+
assert.match(md, /Shared tokens/);
|
|
118
|
+
assert.match(md, /pricing/);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { oklchToSrgb, oklabToSrgb, oklchLikeToHex, parseOklchOrOklab, rgbToHex } from '../src/utils/color-gamut.js';
|
|
4
|
+
import { extractWideGamut } from '../src/extractors/wide-gamut.js';
|
|
5
|
+
import { extractTokenSources } from '../src/extractors/token-sources.js';
|
|
6
|
+
|
|
7
|
+
describe('color-gamut', () => {
|
|
8
|
+
it('converts oklch(1 0 0) to white', () => {
|
|
9
|
+
const hex = oklchLikeToHex('oklch(1 0 0)');
|
|
10
|
+
assert.equal(hex, '#ffffff');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('converts oklch(0 0 0) to black', () => {
|
|
14
|
+
const hex = oklchLikeToHex('oklch(0 0 0)');
|
|
15
|
+
assert.equal(hex, '#000000');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('converts a mid-chroma oklch to approximate sRGB', () => {
|
|
19
|
+
// oklch(62.8% 0.258 29.23) ≈ red (#ff0000) region
|
|
20
|
+
const hex = oklchLikeToHex('oklch(62.8% 0.258 29.23)');
|
|
21
|
+
assert.ok(/^#[0-9a-f]{6}$/.test(hex));
|
|
22
|
+
// Red channel should dominate
|
|
23
|
+
const r = parseInt(hex.slice(1, 3), 16);
|
|
24
|
+
const g = parseInt(hex.slice(3, 5), 16);
|
|
25
|
+
const b = parseInt(hex.slice(5, 7), 16);
|
|
26
|
+
assert.ok(r > g, `expected r>g, got ${hex}`);
|
|
27
|
+
assert.ok(r > b, `expected r>b, got ${hex}`);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('parses oklab values with percentages', () => {
|
|
31
|
+
const p = parseOklchOrOklab('oklab(62.8% 0.1 -0.1)');
|
|
32
|
+
assert.equal(p.type, 'oklab');
|
|
33
|
+
assert.ok(Math.abs(p.L - 0.628) < 1e-6);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('rgbToHex clamps and formats correctly', () => {
|
|
37
|
+
assert.equal(rgbToHex(1, 0, 0), '#ff0000');
|
|
38
|
+
assert.equal(rgbToHex(0, 1, 0), '#00ff00');
|
|
39
|
+
assert.equal(rgbToHex(1.5, -0.5, 0.5), '#ff0080');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('extractWideGamut', () => {
|
|
44
|
+
it('returns zeros for empty input', () => {
|
|
45
|
+
const r = extractWideGamut([]);
|
|
46
|
+
assert.equal(r.totalCount, 0);
|
|
47
|
+
assert.equal(r.oklch.count, 0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('buckets colors by type and emits hex for oklch samples', () => {
|
|
51
|
+
const r = extractWideGamut([
|
|
52
|
+
{ raw: 'oklch(62.8% 0.258 29.23)', type: 'oklch', property: 'color', selector: '.btn' },
|
|
53
|
+
{ raw: 'color-mix(in oklab, red, blue)', type: 'color-mix', property: 'background', selector: '.x' },
|
|
54
|
+
{ raw: 'light-dark(white, black)', type: 'light-dark', property: 'color', selector: '.y' },
|
|
55
|
+
{ raw: 'color(display-p3 1 0 0)', type: 'display-p3', property: 'background', selector: '.p3' },
|
|
56
|
+
]);
|
|
57
|
+
assert.equal(r.oklch.count, 1);
|
|
58
|
+
assert.ok(r.oklch.samples[0].value?.startsWith('#'));
|
|
59
|
+
assert.equal(r.colorMix.count, 1);
|
|
60
|
+
assert.equal(r.lightDark.count, 1);
|
|
61
|
+
assert.equal(r.displayP3.count, 1);
|
|
62
|
+
assert.equal(r.totalCount, 4);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('extractTokenSources', () => {
|
|
67
|
+
it('returns an array of token→sourceUrl entries based on first matching element', () => {
|
|
68
|
+
const design = {
|
|
69
|
+
colors: { primary: { hex: '#0072f5' }, text: ['#111111'] },
|
|
70
|
+
typography: { families: [{ name: 'Geist' }] },
|
|
71
|
+
spacing: { base: 8 },
|
|
72
|
+
borders: { radii: [{ value: '8px' }] },
|
|
73
|
+
};
|
|
74
|
+
const styles = [
|
|
75
|
+
{ color: 'rgb(0, 114, 245)', fontFamily: '"Geist", sans-serif', paddingTop: '8px', borderRadius: '8px', sources: [{ url: 'https://cdn.example/app.css', mediaText: '' }] },
|
|
76
|
+
{ color: 'rgb(17, 17, 17)', sources: [{ url: 'https://cdn.example/text.css', mediaText: '' }] },
|
|
77
|
+
];
|
|
78
|
+
const out = extractTokenSources(design, styles);
|
|
79
|
+
const primary = out.find(t => t.token === 'color.primary');
|
|
80
|
+
assert.equal(primary.sourceUrl, 'https://cdn.example/app.css');
|
|
81
|
+
const text = out.find(t => t.token === 'color.text');
|
|
82
|
+
assert.equal(text.sourceUrl, 'https://cdn.example/text.css');
|
|
83
|
+
const font = out.find(t => t.token === 'font.body');
|
|
84
|
+
assert.equal(font.sourceUrl, 'https://cdn.example/app.css');
|
|
85
|
+
const spacing = out.find(t => t.token === 'spacing.base');
|
|
86
|
+
assert.equal(spacing.sourceUrl, 'https://cdn.example/app.css');
|
|
87
|
+
const radius = out.find(t => t.token === 'radius.base');
|
|
88
|
+
assert.equal(radius.sourceUrl, 'https://cdn.example/app.css');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -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
|
}
|