skillui 1.1.2 → 1.1.4
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/README.md +20 -15
- package/dist/cli.js +105073 -194
- package/package.json +15 -6
- package/dist/cli.d.ts +0 -3
- package/dist/extractors/components.d.ts +0 -11
- package/dist/extractors/components.js +0 -455
- package/dist/extractors/framework.d.ts +0 -4
- package/dist/extractors/framework.js +0 -126
- package/dist/extractors/tokens/computed.d.ts +0 -7
- package/dist/extractors/tokens/computed.js +0 -249
- package/dist/extractors/tokens/css.d.ts +0 -3
- package/dist/extractors/tokens/css.js +0 -510
- package/dist/extractors/tokens/http-css.d.ts +0 -14
- package/dist/extractors/tokens/http-css.js +0 -1689
- package/dist/extractors/tokens/tailwind.d.ts +0 -3
- package/dist/extractors/tokens/tailwind.js +0 -353
- package/dist/extractors/tokens/tokens-file.d.ts +0 -3
- package/dist/extractors/tokens/tokens-file.js +0 -229
- package/dist/extractors/ultra/animations.d.ts +0 -21
- package/dist/extractors/ultra/animations.js +0 -527
- package/dist/extractors/ultra/components-dom.d.ts +0 -13
- package/dist/extractors/ultra/components-dom.js +0 -149
- package/dist/extractors/ultra/interactions.d.ts +0 -14
- package/dist/extractors/ultra/interactions.js +0 -222
- package/dist/extractors/ultra/layout.d.ts +0 -14
- package/dist/extractors/ultra/layout.js +0 -123
- package/dist/extractors/ultra/pages.d.ts +0 -16
- package/dist/extractors/ultra/pages.js +0 -228
- package/dist/font-resolver.d.ts +0 -10
- package/dist/font-resolver.js +0 -280
- package/dist/modes/dir.d.ts +0 -6
- package/dist/modes/dir.js +0 -213
- package/dist/modes/repo.d.ts +0 -6
- package/dist/modes/repo.js +0 -76
- package/dist/modes/ultra.d.ts +0 -22
- package/dist/modes/ultra.js +0 -281
- package/dist/modes/url.d.ts +0 -14
- package/dist/modes/url.js +0 -161
- package/dist/normalizer.d.ts +0 -11
- package/dist/normalizer.js +0 -867
- package/dist/playwright-loader.d.ts +0 -10
- package/dist/playwright-loader.js +0 -71
- package/dist/screenshot.d.ts +0 -9
- package/dist/screenshot.js +0 -94
- package/dist/types-ultra.d.ts +0 -157
- package/dist/types-ultra.js +0 -4
- package/dist/types.d.ts +0 -182
- package/dist/types.js +0 -4
- package/dist/writers/animations-md.d.ts +0 -17
- package/dist/writers/animations-md.js +0 -313
- package/dist/writers/components-md.d.ts +0 -8
- package/dist/writers/components-md.js +0 -151
- package/dist/writers/design-md.d.ts +0 -7
- package/dist/writers/design-md.js +0 -704
- package/dist/writers/interactions-md.d.ts +0 -8
- package/dist/writers/interactions-md.js +0 -146
- package/dist/writers/layout-md.d.ts +0 -8
- package/dist/writers/layout-md.js +0 -120
- package/dist/writers/skill.d.ts +0 -12
- package/dist/writers/skill.js +0 -1006
- package/dist/writers/tokens-json.d.ts +0 -11
- package/dist/writers/tokens-json.js +0 -164
|
@@ -1,1689 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
-
if (k2 === undefined) k2 = k;
|
|
4
|
-
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
-
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
-
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
-
}
|
|
8
|
-
Object.defineProperty(o, k2, desc);
|
|
9
|
-
}) : (function(o, m, k, k2) {
|
|
10
|
-
if (k2 === undefined) k2 = k;
|
|
11
|
-
o[k2] = m[k];
|
|
12
|
-
}));
|
|
13
|
-
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
-
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
-
}) : function(o, v) {
|
|
16
|
-
o["default"] = v;
|
|
17
|
-
});
|
|
18
|
-
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
-
var ownKeys = function(o) {
|
|
20
|
-
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
-
var ar = [];
|
|
22
|
-
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
-
return ar;
|
|
24
|
-
};
|
|
25
|
-
return ownKeys(o);
|
|
26
|
-
};
|
|
27
|
-
return function (mod) {
|
|
28
|
-
if (mod && mod.__esModule) return mod;
|
|
29
|
-
var result = {};
|
|
30
|
-
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
-
__setModuleDefault(result, mod);
|
|
32
|
-
return result;
|
|
33
|
-
};
|
|
34
|
-
})();
|
|
35
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.extractHttpCSSTokens = extractHttpCSSTokens;
|
|
37
|
-
const csstree = __importStar(require("css-tree"));
|
|
38
|
-
async function extractHttpCSSTokens(url, maxPages = 3) {
|
|
39
|
-
const tokens = {
|
|
40
|
-
colors: [],
|
|
41
|
-
fonts: [],
|
|
42
|
-
spacingValues: [],
|
|
43
|
-
shadows: [],
|
|
44
|
-
cssVariables: [],
|
|
45
|
-
breakpoints: [],
|
|
46
|
-
borderRadii: [],
|
|
47
|
-
gradients: [],
|
|
48
|
-
fontVarMap: {},
|
|
49
|
-
animations: [],
|
|
50
|
-
darkModeVars: [],
|
|
51
|
-
zIndexValues: [],
|
|
52
|
-
containerMaxWidth: null,
|
|
53
|
-
fontSources: [],
|
|
54
|
-
pageSections: [],
|
|
55
|
-
transitionDurations: [],
|
|
56
|
-
transitionEasings: [],
|
|
57
|
-
};
|
|
58
|
-
const visited = new Set();
|
|
59
|
-
const toVisit = [url];
|
|
60
|
-
let origin;
|
|
61
|
-
const allHtml = [];
|
|
62
|
-
const allCssContent = [];
|
|
63
|
-
try {
|
|
64
|
-
origin = new URL(url).origin;
|
|
65
|
-
}
|
|
66
|
-
catch {
|
|
67
|
-
console.error(' Invalid URL');
|
|
68
|
-
return { tokens, components: [] };
|
|
69
|
-
}
|
|
70
|
-
while (toVisit.length > 0 && visited.size < maxPages) {
|
|
71
|
-
const currentUrl = toVisit.shift();
|
|
72
|
-
if (visited.has(currentUrl))
|
|
73
|
-
continue;
|
|
74
|
-
visited.add(currentUrl);
|
|
75
|
-
try {
|
|
76
|
-
const html = await fetchText(currentUrl);
|
|
77
|
-
if (!html)
|
|
78
|
-
continue;
|
|
79
|
-
allHtml.push(html);
|
|
80
|
-
// Extract all linked CSS URLs from HTML
|
|
81
|
-
const cssUrls = extractCSSUrls(html, currentUrl);
|
|
82
|
-
// Extract inline <style> blocks (and resolve @import URLs within them)
|
|
83
|
-
const inlineStyles = extractInlineStyles(html);
|
|
84
|
-
// Extract inline style= attributes for color sampling
|
|
85
|
-
extractInlineAttributeColors(html, tokens);
|
|
86
|
-
// Extract meta theme-color
|
|
87
|
-
extractMetaColors(html, tokens);
|
|
88
|
-
// Detect fonts from <link> to Google Fonts / other font CDNs
|
|
89
|
-
extractFontLinks(html, tokens);
|
|
90
|
-
// Detect page structure for pseudo-components
|
|
91
|
-
extractDOMStructure(html, tokens);
|
|
92
|
-
// Detect and decode SPA modules (React, base64-encoded JS)
|
|
93
|
-
extractSPAModules(html, tokens, allCssContent);
|
|
94
|
-
// Collect @import URLs from inline styles
|
|
95
|
-
for (const style of inlineStyles) {
|
|
96
|
-
const importUrls = extractImportUrls(style, currentUrl);
|
|
97
|
-
for (const importUrl of importUrls) {
|
|
98
|
-
if (!cssUrls.includes(importUrl))
|
|
99
|
-
cssUrls.push(importUrl);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
// Fetch and parse each CSS file (including @import targets)
|
|
103
|
-
const fetchedCss = new Set();
|
|
104
|
-
const cssQueue = [...cssUrls];
|
|
105
|
-
while (cssQueue.length > 0) {
|
|
106
|
-
const cssUrl = cssQueue.shift();
|
|
107
|
-
if (fetchedCss.has(cssUrl))
|
|
108
|
-
continue;
|
|
109
|
-
fetchedCss.add(cssUrl);
|
|
110
|
-
try {
|
|
111
|
-
const cssContent = await fetchText(cssUrl);
|
|
112
|
-
if (cssContent) {
|
|
113
|
-
allCssContent.push(cssContent);
|
|
114
|
-
parseCSS(cssContent, tokens, cssUrl);
|
|
115
|
-
// Follow nested @import URLs
|
|
116
|
-
const nestedImports = extractImportUrls(cssContent, cssUrl);
|
|
117
|
-
for (const ni of nestedImports) {
|
|
118
|
-
if (!fetchedCss.has(ni))
|
|
119
|
-
cssQueue.push(ni);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
catch {
|
|
124
|
-
// Skip unreachable CSS files
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
// Parse inline styles
|
|
128
|
-
for (const style of inlineStyles) {
|
|
129
|
-
allCssContent.push(style);
|
|
130
|
-
parseCSS(style, tokens, currentUrl);
|
|
131
|
-
}
|
|
132
|
-
// Find additional page links for crawling (same origin only)
|
|
133
|
-
if (visited.size < maxPages) {
|
|
134
|
-
const links = extractPageLinks(html, currentUrl, origin);
|
|
135
|
-
for (const link of links.slice(0, 5)) {
|
|
136
|
-
if (!visited.has(link))
|
|
137
|
-
toVisit.push(link);
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
catch (err) {
|
|
142
|
-
// Skip pages that fail to load
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
// Post-process: build dark mode pairs
|
|
146
|
-
buildDarkModePairs(tokens);
|
|
147
|
-
// Decode SPA modules for component detection
|
|
148
|
-
let decodedJS = '';
|
|
149
|
-
const combinedHtml = allHtml.join('\n');
|
|
150
|
-
const base64Modules = combinedHtml.matchAll(/data:application\/javascript;base64,([A-Za-z0-9+/=]+)/g);
|
|
151
|
-
for (const m of base64Modules) {
|
|
152
|
-
try {
|
|
153
|
-
decodedJS += '\n' + Buffer.from(m[1], 'base64').toString('utf-8');
|
|
154
|
-
}
|
|
155
|
-
catch { }
|
|
156
|
-
}
|
|
157
|
-
// Detect real UI components from HTML + decoded JS + CSS
|
|
158
|
-
const combinedCss = allCssContent.join('\n');
|
|
159
|
-
const components = detectHTMLComponents(combinedHtml + '\n' + decodedJS, combinedCss);
|
|
160
|
-
return { tokens, components };
|
|
161
|
-
}
|
|
162
|
-
// ── HTML Parsing Helpers ──────────────────────────────────────────────
|
|
163
|
-
function extractCSSUrls(html, baseUrl) {
|
|
164
|
-
const urls = [];
|
|
165
|
-
// <link rel="stylesheet" href="...">
|
|
166
|
-
const linkMatches = html.matchAll(/<link[^>]+rel\s*=\s*["']stylesheet["'][^>]*href\s*=\s*["']([^"']+)["']/gi);
|
|
167
|
-
for (const m of linkMatches) {
|
|
168
|
-
urls.push(resolveUrl(m[1], baseUrl));
|
|
169
|
-
}
|
|
170
|
-
// Also catch <link href="..." rel="stylesheet">
|
|
171
|
-
const linkMatches2 = html.matchAll(/<link[^>]+href\s*=\s*["']([^"']+)["'][^>]*rel\s*=\s*["']stylesheet["']/gi);
|
|
172
|
-
for (const m of linkMatches2) {
|
|
173
|
-
const resolved = resolveUrl(m[1], baseUrl);
|
|
174
|
-
if (!urls.includes(resolved))
|
|
175
|
-
urls.push(resolved);
|
|
176
|
-
}
|
|
177
|
-
// CSS @import from inline styles will be caught by css-tree
|
|
178
|
-
return urls;
|
|
179
|
-
}
|
|
180
|
-
function extractImportUrls(css, baseUrl) {
|
|
181
|
-
const urls = [];
|
|
182
|
-
// @import url("...") or @import url(...) or @import "..."
|
|
183
|
-
const importMatches = css.matchAll(/@import\s+(?:url\(\s*["']?([^"')]+)["']?\s*\)|["']([^"']+)["'])/gi);
|
|
184
|
-
for (const m of importMatches) {
|
|
185
|
-
const importUrl = m[1] || m[2];
|
|
186
|
-
if (importUrl) {
|
|
187
|
-
urls.push(resolveUrl(importUrl, baseUrl));
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
return urls;
|
|
191
|
-
}
|
|
192
|
-
function extractInlineStyles(html) {
|
|
193
|
-
const styles = [];
|
|
194
|
-
const styleMatches = html.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi);
|
|
195
|
-
for (const m of styleMatches) {
|
|
196
|
-
if (m[1].trim())
|
|
197
|
-
styles.push(m[1]);
|
|
198
|
-
}
|
|
199
|
-
return styles;
|
|
200
|
-
}
|
|
201
|
-
function extractInlineAttributeColors(html, tokens) {
|
|
202
|
-
// Extract colors from style="" attributes
|
|
203
|
-
const styleAttrMatches = html.matchAll(/style\s*=\s*["']([^"']+)["']/gi);
|
|
204
|
-
for (const m of styleAttrMatches) {
|
|
205
|
-
const val = m[1];
|
|
206
|
-
// Hex colors
|
|
207
|
-
const hexMatches = val.matchAll(/#([0-9a-fA-F]{3,8})\b/g);
|
|
208
|
-
for (const h of hexMatches) {
|
|
209
|
-
const hex = normalizeHex(h[0]);
|
|
210
|
-
addColor(tokens, hex, 'css');
|
|
211
|
-
}
|
|
212
|
-
// RGB colors
|
|
213
|
-
const rgbMatches = val.matchAll(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/g);
|
|
214
|
-
for (const r of rgbMatches) {
|
|
215
|
-
addColor(tokens, rgbToHex(parseInt(r[1]), parseInt(r[2]), parseInt(r[3])), 'css');
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
// Parse light-dark() CSS function — extract the LIGHT value (first arg)
|
|
219
|
-
// light-dark(white, black) → use "white" as default
|
|
220
|
-
parseLightDarkColors(html, tokens);
|
|
221
|
-
}
|
|
222
|
-
function parseLightDarkColors(content, tokens) {
|
|
223
|
-
// Parse property: light-dark(lightVal, darkVal) — context-aware
|
|
224
|
-
// background: light-dark(white, black) → white is background
|
|
225
|
-
// color: light-dark(black, white) → black is text
|
|
226
|
-
const propMatches = content.matchAll(/(background|color|border-color)\s*:\s*light-dark\(\s*([^,]+)\s*,\s*([^)]+)\s*\)/gi);
|
|
227
|
-
for (const m of propMatches) {
|
|
228
|
-
const prop = m[1].toLowerCase();
|
|
229
|
-
const lightVal = m[2].trim();
|
|
230
|
-
const hex = tryParseColor(lightVal) || namedColorToHex(lightVal);
|
|
231
|
-
if (hex) {
|
|
232
|
-
if (prop === 'background') {
|
|
233
|
-
addColor(tokens, hex, 'css', 'light-bg');
|
|
234
|
-
}
|
|
235
|
-
else if (prop === 'color') {
|
|
236
|
-
addColor(tokens, hex, 'css', 'light-text');
|
|
237
|
-
}
|
|
238
|
-
else {
|
|
239
|
-
addColor(tokens, hex, 'css', 'light-default');
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
// Also detect color-scheme to understand if site is light or dark
|
|
244
|
-
const colorSchemeMatch = content.match(/color-scheme\s*:\s*([^;}\n]+)/i);
|
|
245
|
-
if (colorSchemeMatch) {
|
|
246
|
-
const scheme = colorSchemeMatch[1].trim().toLowerCase();
|
|
247
|
-
if (scheme.startsWith('light')) {
|
|
248
|
-
tokens.cssVariables.push({
|
|
249
|
-
name: '--color-scheme-default',
|
|
250
|
-
value: 'light',
|
|
251
|
-
property: 'color',
|
|
252
|
-
});
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
/**
|
|
257
|
-
* Detect color-scheme from CSS file content (not HTML).
|
|
258
|
-
* Called per-CSS-file so we catch :root { color-scheme: light dark; } in stylesheets.
|
|
259
|
-
* Guards against false positives from inside @media (prefers-color-scheme: dark) blocks.
|
|
260
|
-
*/
|
|
261
|
-
function detectColorSchemeFromCSS(css, tokens) {
|
|
262
|
-
// Already detected — skip
|
|
263
|
-
if (tokens.cssVariables.some(v => v.name === '--color-scheme-default'))
|
|
264
|
-
return;
|
|
265
|
-
// Strip dark-mode media query blocks before searching, so we don't pick up
|
|
266
|
-
// color-scheme: dark from inside @media (prefers-color-scheme: dark) { ... }
|
|
267
|
-
const withoutDarkMedia = css.replace(/@media\s*\(\s*prefers-color-scheme\s*:\s*dark\s*\)\s*\{[\s\S]*?\}\s*\}/g, '');
|
|
268
|
-
const match = withoutDarkMedia.match(/color-scheme\s*:\s*([^;}\n]+)/i);
|
|
269
|
-
if (!match)
|
|
270
|
-
return;
|
|
271
|
-
const scheme = match[1].trim().toLowerCase();
|
|
272
|
-
// "light", "light dark", "only light" → all indicate light as primary
|
|
273
|
-
if (scheme.startsWith('light') || scheme === 'only light') {
|
|
274
|
-
tokens.cssVariables.push({
|
|
275
|
-
name: '--color-scheme-default',
|
|
276
|
-
value: 'light',
|
|
277
|
-
property: 'color',
|
|
278
|
-
});
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
/**
|
|
282
|
-
* Returns true if a font family name looks like an icon/symbol font.
|
|
283
|
-
* These should be excluded from the design typography documentation.
|
|
284
|
-
*/
|
|
285
|
-
function isIconFont(family) {
|
|
286
|
-
return /^(apple\s*(icons?|legacy|sf\s*symbols?)|material\s*(icons?|symbols?)|font\s*awesome|fontawesome|glyphicons?|ionicons?|feather|remixicon|octicons?|bootstrap\s*icons?|hero\s*icons?|phosphor|tabler|lucide)\b/i.test(family)
|
|
287
|
-
|| /^apple\s*icons?\s*\d+/i.test(family)
|
|
288
|
-
|| /^apple\s*legacy\s*chevron/i.test(family);
|
|
289
|
-
}
|
|
290
|
-
/**
|
|
291
|
-
* Returns true if a font family is a system font that cannot be downloaded.
|
|
292
|
-
*/
|
|
293
|
-
function isSystemFont(family) {
|
|
294
|
-
return /^(-apple-system|blinkmacsystemfont|system-ui|ui-sans-serif|ui-serif|ui-monospace|segoe\s*ui|\.sf\s*(pro|compact))/i.test(family);
|
|
295
|
-
}
|
|
296
|
-
/**
|
|
297
|
-
* Returns true if a font family is an emoji font (not useful for UI design docs).
|
|
298
|
-
*/
|
|
299
|
-
function isEmojiFont(family) {
|
|
300
|
-
return /^(apple\s*color\s*emoji|noto\s*color\s*emoji|segoe\s*ui\s*emoji|android\s*emoji|twemoji|emoji)/i.test(family);
|
|
301
|
-
}
|
|
302
|
-
/**
|
|
303
|
-
* From a CSS font-family value string (possibly with fallbacks),
|
|
304
|
-
* extract the first real, non-generic, non-system, non-emoji font name.
|
|
305
|
-
* Used to resolve --font-* CSS variable values.
|
|
306
|
-
*/
|
|
307
|
-
function extractFirstRealFont(value) {
|
|
308
|
-
const candidates = value.split(',').map(f => f.replace(/["']/g, '').trim());
|
|
309
|
-
for (const family of candidates) {
|
|
310
|
-
if (!family)
|
|
311
|
-
continue;
|
|
312
|
-
if (family.startsWith('var('))
|
|
313
|
-
continue;
|
|
314
|
-
if (isGenericFont(family))
|
|
315
|
-
continue;
|
|
316
|
-
if (isSystemFont(family))
|
|
317
|
-
continue;
|
|
318
|
-
if (isIconFont(family))
|
|
319
|
-
continue;
|
|
320
|
-
if (isEmojiFont(family))
|
|
321
|
-
continue;
|
|
322
|
-
if (!isValidFontName(family))
|
|
323
|
-
continue;
|
|
324
|
-
return family;
|
|
325
|
-
}
|
|
326
|
-
return null;
|
|
327
|
-
}
|
|
328
|
-
function extractMetaColors(html, tokens) {
|
|
329
|
-
// <meta name="theme-color" content="#...">
|
|
330
|
-
const themeColor = html.match(/<meta[^>]+name\s*=\s*["']theme-color["'][^>]+content\s*=\s*["']([^"']+)["']/i);
|
|
331
|
-
if (themeColor) {
|
|
332
|
-
const hex = tryParseColor(themeColor[1]);
|
|
333
|
-
if (hex)
|
|
334
|
-
addColor(tokens, hex, 'css', 'theme-color');
|
|
335
|
-
}
|
|
336
|
-
// <meta name="msapplication-TileColor">
|
|
337
|
-
const tileColor = html.match(/<meta[^>]+name\s*=\s*["']msapplication-TileColor["'][^>]+content\s*=\s*["']([^"']+)["']/i);
|
|
338
|
-
if (tileColor) {
|
|
339
|
-
const hex = tryParseColor(tileColor[1]);
|
|
340
|
-
if (hex)
|
|
341
|
-
addColor(tokens, hex, 'css', 'tile-color');
|
|
342
|
-
}
|
|
343
|
-
// Favicon: <link rel="icon"> / <link rel="shortcut icon"> / <link rel="apple-touch-icon">
|
|
344
|
-
if (!tokens.favicon) {
|
|
345
|
-
const faviconPatterns = [
|
|
346
|
-
/<link[^>]+rel\s*=\s*["'][^"']*\bicon\b[^"']*["'][^>]+href\s*=\s*["']([^"']+)["']/i,
|
|
347
|
-
/<link[^>]+href\s*=\s*["']([^"']+)["'][^>]+rel\s*=\s*["'][^"']*\bicon\b[^"']*["']/i,
|
|
348
|
-
];
|
|
349
|
-
for (const pat of faviconPatterns) {
|
|
350
|
-
const m = html.match(pat);
|
|
351
|
-
if (m && m[1]) {
|
|
352
|
-
// Strip query strings from favicon URL — keep just the path/URL
|
|
353
|
-
let faviconHref = m[1].split('?')[0].trim();
|
|
354
|
-
// Prefer standard extensions
|
|
355
|
-
if (/\.(ico|png|svg|jpg|webp)$/i.test(faviconHref)) {
|
|
356
|
-
tokens.favicon = faviconHref;
|
|
357
|
-
}
|
|
358
|
-
else {
|
|
359
|
-
tokens.favicon = faviconHref || '/favicon.ico';
|
|
360
|
-
}
|
|
361
|
-
break;
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
// Default to /favicon.ico if none found
|
|
365
|
-
if (!tokens.favicon)
|
|
366
|
-
tokens.favicon = '/favicon.ico';
|
|
367
|
-
}
|
|
368
|
-
// Site title from <title> tag or og:title
|
|
369
|
-
if (!tokens.siteTitle) {
|
|
370
|
-
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
|
371
|
-
if (titleMatch)
|
|
372
|
-
tokens.siteTitle = titleMatch[1].trim();
|
|
373
|
-
}
|
|
374
|
-
if (!tokens.siteTitle) {
|
|
375
|
-
const ogTitle = html.match(/<meta[^>]+property\s*=\s*["']og:title["'][^>]+content\s*=\s*["']([^"']+)["']/i);
|
|
376
|
-
if (ogTitle)
|
|
377
|
-
tokens.siteTitle = ogTitle[1].trim();
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
function extractFontLinks(html, tokens) {
|
|
381
|
-
// Google Fonts: <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&...">
|
|
382
|
-
const googleFontMatches = html.matchAll(/fonts\.googleapis\.com\/css2?\?[^"']*family=([^"'&]+)/gi);
|
|
383
|
-
for (const m of googleFontMatches) {
|
|
384
|
-
const families = decodeURIComponent(m[1]).split('|');
|
|
385
|
-
for (const f of families) {
|
|
386
|
-
const familyName = f.split(':')[0].replace(/\+/g, ' ').trim();
|
|
387
|
-
if (familyName) {
|
|
388
|
-
tokens.fonts.push({ family: familyName, source: 'css' });
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
// Detect font from @font-face in CSS (handled in parseCSS)
|
|
393
|
-
// Also check for common font CDN patterns
|
|
394
|
-
const fontCDNMatches = html.matchAll(/fonts\.[^"']+["']/gi);
|
|
395
|
-
// Already handled above
|
|
396
|
-
}
|
|
397
|
-
function extractDOMStructure(html, tokens) {
|
|
398
|
-
// Detect viewport meta for responsive
|
|
399
|
-
const viewportMatch = html.match(/<meta[^>]+name\s*=\s*["']viewport["'][^>]+content\s*=\s*["']([^"']+)["']/i);
|
|
400
|
-
if (viewportMatch && viewportMatch[1].includes('width=device-width')) {
|
|
401
|
-
// Responsive site
|
|
402
|
-
}
|
|
403
|
-
// Detect page sections by semantic HTML tags and class patterns
|
|
404
|
-
detectPageSections(html, tokens);
|
|
405
|
-
}
|
|
406
|
-
function extractSPAModules(html, tokens, allCssContent) {
|
|
407
|
-
// Decode base64-encoded JS modules from importmap or inline scripts
|
|
408
|
-
// These contain CSS classes, inline styles, and component structure
|
|
409
|
-
const base64Matches = html.matchAll(/data:application\/javascript;base64,([A-Za-z0-9+/=]+)/g);
|
|
410
|
-
for (const m of base64Matches) {
|
|
411
|
-
try {
|
|
412
|
-
const decoded = Buffer.from(m[1], 'base64').toString('utf-8');
|
|
413
|
-
// Extract CSS-in-JS styles and class names
|
|
414
|
-
extractColorsFromJSModule(decoded, tokens);
|
|
415
|
-
extractClassNamesFromJS(decoded, tokens);
|
|
416
|
-
// Also treat any inline CSS strings as CSS content
|
|
417
|
-
const cssStrings = decoded.matchAll(/`([^`]*(?:background|color|font|padding|margin|border|display|flex)[^`]*)`/g);
|
|
418
|
-
for (const cs of cssStrings) {
|
|
419
|
-
allCssContent.push(cs[1]);
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
catch {
|
|
423
|
-
// Skip unparseable modules
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
// Also check for esm.sh imports to detect frameworks
|
|
427
|
-
if (/esm\.sh\/react@/i.test(html) || /from\s+["']react["']/i.test(html)) {
|
|
428
|
-
// React SPA detected — fonts and components are rendered client-side
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
function extractColorsFromJSModule(js, tokens) {
|
|
432
|
-
// Extract hex colors from JS string literals
|
|
433
|
-
const hexMatches = js.matchAll(/["'`](#[0-9a-fA-F]{3,8})["'`]/g);
|
|
434
|
-
for (const m of hexMatches) {
|
|
435
|
-
const hex = normalizeHex(m[1]);
|
|
436
|
-
if (isValidHex(hex)) {
|
|
437
|
-
addColor(tokens, hex, 'css');
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
// Extract rgb/rgba from JS
|
|
441
|
-
const rgbMatches = js.matchAll(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/g);
|
|
442
|
-
for (const m of rgbMatches) {
|
|
443
|
-
addColor(tokens, rgbToHex(parseInt(m[1]), parseInt(m[2]), parseInt(m[3])), 'css');
|
|
444
|
-
}
|
|
445
|
-
// Extract CSS variable definitions from JS template literals
|
|
446
|
-
const cssVarMatches = js.matchAll(/(--[\w-]+)\s*:\s*([^;}\n"'`]+)/g);
|
|
447
|
-
for (const m of cssVarMatches) {
|
|
448
|
-
const name = m[1];
|
|
449
|
-
const value = m[2].trim();
|
|
450
|
-
if (!tokens.cssVariables.find(v => v.name === name)) {
|
|
451
|
-
tokens.cssVariables.push({ name, value, property: guessPropertyType(name) });
|
|
452
|
-
}
|
|
453
|
-
if (isColorVarName(name)) {
|
|
454
|
-
const hex = tryParseColor(value);
|
|
455
|
-
if (hex)
|
|
456
|
-
addColor(tokens, hex, 'css', name.replace(/^--/, ''));
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
// Extract SVG colors (fill, stroke attributes)
|
|
460
|
-
const svgColorMatches = js.matchAll(/(?:fill|stroke)\s*[:=]\s*["']([^"']+)["']/g);
|
|
461
|
-
for (const m of svgColorMatches) {
|
|
462
|
-
const val = m[1].trim();
|
|
463
|
-
const hex = tryParseColor(val) || namedColorToHex(val);
|
|
464
|
-
if (hex)
|
|
465
|
-
addColor(tokens, hex, 'css');
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
function extractClassNamesFromJS(js, tokens) {
|
|
469
|
-
// Collect ALL Tailwind classes from className patterns
|
|
470
|
-
const allClasses = [];
|
|
471
|
-
const classNameMatches = js.matchAll(/className\s*[:=]\s*["'`]([^"'`]+)["'`]/g);
|
|
472
|
-
for (const m of classNameMatches) {
|
|
473
|
-
const classes = m[1].split(/\s+/).filter(Boolean);
|
|
474
|
-
allClasses.push(...classes);
|
|
475
|
-
}
|
|
476
|
-
// Also from template literal className with interpolation
|
|
477
|
-
const templateMatches = js.matchAll(/className\s*[:=]\s*`([^`]+)`/g);
|
|
478
|
-
for (const m of templateMatches) {
|
|
479
|
-
// Remove template expressions ${...} and split
|
|
480
|
-
const cleaned = m[1].replace(/\$\{[^}]+\}/g, ' ');
|
|
481
|
-
allClasses.push(...cleaned.split(/\s+/).filter(Boolean));
|
|
482
|
-
}
|
|
483
|
-
// Count class frequencies to determine primary patterns
|
|
484
|
-
const classFreq = new Map();
|
|
485
|
-
for (const cls of allClasses) {
|
|
486
|
-
classFreq.set(cls, (classFreq.get(cls) || 0) + 1);
|
|
487
|
-
}
|
|
488
|
-
// ── Colors from Tailwind classes ──
|
|
489
|
-
const twColorMap = {
|
|
490
|
-
'black': '#000000', 'white': '#ffffff',
|
|
491
|
-
'gray-100': '#f3f4f6', 'gray-200': '#e5e7eb', 'gray-300': '#d1d5db',
|
|
492
|
-
'gray-400': '#9ca3af', 'gray-500': '#6b7280', 'gray-600': '#4b5563',
|
|
493
|
-
'gray-700': '#374151', 'gray-800': '#1f2937', 'gray-900': '#111827',
|
|
494
|
-
'red-500': '#ef4444', 'red-600': '#dc2626',
|
|
495
|
-
'blue-500': '#3b82f6', 'green-500': '#22c55e',
|
|
496
|
-
};
|
|
497
|
-
for (const cls of allClasses) {
|
|
498
|
-
// Tailwind arbitrary colors: bg-[#ACD8C9], text-[#ACD8C9], border-[#hex]
|
|
499
|
-
// Also with opacity: bg-[#ACD8C9]/95
|
|
500
|
-
const arbColorMatch = cls.match(/^(?:bg|text|border|fill|stroke)-\[#([0-9a-fA-F]{3,8})\]/);
|
|
501
|
-
if (arbColorMatch) {
|
|
502
|
-
const hex = normalizeHex('#' + arbColorMatch[1]);
|
|
503
|
-
if (isValidHex(hex))
|
|
504
|
-
addColor(tokens, hex, 'css');
|
|
505
|
-
}
|
|
506
|
-
// Named Tailwind colors: bg-black, text-white, text-black, border-black, bg-white
|
|
507
|
-
const namedColorMatch = cls.match(/^(?:bg|text|border)-(black|white|gray-\d+|red-\d+|blue-\d+|green-\d+)(?:\/\d+)?$/);
|
|
508
|
-
if (namedColorMatch && twColorMap[namedColorMatch[1]]) {
|
|
509
|
-
addColor(tokens, twColorMap[namedColorMatch[1]], 'css');
|
|
510
|
-
}
|
|
511
|
-
// Arbitrary font family: font-['Custom Font']
|
|
512
|
-
if (cls.startsWith('font-[') && cls.includes("'")) {
|
|
513
|
-
const fontMatch = cls.match(/font-\[\s*'([^']+)'\s*\]/);
|
|
514
|
-
if (fontMatch && !tokens.fonts.find(f => f.family === fontMatch[1])) {
|
|
515
|
-
tokens.fonts.push({ family: fontMatch[1], source: 'css' });
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
// Tailwind font classes: font-mono, font-sans, font-serif, font-doto (custom)
|
|
519
|
-
const fontClassMatch = cls.match(/^font-(mono|sans|serif|doto|bold|black|medium|semibold|light|thin|extrabold)$/);
|
|
520
|
-
if (fontClassMatch) {
|
|
521
|
-
const fontType = fontClassMatch[1];
|
|
522
|
-
if (fontType === 'mono' && !tokens.fonts.find(f => f.family === 'monospace')) {
|
|
523
|
-
tokens.fonts.push({ family: 'monospace', source: 'css' });
|
|
524
|
-
}
|
|
525
|
-
// Custom font class like font-doto → Doto font
|
|
526
|
-
if (!['mono', 'sans', 'serif', 'bold', 'black', 'medium', 'semibold', 'light', 'thin', 'extrabold'].includes(fontType)) {
|
|
527
|
-
const capName = fontType.charAt(0).toUpperCase() + fontType.slice(1);
|
|
528
|
-
if (!tokens.fonts.find(f => f.family.toLowerCase() === fontType)) {
|
|
529
|
-
tokens.fonts.push({ family: capName, source: 'css' });
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
// Tailwind text sizes: text-xs, text-sm, text-[9px], text-[10px], text-2xl, text-4xl
|
|
534
|
-
const textSizeMatch = cls.match(/^text-\[(\d+(?:px|rem))\]$/);
|
|
535
|
-
if (textSizeMatch) {
|
|
536
|
-
tokens.fonts.push({ family: '', size: textSizeMatch[1], source: 'css' });
|
|
537
|
-
}
|
|
538
|
-
const twSizeMap = {
|
|
539
|
-
'text-xs': '12px', 'text-sm': '14px', 'text-base': '16px',
|
|
540
|
-
'text-lg': '18px', 'text-xl': '20px', 'text-2xl': '24px',
|
|
541
|
-
'text-3xl': '30px', 'text-4xl': '36px', 'text-5xl': '48px',
|
|
542
|
-
};
|
|
543
|
-
if (twSizeMap[cls]) {
|
|
544
|
-
tokens.fonts.push({ family: '', size: twSizeMap[cls], source: 'css' });
|
|
545
|
-
}
|
|
546
|
-
// Tailwind rounded-none → border-radius: 0px
|
|
547
|
-
if (cls === 'rounded-none') {
|
|
548
|
-
if (!tokens.borderRadii.includes('0px'))
|
|
549
|
-
tokens.borderRadii.push('0px');
|
|
550
|
-
}
|
|
551
|
-
// Standard Tailwind rounded classes
|
|
552
|
-
const twRadiusMap = {
|
|
553
|
-
'rounded-sm': '2px', 'rounded': '4px', 'rounded-md': '6px',
|
|
554
|
-
'rounded-lg': '8px', 'rounded-xl': '12px', 'rounded-2xl': '16px',
|
|
555
|
-
'rounded-3xl': '24px', 'rounded-full': '9999px',
|
|
556
|
-
};
|
|
557
|
-
if (twRadiusMap[cls] && !tokens.borderRadii.includes(twRadiusMap[cls])) {
|
|
558
|
-
tokens.borderRadii.push(twRadiusMap[cls]);
|
|
559
|
-
}
|
|
560
|
-
// Tailwind arbitrary shadows: shadow-[2px_2px_0px_0px_rgba(0,0,0,1)]
|
|
561
|
-
const shadowMatch = cls.match(/^shadow-\[(.+)\]$/);
|
|
562
|
-
if (shadowMatch) {
|
|
563
|
-
const shadowVal = shadowMatch[1].replace(/_/g, ' ');
|
|
564
|
-
if (!tokens.shadows.find(s => s.value === shadowVal)) {
|
|
565
|
-
tokens.shadows.push({ value: shadowVal });
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
// Tailwind spacing: p-1, px-2, py-6, m-4, gap-3, space-y-4
|
|
569
|
-
const spacingMatch = cls.match(/^(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap|space-[xy])-(\d+(?:\.\d+)?)$/);
|
|
570
|
-
if (spacingMatch) {
|
|
571
|
-
const twUnit = parseFloat(spacingMatch[1]) * 4; // Tailwind spacing: 1 = 4px
|
|
572
|
-
if (twUnit > 0 && twUnit <= 200)
|
|
573
|
-
tokens.spacingValues.push(Math.round(twUnit));
|
|
574
|
-
}
|
|
575
|
-
// Arbitrary spacing: p-[40px], px-10
|
|
576
|
-
const arbSpacingMatch = cls.match(/^(?:p|px|py|m|mx|my|gap)-\[(\d+)px\]$/);
|
|
577
|
-
if (arbSpacingMatch) {
|
|
578
|
-
const px = parseInt(arbSpacingMatch[1]);
|
|
579
|
-
if (px > 0 && px <= 200)
|
|
580
|
-
tokens.spacingValues.push(px);
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
// ── Detect font dominance ──
|
|
584
|
-
// Count font-mono vs font-sans occurrences to determine primary font style
|
|
585
|
-
const monoCount = classFreq.get('font-mono') || 0;
|
|
586
|
-
const sansCount = classFreq.get('font-sans') || 0;
|
|
587
|
-
if (monoCount > sansCount && monoCount >= 3) {
|
|
588
|
-
// Monospace is the primary UI font — increase its frequency
|
|
589
|
-
const monoFont = tokens.fonts.find(f => f.family === 'monospace');
|
|
590
|
-
if (monoFont) {
|
|
591
|
-
// Already added
|
|
592
|
-
}
|
|
593
|
-
else {
|
|
594
|
-
tokens.fonts.push({ family: 'monospace', source: 'css' });
|
|
595
|
-
}
|
|
596
|
-
// Add extra frequency entries to make monospace the primary
|
|
597
|
-
for (let i = 0; i < monoCount; i++) {
|
|
598
|
-
tokens.fonts.push({ family: 'monospace', source: 'css' });
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
// ── Detect uppercase/tracking patterns ──
|
|
602
|
-
const uppercaseCount = classFreq.get('uppercase') || 0;
|
|
603
|
-
const trackingCount = (classFreq.get('tracking-widest') || 0) + (classFreq.get('tracking-wider') || 0) + (classFreq.get('tracking-tight') || 0);
|
|
604
|
-
// If heavy uppercase usage, this indicates a brutalist/terminal style
|
|
605
|
-
// We'll capture this in antiPatterns or similar
|
|
606
|
-
if (uppercaseCount >= 5) {
|
|
607
|
-
// Uppercase-heavy UI — this is a style signal
|
|
608
|
-
}
|
|
609
|
-
// Also extract font-family from inline style objects in JSX
|
|
610
|
-
const fontFamilyMatches = js.matchAll(/fontFamily\s*:\s*["'`]([^"'`]+)["'`]/g);
|
|
611
|
-
for (const m of fontFamilyMatches) {
|
|
612
|
-
const family = m[1].split(',')[0].replace(/["']/g, '').trim();
|
|
613
|
-
if (family && family.length > 1 && family.length < 50 && !isGenericFont(family)) {
|
|
614
|
-
if (!tokens.fonts.find(f => f.family === family)) {
|
|
615
|
-
tokens.fonts.push({ family, source: 'css' });
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
// Extract border-radius from inline style objects
|
|
620
|
-
const radiusMatches = js.matchAll(/borderRadius\s*:\s*["'`]?(\d+(?:px|rem|%)?)/g);
|
|
621
|
-
for (const m of radiusMatches) {
|
|
622
|
-
const val = m[1].includes('px') || m[1].includes('rem') || m[1].includes('%') ? m[1] : m[1] + 'px';
|
|
623
|
-
if (!tokens.borderRadii.includes(val))
|
|
624
|
-
tokens.borderRadii.push(val);
|
|
625
|
-
}
|
|
626
|
-
// Extract spacing from inline style objects
|
|
627
|
-
const spacingProps = ['padding', 'margin', 'gap', 'paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight', 'marginTop', 'marginBottom'];
|
|
628
|
-
for (const prop of spacingProps) {
|
|
629
|
-
const regex = new RegExp(`${prop}\\s*:\\s*["'\`]?(\\d+(?:px)?)`, 'g');
|
|
630
|
-
const matches = js.matchAll(regex);
|
|
631
|
-
for (const m of matches) {
|
|
632
|
-
const px = parseInt(m[1]);
|
|
633
|
-
if (px > 0 && px <= 200)
|
|
634
|
-
tokens.spacingValues.push(px);
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
function detectPageSections(html, tokens) {
|
|
639
|
-
// Navigation
|
|
640
|
-
const navMatch = html.match(/<nav[^>]*class\s*=\s*["']([^"']*)["'][^>]*>/i);
|
|
641
|
-
if (navMatch || /<nav[\s>]/i.test(html)) {
|
|
642
|
-
tokens.pageSections.push({
|
|
643
|
-
type: 'navigation',
|
|
644
|
-
tag: 'nav',
|
|
645
|
-
classes: navMatch ? navMatch[1].split(/\s+/).filter(Boolean) : [],
|
|
646
|
-
childCount: countChildLinks(html, 'nav'),
|
|
647
|
-
description: 'Top navigation bar',
|
|
648
|
-
});
|
|
649
|
-
}
|
|
650
|
-
// Hero section — look for common class patterns near the top
|
|
651
|
-
const heroPatterns = [
|
|
652
|
-
/class\s*=\s*["'][^"']*\b(hero|banner|jumbotron|masthead)\b[^"']*["']/i,
|
|
653
|
-
];
|
|
654
|
-
for (const pattern of heroPatterns) {
|
|
655
|
-
const heroMatch = html.match(pattern);
|
|
656
|
-
if (heroMatch) {
|
|
657
|
-
tokens.pageSections.push({
|
|
658
|
-
type: 'hero',
|
|
659
|
-
tag: 'section',
|
|
660
|
-
classes: [heroMatch[1]],
|
|
661
|
-
childCount: 0,
|
|
662
|
-
description: 'Hero/banner section with headline and CTAs',
|
|
663
|
-
});
|
|
664
|
-
break;
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
// If no explicit hero class, detect by structure: first large heading
|
|
668
|
-
if (!tokens.pageSections.find(s => s.type === 'hero')) {
|
|
669
|
-
const hasLargeHeading = /<(h1|h2)[^>]*>[\s\S]{5,}<\/(h1|h2)>/i.test(html);
|
|
670
|
-
if (hasLargeHeading) {
|
|
671
|
-
tokens.pageSections.push({
|
|
672
|
-
type: 'hero',
|
|
673
|
-
tag: 'section',
|
|
674
|
-
classes: [],
|
|
675
|
-
childCount: 0,
|
|
676
|
-
description: 'Hero section (detected from heading structure)',
|
|
677
|
-
});
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
// Features/cards section
|
|
681
|
-
const featurePatterns = /class\s*=\s*["'][^"']*\b(features?|benefits?|cards?-grid|card-container)\b[^"']*["']/i;
|
|
682
|
-
const featureMatch = html.match(featurePatterns);
|
|
683
|
-
if (featureMatch) {
|
|
684
|
-
tokens.pageSections.push({
|
|
685
|
-
type: 'features',
|
|
686
|
-
tag: 'section',
|
|
687
|
-
classes: [featureMatch[1]],
|
|
688
|
-
childCount: countRepeatedElements(html, 'card'),
|
|
689
|
-
description: 'Feature/benefit cards grid',
|
|
690
|
-
});
|
|
691
|
-
}
|
|
692
|
-
// FAQ section
|
|
693
|
-
const faqMatch = html.match(/class\s*=\s*["'][^"']*\b(faq|accordion|questions)\b[^"']*["']/i);
|
|
694
|
-
if (faqMatch || /FAQ|Frequently Asked/i.test(html)) {
|
|
695
|
-
tokens.pageSections.push({
|
|
696
|
-
type: 'faq',
|
|
697
|
-
tag: 'section',
|
|
698
|
-
classes: faqMatch ? [faqMatch[1]] : ['faq'],
|
|
699
|
-
childCount: 0,
|
|
700
|
-
description: 'FAQ/accordion section',
|
|
701
|
-
});
|
|
702
|
-
}
|
|
703
|
-
// Footer
|
|
704
|
-
const footerMatch = html.match(/<footer[^>]*class\s*=\s*["']([^"']*)["'][^>]*>/i);
|
|
705
|
-
if (footerMatch || /<footer[\s>]/i.test(html)) {
|
|
706
|
-
tokens.pageSections.push({
|
|
707
|
-
type: 'footer',
|
|
708
|
-
tag: 'footer',
|
|
709
|
-
classes: footerMatch ? footerMatch[1].split(/\s+/).filter(Boolean) : [],
|
|
710
|
-
childCount: countChildLinks(html, 'footer'),
|
|
711
|
-
description: 'Page footer with links and info',
|
|
712
|
-
});
|
|
713
|
-
}
|
|
714
|
-
// CTA sections
|
|
715
|
-
const ctaMatch = html.match(/class\s*=\s*["'][^"']*\b(cta|call-to-action|signup)\b[^"']*["']/i);
|
|
716
|
-
if (ctaMatch) {
|
|
717
|
-
tokens.pageSections.push({
|
|
718
|
-
type: 'cta',
|
|
719
|
-
tag: 'section',
|
|
720
|
-
classes: [ctaMatch[1]],
|
|
721
|
-
childCount: 0,
|
|
722
|
-
description: 'Call-to-action section',
|
|
723
|
-
});
|
|
724
|
-
}
|
|
725
|
-
// Stats section
|
|
726
|
-
const statsMatch = html.match(/class\s*=\s*["'][^"']*\b(stats|metrics|numbers|counters)\b[^"']*["']/i);
|
|
727
|
-
if (statsMatch) {
|
|
728
|
-
tokens.pageSections.push({
|
|
729
|
-
type: 'stats',
|
|
730
|
-
tag: 'section',
|
|
731
|
-
classes: [statsMatch[1]],
|
|
732
|
-
childCount: 0,
|
|
733
|
-
description: 'Statistics/metrics display',
|
|
734
|
-
});
|
|
735
|
-
}
|
|
736
|
-
// Testimonials
|
|
737
|
-
const testimonialMatch = html.match(/class\s*=\s*["'][^"']*\b(testimonials?|reviews?|quotes?)\b[^"']*["']/i);
|
|
738
|
-
if (testimonialMatch) {
|
|
739
|
-
tokens.pageSections.push({
|
|
740
|
-
type: 'testimonials',
|
|
741
|
-
tag: 'section',
|
|
742
|
-
classes: [testimonialMatch[1]],
|
|
743
|
-
childCount: 0,
|
|
744
|
-
description: 'Testimonials/reviews section',
|
|
745
|
-
});
|
|
746
|
-
}
|
|
747
|
-
// Detect card patterns anywhere (repeated elements with "card" in class)
|
|
748
|
-
if (!tokens.pageSections.find(s => s.type === 'cards' || s.type === 'features')) {
|
|
749
|
-
const cardCount = countRepeatedElements(html, 'card');
|
|
750
|
-
if (cardCount >= 3) {
|
|
751
|
-
tokens.pageSections.push({
|
|
752
|
-
type: 'cards',
|
|
753
|
-
tag: 'div',
|
|
754
|
-
classes: ['card'],
|
|
755
|
-
childCount: cardCount,
|
|
756
|
-
description: `Grid of ${cardCount} card elements`,
|
|
757
|
-
});
|
|
758
|
-
}
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
function countChildLinks(html, tag) {
|
|
762
|
-
const tagMatch = html.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i'));
|
|
763
|
-
if (!tagMatch)
|
|
764
|
-
return 0;
|
|
765
|
-
const links = tagMatch[1].match(/<a\s/gi);
|
|
766
|
-
return links ? links.length : 0;
|
|
767
|
-
}
|
|
768
|
-
function countRepeatedElements(html, classPattern) {
|
|
769
|
-
const regex = new RegExp(`class\\s*=\\s*["'][^"']*\\b${classPattern}\\b`, 'gi');
|
|
770
|
-
const matches = html.match(regex);
|
|
771
|
-
return matches ? matches.length : 0;
|
|
772
|
-
}
|
|
773
|
-
function extractTransitionParts(value, tokens) {
|
|
774
|
-
// Parse durations from transition shorthand (e.g., "all 150ms ease", "opacity 0.3s ease-out")
|
|
775
|
-
const durationMatches = value.matchAll(/([\d.]+)(ms|s)\b/g);
|
|
776
|
-
for (const m of durationMatches) {
|
|
777
|
-
let dur = m[2] === 's' ? `${parseFloat(m[1]) * 1000}ms` : `${m[1]}ms`;
|
|
778
|
-
if (!tokens.transitionDurations.includes(dur)) {
|
|
779
|
-
tokens.transitionDurations.push(dur);
|
|
780
|
-
}
|
|
781
|
-
}
|
|
782
|
-
// Parse easings
|
|
783
|
-
const easingPatterns = [
|
|
784
|
-
/\b(ease-in-out|ease-in|ease-out|ease|linear)\b/g,
|
|
785
|
-
/cubic-bezier\([^)]+\)/g,
|
|
786
|
-
];
|
|
787
|
-
for (const pattern of easingPatterns) {
|
|
788
|
-
const matches = value.matchAll(pattern);
|
|
789
|
-
for (const m of matches) {
|
|
790
|
-
const easing = m[0];
|
|
791
|
-
if (!tokens.transitionEasings.includes(easing)) {
|
|
792
|
-
tokens.transitionEasings.push(easing);
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
}
|
|
797
|
-
function extractPageLinks(html, baseUrl, origin) {
|
|
798
|
-
const links = [];
|
|
799
|
-
const hrefMatches = html.matchAll(/<a[^>]+href\s*=\s*["']([^"'#]+)["']/gi);
|
|
800
|
-
for (const m of hrefMatches) {
|
|
801
|
-
try {
|
|
802
|
-
const resolved = new URL(m[1], baseUrl).href;
|
|
803
|
-
if (resolved.startsWith(origin) && !resolved.includes('#') && !links.includes(resolved)) {
|
|
804
|
-
links.push(resolved);
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
catch {
|
|
808
|
-
// Invalid URL
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
return links;
|
|
812
|
-
}
|
|
813
|
-
// ── CSS Parsing ───────────────────────────────────────────────────────
|
|
814
|
-
function parseCSS(content, tokens, cssBaseUrl) {
|
|
815
|
-
// First, extract dark mode blocks via regex (before AST parsing may fail)
|
|
816
|
-
extractDarkModeBlocks(content, tokens);
|
|
817
|
-
// Detect color-scheme in CSS (e.g. :root { color-scheme: light dark; })
|
|
818
|
-
detectColorSchemeFromCSS(content, tokens);
|
|
819
|
-
// Extract @keyframes
|
|
820
|
-
extractKeyframes(content, tokens);
|
|
821
|
-
// Extract @font-face (with URL resolution)
|
|
822
|
-
extractFontFace(content, tokens, cssBaseUrl);
|
|
823
|
-
// Extract @media breakpoints
|
|
824
|
-
extractMediaBreakpoints(content, tokens);
|
|
825
|
-
// Extract z-index
|
|
826
|
-
extractZIndex(content, tokens);
|
|
827
|
-
// Extract container widths
|
|
828
|
-
extractContainerWidth(content, tokens);
|
|
829
|
-
// AST-based parsing
|
|
830
|
-
try {
|
|
831
|
-
const ast = csstree.parse(content, {
|
|
832
|
-
parseCustomProperty: true,
|
|
833
|
-
parseAtrulePrelude: true,
|
|
834
|
-
});
|
|
835
|
-
csstree.walk(ast, {
|
|
836
|
-
visit: 'Declaration',
|
|
837
|
-
enter(node) {
|
|
838
|
-
const prop = node.property;
|
|
839
|
-
const rawValue = csstree.generate(node.value);
|
|
840
|
-
// CSS custom properties
|
|
841
|
-
if (prop.startsWith('--')) {
|
|
842
|
-
const trimmedValue = rawValue.trim();
|
|
843
|
-
tokens.cssVariables.push({
|
|
844
|
-
name: prop,
|
|
845
|
-
value: trimmedValue,
|
|
846
|
-
property: guessPropertyType(prop),
|
|
847
|
-
});
|
|
848
|
-
// Extract color from variable value
|
|
849
|
-
if (isColorVarName(prop)) {
|
|
850
|
-
const hex = tryParseColor(trimmedValue);
|
|
851
|
-
if (hex) {
|
|
852
|
-
const name = prop.replace(/^--/, '');
|
|
853
|
-
addColor(tokens, hex, 'css', name);
|
|
854
|
-
}
|
|
855
|
-
}
|
|
856
|
-
// Populate fontVarMap: --font-xxx, --font-sans, --font-mono, --default-font-family
|
|
857
|
-
// e.g. --font-sans: 'delight' → fontVarMap['--font-sans'] = 'delight'
|
|
858
|
-
if (/^--(font|default[-_]font)/i.test(prop)) {
|
|
859
|
-
// Extract first real font from the value (skip generics/system/emoji)
|
|
860
|
-
const fontVal = extractFirstRealFont(trimmedValue);
|
|
861
|
-
if (fontVal) {
|
|
862
|
-
tokens.fontVarMap[prop] = fontVal;
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
// Direct color properties
|
|
867
|
-
if (isColorProperty(prop)) {
|
|
868
|
-
extractColorsFromValue(rawValue, tokens);
|
|
869
|
-
}
|
|
870
|
-
// Font family — walk all comma-separated families, resolve vars, skip system/icon/emoji/generic
|
|
871
|
-
if (prop === 'font-family') {
|
|
872
|
-
const candidates = rawValue.split(',').map(f => f.replace(/["']/g, '').trim());
|
|
873
|
-
for (let family of candidates) {
|
|
874
|
-
if (!family)
|
|
875
|
-
continue;
|
|
876
|
-
// Resolve CSS variable reference: var(--font-delight) → 'delight'
|
|
877
|
-
if (family.startsWith('var(')) {
|
|
878
|
-
const varName = family.replace(/^var\(\s*/, '').replace(/\s*(?:,[^)]+)?\)$/, '').trim();
|
|
879
|
-
const resolved = tokens.fontVarMap[varName] || tokens.fontVarMap[family];
|
|
880
|
-
if (resolved) {
|
|
881
|
-
family = resolved;
|
|
882
|
-
}
|
|
883
|
-
else {
|
|
884
|
-
continue; // can't resolve yet — skip
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
if (isGenericFont(family))
|
|
888
|
-
continue;
|
|
889
|
-
if (isSystemFont(family))
|
|
890
|
-
continue;
|
|
891
|
-
if (isIconFont(family))
|
|
892
|
-
continue;
|
|
893
|
-
if (isEmojiFont(family))
|
|
894
|
-
continue;
|
|
895
|
-
if (!isValidFontName(family))
|
|
896
|
-
continue;
|
|
897
|
-
if (!tokens.fonts.find(f => f.family === family)) {
|
|
898
|
-
tokens.fonts.push({ family, source: 'css' });
|
|
899
|
-
}
|
|
900
|
-
break; // use the first real (non-system) font in the stack
|
|
901
|
-
}
|
|
902
|
-
}
|
|
903
|
-
// Font size
|
|
904
|
-
if (prop === 'font-size') {
|
|
905
|
-
const size = rawValue.trim();
|
|
906
|
-
const existingNoSize = tokens.fonts.find(f => !f.size);
|
|
907
|
-
if (existingNoSize) {
|
|
908
|
-
existingNoSize.size = size;
|
|
909
|
-
}
|
|
910
|
-
else {
|
|
911
|
-
tokens.fonts.push({ family: '', size, source: 'css' });
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
// Font weight
|
|
915
|
-
if (prop === 'font-weight') {
|
|
916
|
-
const existingNoWeight = tokens.fonts.find(f => !f.weight);
|
|
917
|
-
if (existingNoWeight) {
|
|
918
|
-
existingNoWeight.weight = rawValue.trim();
|
|
919
|
-
}
|
|
920
|
-
}
|
|
921
|
-
// Spacing
|
|
922
|
-
if (isSpacingProperty(prop)) {
|
|
923
|
-
extractSpacingValues(rawValue, tokens);
|
|
924
|
-
}
|
|
925
|
-
// Box shadow
|
|
926
|
-
if (prop === 'box-shadow' && rawValue.trim() !== 'none') {
|
|
927
|
-
if (!tokens.shadows.find(s => s.value === rawValue.trim())) {
|
|
928
|
-
tokens.shadows.push({ value: rawValue.trim() });
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
// Border radius — only collect single-value radii, skip CSS shorthand like "0 0 18px 18px"
|
|
932
|
-
if (prop === 'border-radius' || (prop.startsWith('border-') && prop.endsWith('-radius'))) {
|
|
933
|
-
const val = rawValue.trim();
|
|
934
|
-
// Skip shorthand values (contain spaces between lengths) and slash syntax
|
|
935
|
-
if (!val.includes(' ') && !val.includes('/')) {
|
|
936
|
-
if (!tokens.borderRadii.includes(val)) {
|
|
937
|
-
tokens.borderRadii.push(val);
|
|
938
|
-
}
|
|
939
|
-
}
|
|
940
|
-
}
|
|
941
|
-
// Gradients
|
|
942
|
-
if (rawValue.includes('gradient(')) {
|
|
943
|
-
tokens.gradients.push(rawValue.trim());
|
|
944
|
-
}
|
|
945
|
-
// Transitions
|
|
946
|
-
if (prop === 'transition') {
|
|
947
|
-
tokens.animations.push({
|
|
948
|
-
name: 'css-transition',
|
|
949
|
-
type: 'css-transition',
|
|
950
|
-
value: rawValue.trim(),
|
|
951
|
-
source: 'css',
|
|
952
|
-
});
|
|
953
|
-
// Parse durations and easings from shorthand
|
|
954
|
-
extractTransitionParts(rawValue, tokens);
|
|
955
|
-
}
|
|
956
|
-
// Transition sub-properties (skip var() references — normalizer will handle)
|
|
957
|
-
if (prop === 'transition-duration' || prop === 'animation-duration') {
|
|
958
|
-
const dur = rawValue.trim();
|
|
959
|
-
if (dur && !dur.includes('var(') && /^[\d.]+m?s$/.test(dur) && !tokens.transitionDurations.includes(dur)) {
|
|
960
|
-
tokens.transitionDurations.push(dur);
|
|
961
|
-
}
|
|
962
|
-
}
|
|
963
|
-
if (prop === 'transition-timing-function' || prop === 'animation-timing-function') {
|
|
964
|
-
const ease = rawValue.trim();
|
|
965
|
-
if (ease && !ease.includes('var(') && !tokens.transitionEasings.includes(ease)) {
|
|
966
|
-
tokens.transitionEasings.push(ease);
|
|
967
|
-
}
|
|
968
|
-
}
|
|
969
|
-
},
|
|
970
|
-
});
|
|
971
|
-
}
|
|
972
|
-
catch {
|
|
973
|
-
// Fallback: regex-based extraction for files css-tree can't parse
|
|
974
|
-
extractWithRegex(content, tokens);
|
|
975
|
-
}
|
|
976
|
-
}
|
|
977
|
-
function extractDarkModeBlocks(content, tokens) {
|
|
978
|
-
const rootVars = new Map();
|
|
979
|
-
const darkVars = new Map();
|
|
980
|
-
const rootBlocks = content.matchAll(/:root\s*\{([^}]+)\}/g);
|
|
981
|
-
for (const m of rootBlocks) {
|
|
982
|
-
const vars = m[1].matchAll(/(--[\w-]+)\s*:\s*([^;}\n]+)/g);
|
|
983
|
-
for (const v of vars) {
|
|
984
|
-
rootVars.set(v[1], v[2].trim());
|
|
985
|
-
}
|
|
986
|
-
}
|
|
987
|
-
const darkPatterns = [
|
|
988
|
-
/\.dark\s*\{([^}]+)\}/g,
|
|
989
|
-
/\[data-theme\s*=\s*["']dark["']\]\s*\{([^}]+)\}/g,
|
|
990
|
-
/\.dark\s+:root\s*\{([^}]+)\}/g,
|
|
991
|
-
/:root\.dark\s*\{([^}]+)\}/g,
|
|
992
|
-
/@media\s*\(\s*prefers-color-scheme\s*:\s*dark\s*\)\s*\{[^{]*:root\s*\{([^}]+)\}/g,
|
|
993
|
-
/@media\s*\(\s*prefers-color-scheme\s*:\s*dark\s*\)\s*\{([^}]+)\}/g,
|
|
994
|
-
];
|
|
995
|
-
for (const pattern of darkPatterns) {
|
|
996
|
-
const matches = content.matchAll(pattern);
|
|
997
|
-
for (const m of matches) {
|
|
998
|
-
const vars = m[1].matchAll(/(--[\w-]+)\s*:\s*([^;}\n]+)/g);
|
|
999
|
-
for (const v of vars) {
|
|
1000
|
-
darkVars.set(v[1], v[2].trim());
|
|
1001
|
-
}
|
|
1002
|
-
}
|
|
1003
|
-
}
|
|
1004
|
-
for (const [name, lightVal] of rootVars) {
|
|
1005
|
-
const darkVal = darkVars.get(name);
|
|
1006
|
-
if (darkVal && darkVal !== lightVal) {
|
|
1007
|
-
if (!tokens.darkModeVars.find(d => d.variable === name)) {
|
|
1008
|
-
tokens.darkModeVars.push({
|
|
1009
|
-
variable: name,
|
|
1010
|
-
lightValue: lightVal,
|
|
1011
|
-
darkValue: darkVal,
|
|
1012
|
-
});
|
|
1013
|
-
}
|
|
1014
|
-
}
|
|
1015
|
-
}
|
|
1016
|
-
}
|
|
1017
|
-
function extractKeyframes(content, tokens) {
|
|
1018
|
-
const keyframeMatches = content.matchAll(/@keyframes\s+([\w-]+)\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/g);
|
|
1019
|
-
for (const m of keyframeMatches) {
|
|
1020
|
-
if (!tokens.animations.find(a => a.name === m[1])) {
|
|
1021
|
-
tokens.animations.push({
|
|
1022
|
-
name: m[1],
|
|
1023
|
-
type: 'css-keyframe',
|
|
1024
|
-
value: m[2].trim().slice(0, 200),
|
|
1025
|
-
source: 'css',
|
|
1026
|
-
});
|
|
1027
|
-
}
|
|
1028
|
-
}
|
|
1029
|
-
}
|
|
1030
|
-
function extractFontFace(content, tokens, cssBaseUrl) {
|
|
1031
|
-
const fontFaceMatches = content.matchAll(/@font-face\s*\{([^}]+)\}/g);
|
|
1032
|
-
for (const m of fontFaceMatches) {
|
|
1033
|
-
const familyMatch = m[1].match(/font-family\s*:\s*["']?([^"';,]+)["']?/);
|
|
1034
|
-
const weightMatch = m[1].match(/font-weight\s*:\s*(\d+)/);
|
|
1035
|
-
if (familyMatch) {
|
|
1036
|
-
const family = familyMatch[1].trim();
|
|
1037
|
-
if (!isValidFontName(family))
|
|
1038
|
-
continue;
|
|
1039
|
-
// Skip icon fonts — they pollute typography documentation
|
|
1040
|
-
if (isIconFont(family))
|
|
1041
|
-
continue;
|
|
1042
|
-
// Skip system fonts — they can't be downloaded and are implicit
|
|
1043
|
-
if (isSystemFont(family))
|
|
1044
|
-
continue;
|
|
1045
|
-
if (!tokens.fonts.find(f => f.family === family)) {
|
|
1046
|
-
tokens.fonts.push({
|
|
1047
|
-
family,
|
|
1048
|
-
weight: weightMatch ? weightMatch[1] : undefined,
|
|
1049
|
-
source: 'css',
|
|
1050
|
-
});
|
|
1051
|
-
}
|
|
1052
|
-
// Extract font source URLs — skip icon/symbol font sources
|
|
1053
|
-
const srcMatches = m[1].matchAll(/url\(\s*["']?([^"')]+)["']?\s*\)/g);
|
|
1054
|
-
for (const srcMatch of srcMatches) {
|
|
1055
|
-
let srcUrl = srcMatch[1].trim();
|
|
1056
|
-
// Resolve relative URLs to absolute
|
|
1057
|
-
if (cssBaseUrl && !srcUrl.startsWith('http') && !srcUrl.startsWith('data:')) {
|
|
1058
|
-
srcUrl = resolveUrl(srcUrl, cssBaseUrl);
|
|
1059
|
-
}
|
|
1060
|
-
// Detect format
|
|
1061
|
-
const formatMatch = m[1].match(new RegExp(`url\\([^)]*${escapeRegex(srcUrl)}[^)]*\\)\\s*format\\(\\s*["']?([^"')]+)["']?\\s*\\)`));
|
|
1062
|
-
const format = formatMatch ? formatMatch[1] : guessFormatFromUrl(srcUrl);
|
|
1063
|
-
if (!tokens.fontSources.find(fs => fs.family === family && fs.src === srcUrl)) {
|
|
1064
|
-
tokens.fontSources.push({
|
|
1065
|
-
family,
|
|
1066
|
-
src: srcUrl,
|
|
1067
|
-
format,
|
|
1068
|
-
weight: weightMatch ? weightMatch[1] : undefined,
|
|
1069
|
-
});
|
|
1070
|
-
}
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
}
|
|
1074
|
-
}
|
|
1075
|
-
function escapeRegex(s) {
|
|
1076
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1077
|
-
}
|
|
1078
|
-
function guessFormatFromUrl(url) {
|
|
1079
|
-
if (url.endsWith('.woff2'))
|
|
1080
|
-
return 'woff2';
|
|
1081
|
-
if (url.endsWith('.woff'))
|
|
1082
|
-
return 'woff';
|
|
1083
|
-
if (url.endsWith('.ttf'))
|
|
1084
|
-
return 'truetype';
|
|
1085
|
-
if (url.endsWith('.otf'))
|
|
1086
|
-
return 'opentype';
|
|
1087
|
-
if (url.endsWith('.eot'))
|
|
1088
|
-
return 'embedded-opentype';
|
|
1089
|
-
return undefined;
|
|
1090
|
-
}
|
|
1091
|
-
function extractMediaBreakpoints(content, tokens) {
|
|
1092
|
-
const mediaMatches = content.matchAll(/@media[^{]*\(\s*min-width\s*:\s*([\d.]+)(px|em|rem)\s*\)/g);
|
|
1093
|
-
for (const m of mediaMatches) {
|
|
1094
|
-
const value = `${m[1]}${m[2]}`;
|
|
1095
|
-
if (!tokens.breakpoints.find(b => b.value === value)) {
|
|
1096
|
-
tokens.breakpoints.push({
|
|
1097
|
-
name: nameBreakpoint(parseFloat(m[1]), m[2]),
|
|
1098
|
-
value,
|
|
1099
|
-
source: 'css',
|
|
1100
|
-
});
|
|
1101
|
-
}
|
|
1102
|
-
}
|
|
1103
|
-
// Also max-width breakpoints
|
|
1104
|
-
const maxMediaMatches = content.matchAll(/@media[^{]*\(\s*max-width\s*:\s*([\d.]+)(px|em|rem)\s*\)/g);
|
|
1105
|
-
for (const m of maxMediaMatches) {
|
|
1106
|
-
const value = `${m[1]}${m[2]}`;
|
|
1107
|
-
if (!tokens.breakpoints.find(b => b.value === value)) {
|
|
1108
|
-
tokens.breakpoints.push({
|
|
1109
|
-
name: nameBreakpoint(parseFloat(m[1]), m[2]),
|
|
1110
|
-
value,
|
|
1111
|
-
source: 'css',
|
|
1112
|
-
});
|
|
1113
|
-
}
|
|
1114
|
-
}
|
|
1115
|
-
}
|
|
1116
|
-
function nameBreakpoint(px, unit) {
|
|
1117
|
-
if (unit === 'em' || unit === 'rem')
|
|
1118
|
-
px *= 16;
|
|
1119
|
-
if (px <= 480)
|
|
1120
|
-
return 'xs';
|
|
1121
|
-
if (px <= 640)
|
|
1122
|
-
return 'sm';
|
|
1123
|
-
if (px <= 768)
|
|
1124
|
-
return 'md';
|
|
1125
|
-
if (px <= 1024)
|
|
1126
|
-
return 'lg';
|
|
1127
|
-
if (px <= 1280)
|
|
1128
|
-
return 'xl';
|
|
1129
|
-
return '2xl';
|
|
1130
|
-
}
|
|
1131
|
-
function extractZIndex(content, tokens) {
|
|
1132
|
-
const zMatches = content.matchAll(/z-index\s*:\s*(\d+)/g);
|
|
1133
|
-
for (const m of zMatches) {
|
|
1134
|
-
const val = parseInt(m[1]);
|
|
1135
|
-
if (!tokens.zIndexValues.includes(val)) {
|
|
1136
|
-
tokens.zIndexValues.push(val);
|
|
1137
|
-
}
|
|
1138
|
-
}
|
|
1139
|
-
}
|
|
1140
|
-
function extractContainerWidth(content, tokens) {
|
|
1141
|
-
const maxWidthMatches = content.matchAll(/max-width\s*:\s*([\d.]+)(px|rem|em|%)/g);
|
|
1142
|
-
for (const m of maxWidthMatches) {
|
|
1143
|
-
let px = parseFloat(m[1]);
|
|
1144
|
-
if (m[2] === 'rem' || m[2] === 'em')
|
|
1145
|
-
px *= 16;
|
|
1146
|
-
if (px >= 960 && px <= 1600) {
|
|
1147
|
-
tokens.containerMaxWidth = `${m[1]}${m[2]}`;
|
|
1148
|
-
}
|
|
1149
|
-
}
|
|
1150
|
-
}
|
|
1151
|
-
function extractWithRegex(content, tokens) {
|
|
1152
|
-
// Hex colors
|
|
1153
|
-
const hexMatches = content.matchAll(/#([0-9a-fA-F]{3,8})\b/g);
|
|
1154
|
-
for (const m of hexMatches) {
|
|
1155
|
-
addColor(tokens, normalizeHex(m[0]), 'css');
|
|
1156
|
-
}
|
|
1157
|
-
// RGB/RGBA
|
|
1158
|
-
const rgbMatches = content.matchAll(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/g);
|
|
1159
|
-
for (const m of rgbMatches) {
|
|
1160
|
-
addColor(tokens, rgbToHex(parseInt(m[1]), parseInt(m[2]), parseInt(m[3])), 'css');
|
|
1161
|
-
}
|
|
1162
|
-
// HSL
|
|
1163
|
-
const hslMatches = content.matchAll(/hsla?\(\s*([\d.]+)\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%/g);
|
|
1164
|
-
for (const m of hslMatches) {
|
|
1165
|
-
addColor(tokens, hslToHex(parseFloat(m[1]), parseFloat(m[2]), parseFloat(m[3])), 'css');
|
|
1166
|
-
}
|
|
1167
|
-
// CSS variables
|
|
1168
|
-
const varMatches = content.matchAll(/(--[\w-]+)\s*:\s*([^;}\n]+)/g);
|
|
1169
|
-
for (const m of varMatches) {
|
|
1170
|
-
tokens.cssVariables.push({
|
|
1171
|
-
name: m[1],
|
|
1172
|
-
value: m[2].trim(),
|
|
1173
|
-
property: guessPropertyType(m[1]),
|
|
1174
|
-
});
|
|
1175
|
-
if (isColorVarName(m[1])) {
|
|
1176
|
-
const hex = tryParseColor(m[2].trim());
|
|
1177
|
-
if (hex)
|
|
1178
|
-
addColor(tokens, hex, 'css', m[1].replace(/^--/, ''));
|
|
1179
|
-
}
|
|
1180
|
-
}
|
|
1181
|
-
// Font families
|
|
1182
|
-
const fontMatches = content.matchAll(/font-family\s*:\s*([^;}\n]+)/g);
|
|
1183
|
-
for (const m of fontMatches) {
|
|
1184
|
-
const family = m[1].replace(/["']/g, '').split(',')[0].trim();
|
|
1185
|
-
if (family && !isGenericFont(family) && isValidFontName(family) && !tokens.fonts.find(f => f.family === family)) {
|
|
1186
|
-
tokens.fonts.push({ family, source: 'css' });
|
|
1187
|
-
}
|
|
1188
|
-
}
|
|
1189
|
-
// Box shadows
|
|
1190
|
-
const shadowMatches = content.matchAll(/box-shadow\s*:\s*([^;}\n]+)/g);
|
|
1191
|
-
for (const m of shadowMatches) {
|
|
1192
|
-
const val = m[1].trim();
|
|
1193
|
-
if (val !== 'none' && !tokens.shadows.find(s => s.value === val)) {
|
|
1194
|
-
tokens.shadows.push({ value: val });
|
|
1195
|
-
}
|
|
1196
|
-
}
|
|
1197
|
-
// Border radius
|
|
1198
|
-
const radiusMatches = content.matchAll(/border-radius\s*:\s*([^;}\n]+)/g);
|
|
1199
|
-
for (const m of radiusMatches) {
|
|
1200
|
-
const val = m[1].trim();
|
|
1201
|
-
if (!tokens.borderRadii.includes(val)) {
|
|
1202
|
-
tokens.borderRadii.push(val);
|
|
1203
|
-
}
|
|
1204
|
-
}
|
|
1205
|
-
}
|
|
1206
|
-
function extractColorsFromValue(value, tokens) {
|
|
1207
|
-
const hexMatches = value.matchAll(/#([0-9a-fA-F]{3,8})\b/g);
|
|
1208
|
-
for (const m of hexMatches) {
|
|
1209
|
-
addColor(tokens, normalizeHex(m[0]), 'css');
|
|
1210
|
-
}
|
|
1211
|
-
const rgbMatches = value.matchAll(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/g);
|
|
1212
|
-
for (const m of rgbMatches) {
|
|
1213
|
-
addColor(tokens, rgbToHex(parseInt(m[1]), parseInt(m[2]), parseInt(m[3])), 'css');
|
|
1214
|
-
}
|
|
1215
|
-
}
|
|
1216
|
-
function extractSpacingValues(value, tokens) {
|
|
1217
|
-
const nums = value.matchAll(/([\d.]+)(px|rem|em)/g);
|
|
1218
|
-
for (const m of nums) {
|
|
1219
|
-
let px = parseFloat(m[1]);
|
|
1220
|
-
if (m[2] === 'rem' || m[2] === 'em')
|
|
1221
|
-
px *= 16;
|
|
1222
|
-
if (px > 0 && px <= 200) {
|
|
1223
|
-
tokens.spacingValues.push(Math.round(px));
|
|
1224
|
-
}
|
|
1225
|
-
}
|
|
1226
|
-
}
|
|
1227
|
-
// ── HTML Component Detection ──────────────────────────────────────────
|
|
1228
|
-
function detectHTMLComponents(html, css) {
|
|
1229
|
-
const components = [];
|
|
1230
|
-
const emptyTailwind = { backgrounds: [], borders: [], spacing: [], typography: [], effects: [], layout: [], interactive: [] };
|
|
1231
|
-
// Build a CSS rule lookup: class name → style declarations
|
|
1232
|
-
const cssRules = buildCSSRuleLookup(css);
|
|
1233
|
-
// 1. Buttons — detect from HTML tags, JSX, and class patterns
|
|
1234
|
-
const buttonPatterns = [
|
|
1235
|
-
/<button[^>]*class\s*=\s*["']([^"']*)["'][^>]*>([\s\S]*?)<\/button>/gi,
|
|
1236
|
-
/<a[^>]*class\s*=\s*["'][^"']*\b(btn|button|cta)\b[^"']*["'][^>]*>/gi,
|
|
1237
|
-
];
|
|
1238
|
-
const buttonClasses = new Set();
|
|
1239
|
-
for (const pattern of buttonPatterns) {
|
|
1240
|
-
const matches = html.matchAll(pattern);
|
|
1241
|
-
for (const m of matches) {
|
|
1242
|
-
const classes = (m[1] || '').split(/\s+/).filter(Boolean);
|
|
1243
|
-
classes.forEach(c => buttonClasses.add(c));
|
|
1244
|
-
}
|
|
1245
|
-
}
|
|
1246
|
-
// Also detect JSX button patterns: _jsx("button", ...) or createElement("button", ...)
|
|
1247
|
-
const jsxButtonPattern = /(?:_jsx|createElement)\s*\(\s*["']button["']/gi;
|
|
1248
|
-
const hasJSXButtons = jsxButtonPattern.test(html);
|
|
1249
|
-
if (buttonClasses.size > 0 || /<button[\s>]/i.test(html) || hasJSXButtons) {
|
|
1250
|
-
const btnStyles = extractMatchingStyles(cssRules, buttonClasses, ['btn', 'button', 'cta']);
|
|
1251
|
-
components.push({
|
|
1252
|
-
name: 'Button',
|
|
1253
|
-
filePath: 'html',
|
|
1254
|
-
variants: findVariants(buttonClasses, 'btn'),
|
|
1255
|
-
cssClasses: [...buttonClasses].slice(0, 10),
|
|
1256
|
-
jsxSnippet: '',
|
|
1257
|
-
props: [],
|
|
1258
|
-
category: 'data-input',
|
|
1259
|
-
hasAnimation: btnStyles.includes('transition'),
|
|
1260
|
-
animationDetails: [],
|
|
1261
|
-
statePatterns: [],
|
|
1262
|
-
tailwindPatterns: emptyTailwind,
|
|
1263
|
-
});
|
|
1264
|
-
}
|
|
1265
|
-
// 2. Inputs
|
|
1266
|
-
const hasInputs = /<input[^>]*type\s*=\s*["'](text|email|password|search|url|tel|number)["']/i.test(html)
|
|
1267
|
-
|| /<textarea/i.test(html)
|
|
1268
|
-
|| /<select/i.test(html)
|
|
1269
|
-
|| /(?:_jsx|createElement)\s*\(\s*["']input["']/i.test(html)
|
|
1270
|
-
|| /(?:_jsx|createElement)\s*\(\s*["']textarea["']/i.test(html);
|
|
1271
|
-
if (hasInputs) {
|
|
1272
|
-
const inputClasses = extractElementClasses(html, 'input');
|
|
1273
|
-
const inputStyles = extractMatchingStyles(cssRules, inputClasses, ['input', 'field', 'form']);
|
|
1274
|
-
components.push({
|
|
1275
|
-
name: 'Input',
|
|
1276
|
-
filePath: 'html',
|
|
1277
|
-
variants: [],
|
|
1278
|
-
cssClasses: [...inputClasses].slice(0, 10),
|
|
1279
|
-
jsxSnippet: '',
|
|
1280
|
-
props: [],
|
|
1281
|
-
category: 'data-input',
|
|
1282
|
-
hasAnimation: false,
|
|
1283
|
-
animationDetails: [],
|
|
1284
|
-
statePatterns: [':focus', ':placeholder'],
|
|
1285
|
-
tailwindPatterns: emptyTailwind,
|
|
1286
|
-
});
|
|
1287
|
-
}
|
|
1288
|
-
// 3. Cards
|
|
1289
|
-
const cardClasses = extractClassesByPattern(html, /\bcard\b/i);
|
|
1290
|
-
if (cardClasses.size >= 2) {
|
|
1291
|
-
components.push({
|
|
1292
|
-
name: 'Card',
|
|
1293
|
-
filePath: 'html',
|
|
1294
|
-
variants: findVariants(cardClasses, 'card'),
|
|
1295
|
-
cssClasses: [...cardClasses].slice(0, 10),
|
|
1296
|
-
jsxSnippet: '',
|
|
1297
|
-
props: [],
|
|
1298
|
-
category: 'data-display',
|
|
1299
|
-
hasAnimation: false,
|
|
1300
|
-
animationDetails: [],
|
|
1301
|
-
statePatterns: [],
|
|
1302
|
-
tailwindPatterns: emptyTailwind,
|
|
1303
|
-
});
|
|
1304
|
-
}
|
|
1305
|
-
// 4. Navigation / Header
|
|
1306
|
-
if (/<nav[\s>]/i.test(html) || /<header[\s>]/i.test(html) || /(?:_jsx|createElement)\s*\(\s*["']nav["']/i.test(html)) {
|
|
1307
|
-
const navClasses = new Set();
|
|
1308
|
-
extractElementClasses(html, 'nav').forEach(c => navClasses.add(c));
|
|
1309
|
-
extractElementClasses(html, 'header').forEach(c => navClasses.add(c));
|
|
1310
|
-
const linkCount = countChildLinks(html, 'nav') || countChildLinks(html, 'header');
|
|
1311
|
-
components.push({
|
|
1312
|
-
name: 'Navigation',
|
|
1313
|
-
filePath: 'html',
|
|
1314
|
-
variants: [],
|
|
1315
|
-
cssClasses: [...navClasses].slice(0, 10),
|
|
1316
|
-
jsxSnippet: '',
|
|
1317
|
-
props: [],
|
|
1318
|
-
category: 'navigation',
|
|
1319
|
-
hasAnimation: false,
|
|
1320
|
-
animationDetails: [],
|
|
1321
|
-
statePatterns: [],
|
|
1322
|
-
tailwindPatterns: emptyTailwind,
|
|
1323
|
-
});
|
|
1324
|
-
}
|
|
1325
|
-
// 5. Chips / Tags / Badges
|
|
1326
|
-
const chipClasses = extractClassesByPattern(html, /\b(chip|tag|badge|label|pill)\b/i);
|
|
1327
|
-
if (chipClasses.size > 0) {
|
|
1328
|
-
components.push({
|
|
1329
|
-
name: 'Badge',
|
|
1330
|
-
filePath: 'html',
|
|
1331
|
-
variants: [],
|
|
1332
|
-
cssClasses: [...chipClasses].slice(0, 10),
|
|
1333
|
-
jsxSnippet: '',
|
|
1334
|
-
props: [],
|
|
1335
|
-
category: 'data-display',
|
|
1336
|
-
hasAnimation: false,
|
|
1337
|
-
animationDetails: [],
|
|
1338
|
-
statePatterns: [],
|
|
1339
|
-
tailwindPatterns: emptyTailwind,
|
|
1340
|
-
});
|
|
1341
|
-
}
|
|
1342
|
-
// 6. Modal / Dialog
|
|
1343
|
-
const modalClasses = extractClassesByPattern(html, /\b(modal|dialog|drawer|overlay|popup)\b/i);
|
|
1344
|
-
if (modalClasses.size > 0) {
|
|
1345
|
-
components.push({
|
|
1346
|
-
name: 'Modal',
|
|
1347
|
-
filePath: 'html',
|
|
1348
|
-
variants: [],
|
|
1349
|
-
cssClasses: [...modalClasses].slice(0, 10),
|
|
1350
|
-
jsxSnippet: '',
|
|
1351
|
-
props: [],
|
|
1352
|
-
category: 'overlay',
|
|
1353
|
-
hasAnimation: false,
|
|
1354
|
-
animationDetails: [],
|
|
1355
|
-
statePatterns: [],
|
|
1356
|
-
tailwindPatterns: emptyTailwind,
|
|
1357
|
-
});
|
|
1358
|
-
}
|
|
1359
|
-
// 7. Footer
|
|
1360
|
-
if (/<footer[\s>]/i.test(html) || /(?:_jsx|createElement)\s*\(\s*["']footer["']/i.test(html)) {
|
|
1361
|
-
const footerClasses = extractElementClasses(html, 'footer');
|
|
1362
|
-
components.push({
|
|
1363
|
-
name: 'Footer',
|
|
1364
|
-
filePath: 'html',
|
|
1365
|
-
variants: [],
|
|
1366
|
-
cssClasses: [...footerClasses].slice(0, 10),
|
|
1367
|
-
jsxSnippet: '',
|
|
1368
|
-
props: [],
|
|
1369
|
-
category: 'layout',
|
|
1370
|
-
hasAnimation: false,
|
|
1371
|
-
animationDetails: [],
|
|
1372
|
-
statePatterns: [],
|
|
1373
|
-
tailwindPatterns: emptyTailwind,
|
|
1374
|
-
});
|
|
1375
|
-
}
|
|
1376
|
-
// 8. Images / Media (hero images, avatars)
|
|
1377
|
-
const imgCount = (html.match(/<img\s/gi) || []).length;
|
|
1378
|
-
if (imgCount >= 3) {
|
|
1379
|
-
components.push({
|
|
1380
|
-
name: 'Image',
|
|
1381
|
-
filePath: 'html',
|
|
1382
|
-
variants: [],
|
|
1383
|
-
cssClasses: [],
|
|
1384
|
-
jsxSnippet: '',
|
|
1385
|
-
props: [],
|
|
1386
|
-
category: 'media',
|
|
1387
|
-
hasAnimation: false,
|
|
1388
|
-
animationDetails: [],
|
|
1389
|
-
statePatterns: [],
|
|
1390
|
-
tailwindPatterns: emptyTailwind,
|
|
1391
|
-
});
|
|
1392
|
-
}
|
|
1393
|
-
// 9. SVG / Icon patterns
|
|
1394
|
-
const svgCount = (html.match(/<svg[\s>]/gi) || []).length + (html.match(/(?:_jsx|createElement)\s*\(\s*["']svg["']/gi) || []).length;
|
|
1395
|
-
if (svgCount >= 3 || /lucide-react|heroicons|@phosphor|react-icons/i.test(html)) {
|
|
1396
|
-
components.push({
|
|
1397
|
-
name: 'Icon',
|
|
1398
|
-
filePath: 'html',
|
|
1399
|
-
variants: [],
|
|
1400
|
-
cssClasses: [],
|
|
1401
|
-
jsxSnippet: '',
|
|
1402
|
-
props: [],
|
|
1403
|
-
category: 'media',
|
|
1404
|
-
hasAnimation: false,
|
|
1405
|
-
animationDetails: [],
|
|
1406
|
-
statePatterns: [],
|
|
1407
|
-
tailwindPatterns: emptyTailwind,
|
|
1408
|
-
});
|
|
1409
|
-
}
|
|
1410
|
-
// 10. Lists / Timeline
|
|
1411
|
-
const listClasses = extractClassesByPattern(html, /\b(list|timeline|steps|progress)\b/i);
|
|
1412
|
-
if (listClasses.size > 0) {
|
|
1413
|
-
components.push({
|
|
1414
|
-
name: 'List',
|
|
1415
|
-
filePath: 'html',
|
|
1416
|
-
variants: [],
|
|
1417
|
-
cssClasses: [...listClasses].slice(0, 10),
|
|
1418
|
-
jsxSnippet: '',
|
|
1419
|
-
props: [],
|
|
1420
|
-
category: 'data-display',
|
|
1421
|
-
hasAnimation: false,
|
|
1422
|
-
animationDetails: [],
|
|
1423
|
-
statePatterns: [],
|
|
1424
|
-
tailwindPatterns: emptyTailwind,
|
|
1425
|
-
});
|
|
1426
|
-
}
|
|
1427
|
-
// 11. Map / Canvas (interactive visualization)
|
|
1428
|
-
if (/<canvas[\s>]/i.test(html) || /mapbox|leaflet|google.*maps/i.test(html) || /(?:_jsx|createElement)\s*\(\s*["']canvas["']/i.test(html) || /\bd3\b.*select|topojson/i.test(html)) {
|
|
1429
|
-
components.push({
|
|
1430
|
-
name: 'Map/Canvas',
|
|
1431
|
-
filePath: 'html',
|
|
1432
|
-
variants: [],
|
|
1433
|
-
cssClasses: [],
|
|
1434
|
-
jsxSnippet: '',
|
|
1435
|
-
props: [],
|
|
1436
|
-
category: 'media',
|
|
1437
|
-
hasAnimation: false,
|
|
1438
|
-
animationDetails: [],
|
|
1439
|
-
statePatterns: [],
|
|
1440
|
-
tailwindPatterns: emptyTailwind,
|
|
1441
|
-
});
|
|
1442
|
-
}
|
|
1443
|
-
return components;
|
|
1444
|
-
}
|
|
1445
|
-
function buildCSSRuleLookup(css) {
|
|
1446
|
-
const rules = new Map();
|
|
1447
|
-
// Simple regex-based extraction: .class-name { ... }
|
|
1448
|
-
const ruleMatches = css.matchAll(/\.([\w-]+)\s*\{([^}]*)\}/g);
|
|
1449
|
-
for (const m of ruleMatches) {
|
|
1450
|
-
const className = m[1];
|
|
1451
|
-
const declarations = m[2].trim();
|
|
1452
|
-
if (!rules.has(className)) {
|
|
1453
|
-
rules.set(className, []);
|
|
1454
|
-
}
|
|
1455
|
-
rules.get(className).push(declarations);
|
|
1456
|
-
}
|
|
1457
|
-
return rules;
|
|
1458
|
-
}
|
|
1459
|
-
function extractMatchingStyles(cssRules, classes, patterns) {
|
|
1460
|
-
const allDecls = [];
|
|
1461
|
-
for (const cls of classes) {
|
|
1462
|
-
const decls = cssRules.get(cls);
|
|
1463
|
-
if (decls)
|
|
1464
|
-
allDecls.push(...decls);
|
|
1465
|
-
}
|
|
1466
|
-
for (const pattern of patterns) {
|
|
1467
|
-
for (const [cls, decls] of cssRules) {
|
|
1468
|
-
if (cls.includes(pattern))
|
|
1469
|
-
allDecls.push(...decls);
|
|
1470
|
-
}
|
|
1471
|
-
}
|
|
1472
|
-
return allDecls.join('; ');
|
|
1473
|
-
}
|
|
1474
|
-
function extractElementClasses(html, tag) {
|
|
1475
|
-
const classes = new Set();
|
|
1476
|
-
// HTML: <tag class="...">
|
|
1477
|
-
const regex = new RegExp(`<${tag}[^>]*class\\s*=\\s*["']([^"']*)["']`, 'gi');
|
|
1478
|
-
const matches = html.matchAll(regex);
|
|
1479
|
-
for (const m of matches) {
|
|
1480
|
-
m[1].split(/\s+/).filter(Boolean).forEach(c => classes.add(c));
|
|
1481
|
-
}
|
|
1482
|
-
// JSX: _jsx("tag", { className: "..." })
|
|
1483
|
-
const jsxRegex = new RegExp(`(?:_jsx|createElement)\\s*\\(\\s*["']${tag}["'][^)]*className\\s*:\\s*["'\`]([^"'\`]*)["'\`]`, 'gi');
|
|
1484
|
-
const jsxMatches = html.matchAll(jsxRegex);
|
|
1485
|
-
for (const m of jsxMatches) {
|
|
1486
|
-
m[1].split(/\s+/).filter(Boolean).forEach(c => classes.add(c));
|
|
1487
|
-
}
|
|
1488
|
-
return classes;
|
|
1489
|
-
}
|
|
1490
|
-
function extractClassesByPattern(html, pattern) {
|
|
1491
|
-
const classes = new Set();
|
|
1492
|
-
// Match HTML class="..." and JSX className="..."
|
|
1493
|
-
const allClassAttrs = html.matchAll(/(?:class|className)\s*[:=]\s*["'`]([^"'`]*)["'`]/gi);
|
|
1494
|
-
for (const m of allClassAttrs) {
|
|
1495
|
-
const classList = m[1].split(/\s+/).filter(Boolean);
|
|
1496
|
-
for (const cls of classList) {
|
|
1497
|
-
if (pattern.test(cls)) {
|
|
1498
|
-
classes.add(cls);
|
|
1499
|
-
}
|
|
1500
|
-
}
|
|
1501
|
-
}
|
|
1502
|
-
return classes;
|
|
1503
|
-
}
|
|
1504
|
-
function findVariants(classes, base) {
|
|
1505
|
-
const variants = [];
|
|
1506
|
-
for (const cls of classes) {
|
|
1507
|
-
if (cls.includes(base) && cls !== base) {
|
|
1508
|
-
const variant = cls.replace(new RegExp(`.*${base}[-_]?`, 'i'), '');
|
|
1509
|
-
if (variant && variant.length < 20)
|
|
1510
|
-
variants.push(variant);
|
|
1511
|
-
}
|
|
1512
|
-
}
|
|
1513
|
-
return [...new Set(variants)].slice(0, 5);
|
|
1514
|
-
}
|
|
1515
|
-
function buildDarkModePairs(tokens) {
|
|
1516
|
-
const seen = new Set();
|
|
1517
|
-
tokens.darkModeVars = tokens.darkModeVars.filter(v => {
|
|
1518
|
-
if (seen.has(v.variable))
|
|
1519
|
-
return false;
|
|
1520
|
-
seen.add(v.variable);
|
|
1521
|
-
return true;
|
|
1522
|
-
});
|
|
1523
|
-
}
|
|
1524
|
-
// ── Utility Helpers ───────────────────────────────────────────────────
|
|
1525
|
-
async function fetchText(url) {
|
|
1526
|
-
try {
|
|
1527
|
-
const controller = new AbortController();
|
|
1528
|
-
const timeout = setTimeout(() => controller.abort(), 15000);
|
|
1529
|
-
const response = await fetch(url, {
|
|
1530
|
-
signal: controller.signal,
|
|
1531
|
-
headers: {
|
|
1532
|
-
'User-Agent': 'Mozilla/5.0 (compatible; skillui/1.0; +https://github.com/amaanbuilds/skillui)',
|
|
1533
|
-
'Accept': 'text/html,text/css,application/xhtml+xml,*/*',
|
|
1534
|
-
},
|
|
1535
|
-
redirect: 'follow',
|
|
1536
|
-
});
|
|
1537
|
-
clearTimeout(timeout);
|
|
1538
|
-
if (!response.ok)
|
|
1539
|
-
return null;
|
|
1540
|
-
return await response.text();
|
|
1541
|
-
}
|
|
1542
|
-
catch {
|
|
1543
|
-
return null;
|
|
1544
|
-
}
|
|
1545
|
-
}
|
|
1546
|
-
function resolveUrl(href, base) {
|
|
1547
|
-
try {
|
|
1548
|
-
return new URL(href, base).href;
|
|
1549
|
-
}
|
|
1550
|
-
catch {
|
|
1551
|
-
return href;
|
|
1552
|
-
}
|
|
1553
|
-
}
|
|
1554
|
-
function addColor(tokens, hex, source, name) {
|
|
1555
|
-
if (!hex || !isValidHex(hex))
|
|
1556
|
-
return;
|
|
1557
|
-
const existing = tokens.colors.find(c => c.value === hex);
|
|
1558
|
-
if (existing) {
|
|
1559
|
-
existing.frequency++;
|
|
1560
|
-
if (name && !existing.name)
|
|
1561
|
-
existing.name = name;
|
|
1562
|
-
}
|
|
1563
|
-
else {
|
|
1564
|
-
tokens.colors.push({ value: hex, frequency: 1, source, name });
|
|
1565
|
-
}
|
|
1566
|
-
}
|
|
1567
|
-
function tryParseColor(value) {
|
|
1568
|
-
// Hex
|
|
1569
|
-
const hexMatch = value.match(/^#([0-9a-fA-F]{3,8})$/);
|
|
1570
|
-
if (hexMatch)
|
|
1571
|
-
return normalizeHex(value);
|
|
1572
|
-
// rgb()
|
|
1573
|
-
const rgbMatch = value.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
|
1574
|
-
if (rgbMatch)
|
|
1575
|
-
return rgbToHex(parseInt(rgbMatch[1]), parseInt(rgbMatch[2]), parseInt(rgbMatch[3]));
|
|
1576
|
-
// hsl()
|
|
1577
|
-
const hslMatch = value.match(/^hsla?\(\s*([\d.]+)\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%/);
|
|
1578
|
-
if (hslMatch)
|
|
1579
|
-
return hslToHex(parseFloat(hslMatch[1]), parseFloat(hslMatch[2]), parseFloat(hslMatch[3]));
|
|
1580
|
-
// Bare HSL: "220 20% 10%"
|
|
1581
|
-
const bareHslMatch = value.match(/^\s*([\d.]+)\s+([\d.]+)%\s+([\d.]+)%\s*$/);
|
|
1582
|
-
if (bareHslMatch)
|
|
1583
|
-
return hslToHex(parseFloat(bareHslMatch[1]), parseFloat(bareHslMatch[2]), parseFloat(bareHslMatch[3]));
|
|
1584
|
-
return null;
|
|
1585
|
-
}
|
|
1586
|
-
function isColorProperty(prop) {
|
|
1587
|
-
return /^(color|background-color|background|border-color|outline-color|fill|stroke|accent-color|caret-color|text-decoration-color|column-rule-color)$/i.test(prop);
|
|
1588
|
-
}
|
|
1589
|
-
function isSpacingProperty(prop) {
|
|
1590
|
-
return /^(margin|padding|gap|row-gap|column-gap|margin-top|margin-right|margin-bottom|margin-left|padding-top|padding-right|padding-bottom|padding-left|top|right|bottom|left)$/i.test(prop);
|
|
1591
|
-
}
|
|
1592
|
-
function guessPropertyType(varName) {
|
|
1593
|
-
if (/color|bg|background|foreground/i.test(varName))
|
|
1594
|
-
return 'color';
|
|
1595
|
-
if (/font|family|typeface/i.test(varName))
|
|
1596
|
-
return 'font';
|
|
1597
|
-
if (/size|spacing|gap|padding|margin|radius/i.test(varName))
|
|
1598
|
-
return 'spacing';
|
|
1599
|
-
if (/shadow|elevation/i.test(varName))
|
|
1600
|
-
return 'shadow';
|
|
1601
|
-
return 'unknown';
|
|
1602
|
-
}
|
|
1603
|
-
function isColorVarName(name) {
|
|
1604
|
-
return /color|background|foreground|primary|secondary|accent|muted|destructive|border|card|popover|ring|input|chart|surface|text|brand|success|danger|warning|error|info/i.test(name) &&
|
|
1605
|
-
!/font|size|spacing|radius|shadow|width|height|duration|delay|family|weight|line/i.test(name);
|
|
1606
|
-
}
|
|
1607
|
-
function isGenericFont(f) {
|
|
1608
|
-
return /^(sans-serif|serif|monospace|cursive|fantasy|system-ui|ui-sans-serif|ui-serif|ui-monospace|inherit|initial|unset)$/i.test(f);
|
|
1609
|
-
}
|
|
1610
|
-
function isValidFontName(f) {
|
|
1611
|
-
// Reject font names that contain CSS syntax artifacts
|
|
1612
|
-
// These indicate a broken/concatenated CSS rule was parsed as a font name
|
|
1613
|
-
if (/[{};()]/.test(f))
|
|
1614
|
-
return false;
|
|
1615
|
-
// Reject names containing newlines or HTML tags
|
|
1616
|
-
if (/[\n\r<>]/.test(f))
|
|
1617
|
-
return false;
|
|
1618
|
-
// Reject CSS property names mistakenly captured
|
|
1619
|
-
if (/^\s*(font-family|font-size|font-weight|color|background|margin|padding)\b/i.test(f))
|
|
1620
|
-
return false;
|
|
1621
|
-
// Reject names starting with var(
|
|
1622
|
-
if (/^var\(/i.test(f))
|
|
1623
|
-
return false;
|
|
1624
|
-
// Reject names that are just numbers
|
|
1625
|
-
if (/^\d+(\.\d+)?(px|rem|em|%)?$/.test(f))
|
|
1626
|
-
return false;
|
|
1627
|
-
// Reject names longer than 50 chars (no real font name is that long)
|
|
1628
|
-
if (f.length > 50)
|
|
1629
|
-
return false;
|
|
1630
|
-
return true;
|
|
1631
|
-
}
|
|
1632
|
-
function isValidHex(hex) {
|
|
1633
|
-
return /^#[0-9a-f]{6}$/i.test(hex);
|
|
1634
|
-
}
|
|
1635
|
-
function namedColorToHex(name) {
|
|
1636
|
-
const map = {
|
|
1637
|
-
'black': '#000000', 'white': '#ffffff', 'red': '#ff0000', 'green': '#008000',
|
|
1638
|
-
'blue': '#0000ff', 'yellow': '#ffff00', 'orange': '#ffa500', 'purple': '#800080',
|
|
1639
|
-
'gray': '#808080', 'grey': '#808080', 'pink': '#ffc0cb', 'brown': '#a52a2a',
|
|
1640
|
-
'cyan': '#00ffff', 'magenta': '#ff00ff', 'transparent': null, 'none': null,
|
|
1641
|
-
'currentcolor': null, 'inherit': null,
|
|
1642
|
-
};
|
|
1643
|
-
return map[name.toLowerCase()] ?? null;
|
|
1644
|
-
}
|
|
1645
|
-
function normalizeHex(v) {
|
|
1646
|
-
const match = v.match(/^#([0-9a-fA-F]{3})$/);
|
|
1647
|
-
if (match) {
|
|
1648
|
-
const [r, g, b] = match[1].split('');
|
|
1649
|
-
return `#${r}${r}${g}${g}${b}${b}`.toLowerCase();
|
|
1650
|
-
}
|
|
1651
|
-
return v.toLowerCase().slice(0, 7);
|
|
1652
|
-
}
|
|
1653
|
-
function rgbToHex(r, g, b) {
|
|
1654
|
-
return '#' + [r, g, b].map(c => Math.max(0, Math.min(255, c)).toString(16).padStart(2, '0')).join('').toLowerCase();
|
|
1655
|
-
}
|
|
1656
|
-
function hslToHex(h, s, l) {
|
|
1657
|
-
s /= 100;
|
|
1658
|
-
l /= 100;
|
|
1659
|
-
const c = (1 - Math.abs(2 * l - 1)) * s;
|
|
1660
|
-
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
|
1661
|
-
const m = l - c / 2;
|
|
1662
|
-
let r = 0, g = 0, b = 0;
|
|
1663
|
-
if (h < 60) {
|
|
1664
|
-
r = c;
|
|
1665
|
-
g = x;
|
|
1666
|
-
}
|
|
1667
|
-
else if (h < 120) {
|
|
1668
|
-
r = x;
|
|
1669
|
-
g = c;
|
|
1670
|
-
}
|
|
1671
|
-
else if (h < 180) {
|
|
1672
|
-
g = c;
|
|
1673
|
-
b = x;
|
|
1674
|
-
}
|
|
1675
|
-
else if (h < 240) {
|
|
1676
|
-
g = x;
|
|
1677
|
-
b = c;
|
|
1678
|
-
}
|
|
1679
|
-
else if (h < 300) {
|
|
1680
|
-
r = x;
|
|
1681
|
-
b = c;
|
|
1682
|
-
}
|
|
1683
|
-
else {
|
|
1684
|
-
r = c;
|
|
1685
|
-
b = x;
|
|
1686
|
-
}
|
|
1687
|
-
return rgbToHex(Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255));
|
|
1688
|
-
}
|
|
1689
|
-
//# sourceMappingURL=http-css.js.map
|