@createlex/figgen 1.4.2

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.
@@ -0,0 +1,516 @@
1
+ const crypto = require('crypto');
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+ const path = require('path');
5
+
6
+ const GENERATED_ROOT_DIRNAME = 'FigmaGenerated';
7
+ const GENERATED_LAYOUT_VERSION = 1;
8
+
9
+ function getConfigDirectory() {
10
+ if (process.platform === 'darwin') {
11
+ return path.join(os.homedir(), 'Library', 'Application Support', 'FigmaSwiftUICompanion');
12
+ }
13
+ return path.join(os.homedir(), '.config', 'FigmaSwiftUICompanion');
14
+ }
15
+
16
+ function getConfigPath() {
17
+ return path.join(getConfigDirectory(), 'config.json');
18
+ }
19
+
20
+ function readConfig() {
21
+ const configPath = getConfigPath();
22
+ if (!fs.existsSync(configPath)) {
23
+ return {};
24
+ }
25
+
26
+ try {
27
+ const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
28
+ return parsed && typeof parsed === 'object' ? parsed : {};
29
+ } catch {
30
+ return {};
31
+ }
32
+ }
33
+
34
+ function writeConfig(config) {
35
+ const configPath = getConfigPath();
36
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
37
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
38
+ }
39
+
40
+ function getSavedProjectPath() {
41
+ const config = readConfig();
42
+ return typeof config.projectPath === 'string' && config.projectPath.length > 0
43
+ ? config.projectPath
44
+ : null;
45
+ }
46
+
47
+ function setSavedProjectPath(projectPath) {
48
+ const resolved = resolveWritableProjectPath(projectPath);
49
+ writeConfig({
50
+ ...readConfig(),
51
+ projectPath: resolved,
52
+ updatedAt: new Date().toISOString(),
53
+ });
54
+ return resolved;
55
+ }
56
+
57
+ function clearSavedProjectPath() {
58
+ const config = readConfig();
59
+ delete config.projectPath;
60
+ config.updatedAt = new Date().toISOString();
61
+ writeConfig(config);
62
+ }
63
+
64
+ function loadProjectPath({ explicitPath } = {}) {
65
+ if (explicitPath) {
66
+ return resolveWritableProjectPath(explicitPath);
67
+ }
68
+
69
+ if (process.env.FIGMA_SWIFTUI_PROJECT_PATH) {
70
+ return resolveWritableProjectPath(process.env.FIGMA_SWIFTUI_PROJECT_PATH);
71
+ }
72
+
73
+ return getSavedProjectPath();
74
+ }
75
+
76
+ function writeSwiftUIScreen({ targetDir, code, structName, images = [], additionalFiles = [] }) {
77
+ const resolvedTarget = resolveWritableProjectPath(targetDir);
78
+ const generatedDirs = ensureGeneratedLayout(resolvedTarget);
79
+ const results = {
80
+ swiftFile: null,
81
+ additionalSwiftFiles: [],
82
+ images: [],
83
+ manifestFiles: [],
84
+ generatedRoot: generatedDirs.rootDir,
85
+ errors: [],
86
+ };
87
+
88
+ if (code && structName) {
89
+ const swiftPath = path.join(generatedDirs.screensDir, `${structName}.swift`);
90
+ try {
91
+ fs.writeFileSync(swiftPath, code, 'utf8');
92
+ results.swiftFile = swiftPath;
93
+ } catch (error) {
94
+ results.errors.push(`Failed to write ${structName}.swift: ${error.message}`);
95
+ }
96
+ }
97
+
98
+ // Write additional files (DesignTokens.swift, component files, etc.)
99
+ if (Array.isArray(additionalFiles) && additionalFiles.length > 0) {
100
+ const componentsDir = path.join(generatedDirs.rootDir, 'Components');
101
+ for (const extra of additionalFiles) {
102
+ if (!extra.name || !extra.code) continue;
103
+ let destDir;
104
+ if (extra.dir === 'shared') {
105
+ destDir = generatedDirs.rootDir;
106
+ } else if (extra.dir === 'components') {
107
+ try {
108
+ fs.mkdirSync(componentsDir, { recursive: true });
109
+ } catch {
110
+ // ignore
111
+ }
112
+ destDir = componentsDir;
113
+ } else {
114
+ destDir = generatedDirs.screensDir;
115
+ }
116
+ const filePath = path.join(destDir, extra.name);
117
+ try {
118
+ fs.writeFileSync(filePath, extra.code, 'utf8');
119
+ results.additionalSwiftFiles.push(filePath);
120
+ } catch (error) {
121
+ results.errors.push(`Failed to write ${extra.name}: ${error.message}`);
122
+ }
123
+ }
124
+ }
125
+
126
+ if (Array.isArray(images) && images.length > 0) {
127
+ const assetResult = writeAssetCatalogEntries({
128
+ targetDir: resolvedTarget,
129
+ assets: images.map((image) => ({
130
+ name: image.name,
131
+ format: image.format || 'png',
132
+ base64: image.base64,
133
+ svg: image.svg,
134
+ })),
135
+ });
136
+ results.images.push(...assetResult.files);
137
+ results.errors.push(...assetResult.errors);
138
+ }
139
+
140
+ if (code && structName) {
141
+ try {
142
+ const manifestFiles = writeGeneratedManifest(resolvedTarget, generatedDirs, {
143
+ structName,
144
+ code,
145
+ swiftFile: results.swiftFile,
146
+ images: Array.isArray(images) ? images : [],
147
+ });
148
+ results.manifestFiles.push(...manifestFiles);
149
+ } catch (error) {
150
+ results.errors.push(`Failed to update generated manifest: ${error.message}`);
151
+ }
152
+ }
153
+
154
+ return {
155
+ ok: results.errors.length === 0,
156
+ results,
157
+ projectPath: resolvedTarget,
158
+ generatedRoot: generatedDirs.rootDir,
159
+ layoutVersion: GENERATED_LAYOUT_VERSION,
160
+ };
161
+ }
162
+
163
+ function writeAssetCatalogEntries({ targetDir, assets = [] }) {
164
+ const resolvedTarget = resolveWritableProjectPath(targetDir);
165
+ const xcassetsDir = findOrCreateXcassets(resolvedTarget);
166
+ const files = [];
167
+ const errors = [];
168
+
169
+ for (const asset of assets) {
170
+ try {
171
+ const imagesetDir = path.join(xcassetsDir, `${asset.name}.imageset`);
172
+ fs.mkdirSync(imagesetDir, { recursive: true });
173
+
174
+ if (asset.format === 'svg') {
175
+ const svgPath = path.join(imagesetDir, `${asset.name}.svg`);
176
+ fs.writeFileSync(svgPath, asset.svg, 'utf8');
177
+ fs.writeFileSync(
178
+ path.join(imagesetDir, 'Contents.json'),
179
+ JSON.stringify({
180
+ images: [
181
+ { idiom: 'universal', filename: `${asset.name}.svg` },
182
+ ],
183
+ properties: {
184
+ 'preserves-vector-representation': true,
185
+ },
186
+ info: { version: 1, author: 'figma-swiftui-plugin' },
187
+ }, null, 2),
188
+ 'utf8'
189
+ );
190
+ files.push(svgPath);
191
+ continue;
192
+ }
193
+
194
+ const pngPath = path.join(imagesetDir, `${asset.name}.png`);
195
+ const pngBuffer = Buffer.from(asset.base64, 'base64');
196
+ fs.writeFileSync(pngPath, pngBuffer);
197
+ fs.writeFileSync(
198
+ path.join(imagesetDir, 'Contents.json'),
199
+ JSON.stringify({
200
+ images: [
201
+ { idiom: 'universal', scale: '1x' },
202
+ { idiom: 'universal', scale: '2x', filename: `${asset.name}.png` },
203
+ { idiom: 'universal', scale: '3x' },
204
+ ],
205
+ info: { version: 1, author: 'figma-swiftui-plugin' },
206
+ }, null, 2),
207
+ 'utf8'
208
+ );
209
+ files.push(pngPath);
210
+ } catch (error) {
211
+ errors.push(`Failed to write image ${asset.name}: ${error.message}`);
212
+ }
213
+ }
214
+
215
+ return {
216
+ ok: errors.length === 0,
217
+ xcassetsDir,
218
+ files,
219
+ errors,
220
+ };
221
+ }
222
+
223
+ function inferStructName({ structName, code, selectionNames = [] }) {
224
+ if (structName && typeof structName === 'string') {
225
+ return sanitizeName(structName) || 'GeneratedView';
226
+ }
227
+
228
+ if (typeof code === 'string') {
229
+ const match = code.match(/struct\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*View\b/);
230
+ if (match?.[1]) {
231
+ return match[1];
232
+ }
233
+ }
234
+
235
+ if (selectionNames.length > 0) {
236
+ return sanitizeName(selectionNames[0]) || 'GeneratedView';
237
+ }
238
+
239
+ return 'GeneratedView';
240
+ }
241
+
242
+ function sanitizeName(name) {
243
+ return String(name)
244
+ .replace(/[^a-zA-Z0-9 ]/g, '')
245
+ .replace(/\s+(.)/g, (_, c) => c.toUpperCase())
246
+ .replace(/^\w/, c => c.toUpperCase())
247
+ .trim();
248
+ }
249
+
250
+ function findOrCreateXcassets(targetDir) {
251
+ const entries = fs.readdirSync(targetDir);
252
+ const existing = entries.find((entry) => entry.endsWith('.xcassets'));
253
+ if (existing) {
254
+ return path.join(targetDir, existing);
255
+ }
256
+
257
+ const newDir = path.join(targetDir, 'Assets.xcassets');
258
+ fs.mkdirSync(newDir, { recursive: true });
259
+ fs.writeFileSync(
260
+ path.join(newDir, 'Contents.json'),
261
+ JSON.stringify({ info: { version: 1, author: 'xcode' } }, null, 2),
262
+ 'utf8'
263
+ );
264
+ return newDir;
265
+ }
266
+
267
+ function ensureGeneratedLayout(targetDir) {
268
+ const rootDir = path.join(targetDir, GENERATED_ROOT_DIRNAME);
269
+ const screensDir = path.join(rootDir, 'Screens');
270
+ const componentsDir = path.join(rootDir, 'Components');
271
+ const manifestDir = path.join(rootDir, 'Manifest');
272
+
273
+ [rootDir, screensDir, componentsDir, manifestDir].forEach((dirPath) => {
274
+ fs.mkdirSync(dirPath, { recursive: true });
275
+ });
276
+
277
+ return { rootDir, screensDir, componentsDir, manifestDir };
278
+ }
279
+
280
+ function writeGeneratedManifest(targetDir, generatedDirs, payload) {
281
+ const { structName, code, swiftFile, images } = payload;
282
+ const generatedAt = new Date().toISOString();
283
+ const codeHash = crypto.createHash('sha256').update(code, 'utf8').digest('hex');
284
+ const screenManifestPath = path.join(generatedDirs.manifestDir, `${structName}.json`);
285
+ const indexManifestPath = path.join(generatedDirs.manifestDir, 'index.json');
286
+ const xcassetsDir = findOrCreateXcassets(targetDir);
287
+
288
+ const screenManifest = {
289
+ schemaVersion: GENERATED_LAYOUT_VERSION,
290
+ generatedAt,
291
+ structName,
292
+ codeHash,
293
+ screenFile: swiftFile ? toProjectRelativePath(targetDir, swiftFile) : null,
294
+ componentFiles: [],
295
+ assetCatalog: toProjectRelativePath(targetDir, xcassetsDir),
296
+ assets: images.map((image) => ({
297
+ name: image.name,
298
+ imageset: `Assets.xcassets/${image.name}.imageset`,
299
+ })),
300
+ };
301
+
302
+ fs.writeFileSync(screenManifestPath, JSON.stringify(screenManifest, null, 2), 'utf8');
303
+
304
+ let indexManifest = {
305
+ schemaVersion: GENERATED_LAYOUT_VERSION,
306
+ updatedAt: generatedAt,
307
+ generatedRoot: GENERATED_ROOT_DIRNAME,
308
+ projectPath: targetDir,
309
+ screens: [],
310
+ };
311
+
312
+ if (fs.existsSync(indexManifestPath)) {
313
+ try {
314
+ const parsed = JSON.parse(fs.readFileSync(indexManifestPath, 'utf8'));
315
+ if (parsed && typeof parsed === 'object' && Array.isArray(parsed.screens)) {
316
+ indexManifest = {
317
+ ...indexManifest,
318
+ ...parsed,
319
+ screens: parsed.screens,
320
+ };
321
+ }
322
+ } catch {
323
+ // Ignore invalid existing manifest; it will be replaced.
324
+ }
325
+ }
326
+
327
+ indexManifest.updatedAt = generatedAt;
328
+ indexManifest.projectPath = targetDir;
329
+ indexManifest.generatedRoot = GENERATED_ROOT_DIRNAME;
330
+ indexManifest.screens = [
331
+ ...indexManifest.screens.filter((screen) => screen.structName !== structName),
332
+ {
333
+ structName,
334
+ generatedAt,
335
+ codeHash,
336
+ screenFile: screenManifest.screenFile,
337
+ manifestFile: toProjectRelativePath(targetDir, screenManifestPath),
338
+ assetCount: screenManifest.assets.length,
339
+ assets: screenManifest.assets.map((asset) => asset.name),
340
+ },
341
+ ].sort((left, right) => left.structName.localeCompare(right.structName));
342
+
343
+ fs.writeFileSync(indexManifestPath, JSON.stringify(indexManifest, null, 2), 'utf8');
344
+ return [screenManifestPath, indexManifestPath];
345
+ }
346
+
347
+ function toProjectRelativePath(targetDir, filePath) {
348
+ return path.relative(targetDir, filePath).replace(/\\/g, '/');
349
+ }
350
+
351
+ function resolveWritableProjectPath(inputPath) {
352
+ const resolved = path.resolve(inputPath);
353
+ if (!fs.existsSync(resolved)) {
354
+ return resolved;
355
+ }
356
+
357
+ const stats = fs.statSync(resolved);
358
+ if (!stats.isDirectory()) {
359
+ const parentDir = path.dirname(resolved);
360
+ const projectBaseName = path.basename(resolved, path.extname(resolved));
361
+ const matchingChild = path.join(parentDir, projectBaseName);
362
+ if (looksLikeSourceDirectory(matchingChild)) {
363
+ return matchingChild;
364
+ }
365
+ return parentDir;
366
+ }
367
+
368
+ const localProjectNames = getLocalProjectNames(resolved);
369
+ const currentScore = scoreSourceDirectory(resolved);
370
+
371
+ const childDirs = fs.readdirSync(resolved, { withFileTypes: true })
372
+ .filter((entry) => entry.isDirectory() && !entry.name.endsWith('.xcodeproj') && !entry.name.endsWith('.xcworkspace'))
373
+ .map((entry) => path.join(resolved, entry.name));
374
+
375
+ const sourceCandidates = childDirs
376
+ .filter(looksLikeSourceDirectory)
377
+ .sort((left, right) => scoreSourceDirectory(right, localProjectNames) - scoreSourceDirectory(left, localProjectNames));
378
+
379
+ if (sourceCandidates.length > 0) {
380
+ const bestChild = sourceCandidates[0];
381
+ const bestChildScore = scoreSourceDirectory(bestChild, localProjectNames);
382
+ const childName = path.basename(bestChild);
383
+
384
+ if (
385
+ localProjectNames.has(childName) ||
386
+ bestChildScore > currentScore ||
387
+ !looksLikeSourceDirectory(resolved)
388
+ ) {
389
+ return bestChild;
390
+ }
391
+ }
392
+
393
+ if (looksLikeSourceDirectory(resolved)) {
394
+ return resolved;
395
+ }
396
+
397
+ return resolved;
398
+ }
399
+
400
+ function getLocalProjectNames(dirPath) {
401
+ const projectNames = new Set();
402
+ for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
403
+ if (!entry.isDirectory()) {
404
+ continue;
405
+ }
406
+ if (entry.name.endsWith('.xcodeproj') || entry.name.endsWith('.xcworkspace')) {
407
+ projectNames.add(path.basename(entry.name, path.extname(entry.name)));
408
+ }
409
+ }
410
+ return projectNames;
411
+ }
412
+
413
+ function looksLikeSourceDirectory(dirPath) {
414
+ if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
415
+ return false;
416
+ }
417
+
418
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
419
+ return entries.some((entry) => entry.isFile() && entry.name.endsWith('.swift'))
420
+ || entries.some((entry) => entry.isDirectory() && entry.name.endsWith('.xcassets'));
421
+ }
422
+
423
+ function scoreSourceDirectory(dirPath, preferredNames = new Set()) {
424
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
425
+ let score = 0;
426
+
427
+ for (const entry of entries) {
428
+ if (entry.isFile() && entry.name.endsWith('.swift')) {
429
+ score += 2;
430
+ if (entry.name.endsWith('App.swift')) {
431
+ score += 3;
432
+ }
433
+ }
434
+ if (entry.isDirectory() && entry.name.endsWith('.xcassets')) {
435
+ score += 4;
436
+ }
437
+ }
438
+
439
+ if (preferredNames.has(path.basename(dirPath))) {
440
+ score += 10;
441
+ }
442
+
443
+ return score;
444
+ }
445
+
446
+ /**
447
+ * Write multiple screens from a single generation batch.
448
+ * Shares DesignTokens.swift and Components across all screens.
449
+ *
450
+ * @param {Object} opts
451
+ * @param {string} opts.targetDir - Xcode source folder
452
+ * @param {Array<{structName: string, code: string, images?: Array}>} opts.screens
453
+ * @param {string|null} opts.designTokensCode - Shared DesignTokens.swift
454
+ * @param {Array<{name: string, code: string}>} opts.componentFiles - Shared components
455
+ * @returns {{ok: boolean, screens: Array, errors: string[]}}
456
+ */
457
+ function writeMultiScreenProject({ targetDir, screens = [], designTokensCode = null, componentFiles = [] }) {
458
+ const allErrors = [];
459
+ const screenResults = [];
460
+
461
+ // Build shared additional files list
462
+ const sharedFiles = [];
463
+ if (designTokensCode) {
464
+ sharedFiles.push({ name: 'DesignTokens.swift', code: designTokensCode, dir: 'shared' });
465
+ }
466
+ for (const comp of componentFiles) {
467
+ sharedFiles.push({ name: comp.name, code: comp.code, dir: 'components' });
468
+ }
469
+
470
+ for (let i = 0; i < screens.length; i++) {
471
+ const screen = screens[i];
472
+ // Only include shared files on the first screen write to avoid duplicates
473
+ const additionalFiles = i === 0 ? sharedFiles : [];
474
+
475
+ const result = writeSwiftUIScreen({
476
+ targetDir,
477
+ code: screen.code,
478
+ structName: screen.structName,
479
+ images: Array.isArray(screen.images) ? screen.images : [],
480
+ additionalFiles,
481
+ });
482
+
483
+ screenResults.push({
484
+ structName: screen.structName,
485
+ ok: result.ok,
486
+ swiftFile: result.results?.swiftFile ?? null,
487
+ imageCount: (screen.images ?? []).length,
488
+ });
489
+
490
+ if (!result.ok) {
491
+ allErrors.push(...result.results.errors);
492
+ }
493
+ }
494
+
495
+ return {
496
+ ok: allErrors.length === 0,
497
+ screens: screenResults,
498
+ errors: allErrors,
499
+ projectPath: targetDir,
500
+ };
501
+ }
502
+
503
+ module.exports = {
504
+ GENERATED_ROOT_DIRNAME,
505
+ GENERATED_LAYOUT_VERSION,
506
+ clearSavedProjectPath,
507
+ getConfigPath,
508
+ getSavedProjectPath,
509
+ inferStructName,
510
+ loadProjectPath,
511
+ resolveWritableProjectPath,
512
+ setSavedProjectPath,
513
+ writeAssetCatalogEntries,
514
+ writeMultiScreenProject,
515
+ writeSwiftUIScreen,
516
+ };
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@createlex/figgen",
3
+ "version": "1.4.2",
4
+ "description": "CreateLex MCP runtime for Figma-to-SwiftUI generation and Xcode export",
5
+ "bin": {
6
+ "figgen": "bin/figgen.js"
7
+ },
8
+ "files": [
9
+ "bin/",
10
+ "companion/bridge-server.cjs",
11
+ "companion/createlex-auth.cjs",
12
+ "companion/local-llm-generator.cjs",
13
+ "companion/login.mjs",
14
+ "companion/mcp-server.mjs",
15
+ "companion/package.json",
16
+ "companion/server.js",
17
+ "companion/setup.cjs",
18
+ "companion/xcode-writer.cjs",
19
+ "README.md"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "scripts": {
25
+ "build": "tsc",
26
+ "login": "node bin/figma-swiftui-mcp.js login",
27
+ "start": "node bin/figma-swiftui-mcp.js start",
28
+ "watch": "tsc --watch",
29
+ "test:bridge-smoke": "node ../tests/mcp/figma_plugin_swiftui_bridge_smoke.mjs",
30
+ "test:high-fidelity-smoke": "node ../tests/mcp/figma_plugin_swiftui_high_fidelity_smoke.mjs",
31
+ "start:companion:mcp": "node bin/figma-swiftui-mcp.js start"
32
+ },
33
+ "engines": {
34
+ "node": ">=18.0.0"
35
+ },
36
+ "dependencies": {
37
+ "@modelcontextprotocol/sdk": "^1.28.0",
38
+ "cors": "^2.8.5",
39
+ "express": "^4.18.0",
40
+ "ws": "^8.20.0",
41
+ "zod": "^4.3.6"
42
+ },
43
+ "optionalDependencies": {
44
+ "@anthropic-ai/sdk": "^0.52.0"
45
+ },
46
+ "devDependencies": {
47
+ "@figma/plugin-typings": "*",
48
+ "typescript": "^5.4.0"
49
+ }
50
+ }