designlang 8.0.0 → 10.0.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/.claude/launch.json +11 -0
- package/CHANGELOG.md +76 -0
- package/README.md +151 -13
- package/bin/design-extract.js +180 -1
- package/package.json +9 -3
- package/src/classifiers/smart.js +130 -0
- package/src/crawler.js +20 -0
- package/src/drift.js +137 -0
- package/src/extractors/component-anatomy.js +123 -0
- package/src/extractors/component-library.js +193 -0
- package/src/extractors/imagery-style.js +131 -0
- package/src/extractors/logo.js +142 -0
- package/src/extractors/material-language.js +152 -0
- package/src/extractors/motion.js +184 -0
- package/src/extractors/page-intent.js +172 -0
- package/src/extractors/section-roles.js +135 -0
- package/src/extractors/voice.js +96 -0
- package/src/formatters/markdown.js +179 -0
- package/src/formatters/motion-tokens.js +22 -0
- package/src/formatters/prompt-pack.js +214 -0
- package/src/index.js +40 -0
- package/src/lint.js +198 -0
- package/src/multipage.js +233 -0
- package/src/visual-diff.js +116 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Optional LLM fallback for low-confidence classifications. No SDK deps — we
|
|
2
|
+
// hit the OpenAI or Anthropic REST API directly via global fetch. Runs only
|
|
3
|
+
// when the user passes --smart AND an API key is available in env. Silently
|
|
4
|
+
// no-ops otherwise so the core extractor stays zero-config.
|
|
5
|
+
//
|
|
6
|
+
// Consumers call `refineWithSmart({ pageIntent, sectionRoles, materialLanguage,
|
|
7
|
+
// componentLibrary }, digest)` — we only hit the network for fields where
|
|
8
|
+
// `needsSmart` is true.
|
|
9
|
+
|
|
10
|
+
const TASKS = {
|
|
11
|
+
pageIntent: {
|
|
12
|
+
system: 'You classify a web page into one of these types: landing, pricing, docs, blog, blog-post, product, about, dashboard, auth, legal, unknown. Return only a JSON object {"type":"...","confidence":0.xx,"why":"one sentence"}.',
|
|
13
|
+
},
|
|
14
|
+
materialLanguage: {
|
|
15
|
+
system: 'You classify a website\'s visual material language. Choose one of: glassmorphism, neumorphism, flat, brutalist, skeuomorphic, material-you, soft-ui, mixed. Return only a JSON object {"label":"...","confidence":0.xx,"why":"one sentence"}.',
|
|
16
|
+
},
|
|
17
|
+
componentLibrary: {
|
|
18
|
+
system: 'You identify which UI component library a website most likely uses. Choose from: shadcn/ui, radix-ui, headlessui, mui, chakra-ui, mantine, ant-design, bootstrap, heroui, tailwind-ui, vuetify, tailwindcss, unknown. Return only a JSON object {"library":"...","confidence":0.xx,"why":"one sentence"}.',
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function detectProvider() {
|
|
23
|
+
if (process.env.ANTHROPIC_API_KEY) return 'anthropic';
|
|
24
|
+
if (process.env.OPENAI_API_KEY) return 'openai';
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function buildDigest({ rawData, design, pageIntent }) {
|
|
29
|
+
// A compact digest — URL, title, meta description, first 1000 chars of
|
|
30
|
+
// visible text, top classes. Keep under ~3k tokens.
|
|
31
|
+
const sections = (rawData?.light?.sections) || [];
|
|
32
|
+
const text = sections.map(s => s.text || '').join('\n').slice(0, 1500);
|
|
33
|
+
const metas = (rawData?.light?.stack?.metas || []).slice(0, 10)
|
|
34
|
+
.map(m => `${m.name || ''}: ${(m.content || '').slice(0, 120)}`).join('\n');
|
|
35
|
+
const classes = (rawData?.light?.stack?.classNameSample || []).slice(0, 60).join(' | ').slice(0, 1500);
|
|
36
|
+
return [
|
|
37
|
+
`URL: ${rawData?.url || ''}`,
|
|
38
|
+
`TITLE: ${rawData?.title || ''}`,
|
|
39
|
+
`PATH: ${pageIntent?.path || ''}`,
|
|
40
|
+
`METAS:\n${metas}`,
|
|
41
|
+
`SECTION ROLES: ${(design?.regions || []).map(r => r.role).join(',')}`,
|
|
42
|
+
`TEXT SAMPLE:\n${text}`,
|
|
43
|
+
`CLASS SAMPLE:\n${classes}`,
|
|
44
|
+
].join('\n\n');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function callAnthropic(system, user) {
|
|
48
|
+
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: {
|
|
51
|
+
'x-api-key': process.env.ANTHROPIC_API_KEY,
|
|
52
|
+
'anthropic-version': '2023-06-01',
|
|
53
|
+
'content-type': 'application/json',
|
|
54
|
+
},
|
|
55
|
+
body: JSON.stringify({
|
|
56
|
+
model: process.env.DESIGNLANG_MODEL || 'claude-haiku-4-5-20251001',
|
|
57
|
+
max_tokens: 200,
|
|
58
|
+
system,
|
|
59
|
+
messages: [{ role: 'user', content: user }],
|
|
60
|
+
}),
|
|
61
|
+
});
|
|
62
|
+
if (!res.ok) throw new Error(`anthropic ${res.status}`);
|
|
63
|
+
const json = await res.json();
|
|
64
|
+
const text = (json.content || []).map(b => b.text || '').join('');
|
|
65
|
+
return text;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function callOpenAI(system, user) {
|
|
69
|
+
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: {
|
|
72
|
+
'authorization': `Bearer ${process.env.OPENAI_API_KEY}`,
|
|
73
|
+
'content-type': 'application/json',
|
|
74
|
+
},
|
|
75
|
+
body: JSON.stringify({
|
|
76
|
+
model: process.env.DESIGNLANG_MODEL || 'gpt-4o-mini',
|
|
77
|
+
messages: [
|
|
78
|
+
{ role: 'system', content: system },
|
|
79
|
+
{ role: 'user', content: user },
|
|
80
|
+
],
|
|
81
|
+
max_tokens: 200,
|
|
82
|
+
response_format: { type: 'json_object' },
|
|
83
|
+
}),
|
|
84
|
+
});
|
|
85
|
+
if (!res.ok) throw new Error(`openai ${res.status}`);
|
|
86
|
+
const json = await res.json();
|
|
87
|
+
return json.choices?.[0]?.message?.content || '';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function callLLM(provider, system, user) {
|
|
91
|
+
if (provider === 'anthropic') return callAnthropic(system, user);
|
|
92
|
+
return callOpenAI(system, user);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseJsonLoose(text) {
|
|
96
|
+
try { return JSON.parse(text); } catch { /* try to find a JSON object */ }
|
|
97
|
+
const m = text.match(/\{[\s\S]*\}/);
|
|
98
|
+
if (m) { try { return JSON.parse(m[0]); } catch {} }
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function refineWithSmart({ enabled, rawData, design, pageIntent, sectionRoles, materialLanguage, componentLibrary }) {
|
|
103
|
+
if (!enabled) return { applied: false, reason: 'disabled' };
|
|
104
|
+
const provider = detectProvider();
|
|
105
|
+
if (!provider) return { applied: false, reason: 'no API key (set OPENAI_API_KEY or ANTHROPIC_API_KEY)' };
|
|
106
|
+
|
|
107
|
+
const digest = buildDigest({ rawData, design, pageIntent });
|
|
108
|
+
const updates = {};
|
|
109
|
+
const errors = [];
|
|
110
|
+
|
|
111
|
+
const queue = [];
|
|
112
|
+
if (pageIntent?.needsSmart) queue.push(['pageIntent', pageIntent]);
|
|
113
|
+
if (materialLanguage && (materialLanguage.confidence || 0) < 0.55) queue.push(['materialLanguage', materialLanguage]);
|
|
114
|
+
if (componentLibrary?.needsSmart) queue.push(['componentLibrary', componentLibrary]);
|
|
115
|
+
|
|
116
|
+
for (const [task, current] of queue) {
|
|
117
|
+
const spec = TASKS[task];
|
|
118
|
+
if (!spec) continue;
|
|
119
|
+
const user = `Digest:\n${digest}\n\nCurrent heuristic result:\n${JSON.stringify(current)}\n\nRespond with the requested JSON.`;
|
|
120
|
+
try {
|
|
121
|
+
const raw = await callLLM(provider, spec.system, user);
|
|
122
|
+
const parsed = parseJsonLoose(raw);
|
|
123
|
+
if (parsed) updates[task] = { ...parsed, smart: true, provider };
|
|
124
|
+
} catch (e) {
|
|
125
|
+
errors.push(`${task}: ${e.message}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { applied: true, provider, updates, errors };
|
|
130
|
+
}
|
package/src/crawler.js
CHANGED
|
@@ -559,6 +559,11 @@ async function extractPageData(page, ignoreSelectors, scopeSelector) {
|
|
|
559
559
|
zIndex: cs.zIndex,
|
|
560
560
|
transition: cs.transition,
|
|
561
561
|
animation: cs.animation,
|
|
562
|
+
animationTimeline: cs.animationTimeline || cs.getPropertyValue('animation-timeline') || '',
|
|
563
|
+
animationRangeStart: cs.getPropertyValue('animation-range-start') || '',
|
|
564
|
+
animationRangeEnd: cs.getPropertyValue('animation-range-end') || '',
|
|
565
|
+
viewTimelineName: cs.getPropertyValue('view-timeline-name') || '',
|
|
566
|
+
scrollTimelineName: cs.getPropertyValue('scroll-timeline-name') || '',
|
|
562
567
|
display: cs.display,
|
|
563
568
|
position: cs.position,
|
|
564
569
|
flexDirection: cs.flexDirection,
|
|
@@ -757,10 +762,25 @@ async function extractPageData(page, ignoreSelectors, scopeSelector) {
|
|
|
757
762
|
parseFloat(cs.fontSize) || 0,
|
|
758
763
|
parseFloat(cs.fontWeight) || 0,
|
|
759
764
|
];
|
|
765
|
+
const text = ((el.innerText || el.textContent || '') + '').trim().slice(0, 160);
|
|
766
|
+
const slots = Array.from(el.children).slice(0, 8).map(c => {
|
|
767
|
+
const tagName = c.tagName.toLowerCase();
|
|
768
|
+
let role = 'content';
|
|
769
|
+
if (tagName === 'svg' || tagName === 'img' || c.querySelector?.('svg,img')) role = 'icon';
|
|
770
|
+
else if (/badge|pill|tag|chip/i.test(c.className || '')) role = 'badge';
|
|
771
|
+
else if (/h[1-6]/.test(tagName) || /title|heading/i.test(c.className || '')) role = 'heading';
|
|
772
|
+
else if (/description|subtitle|text|body/i.test(c.className || '')) role = 'text';
|
|
773
|
+
return { tag: tagName, role, text: ((c.innerText || c.textContent || '') + '').trim().slice(0, 80) };
|
|
774
|
+
});
|
|
760
775
|
results.componentCandidates.push({
|
|
761
776
|
kind,
|
|
762
777
|
structuralHash: structuralHashOf(el),
|
|
763
778
|
styleVector,
|
|
779
|
+
text,
|
|
780
|
+
slots,
|
|
781
|
+
disabled: el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true',
|
|
782
|
+
variantHint: (cls.match(/\b(primary|secondary|tertiary|ghost|outline|solid|destructive|danger|success|warning|link|subtle)\b/) || [])[1] || '',
|
|
783
|
+
sizeHint: (cls.match(/\b(xs|sm|md|lg|xl|small|medium|large)\b/) || [])[1] || '',
|
|
764
784
|
css: {
|
|
765
785
|
background: cs.backgroundColor,
|
|
766
786
|
color: cs.color,
|
package/src/drift.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// designlang drift <url> --tokens <file>
|
|
2
|
+
// Compares local project tokens against the live site and reports what's drifted.
|
|
3
|
+
// Designed for CI/CD: exits non-zero when drift exceeds the tolerance budget.
|
|
4
|
+
|
|
5
|
+
import { readFileSync } from 'fs';
|
|
6
|
+
import { extname } from 'path';
|
|
7
|
+
import { extractDesignLanguage } from './index.js';
|
|
8
|
+
|
|
9
|
+
function flattenDtcg(obj, prefix = '', out = {}) {
|
|
10
|
+
for (const [k, v] of Object.entries(obj || {})) {
|
|
11
|
+
if (k.startsWith('$')) continue;
|
|
12
|
+
if (v && typeof v === 'object') {
|
|
13
|
+
if ('$value' in v) {
|
|
14
|
+
out[prefix ? `${prefix}.${k}` : k] = v.$value;
|
|
15
|
+
} else {
|
|
16
|
+
flattenDtcg(v, prefix ? `${prefix}.${k}` : k, out);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function loadLocalTokens(file) {
|
|
24
|
+
const raw = readFileSync(file, 'utf8');
|
|
25
|
+
const ext = extname(file);
|
|
26
|
+
if (ext === '.json') {
|
|
27
|
+
const j = JSON.parse(raw);
|
|
28
|
+
const flat = flattenDtcg(j);
|
|
29
|
+
if (Object.keys(flat).length) return flat;
|
|
30
|
+
const out = {};
|
|
31
|
+
for (const [group, entries] of Object.entries(j)) {
|
|
32
|
+
if (!entries || typeof entries !== 'object') continue;
|
|
33
|
+
for (const [k, v] of Object.entries(entries)) out[`${group}.${k}`] = String(v);
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
if (ext === '.css') {
|
|
38
|
+
const out = {};
|
|
39
|
+
for (const m of raw.matchAll(/--([\w-]+):\s*([^;]+);/g)) out[m[1]] = m[2].trim();
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
throw new Error(`Unsupported token file: ${ext}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function hexToRgb(h) {
|
|
46
|
+
const m = h.replace('#', '').match(/^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
|
|
47
|
+
return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function colorDistance(a, b) {
|
|
51
|
+
const ra = hexToRgb(a), rb = hexToRgb(b);
|
|
52
|
+
if (!ra || !rb) return Infinity;
|
|
53
|
+
return Math.sqrt(ra.reduce((s, v, i) => s + (v - rb[i]) ** 2, 0));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function findNearest(value, palette) {
|
|
57
|
+
if (!/^#[\da-f]{6}$/i.test(value)) return null;
|
|
58
|
+
let best = { distance: Infinity, token: null };
|
|
59
|
+
for (const p of palette) {
|
|
60
|
+
if (!/^#[\da-f]{6}$/i.test(p.hex || '')) continue;
|
|
61
|
+
const d = colorDistance(value, p.hex);
|
|
62
|
+
if (d < best.distance) best = { distance: d, token: p };
|
|
63
|
+
}
|
|
64
|
+
return best;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function checkDrift(url, { tokens: tokensFile, tolerance = 8, options = {} } = {}) {
|
|
68
|
+
const local = loadLocalTokens(tokensFile);
|
|
69
|
+
const design = await extractDesignLanguage(url, options);
|
|
70
|
+
const livePalette = design.colors?.all || [];
|
|
71
|
+
|
|
72
|
+
const drifted = [];
|
|
73
|
+
const matched = [];
|
|
74
|
+
const unknown = [];
|
|
75
|
+
|
|
76
|
+
for (const [name, value] of Object.entries(local)) {
|
|
77
|
+
if (!/^#[\da-f]{3,8}$/i.test(String(value))) continue; // only color tokens for now
|
|
78
|
+
const hex = value.length === 4 ? '#' + value.slice(1).split('').map(c => c + c).join('') : value;
|
|
79
|
+
const nearest = findNearest(hex, livePalette);
|
|
80
|
+
if (!nearest || nearest.distance === Infinity) { unknown.push({ name, value: hex }); continue; }
|
|
81
|
+
if (nearest.distance > tolerance) {
|
|
82
|
+
drifted.push({
|
|
83
|
+
token: name,
|
|
84
|
+
local: hex,
|
|
85
|
+
nearestLive: nearest.token.hex,
|
|
86
|
+
distance: Math.round(nearest.distance),
|
|
87
|
+
role: nearest.token.role || 'unknown',
|
|
88
|
+
});
|
|
89
|
+
} else {
|
|
90
|
+
matched.push({ token: name, local: hex, liveMatch: nearest.token.hex, distance: Math.round(nearest.distance) });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const driftRatio = drifted.length / Math.max(1, drifted.length + matched.length);
|
|
95
|
+
const verdict = driftRatio === 0 ? 'in-sync' : driftRatio < 0.15 ? 'minor-drift' : driftRatio < 0.4 ? 'notable-drift' : 'major-drift';
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
url,
|
|
99
|
+
tokensFile,
|
|
100
|
+
tolerance,
|
|
101
|
+
verdict,
|
|
102
|
+
driftRatio: +driftRatio.toFixed(3),
|
|
103
|
+
drifted,
|
|
104
|
+
matched,
|
|
105
|
+
unknown,
|
|
106
|
+
summary: {
|
|
107
|
+
total: drifted.length + matched.length,
|
|
108
|
+
drifted: drifted.length,
|
|
109
|
+
matched: matched.length,
|
|
110
|
+
unknown: unknown.length,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function formatDriftMarkdown(r) {
|
|
116
|
+
const lines = [
|
|
117
|
+
`# designlang drift report`,
|
|
118
|
+
``,
|
|
119
|
+
`**Live site:** ${r.url}`,
|
|
120
|
+
`**Local tokens:** ${r.tokensFile}`,
|
|
121
|
+
`**Verdict:** ${r.verdict} (drift ratio: ${r.driftRatio})`,
|
|
122
|
+
``,
|
|
123
|
+
`| metric | count |`,
|
|
124
|
+
`|---|---|`,
|
|
125
|
+
`| total color tokens | ${r.summary.total} |`,
|
|
126
|
+
`| matched | ${r.summary.matched} |`,
|
|
127
|
+
`| drifted | ${r.summary.drifted} |`,
|
|
128
|
+
`| unknown | ${r.summary.unknown} |`,
|
|
129
|
+
``,
|
|
130
|
+
];
|
|
131
|
+
if (r.drifted.length) {
|
|
132
|
+
lines.push(`## Drifted tokens`, ``, `| token | local | nearest live | Δ |`, `|---|---|---|---|`);
|
|
133
|
+
for (const d of r.drifted) lines.push(`| \`${d.token}\` | \`${d.local}\` | \`${d.nearestLive}\` (${d.role}) | ${d.distance} |`);
|
|
134
|
+
lines.push('');
|
|
135
|
+
}
|
|
136
|
+
return lines.join('\n');
|
|
137
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Component Anatomy v2 — builds per-kind anatomy trees, variant × state matrices,
|
|
2
|
+
// and typed prop surfaces that downstream generators can turn into stubs.
|
|
3
|
+
|
|
4
|
+
const KNOWN_VARIANTS = ['primary', 'secondary', 'tertiary', 'ghost', 'outline', 'solid', 'destructive', 'danger', 'success', 'warning', 'link', 'subtle'];
|
|
5
|
+
const KNOWN_SIZES = ['xs', 'sm', 'md', 'lg', 'xl', 'small', 'medium', 'large'];
|
|
6
|
+
|
|
7
|
+
function slotFingerprint(slots = []) {
|
|
8
|
+
return slots.map(s => s.role).join('>');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function inferSlots(slots = [], kind) {
|
|
12
|
+
const roles = new Set(slots.map(s => s.role));
|
|
13
|
+
if (kind === 'button') {
|
|
14
|
+
return {
|
|
15
|
+
label: true,
|
|
16
|
+
icon: roles.has('icon'),
|
|
17
|
+
badge: roles.has('badge'),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
if (kind === 'card') {
|
|
21
|
+
return {
|
|
22
|
+
heading: roles.has('heading'),
|
|
23
|
+
description: roles.has('text'),
|
|
24
|
+
media: roles.has('icon'),
|
|
25
|
+
footer: slots.length > 3,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
if (kind === 'input') {
|
|
29
|
+
return { leading: roles.has('icon'), trailing: false };
|
|
30
|
+
}
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function dominant(arr) {
|
|
35
|
+
const counts = {};
|
|
36
|
+
for (const v of arr) counts[v] = (counts[v] || 0) + 1;
|
|
37
|
+
return Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0] || '';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function extractComponentAnatomy(candidates = []) {
|
|
41
|
+
const byKind = {};
|
|
42
|
+
for (const c of candidates) {
|
|
43
|
+
const k = c.kind || 'other';
|
|
44
|
+
(byKind[k] ||= []).push(c);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const anatomies = [];
|
|
48
|
+
for (const [kind, items] of Object.entries(byKind)) {
|
|
49
|
+
if (items.length < 2) continue;
|
|
50
|
+
|
|
51
|
+
// Group variants by explicit class hint, fall back to style vector.
|
|
52
|
+
const variantGroups = {};
|
|
53
|
+
for (const it of items) {
|
|
54
|
+
const v = it.variantHint || 'default';
|
|
55
|
+
(variantGroups[v] ||= []).push(it);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const variants = Object.entries(variantGroups).map(([name, vs]) => {
|
|
59
|
+
const sizes = {};
|
|
60
|
+
for (const it of vs) {
|
|
61
|
+
const sz = it.sizeHint || 'default';
|
|
62
|
+
(sizes[sz] ||= []).push(it);
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
name,
|
|
66
|
+
count: vs.length,
|
|
67
|
+
states: {
|
|
68
|
+
default: { count: vs.filter(v => !v.disabled).length, css: vs.find(v => !v.disabled)?.css || null },
|
|
69
|
+
disabled: { count: vs.filter(v => v.disabled).length, css: vs.find(v => v.disabled)?.css || null },
|
|
70
|
+
},
|
|
71
|
+
sizes: Object.entries(sizes).map(([sName, sItems]) => ({ name: sName, count: sItems.length, css: sItems[0].css })),
|
|
72
|
+
sampleText: vs.slice(0, 5).map(v => v.text).filter(Boolean),
|
|
73
|
+
};
|
|
74
|
+
}).sort((a, b) => b.count - a.count);
|
|
75
|
+
|
|
76
|
+
const slotSignatures = items.map(i => slotFingerprint(i.slots));
|
|
77
|
+
const anatomy = {
|
|
78
|
+
kind,
|
|
79
|
+
totalInstances: items.length,
|
|
80
|
+
slots: inferSlots(items[0].slots, kind),
|
|
81
|
+
dominantSlotShape: dominant(slotSignatures),
|
|
82
|
+
variants,
|
|
83
|
+
props: {
|
|
84
|
+
variant: Object.keys(variantGroups).filter(v => v !== 'default'),
|
|
85
|
+
size: [...new Set(items.map(i => i.sizeHint).filter(Boolean))],
|
|
86
|
+
disabled: items.some(i => i.disabled),
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
anatomies.push(anatomy);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return anatomies.sort((a, b) => b.totalInstances - a.totalInstances);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Emit TypeScript-flavored React stub for the anatomy — includes variant/size props + slot children.
|
|
96
|
+
export function formatAnatomyStubs(anatomies = []) {
|
|
97
|
+
const lines = [
|
|
98
|
+
"// Auto-generated by designlang — component anatomy v2.",
|
|
99
|
+
"// Scaffolds. Wire into your token system; not a runtime library.",
|
|
100
|
+
"",
|
|
101
|
+
"import * as React from 'react';",
|
|
102
|
+
"",
|
|
103
|
+
];
|
|
104
|
+
for (const a of anatomies) {
|
|
105
|
+
const Name = a.kind.charAt(0).toUpperCase() + a.kind.slice(1);
|
|
106
|
+
const variantUnion = (a.props.variant.length ? a.props.variant : ['default']).map(v => `'${v}'`).join(' | ');
|
|
107
|
+
const sizeUnion = (a.props.size.length ? a.props.size : ['md']).map(v => `'${v}'`).join(' | ');
|
|
108
|
+
lines.push(`export interface ${Name}Props {`);
|
|
109
|
+
lines.push(` variant?: ${variantUnion};`);
|
|
110
|
+
lines.push(` size?: ${sizeUnion};`);
|
|
111
|
+
if (a.props.disabled) lines.push(` disabled?: boolean;`);
|
|
112
|
+
if (a.slots.icon) lines.push(` leadingIcon?: React.ReactNode;`);
|
|
113
|
+
if (a.slots.badge) lines.push(` badge?: React.ReactNode;`);
|
|
114
|
+
lines.push(` children?: React.ReactNode;`);
|
|
115
|
+
lines.push(`}`);
|
|
116
|
+
lines.push(``);
|
|
117
|
+
lines.push(`export function ${Name}({ variant = '${a.props.variant[0] || 'default'}', size = 'md', ...rest }: ${Name}Props) {`);
|
|
118
|
+
lines.push(` return React.createElement('${a.kind === 'input' ? 'input' : a.kind === 'link' ? 'a' : a.kind === 'card' ? 'div' : 'button'}', { 'data-variant': variant, 'data-size': size, ...rest });`);
|
|
119
|
+
lines.push(`}`);
|
|
120
|
+
lines.push(``);
|
|
121
|
+
}
|
|
122
|
+
return lines.join('\n');
|
|
123
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Detect the component library a site is built on. Fingerprints: class-name
|
|
2
|
+
// patterns, data-* attributes, script URLs, window globals. Returns the most
|
|
3
|
+
// likely library with a confidence score and the evidence that supported it —
|
|
4
|
+
// LLM agents consume this to pick the right scaffolding (e.g. "use shadcn/ui",
|
|
5
|
+
// "use MUI v5") when rebuilding.
|
|
6
|
+
|
|
7
|
+
const LIB_FINGERPRINTS = [
|
|
8
|
+
{
|
|
9
|
+
id: 'shadcn/ui',
|
|
10
|
+
score: (ctx) => {
|
|
11
|
+
let s = 0; const evidence = [];
|
|
12
|
+
// shadcn copies Radix attributes and uses Tailwind utility density.
|
|
13
|
+
if (ctx.radixAttrCount > 2 && ctx.tailwindLike > 0.4) { s += 0.5; evidence.push('radix+tailwind mix'); }
|
|
14
|
+
if (/\bbutton-primary|\bbutton-destructive/.test(ctx.classBlob) && ctx.radixAttrCount > 0) { s += 0.15; evidence.push('shadcn button tokens'); }
|
|
15
|
+
// `class="... bg-background text-foreground ..."` is a shadcn tell.
|
|
16
|
+
if (/\bbg-background\b|\btext-foreground\b|\bborder-input\b|\bring-offset-background\b/.test(ctx.classBlob)) {
|
|
17
|
+
s += 0.65; evidence.push('shadcn css tokens');
|
|
18
|
+
}
|
|
19
|
+
return { score: s, evidence };
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: 'radix-ui',
|
|
24
|
+
score: (ctx) => {
|
|
25
|
+
const count = ctx.radixAttrCount;
|
|
26
|
+
if (count === 0) return { score: 0, evidence: [] };
|
|
27
|
+
const score = Math.min(0.9, 0.3 + count * 0.05);
|
|
28
|
+
return { score, evidence: [`${count} radix attributes`] };
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: 'headlessui',
|
|
33
|
+
score: (ctx) => {
|
|
34
|
+
const m = (ctx.classSample.join(' ').match(/headlessui-[a-z]+/gi) || []).length;
|
|
35
|
+
if (m < 2) return { score: 0, evidence: [] };
|
|
36
|
+
return { score: Math.min(0.9, 0.3 + m * 0.08), evidence: [`${m} headlessui- class refs`] };
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: 'mui',
|
|
41
|
+
score: (ctx) => {
|
|
42
|
+
const m = (ctx.classBlob.match(/Mui[A-Z][A-Za-z]+-root/g) || []).length;
|
|
43
|
+
if (m < 2) return { score: 0, evidence: [] };
|
|
44
|
+
return { score: Math.min(0.95, 0.4 + m * 0.04), evidence: [`${m} Mui*-root classes`] };
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'chakra-ui',
|
|
49
|
+
score: (ctx) => {
|
|
50
|
+
const m = (ctx.classBlob.match(/\bchakra-[a-z]+/g) || []).length;
|
|
51
|
+
if (m < 3) return { score: 0, evidence: [] };
|
|
52
|
+
return { score: Math.min(0.95, 0.4 + m * 0.03), evidence: [`${m} chakra- classes`] };
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: 'mantine',
|
|
57
|
+
score: (ctx) => {
|
|
58
|
+
const m = (ctx.classBlob.match(/mantine-[A-Za-z0-9]+/g) || []).length;
|
|
59
|
+
if (m < 2) return { score: 0, evidence: [] };
|
|
60
|
+
return { score: Math.min(0.95, 0.4 + m * 0.04), evidence: [`${m} mantine- classes`] };
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: 'ant-design',
|
|
65
|
+
score: (ctx) => {
|
|
66
|
+
const m = (ctx.classBlob.match(/\bant-[a-z]+(-[a-z]+)*/g) || []).length;
|
|
67
|
+
if (m < 3) return { score: 0, evidence: [] };
|
|
68
|
+
return { score: Math.min(0.95, 0.4 + m * 0.03), evidence: [`${m} ant- classes`] };
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'bootstrap',
|
|
73
|
+
score: (ctx) => {
|
|
74
|
+
const hits = ['container', 'row', 'col-md-', 'btn-primary', 'navbar-nav', 'card-body']
|
|
75
|
+
.filter(k => ctx.classBlob.includes(k)).length;
|
|
76
|
+
if (hits < 3) return { score: 0, evidence: [] };
|
|
77
|
+
return { score: Math.min(0.9, 0.3 + hits * 0.1), evidence: [`bootstrap utility hits: ${hits}`] };
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: 'heroui',
|
|
82
|
+
score: (ctx) => {
|
|
83
|
+
const m = (ctx.classBlob.match(/\bheroui-|\bnextui-/g) || []).length;
|
|
84
|
+
if (m < 2) return { score: 0, evidence: [] };
|
|
85
|
+
return { score: Math.min(0.95, 0.4 + m * 0.05), evidence: [`${m} heroui/nextui classes`] };
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: 'tailwind-ui',
|
|
90
|
+
score: (ctx) => {
|
|
91
|
+
// Tailwind UI is a starter/template, not a runtime — use density of
|
|
92
|
+
// Tailwind utilities + typical Tailwind UI patterns as a weak signal.
|
|
93
|
+
if (ctx.tailwindLike < 0.6) return { score: 0, evidence: [] };
|
|
94
|
+
const patterns = ['ring-offset-', 'focus:ring-', 'hover:bg-gray-', 'prose prose-'];
|
|
95
|
+
const hits = patterns.filter(p => ctx.classBlob.includes(p)).length;
|
|
96
|
+
if (hits < 2) return { score: 0, evidence: [] };
|
|
97
|
+
return { score: 0.3 + hits * 0.12, evidence: [`tailwind density=${ctx.tailwindLike.toFixed(2)}, pattern hits=${hits}`] };
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
id: 'vuetify',
|
|
102
|
+
score: (ctx) => {
|
|
103
|
+
const m = (ctx.classBlob.match(/\bv-[a-z]+(-[a-z]+)?/g) || []).length;
|
|
104
|
+
if (m < 5) return { score: 0, evidence: [] };
|
|
105
|
+
return { score: Math.min(0.9, 0.3 + m * 0.02), evidence: [`${m} v-* classes`] };
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: 'tailwindcss',
|
|
110
|
+
score: (ctx) => {
|
|
111
|
+
if (ctx.tailwindLike < 0.35) return { score: 0, evidence: [] };
|
|
112
|
+
// Tailwind itself isn't a component library but we report it as a signal
|
|
113
|
+
// when no higher-level library is detected.
|
|
114
|
+
return { score: 0.3 + (ctx.tailwindLike - 0.35) * 1.2, evidence: [`tailwind-like class density ${(ctx.tailwindLike * 100).toFixed(0)}%`] };
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
function computeTailwindLike(classSample) {
|
|
120
|
+
if (!classSample.length) return 0;
|
|
121
|
+
// A rough "looks like Tailwind" metric: fraction of class tokens that match
|
|
122
|
+
// common utility shapes (pt-4, bg-slate-100, text-2xl, flex, gap-x-2, etc.).
|
|
123
|
+
const utilRe = /^(?:sm:|md:|lg:|xl:|2xl:|hover:|focus:|dark:|group-hover:)*(?:p|m|px|py|pt|pb|pl|pr|mx|my|mt|mb|ml|mr|w|h|min-w|min-h|max-w|max-h|gap|space|text|font|leading|tracking|bg|border|rounded|shadow|ring|ringed|opacity|flex|grid|items|justify|content|self|place|overflow|z|inset|top|bottom|left|right|translate|rotate|scale|skew|transition|duration|ease|delay|animate)(?:-[a-z0-9/.:\[\]%-]+)?$/i;
|
|
124
|
+
let utils = 0, total = 0;
|
|
125
|
+
for (const cls of classSample) {
|
|
126
|
+
for (const tok of cls.split(/\s+/).slice(0, 30)) {
|
|
127
|
+
if (!tok) continue;
|
|
128
|
+
total++;
|
|
129
|
+
if (utilRe.test(tok)) utils++;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return total > 0 ? utils / total : 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function countRadixAttrs(classSample, attrSample = []) {
|
|
136
|
+
// Radix emits attributes like data-radix-popper-content-wrapper, data-state,
|
|
137
|
+
// data-orientation, data-slot. We don't have a full attr dump, but script src
|
|
138
|
+
// for @radix-ui is also a tell.
|
|
139
|
+
let n = 0;
|
|
140
|
+
for (const a of attrSample) {
|
|
141
|
+
if (/data-radix|data-slot|data-state|data-orientation/.test(a)) n++;
|
|
142
|
+
}
|
|
143
|
+
return n;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function extractComponentLibrary(stackSignals = {}) {
|
|
147
|
+
const classSample = (stackSignals.classNameSample || []).slice(0, 500);
|
|
148
|
+
const classBlob = classSample.join(' ');
|
|
149
|
+
const scripts = (stackSignals.scripts || []).join(' ');
|
|
150
|
+
const attrSample = stackSignals.attrSample || [];
|
|
151
|
+
|
|
152
|
+
const tailwindLike = computeTailwindLike(classSample);
|
|
153
|
+
let radixAttrCount = countRadixAttrs(classSample, attrSample);
|
|
154
|
+
if (/@radix-ui/.test(scripts)) radixAttrCount += 3;
|
|
155
|
+
|
|
156
|
+
const ctx = { classSample, classBlob, scripts, tailwindLike, radixAttrCount };
|
|
157
|
+
|
|
158
|
+
const ranked = [];
|
|
159
|
+
for (const lib of LIB_FINGERPRINTS) {
|
|
160
|
+
const { score, evidence } = lib.score(ctx);
|
|
161
|
+
if (score > 0) {
|
|
162
|
+
ranked.push({ id: lib.id, score: Number(score.toFixed(3)), evidence });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// Tailwind CSS is a styling layer, not a component library. If any
|
|
166
|
+
// higher-level library also scored, demote tailwindcss so it doesn't shadow
|
|
167
|
+
// the real answer.
|
|
168
|
+
const hasHigherLevel = ranked.some(r => r.id !== 'tailwindcss' && r.id !== 'tailwind-ui' && r.score > 0.35);
|
|
169
|
+
if (hasHigherLevel) {
|
|
170
|
+
for (const r of ranked) {
|
|
171
|
+
if (r.id === 'tailwindcss') r.score = Math.min(r.score, 0.3);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
ranked.sort((a, b) => b.score - a.score);
|
|
175
|
+
|
|
176
|
+
const primary = ranked[0] || { id: 'unknown', score: 0, evidence: [] };
|
|
177
|
+
// If shadcn and radix both score, prefer shadcn at the top and keep radix as
|
|
178
|
+
// the underlying primitive.
|
|
179
|
+
const alternates = ranked.slice(1, 5);
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
library: primary.id,
|
|
183
|
+
confidence: primary.score,
|
|
184
|
+
evidence: primary.evidence,
|
|
185
|
+
alternates,
|
|
186
|
+
signals: {
|
|
187
|
+
tailwindLike: Number(tailwindLike.toFixed(3)),
|
|
188
|
+
radixAttrCount,
|
|
189
|
+
classSampleSize: classSample.length,
|
|
190
|
+
},
|
|
191
|
+
needsSmart: primary.score < 0.55,
|
|
192
|
+
};
|
|
193
|
+
}
|