wings-plugin-sdk 0.1.0 → 0.3.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 CHANGED
@@ -32,39 +32,213 @@ 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 in the plugin folder, so `src/main.ts` becomes `main.js` and `src/sidebar.tsx` becomes `sidebar.js`.
36
- Folders inside `src/` hold the modules they import.
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 build # writes main.js, sidebar.js, ...
40
- npx wings-plugin build --check # fails if a built file is older than its source
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
- Commit the built files. Wings installs a plugin from GitHub as the files in the repo, without building it.
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
- ## React pages
48
+ ## Pages
46
49
 
47
- A sidebar, popover or view page is your own HTML that loads its built script:
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
- ```html
50
- <main id="app"></main>
51
- <script src="sidebar.js"></script>
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 { useState } from "react";
56
- import { createRoot } from "react-dom/client";
57
- import { useBroadcast } from "wings-plugin-sdk/react";
58
-
59
- function Sidebar() {
60
- const [state, setState] = useState<unknown>(null);
61
- useBroadcast((message) => setState(message));
62
- return <pre>{JSON.stringify(state, null, 2)}</pre>;
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
- createRoot(document.getElementById("app")!).render(<Sidebar />);
96
+ showSidebar(<Usage />);
66
97
  ```
67
98
 
68
- Every `wings.on*` call returns a function that removes the listener, so you can also return it from a
69
- `useEffect` yourself. The API reference is
70
- [plugins/README.md](https://github.com/josippapez/Wings/blob/main/plugins/README.md) in the Wings repo.
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 popover's page takes its sidebar's title, the name Wings gives the
110
+ popover too. A page that builds its DOM itself, without React, gets the same shell
111
+ from `mountShell(place, options)` in `wings-plugin-sdk/shell`, which returns the element to fill.
112
+
113
+ ### Talking to main
114
+
115
+ `useMainState<T>(type, hello?)` returns the newest message of `type` your plugin's other pages broadcast, or
116
+ `null` until one comes. It sends `hello` (`{ type: "hello" }` by default, `null` for none) once when the
117
+ component mounts, so main answers with what it has. `useBroadcast(listener)` gets every message.
118
+
119
+ ### Components
120
+
121
+ | Component | Props | Renders |
122
+ |---|---|---|
123
+ | `Row`, `Stack` | `gap` (`none`, `xs`, `s`, `m`, `l`), `grow`; `Row` also `align`, `justify`, `wrap`; `as` | Children side by side or one under the other; as a `ul` or `ol`, a list with no bullets |
124
+ | `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` |
125
+ | `Title` | `as` (`h1`, `h2`, `h3`), `size` (`large`) | A page's or a card's title, like a sprint's name or a setup step's question |
126
+ | `Heading` | `level` (2, 3, 4), `actions` | A small section heading, `h2` by default, with chips or a button on its right |
127
+ | `Card` | `compact`, `as` (`form`, `ul`, `ol` too) | A card, a card that's a form, or a list |
128
+ | `Gauge`, `Meter` | `label`, `percent`, `note`, `tone` or `levels` | A labelled bar with its percentage, or the bar alone, with the meter role |
129
+ | `Stats` | `items: { label, value, tone? }[]` | Small figures side by side, label over value |
130
+ | `Properties` | `items: { label, value }[]` | Label and value pairs |
131
+ | `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 |
132
+ | `Dot`, `Spinner` | `tone`, `size`, `label`; `Dot` also `color` | A status dot or a spinner, announced only with a `label`. A dot's `color` comes from your data, like a work item type's `#cc293d` |
133
+ | `Pill`, `Tag` | `tone` | A filled pill, or an outlined tag like a branch |
134
+ | `Loading`, `Empty` | `Empty`: `title`, `detail` | Before the first data, and nothing to show |
135
+ | `Button` | `variant` (`primary`, `ghost`, `danger`), `size` (`small`), `busy` | A button, `type="button"`; `busy` shows a spinner and ignores clicks, keeping focus |
136
+ | `Chip`, `Chips` | `pressed`; `Chips`: `label`, `options`, `value`, `onChange` | A small quiet button, and a group of them that picks one, with `aria-pressed`. An option's other props, like `id`, go to its chip |
137
+ | `Tabs` | `label`, `items`, `value`, `onChange`, `children` | Tabs with arrow keys, Home and End, over their panel |
138
+ | `Field` | `label`, `hint`, `error`, `id`, `inline` | A labelled field that names and describes the input in it. `id` becomes the input's, for a page that puts focus back by id; `inline` puts the label beside it |
139
+ | `FieldGroup` | `label`, `hint`, `error` | Several controls under one label, like checkboxes: a `fieldset` with its `legend`, described by the hint; an error marks each checkbox invalid |
140
+ | `TextInput`, `SearchField`, `Select` | `label`; `Select`: `options`, `value`, `onChange` | Inputs Wings styles; outside a `Field`, `label` names them |
141
+ | `Checkbox` | `checked`, `onChange`, `children` | A checkbox with its label |
142
+ | `Combobox` | `label`, `value`, `onInput`, `options: { value, label, detail? }[]`, `onPick`, `freeText`, `openOnFocus`, `filter`, `empty` | A text field with suggestions under it: arrows, Enter, Escape and Tab, and a press outside closes it. With `freeText` it keeps what you type; otherwise it picks one of `options`. `openOnFocus={false}` for a page that puts focus back into it after a render |
143
+ | `Menu` | `label`, `items: { id, label, onSelect, tone? }[]`, `align`, `variant`, `size` | A button that opens a menu: arrows, Home, End, Enter, Escape |
144
+ | `Disclosure` | `summary` | More behind a line that opens it |
145
+
146
+ Every component passes other props, like `id`, `title`, `className`, `onClick` and `aria-*`, to its element.
147
+ `TextInput` and `SearchField` pass `ref` to the input on React 19.
148
+
149
+ A button that's working on something is `busy`: it shows a spinner, ignores clicks and says so with `aria-busy`
150
+ and `aria-disabled`, and unlike `disabled` it keeps the keyboard focus:
151
+
152
+ ```tsx
153
+ <Button variant="primary" busy={saving} onClick={save}>
154
+ Save
155
+ </Button>
156
+ ```
157
+
158
+ ### Ours or your own
159
+
160
+ The components are there for a page that looks like the rest of Wings without effort, not a rule. Write your
161
+ own components next to them, and your own CSS: import it from the page, `import "./sidebar.css"`, and the build
162
+ bundles it into `sidebar.css` and links it from the generated page. Match Wings with its variables, which every
163
+ page has:
164
+
165
+ | Variable | What |
166
+ |---|---|
167
+ | `--wings-fg`, `--wings-muted` | Text, and quieter text |
168
+ | `--wings-surface`, `--wings-hover`, `--wings-pressed`, `--wings-input` | Card, hovered, pressed and input backgrounds |
169
+ | `--wings-hairline`, `--wings-hairline-strong` | Borders |
170
+ | `--wings-ring` | The focus ring |
171
+ | `--wings-primary`, `--wings-primary-fg` | The primary button, and its text |
172
+ | `--wings-info`, `--wings-success`, `--wings-warning`, `--wings-danger` | Tones |
173
+ | `--wings-radius` | A card's corners |
174
+ | `--wings-menu-bg`, `--wings-menu-ring`, `--wings-menu-active`, `--wings-shadow` | A raised layer like a menu or a list of suggestions, as Wings' own menus draw it: its opaque background, its edge, the highlighted option and its shadow |
175
+ | `--wings-font`, `--wings-mono` | Fonts |
176
+
177
+ ```tsx
178
+ // src/sidebar.tsx
179
+ import "./sidebar.css";
180
+
181
+ function Banner({ children }: { children: string }) {
182
+ return <div className="banner">{children}</div>;
183
+ }
184
+ ```
185
+
186
+ ```css
187
+ /* src/sidebar.css */
188
+ .banner {
189
+ padding: 8px 10px;
190
+ border-radius: var(--wings-radius);
191
+ background: color-mix(in srgb, var(--wings-info) 14%, transparent);
192
+ color: var(--wings-info);
193
+ }
194
+ ```
195
+
196
+ Wings' base styles cover plain elements (buttons, inputs, selects and the page's font and colours), the `w-*`
197
+ classes and two older helpers, `.muted` and `.card`; nothing else, so your other classes are yours. Your CSS
198
+ loads after Wings', so it wins where you style a component, like `.my-list .w-list-button { height: 24px; }`.
199
+
200
+ ### The class contract
201
+
202
+ The components render plain elements with `w-*` class names and data attributes, like
203
+ `<div class="w-card" data-size="compact">`, and no styles of their own. Wings styles them in its
204
+ `plugin-ui.css`, which ships with the app, so a Wings update restyles every plugin. The names are a versioned
205
+ public contract: `--wings-ui` on the page's root is its version, `2` now, and a version only adds classes and
206
+ rules. `Meter` sets `--w-value`, how full it is, and a `Dot` with a `color` sets `--w-color`; both are data
207
+ rather than styles.
208
+
209
+ `w-shell` with `data-place` (`sidebar`, `popover`, `view`, `tab`, `settings`) and `data-fill`; `w-row`,
210
+ `w-stack` with `data-gap`, `data-align`, `data-justify`, `data-wrap`, `data-grow`; `w-text` with `data-tone`,
211
+ `data-size`, `data-weight`, `data-mono`, `data-numeric`, `data-truncate`, `data-clamp`, `data-grow`;
212
+ `w-title` with `data-size`; `w-heading`, `w-heading-row`; `w-card` with `data-size`; `w-gauge`, `w-gauge-label`, `w-gauge-value`,
213
+ `w-gauge-note`, `w-meter` with `data-tone`, `w-meter-fill`; `w-stats`, `w-stat-label`, `w-stat-value`;
214
+ `w-props`, `w-prop`, `w-prop-label`, `w-prop-value`; `w-list` with `data-density`, `w-list-item`,
215
+ `w-list-button` with `data-align`; `w-dot` with `data-tone` or `data-color`; `w-pill`, `w-tag` with `data-tone`; `w-spinner` with `data-size`;
216
+ `w-loading`, `w-empty`, `w-empty-detail`; `w-button` with `data-variant`, `data-size`, `aria-disabled`; `w-chips`, `w-chip`;
217
+ `w-tabs`, `w-tab`; `w-field` with `data-layout`, `w-field-row`, `w-field-label`, `w-field-hint`,
218
+ `w-field-group`, `w-input`, `w-search`, `w-select`, `w-check`; `w-combobox`, `w-listbox`, `w-option`,
219
+ `w-option-label`, `w-option-detail`, `w-option-empty`; `w-menu-root`, `w-menu` with `data-align`, `w-menu-item`
220
+ with `data-tone`; `w-disclosure`. A `ul` or `ol` with `w-row`, `w-stack` or `w-card` loses its bullets.
221
+
222
+ Version 2 added `w-title`, `Heading`'s levels, `w-field-group`, `w-field-row`, the combobox and menu classes,
223
+ `data-color` on `w-dot`, busy buttons (`aria-disabled` on `w-button`), lists as rows, stacks and cards, and the four raised-layer variables.
224
+
225
+ ### Older Wings
226
+
227
+ Wings before 0.1.15 has no `w-*` styles, and a Wings with `--wings-ui: 1` lacks version 2's. A page built with
228
+ `wings-plugin-sdk/ui` checks `--wings-ui` when it mounts, and on a Wings without it, or with an older version,
229
+ adds the package's own copy of the styles, before Wings' and your CSS. Since a version only adds rules, the old
230
+ ones Wings has and the new ones from the package fit together. So a plugin works on older Wings without a `minWingsVersion`, and on a current Wings it always gets
231
+ Wings' own styles.
232
+
233
+ ### A linked SDK
234
+
235
+ With the SDK linked from a local folder (`npm link`, or `link:` in `package.json`) rather than installed,
236
+ TypeScript looks for React next to the SDK's folder. Point it at yours in `tsconfig.json`; the build already
237
+ does:
238
+
239
+ ```json
240
+ "paths": {
241
+ "react": ["./node_modules/@types/react"],
242
+ "react/*": ["./node_modules/@types/react/*"]
243
+ }
244
+ ```
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("No plugin to build here: wings-plugin build runs next to wings-plugin.json, with the code in src/.");
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. With
6
- * `check`, writes nothing and lists in `stale` the outputs that differ from what's on disk.
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 { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
6
- import { join, parse } from "node:path";
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[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 code = result.outputFiles[0].text;
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(out) || readFileSync(out, "utf8") !== code) stale.push(out);
126
+ if (!existsSync(file) || readFileSync(file, "utf8") !== text) stale.push(file);
44
127
  } else {
45
- writeFileSync(out, code);
46
- built.push({ file: out, bytes: code.length });
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.1.0",
4
- "description": "Types, React hooks and a build command for Wings plugins.",
3
+ "version": "0.3.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": "./bin.mjs"
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,24 @@
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": ">=18"
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
  }
71
+ },
72
+ "devDependencies": {
73
+ "jsdom": "^30.1.2",
74
+ "react": "^19.1.0",
75
+ "react-dom": "^19.1.0"
49
76
  }
50
77
  }
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;