screencraft 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +30 -0
- package/.env.example +3 -0
- package/MCP_README.md +200 -0
- package/README.md +148 -0
- package/bin/screencraft.js +61 -0
- package/package.json +31 -0
- package/src/auth/keystore.js +148 -0
- package/src/commands/init.js +119 -0
- package/src/commands/launch.js +405 -0
- package/src/detectors/detectBrand.js +1222 -0
- package/src/detectors/simulator.js +317 -0
- package/src/generators/analyzeStyleReference.js +471 -0
- package/src/generators/compositePSD.js +682 -0
- package/src/generators/copy.js +147 -0
- package/src/mcp/index.js +394 -0
- package/src/pipeline/aeSwap.js +369 -0
- package/src/pipeline/download.js +32 -0
- package/src/pipeline/queue.js +101 -0
- package/src/server/index.js +627 -0
- package/src/server/public/app.js +738 -0
- package/src/server/public/index.html +255 -0
- package/src/server/public/style.css +751 -0
- package/src/server/session.js +36 -0
- package/templates/ae/(Footage)/Assets/This Hip-Hop Upbeat (Short version).wav +0 -0
- package/templates/ae/(Footage)/Assets/screen_01_raw.png +0 -0
- package/templates/ae/(Footage)/Assets/screen_02_raw.png +0 -0
- package/templates/ae/(Footage)/Assets/screen_03_raw.png +0 -0
- package/templates/ae/(Footage)/Assets/screen_04_raw.png +0 -0
- package/templates/ae/(Footage)/Assets/screen_05_raw.png +0 -0
- package/templates/ae/(Footage)/Assets/screen_06_raw.png +0 -0
- package/templates/ae/Motion Forge Test 1.0 (converted).aep +0 -0
- package/templates/ae_swap.jsx +284 -0
- package/templates/layouts/minimal.psd +0 -0
- package/templates/screencraft.config.example.js +165 -0
- package/test/output/layout_test.png +0 -0
- package/test/output/style_profile.json +64 -0
- package/test/reference.png +0 -0
- package/test/test_brand.js +69 -0
- package/test/test_psd.js +83 -0
- package/test/test_style_analysis.js +114 -0
|
@@ -0,0 +1,1222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* detectBrand.js
|
|
3
|
+
* --------------
|
|
4
|
+
* Reads the project codebase and extracts brand information
|
|
5
|
+
* without any user input. Returns a BrandProfile object.
|
|
6
|
+
*
|
|
7
|
+
* Detection priority:
|
|
8
|
+
* 1. screencraft.config.js overrides (always wins)
|
|
9
|
+
* 2. Framework-specific color files
|
|
10
|
+
* 3. Generic fallbacks (CSS variables, JSON tokens)
|
|
11
|
+
* 4. Built-in defaults
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
// ─────────────────────────────────────────────────────────────────
|
|
18
|
+
// MAIN EXPORT
|
|
19
|
+
// ─────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* detectBrand(projectRoot, configOverrides)
|
|
23
|
+
* Returns a fully-resolved BrandProfile.
|
|
24
|
+
*/
|
|
25
|
+
async function detectBrand(projectRoot, configOverrides = {}) {
|
|
26
|
+
const framework = await detectFramework(projectRoot);
|
|
27
|
+
|
|
28
|
+
console.log(` ◆ Detected framework: ${framework.name}`);
|
|
29
|
+
|
|
30
|
+
const detected = await runDetector(framework, projectRoot);
|
|
31
|
+
const appInfo = await detectAppInfo(framework, projectRoot);
|
|
32
|
+
const icon = await detectAppIcon(framework, projectRoot);
|
|
33
|
+
const font = await detectFont(framework, projectRoot);
|
|
34
|
+
const logo = await detectLogo(framework, projectRoot);
|
|
35
|
+
|
|
36
|
+
// Config overrides win over auto-detection
|
|
37
|
+
const brand = {
|
|
38
|
+
...detected,
|
|
39
|
+
...stripNulls(configOverrides.brand || {}),
|
|
40
|
+
appName: configOverrides.app?.name || appInfo.name,
|
|
41
|
+
tagline: configOverrides.app?.tagline || null,
|
|
42
|
+
cta: configOverrides.app?.cta || "Download Now",
|
|
43
|
+
icon: configOverrides.brand?.icon || icon,
|
|
44
|
+
font: configOverrides.brand?.font || font,
|
|
45
|
+
logo: configOverrides.brand?.logo || logo,
|
|
46
|
+
framework: framework.name,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
console.log(` ◆ App name: ${brand.appName}`);
|
|
50
|
+
console.log(` ◆ Primary: ${brand.primary}`);
|
|
51
|
+
console.log(` ◆ Secondary: ${brand.secondary}`);
|
|
52
|
+
console.log(` ◆ Accent: ${brand.accent}`);
|
|
53
|
+
console.log(` ◆ Icon: ${brand.icon ? 'found' : 'not found'}`);
|
|
54
|
+
console.log(` ◆ Font: ${brand.font?.family || 'not found'}${brand.font?.weight ? ` (${brand.font.weight})` : ''}`);
|
|
55
|
+
console.log(` ◆ Logo: ${brand.logo ? 'found' : 'not found'}`);
|
|
56
|
+
|
|
57
|
+
return brand;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = { detectBrand, detectFramework };
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
// ─────────────────────────────────────────────────────────────────
|
|
64
|
+
// FRAMEWORK DETECTION
|
|
65
|
+
// ─────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
async function detectFramework(root) {
|
|
68
|
+
const has = (f) => fs.existsSync(path.join(root, f));
|
|
69
|
+
const read = (f) => {
|
|
70
|
+
try { return fs.readFileSync(path.join(root, f), 'utf8'); }
|
|
71
|
+
catch { return ''; }
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Expo (check before React Native — Expo projects also have package.json)
|
|
75
|
+
if (has('app.json') || has('app.config.js') || has('app.config.ts')) {
|
|
76
|
+
const appJson = read('app.json');
|
|
77
|
+
if (appJson.includes('"expo"')) {
|
|
78
|
+
return { name: 'expo', platform: ['ios', 'android'], configFile: 'app.json' };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// React Native (bare)
|
|
83
|
+
if (has('package.json')) {
|
|
84
|
+
const pkg = read('package.json');
|
|
85
|
+
if (pkg.includes('"react-native"')) {
|
|
86
|
+
const hasIos = has('ios');
|
|
87
|
+
const hasAndroid = has('android');
|
|
88
|
+
return {
|
|
89
|
+
name: 'react-native',
|
|
90
|
+
platform: [hasIos && 'ios', hasAndroid && 'android'].filter(Boolean),
|
|
91
|
+
configFile: 'package.json'
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Flutter
|
|
97
|
+
if (has('pubspec.yaml')) {
|
|
98
|
+
return { name: 'flutter', platform: ['ios', 'android'], configFile: 'pubspec.yaml' };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Swift / SwiftUI — handle two cases:
|
|
102
|
+
// 1. Root is a cross-platform project with an ios/ subfolder
|
|
103
|
+
// 2. Root IS the iOS project (contains .xcodeproj directly)
|
|
104
|
+
if (!has('package.json') && !has('pubspec.yaml')) {
|
|
105
|
+
// Case 1: ios/ subfolder
|
|
106
|
+
if (has('ios')) {
|
|
107
|
+
const xcodeProjs = findFiles(path.join(root, 'ios'), '.xcodeproj', 1);
|
|
108
|
+
if (xcodeProjs.length > 0) {
|
|
109
|
+
return { name: 'swift', platform: ['ios'], configFile: xcodeProjs[0] };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Case 2: .xcodeproj in root itself
|
|
113
|
+
const xcodeProjs = findFiles(root, '.xcodeproj', 1);
|
|
114
|
+
if (xcodeProjs.length > 0) {
|
|
115
|
+
return { name: 'swift', platform: ['ios'], configFile: xcodeProjs[0] };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Kotlin / Android native
|
|
120
|
+
if (has('android') && has('android/app/src/main/AndroidManifest.xml')) {
|
|
121
|
+
return { name: 'android-native', platform: ['android'], configFile: 'android/app/src/main/AndroidManifest.xml' };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Next.js / web app (PWA)
|
|
125
|
+
if (has('next.config.js') || has('next.config.ts')) {
|
|
126
|
+
return { name: 'nextjs', platform: ['web'], configFile: 'next.config.js' };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { name: 'unknown', platform: [], configFile: null };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
// ─────────────────────────────────────────────────────────────────
|
|
134
|
+
// FRAMEWORK-SPECIFIC DETECTORS
|
|
135
|
+
// ─────────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
async function runDetector(framework, root) {
|
|
138
|
+
switch (framework.name) {
|
|
139
|
+
case 'expo': return detectExpo(root);
|
|
140
|
+
case 'react-native': return detectReactNative(root);
|
|
141
|
+
case 'flutter': return detectFlutter(root);
|
|
142
|
+
case 'swift': return detectSwift(root);
|
|
143
|
+
case 'android-native':return detectAndroidNative(root);
|
|
144
|
+
case 'nextjs': return detectNextjs(root);
|
|
145
|
+
default: return detectGeneric(root);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── EXPO ──────────────────────────────────────────────────────────
|
|
150
|
+
function detectExpo(root) {
|
|
151
|
+
// Try app.json first
|
|
152
|
+
const appJson = readJson(path.join(root, 'app.json'));
|
|
153
|
+
if (appJson?.expo?.splash?.backgroundColor) {
|
|
154
|
+
// Use splash background as primary if no theme file found
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Theme files — common patterns in Expo projects
|
|
158
|
+
const candidates = [
|
|
159
|
+
'constants/Colors.ts',
|
|
160
|
+
'constants/Colors.js',
|
|
161
|
+
'constants/colors.ts',
|
|
162
|
+
'constants/colors.js',
|
|
163
|
+
'src/constants/Colors.ts',
|
|
164
|
+
'src/constants/colors.ts',
|
|
165
|
+
'src/theme/colors.ts',
|
|
166
|
+
'src/theme/colors.js',
|
|
167
|
+
'theme/colors.ts',
|
|
168
|
+
'theme/colors.js',
|
|
169
|
+
'styles/colors.ts',
|
|
170
|
+
'styles/colors.js',
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
for (const candidate of candidates) {
|
|
174
|
+
const result = parseJsColorFile(path.join(root, candidate));
|
|
175
|
+
if (result) {
|
|
176
|
+
console.log(` ◆ Colors from: ${candidate}`);
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Fallback: read app.json tint color
|
|
182
|
+
const tint = appJson?.expo?.splash?.backgroundColor
|
|
183
|
+
|| appJson?.expo?.primaryColor
|
|
184
|
+
|| null;
|
|
185
|
+
|
|
186
|
+
return buildProfile({ primary: tint });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── REACT NATIVE (BARE) ───────────────────────────────────────────
|
|
190
|
+
function detectReactNative(root) {
|
|
191
|
+
const candidates = [
|
|
192
|
+
'src/theme/colors.ts',
|
|
193
|
+
'src/theme/colors.js',
|
|
194
|
+
'src/constants/colors.ts',
|
|
195
|
+
'src/constants/colors.js',
|
|
196
|
+
'app/theme/colors.ts',
|
|
197
|
+
'app/constants/colors.ts',
|
|
198
|
+
'constants/Colors.ts',
|
|
199
|
+
'constants/colors.js',
|
|
200
|
+
'styles/colors.js',
|
|
201
|
+
'theme.js',
|
|
202
|
+
'theme.ts',
|
|
203
|
+
];
|
|
204
|
+
|
|
205
|
+
for (const candidate of candidates) {
|
|
206
|
+
const result = parseJsColorFile(path.join(root, candidate));
|
|
207
|
+
if (result) {
|
|
208
|
+
console.log(` ◆ Colors from: ${candidate}`);
|
|
209
|
+
return result;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Try reading from iOS native color assets as fallback
|
|
214
|
+
const iosResult = detectSwiftColorAssets(root);
|
|
215
|
+
if (iosResult.primary) return iosResult;
|
|
216
|
+
|
|
217
|
+
return buildProfile({});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── FLUTTER ───────────────────────────────────────────────────────
|
|
221
|
+
function detectFlutter(root) {
|
|
222
|
+
// Flutter color definitions live in .dart files
|
|
223
|
+
// Common patterns: lib/core/theme/app_colors.dart,
|
|
224
|
+
// lib/config/theme.dart, lib/utils/colors.dart
|
|
225
|
+
|
|
226
|
+
const dartCandidates = [
|
|
227
|
+
'lib/core/theme/app_colors.dart',
|
|
228
|
+
'lib/core/theme/colors.dart',
|
|
229
|
+
'lib/config/theme.dart',
|
|
230
|
+
'lib/theme/app_theme.dart',
|
|
231
|
+
'lib/utils/colors.dart',
|
|
232
|
+
'lib/constants/colors.dart',
|
|
233
|
+
'lib/app/theme.dart',
|
|
234
|
+
];
|
|
235
|
+
|
|
236
|
+
for (const candidate of dartCandidates) {
|
|
237
|
+
const result = parseDartColorFile(path.join(root, candidate));
|
|
238
|
+
if (result) {
|
|
239
|
+
console.log(` ◆ Colors from: ${candidate}`);
|
|
240
|
+
return result;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Search recursively for any dart file with "Color(" definitions
|
|
245
|
+
const dartFiles = findFiles(path.join(root, 'lib'), '.dart', 4);
|
|
246
|
+
for (const file of dartFiles) {
|
|
247
|
+
if (file.includes('color') || file.includes('theme')) {
|
|
248
|
+
const result = parseDartColorFile(file);
|
|
249
|
+
if (result) {
|
|
250
|
+
console.log(` ◆ Colors from: ${path.relative(root, file)}`);
|
|
251
|
+
return result;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return buildProfile({});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── SWIFT / SWIFTUI ───────────────────────────────────────────────
|
|
260
|
+
function detectSwift(root) {
|
|
261
|
+
// Handle two cases:
|
|
262
|
+
// A: cross-platform project root with ios/ subfolder
|
|
263
|
+
// B: root IS the iOS project (contains .xcodeproj directly)
|
|
264
|
+
const iosRoot = fs.existsSync(path.join(root, 'ios')) ? path.join(root, 'ios') : root;
|
|
265
|
+
|
|
266
|
+
// Collect colors from multiple sources, merge at the end
|
|
267
|
+
const collected = {};
|
|
268
|
+
|
|
269
|
+
// 1. Color.xcassets (modern SwiftUI)
|
|
270
|
+
const xcassetsColors = detectSwiftColorAssetsRaw(iosRoot);
|
|
271
|
+
if (Object.keys(xcassetsColors).length > 0) {
|
|
272
|
+
console.log(` ◆ Colors from: Color.xcassets`);
|
|
273
|
+
Object.assign(collected, xcassetsColors);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// 2. Swift files with color/theme/brand/style in filename
|
|
277
|
+
const swiftCandidates = findFiles(iosRoot, '.swift', 8)
|
|
278
|
+
.filter(f => {
|
|
279
|
+
const lc = f.toLowerCase();
|
|
280
|
+
return lc.includes('color') || lc.includes('theme') || lc.includes('style') || lc.includes('brand');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
for (const file of swiftCandidates) {
|
|
284
|
+
const result = parseSwiftColorFile(file);
|
|
285
|
+
if (result) {
|
|
286
|
+
console.log(` ◆ Colors from: ${path.relative(root, file)}`);
|
|
287
|
+
// Merge: Swift file fills gaps left by xcassets
|
|
288
|
+
if (!collected.primary && result.primary !== '#1A1A2E') collected.primary = result.primary;
|
|
289
|
+
if (!collected.secondary && result.secondary !== '#F5F5F5') collected.secondary = result.secondary;
|
|
290
|
+
if (!collected.accent && result.accent !== '#E94560') collected.accent = result.accent;
|
|
291
|
+
if (!collected.background && result.background !== '#FFFFFF') collected.background = result.background;
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// 3. Fallback: scan all Swift files if still missing primary
|
|
297
|
+
if (!collected.primary) {
|
|
298
|
+
for (const file of findFiles(iosRoot, '.swift', 8)) {
|
|
299
|
+
const result = parseSwiftColorFile(file);
|
|
300
|
+
if (result) {
|
|
301
|
+
console.log(` ◆ Colors from: ${path.relative(root, file)}`);
|
|
302
|
+
if (!collected.primary && result.primary !== '#1A1A2E') collected.primary = result.primary;
|
|
303
|
+
if (!collected.secondary && result.secondary !== '#F5F5F5') collected.secondary = result.secondary;
|
|
304
|
+
if (!collected.accent && result.accent !== '#E94560') collected.accent = result.accent;
|
|
305
|
+
if (!collected.background && result.background !== '#FFFFFF') collected.background = result.background;
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return buildProfile(collected);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── ANDROID NATIVE ────────────────────────────────────────────────
|
|
315
|
+
function detectAndroidNative(root) {
|
|
316
|
+
// Android colors live in res/values/colors.xml
|
|
317
|
+
const colorsXml = path.join(root, 'android/app/src/main/res/values/colors.xml');
|
|
318
|
+
const result = parseAndroidColorsXml(colorsXml);
|
|
319
|
+
if (result.primary) {
|
|
320
|
+
console.log(` ◆ Colors from: res/values/colors.xml`);
|
|
321
|
+
return result;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Also try themes.xml
|
|
325
|
+
const themesXml = path.join(root, 'android/app/src/main/res/values/themes.xml');
|
|
326
|
+
const themeResult = parseAndroidThemesXml(themesXml);
|
|
327
|
+
if (themeResult.primary) {
|
|
328
|
+
console.log(` ◆ Colors from: res/values/themes.xml`);
|
|
329
|
+
return themeResult;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return buildProfile({});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ── NEXT.JS ───────────────────────────────────────────────────────
|
|
336
|
+
function detectNextjs(root) {
|
|
337
|
+
// 1. Tailwind config
|
|
338
|
+
const tailwindResult = parseTailwindConfig(root);
|
|
339
|
+
if (tailwindResult.primary) {
|
|
340
|
+
console.log(` ◆ Colors from: tailwind.config.js`);
|
|
341
|
+
return tailwindResult;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// 2. CSS variables in globals.css
|
|
345
|
+
const cssResult = parseCssVariables(
|
|
346
|
+
path.join(root, 'styles/globals.css') ||
|
|
347
|
+
path.join(root, 'app/globals.css')
|
|
348
|
+
);
|
|
349
|
+
if (cssResult.primary) {
|
|
350
|
+
console.log(` ◆ Colors from: globals.css`);
|
|
351
|
+
return cssResult;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// 3. Design tokens
|
|
355
|
+
return detectGeneric(root);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ── GENERIC FALLBACK ──────────────────────────────────────────────
|
|
359
|
+
function detectGeneric(root) {
|
|
360
|
+
// Design token files
|
|
361
|
+
const tokenCandidates = [
|
|
362
|
+
'tokens.json',
|
|
363
|
+
'design-tokens.json',
|
|
364
|
+
'src/tokens.json',
|
|
365
|
+
'src/design-tokens.json',
|
|
366
|
+
'styles/tokens.json',
|
|
367
|
+
];
|
|
368
|
+
|
|
369
|
+
for (const candidate of tokenCandidates) {
|
|
370
|
+
const result = parseDesignTokens(path.join(root, candidate));
|
|
371
|
+
if (result.primary) return result;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return buildProfile({});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
// ─────────────────────────────────────────────────────────────────
|
|
379
|
+
// PARSERS
|
|
380
|
+
// ─────────────────────────────────────────────────────────────────
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Parse JS/TS color files.
|
|
384
|
+
* Handles: export const primary = '#...'
|
|
385
|
+
* Colors.primary = '#...'
|
|
386
|
+
* light: { primary: '#...' }
|
|
387
|
+
*/
|
|
388
|
+
function parseJsColorFile(filePath) {
|
|
389
|
+
if (!fs.existsSync(filePath)) return null;
|
|
390
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
391
|
+
|
|
392
|
+
const colors = {};
|
|
393
|
+
|
|
394
|
+
// Match: primary: '#hex' or primary: "#hex"
|
|
395
|
+
const patterns = [
|
|
396
|
+
{ key: 'primary', regex: /primary['":\s]+['"]?(#[0-9A-Fa-f]{6})/i },
|
|
397
|
+
{ key: 'secondary', regex: /secondary['":\s]+['"]?(#[0-9A-Fa-f]{6})/i },
|
|
398
|
+
{ key: 'accent', regex: /accent['":\s]+['"]?(#[0-9A-Fa-f]{6})/i },
|
|
399
|
+
{ key: 'tint', regex: /tint['":\s]+['"]?(#[0-9A-Fa-f]{6})/i },
|
|
400
|
+
{ key: 'background',regex: /background['":\s]+['"]?(#[0-9A-Fa-f]{6})/i },
|
|
401
|
+
];
|
|
402
|
+
|
|
403
|
+
for (const { key, regex } of patterns) {
|
|
404
|
+
const match = content.match(regex);
|
|
405
|
+
if (match) colors[key] = match[1];
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Resolve tint → secondary if no explicit secondary
|
|
409
|
+
if (!colors.secondary && colors.tint) {
|
|
410
|
+
colors.secondary = colors.tint;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (!colors.primary) return null;
|
|
414
|
+
return buildProfile(colors);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Parse Dart color files.
|
|
419
|
+
* Handles: static const Color primary = Color(0xFF1B3A6B);
|
|
420
|
+
* Color get primary => const Color(0xFF1B3A6B);
|
|
421
|
+
*/
|
|
422
|
+
function parseDartColorFile(filePath) {
|
|
423
|
+
if (!fs.existsSync(filePath)) return null;
|
|
424
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
425
|
+
|
|
426
|
+
const colors = {};
|
|
427
|
+
|
|
428
|
+
// Match Color(0xFFRRGGBB) or Color(0xAARRGGBB)
|
|
429
|
+
const patterns = [
|
|
430
|
+
{ key: 'primary', regex: /primary[^=\n]*=?[^(]*Color\(0x[A-Fa-f0-9]{2}([A-Fa-f0-9]{6})\)/i },
|
|
431
|
+
{ key: 'secondary', regex: /secondary[^=\n]*=?[^(]*Color\(0x[A-Fa-f0-9]{2}([A-Fa-f0-9]{6})\)/i },
|
|
432
|
+
{ key: 'accent', regex: /accent[^=\n]*=?[^(]*Color\(0x[A-Fa-f0-9]{2}([A-Fa-f0-9]{6})\)/i },
|
|
433
|
+
];
|
|
434
|
+
|
|
435
|
+
for (const { key, regex } of patterns) {
|
|
436
|
+
const match = content.match(regex);
|
|
437
|
+
if (match) colors[key] = '#' + match[1];
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (!colors.primary) return null;
|
|
441
|
+
return buildProfile(colors);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Parse Swift color files.
|
|
446
|
+
* Handles:
|
|
447
|
+
* static let primary = UIColor(hex: "#1B3A6B")
|
|
448
|
+
* static let brandPurple = Color(hex: "7068B1")
|
|
449
|
+
* Color("PrimaryColor") → reads from xcassets
|
|
450
|
+
*/
|
|
451
|
+
function parseSwiftColorFile(filePath) {
|
|
452
|
+
if (!fs.existsSync(filePath)) return null;
|
|
453
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
454
|
+
|
|
455
|
+
const colors = {};
|
|
456
|
+
|
|
457
|
+
// Pass 1: exact name matches (primary, secondary, accent)
|
|
458
|
+
const exactPatterns = [
|
|
459
|
+
{ key: 'primary', regex: /primary[^=\n]*=.*?(?:UIColor|Color)\s*\(\s*(?:hex:\s*)?["']#?([0-9A-Fa-f]{6})/i },
|
|
460
|
+
{ key: 'secondary', regex: /secondary[^=\n]*=.*?(?:UIColor|Color)\s*\(\s*(?:hex:\s*)?["']#?([0-9A-Fa-f]{6})/i },
|
|
461
|
+
{ key: 'accent', regex: /accent[^=\n]*=.*?(?:UIColor|Color)\s*\(\s*(?:hex:\s*)?["']#?([0-9A-Fa-f]{6})/i },
|
|
462
|
+
];
|
|
463
|
+
|
|
464
|
+
for (const { key, regex } of exactPatterns) {
|
|
465
|
+
const match = content.match(regex);
|
|
466
|
+
if (match) colors[key] = '#' + match[1];
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (colors.primary) return buildProfile(colors);
|
|
470
|
+
|
|
471
|
+
// Pass 2: collect all Color(hex: "...") definitions with their variable names
|
|
472
|
+
// Handles: static let brandPurple = Color(hex: "7068B1")
|
|
473
|
+
const allColors = [];
|
|
474
|
+
const colorRegex = /(?:static\s+)?(?:let|var)\s+(\w+)\s*=\s*(?:UIColor|Color)\s*\(\s*(?:hex:\s*)?["']#?([0-9A-Fa-f]{6})/gi;
|
|
475
|
+
let m;
|
|
476
|
+
while ((m = colorRegex.exec(content)) !== null) {
|
|
477
|
+
allColors.push({ name: m[1].toLowerCase(), hex: '#' + m[2] });
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (allColors.length === 0) return null;
|
|
481
|
+
|
|
482
|
+
// Assign by name heuristics
|
|
483
|
+
for (const c of allColors) {
|
|
484
|
+
if (!colors.background && (c.name.includes('background') || c.name.includes('bg') || c.name.includes('deep'))) {
|
|
485
|
+
colors.background = c.hex;
|
|
486
|
+
} else if (!colors.primary && (c.name.includes('brand') || c.name.includes('primary') || c.name.includes('main'))) {
|
|
487
|
+
colors.primary = c.hex;
|
|
488
|
+
} else if (!colors.accent && (c.name.includes('accent') || c.name.includes('tint') || c.name.includes('highlight') || c.name.includes('mint'))) {
|
|
489
|
+
colors.accent = c.hex;
|
|
490
|
+
} else if (!colors.secondary && (c.name.includes('secondary') || c.name.includes('teal') || c.name.includes('blue'))) {
|
|
491
|
+
colors.secondary = c.hex;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// If still no primary, use the first non-background color
|
|
496
|
+
if (!colors.primary) {
|
|
497
|
+
const first = allColors.find(c => c.hex !== colors.background);
|
|
498
|
+
if (first) colors.primary = first.hex;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// If still no secondary, use next unused color
|
|
502
|
+
if (!colors.secondary) {
|
|
503
|
+
const used = new Set([colors.primary, colors.background, colors.accent]);
|
|
504
|
+
const next = allColors.find(c => !used.has(c.hex));
|
|
505
|
+
if (next) colors.secondary = next.hex;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (!colors.primary) return null;
|
|
509
|
+
return buildProfile(colors);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Parse Color.xcassets — returns raw colors object (no defaults).
|
|
514
|
+
* Used by detectSwift for merging with Swift file colors.
|
|
515
|
+
*/
|
|
516
|
+
function detectSwiftColorAssetsRaw(root) {
|
|
517
|
+
const xcassetDirs = findFiles(root, '.xcassets', 3);
|
|
518
|
+
const colors = {};
|
|
519
|
+
|
|
520
|
+
for (const xcassetDir of xcassetDirs) {
|
|
521
|
+
const colorsets = findFiles(xcassetDir, '.colorset', 3);
|
|
522
|
+
for (const colorset of colorsets) {
|
|
523
|
+
const name = path.basename(colorset, '.colorset').toLowerCase();
|
|
524
|
+
const contentsPath = path.join(colorset, 'Contents.json');
|
|
525
|
+
if (!fs.existsSync(contentsPath)) continue;
|
|
526
|
+
|
|
527
|
+
try {
|
|
528
|
+
const json = JSON.parse(fs.readFileSync(contentsPath, 'utf8'));
|
|
529
|
+
const components = json?.colors?.[0]?.color?.components;
|
|
530
|
+
if (!components) continue;
|
|
531
|
+
|
|
532
|
+
const hex = componentsToHex(components);
|
|
533
|
+
if (!hex) continue;
|
|
534
|
+
|
|
535
|
+
if (name.includes('primary') && !colors.primary) colors.primary = hex;
|
|
536
|
+
if (name.includes('secondary') && !colors.secondary) colors.secondary = hex;
|
|
537
|
+
if (name.includes('accent') && !colors.accent) colors.accent = hex;
|
|
538
|
+
if (name.includes('tint') && !colors.secondary) colors.secondary = hex;
|
|
539
|
+
} catch {}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return colors;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Parse Color.xcassets — returns full profile with defaults.
|
|
548
|
+
* Used by detectReactNative as fallback.
|
|
549
|
+
*/
|
|
550
|
+
function detectSwiftColorAssets(root) {
|
|
551
|
+
return buildProfile(detectSwiftColorAssetsRaw(root));
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Parse Android res/values/colors.xml
|
|
556
|
+
*/
|
|
557
|
+
function parseAndroidColorsXml(filePath) {
|
|
558
|
+
if (!fs.existsSync(filePath)) return buildProfile({});
|
|
559
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
560
|
+
const colors = {};
|
|
561
|
+
|
|
562
|
+
const matches = content.matchAll(/<color name="([^"]+)">#?([0-9A-Fa-f]{6,8})<\/color>/g);
|
|
563
|
+
for (const [, name, hex] of matches) {
|
|
564
|
+
const n = name.toLowerCase();
|
|
565
|
+
const h = '#' + hex.slice(-6); // Strip alpha if present
|
|
566
|
+
if (n.includes('primary') && !colors.primary) colors.primary = h;
|
|
567
|
+
if (n.includes('secondary') && !colors.secondary) colors.secondary = h;
|
|
568
|
+
if (n.includes('accent') && !colors.accent) colors.accent = h;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return buildProfile(colors);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Parse Android res/values/themes.xml
|
|
576
|
+
* Looks for colorPrimary, colorSecondary, colorAccent attributes.
|
|
577
|
+
*/
|
|
578
|
+
function parseAndroidThemesXml(filePath) {
|
|
579
|
+
if (!fs.existsSync(filePath)) return buildProfile({});
|
|
580
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
581
|
+
const colors = {};
|
|
582
|
+
|
|
583
|
+
const patterns = [
|
|
584
|
+
{ key: 'primary', regex: /colorPrimary[^>]*>#?([0-9A-Fa-f]{6,8})/i },
|
|
585
|
+
{ key: 'secondary', regex: /colorSecondary[^>]*>#?([0-9A-Fa-f]{6,8})/i },
|
|
586
|
+
{ key: 'accent', regex: /colorAccent[^>]*>#?([0-9A-Fa-f]{6,8})/i },
|
|
587
|
+
];
|
|
588
|
+
|
|
589
|
+
for (const { key, regex } of patterns) {
|
|
590
|
+
const match = content.match(regex);
|
|
591
|
+
if (match) colors[key] = '#' + match[1].slice(-6);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return buildProfile(colors);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Parse Tailwind config for brand colors.
|
|
599
|
+
*/
|
|
600
|
+
function parseTailwindConfig(root) {
|
|
601
|
+
const candidates = ['tailwind.config.js', 'tailwind.config.ts', 'tailwind.config.cjs'];
|
|
602
|
+
for (const c of candidates) {
|
|
603
|
+
const filePath = path.join(root, c);
|
|
604
|
+
if (!fs.existsSync(filePath)) continue;
|
|
605
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
606
|
+
const colors = {};
|
|
607
|
+
|
|
608
|
+
const patterns = [
|
|
609
|
+
{ key: 'primary', regex: /primary['":\s]+['"]?(#[0-9A-Fa-f]{6})/i },
|
|
610
|
+
{ key: 'secondary', regex: /secondary['":\s]+['"]?(#[0-9A-Fa-f]{6})/i },
|
|
611
|
+
{ key: 'accent', regex: /accent['":\s]+['"]?(#[0-9A-Fa-f]{6})/i },
|
|
612
|
+
];
|
|
613
|
+
|
|
614
|
+
for (const { key, regex } of patterns) {
|
|
615
|
+
const match = content.match(regex);
|
|
616
|
+
if (match) colors[key] = match[1];
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (colors.primary) return buildProfile(colors);
|
|
620
|
+
}
|
|
621
|
+
return buildProfile({});
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Parse CSS custom properties (variables).
|
|
626
|
+
* Handles: --color-primary: #hex; --primary: #hex;
|
|
627
|
+
*/
|
|
628
|
+
function parseCssVariables(filePath) {
|
|
629
|
+
if (!fs.existsSync(filePath)) return buildProfile({});
|
|
630
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
631
|
+
const colors = {};
|
|
632
|
+
|
|
633
|
+
const patterns = [
|
|
634
|
+
{ key: 'primary', regex: /--(?:color-)?primary\s*:\s*(#[0-9A-Fa-f]{6})/i },
|
|
635
|
+
{ key: 'secondary', regex: /--(?:color-)?secondary\s*:\s*(#[0-9A-Fa-f]{6})/i },
|
|
636
|
+
{ key: 'accent', regex: /--(?:color-)?accent\s*:\s*(#[0-9A-Fa-f]{6})/i },
|
|
637
|
+
];
|
|
638
|
+
|
|
639
|
+
for (const { key, regex } of patterns) {
|
|
640
|
+
const match = content.match(regex);
|
|
641
|
+
if (match) colors[key] = match[1];
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
return buildProfile(colors);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Parse design token JSON files.
|
|
649
|
+
* Handles W3C token format and Style Dictionary format.
|
|
650
|
+
*/
|
|
651
|
+
function parseDesignTokens(filePath) {
|
|
652
|
+
if (!fs.existsSync(filePath)) return buildProfile({});
|
|
653
|
+
try {
|
|
654
|
+
const json = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
655
|
+
const colors = {};
|
|
656
|
+
|
|
657
|
+
// W3C format: { "color": { "primary": { "$value": "#hex" } } }
|
|
658
|
+
const primary = json?.color?.primary?.$value || json?.colors?.primary?.$value;
|
|
659
|
+
const secondary = json?.color?.secondary?.$value || json?.colors?.secondary?.$value;
|
|
660
|
+
const accent = json?.color?.accent?.$value || json?.colors?.accent?.$value;
|
|
661
|
+
|
|
662
|
+
if (primary) colors.primary = primary;
|
|
663
|
+
if (secondary) colors.secondary = secondary;
|
|
664
|
+
if (accent) colors.accent = accent;
|
|
665
|
+
|
|
666
|
+
return buildProfile(colors);
|
|
667
|
+
} catch {
|
|
668
|
+
return buildProfile({});
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
// ─────────────────────────────────────────────────────────────────
|
|
674
|
+
// APP INFO DETECTION
|
|
675
|
+
// ─────────────────────────────────────────────────────────────────
|
|
676
|
+
|
|
677
|
+
async function detectAppInfo(framework, root) {
|
|
678
|
+
switch (framework.name) {
|
|
679
|
+
case 'expo': {
|
|
680
|
+
const appJson = readJson(path.join(root, 'app.json'));
|
|
681
|
+
return {
|
|
682
|
+
name: appJson?.expo?.name || appJson?.name || null,
|
|
683
|
+
bundleId: appJson?.expo?.ios?.bundleIdentifier || null,
|
|
684
|
+
version: appJson?.expo?.version || null,
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
case 'react-native': {
|
|
688
|
+
// Read from ios/Info.plist
|
|
689
|
+
const plist = readPlist(path.join(root, 'ios'));
|
|
690
|
+
const pkg = readJson(path.join(root, 'package.json'));
|
|
691
|
+
return {
|
|
692
|
+
name: plist?.CFBundleDisplayName || plist?.CFBundleName || pkg?.name || null,
|
|
693
|
+
version: plist?.CFBundleShortVersionString || pkg?.version || null,
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
case 'flutter': {
|
|
697
|
+
const pubspec = readYaml(path.join(root, 'pubspec.yaml'));
|
|
698
|
+
return {
|
|
699
|
+
name: pubspec?.name || null,
|
|
700
|
+
version: pubspec?.version || null,
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
case 'swift': {
|
|
704
|
+
const plist = readPlist(path.join(root, 'ios'));
|
|
705
|
+
return {
|
|
706
|
+
name: plist?.CFBundleDisplayName || plist?.CFBundleName || null,
|
|
707
|
+
version: plist?.CFBundleShortVersionString || null,
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
case 'android-native': {
|
|
711
|
+
const manifest = fs.readFileSync(
|
|
712
|
+
path.join(root, 'android/app/src/main/AndroidManifest.xml'), 'utf8'
|
|
713
|
+
);
|
|
714
|
+
const match = manifest.match(/android:label="@string\/([^"]+)"/);
|
|
715
|
+
return { name: match ? match[1] : null };
|
|
716
|
+
}
|
|
717
|
+
default:
|
|
718
|
+
return { name: readJson(path.join(root, 'package.json'))?.name || null };
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
// ─────────────────────────────────────────────────────────────────
|
|
724
|
+
// APP ICON DETECTION
|
|
725
|
+
// ─────────────────────────────────────────────────────────────────
|
|
726
|
+
|
|
727
|
+
async function detectAppIcon(framework, root) {
|
|
728
|
+
// Quick-check common fixed paths first
|
|
729
|
+
const candidates = [
|
|
730
|
+
// Expo / React Native
|
|
731
|
+
'assets/icon.png',
|
|
732
|
+
'assets/images/icon.png',
|
|
733
|
+
'src/assets/icon.png',
|
|
734
|
+
// Android
|
|
735
|
+
'android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png',
|
|
736
|
+
'android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png',
|
|
737
|
+
// Generic
|
|
738
|
+
'icon.png',
|
|
739
|
+
'app-icon.png',
|
|
740
|
+
];
|
|
741
|
+
|
|
742
|
+
for (const candidate of candidates) {
|
|
743
|
+
const full = path.join(root, candidate);
|
|
744
|
+
if (fs.existsSync(full)) return full;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// Deep search: find .appiconset directories and read Contents.json
|
|
748
|
+
// to locate the largest icon (works regardless of filename conventions)
|
|
749
|
+
const appiconsets = findFiles(root, '.appiconset', 8);
|
|
750
|
+
for (const dir of appiconsets) {
|
|
751
|
+
const icon = findLargestIconInSet(dir);
|
|
752
|
+
if (icon) return icon;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Last resort: any large PNG named *icon* anywhere in the project
|
|
756
|
+
const allPngs = findFiles(root, '.png', 6);
|
|
757
|
+
const iconPng = allPngs.find(f => {
|
|
758
|
+
const name = path.basename(f).toLowerCase();
|
|
759
|
+
return name.includes('icon') && (name.includes('1024') || name.includes('large') || name.includes('appicon'));
|
|
760
|
+
});
|
|
761
|
+
if (iconPng) return iconPng;
|
|
762
|
+
|
|
763
|
+
return null;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Read Contents.json inside an .appiconset and return the path
|
|
768
|
+
* to the largest available icon image.
|
|
769
|
+
*/
|
|
770
|
+
function findLargestIconInSet(appiconsetDir) {
|
|
771
|
+
const contentsPath = path.join(appiconsetDir, 'Contents.json');
|
|
772
|
+
if (!fs.existsSync(contentsPath)) {
|
|
773
|
+
// No Contents.json — just grab the largest PNG by name
|
|
774
|
+
const pngs = findFiles(appiconsetDir, '.png', 1);
|
|
775
|
+
return pngs.length > 0 ? pngs[0] : null;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
try {
|
|
779
|
+
const json = JSON.parse(fs.readFileSync(contentsPath, 'utf8'));
|
|
780
|
+
const images = json?.images || [];
|
|
781
|
+
|
|
782
|
+
// Find the largest icon by parsing the "size" field (e.g., "1024x1024")
|
|
783
|
+
let best = null;
|
|
784
|
+
let bestSize = 0;
|
|
785
|
+
for (const img of images) {
|
|
786
|
+
if (!img.filename) continue;
|
|
787
|
+
const sizeStr = img.size || '0x0';
|
|
788
|
+
const dim = parseInt(sizeStr.split('x')[0], 10) || 0;
|
|
789
|
+
const scale = parseFloat((img.scale || '1x').replace('x', '')) || 1;
|
|
790
|
+
const effective = dim * scale;
|
|
791
|
+
if (effective > bestSize) {
|
|
792
|
+
const full = path.join(appiconsetDir, img.filename);
|
|
793
|
+
if (fs.existsSync(full)) {
|
|
794
|
+
best = full;
|
|
795
|
+
bestSize = effective;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
return best;
|
|
800
|
+
} catch {
|
|
801
|
+
return null;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
// ─────────────────────────────────────────────────────────────────
|
|
807
|
+
// FONT DETECTION
|
|
808
|
+
// ─────────────────────────────────────────────────────────────────
|
|
809
|
+
|
|
810
|
+
async function detectFont(framework, root) {
|
|
811
|
+
switch (framework.name) {
|
|
812
|
+
case 'swift': return detectFontSwift(root);
|
|
813
|
+
case 'expo': return detectFontExpoRN(root);
|
|
814
|
+
case 'react-native': return detectFontExpoRN(root);
|
|
815
|
+
case 'flutter': return detectFontFlutter(root);
|
|
816
|
+
case 'android-native':return detectFontAndroid(root);
|
|
817
|
+
case 'nextjs': return detectFontWeb(root);
|
|
818
|
+
default: return detectFontGeneric(root);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// ── Swift / iOS ──────────────────────────────────────────────────
|
|
823
|
+
function detectFontSwift(root) {
|
|
824
|
+
const iosRoot = fs.existsSync(path.join(root, 'ios')) ? path.join(root, 'ios') : root;
|
|
825
|
+
|
|
826
|
+
// 1. Look for bundled font files
|
|
827
|
+
const fontFiles = [
|
|
828
|
+
...findFiles(iosRoot, '.ttf', 6),
|
|
829
|
+
...findFiles(iosRoot, '.otf', 6),
|
|
830
|
+
];
|
|
831
|
+
|
|
832
|
+
if (fontFiles.length > 0) {
|
|
833
|
+
const picked = pickBestFontFile(fontFiles);
|
|
834
|
+
return { family: fontFamilyFromFile(picked), file: picked, weight: fontWeightFromFile(picked) };
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// 2. Parse Swift files for .custom("FontName", ...) usage
|
|
838
|
+
const swiftFiles = findFiles(iosRoot, '.swift', 6);
|
|
839
|
+
for (const file of swiftFiles) {
|
|
840
|
+
try {
|
|
841
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
842
|
+
const match = content.match(/\.custom\(\s*["']([^"']+)["']/);
|
|
843
|
+
if (match) return { family: match[1], file: null, weight: null };
|
|
844
|
+
} catch {}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
return { family: null, file: null, weight: null };
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// ── Expo / React Native ──────────────────────────────────────────
|
|
851
|
+
function detectFontExpoRN(root) {
|
|
852
|
+
// 1. Check common font directories
|
|
853
|
+
const fontDirs = [
|
|
854
|
+
'assets/fonts', 'src/assets/fonts', 'app/assets/fonts',
|
|
855
|
+
'assets', 'src/fonts', 'fonts',
|
|
856
|
+
];
|
|
857
|
+
|
|
858
|
+
for (const dir of fontDirs) {
|
|
859
|
+
const full = path.join(root, dir);
|
|
860
|
+
if (!fs.existsSync(full)) continue;
|
|
861
|
+
const fonts = [
|
|
862
|
+
...findFiles(full, '.ttf', 1),
|
|
863
|
+
...findFiles(full, '.otf', 1),
|
|
864
|
+
];
|
|
865
|
+
if (fonts.length > 0) {
|
|
866
|
+
const picked = pickBestFontFile(fonts);
|
|
867
|
+
return { family: fontFamilyFromFile(picked), file: picked, weight: fontWeightFromFile(picked) };
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// 2. Check for @expo-google-fonts in package.json
|
|
872
|
+
const pkg = readJson(path.join(root, 'package.json'));
|
|
873
|
+
if (pkg?.dependencies) {
|
|
874
|
+
const googleFont = Object.keys(pkg.dependencies).find(d => d.startsWith('@expo-google-fonts/'));
|
|
875
|
+
if (googleFont) {
|
|
876
|
+
// e.g. "@expo-google-fonts/inter" → "Inter"
|
|
877
|
+
const name = googleFont.split('/')[1].split('-').map(w => w[0].toUpperCase() + w.slice(1)).join(' ');
|
|
878
|
+
return { family: name, file: null, weight: null };
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
return { family: null, file: null, weight: null };
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// ── Flutter ──────────────────────────────────────────────────────
|
|
886
|
+
function detectFontFlutter(root) {
|
|
887
|
+
// 1. Parse pubspec.yaml for fonts section
|
|
888
|
+
const pubspecPath = path.join(root, 'pubspec.yaml');
|
|
889
|
+
if (fs.existsSync(pubspecPath)) {
|
|
890
|
+
const content = fs.readFileSync(pubspecPath, 'utf8');
|
|
891
|
+
// Match: " fonts:\n - family: Inter"
|
|
892
|
+
const familyMatch = content.match(/fonts:\s*\n\s*-\s*family:\s*(.+)/);
|
|
893
|
+
if (familyMatch) {
|
|
894
|
+
const family = familyMatch[1].trim();
|
|
895
|
+
// Try to find the actual font file
|
|
896
|
+
const fontFiles = [
|
|
897
|
+
...findFiles(root, '.ttf', 4),
|
|
898
|
+
...findFiles(root, '.otf', 4),
|
|
899
|
+
];
|
|
900
|
+
const matchingFile = fontFiles.find(f => path.basename(f).toLowerCase().includes(family.toLowerCase()));
|
|
901
|
+
const picked = matchingFile || (fontFiles.length > 0 ? pickBestFontFile(fontFiles) : null);
|
|
902
|
+
return { family, file: picked, weight: picked ? fontWeightFromFile(picked) : null };
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// 2. Fallback: find font files in common Flutter font locations
|
|
907
|
+
const fontFiles = [
|
|
908
|
+
...findFiles(path.join(root, 'assets', 'fonts'), '.ttf', 2),
|
|
909
|
+
...findFiles(path.join(root, 'assets', 'fonts'), '.otf', 2),
|
|
910
|
+
...findFiles(path.join(root, 'fonts'), '.ttf', 2),
|
|
911
|
+
...findFiles(path.join(root, 'fonts'), '.otf', 2),
|
|
912
|
+
];
|
|
913
|
+
|
|
914
|
+
if (fontFiles.length > 0) {
|
|
915
|
+
const picked = pickBestFontFile(fontFiles);
|
|
916
|
+
return { family: fontFamilyFromFile(picked), file: picked, weight: fontWeightFromFile(picked) };
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
return { family: null, file: null, weight: null };
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// ── Android Native ───────────────────────────────────────────────
|
|
923
|
+
function detectFontAndroid(root) {
|
|
924
|
+
const fontDir = path.join(root, 'android/app/src/main/res/font');
|
|
925
|
+
const fontFiles = [
|
|
926
|
+
...findFiles(fontDir, '.ttf', 1),
|
|
927
|
+
...findFiles(fontDir, '.otf', 1),
|
|
928
|
+
];
|
|
929
|
+
|
|
930
|
+
if (fontFiles.length > 0) {
|
|
931
|
+
const picked = pickBestFontFile(fontFiles);
|
|
932
|
+
return { family: fontFamilyFromFile(picked), file: picked, weight: fontWeightFromFile(picked) };
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Also check assets/fonts
|
|
936
|
+
const assetsDir = path.join(root, 'android/app/src/main/assets/fonts');
|
|
937
|
+
const assetFonts = [
|
|
938
|
+
...findFiles(assetsDir, '.ttf', 1),
|
|
939
|
+
...findFiles(assetsDir, '.otf', 1),
|
|
940
|
+
];
|
|
941
|
+
|
|
942
|
+
if (assetFonts.length > 0) {
|
|
943
|
+
const picked = pickBestFontFile(assetFonts);
|
|
944
|
+
return { family: fontFamilyFromFile(picked), file: picked, weight: fontWeightFromFile(picked) };
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
return { family: null, file: null, weight: null };
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// ── Next.js / Web ────────────────────────────────────────────────
|
|
951
|
+
function detectFontWeb(root) {
|
|
952
|
+
// 1. Check for next/font imports in layout/page files
|
|
953
|
+
const layoutCandidates = [
|
|
954
|
+
'app/layout.tsx', 'app/layout.jsx', 'app/layout.js',
|
|
955
|
+
'pages/_app.tsx', 'pages/_app.jsx', 'pages/_app.js',
|
|
956
|
+
'src/app/layout.tsx', 'src/app/layout.jsx',
|
|
957
|
+
];
|
|
958
|
+
|
|
959
|
+
for (const candidate of layoutCandidates) {
|
|
960
|
+
const filePath = path.join(root, candidate);
|
|
961
|
+
if (!fs.existsSync(filePath)) continue;
|
|
962
|
+
try {
|
|
963
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
964
|
+
// Match: import { Inter } from 'next/font/google'
|
|
965
|
+
const nextFontMatch = content.match(/import\s*\{?\s*(\w+)\s*\}?\s*from\s*['"]next\/font\/google['"]/);
|
|
966
|
+
if (nextFontMatch) {
|
|
967
|
+
const name = nextFontMatch[1].replace(/_/g, ' ');
|
|
968
|
+
return { family: name, file: null, weight: null };
|
|
969
|
+
}
|
|
970
|
+
// Match: import localFont from 'next/font/local' ... src: './fonts/MyFont.woff2'
|
|
971
|
+
const localFontMatch = content.match(/src:\s*['"]([^'"]+\.(?:ttf|otf|woff2?))['"]/);
|
|
972
|
+
if (localFontMatch) {
|
|
973
|
+
const fontFile = path.resolve(path.dirname(filePath), localFontMatch[1]);
|
|
974
|
+
if (fs.existsSync(fontFile)) {
|
|
975
|
+
return { family: fontFamilyFromFile(fontFile), file: fontFile, weight: fontWeightFromFile(fontFile) };
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
} catch {}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// 2. Tailwind fontFamily
|
|
982
|
+
const tailwindCandidates = ['tailwind.config.js', 'tailwind.config.ts', 'tailwind.config.cjs'];
|
|
983
|
+
for (const c of tailwindCandidates) {
|
|
984
|
+
const filePath = path.join(root, c);
|
|
985
|
+
if (!fs.existsSync(filePath)) continue;
|
|
986
|
+
try {
|
|
987
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
988
|
+
// Match: sans: ['Inter', ...] or heading: ['Poppins', ...]
|
|
989
|
+
const fontMatch = content.match(/(?:sans|heading|body|display)\s*:\s*\[\s*['"]([^'"]+)['"]/);
|
|
990
|
+
if (fontMatch) return { family: fontMatch[1], file: null, weight: null };
|
|
991
|
+
} catch {}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// 3. CSS @font-face
|
|
995
|
+
const cssCandidates = [
|
|
996
|
+
'styles/globals.css', 'app/globals.css', 'src/styles/globals.css',
|
|
997
|
+
'styles/global.css', 'app/global.css',
|
|
998
|
+
];
|
|
999
|
+
for (const candidate of cssCandidates) {
|
|
1000
|
+
const filePath = path.join(root, candidate);
|
|
1001
|
+
if (!fs.existsSync(filePath)) continue;
|
|
1002
|
+
try {
|
|
1003
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
1004
|
+
const faceMatch = content.match(/@font-face\s*\{[^}]*font-family:\s*['"]?([^'";]+)/);
|
|
1005
|
+
if (faceMatch) {
|
|
1006
|
+
const srcMatch = content.match(/@font-face\s*\{[^}]*src:[^}]*url\(['"]?([^'")\s]+\.(?:ttf|otf|woff2?))/);
|
|
1007
|
+
const fontFile = srcMatch ? path.resolve(path.dirname(filePath), srcMatch[1]) : null;
|
|
1008
|
+
return {
|
|
1009
|
+
family: faceMatch[1].trim(),
|
|
1010
|
+
file: fontFile && fs.existsSync(fontFile) ? fontFile : null,
|
|
1011
|
+
weight: null,
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
} catch {}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
return detectFontGeneric(root);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// ── Generic Fallback ─────────────────────────────────────────────
|
|
1021
|
+
function detectFontGeneric(root) {
|
|
1022
|
+
const fontDirs = ['fonts', 'assets/fonts', 'static/fonts', 'src/fonts', 'public/fonts'];
|
|
1023
|
+
|
|
1024
|
+
for (const dir of fontDirs) {
|
|
1025
|
+
const full = path.join(root, dir);
|
|
1026
|
+
if (!fs.existsSync(full)) continue;
|
|
1027
|
+
const fonts = [
|
|
1028
|
+
...findFiles(full, '.ttf', 2),
|
|
1029
|
+
...findFiles(full, '.otf', 2),
|
|
1030
|
+
];
|
|
1031
|
+
if (fonts.length > 0) {
|
|
1032
|
+
const picked = pickBestFontFile(fonts);
|
|
1033
|
+
return { family: fontFamilyFromFile(picked), file: picked, weight: fontWeightFromFile(picked) };
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
return { family: null, file: null, weight: null };
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* From a list of font files, pick the best one for headlines.
|
|
1042
|
+
* Prefers: Bold > SemiBold > Medium > Regular. Prefers shorter family names.
|
|
1043
|
+
*/
|
|
1044
|
+
function pickBestFontFile(files) {
|
|
1045
|
+
const weights = ['bold', 'semibold', 'semi-bold', 'medium', 'regular', 'book'];
|
|
1046
|
+
for (const w of weights) {
|
|
1047
|
+
const match = files.find(f => path.basename(f).toLowerCase().includes(w));
|
|
1048
|
+
if (match) return match;
|
|
1049
|
+
}
|
|
1050
|
+
return files[0];
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* Extract font family name from filename.
|
|
1055
|
+
* "Inter-Bold.ttf" → "Inter"
|
|
1056
|
+
* "WorkSans-SemiBold.otf" → "Work Sans"
|
|
1057
|
+
*/
|
|
1058
|
+
function fontFamilyFromFile(filePath) {
|
|
1059
|
+
let name = path.basename(filePath).replace(/\.(ttf|otf|woff2?)$/i, '');
|
|
1060
|
+
// Strip weight/style suffixes
|
|
1061
|
+
name = name.replace(/[-_]?(Thin|ExtraLight|Light|Regular|Medium|SemiBold|Semi-Bold|Bold|ExtraBold|Black|Heavy|Italic|Variable|VF)$/gi, '');
|
|
1062
|
+
name = name.replace(/[-_]?(thin|extralight|light|regular|medium|semibold|bold|extrabold|black|heavy|italic|variable|vf)$/gi, '');
|
|
1063
|
+
// Convert CamelCase to spaces: "WorkSans" → "Work Sans"
|
|
1064
|
+
name = name.replace(/([a-z])([A-Z])/g, '$1 $2');
|
|
1065
|
+
// Clean up separators
|
|
1066
|
+
name = name.replace(/[-_]/g, ' ').trim();
|
|
1067
|
+
return name || null;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* Extract weight hint from filename.
|
|
1072
|
+
* "Inter-Bold.ttf" → "Bold"
|
|
1073
|
+
*/
|
|
1074
|
+
function fontWeightFromFile(filePath) {
|
|
1075
|
+
const basename = path.basename(filePath).toLowerCase();
|
|
1076
|
+
const weights = [
|
|
1077
|
+
'thin', 'extralight', 'light', 'regular', 'medium',
|
|
1078
|
+
'semibold', 'semi-bold', 'bold', 'extrabold', 'black', 'heavy',
|
|
1079
|
+
];
|
|
1080
|
+
for (const w of weights) {
|
|
1081
|
+
if (basename.includes(w)) return w.charAt(0).toUpperCase() + w.slice(1);
|
|
1082
|
+
}
|
|
1083
|
+
return null;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
// ─────────────────────────────────────────────────────────────────
|
|
1088
|
+
// LOGO DETECTION
|
|
1089
|
+
// ─────────────────────────────────────────────────────────────────
|
|
1090
|
+
|
|
1091
|
+
async function detectLogo(framework, root) {
|
|
1092
|
+
// 1. Check common logo filenames across typical directories
|
|
1093
|
+
const dirs = ['', 'assets', 'src/assets', 'images', 'src/images', 'public', 'static', 'resources'];
|
|
1094
|
+
const names = ['logo', 'logo-full', 'logo-dark', 'logo-light', 'wordmark', 'brand-logo', 'app-logo'];
|
|
1095
|
+
// Prefer vector > raster
|
|
1096
|
+
const exts = ['.svg', '.png', '.pdf', '.jpg'];
|
|
1097
|
+
|
|
1098
|
+
for (const dir of dirs) {
|
|
1099
|
+
for (const name of names) {
|
|
1100
|
+
for (const ext of exts) {
|
|
1101
|
+
const full = path.join(root, dir, name + ext);
|
|
1102
|
+
if (fs.existsSync(full)) return full;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// 2. Framework-specific splash/launch images
|
|
1108
|
+
if (framework.name === 'expo') {
|
|
1109
|
+
const appJson = readJson(path.join(root, 'app.json'));
|
|
1110
|
+
const splashImage = appJson?.expo?.splash?.image;
|
|
1111
|
+
if (splashImage) {
|
|
1112
|
+
const full = path.join(root, splashImage);
|
|
1113
|
+
if (fs.existsSync(full)) return full;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// 3. Search xcassets for .imageset directories with "logo" in the name
|
|
1118
|
+
const imagesets = findFiles(root, '.imageset', 8);
|
|
1119
|
+
for (const dir of imagesets) {
|
|
1120
|
+
if (path.basename(dir).toLowerCase().includes('logo')) {
|
|
1121
|
+
const pngs = findFiles(dir, '.png', 1);
|
|
1122
|
+
if (pngs.length > 0) return pngs[0];
|
|
1123
|
+
const svgs = findFiles(dir, '.svg', 1);
|
|
1124
|
+
if (svgs.length > 0) return svgs[0];
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// 4. Deep search for any file with "logo" in the name
|
|
1129
|
+
const allFiles = [
|
|
1130
|
+
...findFiles(root, '.svg', 8),
|
|
1131
|
+
...findFiles(root, '.png', 8),
|
|
1132
|
+
];
|
|
1133
|
+
const logoFile = allFiles.find(f => {
|
|
1134
|
+
const name = path.basename(f).toLowerCase();
|
|
1135
|
+
return name.includes('logo') && !f.includes('node_modules');
|
|
1136
|
+
});
|
|
1137
|
+
if (logoFile) return logoFile;
|
|
1138
|
+
|
|
1139
|
+
return null;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
// ─────────────────────────────────────────────────────────────────
|
|
1144
|
+
// HELPERS
|
|
1145
|
+
// ─────────────────────────────────────────────────────────────────
|
|
1146
|
+
|
|
1147
|
+
function buildProfile({ primary, secondary, accent, background } = {}) {
|
|
1148
|
+
return {
|
|
1149
|
+
primary: primary || '#1A1A2E', // Deep navy default
|
|
1150
|
+
secondary: secondary || '#F5F5F5', // Light gray default
|
|
1151
|
+
accent: accent || '#E94560', // Red accent default
|
|
1152
|
+
background: background || '#FFFFFF',
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
function stripNulls(obj) {
|
|
1157
|
+
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== null));
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function readJson(filePath) {
|
|
1161
|
+
try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); }
|
|
1162
|
+
catch { return null; }
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
function readYaml(filePath) {
|
|
1166
|
+
// Minimal YAML reader for pubspec.yaml — just extracts top-level keys
|
|
1167
|
+
if (!fs.existsSync(filePath)) return null;
|
|
1168
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
1169
|
+
const result = {};
|
|
1170
|
+
for (const line of content.split('\n')) {
|
|
1171
|
+
const match = line.match(/^(\w+):\s*(.+)/);
|
|
1172
|
+
if (match) result[match[1]] = match[2].trim();
|
|
1173
|
+
}
|
|
1174
|
+
return result;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
function readPlist(iosDir) {
|
|
1178
|
+
// Reads Info.plist as text — returns key/value map for simple string values
|
|
1179
|
+
const plistFiles = findFiles(iosDir, 'Info.plist', 3);
|
|
1180
|
+
if (!plistFiles.length) return null;
|
|
1181
|
+
|
|
1182
|
+
try {
|
|
1183
|
+
const content = fs.readFileSync(plistFiles[0], 'utf8');
|
|
1184
|
+
const result = {};
|
|
1185
|
+
const keys = content.matchAll(/<key>([^<]+)<\/key>\s*<string>([^<]+)<\/string>/g);
|
|
1186
|
+
for (const [, key, value] of keys) result[key] = value;
|
|
1187
|
+
return result;
|
|
1188
|
+
} catch { return null; }
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
function findFiles(dir, ext, maxDepth = 3, depth = 0) {
|
|
1192
|
+
if (depth > maxDepth || !fs.existsSync(dir)) return [];
|
|
1193
|
+
const results = [];
|
|
1194
|
+
try {
|
|
1195
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
1196
|
+
const full = path.join(dir, entry.name);
|
|
1197
|
+
if (entry.isDirectory()) {
|
|
1198
|
+
// Some extensions (.xcodeproj, .xcassets, .colorset, .appiconset) are directories
|
|
1199
|
+
if (entry.name.endsWith(ext)) {
|
|
1200
|
+
results.push(full);
|
|
1201
|
+
} else if (!entry.name.startsWith('.') && entry.name !== 'node_modules') {
|
|
1202
|
+
results.push(...findFiles(full, ext, maxDepth, depth + 1));
|
|
1203
|
+
}
|
|
1204
|
+
} else if (entry.name.endsWith(ext)) {
|
|
1205
|
+
results.push(full);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
} catch {}
|
|
1209
|
+
return results;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function componentsToHex(components) {
|
|
1213
|
+
try {
|
|
1214
|
+
// Components can be 0-1 floats or 0-255 integers
|
|
1215
|
+
let r = parseFloat(components.red);
|
|
1216
|
+
let g = parseFloat(components.green);
|
|
1217
|
+
let b = parseFloat(components.blue);
|
|
1218
|
+
if (r <= 1 && g <= 1 && b <= 1) { r *= 255; g *= 255; b *= 255; }
|
|
1219
|
+
const toHex = n => Math.round(n).toString(16).padStart(2, '0');
|
|
1220
|
+
return '#' + toHex(r) + toHex(g) + toHex(b);
|
|
1221
|
+
} catch { return null; }
|
|
1222
|
+
}
|