@figma/code-connect 1.4.8 → 1.5.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.
Files changed (60) hide show
  1. package/README.md +3 -0
  2. package/dist/commands/connect.d.ts +5 -0
  3. package/dist/commands/connect.d.ts.map +1 -1
  4. package/dist/commands/connect.js +94 -26
  5. package/dist/commands/connect.js.map +1 -1
  6. package/dist/commands/connect_template.d.ts.map +1 -1
  7. package/dist/commands/connect_template.js +2 -2
  8. package/dist/commands/connect_template.js.map +1 -1
  9. package/dist/commands/preview_utils.d.ts +103 -5
  10. package/dist/commands/preview_utils.d.ts.map +1 -1
  11. package/dist/commands/preview_utils.js +453 -76
  12. package/dist/commands/preview_utils.js.map +1 -1
  13. package/dist/commands/property_list_table.d.ts +11 -0
  14. package/dist/commands/property_list_table.d.ts.map +1 -0
  15. package/dist/commands/property_list_table.js +67 -0
  16. package/dist/commands/property_list_table.js.map +1 -0
  17. package/dist/common/updates.d.ts +4 -1
  18. package/dist/common/updates.d.ts.map +1 -1
  19. package/dist/common/updates.js +26 -5
  20. package/dist/common/updates.js.map +1 -1
  21. package/dist/connect/api.d.ts +34 -1
  22. package/dist/connect/api.d.ts.map +1 -1
  23. package/dist/connect/api.js.map +1 -1
  24. package/dist/connect/batch_templates.d.ts +1 -1
  25. package/dist/connect/batch_templates.d.ts.map +1 -1
  26. package/dist/connect/batch_templates.js +2 -2
  27. package/dist/connect/batch_templates.js.map +1 -1
  28. package/dist/connect/intrinsics.d.ts +1 -0
  29. package/dist/connect/intrinsics.d.ts.map +1 -1
  30. package/dist/connect/intrinsics.js +29 -0
  31. package/dist/connect/intrinsics.js.map +1 -1
  32. package/dist/connect/migration_batch_helpers.d.ts.map +1 -1
  33. package/dist/connect/migration_batch_helpers.js +1 -15
  34. package/dist/connect/migration_batch_helpers.js.map +1 -1
  35. package/dist/connect/migration_helpers.d.ts +6 -0
  36. package/dist/connect/migration_helpers.d.ts.map +1 -1
  37. package/dist/connect/migration_helpers.js +66 -0
  38. package/dist/connect/migration_helpers.js.map +1 -1
  39. package/dist/connect/parser_executables.d.ts.map +1 -1
  40. package/dist/connect/parser_executables.js +25 -13
  41. package/dist/connect/parser_executables.js.map +1 -1
  42. package/dist/connect/property_combinations.d.ts +69 -0
  43. package/dist/connect/property_combinations.d.ts.map +1 -0
  44. package/dist/connect/property_combinations.js +172 -0
  45. package/dist/connect/property_combinations.js.map +1 -0
  46. package/dist/connect/raw_template_bundler.d.ts +13 -0
  47. package/dist/connect/raw_template_bundler.d.ts.map +1 -0
  48. package/dist/connect/raw_template_bundler.js +178 -0
  49. package/dist/connect/raw_template_bundler.js.map +1 -0
  50. package/dist/connect/raw_templates.d.ts +1 -1
  51. package/dist/connect/raw_templates.d.ts.map +1 -1
  52. package/dist/connect/raw_templates.js +85 -24
  53. package/dist/connect/raw_templates.js.map +1 -1
  54. package/dist/parser_scripts/get_swift_parser_dir.js +5 -5
  55. package/dist/parser_scripts/get_swift_parser_dir.js.map +1 -1
  56. package/dist/react/parser.d.ts.map +1 -1
  57. package/dist/react/parser.js.map +1 -1
  58. package/figma-types-no-require.d.ts +231 -0
  59. package/figma-types.d.ts +19 -8
  60. package/package.json +8 -4
@@ -37,14 +37,21 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.collectNodesToPreview = collectNodesToPreview;
40
+ exports.fetchComponentPropertyDefinitions = fetchComponentPropertyDefinitions;
41
+ exports.parsePropsArg = parsePropsArg;
40
42
  exports.filterTemplatesForNodes = filterTemplatesForNodes;
41
43
  exports.getPrettierParserMap = getPrettierParserMap;
42
44
  exports.isPrettierParseable = isPrettierParseable;
43
45
  exports.formatSnippet = formatSnippet;
44
46
  exports.displayResults = displayResults;
47
+ exports.parseMaxCombinationsArg = parseMaxCombinationsArg;
48
+ exports.chunkNodeIdsForPreview = chunkNodeIdsForPreview;
49
+ exports.parseMissingProperty = parseMissingProperty;
50
+ exports.chunkOutcomeToResults = chunkOutcomeToResults;
45
51
  exports.handlePreview = handlePreview;
46
52
  const fs_1 = __importDefault(require("fs"));
47
53
  const path_1 = __importDefault(require("path"));
54
+ const chalk_1 = __importDefault(require("chalk"));
48
55
  const prettier = __importStar(require("prettier"));
49
56
  const connect_1 = require("./connect");
50
57
  const logging_1 = require("../common/logging");
@@ -52,6 +59,8 @@ const validation_1 = require("../connect/validation");
52
59
  const project_1 = require("../connect/project");
53
60
  const figma_rest_api_1 = require("../connect/figma_rest_api");
54
61
  const fetch_1 = require("../common/fetch");
62
+ const property_list_table_1 = require("./property_list_table");
63
+ const property_combinations_1 = require("../connect/property_combinations");
55
64
  /**
56
65
  * Collect nodes to preview from file arguments.
57
66
  * Matches by exact path first, then by basename.
@@ -131,6 +140,85 @@ async function collectNodesToPreview(files, allCodeConnectObjects, dir, cmd) {
131
140
  }
132
141
  return nodesToPreview;
133
142
  }
143
+ /**
144
+ * Fetch one node's property definitions from the REST `files/:key/nodes` endpoint.
145
+ */
146
+ async function fetchNodeSchema(baseApiUrl, fileKey, nodeId, accessToken) {
147
+ try {
148
+ const nodesUrl = `${baseApiUrl}/files/${fileKey}/nodes?ids=${nodeId}`;
149
+ const resp = await fetch_1.request.get(nodesUrl, { headers: (0, figma_rest_api_1.getHeaders)(accessToken) });
150
+ if (resp.response.status !== 200) {
151
+ logging_1.logger.debug(`files/nodes returned ${resp.response.status} for ${nodeId}`);
152
+ return { status: 'error' };
153
+ }
154
+ const node = resp.data.nodes?.[nodeId];
155
+ const componentSetId = node?.components?.[nodeId]?.componentSetId || undefined;
156
+ const defs = node?.document?.componentPropertyDefinitions;
157
+ if (defs && Object.keys(defs).length > 0)
158
+ return { status: 'ok', defs, componentSetId };
159
+ logging_1.logger.debug(`files/nodes returned no componentPropertyDefinitions for ${nodeId}`);
160
+ return { status: 'empty', componentSetId };
161
+ }
162
+ catch (err) {
163
+ const detail = (0, fetch_1.isFetchError)(err) ? `status ${err.response?.status}` : String(err);
164
+ logging_1.logger.debug(`files/nodes fetch failed for ${nodeId} (${detail})`);
165
+ return { status: 'error' };
166
+ }
167
+ }
168
+ /**
169
+ * Fetch a component's property definitions from the REST `files/:key/nodes` endpoint.
170
+ */
171
+ async function fetchComponentPropertyDefinitions(baseApiUrl, fileKey, nodeId, accessToken) {
172
+ const node = await fetchNodeSchema(baseApiUrl, fileKey, nodeId, accessToken);
173
+ if (node.status === 'error')
174
+ return { status: 'error' };
175
+ // Variant child → resolve to the parent COMPONENT_SET (whose schema carries the
176
+ // variant axes). Fall back to the variant's own defs if the set can't be fetched.
177
+ if (node.componentSetId) {
178
+ const set = await fetchNodeSchema(baseApiUrl, fileKey, node.componentSetId, accessToken);
179
+ if (set.status === 'ok')
180
+ return { status: 'ok', defs: set.defs };
181
+ logging_1.logger.debug(`Component set ${node.componentSetId} for variant ${nodeId} had no usable schema ` +
182
+ `(${set.status}); using the variant's own definitions`);
183
+ }
184
+ return node.status === 'ok' ? { status: 'ok', defs: node.defs } : { status: 'empty' };
185
+ }
186
+ /** Case-insensitive lookup of a `ComponentPropertyType` from a `--props` prefix token. */
187
+ function parsePropertyTypePrefix(token) {
188
+ const upper = token.trim().toUpperCase();
189
+ return Object.values(figma_rest_api_1.FigmaRestApi.ComponentPropertyType).includes(upper)
190
+ ? upper
191
+ : undefined;
192
+ }
193
+ /**
194
+ * Parse a single `--props` argument into a name/value pair, with an optional
195
+ * `TYPE:` prefix to disambiguate properties that share a name but differ in
196
+ * type (e.g. a BOOLEAN and a TEXT property both named `textMsg`).
197
+ * Example: --props Variant=Primary "Has Icon=true" "BOOLEAN:textMsg=false"
198
+ *
199
+ * The prefix is only recognized when the token before the first `:` matches a
200
+ * known property type (case-insensitive); otherwise the whole name-part is kept
201
+ * as the property name, so real names containing `:` are preserved.
202
+ */
203
+ function parsePropsArg(props) {
204
+ return props
205
+ .flatMap((arg) => arg.split(','))
206
+ .map((pair) => pair.trim())
207
+ .filter((pair) => pair.length > 0)
208
+ .map((pair) => {
209
+ const eq = pair.indexOf('=');
210
+ const namePart = (eq === -1 ? pair : pair.slice(0, eq)).trim();
211
+ const value = eq === -1 ? '' : pair.slice(eq + 1).trim();
212
+ const colon = namePart.indexOf(':');
213
+ if (colon !== -1) {
214
+ const type = parsePropertyTypePrefix(namePart.slice(0, colon));
215
+ if (type) {
216
+ return { name: namePart.slice(colon + 1).trim(), value, type };
217
+ }
218
+ }
219
+ return { name: namePart, value };
220
+ });
221
+ }
134
222
  /**
135
223
  * Filter templates to only those matching the requested node IDs.
136
224
  */
@@ -222,20 +310,30 @@ async function formatSnippet(snippet, language) {
222
310
  /**
223
311
  * Display results with terminal colors
224
312
  */
313
+ const purple = chalk_1.default.ansi256(93);
314
+ const red = chalk_1.default.ansi256(196);
315
+ const gray = chalk_1.default.ansi256(243);
316
+ const yellow = chalk_1.default.ansi256(179);
225
317
  function displayResults(results) {
226
318
  console.log('');
227
319
  const successCount = results.filter((r) => r.success).length;
228
320
  const errorCount = results.filter((r) => !r.success).length;
229
- const purple = '\x1b[38;5;93m';
230
- const red = '\x1b[38;5;196m';
231
- const gray = '\x1b[38;5;243m';
232
- const reset = '\x1b[0m';
233
- const bold = '\x1b[1m';
321
+ // A muted note when nested children rendered as placeholders (not an error).
322
+ const printUnresolved = (result) => {
323
+ if (!result.unresolvedInstances?.length)
324
+ return;
325
+ const names = result.unresolvedInstances.map((u) => u.name || u.guid).join(', ');
326
+ console.log(` ${yellow(`⚠ ${result.unresolvedInstances.length} nested instance(s) not Code Connected: ${names}`)}`);
327
+ };
234
328
  for (const result of results) {
329
+ // When expanding property combinations, label the header with the specific combination.
330
+ const combinationSuffix = result.propertyCombinationLabel
331
+ ? ` ${gray(`— ${result.propertyCombinationLabel}`)}`
332
+ : '';
235
333
  if (result.success && result.snippet) {
236
- const componentInfo = result.component ? ` ${gray}→ ${result.component}${reset}` : '';
237
- console.log(`${purple}●${reset} ${bold}${result.filePath}${reset}${componentInfo}`);
238
- console.log(` ${gray}${result.url}${reset}`);
334
+ const componentInfo = result.component ? ` ${gray(`→ ${result.component}`)}` : '';
335
+ console.log(`${purple('●')} ${chalk_1.default.bold(result.filePath)}${componentInfo}${combinationSuffix}`);
336
+ console.log(` ${gray(result.url)}`);
239
337
  console.log('');
240
338
  const indentedSnippet = result.snippet
241
339
  .trim()
@@ -243,17 +341,30 @@ function displayResults(results) {
243
341
  .map((line) => ' ' + line)
244
342
  .join('\n');
245
343
  console.log(indentedSnippet);
344
+ printUnresolved(result);
246
345
  console.log('');
247
346
  }
248
347
  else {
249
- const componentInfo = result.component ? ` ${gray}(${result.component})${reset}` : '';
250
- console.log(`${red}✕${reset} ${bold}${result.filePath}${reset}${componentInfo}`);
251
- console.log(` ${gray}${result.url}${reset}`);
252
- console.log(` ${red}Error:${reset} ${result.error}`);
348
+ const componentInfo = result.component ? ` ${gray(`(${result.component})`)}` : '';
349
+ console.log(`${red('✕')} ${chalk_1.default.bold(result.filePath)}${componentInfo}${combinationSuffix}`);
350
+ console.log(` ${gray(result.url)}`);
351
+ console.log(` ${red('Error:')} ${result.error}`);
352
+ // One-line repair hint: the offending property and the real vocabulary.
353
+ // (Transport errors carry no vocabulary — nothing to hint.)
354
+ const available = result.errorDetails?.availableProperties?.map((p) => p.name).join(', ');
355
+ const missingProperty = result.errorDetails?.missingProperty;
356
+ if (missingProperty && available) {
357
+ console.log(` ${gray(`property "${missingProperty}" not found — available: ${available}`)}`);
358
+ }
359
+ else if (available) {
360
+ console.log(` ${gray(`available properties: ${available}`)}`);
361
+ }
362
+ printUnresolved(result);
253
363
  console.log('');
254
364
  }
255
365
  }
256
- console.log(`${bold}Summary:${reset} ${purple}${successCount} succeeded${reset}, ${errorCount > 0 ? `${red}${errorCount} failed${reset}` : `${gray}${errorCount} failed${reset}`}`);
366
+ const failed = errorCount > 0 ? red(`${errorCount} failed`) : gray(`${errorCount} failed`);
367
+ console.log(`${chalk_1.default.bold('Summary:')} ${purple(`${successCount} succeeded`)}, ${failed}`);
257
368
  }
258
369
  // The server caps inbound request bodies at 5MB. The figmaDocs payload
259
370
  // (one Code Connect template per requested component) dominates the body size,
@@ -265,6 +376,22 @@ const PREVIEW_CHUNK_SIZE = 50;
265
376
  // overwhelms upstream token validation and causes spurious 403 "Invalid
266
377
  // token" responses for the chunks that queue up too long.
267
378
  const PREVIEW_MAX_CONCURRENCY = 5;
379
+ // Mirrors MAX_PROPERTY_COMBINATIONS_PER_REQUEST in pixie/code_connect_preview_handler.ts.
380
+ // `--all` caps enumeration here and warns rather than sending a request that fails wholesale.
381
+ const MAX_PROPERTY_COMBINATIONS = 500;
382
+ function parseMaxCombinationsArg(value) {
383
+ if (value === undefined) {
384
+ return MAX_PROPERTY_COMBINATIONS;
385
+ }
386
+ const parsed = Number(value);
387
+ if (!Number.isInteger(parsed) || parsed < 1) {
388
+ (0, logging_1.exitWithError)('--max-combinations must be a positive integer');
389
+ }
390
+ if (parsed > MAX_PROPERTY_COMBINATIONS) {
391
+ (0, logging_1.exitWithError)(`--max-combinations cannot exceed ${MAX_PROPERTY_COMBINATIONS}`);
392
+ }
393
+ return parsed;
394
+ }
268
395
  function chunkArray(arr, size) {
269
396
  const chunks = [];
270
397
  for (let i = 0; i < arr.length; i += size) {
@@ -272,6 +399,41 @@ function chunkArray(arr, size) {
272
399
  }
273
400
  return chunks;
274
401
  }
402
+ /**
403
+ * Split node IDs into request chunks that respect BOTH the node-count cap
404
+ * (PREVIEW_CHUNK_SIZE) and, when rendering property combinations, the server's
405
+ * per-request combination cap (MAX_PROPERTY_COMBINATIONS).
406
+ *
407
+ * The server sums combinations across every node in a request and rejects the
408
+ * whole request if the total exceeds the cap, so bundling by node count alone
409
+ * can push several individually-valid nodes over the limit and fail them all.
410
+ * Each node is already truncated to <= MAX_PROPERTY_COMBINATIONS upstream, so a
411
+ * single node always fits (worst case: one node per chunk).
412
+ */
413
+ function chunkNodeIdsForPreview(nodeIds, propertyCombinationsByNodeId) {
414
+ if (!propertyCombinationsByNodeId) {
415
+ return chunkArray(nodeIds, PREVIEW_CHUNK_SIZE);
416
+ }
417
+ const chunks = [];
418
+ let current = [];
419
+ let currentCombinations = 0;
420
+ for (const nodeId of nodeIds) {
421
+ const count = propertyCombinationsByNodeId[nodeId]?.length ?? 0;
422
+ // Close the current chunk before adding this node would breach either cap.
423
+ if (current.length > 0 &&
424
+ (current.length >= PREVIEW_CHUNK_SIZE ||
425
+ currentCombinations + count > MAX_PROPERTY_COMBINATIONS)) {
426
+ chunks.push(current);
427
+ current = [];
428
+ currentCombinations = 0;
429
+ }
430
+ current.push(nodeId);
431
+ currentCombinations += count;
432
+ }
433
+ if (current.length > 0)
434
+ chunks.push(current);
435
+ return chunks;
436
+ }
275
437
  async function runInWaves(items, fn, maxConcurrency) {
276
438
  const out = [];
277
439
  for (let i = 0; i < items.length; i += maxConcurrency) {
@@ -281,14 +443,55 @@ async function runInWaves(items, fn, maxConcurrency) {
281
443
  }
282
444
  return out;
283
445
  }
284
- function sendPreviewChunk({ chunkNodeIds, requiredTemplates, baseApiUrl, fileKey, accessToken, }) {
446
+ function sendPreviewChunk({ chunkNodeIds, requiredTemplates, baseApiUrl, fileKey, accessToken, propertyCombinationsByNodeId, }) {
285
447
  const chunkTemplates = filterTemplatesForNodes(chunkNodeIds, requiredTemplates);
448
+ // Only include property combinations for the node IDs in this chunk.
449
+ let renderCombinations;
450
+ if (propertyCombinationsByNodeId) {
451
+ renderCombinations = {};
452
+ for (const nodeId of chunkNodeIds) {
453
+ if (propertyCombinationsByNodeId[nodeId]) {
454
+ renderCombinations[nodeId] = propertyCombinationsByNodeId[nodeId];
455
+ }
456
+ }
457
+ }
286
458
  return fetch_1.request
287
- .post(`${baseApiUrl}/code_connect/preview_snippets?file_key=${fileKey}`, { nodeIds: chunkNodeIds, figmaDocs: { all: chunkTemplates } }, { headers: (0, figma_rest_api_1.getHeaders)(accessToken) })
459
+ .post(`${baseApiUrl}/code_connect/preview_snippets?file_key=${fileKey}`, {
460
+ nodeIds: chunkNodeIds,
461
+ figmaDocs: { all: chunkTemplates },
462
+ ...(renderCombinations && Object.keys(renderCombinations).length > 0
463
+ ? { renderCombinations }
464
+ : {}),
465
+ }, { headers: (0, figma_rest_api_1.getHeaders)(accessToken) })
288
466
  .then((response) => ({ chunkNodeIds, response, error: null }))
289
467
  .catch((err) => ({ chunkNodeIds, response: null, error: err }));
290
468
  }
291
- function chunkOutcomeToResults(outcome, nodes, fileKey) {
469
+ /**
470
+ * Parse the offending property name out of a "property not found" render error,
471
+ * e.g. `PropertyNotFoundError: property 'Varient' not found` -> `Varient`.
472
+ */
473
+ function parseMissingProperty(error) {
474
+ if (!error)
475
+ return undefined;
476
+ const match = error.match(/propert(?:y|ies)[^'"`]*['"`]([^'"`]+)['"`]/i) ?? error.match(/['"`]([^'"`]+)['"`]/);
477
+ return match?.[1];
478
+ }
479
+ /** Build `errorDetails` for a failed result. Undefined when there's no property context (not --all). */
480
+ function buildErrorDetails(error, errorKind, availableProperties) {
481
+ // Transport failure: network/backend problem, not a bad property — carry only the kind.
482
+ if (errorKind === 'transport')
483
+ return { kind: 'transport' };
484
+ if (!availableProperties)
485
+ return undefined;
486
+ const details = { availableProperties };
487
+ if (errorKind)
488
+ details.kind = errorKind;
489
+ const missingProperty = parseMissingProperty(error);
490
+ if (missingProperty)
491
+ details.missingProperty = missingProperty;
492
+ return details;
493
+ }
494
+ function chunkOutcomeToResults(outcome, nodes, fileKey, availablePropsByNodeId) {
292
495
  const { chunkNodeIds, response, error } = outcome;
293
496
  const results = [];
294
497
  if (error) {
@@ -311,6 +514,8 @@ function chunkOutcomeToResults(outcome, nodes, fileKey) {
311
514
  filePath: node.filePath,
312
515
  success: false,
313
516
  error: errorMsg,
517
+ // Chunk-level failure is a server/network error, not a template problem.
518
+ errorDetails: { kind: 'transport' },
314
519
  });
315
520
  }
316
521
  }
@@ -321,39 +526,61 @@ function chunkOutcomeToResults(outcome, nodes, fileKey) {
321
526
  // Track match index per nodeId so duplicate node IDs get the correct file attribution.
322
527
  // The server returns results in the same order as the templates we sent. Each chunk's
323
528
  // nodeIds are disjoint (we dedupe before chunking), so per-chunk indexing is correct.
529
+ // Property-combination results are expanded renders of one node, not one result per
530
+ // matching template, so they should not consume this per-template index.
324
531
  const nodeMatchIndex = {};
325
532
  for (const result of response.data.meta.results) {
326
533
  const idx = nodeMatchIndex[result.nodeId] ?? 0;
327
534
  const matchingNodes = nodes.filter((n) => n.nodeId === result.nodeId);
328
- const node = matchingNodes[idx] || matchingNodes[0];
329
- nodeMatchIndex[result.nodeId] = idx + 1;
535
+ const node = result.propertyCombinationLabel
536
+ ? matchingNodes[0]
537
+ : matchingNodes[idx] || matchingNodes[0];
538
+ if (!result.propertyCombinationLabel) {
539
+ nodeMatchIndex[result.nodeId] = idx + 1;
540
+ }
541
+ const error = result.error || (!result.snippet ? 'No snippet returned by server' : undefined);
542
+ const success = !result.error && !!result.snippet;
330
543
  results.push({
331
544
  url: node?.url || result.nodeUrl,
332
545
  nodeId: result.nodeId,
333
546
  filePath: node?.filePath || '',
334
- success: !result.error && !!result.snippet,
547
+ success,
335
548
  snippet: result.snippet,
336
549
  language: result.language,
337
550
  component: result.component,
338
- error: result.error || (!result.snippet ? 'No snippet returned by server' : undefined),
551
+ error,
552
+ ...(result.propertyCombinationLabel
553
+ ? { propertyCombinationLabel: result.propertyCombinationLabel }
554
+ : {}),
555
+ ...(result.unresolvedInstances?.length
556
+ ? { unresolvedInstances: result.unresolvedInstances }
557
+ : {}),
558
+ ...(success
559
+ ? {}
560
+ : {
561
+ errorDetails: buildErrorDetails(error, result.errorKind, availablePropsByNodeId?.[result.nodeId]),
562
+ }),
339
563
  });
340
564
  }
341
565
  return results;
342
566
  }
343
567
  for (const nodeId of chunkNodeIds) {
344
568
  for (const node of nodes.filter((n) => n.nodeId === nodeId)) {
569
+ const error = `API request failed with status ${response.response.status}`;
345
570
  results.push({
346
571
  url: node.url,
347
572
  nodeId: node.nodeId,
348
573
  filePath: node.filePath,
349
574
  success: false,
350
- error: `API request failed with status ${response.response.status}`,
575
+ error,
576
+ // Non-2xx from the endpoint is a transport-level failure, not a template problem.
577
+ errorDetails: { kind: 'transport' },
351
578
  });
352
579
  }
353
580
  }
354
581
  return results;
355
582
  }
356
- async function previewFile({ fileKey, nodes, baseApiUrl, accessToken, dir, allCodeConnectObjects, }) {
583
+ async function previewFile({ fileKey, nodes, baseApiUrl, accessToken, dir, allCodeConnectObjects, propertyCombinationsByNodeId, availablePropsByNodeId, }) {
357
584
  const allNodeIds = nodes.map((n) => n.nodeId);
358
585
  // Deduplicate — multiple figma.connect() calls may share the same nodeId;
359
586
  // the individual templates are sent in figmaDocs and the server iterates per-template.
@@ -362,9 +589,183 @@ async function previewFile({ fileKey, nodes, baseApiUrl, accessToken, dir, allCo
362
589
  // for the same nodeId exist on the server and shouldn't be rendered individually.
363
590
  const requestedFilePaths = new Set(nodes.map((n) => path_1.default.resolve(dir, n.filePath)));
364
591
  const requiredTemplates = filterTemplatesForNodes(allNodeIds, allCodeConnectObjects).filter((t) => requestedFilePaths.has(path_1.default.resolve(t._codeConnectFilePath || '')));
365
- const chunks = chunkArray(nodeIds, PREVIEW_CHUNK_SIZE);
366
- const outcomes = await runInWaves(chunks, (chunkNodeIds) => sendPreviewChunk({ chunkNodeIds, requiredTemplates, baseApiUrl, fileKey, accessToken }), PREVIEW_MAX_CONCURRENCY);
367
- return outcomes.flatMap((outcome) => chunkOutcomeToResults(outcome, nodes, fileKey));
592
+ const chunks = chunkNodeIdsForPreview(nodeIds, propertyCombinationsByNodeId);
593
+ const outcomes = await runInWaves(chunks, (chunkNodeIds) => sendPreviewChunk({
594
+ chunkNodeIds,
595
+ requiredTemplates,
596
+ baseApiUrl,
597
+ fileKey,
598
+ accessToken,
599
+ propertyCombinationsByNodeId,
600
+ }), PREVIEW_MAX_CONCURRENCY);
601
+ return outcomes.flatMap((outcome) => chunkOutcomeToResults(outcome, nodes, fileKey, availablePropsByNodeId));
602
+ }
603
+ function collectAllLocalNodesToPreview(allCodeConnectObjects, dir, cmd) {
604
+ const nodesToCheck = [];
605
+ for (const doc of allCodeConnectObjects) {
606
+ const parsed = (0, validation_1.parseFigmaNode)(cmd.verbose, doc, true);
607
+ if (parsed) {
608
+ nodesToCheck.push({
609
+ fileKey: parsed.fileKey,
610
+ nodeId: parsed.nodeId,
611
+ url: doc.figmaNode,
612
+ filePath: path_1.default.relative(dir, doc._codeConnectFilePath || ''),
613
+ });
614
+ }
615
+ }
616
+ return nodesToCheck;
617
+ }
618
+ function groupNodesByFileKey(nodes) {
619
+ const nodesByFileKey = {};
620
+ for (const node of nodes) {
621
+ nodesByFileKey[node.fileKey] ??= [];
622
+ nodesByFileKey[node.fileKey].push(node);
623
+ }
624
+ return nodesByFileKey;
625
+ }
626
+ async function handleInspect({ nodesByFileKey, configuredApiUrl, accessToken, outputFormat, }) {
627
+ const items = [];
628
+ const seen = new Set();
629
+ for (const [fileKey, nodes] of Object.entries(nodesByFileKey)) {
630
+ const baseApiUrl = (0, figma_rest_api_1.getApiUrl)(nodes[0].url, configuredApiUrl);
631
+ for (const node of nodes) {
632
+ const dedupeKey = `${node.filePath}::${node.nodeId}`;
633
+ if (seen.has(dedupeKey))
634
+ continue;
635
+ seen.add(dedupeKey);
636
+ const result = await fetchComponentPropertyDefinitions(baseApiUrl, fileKey, node.nodeId, accessToken);
637
+ if (result.status === 'error') {
638
+ logging_1.logger.warn(`Couldn't fetch property definitions for node ${node.nodeId}`);
639
+ }
640
+ else if (result.status === 'empty') {
641
+ logging_1.logger.info(`Node ${node.nodeId} has no component properties or variants.`);
642
+ }
643
+ items.push({
644
+ filePath: node.filePath,
645
+ nodeId: node.nodeId,
646
+ availableProperties: result.status === 'ok' ? (0, property_combinations_1.toAvailableProperties)(result.defs) : [],
647
+ });
648
+ }
649
+ }
650
+ if (outputFormat === 'json') {
651
+ console.log(JSON.stringify(items, null, 2));
652
+ }
653
+ else {
654
+ (0, property_list_table_1.displayPropertyList)(items);
655
+ }
656
+ }
657
+ function validatePreviewOptions(cmd) {
658
+ if (cmd.all && cmd.props) {
659
+ (0, logging_1.exitWithError)('Cannot combine --props and --all; use one or the other');
660
+ }
661
+ if (cmd.maxCombinations !== undefined && !cmd.all) {
662
+ (0, logging_1.exitWithError)('--max-combinations can only be used with --all');
663
+ }
664
+ }
665
+ async function buildPropertyPreviewInputs({ enabled, baseApiUrl, fileKey, nodes, accessToken, propsPairs, maxCombinations, }) {
666
+ if (!enabled)
667
+ return {};
668
+ const propertyCombinationsByNodeId = {};
669
+ const availablePropsByNodeId = {};
670
+ for (const nodeId of [...new Set(nodes.map((n) => n.nodeId))]) {
671
+ const result = await fetchComponentPropertyDefinitions(baseApiUrl, fileKey, nodeId, accessToken);
672
+ if (result.status === 'error') {
673
+ logging_1.logger.warn(`Couldn't fetch property definitions for node ${nodeId} (ensure your token has the ` +
674
+ `Code Connect: Write and File content: Read scopes); rendering its default property combination only`);
675
+ continue;
676
+ }
677
+ if (result.status === 'empty') {
678
+ // Not an error: the component simply has no properties or variants to vary.
679
+ if (propsPairs) {
680
+ logging_1.logger.warn(`Node ${nodeId} has no component properties or variants, so --props ` +
681
+ `(${propsPairs.map((p) => p.name).join(', ')}) has no effect; rendering its only property combination.`);
682
+ }
683
+ else {
684
+ logging_1.logger.info(`Node ${nodeId} has no component properties or variants to vary; rendering its only property combination.`);
685
+ }
686
+ continue;
687
+ }
688
+ const defs = result.defs;
689
+ if (propsPairs) {
690
+ const { propertyCombination, availableProperties, unknown, invalid, ambiguous } = (0, property_combinations_1.buildPropertyCombinationFromProps)(defs, propsPairs);
691
+ if (unknown.length > 0) {
692
+ logging_1.logger.warn(`Unknown propert${unknown.length > 1 ? 'ies' : 'y'} for node ${nodeId}: ${unknown.join(', ')}. ` +
693
+ `Available: ${availableProperties.map((p) => p.name).join(', ')}`);
694
+ }
695
+ for (const { name, value, options } of invalid) {
696
+ logging_1.logger.warn(`"${value}" is not a valid value for ${name} on node ${nodeId}. Options: ${options.join(', ')}`);
697
+ }
698
+ for (const { name, types } of ambiguous) {
699
+ logging_1.logger.warn(`Property "${name}" on node ${nodeId} matches multiple types (${types.join(', ')}); ` +
700
+ `prefix the value with a type to disambiguate, e.g. ${types[0]}:${name}=... . Skipping.`);
701
+ }
702
+ availablePropsByNodeId[nodeId] = availableProperties;
703
+ propertyCombinationsByNodeId[nodeId] = [propertyCombination];
704
+ }
705
+ else {
706
+ const { propertyCombinations, availableProperties, truncated } = (0, property_combinations_1.enumeratePropertyCombinations)(defs, {
707
+ maxCombinations,
708
+ });
709
+ availablePropsByNodeId[nodeId] = availableProperties;
710
+ if (truncated) {
711
+ logging_1.logger.warn(`Node ${nodeId} has ${truncated.total} property combinations, exceeding the ${truncated.cap}-combination ` +
712
+ `preview limit; rendering the first ${truncated.cap}. Use --props to preview a ` +
713
+ `specific property combination.`);
714
+ }
715
+ if (propertyCombinations.length > 0) {
716
+ propertyCombinationsByNodeId[nodeId] = propertyCombinations;
717
+ }
718
+ }
719
+ }
720
+ return { propertyCombinationsByNodeId, availablePropsByNodeId };
721
+ }
722
+ async function previewNodesForFile({ fileKey, nodes, configuredApiUrl, accessToken, dir, allCodeConnectObjects, renderPropertyCombinations, propsPairs, maxCombinations, }) {
723
+ const baseApiUrl = (0, figma_rest_api_1.getApiUrl)(nodes[0].url, configuredApiUrl);
724
+ const { propertyCombinationsByNodeId, availablePropsByNodeId } = await buildPropertyPreviewInputs({
725
+ enabled: renderPropertyCombinations,
726
+ baseApiUrl,
727
+ fileKey,
728
+ nodes,
729
+ accessToken,
730
+ propsPairs,
731
+ maxCombinations,
732
+ });
733
+ return previewFile({
734
+ fileKey,
735
+ nodes,
736
+ baseApiUrl,
737
+ accessToken,
738
+ dir,
739
+ allCodeConnectObjects,
740
+ propertyCombinationsByNodeId,
741
+ availablePropsByNodeId,
742
+ });
743
+ }
744
+ async function validateRenderedSnippets(results) {
745
+ for (const result of results) {
746
+ if (result.success && result.snippet) {
747
+ const prettierValid = await isPrettierParseable(result.snippet, result.language);
748
+ if (!prettierValid) {
749
+ // Prettier can parse this language but failed — mark as error
750
+ result.success = false;
751
+ result.error = 'Snippet has syntax errors and may not be valid code';
752
+ }
753
+ }
754
+ }
755
+ }
756
+ async function outputPreviewResults(results, outputFormat) {
757
+ if (outputFormat === 'json') {
758
+ console.log(JSON.stringify(results, null, 2));
759
+ return;
760
+ }
761
+ const formattedResults = await Promise.all(results.map(async (result) => {
762
+ if (result.success && result.snippet) {
763
+ const formattedSnippet = await formatSnippet(result.snippet, result.language);
764
+ return { ...result, snippet: formattedSnippet };
765
+ }
766
+ return result;
767
+ }));
768
+ displayResults(formattedResults);
368
769
  }
369
770
  /**
370
771
  * Handle the preview command
@@ -375,75 +776,51 @@ async function handlePreview(files, cmd) {
375
776
  const projectInfo = await (0, project_1.getProjectInfo)(dir, cmd.config);
376
777
  const accessToken = (0, connect_1.getAccessTokenOrExit)(cmd);
377
778
  const outputFormat = cmd.output || 'table';
378
- const allCodeConnectObjects = await (0, connect_1.getCodeConnectObjects)(cmd, projectInfo, true);
379
- let nodesToCheck;
380
- if (files && files.length > 0) {
381
- nodesToCheck = await collectNodesToPreview(files, allCodeConnectObjects, dir, cmd);
779
+ const configuredApiUrl = cmd.apiUrl || projectInfo.config.apiUrl;
780
+ if (cmd.all && (!files || files.length === 0)) {
781
+ (0, logging_1.exitWithError)('--all requires a specific component, e.g. `figma connect preview Button.figma.ts --all`');
382
782
  }
383
- else {
783
+ const allCodeConnectObjects = await (0, connect_1.getCodeConnectObjects)(cmd, projectInfo, true);
784
+ const nodesToCheck = files && files.length > 0
785
+ ? await collectNodesToPreview(files, allCodeConnectObjects, dir, cmd)
786
+ : collectAllLocalNodesToPreview(allCodeConnectObjects, dir, cmd);
787
+ if (!files || files.length === 0) {
384
788
  logging_1.logger.info('Previewing all local Code Connect files...');
385
- nodesToCheck = [];
386
- for (const doc of allCodeConnectObjects) {
387
- const parsed = (0, validation_1.parseFigmaNode)(cmd.verbose, doc, true);
388
- if (parsed) {
389
- nodesToCheck.push({
390
- fileKey: parsed.fileKey,
391
- nodeId: parsed.nodeId,
392
- url: doc.figmaNode,
393
- filePath: path_1.default.relative(dir, doc._codeConnectFilePath || ''),
394
- });
395
- }
396
- }
397
789
  }
398
790
  if (nodesToCheck.length === 0) {
399
791
  (0, logging_1.exitWithError)('No valid Code Connect files found to preview');
400
792
  }
401
793
  logging_1.logger.info(`Previewing ${nodesToCheck.length} component(s)...`);
402
- // Group nodes by fileKey for batching
403
- const nodesByFileKey = {};
404
- for (const node of nodesToCheck) {
405
- if (!nodesByFileKey[node.fileKey]) {
406
- nodesByFileKey[node.fileKey] = [];
407
- }
408
- nodesByFileKey[node.fileKey].push(node);
794
+ const nodesByFileKey = groupNodesByFileKey(nodesToCheck);
795
+ if (cmd.inspect) {
796
+ await handleInspect({
797
+ nodesByFileKey,
798
+ configuredApiUrl,
799
+ accessToken,
800
+ outputFormat,
801
+ });
802
+ return;
409
803
  }
804
+ validatePreviewOptions(cmd);
805
+ const maxCombinations = parseMaxCombinationsArg(cmd.maxCombinations);
806
+ const propsPairs = cmd.props ? parsePropsArg(cmd.props) : undefined;
410
807
  const results = [];
411
808
  for (const [fileKey, nodes] of Object.entries(nodesByFileKey)) {
412
- const baseApiUrl = (0, figma_rest_api_1.getApiUrl)(nodes[0].url, cmd.apiUrl || projectInfo.config.apiUrl);
413
- const fileResults = await previewFile({
809
+ const fileResults = await previewNodesForFile({
414
810
  fileKey,
415
811
  nodes,
416
- baseApiUrl,
812
+ configuredApiUrl,
417
813
  accessToken,
418
814
  dir,
419
815
  allCodeConnectObjects,
816
+ renderPropertyCombinations: cmd.all || !!propsPairs,
817
+ propsPairs,
818
+ maxCombinations,
420
819
  });
421
820
  results.push(...fileResults);
422
821
  }
423
- // Validate snippet syntax
424
- for (const result of results) {
425
- if (result.success && result.snippet) {
426
- const prettierValid = await isPrettierParseable(result.snippet, result.language);
427
- if (!prettierValid) {
428
- // Prettier can parse this language but failed — mark as error
429
- result.success = false;
430
- result.error = 'Snippet has syntax errors and may not be valid code';
431
- }
432
- }
433
- }
434
- if (outputFormat === 'json') {
435
- console.log(JSON.stringify(results, null, 2));
436
- }
437
- else {
438
- const formattedResults = await Promise.all(results.map(async (result) => {
439
- if (result.success && result.snippet) {
440
- const formattedSnippet = await formatSnippet(result.snippet, result.language);
441
- return { ...result, snippet: formattedSnippet };
442
- }
443
- return result;
444
- }));
445
- displayResults(formattedResults);
446
- }
822
+ await validateRenderedSnippets(results);
823
+ await outputPreviewResults(results, outputFormat);
447
824
  if (results.every((r) => !r.success)) {
448
825
  process.exit(1);
449
826
  }