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.
Files changed (77) hide show
  1. package/.github/og-preview.png +0 -0
  2. package/.github/workflows/manavarya-bot.yml +17 -0
  3. package/.vercel/README.txt +11 -0
  4. package/.vercel/project.json +1 -0
  5. package/CHANGELOG.md +29 -0
  6. package/CONTRIBUTING.md +25 -0
  7. package/README.md +38 -11
  8. package/bin/design-extract.js +41 -2
  9. package/chrome-extension/README.md +41 -0
  10. package/chrome-extension/icons/favicon.svg +7 -0
  11. package/chrome-extension/icons/icon-128.png +0 -0
  12. package/chrome-extension/icons/icon-16.png +0 -0
  13. package/chrome-extension/icons/icon-32.png +0 -0
  14. package/chrome-extension/icons/icon-48.png +0 -0
  15. package/chrome-extension/manifest.json +26 -0
  16. package/chrome-extension/popup.html +167 -0
  17. package/chrome-extension/popup.js +59 -0
  18. package/docs/superpowers/specs/2026-04-18-website-redesign-design.md +120 -0
  19. package/docs/superpowers/specs/2026-04-19-designlang-v7-1-design.md +111 -0
  20. package/package.json +1 -1
  21. package/src/config.js +5 -1
  22. package/src/crawler.js +361 -2
  23. package/src/extractors/interaction-states.js +57 -0
  24. package/src/extractors/modern-css.js +100 -0
  25. package/src/extractors/token-sources.js +65 -0
  26. package/src/extractors/wide-gamut.js +47 -0
  27. package/src/formatters/routes-reconciliation.js +160 -0
  28. package/src/index.js +29 -0
  29. package/src/utils/color-gamut.js +82 -0
  30. package/src/utils-cookies.js +73 -0
  31. package/tests/cookies.test.js +98 -0
  32. package/tests/interaction-states.test.js +62 -0
  33. package/tests/modern-css.test.js +104 -0
  34. package/tests/routes-reconciliation.test.js +120 -0
  35. package/tests/wide-gamut.test.js +90 -0
  36. package/website/app/api/extract/route.js +216 -56
  37. package/website/app/components/A11ySlider.js +369 -0
  38. package/website/app/components/Comparison.js +286 -0
  39. package/website/app/components/CssHealth.js +243 -0
  40. package/website/app/components/HeroExtractor.js +455 -0
  41. package/website/app/components/Marginalia.js +3 -0
  42. package/website/app/components/McpSection.js +223 -0
  43. package/website/app/components/PlatformTabs.js +250 -0
  44. package/website/app/components/RegionsComponents.js +429 -0
  45. package/website/app/components/Rule.js +13 -0
  46. package/website/app/components/Specimens.js +237 -0
  47. package/website/app/components/StructuredData.js +144 -0
  48. package/website/app/components/TokenBrowser.js +344 -0
  49. package/website/app/components/token-browser-sample.js +65 -0
  50. package/website/app/globals.css +415 -633
  51. package/website/app/icon.svg +7 -0
  52. package/website/app/layout.js +113 -6
  53. package/website/app/opengraph-image.js +170 -0
  54. package/website/app/page.js +372 -148
  55. package/website/app/robots.js +15 -0
  56. package/website/app/seo-config.js +82 -0
  57. package/website/app/sitemap.js +18 -0
  58. package/website/lib/cache.js +73 -0
  59. package/website/lib/rate-limit.js +30 -0
  60. package/website/lib/rate-limit.test.js +55 -0
  61. package/website/lib/specimens.json +86 -0
  62. package/website/lib/token-helpers.js +70 -0
  63. package/website/lib/url-safety.js +103 -0
  64. package/website/lib/url-safety.test.js +116 -0
  65. package/website/lib/zip-files.js +15 -0
  66. package/website/package-lock.json +85 -0
  67. package/website/package.json +1 -0
  68. package/website/public/favicon.svg +7 -0
  69. package/website/public/logo-specimen.svg +76 -0
  70. package/website/public/mark.svg +12 -0
  71. package/website/public/site.webmanifest +13 -0
  72. package/website/app/favicon.ico +0 -0
  73. package/website/public/file.svg +0 -1
  74. package/website/public/globe.svg +0 -1
  75. package/website/public/next.svg +0 -1
  76. package/website/public/vercel.svg +0 -1
  77. 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
- export const maxDuration = 60;
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
- try {
28
- const { url } = await request.json();
29
-
30
- if (!url) {
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
- const summary = {
61
- url: design.meta.url,
62
- title: design.meta.title,
63
- colors: design.colors.all.length,
64
- colorList: design.colors.all.slice(0, 20).map(c => c.hex),
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
- return Response.json({ summary, files });
78
- } catch (err) {
79
- console.error('Extraction failed:', err);
185
+ const ip = extractIp(request);
186
+ const rate = checkRate(`extract:${ip}`);
187
+ if (!rate.allowed) {
80
188
  return Response.json(
81
- { error: err.message || 'Extraction failed' },
82
- { status: 500 }
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
  }