wings-plugin-sdk 0.1.0 → 0.2.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/README.md +174 -22
- package/bin.mjs +10 -4
- package/build.d.ts +12 -3
- package/build.mjs +136 -9
- package/package.json +27 -5
- package/shell.d.ts +14 -0
- package/shell.js +38 -0
- package/ui-css.d.ts +4 -0
- package/ui-css.js +547 -0
- package/ui.d.ts +144 -0
- package/ui.js +290 -0
- package/wings.d.ts +9 -3
package/README.md
CHANGED
|
@@ -32,39 +32,191 @@ npm install --save-dev react react-dom @types/react @types/react-dom # only fo
|
|
|
32
32
|
## Build
|
|
33
33
|
|
|
34
34
|
Put the code in `src/`, next to `wings-plugin.json`. Every file at the top of `src/` builds to the file of the
|
|
35
|
-
same name
|
|
36
|
-
Folders inside `src/` hold
|
|
35
|
+
same name, so `src/main.ts` becomes `main.js` and `src/sidebar.tsx` becomes `sidebar.js`. A page that uses
|
|
36
|
+
`wings-plugin-sdk/ui` also gets its `.html` written for it, unless you wrote your own. Folders inside `src/` hold
|
|
37
|
+
the modules they import.
|
|
37
38
|
|
|
38
39
|
```sh
|
|
39
|
-
npx wings-plugin
|
|
40
|
-
npx wings-plugin build
|
|
40
|
+
npx wings-plugin pack # builds and packs the plugin as <id>.wings-plugin, see Pack
|
|
41
|
+
npx wings-plugin build # builds in place, for a repo that commits its built files
|
|
42
|
+
npx wings-plugin build --check # fails if a committed built file is older than its source
|
|
41
43
|
```
|
|
42
44
|
|
|
43
|
-
|
|
45
|
+
A plugin in its own repo uses `pack`, and its repo holds only source. `build` is for a folder that Wings loads
|
|
46
|
+
as it is, like the plugins in the Wings repo.
|
|
44
47
|
|
|
45
|
-
##
|
|
48
|
+
## Pages
|
|
46
49
|
|
|
47
|
-
A sidebar, popover or
|
|
50
|
+
A sidebar, popover, view, tab or settings page is a file in `src/` that calls one of the `show*` functions in
|
|
51
|
+
[UI](#ui). The build writes its HTML. Every `wings.on*` call returns a function that removes the listener, so
|
|
52
|
+
`useBroadcast` and `useMainState`, or your own `useEffect`, clean up when a component goes. The API reference is
|
|
53
|
+
[plugins/README.md](https://github.com/josippapez/Wings/blob/main/plugins/README.md) in the Wings repo.
|
|
54
|
+
|
|
55
|
+
## Pack
|
|
48
56
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
57
|
+
A plugin in its own repo doesn't need to commit what it builds. `wings-plugin pack` builds it into a temporary
|
|
58
|
+
folder and zips that as `<id>.wings-plugin`, the file you install in Wings' Plugins manager or attach to a GitHub
|
|
59
|
+
release:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
npx wings-plugin pack # writes hello-wings.wings-plugin and prints its path
|
|
52
63
|
```
|
|
53
64
|
|
|
65
|
+
The package holds `wings-plugin.json` at its top, every built entry, the generated pages and CSS, and the
|
|
66
|
+
files your plugin ships, like its own `.html`, `.css` and images. It leaves out `src/`, `node_modules`,
|
|
67
|
+
`package.json` and lock files, `tsconfig*.json`, `README*`, `HANDOVER*`, dotfiles and other `.wings-plugin` files.
|
|
68
|
+
Your folder gets no built files. From a script, `pack(dir, { out })` in `wings-plugin-sdk/build` does the same and
|
|
69
|
+
resolves the package's path.
|
|
70
|
+
|
|
71
|
+
## UI
|
|
72
|
+
|
|
73
|
+
`wings-plugin-sdk/ui` lets a page say what it shows and where, and Wings decides how it looks. A page built from
|
|
74
|
+
it never touches the DOM, writes HTML or lays itself out with CSS, so when Wings moves or redesigns a place,
|
|
75
|
+
your page follows without a release.
|
|
76
|
+
|
|
54
77
|
```tsx
|
|
55
|
-
import {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
function
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
78
|
+
import { Card, Gauge, Heading, showSidebar, useMainState } from "wings-plugin-sdk/ui";
|
|
79
|
+
|
|
80
|
+
type Usage = { type: "usage"; percent: number };
|
|
81
|
+
|
|
82
|
+
function Usage() {
|
|
83
|
+
// Says hello to your main script once, then shows the newest `usage` message it broadcasts.
|
|
84
|
+
const usage = useMainState<Usage>("usage");
|
|
85
|
+
if (!usage) return null;
|
|
86
|
+
return (
|
|
87
|
+
<>
|
|
88
|
+
<Heading>Usage</Heading>
|
|
89
|
+
<Card>
|
|
90
|
+
<Gauge label="Today" percent={usage.percent} levels={[75, 90]} />
|
|
91
|
+
</Card>
|
|
92
|
+
</>
|
|
93
|
+
);
|
|
63
94
|
}
|
|
64
95
|
|
|
65
|
-
|
|
96
|
+
showSidebar(<Usage />);
|
|
66
97
|
```
|
|
67
98
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
99
|
+
### Places
|
|
100
|
+
|
|
101
|
+
`showSidebar`, `showPopover`, `showView`, `showTab` and `showSettings` take any React element, your own
|
|
102
|
+
components too, and mount it in that place's shell: Wings' padding, spacing and scrolling for a sidebar, a
|
|
103
|
+
popover, a view (in a modal or a window), a tab or the settings page. Call one per page. `{ fill: true }` lays
|
|
104
|
+
the page out edge to edge with no padding, for a page that scrolls on its own, like a canvas.
|
|
105
|
+
|
|
106
|
+
You don't write the page's HTML. For `src/sidebar.tsx` that calls one of these, `wings-plugin build` writes
|
|
107
|
+
`sidebar.html` with a mount point, its CSS and its script, titled like the manifest's page. A `sidebar.html` you
|
|
108
|
+
write yourself wins: the build only writes a page that's missing or that it wrote before (it carries a
|
|
109
|
+
`Generated by wings-plugin build` line). A page that builds its DOM itself, without React, gets the same shell
|
|
110
|
+
from `mountShell(place, options)` in `wings-plugin-sdk/shell`, which returns the element to fill.
|
|
111
|
+
|
|
112
|
+
### Talking to main
|
|
113
|
+
|
|
114
|
+
`useMainState<T>(type, hello?)` returns the newest message of `type` your plugin's other pages broadcast, or
|
|
115
|
+
`null` until one comes. It sends `hello` (`{ type: "hello" }` by default, `null` for none) once when the
|
|
116
|
+
component mounts, so main answers with what it has. `useBroadcast(listener)` gets every message.
|
|
117
|
+
|
|
118
|
+
### Components
|
|
119
|
+
|
|
120
|
+
| Component | Props | Renders |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| `Row`, `Stack` | `gap` (`none`, `xs`, `s`, `m`, `l`), `grow`; `Row` also `align`, `justify`, `wrap`; `as` | Children side by side or one under the other |
|
|
123
|
+
| `Text` | `tone` (`muted`, `info`, `success`, `warn`, `danger`), `size` (`small`, `tiny`), `weight` (`medium`, `strong`), `mono`, `numeric`, `truncate`, `clamp`, `grow`, `as` | A span, or the element in `as` |
|
|
124
|
+
| `Heading` | `actions` | A section heading, `h2`, with chips or a button on its right |
|
|
125
|
+
| `Card` | `compact` | A card |
|
|
126
|
+
| `Gauge`, `Meter` | `label`, `percent`, `note`, `tone` or `levels` | A labelled bar with its percentage, or the bar alone, with the meter role |
|
|
127
|
+
| `Stats` | `items: { label, value, tone? }[]` | Small figures side by side, label over value |
|
|
128
|
+
| `Properties` | `items: { label, value }[]` | Label and value pairs |
|
|
129
|
+
| `List`, `ListItem`, `ListButton` | `ordered` and `compact` on `List`, `align` on the rows | A list of rows; a `ListButton` in a `ListItem` makes the row a button |
|
|
130
|
+
| `Dot`, `Spinner` | `tone`, `size`, `label` | A status dot or a spinner, announced only with a `label` |
|
|
131
|
+
| `Pill`, `Tag` | `tone` | A filled pill, or an outlined tag like a branch |
|
|
132
|
+
| `Loading`, `Empty` | `Empty`: `title`, `detail` | Before the first data, and nothing to show |
|
|
133
|
+
| `Button` | `variant` (`primary`, `ghost`, `danger`), `size` (`small`) | A button, `type="button"` |
|
|
134
|
+
| `Chip`, `Chips` | `pressed`; `Chips`: `label`, `options`, `value`, `onChange` | A small quiet button, and a group of them that picks one, with `aria-pressed` |
|
|
135
|
+
| `Tabs` | `label`, `items`, `value`, `onChange`, `children` | Tabs with arrow keys, Home and End, over their panel |
|
|
136
|
+
| `Field` | `label`, `hint`, `error` | A labelled field that names and describes the input in it |
|
|
137
|
+
| `TextInput`, `SearchField`, `Select` | `label`; `Select`: `options`, `value`, `onChange` | Inputs Wings styles; outside a `Field`, `label` names them |
|
|
138
|
+
| `Checkbox` | `checked`, `onChange`, `children` | A checkbox with its label |
|
|
139
|
+
| `Disclosure` | `summary` | More behind a line that opens it |
|
|
140
|
+
|
|
141
|
+
Every component passes other props, like `id`, `title`, `className`, `onClick` and `aria-*`, to its element.
|
|
142
|
+
`TextInput` and `SearchField` pass `ref` to the input on React 19.
|
|
143
|
+
|
|
144
|
+
### Ours or your own
|
|
145
|
+
|
|
146
|
+
The components are there for a page that looks like the rest of Wings without effort, not a rule. Write your
|
|
147
|
+
own components next to them, and your own CSS: import it from the page, `import "./sidebar.css"`, and the build
|
|
148
|
+
bundles it into `sidebar.css` and links it from the generated page. Match Wings with its variables, which every
|
|
149
|
+
page has:
|
|
150
|
+
|
|
151
|
+
| Variable | What |
|
|
152
|
+
|---|---|
|
|
153
|
+
| `--wings-fg`, `--wings-muted` | Text, and quieter text |
|
|
154
|
+
| `--wings-surface`, `--wings-hover`, `--wings-pressed`, `--wings-input` | Card, hovered, pressed and input backgrounds |
|
|
155
|
+
| `--wings-hairline`, `--wings-hairline-strong` | Borders |
|
|
156
|
+
| `--wings-ring` | The focus ring |
|
|
157
|
+
| `--wings-primary`, `--wings-primary-fg` | The primary button, and its text |
|
|
158
|
+
| `--wings-info`, `--wings-success`, `--wings-warning`, `--wings-danger` | Tones |
|
|
159
|
+
| `--wings-radius` | A card's corners |
|
|
160
|
+
| `--wings-font`, `--wings-mono` | Fonts |
|
|
161
|
+
|
|
162
|
+
```tsx
|
|
163
|
+
// src/sidebar.tsx
|
|
164
|
+
import "./sidebar.css";
|
|
165
|
+
|
|
166
|
+
function Banner({ children }: { children: string }) {
|
|
167
|
+
return <div className="banner">{children}</div>;
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
```css
|
|
172
|
+
/* src/sidebar.css */
|
|
173
|
+
.banner {
|
|
174
|
+
padding: 8px 10px;
|
|
175
|
+
border-radius: var(--wings-radius);
|
|
176
|
+
background: color-mix(in srgb, var(--wings-info) 14%, transparent);
|
|
177
|
+
color: var(--wings-info);
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Wings' base styles cover plain elements (buttons, inputs, selects and the page's font and colours), the `w-*`
|
|
182
|
+
classes and two older helpers, `.muted` and `.card`; nothing else, so your other classes are yours. Your CSS loads after Wings', so it wins where you
|
|
183
|
+
style a component, like `.my-list .w-list-button { height: 24px; }`.
|
|
184
|
+
|
|
185
|
+
### The class contract
|
|
186
|
+
|
|
187
|
+
The components render plain elements with `w-*` class names and data attributes, like
|
|
188
|
+
`<div class="w-card" data-size="compact">`, and no styles of their own. Wings styles them in its
|
|
189
|
+
`plugin-ui.css`, which ships with the app, so a Wings update restyles every plugin. The names are a versioned
|
|
190
|
+
public contract: `--wings-ui` on the page's root is its version, `1` now, and a version only adds classes.
|
|
191
|
+
`Meter` sets `--w-value`, how full it is, which is data rather than a style.
|
|
192
|
+
|
|
193
|
+
`w-shell` with `data-place` (`sidebar`, `popover`, `view`, `tab`, `settings`) and `data-fill`; `w-row`,
|
|
194
|
+
`w-stack` with `data-gap`, `data-align`, `data-justify`, `data-wrap`, `data-grow`; `w-text` with `data-tone`,
|
|
195
|
+
`data-size`, `data-weight`, `data-mono`, `data-numeric`, `data-truncate`, `data-clamp`, `data-grow`;
|
|
196
|
+
`w-heading`, `w-heading-row`; `w-card` with `data-size`; `w-gauge`, `w-gauge-label`, `w-gauge-value`,
|
|
197
|
+
`w-gauge-note`, `w-meter` with `data-tone`, `w-meter-fill`; `w-stats`, `w-stat-label`, `w-stat-value`;
|
|
198
|
+
`w-props`, `w-prop`, `w-prop-label`, `w-prop-value`; `w-list` with `data-density`, `w-list-item`,
|
|
199
|
+
`w-list-button` with `data-align`; `w-dot`, `w-pill`, `w-tag` with `data-tone`; `w-spinner` with `data-size`;
|
|
200
|
+
`w-loading`, `w-empty`, `w-empty-detail`; `w-button` with `data-variant`, `data-size`; `w-chips`, `w-chip`;
|
|
201
|
+
`w-tabs`, `w-tab`; `w-field`, `w-field-label`, `w-field-hint`, `w-input`, `w-search`, `w-select`, `w-check`;
|
|
202
|
+
`w-disclosure`.
|
|
203
|
+
|
|
204
|
+
### Older Wings
|
|
205
|
+
|
|
206
|
+
Wings before 0.1.15 has no `w-*` styles. A page built with `wings-plugin-sdk/ui` checks `--wings-ui` when it
|
|
207
|
+
mounts, and on a Wings without it, or with an older version, adds the same styles from the package, before
|
|
208
|
+
your CSS. So a plugin works on older Wings without a `minWingsVersion`, and on a current Wings it always gets
|
|
209
|
+
Wings' own styles.
|
|
210
|
+
|
|
211
|
+
### A linked SDK
|
|
212
|
+
|
|
213
|
+
With the SDK linked from a local folder (`npm link`, or `link:` in `package.json`) rather than installed,
|
|
214
|
+
TypeScript looks for React next to the SDK's folder. Point it at yours in `tsconfig.json`; the build already
|
|
215
|
+
does:
|
|
216
|
+
|
|
217
|
+
```json
|
|
218
|
+
"paths": {
|
|
219
|
+
"react": ["./node_modules/@types/react"],
|
|
220
|
+
"react/*": ["./node_modules/@types/react/*"]
|
|
221
|
+
}
|
|
222
|
+
```
|
package/bin.mjs
CHANGED
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// wings-plugin build builds the plugin in this folder
|
|
3
3
|
// wings-plugin build --check fails if a built file is older than its source
|
|
4
|
+
// wings-plugin pack builds the plugin into <id>.wings-plugin, the file Wings installs, without
|
|
5
|
+
// writing built files into the folder
|
|
4
6
|
import { relative } from "node:path";
|
|
5
|
-
import { buildPlugin, entries } from "./build.mjs";
|
|
7
|
+
import { buildPlugin, entries, pack } from "./build.mjs";
|
|
6
8
|
|
|
7
9
|
const [command, ...flags] = process.argv.slice(2);
|
|
8
|
-
if (command !== "build") {
|
|
9
|
-
console.error("Usage: wings-plugin build [--check]");
|
|
10
|
+
if (command !== "build" && command !== "pack") {
|
|
11
|
+
console.error("Usage: wings-plugin build [--check] | wings-plugin pack");
|
|
10
12
|
process.exit(2);
|
|
11
13
|
}
|
|
12
14
|
const dir = process.cwd();
|
|
13
15
|
if (!entries(dir).length) {
|
|
14
|
-
console.error(
|
|
16
|
+
console.error(`No plugin to ${command} here: wings-plugin ${command} runs next to wings-plugin.json, with the code in src/.`);
|
|
15
17
|
process.exit(1);
|
|
16
18
|
}
|
|
19
|
+
if (command === "pack") {
|
|
20
|
+
console.log(relative(dir, await pack(dir)));
|
|
21
|
+
process.exit(0);
|
|
22
|
+
}
|
|
17
23
|
const check = flags.includes("--check");
|
|
18
24
|
const { built, stale } = await buildPlugin(dir, { check });
|
|
19
25
|
for (const { file, bytes } of built) console.log(`${relative(dir, file)} ${(bytes / 1024).toFixed(1)} KB`);
|
package/build.d.ts
CHANGED
|
@@ -2,10 +2,19 @@
|
|
|
2
2
|
export function entries(dir: string): { src: string; out: string }[];
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Builds the plugin in `dir`: `src/main.ts` to `main.js`, `src/sidebar.tsx` to `sidebar.js`, and so on.
|
|
6
|
-
*
|
|
5
|
+
* Builds the plugin in `dir`: `src/main.ts` to `main.js`, `src/sidebar.tsx` to `sidebar.js`, and so on. CSS a page
|
|
6
|
+
* imports becomes `<name>.css`, and a page that mounts with `wings-plugin-sdk/ui` or `/shell` gets a generated
|
|
7
|
+
* `<name>.html` unless the plugin has its own. With `check`, writes nothing and lists in `stale` the outputs that
|
|
8
|
+
* differ from what's on disk. With `outDir`, writes there instead of `dir`.
|
|
7
9
|
*/
|
|
8
10
|
export function buildPlugin(
|
|
9
11
|
dir: string,
|
|
10
|
-
options?: { check?: boolean; banner?: string },
|
|
12
|
+
options?: { check?: boolean; banner?: string; outDir?: string },
|
|
11
13
|
): Promise<{ built: { file: string; bytes: number }[]; stale: string[] }>;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Builds the plugin in `dir` with the files it ships into `<id>.wings-plugin` in `out` (`dir` by default), the
|
|
17
|
+
* file Wings installs, leaving no built files in `dir`. Leaves out `src/`, `node_modules`, package and lock
|
|
18
|
+
* files, `tsconfig*.json`, `README*`, `HANDOVER*`, dotfiles and other `.wings-plugin` files. Resolves its path.
|
|
19
|
+
*/
|
|
20
|
+
export function pack(dir: string, options?: { out?: string }): Promise<string>;
|
package/build.mjs
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
// Builds a plugin's src/ into the files Wings loads. Every file at the top of src/ is one entry, so src/main.ts
|
|
2
2
|
// becomes main.js and src/sidebar.tsx becomes sidebar.js, next to wings-plugin.json. Folders inside src/ hold
|
|
3
3
|
// the modules they import. Wings runs plain scripts, so each file is one bundle with its imports, React too.
|
|
4
|
+
// The CSS a page imports, like `import "./sidebar.css"`, becomes <name>.css, and a page that mounts with
|
|
5
|
+
// wings-plugin-sdk/ui or /shell gets a <name>.html written for it unless the plugin has its own.
|
|
4
6
|
import * as esbuild from "esbuild";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
+
import { zipSync } from "fflate";
|
|
8
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join, parse, relative } from "node:path";
|
|
7
11
|
|
|
8
12
|
const BANNER = "// Built from src/ by wings-plugin build. Edit src/, not this file.";
|
|
13
|
+
/** Marks a page or a stylesheet this build wrote, so it may write it again. A file without it is the plugin's own. */
|
|
14
|
+
const GENERATED = "Generated by wings-plugin build";
|
|
9
15
|
|
|
10
16
|
/** The entries of the plugin in `dir`: `{ src, out }` for each file at the top of its src/. */
|
|
11
17
|
export function entries(dir) {
|
|
@@ -16,35 +22,156 @@ export function entries(dir) {
|
|
|
16
22
|
.map((f) => ({ src: join(src, f.name), out: join(dir, `${parse(f.name).name}.js`) }));
|
|
17
23
|
}
|
|
18
24
|
|
|
25
|
+
const escapeHtml = (s) => s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
26
|
+
|
|
27
|
+
/** The manifest's title for the page in `file`, if a sidebar, popover, view or settings page names it. */
|
|
28
|
+
function pageTitle(dir, file) {
|
|
29
|
+
const { contributes = {} } = JSON.parse(readFileSync(join(dir, "wings-plugin.json"), "utf8"));
|
|
30
|
+
const pages = [...(contributes.sidebars ?? []), ...(contributes.views ?? [])];
|
|
31
|
+
const page = pages.find((p) => p.page === file) ?? pages.find((p) => p.popover?.page === file);
|
|
32
|
+
if (page) return page.title;
|
|
33
|
+
if (contributes.settings?.page === file) return "Settings";
|
|
34
|
+
return parse(file).name;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The page Wings loads for an entry that mounts with the ui shell: a mount point, its CSS and its script. */
|
|
38
|
+
function pageHtml(dir, name, src, hasCss) {
|
|
39
|
+
return `<!doctype html>
|
|
40
|
+
<!-- ${GENERATED} from ${relative(dir, src)}. To write your own, replace this file with one without this line. -->
|
|
41
|
+
<html lang="en">
|
|
42
|
+
<head>
|
|
43
|
+
<meta name="generator" content="wings-plugin build" />
|
|
44
|
+
<title>${escapeHtml(pageTitle(dir, `${name}.html`))}</title>
|
|
45
|
+
${hasCss ? ` <link rel="stylesheet" href="${name}.css" />\n` : ""} </head>
|
|
46
|
+
<body>
|
|
47
|
+
<main id="app"></main>
|
|
48
|
+
<script src="${name}.js"></script>
|
|
49
|
+
</body>
|
|
50
|
+
</html>
|
|
51
|
+
`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Whether `file` is missing or was written by this build, so the build may write it. */
|
|
55
|
+
const ours = (file) => !existsSync(file) || readFileSync(file, "utf8").includes(GENERATED);
|
|
56
|
+
|
|
57
|
+
/** Notes whether the bundle imports wings-plugin-sdk's ui or shell, which mount the page themselves. */
|
|
58
|
+
function shellPlugin(seen) {
|
|
59
|
+
return {
|
|
60
|
+
name: "wings-shell",
|
|
61
|
+
setup(build) {
|
|
62
|
+
build.onResolve({ filter: /^wings-plugin-sdk\/(ui|shell)$/ }, () => {
|
|
63
|
+
seen.shell = true;
|
|
64
|
+
// Resolve as usual.
|
|
65
|
+
return undefined;
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolves React from the plugin's folder wherever it's imported, so a page and a linked wings-plugin-sdk (npm
|
|
73
|
+
* link, or link: in package.json) share one React instead of the SDK finding its own, or none.
|
|
74
|
+
*/
|
|
75
|
+
function oneReact(dir) {
|
|
76
|
+
return {
|
|
77
|
+
name: "one-react",
|
|
78
|
+
setup(build) {
|
|
79
|
+
build.onResolve({ filter: /^react(-dom)?(\/.*)?$/ }, (args) => {
|
|
80
|
+
if (args.pluginData?.fromPlugin) return undefined;
|
|
81
|
+
return build.resolve(args.path, { kind: args.kind, resolveDir: dir, pluginData: { fromPlugin: true } });
|
|
82
|
+
});
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
19
87
|
/**
|
|
20
88
|
* Builds the plugin in `dir`. With `check`, writes nothing and returns the outputs that differ from what's on
|
|
21
|
-
* disk instead, so a release can refuse built files older than their source.
|
|
89
|
+
* disk instead, so a release can refuse built files older than their source. With `outDir`, writes the outputs
|
|
90
|
+
* there instead of next to wings-plugin.json; a page's own HTML or CSS in `dir` still wins over a generated one.
|
|
22
91
|
*/
|
|
23
|
-
export async function buildPlugin(dir, { check = false, banner = BANNER } = {}) {
|
|
92
|
+
export async function buildPlugin(dir, { check = false, banner = BANNER, outDir = dir } = {}) {
|
|
24
93
|
const built = [];
|
|
25
94
|
const stale = [];
|
|
95
|
+
const files = [];
|
|
26
96
|
for (const { src, out } of entries(dir)) {
|
|
97
|
+
const { name } = parse(out);
|
|
98
|
+
const seen = { shell: false };
|
|
27
99
|
const result = await esbuild.build({
|
|
28
100
|
entryPoints: [src],
|
|
101
|
+
outfile: out,
|
|
29
102
|
bundle: true,
|
|
30
103
|
format: "iife",
|
|
31
104
|
// WKWebView on the oldest macOS Wings supports.
|
|
32
105
|
target: "safari16",
|
|
33
106
|
jsx: "automatic",
|
|
34
107
|
define: { "process.env.NODE_ENV": '"production"' },
|
|
35
|
-
banner: { js: banner },
|
|
108
|
+
banner: { js: banner, css: `/* ${GENERATED} from src/. Edit src/, not this file. */` },
|
|
109
|
+
plugins: [shellPlugin(seen), oneReact(dir)],
|
|
36
110
|
legalComments: "none",
|
|
37
111
|
minify: true,
|
|
38
112
|
write: false,
|
|
39
113
|
logLevel: "warning",
|
|
40
114
|
});
|
|
41
|
-
const
|
|
115
|
+
const js = result.outputFiles.find((f) => f.path.endsWith(".js"));
|
|
116
|
+
const css = result.outputFiles.find((f) => f.path.endsWith(".css"));
|
|
117
|
+
files.push({ name: `${name}.js`, text: js.text, owned: true });
|
|
118
|
+
if (css) files.push({ name: `${name}.css`, text: css.text, owned: ours(join(dir, `${name}.css`)) });
|
|
119
|
+
if (seen.shell && ours(join(dir, `${name}.html`))) files.push({ name: `${name}.html`, text: pageHtml(dir, name, src, Boolean(css)), owned: true });
|
|
120
|
+
}
|
|
121
|
+
const foreign = files.filter((f) => !f.owned).map((f) => f.name);
|
|
122
|
+
if (foreign.length) throw new Error(`${foreign.join(", ")} would be overwritten by the CSS built from src/. Move it into src/ or rename it.`);
|
|
123
|
+
for (const { name, text } of files) {
|
|
124
|
+
const file = join(outDir, name);
|
|
42
125
|
if (check) {
|
|
43
|
-
if (!existsSync(
|
|
126
|
+
if (!existsSync(file) || readFileSync(file, "utf8") !== text) stale.push(file);
|
|
44
127
|
} else {
|
|
45
|
-
writeFileSync(
|
|
46
|
-
built.push({ file
|
|
128
|
+
writeFileSync(file, text);
|
|
129
|
+
built.push({ file, bytes: text.length });
|
|
47
130
|
}
|
|
48
131
|
}
|
|
49
132
|
return { built, stale };
|
|
50
133
|
}
|
|
134
|
+
|
|
135
|
+
/** Files at the top of a plugin folder that are for building it, not for Wings. */
|
|
136
|
+
const SOURCE_ONLY = /^(src|node_modules|package(-lock)?\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?|tsconfig.*\.json|README.*|HANDOVER.*|.*\.wings-plugin)$/;
|
|
137
|
+
|
|
138
|
+
/** Copies what the plugin ships from `from` to `to`: everything but its source, dependencies and dotfiles. */
|
|
139
|
+
function copyShipped(from, to, top = true) {
|
|
140
|
+
for (const entry of readdirSync(from, { withFileTypes: true })) {
|
|
141
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules" || (top && SOURCE_ONLY.test(entry.name))) continue;
|
|
142
|
+
if (entry.isDirectory()) {
|
|
143
|
+
mkdirSync(join(to, entry.name));
|
|
144
|
+
copyShipped(join(from, entry.name), join(to, entry.name), false);
|
|
145
|
+
} else if (entry.isFile()) copyFileSync(join(from, entry.name), join(to, entry.name));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Every file under `dir`, by its path inside it with `/` between folders. */
|
|
150
|
+
function filesIn(dir, prefix = "") {
|
|
151
|
+
const out = {};
|
|
152
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
153
|
+
const path = prefix + entry.name;
|
|
154
|
+
if (entry.isDirectory()) Object.assign(out, filesIn(join(dir, entry.name), `${path}/`));
|
|
155
|
+
else out[path] = new Uint8Array(readFileSync(join(dir, entry.name)));
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Builds the plugin in `dir` into a temporary folder with the files it ships, and zips that as `<id>.wings-plugin`
|
|
162
|
+
* in `out` (the plugin folder by default), the file Wings installs. Resolves the package's path. The plugin's
|
|
163
|
+
* folder gets no built files, so a plugin's own repo doesn't commit them.
|
|
164
|
+
*/
|
|
165
|
+
export async function pack(dir, { out = dir } = {}) {
|
|
166
|
+
const { id } = JSON.parse(readFileSync(join(dir, "wings-plugin.json"), "utf8"));
|
|
167
|
+
const tmp = mkdtempSync(join(tmpdir(), "wings-plugin-"));
|
|
168
|
+
try {
|
|
169
|
+
copyShipped(dir, tmp);
|
|
170
|
+
await buildPlugin(dir, { outDir: tmp });
|
|
171
|
+
const file = join(out, `${id}.wings-plugin`);
|
|
172
|
+
writeFileSync(file, zipSync(filesIn(tmp), { level: 9 }));
|
|
173
|
+
return file;
|
|
174
|
+
} finally {
|
|
175
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
176
|
+
}
|
|
177
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wings-plugin-sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Types, React hooks and a build command for Wings plugins.",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Types, React hooks, UI components and a build command for Wings plugins.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"types": "./wings.d.ts",
|
|
@@ -13,18 +13,35 @@
|
|
|
13
13
|
"types": "./react.d.ts",
|
|
14
14
|
"default": "./react.js"
|
|
15
15
|
},
|
|
16
|
+
"./ui": {
|
|
17
|
+
"types": "./ui.d.ts",
|
|
18
|
+
"default": "./ui.js"
|
|
19
|
+
},
|
|
20
|
+
"./shell": {
|
|
21
|
+
"types": "./shell.d.ts",
|
|
22
|
+
"default": "./shell.js"
|
|
23
|
+
},
|
|
16
24
|
"./build": {
|
|
17
25
|
"types": "./build.d.ts",
|
|
18
26
|
"default": "./build.mjs"
|
|
19
27
|
}
|
|
20
28
|
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "node --test"
|
|
31
|
+
},
|
|
21
32
|
"bin": {
|
|
22
|
-
"wings-plugin": "
|
|
33
|
+
"wings-plugin": "bin.mjs"
|
|
23
34
|
},
|
|
24
35
|
"files": [
|
|
25
36
|
"wings.d.ts",
|
|
26
37
|
"react.js",
|
|
27
38
|
"react.d.ts",
|
|
39
|
+
"ui.js",
|
|
40
|
+
"ui.d.ts",
|
|
41
|
+
"ui-css.js",
|
|
42
|
+
"ui-css.d.ts",
|
|
43
|
+
"shell.js",
|
|
44
|
+
"shell.d.ts",
|
|
28
45
|
"build.mjs",
|
|
29
46
|
"build.d.ts",
|
|
30
47
|
"bin.mjs",
|
|
@@ -37,14 +54,19 @@
|
|
|
37
54
|
"directory": "packages/plugin-sdk"
|
|
38
55
|
},
|
|
39
56
|
"dependencies": {
|
|
40
|
-
"esbuild": "^0.28.2"
|
|
57
|
+
"esbuild": "^0.28.2",
|
|
58
|
+
"fflate": "^0.8.3"
|
|
41
59
|
},
|
|
42
60
|
"peerDependencies": {
|
|
43
|
-
"react": ">=
|
|
61
|
+
"react": ">=19",
|
|
62
|
+
"react-dom": ">=19"
|
|
44
63
|
},
|
|
45
64
|
"peerDependenciesMeta": {
|
|
46
65
|
"react": {
|
|
47
66
|
"optional": true
|
|
67
|
+
},
|
|
68
|
+
"react-dom": {
|
|
69
|
+
"optional": true
|
|
48
70
|
}
|
|
49
71
|
}
|
|
50
72
|
}
|
package/shell.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/// <reference path="./wings.d.ts" />
|
|
2
|
+
/** Where a page shows in Wings. Each gets its own padding and spacing from Wings' plugin-ui.css. */
|
|
3
|
+
export type Place = "sidebar" | "popover" | "view" | "tab" | "settings";
|
|
4
|
+
|
|
5
|
+
export interface ShellOptions {
|
|
6
|
+
/** Lay the page out edge to edge with no padding, filling the frame, for a page that scrolls on its own. */
|
|
7
|
+
fill?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Makes the page's `#app` element (or a new `<main>`) the shell for `place`, and returns it, for a page that
|
|
12
|
+
* builds its DOM itself. React pages use `showSidebar` and the others from `wings-plugin-sdk/ui` instead.
|
|
13
|
+
*/
|
|
14
|
+
export function mountShell(place: Place, options?: ShellOptions): HTMLElement;
|
package/shell.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// The frame a plugin page lives in, without React: where it is, its padding and how it scrolls come from Wings'
|
|
2
|
+
// plugin-ui.css, so a page only says which place it is. wings-plugin-sdk/ui mounts React into it.
|
|
3
|
+
import { fallbackCss, UI_VERSION } from "./ui-css.js";
|
|
4
|
+
|
|
5
|
+
const PLACES = ["sidebar", "popover", "view", "tab", "settings"];
|
|
6
|
+
// Sidebars and popovers change while you look at them, so a screen reader hears what changed.
|
|
7
|
+
const LIVE = new Set(["sidebar", "popover"]);
|
|
8
|
+
|
|
9
|
+
/** Wings older than the classes this package renders have none, or older ones: bring them, before the page's own CSS. */
|
|
10
|
+
function ensureStyles() {
|
|
11
|
+
const host = Number(getComputedStyle(document.documentElement).getPropertyValue("--wings-ui")) || 0;
|
|
12
|
+
if (host >= UI_VERSION || document.querySelector("style[data-wings-ui]")) return;
|
|
13
|
+
const style = document.createElement("style");
|
|
14
|
+
style.dataset.wingsUi = String(UI_VERSION);
|
|
15
|
+
style.textContent = fallbackCss;
|
|
16
|
+
document.head.prepend(style);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Makes the page's `#app` element (or a new `<main>`) the shell for `place`, and returns it. Calling it again
|
|
21
|
+
* returns the same element. `fill` lays the page out edge to edge, for a page that scrolls on its own.
|
|
22
|
+
*/
|
|
23
|
+
export function mountShell(place, { fill = false } = {}) {
|
|
24
|
+
if (!PLACES.includes(place)) throw new Error(`Unknown place ${place}: use one of ${PLACES.join(", ")}`);
|
|
25
|
+
ensureStyles();
|
|
26
|
+
let shell = document.getElementById("app");
|
|
27
|
+
if (!shell) {
|
|
28
|
+
shell = document.createElement("main");
|
|
29
|
+
shell.id = "app";
|
|
30
|
+
document.body.append(shell);
|
|
31
|
+
}
|
|
32
|
+
shell.classList.add("w-shell");
|
|
33
|
+
shell.dataset.place = place;
|
|
34
|
+
shell.toggleAttribute("data-fill", fill);
|
|
35
|
+
if (LIVE.has(place)) shell.setAttribute("aria-live", "polite");
|
|
36
|
+
else shell.removeAttribute("aria-live");
|
|
37
|
+
return shell;
|
|
38
|
+
}
|
package/ui-css.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** The version of the classes wings-plugin-sdk/ui renders. Wings' plugin-ui.css sets it as `--wings-ui`. */
|
|
2
|
+
export const UI_VERSION: number;
|
|
3
|
+
/** The wings-plugin-sdk/ui styles, for Wings versions whose plugin-ui.css doesn't have them yet. */
|
|
4
|
+
export const fallbackCss: string;
|