├── .github ├── dependabot.yml └── workflows │ └── main.yml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── figma-transformer.svg ├── jest.config.js ├── package.json ├── src ├── __tests__ │ ├── index.test.ts │ ├── testFile.json │ └── utils.test.ts ├── index.ts ├── types.ts └── utils.ts ├── tsconfig.json └── yarn.lock /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | 7 | steps: 8 | - name: Begin CI... 9 | uses: actions/checkout@v2 10 | 11 | - name: Use Node 12 12 | uses: actions/setup-node@v1 13 | with: 14 | node-version: 12.x 15 | 16 | - name: Use cached node_modules 17 | uses: actions/cache@v1 18 | with: 19 | path: node_modules 20 | key: nodeModules-${{ hashFiles('**/yarn.lock') }} 21 | restore-keys: | 22 | nodeModules- 23 | 24 | - name: Install dependencies 25 | run: yarn install --frozen-lockfile 26 | env: 27 | CI: true 28 | 29 | - name: Lint 30 | run: yarn lint 31 | env: 32 | CI: true 33 | 34 | - name: Test 35 | run: yarn test --ci --coverage --maxWorkers=2 36 | env: 37 | CI: true 38 | 39 | - name: Upload coverage to Codecov 40 | uses: codecov/codecov-action@v1.0.12 41 | 42 | - name: Build 43 | run: yarn build 44 | env: 45 | CI: true 46 | 47 | - name: Publish to npm 48 | uses: pascalgn/npm-publish-action@1.3.3 49 | env: 50 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 52 | 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .rts2_cache_cjs 5 | .rts2_cache_esm 6 | .rts2_cache_umd 7 | .rts2_cache_system 8 | dist 9 | coverage -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 4, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": false, 7 | "trailingComma": "es5", 8 | "bracketSpacing": true, 9 | "jsxBracketSameLine": false, 10 | "fluid": false 11 | } 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Bernardo Raposo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |
3 | 4 |

5 | 6 |

7 | figma-transformer 8 |

9 | 10 |
11 | 12 | A tiny utility library that makes the Figma API more human friendly. 13 | 14 | [![npm version][version-badge]][npm] 15 | [![npm downloads][downloads-badge]][npm] 16 | [![gzip size][size-badge]][size] 17 | ![modules][modules-badge] 18 | [![MIT License][license-badge]][license] 19 | [![PRs Welcome][prs-badge]][prs] 20 | 21 |
22 | 23 | ## How to use `figma-transformer`? 24 | 25 | ```js 26 | import { processFile } from "figma-transformer"; 27 | 28 | // Fetch the file you want using your favourite method 29 | const originalFile = fetchFigmaFile(); 30 | 31 | const file = processFile(originalFile); 32 | 33 | // ✨ You can now use `file` for whatever you need! ✨ 34 | 35 | // Let's get the styles for a component named "Test" 36 | const testStyles = file.shortcuts.components.find( 37 | component => component.name === "Test" 38 | ).shortcuts.styles; 39 | ``` 40 | 41 | ## Why use `figma-transformer`? 42 | 43 | The Figma API is great but sometimes it feels like it's built for machines, not humans. The more you use it, the more you'll end up wasting a lot of time to get to the information that you want. 44 | 45 | These are the most common problems: 46 | 47 | - Code needs to change if file structure changes 48 | - Incomplete information about styles and components 49 | - No type safety 50 | 51 | With `figma-transformer` you get the file structure that you wished the Figma API had. 52 | 53 | ## How does `figma-transformer` solve these problems? 54 | 55 | ### Break free from the file structure 56 | 57 | The Figma API response is very strict in terms of the file structure. To get to a specific node you have to navigate through the entire tree of nodes and it's really easy for your code to break if there's a change in the design file that changes the initial hierarchy. 58 | 59 | We break from that rigid structure by creating shortcuts that are grouped by node type, making it a lot easier to access the nodes that we want irrespective of their placement in the file. 60 | 61 | ```js 62 | { 63 | "children": [{...}, {...}], 64 | "shortcuts": { 65 | "CANVAS": [...], 66 | "INSTANCE": [...], 67 | "RECTANGLE": [...], 68 | "STYLE": [...], 69 | "TEXT": [...], 70 | "FRAME": [...], 71 | "COMPONENT": [...], 72 | "GROUP": [...] 73 | } 74 | } 75 | ``` 76 | 77 | We can see that even though this node just has two direct children, it actually contains a lot more elements down the tree, which are surfaced in the shortcuts. 78 | 79 | Each node of the document tree contains the shortcuts to all their respective child nodes, which reduces the amount of work needed to get to the information we need. 80 | 81 | ### Missing information from nodes 82 | 83 | From the API we can get the information about the styles and components that are present in the file, which is great, but it doesn't contain all the information so we need to parse the entire file to get the additional information that we usuallly need. 84 | 85 | Let's look at how the Figma API describes the styles in a document: 86 | 87 | ```js 88 | styles: { 89 | "1:12": { 90 | key: "ea017aed6616af00f3c4d59e3d945c8c3e47adca", 91 | name: "Green", 92 | styleType: "FILL", 93 | description: "", 94 | }, 95 | "1:11": { 96 | key: "e234400b962ffafce654af9b3220ce88857523ec", 97 | name: "Red", 98 | styleType: "FILL", 99 | description: "", 100 | }, 101 | "97:6": { 102 | key: "cc806814e1b9b7d20ce0b6bed8adf52099899c01", 103 | name: "Body", 104 | styleType: "TEXT", 105 | description: "", 106 | }, 107 | }, 108 | ``` 109 | 110 | and this is how it's represented after being processed (note the populated styles from the associated nodes) 111 | 112 | ```js 113 | [ 114 | { 115 | id: "1:12", 116 | key: "ea017aed6616af00f3c4d59e3d945c8c3e47adca", 117 | name: "Green", 118 | styleType: "FILL", 119 | description: "", 120 | styles: [ 121 | { 122 | blendMode: "NORMAL", 123 | type: "SOLID", 124 | color: { 125 | r: 0.047774821519851685, 126 | g: 0.9563318490982056, 127 | b: 0.02923285961151123, 128 | a: 1, 129 | }, 130 | }, 131 | ], 132 | type: "STYLE", 133 | }, 134 | { 135 | id: "1:11", 136 | key: "e234400b962ffafce654af9b3220ce88857523ec", 137 | name: "Red", 138 | styleType: "FILL", 139 | description: "", 140 | styles: [ 141 | { 142 | blendMode: "NORMAL", 143 | type: "SOLID", 144 | color: { 145 | r: 0.8515284061431885, 146 | g: 0.11155396699905396, 147 | b: 0.11155396699905396, 148 | a: 1, 149 | }, 150 | }, 151 | ], 152 | textStyles: { 153 | fontFamily: "Roboto", 154 | fontPostScriptName: null, 155 | fontWeight: 400, 156 | fontSize: 12, 157 | textAlignHorizontal: "LEFT", 158 | textAlignVertical: "TOP", 159 | letterSpacing: 0, 160 | lineHeightPx: 14.0625, 161 | lineHeightPercent: 100, 162 | lineHeightUnit: "INTRINSIC_%", 163 | }, 164 | type: "STYLE", 165 | }, 166 | { 167 | id: "97:6", 168 | key: "cc806814e1b9b7d20ce0b6bed8adf52099899c01", 169 | name: "Body", 170 | styleType: "TEXT", 171 | description: "", 172 | textStyles: { 173 | fontFamily: "Roboto", 174 | fontPostScriptName: null, 175 | fontWeight: 400, 176 | fontSize: 12, 177 | textAlignHorizontal: "LEFT", 178 | textAlignVertical: "TOP", 179 | letterSpacing: 0, 180 | lineHeightPx: 14.0625, 181 | lineHeightPercent: 100, 182 | lineHeightUnit: "INTRINSIC_%", 183 | }, 184 | type: "STYLE", 185 | }, 186 | ]; 187 | ``` 188 | 189 | The same happens with the components, this is what we get from the API: 190 | 191 | ```js 192 | components: { 193 | "1:5": { key: "", name: "Rectangle", description: "" }, 194 | }, 195 | ``` 196 | 197 | and this is the processed data: 198 | 199 | ```js 200 | { 201 | "id": "1:5", 202 | "parentId": "7:0", 203 | "fileId": "cLp23bR627jcuNSoBGkhL04E", 204 | "name": "Rectangle", 205 | "type": "COMPONENT", 206 | "blendMode": "PASS_THROUGH", 207 | "absoluteBoundingBox": { 208 | "x": -232, 209 | "y": -208, 210 | "width": 201, 211 | "height": 109 212 | }, 213 | "constraints": { 214 | "vertical": "TOP", 215 | "horizontal": "LEFT" 216 | }, 217 | "clipsContent": false, 218 | "background": [ 219 | { 220 | "blendMode": "NORMAL", 221 | "visible": false, 222 | "type": "SOLID", 223 | "color": { 224 | "r": 1, 225 | "g": 1, 226 | "b": 1, 227 | "a": 1 228 | } 229 | } 230 | ], 231 | "backgroundColor": { 232 | "r": 0, 233 | "g": 0, 234 | "b": 0, 235 | "a": 0 236 | }, 237 | "effects": [], 238 | "children": [...], 239 | "shortcuts": {...} 240 | } 241 | ``` 242 | 243 | Not only we have the complete node definition but we also have its child nodes and shortcuts so we can easily navigate through the component tree if needed. 244 | 245 | ### Improved type safety 246 | 247 | The Figma API doesn't have official type definitions, but fortunately we can provide a better developer experience by extending the TypeScript type definitions provided by the awesome [figma-js](https://github.com/jongold/figma-js) library. 248 | 249 | This means that you can continue to use your preferred way of fetching the data from the Figma API and `figma-transformer` will provide the types for you. 250 | 251 | ## Examples 252 | 253 | Let's see more specific examples where the benefits of the library really stand out. 254 | 255 | **Getting all text used in a document** 256 | 257 | ```js 258 | const text = file.shortcuts.texts.map(node => node.characters); 259 | ``` 260 | 261 | **Finding the styles applied to a specific component** 262 | 263 | ```js 264 | const styles = file.shortcuts.components 265 | .filter(component => component.name === "Test") 266 | .map(component => component.shortcuts.styles); 267 | ``` 268 | 269 | **Getting the fill colours for all the rectangles in the first page** 270 | 271 | ```js 272 | const fills = file.shortcuts.pages 273 | .filter(page => page.name === "Page 1") 274 | .map(page => page.shortcuts.rectangles.fills); 275 | ``` 276 | 277 | ## Projects using `figma-transformer` 278 | 279 | - [figma-graphql](https://github.com/braposo/figma-graphql) 280 | - [theme.figma](https://github.com/ds-tools/theme.figma) 281 | 282 | --- 283 | 284 | ## Local Development 285 | 286 | Below is a list of commands you will probably find useful. 287 | 288 | #### `npm start` or `yarn start` 289 | 290 | Runs the project in development/watch mode. Your project will be rebuilt upon changes. TSDX has a special logger for you convenience. Error messages are pretty printed and formatted for compatibility VS Code's Problems tab. 291 | 292 | Your library will be rebuilt if you make edits. 293 | 294 | #### `npm run build` or `yarn build` 295 | 296 | Bundles the package to the `dist` folder. 297 | The package is optimized and bundled with Rollup into multiple formats (CommonJS, UMD, and ES Module). 298 | 299 | #### `npm test` or `yarn test` 300 | 301 | Runs the test watcher (Jest) in an interactive mode. 302 | By default, runs tests related to files changed since the last commit. 303 | 304 | _This project was bootstrapped with [TSDX](https://github.com/jaredpalmer/tsdx)._ 305 | 306 | [npm]: https://www.npmjs.com/package/figma-transformer 307 | [license]: https://github.com/braposo/figma-transformer/blob/master/LICENSE 308 | [prs]: http://makeapullrequest.com 309 | [size]: https://unpkg.com/figma-transformer/dist/figma-transformer.cjs.production.min.js 310 | [version-badge]: https://img.shields.io/npm/v/figma-transformer.svg?style=flat-square 311 | [downloads-badge]: https://img.shields.io/npm/dm/figma-transformer.svg?style=flat-square 312 | [license-badge]: https://img.shields.io/npm/l/figma-transformer.svg?style=flat-square 313 | [size-badge]: http://img.badgesize.io/https://unpkg.com/figma-transformer/dist/figma-transformer.cjs.production.min.js?compression=gzip&style=flat-square 314 | [modules-badge]: https://img.shields.io/badge/module%20formats-cjs%2C%20esm-green.svg?style=flat-square 315 | [prs-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square 316 | -------------------------------------------------------------------------------- /figma-transformer.svg: -------------------------------------------------------------------------------- 1 | 2 | 6 | 10 | 14 | 18 | 22 | 23 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleNameMapper: { 3 | src: "/src/index", 4 | "src/(.*)": "/src/$1", 5 | }, 6 | modulePathIgnorePatterns: ["/dist/"], 7 | coverageThreshold: { 8 | global: { 9 | branches: 100, 10 | functions: 100, 11 | lines: 100, 12 | statements: 100, 13 | }, 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "figma-transformer", 3 | "version": "2.1.0", 4 | "license": "MIT", 5 | "keywords": [ 6 | "figma", 7 | "design", 8 | "process", 9 | "transformer", 10 | "api" 11 | ], 12 | "author": "Bernardo Raposo (https://github.com/braposo)", 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/braposo/figma-transformer.git" 16 | }, 17 | "homepage": "https://github.com/braposo/figma-transformer#readme", 18 | "main": "dist/index.js", 19 | "module": "dist/figma-transformer.esm.js", 20 | "typings": "dist/index.d.ts", 21 | "files": [ 22 | "dist" 23 | ], 24 | "scripts": { 25 | "start": "tsdx watch", 26 | "build": "tsdx build", 27 | "test": "tsdx test", 28 | "lint": "tsdx lint src", 29 | "prepublishOnly": "yarn validate", 30 | "validate": "yarn test --coverage && yarn lint --fix && yarn build && bundlesize" 31 | }, 32 | "sideEffects": false, 33 | "peerDependencies": {}, 34 | "husky": { 35 | "hooks": { 36 | "pre-commit": "yarn lint && yarn test" 37 | } 38 | }, 39 | "devDependencies": { 40 | "@types/jest": "^26.0.14", 41 | "bundlesize": "^0.18.0", 42 | "figma-js": "^1.13.0", 43 | "husky": "^4.3.0", 44 | "tsdx": "^0.14.0", 45 | "typescript": "3.9.7" 46 | }, 47 | "dependencies": {}, 48 | "bundlesize": [ 49 | { 50 | "path": "./dist/figma-transformer.cjs.production.min.js", 51 | "maxSize": "1.1 kB" 52 | } 53 | ] 54 | } 55 | -------------------------------------------------------------------------------- /src/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import { processFile } from ".."; 2 | import testFile from "./testFile.json"; 3 | import { 4 | FileResponse, 5 | ComponentMetadata, 6 | Style, 7 | Text, 8 | VectorBase, 9 | Component, 10 | } from "figma-js"; 11 | 12 | describe("processFile", () => { 13 | const fileResponse = processFile(testFile as FileResponse); 14 | 15 | it("should have correct structure", () => { 16 | expect(Object.keys(fileResponse)).toEqual([ 17 | "fileId", 18 | "name", 19 | "lastModified", 20 | "thumbnailUrl", 21 | "version", 22 | "children", 23 | "shortcuts", 24 | ]); 25 | }); 26 | 27 | it("should have the right file information", () => { 28 | const { fileId, name, version } = fileResponse; 29 | 30 | expect(fileId).toBeUndefined(); 31 | expect(name).toEqual("figma-graphql test file"); 32 | expect(version).toBeDefined(); 33 | }); 34 | 35 | it("should have the fileId when defined", () => { 36 | const { fileId, name, version } = processFile( 37 | testFile as FileResponse, 38 | "cLp23bR627jcuNSoBGkhL04E" 39 | ); 40 | 41 | expect(fileId).toEqual("cLp23bR627jcuNSoBGkhL04E"); 42 | expect(name).toEqual("figma-graphql test file"); 43 | expect(version).toBeDefined(); 44 | }); 45 | 46 | it("should keep the same info from styles declaration", () => { 47 | const { shortcuts } = fileResponse; 48 | const nodeId = "1:12"; 49 | 50 | const originalComponent: Style = testFile.styles[nodeId] as Style; 51 | const parsedComponent = shortcuts.styles.find(n => n.id === nodeId); 52 | const keys = Object.keys(originalComponent) as [keyof Style]; 53 | 54 | keys.forEach(key => { 55 | expect(originalComponent[key]).toEqual( 56 | parsedComponent && parsedComponent[key] 57 | ); 58 | }); 59 | }); 60 | 61 | it("should keep the same info from components declaration", () => { 62 | const { shortcuts } = fileResponse; 63 | const nodeId = "1:5"; 64 | 65 | const originalComponent: ComponentMetadata = 66 | testFile.components[nodeId]; 67 | const parsedComponent = shortcuts.components.find(n => n.id === nodeId); 68 | 69 | const keys = Object.keys(originalComponent) as [ 70 | keyof ComponentMetadata 71 | ]; 72 | 73 | expect(parsedComponent).toBeDefined(); 74 | 75 | keys.forEach(key => { 76 | expect(originalComponent[key]).toEqual( 77 | parsedComponent && parsedComponent[key] 78 | ); 79 | }); 80 | }); 81 | 82 | it("should create the correct shortcuts", () => { 83 | const { shortcuts } = fileResponse; 84 | expect(shortcuts.pages).toHaveLength(2); 85 | expect(shortcuts.instances).toHaveLength(2); 86 | expect(shortcuts.rectangles).toHaveLength(4); 87 | expect(shortcuts.styles).toHaveLength( 88 | Object.keys(testFile.styles).length 89 | ); 90 | expect(shortcuts.texts).toHaveLength(4); 91 | expect(shortcuts.frames).toHaveLength(3); 92 | expect(shortcuts.components).toHaveLength( 93 | Object.keys(testFile.components).length 94 | ); 95 | expect(shortcuts.groups).toHaveLength(1); 96 | }); 97 | 98 | it("should add fill styles to STYLE shortcut", () => { 99 | const { shortcuts } = fileResponse; 100 | const { styles } = shortcuts; 101 | 102 | const style = styles.find(style => style.styleType === "FILL"); 103 | 104 | expect(style).toBeDefined(); 105 | 106 | const originalStyles = getObjects( 107 | testFile, 108 | style?.styleType.toLowerCase(), 109 | style?.id 110 | ); 111 | 112 | const originalStyle = originalStyles[0] as VectorBase; 113 | 114 | expect(style?.styles).toBe(originalStyle.fills); 115 | }); 116 | 117 | it("should add text styles to STYLE shortcut", () => { 118 | const { shortcuts } = fileResponse; 119 | const { styles } = shortcuts; 120 | const style = styles.find(style => style.styleType === "TEXT"); 121 | 122 | expect(style).toBeDefined(); 123 | 124 | const originalStyles = getObjects( 125 | testFile, 126 | style?.styleType.toLowerCase(), 127 | style?.id 128 | ); 129 | 130 | const originalStyle = originalStyles[0] as Text; 131 | 132 | expect(style?.textStyles).toBe(originalStyle.style); 133 | }); 134 | 135 | it("should add effect styles to STYLE shortcut", () => { 136 | const { shortcuts } = fileResponse; 137 | const { styles } = shortcuts; 138 | const style = styles.find(style => style.styleType === "EFFECT"); 139 | 140 | expect(style).toBeDefined(); 141 | 142 | const originalStyles = getObjects( 143 | testFile, 144 | style?.styleType.toLowerCase(), 145 | style?.id 146 | ); 147 | 148 | const originalStyle = originalStyles[0] as VectorBase; 149 | 150 | expect(style?.styles).toBe(originalStyle.effects); 151 | }); 152 | 153 | it("should add component info to COMPONENT shortcut", () => { 154 | const { shortcuts } = fileResponse; 155 | const { components } = shortcuts; 156 | const component = components.find( 157 | component => component.name === "Rectangle" 158 | ); 159 | 160 | expect(component).toBeDefined(); 161 | 162 | const originalComponents = getObjects(testFile, "id", component?.id); 163 | const originalComponent = originalComponents[0] as Component; 164 | const { 165 | children, 166 | ...originalComponentWithoutChildren 167 | } = originalComponent; 168 | 169 | Object.entries(originalComponentWithoutChildren).forEach( 170 | ([key, val]) => { 171 | expect(component && component[key as keyof Component]).toEqual( 172 | val 173 | ); 174 | } 175 | ); 176 | 177 | expect(component?.children).toHaveLength(children.length); 178 | }); 179 | }); 180 | 181 | function getObjects( 182 | obj: Record, 183 | key?: string, 184 | val?: any, 185 | parent?: any 186 | ): Record[] { 187 | var objects: Record[] = []; 188 | for (var i in obj) { 189 | if (!obj.hasOwnProperty(i)) continue; 190 | if (typeof obj[i] == "object") { 191 | objects = objects.concat(getObjects(obj[i], key, val, obj)); 192 | } 193 | //if key matches and value matches or if key matches and value is not passed (eliminating the case where key matches but passed value does not) 194 | else if ((i === key && obj[i] === val) || (i === key && val === "")) { 195 | // 196 | objects.push(parent); 197 | } else if (obj[i] === val && key === "") { 198 | //only add if the object is not already in the array 199 | if (objects.lastIndexOf(obj) === -1) { 200 | objects.push(parent); 201 | } 202 | } 203 | } 204 | return objects.flat(); 205 | } 206 | -------------------------------------------------------------------------------- /src/__tests__/testFile.json: -------------------------------------------------------------------------------- 1 | { 2 | "document": { 3 | "id": "0:0", 4 | "name": "Document", 5 | "type": "DOCUMENT", 6 | "children": [ 7 | { 8 | "id": "0:1", 9 | "name": "Page 1", 10 | "type": "CANVAS", 11 | "children": [ 12 | { 13 | "id": "1:6", 14 | "name": "Rectangle", 15 | "type": "INSTANCE", 16 | "blendMode": "PASS_THROUGH", 17 | "children": [ 18 | { 19 | "id": "I1:6;1:2", 20 | "name": "Rectangle", 21 | "type": "RECTANGLE", 22 | "blendMode": "PASS_THROUGH", 23 | "absoluteBoundingBox": { 24 | "x": 24, 25 | "y": -208, 26 | "width": 201, 27 | "height": 109 28 | }, 29 | "constraints": { 30 | "vertical": "SCALE", 31 | "horizontal": "SCALE" 32 | }, 33 | "fills": [ 34 | { 35 | "blendMode": "NORMAL", 36 | "type": "SOLID", 37 | "color": { 38 | "r": 0.047774821519851685, 39 | "g": 0.9563318490982056, 40 | "b": 0.02923285961151123, 41 | "a": 1 42 | } 43 | } 44 | ], 45 | "strokes": [], 46 | "strokeWeight": 3, 47 | "strokeAlign": "CENTER", 48 | "styles": { 49 | "fill": "1:12", 50 | "effect": "388:0" 51 | }, 52 | "effects": [ 53 | { 54 | "type": "DROP_SHADOW", 55 | "visible": true, 56 | "color": { 57 | "r": 0, 58 | "g": 0, 59 | "b": 0, 60 | "a": 0.25 61 | }, 62 | "blendMode": "NORMAL", 63 | "offset": { 64 | "x": 0, 65 | "y": 4 66 | }, 67 | "radius": 4 68 | } 69 | ] 70 | }, 71 | { 72 | "id": "I1:6;18:0", 73 | "name": "Text", 74 | "type": "TEXT", 75 | "blendMode": "PASS_THROUGH", 76 | "absoluteBoundingBox": { 77 | "x": 57, 78 | "y": -163, 79 | "width": 63, 80 | "height": 20 81 | }, 82 | "constraints": { 83 | "vertical": "SCALE", 84 | "horizontal": "SCALE" 85 | }, 86 | "fills": [ 87 | { 88 | "blendMode": "NORMAL", 89 | "type": "SOLID", 90 | "color": { 91 | "r": 0.8515284061431885, 92 | "g": 0.11155396699905396, 93 | "b": 0.11155396699905396, 94 | "a": 1 95 | } 96 | } 97 | ], 98 | "strokes": [], 99 | "strokeWeight": 1, 100 | "strokeAlign": "OUTSIDE", 101 | "styles": { 102 | "fill": "1:11" 103 | }, 104 | "effects": [], 105 | "characters": "Text", 106 | "style": { 107 | "fontFamily": "Roboto", 108 | "fontPostScriptName": null, 109 | "fontWeight": 400, 110 | "fontSize": 12, 111 | "textAlignHorizontal": "LEFT", 112 | "textAlignVertical": "TOP", 113 | "letterSpacing": 0, 114 | "lineHeightPx": 14.0625, 115 | "lineHeightPercent": 100, 116 | "lineHeightUnit": "INTRINSIC_%" 117 | }, 118 | "layoutVersion": 0, 119 | "characterStyleOverrides": [], 120 | "styleOverrideTable": {} 121 | } 122 | ], 123 | "absoluteBoundingBox": { 124 | "x": 24, 125 | "y": -208, 126 | "width": 201, 127 | "height": 109 128 | }, 129 | "constraints": { 130 | "vertical": "TOP", 131 | "horizontal": "LEFT" 132 | }, 133 | "clipsContent": false, 134 | "background": [ 135 | { 136 | "blendMode": "NORMAL", 137 | "visible": false, 138 | "type": "SOLID", 139 | "color": { 140 | "r": 1, 141 | "g": 1, 142 | "b": 1, 143 | "a": 1 144 | } 145 | } 146 | ], 147 | "fills": [ 148 | { 149 | "blendMode": "NORMAL", 150 | "visible": false, 151 | "type": "SOLID", 152 | "color": { 153 | "r": 1, 154 | "g": 1, 155 | "b": 1, 156 | "a": 1 157 | } 158 | } 159 | ], 160 | "strokes": [], 161 | "strokeWeight": 1, 162 | "strokeAlign": "OUTSIDE", 163 | "backgroundColor": { 164 | "r": 0, 165 | "g": 0, 166 | "b": 0, 167 | "a": 0 168 | }, 169 | "effects": [], 170 | "componentId": "1:5" 171 | }, 172 | { 173 | "id": "7:0", 174 | "name": "Frame 1", 175 | "type": "FRAME", 176 | "blendMode": "PASS_THROUGH", 177 | "children": [ 178 | { 179 | "id": "1:5", 180 | "name": "Rectangle", 181 | "type": "COMPONENT", 182 | "blendMode": "PASS_THROUGH", 183 | "children": [ 184 | { 185 | "id": "1:2", 186 | "name": "Rectangle", 187 | "type": "RECTANGLE", 188 | "blendMode": "PASS_THROUGH", 189 | "absoluteBoundingBox": { 190 | "x": -232, 191 | "y": -208, 192 | "width": 201, 193 | "height": 109 194 | }, 195 | "constraints": { 196 | "vertical": "SCALE", 197 | "horizontal": "SCALE" 198 | }, 199 | "fills": [ 200 | { 201 | "blendMode": "NORMAL", 202 | "type": "SOLID", 203 | "color": { 204 | "r": 0.8515284061431885, 205 | "g": 0.11155396699905396, 206 | "b": 0.11155396699905396, 207 | "a": 1 208 | } 209 | } 210 | ], 211 | "strokes": [ 212 | { 213 | "blendMode": "NORMAL", 214 | "type": "SOLID", 215 | "color": { 216 | "r": 0.058090269565582275, 217 | "g": 0.939568817615509, 218 | "b": 0.9958333373069763, 219 | "a": 1 220 | } 221 | } 222 | ], 223 | "strokeWeight": 3, 224 | "strokeAlign": "CENTER", 225 | "styles": { 226 | "fill": "1:11", 227 | "stroke": "387:0", 228 | "effect": "388:0" 229 | }, 230 | "effects": [ 231 | { 232 | "type": "DROP_SHADOW", 233 | "visible": true, 234 | "color": { 235 | "r": 0, 236 | "g": 0, 237 | "b": 0, 238 | "a": 0.25 239 | }, 240 | "blendMode": "NORMAL", 241 | "offset": { 242 | "x": 0, 243 | "y": 4 244 | }, 245 | "radius": 4 246 | } 247 | ] 248 | }, 249 | { 250 | "id": "18:0", 251 | "name": "Text", 252 | "type": "TEXT", 253 | "blendMode": "PASS_THROUGH", 254 | "absoluteBoundingBox": { 255 | "x": -199, 256 | "y": -163, 257 | "width": 63, 258 | "height": 20 259 | }, 260 | "constraints": { 261 | "vertical": "SCALE", 262 | "horizontal": "SCALE" 263 | }, 264 | "fills": [ 265 | { 266 | "blendMode": "NORMAL", 267 | "type": "SOLID", 268 | "color": { 269 | "r": 0.047774821519851685, 270 | "g": 0.9563318490982056, 271 | "b": 0.02923285961151123, 272 | "a": 1 273 | } 274 | } 275 | ], 276 | "strokes": [], 277 | "strokeWeight": 1, 278 | "strokeAlign": "OUTSIDE", 279 | "styles": { 280 | "fill": "1:12" 281 | }, 282 | "effects": [], 283 | "characters": "Text", 284 | "style": { 285 | "fontFamily": "Roboto", 286 | "fontPostScriptName": null, 287 | "fontWeight": 400, 288 | "fontSize": 12, 289 | "textAlignHorizontal": "LEFT", 290 | "textAlignVertical": "TOP", 291 | "letterSpacing": 0, 292 | "lineHeightPx": 14.0625, 293 | "lineHeightPercent": 100, 294 | "lineHeightUnit": "INTRINSIC_%" 295 | }, 296 | "layoutVersion": 0, 297 | "characterStyleOverrides": [], 298 | "styleOverrideTable": {} 299 | } 300 | ], 301 | "absoluteBoundingBox": { 302 | "x": -232, 303 | "y": -208, 304 | "width": 201, 305 | "height": 109 306 | }, 307 | "constraints": { 308 | "vertical": "TOP", 309 | "horizontal": "LEFT" 310 | }, 311 | "clipsContent": false, 312 | "background": [ 313 | { 314 | "blendMode": "NORMAL", 315 | "visible": false, 316 | "type": "SOLID", 317 | "color": { 318 | "r": 1, 319 | "g": 1, 320 | "b": 1, 321 | "a": 1 322 | } 323 | } 324 | ], 325 | "fills": [ 326 | { 327 | "blendMode": "NORMAL", 328 | "visible": false, 329 | "type": "SOLID", 330 | "color": { 331 | "r": 1, 332 | "g": 1, 333 | "b": 1, 334 | "a": 1 335 | } 336 | } 337 | ], 338 | "strokes": [], 339 | "strokeWeight": 1, 340 | "strokeAlign": "INSIDE", 341 | "backgroundColor": { 342 | "r": 0, 343 | "g": 0, 344 | "b": 0, 345 | "a": 0 346 | }, 347 | "effects": [] 348 | } 349 | ], 350 | "absoluteBoundingBox": { 351 | "x": -255, 352 | "y": -230, 353 | "width": 252, 354 | "height": 151 355 | }, 356 | "constraints": { 357 | "vertical": "TOP", 358 | "horizontal": "LEFT" 359 | }, 360 | "clipsContent": true, 361 | "background": [ 362 | { 363 | "blendMode": "NORMAL", 364 | "type": "SOLID", 365 | "color": { 366 | "r": 1, 367 | "g": 1, 368 | "b": 1, 369 | "a": 1 370 | } 371 | } 372 | ], 373 | "fills": [ 374 | { 375 | "blendMode": "NORMAL", 376 | "type": "SOLID", 377 | "color": { 378 | "r": 1, 379 | "g": 1, 380 | "b": 1, 381 | "a": 1 382 | } 383 | } 384 | ], 385 | "strokes": [], 386 | "strokeWeight": 1, 387 | "strokeAlign": "INSIDE", 388 | "backgroundColor": { 389 | "r": 1, 390 | "g": 1, 391 | "b": 1, 392 | "a": 1 393 | }, 394 | "effects": [] 395 | }, 396 | { 397 | "id": "46:0", 398 | "name": "Frame 1.1", 399 | "visible": false, 400 | "type": "FRAME", 401 | "blendMode": "PASS_THROUGH", 402 | "children": [ 403 | { 404 | "id": "46:1", 405 | "name": "Rectangle", 406 | "type": "INSTANCE", 407 | "blendMode": "PASS_THROUGH", 408 | "children": [ 409 | { 410 | "id": "I46:1;1:2", 411 | "name": "Rectangle", 412 | "type": "RECTANGLE", 413 | "blendMode": "PASS_THROUGH", 414 | "absoluteBoundingBox": { 415 | "x": -232, 416 | "y": -16, 417 | "width": 201, 418 | "height": 109 419 | }, 420 | "constraints": { 421 | "vertical": "SCALE", 422 | "horizontal": "SCALE" 423 | }, 424 | "fills": [ 425 | { 426 | "blendMode": "NORMAL", 427 | "type": "SOLID", 428 | "color": { 429 | "r": 0.8515284061431885, 430 | "g": 0.11155396699905396, 431 | "b": 0.11155396699905396, 432 | "a": 1 433 | } 434 | } 435 | ], 436 | "strokes": [ 437 | { 438 | "blendMode": "NORMAL", 439 | "type": "SOLID", 440 | "color": { 441 | "r": 0.058090269565582275, 442 | "g": 0.939568817615509, 443 | "b": 0.9958333373069763, 444 | "a": 1 445 | } 446 | } 447 | ], 448 | "strokeWeight": 3, 449 | "strokeAlign": "CENTER", 450 | "styles": { 451 | "fill": "1:11", 452 | "stroke": "387:0", 453 | "effect": "388:0" 454 | }, 455 | "effects": [ 456 | { 457 | "type": "DROP_SHADOW", 458 | "visible": true, 459 | "color": { 460 | "r": 0, 461 | "g": 0, 462 | "b": 0, 463 | "a": 0.25 464 | }, 465 | "blendMode": "NORMAL", 466 | "offset": { 467 | "x": 0, 468 | "y": 4 469 | }, 470 | "radius": 4 471 | } 472 | ] 473 | }, 474 | { 475 | "id": "I46:1;18:0", 476 | "name": "Text", 477 | "type": "TEXT", 478 | "blendMode": "PASS_THROUGH", 479 | "absoluteBoundingBox": { 480 | "x": -199, 481 | "y": 29, 482 | "width": 63, 483 | "height": 20 484 | }, 485 | "constraints": { 486 | "vertical": "SCALE", 487 | "horizontal": "SCALE" 488 | }, 489 | "fills": [ 490 | { 491 | "blendMode": "NORMAL", 492 | "type": "SOLID", 493 | "color": { 494 | "r": 0.047774821519851685, 495 | "g": 0.9563318490982056, 496 | "b": 0.02923285961151123, 497 | "a": 1 498 | } 499 | } 500 | ], 501 | "strokes": [], 502 | "strokeWeight": 1, 503 | "strokeAlign": "OUTSIDE", 504 | "styles": { 505 | "fill": "1:12" 506 | }, 507 | "effects": [], 508 | "characters": "Text", 509 | "style": { 510 | "fontFamily": "Roboto", 511 | "fontPostScriptName": null, 512 | "fontWeight": 400, 513 | "fontSize": 12, 514 | "textAlignHorizontal": "LEFT", 515 | "textAlignVertical": "TOP", 516 | "letterSpacing": 0, 517 | "lineHeightPx": 14.0625, 518 | "lineHeightPercent": 100, 519 | "lineHeightUnit": "INTRINSIC_%" 520 | }, 521 | "layoutVersion": 0, 522 | "characterStyleOverrides": [], 523 | "styleOverrideTable": {} 524 | } 525 | ], 526 | "absoluteBoundingBox": { 527 | "x": -232, 528 | "y": -16, 529 | "width": 201, 530 | "height": 109 531 | }, 532 | "constraints": { 533 | "vertical": "TOP", 534 | "horizontal": "LEFT" 535 | }, 536 | "clipsContent": false, 537 | "background": [ 538 | { 539 | "blendMode": "NORMAL", 540 | "visible": false, 541 | "type": "SOLID", 542 | "color": { 543 | "r": 1, 544 | "g": 1, 545 | "b": 1, 546 | "a": 1 547 | } 548 | } 549 | ], 550 | "fills": [ 551 | { 552 | "blendMode": "NORMAL", 553 | "visible": false, 554 | "type": "SOLID", 555 | "color": { 556 | "r": 1, 557 | "g": 1, 558 | "b": 1, 559 | "a": 1 560 | } 561 | } 562 | ], 563 | "strokes": [], 564 | "strokeWeight": 1, 565 | "strokeAlign": "INSIDE", 566 | "backgroundColor": { 567 | "r": 0, 568 | "g": 0, 569 | "b": 0, 570 | "a": 0 571 | }, 572 | "effects": [], 573 | "componentId": "1:5" 574 | } 575 | ], 576 | "absoluteBoundingBox": { 577 | "x": -255, 578 | "y": -38, 579 | "width": 252, 580 | "height": 151 581 | }, 582 | "constraints": { 583 | "vertical": "TOP", 584 | "horizontal": "LEFT" 585 | }, 586 | "clipsContent": true, 587 | "background": [ 588 | { 589 | "blendMode": "NORMAL", 590 | "type": "SOLID", 591 | "color": { 592 | "r": 1, 593 | "g": 1, 594 | "b": 1, 595 | "a": 1 596 | } 597 | } 598 | ], 599 | "fills": [ 600 | { 601 | "blendMode": "NORMAL", 602 | "type": "SOLID", 603 | "color": { 604 | "r": 1, 605 | "g": 1, 606 | "b": 1, 607 | "a": 1 608 | } 609 | } 610 | ], 611 | "strokes": [], 612 | "strokeWeight": 1, 613 | "strokeAlign": "INSIDE", 614 | "backgroundColor": { 615 | "r": 1, 616 | "g": 1, 617 | "b": 1, 618 | "a": 1 619 | }, 620 | "effects": [] 621 | }, 622 | { 623 | "id": "45:0", 624 | "name": "Hello", 625 | "type": "TEXT", 626 | "blendMode": "PASS_THROUGH", 627 | "absoluteBoundingBox": { 628 | "x": -255, 629 | "y": -335, 630 | "width": 126, 631 | "height": 45 632 | }, 633 | "constraints": { 634 | "vertical": "TOP", 635 | "horizontal": "LEFT" 636 | }, 637 | "fills": [ 638 | { 639 | "blendMode": "NORMAL", 640 | "type": "SOLID", 641 | "color": { 642 | "r": 0, 643 | "g": 0, 644 | "b": 0, 645 | "a": 1 646 | } 647 | } 648 | ], 649 | "strokes": [], 650 | "strokeWeight": 1, 651 | "strokeAlign": "OUTSIDE", 652 | "effects": [], 653 | "characters": "Hello", 654 | "style": { 655 | "fontFamily": "Roboto", 656 | "fontPostScriptName": null, 657 | "fontWeight": 400, 658 | "fontSize": 12, 659 | "textAlignHorizontal": "LEFT", 660 | "textAlignVertical": "TOP", 661 | "letterSpacing": 0, 662 | "lineHeightPx": 14.0625, 663 | "lineHeightPercent": 100, 664 | "lineHeightUnit": "INTRINSIC_%" 665 | }, 666 | "characterStyleOverrides": [], 667 | "styleOverrideTable": {}, 668 | "styles": { 669 | "text": "97:6" 670 | } 671 | } 672 | ], 673 | "backgroundColor": { 674 | "r": 0.8980392217636108, 675 | "g": 0.8980392217636108, 676 | "b": 0.8980392217636108, 677 | "a": 1 678 | }, 679 | "prototypeStartNodeID": null, 680 | "prototypeDevice": { 681 | "type": "NONE", 682 | "rotation": "NONE" 683 | } 684 | }, 685 | { 686 | "id": "28:4", 687 | "name": "Page 2", 688 | "type": "CANVAS", 689 | "children": [ 690 | { 691 | "id": "28:5", 692 | "name": "Frame", 693 | "type": "FRAME", 694 | "blendMode": "PASS_THROUGH", 695 | "children": [ 696 | { 697 | "id": "56:6", 698 | "name": "Group", 699 | "type": "GROUP", 700 | "blendMode": "PASS_THROUGH", 701 | "children": [ 702 | { 703 | "id": "28:6", 704 | "name": "Rectangle", 705 | "type": "RECTANGLE", 706 | "blendMode": "PASS_THROUGH", 707 | "absoluteBoundingBox": { 708 | "x": -212, 709 | "y": -16, 710 | "width": 144, 711 | "height": 62 712 | }, 713 | "constraints": { 714 | "vertical": "TOP", 715 | "horizontal": "LEFT" 716 | }, 717 | "fills": [ 718 | { 719 | "blendMode": "NORMAL", 720 | "type": "SOLID", 721 | "color": { 722 | "r": 0.7686274647712708, 723 | "g": 0.7686274647712708, 724 | "b": 0.7686274647712708, 725 | "a": 1 726 | } 727 | }, 728 | { 729 | "blendMode": "NORMAL", 730 | "type": "GRADIENT_LINEAR", 731 | "gradientHandlePositions": [ 732 | { 733 | "x": 0.5, 734 | "y": -3.0616171314629196e-17 735 | }, 736 | { 737 | "x": 0.5, 738 | "y": 0.9999999999999999 739 | }, 740 | { 741 | "x": 0, 742 | "y": 0 743 | } 744 | ], 745 | "gradientStops": [ 746 | { 747 | "color": { 748 | "r": 1, 749 | "g": 1, 750 | "b": 1, 751 | "a": 1 752 | }, 753 | "position": 0 754 | }, 755 | { 756 | "color": { 757 | "r": 1, 758 | "g": 1, 759 | "b": 1, 760 | "a": 0 761 | }, 762 | "position": 1 763 | } 764 | ] 765 | } 766 | ], 767 | "strokes": [], 768 | "strokeWeight": 1, 769 | "strokeAlign": "INSIDE", 770 | "effects": [] 771 | } 772 | ], 773 | "absoluteBoundingBox": { 774 | "x": -212, 775 | "y": -16, 776 | "width": 144, 777 | "height": 62 778 | }, 779 | "constraints": { 780 | "vertical": "TOP", 781 | "horizontal": "LEFT" 782 | }, 783 | "clipsContent": false, 784 | "background": [], 785 | "fills": [], 786 | "strokes": [], 787 | "strokeWeight": 1, 788 | "strokeAlign": "INSIDE", 789 | "backgroundColor": { 790 | "r": 0, 791 | "g": 0, 792 | "b": 0, 793 | "a": 0 794 | }, 795 | "effects": [] 796 | } 797 | ], 798 | "absoluteBoundingBox": { 799 | "x": -294, 800 | "y": -65, 801 | "width": 305, 802 | "height": 136 803 | }, 804 | "constraints": { 805 | "vertical": "TOP", 806 | "horizontal": "LEFT" 807 | }, 808 | "clipsContent": true, 809 | "background": [ 810 | { 811 | "blendMode": "NORMAL", 812 | "type": "SOLID", 813 | "color": { 814 | "r": 1, 815 | "g": 1, 816 | "b": 1, 817 | "a": 1 818 | } 819 | } 820 | ], 821 | "fills": [ 822 | { 823 | "blendMode": "NORMAL", 824 | "type": "SOLID", 825 | "color": { 826 | "r": 1, 827 | "g": 1, 828 | "b": 1, 829 | "a": 1 830 | } 831 | } 832 | ], 833 | "strokes": [], 834 | "strokeWeight": 1, 835 | "strokeAlign": "INSIDE", 836 | "backgroundColor": { 837 | "r": 1, 838 | "g": 1, 839 | "b": 1, 840 | "a": 1 841 | }, 842 | "effects": [] 843 | } 844 | ], 845 | "backgroundColor": { 846 | "r": 0.8980392217636108, 847 | "g": 0.8980392217636108, 848 | "b": 0.8980392217636108, 849 | "a": 1 850 | }, 851 | "prototypeStartNodeID": null, 852 | "prototypeDevice": { 853 | "type": "NONE", 854 | "rotation": "NONE" 855 | } 856 | } 857 | ] 858 | }, 859 | "components": { 860 | "1:5": { 861 | "key": "", 862 | "name": "Rectangle", 863 | "description": "" 864 | } 865 | }, 866 | "schemaVersion": 0, 867 | "styles": { 868 | "1:12": { 869 | "key": "ea017aed6616af00f3c4d59e3d945c8c3e47adca", 870 | "name": "Green", 871 | "styleType": "FILL", 872 | "description": "" 873 | }, 874 | "388:0": { 875 | "key": "3b6c1841f2c88dd0d5deff6766852127675a6369", 876 | "name": "drop shadow", 877 | "styleType": "EFFECT", 878 | "description": "" 879 | }, 880 | "1:11": { 881 | "key": "e234400b962ffafce654af9b3220ce88857523ec", 882 | "name": "Red", 883 | "styleType": "FILL", 884 | "description": "" 885 | }, 886 | "387:0": { 887 | "key": "c95a769d3d480636cf66d18f475b3a6fb6eb03fe", 888 | "name": "Blue", 889 | "styleType": "FILL", 890 | "description": "" 891 | }, 892 | "97:6": { 893 | "key": "cc806814e1b9b7d20ce0b6bed8adf52099899c01", 894 | "name": "Body", 895 | "styleType": "TEXT", 896 | "description": "" 897 | } 898 | }, 899 | "name": "figma-graphql test file", 900 | "lastModified": "2020-06-28T13:21:09.007369Z", 901 | "thumbnailUrl": "https://s3-alpha-sig.figma.com/thumbnails/bfca1972-b085-422d-a3bb-98804917f8ab?Expires=1593993600&Signature=Hil~XV7irTJk1TbOTofE8tNKxs6nXM3s~YaQG0dOzLDicLA64tGbWzxiKuZUDIVz4ECom2xuh2WP-ngXor2R6TUqcWy0xjis8eiCJLSZ9qtEBtu1MZ4hhzyjyLDZ5AtNvPhmDlDYqE955ip5LGfUuAsbqWQithqjorHHy2tYzMdcYTIiT0YwlPgF2qDkVl2OFR4R93Ji-DbbeQj5-m2LUoV8PNCw~zC7C0pwPcXp1kNEQ3l6GXBvADf3CI28b3HcEPQwLVAzrYyvqse6lKOnZY3i7FNVqQqy5~gOkszH3YNHZfhMq9aaEf9WmpjZwo6s6YNxkSo4gwnHzAgFhcV24Q__&Key-Pair-Id=APKAINTVSUGEWH5XD5UA", 902 | "version": "371462163", 903 | "role": "owner" 904 | } 905 | -------------------------------------------------------------------------------- /src/__tests__/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { Node } from "figma-js"; 2 | import { uniqBy, groupBy, groupNodes } from "../utils"; 3 | 4 | describe("uniqBy", () => { 5 | it("should return unique values by key", () => { 6 | const nodes = [ 7 | { name: "node 1", id: "1", type: "VECTOR" }, 8 | { name: "node 2", id: "2", type: "VECTOR" }, 9 | { name: "node 2", id: "3", type: "VECTOR" }, 10 | ]; 11 | 12 | expect(uniqBy(nodes, "id")).toHaveLength(3); 13 | expect(uniqBy(nodes, "name")).toHaveLength(2); 14 | expect(uniqBy(nodes, "type")).toHaveLength(1); 15 | }); 16 | 17 | it("should handle undefined array", () => { 18 | const nodes: any[] = []; 19 | 20 | expect(uniqBy(nodes, "id")).toHaveLength(0); 21 | }); 22 | }); 23 | 24 | describe("groupBy", () => { 25 | it("should group values by key", () => { 26 | const nodes = [ 27 | { name: "node 1", id: "1", type: "VECTOR" }, 28 | { name: "node 2", id: "2", type: "VECTOR" }, 29 | { name: "node 2", id: "3", type: "VECTOR" }, 30 | ]; 31 | 32 | const groupById = groupBy(nodes, "id"); 33 | expect(Object.keys(groupById)).toHaveLength(3); 34 | Object.values(groupById).forEach(val => { 35 | expect(val).toHaveLength(1); 36 | }); 37 | 38 | const groupByName = groupBy(nodes, "name"); 39 | expect(Object.keys(groupByName)).toHaveLength(2); 40 | expect(groupByName["node 1"]).toHaveLength(1); 41 | expect(groupByName["node 2"]).toHaveLength(2); 42 | 43 | const groupByType = groupBy(nodes, "type"); 44 | expect(Object.keys(groupByType)).toHaveLength(1); 45 | expect(groupByType.VECTOR).toHaveLength(3); 46 | }); 47 | }); 48 | 49 | describe("groupNodes", () => { 50 | it("should group unique id nodes by type", () => { 51 | const nodes = [ 52 | { name: "node 1", id: "1", type: "VECTOR" }, 53 | { name: "node 2", id: "2", type: "VECTOR" }, 54 | { name: "node 2", id: "3", type: "VECTOR" }, 55 | { name: "node 4", id: "2", type: "VECTOR" }, 56 | { name: "node 5", id: "5", type: "RECTANGLE" }, 57 | ] as Node[]; 58 | 59 | const groupedNodes = groupNodes(nodes); 60 | 61 | expect(groupedNodes.vectors).toHaveLength(3); 62 | expect(groupedNodes.rectangles).toHaveLength(1); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Document, 3 | FileResponse, 4 | Style, 5 | ComponentMetadata, 6 | Node, 7 | } from "figma-js"; 8 | import { groupNodes } from "./utils"; 9 | import { ProcessedFile } from "./types"; 10 | 11 | export function processFile(data: FileResponse, id?: string): ProcessedFile { 12 | const { 13 | name, 14 | lastModified, 15 | thumbnailUrl, 16 | version, 17 | document, 18 | styles, 19 | components, 20 | } = data; 21 | 22 | const [processedNodes, processedShortcuts] = processNodes( 23 | document, 24 | styles, 25 | components, 26 | id 27 | ); 28 | 29 | return { 30 | fileId: id, 31 | name, 32 | lastModified, 33 | thumbnailUrl, 34 | version, 35 | children: processedNodes[0].children, 36 | shortcuts: groupNodes(processedShortcuts), 37 | }; 38 | } 39 | 40 | export function processNodes( 41 | nodes: Document, 42 | documentStyles: { [key: string]: Style }, 43 | components: { [key: string]: ComponentMetadata }, 44 | fileId?: string 45 | ) { 46 | const parsedStyles = new Map(Object.entries(documentStyles)); 47 | const parsedComponents = new Map(Object.entries(components)); 48 | 49 | const traverseChildren = (node: any, parentId: string) => { 50 | const { id, styles, children, ...rest } = node; 51 | let nodeStyles: any[] = []; 52 | 53 | // If node has styles definitions populate that with the actual styles 54 | if (styles != null) { 55 | nodeStyles = Object.entries(styles).map(([key, styleId]) => { 56 | const documentStyle = parsedStyles.get(styleId as string); 57 | 58 | return { 59 | id: styleId, 60 | ...documentStyle, 61 | styles: node[`${key}s`], 62 | textStyles: node.style, 63 | type: "STYLE", 64 | }; 65 | }); 66 | } 67 | 68 | // Reached a leaf so returning the simplified node 69 | if (children == null || children.length === 0) { 70 | return [[{ id, parentId, fileId, ...rest }], nodeStyles]; 71 | } 72 | 73 | // If it gets here then it means it has children 74 | // so we're going to recursively go through them 75 | // and combine everything 76 | const [parsedChildren, shortcuts] = children.reduce( 77 | (acc: [Node[], Node[]], child: Node) => { 78 | const [accChildren, accShortcuts] = acc; 79 | const [tChildren, tShortcuts] = traverseChildren(child, id); 80 | return [ 81 | [...accChildren, ...tChildren], 82 | [...accShortcuts, ...tChildren, ...tShortcuts], 83 | ]; 84 | }, 85 | [[], []] 86 | ); 87 | 88 | const componentInfo = parsedComponents.get(id); 89 | 90 | // Finally we return the parsed node with the 91 | // parsed children grouped by type 92 | const parsedNode = { 93 | id, 94 | parentId, 95 | fileId, 96 | ...rest, 97 | ...(componentInfo && componentInfo), 98 | children: parsedChildren, 99 | shortcuts: groupNodes(shortcuts), 100 | }; 101 | 102 | return [[parsedNode], shortcuts]; 103 | }; 104 | 105 | return traverseChildren(nodes, "0:0"); 106 | } 107 | 108 | export * from "./types"; 109 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Node, 3 | NodeType, 4 | Component, 5 | Canvas, 6 | Instance, 7 | Line, 8 | Frame, 9 | Group, 10 | Vector, 11 | Star, 12 | Ellipse, 13 | BooleanGroup, 14 | RegularPolygon, 15 | Rectangle, 16 | Text, 17 | Slice, 18 | Style, 19 | ComponentMetadata, 20 | Paint, 21 | TypeStyle, 22 | } from "figma-js"; 23 | 24 | export type ShortcutType = NodeType | "STYLE"; 25 | 26 | export type NodeWithShortcuts = T & { shortcuts?: Shortcuts }; 27 | export type StyleNode = Style & Node & { styles: Paint; textStyles: TypeStyle }; 28 | export type Shortcuts = Record< 29 | "components", 30 | NodeWithShortcuts[] 31 | > & 32 | Record<"pages", NodeWithShortcuts[]> & 33 | Record<"lines", NodeWithShortcuts[]> & 34 | Record<"instances", NodeWithShortcuts[]> & 35 | Record<"frames", NodeWithShortcuts[]> & 36 | Record<"groups", NodeWithShortcuts[]> & 37 | Record<"vectors", NodeWithShortcuts[]> & 38 | Record<"booleans", NodeWithShortcuts[]> & 39 | Record<"stars", NodeWithShortcuts[]> & 40 | Record<"ellipses", NodeWithShortcuts[]> & 41 | Record<"regularPolygons", NodeWithShortcuts[]> & 42 | Record<"rectangles", NodeWithShortcuts[]> & 43 | Record<"texts", NodeWithShortcuts[]> & 44 | Record<"slices", NodeWithShortcuts[]> & 45 | Record<"styles", StyleNode[]>; 46 | 47 | export type ProcessedFile = { 48 | fileId?: string; 49 | name: string; 50 | lastModified: string; 51 | thumbnailUrl: string; 52 | version: string; 53 | children: any; 54 | shortcuts: Shortcuts; 55 | }; 56 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { Node } from "figma-js"; 2 | import { Shortcuts, ShortcutType } from "./types"; 3 | 4 | export function uniqBy(arr: any[], key: string) { 5 | const set = new Set(); 6 | return arr.filter(el => (v => !set.has(v) && set.add(v))(el[key])); 7 | } 8 | 9 | export function groupBy(arr: any[], key: string) { 10 | return arr.reduce(function(rv, x) { 11 | (rv[x[key]] = rv[x[key]] || []).push(x); 12 | return rv; 13 | }, {}); 14 | } 15 | 16 | export const groupNodes = (nodes: Node[]): Shortcuts => 17 | parseShortcutKeys(groupBy(uniqBy(nodes, "id"), "type")); 18 | 19 | export const parseShortcutKeys = (obj: Record): Shortcuts => { 20 | const mapKeys: Record = { 21 | DOCUMENT: "documents", 22 | COMPONENT: "components", 23 | CANVAS: "pages", 24 | LINE: "lines", 25 | INSTANCE: "instances", 26 | FRAME: "frames", 27 | GROUP: "groups", 28 | VECTOR: "vectors", 29 | BOOLEAN: "booleans", 30 | STAR: "stars", 31 | ELLIPSE: "ellipses", 32 | REGULAR_POLYGON: "regularPolygon", 33 | RECTANGLE: "rectangles", 34 | TEXT: "texts", 35 | SLICE: "slices", 36 | STYLE: "styles", 37 | }; 38 | 39 | return Object.fromEntries( 40 | Object.entries(obj).map(([k, v]) => [mapKeys[k as ShortcutType], v]) 41 | ) as Shortcuts; 42 | }; 43 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "compilerOptions": { 4 | "target": "es5", 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "./src", 11 | "strict": true, 12 | "noImplicitAny": true, 13 | "strictNullChecks": true, 14 | "strictFunctionTypes": true, 15 | "strictPropertyInitialization": true, 16 | "noImplicitThis": true, 17 | "alwaysStrict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noImplicitReturns": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "moduleResolution": "node", 23 | "baseUrl": "./", 24 | "jsx": "react", 25 | "esModuleInterop": true, 26 | "resolveJsonModule": true 27 | } 28 | } 29 | --------------------------------------------------------------------------------