Skip to content

Commit

Permalink
feat: add support to exporting components and styles by node IDs
Browse files Browse the repository at this point in the history
  • Loading branch information
marcomontalbano committed Feb 12, 2024
1 parent b323aba commit f9a47c3
Show file tree
Hide file tree
Showing 14 changed files with 102 additions and 29 deletions.
2 changes: 1 addition & 1 deletion .figmaexportrc.example.js
Expand Up @@ -4,7 +4,7 @@ module.exports = {
['styles', {
fileId: 'fzYhvQpqwhZDUImRz431Qo',
// version: 'xxx123456', // optional - file's version history is only supported on paid Figma plans
// onlyFromPages: ['icons'], // optional - Figma page names (all pages when not specified)
// onlyFromPages: ['icons'], // optional - Figma page names or IDs (all pages when not specified)
outputters: [
require('@figma-export/output-styles-as-sass')({
output: './output'
Expand Down
2 changes: 1 addition & 1 deletion .figmaexportrc.example.local.ts
Expand Up @@ -20,7 +20,7 @@ import outputComponentsAsSvgstore from './packages/output-components-as-svgstore

const styleOptions: StylesCommandOptions = {
fileId: 'fzYhvQpqwhZDUImRz431Qo',
// onlyFromPages: ['icons'], // optional - Figma page names (all pages when not specified)
// onlyFromPages: ['icons'], // optional - Figma page names or IDs (all pages when not specified)
outputters: [
outputStylesAsCss({
output: './output/styles/css'
Expand Down
2 changes: 1 addition & 1 deletion .figmaexportrc.example.ts
Expand Up @@ -14,7 +14,7 @@ import outputComponentsAsEs6 from '@figma-export/output-components-as-es6';
const styleOptions: StylesCommandOptions = {
fileId: 'fzYhvQpqwhZDUImRz431Qo',
// version: 'xxx123456', // optional - file's version history is only supported on paid Figma plans
// onlyFromPages: ['icons'], // optional - Figma page names (all pages when not specified)
// onlyFromPages: ['icons'], // optional - Figma page names or IDs (all pages when not specified)
outputters: [
outputStylesAsSass({
output: './output'
Expand Down
2 changes: 1 addition & 1 deletion README.md
Expand Up @@ -176,7 +176,7 @@ module.exports = {
['styles', {
fileId: 'fzYhvQpqwhZDUImRz431Qo',
// version: 'xxx123456', // optional - file's version history is only supported on paid Figma plans
// onlyFromPages: ['icons'], // optional - Figma page names (all pages when not specified)
// onlyFromPages: ['icons'], // optional - Figma page names or IDs (all pages when not specified)
outputters: [
require('@figma-export/output-styles-as-sass')({
output: './output/styles'
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/components.ts
Expand Up @@ -14,7 +14,7 @@ export const addComponents = (prog: Sade, spinner: Ora) => prog
.option('-c, --concurrency', 'Concurrency when fetching', 30)
.option('-r, --retries', 'Maximum number of retries when fetching fails', 3)
.option('-o, --output', 'Output directory', 'output')
.option('-p, --page', 'Figma page names (all pages when not specified)')
.option('-p, --page', 'Figma page names or IDs (all pages when not specified)')
.option('-t, --types', 'Node types to be exported (COMPONENT or INSTANCE)', 'COMPONENT')
.option('--fileVersion', `A specific version ID to get. Omitting this will get the current version of the file.
https://help.figma.com/hc/en-us/articles/360038006754-View-a-file-s-version-history`)
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/styles.ts
Expand Up @@ -11,7 +11,7 @@ export const addStyles = (prog: Sade, spinner: Ora) => prog
.describe('Export styles from a Figma file.')
.option('-O, --outputter', 'Outputter module or path')
.option('-o, --output', 'Output directory', 'output')
.option('-p, --page', 'Figma page names (all pages when not specified)')
.option('-p, --page', 'Figma page names or IDs (all pages when not specified)')
.option('--fileVersion', `A specific version ID to get. Omitting this will get the current version of the file.
https://help.figma.com/hc/en-us/articles/360038006754-View-a-file-s-version-history`)
.example('styles fzYhvQpqwhZDUImRz431Qo -O @figma-export/output-styles-as-css')
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/integration.test.ts
Expand Up @@ -8,7 +8,7 @@ describe('@figma-export/core', () => {
const pageNodes = await exportComponents({
fileId: 'fzYhvQpqwhZDUImRz431Qo',
token: process.env.FIGMA_TOKEN ?? '',
onlyFromPages: ['unit-test'],
onlyFromPages: ['138:28'],
// eslint-disable-next-line @typescript-eslint/no-empty-function
log: () => {},
});
Expand Down
56 changes: 56 additions & 0 deletions packages/core/src/lib/export-components.test.ts
Expand Up @@ -154,6 +154,62 @@ describe('export-component', () => {
expect(outputter).to.have.been.calledOnceWithExactly(pagesWithSvg);
});

it('should filter by selected page IDs when setting onlyFromPages', async () => {
const pagesWithSvg = await exportComponents({
fileId: 'fileABCD',
version: 'versionABCD',
token: 'token1234',
log: logger,
outputters: [outputter],
transformers: [transformer],
onlyFromPages: ['10:7'],
});

nockScope.done();

expect(FigmaExport.getClient).to.have.been.calledOnceWithExactly('token1234');
expect(clientFileImages).to.have.been.calledOnceWith('fileABCD', {
format: 'svg',
ids: ['10:8', '8:1', '9:1'],
svg_include_id: true,
version: 'versionABCD',
});

expect(clientFile).to.have.been.calledTwice;
expect(clientFile.firstCall).to.have.been.calledWith('fileABCD', { version: 'versionABCD', depth: 1, ids: undefined });
expect(clientFile.secondCall).to.have.been.calledWith('fileABCD', {
version: 'versionABCD', depth: undefined, ids: ['10:7'],
});

expect(logger).to.have.been.callCount(6);
expect(logger.getCall(0)).to.have.been.calledWith('fetching document');
expect(logger.getCall(1)).to.have.been.calledWith('preparing components');
expect(logger.getCall(2)).to.have.been.calledWith('fetching components 1/3');
expect(logger.getCall(3)).to.have.been.calledWith('fetching components 2/3');
expect(logger.getCall(4)).to.have.been.calledWith('fetching components 3/3');
expect(logger.getCall(5)).to.have.been.calledWith('exported components from fileABCD');

expect(transformer).to.have.been.calledThrice;
expect(transformer.firstCall).to.have.been.calledWith(figmaDocument.svg.content);
expect(transformer.secondCall).to.have.been.calledWith(figmaDocument.svg.content);
expect(transformer.thirdCall).to.have.been.calledWith(figmaDocument.svg.content);

expect(outputter).to.have.been.calledOnceWithExactly(pagesWithSvg);
});

it('should throw an error when onlyFromPages is set to a page not found', async () => {
// eslint-disable-next-line no-return-await
await expect(exportComponents({
fileId: 'fileABCD',
version: 'versionABCD',
token: 'token1234',
log: logger,
outputters: [outputter],
transformers: [transformer],
onlyFromPages: ['nonexistingNameOrId'],
})).to.be.rejectedWith('Cannot find any page with "onlyForPages" equal to ["nonexistingNameOrId"]');
});

it('should use default "logger" if not defined', async () => {
await exportComponents({
fileId: 'fileABCD',
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/lib/export-components.ts
Expand Up @@ -11,6 +11,7 @@ export const components: FigmaExport.ComponentsCommand = async ({
token,
fileId,
version,
ids = [],
onlyFromPages = [],
filterComponent = () => true,
includeTypes = ['COMPONENT'],
Expand All @@ -31,6 +32,7 @@ export const components: FigmaExport.ComponentsCommand = async ({
{
fileId,
version,
ids,
onlyFromPages,
},
);
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/lib/export-styles.ts
Expand Up @@ -7,6 +7,7 @@ export const styles: FigmaExport.StylesCommand = async ({
token,
fileId,
version,
ids = [],
onlyFromPages = [],
outputters = [],
log = (msg): void => {
Expand All @@ -22,6 +23,7 @@ export const styles: FigmaExport.StylesCommand = async ({
{
fileId,
version,
ids,
onlyFromPages,
},
);
Expand Down
28 changes: 18 additions & 10 deletions packages/core/src/lib/figma.ts
Expand Up @@ -12,7 +12,7 @@ import {
chunk,
emptySvg,
PickOption,
sanitizeOnlyFromPages,
forceArray,
} from './utils';

/**
Expand Down Expand Up @@ -65,10 +65,11 @@ const getPagesFromDocument = (
document: Figma.Document,
options: PickOption<FigmaExport.StylesCommand | FigmaExport.StylesCommand, 'onlyFromPages'> = {},
): Figma.Canvas[] => {
const onlyFromPages = sanitizeOnlyFromPages(options.onlyFromPages);
const onlyFromPages = forceArray(options.onlyFromPages);
return document.children
.filter((node): node is Figma.Canvas => {
return node.type === 'CANVAS' && (onlyFromPages.length === 0 || onlyFromPages.includes(node.name));
return node.type === 'CANVAS' && (
onlyFromPages.length === 0 || onlyFromPages.includes(node.name) || onlyFromPages.includes(node.id));
});
};

Expand All @@ -91,7 +92,11 @@ const getAllPageIds = async (
.map((page) => page.id);

if (pageIds.length === 0) {
throw new Error(`Cannot find any page with "onlyForPages" equal to [${sanitizeOnlyFromPages(options.onlyFromPages).join(', ')}].`);
const errorAsString = forceArray(options.onlyFromPages)
.map((page) => `"${page}"`)
.join(', ');

throw new Error(`Cannot find any page with "onlyForPages" equal to [${errorAsString}].`);
}

return pageIds;
Expand Down Expand Up @@ -150,23 +155,26 @@ export const getComponents = (
// eslint-disable-next-line no-underscore-dangle
const __getDocumentAndStyles = async (
client: Figma.ClientInterface,
options: PickOption<FigmaExport.ComponentsCommand, 'fileId' | 'version' | 'onlyFromPages'>,
options: PickOption<FigmaExport.ComponentsCommand, 'fileId' | 'version' | 'ids' | 'onlyFromPages'>,
): ReturnType<typeof getFile> => {
return getFile(
client,
options,
{
// when `onlyFromPages` is set, we avoid traversing all the document tree, but instead we get only requested ids.
ids: sanitizeOnlyFromPages(options.onlyFromPages).length > 0
? await getAllPageIds(client, options)
: undefined,
// eslint-disable-next-line no-nested-ternary
ids: forceArray(options.ids).length > 0
? options.ids
: forceArray(options.onlyFromPages).length > 0
? await getAllPageIds(client, options)
: undefined,
},
);
};

export const getDocument = async (
client: Figma.ClientInterface,
options: PickOption<FigmaExport.ComponentsCommand, 'fileId' | 'version' | 'onlyFromPages'>,
options: PickOption<FigmaExport.ComponentsCommand, 'fileId' | 'version' | 'ids' | 'onlyFromPages'>,
): Promise<Figma.Document> => {
const { document } = await __getDocumentAndStyles(client, options);

Expand All @@ -179,7 +187,7 @@ export const getDocument = async (

export const getStyles = async (
client: Figma.ClientInterface,
options: PickOption<FigmaExport.ComponentsCommand, 'fileId' | 'version' | 'onlyFromPages'>,
options: PickOption<FigmaExport.StylesCommand, 'fileId' | 'version' | 'ids' | 'onlyFromPages'>,
): Promise<{
readonly [key: string]: Figma.Style
}> => {
Expand Down
10 changes: 5 additions & 5 deletions packages/core/src/lib/utils.test.ts
Expand Up @@ -102,12 +102,12 @@ describe('utils.', () => {
});
});

describe('sanitizeOnlyFromPages', () => {
describe('forceArray', () => {
it('should return a not nullish and not empty string array', () => {
expect(utils.sanitizeOnlyFromPages(undefined)).to.deep.equal([]);
expect(utils.sanitizeOnlyFromPages([''])).to.deep.equal([]);
expect(utils.sanitizeOnlyFromPages(['John'])).to.deep.equal(['John']);
expect(utils.sanitizeOnlyFromPages(['John', 'Doe'])).to.deep.equal(['John', 'Doe']);
expect(utils.forceArray(undefined)).to.deep.equal([]);
expect(utils.forceArray([''])).to.deep.equal([]);
expect(utils.forceArray(['John'])).to.deep.equal(['John']);
expect(utils.forceArray(['John', 'Doe'])).to.deep.equal(['John', 'Doe']);
});
});
});
9 changes: 4 additions & 5 deletions packages/core/src/lib/utils.ts
Expand Up @@ -75,10 +75,9 @@ export type PickOption<T extends FigmaExport.ComponentsCommand | FigmaExport.Sty
Pick<Parameters<T>[0], K>

/**
* Sanitize `onlyFromPages` option by converting to a not nullish and not empty string array.
* Sanitize an array by converting it to a not nullish and not empty string array.
*/
export function sanitizeOnlyFromPages(
onlyFromPages: PickOption<FigmaExport.ComponentsCommand | FigmaExport.StylesCommand, 'onlyFromPages'>['onlyFromPages'],
) {
return (onlyFromPages ?? []).filter((v) => notNullish(v) && notEmptyString(v));
export function forceArray(maybeArray: string[] | undefined) {
return (maybeArray ?? [])
.filter((v) => notNullish(v) && notEmptyString(v));
}
10 changes: 8 additions & 2 deletions packages/types/src/commands.ts
Expand Up @@ -29,7 +29,10 @@ export type ComponentsCommandOptions = {
*/
version?: string;

/** Figma page names (all pages when not specified) */
/** // TODO: add description */
ids?: string[];

/** Figma page names or IDs (all pages when not specified) */
onlyFromPages?: string[];

/** Filter components to export */
Expand Down Expand Up @@ -64,7 +67,10 @@ export type StylesCommandOptions = {
*/
version?: string;

/** Figma page names (all pages when not specified) */
/** // TODO: add description */
ids?: string[];

/** Figma page names or IDs (all pages when not specified) */
onlyFromPages?: string[];

/** Outputter module name or path */
Expand Down

0 comments on commit f9a47c3

Please sign in to comment.