├── .gitignore ├── LICENSE ├── README.md ├── build.js ├── deps ├── build.sh ├── source-map.js └── uglify-es.js ├── dist.sh ├── docs ├── index.css ├── index.html └── raster.css ├── examples ├── .npmignore ├── basic │ ├── README.md │ ├── manifest.js │ └── plugin.ts ├── build-all.sh ├── extra-lib │ ├── README.md │ ├── figma.d.ts │ ├── figplug.d.ts │ ├── libone.d.ts │ ├── libone.js │ ├── libthree.d.ts │ ├── libtwo.js │ ├── manifest.json │ ├── plugin.ts │ ├── tsconfig.json │ ├── ui.html │ ├── ui.ts │ ├── uilib1.d.ts │ ├── uilib1.js │ └── uilib2.js ├── manifest-build │ ├── README.md │ ├── manifest.json │ └── src │ │ ├── manifest.js │ │ └── plugin.ts ├── ui-html │ ├── README.md │ ├── assets │ │ └── logo.svg │ ├── manifest.json │ ├── plugin.ts │ └── ui.html ├── ui-react │ ├── README.md │ ├── assets │ │ ├── logo.svg │ │ ├── rectangle.jpg │ │ └── under-construction.gif │ ├── manifest.json │ ├── package-lock.json │ ├── package.json │ ├── plugin.ts │ ├── tsconfig.json │ ├── ui.css │ └── ui.tsx └── ui │ ├── README.md │ ├── manifest.json │ ├── plugin.ts │ ├── ui.css │ ├── ui.html │ └── ui.ts ├── lib ├── .npmignore ├── figma-plugin-1.0.0.d.ts ├── figplug.d.ts ├── figplug.js ├── template-package-react.json ├── template-plugin-ui.ts ├── template-plugin.ts ├── template-tsconfig.json ├── template-ui-react.html ├── template-ui-react.tsx ├── template-ui.html ├── template-ui.ts ├── template-ui.ts.html └── template.css ├── misc └── install-globally-from-source.sh ├── package-lock.json ├── package.json ├── src ├── asset.ts ├── check-version.ts ├── cli.ts ├── ctx.ts ├── fs.ts ├── gif.ts ├── global.d.ts ├── global.js ├── html.ts ├── http.ts ├── init.ts ├── jpeg.ts ├── main.ts ├── manifest.ts ├── pkgbuild.d.ts ├── pkgbuild.js ├── plugin.ts ├── postcss-nesting.d.ts ├── proc.ts ├── strings.ts ├── termstyle.ts └── util.ts ├── test.sh └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.g.* 3 | *.sublime* 4 | .tscache* 5 | report.* 6 | 7 | /node_modules 8 | /build 9 | /bin 10 | /examples/**/build 11 | /examples/**/node_modules 12 | /_local 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Rasmus Andersson 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # figplug 2 | 3 | Figma plugin helper. 4 | 5 | - Simplify creation of plugins 6 | - Simplify compiling of plugins 7 | - Yields plugins with efficient code that loads fast 8 | - TypeScript 9 | - Supports React out of the box 10 | 11 | Install: `npm install -g figplug` 12 | 13 | Examples: 14 | 15 | ```sh 16 | # create a plugin 17 | figplug init -ui my-plugin 18 | # build a plugin 19 | figplug build -w my-plugin 20 | # Your plugin is now available in "my-plugin/build". 21 | # -w makes figbuild watch your source files for changes 22 | # and rebuild your plugin automatically. 23 | ``` 24 | 25 | 26 | ### init 27 | 28 | Initialize Figma plugins in directories provided as ``, or the current directory. 29 | 30 | ``` 31 | Usage: figplug init [ ...] 32 | Initialize Figma plugins in directories provided as , or the current directory. 33 | options: 34 | -ui Generate UI written in TypeScript & HTML 35 | -html Generate UI written purely in HTML 36 | -react Generate UI written in React 37 | -f, -force Overwrite or replace existing files 38 | -api= Specify Figma Plugin API version. Defaults to "1.0.0". 39 | -name= Name of plugin. Defaults to directory name. 40 | -srcdir= Where to put source files, relative to . Defaults to ".". 41 | -v, -verbose Print additional information to stdout 42 | -debug Print a lot of information to stdout. Implies -v 43 | -version Print figplug version information 44 | ``` 45 | 46 | ### build 47 | 48 | Builds Figma plugins. 49 | 50 | ``` 51 | Usage: figplug build [options] [ ...] 52 | Builds Figma plugins. 53 | 54 | Path to a plugin directory or a manifest file. Defaults to ".". 55 | You can optionally specify an output directory for every path through 56 | :. Example: src:build. 57 | This is useful when building multiple plugins at the same time. 58 | 59 | options: 60 | -w Watch sources for changes and rebuild incrementally 61 | -g Generate debug code (assertions and DEBUG branches). 62 | -O Generate optimized code. 63 | -lib= Include a global library in plugin code. Can be set multiple times. 64 | -clean Force rebuilding of everything, ignoring cache. Implied with -O. 65 | -nomin Do not minify or mangle optimized code when -O is enabled. 66 | -o=, 67 | -output= Write output to directory. Defaults to ./build 68 | -v, -verbose Print additional information to stdout 69 | -debug Print a lot of information to stdout. Implies -v 70 | -version Print figplug version information 71 | ``` 72 | -------------------------------------------------------------------------------- /deps/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | cd "$(dirname "$0")" 3 | NMDIR=../node_modules 4 | OUTDIR=. 5 | 6 | mkdir -p "$OUTDIR" 7 | 8 | function optimize { 9 | echo "optimizing $1" 10 | "$NMDIR/.bin/uglifyjs" \ 11 | --compress \ 12 | --toplevel \ 13 | --ecma 7 \ 14 | "--beautify=beautify=true,preserve_line=false,comments=false" \ 15 | -o "$1" \ 16 | -- "$1" 17 | } 18 | 19 | # ---------------------------------------------------------------------------- 20 | # source-map 21 | 22 | SOURCEMAP_NMDIR=$NMDIR 23 | if [ -d "$NMDIR/uglify-es/node_modules/source-map" ]; then 24 | # uglify-es depends on specific version of source map 25 | SOURCEMAP_NMDIR=$NMDIR/uglify-es/node_modules 26 | fi 27 | VERSION=$(node -p "require('$SOURCEMAP_NMDIR/source-map/package.json').version") 28 | OUTFILE=$OUTDIR/source-map.js 29 | cat <<_JS_ > "$OUTFILE" 30 | const exports = {}, module = {exports};(function(){ 31 | $(cat "$SOURCEMAP_NMDIR/source-map/dist/source-map.js") 32 | }).apply({}); 33 | export const SourceMapGenerator = module.exports.SourceMapGenerator; 34 | export const SourceMapConsumer = module.exports.SourceMapConsumer; 35 | export const SourceNode = module.exports.SourceNode; 36 | export default { 37 | SourceMapGenerator: module.exports.SourceMapGenerator, 38 | SourceMapConsumer: module.exports.SourceMapConsumer, 39 | SourceNode: module.exports.SourceNode, 40 | VERSION: "$VERSION" 41 | } 42 | _JS_ 43 | optimize "$OUTFILE" & 44 | 45 | 46 | # # ---------------------------------------------------------------------------- 47 | # # rollup 48 | 49 | # VERSION=$(node -p "require('$NMDIR/rollup/package.json').version") 50 | # OUTFILE=$OUTDIR/rollup.js 51 | # cat <<_JS_ > "$OUTFILE" 52 | # const exports = {}; 53 | # $(cat "$NMDIR/rollup/dist/rollup.js") 54 | # export default { 55 | # rollup: exports.rollup, 56 | # watch: exports.watch, 57 | # VERSION: "$VERSION" 58 | # } 59 | # _JS_ 60 | # optimize "$OUTFILE" & 61 | 62 | # ---------------------------------------------------------------------------- 63 | # uglify-es 64 | 65 | VERSION=$(node -p "require('$NMDIR/uglify-es/package.json').version") 66 | OUTFILE=$OUTDIR/uglify-es.js 67 | # file list extracted from uglify-es/tools/node.js 68 | uglify_src_files=( \ 69 | utils.js \ 70 | ast.js \ 71 | parse.js \ 72 | transform.js \ 73 | scope.js \ 74 | output.js \ 75 | compress.js \ 76 | sourcemap.js \ 77 | mozilla-ast.js \ 78 | propmangle.js \ 79 | minify.js \ 80 | ) 81 | echo 'import MOZ_SourceMap from "./source-map.js"' > "$OUTFILE" 82 | for f in ${uglify_src_files[@]}; do 83 | cat "$NMDIR/uglify-es/lib/$f" >> "$OUTFILE" 84 | done 85 | cat <<_JS_ >> "$OUTFILE" 86 | export default { 87 | TreeWalker, 88 | parse, 89 | TreeTransformer, 90 | Dictionary, 91 | push_uniq, 92 | minify, 93 | ast: { 94 | $(grep -E 'var AST_.+' "$NMDIR/uglify-es/lib/ast.js" \ 95 | | sort -u \ 96 | | sed -E 's/var AST_([a-zA-Z0-9_]+).+/ \1: AST_\1,/g') 97 | }, 98 | }; 99 | _JS_ 100 | optimize "$OUTFILE" & 101 | 102 | # ---------------------------------------------------------------------------- 103 | # wait for all processes to finish 104 | wait 105 | 106 | # ---------------------------------------------------------------------------- 107 | # remove embedded source code from sourcemap 108 | 109 | # echo "patching sourcemaps" 110 | # node <<_JS_ 111 | # const fs = require('fs') 112 | # for (let file of [ 113 | # 'uglify-es.js.map', 114 | # 'uglify-es.umd.js.map', 115 | # ]) { 116 | # const map = JSON.parse(fs.readFileSync(file, 'utf8')) 117 | # if (map.sourcesContent) { 118 | # delete map.sourcesContent 119 | # fs.writeFileSync(file, JSON.stringify(map), 'utf8') 120 | # } 121 | # } 122 | # _JS_ 123 | -------------------------------------------------------------------------------- /dist.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | cd "$(dirname "$0")" 3 | 4 | rm -rf build/* 5 | rm -f bin/figplug bin/figplug.map 6 | 7 | if ! (git diff-index --quiet HEAD --); then 8 | echo "There are uncommitted changes:" >&2 9 | git status -s --untracked-files=no --ignored=no 10 | exit 1 11 | fi 12 | 13 | echo "building bin/figplug" 14 | ./build.js -O 15 | 16 | echo "testing figplug" 17 | ./test.sh 18 | 19 | VERSION=$(node -e 'process.stdout.write(require("./package.json").version)') 20 | 21 | echo "" 22 | echo "Next steps:" 23 | echo "1) git tag v${VERSION} && git push --tags origin master" 24 | echo "2) npm publish ." 25 | echo "3) Bump version in package.json" 26 | echo "" 27 | -------------------------------------------------------------------------------- /docs/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --fontSize: calc(100vw / 80); 3 | --columnGap: calc(var(--lineHeight) * 3); 4 | --accent: #ff2cb1; 5 | } 6 | @media only screen and (max-width: 600px) { :root { 7 | --fontSize: calc(100vw / 30); 8 | }} 9 | @media only screen and (max-device-width: 812px) and (orientation: landscape) { :root { 10 | --fontSize: 1.7vw; 11 | }} 12 | 13 | pre, tt, code, code-snippet { 14 | font-family: IBM Plex Mono, monospace; 15 | -webkit-font-feature-settings: "ss02" 1,"zero" 1; 16 | font-feature-settings: "ss02" 1,"zero" 1; 17 | } 18 | 19 | code-snippet { 20 | display: block; 21 | /*background: #eee; 22 | padding: calc(var(--lineHeight) / 2) calc(var(--lineHeight) / 4);*/ 23 | color: var(--accent); 24 | margin: calc(var(--lineHeight) / 2) 0 !important; 25 | border-radius: 2px; 26 | overflow-wrap: break-word; 27 | word-wrap: break-word; 28 | -webkit-nbsp-mode: space; 29 | line-break: after-white-space; 30 | max-width: 100%; 31 | } 32 | code-snippet a { 33 | overflow-wrap: break-word; 34 | word-wrap: break-word; 35 | -webkit-nbsp-mode: space; 36 | line-break: after-white-space; 37 | max-width: 100%; 38 | } 39 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | figplug 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |

figplug

16 |

17 | figplug is a small program for building Figma plugins. 18 | It offers all the things you need for most projects: 19 | TypeScript, React/JSX, asset bundling, plugin manifest generation, etc. 20 |

21 |
22 | 23 |

Use

24 |

25 | Create or initialize a new plugin project: 26 | figplug init 27 |

28 |

29 | Create a new plugin with UI: 30 | figplug init -ui 31 |

32 |

33 | Build a plugin, rebuilding it automatically as your source files change: 34 | figplug build -w 35 |

36 |

37 | Build a plugin with optimizations: 38 | figplug build -O 39 |

40 |

41 | See help for more options: 42 | 43 | figplug help init
44 | figplug help build 45 |
46 |

47 |
48 | 49 |

Install

50 | npm install -g figplug 51 |

52 | To uninstall, run npm uninstall -g figplug 53 |

54 |

Source code

55 |

56 | github.com/rsms/figplug 57 |

58 |

Examples

59 |

60 | github.com/rsms/figplug examples 61 |

62 |
63 | 64 |
65 | 66 | 67 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /docs/raster.css: -------------------------------------------------------------------------------- 1 | /* Raster v6 (rsms.me/raster) */ 2 | @import url("https://rsms.me/inter/inter.css");@import url("https://rsms.me/res/fonts/ibm-plex-mono.css");:root{ 3 | --fontSize:12px; 4 | --lineHeight:calc(var(--fontSize)*1.5); 5 | --baseline:calc(var(--lineHeight)/2); 6 | --blockSpacingTop:0px; 7 | --blockSpacingBottom:calc(var(--lineHeight)*1); 8 | --hrThickness:2px; 9 | --h1-size:2.8rem; 10 | --h2-size:2.2rem; 11 | --h3-size:1.4rem; 12 | --h4-size:1.1rem; 13 | --columnGap:var(--lineHeight); 14 | --rowGap:var(--lineHeight); 15 | --red:#ee2711; 16 | --blue:#1871e9; 17 | --green:#12c05b; 18 | --yellow:#f9bf0f; 19 | --displayScale:1; 20 | --pixel:1px 21 | }@media only screen and (-webkit-min-device-pixel-ratio:1.5),only screen and (min-device-pixel-ratio:1.5),only screen and (min-resolution:1.5dppx){:root{--displayScale:2;--pixel:0.5px}}@media only screen and (-webkit-min-device-pixel-ratio:2.5),only screen and (min-device-pixel-ratio:2.5),only screen and (min-resolution:2.5dppx){:root{--displayScale:3;--pixel:0.34px}}@media only screen and (-webkit-min-device-pixel-ratio:3.5),only screen and (min-device-pixel-ratio:3.5),only screen and (min-resolution:3.5dppx){:root{--displayScale:4;--pixel:0.25px}}*{font:inherit;line-height:inherit}a,abbr,acronym,address,applet,article,aside,audio,b,big,blockquote,body,canvas,caption,center,cite,code,dd,del,details,dfn,div,dl,dt,em,embed,fieldset,figcaption,figure,footer,form,grid,h1,h2,h3,h4,h5,h6,header,hgroup,hr,html,i,iframe,img,ins,kbd,label,legend,li,main,mark,menu,nav,noscript,object,ol,output,p,pre,q,s,samp,section,small,span,strike,strong,sub,summary,sup,table,tbody,td,tfoot,th,thead,time,tr,tt,u,ul,var,video{margin:0;padding:0;border:0;vertical-align:baseline}blockquote,q{quotes:none}blockquote:after,blockquote:before,q:after,q:before{content:"";content:none}table{border-collapse:collapse;border-spacing:0}a,a:active,a:visited{color:inherit}grid{display:grid;--grid-tc:repeat(4,1fr);grid-template-columns:var(--grid-tc);--grid-cs:1;--grid-ce:-1}grid>c{display:block;-moz-appearance:none;appearance:none;-webkit-appearance:none}grid[columns="1"]{--grid-tc:repeat(1,1fr)}grid[columns="2"]{--grid-tc:repeat(2,1fr)}grid[columns="3"]{--grid-tc:repeat(3,1fr)}grid[columns="4"]{--grid-tc:repeat(4,1fr)}grid[columns="5"]{--grid-tc:repeat(5,1fr)}grid[columns="6"]{--grid-tc:repeat(6,1fr)}grid[columns="7"]{--grid-tc:repeat(7,1fr)}grid[columns="8"]{--grid-tc:repeat(8,1fr)}grid[columns="9"]{--grid-tc:repeat(9,1fr)}grid[columns="10"]{--grid-tc:repeat(10,1fr)}grid[columns="11"]{--grid-tc:repeat(11,1fr)}grid[columns="12"]{--grid-tc:repeat(12,1fr)}grid[columns="13"]{--grid-tc:repeat(13,1fr)}grid[columns="14"]{--grid-tc:repeat(14,1fr)}grid[columns="15"]{--grid-tc:repeat(15,1fr)}grid[columns="16"]{--grid-tc:repeat(16,1fr)}grid[columns="17"]{--grid-tc:repeat(17,1fr)}grid[columns="18"]{--grid-tc:repeat(18,1fr)}grid[columns="19"]{--grid-tc:repeat(19,1fr)}grid[columns="20"]{--grid-tc:repeat(20,1fr)}grid[columns="21"]{--grid-tc:repeat(21,1fr)}grid[columns="22"]{--grid-tc:repeat(22,1fr)}grid[columns="23"]{--grid-tc:repeat(23,1fr)}grid[columns="24"]{--grid-tc:repeat(24,1fr)}grid[columns="25"]{--grid-tc:repeat(25,1fr)}grid[columns="26"]{--grid-tc:repeat(26,1fr)}grid[columns="27"]{--grid-tc:repeat(27,1fr)}grid[columns="28"]{--grid-tc:repeat(28,1fr)}grid[columns="29"]{--grid-tc:repeat(29,1fr)}grid[columns="30"]{--grid-tc:repeat(30,1fr)}grid>c[span^="1"]{--grid-cs:1}grid>c[span^="2"]{--grid-cs:2}grid>c[span^="3"]{--grid-cs:3}grid>c[span^="4"]{--grid-cs:4}grid>c[span^="5"]{--grid-cs:5}grid>c[span^="6"]{--grid-cs:6}grid>c[span^="7"]{--grid-cs:7}grid>c[span^="8"]{--grid-cs:8}grid>c[span^="9"]{--grid-cs:9}grid>c[span^="10"]{--grid-cs:10}grid>c[span^="11"]{--grid-cs:11}grid>c[span^="12"]{--grid-cs:12}grid>c[span^="13"]{--grid-cs:13}grid>c[span^="14"]{--grid-cs:14}grid>c[span^="15"]{--grid-cs:15}grid>c[span^="16"]{--grid-cs:16}grid>c[span^="17"]{--grid-cs:17}grid>c[span^="18"]{--grid-cs:18}grid>c[span^="19"]{--grid-cs:19}grid>c[span^="20"]{--grid-cs:20}grid>c[span^="21"]{--grid-cs:21}grid>c[span^="22"]{--grid-cs:22}grid>c[span^="23"]{--grid-cs:23}grid>c[span^="24"]{--grid-cs:24}grid>c[span^="25"]{--grid-cs:25}grid>c[span^="26"]{--grid-cs:26}grid>c[span^="27"]{--grid-cs:27}grid>c[span^="28"]{--grid-cs:28}grid>c[span^="29"]{--grid-cs:29}grid>c[span^="30"]{--grid-cs:30}grid>c[span$="+1"],grid>c[span="1"]{--grid-ce:1}grid>c[span$="+2"],grid>c[span$="-1"],grid>c[span="2"]{--grid-ce:2}grid>c[span$="+3"],grid>c[span$="-2"],grid>c[span="3"]{--grid-ce:3}grid>c[span$="+4"],grid>c[span$="-3"],grid>c[span="4"]{--grid-ce:4}grid>c[span$="+5"],grid>c[span$="-4"],grid>c[span="5"]{--grid-ce:5}grid>c[span$="+6"],grid>c[span$="-5"],grid>c[span="6"]{--grid-ce:6}grid>c[span$="+7"],grid>c[span$="-6"],grid>c[span="7"]{--grid-ce:7}grid>c[span$="+8"],grid>c[span$="-7"],grid>c[span="8"]{--grid-ce:8}grid>c[span$="+9"],grid>c[span$="-8"],grid>c[span="9"]{--grid-ce:9}grid>c[span$="+10"],grid>c[span$="-9"],grid>c[span="10"]{--grid-ce:10}grid>c[span$="+11"],grid>c[span$="-10"],grid>c[span="11"]{--grid-ce:11}grid>c[span$="+12"],grid>c[span$="-11"],grid>c[span="12"]{--grid-ce:12}grid>c[span$="+13"],grid>c[span$="-12"],grid>c[span="13"]{--grid-ce:13}grid>c[span$="+14"],grid>c[span$="-13"],grid>c[span="14"]{--grid-ce:14}grid>c[span$="+15"],grid>c[span$="-14"],grid>c[span="15"]{--grid-ce:15}grid>c[span$="+16"],grid>c[span$="-15"],grid>c[span="16"]{--grid-ce:16}grid>c[span$="+17"],grid>c[span$="-16"],grid>c[span="17"]{--grid-ce:17}grid>c[span$="+18"],grid>c[span$="-17"],grid>c[span="18"]{--grid-ce:18}grid>c[span$="+19"],grid>c[span$="-18"],grid>c[span="19"]{--grid-ce:19}grid>c[span$="+20"],grid>c[span$="-19"],grid>c[span="20"]{--grid-ce:20}grid>c[span$="+21"],grid>c[span$="-20"],grid>c[span="21"]{--grid-ce:21}grid>c[span$="+22"],grid>c[span$="-21"],grid>c[span="22"]{--grid-ce:22}grid>c[span$="+23"],grid>c[span$="-22"],grid>c[span="23"]{--grid-ce:23}grid>c[span$="+24"],grid>c[span$="-23"],grid>c[span="24"]{--grid-ce:24}grid>c[span$="+25"],grid>c[span$="-24"],grid>c[span="25"]{--grid-ce:25}grid>c[span$="+26"],grid>c[span$="-25"],grid>c[span="26"]{--grid-ce:26}grid>c[span$="+27"],grid>c[span$="-26"],grid>c[span="27"]{--grid-ce:27}grid>c[span$="+28"],grid>c[span$="-27"],grid>c[span="28"]{--grid-ce:28}grid>c[span$="+29"],grid>c[span$="-28"],grid>c[span="29"]{--grid-ce:29}grid>c[span$="+30"],grid>c[span$="-29"],grid>c[span="30"]{--grid-ce:30}grid>c[span$="-30"]{--grid-ce:31}grid>c[span]{grid-column-end:span var(--grid-ce)}grid>c[span*="+"],grid>c[span*="-"],grid>c[span*=".."]{grid-column-start:var(--grid-cs)}grid>c[span*="-"],grid>c[span*=".."]{grid-column-end:var(--grid-ce)}grid>c[span=row]{grid-column:1/-1}@media only screen and (max-width:600px){grid[columns-s="1"]{--grid-tc:repeat(1,1fr)}grid[columns-s="2"]{--grid-tc:repeat(2,1fr)}grid[columns-s="3"]{--grid-tc:repeat(3,1fr)}grid[columns-s="4"]{--grid-tc:repeat(4,1fr)}grid[columns-s="5"]{--grid-tc:repeat(5,1fr)}grid[columns-s="6"]{--grid-tc:repeat(6,1fr)}grid[columns-s="7"]{--grid-tc:repeat(7,1fr)}grid[columns-s="8"]{--grid-tc:repeat(8,1fr)}grid[columns-s="9"]{--grid-tc:repeat(9,1fr)}grid[columns-s="10"]{--grid-tc:repeat(10,1fr)}grid[columns-s="11"]{--grid-tc:repeat(11,1fr)}grid[columns-s="12"]{--grid-tc:repeat(12,1fr)}grid[columns-s="13"]{--grid-tc:repeat(13,1fr)}grid[columns-s="14"]{--grid-tc:repeat(14,1fr)}grid[columns-s="15"]{--grid-tc:repeat(15,1fr)}grid[columns-s="16"]{--grid-tc:repeat(16,1fr)}grid[columns-s="17"]{--grid-tc:repeat(17,1fr)}grid[columns-s="18"]{--grid-tc:repeat(18,1fr)}grid[columns-s="19"]{--grid-tc:repeat(19,1fr)}grid[columns-s="20"]{--grid-tc:repeat(20,1fr)}grid[columns-s="21"]{--grid-tc:repeat(21,1fr)}grid[columns-s="22"]{--grid-tc:repeat(22,1fr)}grid[columns-s="23"]{--grid-tc:repeat(23,1fr)}grid[columns-s="24"]{--grid-tc:repeat(24,1fr)}grid[columns-s="25"]{--grid-tc:repeat(25,1fr)}grid[columns-s="26"]{--grid-tc:repeat(26,1fr)}grid[columns-s="27"]{--grid-tc:repeat(27,1fr)}grid[columns-s="28"]{--grid-tc:repeat(28,1fr)}grid[columns-s="29"]{--grid-tc:repeat(29,1fr)}grid[columns-s="30"]{--grid-tc:repeat(30,1fr)}grid>c[span-s^="1"]{--grid-cs:1}grid>c[span-s^="2"]{--grid-cs:2}grid>c[span-s^="3"]{--grid-cs:3}grid>c[span-s^="4"]{--grid-cs:4}grid>c[span-s^="5"]{--grid-cs:5}grid>c[span-s^="6"]{--grid-cs:6}grid>c[span-s^="7"]{--grid-cs:7}grid>c[span-s^="8"]{--grid-cs:8}grid>c[span-s^="9"]{--grid-cs:9}grid>c[span-s^="10"]{--grid-cs:10}grid>c[span-s^="11"]{--grid-cs:11}grid>c[span-s^="12"]{--grid-cs:12}grid>c[span-s^="13"]{--grid-cs:13}grid>c[span-s^="14"]{--grid-cs:14}grid>c[span-s^="15"]{--grid-cs:15}grid>c[span-s^="16"]{--grid-cs:16}grid>c[span-s^="17"]{--grid-cs:17}grid>c[span-s^="18"]{--grid-cs:18}grid>c[span-s^="19"]{--grid-cs:19}grid>c[span-s^="20"]{--grid-cs:20}grid>c[span-s^="21"]{--grid-cs:21}grid>c[span-s^="22"]{--grid-cs:22}grid>c[span-s^="23"]{--grid-cs:23}grid>c[span-s^="24"]{--grid-cs:24}grid>c[span-s^="25"]{--grid-cs:25}grid>c[span-s^="26"]{--grid-cs:26}grid>c[span-s^="27"]{--grid-cs:27}grid>c[span-s^="28"]{--grid-cs:28}grid>c[span-s^="29"]{--grid-cs:29}grid>c[span-s^="30"]{--grid-cs:30}grid>c[span-s$="+1"],grid>c[span-s="1"]{--grid-ce:1}grid>c[span-s$="+2"],grid>c[span-s$="-1"],grid>c[span-s="2"]{--grid-ce:2}grid>c[span-s$="+3"],grid>c[span-s$="-2"],grid>c[span-s="3"]{--grid-ce:3}grid>c[span-s$="+4"],grid>c[span-s$="-3"],grid>c[span-s="4"]{--grid-ce:4}grid>c[span-s$="+5"],grid>c[span-s$="-4"],grid>c[span-s="5"]{--grid-ce:5}grid>c[span-s$="+6"],grid>c[span-s$="-5"],grid>c[span-s="6"]{--grid-ce:6}grid>c[span-s$="+7"],grid>c[span-s$="-6"],grid>c[span-s="7"]{--grid-ce:7}grid>c[span-s$="+8"],grid>c[span-s$="-7"],grid>c[span-s="8"]{--grid-ce:8}grid>c[span-s$="+9"],grid>c[span-s$="-8"],grid>c[span-s="9"]{--grid-ce:9}grid>c[span-s$="+10"],grid>c[span-s$="-9"],grid>c[span-s="10"]{--grid-ce:10}grid>c[span-s$="+11"],grid>c[span-s$="-10"],grid>c[span-s="11"]{--grid-ce:11}grid>c[span-s$="+12"],grid>c[span-s$="-11"],grid>c[span-s="12"]{--grid-ce:12}grid>c[span-s$="+13"],grid>c[span-s$="-12"],grid>c[span-s="13"]{--grid-ce:13}grid>c[span-s$="+14"],grid>c[span-s$="-13"],grid>c[span-s="14"]{--grid-ce:14}grid>c[span-s$="+15"],grid>c[span-s$="-14"],grid>c[span-s="15"]{--grid-ce:15}grid>c[span-s$="+16"],grid>c[span-s$="-15"],grid>c[span-s="16"]{--grid-ce:16}grid>c[span-s$="+17"],grid>c[span-s$="-16"],grid>c[span-s="17"]{--grid-ce:17}grid>c[span-s$="+18"],grid>c[span-s$="-17"],grid>c[span-s="18"]{--grid-ce:18}grid>c[span-s$="+19"],grid>c[span-s$="-18"],grid>c[span-s="19"]{--grid-ce:19}grid>c[span-s$="+20"],grid>c[span-s$="-19"],grid>c[span-s="20"]{--grid-ce:20}grid>c[span-s$="+21"],grid>c[span-s$="-20"],grid>c[span-s="21"]{--grid-ce:21}grid>c[span-s$="+22"],grid>c[span-s$="-21"],grid>c[span-s="22"]{--grid-ce:22}grid>c[span-s$="+23"],grid>c[span-s$="-22"],grid>c[span-s="23"]{--grid-ce:23}grid>c[span-s$="+24"],grid>c[span-s$="-23"],grid>c[span-s="24"]{--grid-ce:24}grid>c[span-s$="+25"],grid>c[span-s$="-24"],grid>c[span-s="25"]{--grid-ce:25}grid>c[span-s$="+26"],grid>c[span-s$="-25"],grid>c[span-s="26"]{--grid-ce:26}grid>c[span-s$="+27"],grid>c[span-s$="-26"],grid>c[span-s="27"]{--grid-ce:27}grid>c[span-s$="+28"],grid>c[span-s$="-27"],grid>c[span-s="28"]{--grid-ce:28}grid>c[span-s$="+29"],grid>c[span-s$="-28"],grid>c[span-s="29"]{--grid-ce:29}grid>c[span-s$="+30"],grid>c[span-s$="-29"],grid>c[span-s="30"]{--grid-ce:30}grid>c[span-s$="-30"]{--grid-ce:31}grid>c[span-s]{grid-column-end:span var(--grid-ce)}grid>c[span-s*="+"],grid>c[span-s*="-"],grid>c[span-s*=".."]{grid-column-start:var(--grid-cs)}grid>c[span-s*="-"],grid>c[span-s*=".."]{grid-column-end:var(--grid-ce)}grid>c[span-s=row]{grid-column:1/-1}}@media only screen and (min-width:1599px){grid[columns-l="1"]{--grid-tc:repeat(1,1fr)}grid[columns-l="2"]{--grid-tc:repeat(2,1fr)}grid[columns-l="3"]{--grid-tc:repeat(3,1fr)}grid[columns-l="4"]{--grid-tc:repeat(4,1fr)}grid[columns-l="5"]{--grid-tc:repeat(5,1fr)}grid[columns-l="6"]{--grid-tc:repeat(6,1fr)}grid[columns-l="7"]{--grid-tc:repeat(7,1fr)}grid[columns-l="8"]{--grid-tc:repeat(8,1fr)}grid[columns-l="9"]{--grid-tc:repeat(9,1fr)}grid[columns-l="10"]{--grid-tc:repeat(10,1fr)}grid[columns-l="11"]{--grid-tc:repeat(11,1fr)}grid[columns-l="12"]{--grid-tc:repeat(12,1fr)}grid[columns-l="13"]{--grid-tc:repeat(13,1fr)}grid[columns-l="14"]{--grid-tc:repeat(14,1fr)}grid[columns-l="15"]{--grid-tc:repeat(15,1fr)}grid[columns-l="16"]{--grid-tc:repeat(16,1fr)}grid[columns-l="17"]{--grid-tc:repeat(17,1fr)}grid[columns-l="18"]{--grid-tc:repeat(18,1fr)}grid[columns-l="19"]{--grid-tc:repeat(19,1fr)}grid[columns-l="20"]{--grid-tc:repeat(20,1fr)}grid[columns-l="21"]{--grid-tc:repeat(21,1fr)}grid[columns-l="22"]{--grid-tc:repeat(22,1fr)}grid[columns-l="23"]{--grid-tc:repeat(23,1fr)}grid[columns-l="24"]{--grid-tc:repeat(24,1fr)}grid[columns-l="25"]{--grid-tc:repeat(25,1fr)}grid[columns-l="26"]{--grid-tc:repeat(26,1fr)}grid[columns-l="27"]{--grid-tc:repeat(27,1fr)}grid[columns-l="28"]{--grid-tc:repeat(28,1fr)}grid[columns-l="29"]{--grid-tc:repeat(29,1fr)}grid[columns-l="30"]{--grid-tc:repeat(30,1fr)}grid>c[span-l^="1"]{--grid-cs:1}grid>c[span-l^="2"]{--grid-cs:2}grid>c[span-l^="3"]{--grid-cs:3}grid>c[span-l^="4"]{--grid-cs:4}grid>c[span-l^="5"]{--grid-cs:5}grid>c[span-l^="6"]{--grid-cs:6}grid>c[span-l^="7"]{--grid-cs:7}grid>c[span-l^="8"]{--grid-cs:8}grid>c[span-l^="9"]{--grid-cs:9}grid>c[span-l^="10"]{--grid-cs:10}grid>c[span-l^="11"]{--grid-cs:11}grid>c[span-l^="12"]{--grid-cs:12}grid>c[span-l^="13"]{--grid-cs:13}grid>c[span-l^="14"]{--grid-cs:14}grid>c[span-l^="15"]{--grid-cs:15}grid>c[span-l^="16"]{--grid-cs:16}grid>c[span-l^="17"]{--grid-cs:17}grid>c[span-l^="18"]{--grid-cs:18}grid>c[span-l^="19"]{--grid-cs:19}grid>c[span-l^="20"]{--grid-cs:20}grid>c[span-l^="21"]{--grid-cs:21}grid>c[span-l^="22"]{--grid-cs:22}grid>c[span-l^="23"]{--grid-cs:23}grid>c[span-l^="24"]{--grid-cs:24}grid>c[span-l^="25"]{--grid-cs:25}grid>c[span-l^="26"]{--grid-cs:26}grid>c[span-l^="27"]{--grid-cs:27}grid>c[span-l^="28"]{--grid-cs:28}grid>c[span-l^="29"]{--grid-cs:29}grid>c[span-l^="30"]{--grid-cs:30}grid>c[span-l$="+1"],grid>c[span-l="1"]{--grid-ce:1}grid>c[span-l$="+2"],grid>c[span-l$="-1"],grid>c[span-l="2"]{--grid-ce:2}grid>c[span-l$="+3"],grid>c[span-l$="-2"],grid>c[span-l="3"]{--grid-ce:3}grid>c[span-l$="+4"],grid>c[span-l$="-3"],grid>c[span-l="4"]{--grid-ce:4}grid>c[span-l$="+5"],grid>c[span-l$="-4"],grid>c[span-l="5"]{--grid-ce:5}grid>c[span-l$="+6"],grid>c[span-l$="-5"],grid>c[span-l="6"]{--grid-ce:6}grid>c[span-l$="+7"],grid>c[span-l$="-6"],grid>c[span-l="7"]{--grid-ce:7}grid>c[span-l$="+8"],grid>c[span-l$="-7"],grid>c[span-l="8"]{--grid-ce:8}grid>c[span-l$="+9"],grid>c[span-l$="-8"],grid>c[span-l="9"]{--grid-ce:9}grid>c[span-l$="+10"],grid>c[span-l$="-9"],grid>c[span-l="10"]{--grid-ce:10}grid>c[span-l$="+11"],grid>c[span-l$="-10"],grid>c[span-l="11"]{--grid-ce:11}grid>c[span-l$="+12"],grid>c[span-l$="-11"],grid>c[span-l="12"]{--grid-ce:12}grid>c[span-l$="+13"],grid>c[span-l$="-12"],grid>c[span-l="13"]{--grid-ce:13}grid>c[span-l$="+14"],grid>c[span-l$="-13"],grid>c[span-l="14"]{--grid-ce:14}grid>c[span-l$="+15"],grid>c[span-l$="-14"],grid>c[span-l="15"]{--grid-ce:15}grid>c[span-l$="+16"],grid>c[span-l$="-15"],grid>c[span-l="16"]{--grid-ce:16}grid>c[span-l$="+17"],grid>c[span-l$="-16"],grid>c[span-l="17"]{--grid-ce:17}grid>c[span-l$="+18"],grid>c[span-l$="-17"],grid>c[span-l="18"]{--grid-ce:18}grid>c[span-l$="+19"],grid>c[span-l$="-18"],grid>c[span-l="19"]{--grid-ce:19}grid>c[span-l$="+20"],grid>c[span-l$="-19"],grid>c[span-l="20"]{--grid-ce:20}grid>c[span-l$="+21"],grid>c[span-l$="-20"],grid>c[span-l="21"]{--grid-ce:21}grid>c[span-l$="+22"],grid>c[span-l$="-21"],grid>c[span-l="22"]{--grid-ce:22}grid>c[span-l$="+23"],grid>c[span-l$="-22"],grid>c[span-l="23"]{--grid-ce:23}grid>c[span-l$="+24"],grid>c[span-l$="-23"],grid>c[span-l="24"]{--grid-ce:24}grid>c[span-l$="+25"],grid>c[span-l$="-24"],grid>c[span-l="25"]{--grid-ce:25}grid>c[span-l$="+26"],grid>c[span-l$="-25"],grid>c[span-l="26"]{--grid-ce:26}grid>c[span-l$="+27"],grid>c[span-l$="-26"],grid>c[span-l="27"]{--grid-ce:27}grid>c[span-l$="+28"],grid>c[span-l$="-27"],grid>c[span-l="28"]{--grid-ce:28}grid>c[span-l$="+29"],grid>c[span-l$="-28"],grid>c[span-l="29"]{--grid-ce:29}grid>c[span-l$="+30"],grid>c[span-l$="-29"],grid>c[span-l="30"]{--grid-ce:30}grid>c[span-l$="-30"]{--grid-ce:31}grid>c[span-l]{grid-column-end:span var(--grid-ce)}grid>c[span-l*="+"],grid>c[span-l*="-"],grid>c[span-l*=".."]{grid-column-start:var(--grid-cs)}grid>c[span-l*="-"],grid>c[span-l*=".."]{grid-column-end:var(--grid-ce)}grid>c[span-l=row]{grid-column:1/-1}}grid.debug>*{--color:rgba(248,110,91,0.3);background-image:linear-gradient(180deg,var(--color) 0,var(--color))}grid.debug>:nth-child(6n+2){--color:rgba(103,126,208,0.3)}grid.debug>:nth-child(6n+3){--color:rgba(224,174,72,0.3)}grid.debug>:nth-child(6n+4){--color:rgba(77,214,115,0.3)}grid.debug>:nth-child(6n+5){--color:rgba(217,103,219,0.3)}grid.debug>:nth-child(6n+6){--color:rgba(94,204,211,0.3)}grid.debug>:nth-child(6n+7){--color:rgba(248,110,91,0.3)}html{font-family:Inter,-system-ui,system-ui,sans-serif}@supports (font-variation-settings:normal){html{font-family:Inter var,-system-ui,system-ui,sans-serif}}html{font-size:var(--fontSize);line-height:var(--lineHeight);background:#fff;color:#000;letter-spacing:-.01em;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;-ms-text-size-adjust:100%;text-size-adjust:100%;-webkit-font-variant-ligatures:contextual common-ligatures;font-variant-ligatures:contextual common-ligatures;-webkit-font-feature-settings:"cv10" 1;font-feature-settings:"cv10" 1}body{-webkit-overflow-scrolling:touch;scroll-behavior:smooth;overflow-x:hidden;padding:calc(var(--lineHeight)*2);padding-bottom:calc(var(--lineHeight)*3)}@media only screen and (max-width:600px){body{padding:var(--lineHeight);padding-bottom:calc(var(--lineHeight)*2)}}[flow-cols-l],[flow-cols-s],[flow-cols]{-webkit-column-gap:var(--columnGap);column-gap:var(--columnGap);-webkit-column-fill:balance;column-fill:balance}[flow-cols="1"]{-webkit-column-count:1;column-count:1}[flow-cols="2"]{-webkit-column-count:2;column-count:2}[flow-cols="3"]{-webkit-column-count:3;column-count:3}[flow-cols="4"]{-webkit-column-count:4;column-count:4}[flow-cols="5"]{-webkit-column-count:5;column-count:5}[flow-cols="6"]{-webkit-column-count:6;column-count:6}[flow-cols="7"]{-webkit-column-count:7;column-count:7}[flow-cols="8"]{-webkit-column-count:8;column-count:8}@media only screen and (max-width:600px){[flow-cols-s="1"]{-webkit-column-count:1;column-count:1}[flow-cols-s="2"]{-webkit-column-count:2;column-count:2}[flow-cols-s="3"]{-webkit-column-count:3;column-count:3}[flow-cols-s="4"]{-webkit-column-count:4;column-count:4}[flow-cols-s="5"]{-webkit-column-count:5;column-count:5}[flow-cols-s="6"]{-webkit-column-count:6;column-count:6}[flow-cols-s="7"]{-webkit-column-count:7;column-count:7}[flow-cols-s="8"]{-webkit-column-count:8;column-count:8}}@media only screen and (min-width:1599px){[flow-cols-l="1"]{-webkit-column-count:1;column-count:1}[flow-cols-l="2"]{-webkit-column-count:2;column-count:2}[flow-cols-l="3"]{-webkit-column-count:3;column-count:3}[flow-cols-l="4"]{-webkit-column-count:4;column-count:4}[flow-cols-l="5"]{-webkit-column-count:5;column-count:5}[flow-cols-l="6"]{-webkit-column-count:6;column-count:6}[flow-cols-l="7"]{-webkit-column-count:7;column-count:7}[flow-cols-l="8"]{-webkit-column-count:8;column-count:8}}address,article,aside,blockquote,dd,dl,dt,fieldset,figure,form,grid,h1,h2,h3,h4,h5,h6,li,nav,ol,p,pre,table,tfoot,ul,video{margin-top:var(--blockSpacingTop);margin-bottom:var(--blockSpacingBottom)}:first-child{margin-top:unset}:last-child{margin-bottom:unset}hr:first-child{margin-top:calc(var(--hrThickness)/-2);margin-bottom:calc(var(--lineHeight) - var(--hrThickness)/2)}hr:last-child{margin-bottom:calc(var(--hrThickness)/-2)}hr,hr:last-child,hr:only-child{margin-top:calc(var(--lineHeight) - var(--hrThickness)/2)}hr,hr:only-child{border:none;background:#000;height:var(--hrThickness);margin-bottom:calc(var(--lineHeight) - var(--hrThickness)/2)}*+hr:last-child{margin-top:calc(var(--hrThickness)/-2)}hr:not(:first-child){margin-top:var(--lineHeight);margin-bottom:calc(var(--lineHeight) - var(--hrThickness))}grid>hr{grid-column:1/-1}grid>hr,grid>hr:not(:first-child):not(:last-child){margin-top:calc(var(--lineHeight) - var(--hrThickness));margin-bottom:0}code,pre,textarea.code,tt{font-family:IBM Plex Mono,monospace;-webkit-font-feature-settings:"ss02" 1,"zero" 1;font-feature-settings:"ss02" 1,"zero" 1}pre{white-space:pre-wrap}code{white-space:nowrap}.bold,b,strong{font-weight:600}.italic,em,i{font-style:italic}code b,pre b,textarea.code b,tt b{font-weight:540}h{display:block;-moz-appearance:none;appearance:none;-webkit-appearance:none}.h1,h,h1{font-weight:720;letter-spacing:-.05em;font-size:var(--h1-size);line-height:calc(var(--lineHeight)*2);margin-left:calc(var(--h1-size)/-22);margin-top:calc(var(--lineHeight)*2);margin-bottom:var(--lineHeight);word-break:break-word}h1.single-line{margin-top:var(--lineHeight);padding-top:calc(var(--lineHeight)*0.5)}h1.single-line,h1.single-line:first-child{padding-bottom:calc(var(--lineHeight)*0.5)}h1.single-line:first-child{margin-top:0}.h2,h2{font-weight:700;letter-spacing:-.03em;font-size:var(--h2-size);line-height:calc(var(--lineHeight)*2);margin-left:calc(var(--h2-size)/-26);margin-bottom:var(--lineHeight)}*+h2,h2.single-line{margin-top:var(--lineHeight);padding-top:calc(var(--lineHeight)*0.5);padding-bottom:calc(var(--lineHeight)*0.5);margin-bottom:0}h2.single-line:first-child{margin-top:unset}.h3,.h4,h3,h4{font-weight:700;letter-spacing:-.02em;font-size:var(--h3-size);padding-top:calc(var(--baseline)*0.75);padding-bottom:calc(var(--baseline)*0.25);margin-bottom:var(--baseline)}.h4,h4{font-weight:700;letter-spacing:-.012em;font-size:var(--h4-size)}h3.single-line,h4.single-line{padding-bottom:calc(var(--baseline)*1.25);margin-bottom:0}h3+h1,h3+h1.single-line,h4+h1,h4+h1.single-line{margin-top:calc(var(--baseline)*3)}h3.single-line+h1,h3.single-line+h1.single-line,h3.single-line+h2,h3.single-line+h2.single-line,h4.single-line+h1,h4.single-line+h1.single-line,h4.single-line+h2,h4.single-line+h2.single-line{margin-top:var(--lineHeight)}h3+h2,h3+h2.single-line,h4+h2,h4+h2.single-line{margin-top:var(--baseline)}.h5,.h6,h5,h6{font-weight:670;letter-spacing:-.015em}.h5,.h6,grid>c.h1,grid>c.h2,grid>c.h3,grid>c.h4,grid>c.h5,grid>c.h6,h5,h6{margin-bottom:0}.h1.large,h1.large{--h1-size:4rem;line-height:calc(var(--lineHeight)*3);font-weight:730}.h1.xlarge,h1.xlarge{--h1-size:5.5rem;line-height:calc(var(--lineHeight)*4);font-weight:740}.h1.xxlarge,h1.xxlarge{--h1-size:7.5rem;line-height:calc(var(--lineHeight)*5);font-weight:750}.h1.xxxlarge,h1.xxxlarge{--h1-size:10.5rem;line-height:calc(var(--lineHeight)*7);font-weight:760}.small{font-size:.85rem;line-height:var(--lineHeight)}.xsmall{font-size:.8em;line-height:calc(var(--lineHeight)*0.75);padding-top:calc(var(--lineHeight)*0.25)}.xxsmall{font-size:.65em;line-height:calc(var(--lineHeight)*0.7);padding-top:calc(var(--lineHeight)*0.3)}.xxxsmall{font-size:.5em;line-height:calc(var(--lineHeight)*0.5);padding-bottom:calc(var(--lineHeight)*0.25)}a{text-decoration:underline;-webkit-text-decoration:underline rgba(0,0,0,.3);text-decoration:underline rgba(0,0,0,.3);white-space:nowrap}a:hover{color:var(--blue)}.h1>a,.h2>a,.h3>a,.h4>a,.h5>a,.h6>a,h1>a,h2>a,h3>a,h4>a,h5>a,h6>a{text-decoration:none}.h1>a:hover,.h2>a:hover,.h3>a:hover,.h4>a:hover,.h5>a:hover,.h6>a:hover,h1>a:hover,h2>a:hover,h3>a:hover,h4>a:hover,h5>a:hover,h6>a:hover{text-decoration:underline;-webkit-text-decoration:underline rgba(0,0,0,.3);text-decoration:underline rgba(0,0,0,.3);color:inherit}img,img:first-child,img:last-child{display:block;margin-top:var(--baseline);margin-bottom:var(--baseline)}img:only-child{margin:0}*+img{margin-top:calc(var(--baseline)*-1)}img.cover,img.fill{-o-object-fit:cover;object-fit:cover}grid>c>img,grid>c>p>img{-o-object-fit:contain;object-fit:contain;max-width:100%}grid{grid-column-gap:var(--columnGap);grid-row-gap:var(--rowGap)}grid.compact{grid-row-gap:0}ol,ul{list-style-position:outside}ol.compact>li,ul.compact>li{margin:0}ul{padding-left:1.3em}ol{list-style:none;counter-reset:counter1;padding-left:2em}ol>li{counter-increment:counter1;position:relative}ol>li:before{content:counter(counter1) ". ";font-weight:500;position:absolute;--space:0.5em;--width:2em;left:calc(-1*var(--width));width:var(--width);height:var(--lineHeight);text-align:left}@media only screen and (max-width:600px){.only-large-window{display:none}}@media only screen and (min-width:601px){.only-small-window{display:none}}.show-base-grid{background-image:repeating-linear-gradient(0deg,hsla(0,0%,47.1%,.05),hsla(0,0%,47.1%,.05) 1px,transparent 0,transparent calc(var(--baseline)/2),rgba(20,230,245,.3) calc(var(--baseline)/2),rgba(20,230,245,.3) calc(var(--baseline)/2 + 1px),transparent calc(var(--baseline)/2 + 1px),transparent var(--baseline));background-repeat:repeat-y;background-size:100% var(--baseline);background-position:0 .5px}.single-line{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.block{display:block}.inline{display:inline-block}.flex-h{display:flex;flex-direction:row}.flex-v{display:flex;flex-direction:column}.left{text-align:left}.right{text-align:right}.center{text-align:center}.flex-v.center{align-self:center}.flex-h .bottom{align-self:flex-end}img.top{-o-object-position:center top;object-position:center top;align-self:center}img.center{-o-object-position:center center;object-position:center center;align-self:center}img.bottom{-o-object-position:center bottom;object-position:center bottom;align-self:center}img.left.top{-o-object-position:left top;object-position:left top;align-self:flex-start}img.left.center{-o-object-position:left center;object-position:left center;align-self:flex-start}img.left.bottom{-o-object-position:left bottom;object-position:left bottom;align-self:flex-start}img.right.top{-o-object-position:right top;object-position:right top;align-self:flex-end}img.right.center{-o-object-position:right center;object-position:right center;align-self:flex-end}img.right.bottom{-o-object-position:right bottom;object-position:right bottom;align-self:flex-end}.padding0{padding:0}.padding1{padding:calc(var(--lineHeight)*1)}.padding2{padding:calc(var(--lineHeight)*2)}.padding3{padding:calc(var(--lineHeight)*3)}.padding4{padding:calc(var(--lineHeight)*4)}.padding5{padding:calc(var(--lineHeight)*5);padding:calc(var(--lineHeight)*6);padding:calc(var(--lineHeight)*7);padding:calc(var(--lineHeight)*8)}.margin0{margin:0}.margin1{margin:calc(var(--lineHeight)*1)}.margin2{margin:calc(var(--lineHeight)*2)}.margin3{margin:calc(var(--lineHeight)*3)}.margin4{margin:calc(var(--lineHeight)*4)}.margin5{margin:calc(var(--lineHeight)*5);margin:calc(var(--lineHeight)*6);margin:calc(var(--lineHeight)*7);margin:calc(var(--lineHeight)*8)}.w-1{width:calc(var(--lineHeight)*1)}.w-2{width:calc(var(--lineHeight)*2)}.w-3{width:calc(var(--lineHeight)*3)}.w-4{width:calc(var(--lineHeight)*4)}.w-5{width:calc(var(--lineHeight)*5)}.w-6{width:calc(var(--lineHeight)*6)}.w-7{width:calc(var(--lineHeight)*7)}.w-8{width:calc(var(--lineHeight)*8)}.w-9{width:calc(var(--lineHeight)*9)}.w-10{width:calc(var(--lineHeight)*10)}.w-11{width:calc(var(--lineHeight)*11)}.w-12{width:calc(var(--lineHeight)*12)}.w-13{width:calc(var(--lineHeight)*13)}.w-14{width:calc(var(--lineHeight)*14)}.w-15{width:calc(var(--lineHeight)*15)}.w-16{width:calc(var(--lineHeight)*16)}.w-17{width:calc(var(--lineHeight)*17)}.w-18{width:calc(var(--lineHeight)*18)}.w-19{width:calc(var(--lineHeight)*19)}.w-20{width:calc(var(--lineHeight)*20)}.w-21{width:calc(var(--lineHeight)*21)}.w-22{width:calc(var(--lineHeight)*22)}.w-23{width:calc(var(--lineHeight)*23)}.w-24{width:calc(var(--lineHeight)*24)}.w-25{width:calc(var(--lineHeight)*25)}.w-26{width:calc(var(--lineHeight)*26)}.w-27{width:calc(var(--lineHeight)*27)}.w-28{width:calc(var(--lineHeight)*28)}.w-29{width:calc(var(--lineHeight)*29)}.w-30{width:calc(var(--lineHeight)*30)}.w-31{width:calc(var(--lineHeight)*31)}.w-32{width:calc(var(--lineHeight)*32)}.w-33{width:calc(var(--lineHeight)*33)}.w-34{width:calc(var(--lineHeight)*34)}.w-35{width:calc(var(--lineHeight)*35)}.w-36{width:calc(var(--lineHeight)*36)}.w-37{width:calc(var(--lineHeight)*37)}.w-38{width:calc(var(--lineHeight)*38)}.w-39{width:calc(var(--lineHeight)*39)}.w-40{width:calc(var(--lineHeight)*40)}.h-1{height:calc(var(--lineHeight)*1)}.h-2{height:calc(var(--lineHeight)*2)}.h-3{height:calc(var(--lineHeight)*3)}.h-4{height:calc(var(--lineHeight)*4)}.h-5{height:calc(var(--lineHeight)*5)}.h-6{height:calc(var(--lineHeight)*6)}.h-7{height:calc(var(--lineHeight)*7)}.h-8{height:calc(var(--lineHeight)*8)}.h-9{height:calc(var(--lineHeight)*9)}.h-10{height:calc(var(--lineHeight)*10)}.h-11{height:calc(var(--lineHeight)*11)}.h-12{height:calc(var(--lineHeight)*12)}.h-13{height:calc(var(--lineHeight)*13)}.h-14{height:calc(var(--lineHeight)*14)}.h-15{height:calc(var(--lineHeight)*15)}.h-16{height:calc(var(--lineHeight)*16)}.h-17{height:calc(var(--lineHeight)*17)}.h-18{height:calc(var(--lineHeight)*18)}.h-19{height:calc(var(--lineHeight)*19)}.h-20{height:calc(var(--lineHeight)*20)}.h-21{height:calc(var(--lineHeight)*21)}.h-22{height:calc(var(--lineHeight)*22)}.h-23{height:calc(var(--lineHeight)*23)}.h-24{height:calc(var(--lineHeight)*24)}.h-25{height:calc(var(--lineHeight)*25)}.h-26{height:calc(var(--lineHeight)*26)}.h-27{height:calc(var(--lineHeight)*27)}.h-28{height:calc(var(--lineHeight)*28)}.h-29{height:calc(var(--lineHeight)*29)}.h-30{height:calc(var(--lineHeight)*30)}.h-31{height:calc(var(--lineHeight)*31)}.h-32{height:calc(var(--lineHeight)*32)}.h-33{height:calc(var(--lineHeight)*33)}.h-34{height:calc(var(--lineHeight)*34)}.h-35{height:calc(var(--lineHeight)*35)}.h-36{height:calc(var(--lineHeight)*36)}.h-37{height:calc(var(--lineHeight)*37)}.h-38{height:calc(var(--lineHeight)*38)}.h-39{height:calc(var(--lineHeight)*39)}.h-40{height:calc(var(--lineHeight)*40)} -------------------------------------------------------------------------------- /examples/.npmignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | **/build 3 | **/.ts* 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /examples/basic/README.md: -------------------------------------------------------------------------------- 1 | Example of a simple plugin written in TypeScript 2 | -------------------------------------------------------------------------------- /examples/basic/manifest.js: -------------------------------------------------------------------------------- 1 | { api: "1.0.0", 2 | name: "figplug example: basic", 3 | id: "YOUR_ID_HERE", 4 | main: "plugin.ts", 5 | } 6 | -------------------------------------------------------------------------------- /examples/basic/plugin.ts: -------------------------------------------------------------------------------- 1 | const redPaint :SolidPaint = { type: "SOLID", color: { r: 255, g: 0, b: 0 } } 2 | 3 | let rect = figma.createRectangle() 4 | rect.fills = [ redPaint ] 5 | 6 | figma.closePlugin() 7 | -------------------------------------------------------------------------------- /examples/build-all.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | cd "$(dirname "$0")" 3 | 4 | for p in \ 5 | basic \ 6 | ui \ 7 | ui-html \ 8 | ui-react \ 9 | ;do 10 | ../bin/figplug -v "$p" & 11 | done 12 | 13 | wait 14 | -------------------------------------------------------------------------------- /examples/extra-lib/README.md: -------------------------------------------------------------------------------- 1 | Demonstrates including libraries in plugins. 2 | 3 | A library is code included in the global scope of the plugin. 4 | 5 | Libraries come in two forms: 6 | 7 | 1. JavaScript with optional type definitions 8 | 2. Pure type definitions 9 | 10 | Libraries which are purely type definitions are useful in cases where you want to declare 11 | global types but require no implementation. 12 | 13 | Libraries with implementation code in JavaScript simply have their JS code added to the 14 | beginning of the output product code, and are included and adjusted for in sourcemaps. 15 | The order of library code in the output product is determined by: 16 | 17 | - order of `-lib` and `-uilib` flags, followed by 18 | - order of `figplug.libs` and `figplug.uilibs` in a manifest file 19 | 20 | Example: Consider the following manifest: 21 | 22 | ```json 23 | { "api": "1.0.0", 24 | "name": "extra-lib", 25 | "main": "plugin.ts", 26 | "ui": "ui.ts", 27 | "figplug": { 28 | "libs": ["libone", "libtwo", "libthree"], 29 | "uilibs": ["uilib2", "libone"] 30 | } 31 | } 32 | ``` 33 | 34 | And the following invocation to `build`: 35 | 36 | ```txt 37 | figplug build -lib=libone -uilib=uilib1 38 | ``` 39 | 40 | This would produce a `plugin.js` file with the following code: 41 | 42 | 1. figplug built-in helpers, like `assert` 43 | 2. code of libone 44 | 3. code of libtwo 45 | 4. code of libthree 46 | 5. code of plugin.ts 47 | 48 | And a `ui.html` file with the following code: (wrapped in HTML) 49 | 50 | 1. figplug built-in helpers, like `assert` 51 | 2. code of uilib1 52 | 3. code of uilib2 53 | 4. code of libone 54 | 5. code of ui.ts 55 | 56 | A Library with JavaScript code may also include a `.d.ts` TypeScript definition file, 57 | which when exists is provided as global definitions (a "lib" in TypeScript terminology.) 58 | -------------------------------------------------------------------------------- /examples/extra-lib/figma.d.ts: -------------------------------------------------------------------------------- 1 | // Global variable with Figma's plugin API. 2 | declare const figma: PluginAPI 3 | declare const __html__: string 4 | 5 | interface PluginAPI { 6 | readonly apiVersion: "1.0.0" 7 | readonly command: string 8 | readonly root: DocumentNode 9 | readonly viewport: ViewportAPI 10 | closePlugin(message?: string): void 11 | 12 | showUI(html: string, options?: ShowUIOptions): void 13 | readonly ui: UIAPI 14 | 15 | readonly clientStorage: ClientStorageAPI 16 | 17 | getNodeById(id: string): BaseNode | null 18 | getStyleById(id: string): BaseStyle | null 19 | 20 | currentPage: PageNode 21 | 22 | readonly mixed: symbol 23 | 24 | createRectangle(): RectangleNode 25 | createLine(): LineNode 26 | createEllipse(): EllipseNode 27 | createPolygon(): PolygonNode 28 | createStar(): StarNode 29 | createVector(): VectorNode 30 | createText(): TextNode 31 | createBooleanOperation(): BooleanOperationNode 32 | createFrame(): FrameNode 33 | createComponent(): ComponentNode 34 | createPage(): PageNode 35 | createSlice(): SliceNode 36 | 37 | createPaintStyle(): PaintStyle 38 | createTextStyle(): TextStyle 39 | createEffectStyle(): EffectStyle 40 | createGridStyle(): GridStyle 41 | 42 | importComponentByKeyAsync(key: string): Promise 43 | importStyleByKeyAsync(key: string): Promise 44 | 45 | listAvailableFontsAsync(): Promise 46 | loadFontAsync(fontName: FontName): Promise 47 | readonly hasMissingFont: boolean 48 | 49 | createNodeFromSvg(svg: string): FrameNode 50 | 51 | createImage(data: Uint8Array): Image 52 | getImageByHash(hash: string): Image 53 | 54 | group(nodes: ReadonlyArray, parent: BaseNode & ChildrenMixin, index?: number): FrameNode 55 | flatten(nodes: ReadonlyArray, parent?: BaseNode & ChildrenMixin, index?: number): VectorNode 56 | } 57 | 58 | interface ClientStorageAPI { 59 | getAsync(key: string): Promise 60 | setAsync(key: string, value: any): Promise 61 | } 62 | 63 | type ShowUIOptions = { 64 | visible?: boolean, 65 | width?: number, 66 | height?: number, 67 | } 68 | 69 | type UIPostMessageOptions = { 70 | targetOrigin?: string, 71 | } 72 | 73 | type OnMessageProperties = { 74 | sourceOrigin: string, 75 | } 76 | 77 | interface UIAPI { 78 | show(): void 79 | hide(): void 80 | resize(width: number, height: number): void 81 | close(): void 82 | 83 | postMessage(pluginMessage: any, options?: UIPostMessageOptions): void 84 | onmessage: ((pluginMessage: any, props: OnMessageProperties) => void) | undefined 85 | } 86 | 87 | interface ViewportAPI { 88 | center: { x: number, y: number } 89 | zoom: number 90 | scrollAndZoomIntoView(nodes: ReadonlyArray) 91 | } 92 | 93 | //////////////////////////////////////////////////////////////////////////////// 94 | // Datatypes 95 | 96 | type Transform = [ 97 | [number, number, number], 98 | [number, number, number] 99 | ] 100 | 101 | interface Vector { 102 | readonly x: number 103 | readonly y: number 104 | } 105 | 106 | interface RGB { 107 | readonly r: number 108 | readonly g: number 109 | readonly b: number 110 | } 111 | 112 | interface RGBA { 113 | readonly r: number 114 | readonly g: number 115 | readonly b: number 116 | readonly a: number 117 | } 118 | 119 | interface FontName { 120 | readonly family: string 121 | readonly style: string 122 | } 123 | 124 | type TextCase = "ORIGINAL" | "UPPER" | "LOWER" | "TITLE" 125 | 126 | type TextDecoration = "NONE" | "UNDERLINE" | "STRIKETHROUGH" 127 | 128 | interface ArcData { 129 | readonly startingAngle: number 130 | readonly endingAngle: number 131 | readonly innerRadius: number 132 | } 133 | 134 | interface ShadowEffect { 135 | readonly type: "DROP_SHADOW" | "INNER_SHADOW" 136 | readonly color: RGBA 137 | readonly offset: Vector 138 | readonly radius: number 139 | readonly visible: boolean 140 | readonly blendMode: BlendMode 141 | } 142 | 143 | interface BlurEffect { 144 | readonly type: "LAYER_BLUR" | "BACKGROUND_BLUR" 145 | readonly radius: number 146 | readonly visible: boolean 147 | } 148 | 149 | type Effect = ShadowEffect | BlurEffect 150 | 151 | type ConstraintType = "MIN" | "CENTER" | "MAX" | "STRETCH" | "SCALE" 152 | 153 | interface Constraints { 154 | readonly horizontal: ConstraintType 155 | readonly vertical: ConstraintType 156 | } 157 | 158 | interface ColorStop { 159 | readonly position: number 160 | readonly color: RGBA 161 | } 162 | 163 | interface ImageFilters { 164 | exposure?: number 165 | contrast?: number 166 | saturation?: number 167 | temperature?: number 168 | tint?: number 169 | highlights?: number 170 | shadows?: number 171 | } 172 | 173 | interface SolidPaint { 174 | readonly type: "SOLID" 175 | readonly color: RGB 176 | 177 | readonly visible?: boolean 178 | readonly opacity?: number 179 | readonly blendMode?: BlendMode 180 | } 181 | 182 | interface GradientPaint { 183 | readonly type: "GRADIENT_LINEAR" | "GRADIENT_RADIAL" | "GRADIENT_ANGULAR" | "GRADIENT_DIAMOND" 184 | readonly gradientTransform: Transform 185 | readonly gradientStops: ReadonlyArray 186 | 187 | readonly visible?: boolean 188 | readonly opacity?: number 189 | readonly blendMode?: BlendMode 190 | } 191 | 192 | interface ImagePaint { 193 | readonly type: "IMAGE" 194 | readonly scaleMode: "FILL" | "FIT" | "CROP" | "TILE" 195 | readonly imageHash: string | null 196 | readonly imageTransform?: Transform // setting for "CROP" 197 | readonly scalingFactor?: number // setting for "TILE" 198 | readonly filters?: ImageFilters 199 | 200 | readonly visible?: boolean 201 | readonly opacity?: number 202 | readonly blendMode?: BlendMode 203 | } 204 | 205 | type Paint = SolidPaint | GradientPaint | ImagePaint 206 | 207 | interface Guide { 208 | readonly axis: "X" | "Y" 209 | readonly offset: number 210 | } 211 | 212 | interface RowsColsLayoutGrid { 213 | readonly pattern: "ROWS" | "COLUMNS" 214 | readonly alignment: "MIN" | "MAX" | "STRETCH" | "CENTER" 215 | readonly gutterSize: number 216 | 217 | readonly count: number // Infinity when "Auto" is set in the UI 218 | readonly sectionSize?: number // Not set for alignment: "STRETCH" 219 | readonly offset?: number // Not set for alignment: "CENTER" 220 | 221 | readonly visible?: boolean 222 | readonly color?: RGBA 223 | } 224 | 225 | interface GridLayoutGrid { 226 | readonly pattern: "GRID" 227 | readonly sectionSize: number 228 | 229 | readonly visible?: boolean 230 | readonly color?: RGBA 231 | } 232 | 233 | type LayoutGrid = RowsColsLayoutGrid | GridLayoutGrid 234 | 235 | interface ExportSettingsConstraints { 236 | type: "SCALE" | "WIDTH" | "HEIGHT" 237 | value: number 238 | } 239 | 240 | interface ExportSettingsImage { 241 | format: "JPG" | "PNG" 242 | contentsOnly?: boolean // defaults to true 243 | suffix?: string 244 | constraint?: ExportSettingsConstraints 245 | } 246 | 247 | interface ExportSettingsSVG { 248 | format: "SVG" 249 | contentsOnly?: boolean // defaults to true 250 | suffix?: string 251 | svgOutlineText?: boolean // defaults to true 252 | svgIdAttribute?: boolean // defaults to false 253 | svgSimplifyStroke?: boolean // defaults to true 254 | } 255 | 256 | interface ExportSettingsPDF { 257 | format: "PDF" 258 | contentsOnly?: boolean // defaults to true 259 | suffix?: string 260 | } 261 | 262 | type ExportSettings = ExportSettingsImage | ExportSettingsSVG | ExportSettingsPDF 263 | 264 | type WindingRule = "NONZERO" | "EVENODD" 265 | 266 | interface VectorVertex { 267 | readonly x: number 268 | readonly y: number 269 | readonly strokeCap?: StrokeCap 270 | readonly strokeJoin?: StrokeJoin 271 | readonly cornerRadius?: number 272 | readonly handleMirroring?: HandleMirroring 273 | } 274 | 275 | interface VectorSegment { 276 | readonly start: number 277 | readonly end: number 278 | readonly tangentStart?: Vector // Defaults to { x: 0, y: 0 } 279 | readonly tangentEnd?: Vector // Defaults to { x: 0, y: 0 } 280 | } 281 | 282 | interface VectorRegion { 283 | readonly windingRule: WindingRule 284 | readonly loops: ReadonlyArray> 285 | } 286 | 287 | interface VectorNetwork { 288 | readonly vertices: ReadonlyArray 289 | readonly segments: ReadonlyArray 290 | readonly regions?: ReadonlyArray // Defaults to [] 291 | } 292 | 293 | interface VectorPath { 294 | readonly windingRule: WindingRule | "NONE" 295 | readonly data: string 296 | } 297 | 298 | type VectorPaths = ReadonlyArray 299 | 300 | type LetterSpacing = { 301 | readonly value: number 302 | readonly unit: "PIXELS" | "PERCENT" 303 | } 304 | 305 | type LineHeight = { 306 | readonly value: number 307 | readonly unit: "PIXELS" | "PERCENT" 308 | } | { 309 | readonly unit: "AUTO" 310 | } 311 | 312 | type BlendMode = 313 | "PASS_THROUGH" | 314 | "NORMAL" | 315 | "DARKEN" | 316 | "MULTIPLY" | 317 | "LINEAR_BURN" | 318 | "COLOR_BURN" | 319 | "LIGHTEN" | 320 | "SCREEN" | 321 | "LINEAR_DODGE" | 322 | "COLOR_DODGE" | 323 | "OVERLAY" | 324 | "SOFT_LIGHT" | 325 | "HARD_LIGHT" | 326 | "DIFFERENCE" | 327 | "EXCLUSION" | 328 | "HUE" | 329 | "SATURATION" | 330 | "COLOR" | 331 | "LUMINOSITY" 332 | 333 | interface Font { 334 | fontName: FontName 335 | } 336 | 337 | //////////////////////////////////////////////////////////////////////////////// 338 | // Mixins 339 | 340 | interface BaseNodeMixin { 341 | readonly id: string 342 | readonly parent: (BaseNode & ChildrenMixin) | null 343 | name: string // Note: setting this also sets `autoRename` to false on TextNodes 344 | readonly removed: boolean 345 | toString(): string 346 | remove(): void 347 | 348 | getPluginData(key: string): string 349 | setPluginData(key: string, value: string): void 350 | 351 | // Namespace is a string that must be at least 3 alphanumeric characters, and should 352 | // be a name related to your plugin. Other plugins will be able to read this data. 353 | getSharedPluginData(namespace: string, key: string): string 354 | setSharedPluginData(namespace: string, key: string, value: string): void 355 | } 356 | 357 | interface SceneNodeMixin { 358 | visible: boolean 359 | locked: boolean 360 | } 361 | 362 | interface ChildrenMixin { 363 | readonly children: ReadonlyArray 364 | 365 | appendChild(child: BaseNode): void 366 | insertChild(index: number, child: BaseNode): void 367 | 368 | findAll(callback?: (node: BaseNode) => boolean): ReadonlyArray 369 | findOne(callback: (node: BaseNode) => boolean): BaseNode | null 370 | } 371 | 372 | interface ConstraintMixin { 373 | constraints: Constraints 374 | } 375 | 376 | interface LayoutMixin { 377 | readonly absoluteTransform: Transform 378 | relativeTransform: Transform 379 | x: number 380 | y: number 381 | rotation: number // In degrees 382 | 383 | readonly width: number 384 | readonly height: number 385 | 386 | resize(width: number, height: number): void 387 | resizeWithoutConstraints(width: number, height: number): void 388 | } 389 | 390 | interface BlendMixin { 391 | opacity: number 392 | blendMode: BlendMode 393 | isMask: boolean 394 | effects: ReadonlyArray 395 | effectStyleId: string 396 | } 397 | 398 | interface FrameMixin { 399 | backgrounds: ReadonlyArray 400 | layoutGrids: ReadonlyArray 401 | clipsContent: boolean 402 | guides: ReadonlyArray 403 | gridStyleId: string 404 | backgroundStyleId: string 405 | } 406 | 407 | type StrokeCap = "NONE" | "ROUND" | "SQUARE" | "ARROW_LINES" | "ARROW_EQUILATERAL" 408 | type StrokeJoin = "MITER" | "BEVEL" | "ROUND" 409 | type HandleMirroring = "NONE" | "ANGLE" | "ANGLE_AND_LENGTH" 410 | 411 | interface GeometryMixin { 412 | fills: ReadonlyArray | symbol 413 | strokes: ReadonlyArray 414 | strokeWeight: number 415 | strokeAlign: "CENTER" | "INSIDE" | "OUTSIDE" 416 | strokeCap: StrokeCap | symbol 417 | strokeJoin: StrokeJoin | symbol 418 | dashPattern: ReadonlyArray 419 | fillStyleId: string | symbol 420 | strokeStyleId: string 421 | } 422 | 423 | interface CornerMixin { 424 | cornerRadius: number | symbol 425 | cornerSmoothing: number 426 | } 427 | 428 | interface ExportMixin { 429 | exportSettings: ExportSettings[] 430 | exportAsync(settings?: ExportSettings): Promise // Defaults to PNG format 431 | } 432 | 433 | interface DefaultShapeMixin extends 434 | BaseNodeMixin, SceneNodeMixin, 435 | BlendMixin, GeometryMixin, LayoutMixin, ExportMixin { 436 | } 437 | 438 | interface DefaultContainerMixin extends 439 | BaseNodeMixin, SceneNodeMixin, 440 | ChildrenMixin, FrameMixin, 441 | BlendMixin, ConstraintMixin, LayoutMixin, ExportMixin { 442 | } 443 | 444 | //////////////////////////////////////////////////////////////////////////////// 445 | // Nodes 446 | 447 | interface DocumentNode extends BaseNodeMixin, ChildrenMixin { 448 | readonly type: "DOCUMENT" 449 | } 450 | 451 | interface PageNode extends BaseNodeMixin, ChildrenMixin, ExportMixin { 452 | readonly type: "PAGE" 453 | clone(): PageNode 454 | 455 | guides: ReadonlyArray 456 | selection: ReadonlyArray 457 | } 458 | 459 | interface FrameNode extends DefaultContainerMixin { 460 | readonly type: "FRAME" | "GROUP" 461 | clone(): FrameNode 462 | } 463 | 464 | interface SliceNode extends BaseNodeMixin, SceneNodeMixin, LayoutMixin, ExportMixin { 465 | readonly type: "SLICE" 466 | clone(): SliceNode 467 | } 468 | 469 | interface RectangleNode extends DefaultShapeMixin, ConstraintMixin, CornerMixin { 470 | readonly type: "RECTANGLE" 471 | clone(): RectangleNode 472 | topLeftRadius: number 473 | topRightRadius: number 474 | bottomLeftRadius: number 475 | bottomRightRadius: number 476 | } 477 | 478 | interface LineNode extends DefaultShapeMixin, ConstraintMixin { 479 | readonly type: "LINE" 480 | clone(): LineNode 481 | } 482 | 483 | interface EllipseNode extends DefaultShapeMixin, ConstraintMixin, CornerMixin { 484 | readonly type: "ELLIPSE" 485 | clone(): EllipseNode 486 | arcData: ArcData 487 | } 488 | 489 | interface PolygonNode extends DefaultShapeMixin, ConstraintMixin, CornerMixin { 490 | readonly type: "POLYGON" 491 | clone(): PolygonNode 492 | pointCount: number 493 | } 494 | 495 | interface StarNode extends DefaultShapeMixin, ConstraintMixin, CornerMixin { 496 | readonly type: "STAR" 497 | clone(): StarNode 498 | pointCount: number 499 | innerRadius: number 500 | } 501 | 502 | interface VectorNode extends DefaultShapeMixin, ConstraintMixin, CornerMixin { 503 | readonly type: "VECTOR" 504 | clone(): VectorNode 505 | vectorNetwork: VectorNetwork 506 | vectorPaths: VectorPaths 507 | handleMirroring: HandleMirroring | symbol 508 | } 509 | 510 | interface TextNode extends DefaultShapeMixin, ConstraintMixin { 511 | readonly type: "TEXT" 512 | clone(): TextNode 513 | characters: string 514 | readonly hasMissingFont: boolean 515 | textAlignHorizontal: "LEFT" | "CENTER" | "RIGHT" | "JUSTIFIED" 516 | textAlignVertical: "TOP" | "CENTER" | "BOTTOM" 517 | textAutoResize: "NONE" | "WIDTH_AND_HEIGHT" | "HEIGHT" 518 | paragraphIndent: number 519 | paragraphSpacing: number 520 | autoRename: boolean 521 | 522 | textStyleId: string | symbol 523 | fontSize: number | symbol 524 | fontName: FontName | symbol 525 | textCase: TextCase | symbol 526 | textDecoration: TextDecoration | symbol 527 | letterSpacing: LetterSpacing | symbol 528 | lineHeight: LineHeight | symbol 529 | 530 | getRangeFontSize(start: number, end: number): number | symbol 531 | setRangeFontSize(start: number, end: number, value: number): void 532 | getRangeFontName(start: number, end: number): FontName | symbol 533 | setRangeFontName(start: number, end: number, value: FontName): void 534 | getRangeTextCase(start: number, end: number): TextCase | symbol 535 | setRangeTextCase(start: number, end: number, value: TextCase): void 536 | getRangeTextDecoration(start: number, end: number): TextDecoration | symbol 537 | setRangeTextDecoration(start: number, end: number, value: TextDecoration): void 538 | getRangeLetterSpacing(start: number, end: number): LetterSpacing | symbol 539 | setRangeLetterSpacing(start: number, end: number, value: LetterSpacing): void 540 | getRangeLineHeight(start: number, end: number): LineHeight | symbol 541 | setRangeLineHeight(start: number, end: number, value: LineHeight): void 542 | getRangeFills(start: number, end: number): Paint[] | symbol 543 | setRangeFills(start: number, end: number, value: Paint[]): void 544 | getRangeTextStyleId(start: number, end: number): string | symbol 545 | setRangeTextStyleId(start: number, end: number, value: string): void 546 | getRangeFillStyleId(start: number, end: number): string | symbol 547 | setRangeFillStyleId(start: number, end: number, value: string): void 548 | } 549 | 550 | interface ComponentNode extends DefaultContainerMixin { 551 | readonly type: "COMPONENT" 552 | clone(): ComponentNode 553 | 554 | createInstance(): InstanceNode 555 | description: string 556 | readonly remote: boolean 557 | readonly key: string // The key to use with "importComponentByKeyAsync" 558 | } 559 | 560 | interface InstanceNode extends DefaultContainerMixin { 561 | readonly type: "INSTANCE" 562 | clone(): InstanceNode 563 | masterComponent: ComponentNode 564 | } 565 | 566 | interface BooleanOperationNode extends DefaultShapeMixin, ChildrenMixin, CornerMixin { 567 | readonly type: "BOOLEAN_OPERATION" 568 | clone(): BooleanOperationNode 569 | booleanOperation: "UNION" | "INTERSECT" | "SUBTRACT" | "EXCLUDE" 570 | } 571 | 572 | type BaseNode = 573 | DocumentNode | 574 | PageNode | 575 | SceneNode 576 | 577 | type SceneNode = 578 | SliceNode | 579 | FrameNode | 580 | ComponentNode | 581 | InstanceNode | 582 | BooleanOperationNode | 583 | VectorNode | 584 | StarNode | 585 | LineNode | 586 | EllipseNode | 587 | PolygonNode | 588 | RectangleNode | 589 | TextNode 590 | 591 | type NodeType = 592 | "DOCUMENT" | 593 | "PAGE" | 594 | "SLICE" | 595 | "FRAME" | 596 | "GROUP" | 597 | "COMPONENT" | 598 | "INSTANCE" | 599 | "BOOLEAN_OPERATION" | 600 | "VECTOR" | 601 | "STAR" | 602 | "LINE" | 603 | "ELLIPSE" | 604 | "POLYGON" | 605 | "RECTANGLE" | 606 | "TEXT" 607 | 608 | //////////////////////////////////////////////////////////////////////////////// 609 | // Styles 610 | type StyleType = "PAINT" | "TEXT" | "EFFECT" | "GRID" 611 | 612 | interface BaseStyle { 613 | readonly id: string 614 | readonly type: StyleType 615 | name: string 616 | description: string 617 | remote: boolean 618 | readonly key: string // The key to use with "importStyleByKeyAsync" 619 | remove(): void 620 | } 621 | 622 | interface PaintStyle extends BaseStyle { 623 | type: "PAINT" 624 | paints: ReadonlyArray 625 | } 626 | 627 | interface TextStyle extends BaseStyle { 628 | type: "TEXT" 629 | fontSize: number 630 | textDecoration: TextDecoration 631 | fontName: FontName 632 | letterSpacing: LetterSpacing 633 | lineHeight: LineHeight 634 | paragraphIndent: number 635 | paragraphSpacing: number 636 | textCase: TextCase 637 | } 638 | 639 | interface EffectStyle extends BaseStyle { 640 | type: "EFFECT" 641 | effects: ReadonlyArray 642 | } 643 | 644 | interface GridStyle extends BaseStyle { 645 | type: "GRID" 646 | layoutGrids: ReadonlyArray 647 | } 648 | 649 | //////////////////////////////////////////////////////////////////////////////// 650 | // Other 651 | 652 | interface Image { 653 | readonly hash: string 654 | getBytesAsync(): Promise 655 | } 656 | -------------------------------------------------------------------------------- /examples/extra-lib/figplug.d.ts: -------------------------------------------------------------------------------- 1 | // Helpers provided automatically, as needed, by figplug. 2 | 3 | // symbolic type aliases 4 | type int = number 5 | type float = number 6 | type byte = number 7 | type bool = boolean 8 | 9 | // compile-time constants 10 | declare const DEBUG :boolean 11 | declare const VERSION :string 12 | 13 | // global namespace. Same as `window` in a regular web context. 14 | declare const global :{[k:string]:any} 15 | 16 | // panic prints a message, stack trace and exits the process 17 | // 18 | declare function panic(msg :any, ...v :any[]) :void 19 | 20 | // repr returns a detailed string representation of the input 21 | // 22 | declare function repr(obj :any) :string 23 | 24 | // print works just like console.log 25 | declare function print(msg :any, ...v :any[]) :void 26 | 27 | // dlog works just like console.log but is stripped out from non-debug builds 28 | declare function dlog(msg :any, ...v :any[]) :void 29 | 30 | // assert checks the condition for truth, and if false, prints an optional 31 | // message, stack trace and exits the process. 32 | // assert is removed in release builds 33 | declare var assert :AssertFun 34 | declare var AssertionError :ErrorConstructor 35 | declare interface AssertFun { 36 | (cond :any, msg? :string, cons? :Function) :void 37 | 38 | // throws can be set to true to cause assertions to be thrown as exceptions, 39 | // or set to false to cause the process to exit. 40 | // Only has an effect in Nodejs-like environments. 41 | // false by default. 42 | throws :bool 43 | } 44 | -------------------------------------------------------------------------------- /examples/extra-lib/libone.d.ts: -------------------------------------------------------------------------------- 1 | declare var libone :string 2 | -------------------------------------------------------------------------------- /examples/extra-lib/libone.js: -------------------------------------------------------------------------------- 1 | var libone = "Hello from libone" 2 | -------------------------------------------------------------------------------- /examples/extra-lib/libthree.d.ts: -------------------------------------------------------------------------------- 1 | // types-only "library" 2 | declare interface LibThree { 3 | three :number 4 | } 5 | -------------------------------------------------------------------------------- /examples/extra-lib/libtwo.js: -------------------------------------------------------------------------------- 1 | console.log("Hello from libtwo") 2 | -------------------------------------------------------------------------------- /examples/extra-lib/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "api": "1.0.0", 3 | "name": "extra-lib", 4 | "main": "plugin.ts", 5 | "ui": "ui.ts", 6 | "figplug": { 7 | // libraries 8 | "libs": ["libone", "libtwo", "libthree"], 9 | "uilibs": ["uilib2", "libone"] 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /examples/extra-lib/plugin.ts: -------------------------------------------------------------------------------- 1 | // this comes from libone, enabled by -lib=libone 2 | print("libone:", libone) 3 | 4 | // this type is defined by libthree, enabled by -lib=libthree 5 | let libthree :LibThree 6 | 7 | // Do nothing in this simple example 8 | figma.closePlugin() 9 | -------------------------------------------------------------------------------- /examples/extra-lib/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "target": "es2017", 5 | "lib": [ 6 | "es2017", 7 | "dom" 8 | ] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /examples/extra-lib/ui.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | Replaced by plugin at runtime 8 | -------------------------------------------------------------------------------- /examples/extra-lib/ui.ts: -------------------------------------------------------------------------------- 1 | 2 | // as an alternative to a .d.ts file for a lib, its interface can 3 | // be declared at use instead. 4 | declare var uilib2 :string 5 | 6 | document.body.innerText = ` 7 | value of uilib1: ${uilib1} 8 | value of uilib2: ${uilib2} 9 | value of libone: ${libone} 10 | ` 11 | -------------------------------------------------------------------------------- /examples/extra-lib/uilib1.d.ts: -------------------------------------------------------------------------------- 1 | declare var uilib1 :string 2 | -------------------------------------------------------------------------------- /examples/extra-lib/uilib1.js: -------------------------------------------------------------------------------- 1 | var uilib1 = "Hello from uilib1" 2 | -------------------------------------------------------------------------------- /examples/extra-lib/uilib2.js: -------------------------------------------------------------------------------- 1 | var uilib2 = "O hai from uilib2" 2 | -------------------------------------------------------------------------------- /examples/manifest-build/README.md: -------------------------------------------------------------------------------- 1 | This demonstrates making use of the `build` property of manifest.json 2 | which makes Figma run figplug automatically. 3 | 4 | Build with `figplug -o=. src` 5 | -------------------------------------------------------------------------------- /examples/manifest-build/manifest.json: -------------------------------------------------------------------------------- 1 | { "version": "0.6.0", 2 | "name": "figplug example: manifest-build", 3 | "script": "plugin.js", 4 | "build": "../../bin/figplug -o=. src" 5 | } -------------------------------------------------------------------------------- /examples/manifest-build/src/manifest.js: -------------------------------------------------------------------------------- 1 | { api: "1.0.0", 2 | name: "figplug example: manifest-build", 3 | main: "plugin.ts", 4 | build: "../../bin/figplug -o=. src", 5 | } -------------------------------------------------------------------------------- /examples/manifest-build/src/plugin.ts: -------------------------------------------------------------------------------- 1 | const redPaint :SolidPaint = { type: "SOLID", color: { r: 1, g: 0, b: 0 } } 2 | 3 | function createRectangles(count :number) { 4 | const nodes :SceneNode[] = [] 5 | for (let i = 0; i < count; i++) { 6 | const rect = figma.createRectangle() 7 | rect.x = i * 150 8 | rect.fills = [ redPaint ] 9 | figma.currentPage.appendChild(rect) 10 | nodes.push(rect) 11 | } 12 | figma.currentPage.selection = nodes 13 | figma.viewport.scrollAndZoomIntoView(nodes) 14 | } 15 | 16 | figma.showUI(__html__) 17 | 18 | figma.ui.onmessage = msg => { 19 | if (msg.type === 'create-rectangles' && typeof msg.count == 'number') { 20 | createRectangles(msg.count) 21 | } 22 | figma.closePlugin() 23 | } 24 | -------------------------------------------------------------------------------- /examples/ui-html/README.md: -------------------------------------------------------------------------------- 1 | Example of a simple plugin written in TypeScript with a HTML-only UI 2 | -------------------------------------------------------------------------------- /examples/ui-html/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /examples/ui-html/manifest.json: -------------------------------------------------------------------------------- 1 | { "api": "1.0.0", 2 | "name": "figplug example: ui-ts-html", 3 | "id": "YOUR_ID_HERE", 4 | "main": "plugin.ts", 5 | "ui": "ui.html", 6 | 7 | // by default, the VERSION constant is set from the nearest package.json, 8 | // but its value can be explicitly specified like this: 9 | "figplug": { "version": "1.2.3" } 10 | } 11 | -------------------------------------------------------------------------------- /examples/ui-html/plugin.ts: -------------------------------------------------------------------------------- 1 | const redPaint :SolidPaint = { type: "SOLID", color: { r: 1, g: 0, b: 0 } } 2 | 3 | function createRectangles(count :number) { 4 | const nodes :SceneNode[] = [] 5 | for (let i = 0; i < count; i++) { 6 | const rect = figma.createRectangle() 7 | rect.x = i * 150 8 | rect.fills = [ redPaint ] 9 | figma.currentPage.appendChild(rect) 10 | nodes.push(rect) 11 | } 12 | figma.currentPage.selection = nodes 13 | figma.viewport.scrollAndZoomIntoView(nodes) 14 | } 15 | 16 | figma.showUI(__html__) 17 | 18 | figma.ui.onmessage = msg => { 19 | if (msg.type === 'create-rectangles' && typeof msg.count == 'number') { 20 | createRectangles(msg.count) 21 | } 22 | figma.closePlugin() 23 | } 24 | 25 | console.log("VERSION", VERSION) 26 | -------------------------------------------------------------------------------- /examples/ui-html/ui.html: -------------------------------------------------------------------------------- 1 |

Rectangle Creator

2 | 3 | 4 | hello 5 | 6 | 10 | 11 |

Count:

12 | 13 | 14 | 27 | -------------------------------------------------------------------------------- /examples/ui-react/README.md: -------------------------------------------------------------------------------- 1 | Example of a simple plugin with UI written in TypeScript with HTML and CSS 2 | -------------------------------------------------------------------------------- /examples/ui-react/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /examples/ui-react/assets/rectangle.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsms/figplug/a3f377f6140728ff4990018425c506e8356fff53/examples/ui-react/assets/rectangle.jpg -------------------------------------------------------------------------------- /examples/ui-react/assets/under-construction.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsms/figplug/a3f377f6140728ff4990018425c506e8356fff53/examples/ui-react/assets/under-construction.gif -------------------------------------------------------------------------------- /examples/ui-react/manifest.json: -------------------------------------------------------------------------------- 1 | { "api": "1.0.0", 2 | "name": "figplug example: ui-ts", 3 | "id": "YOUR_ID_HERE", 4 | "main": "plugin.ts", 5 | "ui": "ui.tsx" 6 | } 7 | -------------------------------------------------------------------------------- /examples/ui-react/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ui-react", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "@types/prop-types": { 8 | "version": "15.7.1", 9 | "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.1.tgz", 10 | "integrity": "sha512-CFzn9idOEpHrgdw8JsoTkaDDyRWk1jrzIV8djzcgpq0y9tG4B4lFT+Nxh52DVpDXV+n4+NPNv7M1Dj5uMp6XFg==" 11 | }, 12 | "@types/react": { 13 | "version": "16.8.19", 14 | "resolved": "https://registry.npmjs.org/@types/react/-/react-16.8.19.tgz", 15 | "integrity": "sha512-QzEzjrd1zFzY9cDlbIiFvdr+YUmefuuRYrPxmkwG0UQv5XF35gFIi7a95m1bNVcFU0VimxSZ5QVGSiBmlggQXQ==", 16 | "requires": { 17 | "@types/prop-types": "*", 18 | "csstype": "^2.2.0" 19 | } 20 | }, 21 | "@types/react-dom": { 22 | "version": "16.8.4", 23 | "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-16.8.4.tgz", 24 | "integrity": "sha512-eIRpEW73DCzPIMaNBDP5pPIpK1KXyZwNgfxiVagb5iGiz6da+9A5hslSX6GAQKdO7SayVCS/Fr2kjqprgAvkfA==", 25 | "requires": { 26 | "@types/react": "*" 27 | } 28 | }, 29 | "csstype": { 30 | "version": "2.6.5", 31 | "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.5.tgz", 32 | "integrity": "sha512-JsTaiksRsel5n7XwqPAfB0l3TFKdpjW/kgAELf9vrb5adGA7UCPLajKK5s3nFrcFm3Rkyp/Qkgl73ENc1UY3cA==" 33 | }, 34 | "js-tokens": { 35 | "version": "4.0.0", 36 | "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", 37 | "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" 38 | }, 39 | "loose-envify": { 40 | "version": "1.4.0", 41 | "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", 42 | "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", 43 | "requires": { 44 | "js-tokens": "^3.0.0 || ^4.0.0" 45 | } 46 | }, 47 | "object-assign": { 48 | "version": "4.1.1", 49 | "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", 50 | "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" 51 | }, 52 | "prop-types": { 53 | "version": "15.7.2", 54 | "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", 55 | "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", 56 | "requires": { 57 | "loose-envify": "^1.4.0", 58 | "object-assign": "^4.1.1", 59 | "react-is": "^16.8.1" 60 | } 61 | }, 62 | "react": { 63 | "version": "16.8.6", 64 | "resolved": "https://registry.npmjs.org/react/-/react-16.8.6.tgz", 65 | "integrity": "sha512-pC0uMkhLaHm11ZSJULfOBqV4tIZkx87ZLvbbQYunNixAAvjnC+snJCg0XQXn9VIsttVsbZP/H/ewzgsd5fxKXw==", 66 | "requires": { 67 | "loose-envify": "^1.1.0", 68 | "object-assign": "^4.1.1", 69 | "prop-types": "^15.6.2", 70 | "scheduler": "^0.13.6" 71 | } 72 | }, 73 | "react-dom": { 74 | "version": "16.8.6", 75 | "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.8.6.tgz", 76 | "integrity": "sha512-1nL7PIq9LTL3fthPqwkvr2zY7phIPjYrT0jp4HjyEQrEROnw4dG41VVwi/wfoCneoleqrNX7iAD+pXebJZwrwA==", 77 | "requires": { 78 | "loose-envify": "^1.1.0", 79 | "object-assign": "^4.1.1", 80 | "prop-types": "^15.6.2", 81 | "scheduler": "^0.13.6" 82 | } 83 | }, 84 | "react-is": { 85 | "version": "16.8.6", 86 | "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.6.tgz", 87 | "integrity": "sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==" 88 | }, 89 | "scheduler": { 90 | "version": "0.13.6", 91 | "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.13.6.tgz", 92 | "integrity": "sha512-IWnObHt413ucAYKsD9J1QShUKkbKLQQHdxRyw73sw4FN26iWr3DY/H34xGPe4nmL1DwXyWmSWmMrA9TfQbE/XQ==", 93 | "requires": { 94 | "loose-envify": "^1.1.0", 95 | "object-assign": "^4.1.1" 96 | } 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /examples/ui-react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ui-react", 3 | "version": "1.0.0", 4 | "description": "Example of a plugin which UI is made using React", 5 | "main": "build/plugin.js", 6 | "scripts": { 7 | "build": "figplug -O" 8 | }, 9 | "author": "", 10 | "license": "MIT", 11 | "dependencies": { 12 | "@types/react": "^16.8.19", 13 | "@types/react-dom": "^16.8.4", 14 | "react": "^16.8.6", 15 | "react-dom": "^16.8.6" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /examples/ui-react/plugin.ts: -------------------------------------------------------------------------------- 1 | const redPaint :SolidPaint = { type: "SOLID", color: { r: 1, g: 0, b: 0 } } 2 | 3 | function createRectangles(count :number) { 4 | const nodes :SceneNode[] = [] 5 | for (let i = 0; i < count; i++) { 6 | const rect = figma.createRectangle() 7 | rect.x = i * 150 8 | rect.fills = [ redPaint ] 9 | figma.currentPage.appendChild(rect) 10 | nodes.push(rect) 11 | } 12 | figma.currentPage.selection = nodes 13 | figma.viewport.scrollAndZoomIntoView(nodes) 14 | } 15 | 16 | figma.showUI(__html__) 17 | 18 | figma.ui.onmessage = msg => { 19 | if (msg.type === 'create-rectangles' && typeof msg.count == 'number') { 20 | createRectangles(msg.count) 21 | } 22 | figma.closePlugin() 23 | } 24 | -------------------------------------------------------------------------------- /examples/ui-react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /examples/ui-react/ui.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: lightpink; 3 | } 4 | 5 | .app { 6 | background: white; 7 | } 8 | 9 | p { 10 | /* Supports cssnext features like nested rules */ 11 | & input { 12 | border: 1px solid black; 13 | border-radius: 3px; 14 | background: white; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /examples/ui-react/ui.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | 4 | // example of embedding and importing assets/resources 5 | import logoSvg from './assets/logo.svg' 6 | import logoSvgJsx from './assets/logo.svg?jsx' 7 | import gif from './assets/under-construction.gif' 8 | import jpegImage from './assets/rectangle.jpg' 9 | import packageJson from './package.json' 10 | 11 | class App extends React.Component { 12 | textbox: HTMLInputElement 13 | 14 | countRef = (element: HTMLInputElement) => { 15 | if (element) element.value = '5' 16 | this.textbox = element 17 | } 18 | 19 | onCreate = () => { 20 | const count = parseInt(this.textbox.value, 10) 21 | parent.postMessage({ 22 | pluginMessage: { type: 'create-rectangles', count } 23 | }, '*') 24 | } 25 | 26 | onCancel = () => { 27 | parent.postMessage({ pluginMessage: { type: 'cancel' } }, '*') 28 | } 29 | 30 | render() { 31 | return
32 | 33 | 34 |
39 | {logoSvgJsx} 40 |
41 |         {JSON.stringify(packageJson, null, 2)}
42 |       
43 |

Rectangle Creator

44 |

Count:

45 | 46 | 47 |
48 | } 49 | } 50 | 51 | ReactDOM.render(, document.getElementById('root')) 52 | -------------------------------------------------------------------------------- /examples/ui/README.md: -------------------------------------------------------------------------------- 1 | Example of a simple plugin with UI written in TypeScript with HTML and CSS 2 | -------------------------------------------------------------------------------- /examples/ui/manifest.json: -------------------------------------------------------------------------------- 1 | { "api": "1.0.0", 2 | "name": "figplug example: ui-ts", 3 | "id": "YOUR_ID_HERE", 4 | "main": "plugin.ts", 5 | "ui": "ui.ts" 6 | } 7 | -------------------------------------------------------------------------------- /examples/ui/plugin.ts: -------------------------------------------------------------------------------- 1 | const redPaint :SolidPaint = { type: "SOLID", color: { r: 1, g: 0, b: 0 } } 2 | 3 | function createRectangles(count :number) { 4 | const nodes :SceneNode[] = [] 5 | for (let i = 0; i < count; i++) { 6 | const rect = figma.createRectangle() 7 | rect.x = i * 150 8 | rect.fills = [ redPaint ] 9 | figma.currentPage.appendChild(rect) 10 | nodes.push(rect) 11 | } 12 | figma.currentPage.selection = nodes 13 | figma.viewport.scrollAndZoomIntoView(nodes) 14 | } 15 | 16 | figma.showUI(__html__) 17 | 18 | figma.ui.onmessage = msg => { 19 | if (msg.type === 'create-rectangles' && typeof msg.count == 'number') { 20 | createRectangles(msg.count) 21 | } 22 | figma.closePlugin() 23 | } 24 | -------------------------------------------------------------------------------- /examples/ui/ui.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: lightpink; 3 | } 4 | 5 | p { 6 | /* Supports cssnext features like nested rules */ 7 | & input { 8 | border: 1px solid black; 9 | border-radius: 3px; 10 | background: white; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /examples/ui/ui.html: -------------------------------------------------------------------------------- 1 |

Rectangle Creator

2 |

Count:

3 | 4 | -------------------------------------------------------------------------------- /examples/ui/ui.ts: -------------------------------------------------------------------------------- 1 | 2 | function id(obj :T) :Exclude { 3 | if (obj === undefined || obj === null) { 4 | throw new Error("null value") 5 | } 6 | return obj as any as Exclude 7 | } 8 | 9 | id(document.getElementById('create')).onclick = () => { 10 | const textbox = id(document.getElementById('count') as HTMLInputElement) 11 | const count = parseInt(textbox.value, 10) 12 | parent.postMessage({ pluginMessage: { type: 'create-rectangles', count } }, '*') 13 | } 14 | 15 | id(document.getElementById('cancel')).onclick = () => { 16 | parent.postMessage({ pluginMessage: { type: 'cancel' } }, '*') 17 | } 18 | -------------------------------------------------------------------------------- /lib/.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /lib/figplug.d.ts: -------------------------------------------------------------------------------- 1 | // Helpers provided automatically, as needed, by figplug. 2 | 3 | // symbolic type aliases 4 | type int = number 5 | type float = number 6 | type byte = number 7 | type bool = boolean 8 | 9 | // compile-time constants 10 | declare const DEBUG :boolean 11 | declare const VERSION :string 12 | 13 | // global namespace. Same as `window` in a regular web context. 14 | declare const global :{[k:string]:any} 15 | 16 | // panic prints a message, stack trace and exits the process 17 | // 18 | declare function panic(msg :any, ...v :any[]) :void 19 | 20 | // repr returns a detailed string representation of the input 21 | // 22 | declare function repr(obj :any) :string 23 | 24 | // print works just like console.log 25 | declare function print(msg :any, ...v :any[]) :void 26 | 27 | // dlog works just like console.log but is stripped out from non-debug builds 28 | declare function dlog(msg :any, ...v :any[]) :void 29 | 30 | // assert checks the condition for truth, and if false, prints an optional 31 | // message, stack trace and exits the process. 32 | // assert is removed in release builds 33 | declare var assert :AssertFun 34 | declare var AssertionError :ErrorConstructor 35 | declare interface AssertFun { 36 | (cond :any, msg? :string, cons? :Function) :void 37 | 38 | // throws can be set to true to cause assertions to be thrown as exceptions, 39 | // or set to false to cause the process to exit. 40 | // Only has an effect in Nodejs-like environments. 41 | // false by default. 42 | throws :bool 43 | } 44 | -------------------------------------------------------------------------------- /lib/figplug.js: -------------------------------------------------------------------------------- 1 | var global = ( 2 | typeof global != 'undefined' ? global : 3 | typeof window != 'undefined' ? window : 4 | this 5 | ) 6 | 7 | function _stackTrace(cons) { 8 | const x = {stack:''} 9 | if (Error.captureStackTrace) { 10 | Error.captureStackTrace(x, cons) 11 | const p = x.stack.indexOf('\n') 12 | if (p != -1) { 13 | return x.stack.substr(p+1) 14 | } 15 | } 16 | return x.stack 17 | } 18 | 19 | // _parseStackFrame(sf :string) : StackFrameInfo | null 20 | // interface StackFrameInfo { 21 | // func :string 22 | // file :string 23 | // line :int 24 | // col :int 25 | // } 26 | // 27 | function _parseStackFrame(sf) { 28 | let m = /^\s*at\s+([^\s]+)\s+\((?:.+\/(src\/[^\:]+)|([^\:]+))\:(\d+)\:(\d+)\)$/.exec(sf) 29 | if (m) { 30 | return { 31 | func: m[1], 32 | file: m[2] || m[3], 33 | line: parseInt(m[4]), 34 | col: parseInt(m[5]), 35 | } 36 | } 37 | return null 38 | } 39 | 40 | function panic(msg) { 41 | console.error.apply(console, 42 | ['panic:', msg].concat(Array.prototype.slice.call(arguments, 1)) 43 | ) 44 | if (typeof process != 'undefined') { 45 | console.error(_stackTrace(panic)) 46 | process.exit(2) 47 | } else { 48 | let e = new Error(msg) 49 | e.name = 'Panic' 50 | throw e 51 | } 52 | } 53 | 54 | function print() { 55 | console.log.apply(console, Array.prototype.slice.call(arguments)) 56 | } 57 | 58 | const dlog = DEBUG ? console.log.bind(console, '[debug]') : ()=>{} 59 | 60 | function assert() { 61 | if (DEBUG) { // for DCE 62 | var cond = arguments[0] 63 | , msg = arguments[1] 64 | , cons = arguments[2] || assert 65 | if (!cond) { 66 | if (!assert.throws && typeof process != 'undefined') { 67 | var stack = _stackTrace(cons) 68 | console.error('assertion failure:', msg || cond) 69 | var sf = _parseStackFrame(stack.substr(0, stack.indexOf('\n') >>> 0)) 70 | if (sf) { 71 | try { 72 | const fs = require('fs') 73 | const lines = fs.readFileSync(sf.file, 'utf8').split(/\n/) 74 | const line_before = lines[sf.line - 2] 75 | const line = lines[sf.line - 1] 76 | const line_after = lines[sf.line] 77 | let context = [' > ' + line] 78 | if (typeof line_before == 'string') { 79 | context.unshift(' ' + line_before) 80 | } 81 | if (typeof line_after == 'string') { 82 | context.push(' ' + line_after) 83 | } 84 | console.error(sf.file + ':' + sf.line + ':' + sf.col) 85 | console.error(context.join('\n') + '\n\nStack trace:') 86 | } catch (_) {} 87 | } 88 | console.error(stack) 89 | exit(3) 90 | } else { 91 | var e = new Error('assertion failure: ' + (msg || cond)) 92 | e.name = 'AssertionError' 93 | e.stack = _stackTrace(cons) 94 | throw e 95 | } 96 | } 97 | } 98 | } 99 | 100 | function repr(obj) { 101 | // TODO: something better 102 | try { 103 | return JSON.stringify(obj, null, 2) 104 | } catch (_) { 105 | return String(obj) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /lib/template-package-react.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.0.1", 3 | "dependencies": { 4 | "@types/react": "^16.0.0", 5 | "@types/react-dom": "^16.0.0", 6 | "react": "^16.0.0", 7 | "react-dom": "^16.0.0" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /lib/template-plugin-ui.ts: -------------------------------------------------------------------------------- 1 | const redPaint :SolidPaint = { type: "SOLID", color: { r: 1, g: 0, b: 0 } } 2 | 3 | function createRectangles(count :number) { 4 | const nodes :SceneNode[] = [] 5 | for (let i = 0; i < count; i++) { 6 | const rect = figma.createRectangle() 7 | rect.x = i * 150 8 | rect.fills = [ redPaint ] 9 | figma.currentPage.appendChild(rect) 10 | nodes.push(rect) 11 | } 12 | figma.currentPage.selection = nodes 13 | figma.viewport.scrollAndZoomIntoView(nodes) 14 | } 15 | 16 | figma.showUI(__html__) 17 | 18 | figma.ui.onmessage = msg => { 19 | if (msg.type === 'create-rectangles' && typeof msg.count == 'number') { 20 | createRectangles(msg.count) 21 | } 22 | figma.closePlugin() 23 | } 24 | -------------------------------------------------------------------------------- /lib/template-plugin.ts: -------------------------------------------------------------------------------- 1 | const redPaint :SolidPaint = { type: "SOLID", color: { r: 1, g: 0, b: 0 } } 2 | 3 | const nodes :SceneNode[] = [] 4 | for (let i = 0; i < 4; i++) { 5 | const rect = figma.createRectangle() 6 | rect.x = i * 150 7 | rect.fills = [ redPaint ] 8 | figma.currentPage.appendChild(rect) 9 | nodes.push(rect) 10 | } 11 | figma.currentPage.selection = nodes 12 | figma.viewport.scrollAndZoomIntoView(nodes) 13 | figma.closePlugin() 14 | -------------------------------------------------------------------------------- /lib/template-tsconfig.json: -------------------------------------------------------------------------------- 1 | // Note: this is used as both a template for new projects as well 2 | // as the default config file for projects that does not declare one. 3 | // Therefore this needs to be fully functional. 4 | { 5 | "compilerOptions": { 6 | "moduleResolution": "node", 7 | "target": "es2017", 8 | "lib": [ 9 | "es2017", 10 | "dom" 11 | ] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /lib/template-ui-react.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 | 8 | 9 | -------------------------------------------------------------------------------- /lib/template-ui-react.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | 4 | class App extends React.Component { 5 | textbox: HTMLInputElement 6 | 7 | countRef = (element: HTMLInputElement) => { 8 | if (element) element.value = '5' 9 | this.textbox = element 10 | } 11 | 12 | onCreate = () => { 13 | const count = parseInt(this.textbox.value, 10) 14 | parent.postMessage({ 15 | pluginMessage: { type: 'create-rectangles', count } 16 | }, '*') 17 | } 18 | 19 | onCancel = () => { 20 | parent.postMessage({ pluginMessage: { type: 'cancel' } }, '*') 21 | } 22 | 23 | render() { 24 | return
25 |

Rectangle Creator

26 |

Count:

27 | 28 | 29 |
30 | } 31 | } 32 | 33 | ReactDOM.render(, document.getElementById('root')) 34 | -------------------------------------------------------------------------------- /lib/template-ui.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |

Rectangle Creator

7 |

Count:

8 | 9 | 10 | 23 | 24 | -------------------------------------------------------------------------------- /lib/template-ui.ts: -------------------------------------------------------------------------------- 1 | 2 | document.getElementById('create')!.onclick = () => { 3 | const textbox = document.getElementById('count')! as HTMLInputElement 4 | const count = parseInt(textbox.value, 10) 5 | parent.postMessage({ pluginMessage: { type: 'create-rectangles', count } }, '*') 6 | } 7 | 8 | document.getElementById('cancel')!.onclick = () => { 9 | parent.postMessage({ pluginMessage: { type: 'cancel' } }, '*') 10 | } 11 | -------------------------------------------------------------------------------- /lib/template-ui.ts.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |

Rectangle Creator

6 |

Count:

7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /lib/template.css: -------------------------------------------------------------------------------- 1 | @import url("https://rsms.me/inter/inter.css"); 2 | 3 | /* reset */ 4 | * { font-family: inherit; line-height: inherit; font-synthesis: none; } 5 | a, abbr, acronym, address, applet, article, aside, audio, b, big, blockquote, 6 | body, canvas, caption, center, cite, code, dd, del, details, dfn, div, dl, dt, 7 | em, embed, fieldset, figcaption, figure, footer, form, grid, h1, h2, h3, h4, h5, 8 | h6, header, hgroup, hr, html, i, iframe, img, ins, kbd, label, legend, li, main, 9 | mark, menu, nav, noscript, object, ol, output, p, pre, q, s, samp, section, 10 | small, span, strike, strong, sub, summary, sup, table, tbody, td, tfoot, th, 11 | thead, time, tr, tt, u, ul, var, video { 12 | margin: 0; 13 | padding: 0; 14 | border: 0; 15 | vertical-align: baseline; 16 | } 17 | blockquote, q { quotes: none; } 18 | blockquote:before, blockquote:after, q:before, q:after { 19 | content: ""; 20 | content: none; 21 | } 22 | table { 23 | border-collapse: collapse; 24 | border-spacing: 0; 25 | } 26 | a, a:active, a:visited { color: inherit; } 27 | /* end of reset */ 28 | 29 | body { 30 | background: white; 31 | color: #222; 32 | font: 12px/16px 'Inter', system-ui, -system-ui, sans-serif; 33 | font-feature-settings: 'kern' 1, 'liga' 1, 'calt' 1; 34 | } 35 | @supports (font-variation-settings: normal) { 36 | body { font-family: 'Inter var', system-ui, sans-serif; } 37 | } 38 | -------------------------------------------------------------------------------- /misc/install-globally-from-source.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | cd "$(dirname "$0")/.." 3 | SRCDIR=$PWD 4 | 5 | ./build.js -O 6 | 7 | rm -rf build/npm-package 8 | mkdir -p build/npm-package 9 | pushd build/npm-package > /dev/null 10 | 11 | if ! (npm pack "$SRCDIR" > /dev/null 2>&1); then # very noisy 12 | # repeat to print errors 13 | npm pack "$SRCDIR" 14 | exit 1 15 | fi 16 | TAR_FILE=$(echo figplug-*.tgz) 17 | npm uninstall -g figplug 18 | npm install -g "$TAR_FILE" 19 | 20 | echo "Installed local version globally as figplug." 21 | echo "To uninstall: npm uninstall -g figplug" 22 | 23 | popd > /dev/null 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "figplug", 3 | "version": "0.1.20", 4 | "description": "Figma plugin builder", 5 | "bin": { 6 | "figplug": "bin/figplug" 7 | }, 8 | "license": "MIT", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/rsms/figplug.git" 12 | }, 13 | "keywords": [ 14 | "figma", 15 | "helper", 16 | "plugin", 17 | "builder", 18 | "compiler" 19 | ], 20 | "author": "Rasmus Andersson ", 21 | "homepage": "https://rsms.me/figplug/", 22 | "files": [ 23 | "LICENSE", 24 | "README.md", 25 | "bin/figplug", 26 | "bin/figplug.map", 27 | "lib", 28 | "examples", 29 | ".gitignore", 30 | "build.js", 31 | "test.sh", 32 | "tsconfig.json", 33 | "src/*.ts", 34 | "src/*.js", 35 | "deps/*.sh", 36 | "deps/*.js" 37 | ], 38 | "scripts": { 39 | "build-o": "node build.js -O", 40 | "build-g": "node build.js -w" 41 | }, 42 | "engines": { 43 | "node": ">=8.0.0" 44 | }, 45 | "dependencies": { 46 | "postcss-nesting": "^7.0.1", 47 | "rollup": "^1.26.3", 48 | "rollup-plugin-commonjs": "^10.1.0", 49 | "rollup-plugin-node-resolve": "^5.2.0", 50 | "rollup-plugin-typescript2": "^0.25.2", 51 | "svgo": "^1.3.2", 52 | "typescript": "^3.9.7" 53 | }, 54 | "devDependencies": { 55 | "@types/node": "^12.12.6", 56 | "@types/svgo": "^1.3.0", 57 | "global-dirs": "^1.0.0", 58 | "rollup-plugin-json": "^4.0.0", 59 | "source-map-support": "^0.5.16", 60 | "uglify-es": "^3.3.9" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/asset.ts: -------------------------------------------------------------------------------- 1 | import * as Svgo from 'svgo' 2 | import * as Path from 'path' 3 | import * as rollup from 'rollup' 4 | import { gifInfoBuf } from './gif' 5 | import { jpegInfoBuf } from './jpeg' 6 | import * as typescript from 'typescript' 7 | import { readfile } from './fs' 8 | import { dirname, basename } from 'path' 9 | 10 | let _svgoInstance :Svgo|null = null 11 | 12 | function getSvgo() :Svgo { 13 | return _svgoInstance || (_svgoInstance = new Svgo({ 14 | full: true, 15 | plugins: [ 16 | { cleanupAttrs: true }, 17 | { removeDoctype: true }, 18 | { removeXMLProcInst: true }, 19 | { removeComments: true }, 20 | { removeMetadata: true }, 21 | { removeTitle: true }, 22 | { removeDesc: true }, 23 | { removeUselessDefs: true }, 24 | { removeEditorsNSData: true }, 25 | { removeEmptyAttrs: true }, 26 | { removeHiddenElems: true }, 27 | { removeEmptyText: true }, 28 | { removeEmptyContainers: true }, 29 | { removeViewBox: false }, 30 | { cleanupEnableBackground: true }, 31 | { convertStyleToAttrs: true }, 32 | { convertColors: true }, 33 | { convertPathData: true }, 34 | { convertTransform: true }, 35 | { removeUnknownsAndDefaults: true }, 36 | { removeNonInheritableGroupAttrs: true }, 37 | { removeUselessStrokeAndFill: true }, 38 | { removeUnusedNS: true }, 39 | { cleanupIDs: true }, 40 | { cleanupNumericValues: true }, 41 | { moveElemsAttrsToGroup: true }, 42 | { moveGroupAttrsToElems: true }, 43 | { collapseGroups: true }, 44 | { removeRasterImages: false }, 45 | { mergePaths: true }, 46 | { convertShapeToPath: true }, 47 | { sortAttrs: true }, 48 | //{ removeDimensions: true }, 49 | ], 50 | })) 51 | } 52 | 53 | 54 | export class AssetInfo { 55 | // one of these are set, depending on encoding 56 | b64data :string = "" 57 | textData :string = "" 58 | 59 | urlPrefix :string = "" 60 | mimeType :string = "" 61 | 62 | // file-type dependent attributes, like width and height for images 63 | attrs :{[key:string]:any} = {} 64 | 65 | // cache 66 | _url :string = "" 67 | 68 | get url() :string { 69 | if (!this._url) { 70 | this._url = this.urlPrefix + (this.textData || this.b64data) 71 | } 72 | return this._url 73 | } 74 | 75 | getTextData() :string { 76 | return this.textData || this.getData().toString("utf8") 77 | } 78 | 79 | getData() :Buffer { 80 | return this.textData ? Buffer.from(this.textData, "utf8") 81 | : Buffer.from(this.b64data, "base64") 82 | } 83 | } 84 | 85 | 86 | export class AssetBundler { 87 | mimeTypes = new Map([ 88 | ['.jpg', 'image/jpeg'], 89 | ['.jpeg', 'image/jpeg'], 90 | ['.png', 'image/png'], 91 | ['.gif', 'image/gif'], 92 | ['.svg', 'image/svg+xml'], 93 | ['.dat', 'application/octet-stream'], 94 | ]) 95 | 96 | assetCache = new Map() // .d.ts path => {meta, mimeType, file} 97 | 98 | // the following properties are updated on each compilation, even in 99 | // watch mode. 100 | tsService :typescript.LanguageService|null = null 101 | tsCompilerOptions = {} as typescript.CompilerOptions 102 | 103 | _typescriptProxy :typeof typescript|null = null 104 | _rollupPlugin :rollup.Plugin|null = null 105 | 106 | 107 | mimeTypeIsText(mimeType :string) :bool { 108 | // TODO: expand this when we expand this.mimeTypes 109 | return mimeType == "image/svg+xml" 110 | } 111 | 112 | mimeTypeIsJSXCompatible(mimeType :string) :bool { 113 | return mimeType == "image/svg+xml" 114 | } 115 | 116 | mimeTypeIsImage(mimeType :string) :bool { 117 | return mimeType.startsWith("image/") 118 | } 119 | 120 | 121 | extractPathQueryString(path :string) :[string,string] { // [path, query] 122 | let qi = path.lastIndexOf("?") 123 | if (qi != -1) { 124 | let si = path.lastIndexOf("/") 125 | if (si < qi) { 126 | return [ path.substr(0, qi), path.substr(qi + 1) ] 127 | } 128 | } 129 | return [ path, "" ] 130 | } 131 | 132 | // Wrap typescript to allow virtualized .d.ts asset files 133 | // 134 | getTypescriptProxy() :typeof typescript { 135 | const a = this 136 | return this._typescriptProxy || (this._typescriptProxy = { 137 | __proto__: typescript, 138 | 139 | sys: { 140 | __proto__: typescript.sys, 141 | 142 | fileExists(path :string) :bool { 143 | if (a.assetCache.has(path) || typescript.sys.fileExists(path)) { 144 | return true 145 | } 146 | if (path.endsWith(".d.ts")) { 147 | let srcpath = path.substr(0, path.length - 5) // strip ".d.ts" 148 | let [file, meta] = a.extractPathQueryString(srcpath) 149 | let ext = Path.extname(file).toLowerCase() 150 | let mimeType = a.mimeTypes.get(ext) 151 | if (mimeType && typescript.sys.fileExists(file)) { 152 | a.assetCache.set(path, { meta, mimeType, file }) 153 | return true 154 | } 155 | } 156 | return false 157 | }, 158 | 159 | readFile(path :string, encoding? :string) :string | undefined { 160 | let ent = a.assetCache.get(path) 161 | if (ent !== undefined) { 162 | let { meta, mimeType, file } = ent 163 | if (meta == "jsx") { 164 | if (a.mimeTypeIsJSXCompatible(mimeType)) { 165 | return ( 166 | "import React from 'react';\n" + 167 | "const s :React.StatelessComponent<" + 168 | "React.SVGAttributes>;\n" + 169 | "export default s;\n" 170 | ) 171 | } else { 172 | console.error(`${file}: not valid JSX`) 173 | } 174 | } 175 | if (a.mimeTypeIsImage(mimeType)) { 176 | return "const a :{url:string,width:number,height:number};\nexport default a;\n" 177 | } 178 | return "const a :{url:string};\nexport default a;\n" 179 | } 180 | return typescript.sys.readFile(path, encoding) 181 | }, 182 | }, 183 | 184 | // createLanguageService( 185 | // host: LanguageServiceHost, 186 | // documentRegistry?: DocumentRegistry, 187 | // syntaxOnly?: boolean 188 | // ): LanguageService 189 | createLanguageService( 190 | host :typescript.LanguageServiceHost, 191 | documentRegistry? :typescript.DocumentRegistry, 192 | syntaxOnly? :bool 193 | ) :typescript.LanguageService { 194 | 195 | // provide trace function in case tracing is enabled 196 | this.tsCompilerOptions = host.getCompilationSettings() 197 | if (this.tsCompilerOptions.traceResolution) { 198 | host.trace = msg => print(">>", msg) 199 | } 200 | 201 | const ts = this 202 | 203 | // Patch the TS rollup plugin to work around 204 | // https://github.com/ezolenko/rollup-plugin-typescript2/issues/154 205 | // getScriptSnapshot(fileName: string): 206 | // tsTypes.IScriptSnapshot | undefined 207 | host.getScriptSnapshot = function(fileName) { 208 | fileName = fileName.replace(/\\+/g, "/") 209 | let snapshot = (host as any).snapshots[fileName] 210 | if (!snapshot) { 211 | snapshot = ts.ScriptSnapshot.fromString(ts.sys.readFile(fileName)) 212 | ;(host as any).snapshots[fileName] = snapshot 213 | ;(host as any).versions[fileName] = 214 | ((host as any).versions[fileName] || 0) + 1 215 | } 216 | return snapshot 217 | } 218 | 219 | let s = typescript.createLanguageService( 220 | host, 221 | documentRegistry, 222 | syntaxOnly 223 | ) 224 | 225 | a.tsService = s 226 | 227 | return s 228 | }, 229 | } as any as typeof typescript) 230 | } // getTypescriptProxy 231 | 232 | 233 | getRollupPlugin() { 234 | const a = this 235 | return this._rollupPlugin || (this._rollupPlugin = { 236 | name: 'asset', 237 | 238 | buildEnd(err?: Error) :Promise|void { 239 | a.assetCache.clear() 240 | }, 241 | 242 | resolveId(id: string, parentId: string | undefined) :rollup.ResolveIdResult { 243 | if (id.indexOf("?") != -1) { 244 | let [file, meta] = a.extractPathQueryString(id) 245 | if (meta && a.mimeTypes.has(Path.extname(file).toLowerCase())) { 246 | return ( 247 | parentId ? Path.resolve(dirname(parentId), id) : 248 | Path.resolve(id) 249 | ) 250 | } 251 | } 252 | return undefined 253 | }, 254 | 255 | load(id :string) :Promise { 256 | let ext = Path.extname(id) 257 | if (ext == ".json") { 258 | return readfile(id, "utf8").then(json => 259 | `export default ${JSON.stringify(JSON.parse(json))}` 260 | ) 261 | } 262 | 263 | let [id2, mimeType, meta] = a.parseFilename(id) 264 | id = id2 265 | if (!mimeType) { 266 | return Promise.resolve(null) 267 | } 268 | 269 | this.addWatchFile(id) 270 | 271 | let jsid = basename(id) 272 | jsid = jsid.substr(0, jsid.length - ext.length) 273 | .replace(/[^A-Za-z0-9_]+/g, "_") 274 | 275 | // print("LOAD", id, {meta}) 276 | 277 | if (meta == "jsx") { 278 | // generate JSX virtual dom instead of data url 279 | return a.readAsJSXModule(id, jsid) 280 | } 281 | 282 | return a.loadAssetInfo(id, mimeType).then(obj => 283 | `const asset_${jsid} = ${JSON.stringify(obj)};\n` + 284 | `export default asset_${jsid};` 285 | ) 286 | } 287 | }) 288 | } // getRollupPlugin 289 | 290 | 291 | // parseFilename returns [filename, mimeType?, queryString] 292 | parseFilename(filename :string) :[string, string|undefined, string] { 293 | const a = this 294 | let [fn, queryString] = a.extractPathQueryString(filename) 295 | let ext = Path.extname(fn) 296 | let mimeType = a.mimeTypes.get(ext.toLowerCase()) 297 | return [fn, mimeType, queryString] 298 | } 299 | 300 | 301 | // readAsJSX reads a resource which contents is valid JSX, like an SVG, 302 | // and parses it as JSX, returning ESNext JavaScript module code. 303 | async readAsJSXModule(path :string, jsid :string) :Promise { 304 | let svg = await readfile(path, "utf8") 305 | let jsxSource = ( 306 | `import React from "react";\n` + 307 | `const asset_${jsid} = ${svg};\n` + 308 | `export default asset_${jsid};\n` 309 | ) 310 | // transpile( 311 | // input: string, 312 | // compilerOptions?: CompilerOptions, 313 | // fileName?: string, 314 | // diagnostics?: Diagnostic[], 315 | // moduleName?: string 316 | // ): string; 317 | let compilerOptions = { 318 | jsx: this.tsCompilerOptions.jsx || typescript.JsxEmit.React, 319 | module: typescript.ModuleKind.ESNext, // "esnext" 320 | target: typescript.ScriptTarget.ESNext, // "esnext" 321 | } as typescript.CompilerOptions 322 | return typescript.transpile(jsxSource, compilerOptions, path+".jsx") 323 | } 324 | 325 | 326 | async loadAssetInfo(path :string, mimeType? :string) :Promise { 327 | let obj = new AssetInfo() 328 | 329 | if (mimeType) { 330 | obj.mimeType = mimeType 331 | 332 | if (mimeType == "image/gif") { 333 | let data = await readfile(path, "base64") 334 | let head = Buffer.from(data.substr(0,16), "base64") 335 | try { 336 | obj.attrs = gifInfoBuf(head) 337 | } catch(err) { 338 | console.error(`${path}: not a GIF image (${err})`) 339 | obj.attrs.width = 0 340 | obj.attrs.height = 0 341 | } 342 | obj.urlPrefix = `data:${mimeType};base64,` 343 | obj.b64data = data 344 | 345 | } else if (mimeType == "image/jpeg") { 346 | let data = await readfile(path) 347 | try { 348 | obj.attrs = jpegInfoBuf(data) 349 | } catch(err) { 350 | console.error(`${path}: not a JPEG image (${err})`) 351 | obj.attrs.width = 0 352 | obj.attrs.height = 0 353 | } 354 | obj.urlPrefix = `data:${mimeType};base64,` 355 | obj.b64data = data.toString("base64") 356 | 357 | } else if (this.mimeTypeIsText(mimeType)) { 358 | // for text types, attempt text encoding 359 | let data = await readfile(path, "utf8") 360 | 361 | if (mimeType == "image/svg+xml") { 362 | let res = await getSvgo().optimize(data, {path}) 363 | if (res.data.match(/^[^\r\n\t]+$/)) { 364 | obj.attrs = res.info as {[k:string]:any} 365 | if (obj.attrs.width) { 366 | obj.attrs.width = parseInt(obj.attrs.width) 367 | if (isNaN(obj.attrs.width)) { 368 | obj.attrs.width = 0 369 | } 370 | } else { 371 | obj.attrs.width = 0 372 | } 373 | if (obj.attrs.height) { 374 | obj.attrs.height = parseInt(obj.attrs.height) 375 | if (isNaN(obj.attrs.height)) { 376 | obj.attrs.height = 0 377 | } 378 | } else { 379 | obj.attrs.height = 0 380 | } 381 | obj.urlPrefix = `data:${mimeType};utf8,` 382 | obj.textData = res.data 383 | } 384 | } 385 | } 386 | } 387 | 388 | if (!obj.urlPrefix) { 389 | // fallback to base-64 encoding the data 390 | let data = await readfile(path, "base64") 391 | obj.urlPrefix = `data:${mimeType};base64,` 392 | obj.b64data = data 393 | } 394 | 395 | return obj 396 | } 397 | 398 | } 399 | -------------------------------------------------------------------------------- /src/check-version.ts: -------------------------------------------------------------------------------- 1 | import * as http from "./http" 2 | import { getTermStyle } from "./termstyle" 3 | 4 | 5 | export enum VersionCheckResult { 6 | UsingLatest = 0, // using the current version 7 | UsingFuture = 1, // using a future, unreleased version 8 | UsingOld = 2, // new version available 9 | 10 | Error = 100, 11 | } 12 | 13 | 14 | export async function checkForNewVersion() :Promise { 15 | try { 16 | let res = await http.GET("https://registry.npmjs.org/figplug/latest", { 17 | timeout: 10000 18 | }) 19 | 20 | if (res.statusCode < 200 || res.statusCode > 299) { 21 | throw new Error(`http error ${res.statusCode}`) 22 | } 23 | 24 | let contentType = res.headers["content-type"] 25 | if (!contentType || !contentType.match(/\/json/i)) { 26 | throw new Error(`non-json response from https://registry.npmjs.org/figplug/latest`) 27 | } 28 | 29 | let info = JSON.parse(res.decodeTextBody()) 30 | 31 | if (info.version && typeof info.version == "string") { 32 | let newVersionLabel = compareVersions(VERSION, info.version) 33 | if (newVersionLabel != "") { 34 | printNewVersionBanner(info.version, newVersionLabel) 35 | return VersionCheckResult.UsingOld 36 | } else { 37 | let label2 = compareVersions(newVersionLabel, info.version) 38 | if (label2 != "") { 39 | return VersionCheckResult.UsingFuture 40 | } 41 | } 42 | } 43 | return VersionCheckResult.UsingLatest 44 | } catch (e) { 45 | dlog(`checkForNewVersion failed ${e.stack||e}`) 46 | return VersionCheckResult.Error 47 | } 48 | } 49 | 50 | 51 | function compareVersions(local :string, remote :string) :string { 52 | let L = local.split(".").map(Number) 53 | let R = remote.split(".").map(Number) 54 | if (L[0] < R[0]) { 55 | return "major version" 56 | } else if (L[1] < R[1]) { 57 | return "version" 58 | } else if (L[2] < R[2]) { 59 | return "minor version" 60 | } 61 | return "" 62 | } 63 | 64 | 65 | function printNewVersionBanner(version :string, newVersionLabel :string) { 66 | let style = getTermStyle(process.stdout) 67 | process.stdout.write( 68 | "\n" + 69 | style.bold(` New ${newVersionLabel} of figplug is available.\n`) + 70 | " Run " + style.green("npm install -g figplug") + " to update " + 71 | `${style.pink(VERSION)} → ${style.cyan(version)}\n` + 72 | "\n" 73 | ) 74 | } 75 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | import { basename } from 'path' 2 | 3 | 4 | // prog is the name of the program 5 | // export const prog :string = process.argv[1] 6 | export const prog = process.env["_"] || basename(process.argv[1]) 7 | 8 | 9 | // die prints message to stderr and exits with status 1 10 | export function die(message :any, ...msg :any[]) { 11 | if (message instanceof Error) { 12 | message = message.stack || message.message || String(message) 13 | } 14 | console.error( 15 | `${prog}: ${message}` + 16 | (msg && msg.length > 0 ? msg.join(' ') : "") 17 | ) 18 | process.exit(1) 19 | } 20 | 21 | // parseopt types 22 | export type Options = { [k :string] :any } 23 | export type FlagSpec = string | [ string|string[] , string?, string? ] 24 | export type Usage = string 25 | | (()=>string) 26 | | null 27 | | undefined 28 | 29 | // parseopt parses command-line arguments. 30 | // Returns options and unparsed remaining arguments. 31 | // 32 | // flag format: 33 | // 34 | // flag = flagname | flagspec 35 | // flagname = "-"* 36 | // flagnames = Array< flagname+ > 37 | // flagspec = Tuple< flagnames | flagname > 38 | // 39 | // flag format examples: 40 | // 41 | // "verbose" 42 | // Simple boolean flag that can be set with -verbose or --verbose. 43 | // 44 | // [ "v", "Show version" ] 45 | // Boolean flag "v" with description text shown in program usage. 46 | // 47 | // [ ["v", "version"], "Show version" ] 48 | // Boolean flag "v" with alternate name "version" with description. 49 | // 50 | // [ ["v", "version"] ] 51 | // Boolean flag "v" with alternate name "version" without description. 52 | // 53 | // [ "o", "Output file", "" ] 54 | // Value flag with description. Value type defaults to string. 55 | // Can be invoked as -o=path, --o=path, -o path, and --o path. 56 | // 57 | // [ "o", "", "" ] 58 | // Value flag without description. 59 | // 60 | // [ "limit", "Show no more than items", ":number" ] 61 | // Value flag with type constraint. Passing a value that is not a JS number 62 | // causes an error message. 63 | // 64 | // [ "with-openssl", "", "enable:bool" ] 65 | // Boolean flag 66 | // 67 | export function parseopt(argv :string[], usage :Usage, ...flags :FlagSpec[]) :[Options, string[]] { 68 | let [flagmap, opts] = parseFlagSpecs(flags) 69 | 70 | let options :Options = {} 71 | 72 | let i = 0 73 | for (; i < argv.length; i++) { 74 | // read argument 75 | let arg = argv[i] 76 | if (arg == '--') { 77 | i++ 78 | break 79 | } 80 | if (arg[0] != '-') { 81 | break 82 | } 83 | arg = arg.replace(/^\-+/, '') 84 | let eqp = arg.indexOf('=') 85 | let argval :string|undefined = undefined 86 | if (eqp != -1) { 87 | // e.g. -name=value 88 | argval = arg.substr(eqp + 1) 89 | arg = arg.substr(0, eqp) 90 | } 91 | 92 | // lookup flag 93 | let opt = flagmap.get(arg) 94 | if (!opt) { 95 | if (arg == "h" || arg == "help") { 96 | printUsage(opts, usage) 97 | process.exit(0) 98 | } else { 99 | console.error(`unknown option -${arg} (see ${prog} -help)`) 100 | process.exit(1) 101 | } 102 | } 103 | 104 | // save option 105 | if (opt.valueName) { 106 | if (argval === undefined) { 107 | // -k v 108 | argval = argv[i + 1] 109 | if (argval !== undefined && argval[0] != "-") { 110 | i++ 111 | // } else if (opt.valueType == "boolean") { 112 | // argval = "true" 113 | } else { 114 | console.error(`missing value for option -${arg} (see ${prog} -help)`) 115 | process.exit(1) 116 | } 117 | } // else -k=v 118 | try { 119 | let value = opt.valueParser ? opt.valueParser(argval) : argval 120 | if (opt.multi) { 121 | if (arg in options) { 122 | options[arg].push(value) 123 | } else { 124 | options[arg] = [value] 125 | } 126 | } else { 127 | options[arg] = value 128 | } 129 | } catch (err) { 130 | console.error(`invalid value for option -${arg} (${err.message})`) 131 | } 132 | } else if (argval !== undefined) { 133 | console.error(`unexpected value provided for flag -${arg}`) 134 | process.exit(1) 135 | } else { 136 | // e.g. -k 137 | options[arg] = true 138 | } 139 | } 140 | 141 | return [options, argv.slice(i)] 142 | } 143 | 144 | 145 | interface Opt { 146 | flags :string[] 147 | description? :string 148 | valueName? :string 149 | valueType? :string 150 | multi? :bool // true for list types e.g. "foo:string[]" 151 | valueParser? :(v:string)=>any 152 | } 153 | 154 | 155 | function parseFlagSpecs(flagspecs :FlagSpec[]) :[Map,Opt[]] { 156 | let flagmap = new Map() 157 | let opts :Opt[] = [] 158 | for (let spec of flagspecs) { 159 | let opt = flagspecToOpt(spec) 160 | opts.push(opt) 161 | for (let k of opt.flags) { 162 | flagmap.set(k, opt) 163 | } 164 | } 165 | return [flagmap, opts] 166 | } 167 | 168 | 169 | function flagspecToOpt(f :FlagSpec) :Opt { 170 | const cleanFlag = (s :string) => s.replace(/^\-+/, '') 171 | if (typeof f == "string") { 172 | return { flags: [ cleanFlag(f) ] } 173 | } 174 | let o :Opt = { 175 | flags: ( 176 | typeof f[0] == "string" ? [ cleanFlag(f[0]) ] : 177 | f[0].map(cleanFlag) 178 | ), 179 | description: f[1] || undefined 180 | } 181 | if (f[2]) { 182 | let [name, type] = f[2].split(/:/, 2) 183 | if (type) { 184 | o.multi = type.endsWith("[]") 185 | if (o.multi) { 186 | type = type.substr(0, type.length-2) 187 | } 188 | switch (type.toLowerCase()) { 189 | 190 | case 'string': 191 | case 'str': 192 | case '': 193 | type = 'string' 194 | break 195 | 196 | case 'bool': 197 | case 'boolean': 198 | type = 'boolean' 199 | o.valueParser = s => { 200 | s = s.toLowerCase() 201 | return s != "false" && s != "0" && s != "no" && s != "off" 202 | } 203 | break 204 | 205 | case 'number': 206 | case 'num': 207 | case 'float': 208 | case 'int': 209 | type = 'number' 210 | o.valueParser = s => { 211 | let n = Number(s) 212 | if (isNaN(n)) { 213 | throw new Error(`${repr(s)} is not a number`) 214 | } 215 | return n 216 | } 217 | break 218 | 219 | default: 220 | throw new Error(`invalid argument type "${type}"`) 221 | } 222 | } else { 223 | type = "string" 224 | } 225 | o.valueName = name || type 226 | o.valueType = type 227 | } 228 | return o 229 | } 230 | 231 | 232 | function printUsage(opts :Opt[], usage? :Usage) { 233 | let vars :{[k:string]:any} = { 234 | prog: prog, 235 | } 236 | let s = ( 237 | usage ? typeof usage == 'function' ? usage() : usage : 238 | opts && opts.length > 0 ? `Usage: $prog [options]` : `Usage: $prog` 239 | ) 240 | s = s.replace(/\$(\w+)/g, (m, v) => { 241 | let sub = vars[v] 242 | if (!sub) { 243 | throw new Error(`unknown variable $${v}`) 244 | } 245 | return sub 246 | }) 247 | if (opts.length > 0) { 248 | s += '\noptions:\n' 249 | let longestFlagName = 0 250 | let flagNames :string[] = [] 251 | for (let f of opts) { 252 | 253 | let flagName = " -" + ( 254 | f.valueName ? f.flags.map(s => ( 255 | f.valueType == "boolean" ? 256 | s + '=on|off' : 257 | s + '=' + f.valueName + '' 258 | )) : f.flags 259 | ).join(', -') 260 | 261 | if (flagName.length > 20) { 262 | flagName = flagName.replace(/, -/g, ',\n -') 263 | for (let line of flagName.split(/\n/)) { 264 | longestFlagName = Math.max(longestFlagName, line.length) 265 | } 266 | } else { 267 | longestFlagName = Math.max(longestFlagName, flagName.length) 268 | } 269 | flagNames.push(flagName) 270 | } 271 | const spaces = ' ' 272 | for (let i = 0; i < opts.length; i++) { 273 | let f = opts[i] 274 | let names = flagNames[i] 275 | // "length" of name is length of last line (catches multi-line names) 276 | let namelen = (v => v[v.length-1].length)(names.split("\n")) 277 | let padding = spaces.substr(0, longestFlagName - namelen) 278 | if (f.description) { 279 | s += `${names}${padding} ${f.description}\n` 280 | } else { 281 | s += `${names}\n` 282 | } 283 | } 284 | } 285 | console.error(s) 286 | } 287 | 288 | -------------------------------------------------------------------------------- /src/ctx.ts: -------------------------------------------------------------------------------- 1 | import { dirname } from 'path' 2 | 3 | export const figplugDir = dirname(__dirname) 4 | 5 | export class BuildCtx { 6 | watch = false 7 | debug = false 8 | optimize = false 9 | clean = false 10 | nomin = false 11 | verbose = false 12 | verbose2 = false 13 | outdir = "" // empty means "infer from source" 14 | libs :string[] = [] // filenames 15 | uilibs :string[] = [] // filenames 16 | noGenManifest = false // do not generate manifest.json 17 | externalSourceMap = false // store source map in file instead of inline data url 18 | noSourceMap = false // disable source map generation 19 | version = "0" 20 | 21 | constructor(props? :Partial) { 22 | if (props) { 23 | for (let k in props) { 24 | (this as any)[k] = (props as any)[k] 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/fs.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import { promisify } from 'util' 3 | import { dirname, resolve as resolvePath } from 'path' 4 | import { URL } from 'url' 5 | import { parseVersion } from './util' 6 | 7 | export const stat = promisify(fs.stat) 8 | export const mkdir = promisify(fs.mkdir) 9 | 10 | const node_v10_12_0 = parseVersion("10.12.0") 11 | const node_version = parseVersion(process.version.substr(1)) 12 | 13 | export const mkdirs :(path :string)=>Promise = ( 14 | 15 | node_version >= node_v10_12_0 ? // node 10.12.0 adds "recursive" option 16 | (path :string) :Promise => mkdir(path, {recursive:true}) : 17 | 18 | // legacy nodejs 19 | (path :string) :Promise => { 20 | async function _mkdir(p :string) :Promise { 21 | try { 22 | await mkdir(p) 23 | } catch (err) { 24 | if (err.code == 'ENOENT') { 25 | let p2 = dirname(p) 26 | if (p2 == p) { throw err } 27 | return await _mkdir(p2).then(() => _mkdir(p)) 28 | } if (err.code == 'EEXIST') { 29 | try { 30 | if ((await stat(p)).isDirectory()) { 31 | return // okay, exists and is directory 32 | } 33 | } catch (_) {} 34 | } 35 | throw err 36 | } 37 | } 38 | return _mkdir(resolvePath(path)) 39 | } 40 | ) 41 | 42 | export const readdir = promisify(fs.readdir) 43 | 44 | export const readfile = promisify(fs.readFile) 45 | 46 | export async function exists(path :fs.PathLike) :Promise { 47 | try { 48 | await stat(path) 49 | return true 50 | } catch(_) {} 51 | return false 52 | } 53 | 54 | export async function isFile(path :fs.PathLike) :Promise { 55 | try { 56 | let st = await stat(path) 57 | return st.isFile() 58 | } catch(_) {} 59 | return false 60 | } 61 | 62 | export async function isDir(path :fs.PathLike) :Promise { 63 | try { 64 | let st = await stat(path) 65 | return st.isDirectory() 66 | } catch(_) {} 67 | return false 68 | } 69 | 70 | export function strpath(path :fs.PathLike) :string { 71 | if (path instanceof URL) { 72 | if (path.protocol.toLowerCase() != 'file') { 73 | throw new Error("not a file URL") 74 | } 75 | if (path.hostname != "" && path.hostname != 'localhost') { 76 | throw new Error("file URL with remote host") 77 | } 78 | return path.pathname 79 | } 80 | return ( 81 | typeof path == "string" ? path : 82 | path instanceof Buffer ? path.toString("utf8") : 83 | String(path) 84 | ) 85 | } 86 | 87 | const _writefile = promisify(fs.writeFile) 88 | 89 | export function writefile( 90 | path :fs.PathLike | number, 91 | data :any, 92 | options :fs.WriteFileOptions, 93 | ) :Promise { 94 | return _writefile(path, data, options).catch(async (err) => { 95 | if (err.code != 'ENOENT' || typeof path == "number") { 96 | throw err 97 | } 98 | // directory not found -- create directories and retry 99 | await mkdirs(dirname(strpath(path))) 100 | await _writefile(path, data, options) 101 | }) 102 | } 103 | 104 | 105 | const _copyfile = promisify(fs.copyFile) 106 | 107 | export function copyfile(src :fs.PathLike, dst :fs.PathLike, flags?: number) :Promise { 108 | return _copyfile(src, dst, flags).catch(async (err) => { 109 | if (err.code != 'ENOENT') { 110 | throw err 111 | } 112 | // directory not found -- create directories and retry 113 | await mkdirs(dirname(strpath(dst))) 114 | await _copyfile(src, dst, flags) 115 | }) 116 | } 117 | -------------------------------------------------------------------------------- /src/gif.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface GifInfo { 3 | version :string 4 | width :int 5 | height :int 6 | } 7 | 8 | export function gifInfoBuf(buf :ArrayLike) :GifInfo { 9 | // header is 6 bytes and should be either "GIF87a" or "GIF89a" 10 | if (buf.length < 10 || 11 | buf[0] != 71 || buf[1] != 73 || buf[2] != 70 || buf[3] != 56 || 12 | (buf[5] != 97 && buf[5] != 98)) { // GIF8_[a|b] 13 | throw new Error("not a gif") 14 | } 15 | 16 | let v = buf[4] - 48 // e.g. 7 or 9 17 | let version = `8${v}${String.fromCharCode(buf[5])}` 18 | if (v != 7 && v != 9) { 19 | throw new Error(`unsupported gif version GIF${version}`) 20 | } 21 | 22 | // header is followed by width and height as uint16 23 | return { 24 | version, 25 | width: (buf[7] << 8) + buf[6], 26 | height: (buf[9] << 8) + buf[8], 27 | } 28 | } 29 | 30 | 31 | // export function gifInfoFile(path) { 32 | // return new Promise((resolve, reject) => { 33 | // fs.open(path, "r", (err, fd) => { 34 | // let buf = Buffer.allocUnsafe(10) 35 | // let nread = fs.readSync(fd, buf, 0, 10, 0) 36 | // fs.close(fd, ()=>{}) 37 | // if (nread < 10) { 38 | // reject("not a gif") 39 | // } 40 | // resolve(gifInfoBuf(buf)) 41 | // }) 42 | // }) 43 | // } 44 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | // symbolic aliases 2 | type int = number 3 | type float = number 4 | type byte = number 5 | type bool = boolean 6 | 7 | // non-standard global constants 8 | declare const DEBUG :boolean 9 | declare const VERSION :string 10 | declare const VERSION_TAG :string 11 | declare const VERSION_WITH_TAG :string 12 | declare const FIGMA_API_VERSIONS :string[] 13 | declare const global :{[k:string]:any} 14 | 15 | // panic prints a message, stack trace and exits the process 16 | // 17 | declare function panic(msg :any, ...v :any[]) :void 18 | 19 | // repr returns a detailed string representation of the input 20 | // 21 | declare function repr(obj :any) :string 22 | 23 | // print works just like console.log 24 | declare function print(msg :any, ...v :any[]) :void 25 | 26 | // dlog works just like console.log but is stripped out from non-debug builds 27 | declare function dlog(msg :any, ...v :any[]) :void 28 | 29 | // assert checks the condition for truth, and if false, prints an optional 30 | // message, stack trace and exits the process. 31 | // assert is removed in release builds 32 | declare var assert :AssertFun 33 | declare var AssertionError :ErrorConstructor 34 | declare interface AssertFun { 35 | (cond :any, msg? :string, cons? :Function) :void 36 | 37 | // throws can be set to true to cause assertions to be thrown as exceptions, 38 | // or set to false to cause the process to exit. 39 | // Only has an effect in Nodejs-like environments. 40 | // false by default. 41 | throws :bool 42 | } 43 | -------------------------------------------------------------------------------- /src/global.js: -------------------------------------------------------------------------------- 1 | try { 2 | typeof require != 'undefined' && require("source-map-support").install() 3 | } catch(_) {} 4 | 5 | var global = ( 6 | typeof global != 'undefined' ? global : 7 | typeof window != 'undefined' ? window : 8 | this 9 | ) 10 | 11 | function _stackTrace(cons) { 12 | const x = {stack:''} 13 | if (Error.captureStackTrace) { 14 | Error.captureStackTrace(x, cons) 15 | const p = x.stack.indexOf('\n') 16 | if (p != -1) { 17 | return x.stack.substr(p+1) 18 | } 19 | } 20 | return x.stack 21 | } 22 | 23 | // _parseStackFrame(sf :string) : StackFrameInfo | null 24 | // interface StackFrameInfo { 25 | // func :string 26 | // file :string 27 | // line :int 28 | // col :int 29 | // } 30 | // 31 | function _parseStackFrame(sf) { 32 | let m = /^\s*at\s+([^\s]+)\s+\((?:.+\/(src\/[^\:]+)|([^\:]+))\:(\d+)\:(\d+)\)$/.exec(sf) 33 | if (m) { 34 | return { 35 | func: m[1], 36 | file: m[2] || m[3], 37 | line: parseInt(m[4]), 38 | col: parseInt(m[5]), 39 | } 40 | } 41 | return null 42 | } 43 | 44 | function panic(msg) { 45 | console.error.apply(console, 46 | ['panic:', msg].concat(Array.prototype.slice.call(arguments, 1)) 47 | ) 48 | if (typeof process != 'undefined') { 49 | console.error(_stackTrace(panic)) 50 | process.exit(2) 51 | } else { 52 | let e = new Error(msg) 53 | e.name = 'Panic' 54 | throw e 55 | } 56 | } 57 | 58 | function print() { 59 | console.log.apply(console, Array.prototype.slice.call(arguments)) 60 | } 61 | 62 | const dlog = DEBUG ? console.log.bind(console, '[debug]') : ()=>{} 63 | 64 | function assert() { 65 | if (DEBUG) { // for DCE 66 | var cond = arguments[0] 67 | , msg = arguments[1] 68 | , cons = arguments[2] || assert 69 | if (!cond) { 70 | if (!assert.throws && typeof process != 'undefined') { 71 | var stack = _stackTrace(cons) 72 | console.error('assertion failure:', msg || cond) 73 | var sf = _parseStackFrame(stack.substr(0, stack.indexOf('\n') >>> 0)) 74 | if (sf) { 75 | try { 76 | const fs = require('fs') 77 | const lines = fs.readFileSync(sf.file, 'utf8').split(/\n/) 78 | const line_before = lines[sf.line - 2] 79 | const line = lines[sf.line - 1] 80 | const line_after = lines[sf.line] 81 | let context = [' > ' + line] 82 | if (typeof line_before == 'string') { 83 | context.unshift(' ' + line_before) 84 | } 85 | if (typeof line_after == 'string') { 86 | context.push(' ' + line_after) 87 | } 88 | console.error(sf.file + ':' + sf.line + ':' + sf.col) 89 | console.error(context.join('\n') + '\n\nStack trace:') 90 | } catch (_) {} 91 | } 92 | console.error(stack) 93 | exit(3) 94 | } else { 95 | var e = new Error('assertion failure: ' + (msg || cond)) 96 | e.name = 'AssertionError' 97 | e.stack = _stackTrace(cons) 98 | throw e 99 | } 100 | } 101 | } 102 | } 103 | 104 | function repr(obj) { 105 | // TODO: something better 106 | try { 107 | return JSON.stringify(obj, null, 2) 108 | } catch (_) { 109 | return String(obj) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/html.ts: -------------------------------------------------------------------------------- 1 | 2 | // findHeadIndex finds the best insertion point in string html for 3 | // adding content that should optimally be placed inside . 4 | // 5 | export function findHeadIndex(html :string) :int { 6 | // try | 7 | let m = /\s*<\/head[^\>]*>/igm.exec(html) 8 | if (m) { 9 | return m.index 10 | } 11 | 12 | // try | 13 | m = /\s*]*>/igm.exec(html) 14 | if (m) { 15 | return m.index 16 | } 17 | 18 | // try | 19 | m = /(]*>[ \t]*[\r\n]?)/igm.exec(html) 20 | if (m) { 21 | return m.index + m[1].length 22 | } 23 | 24 | // try | 25 | m = /(<\!doctype[^\>]*>[ \t]*[\r\n]?)/igm.exec(html) 26 | if (m) { 27 | return m.index + m[1].length 28 | } 29 | 30 | // fall back to 0 31 | return 0 32 | } 33 | 34 | // findTailIndex finds the best insertion point in string html for 35 | // adding content that should optimally be placed at the end of the html 36 | // document. 37 | // 38 | export function findTailIndex(html :string) :int { 39 | // try | 40 | let m = /<\/body[^\>]*>/igm.exec(html) 41 | if (m) { 42 | return m.index 43 | } 44 | 45 | // try | 46 | m = /(<\/html[^\>]*>)/igm.exec(html) 47 | if (m) { 48 | return m.index 49 | } 50 | 51 | // fall back to length 52 | return html.length 53 | } 54 | -------------------------------------------------------------------------------- /src/http.ts: -------------------------------------------------------------------------------- 1 | import * as http from "http" 2 | import * as https from "https" 3 | import { AppendBuffer } from "./util" 4 | 5 | export interface HttpResponse extends http.IncomingMessage { 6 | statusCode :number 7 | body :Uint8Array 8 | decodeTextBody(encoding? :string) :string 9 | } 10 | 11 | export function GET(url :string, options? :http.RequestOptions) :Promise { 12 | return new Promise((resolve, reject) => { 13 | let httpmod = url.startsWith("https:") ? https : http 14 | let req = httpmod.get(url, options||{}, res_ => { 15 | let res = res_ as HttpResponse 16 | 17 | // parse content length, if available 18 | let contentLength = -1 19 | let contentLengthStr = res.headers["content-length"] 20 | if (contentLengthStr) { 21 | contentLength = parseInt(contentLengthStr) 22 | if (isNaN(contentLength)) { 23 | contentLength = -1 24 | } 25 | } 26 | 27 | let buf = new AppendBuffer(contentLength != -1 ? contentLength : 512) 28 | 29 | res.on('data', chunk => { 30 | buf.write(chunk) 31 | }) 32 | 33 | res.on('end', () => { 34 | res.body = buf.bytes() 35 | res.decodeTextBody = (enc :string = "utf8") => Buffer.from(res.body).toString(enc) 36 | resolve(res) 37 | }) 38 | 39 | }) 40 | req.on('error', reject) 41 | }) 42 | } 43 | -------------------------------------------------------------------------------- /src/init.ts: -------------------------------------------------------------------------------- 1 | import { readfile, writefile, copyfile, exists } from './fs' 2 | import { jsonfmt, jsonparse } from './util' 3 | import * as proc from './proc' 4 | import { figplugDir } from './ctx' 5 | import { ManifestProps } from './manifest' 6 | import { 7 | join as pjoin, 8 | dirname, 9 | basename, 10 | resolve as resolvePath, 11 | isAbsolute as isabspath, 12 | relative as relpath, 13 | } from 'path' 14 | 15 | 16 | export async function initPlugin(props :InitOptions) :Promise { 17 | let init = new PluginInitializer(props) 18 | return init.initPlugin() 19 | } 20 | 21 | type PluginUIKind = "ts+html" | "html" | "react" | null 22 | 23 | export interface InitOptions { 24 | dir :string 25 | srcdir? :string // defaults to dir 26 | name? :string // defaults to basename(dir) 27 | ui? :PluginUIKind 28 | overwrite? :bool 29 | verbose? :bool 30 | debug? :bool 31 | apiVersion? :string // defaults to latest; can also use value "latest" 32 | } 33 | 34 | export class PluginInitializer { 35 | dir :string 36 | srcdir :string 37 | name :string 38 | ui :PluginUIKind 39 | overwrite :bool 40 | verbose :bool 41 | debug :bool 42 | 43 | apiVersion :string 44 | manifestFile :string 45 | tsconfigFile :string 46 | packageFile :string 47 | figmaDtsFile :string 48 | figplugDtsFile :string 49 | pluginFile :string 50 | htmlFile :string 51 | cssFile :string 52 | uiFile :string 53 | 54 | wrotePackage :bool = false 55 | 56 | constructor(props :InitOptions) { 57 | this.verbose = !!props.verbose 58 | this.debug = !!props.debug 59 | assert(props.dir) 60 | this.dir = props.dir 61 | this.srcdir = ( 62 | props.srcdir ? ( 63 | isabspath(props.srcdir) ? props.srcdir : 64 | pjoin(this.dir, props.srcdir) 65 | ) : 66 | this.dir 67 | ) 68 | this.name = props.name || basename(resolvePath(this.dir)) 69 | this.ui = props.ui || null 70 | this.overwrite = !!props.overwrite 71 | 72 | this.apiVersion = FIGMA_API_VERSIONS[0] // latest 73 | if (props.apiVersion && props.apiVersion != "latest") { 74 | if (FIGMA_API_VERSIONS.includes(props.apiVersion)) { 75 | this.apiVersion = props.apiVersion 76 | } else { 77 | console.warn( 78 | `Unknown Figma Plugin API version ${repr(props.apiVersion)}. ` + 79 | `Using version ${FIGMA_API_VERSIONS[0]} instead.` 80 | ) 81 | } 82 | } 83 | 84 | this.manifestFile = pjoin(this.dir, "manifest.json") 85 | this.tsconfigFile = pjoin(this.dir, "tsconfig.json") 86 | this.packageFile = pjoin(this.dir, "package.json") 87 | this.figmaDtsFile = pjoin(this.dir, "figma.d.ts") 88 | this.figplugDtsFile = pjoin(this.dir, "figplug.d.ts") 89 | this.pluginFile = pjoin(this.srcdir, "plugin.ts") 90 | this.htmlFile = pjoin(this.srcdir, "ui.html") 91 | this.cssFile = pjoin(this.srcdir, "ui.css") 92 | this.uiFile = pjoin( 93 | this.srcdir, 94 | this.ui == "react" ? "ui.tsx" : "ui.ts" 95 | ) 96 | } 97 | 98 | 99 | async initPlugin() :Promise { 100 | let tasks :Promise[] = [ 101 | this.writeManifest(), 102 | this.writePlugin(), 103 | this.writeFigmaTypeDefsFile(), 104 | this.writeFigplugTypeDefsFile(), 105 | this.writeTSConfig(), 106 | ] 107 | switch (this.ui) { 108 | case null: 109 | break 110 | case "html": 111 | tasks.push(this.writeHTML()) 112 | tasks.push(this.writeCSS()) 113 | break 114 | case "ts+html": 115 | tasks.push(this.writeHTML()) 116 | tasks.push(this.writeCSS()) 117 | tasks.push(this.writeUITS()) 118 | break 119 | case "react": 120 | tasks.push(this.writeHTML()) 121 | tasks.push(this.writeUITSX()) 122 | tasks.push(this.writePackageJson()) 123 | break 124 | default: 125 | throw new Error(`unexpected value for ui: ${repr(this.ui)}`) 126 | } 127 | return Promise.all(tasks).then(this.initStage2.bind(this)) 128 | } 129 | 130 | 131 | async initStage2(results :bool[]) :Promise { 132 | if (!results.every(r => r)) { 133 | // some task failed 134 | return false 135 | } 136 | 137 | if (this.wrotePackage) { 138 | if (this.verbose) { 139 | print("npm install") 140 | } 141 | let args = this.debug ? ["install"] : ["install", "--silent"] 142 | let status = await proc.spawn("npm", args, { 143 | cwd: dirname(resolvePath(this.packageFile)), 144 | windowsHide: true, 145 | stdio: this.verbose ? "inherit" : "pipe", 146 | }) 147 | if (status != 0) { 148 | return false 149 | } 150 | } 151 | 152 | return true 153 | } 154 | 155 | 156 | warnFileExist(file :string) :false { 157 | console.error(`skipping existing file ${file}`) 158 | return false 159 | } 160 | 161 | 162 | writefile(filename :string, text :string) :Promise { 163 | if (this.verbose) { 164 | print(`write ${relpath(this.dir, filename)}`) 165 | } 166 | return writefile(filename, text, "utf8").then(() => true) 167 | } 168 | 169 | 170 | copyfile(srcfile :string, dstfile :string, message :string = "") :Promise { 171 | if (this.verbose) { 172 | print(`write ${relpath(this.dir, dstfile)}` + (message ? " " + message : "")) 173 | } 174 | return copyfile(srcfile, dstfile).then(() => true) 175 | } 176 | 177 | 178 | async compareFiles(file1 :string, file2: string) :Promise { 179 | let [data1, data2] = await Promise.all([ 180 | readfile(file1), 181 | readfile(file2), 182 | ]) 183 | return data1.compare(data2) 184 | } 185 | 186 | 187 | async writeFigmaTypeDefsFile() :Promise { 188 | let templateFile = pjoin( 189 | figplugDir, 190 | "lib", 191 | `figma-plugin-${this.apiVersion}.d.ts` 192 | ) 193 | let message = "" 194 | if (!this.overwrite && await exists(this.figmaDtsFile)) { 195 | if (await this.compareFiles(templateFile, this.figmaDtsFile) == 0) { 196 | // identical -- no need to write 197 | return true 198 | } 199 | message = "(new version)" 200 | } 201 | return this.copyfile(templateFile, this.figmaDtsFile, message) 202 | } 203 | 204 | 205 | async writeFigplugTypeDefsFile() :Promise { 206 | let templateFile = pjoin(figplugDir, "lib", "figplug.d.ts") 207 | let message = "" 208 | if (!this.overwrite && await exists(this.figplugDtsFile)) { 209 | if (await this.compareFiles(templateFile, this.figplugDtsFile) == 0) { 210 | // identical -- no need to write 211 | return true 212 | } 213 | message = "(new version)" 214 | } 215 | return this.copyfile(templateFile, this.figplugDtsFile, message) 216 | } 217 | 218 | 219 | async writeTSConfig() :Promise { 220 | if (!this.overwrite && await exists(this.tsconfigFile)) { 221 | return this.warnFileExist(this.tsconfigFile) 222 | } 223 | let tsconfig = await getTsConfigTemplate() 224 | if (this.ui == "react") { 225 | tsconfig.compilerOptions.jsx = "react" 226 | } 227 | let tsconfigJson = jsonfmt(tsconfig) + "\n" 228 | return this.writefile(this.tsconfigFile, tsconfigJson) 229 | } 230 | 231 | 232 | async writeCSS() :Promise { 233 | if (!this.overwrite && await exists(this.cssFile)) { 234 | return this.warnFileExist(this.cssFile) 235 | } 236 | return this.copyfile(pjoin(figplugDir, "lib", "template.css"), this.cssFile) 237 | } 238 | 239 | 240 | async writeHTML() :Promise { 241 | if (!this.overwrite && await exists(this.htmlFile)) { 242 | return this.warnFileExist(this.htmlFile) 243 | } 244 | let templateFile = pjoin( 245 | figplugDir, 246 | "lib", 247 | ( this.ui == "react" ? "template-ui-react.html" : 248 | this.ui == "ts+html" ? "template-ui.ts.html" : 249 | "template-ui.html" 250 | ), 251 | ) 252 | return this.copyfile(templateFile, this.htmlFile) 253 | } 254 | 255 | 256 | async writeUITS() :Promise { 257 | if (!this.overwrite && await exists(this.uiFile)) { 258 | return this.warnFileExist(this.uiFile) 259 | } 260 | let templateFile = pjoin(figplugDir, "lib", "template-ui.ts") 261 | return this.copyfile(templateFile, this.uiFile) 262 | } 263 | 264 | 265 | async writeUITSX() :Promise { 266 | if (!this.overwrite && await exists(this.uiFile)) { 267 | return this.warnFileExist(this.uiFile) 268 | } 269 | let templateFile = pjoin(figplugDir, "lib", "template-ui-react.tsx") 270 | return this.copyfile(templateFile, this.uiFile) 271 | } 272 | 273 | 274 | async writePackageJson() :Promise { 275 | if (!this.overwrite && await exists(this.packageFile)) { 276 | return this.warnFileExist(this.packageFile) 277 | } 278 | this.wrotePackage = true 279 | let templateFile = pjoin(figplugDir, "lib", "template-package-react.json") 280 | return this.copyfile(templateFile, this.packageFile) 281 | } 282 | 283 | 284 | async writePlugin() :Promise { 285 | if (!this.overwrite && await exists(this.pluginFile)) { 286 | return this.warnFileExist(this.pluginFile) 287 | } 288 | let templateFile = pjoin( 289 | figplugDir, 290 | "lib", 291 | this.ui ? "template-plugin-ui.ts" : 292 | "template-plugin.ts" 293 | ) 294 | return this.copyfile(templateFile, this.pluginFile) 295 | } 296 | 297 | 298 | async writeManifest() :Promise { 299 | // manifest data 300 | let manifest :ManifestProps = { 301 | api: this.apiVersion, 302 | name: this.name, 303 | main: relpath(this.dir, this.pluginFile), 304 | } 305 | if (this.ui == "html") { 306 | manifest.ui = relpath(this.dir, this.htmlFile) 307 | } else if (this.ui) { 308 | manifest.ui = relpath(this.dir, this.uiFile) 309 | } 310 | 311 | // existing manifest? 312 | let existingJs 313 | try { existingJs = await readfile(this.manifestFile, "utf8") } catch (_) {} 314 | if (existingJs) { 315 | if (!this.overwrite) { 316 | return this.warnFileExist(this.manifestFile) 317 | } 318 | let existingManifest = jsonparse(existingJs) 319 | manifest = Object.assign(existingManifest, manifest) 320 | } 321 | 322 | let json = jsonfmt(manifest) + "\n" 323 | return this.writefile(this.manifestFile, json) 324 | } 325 | } 326 | 327 | 328 | let _tsConfigTemplate = null as string | null 329 | 330 | async function getTsConfigTemplate() :Promise<{[k:string]:any}> { 331 | if (!_tsConfigTemplate) { 332 | let fn = pjoin(figplugDir, "lib", "template-tsconfig.json") 333 | _tsConfigTemplate = JSON.stringify(jsonparse(await readfile(fn, "utf8"))) 334 | } 335 | return JSON.parse(_tsConfigTemplate) // copy 336 | } 337 | -------------------------------------------------------------------------------- /src/jpeg.ts: -------------------------------------------------------------------------------- 1 | // Ported from NanoJPEG version 1.3.5 (2016-11-14) 2 | // 3 | // Copyright (c) 2009-2016 Martin J. Fiedler 4 | // published under the terms of the MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to 8 | // deal in the Software without restriction, including without limitation the 9 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 10 | // sell copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in 14 | // all copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 22 | // DEALINGS IN THE SOFTWARE. 23 | // 24 | 25 | export interface JpegInfo { 26 | isGreyscale :bool 27 | width :int 28 | height :int 29 | } 30 | 31 | function readUint16BE(buf :ArrayLike, index :int) :int { 32 | return (buf[index] << 8) | buf[index + 1]; 33 | } 34 | 35 | export function jpegInfoBuf(buf :ArrayLike) :JpegInfo { 36 | let i = 0, end = buf.length - 1 37 | if ((buf[i] ^ 0xFF) | (buf[i + 1] ^ 0xD8)) { 38 | throw new Error("invalid jpeg data") 39 | } 40 | i += 2 41 | 42 | function njDecodeLength() :int { 43 | let bytesRemaining = buf.length - i 44 | if (bytesRemaining < 2) { 45 | throw new Error("jpeg data truncated") 46 | } 47 | let length = readUint16BE(buf, i) 48 | if (length > bytesRemaining) { 49 | print({ length, bytesRemaining }) 50 | throw new Error("jpeg data truncated") 51 | } 52 | i += 2 53 | return length 54 | } 55 | 56 | function njDecodeSOF() :JpegInfo { 57 | let length = njDecodeLength() 58 | if (length < 9) { 59 | throw new Error("jpeg syntax error") 60 | } 61 | 62 | // if (buf[i] != 8) { 63 | // throw new Error("unsupported JPEG format") 64 | // } 65 | // try anyways... 66 | 67 | let ncomp = buf[i + 5] 68 | 69 | return { 70 | height: readUint16BE(buf, i + 1), 71 | width: readUint16BE(buf, i + 3), 72 | isGreyscale: ncomp == 1, 73 | } 74 | } 75 | 76 | while (true) { 77 | if (i >= end || (buf[i] != 0xFF)) { 78 | break 79 | } 80 | i += 2 81 | switch (buf[i-1]) { 82 | case 0xC0: return njDecodeSOF() 83 | // case 0xC4: njDecodeDHT 84 | // case 0xDB: njDecodeDQT 85 | // case 0xDD: njDecodeDRI 86 | // case 0xDA: njDecodeScan 87 | default: 88 | // print(`skip section 0x${buf[i-1].toString(16)}`) 89 | i += njDecodeLength() 90 | } 91 | } 92 | 93 | throw new Error("invalid jpeg (missing SOF section)") 94 | } 95 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { unique, isGloballyInstalled } from './util' 2 | import { Manifest } from './manifest' 3 | import { parseopt, die, prog, FlagSpec } from './cli' 4 | import { PluginTarget } from './plugin' 5 | import * as Path from 'path' 6 | import * as fs from 'fs' 7 | import { readfile, isDir } from './fs' 8 | import { BuildCtx } from './ctx' 9 | import { initPlugin, InitOptions } from './init' 10 | import { checkForNewVersion, VersionCheckResult } from "./check-version" 11 | 12 | 13 | async function buildPlugin(manifest :Manifest, c :BuildCtx) { 14 | if (DEBUG) { 15 | Object.freeze(c) 16 | } 17 | let p = new PluginTarget(manifest, c) 18 | return p.build(c) 19 | } 20 | 21 | const baseCliOptions :FlagSpec[] = [ 22 | [["v", "verbose"], "Print additional information to stdout"], 23 | ["debug", "Print a lot of information to stdout. Implies -v"], 24 | ["version", "Print figplug version information"], 25 | ["no-check-version", "Do not check for new version"], 26 | ] 27 | 28 | 29 | function updateBaseCliOptions(baseopt: {[k:string]:any}={}, opt: {[k:string]:any}={}) { 30 | baseopt.debug = opt.debug || baseopt.debug 31 | baseopt.verbose = opt.v || opt.verbose || baseopt.verbose || opt.debug || baseopt.debug 32 | } 33 | 34 | 35 | async function main(argv :string[]) :Promise { 36 | const [opt, args] = parseopt(argv.slice(1), 37 | "Usage: $prog [options] [ ...]\n" + 38 | "\n" + 39 | "commands:\n" + 40 | " init [] Initialize a plugin\n" + 41 | " build [] Build a plugin\n" + 42 | " version Print figplug version information\n" + 43 | " help [] Equivalent to $prog command -help\n" 44 | , 45 | ...baseCliOptions 46 | ) 47 | if (args.length == 0) { 48 | die(`missing . Try ${prog} -help`) 49 | } 50 | 51 | // normalize options (-debug implies -verbose; -v => -verbose) 52 | opt.verbose = opt.verbose2 || opt.v || opt.verbose 53 | 54 | let command = args[0] 55 | 56 | if (command == "help") { 57 | // convert "prog help command" => "prog command -help" 58 | command = args[1] 59 | if (!command) { 60 | return main([argv[0], "-h"]) 61 | } 62 | args[0] = command 63 | args[1] = "-help" 64 | } 65 | 66 | if (opt.version) { 67 | return main_version(args, opt) 68 | } 69 | 70 | // start version check in background 71 | if (!DEBUG && command != "version" && !opt["no-check-version"] && isGloballyInstalled()) { 72 | // Note: "version" command checks for version in a custom way 73 | checkForNewVersion() 74 | } 75 | 76 | switch (command) { 77 | case "init": return main_init(args, opt) 78 | case "build": return main_build(args, opt) 79 | case "version": return main_version(args, opt) 80 | default: { 81 | die(`unknown command ${repr(command)}. Try ${prog} -help`) 82 | } 83 | } 84 | } 85 | 86 | 87 | async function main_version(argv :string[], baseopt: {[k:string]:any}={}) { 88 | const [opt, ] = parseopt(argv.slice(1), 89 | `Usage: $prog ${argv[0]} [-v|-verbose]\n` + 90 | "Print figplug version information." 91 | , 92 | ...baseCliOptions.filter(f => !Array.isArray(f) || f[0] != "version") 93 | ) 94 | 95 | updateBaseCliOptions(baseopt, opt) 96 | opt.verbose = opt.verbose || opt.v || baseopt.verbose 97 | 98 | print(`figplug ${VERSION}` + (VERSION_TAG ? ` (${VERSION_TAG})` : "")) 99 | print( 100 | `Supported Figma Plugin API versions:` + 101 | `\n ${FIGMA_API_VERSIONS.join("\n ")}` 102 | ) 103 | 104 | if (!opt.verbose) { 105 | process.exit(0) 106 | } 107 | 108 | print(`System and library info:`) 109 | let p = JSON.parse(fs.readFileSync(__dirname + "/../package.json", "utf8")) 110 | let nmdir = __dirname + "/../node_modules/" 111 | let extraInfo = [ 112 | ["arch", process.arch], 113 | ["platform", process.platform], 114 | ] as string[][] 115 | for (let k of Object.keys(process.versions)) { 116 | extraInfo.push([k, (process.versions as any)[k]]) 117 | } 118 | let longestName = extraInfo.reduce((a, e) => Math.max(a, e[0].length), 0) 119 | let spaces = " " 120 | for (let [k,v] of extraInfo) { 121 | k += spaces.substr(0, longestName - k.length) 122 | print(` ${k} ${v}`) 123 | } 124 | 125 | extraInfo.splice(0, extraInfo.length) 126 | for (let dn of Object.keys(p.dependencies)) { 127 | try { 128 | let p2 = JSON.parse( 129 | fs.readFileSync(`${nmdir}/${dn}/package.json`, "utf8") 130 | ) 131 | extraInfo.push([dn, p2.version]) 132 | } catch (_) { 133 | extraInfo.push([dn, "(unavailable)"]) 134 | } 135 | } 136 | longestName = extraInfo.reduce((a, e) => Math.max(a, e[0].length), 0) 137 | print(` deps:`) 138 | for (let [k,v] of extraInfo) { 139 | k += spaces.substr(0, longestName - k.length) 140 | print(` ${k} ${v}`) 141 | } 142 | 143 | if (opt["no-check-version"] || !isGloballyInstalled()) { 144 | process.exit(0) 145 | } 146 | 147 | if (!DEBUG) { 148 | console.log("Checking for new version...") 149 | switch (await checkForNewVersion()) { 150 | case VersionCheckResult.UsingLatest: 151 | console.log(`You are using the latest version of figplug.`) 152 | break 153 | case VersionCheckResult.UsingFuture: 154 | console.log(`You are using a future, unreleased version of figplug.`) 155 | break 156 | case VersionCheckResult.Error: 157 | console.log(`An error occured while checking for new version.`) 158 | break 159 | case VersionCheckResult.UsingOld: 160 | break 161 | } 162 | } 163 | } 164 | 165 | 166 | async function main_init(argv :string[], baseopt: {[k:string]:any}={}) { 167 | const [opt, args] = parseopt(argv.slice(1), 168 | `Usage: $prog ${argv[0]} [ ...]\n` + 169 | "Initialize Figma plugins in directories provided as , or the current directory." 170 | , 171 | ["ui", "Generate UI written in TypeScript & HTML"], 172 | ["html", "Generate UI written purely in HTML"], 173 | ["react", "Generate UI written in React"], 174 | [["f", "force"], "Overwrite or replace existing files"], 175 | ["api", `Specify Figma Plugin API version. Defaults to "${FIGMA_API_VERSIONS[0]}".`, ""], 176 | ["name", "Name of plugin. Defaults to directory name.", ""], 177 | ["srcdir", "Where to put source files, relative to . Defaults to \".\".", ""], 178 | ...baseCliOptions 179 | ) 180 | 181 | updateBaseCliOptions(baseopt, opt) 182 | 183 | let dirs = args.length == 0 ? ["."] : args 184 | 185 | let baseOptions :Partial = { 186 | verbose: baseopt.verbose, 187 | debug: baseopt.verbose2, 188 | name: opt.name, 189 | overwrite: !!opt.force, 190 | srcdir: opt.srcdir as string|undefined, 191 | apiVersion: opt.api as string|undefined, 192 | ui: ( 193 | opt["react"] ? "react" : 194 | opt["html"] ? "html" : 195 | opt.ui ? "ts+html" : 196 | undefined 197 | ), 198 | } 199 | 200 | let allSuccess = await Promise.all( 201 | dirs.map(dir => 202 | initPlugin({ ...baseOptions, dir }) 203 | ) 204 | ).then(v => v.every(r => r)) 205 | 206 | if (!allSuccess) { 207 | console.error( 208 | `Remove files you'd like to be re-created, `+ 209 | `or run with -force to overwrite all files.` 210 | ) 211 | process.exit(1) 212 | } else { 213 | process.exit(0) 214 | } 215 | } 216 | 217 | 218 | async function main_build(argv :string[], baseopt: {[k:string]:any}={}) { 219 | const [opt, args] = parseopt(argv.slice(1), 220 | `Usage: $prog ${argv[0]} [options] [ ...]\n` + 221 | "Builds Figma plugins.\n" + 222 | "\n" + 223 | " Path to a plugin directory or a manifest file. Defaults to \".\".\n" + 224 | " You can optionally specify an output directory for every path through\n" + 225 | " :. Example: src:build.\n" + 226 | " This is useful when building multiple plugins at the same time.\n" 227 | , 228 | ["w", "Watch sources for changes and rebuild incrementally"], 229 | ["g", "Generate debug code (assertions and DEBUG branches)."], 230 | ["O", "Generate optimized code."], 231 | ["lib", "Include a global JS library in plugin code. " + 232 | "Can be set multiple times.", ":string[]"], 233 | ["uilib", "Include a global JS library in UI code. " + 234 | "Can be set multiple times.", ":string[]"], 235 | ["clean", "Force rebuilding of everything, ignoring cache. Implied with -O."], 236 | ["nomin", "Do not minify or mangle optimized code when -O is enabled."], 237 | ["no-manifest", "Do not generate manifest.json"], 238 | ["no-source-map", "Do not generate a source map."], 239 | ["ext-source-map", "Place source map in separate file instead of inlining."], 240 | [["o", "output"], "Write output to directory. Defaults to ./build", ""], 241 | ...baseCliOptions 242 | ) 243 | 244 | updateBaseCliOptions(baseopt, opt) 245 | 246 | // create build context object 247 | const c = new BuildCtx() 248 | c.verbose2 = baseopt.debug || c.verbose2 249 | c.verbose = baseopt.verbose || c.verbose 250 | c.watch = opt.w || c.watch 251 | c.debug = opt.g || c.debug 252 | c.optimize = opt.O || c.optimize 253 | c.clean = opt.clean || c.clean 254 | c.nomin = opt.nomin || c.nomin 255 | c.outdir = opt.o || opt.outdir || c.outdir 256 | c.libs = opt.lib || [] 257 | c.uilibs = opt.uilib || [] 258 | c.noGenManifest = !!opt["no-manifest"] 259 | c.noSourceMap = !!opt["no-source-map"] 260 | c.externalSourceMap = !!opt["ext-source-map"] 261 | 262 | // set manifest locations based on CLI arguments 263 | let manifestPaths = unique( 264 | (args.length == 0 ? [process.cwd() + Path.sep] : args) 265 | .map(s => Path.resolve(s)) 266 | ) 267 | 268 | // build a plugin for each input manifest location 269 | return Promise.all(manifestPaths.map(async (path) => { 270 | let c2 :BuildCtx = c 271 | 272 | let i = path.indexOf(":") 273 | if (i != -1) { 274 | let outdir = path.substr(i+1) 275 | path = path.substr(0, i) 276 | c2 = new BuildCtx(c2) 277 | c2.outdir = outdir 278 | } 279 | 280 | let manifest = await Manifest.load(path) 281 | c2.version = (await findVersion(c2, manifest)) || c2.version 282 | 283 | return buildPlugin(manifest, c2) 284 | })).then(() => { 285 | process.exit(0) 286 | }) 287 | } 288 | 289 | 290 | async function findVersion(c :BuildCtx, manifest :Manifest) :Promise { 291 | if (manifest.props.figplug && manifest.props.figplug.version) { 292 | return manifest.props.figplug.version 293 | } 294 | // look for package.json 295 | let manifestdir = Path.dirname(manifest.file) 296 | let srcdir = Path.dirname(Path.resolve(manifestdir, manifest.props.main)) 297 | let longestUnionPath = ( 298 | manifestdir.startsWith(srcdir) ? manifestdir : 299 | srcdir.startsWith(manifestdir) ? srcdir : 300 | "" 301 | ) 302 | let dirs = longestUnionPath ? [ longestUnionPath ] : [ manifestdir, srcdir ] 303 | let versionFound = "" 304 | await Promise.all(dirs.map(async dir => { 305 | let maxdepth = 5 306 | while (maxdepth-- && dir != "/" && dir.indexOf(":\\") != 1 && !versionFound) { 307 | let packageFile = Path.join(dir, "package.json") 308 | let packageJson :{version?:string}|undefined 309 | try { 310 | packageJson = JSON.parse(await readfile(packageFile, "utf8")) 311 | if (packageJson && packageJson.version) { 312 | if (!versionFound) { 313 | versionFound = packageJson.version 314 | } 315 | break 316 | } 317 | } catch (_) { 318 | // ignore non-existing, unreadable or unparsable file 319 | } 320 | // if dir contains VCS dir, stop. 321 | if ((await Promise.all([ ".git", ".hg", ".svn" ].map(vcsDir => 322 | isDir(Path.join(dir, vcsDir)) 323 | ))).some(v => v)) { 324 | break 325 | } 326 | dir = Path.dirname(dir) 327 | } 328 | })) 329 | return versionFound 330 | } 331 | 332 | 333 | function onError(err :any) { 334 | if (typeof err != "object" || !(err as any)._wasReported) { 335 | die(err) 336 | } else { 337 | process.exit(1) 338 | } 339 | } 340 | 341 | process.on('unhandledRejection', onError) 342 | main(process.argv.slice(1)).catch(onError) 343 | -------------------------------------------------------------------------------- /src/manifest.ts: -------------------------------------------------------------------------------- 1 | import * as Path from 'path' 2 | import { readfile, stat } from './fs' 3 | import { jsonparse } from './util' 4 | import { join as pjoin } from 'path' 5 | 6 | export interface ManifestFigPlugProps { 7 | libs? :string[] 8 | uilibs? :string[] 9 | moduleId? :string 10 | version? :string // sets VERSION constant; overrides version in package.json 11 | } 12 | 13 | export interface ManifestProps { 14 | name :string 15 | api :string 16 | main :string 17 | id? :string 18 | ui? :string 19 | menu? :MenuEntry[] 20 | build? :string 21 | figplug? :ManifestFigPlugProps 22 | 23 | [k:string] :any // unknown future properties 24 | } 25 | 26 | export type MenuEntry = MenuItem | MenuSeparator | Menu 27 | export interface MenuItem { 28 | name :string 29 | command :string 30 | } 31 | export interface MenuSeparator { 32 | separator :true 33 | } 34 | export interface Menu { 35 | name :string 36 | menu :MenuEntry[] 37 | } 38 | 39 | // type union of possible values of figma manifest 40 | type ManifestValue = string | MenuEntry[] 41 | 42 | // props required by Figma.ManifestJson 43 | const requiredProps = [ 44 | "name", 45 | "api", 46 | "main", 47 | ] 48 | 49 | // TODO: consider preprocessing the TypeScript definitions to automatically 50 | // generate the data above. 51 | 52 | 53 | export class Manifest { 54 | readonly file: string 55 | readonly props: ManifestProps 56 | 57 | constructor(file :string, props: ManifestProps) { 58 | this.file = file 59 | this.props = props 60 | } 61 | 62 | // returns a map of properties in a predefined, well-known order. 63 | // 64 | propMap() :Map { 65 | let m = new Map() 66 | for (let name of Object.keys(this.props)) { 67 | if (name.toLowerCase() != "figplug") { 68 | m.set(name, this.props[name]) 69 | } 70 | } 71 | return m 72 | } 73 | 74 | // load a manifest file at path which can name a file or a directory. 75 | // 76 | static async load(path :string) :Promise { 77 | if (path.endsWith(".json") || path.endsWith(".js")) { 78 | return Manifest.loadFile(Path.resolve(path)) 79 | } else if (path.endsWith(Path.sep)) { // e.g. foo/bar/ 80 | return Manifest.loadDir(Path.resolve(path)) 81 | } 82 | path = Path.resolve(path) 83 | if ((await stat(path)).isDirectory()) { 84 | return Manifest.loadDir(path) 85 | } 86 | return Manifest.loadFile(path) 87 | } 88 | 89 | // loadDir loads some manifest file in a directory 90 | // 91 | static loadDir(dir :string) :Promise { 92 | return Manifest.loadFile(pjoin(dir, "manifest.json")).catch(e => { 93 | if (e.code == 'ENOENT') { 94 | return Manifest.loadFile(pjoin(dir, "manifest.js")) 95 | } 96 | throw e 97 | }) 98 | } 99 | 100 | // loadFile loads a manifest file 101 | // 102 | static async loadFile(file :string) :Promise { 103 | let props = jsonparse(await readfile(file, 'utf8'), file) as ManifestProps 104 | 105 | // verify that required properties are present 106 | for (let prop of requiredProps) { 107 | if ((props as any)[prop] === undefined) { 108 | throw new Error(`missing ${repr(prop)} property in ${file}`) 109 | } 110 | } 111 | 112 | return new Manifest(file, props) 113 | } 114 | } 115 | 116 | 117 | // const internalManifestProps = new Set([ 118 | // 'manifestFile', 119 | // ]) 120 | 121 | // // manifestToFigmaManifest returns a Figma.ManifestJson without 122 | // // figplug properties. 123 | // // 124 | // export function manifestToFigmaManifest(m :Manifest) :Figma.ManifestJson { 125 | // let fm :{[k:string]:any} = {} 126 | // for (let k of Object.keys(m)) { 127 | // if (!internalManifestProps.has(k)) { 128 | // fm[k] = (m as any)[k] 129 | // } 130 | // } 131 | // return fm as Figma.ManifestJson 132 | // } 133 | -------------------------------------------------------------------------------- /src/pkgbuild.d.ts: -------------------------------------------------------------------------------- 1 | import { BuildCtx } from './ctx' 2 | import * as strings from './strings' 3 | 4 | export interface Pkg { 5 | dir :string // absolute path of directory 6 | name :string // name of package 7 | version :string // from package.json, or "" if undefined 8 | info :{[k:string]:any} // package.json, if found 9 | 10 | init(dir :string) 11 | } 12 | 13 | export const pkg :Pkg 14 | 15 | export type ConstantDefinitions = { [name :string] : any } 16 | 17 | 18 | export class ProductProps { 19 | name :string 20 | id :string // derived from entry if not set 21 | version :string 22 | entry :string // entry source file 23 | outfile :string // output js file 24 | cachedir :string // cache dir for build intermediates like .tscache 25 | basedir :string // base directory where tsconfig is expected to be found 26 | 27 | mapfile :string // output map file. default to {outfile}.map 28 | debug :bool // enable debugging features like assertions 29 | optimize :bool // optimize output 30 | nomin :bool // disable minification (no effect if optimize=false) 31 | srcdir :string // source base directory. affects source maps 32 | clean :bool // clean build cache; always rebuild from source. 33 | libs :LibBase[] // libraries 34 | banner :string // JavaScript to put at top of product code 35 | jsx :string // non-empty to enable JSX processing with named impl 36 | subs :strings.Subs // substitute left string with right string in generated code 37 | 38 | targetESVersion :number // 0 == latest 39 | } 40 | 41 | export interface IncrementalBuildProcess extends Promise { 42 | end() :void // ends build process 43 | restart() :Promise // restarts build process 44 | readonly ended :bool // true after process has ended 45 | } 46 | 47 | export interface BuildResult { 48 | js :string 49 | map :string 50 | } 51 | 52 | export class Product extends ProductProps { 53 | readonly outdir :string // dirname of outfile 54 | readonly output :BuildResult // changes on build 55 | readonly defines :ConstantDefinitions // definitions 56 | readonly definesInline :ConstantDefinitions // definitions to be inlined 57 | 58 | // libraries can be modified, but should never be changed during a build. 59 | libs :Lib[] 60 | stdlibs :StdLib[] 61 | 62 | constructor(props :Partial) 63 | 64 | // copy returns a shallow copy 65 | copy() :Product 66 | 67 | async build(c :BuildCtx) :Promise 68 | buildIncrementally( 69 | c :BuildCtx, 70 | onStartBuild? :(isInitial: bool)=>any, 71 | onEndBuild? :(error? :Error)=>any, // error is present when ended with error 72 | ) :IncrementalBuildProcess 73 | } 74 | 75 | 76 | export interface LibProps { 77 | dfile? :string 78 | jsfile? :string 79 | cachedir? :string 80 | } 81 | export class LibBase {} 82 | 83 | export class Lib extends LibBase { 84 | dfile :string 85 | jsfile :string 86 | cachedir :string 87 | 88 | constructor(dfile :string) 89 | constructor(props :LibProps) 90 | 91 | getDefines(debug :bool) :ConstantDefinitions 92 | getCode(c :BuildCtx) :Promise 93 | } 94 | 95 | // StdLib represents a standard TypeScript library like "dom" 96 | export class StdLib extends LibBase { 97 | readonly name :string 98 | constructor(name :string) 99 | } 100 | 101 | // UserLib represents a user-provided library 102 | export class UserLib extends Lib {} 103 | -------------------------------------------------------------------------------- /src/plugin.ts: -------------------------------------------------------------------------------- 1 | import { BuildCtx, figplugDir } from './ctx' 2 | import { Lib, StdLib, UserLib, LibProps, Product, IncrementalBuildProcess } from './pkgbuild' 3 | import * as os from 'os' 4 | import { existsSync, watch as watchFile } from 'fs' 5 | import * as Html from 'html' 6 | import { jsonfmt, rpath, fmtDuration, parseQueryString } from './util' 7 | import { readfile, writefile, isFile } from './fs' 8 | import postcssNesting from 'postcss-nesting' 9 | import { Manifest } from './manifest' 10 | import * as Path from 'path' 11 | import { 12 | join as pjoin, 13 | dirname, 14 | basename, 15 | parse as parsePath, 16 | resolve as presolve, 17 | } from 'path' 18 | import { AssetBundler, AssetInfo } from './asset' 19 | 20 | 21 | const domTSLib = new StdLib("dom") 22 | 23 | 24 | let _figplugLib :Lib|null = null 25 | 26 | function getFigplugLib() :Lib { 27 | return _figplugLib || (_figplugLib = new Lib({ 28 | dfile: pjoin(figplugDir, 'lib', 'figplug.d.ts'), 29 | jsfile: pjoin(figplugDir, 'lib', 'figplug.js'), 30 | cachedir: pjoin(os.tmpdir(), 'figplug'), 31 | })) 32 | } 33 | 34 | 35 | let figmaPluginLibCache = new Map() // by version 36 | 37 | function getFigmaPluginLib(apiVersion? :string) :Lib { 38 | let v :string = FIGMA_API_VERSIONS[0] // latest version 39 | if (apiVersion && apiVersion != "latest") { 40 | v = apiVersion 41 | } 42 | let lib = figmaPluginLibCache.get(v) 43 | if (!lib) { 44 | let dfile = pjoin(figplugDir, 'lib', `figma-plugin-${v}.d.ts`) 45 | if (!existsSync(dfile)) { 46 | console.warn( 47 | `warning: unknown Figma API version ${apiVersion}.`+ 48 | ` Using type definitions for latest known version.` 49 | ) 50 | dfile = pjoin(figplugDir, 'lib', `figma-plugin.d.ts`) 51 | } 52 | lib = new Lib(dfile) 53 | figmaPluginLibCache.set(v, lib) 54 | } 55 | return lib 56 | } 57 | 58 | 59 | async function setLibPropsFiles(input: string, fn :string, props :LibProps) { 60 | if (fn.endsWith(".d.ts")) { 61 | // provided foo.d.ts 62 | // =? foo.js 63 | // == foo.d.ts 64 | if (props.dfile) { 65 | throw new Error(`duplicate .d.ts file provided for -lib=${repr(input)}`) 66 | } 67 | props.dfile = fn 68 | } else if (fn.endsWith(".js")) { 69 | // provided foo.js 70 | // == foo.js 71 | // =? foo.d.ts 72 | if (props.jsfile) { 73 | throw new Error(`duplicate .js file provided for -lib=${repr(input)}`) 74 | } 75 | props.jsfile = fn 76 | } else { 77 | // assume fn lacks extension -- look for both fn.js and fn.d.ts 78 | let jsfn = fn + ".js" 79 | let dtsfn = fn + ".d.ts" 80 | let hasJS = props.jsfile ? Promise.resolve(false) : isFile(jsfn) 81 | let hasDTS = props.dfile ? Promise.resolve(false) : isFile(dtsfn) 82 | if (await hasJS) { 83 | props.jsfile = jsfn 84 | } 85 | if (await hasDTS) { 86 | props.dfile = dtsfn 87 | } 88 | } 89 | } 90 | 91 | 92 | async function getUserLib(filename :string, basedir :string, cachedir :string) :Promise { 93 | let props = {} as LibProps 94 | let names = filename.split(":").map(fn => Path.isAbsolute(fn) ? fn : Path.resolve(basedir, fn)) 95 | 96 | if (names.length > 1) { 97 | // foo.js:foo.d.ts 98 | if (names.length > 2) { 99 | throw new Error(`too many filenames provided for -lib=${repr(filename)}`) 100 | } 101 | for (let fn of names) { 102 | fn = Path.resolve(fn) 103 | await setLibPropsFiles(filename, fn, props) 104 | } 105 | } else { 106 | let fn = Path.resolve(names[0]) 107 | await setLibPropsFiles(filename, fn, props) 108 | } 109 | if (!props.dfile && !props.jsfile) { 110 | throw new Error(`library not found ${filename} (${names.join(", ")})`) 111 | } 112 | if (!props.jsfile) { 113 | // .d.ts file was set -- try to discover matching .js file 114 | let jsfn = props.dfile!.substr(0, props.dfile!.length - ".d.ts".length) + ".js" 115 | if (await isFile(jsfn)) { 116 | props.jsfile = jsfn 117 | } 118 | } else if (!props.dfile) { 119 | // .js file was set -- try to discover matching .d.ts file 120 | let dtsfn = props.jsfile!.substr(0, props.jsfile!.length - ".js".length) + ".d.ts" 121 | if (await isFile(dtsfn)) { 122 | props.dfile = dtsfn 123 | } 124 | } 125 | if (props.jsfile) { 126 | props.cachedir = cachedir 127 | } 128 | // Note: It probably doesn't help much to cache these, so we don't and keep 129 | // this code a little simpler. 130 | return new UserLib(props) 131 | } 132 | 133 | 134 | function stripFileExt(filename :string) :string { 135 | let ext = Path.extname(filename) 136 | return ext ? filename.substr(0, filename.length - ext.length) : filename 137 | } 138 | 139 | 140 | interface UserLibSpec { 141 | fn :string // possibly-relative filename 142 | basedir :string // absolute base dir 143 | } 144 | 145 | 146 | export class PluginTarget { 147 | readonly manifest :Manifest 148 | readonly basedir :string // root of plugin; dirname of manifest.json 149 | readonly srcdir :string 150 | readonly outdir :string 151 | readonly cachedir :string // == pjoin(this.outdir, ".figplug-cache") 152 | readonly name :string // e.g. "Foo Bar" from manifest.props.name 153 | readonly pluginProduct :Product 154 | readonly uiProduct :Product|null = null 155 | 156 | // user lib specs provided with constructor and manifest 157 | readonly pUserLibs :UserLibSpec[] = [] 158 | readonly uiUserLibs :UserLibSpec[] = [] 159 | needLoadUserLibs :bool = false // true when loadUserLibs needs to be called by build() 160 | 161 | // output files 162 | readonly pluginOutFile :string 163 | readonly htmlOutFile :string = "" // non-empty when uiProduct is set 164 | readonly htmlInFile :string = "" // non-empty when uiProduct is set 165 | readonly cssInFile :string = "" // non-empty when uiProduct is set 166 | 167 | // incremental build promises 168 | pluginIncrBuildProcess :IncrementalBuildProcess|null = null 169 | uiIncrBuildProcess :IncrementalBuildProcess|null = null 170 | 171 | 172 | constructor(manifest :Manifest, c :BuildCtx) { 173 | this.manifest = manifest 174 | this.basedir = dirname(manifest.file) 175 | 176 | let pluginSrcFile = presolve(this.basedir, manifest.props.main) 177 | 178 | this.srcdir = dirname(pluginSrcFile) 179 | this.outdir = c.outdir || pjoin(this.srcdir, "build") 180 | this.cachedir = pjoin(this.outdir, ".figplug-cache") 181 | this.name = manifest.props.name 182 | 183 | let customModuleId = manifest.props.figplug && manifest.props.figplug.moduleId 184 | let moduleId = customModuleId || stripFileExt(manifest.props.main) 185 | 186 | this.pluginOutFile = pjoin( 187 | this.outdir, 188 | (customModuleId || parsePath(pluginSrcFile).name) + '.js' 189 | ) 190 | 191 | // setup libs 192 | let figplugLib = getFigplugLib() 193 | let figmaPluginLib = getFigmaPluginLib(manifest.props.api) 194 | 195 | // setup user libs 196 | this.initUserLibs(c.libs, c.uilibs) 197 | 198 | // setup plugin product 199 | this.pluginProduct = new Product({ 200 | version: c.version, 201 | id: moduleId, 202 | entry: pluginSrcFile, 203 | outfile: this.pluginOutFile, 204 | basedir: this.basedir, 205 | cachedir: this.cachedir, 206 | libs: [ figplugLib, figmaPluginLib ], 207 | targetESVersion: 8, // Figma's JS VM supports ES2017 208 | mapfile: ( 209 | c.externalSourceMap ? (this.pluginOutFile + '.map') : 210 | pjoin(this.cachedir, this.pluginOutFile.replace(/[^A-Za-z0-9_\-]+/g, ".") + '.map') 211 | ), 212 | }) 213 | 214 | // setup ui product 215 | if (manifest.props.ui) { 216 | let uisrcFile = pjoin(this.basedir, manifest.props.ui) 217 | let uisrcFilePath = parsePath(uisrcFile) 218 | let ext = uisrcFilePath.ext.toLowerCase() 219 | let uisrcDir = uisrcFilePath.dir 220 | let uisrcName = pjoin(uisrcDir, uisrcFilePath.name) 221 | 222 | this.htmlInFile = uisrcName + '.html' 223 | this.cssInFile = uisrcName + '.css' 224 | this.htmlOutFile = pjoin(this.outdir, uisrcFilePath.name + '.html') 225 | 226 | if (!uisrcFile.endsWith(".html")) { 227 | this.uiProduct = new Product({ 228 | version: this.pluginProduct.version, 229 | id: stripFileExt(manifest.props.ui), 230 | entry: uisrcFile, 231 | outfile: pjoin(this.cachedir, 'ui.js'), 232 | basedir: this.basedir, 233 | cachedir: this.cachedir, 234 | libs: [ figplugLib, domTSLib ], 235 | jsx: (ext == ".tsx" || ext == ".jsx") ? "react" : "", 236 | targetESVersion: this.pluginProduct.targetESVersion, 237 | }) 238 | } // else: HTML-only UI 239 | } 240 | } 241 | 242 | 243 | initUserLibs(pUserLibs :string[], uiUserLibs :string[]) { 244 | let mp = this.manifest.props 245 | 246 | // sets used to avoid duplicate entries. Values are absolute paths. 247 | let seenp = new Set() 248 | let seenui = new Set() 249 | 250 | let add = (seen :Set, v :UserLibSpec[], basedir :string, fn :string) => { 251 | let path = presolve(basedir, fn) 252 | if (!seen.has(path)) { 253 | seen.add(path) 254 | v.push({ fn, basedir }) 255 | } 256 | } 257 | 258 | // Add libs from config/CLI. 259 | // libs defined on command line are relative to current working directory 260 | let basedir = process.cwd() 261 | for (let fn of pUserLibs) { 262 | add(seenp, this.pUserLibs, basedir, fn) 263 | } 264 | if (mp.ui) for (let fn of uiUserLibs) { 265 | add(seenui, this.uiUserLibs, basedir, fn) 266 | } 267 | 268 | // Add libs from manifest 269 | if (mp.figplug) { 270 | let basedir = this.srcdir // plugin libs defined in manifest are relative to srcdir 271 | if (mp.figplug.libs) for (let fn of mp.figplug.libs) { 272 | add(seenp, this.pUserLibs, basedir, fn) 273 | } 274 | if (mp.ui && mp.figplug.uilibs) for (let fn of mp.figplug.uilibs) { 275 | add(seenui, this.uiUserLibs, basedir, fn) 276 | } 277 | } 278 | 279 | // set load flag if there are any user libs 280 | this.needLoadUserLibs = (this.pUserLibs.length + this.uiUserLibs.length) > 0 281 | } 282 | 283 | 284 | async loadUserLibs(c :BuildCtx) :Promise { 285 | assert(this.needLoadUserLibs) 286 | this.needLoadUserLibs = false 287 | 288 | // dedup libs to make sure we only have once UserLib instance per actual lib file 289 | let loadLibs = new Map() 290 | let libPaths :string[] = [] 291 | for (let ls of this.pUserLibs.concat(this.uiUserLibs)) { 292 | let path = presolve(ls.basedir, ls.fn) 293 | loadLibs.set(path, ls) 294 | libPaths.push(path) 295 | } 296 | 297 | // load and await all 298 | if (c.verbose2) { 299 | print(`[${this.name}] load libs:\n ` + Array.from(loadLibs.keys()).join("\n ")) 300 | } 301 | let loadedLibs = new Map() 302 | await Promise.all(Array.from(loadLibs).map(([path, ls]) => 303 | getUserLib(ls.fn, ls.basedir, this.cachedir).then(lib => { 304 | loadedLibs.set(path, lib) 305 | }) 306 | )) 307 | 308 | // add libs to products 309 | let libs = libPaths.map(path => loadedLibs.get(path)!) 310 | let i = 0 311 | for (; i < this.pUserLibs.length; i++) { 312 | if (c.verbose) { print(`add plugin ${libs[i]}`) } 313 | this.pluginProduct.libs.push(libs[i]) 314 | } 315 | // remainder of libs are ui libs 316 | for (; i < libs.length; i++) { 317 | assert(this.uiProduct) 318 | if (c.verbose) { print(`add UI ${libs[i]}`) } 319 | this.uiProduct!.libs.push(libs[i]) 320 | } 321 | } 322 | 323 | 324 | async build(c :BuildCtx, onbuild? :()=>void) :Promise { 325 | // TODO: if there's a package.json file in this.basedir then read the 326 | // version from it and assign it to this.version 327 | 328 | if (this.needLoadUserLibs) { 329 | await this.loadUserLibs(c) 330 | } 331 | 332 | // sanity-check input and output files 333 | if (this.pluginProduct.entry == this.pluginProduct.outfile) { 334 | throw "plugin input file is same as output file: " + 335 | repr(this.pluginProduct.entry) 336 | } 337 | if (this.htmlInFile && this.htmlInFile == this.htmlOutFile) { 338 | throw `html input file is same as output file: ` + repr(this.htmlInFile) 339 | } 340 | if (this.uiProduct && this.uiProduct.entry == this.uiProduct.outfile) { 341 | throw "ui input file is same as output file: " + 342 | repr(this.uiProduct.entry) 343 | } 344 | 345 | // setup string subs for ui 346 | if (this.uiProduct) { 347 | this.uiProduct.subs = [ 348 | ["process.env.NODE_ENV", c.debug ? "'development'" : "'production'"], 349 | ] 350 | } 351 | 352 | // reporting 353 | let onStartBuild = () => {} 354 | let onEndBuild = onbuild ? onbuild : (()=>{}) 355 | if (c.verbose || c.watch) { 356 | let info = ( 357 | "plugin " + repr(this.name) + 358 | " at " + rpath(this.srcdir) + 359 | " -> " + rpath(this.outdir) 360 | ) 361 | let startTime = 0 362 | onStartBuild = () => { 363 | startTime = Date.now() 364 | print(`building ${info}`) 365 | } 366 | onEndBuild = () => { 367 | let time = fmtDuration(Date.now() - startTime) 368 | print(`built ${info} in ${time}`) 369 | onbuild && onbuild() 370 | } 371 | } 372 | 373 | // build once or incrementally depending on c.watch 374 | return Promise.all([ 375 | this.writeManifestFile(c, this.manifest), 376 | c.watch ? 377 | this.buildIncr(c, onStartBuild, onEndBuild) : 378 | this.buildOnce(c, onStartBuild, onEndBuild) 379 | ]).then(() => {}) 380 | } 381 | 382 | 383 | async buildIncr(c :BuildCtx, onStartBuild :()=>void, onEndBuild :(e? :Error)=>void) :Promise { 384 | // TODO: return cancelable promise, like we do for 385 | // Product.buildIncrementally. 386 | 387 | if (this.pluginIncrBuildProcess || this.uiIncrBuildProcess) { 388 | throw new Error(`already has incr build process`) 389 | } 390 | 391 | // reload manifest on change 392 | watchFile(this.manifest.file, {}, async () => { 393 | try { 394 | let manifest2 = await Manifest.loadFile(this.manifest.file) 395 | if (this.manifest.props.main != manifest2.props.main || 396 | this.manifest.props.ui != manifest2.props.ui) 397 | { 398 | // source changed -- need to restart build process 399 | // TODO: automate restarting the build 400 | console.error( 401 | '\n' + 402 | `Warning: Need to restart program -- ` + 403 | 'source files in manifest changed.' + 404 | '\n' 405 | ) 406 | } else { 407 | this.writeManifestFile(c, manifest2) 408 | } 409 | } catch (err) { 410 | console.error(err.message) 411 | } 412 | }) 413 | 414 | let onStartBuildHtml = () => {} 415 | const buildHtml = () => { 416 | onStartBuildHtml() 417 | return this.buildHTML(c) 418 | } 419 | 420 | // watch HTML and CSS source files for changes 421 | if (this.htmlInFile && existsSync(this.htmlInFile)) { 422 | watchFile(this.htmlInFile, {}, buildHtml) 423 | } 424 | if (this.cssInFile && existsSync(this.cssInFile)) { 425 | watchFile(this.cssInFile, {}, buildHtml) 426 | } 427 | 428 | // Watch user libraries 429 | let isRestartingPluginBuild = false 430 | const rebuildPlugin = () => { 431 | if (this.pluginIncrBuildProcess && !isRestartingPluginBuild) { 432 | isRestartingPluginBuild = true 433 | this.pluginIncrBuildProcess.restart().then(() => { isRestartingPluginBuild = false }) 434 | } 435 | } 436 | for (let lib of this.pluginProduct.libs) { 437 | if (lib instanceof UserLib) { 438 | if (lib.jsfile) { watchFile(lib.jsfile, {}, rebuildPlugin) } 439 | if (lib.dfile) { watchFile(lib.dfile, {}, rebuildPlugin) } 440 | } 441 | } 442 | if (this.uiProduct) { 443 | let isRestartingUIBuild = false 444 | const rebuildUI = () => { 445 | if (this.uiIncrBuildProcess && !isRestartingUIBuild) { 446 | isRestartingUIBuild = true 447 | this.uiIncrBuildProcess.restart().then(() => { isRestartingUIBuild = false }) 448 | } 449 | } 450 | for (let lib of this.uiProduct.libs) { 451 | if (lib instanceof UserLib) { 452 | if (lib.jsfile) { watchFile(lib.jsfile, {}, rebuildUI) } 453 | if (lib.dfile) { watchFile(lib.dfile, {}, rebuildUI) } 454 | } 455 | } 456 | } 457 | 458 | // have UI? 459 | if (this.uiProduct || this.htmlInFile) { 460 | let buildCounter = 0 461 | let onstart = () => { 462 | if (buildCounter++ == 0) { 463 | onStartBuild() 464 | } 465 | } 466 | let onend = (err? :Error) => { 467 | if (--buildCounter == 0) { 468 | onEndBuild(err) 469 | } 470 | } 471 | 472 | this.pluginIncrBuildProcess = this.pluginProduct.buildIncrementally(c, onstart, onend) 473 | 474 | if (this.uiProduct) { 475 | // TS UI 476 | this.uiIncrBuildProcess = this.uiProduct.buildIncrementally( 477 | c, 478 | onstart, 479 | err => err ? null 480 | : buildHtml().then(() => onend()).catch(onend) 481 | ) 482 | return Promise.all([ 483 | this.pluginIncrBuildProcess, 484 | this.uiIncrBuildProcess, 485 | ]).then(() => {}) 486 | } 487 | 488 | if (this.htmlInFile) { 489 | // HTML-only UI 490 | onStartBuildHtml = onstart 491 | return Promise.all([ 492 | this.pluginIncrBuildProcess, 493 | buildHtml().then(() => onend()).catch(onend), 494 | ]).then(() => {}) 495 | } 496 | } else { 497 | // no UI 498 | return this.pluginIncrBuildProcess = this.pluginProduct.buildIncrementally( 499 | c, 500 | onStartBuild, 501 | onEndBuild, 502 | ) 503 | } 504 | } 505 | 506 | 507 | buildOnce(c :BuildCtx, onStartBuild :()=>void, onEndBuild :()=>void) :Promise { 508 | onStartBuild() 509 | 510 | if (this.uiProduct) { 511 | // TS UI 512 | return Promise.all([ 513 | this.pluginProduct.build(c), 514 | this.uiProduct.build(c).then(() => this.buildHTML(c)), 515 | ]).then(onEndBuild) 516 | } 517 | 518 | if (this.htmlInFile) { 519 | // HTML-only UI 520 | return Promise.all([ 521 | this.pluginProduct.build(c), 522 | this.buildHTML(c), 523 | ]).then(onEndBuild) 524 | } 525 | 526 | // no UI 527 | return this.pluginProduct.build(c).then(onEndBuild) 528 | } 529 | 530 | 531 | async buildHTML(c :BuildCtx) :Promise { 532 | const defaultHtml = ( 533 | "
" 534 | ) 535 | 536 | let startTime = 0 537 | if (c.verbose) { 538 | startTime = Date.now() 539 | print(`build module ${repr(rpath(this.htmlOutFile))}`) 540 | } 541 | 542 | // read contents of HTML and CSS files 543 | let [html, css] = await Promise.all([ 544 | readfile(this.htmlInFile, 'utf8').catch(err => { 545 | if (err.code != 'ENOENT') { throw err } 546 | return defaultHtml 547 | }), 548 | readfile(this.cssInFile, 'utf8').catch(err => { 549 | if (err.code != 'ENOENT') { throw err } 550 | return "" 551 | }), 552 | ]) 553 | 554 | 555 | // Static includes 556 | html = await this.processInlineFiles(c, html) 557 | 558 | // HTML head and tail 559 | let head = "" 560 | let tail = "" 561 | 562 | if (this.uiProduct) { 563 | let js = this.uiProduct.output.js 564 | tail += '' 565 | } 566 | 567 | // process CSS if any was loaded 568 | if (css.trim() != "") { 569 | css = await this.processCss(css, this.cssInFile) 570 | head = '' 571 | } 572 | 573 | // find best offset in html text to insert HTML head content 574 | let htmlOut = "" 575 | let tailInsertPos = Html.findTailIndex(html) 576 | if (head.length) { 577 | let headInsertPos = Html.findHeadIndex(html) 578 | htmlOut = ( 579 | html.substr(0, headInsertPos) + 580 | head + 581 | html.substring(headInsertPos, tailInsertPos) + 582 | tail + 583 | html.substr(tailInsertPos) 584 | ) 585 | } else { 586 | htmlOut = ( 587 | html.substr(0, tailInsertPos) + 588 | tail + 589 | html.substr(tailInsertPos) 590 | ) 591 | } 592 | 593 | if (c.verbose) { 594 | let time = fmtDuration(Date.now() - startTime) 595 | print(`built module ${repr(rpath(this.htmlOutFile))} in ${time}`) 596 | } 597 | 598 | return writefile(this.htmlOutFile, htmlOut, 'utf8') 599 | } 600 | 601 | 602 | // processInlineFiles finds, parses and inlines files referenced by `html`. 603 | // 604 | // This function acts on two different kinds of information: 605 | // 606 | // - HTML elements like img with a src attribute. 607 | // The src attribute value is replaced with a data url and width and height attributes 608 | // are added (unless specified) 609 | // 610 | // - Explicit directives. 611 | // This entire directive is replaced by the contents of the file. 612 | // 613 | // Filenames are relative to dirname(this.htmlInFile). 614 | // 615 | // An optional query string parameter "?as=" can be provided with the filename 616 | // to directives, which controls what the output will be. 617 | // The values for "as=" are: 618 | // 619 | // as=bytearray 620 | // Inserts a comma-separated sequence of bytes of the file in decimal form. 621 | // e.g. 69,120,97,109,112,108,101 for the ASCII data "Example" 622 | // 623 | // as=jsobj 624 | // Inserts a JavaScript literal object with the following interface: 625 | // { 626 | // mimeType :string // File type. Empty if unknown. 627 | // width? :number // for images, the width of the image 628 | // height? :number // for images, the height of the image 629 | // } 630 | // 631 | // Absence of as= query parameters means that the contents of the file is inserted as text. 632 | // 633 | // Note: File loads are deduplicated, so there's really no performance penalty for including 634 | // the same file multiple times. For instance: 635 | // 636 | // 637 | // 641 | // 642 | // This would only cause foo.png to be read once. 643 | // 644 | async processInlineFiles(c :BuildCtx, html :string) :Promise { 645 | interface InlineFile { 646 | type :"html"|"include" 647 | filename :string 648 | mimeType :string 649 | index :number 650 | params :Record 651 | loadp :Promise 652 | assetInfo :AssetInfo|null // non-null when loadp is loaded 653 | loadErr :Error|null // non-null on load error (assetInfo will be null) 654 | 655 | // defined for type=="html" 656 | tagname :string 657 | prefix :string 658 | suffix :string 659 | } 660 | 661 | const re = /<([^\s>]+)([^>]+)src=(?:"([^"]+)"|'([^']+)')([^>]*)>|<\?\s*include\s+(?:"([^"]+)"|'([^']+)')\s*\?>/mig 662 | const reGroupCount = 7 // used for index of "index" in args to replace callback 663 | 664 | // Find 665 | let inlineFiles :InlineFile[] = [] // indexed by character offset in html 666 | let errors :string[] = [] // error messages indexed by character offset in html 667 | let htmlInFileDir = dirname(this.htmlInFile) 668 | html.replace(re, (substr :string, ...m :any[]) => { 669 | let f = { 670 | index: m[reGroupCount], 671 | params: {}, 672 | mimeType: "", 673 | } as InlineFile 674 | if (m[0]) { 675 | let srcval = m[2] || m[3] 676 | if (srcval.indexOf(":") != -1) { 677 | // skip URLs 678 | return substr 679 | } 680 | f.type = "html" 681 | f.filename = pjoin(htmlInFileDir, srcval) 682 | f.tagname = m[0].toLowerCase() 683 | f.prefix = "<" + m[0] + m[1] 684 | f.suffix = m[4].trimRight() + ">" 685 | } else { 686 | // 687 | let srcval = m[5] 688 | if (srcval.indexOf(":") != -1) { 689 | // URLs not supported in ?include? 690 | let error = `invalid file path` 691 | console.error( 692 | `error in ${rpath(this.htmlInFile)}: ${error} in directive: ${substr} -- ignoring` 693 | ) 694 | errors[f.index] = error 695 | return "" 696 | } 697 | f.type = "include" 698 | f.filename = pjoin(htmlInFileDir, m[5]) 699 | } 700 | inlineFiles[f.index] = f 701 | return substr 702 | }) 703 | 704 | if (inlineFiles.length == 0) { 705 | // nothing found -- nothing to do 706 | return html 707 | } 708 | 709 | // Load data files 710 | let fileLoaders = new Map>() // filename => AssetInfo 711 | let assetBundler = new AssetBundler() 712 | 713 | for (let k in inlineFiles) { 714 | let f = inlineFiles[k] 715 | // parse filename and update f 716 | let [filename, mimeType, queryString] = assetBundler.parseFilename(f.filename) 717 | f.params = parseQueryString(queryString) 718 | f.filename = filename 719 | f.mimeType = mimeType || "" 720 | 721 | // start loading file 722 | let loadp = fileLoaders.get(filename) 723 | if (!loadp) { 724 | loadp = assetBundler.loadAssetInfo(filename, f.mimeType) 725 | fileLoaders.set(filename, loadp) 726 | } 727 | f.loadp = loadp.then(assetInfo => { 728 | f.assetInfo = assetInfo 729 | }).catch(err => { 730 | let errmsg = String(err).replace( 731 | filename, 732 | Path.relative(dirname(this.htmlInFile), filename) 733 | ) 734 | console.error(`error in ${rpath(this.htmlInFile)}: ${errmsg}`) 735 | f.loadErr = err 736 | }) 737 | } 738 | 739 | // await file loaders, ignoring errors 740 | await Promise.all(Array.from(fileLoaders.values()).map(p => p.catch(err => {}))) 741 | 742 | // Replace in html 743 | html = html.replace(re, (substr :string, ...m :any[]) => { 744 | let index = m[reGroupCount] 745 | let f = inlineFiles[index] 746 | if (!f) { 747 | let error = errors[index] 748 | if (error) { 749 | return `` 750 | } 751 | // unmodified 752 | return substr 753 | } 754 | 755 | if (!f.assetInfo) { 756 | return substr 757 | } 758 | 759 | if (f.type == "html") { 760 | // Note: f.tagname is lower-cased 761 | 762 | if (f.tagname == "script") { 763 | // special case for -> 764 | let s = f.prefix.trim() + (f.suffix == ">" ? ">" : " " + f.suffix.trimLeft()) 765 | let text = f.assetInfo.getTextData() 766 | if (s.endsWith("/>")) { 767 | // 768 | s = s.substr(0, s.length - 2) + ">" + text + "" 769 | } else { 770 | s += text 771 | } 772 | return s 773 | } 774 | 775 | const sizeTagNames = {"svg":1,"img":1} 776 | let s = f.prefix + `src='${f.assetInfo!.url}'` 777 | if (f.tagname in sizeTagNames && typeof f.assetInfo.attrs.width == "number") { 778 | let width = f.assetInfo.attrs.width as number 779 | let height = f.assetInfo.attrs.height as number 780 | if (width > 0 && height > 0) { 781 | let wm = substr.match(/width=(?:"([^"]+)"|'([^"]+)')/i) 782 | let hm = substr.match(/height=(?:"([^"]+)"|'([^"]+)')/i) 783 | if (wm && !hm) { 784 | // width set but not height -- set height based on width and aspect ratio 785 | let w = parseInt(wm[1] || wm[2]) 786 | if (!isNaN(w) && w > 0) { 787 | s += ` height="${(w * (height / width)).toFixed(0)}" ` 788 | } 789 | } else if (!wm && hm) { 790 | // height set but not width -- set width based on height and aspect ratio 791 | let h = parseInt(hm[1] || hm[2]) 792 | if (!isNaN(h) && h > 0) { 793 | s += ` width="${(h * (width / height)).toFixed(0)}" ` 794 | } 795 | } else if (!wm && !hm) { 796 | // set width & height 797 | s += ` width="${width}" height="${height}" ` 798 | } 799 | } 800 | } 801 | 802 | return s + f.suffix 803 | } 804 | 805 | let asType = "as" in f.params ? (f.params["as"][0]||"").toLowerCase() : "" 806 | if (asType == "bytearray") { 807 | return Array.from(f.assetInfo.getData()).join(",") 808 | } 809 | let text = f.assetInfo.getTextData() 810 | if (asType == "jsobj") { 811 | return JSON.stringify(Object.assign({ 812 | mimeType: f.assetInfo.mimeType, 813 | }, f.assetInfo.attrs), null, 2) 814 | } 815 | return text 816 | }) 817 | 818 | return html 819 | } 820 | 821 | 822 | processCss(css :string, filename :string) :Promise { 823 | return postcssNesting.process(css, { 824 | from: filename, 825 | }, { 826 | features: { 827 | 'nesting-rules': true, 828 | }, 829 | }).then(r => r.toString()) as Promise 830 | } 831 | 832 | 833 | writeManifestFile(c :BuildCtx, manifest :Manifest) :Promise { 834 | if (c.noGenManifest) { 835 | c.verbose2 && print(`[${this.name}] skip writing manifest.json`) 836 | return Promise.resolve() 837 | } 838 | 839 | let props = manifest.propMap() 840 | 841 | // override source file names 842 | props.set("main", basename(this.pluginProduct.outfile)) 843 | if (this.htmlOutFile) { 844 | props.set("ui", basename(this.htmlOutFile)) 845 | } else { 846 | props.delete("ui") 847 | } 848 | 849 | // generate JSON 850 | let json = jsonfmt(props) 851 | 852 | // write file 853 | let file = pjoin(this.outdir, 'manifest.json') 854 | 855 | c.verbose2 && print(`write ${rpath(file)}`) 856 | 857 | return writefile(file, json, 'utf8') 858 | } 859 | 860 | 861 | toString() :string { 862 | let name = JSON.stringify(this.manifest.props.name) 863 | let dir = JSON.stringify( 864 | rpath(this.srcdir) || basename(process.cwd()) 865 | ) 866 | return `Plugin(${name} at ${dir})` 867 | } 868 | } 869 | -------------------------------------------------------------------------------- /src/postcss-nesting.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'postcss-nesting' { 2 | 3 | interface ProcessOptions { 4 | /** 5 | * The path of the CSS source file. You should always set "from", because it is 6 | * used in source map generation and syntax error messages. 7 | */ 8 | from?: string; 9 | /** 10 | * The path where you'll put the output CSS file. You should always set "to" 11 | * to generate correct source maps. 12 | */ 13 | to?: string; 14 | /** 15 | * Function to generate AST by string. 16 | */ 17 | parser?: Parser; 18 | /** 19 | * Class to generate string by AST. 20 | */ 21 | stringifier?: Stringifier; 22 | /** 23 | * Object with parse and stringify. 24 | */ 25 | syntax?: Syntax; 26 | /** 27 | * Source map options 28 | */ 29 | map?: SourceMapOptions | boolean; 30 | } 31 | 32 | interface PluginOptions { 33 | [k:string] :any 34 | } 35 | 36 | function process( 37 | css :string, 38 | processOptions? :ProcessOptions, 39 | pluginOptions? :PluginOptions 40 | ) :Promise 41 | } 42 | -------------------------------------------------------------------------------- /src/proc.ts: -------------------------------------------------------------------------------- 1 | import { promisify } from 'util' 2 | import * as cp from 'child_process' 3 | 4 | export const exec = promisify(cp.execFile) 5 | 6 | export interface SpawnedProc< 7 | ProcType extends cp.ChildProcess|cp.ChildProcessWithoutNullStreams 8 | > extends Promise { 9 | proc :ProcType 10 | } 11 | 12 | export function spawn( 13 | command: string, 14 | options?: cp.SpawnOptionsWithoutStdio 15 | ): SpawnedProc; 16 | 17 | export function spawn( 18 | command: string, 19 | options: cp.SpawnOptions 20 | ): SpawnedProc; 21 | 22 | export function spawn( 23 | command: string, 24 | args?: ReadonlyArray, 25 | options?: cp.SpawnOptionsWithoutStdio 26 | ): SpawnedProc; 27 | 28 | export function spawn( 29 | command: string, 30 | args: ReadonlyArray, 31 | options: cp.SpawnOptions 32 | ): SpawnedProc; 33 | 34 | export function spawn( 35 | command: string, 36 | arg1? :ReadonlyArray 37 | |cp.SpawnOptions 38 | |cp.SpawnOptionsWithoutStdio, 39 | arg2? :cp.SpawnOptions 40 | |cp.SpawnOptionsWithoutStdio, 41 | ) :SpawnedProc { 42 | let proc :cp.ChildProcess|cp.ChildProcessWithoutNullStreams 43 | 44 | if (arg2 && arg1) { 45 | proc = cp.spawn( 46 | command, 47 | arg1 as ReadonlyArray, 48 | arg2 as cp.SpawnOptions|cp.SpawnOptionsWithoutStdio 49 | ) 50 | } else if (arg2) { 51 | proc = cp.spawn(command, arg2 as cp.SpawnOptionsWithoutStdio) 52 | } else if (arg1) { 53 | proc = cp.spawn( 54 | command, 55 | arg1 as cp.SpawnOptionsWithoutStdio | cp.SpawnOptions 56 | ) 57 | } else { 58 | proc = cp.spawn(command) 59 | } 60 | 61 | let p = new Promise((resolve, reject) => { 62 | proc.on("close", code => resolve(code)) 63 | proc.on("error", err => reject(err)) 64 | }) as any as SpawnedProc 65 | 66 | p.proc = proc 67 | 68 | return p 69 | } 70 | -------------------------------------------------------------------------------- /src/strings.ts: -------------------------------------------------------------------------------- 1 | 2 | export type Subs = ArrayLike<[string|RegExp, string|((match:string)=>string)]> 3 | 4 | // sub substitutes one or more strings in text. 5 | // 6 | // subs should be an array of lookups (left) and replacements (right). 7 | // The lookup can be a string or a regular expression. 8 | // 9 | // If the lookup is a string, it will be matched when found in between word 10 | // boundaries (\b in RegExp.) 11 | // 12 | // If the lookup is a RegExp object, it will be matched as-is. flags are ignored. 13 | // 14 | // If the replacement is a function, it is called with the matching string and 15 | // its return value is used as the replacement. 16 | // 17 | // Substitutions are exclusive and does not affect each other. 18 | // I.e. replacing the follwing in the text "foo bar baz" ... 19 | // subs = [ 20 | // ["foo", "bar"], 21 | // ["bar", "lol"], 22 | // ] 23 | // Yields "bar lol baz" (rather than "lol lol baz"). 24 | // In other words, one replacement does not affect the match of another. 25 | // 26 | export function sub(text :string, subs :Subs) :string { 27 | // since we are performing multiple substitutions on the same text, we need to 28 | // avoid one substitution matching on a previous subs. result. 29 | // 30 | // For instance, let's say we have this input text: 31 | // foo bar baz 32 | // And we want to make the following substitutions: 33 | // foo => bar 34 | // bar => lol 35 | // The result we expect is this: 36 | // bar lol baz 37 | // However, if we simply apply each substitution in order, we get this: 38 | // The result we expect is this: 39 | // lol lol baz 40 | // Note the extra lol at the beginning. 41 | // 42 | // So, to work around this situation, we build one regexp with OR groups 43 | // and perform one replacement, making use of the efficient regular expressions 44 | // engines of modern JS runtimes. 45 | // 46 | let re = "" 47 | for (let i = 0; i < subs.length; i++) { 48 | let m = subs[i][0] 49 | if (i > 0) { 50 | re += "|" 51 | } 52 | re += m instanceof RegExp ? `(${m.source})` : "(\\b" + escapeRegExp(m) + "\\b)" 53 | } 54 | return text.replace(new RegExp(re, "gm"), (s, ...matches) => { 55 | // find sub based on group position in matches 56 | for (let i = 0; i < subs.length; i++) { 57 | let m = matches[i] 58 | if (m !== undefined) { 59 | let repl = subs[i][1] 60 | return repl instanceof Function ? repl(m) : repl 61 | } 62 | } 63 | return s 64 | }) 65 | } 66 | 67 | 68 | // escapeRegExp takes some string and returns a version that is suitable as 69 | // vanilla representation of `s` in a RegExp call. 70 | // 71 | function escapeRegExp(s :string) :string { 72 | return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") 73 | } 74 | -------------------------------------------------------------------------------- /src/termstyle.ts: -------------------------------------------------------------------------------- 1 | type StyleFun = (s: string) => string 2 | 3 | interface Style { 4 | readonly isAvailable :bool 5 | 6 | readonly clear :string 7 | 8 | readonly bold :StyleFun 9 | readonly italic :StyleFun 10 | readonly underline :StyleFun 11 | readonly inverse :StyleFun 12 | 13 | readonly white :StyleFun 14 | readonly grey :StyleFun 15 | readonly black :StyleFun 16 | readonly blue :StyleFun 17 | readonly cyan :StyleFun 18 | readonly green :StyleFun 19 | readonly magenta :StyleFun 20 | readonly purple :StyleFun 21 | readonly pink :StyleFun 22 | readonly red :StyleFun 23 | readonly yellow :StyleFun 24 | readonly lightyellow :StyleFun 25 | readonly orange :StyleFun 26 | } 27 | 28 | let _cacheKey = Symbol("termstyle") 29 | 30 | 31 | export function getTermStyle(ws :NodeJS.WriteStream) :Style { 32 | let cachedStyle = (ws as any)[_cacheKey] as Style|undefined 33 | if (cachedStyle) { 34 | return cachedStyle 35 | } 36 | 37 | const TERM = typeof process != 'undefined' && process.env.TERM || '' 38 | const termColorSupport :number = ( 39 | TERM && ['xterm','screen','vt100'].some(s => TERM.indexOf(s) != -1) ? ( 40 | TERM.indexOf('256color') != -1 ? 256 : 41 | 16 42 | ) : 0 43 | ) 44 | const sfn = ( 45 | !ws.isTTY ? 46 | (_open :string, _ :string, _close :string) :StyleFun => { 47 | return (s :string) => s 48 | } : 49 | 50 | termColorSupport < 256 ? 51 | (open :string, _ :string, close :string) :StyleFun => { 52 | open = '\x1b[' + open + 'm' 53 | close = '\x1b[' + close + 'm' 54 | return (s :string) => open + s + close 55 | } : 56 | 57 | (_ :string, open :string, close :string) :StyleFun => { 58 | open = '\x1b[' + open + 'm' 59 | close = '\x1b[' + close + 'm' 60 | return (s :string) => open + s + close 61 | } 62 | ) 63 | 64 | let style :Style = { 65 | isAvailable: termColorSupport > 0 && !!ws.isTTY, 66 | 67 | clear : "\e[0m", 68 | 69 | bold : sfn('1', '1', '22'), 70 | italic : sfn('3', '3', '23'), 71 | underline : sfn('4', '4', '24'), 72 | inverse : sfn('7', '7', '27'), 73 | 74 | white : sfn('37', '38;2;255;255;255', '39'), 75 | grey : sfn('90', '38;5;244', '39'), 76 | black : sfn('30', '38;5;16', '39'), 77 | blue : sfn('34', '38;5;75', '39'), 78 | cyan : sfn('36', '38;5;87', '39'), 79 | green : sfn('32', '38;5;84', '39'), 80 | magenta : sfn('35', '38;5;213', '39'), 81 | purple : sfn('35', '38;5;141', '39'), 82 | pink : sfn('35', '38;5;211', '39'), 83 | red : sfn('31', '38;2;255;110;80', '39'), 84 | yellow : sfn('33', '38;5;227', '39'), 85 | lightyellow : sfn('93', '38;5;229', '39'), 86 | orange : sfn('33', '38;5;215', '39'), 87 | } 88 | 89 | ;(ws as any)[_cacheKey] = style 90 | return style 91 | } 92 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import * as vm from "vm" 2 | import * as fs from "fs" 3 | import { relative as relpath } from "path" 4 | import * as globalDirs from "../node_modules/global-dirs" 5 | 6 | declare const btoa :undefined|((s:string)=>string) 7 | 8 | // base64enc returns the Base64 encoding of a JS string 9 | export const base64enc :(s :string)=>string = ( 10 | typeof btoa == 'function' ? btoa : s => { 11 | return Buffer.from(s, "utf8").toString("base64") 12 | } 13 | ) 14 | 15 | // jsonparse parses "relaxed" JSON which can be in JavaScript format 16 | export function jsonparse(jsonText :string, filename? :string) { 17 | return vm.runInNewContext( 18 | '(()=>(' + jsonText + '))()', 19 | { /* sandbox */ }, 20 | { filename, displayErrors: true } 21 | ) 22 | } 23 | 24 | // jsonfmt formats a value into JSON with compact pretty-printing 25 | export function jsonfmt(value :any) :string { 26 | if (value instanceof Map) { 27 | let json = "{ " 28 | const trailer = ",\n " 29 | for (let [k, v] of value as Map) { 30 | json += JSON.stringify(k) + ": " + jsonfmt(v) + trailer 31 | } 32 | return ( 33 | json == "{ " ? "{}" : 34 | json.substr(0, json.length-trailer.length) + "\n}" 35 | ) 36 | } 37 | return JSON.stringify(value, null, 2) 38 | } 39 | 40 | // mapToObject creates a JS key-value object from a Map object 41 | export function mapToObject(map :Map) :{[k:string]:V} { 42 | let o :{[k:string]:V} = {} 43 | for (let [k,v] of map) { o[String(k)] = v } 44 | return o 45 | } 46 | 47 | // export function sortedMap(obj :{[k:string]:T}) :Map 48 | export function sortedMap(obj :T) :Map { 49 | let keys = Object.keys(obj) 50 | keys.sort() 51 | return new Map(keys.map(k => [ k, (obj as any)[k] ] )) 52 | } 53 | 54 | 55 | // unique returns collection v without duplicate values 56 | // while maintaining order. 57 | // 58 | export function unique(v :Iterable) :T[] { 59 | return Array.from(new Set(v)) 60 | } 61 | 62 | // rpath returns path relative to the current working directory, 63 | // or "." if path==cwd. 64 | // 65 | export function rpath(path :string) :string { 66 | return relpath(".", path) || "." 67 | } 68 | 69 | // inlineSourceMap takes a json string of a source map and returns a 70 | // sourceMappingURL JS comment with the source map as a data url. 71 | // 72 | export function inlineSourceMap(json :string) :string { 73 | return '//#sourceMappingURL=data:application\/json;base64,' + 74 | base64enc(json) + "\n" 75 | } 76 | 77 | 78 | // parseQueryString parses a URL-style query string 79 | // 80 | export function parseQueryString(s :string) :Record { 81 | if (s[0] == "?") { 82 | s = s.substr(1) 83 | } 84 | let items :Record = {} 85 | for (let pair of s.split(/\&+/)) { 86 | let [k, v] = pair.split(/\=+/, 2) 87 | if (!v) { v = "" } 88 | let e = items[k] 89 | if (e) { 90 | e.push(v) 91 | } else { 92 | items[k] = [v] 93 | } 94 | } 95 | return items 96 | } 97 | 98 | 99 | // // utf8ByteSize returns the number of bytes needed to represent 100 | // // codepoint cp as UTF-8 101 | // // 102 | // export function utf8ByteSize(cp :int) :int { 103 | // return ( 104 | // (cp < 0x80) ? 1 : 105 | // (cp < 0x800) ? 2 : 106 | // (cp < 0x10000) ? 3 : 107 | // 4 108 | // ) 109 | // } 110 | 111 | 112 | // strUTF8Size returns the number of bytes required to store s as UTF-8 113 | // 114 | export function strUTF8Size(s :string) :int { 115 | return Buffer.from(s, 'utf8').length 116 | // let len = 0, i = 0 117 | // for (; i < s.length; i++) { 118 | // if (s.charCodeAt(i) > 0xff) { 119 | // len++ 120 | // } 121 | // } 122 | // return len + i 123 | } 124 | 125 | 126 | // fmtByteSize returns human-readable text of size, which is assumed to 127 | // be number of bytes. 128 | // 129 | export function fmtByteSize(size :number) :string { 130 | const round = (n :number) :number => Math.ceil(n*10)/10 131 | if (size <= 1000) { return size + " B" } 132 | if (size < 1000*1024) { return round(size/1024) + " kB" } 133 | if (size < 1000*1024*1024) { return round(size/(1024*1024)) + " MB" } 134 | return round(size/(1024*1024*1024)) + " GB" 135 | } 136 | 137 | // fmtDuration returns human-readable text of a duration of time. 138 | // 139 | export function fmtDuration(milliseconds :number) :string { 140 | const round = (n :number) :number => Math.ceil(n*10)/10 141 | if (milliseconds < 1000) { return milliseconds + "ms" } 142 | if (milliseconds < 1000*60) { return round(milliseconds/1000) + "s" } 143 | if (milliseconds < 1000*60*60) { return round(milliseconds/1000*60) + "min" } 144 | return round(milliseconds/1000*60*60) + "hr" 145 | } 146 | 147 | // parseVersion takes a dot-separated version string with 1-4 version 148 | // components and returns a 32-bit integer encoding the versions in a 149 | // comparable format. E.g. "2.8.10.20" corresponds to 0x02080a14 150 | // 151 | export function parseVersion(s :string) :int { 152 | let v = s.split(".").map(Number) 153 | if (v.length > 4) { 154 | throw new Error(`too many version numbers in "${s}" (expected <=4)`) 155 | } 156 | while (v.length < 4) { 157 | v.unshift(0) 158 | } 159 | return v[0] << 24 | v[1] << 16 | v[2] << 8 | v[3] // 8 bytes per component 160 | } 161 | 162 | 163 | // bufcopy creates a new buffer containing bytes with some additional space. 164 | // 165 | export function bufcopy(bytes :ArrayLike, addlSize :int) { 166 | const size = bytes.length + addlSize 167 | const b2 = new Uint8Array(size) 168 | b2.set(bytes, 0) 169 | return b2 170 | } 171 | 172 | 173 | // return true if the current program is globally installed 174 | export function isGloballyInstalled() :bool { 175 | return ( 176 | __dirname.startsWith(globalDirs.yarn.packages) || 177 | __dirname.startsWith(fs.realpathSync(globalDirs.npm.packages)) 178 | ) 179 | } 180 | 181 | 182 | export class AppendBuffer { 183 | buffer :Uint8Array 184 | length :int // current offset 185 | 186 | constructor(size :int) { 187 | this.length = 0 188 | this.buffer = new Uint8Array(size) 189 | } 190 | 191 | reset() { 192 | this.length = 0 193 | } 194 | 195 | // Make sure there's space for at least `size` additional bytes 196 | reserve(addlSize :int) { 197 | if (this.length + addlSize >= this.buffer.length) { 198 | this._grow(addlSize) 199 | } 200 | } 201 | 202 | // bytes returns a Uint8Array of the written bytes which references the underlying storage. 203 | // Further modifications are observable both by the receiver and the returned array. 204 | // Use 205 | // 206 | bytes() :Uint8Array { 207 | return this.buffer.subarray(0, this.length) 208 | } 209 | 210 | // bytesCopy returns a Uint8Array of the written bytes as a copy. 211 | // 212 | bytesCopy() :Uint8Array { 213 | return this.buffer.slice(0, this.length) 214 | } 215 | 216 | writeByte(b :int) :void { 217 | if (this.length >= this.buffer.length) { 218 | this._grow(8) 219 | } 220 | this.buffer[this.length++] = b 221 | } 222 | 223 | // write b n times 224 | writeNbytes(b :int, n :int) :void { 225 | if (this.length + n >= this.buffer.length) { 226 | this._grow(n) 227 | } 228 | let end = this.length + n 229 | this.buffer.fill(b, this.length, end) 230 | this.length = end 231 | } 232 | 233 | write(src :Uint8Array, srcStart? :int, srcEnd? :int) :int { 234 | if (srcStart === undefined) { 235 | srcStart = 0 236 | } 237 | const end = (srcEnd === undefined) ? src.length : srcEnd 238 | const size = end - srcStart 239 | if (this.length + size >= this.buffer.length) { 240 | this._grow(size) 241 | } 242 | this.buffer.set(src.subarray(srcStart, srcEnd), this.length) 243 | this.length += size 244 | return size 245 | } 246 | 247 | private _grow(minAddlSize :int) { 248 | this.buffer = bufcopy(this.buffer, Math.max(minAddlSize, this.buffer.length)) 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | cd "$(dirname "$0")" 3 | 4 | # test project definitions 5 | test_projects=( \ 6 | # dir : init args 7 | "simple : " \ 8 | "ui : -ui " \ 9 | "ui-html : -html " \ 10 | "ui-react : -react " \ 11 | ) 12 | 13 | # examples (in the "examples" directory) to build in addition to test_projects 14 | build_examples=( \ 15 | basic \ 16 | ui \ 17 | ui-html \ 18 | ) 19 | 20 | # ---------------------------------------------------------------------------- 21 | # parse CLI options 22 | 23 | KEEP_WORKING_DIRS=false 24 | if [[ "$1" == "-h"* ]] || [[ "$1" == "--h"* ]]; then 25 | echo "usage: $0 [-h | --keep-working-dirs] [bin/figplug.g | bin/figplug]" 26 | exit 1 27 | elif [[ "$1" == "--keep-working-dirs" ]]; then 28 | KEEP_WORKING_DIRS=true 29 | shift 30 | fi 31 | 32 | SRCDIR=$PWD 33 | 34 | PROG=bin/figplug 35 | if [ "$1" != "" ]; then 36 | PROG=$1 37 | fi 38 | if ! [ -f "$PROG" ]; then 39 | if [[ "$PROG" == "bin/figplug.g" ]]; then 40 | ./build.js 41 | elif [[ "$PROG" == "bin/figplug" ]]; then 42 | ./build.js -O 43 | else 44 | echo "unknown program $PROG" >&2 45 | exit 1 46 | fi 47 | fi 48 | 49 | 50 | # ---------------------------------------------------------------------------- 51 | # parse test_projects into two tandem arrays 52 | pdirs=() 53 | pargs=() 54 | for e in "${test_projects[@]}"; do 55 | pdirs+=( "$(echo "${e%%:*}" | xargs)" ) 56 | args=""; for arg in $(echo "${e#*:}" | xargs); do args+=" $arg"; done 57 | pargs+=( "$args" ) 58 | done 59 | # We can now iterate over the projects like this: 60 | # for (( i=0; i<=$(( ${#pdirs[*]} -1 )); i++ )); do 61 | # echo "${pdirs[$i]} :${pargs[$i]};" 62 | # done 63 | 64 | 65 | # ---------------------------------------------------------------------------- 66 | # we need two temporary directories: 67 | # 1. for the package installation 68 | # 2. for test projects 69 | tmpdir1=$(mktemp -d -t figplug-test-pkg) 70 | tmpdir2=$(mktemp -d -t figplug-test-proj) 71 | 72 | # remove temporary directories on exit 73 | function atexit { 74 | if $KEEP_WORKING_DIRS; then 75 | echo "working directories left intact:" 76 | echo "package: $tmpdir1/figplug" 77 | echo "projects: $tmpdir2" 78 | else 79 | # echo "cleaning up" 80 | rm -rf "$tmpdir1" "$tmpdir2" 81 | fi 82 | } 83 | trap atexit EXIT 84 | 85 | 86 | # install package in isolated temporary directory tmpdir1 87 | echo "using package working directory ${tmpdir1}" 88 | cd "$tmpdir1" 89 | if ! (npm pack "$SRCDIR" > /dev/null 2>&1); then # very noisy 90 | # repeat to print errors 91 | npm pack "$SRCDIR" 92 | exit 1 93 | fi 94 | TAR_FILE=$(echo figplug-*.tgz) 95 | tar xzf "$TAR_FILE" 96 | mv package figplug 97 | cd figplug 98 | npm install --only=prod --no-package-lock --no-optional --no-shrinkwrap --no-audit 99 | if [[ "$PROG" == "bin/figplug.g" ]]; then 100 | cp -a "$SRCDIR/bin/figplug.g" bin/figplug.g 101 | fi 102 | FIGPLUG_NAME=$(basename "$PROG") 103 | FIGPLUG=$PWD/bin/$FIGPLUG_NAME 104 | 105 | 106 | # create test projects in tmpdir2 107 | echo "using project working directory ${tmpdir2}" 108 | echo "using figplug installed at $FIGPLUG" 109 | cd "$tmpdir2" ; echo cd "$tmpdir2" 110 | for (( i=0; i<=$(( ${#pdirs[*]} -1 )); i++ )); do 111 | args="init -v ${pargs[$i]} ${pdirs[$i]}" 112 | echo ">> $FIGPLUG_NAME $args" 113 | "$FIGPLUG" $args 114 | done 115 | 116 | # build test projects 117 | for dir in ${pdirs[@]}; do 118 | args="build -v $dir" 119 | echo ">> $FIGPLUG_NAME $args" 120 | "$FIGPLUG" $args 121 | done 122 | 123 | # build examples 124 | cd "$tmpdir1/figplug" ; echo cd "$tmpdir1/figplug" 125 | for dir in ${build_examples[@]}; do 126 | args="build -v examples/$dir" 127 | echo ">> $FIGPLUG_NAME $args" 128 | "$FIGPLUG" $args 129 | done 130 | 131 | 132 | echo "————————————————————————————————————————" 133 | echo "tests OK" 134 | echo "————————————————————————————————————————" 135 | 136 | #popd >/dev/null # back to ./build 137 | #popd >/dev/null # back to . 138 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // strict: 4 | // "noUnusedLocals": true, 5 | // "noUnusedParameters": true, 6 | 7 | "allowUnreachableCode": false, 8 | // "isolatedModules": true, 9 | "noFallthroughCasesInSwitch": true, 10 | "noImplicitAny": true, 11 | "noImplicitReturns": true, 12 | "noImplicitThis": true, 13 | "preserveConstEnums": true, 14 | // "removeComments": true, 15 | "strictNullChecks": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "alwaysStrict": true, 18 | "allowSyntheticDefaultImports": true, 19 | "noEmitOnError": true, 20 | 21 | // "module": "commonjs", 22 | "module": "esnext", 23 | "moduleResolution": "node", 24 | "resolveJsonModule": true, 25 | "newLine": "LF", 26 | "target": "es2017", 27 | "sourceMap": true, 28 | "lib": [ 29 | // "dom", 30 | "es2017" 31 | ], 32 | "baseUrl": "src", 33 | "outDir": "out", 34 | "pretty": true 35 | }, 36 | "include": [ 37 | "build/figma-plugin-ns.d.ts", 38 | "src/global.d.ts", 39 | "src/main.ts", 40 | ] 41 | } 42 | --------------------------------------------------------------------------------