├── src ├── utils │ ├── index.ts │ ├── utils.test.ts │ └── utils.ts ├── logger │ ├── index.ts │ └── logger.ts ├── toolkits │ ├── index.ts │ └── figma │ │ ├── index.ts │ │ ├── helpers.ts │ │ ├── client.ts │ │ └── typings │ │ └── index.d.ts ├── base │ ├── rule │ │ ├── index.ts │ │ └── abstract-rule.ts │ ├── index.ts │ └── walker │ │ ├── index.ts │ │ ├── rule-walker.ts │ │ └── abstract-walker.ts ├── index.ts ├── cli.ts ├── rules │ ├── must-be-in-frame.ts │ ├── duplicate-component.ts │ ├── a11y-contrast-ratio.ts │ └── prefer-local-style │ │ ├── helpers.ts │ │ └── index.ts └── dslint.ts ├── .gitignore ├── screenshots └── screenshot-1.png ├── .prettierrc ├── tsconfig.json ├── typings ├── nearest-color.d.ts └── index.d.ts ├── LICENSE ├── package.json └── README.md /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './utils'; 2 | -------------------------------------------------------------------------------- /src/logger/index.ts: -------------------------------------------------------------------------------- 1 | export * from './logger'; 2 | -------------------------------------------------------------------------------- /src/toolkits/index.ts: -------------------------------------------------------------------------------- 1 | export * from './figma'; 2 | -------------------------------------------------------------------------------- /src/base/rule/index.ts: -------------------------------------------------------------------------------- 1 | export * from './abstract-rule'; 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | .DS_Store 3 | dist/ 4 | node_modules/ 5 | .vscode/ -------------------------------------------------------------------------------- /src/base/index.ts: -------------------------------------------------------------------------------- 1 | export * from './rule'; 2 | export * from './walker'; 3 | -------------------------------------------------------------------------------- /src/toolkits/figma/index.ts: -------------------------------------------------------------------------------- 1 | export * from './client'; 2 | export * from './helpers'; 3 | -------------------------------------------------------------------------------- /src/base/walker/index.ts: -------------------------------------------------------------------------------- 1 | export * from './abstract-walker'; 2 | export * from './rule-walker'; 3 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './base'; 2 | export * from './dslint'; 3 | export * from './utils'; 4 | -------------------------------------------------------------------------------- /screenshots/screenshot-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vutran/dslint/HEAD/screenshots/screenshot-1.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": false, 3 | "singleQuote": true, 4 | "trailingComma": "es5", 5 | "bracketSpacing": false 6 | } 7 | -------------------------------------------------------------------------------- /src/toolkits/figma/helpers.ts: -------------------------------------------------------------------------------- 1 | export function toRGB(color: Figma.Property.Color) { 2 | return { 3 | r: color.r * 255, 4 | g: color.g * 255, 5 | b: color.b * 255, 6 | }; 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "lib": ["es2015"], 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "noImplicitAny": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /typings/nearest-color.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'nearest-color' { 2 | interface RGB { 3 | r: number; 4 | g: number; 5 | b: number; 6 | } 7 | interface Color { 8 | name: string; 9 | value: string; 10 | rgb: RGB; 11 | distance: number; 12 | } 13 | 14 | type GetColorFn = (color: RGB) => Color; 15 | 16 | export const nearestColor: any; 17 | export const from: (colors: any) => GetColorFn; 18 | } 19 | -------------------------------------------------------------------------------- /src/base/rule/abstract-rule.ts: -------------------------------------------------------------------------------- 1 | export abstract class AbstractRule implements DSLint.Rules.AbstractRule { 2 | public ruleDidLoad( 3 | file: Figma.File, 4 | client: Figma.Client.Client, 5 | config: DSLint.Configuration 6 | ): void {} 7 | 8 | public abstract apply( 9 | file: Figma.File, 10 | config: DSLint.Configuration 11 | ): DSLint.Rules.Failure[]; 12 | 13 | public applyWithWalker(walker: DSLint.RuleWalker): DSLint.Rules.Failure[] { 14 | walker.walk(walker.getNode()); 15 | return walker.getAllFailures(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/utils.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import {getAllRules} from './'; 3 | 4 | describe('getAllRules', () => { 5 | const config: DSLint.Configuration = {fileKey: ''}; 6 | const options: any = {}; // TODO(vutran) 7 | const rulesPath = path.resolve(__dirname, '..', 'rules'); 8 | const rules = getAllRules([rulesPath], config, options); 9 | const ruleNames = rules.map(rule => rule.metadata.ruleName); 10 | 11 | it('should have the prefer-local-style rule', () => { 12 | expect(ruleNames).toContain('prefer-local-style'); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/base/walker/rule-walker.ts: -------------------------------------------------------------------------------- 1 | import {AbstractWalker} from './'; 2 | 3 | export abstract class RuleWalker extends AbstractWalker 4 | implements DSLint.RuleWalker { 5 | options: T & DSLint.RuleWalkerOptions; 6 | failures: DSLint.Rules.Failure[]; 7 | 8 | constructor(node: Figma.Node, options?: T & DSLint.RuleWalkerOptions) { 9 | super(node, options); 10 | this.failures = []; 11 | } 12 | 13 | public getRuleName() { 14 | return this.options.ruleName; 15 | } 16 | 17 | public addFailure(failure: DSLint.Rules.AddFailure) { 18 | const {ruleName} = this.options; 19 | this.failures.push({ruleName, ...failure}); 20 | } 21 | 22 | public getAllFailures() { 23 | return this.failures; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import {dslint} from './dslint'; 4 | import {getConfig, getCoreRulesPath} from './utils'; 5 | import {logResults} from './logger'; 6 | 7 | const FIGMA_TOKEN = process.env.FIGMA_TOKEN || ''; 8 | if (!FIGMA_TOKEN) { 9 | throw new Error('Missing Figma Token'); 10 | } 11 | 12 | async function main() { 13 | const config = getConfig(); 14 | const rulesPath = getCoreRulesPath(); 15 | const startTime = Date.now(); 16 | const failures = await dslint( 17 | config.fileKey, 18 | FIGMA_TOKEN, 19 | [rulesPath], 20 | config 21 | ); 22 | const endTime = Date.now(); 23 | const diffTime = endTime - startTime; 24 | logResults(config.fileKey, failures, diffTime); 25 | } 26 | 27 | main(); 28 | -------------------------------------------------------------------------------- /src/rules/must-be-in-frame.ts: -------------------------------------------------------------------------------- 1 | import {AbstractRule} from '../base/rule'; 2 | import {RuleWalker} from '../base/walker'; 3 | 4 | /** 5 | * All drawable nodes must be in a frame. 6 | */ 7 | export class Rule extends AbstractRule { 8 | static metadata: DSLint.Rules.Metadata = { 9 | ruleName: 'must-be-in-frame', 10 | description: 'All drawable nodes must be in a frame.', 11 | }; 12 | 13 | apply(file: Figma.File): DSLint.Rules.Failure[] { 14 | const ruleName = Rule.metadata.ruleName; 15 | return this.applyWithWalker(new InFrameWalker(file.document, {ruleName})); 16 | } 17 | } 18 | 19 | class InFrameWalker extends RuleWalker { 20 | visitCanvas(node: Figma.Node & Figma.Mixins.Children) { 21 | node.children.forEach(child => { 22 | // Assert that non-FRAME children are drawable nodes (vector, text, etc.) 23 | if (child.type !== 'FRAME') { 24 | this.addFailure({ 25 | location: child.id, 26 | node: child, 27 | message: `Expected "${child.name}" to be in a Frame`, 28 | }); 29 | } 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import mri from 'mri'; 4 | 5 | export function getConfig(): DSLint.Configuration { 6 | const argv = process.argv.slice(2); 7 | const args = mri(argv); 8 | return { 9 | fileKey: args._.join(''), 10 | teamId: args.teamId, 11 | }; 12 | } 13 | 14 | /** 15 | * Returns the path to the core rules 16 | */ 17 | export function getCoreRulesPath() { 18 | return path.resolve(__dirname, '..', 'rules'); 19 | } 20 | 21 | /** 22 | * Returns a list of Rules in the given directories 23 | */ 24 | export function getAllRules( 25 | rulesPaths: string[], 26 | config: DSLint.Configuration, 27 | options: DSLint.RuleLoaderOptions 28 | ): DSLint.Rules.RuleClass[] { 29 | return rulesPaths.reduce((acc, next) => { 30 | const st = fs.statSync(next); 31 | if (st.isDirectory) { 32 | const dirFiles = fs.readdirSync(next); 33 | dirFiles.forEach(async file => { 34 | const f = path.resolve(next, file); 35 | const rule = require(f).Rule; 36 | acc.push(rule); 37 | }); 38 | } 39 | return acc; 40 | }, []); 41 | } 42 | -------------------------------------------------------------------------------- /src/toolkits/figma/client.ts: -------------------------------------------------------------------------------- 1 | import got from 'got'; 2 | 3 | export class Client implements Figma.Client.Client { 4 | private options: Figma.Client.Options; 5 | private headers: Figma.Client.Headers; 6 | 7 | public constructor(options: Figma.Client.Options) { 8 | this.options = options; 9 | 10 | this.headers = options.bearerAccessToken 11 | ? {Authorization: `Bearer ${options.bearerAccessToken}`} 12 | : {'X-Figma-Token': options.personalAccessToken}; 13 | } 14 | 15 | public get(endpoint: string, options?: got.GotJSONOptions) { 16 | const url = `https://api.figma.com/v1/${endpoint.replace(/^\//, '')}`; 17 | return got( 18 | url, 19 | Object.assign({}, options, {json: true, headers: this.headers}) 20 | ); 21 | } 22 | 23 | public file(key: string): got.GotPromise { 24 | return this.get(`/files/${key}`); 25 | } 26 | 27 | public fileNodes(key: string): got.GotPromise { 28 | return this.get(`/files/${key}/nodes`); 29 | } 30 | 31 | public styles(key: string): got.GotPromise { 32 | return this.get(`/styles/${key}`); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Vu Tran 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. 22 | -------------------------------------------------------------------------------- /src/logger/logger.ts: -------------------------------------------------------------------------------- 1 | import kleur from 'kleur'; 2 | import wcwidth from 'wcwidth'; 3 | 4 | const COLUMN_PADDING = 3; 5 | 6 | export function logError(fileKey: string, failure: DSLint.Rules.Failure) { 7 | console.error( 8 | kleur.gray(failure.location), 9 | failure.message, 10 | kleur.gray(failure.ruleName) 11 | ); 12 | } 13 | 14 | export function logResults( 15 | fileKey: string, 16 | failures: DSLint.Rules.Failure[], 17 | timeSpent: number 18 | ) { 19 | if (failures.length > 0) { 20 | const maxIdLen = Math.max(...failures.map(f => f.location.length)); 21 | const maxMsgLen = Math.max(...failures.map(f => f.message.length)); 22 | 23 | failures 24 | .map(f => ({ 25 | ...f, 26 | location: f.location.padEnd(maxIdLen + COLUMN_PADDING, ' '), 27 | message: f.message.padEnd(maxMsgLen + COLUMN_PADDING, ' '), 28 | })) 29 | .forEach(f => logError(fileKey, f)); 30 | console.error(''); 31 | console.error(`${kleur.red(`Total errors: ${failures.length}`)}`); 32 | console.error(`${kleur.red(`Time spent: ${timeSpent / 1000}s`)}`); 33 | } else { 34 | console.error(''); 35 | console.log('No errors.'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/dslint.ts: -------------------------------------------------------------------------------- 1 | import {getAllRules} from './utils'; 2 | import {Client} from './toolkits/figma'; 3 | 4 | export function lint( 5 | file: Figma.File, 6 | rules: DSLint.Rules.AbstractRule[], 7 | config?: DSLint.Configuration 8 | ): DSLint.Rules.Failure[] { 9 | let failures: DSLint.Rules.Failure[] = []; 10 | 11 | rules.forEach(rule => { 12 | failures = failures.concat(rule.apply(file, config)); 13 | }); 14 | 15 | return failures; 16 | } 17 | 18 | export async function dslint( 19 | fileKey: string, 20 | personalAccessToken: string, 21 | rulesPaths: string[], 22 | config: DSLint.Configuration 23 | ): Promise { 24 | try { 25 | const client = new Client({personalAccessToken}); 26 | const file = (await client.file(fileKey)).body; 27 | const rulesCtors = getAllRules(rulesPaths, config, {client, file}); 28 | const rules = await Promise.all( 29 | rulesCtors.map(async r => { 30 | const ruleInstance = new r(); 31 | if (typeof ruleInstance.ruleDidLoad === 'function') { 32 | await ruleInstance.ruleDidLoad(file, client, config); 33 | } 34 | return ruleInstance; 35 | }) 36 | ); 37 | return lint(file, rules, config); 38 | } catch (err) { 39 | console.trace(err); 40 | } 41 | return []; 42 | } 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dslint", 3 | "version": "0.0.2", 4 | "description": "", 5 | "main": "dist/index.js", 6 | "typings": "typings/index.d.ts", 7 | "bin": { 8 | "dslint": "./dist/cli.js" 9 | }, 10 | "files": [ 11 | "LICENSE", 12 | "README.md", 13 | "dist", 14 | "typings" 15 | ], 16 | "scripts": { 17 | "clean": "rm -rf ./dist", 18 | "build": "tsc --outDir dist --rootDir src", 19 | "build:clean": "npm run clean && npm run build", 20 | "build:watch": "npm run build -- --watch", 21 | "test": "jest src/", 22 | "test:watch": "npm t -- --watch" 23 | }, 24 | "author": "Vu Tran ", 25 | "license": "MIT", 26 | "devDependencies": { 27 | "@types/got": "^9.4.1", 28 | "@types/jest": "^24.0.11", 29 | "@types/mri": "^1.1.0", 30 | "@types/node": "^11.12.0", 31 | "@types/wcwidth": "^1.0.0", 32 | "husky": "^1.3.1", 33 | "jest": "^24.5.0", 34 | "lint-staged": "^8.1.5", 35 | "prettier": "^1.16.4", 36 | "ts-jest": "^26.1.2", 37 | "typescript": "^3.3.4000" 38 | }, 39 | "husky": { 40 | "hooks": { 41 | "pre-commit": "lint-staged" 42 | } 43 | }, 44 | "jest": { 45 | "preset": "ts-jest", 46 | "testEnvironment": "node" 47 | }, 48 | "lint-staged": { 49 | "*.{ts,json,md}": [ 50 | "prettier --write", 51 | "git add" 52 | ] 53 | }, 54 | "dependencies": { 55 | "contrast-ratio": "^1.1.0", 56 | "got": "^9.6.0", 57 | "kleur": "^4.0.2", 58 | "mri": "^1.1.4", 59 | "nearest-color": "^0.4.4", 60 | "wcwidth": "^1.0.1" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/rules/duplicate-component.ts: -------------------------------------------------------------------------------- 1 | import {AbstractRule} from '../base/rule'; 2 | import {RuleWalker} from '../base/walker'; 3 | 4 | /** 5 | * Ensures there are no duplicate named components. 6 | */ 7 | export class Rule extends AbstractRule { 8 | static metadata: DSLint.Rules.Metadata = { 9 | ruleName: 'duplicate-component', 10 | description: 'Disallows duplicate component names.', 11 | }; 12 | 13 | apply(file: Figma.File) { 14 | const ruleName = Rule.metadata.ruleName; 15 | return this.applyWithWalker( 16 | new ComponentWalker(file.document, {ruleName, file}) 17 | ); 18 | } 19 | } 20 | 21 | interface ComponentWalkerOptions { 22 | file: Figma.File; 23 | } 24 | 25 | class ComponentWalker extends RuleWalker { 26 | // Map of component name -> list of tuples (component name, and id) 27 | count: Map; 28 | 29 | visitDocument(node: Figma.Nodes.Document) { 30 | const {file} = this.options; 31 | this.count = new Map(); 32 | 33 | Object.entries(file.components).forEach(([cId, c]) => { 34 | const name = c.name.toLowerCase(); 35 | const n = this.count.get(name) || []; 36 | this.count.set(name, [...n, [c.name, cId]]); 37 | }); 38 | 39 | this.count.forEach(components => { 40 | if (components.length > 1) { 41 | components.forEach(component => { 42 | this.addFailure({ 43 | location: component[1], 44 | message: `Duplicate component name "${component[0]}".`, 45 | }); 46 | }); 47 | } 48 | }); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DSLint 2 | 3 | > DSLint is an extensible linting tool for designers. Similar to code linting, design linting can be used to find problematic patterns in your design files. 4 | 5 | ![Figma](./screenshots/screenshot-1.png?raw=true) 6 | 7 | ## Install 8 | 9 | If you'd like to use the CLI version, you can instally it globally: 10 | 11 | ```bash 12 | $ npm i -g dslint 13 | ``` 14 | 15 | If you'd like to use the JavaScript API for your own applications, you can install it as a dependency: 16 | 17 | ```bash 18 | $ npm i -S dslint 19 | ``` 20 | 21 | ## CLI Usage 22 | 23 | ### Environmental Variables 24 | 25 | - `FIGMA_TOKEN` - A personal access token from Figma API 26 | 27 | Basic usage: 28 | 29 | ```bash 30 | 31 | $ dslint abcdefg1234567890 32 | ``` 33 | 34 | ## JavaScript API 35 | 36 | Linting a file 37 | 38 | ```ts 39 | import {dslint, getCoreRulesPath} from 'dslint'; 40 | 41 | const fileKey = 'abcdefg1234567890'; 42 | const token = 'my-figma-token'; 43 | 44 | const rulesPaths = [ 45 | // optionally include the core set of rules already provided 46 | getCoreRulesPath(), 47 | // optionally add more rules directory 48 | path.resolve(__dirname, './rules'), 49 | ]; 50 | 51 | dslint(fileKey, token, rulesPaths).then(failures => { 52 | console.log(failures); 53 | }); 54 | ``` 55 | 56 | Linting an object tree 57 | 58 | ```ts 59 | import {lint} from 'dslint'; 60 | 61 | // Figma.File 62 | const file = { ... }; 63 | 64 | // DSLint.Rules.AbstractRule[] 65 | const rules = [ ... ]; 66 | 67 | const failures = lint(file, rules); 68 | ``` 69 | 70 | ## Writing a Custom Lint Rule 71 | 72 | DSLint ships with some basic rules you can apply to your design systems. However, these rules may not account for some of the best practices your team follows. DSLint was written to allow you to extend the system with your own custom rules which can be written in JavaScript. See below for a TypeScript example. 73 | 74 | ### Requirements 75 | 76 | - The exported module should be a class named `Rule`. 77 | - All rules should extend the `AbstractRule` class. 78 | - All rules must implement the `apply()` method that return a list of failures. 79 | 80 | ```ts 81 | import {AbstractRule, RuleWalker} from 'dslint'; 82 | 83 | /** 84 | * Simple rule that detects for component nodes. 85 | */ 86 | export class Rule extends AbstractRule { 87 | static metadata = { 88 | ruleName: 'my-custom-rule', 89 | description: 'Logs when a component is detected.', 90 | }; 91 | 92 | apply(file: Figma.File): DSLint.Rules.Failure[] { 93 | const ruleName = Rule.metadata.ruleName; 94 | return this.applyWithWalker(new ComponentWalker(file.document, {ruleName})); 95 | } 96 | } 97 | 98 | class ComponentWalker extends RuleWalker { 99 | visitComponent(node: Figma.Nodes.Component) { 100 | this.addFailure({ 101 | location: node.id, 102 | message: `Component detected: ${node.name}`, 103 | }); 104 | } 105 | } 106 | ``` 107 | -------------------------------------------------------------------------------- /src/rules/a11y-contrast-ratio.ts: -------------------------------------------------------------------------------- 1 | import {AbstractRule} from '../base/rule'; 2 | import contrastRatio from 'contrast-ratio'; 3 | import {RuleWalker} from '../base/walker'; 4 | import {toRGB} from '../toolkits/figma'; 5 | 6 | /** 7 | * Check if a component complies to the WCAG 2.0 contrast ratio of 4.5:1 8 | */ 9 | export class Rule extends AbstractRule { 10 | static metadata = { 11 | ruleName: 'a11y-contrast-ratio', 12 | description: 13 | 'Check if a component complies to the WCAG 2.0 contrast ratio of 4.5:1.', 14 | }; 15 | 16 | apply(file: Figma.File): DSLint.Rules.Failure[] { 17 | const ruleName = Rule.metadata.ruleName; 18 | return this.applyWithWalker(new ComponentWalker(file.document, {ruleName})); 19 | } 20 | } 21 | 22 | class ComponentWalker extends RuleWalker { 23 | fg: DSLint.Color[]; 24 | bg: DSLint.Color[]; 25 | 26 | constructor(node: Figma.Node, options: DSLint.RuleWalkerOptions) { 27 | super(node, options); 28 | this.bg = []; 29 | this.fg = []; 30 | } 31 | 32 | private getFills(node: Figma.Node & Figma.Mixins.Fills) { 33 | return node.fills 34 | .filter(fill => fill.type === 'SOLID') 35 | .map(fill => fill.color); 36 | } 37 | 38 | private addBg(fills: Figma.Property.Color[]) { 39 | fills.forEach(fill => { 40 | this.bg.push(toRGB(fill)); 41 | }); 42 | } 43 | 44 | private addFg(fills: Figma.Property.Color[]) { 45 | fills.forEach(fill => { 46 | this.fg.push(toRGB(fill)); 47 | }); 48 | } 49 | 50 | private check(node: Figma.Node, fgs: DSLint.Color[], bgs: DSLint.Color[]) { 51 | if (fgs.length === 0 || bgs.length === 0) { 52 | return; 53 | } 54 | 55 | fgs.forEach(fg => { 56 | bgs.forEach(bg => { 57 | const ratio = contrastRatio([fg.r, fg.g, fg.b], [bg.r, bg.g, bg.b]); 58 | const passed = ratio > 4.5; 59 | if (!passed) { 60 | this.addFailure({ 61 | location: node.id, 62 | node, 63 | message: `Contrast ratio does not meet 4.5:1 for "${node.name}"`, 64 | }); 65 | } 66 | }); 67 | }); 68 | } 69 | 70 | visitComponent(node: Figma.Nodes.Component) { 71 | // Ensure we start over each time we visit a new component 72 | this.bg = []; 73 | this.fg = []; 74 | // Calls the super method to walk children to collect child fills before running our checks 75 | super.visitComponent(node); 76 | this.check(node, this.fg, this.bg); 77 | } 78 | 79 | // FOREGROUND 80 | 81 | visitText(node: Figma.Nodes.Text) { 82 | const fills = this.getFills(node); 83 | this.addFg(fills); 84 | } 85 | 86 | // BACKGROUND 87 | 88 | visitRectangle(node: Figma.Nodes.Rectangle) { 89 | const fills = this.getFills(node); 90 | this.addBg(fills); 91 | } 92 | 93 | visitEllipse(node: Figma.Nodes.Ellipse) { 94 | const fills = this.getFills(node); 95 | this.addBg(fills); 96 | } 97 | 98 | visitLine(node: Figma.Nodes.Line) { 99 | const fills = this.getFills(node); 100 | this.addBg(fills); 101 | } 102 | 103 | visitVector(node: Figma.Nodes.Vector) { 104 | const fills = this.getFills(node); 105 | this.addBg(fills); 106 | } 107 | 108 | visitStar(node: Figma.Nodes.Star) { 109 | const fills = this.getFills(node); 110 | this.addBg(fills); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/base/walker/abstract-walker.ts: -------------------------------------------------------------------------------- 1 | export abstract class AbstractWalker implements DSLint.Walker { 2 | node: Figma.Node; 3 | options: DSLint.WalkerOptions; 4 | 5 | constructor(node: Figma.Node, options?: DSLint.WalkerOptions) { 6 | this.node = node; 7 | this.options = options; 8 | } 9 | 10 | public getNode() { 11 | return this.node; 12 | } 13 | 14 | public visit(node: Figma.Node) { 15 | switch (node.type) { 16 | case 'DOCUMENT': 17 | this.visitDocument(node); 18 | break; 19 | case 'CANVAS': 20 | this.visitCanvas(node as Figma.Nodes.Canvas); 21 | break; 22 | case 'FRAME': 23 | this.visitFrame(node as Figma.Nodes.Frame); 24 | break; 25 | case 'GROUP': 26 | this.visitGroup(node as Figma.Nodes.Group); 27 | break; 28 | case 'BOOLEAN_OPERATION': 29 | this.visitBooleanOperation(node as Figma.Nodes.BooleanOperation); 30 | break; 31 | case 'STAR': 32 | this.visitStar(node as Figma.Nodes.Star); 33 | break; 34 | case 'LINE': 35 | this.visitLine(node as Figma.Nodes.Line); 36 | break; 37 | case 'ELLIPSE': 38 | this.visitEllipse(node as Figma.Nodes.Ellipse); 39 | break; 40 | case 'REGULAR_POLYGON': 41 | this.visitRegularPolygon(node as Figma.Nodes.RegularPolygon); 42 | break; 43 | case 'RECTANGLE': 44 | this.visitRectangle(node as Figma.Nodes.Rectangle); 45 | break; 46 | case 'TEXT': 47 | this.visitText(node as Figma.Nodes.Text); 48 | break; 49 | case 'SLICE': 50 | this.visitSlice(node as Figma.Nodes.Slice); 51 | break; 52 | case 'COMPONENT': 53 | this.visitComponent(node as Figma.Nodes.Component); 54 | break; 55 | case 'INSTANCE': 56 | this.visitInstance(node as Figma.Nodes.Instance); 57 | break; 58 | } 59 | } 60 | 61 | public visitDocument(node: Figma.Nodes.Document) { 62 | this.walkChildren(node); 63 | } 64 | 65 | public visitCanvas(node: Figma.Nodes.Canvas) { 66 | this.walkChildren(node); 67 | } 68 | 69 | public visitFrame(node: Figma.Nodes.Frame) { 70 | this.walkChildren(node); 71 | } 72 | 73 | public visitGroup(node: Figma.Nodes.Group) { 74 | this.walkChildren(node); 75 | } 76 | 77 | public visitBooleanOperation(node: Figma.Nodes.BooleanOperation) { 78 | this.walkChildren(node); 79 | } 80 | 81 | public visitStar(node: Figma.Nodes.Star) { 82 | this.walkChildren(node); 83 | } 84 | 85 | public visitLine(node: Figma.Nodes.Line) { 86 | this.walkChildren(node); 87 | } 88 | 89 | public visitEllipse(node: Figma.Nodes.Ellipse) { 90 | this.walkChildren(node); 91 | } 92 | 93 | public visitRegularPolygon(node: Figma.Nodes.RegularPolygon) { 94 | this.walkChildren(node); 95 | } 96 | 97 | public visitRectangle(node: Figma.Nodes.Rectangle) { 98 | this.walkChildren(node); 99 | } 100 | 101 | public visitText(node: Figma.Nodes.Text) { 102 | this.walkChildren(node); 103 | } 104 | 105 | public visitSlice(node: Figma.Nodes.Slice) { 106 | this.walkChildren(node); 107 | } 108 | 109 | public visitComponent(node: Figma.Nodes.Component) { 110 | this.walkChildren(node); 111 | } 112 | 113 | public visitInstance(node: Figma.Nodes.Component) { 114 | this.walkChildren(node); 115 | } 116 | 117 | public walk(node: Figma.Node) { 118 | this.visit(node); 119 | } 120 | 121 | public walkChildren(node: Figma.Node & Figma.Mixins.Children) { 122 | if (node.children) { 123 | node.children.forEach(child => { 124 | this.visit(child); 125 | }); 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /typings/index.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace DSLint { 2 | // Used for tracking `any` type 3 | type AnyType = any; 4 | 5 | interface Configuration { 6 | fileKey: string; 7 | teamId?: string; 8 | } 9 | 10 | interface RuleLoaderOptions { 11 | client: Figma.Client.Client; 12 | file: Figma.File; 13 | } 14 | 15 | namespace Rules { 16 | interface Failure extends AddFailure { 17 | ruleName: string; 18 | } 19 | 20 | // These are the only required options for calling `addFailure()`. 21 | interface AddFailure { 22 | location: Figma.NodeId; 23 | message: string; 24 | // Optional since some rules can be applied at the global level 25 | node?: Figma.Node; 26 | // Optional thumbnail 27 | thumbnail?: string; 28 | // Additional rule-specific metadata 29 | ruleData?: AnyType; 30 | } 31 | 32 | // Static properties/methods 33 | interface Metadata { 34 | ruleName: string; 35 | description: string; 36 | } 37 | 38 | // Instance properties/methods 39 | interface AbstractRule { 40 | ruleDidLoad( 41 | file?: Figma.File, 42 | // TODO(vutran) - I think we should avoid passing the client. Trade-off is user provided 43 | // client vs. promoting these types of complex rules 44 | client?: Figma.Client.Client, 45 | config?: DSLint.Configuration 46 | ): Promise | void; 47 | apply(file?: Figma.File, config?: DSLint.Configuration): Failure[]; 48 | } 49 | 50 | interface RuleClass { 51 | metadata: Metadata; 52 | new (): AbstractRule; 53 | } 54 | } 55 | 56 | interface WalkerOptions {} 57 | 58 | interface Walker { 59 | node: Figma.Node; 60 | options: WalkerOptions; 61 | getNode(): Figma.Node; 62 | visit(node: Figma.Node): void; 63 | visitDocument(node: Figma.Nodes.Document): void; 64 | visitCanvas(node: Figma.Nodes.Canvas): void; 65 | visitFrame(node: Figma.Nodes.Frame): void; 66 | visitGroup(node: Figma.Nodes.Group): void; 67 | visitBooleanOperation(node: Figma.Nodes.BooleanOperation): void; 68 | visitStar(node: Figma.Nodes.Star): void; 69 | visitLine(node: Figma.Nodes.Line): void; 70 | visitEllipse(node: Figma.Nodes.Ellipse): void; 71 | visitRegularPolygon(node: Figma.Nodes.RegularPolygon): void; 72 | visitRectangle(node: Figma.Nodes.Rectangle): void; 73 | visitText(node: Figma.Nodes.Text): void; 74 | visitSlice(node: Figma.Nodes.Slice): void; 75 | visitComponent(node: Figma.Nodes.Component): void; 76 | visitInstance(node: Figma.Nodes.Instance): void; 77 | walk(node: Figma.Node): void; 78 | walkChildren(node: Figma.Node & Figma.Mixins.Children): void; 79 | } 80 | 81 | interface RuleWalkerOptions extends WalkerOptions { 82 | ruleName: string; 83 | } 84 | 85 | interface DocumentRuleWalkerOptions extends WalkerOptions { 86 | rules: DSLint.Rules.AbstractRule[]; 87 | file: Figma.File; 88 | } 89 | 90 | interface RuleWalker extends Walker { 91 | failures: DSLint.Rules.Failure[]; 92 | addFailure(failure: DSLint.Rules.Failure): void; 93 | getAllFailures(): DSLint.Rules.Failure[]; 94 | } 95 | 96 | interface Color { 97 | r: number; 98 | g: number; 99 | b: number; 100 | } 101 | } 102 | 103 | declare module 'dslint' { 104 | export function lint( 105 | file: Figma.File, 106 | rules: DSLint.Rules.AbstractRule[], 107 | config?: DSLint.Configuration 108 | ): DSLint.Rules.Failure[]; 109 | 110 | export function dslint( 111 | fileKey: string, 112 | personalAccessToken: string, 113 | // A list of paths to load rules from 114 | rulesPath: string 115 | ): Promise; 116 | 117 | /** 118 | * Returns the path to the core rules 119 | */ 120 | export function getCoreRulesPath(): string[]; 121 | } 122 | -------------------------------------------------------------------------------- /src/rules/prefer-local-style/helpers.ts: -------------------------------------------------------------------------------- 1 | import {AbstractWalker} from '../../base/walker'; 2 | 3 | // Fills, strokes, effects, and grids are available regardless if there's a local style applied 4 | // or not. It can be assumed that the node is using a one-off color if there are inline styles, 5 | // but no local styles associated. 6 | type InlineFill = Figma.Mixins.Styles & Figma.Mixins.Fills; 7 | type InlineStroke = Figma.Mixins.Styles & Figma.Mixins.Strokes; 8 | type InlineEffect = Figma.Mixins.Styles & Figma.Mixins.Effects; 9 | type InlineGrid = Figma.Mixins.Styles & Figma.Mixins.Grid; 10 | 11 | // It can also be assumed that the node is using a one-off text style if there are inline 12 | // type styles, but no local styles associated. 13 | type InlineType = Figma.Mixins.Styles & Figma.Mixins.Type; 14 | 15 | export function hasLocalFill(node: Figma.Node): node is InlineFill { 16 | const localStyles = (node as Figma.Mixins.Styles).styles; 17 | return !!( 18 | localStyles && 19 | (localStyles.fill || (localStyles.fills && localStyles.fills.length)) 20 | ); 21 | } 22 | 23 | export function hasLocalStroke(node: Figma.Node): node is InlineStroke { 24 | const localStyles = (node as Figma.Mixins.Styles).styles; 25 | return !!(localStyles && localStyles.stroke); 26 | } 27 | 28 | export function hasLocalEffect(node: Figma.Node): node is InlineEffect { 29 | const localStyles = (node as Figma.Mixins.Styles).styles; 30 | return !!(localStyles && localStyles.effect); 31 | } 32 | 33 | export function hasLocalType(node: Figma.Node): node is InlineType { 34 | const localStyles = (node as Figma.Mixins.Styles).styles; 35 | return !!(localStyles && localStyles.text); 36 | } 37 | 38 | export function hasLocalGrid(node: Figma.Node): node is InlineGrid { 39 | const localStyles = (node as Figma.Mixins.Styles).styles; 40 | return !!(localStyles && localStyles.grid); 41 | } 42 | 43 | /** Returns true if the node has inline fills */ 44 | export function isInlineFill(node: Figma.Node): node is InlineFill { 45 | const fills = (node as Figma.Mixins.Fills).fills; 46 | const solidfills = fills && fills.filter(fill => fill.type === 'SOLID'); 47 | return !hasLocalFill(node) && solidfills && solidfills.length > 0; 48 | } 49 | 50 | /** Returns true if the node has inline strokes */ 51 | export function isInlineStroke(node: Figma.Node): node is InlineStroke { 52 | const strokes = (node as Figma.Mixins.Strokes).strokes; 53 | return !hasLocalStroke(node) && strokes && strokes.length > 0; 54 | } 55 | 56 | /** Returns true if the node has inline effects */ 57 | export function isInlineEffect(node: Figma.Node): node is InlineEffect { 58 | const effects = (node as Figma.Mixins.Effects).effects; 59 | return !hasLocalEffect(node) && effects && effects.length > 0; 60 | } 61 | 62 | /** Returns true if the node has inline types */ 63 | export function isInlineType(node: Figma.Node): node is InlineType { 64 | const style = (node as Figma.Mixins.Type).style; 65 | return !hasLocalType(node) && style && Object.keys(style).length > 0; 66 | } 67 | 68 | /** Returns true if the node has inline layout grids */ 69 | export function isInlineGrid(node: Figma.Node): node is InlineGrid { 70 | const layoutGrids = (node as Figma.Mixins.Grid).layoutGrids; 71 | return !hasLocalGrid(node) && layoutGrids && layoutGrids.length > 0; 72 | } 73 | 74 | export class LocalStyleWalker extends AbstractWalker { 75 | localStyles?: Map< 76 | Figma.StyleId, 77 | Figma.Property.LocalStyle[] | Figma.Property.Type 78 | >; 79 | 80 | constructor(node: Figma.Node) { 81 | super(node); 82 | this.localStyles = new Map(); 83 | } 84 | 85 | visit(node: Figma.Node) { 86 | super.visit(node); 87 | 88 | if (hasLocalFill(node)) { 89 | this.localStyles.set(node.styles.fill, node.fills); 90 | } 91 | 92 | if (hasLocalStroke(node)) { 93 | this.localStyles.set(node.styles.stroke, node.strokes); 94 | } 95 | 96 | if (hasLocalEffect(node)) { 97 | this.localStyles.set(node.styles.effect, node.effects); 98 | } 99 | 100 | if (hasLocalGrid(node)) { 101 | this.localStyles.set(node.styles.grid, node.layoutGrids); 102 | } 103 | 104 | if (hasLocalType(node)) { 105 | this.localStyles.set(node.styles.text, node.style); 106 | } 107 | } 108 | 109 | getLocalStyles() { 110 | return this.localStyles; 111 | } 112 | } 113 | 114 | /** 115 | * Fetches all local style for the given file. 116 | * 117 | * NOTE(vutran) - Previously tried the /v1/styles/:key endpoint but it doesn't return the 118 | * proper data (fill/stroke/effect/text values) for the given style. Instead, we're going to just 119 | * recurse through all nodes in `file` and extract the associated values if a local style 120 | * is applied. 121 | */ 122 | export async function getLocalStyles( 123 | file: Figma.File, 124 | client: Figma.Client.Client 125 | ): Promise { 126 | const metadata = new Map(); 127 | 128 | const styleIdToKey = new Map(); 129 | 130 | Object.entries(file.styles).forEach(([key, value]) => { 131 | styleIdToKey.set(key, value.key); 132 | }); 133 | 134 | const keys = Array.from(styleIdToKey.values()); 135 | 136 | for (const key of keys) { 137 | try { 138 | const style = (await client.styles(key)).body.meta; 139 | metadata.set(key, style); 140 | } catch (err) { 141 | throw new Error( 142 | `Oops, failed trying to load a style ${key}. Please make sure your file is published.` 143 | ); 144 | } 145 | } 146 | 147 | // extract the properties from the document tree 148 | const walker = new LocalStyleWalker(file.document); 149 | walker.walk(file.document); 150 | const properties = walker.getLocalStyles(); 151 | 152 | // builds the local style (metadata + properties) map 153 | const localStyles = new Map(); 154 | for (const styleId of Array.from(properties.keys())) { 155 | const styleKey = styleIdToKey.get(styleId); 156 | localStyles.set(styleId, { 157 | metadata: metadata.get(styleKey), 158 | properties: properties.get(styleId), 159 | }); 160 | } 161 | 162 | return Promise.resolve(localStyles); 163 | } 164 | -------------------------------------------------------------------------------- /src/rules/prefer-local-style/index.ts: -------------------------------------------------------------------------------- 1 | import nearestColor from 'nearest-color'; 2 | import {AbstractRule} from '../../base/rule'; 3 | import {RuleWalker} from '../../base/walker'; 4 | import {toRGB} from '../../toolkits/figma'; 5 | import { 6 | isInlineFill, 7 | isInlineStroke, 8 | isInlineEffect, 9 | isInlineType, 10 | getLocalStyles, 11 | } from './helpers'; 12 | 13 | /** 14 | * Prefer Local Styles over hard-coded styles (fills, strokes, effects, and text). 15 | */ 16 | export class Rule extends AbstractRule { 17 | static metadata: DSLint.Rules.Metadata = { 18 | ruleName: 'prefer-local-style', 19 | description: 20 | 'Prefer Local Styles over hard-coded styles (fills, strokes, effects, and text).', 21 | }; 22 | 23 | localStyles: Figma.LocalStyles; 24 | 25 | async ruleDidLoad( 26 | file: Figma.File, 27 | client: Figma.Client.Client, 28 | config: DSLint.Configuration 29 | ) { 30 | this.localStyles = await getLocalStyles(file, client); 31 | } 32 | 33 | apply( 34 | file: Figma.File, 35 | config: DSLint.Configuration 36 | ): DSLint.Rules.Failure[] { 37 | const ruleName = Rule.metadata.ruleName; 38 | const localStyles = this.localStyles; 39 | return this.applyWithWalker( 40 | new LocalStyleWalker(file.document, {ruleName, localStyles}) 41 | ); 42 | } 43 | } 44 | 45 | interface LocalStyleWalkerOptions { 46 | localStyles: Figma.LocalStyles; 47 | } 48 | 49 | class LocalStyleWalker extends RuleWalker { 50 | /** 51 | * Given the set of text style, find the nearest font style. 52 | * This is done by comparing all values and returning the local style with the most matches. 53 | * Each value is weighted differently: (size (4), family (3), weight (2), line-height (1)) 54 | * 55 | * Limitations: There isn't any local style available in the file by default. If no nodes 56 | * are referencing any local styles, there will be no recommendations. At least 1 node needs to 57 | * be referencing a local style to ensure we can properly load the rest. 58 | */ 59 | findNearestTypes( 60 | style: Figma.Property.Type, 61 | localStyles: Figma.LocalStyles 62 | ): Figma.Metadata.Style { 63 | // keep track of the best match 64 | let highestPoint = 0; 65 | let highest: Figma.StyleKey = null; 66 | 67 | // keep track of the best font size 68 | let bestFontSizeDist = 0; 69 | let bestFontSize = 0; 70 | 71 | localStyles.forEach((localStyle, styleId) => { 72 | if (!localStyle || !localStyle.metadata) { 73 | return; 74 | } 75 | 76 | let points = 0; 77 | if (localStyle.properties.fontSize == style.fontSize) { 78 | points += 4; 79 | } else { 80 | let sizeMatch = false; 81 | if (!bestFontSize) { 82 | sizeMatch = true; 83 | } else { 84 | const dist = Math.abs( 85 | localStyle.properties.fontSize - style.fontSize 86 | ); 87 | if (dist < bestFontSizeDist) { 88 | sizeMatch = true; 89 | } 90 | } 91 | if (sizeMatch) { 92 | bestFontSize = localStyle.properties.fontSize; 93 | bestFontSizeDist = Math.abs( 94 | localStyle.properties.fontSize - style.fontSize 95 | ); 96 | points += 4; 97 | } 98 | } 99 | if (localStyle.properties.fontFamily == style.fontFamily) { 100 | points += 3; 101 | } 102 | if (localStyle.properties.fontWeight == style.fontWeight) { 103 | points += 2; 104 | } 105 | if (localStyle.properties.lineHeightPx == style.lineHeightPx) { 106 | points += 1; 107 | } 108 | if (points >= highestPoint) { 109 | highestPoint = points; 110 | highest = styleId; 111 | } 112 | }); 113 | 114 | return highest && localStyles.get(highest).metadata; 115 | } 116 | 117 | /** 118 | * Given the list of paints, find the nearest local style. 119 | * 120 | * Limitations: There isn't any local style available in the file by default. If no nodes 121 | * are referencing any local styles, there will be no recommendations. At least 1 node needs to 122 | * be referencing a local style to ensure we can properly load the rest. 123 | */ 124 | findNearestFills( 125 | paints: Figma.Property.Paint[], 126 | localStyles: Figma.LocalStyles 127 | ) { 128 | if (localStyles.size === 0) { 129 | return; 130 | } 131 | let rec; 132 | const colors = this.getColors(localStyles); 133 | 134 | if (Object.keys(colors).length > 0) { 135 | const getRecommendedLocalStyle = nearestColor.from(colors); 136 | 137 | paints.forEach(paint => { 138 | rec = getRecommendedLocalStyle(toRGB(paint.color)); 139 | }); 140 | } 141 | 142 | return rec; /* type: nearest-color.Color */ 143 | } 144 | 145 | // Builds a list of color maps for recommendation (local style name => rgb/hex) 146 | getColors(localStyles: Figma.LocalStyles) { 147 | const colors: {[key: string]: DSLint.AnyType} = {}; 148 | 149 | localStyles.forEach((style, styleId) => { 150 | if (!style || !style.metadata) { 151 | return; 152 | } 153 | 154 | // grab the color based on the local style type 155 | switch (style.metadata.style_type) { 156 | case 'FILL': 157 | const color = style.properties 158 | .filter(prop => (prop as Figma.Property.Paint).type === 'SOLID') 159 | .map(prop => toRGB(prop.color)); 160 | colors[style.metadata.name] = color[0]; 161 | break; 162 | case 'EFFECT': 163 | // TODO(vutran) - pass 164 | break; 165 | } 166 | }); 167 | 168 | return colors; 169 | } 170 | 171 | createMessage(msg: string, rec?: DSLint.AnyType) { 172 | if (rec && rec.name) { 173 | return `${msg}. Recommended local style: ${rec.name}`; 174 | } 175 | return msg; 176 | } 177 | 178 | checkNode(node: Figma.Node) { 179 | const {localStyles} = this.options; 180 | if (isInlineFill(node)) { 181 | // type: nearest-color.Color 182 | const rec: DSLint.AnyType = this.findNearestFills( 183 | node.fills, 184 | localStyles 185 | ); 186 | 187 | this.addFailure({ 188 | location: node.id, 189 | node, 190 | message: this.createMessage( 191 | `Unexpected inline fill for "${node.name}"`, 192 | rec 193 | ), 194 | ruleData: { 195 | type: 'fill', 196 | rec, 197 | }, 198 | }); 199 | } 200 | 201 | if (isInlineStroke(node)) { 202 | // type: nearest-color.Color 203 | const rec: DSLint.AnyType = this.findNearestFills( 204 | node.strokes, 205 | localStyles 206 | ); 207 | 208 | this.addFailure({ 209 | location: node.id, 210 | node, 211 | message: this.createMessage( 212 | `Unexpected inline stroke for "${node.name}"`, 213 | rec 214 | ), 215 | ruleData: { 216 | type: 'stroke', 217 | rec, 218 | }, 219 | }); 220 | } 221 | 222 | if (isInlineEffect(node)) { 223 | this.addFailure({ 224 | location: node.id, 225 | node, 226 | message: this.createMessage( 227 | `Unexpected inline effect style for "${node.name}"` 228 | ), 229 | ruleData: { 230 | type: 'effect', 231 | }, 232 | }); 233 | } 234 | 235 | if (node.type === 'TEXT' && isInlineType(node)) { 236 | const {localStyles} = this.options; 237 | const rec = this.findNearestTypes( 238 | node.style, 239 | localStyles as any /* for typecheck */ 240 | ); 241 | 242 | this.addFailure({ 243 | location: node.id, 244 | node, 245 | message: this.createMessage( 246 | `Unexpected inline text style for "${node.name}"`, 247 | rec 248 | ), 249 | thumbnail: rec && rec.thumbnail_url, 250 | ruleData: { 251 | type: 'text', 252 | rec, 253 | }, 254 | }); 255 | } 256 | } 257 | 258 | public visit(node: Figma.Node) { 259 | super.visit(node); 260 | this.checkNode(node); 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /src/toolkits/figma/typings/index.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace Figma { 2 | namespace Client { 3 | type AuthKey = 'bearerAccessToken' | 'personalAccessToken'; 4 | 5 | type Headers = AuthorizationHeaders | PersonalTokenHeaders; 6 | interface Options { 7 | bearerAccessToken?: string; 8 | personalAccessToken?: string; 9 | } 10 | 11 | interface AuthorizationHeaders { 12 | Authorization: string; 13 | } 14 | 15 | interface PersonalTokenHeaders { 16 | 'X-Figma-Token': string; 17 | } 18 | 19 | interface Client { 20 | get(endpoint: string, options: any): any; 21 | file(key: string): any; 22 | fileNodes(key: string): any; 23 | styles(key: string): any; 24 | } 25 | } 26 | 27 | // Alias from DSLint typings 28 | type AnyType = DSLint.AnyType; 29 | 30 | // NOTE(vutran) - Any types that are currently missing in the public documentations should be set as `MissingType`. 31 | type MissingType = AnyType; 32 | 33 | type NodeType = 34 | | 'BOOLEAN_OPERATION' 35 | | 'CANVAS' 36 | | 'COMPONENT' 37 | | 'DOCUMENT' 38 | | 'ELLIPSE' 39 | | 'FRAME' 40 | | 'GROUP' 41 | | 'INSTANCE' 42 | | 'LINE' 43 | | 'RECTANGLE' 44 | | 'REGULAR_POLYGON' 45 | | 'SLICE' 46 | | 'STAR' 47 | | 'TEXT' 48 | | 'VECTOR'; 49 | 50 | // This is usually in the format of "{m}:{n}" (eg: "1:1") 51 | type Identifier = string; 52 | type NodeId = Identifier; 53 | type StyleId = Identifier; 54 | type ComponentId = Identifier; 55 | 56 | // A StyleKey is a unique hash for the style. This is to be passed into /style/:key 57 | type StyleKey = string; 58 | 59 | interface Node { 60 | id: NodeId; 61 | name: string; 62 | visible: string; 63 | type: NodeType; 64 | } 65 | 66 | interface ComponentsMap { 67 | // should be `NodeId` 68 | [nodeId: string]: Property.Component; 69 | } 70 | 71 | interface File { 72 | name: string; 73 | lastModified: string; 74 | thumbnailURL: string; 75 | version: string; 76 | document: Nodes.Document; 77 | components: ComponentsMap; 78 | schemaVersion: number; 79 | styles: Map; 80 | } 81 | 82 | // NOTE(vutran) - This isn't part of the API; just a mapping of the local styles metadata 83 | // and it's properties extracted from the document tree. 84 | interface LocalStyleMap { 85 | metadata: Metadata.Style; 86 | properties: T; 87 | } 88 | 89 | type LocalStyles = Map>; 90 | 91 | /** 92 | * Collection of mixins for extending nodes 93 | */ 94 | namespace Mixins { 95 | // NOTE(vutran) - This isn't in the public docs, but some node types can contain a collection of child nodes 96 | interface Children extends Node { 97 | children?: T[]; 98 | } 99 | 100 | interface Fills extends Node { 101 | fills: Property.Paint[]; 102 | } 103 | 104 | interface Strokes extends Node { 105 | strokes: Property.Paint[]; 106 | } 107 | 108 | interface Effects extends Node { 109 | effects: Property.Effect[]; 110 | } 111 | 112 | interface Grid extends Node { 113 | layoutGrids: Property.LayoutGrid[]; 114 | } 115 | 116 | interface Type extends Node { 117 | style: Property.Type; 118 | } 119 | 120 | // Local style keys 121 | interface Styles extends Node { 122 | styles: { 123 | background?: StyleId; 124 | effect?: StyleId; 125 | fill?: StyleId; 126 | fills?: StyleId; 127 | grid?: StyleId; 128 | stroke?: StyleId; 129 | text?: StyleId; 130 | }; 131 | } 132 | } 133 | 134 | namespace Nodes { 135 | /** 136 | * Node Types 137 | */ 138 | interface Document extends Mixins.Children {} 139 | 140 | interface Canvas extends Mixins.Children { 141 | backgroundColor: Property.Color; 142 | prototypeStartNodeID: string; 143 | exportSettings: AnyType[]; 144 | } 145 | 146 | interface Frame 147 | extends Mixins.Children, 148 | // This is not documented but frames can have fills 149 | Mixins.Fills, 150 | // This is not document but frames can have inline styles 151 | Mixins.Styles, 152 | // Strokes seem to be a aggregate of strokes applied to child nodes and not reflected to the Frame itself 153 | Mixins.Strokes, 154 | Mixins.Effects, 155 | Mixins.Grid { 156 | background: AnyType[]; 157 | // @deprecated 158 | backgroundColor: AnyType; 159 | exportSettings: AnyType[]; 160 | blendMode: AnyType; 161 | preserveRatio: boolean; 162 | constraints: AnyType; 163 | transitionID: string; 164 | transitionDuration: number; 165 | transitionEasing: AnyType; 166 | opacity: number; 167 | absoluteBoundingBox: AnyType; 168 | size: AnyType; 169 | relativeTransform: AnyType; 170 | clipsContent: boolean; 171 | isMask: boolean; 172 | } 173 | 174 | type Group = Frame; 175 | 176 | interface Vector 177 | extends Node, 178 | Mixins.Fills, 179 | Mixins.Strokes, 180 | Mixins.Effects, 181 | Mixins.Styles { 182 | exportSettings: AnyType[]; 183 | blendMode: AnyType; 184 | preserveRatio: boolean; 185 | constraints: AnyType; 186 | transitionNodeID: string; 187 | transitionDuration: number; 188 | transitionEasing: AnyType; 189 | opacity: number; 190 | absoluteBoundingBox: AnyType; 191 | size: AnyType; 192 | relativeTransform: AnyType; 193 | isMask: boolean; 194 | fillGeometry: AnyType[]; 195 | strokeWeight: number; 196 | strokeGeometry: AnyType[]; 197 | strokeAlign: 'INSIDE' | 'OUTSIDE' | 'CENTER'; 198 | } 199 | 200 | interface BooleanOperation extends Node { 201 | booleanOperation: 'UNION' | 'INTERSECT' | 'SUBTRACT'; 202 | } 203 | 204 | type Star = Vector; 205 | 206 | type Line = Vector; 207 | 208 | type Ellipse = Vector; 209 | 210 | type RegularPolygon = Vector; 211 | 212 | interface Rectangle extends Vector { 213 | cornerRadius: number; 214 | rectangleCornerRadii: number[]; 215 | } 216 | 217 | interface Text extends Vector { 218 | characters: string; 219 | style: Figma.Property.TypeStyle; 220 | characterStyleOverrides: number[]; 221 | styleOverrideTable: Map; 222 | } 223 | 224 | interface Slice extends Node { 225 | exportSettings: AnyType; 226 | absoluteBoundingBox: AnyType; 227 | vector: AnyType; 228 | relativeTransform: AnyType; 229 | } 230 | 231 | type Component = Frame; 232 | 233 | interface Instance extends Frame { 234 | componentId: Figma.ComponentId; 235 | } 236 | } 237 | 238 | /** 239 | * Property Types 240 | */ 241 | namespace Property { 242 | interface Color { 243 | r: number; 244 | g: number; 245 | b: number; 246 | a: number; 247 | } 248 | 249 | interface ExportSetting { 250 | suffix: string; 251 | format: 'JPG' | 'PNG' | 'SVG'; 252 | constraint: Constraint; 253 | } 254 | 255 | interface Constraint { 256 | type: 'SCALE' | 'WIDTH' | 'HEIGHT'; 257 | value: number; 258 | } 259 | 260 | interface Rectangle { 261 | x: number; 262 | y: number; 263 | width: number; 264 | height: number; 265 | } 266 | 267 | type BlendMode = 268 | | 'PASS_THROUGH' 269 | | 'NORMAL' 270 | | 'DARKEN' 271 | | 'MULTIPLY' 272 | | 'LINEAR_BURN' 273 | | 'COLOR_BURN' 274 | | 'LIGHTEN' 275 | | 'SCREEN' 276 | | 'LINEAR_DODGE' 277 | | 'COLOR_DODGE' 278 | | 'OVERLAY' 279 | | 'SOFT_LIGHT' 280 | | 'HARD_LIGHT' 281 | | 'DIFFERENCE' 282 | | 'EXCLUSION' 283 | | 'HUE' 284 | | 'SATURATION' 285 | | 'COLOR' 286 | | 'LUMINOSITY'; 287 | 288 | type EasingType = 'EASE_IN' | 'EASE_OUT' | 'EASE_IN_AND_OUT'; 289 | 290 | interface LayoutConstraint { 291 | vertical: 'TOP' | 'BOTTOM' | 'CENTER' | 'TOP_BOTTOM' | 'SCALE'; 292 | horizontal: 'LEFT' | 'RIGHT' | 'CENTER' | 'LEFT_RIGHT' | 'SCALE'; 293 | } 294 | 295 | interface LayoutGrid { 296 | pattern: 'COLUMNS' | 'ROWS' | 'GRID'; 297 | sectionSize: number; 298 | visible: boolean; 299 | color: Color; 300 | alignment: 'MIN' | 'MAX' | 'CENTER'; 301 | gutterSize: number; 302 | offset: number; 303 | count: number; 304 | } 305 | 306 | interface Effect { 307 | type: 'INNER_SHADOW' | 'DROP_SHADOW' | 'LAYER_BLUR' | 'BACKGROUND_BLUR'; 308 | visible: boolean; 309 | radius: number; 310 | color: Color; 311 | blendMode: BlendMode; 312 | offset: Vector; 313 | } 314 | 315 | interface Paint { 316 | type: 317 | | 'SOLID' 318 | | 'GRADIENT_LINEAR' 319 | | 'GRADIENT_RADIAL' 320 | | 'GRADIENT_ANGULAR' 321 | | 'GRADIENT_DIAMOND' 322 | | 'IMAGE' 323 | | 'EMOJI'; 324 | visible: boolean; 325 | opacity: number; 326 | color: Color; 327 | blendMode: BlendMode; 328 | gradientHandlePositions: Vector[]; 329 | gradientStops: ColorStop[]; 330 | scaleMode: 'FILL' | 'FIT' | 'TILE' | 'STRETCH'; 331 | imageTransform: Property.Transform; 332 | scalingFactor: number; 333 | imageRef: string; 334 | } 335 | 336 | interface Vector { 337 | x: number; 338 | y: number; 339 | } 340 | 341 | interface FrameOffset { 342 | node_id: string; 343 | node_offset: Vector; 344 | } 345 | 346 | interface ColorStop { 347 | position: number; 348 | color: Color; 349 | } 350 | 351 | type TypeStyle = Type & Mixins.Fills; 352 | 353 | interface Type { 354 | fontFamily: string; 355 | fontPostScriptName: string; 356 | italic: boolean; 357 | fontWeight: number; 358 | fontSize: number; 359 | textDecoration: 'STRIKETHROUGH' | 'UNDERLINE'; 360 | textAlignHorizontal: 'LEFT' | 'RIGHT' | 'CENTER' | 'JUSTFIED'; 361 | textAlignVertical: 'TOP' | 'CENTER' | 'BOTTOM'; 362 | letterSpacing: number; 363 | lineHeightPx: number; 364 | lineHeightPercent: number; 365 | } 366 | 367 | interface Component { 368 | key: string; 369 | name: string; 370 | description: string; 371 | } 372 | 373 | interface Style { 374 | key: string; 375 | name: string; 376 | // NOTE(vutran) - In the public docs, this is defined as `style_type`, but the API returns `style_type`. 377 | styleType: 'FILL' | 'TEXT' | 'EFFECT' | 'GRID'; 378 | } 379 | 380 | // NOTE(vutran) - Not sure if this is correct since it is missing in the doc. 381 | // Copied from: https://github.com/figma/figma-extension-api/blob/bce0eeb50d751cb5fc54c8f81bb5cf2e622440ef/types/index.d.ts#L145 382 | type Transform = [[number, number, number], [number, number, number]]; 383 | 384 | // NOTE(vutran) - Alias for all possible local style types (color, effect, layout grid). 385 | // This isn't defined in the docs, just an alias 386 | type LocalStyle = Paint | Effect | LayoutGrid; 387 | } 388 | 389 | namespace Metadata { 390 | interface Component { 391 | key: string; 392 | file_key: string; 393 | node_id: string; 394 | thumbnail_url: string; 395 | name: string; 396 | description: string; 397 | created_at: string; 398 | updated_at: string; 399 | user: User; 400 | containing_frame: FrameInfo; 401 | containing_page: PageInfo; 402 | } 403 | 404 | interface Style { 405 | key: string; 406 | file_key: string; 407 | node_id: string; 408 | style_type: 'FILL' | 'TEXT' | 'EFFECT' | 'GRID'; 409 | thumbnail_url: string; 410 | name: string; 411 | description: string; 412 | created_at: string; 413 | updated_at: string; 414 | user: User; 415 | sort_position: string; 416 | } 417 | 418 | interface FrameInfo { 419 | node_id: string; 420 | name: string; 421 | background_color: string; 422 | page_id: string; 423 | page_name: string; 424 | } 425 | 426 | interface User { 427 | id: string; 428 | handle: string; 429 | img_url: string; 430 | email: string; 431 | } 432 | 433 | type PageInfo = MissingType; 434 | } 435 | } 436 | --------------------------------------------------------------------------------