figma-plugin-utilities 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -1
- package/README.md +8 -6
- package/package.json +20 -2
- package/src/components/EmptyState.svelte +29 -5
- package/src/components/FieldGroup.svelte +37 -2
- package/src/lib/errorHandling.js +4 -3
- package/src/lib/figma-helpers.ts +1 -1
- package/src/lib/messages.js +1 -1
- package/src/lib/resize.js +13 -6
- package/src/vite.d.ts +13 -0
- package/src/vite.js +165 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.6.0] - 2026-10-10
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- `figma-plugin-utilities/vite` — `figmaPluginConfig(import.meta.url)`, a plugin's whole Vite config: each thread built on its own (`vite build && vite build --mode code`), so both may import the same module, the UI's CSS and JS inlined into `dist/index.html`, `src/manifest.json` copied, at `es2017`. `moduleScript` inlines the UI as a module script. `ui3InlineSvg` and `inlineFigmaHtml` are exported too. `vite` and `@sveltejs/vite-plugin-svelte` are optional peer dependencies
|
|
9
|
+
- **EmptyState** — `iconSize`, the icon's size in px (24 by default), for an icon given as SVG markup
|
|
10
|
+
- **FieldGroup** — `hint`, a line of secondary text under the control: what to enter, or what the choice does. Empty shows none, so a conditional hint is a string; the `hint` slot takes markup and always shows. Like the label, it can't be selected. The hint and the control's error take the label's size: body-medium, or body-small in a small group. The Figma component has the hint too, which Code Connect and the mockup builder read
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- **figma-ui3-kit-svelte** peer is `^0.8.0`, whose Input, Textarea, Dropdown and Dropzone size their error by the `--field-error-*` properties FieldGroup sets
|
|
14
|
+
- `resizeToFit` — measures only the `container` you pass, and without one or a `height` warns and leaves the window as it is. It measured `document.body` by default, which fills the window, so the window could grow but never shrink
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- `sendToUI`, `sendToPlugin` — a `type` field in the data no longer replaces the message's type
|
|
18
|
+
- `formatErrorMessage` — the network, CORS and JSON messages follow the copy guidelines: no "Please" or "Network error:" prefix
|
|
19
|
+
- **FieldGroup** — `labelFor` ties the label to its control, so clicking the label focuses an `Input` or `Textarea`. It was passed to Label as `for`, which Label doesn't take, so no label was tied to anything
|
|
20
|
+
- **EmptyState** — `icon`, `action` and `actions` have types, so a plugin's `svelte-check` with `checkJs` passes
|
|
21
|
+
- `autoResize` — the options default to `{ container: null }`, which matches their type; without a container it still warns and does nothing
|
|
22
|
+
|
|
23
|
+
## [0.5.1] - 2026-10-07
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- **figma-ui3-kit-svelte** is a peer dependency, `^0.7.0`: install it beside the utilities. 0.7.0 is what `confirmDiscardChanges` needs for Modal's `beforeClose`, and **ConfirmModal** over another modal for Escape to close only the top one
|
|
27
|
+
|
|
5
28
|
## [0.5.0] - 2026-10-07
|
|
6
29
|
|
|
7
30
|
### Added
|
|
@@ -56,7 +79,7 @@
|
|
|
56
79
|
- The dev-mode `console.warn` **FieldGroup** logged when `label` was set without `labelFor` — a `Dropdown` is a button and cannot be a `<label for>` target, so it fired on correct code. Dropped in the a11y pass, recorded late
|
|
57
80
|
|
|
58
81
|
### Fixed
|
|
59
|
-
- **StatusBar** — the default `info` type sets `color: var(--figma-color-text)`. The `error`, `success` and `warning` types each set a foreground; the default one relied on inheritance, and nothing up the tree sets `color`, so the message rendered in the UA's black on the dark theme's
|
|
82
|
+
- **StatusBar** — the default `info` type sets `color: var(--figma-color-text)`. The `error`, `success` and `warning` types each set a foreground; the default one relied on inheritance, and nothing up the tree sets `color`, so the message rendered in the UA's black on the dark theme's gray bar
|
|
60
83
|
- **EmptyState** — the actions are a keyed `{#each}`, so swapping one action for another reuses the right button rather than repainting the row
|
|
61
84
|
- **docs** — `figma-frame-builders` is documented, `sanitizeInput` no longer claims to escape HTML (it stringifies, truncates, strips control characters and trims), and `formatErrorMessage`, `handleAsyncError`, `withErrorHandling` and `logError` are documented with their real signatures. `withErrorHandling(fn, operation)` calls `fn()` with no arguments and returns its result; it was documented as returning a wrapped function
|
|
62
85
|
|
package/README.md
CHANGED
|
@@ -5,9 +5,11 @@ Shared Svelte components and utilities for Figma plugins.
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npm install figma-plugin-utilities
|
|
8
|
+
npm install figma-plugin-utilities figma-ui3-kit-svelte
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
+
The components are built from `figma-ui3-kit-svelte`, a peer dependency: install it beside the utilities.
|
|
12
|
+
|
|
11
13
|
## Usage
|
|
12
14
|
|
|
13
15
|
### Import Everything
|
|
@@ -78,10 +80,10 @@ import { sendToPlugin, createMessageHandler } from "figma-plugin-utilities/lib";
|
|
|
78
80
|
| `Header` | Header bar with `left`, `center`, `right` slots and optional title |
|
|
79
81
|
| `Footer` | Footer with `right`, `split`, and `full` layout variants |
|
|
80
82
|
| `StatusBar` | Toast notifications with auto-dismiss (info/success/error/warning) |
|
|
81
|
-
| `EmptyState` | Empty/error states with optional icon and action buttons; `size`, `centered`, and `role="alert"` for failures |
|
|
83
|
+
| `EmptyState` | Empty/error states with optional icon and action buttons; `size`, `iconSize`, `centered`, and `role="alert"` for failures |
|
|
82
84
|
| `ListItem` | Selectable list items with metadata and `badge` slots, an action menu (`menuOpen`, `menuToggle`, `menuClose`) |
|
|
83
|
-
| `LoadingState` |
|
|
84
|
-
| `FieldGroup` | Label + input wrapper; `labelFor` binds the label to a text control |
|
|
85
|
+
| `LoadingState` | Centered message as `role="status"` (text only, no spinner) |
|
|
86
|
+
| `FieldGroup` | Label + input wrapper; `labelFor` binds the label to a text control, `hint` adds a line under it |
|
|
85
87
|
| `CheckboxCard` | Large checkbox with card styling and better touch targets; `change` event |
|
|
86
88
|
| `Section` | Titled group of fields as in Figma's panels: a `Header` with the title and an `actions` slot, content padded by the section itself |
|
|
87
89
|
| `DataTable` | Named rows with a cell per column (a set at each breakpoint, a style before and after): columns with their own width and alignment, a read-only mode with table roles, row selection with an `editor` slot, notes as badges, with tooltips, and a `+N` count past two, an `action` slot, values as badges or variable chips, removed rows and a marked column |
|
|
@@ -377,8 +379,8 @@ const parsed = parseJsonSafe(jsonString);
|
|
|
377
379
|
Utilities for dynamically resizing the plugin window to fit its content.
|
|
378
380
|
|
|
379
381
|
```javascript
|
|
380
|
-
// One-time resize to fit content
|
|
381
|
-
resizeToFit({ width: 300, minHeight: 100, maxHeight: 600 });
|
|
382
|
+
// One-time resize to fit content: measure a naturally-flowing wrapper, not document.body
|
|
383
|
+
resizeToFit({ container: myContainerEl, width: 300, minHeight: 100, maxHeight: 600 });
|
|
382
384
|
|
|
383
385
|
// Watch for content changes and auto-resize
|
|
384
386
|
const cleanup = autoResize({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "figma-plugin-utilities",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Shared Svelte components and utilities for Figma plugins",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"svelte": "./src/index.js",
|
|
@@ -58,6 +58,11 @@
|
|
|
58
58
|
"types": "./src/lib/confirm.ts",
|
|
59
59
|
"import": "./src/lib/confirm.ts",
|
|
60
60
|
"default": "./src/lib/confirm.ts"
|
|
61
|
+
},
|
|
62
|
+
"./vite": {
|
|
63
|
+
"types": "./src/vite.d.ts",
|
|
64
|
+
"import": "./src/vite.js",
|
|
65
|
+
"default": "./src/vite.js"
|
|
61
66
|
}
|
|
62
67
|
},
|
|
63
68
|
"files": [
|
|
@@ -84,6 +89,19 @@
|
|
|
84
89
|
],
|
|
85
90
|
"author": "Marius Roosendaal",
|
|
86
91
|
"license": "MIT",
|
|
92
|
+
"peerDependencies": {
|
|
93
|
+
"figma-ui3-kit-svelte": "^0.8.0",
|
|
94
|
+
"@sveltejs/vite-plugin-svelte": "^3.0.2",
|
|
95
|
+
"vite": "^5.2.0"
|
|
96
|
+
},
|
|
97
|
+
"peerDependenciesMeta": {
|
|
98
|
+
"@sveltejs/vite-plugin-svelte": {
|
|
99
|
+
"optional": true
|
|
100
|
+
},
|
|
101
|
+
"vite": {
|
|
102
|
+
"optional": true
|
|
103
|
+
}
|
|
104
|
+
},
|
|
87
105
|
"devDependencies": {
|
|
88
106
|
"@eslint/js": "^9.39.2",
|
|
89
107
|
"@figma/plugin-typings": "^1.138.0",
|
|
@@ -91,7 +109,7 @@
|
|
|
91
109
|
"@types/node": "^22.13.4",
|
|
92
110
|
"eslint": "^9.39.2",
|
|
93
111
|
"eslint-plugin-svelte": "^3.23.0",
|
|
94
|
-
"figma-ui3-kit-svelte": "^0.
|
|
112
|
+
"figma-ui3-kit-svelte": "^0.8.0",
|
|
95
113
|
"globals": "^17.12.0",
|
|
96
114
|
"prettier": "^3.9.8",
|
|
97
115
|
"prettier-plugin-svelte": "^3.4.0",
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* @example
|
|
8
8
|
* <EmptyState
|
|
9
9
|
* message="No items found"
|
|
10
|
-
* icon=
|
|
10
|
+
* icon={IconSearch}
|
|
11
11
|
* actions={[{ label: "Add Item", handler: handleAdd }]}
|
|
12
12
|
* />
|
|
13
13
|
*/
|
|
@@ -15,13 +15,31 @@
|
|
|
15
15
|
/** Message to display */
|
|
16
16
|
export let message = "";
|
|
17
17
|
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Optional icon: SVG markup, such as an icon from figma-ui3-kit-svelte/icons, or a component
|
|
20
|
+
* @type {string | import("svelte").ComponentType | null}
|
|
21
|
+
*/
|
|
19
22
|
export let icon = null;
|
|
20
23
|
|
|
21
|
-
/**
|
|
24
|
+
/** Icon size in px, for an icon given as SVG markup; the svg is scaled to it */
|
|
25
|
+
export let iconSize = 24;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @typedef {object} EmptyStateAction
|
|
29
|
+
* @property {string} label
|
|
30
|
+
* @property {() => void} handler
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Single action for backward compatibility { label, handler }
|
|
35
|
+
* @type {EmptyStateAction | null}
|
|
36
|
+
*/
|
|
22
37
|
export let action = null;
|
|
23
38
|
|
|
24
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Multiple actions [{ label, handler }]
|
|
41
|
+
* @type {EmptyStateAction[] | null}
|
|
42
|
+
*/
|
|
25
43
|
export let actions = null;
|
|
26
44
|
|
|
27
45
|
/** Size variant: 'small', 'medium', 'large' */
|
|
@@ -49,7 +67,7 @@
|
|
|
49
67
|
{#if icon}
|
|
50
68
|
<div class="empty-state__icon" aria-hidden="true">
|
|
51
69
|
{#if typeof icon === "string"}
|
|
52
|
-
<Icon iconName={icon} />
|
|
70
|
+
<Icon iconName={icon} size={iconSize} />
|
|
53
71
|
{:else}
|
|
54
72
|
<svelte:component this={icon} />
|
|
55
73
|
{/if}
|
|
@@ -111,6 +129,12 @@
|
|
|
111
129
|
line-height: var(--body-large-line-height);
|
|
112
130
|
}
|
|
113
131
|
|
|
132
|
+
/* Icon sizes its box, but the svg keeps the 24px it's drawn at. */
|
|
133
|
+
.empty-state__icon :global(.icon-component svg) {
|
|
134
|
+
width: 100%;
|
|
135
|
+
height: 100%;
|
|
136
|
+
}
|
|
137
|
+
|
|
114
138
|
.empty-state__actions {
|
|
115
139
|
display: flex;
|
|
116
140
|
flex-direction: row;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script>
|
|
2
|
-
import { Label } from "figma-ui3-kit-svelte";
|
|
2
|
+
import { Label, Text } from "figma-ui3-kit-svelte";
|
|
3
3
|
|
|
4
4
|
/** Label text (optional) */
|
|
5
5
|
export let label = "";
|
|
@@ -9,23 +9,58 @@
|
|
|
9
9
|
|
|
10
10
|
/** Size of the label (optional) */
|
|
11
11
|
export let size = undefined;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A line under the control: what to enter, or what the choice does
|
|
15
|
+
* (optional). Empty shows none, so a conditional hint is a string; the
|
|
16
|
+
* `hint` slot takes markup and always shows. It takes the label's size, as
|
|
17
|
+
* the control's error does: body-medium, or body-small in a small group.
|
|
18
|
+
*/
|
|
19
|
+
export let hint = "";
|
|
12
20
|
</script>
|
|
13
21
|
|
|
14
22
|
<div class="field-group" class:small={size === "small"}>
|
|
15
23
|
{#if label}
|
|
16
|
-
<Label
|
|
24
|
+
<Label htmlFor={labelFor} {size}>{label}</Label>
|
|
17
25
|
{/if}
|
|
18
26
|
<slot />
|
|
27
|
+
{#if $$slots.hint || hint}
|
|
28
|
+
<Text
|
|
29
|
+
class="field-group__hint"
|
|
30
|
+
variant={size === "small" ? "body-small" : "body-medium"}
|
|
31
|
+
color="--figma-color-text-secondary"
|
|
32
|
+
block
|
|
33
|
+
>
|
|
34
|
+
<slot name="hint">{hint}</slot>
|
|
35
|
+
</Text>
|
|
36
|
+
{/if}
|
|
19
37
|
</div>
|
|
20
38
|
|
|
21
39
|
<style>
|
|
40
|
+
/* The control's error takes the label's size, as the hint does. Read by
|
|
41
|
+
the kit's Input, Textarea, Dropdown and Dropzone errors, and set on every
|
|
42
|
+
group so a default group inside a small one is medium again. */
|
|
22
43
|
.field-group {
|
|
23
44
|
display: flex;
|
|
24
45
|
flex-direction: column;
|
|
25
46
|
gap: var(--size-xxsmall);
|
|
47
|
+
--field-error-font-size: var(--body-medium-font-size);
|
|
48
|
+
--field-error-font-weight: var(--body-medium-font-weight);
|
|
49
|
+
--field-error-letter-spacing: var(--body-medium-letter-spacing);
|
|
50
|
+
--field-error-line-height: var(--body-medium-line-height);
|
|
26
51
|
}
|
|
27
52
|
|
|
28
53
|
.field-group.small {
|
|
29
54
|
gap: var(--size-xxxsmall);
|
|
55
|
+
--field-error-font-size: var(--body-small-font-size);
|
|
56
|
+
--field-error-font-weight: var(--body-small-font-weight);
|
|
57
|
+
--field-error-letter-spacing: var(--body-small-letter-spacing);
|
|
58
|
+
--field-error-line-height: var(--body-small-line-height);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* Like the label, the hint is interface text, not content to copy */
|
|
62
|
+
.field-group :global(.field-group__hint) {
|
|
63
|
+
cursor: default;
|
|
64
|
+
user-select: none;
|
|
30
65
|
}
|
|
31
66
|
</style>
|
package/src/lib/errorHandling.js
CHANGED
|
@@ -44,12 +44,13 @@ export function formatErrorMessage(error, context) {
|
|
|
44
44
|
// Common error patterns to make more user-friendly
|
|
45
45
|
if (message.includes("Failed to fetch") || message.includes("NetworkError")) {
|
|
46
46
|
userMessage =
|
|
47
|
-
"
|
|
47
|
+
"Couldn't connect to the server. Check your internet connection and try again.";
|
|
48
48
|
} else if (message.includes("CORS")) {
|
|
49
49
|
userMessage =
|
|
50
|
-
"
|
|
50
|
+
"The resource host doesn't allow plugin access. Try resources from allowed domains.";
|
|
51
51
|
} else if (message.includes("JSON")) {
|
|
52
|
-
userMessage =
|
|
52
|
+
userMessage =
|
|
53
|
+
"Couldn't read the data as JSON. Check its format and try again.";
|
|
53
54
|
} else if (message.includes("not found")) {
|
|
54
55
|
userMessage = message
|
|
55
56
|
.replace(/not found/gi, "not found")
|
package/src/lib/figma-helpers.ts
CHANGED
package/src/lib/messages.js
CHANGED
package/src/lib/resize.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Usage in UI:
|
|
5
5
|
* import { resizeToFit, autoResize } from "figma-plugin-utilities";
|
|
6
|
-
* resizeToFit(); // One-time resize
|
|
7
|
-
* autoResize(); // Watch for changes and auto-resize
|
|
6
|
+
* resizeToFit({ container }); // One-time resize to a content wrapper
|
|
7
|
+
* autoResize({ container }); // Watch for changes and auto-resize
|
|
8
8
|
*
|
|
9
9
|
* Usage in code.ts:
|
|
10
10
|
* import { handleResize } from "figma-plugin-utilities/lib/figma-helpers";
|
|
@@ -46,7 +46,10 @@ export function getContentHeight(container) {
|
|
|
46
46
|
* @param {number} [options.minHeight=100] - Minimum height in pixels
|
|
47
47
|
* @param {number} [options.maxHeight=800] - Maximum height in pixels
|
|
48
48
|
* @param {number} [options.padding=0] - Extra padding to add to calculated height
|
|
49
|
-
* @param {HTMLElement} [options.container] - Container element to measure
|
|
49
|
+
* @param {HTMLElement} [options.container] - Container element to measure,
|
|
50
|
+
* required without `height`. Not `document.body`: the kit sets
|
|
51
|
+
* `html, body, #app { height: 100% }`, so the body measures the window,
|
|
52
|
+
* which then can grow but never shrink.
|
|
50
53
|
*/
|
|
51
54
|
export function resizeToFit(options = {}) {
|
|
52
55
|
const {
|
|
@@ -55,12 +58,16 @@ export function resizeToFit(options = {}) {
|
|
|
55
58
|
minHeight = 100,
|
|
56
59
|
maxHeight = 800,
|
|
57
60
|
padding = 0,
|
|
58
|
-
container
|
|
61
|
+
container,
|
|
59
62
|
} = options;
|
|
60
63
|
|
|
61
64
|
let finalHeight = height;
|
|
62
65
|
|
|
63
66
|
if (finalHeight === undefined) {
|
|
67
|
+
if (!container) {
|
|
68
|
+
console.warn("resizeToFit: pass a height, or a container to measure");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
64
71
|
finalHeight = getContentHeight(container) + padding;
|
|
65
72
|
}
|
|
66
73
|
|
|
@@ -78,7 +85,7 @@ export function resizeToFit(options = {}) {
|
|
|
78
85
|
* Use bind:this on a wrapper element that flows naturally with content.
|
|
79
86
|
*
|
|
80
87
|
* @param {object} options - Auto-resize options
|
|
81
|
-
* @param {HTMLElement} options.container - Container element to observe (required, must not have fixed height)
|
|
88
|
+
* @param {HTMLElement | null} options.container - Container element to observe (required, must not have fixed height)
|
|
82
89
|
* @param {number} [options.width] - Width in pixels (uses default if not specified)
|
|
83
90
|
* @param {number} [options.minHeight=100] - Minimum height in pixels
|
|
84
91
|
* @param {number} [options.maxHeight=800] - Maximum height in pixels
|
|
@@ -87,7 +94,7 @@ export function resizeToFit(options = {}) {
|
|
|
87
94
|
* @param {number} [options.threshold=20] - Minimum height change to trigger resize (prevents position reset)
|
|
88
95
|
* @returns {function} Cleanup function to stop observing
|
|
89
96
|
*/
|
|
90
|
-
export function autoResize(options = {}) {
|
|
97
|
+
export function autoResize(options = { container: null }) {
|
|
91
98
|
const {
|
|
92
99
|
container,
|
|
93
100
|
width = defaultWidth,
|
package/src/vite.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Plugin, UserConfigFnObject } from "vite";
|
|
2
|
+
|
|
3
|
+
export function ui3InlineSvg(): Plugin;
|
|
4
|
+
|
|
5
|
+
export function inlineFigmaHtml(options: {
|
|
6
|
+
root: string;
|
|
7
|
+
moduleScript?: boolean;
|
|
8
|
+
}): Plugin;
|
|
9
|
+
|
|
10
|
+
export function figmaPluginConfig(
|
|
11
|
+
configUrl: string,
|
|
12
|
+
options?: { moduleScript?: boolean },
|
|
13
|
+
): UserConfigFnObject;
|
package/src/vite.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Vite config for a Figma plugin: `src/index.html` and `src/code.ts` in, and
|
|
2
|
+
// `dist/index.html`, `dist/code.js` and `dist/manifest.json` out.
|
|
3
|
+
//
|
|
4
|
+
// Two builds, one per thread: the UI (`vite build`) and the sandbox
|
|
5
|
+
// (`vite build --mode code`). Built together, a module both import becomes a
|
|
6
|
+
// shared chunk, and code.js starts with an `import` Figma's sandbox can't load
|
|
7
|
+
// ("expecting '('"). Apart, each is one self-contained file, so the threads can
|
|
8
|
+
// share code, such as lib/scale.
|
|
9
|
+
import { dirname, resolve } from "node:path";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
|
13
|
+
|
|
14
|
+
// Figma's JS sandbox doesn't support ES2020+ (no ?. or ??)
|
|
15
|
+
const TARGET = "es2017";
|
|
16
|
+
|
|
17
|
+
function escapeRegExp(value) {
|
|
18
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Bundles figma-ui3-kit-svelte's SVG imports as strings, for its icons.
|
|
23
|
+
* @returns {import("vite").Plugin}
|
|
24
|
+
*/
|
|
25
|
+
export function ui3InlineSvg() {
|
|
26
|
+
return {
|
|
27
|
+
name: "ui3-inline-svg",
|
|
28
|
+
enforce: "pre",
|
|
29
|
+
load(id) {
|
|
30
|
+
if (!id.includes("figma-ui3-kit-svelte")) return;
|
|
31
|
+
const filePath = id.split("?")[0];
|
|
32
|
+
if (!filePath.endsWith(".svg")) return;
|
|
33
|
+
const svg = readFileSync(filePath, "utf-8");
|
|
34
|
+
return `export default ${JSON.stringify(svg)};`;
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Inlines the UI build's CSS and JS into one `index.html`, as Figma needs, and
|
|
41
|
+
* copies `src/manifest.json` to the output.
|
|
42
|
+
*
|
|
43
|
+
* Replacements are functions, so a `$&` in minified code isn't read as a
|
|
44
|
+
* pattern.
|
|
45
|
+
* @param {{ root: string, moduleScript?: boolean }} options
|
|
46
|
+
* @returns {import("vite").Plugin}
|
|
47
|
+
*/
|
|
48
|
+
export function inlineFigmaHtml({ root, moduleScript = false }) {
|
|
49
|
+
const scriptTag = moduleScript ? '<script type="module">' : "<script>";
|
|
50
|
+
return {
|
|
51
|
+
name: "inline-figma-html",
|
|
52
|
+
apply: "build",
|
|
53
|
+
enforce: "post",
|
|
54
|
+
generateBundle(_options, bundle) {
|
|
55
|
+
const htmlKey =
|
|
56
|
+
Object.keys(bundle).find((key) => key.endsWith("index.html")) ?? "";
|
|
57
|
+
const htmlAsset = htmlKey ? bundle[htmlKey] : undefined;
|
|
58
|
+
if (!htmlAsset || htmlAsset.type !== "asset") return;
|
|
59
|
+
|
|
60
|
+
let html = String(htmlAsset.source);
|
|
61
|
+
html = html.replace(/<link\s+[^>]*rel=["']modulepreload["'][^>]*>/gi, "");
|
|
62
|
+
|
|
63
|
+
for (const [fileName, asset] of Object.entries(bundle)) {
|
|
64
|
+
if (asset.type !== "asset" || !fileName.endsWith(".css")) continue;
|
|
65
|
+
const css = String(asset.source ?? "");
|
|
66
|
+
const hrefPattern = new RegExp(
|
|
67
|
+
`<link[^>]+rel=["']stylesheet["'][^>]+href=["'](?:\\./|/|\\.\\./)?${escapeRegExp(
|
|
68
|
+
fileName,
|
|
69
|
+
)}["'][^>]*>`,
|
|
70
|
+
"i",
|
|
71
|
+
);
|
|
72
|
+
if (hrefPattern.test(html)) {
|
|
73
|
+
html = html.replace(hrefPattern, () => `<style>${css}</style>`);
|
|
74
|
+
delete bundle[fileName];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const [fileName, chunk] of Object.entries(bundle)) {
|
|
79
|
+
if (chunk.type !== "chunk") continue;
|
|
80
|
+
const scriptPattern = new RegExp(
|
|
81
|
+
`<script[^>]+src=["'](?:\\./|/|\\.\\./)?${escapeRegExp(
|
|
82
|
+
fileName,
|
|
83
|
+
)}["'][^>]*></script>`,
|
|
84
|
+
"i",
|
|
85
|
+
);
|
|
86
|
+
if (scriptPattern.test(html)) {
|
|
87
|
+
html = html.replace(
|
|
88
|
+
scriptPattern,
|
|
89
|
+
() => `${scriptTag}${chunk.code}</script>`,
|
|
90
|
+
);
|
|
91
|
+
delete bundle[fileName];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
htmlAsset.source = html;
|
|
96
|
+
if (htmlKey !== "index.html") {
|
|
97
|
+
delete bundle[htmlKey];
|
|
98
|
+
htmlAsset.fileName = "index.html";
|
|
99
|
+
bundle["index.html"] = htmlAsset;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
this.emitFile({
|
|
103
|
+
type: "asset",
|
|
104
|
+
fileName: "manifest.json",
|
|
105
|
+
source: readFileSync(resolve(root, "src/manifest.json"), "utf-8"),
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The plugin's Vite config, for `vite.config.ts`:
|
|
113
|
+
*
|
|
114
|
+
* export default figmaPluginConfig(import.meta.url);
|
|
115
|
+
*
|
|
116
|
+
* Build with `vite build && vite build --mode code`.
|
|
117
|
+
*
|
|
118
|
+
* `moduleScript` inlines the UI as `<script type="module">`, which runs once
|
|
119
|
+
* the page is parsed. Without it the script is classic and runs in `<head>`,
|
|
120
|
+
* so `main.js` waits for DOMContentLoaded before it mounts.
|
|
121
|
+
* @param {string} configUrl `import.meta.url` of the plugin's vite.config.ts
|
|
122
|
+
* @param {{ moduleScript?: boolean }} [options]
|
|
123
|
+
* @returns {import("vite").UserConfigFnObject}
|
|
124
|
+
*/
|
|
125
|
+
export function figmaPluginConfig(configUrl, { moduleScript = false } = {}) {
|
|
126
|
+
const root = dirname(fileURLToPath(configUrl));
|
|
127
|
+
return ({ mode }) =>
|
|
128
|
+
mode === "code"
|
|
129
|
+
? {
|
|
130
|
+
base: "./",
|
|
131
|
+
build: {
|
|
132
|
+
outDir: "dist",
|
|
133
|
+
emptyOutDir: false, // The UI build's index.html and manifest.json stay
|
|
134
|
+
target: TARGET,
|
|
135
|
+
rollupOptions: {
|
|
136
|
+
input: resolve(root, "src/code.ts"),
|
|
137
|
+
output: {
|
|
138
|
+
entryFileNames: "code.js",
|
|
139
|
+
inlineDynamicImports: true,
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
: {
|
|
145
|
+
base: "./",
|
|
146
|
+
plugins: [
|
|
147
|
+
ui3InlineSvg(),
|
|
148
|
+
svelte(),
|
|
149
|
+
inlineFigmaHtml({ root, moduleScript }),
|
|
150
|
+
],
|
|
151
|
+
build: {
|
|
152
|
+
outDir: "dist",
|
|
153
|
+
emptyOutDir: false, // The sandbox build's code.js stays
|
|
154
|
+
target: TARGET,
|
|
155
|
+
rollupOptions: {
|
|
156
|
+
input: resolve(root, "src/index.html"),
|
|
157
|
+
output: {
|
|
158
|
+
entryFileNames: "[name].js",
|
|
159
|
+
chunkFileNames: "[name]-[hash].js",
|
|
160
|
+
assetFileNames: "[name]-[hash][extname]",
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|