figma-local 1.9.0 → 2.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/src/index.js CHANGED
@@ -20,6 +20,10 @@ import {
20
20
  STAGE1_METADATA, buildFrameStructureCode, buildUsedTokensCode, formatLeanContext
21
21
  } from './read.js';
22
22
  import { generatePrompt } from './prompt-templates.js';
23
+ import {
24
+ buildSingleAuditCode, buildAllAuditCode, buildSelectionAuditCode,
25
+ formatAuditResult, formatAllAuditResult
26
+ } from './component-audit.js';
23
27
  import {
24
28
  nullDevice, killPort, getPortPid, sleepAfterStop,
25
29
  startFigmaApp, killFigmaApp,
@@ -10581,27 +10585,34 @@ program
10581
10585
  .option('--key <key>', 'Component key for importing')
10582
10586
  .option('--name <name>', 'Filter by name (partial match)')
10583
10587
  .option('--json', 'Output raw JSON')
10588
+ .option('--api', 'Use Figma REST API instead of plugin (for index)')
10589
+ .option('--token <token>', 'Figma personal access token (for --api)')
10590
+ .option('--file <url>', 'Figma file URL (for --api)')
10591
+ .option('--page <pageName>', 'Index only a specific page (for index)')
10584
10592
  .addHelpText('after', `
10585
10593
  Actions:
10586
10594
  collections List all available library variable collections
10587
10595
  variables List variables from a library collection (use --name to filter)
10588
10596
  components List available library components on current page (use --name to filter)
10589
- index Scan the current open file and save all components to a local index
10597
+ index Scan and save all components to a local index
10590
10598
  search Search indexed libraries for components (use --name to filter)
10591
10599
  import Import a component by key (use --key)
10592
10600
  list List all indexed libraries
10593
10601
 
10602
+ Index modes:
10603
+ fig library index Scan current file via plugin (small files)
10604
+ fig library index --page "Buttons" Scan only one page (large files)
10605
+ fig library index --api --token "figd_..." --file "URL" Use REST API (best for large files)
10606
+ fig library index --api --file "URL" Use saved token
10607
+
10594
10608
  Examples:
10595
10609
  fig library collections List all library variable collections
10596
- fig library variables List all library variables
10597
10610
  fig library variables --name "color" List variables matching "color"
10598
- fig library components List available library components
10599
10611
  fig library components --name "button" Find button components
10600
- fig library index Index all components in the current open file
10612
+ fig library index --api --token "figd_..." --file "https://www.figma.com/design/ABC/..."
10601
10613
  fig library search --name "button" Search indexed libraries for "button"
10602
10614
  fig library list List all indexed libraries
10603
10615
  fig library import --key "abc123..." Import a component by its key
10604
- fig library import --key "abc123..." --name "MyButton" Import and rename
10605
10616
  `)
10606
10617
  .action(async (action, options) => {
10607
10618
  checkConnection();
@@ -10794,13 +10805,214 @@ Examples:
10794
10805
  console.log(` ${chalk.gray('id:')} ${result.id}`);
10795
10806
  console.log(chalk.gray(' Component is now selected on the canvas.'));
10796
10807
  } else if (action === 'index') {
10797
- spinner.text = 'Scanning all pages for components...';
10798
- const code = `(function() {
10808
+ // Helper to save index data and print summary
10809
+ function saveIndexAndPrint(result, opts) {
10810
+ const libDir = join(homedir(), '.figma-local', 'libraries');
10811
+ mkdirSync(libDir, { recursive: true });
10812
+ const safeName = result.fileName.replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase();
10813
+ const libFile = join(libDir, `${safeName}.json`);
10814
+
10815
+ // If file exists and we're doing page-by-page, merge
10816
+ let existingComponents = [];
10817
+ let existingPages = new Set();
10818
+ if (opts.page && existsSync(libFile)) {
10819
+ try {
10820
+ const existing = JSON.parse(readFileSync(libFile, 'utf8'));
10821
+ // Keep components from OTHER pages
10822
+ existingComponents = (existing.components || []).filter(c => c.page !== opts.page);
10823
+ existing.components.forEach(c => existingPages.add(c.page));
10824
+ } catch (e) { /* fresh start */ }
10825
+ }
10826
+ const mergedComponents = [...existingComponents, ...result.components];
10827
+ result.components.forEach(c => existingPages.add(c.page));
10828
+
10829
+ const indexData = {
10830
+ fileName: result.fileName,
10831
+ fileKey: result.fileKey || '',
10832
+ indexedAt: new Date().toISOString(),
10833
+ pageCount: opts.page ? existingPages.size : result.pageCount,
10834
+ componentCount: mergedComponents.length,
10835
+ components: mergedComponents
10836
+ };
10837
+ writeFileSync(libFile, JSON.stringify(indexData, null, 2));
10838
+ return { indexData, libFile };
10839
+ }
10840
+
10841
+ function printIndexSummary(indexData, libFile, opts) {
10842
+ console.log(` ${chalk.gray('Saved to:')} ${libFile}`);
10843
+ if (opts.json) {
10844
+ console.log(JSON.stringify(indexData, null, 2));
10845
+ } else {
10846
+ const sets = {};
10847
+ const standalone = [];
10848
+ for (const c of indexData.components) {
10849
+ if (c.componentSet) {
10850
+ if (!sets[c.componentSet]) sets[c.componentSet] = [];
10851
+ sets[c.componentSet].push(c);
10852
+ } else {
10853
+ standalone.push(c);
10854
+ }
10855
+ }
10856
+ if (Object.keys(sets).length > 0) {
10857
+ console.log(chalk.cyan('\n Component Sets:'));
10858
+ for (const [setName, variants] of Object.entries(sets)) {
10859
+ console.log(` ${chalk.white(setName)} ${chalk.gray(`(${variants.length} variant${variants.length !== 1 ? 's' : ''})`)}`);
10860
+ }
10861
+ }
10862
+ if (standalone.length > 0) {
10863
+ console.log(chalk.cyan('\n Standalone Components:'));
10864
+ for (const c of standalone.slice(0, 50)) {
10865
+ console.log(` ${chalk.white(c.name)} ${chalk.gray(`[${c.page}]`)}`);
10866
+ }
10867
+ if (standalone.length > 50) {
10868
+ console.log(chalk.gray(` ... and ${standalone.length - 50} more`));
10869
+ }
10870
+ }
10871
+ }
10872
+ }
10873
+
10874
+ // ---- REST API mode ----
10875
+ if (options.api) {
10876
+ // Get or save token
10877
+ const configDir = join(homedir(), '.figma-local');
10878
+ mkdirSync(configDir, { recursive: true });
10879
+ const tokenFile = join(configDir, 'figma-token');
10880
+ let token = options.token || '';
10881
+ if (!token && existsSync(tokenFile)) {
10882
+ token = readFileSync(tokenFile, 'utf8').trim();
10883
+ }
10884
+ if (!token) {
10885
+ spinner.fail('Figma token required. Get one from: Figma → Settings → Personal Access Tokens\nThen run: fig library index --api --token "figd_..." --file "URL"');
10886
+ process.exit(1);
10887
+ }
10888
+ // Save token for future use
10889
+ if (options.token) {
10890
+ writeFileSync(tokenFile, token);
10891
+ }
10892
+
10893
+ if (!options.file) {
10894
+ spinner.fail('--file is required with --api. Provide a Figma file URL.\nExample: fig library index --api --file "https://www.figma.com/design/ABC123/MyFile"');
10895
+ process.exit(1);
10896
+ }
10897
+
10898
+ // Extract file key from URL
10899
+ const fileKeyMatch = options.file.match(/(?:file|design)\/([a-zA-Z0-9]+)/);
10900
+ if (!fileKeyMatch) {
10901
+ spinner.fail('Could not extract file key from URL. Use a URL like: https://www.figma.com/design/ABC123/MyFile');
10902
+ process.exit(1);
10903
+ }
10904
+ const fileKey = fileKeyMatch[1];
10905
+
10906
+ spinner.text = `Fetching file metadata from Figma API...`;
10907
+
10908
+ // Fetch the file from the REST API
10909
+ const https = await import('https');
10910
+ function figmaApiGet(endpoint) {
10911
+ return new Promise((resolve, reject) => {
10912
+ const url = `https://api.figma.com/v1${endpoint}`;
10913
+ const req = https.get(url, { headers: { 'X-Figma-Token': token } }, (res) => {
10914
+ let data = '';
10915
+ res.on('data', chunk => data += chunk);
10916
+ res.on('end', () => {
10917
+ if (res.statusCode === 403) {
10918
+ reject(new Error('Access denied. Check your token has read access to this file.'));
10919
+ } else if (res.statusCode === 404) {
10920
+ reject(new Error('File not found. Check the URL is correct.'));
10921
+ } else if (res.statusCode !== 200) {
10922
+ reject(new Error(`Figma API returned ${res.statusCode}: ${data.slice(0, 200)}`));
10923
+ } else {
10924
+ try { resolve(JSON.parse(data)); }
10925
+ catch (e) { reject(new Error('Invalid JSON response from Figma API')); }
10926
+ }
10927
+ });
10928
+ });
10929
+ req.on('error', reject);
10930
+ });
10931
+ }
10932
+
10799
10933
  try {
10800
- var components = [];
10801
- var pages = figma.root.children;
10802
- for (var p = 0; p < pages.length; p++) {
10803
- var page = pages[p];
10934
+ // Get file components via the components endpoint (lightweight)
10935
+ spinner.text = 'Fetching components from Figma API...';
10936
+ const compData = await figmaApiGet(`/files/${fileKey}/components`);
10937
+
10938
+ const components = [];
10939
+ if (compData.meta && compData.meta.components) {
10940
+ for (const comp of compData.meta.components) {
10941
+ const c = {
10942
+ name: comp.name,
10943
+ key: comp.key,
10944
+ id: comp.node_id,
10945
+ page: comp.containing_frame ? comp.containing_frame.pageName || '' : '',
10946
+ description: comp.description || '',
10947
+ };
10948
+ if (comp.containing_frame && comp.containing_frame.name) {
10949
+ // The containing_frame for component set variants shows the set name
10950
+ c.componentSet = comp.containing_frame.name !== comp.name ? comp.containing_frame.name : undefined;
10951
+ }
10952
+ components.push(c);
10953
+ }
10954
+ }
10955
+
10956
+ // Also get component sets
10957
+ spinner.text = 'Fetching component sets from Figma API...';
10958
+ try {
10959
+ const setsData = await figmaApiGet(`/files/${fileKey}/component_sets`);
10960
+ if (setsData.meta && setsData.meta.component_sets) {
10961
+ for (const set of setsData.meta.component_sets) {
10962
+ // Mark components that belong to this set
10963
+ for (const comp of components) {
10964
+ if (comp.id && set.node_id && comp.componentSet === undefined) {
10965
+ // Check if node is child of this set by matching containing_frame
10966
+ }
10967
+ }
10968
+ }
10969
+ }
10970
+ } catch (e) { /* component_sets endpoint may not exist for all plans */ }
10971
+
10972
+ // Get file name
10973
+ spinner.text = 'Fetching file info...';
10974
+ let fileName = fileKey;
10975
+ try {
10976
+ const fileInfo = await figmaApiGet(`/files/${fileKey}?depth=1`);
10977
+ if (fileInfo.name) fileName = fileInfo.name;
10978
+ } catch (e) { /* use fileKey as fallback */ }
10979
+
10980
+ const result = {
10981
+ fileName,
10982
+ fileKey,
10983
+ pageCount: 0,
10984
+ componentCount: components.length,
10985
+ components
10986
+ };
10987
+
10988
+ const { indexData, libFile } = saveIndexAndPrint(result, options);
10989
+ spinner.succeed(`Indexed ${components.length} components from "${fileName}" via API`);
10990
+ printIndexSummary(indexData, libFile, options);
10991
+
10992
+ } catch (e) {
10993
+ spinner.fail(`Figma API error: ${e.message}`);
10994
+ process.exit(1);
10995
+ }
10996
+
10997
+ // ---- Plugin mode with --page filter ----
10998
+ } else if (options.page) {
10999
+ const pageName = options.page;
11000
+ spinner.text = `Scanning page "${pageName}" for components...`;
11001
+ const safePageName = pageName.replace(/'/g, "\\'");
11002
+ const code = `(function() {
11003
+ try {
11004
+ var components = [];
11005
+ var targetPage = null;
11006
+ var pages = figma.root.children;
11007
+ for (var p = 0; p < pages.length; p++) {
11008
+ if (pages[p].name === '${safePageName}' || pages[p].name.toLowerCase().indexOf('${safePageName.toLowerCase()}') !== -1) {
11009
+ targetPage = pages[p];
11010
+ break;
11011
+ }
11012
+ }
11013
+ if (!targetPage) {
11014
+ return { error: 'Page "${safePageName}" not found. Available pages: ' + pages.map(function(p) { return p.name; }).join(', ') };
11015
+ }
10804
11016
  function scanNode(node, pageName) {
10805
11017
  if (node.type === 'COMPONENT') {
10806
11018
  var comp = {
@@ -10823,66 +11035,79 @@ Examples:
10823
11035
  }
10824
11036
  }
10825
11037
  }
10826
- scanNode(page, page.name);
11038
+ scanNode(targetPage, targetPage.name);
11039
+ return {
11040
+ fileName: figma.root.name,
11041
+ fileKey: figma.fileKey || '',
11042
+ pageCount: 1,
11043
+ componentCount: components.length,
11044
+ components: components
11045
+ };
11046
+ } catch(e) {
11047
+ return { error: e.message || 'Failed to scan page for components.' };
10827
11048
  }
10828
- return {
10829
- fileName: figma.root.name,
10830
- fileKey: figma.fileKey || '',
10831
- pageCount: pages.length,
10832
- componentCount: components.length,
10833
- components: components
10834
- };
10835
- } catch(e) {
10836
- return { error: e.message || 'Failed to scan file for components.' };
11049
+ })()`;
11050
+ const result = await daemonExec('eval', { code });
11051
+ if (result.error) {
11052
+ spinner.fail(result.error);
11053
+ process.exit(1);
10837
11054
  }
10838
- })()`;
10839
- const result = await daemonExec('eval', { code });
10840
- if (result.error) {
10841
- spinner.fail(result.error);
10842
- process.exit(1);
10843
- }
10844
- // Save to ~/.figma-local/libraries/
10845
- const libDir = join(homedir(), '.figma-local', 'libraries');
10846
- mkdirSync(libDir, { recursive: true });
10847
- const safeName = result.fileName.replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase();
10848
- const libFile = join(libDir, `${safeName}.json`);
10849
- const indexData = {
10850
- fileName: result.fileName,
10851
- fileKey: result.fileKey,
10852
- indexedAt: new Date().toISOString(),
10853
- pageCount: result.pageCount,
10854
- componentCount: result.componentCount,
10855
- components: result.components
10856
- };
10857
- writeFileSync(libFile, JSON.stringify(indexData, null, 2));
10858
- spinner.succeed(`Indexed ${result.componentCount} components from "${result.fileName}" (${result.pageCount} pages)`);
10859
- console.log(` ${chalk.gray('Saved to:')} ${libFile}`);
10860
- if (options.json) {
10861
- console.log(JSON.stringify(indexData, null, 2));
11055
+ const { indexData, libFile } = saveIndexAndPrint(result, { page: pageName });
11056
+ spinner.succeed(`Indexed ${result.componentCount} components from page "${pageName}" (${indexData.componentCount} total in library)`);
11057
+ printIndexSummary(indexData, libFile, options);
11058
+
11059
+ // ---- Plugin mode full scan ----
10862
11060
  } else {
10863
- // Show summary by component set
10864
- const sets = {};
10865
- const standalone = [];
10866
- for (const c of result.components) {
10867
- if (c.componentSet) {
10868
- if (!sets[c.componentSet]) sets[c.componentSet] = [];
10869
- sets[c.componentSet].push(c);
10870
- } else {
10871
- standalone.push(c);
10872
- }
10873
- }
10874
- if (Object.keys(sets).length > 0) {
10875
- console.log(chalk.cyan('\n Component Sets:'));
10876
- for (const [setName, variants] of Object.entries(sets)) {
10877
- console.log(` ${chalk.white(setName)} ${chalk.gray(`(${variants.length} variant${variants.length !== 1 ? 's' : ''})`)}`);
10878
- }
10879
- }
10880
- if (standalone.length > 0) {
10881
- console.log(chalk.cyan('\n Standalone Components:'));
10882
- for (const c of standalone) {
10883
- console.log(` ${chalk.white(c.name)} ${chalk.gray(`[${c.page}]`)}`);
11061
+ spinner.text = 'Scanning all pages for components...';
11062
+ const code = `(function() {
11063
+ try {
11064
+ var components = [];
11065
+ var pages = figma.root.children;
11066
+ for (var p = 0; p < pages.length; p++) {
11067
+ var page = pages[p];
11068
+ function scanNode(node, pageName) {
11069
+ if (node.type === 'COMPONENT') {
11070
+ var comp = {
11071
+ name: node.name,
11072
+ key: node.key,
11073
+ id: node.id,
11074
+ page: pageName,
11075
+ description: node.description || '',
11076
+ w: Math.round(node.width),
11077
+ h: Math.round(node.height)
11078
+ };
11079
+ if (node.parent && node.parent.type === 'COMPONENT_SET') {
11080
+ comp.componentSet = node.parent.name;
11081
+ }
11082
+ components.push(comp);
11083
+ }
11084
+ if ('children' in node) {
11085
+ for (var i = 0; i < node.children.length; i++) {
11086
+ scanNode(node.children[i], pageName);
11087
+ }
11088
+ }
11089
+ }
11090
+ scanNode(page, page.name);
11091
+ }
11092
+ return {
11093
+ fileName: figma.root.name,
11094
+ fileKey: figma.fileKey || '',
11095
+ pageCount: pages.length,
11096
+ componentCount: components.length,
11097
+ components: components
11098
+ };
11099
+ } catch(e) {
11100
+ return { error: e.message || 'Failed to scan file for components.' };
10884
11101
  }
11102
+ })()`;
11103
+ const result = await daemonExec('eval', { code });
11104
+ if (result.error) {
11105
+ spinner.fail(result.error);
11106
+ process.exit(1);
10885
11107
  }
11108
+ const { indexData, libFile } = saveIndexAndPrint(result, {});
11109
+ spinner.succeed(`Indexed ${result.componentCount} components from "${result.fileName}" (${result.pageCount} pages)`);
11110
+ printIndexSummary(indexData, libFile, options);
10886
11111
  }
10887
11112
  } else if (action === 'search') {
10888
11113
  const nameFilter = options.name ? options.name.toLowerCase() : '';
@@ -10978,4 +11203,121 @@ Examples:
10978
11203
  }
10979
11204
  });
10980
11205
 
11206
+ // ─── Component Audit ────────────────────────────────────────────────────────
11207
+
11208
+ program
11209
+ .command('component-audit [name]')
11210
+ .description('Audit components for quality issues (naming, tokens, layout, variants, detached instances)')
11211
+ .option('--all', 'Audit ALL components on the current page')
11212
+ .option('--node <nodeId>', 'Audit a specific component by node ID')
11213
+ .option('--json', 'Output raw JSON')
11214
+ .option('--verbose', 'Show info-level issues in addition to errors/warnings')
11215
+ .addHelpText('after', `
11216
+ Checks run on every component:
11217
+ missing-description No description set on the component
11218
+ incomplete-variants Component set is missing expected variant combinations
11219
+ hidden-layer Child layer is hidden (dead weight)
11220
+ generic-layer-name Layer has a default name like "Frame 2" or "Rectangle"
11221
+ empty-text Text node with no content
11222
+ hardcoded-color Solid fill with no variable binding
11223
+ no-auto-layout Frame with 2+ children but no auto layout
11224
+ detached-instance Instance whose main component is missing
11225
+ deep-nesting Node nested 7+ levels deep
11226
+
11227
+ Severity:
11228
+ error Must fix — structural or correctness problem
11229
+ warning Should fix — design system or quality concern
11230
+ info Nice to fix — best practice recommendation (shown with --verbose)
11231
+
11232
+ Examples:
11233
+ fig component-audit Audit current selection
11234
+ fig component-audit "Button" Audit the component named "Button"
11235
+ fig component-audit --all Audit every component on this page
11236
+ fig component-audit --node "123:456" Audit by node ID
11237
+ fig component-audit --all --json Machine-readable output
11238
+ fig component-audit --all --verbose Include info-level issues
11239
+ `)
11240
+ .action(async (name, options) => {
11241
+ await checkConnection();
11242
+ const spinner = ora('Running component audit...').start();
11243
+
11244
+ try {
11245
+ // ── Determine which audit code to run ──────────────────────────────────
11246
+ let code;
11247
+ let isAll = false;
11248
+
11249
+ if (options.all) {
11250
+ isAll = true;
11251
+ code = buildAllAuditCode();
11252
+ spinner.text = 'Auditing all components on page...';
11253
+ } else if (options.node) {
11254
+ code = buildSingleAuditCode(options.node);
11255
+ spinner.text = `Auditing node ${options.node}...`;
11256
+ } else if (name) {
11257
+ // Find component by name via metadata, then audit it
11258
+ spinner.text = `Looking up component "${name}"...`;
11259
+ const findCode = `
11260
+ (function() {
11261
+ function findByName(node, n) {
11262
+ if ((node.type === 'COMPONENT' || node.type === 'COMPONENT_SET' || node.type === 'FRAME') &&
11263
+ node.name.toLowerCase() === n.toLowerCase()) return node.id;
11264
+ if (node.children) {
11265
+ for (var i = 0; i < node.children.length; i++) {
11266
+ var found = findByName(node.children[i], n);
11267
+ if (found) return found;
11268
+ }
11269
+ }
11270
+ return null;
11271
+ }
11272
+ return findByName(figma.currentPage, ${JSON.stringify(name)});
11273
+ })()
11274
+ `;
11275
+ const nodeId = await daemonExec('eval', { code: findCode });
11276
+ if (!nodeId) {
11277
+ spinner.fail(`No component named "${name}" found on the current page.`);
11278
+ process.exit(1);
11279
+ }
11280
+ code = buildSingleAuditCode(nodeId);
11281
+ spinner.text = `Auditing "${name}"...`;
11282
+ } else {
11283
+ // Default: audit current selection
11284
+ code = buildSelectionAuditCode();
11285
+ spinner.text = 'Auditing selection...';
11286
+ }
11287
+
11288
+ const result = await daemonExec('eval', { code });
11289
+
11290
+ if (result && result.error) {
11291
+ spinner.fail(result.error);
11292
+ process.exit(1);
11293
+ }
11294
+
11295
+ // ── Output ─────────────────────────────────────────────────────────────
11296
+ if (isAll) {
11297
+ const comps = result.components || [];
11298
+ spinner.succeed(
11299
+ `Audited ${comps.length} component${comps.length !== 1 ? 's' : ''} on page "${result.page}"`
11300
+ );
11301
+ if (options.json) {
11302
+ console.log(JSON.stringify(result, null, 2));
11303
+ } else {
11304
+ console.log(formatAllAuditResult(result, chalk, options.verbose || false));
11305
+ }
11306
+ } else {
11307
+ const score = result.score;
11308
+ const label = score >= 80 ? 'Good' : score >= 60 ? 'Fair' : 'Needs work';
11309
+ spinner.succeed(`Audited "${result.name}" — score ${score}/100 (${label})`);
11310
+ if (options.json) {
11311
+ console.log(JSON.stringify(result, null, 2));
11312
+ } else {
11313
+ console.log(formatAuditResult(result, chalk, options.verbose !== false));
11314
+ }
11315
+ }
11316
+ } catch (e) {
11317
+ spinner.fail(`Audit failed: ${e.message}`);
11318
+ process.exit(1);
11319
+ }
11320
+ });
11321
+
10981
11322
  program.parse();
11323
+