├── .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 |
5 |
6 |
10 |
11 | Count:
12 | Create
13 | Cancel
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 |
Create
46 |
Cancel
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 | Create
4 | Cancel
--------------------------------------------------------------------------------
/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 |
Create
28 |
Cancel
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 | Create
9 | Cancel
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 | Create
8 | Cancel
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 |
--------------------------------------------------------------------------------