designlang 6.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/.github/FUNDING.yml +1 -0
- package/.github/ISSUE_TEMPLATE/bug_report.yml +62 -0
- package/.github/ISSUE_TEMPLATE/config.yml +8 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +28 -0
- package/.vercel/README.txt +11 -0
- package/.vercel/project.json +1 -0
- package/CHANGELOG.md +58 -0
- package/CONTRIBUTING.md +25 -0
- package/README.md +120 -8
- package/bin/design-extract.js +106 -3
- 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/plans/2026-04-18-designlang-v7.md +1121 -0
- package/docs/superpowers/specs/2026-04-18-designlang-v7-design.md +150 -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 +5 -4
- package/src/config.js +26 -0
- package/src/crawler.js +136 -2
- package/src/extractors/a11y-remediation.js +47 -0
- package/src/extractors/component-clusters.js +39 -0
- package/src/extractors/css-health.js +151 -0
- package/src/extractors/scoring.js +20 -1
- package/src/extractors/semantic-regions.js +44 -0
- package/src/extractors/stack-fingerprint.js +88 -0
- package/src/formatters/_token-ref.js +44 -0
- package/src/formatters/agent-rules.js +116 -0
- package/src/formatters/android-compose.js +164 -0
- package/src/formatters/dtcg-tokens.js +175 -0
- package/src/formatters/flutter-dart.js +130 -0
- package/src/formatters/ios-swiftui.js +161 -0
- package/src/formatters/markdown.js +25 -0
- package/src/formatters/wordpress.js +183 -0
- package/src/index.js +30 -0
- package/src/mcp/resources.js +64 -0
- package/src/mcp/server.js +110 -0
- package/src/mcp/tools.js +149 -0
- package/src/utils-cookies.js +73 -0
- package/tests/cli.test.js +50 -0
- package/tests/cookies.test.js +98 -0
- package/tests/extractors.test.js +131 -0
- package/tests/formatters.test.js +232 -0
- package/tests/mcp.test.js +68 -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,39 @@
|
|
|
1
|
+
// Cluster similar component instances by structuralHash + style vector cosine similarity.
|
|
2
|
+
|
|
3
|
+
function cosine(a = [], b = []) {
|
|
4
|
+
const n = Math.min(a.length, b.length);
|
|
5
|
+
let dot = 0, na = 0, nb = 0;
|
|
6
|
+
for (let i = 0; i < n; i++) {
|
|
7
|
+
dot += a[i] * b[i];
|
|
8
|
+
na += a[i] * a[i];
|
|
9
|
+
nb += b[i] * b[i];
|
|
10
|
+
}
|
|
11
|
+
return na && nb ? dot / (Math.sqrt(na) * Math.sqrt(nb)) : (na === nb ? 1 : 0);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function clusterComponents(elements = [], { threshold = 0.95 } = {}) {
|
|
15
|
+
const byKind = {};
|
|
16
|
+
for (const el of elements) {
|
|
17
|
+
const key = `${el.kind}|${el.structuralHash}`;
|
|
18
|
+
(byKind[key] ||= []).push(el);
|
|
19
|
+
}
|
|
20
|
+
const out = [];
|
|
21
|
+
for (const group of Object.values(byKind)) {
|
|
22
|
+
const variants = [];
|
|
23
|
+
for (const el of group) {
|
|
24
|
+
const match = variants.find(v => cosine(v.example.styleVector || [], el.styleVector || []) >= threshold);
|
|
25
|
+
if (match) {
|
|
26
|
+
match.instanceCount++;
|
|
27
|
+
} else {
|
|
28
|
+
variants.push({ example: el, instanceCount: 1 });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
out.push({
|
|
32
|
+
kind: group[0].kind,
|
|
33
|
+
structuralHash: group[0].structuralHash,
|
|
34
|
+
instanceCount: group.length,
|
|
35
|
+
variants: variants.map(v => ({ css: v.example.css, instanceCount: v.instanceCount })),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// CSS health audit — operates on page.coverage.stopCSSCoverage() output
|
|
2
|
+
// serialized as [{ url, text, totalBytes, ranges:[{start,end}] }].
|
|
3
|
+
|
|
4
|
+
function countUsed(ranges = []) {
|
|
5
|
+
let used = 0;
|
|
6
|
+
for (const r of ranges) used += Math.max(0, (r.end || 0) - (r.start || 0));
|
|
7
|
+
return used;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function countImportant(text) {
|
|
11
|
+
const m = text.match(/!important/g);
|
|
12
|
+
return m ? m.length : 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function countDuplicates(text) {
|
|
16
|
+
// Count prop:value pairs that appear >=2x across the sheet.
|
|
17
|
+
const seen = new Map();
|
|
18
|
+
const re = /([\w-]+)\s*:\s*([^;{}!]+)(\s*!important)?\s*;?/g;
|
|
19
|
+
for (const m of text.matchAll(re)) {
|
|
20
|
+
const key = `${m[1].trim()}:${m[2].trim()}`;
|
|
21
|
+
seen.set(key, (seen.get(key) || 0) + 1);
|
|
22
|
+
}
|
|
23
|
+
let dup = 0;
|
|
24
|
+
for (const n of seen.values()) if (n >= 2) dup += (n - 1);
|
|
25
|
+
return dup;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function countVendorPrefixes(text) {
|
|
29
|
+
return {
|
|
30
|
+
webkit: (text.match(/-webkit-/g) || []).length,
|
|
31
|
+
moz: (text.match(/-moz-/g) || []).length,
|
|
32
|
+
ms: (text.match(/-ms-/g) || []).length,
|
|
33
|
+
o: (text.match(/(^|[^-\w])-o-/g) || []).length,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function extractKeyframes(text) {
|
|
38
|
+
const out = [];
|
|
39
|
+
const re = /@keyframes\s+([\w-]+)\s*\{([\s\S]*?)\n\}/g;
|
|
40
|
+
for (const m of text.matchAll(re)) {
|
|
41
|
+
const body = m[2];
|
|
42
|
+
const steps = (body.match(/(\d+%|from|to)\s*\{/g) || []).length;
|
|
43
|
+
out.push({ name: m[1], steps });
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function specificityFor(selector) {
|
|
49
|
+
// Simple WCAG-ish triple: ids, classes+attrs+pseudo-classes, types
|
|
50
|
+
const ids = (selector.match(/#[\w-]+/g) || []).length;
|
|
51
|
+
const classes = (selector.match(/\.[\w-]+|\[[^\]]+\]|:(?!:)[\w-]+(?:\([^)]+\))?/g) || []).length;
|
|
52
|
+
const types = (selector.match(/(?:^|[\s>+~,])([a-z][\w-]*)/gi) || []).length;
|
|
53
|
+
return [ids, classes, types];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function specificityDistribution(text) {
|
|
57
|
+
const triples = [];
|
|
58
|
+
const re = /([^{}]+)\{([^}]*)\}/g;
|
|
59
|
+
for (const m of text.matchAll(re)) {
|
|
60
|
+
const selectorList = m[1];
|
|
61
|
+
for (const sel of selectorList.split(',').map(s => s.trim()).filter(Boolean)) {
|
|
62
|
+
if (sel.startsWith('@')) continue;
|
|
63
|
+
triples.push(specificityFor(sel));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (triples.length === 0) {
|
|
67
|
+
return { max: [0, 0, 0], average: [0, 0, 0], count: 0 };
|
|
68
|
+
}
|
|
69
|
+
let max = [0, 0, 0];
|
|
70
|
+
let sum = [0, 0, 0];
|
|
71
|
+
for (const t of triples) {
|
|
72
|
+
for (let i = 0; i < 3; i++) {
|
|
73
|
+
sum[i] += t[i];
|
|
74
|
+
if (t[i] > max[i]) max[i] = t[i];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const avg = sum.map(v => Math.round((v / triples.length) * 100) / 100);
|
|
78
|
+
return { max, average: avg, count: triples.length };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function extractCssHealth(coverage = []) {
|
|
82
|
+
const sheets = [];
|
|
83
|
+
let totalBytes = 0;
|
|
84
|
+
let usedBytes = 0;
|
|
85
|
+
let importantCount = 0;
|
|
86
|
+
let duplicates = 0;
|
|
87
|
+
const vendorPrefixes = { webkit: 0, moz: 0, ms: 0, o: 0 };
|
|
88
|
+
const keyframes = [];
|
|
89
|
+
let specMax = [0, 0, 0];
|
|
90
|
+
let specSumWeighted = [0, 0, 0];
|
|
91
|
+
let specCount = 0;
|
|
92
|
+
|
|
93
|
+
for (const c of coverage) {
|
|
94
|
+
const text = c.text || '';
|
|
95
|
+
const sheetTotal = typeof c.totalBytes === 'number' ? c.totalBytes : text.length;
|
|
96
|
+
const sheetUsed = countUsed(c.ranges);
|
|
97
|
+
const sheetUnused = Math.max(0, sheetTotal - sheetUsed);
|
|
98
|
+
sheets.push({
|
|
99
|
+
url: c.url || '',
|
|
100
|
+
totalBytes: sheetTotal,
|
|
101
|
+
usedBytes: sheetUsed,
|
|
102
|
+
unusedBytes: sheetUnused,
|
|
103
|
+
unusedPercent: sheetTotal ? Math.round((sheetUnused / sheetTotal) * 100) : 0,
|
|
104
|
+
});
|
|
105
|
+
totalBytes += sheetTotal;
|
|
106
|
+
usedBytes += sheetUsed;
|
|
107
|
+
|
|
108
|
+
importantCount += countImportant(text);
|
|
109
|
+
duplicates += countDuplicates(text);
|
|
110
|
+
|
|
111
|
+
const vp = countVendorPrefixes(text);
|
|
112
|
+
vendorPrefixes.webkit += vp.webkit;
|
|
113
|
+
vendorPrefixes.moz += vp.moz;
|
|
114
|
+
vendorPrefixes.ms += vp.ms;
|
|
115
|
+
vendorPrefixes.o += vp.o;
|
|
116
|
+
|
|
117
|
+
keyframes.push(...extractKeyframes(text));
|
|
118
|
+
|
|
119
|
+
const spec = specificityDistribution(text);
|
|
120
|
+
for (let i = 0; i < 3; i++) {
|
|
121
|
+
if (spec.max[i] > specMax[i]) specMax[i] = spec.max[i];
|
|
122
|
+
specSumWeighted[i] += spec.average[i] * spec.count;
|
|
123
|
+
}
|
|
124
|
+
specCount += spec.count;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const unusedBytes = Math.max(0, totalBytes - usedBytes);
|
|
128
|
+
const unusedPercent = totalBytes ? Math.round((unusedBytes / totalBytes) * 100) : 0;
|
|
129
|
+
const specAvg = specCount > 0
|
|
130
|
+
? specSumWeighted.map(v => Math.round((v / specCount) * 100) / 100)
|
|
131
|
+
: [0, 0, 0];
|
|
132
|
+
|
|
133
|
+
const issues = [];
|
|
134
|
+
if (importantCount > 0) issues.push(`${importantCount} !important rule${importantCount > 1 ? 's' : ''}`);
|
|
135
|
+
if (duplicates > 0) issues.push(`${duplicates} duplicate declaration${duplicates > 1 ? 's' : ''}`);
|
|
136
|
+
if (unusedPercent >= 50) issues.push(`${unusedPercent}% unused CSS`);
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
sheets,
|
|
140
|
+
totalBytes,
|
|
141
|
+
usedBytes,
|
|
142
|
+
unusedBytes,
|
|
143
|
+
unusedPercent,
|
|
144
|
+
importantCount,
|
|
145
|
+
duplicates,
|
|
146
|
+
vendorPrefixes,
|
|
147
|
+
keyframes,
|
|
148
|
+
specificity: { max: specMax, average: specAvg, count: specCount },
|
|
149
|
+
issues,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -73,7 +73,26 @@ export function scoreDesignSystem(design) {
|
|
|
73
73
|
issues.push(`${design.accessibility.failCount} WCAG contrast failures`);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
// 7. CSS
|
|
76
|
+
// 7. CSS health (0-100) — additive; does not affect existing weights.
|
|
77
|
+
if (design.cssHealth) {
|
|
78
|
+
const ch = design.cssHealth;
|
|
79
|
+
let h = 100;
|
|
80
|
+
if (ch.unusedPercent >= 70) h -= 30;
|
|
81
|
+
else if (ch.unusedPercent >= 50) h -= 20;
|
|
82
|
+
else if (ch.unusedPercent >= 30) h -= 10;
|
|
83
|
+
if (ch.importantCount >= 20) h -= 20;
|
|
84
|
+
else if (ch.importantCount >= 5) h -= 10;
|
|
85
|
+
else if (ch.importantCount >= 1) h -= 5;
|
|
86
|
+
if (ch.duplicates >= 20) h -= 15;
|
|
87
|
+
else if (ch.duplicates >= 5) h -= 8;
|
|
88
|
+
else if (ch.duplicates >= 1) h -= 3;
|
|
89
|
+
scores.cssHealth = Math.max(0, h);
|
|
90
|
+
if (ch.importantCount >= 5) issues.push(`${ch.importantCount} !important rules — prefer specificity over overrides`);
|
|
91
|
+
if (ch.unusedPercent >= 50) issues.push(`${ch.unusedPercent}% of CSS is unused — consider purging`);
|
|
92
|
+
if (ch.duplicates >= 5) issues.push(`${ch.duplicates} duplicate CSS declarations`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 8. CSS variable usage (0-100)
|
|
77
96
|
const varCount = Object.values(design.variables).reduce((s, v) => s + Object.keys(v).length, 0);
|
|
78
97
|
if (varCount >= 20) scores.tokenization = 100;
|
|
79
98
|
else if (varCount >= 10) scores.tokenization = 75;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Classify page sections: nav/hero/features/pricing/testimonials/cta/footer/sidebar/content.
|
|
2
|
+
|
|
3
|
+
const KW = {
|
|
4
|
+
pricing: /\b(\$\s*\d|per\s?month|\/mo\b|pricing|free|billed)/i,
|
|
5
|
+
testimonials: /(customer|review|testimonial|said|"|")/i,
|
|
6
|
+
features: /(feature|benefit|why|what you get)/i,
|
|
7
|
+
cta: /(get started|sign up|try free|start now|request demo|contact sales)/i,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function classify(s) {
|
|
11
|
+
const role = (s.role || '').toLowerCase();
|
|
12
|
+
const tag = (s.tag || '').toLowerCase();
|
|
13
|
+
if (tag === 'nav' || role === 'navigation') return 'nav';
|
|
14
|
+
if (tag === 'header' || role === 'banner') return 'nav';
|
|
15
|
+
if (tag === 'footer' || role === 'contentinfo') return 'footer';
|
|
16
|
+
if (tag === 'aside' || role === 'complementary') return 'sidebar';
|
|
17
|
+
|
|
18
|
+
const cls = (s.className || '').toLowerCase();
|
|
19
|
+
const id = (s.id || '').toLowerCase();
|
|
20
|
+
const blob = `${cls} ${id}`;
|
|
21
|
+
const text = s.text || '';
|
|
22
|
+
const headings = s.headings || [];
|
|
23
|
+
|
|
24
|
+
if (/hero/.test(blob)) return 'hero';
|
|
25
|
+
if (/pricing/.test(blob) || KW.pricing.test(text)) return 'pricing';
|
|
26
|
+
if (/testimonial|review/.test(blob) || KW.testimonials.test(text)) return 'testimonials';
|
|
27
|
+
if (/features?|grid/.test(blob) && s.cardCount >= 3) return 'features';
|
|
28
|
+
if (KW.features.test(text) && s.cardCount >= 3) return 'features';
|
|
29
|
+
if (s.buttonCount <= 2 && headings.length && text.length < 400 && KW.cta.test(text)) return 'cta';
|
|
30
|
+
if (headings.length === 1 && s.buttonCount >= 1 && s.bounds && s.bounds.h > 300) return 'hero';
|
|
31
|
+
return 'content';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function extractSemanticRegions(sections = []) {
|
|
35
|
+
return sections.map(s => ({
|
|
36
|
+
role: classify(s),
|
|
37
|
+
tag: s.tag,
|
|
38
|
+
bounds: s.bounds,
|
|
39
|
+
heading: (s.headings && s.headings[0]) || null,
|
|
40
|
+
buttonCount: s.buttonCount || 0,
|
|
41
|
+
cardCount: s.cardCount || 0,
|
|
42
|
+
className: s.className || null,
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Framework + CSS layer + analytics fingerprint.
|
|
2
|
+
// Pure function over signals collected by the crawler.
|
|
3
|
+
|
|
4
|
+
const FRAMEWORK_BY_GLOBAL = {
|
|
5
|
+
'__NEXT_DATA__': 'next',
|
|
6
|
+
'__NUXT__': 'nuxt',
|
|
7
|
+
'___gatsby': 'gatsby',
|
|
8
|
+
'_remixContext': 'remix',
|
|
9
|
+
'React': 'react',
|
|
10
|
+
'Vue': 'vue',
|
|
11
|
+
'Shopify': 'shopify',
|
|
12
|
+
'wp': 'wordpress',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const SCRIPT_PATTERNS = [
|
|
16
|
+
[/_next\/static/, 'next'],
|
|
17
|
+
[/\/nuxt\//, 'nuxt'],
|
|
18
|
+
[/\/astro\//, 'astro'],
|
|
19
|
+
[/\/sveltekit\//, 'sveltekit'],
|
|
20
|
+
[/shopify\./, 'shopify'],
|
|
21
|
+
[/wp-(content|includes)/, 'wordpress'],
|
|
22
|
+
[/webflow\.com/, 'webflow'],
|
|
23
|
+
[/framerusercontent/, 'framer'],
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const ANALYTICS = {
|
|
27
|
+
gtag: /googletagmanager\.com|google-analytics/,
|
|
28
|
+
plausible: /plausible\.io/,
|
|
29
|
+
posthog: /posthog\.com/,
|
|
30
|
+
segment: /segment\.(io|com)/,
|
|
31
|
+
mixpanel: /mixpanel/,
|
|
32
|
+
amplitude: /amplitude/,
|
|
33
|
+
hotjar: /hotjar/,
|
|
34
|
+
vercelInsights: /\/_vercel\/insights/,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const TAILWIND_UTIL = /(^|\s)(flex|grid|block|inline|hidden|text-(xs|sm|base|lg|xl|\d+xl)|text-(gray|slate|zinc|red|blue|green|amber|neutral|stone)-\d+|bg-(gray|slate|zinc|red|blue|green|amber|neutral|stone)-\d+|p[xy]?-\d+|m[xy]?-\d+|gap-\d+|rounded(-\w+)?|shadow(-\w+)?|items-(start|center|end|baseline|stretch)|justify-(start|center|end|between|around|evenly)|grid-cols-\d+|col-span-\d+)(\s|$)/;
|
|
38
|
+
|
|
39
|
+
function detectFramework(signals) {
|
|
40
|
+
for (const g of signals.windowGlobals || []) {
|
|
41
|
+
if (FRAMEWORK_BY_GLOBAL[g]) return FRAMEWORK_BY_GLOBAL[g];
|
|
42
|
+
}
|
|
43
|
+
for (const s of signals.scripts || []) {
|
|
44
|
+
for (const [re, name] of SCRIPT_PATTERNS) if (re.test(s)) return name;
|
|
45
|
+
}
|
|
46
|
+
return 'unknown';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function detectTailwind(signals) {
|
|
50
|
+
const classes = (signals.classNameSample || []).filter(c => typeof c === 'string');
|
|
51
|
+
const hits = classes.filter(c => TAILWIND_UTIL.test(c));
|
|
52
|
+
if (hits.length < Math.max(5, classes.length * 0.1)) return null;
|
|
53
|
+
const utilFreq = new Map();
|
|
54
|
+
for (const c of hits) {
|
|
55
|
+
for (const u of c.split(/\s+/).filter(Boolean)) {
|
|
56
|
+
utilFreq.set(u, (utilFreq.get(u) || 0) + 1);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const topUtilities = [...utilFreq.entries()]
|
|
60
|
+
.sort((a, b) => b[1] - a[1])
|
|
61
|
+
.slice(0, 100)
|
|
62
|
+
.map(([u, n]) => ({ utility: u, count: n }));
|
|
63
|
+
return { detected: true, utilities: topUtilities };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function detectAnalytics(signals) {
|
|
67
|
+
const found = [];
|
|
68
|
+
for (const [name, re] of Object.entries(ANALYTICS)) {
|
|
69
|
+
if ((signals.scripts || []).some(s => re.test(s))) found.push(name);
|
|
70
|
+
}
|
|
71
|
+
return found;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function extractStackFingerprint(signals = {}) {
|
|
75
|
+
const framework = detectFramework(signals);
|
|
76
|
+
const tailwind = detectTailwind(signals);
|
|
77
|
+
const css = { layer: tailwind ? 'tailwind' : 'unknown', tailwind: tailwind || null };
|
|
78
|
+
return {
|
|
79
|
+
framework,
|
|
80
|
+
css,
|
|
81
|
+
analytics: detectAnalytics(signals),
|
|
82
|
+
detectedFrom: {
|
|
83
|
+
globalCount: (signals.windowGlobals || []).length,
|
|
84
|
+
scriptCount: (signals.scripts || []).length,
|
|
85
|
+
classSampleSize: (signals.classNameSample || []).length,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Internal helper: resolve DTCG reference strings like "{primitive.color.brand.primary}"
|
|
2
|
+
// against a token tree, following chains until we reach a non-reference $value.
|
|
3
|
+
|
|
4
|
+
const REF_PATTERN = /^\{([^}]+)\}$/;
|
|
5
|
+
|
|
6
|
+
function parseRef(value) {
|
|
7
|
+
if (typeof value !== 'string') return null;
|
|
8
|
+
const match = value.match(REF_PATTERN);
|
|
9
|
+
return match ? match[1] : null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function getAtPath(tokens, path) {
|
|
13
|
+
const parts = path.split('.');
|
|
14
|
+
let node = tokens;
|
|
15
|
+
for (const part of parts) {
|
|
16
|
+
if (node == null || typeof node !== 'object') return undefined;
|
|
17
|
+
node = node[part];
|
|
18
|
+
}
|
|
19
|
+
return node;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Resolve a token at the given dotted path to its leaf $value (following references).
|
|
23
|
+
// Returns undefined if the path is missing or a reference cannot be resolved.
|
|
24
|
+
export function resolveRef(tokens, path, seen = new Set()) {
|
|
25
|
+
if (seen.has(path)) return undefined; // cycle
|
|
26
|
+
seen.add(path);
|
|
27
|
+
|
|
28
|
+
const node = getAtPath(tokens, path);
|
|
29
|
+
if (node == null) return undefined;
|
|
30
|
+
|
|
31
|
+
// If we got a token object with $value
|
|
32
|
+
if (typeof node === 'object' && '$value' in node) {
|
|
33
|
+
const inner = node.$value;
|
|
34
|
+
const refPath = parseRef(inner);
|
|
35
|
+
if (refPath) return resolveRef(tokens, refPath, seen);
|
|
36
|
+
return inner;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// If the node is itself a reference string
|
|
40
|
+
const refPath = parseRef(node);
|
|
41
|
+
if (refPath) return resolveRef(tokens, refPath, seen);
|
|
42
|
+
|
|
43
|
+
return node;
|
|
44
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Agent rules emitter. Produces ready-to-drop files that teach coding agents
|
|
2
|
+
// (Cursor, Claude Code, generic) to prefer the extracted design tokens
|
|
3
|
+
// instead of inventing new colors/typography.
|
|
4
|
+
//
|
|
5
|
+
// Output: { relativePath: fileContent } — caller is responsible for writing.
|
|
6
|
+
|
|
7
|
+
import { resolveRef } from './_token-ref.js';
|
|
8
|
+
|
|
9
|
+
function hostFromUrl(url) {
|
|
10
|
+
try { return new URL(url).hostname; } catch { return url || 'unknown'; }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Resolve a semantic token path to its concrete leaf value, or fall back.
|
|
14
|
+
function resolveSemantic(tokens, path, fallback) {
|
|
15
|
+
const v = resolveRef(tokens, path);
|
|
16
|
+
return (typeof v === 'string' && v) ? v : fallback;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function firstFontFamily(tokens) {
|
|
20
|
+
const fam = tokens?.primitive?.fontFamily || {};
|
|
21
|
+
const keys = Object.keys(fam);
|
|
22
|
+
if (!keys.length) return 'system-ui';
|
|
23
|
+
const v = fam[keys[0]]?.$value;
|
|
24
|
+
return typeof v === 'string' ? v : 'system-ui';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function buildBody({ url, tokens, design, iso }) {
|
|
28
|
+
const actionPrimary = resolveSemantic(tokens, 'semantic.color.action.primary', '#000000');
|
|
29
|
+
const surfaceDefault = resolveSemantic(tokens, 'semantic.color.surface.default', '#ffffff');
|
|
30
|
+
const textBody = resolveSemantic(tokens, 'semantic.color.text.body', '#111111');
|
|
31
|
+
const radiusControl = resolveSemantic(tokens, 'semantic.radius.control', '0px');
|
|
32
|
+
const fontFamily = firstFontFamily(tokens);
|
|
33
|
+
|
|
34
|
+
const lines = [];
|
|
35
|
+
lines.push(`Source: ${url}`);
|
|
36
|
+
lines.push(`Extracted by designlang v7.0.0 on ${iso}`);
|
|
37
|
+
lines.push('');
|
|
38
|
+
lines.push('## Semantic tokens (use these)');
|
|
39
|
+
lines.push(`- color.action.primary: ${actionPrimary}`);
|
|
40
|
+
lines.push(`- color.surface.default: ${surfaceDefault}`);
|
|
41
|
+
lines.push(`- color.text.body: ${textBody}`);
|
|
42
|
+
lines.push(`- radius.control: ${radiusControl}`);
|
|
43
|
+
lines.push(`- typography.body.fontFamily: ${fontFamily}`);
|
|
44
|
+
|
|
45
|
+
const regions = Array.isArray(design?.regions) ? design.regions : [];
|
|
46
|
+
if (regions.length) {
|
|
47
|
+
lines.push('');
|
|
48
|
+
lines.push('## Regions');
|
|
49
|
+
const names = regions.map(r => r.role || r.name).filter(Boolean);
|
|
50
|
+
for (const n of names) lines.push(`- ${n}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
lines.push('');
|
|
54
|
+
lines.push('## How to use');
|
|
55
|
+
lines.push('- Prefer `semantic.*` tokens over `primitive.*`.');
|
|
56
|
+
lines.push('- Never invent new tokens or hex values; reuse the ones above.');
|
|
57
|
+
lines.push('- When a value is missing, pick the closest existing semantic token and flag the gap.');
|
|
58
|
+
lines.push('- Reference tokens by their dotted path (e.g. `semantic.color.action.primary`).');
|
|
59
|
+
|
|
60
|
+
return { body: lines.join('\n'), actionPrimary };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function cursorFile({ url, body }) {
|
|
64
|
+
const front = [
|
|
65
|
+
'---',
|
|
66
|
+
`description: Design system extracted from ${url} — use these tokens, do not invent new ones.`,
|
|
67
|
+
'globs: **/*.{ts,tsx,jsx,js,css,scss,html,vue,svelte,swift,kt,dart,php}',
|
|
68
|
+
'alwaysApply: true',
|
|
69
|
+
'---',
|
|
70
|
+
'',
|
|
71
|
+
'# Design system reference',
|
|
72
|
+
].join('\n');
|
|
73
|
+
return `${front}\n${body}\n`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function claudeSkillFile({ url, body }) {
|
|
77
|
+
const host = hostFromUrl(url);
|
|
78
|
+
const front = [
|
|
79
|
+
'---',
|
|
80
|
+
'name: designlang-tokens',
|
|
81
|
+
`description: Use when styling UI for ${host} — references the extracted design system tokens instead of inventing colors, spacing, or typography.`,
|
|
82
|
+
'---',
|
|
83
|
+
'',
|
|
84
|
+
'# designlang tokens',
|
|
85
|
+
].join('\n');
|
|
86
|
+
return `${front}\n${body}\n`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function claudeFragmentFile({ url, body }) {
|
|
90
|
+
// Plain H2 section ready to append to a project's CLAUDE.md (no frontmatter).
|
|
91
|
+
return `## Design system (via designlang)\n\n${body}\n`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function agentsMdFile({ url, body }) {
|
|
95
|
+
const head = [
|
|
96
|
+
'# Agent instructions — design system',
|
|
97
|
+
'',
|
|
98
|
+
`This project follows the design system extracted from ${url}.`,
|
|
99
|
+
'Any coding agent working here must use the tokens below and avoid inventing new ones.',
|
|
100
|
+
'',
|
|
101
|
+
].join('\n');
|
|
102
|
+
return `${head}${body}\n`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function formatAgentRules({ design, tokens, url }) {
|
|
106
|
+
const resolvedUrl = url || tokens?.$metadata?.source || design?.meta?.url || 'unknown';
|
|
107
|
+
const iso = tokens?.$metadata?.generatedAt || new Date().toISOString();
|
|
108
|
+
const { body } = buildBody({ url: resolvedUrl, tokens, design: design || {}, iso });
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
'.cursor/rules/designlang.mdc': cursorFile({ url: resolvedUrl, body }),
|
|
112
|
+
'.claude/skills/designlang/SKILL.md': claudeSkillFile({ url: resolvedUrl, body }),
|
|
113
|
+
'CLAUDE.md.fragment': claudeFragmentFile({ url: resolvedUrl, body }),
|
|
114
|
+
'agents.md': agentsMdFile({ url: resolvedUrl, body }),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Android Compose emitter — consumes a DTCG token object and produces three
|
|
2
|
+
// files: Theme.kt (Kotlin Compose), colors.xml, and dimens.xml.
|
|
3
|
+
|
|
4
|
+
import { resolveRef } from './_token-ref.js';
|
|
5
|
+
|
|
6
|
+
const HEADER_VERSION = '7.0.0';
|
|
7
|
+
|
|
8
|
+
function* walkLeaves(node, prefix) {
|
|
9
|
+
if (node == null || typeof node !== 'object') return;
|
|
10
|
+
if ('$value' in node && '$type' in node) {
|
|
11
|
+
yield { path: prefix, token: node };
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
for (const key of Object.keys(node)) {
|
|
15
|
+
yield* walkLeaves(node[key], prefix ? `${prefix}.${key}` : key);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// "semantic.color.action.primary" → "action.primary" → "ActionPrimary"
|
|
20
|
+
function pascalFromPath(path) {
|
|
21
|
+
const parts = path.split('.');
|
|
22
|
+
const trimmed = parts.slice(1); // drop root (primitive/semantic)
|
|
23
|
+
let segs;
|
|
24
|
+
if (trimmed[0] === 'color' && trimmed.length >= 3) {
|
|
25
|
+
segs = trimmed.slice(1);
|
|
26
|
+
} else {
|
|
27
|
+
segs = trimmed;
|
|
28
|
+
}
|
|
29
|
+
return segs
|
|
30
|
+
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
|
31
|
+
.join('')
|
|
32
|
+
.replace(/[^a-zA-Z0-9]/g, '');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// "semantic.color.action.primary" → "action_primary"
|
|
36
|
+
function snakeFromPath(path) {
|
|
37
|
+
const parts = path.split('.');
|
|
38
|
+
const trimmed = parts.slice(1);
|
|
39
|
+
let segs;
|
|
40
|
+
if (trimmed[0] === 'color' && trimmed.length >= 3) {
|
|
41
|
+
segs = trimmed.slice(1);
|
|
42
|
+
} else {
|
|
43
|
+
segs = trimmed;
|
|
44
|
+
}
|
|
45
|
+
return segs
|
|
46
|
+
.map((s) => s.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase())
|
|
47
|
+
.join('_')
|
|
48
|
+
.replace(/[^a-z0-9_]/g, '');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Normalize "#rgb"/"#rrggbb"/"#aarrggbb" to "FFRRGGBB" (alpha-first, uppercase).
|
|
52
|
+
function hexToArgb(hex) {
|
|
53
|
+
if (typeof hex !== 'string') return null;
|
|
54
|
+
let h = hex.trim().replace(/^#/, '');
|
|
55
|
+
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
|
|
56
|
+
if (h.length === 6 && /^[0-9a-fA-F]{6}$/.test(h)) return `FF${h.toUpperCase()}`;
|
|
57
|
+
if (h.length === 8 && /^[0-9a-fA-F]{8}$/.test(h)) return h.toUpperCase();
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function dimensionToNumber(value) {
|
|
62
|
+
if (typeof value === 'number') return value;
|
|
63
|
+
if (typeof value !== 'string') return null;
|
|
64
|
+
const m = value.match(/^(-?\d+(?:\.\d+)?)(px|pt|dp|rem|em)?$/);
|
|
65
|
+
if (!m) return null;
|
|
66
|
+
const n = parseFloat(m[1]);
|
|
67
|
+
if (m[2] === 'rem' || m[2] === 'em') return n * 16;
|
|
68
|
+
return n;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function collectColors(tokens) {
|
|
72
|
+
const entries = [];
|
|
73
|
+
const sem = tokens?.semantic?.color;
|
|
74
|
+
if (sem) {
|
|
75
|
+
for (const leaf of walkLeaves(sem, 'semantic.color')) {
|
|
76
|
+
if (leaf.token.$type !== 'color') continue;
|
|
77
|
+
const resolved = resolveRef(tokens, leaf.path);
|
|
78
|
+
const argb = hexToArgb(resolved);
|
|
79
|
+
if (!argb) continue;
|
|
80
|
+
entries.push({ path: leaf.path, argb });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const prim = tokens?.primitive?.color;
|
|
84
|
+
if (prim) {
|
|
85
|
+
for (const leaf of walkLeaves(prim, 'primitive.color')) {
|
|
86
|
+
if (leaf.token.$type !== 'color') continue;
|
|
87
|
+
const resolved = resolveRef(tokens, leaf.path);
|
|
88
|
+
const argb = hexToArgb(resolved);
|
|
89
|
+
if (!argb) continue;
|
|
90
|
+
entries.push({ path: leaf.path, argb });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return entries;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function collectDimensions(tokens) {
|
|
97
|
+
const entries = [];
|
|
98
|
+
const spacing = tokens?.primitive?.spacing || {};
|
|
99
|
+
for (const key of Object.keys(spacing)) {
|
|
100
|
+
const tok = spacing[key];
|
|
101
|
+
if (!tok || tok.$type !== 'dimension') continue;
|
|
102
|
+
const n = dimensionToNumber(resolveRef(tokens, `primitive.spacing.${key}`));
|
|
103
|
+
if (n == null) continue;
|
|
104
|
+
entries.push({ path: `primitive.spacing.${key}`, value: n });
|
|
105
|
+
}
|
|
106
|
+
const radius = tokens?.primitive?.radius || {};
|
|
107
|
+
for (const key of Object.keys(radius)) {
|
|
108
|
+
const tok = radius[key];
|
|
109
|
+
if (!tok || tok.$type !== 'dimension') continue;
|
|
110
|
+
const n = dimensionToNumber(resolveRef(tokens, `primitive.radius.${key}`));
|
|
111
|
+
if (n == null) continue;
|
|
112
|
+
entries.push({ path: `primitive.radius.${key}`, value: n });
|
|
113
|
+
}
|
|
114
|
+
return entries;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function buildThemeKt(tokens, colors, dims) {
|
|
118
|
+
const source = tokens?.$metadata?.source || '';
|
|
119
|
+
const lines = [];
|
|
120
|
+
lines.push(`// Generated by designlang v${HEADER_VERSION}`);
|
|
121
|
+
if (source) lines.push(`// Source: ${source}`);
|
|
122
|
+
lines.push('package com.designlang.tokens');
|
|
123
|
+
lines.push('');
|
|
124
|
+
lines.push('import androidx.compose.ui.graphics.Color');
|
|
125
|
+
lines.push('import androidx.compose.ui.unit.dp');
|
|
126
|
+
lines.push('');
|
|
127
|
+
lines.push('object DesignTokens {');
|
|
128
|
+
for (const { path, argb } of colors) {
|
|
129
|
+
lines.push(` val ${pascalFromPath(path)} = Color(0x${argb})`);
|
|
130
|
+
}
|
|
131
|
+
for (const { path, value } of dims) {
|
|
132
|
+
lines.push(` val ${pascalFromPath(path)} = ${value}.dp`);
|
|
133
|
+
}
|
|
134
|
+
lines.push('}');
|
|
135
|
+
return lines.join('\n') + '\n';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function buildColorsXml(colors) {
|
|
139
|
+
const lines = ['<?xml version="1.0" encoding="utf-8"?>', '<resources>'];
|
|
140
|
+
for (const { path, argb } of colors) {
|
|
141
|
+
lines.push(` <color name="${snakeFromPath(path)}">#${argb}</color>`);
|
|
142
|
+
}
|
|
143
|
+
lines.push('</resources>');
|
|
144
|
+
return lines.join('\n') + '\n';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function buildDimensXml(dims) {
|
|
148
|
+
const lines = ['<?xml version="1.0" encoding="utf-8"?>', '<resources>'];
|
|
149
|
+
for (const { path, value } of dims) {
|
|
150
|
+
lines.push(` <dimen name="${snakeFromPath(path)}">${value}dp</dimen>`);
|
|
151
|
+
}
|
|
152
|
+
lines.push('</resources>');
|
|
153
|
+
return lines.join('\n') + '\n';
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function formatAndroidCompose(tokens) {
|
|
157
|
+
const colors = collectColors(tokens);
|
|
158
|
+
const dims = collectDimensions(tokens);
|
|
159
|
+
return {
|
|
160
|
+
'Theme.kt': buildThemeKt(tokens, colors, dims),
|
|
161
|
+
'colors.xml': buildColorsXml(colors),
|
|
162
|
+
'dimens.xml': buildDimensXml(dims),
|
|
163
|
+
};
|
|
164
|
+
}
|