@dolphy-app/extension-sdk 0.2.0 → 0.4.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 +25 -8
- package/dist/answer-element-BOQcuxYh.js +114 -0
- package/dist/answer-view-D6wnyThb.d.ts +28 -0
- package/dist/index.d.ts +107 -27
- package/dist/index.js +48 -117
- package/dist/runtime.d.ts +17 -0
- package/dist/runtime.js +24 -0
- package/dist/testing.d.ts +330 -11
- package/dist/testing.js +722 -11
- package/docs/debugging.md +210 -0
- package/docs/no-build.md +99 -0
- package/docs/quick-start.md +172 -0
- package/docs/recipe-command-panel.md +211 -0
- package/docs/recipe-event-storage.md +266 -0
- package/docs/recipe-exercise-type.md +379 -0
- package/docs/recipe-import-export.md +241 -0
- package/docs/recipe-settings.md +161 -0
- package/docs/recipe-theme.md +116 -0
- package/docs/recipe-ui-kit.md +172 -0
- package/docs/recipe-when-dependencies.md +158 -0
- package/package.json +10 -4
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# Recipe: settings
|
|
2
|
+
|
|
3
|
+
Let the user configure the extension in Settings → Extensions. The app draws the
|
|
4
|
+
form from the definitions in the manifest, validates the values and stores them;
|
|
5
|
+
your code reads them. This recipe has no template of its own: start from
|
|
6
|
+
`blank` and replace the three files below, which are checked as a whole project.
|
|
7
|
+
See [quick-start.md](quick-start.md) for the commands.
|
|
8
|
+
|
|
9
|
+
## The manifest
|
|
10
|
+
|
|
11
|
+
File `extension.json` (settings):
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"$schema": "./node_modules/@dolphy-app/extension-api/dist/extension.schema.json",
|
|
16
|
+
"id": "acme.hello",
|
|
17
|
+
"version": "0.1.0",
|
|
18
|
+
"apiVersion": 1,
|
|
19
|
+
"name": "Hello settings",
|
|
20
|
+
"description": "A greeting command whose text follows the user's settings.",
|
|
21
|
+
"author": "your-github-login",
|
|
22
|
+
"tags": ["productivity"],
|
|
23
|
+
"contributes": {
|
|
24
|
+
"commands": [{ "id": "acme.hello.greet", "title": "Greet" }],
|
|
25
|
+
"settings": [
|
|
26
|
+
{
|
|
27
|
+
"id": "acme.hello.name",
|
|
28
|
+
"type": "string",
|
|
29
|
+
"label": "Name to greet",
|
|
30
|
+
"default": "world",
|
|
31
|
+
"maxLength": 40
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"id": "acme.hello.times",
|
|
35
|
+
"type": "number",
|
|
36
|
+
"label": "Exclamation marks",
|
|
37
|
+
"default": 1,
|
|
38
|
+
"min": 1,
|
|
39
|
+
"max": 5,
|
|
40
|
+
"integer": true
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"id": "acme.hello.style",
|
|
44
|
+
"type": "enum",
|
|
45
|
+
"label": "Style",
|
|
46
|
+
"default": "plain",
|
|
47
|
+
"options": [
|
|
48
|
+
{ "value": "plain", "label": "Plain" },
|
|
49
|
+
{ "value": "loud", "label": "Loud" }
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
- Types: `boolean`, `string` (`maxLength`), `number` (`min`, `max`, `integer`)
|
|
58
|
+
and `enum` (`options: [{ value, label }]`).
|
|
59
|
+
- A setting `id` is the extension id or starts with `<id>.`. `default` must
|
|
60
|
+
satisfy the constraints, otherwise the manifest is rejected.
|
|
61
|
+
- `label` (1–60 characters) and `description` (up to 500) are shown as written;
|
|
62
|
+
they are data of the extension and are not translated.
|
|
63
|
+
|
|
64
|
+
## The code
|
|
65
|
+
|
|
66
|
+
File `src/index.ts` (settings):
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import {
|
|
70
|
+
defineExtension,
|
|
71
|
+
inActivate,
|
|
72
|
+
notify,
|
|
73
|
+
} from '@dolphy-app/extension-sdk';
|
|
74
|
+
|
|
75
|
+
export const host = defineExtension({
|
|
76
|
+
commands: { 'acme.hello.greet': inActivate },
|
|
77
|
+
activate(ctx) {
|
|
78
|
+
ctx.commands.register('acme.hello.greet', () => {
|
|
79
|
+
// `get` is synchronous and returns the user's value or the default
|
|
80
|
+
const name = ctx.settings.get('acme.hello.name');
|
|
81
|
+
const marks = '!'.repeat(ctx.settings.get('acme.hello.times'));
|
|
82
|
+
const style = ctx.settings.get('acme.hello.style');
|
|
83
|
+
const text = `Hello, ${name}${marks}`;
|
|
84
|
+
return notify(style === 'loud' ? text.toUpperCase() : text);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// a change in Settings → Extensions reaches the running extension
|
|
88
|
+
ctx.settings.onDidChange((change) => {
|
|
89
|
+
ctx.logger.info({ id: change.id, value: change.value }, 'setting changed');
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
- The ids are typed from the manifest: `ctx.settings.get('acme.hello.times')` is
|
|
96
|
+
a `number`, `style` is `'plain' | 'loud'`, an undeclared id does not compile.
|
|
97
|
+
- Read a value where you use it. Cache it only if you also subscribe with
|
|
98
|
+
`onDidChange`, as the `exercise` template does.
|
|
99
|
+
- The engine validates every value (type, range, integer, length, `options`)
|
|
100
|
+
before it reaches you, so the code needs no checks of its own.
|
|
101
|
+
|
|
102
|
+
## The test
|
|
103
|
+
|
|
104
|
+
File `test/index.test.ts` (settings):
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import type { SettingContribution } from '@dolphy-app/extension-sdk';
|
|
108
|
+
import {
|
|
109
|
+
createMemorySettings,
|
|
110
|
+
loadCommands,
|
|
111
|
+
} from '@dolphy-app/extension-sdk/testing';
|
|
112
|
+
import { expect, it } from 'vitest';
|
|
113
|
+
import manifest from '../extension.json';
|
|
114
|
+
import { host } from '../src/index.ts';
|
|
115
|
+
|
|
116
|
+
const definitions = manifest.contributes.settings as SettingContribution[];
|
|
117
|
+
|
|
118
|
+
it('the greeting follows the settings, also after a change', async () => {
|
|
119
|
+
const settings = createMemorySettings(definitions);
|
|
120
|
+
const commands = await loadCommands(host, {
|
|
121
|
+
settings,
|
|
122
|
+
declaredCommands: ['acme.hello.greet'],
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(await commands.run('acme.hello.greet')).toEqual({
|
|
126
|
+
kind: 'notify',
|
|
127
|
+
text: 'Hello, world!',
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
await settings.set('acme.hello.name', 'Ada');
|
|
131
|
+
await settings.set('acme.hello.times', 3);
|
|
132
|
+
await settings.set('acme.hello.style', 'loud');
|
|
133
|
+
expect(await commands.run('acme.hello.greet')).toEqual({
|
|
134
|
+
kind: 'notify',
|
|
135
|
+
text: 'HELLO, ADA!!!',
|
|
136
|
+
});
|
|
137
|
+
await commands.dispose();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('the memory settings reject a value the engine would reject', async () => {
|
|
141
|
+
const settings = createMemorySettings(definitions);
|
|
142
|
+
await expect(settings.set('acme.hello.times', 9)).rejects.toThrow(
|
|
143
|
+
'acme.hello.times',
|
|
144
|
+
);
|
|
145
|
+
await expect(settings.set('acme.hello.style', 'quiet')).rejects.toThrow(
|
|
146
|
+
'acme.hello.style',
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
`createMemorySettings(definitions, values?)` is the settings the host gives
|
|
152
|
+
`ctx`: `get` returns the default until a value is set, `set(id, value)` checks
|
|
153
|
+
the value against the definition and calls the `onDidChange` subscribers. Pass
|
|
154
|
+
it to `loadCommands` (or `loadEvents`, `loadExerciseType`) through `settings`.
|
|
155
|
+
`loadEvents` takes the definitions and `settingValues` directly.
|
|
156
|
+
|
|
157
|
+
## Try and ship
|
|
158
|
+
|
|
159
|
+
Run `pnpm dev`, start the app with `DOLPHY_DEV_EXTENSIONS`, open Settings →
|
|
160
|
+
Extensions, press "Settings" on the extension, change a value and run "Greet"
|
|
161
|
+
from the palette.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Recipe: a theme
|
|
2
|
+
|
|
3
|
+
A theme is data: no TypeScript, no `main.mjs`. This recipe is the `theme`
|
|
4
|
+
template (`npx @dolphy-app/create-extension <dir> --id acme.hello --template theme`).
|
|
5
|
+
The files below are exactly what the generator writes for the id `acme.hello`.
|
|
6
|
+
See [quick-start.md](quick-start.md) for the commands.
|
|
7
|
+
|
|
8
|
+
## The manifest
|
|
9
|
+
|
|
10
|
+
File `extension.json` (theme):
|
|
11
|
+
|
|
12
|
+
```json
|
|
13
|
+
{
|
|
14
|
+
"$schema": "./node_modules/@dolphy-app/extension-api/dist/extension.schema.json",
|
|
15
|
+
"id": "acme.hello",
|
|
16
|
+
"version": "0.1.0",
|
|
17
|
+
"apiVersion": 1,
|
|
18
|
+
"name": "Midnight",
|
|
19
|
+
"description": "A dark color theme with an amber accent for the Dolphy app.",
|
|
20
|
+
"author": "your-github-login",
|
|
21
|
+
"tags": ["theme"],
|
|
22
|
+
"contributes": {
|
|
23
|
+
"themes": [
|
|
24
|
+
{
|
|
25
|
+
"id": "acme.hello",
|
|
26
|
+
"label": "Midnight",
|
|
27
|
+
"dark": true,
|
|
28
|
+
"colors": {
|
|
29
|
+
"background": "#101820",
|
|
30
|
+
"surface": "#1B2733",
|
|
31
|
+
"on-background": "#E6EDF3",
|
|
32
|
+
"on-surface": "#E6EDF3",
|
|
33
|
+
"primary": "#FFB000",
|
|
34
|
+
"on-primary": "#101820"
|
|
35
|
+
},
|
|
36
|
+
"variables": { "border-opacity": 0.2 }
|
|
37
|
+
}
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- `contributes.themes[]` has an `id` (not `system`, `light` or `dark`), a
|
|
44
|
+
`label` of 1–60 characters and `dark`, which says whether the theme is dark
|
|
45
|
+
and so picks the base colors of the interface that `colors` then override.
|
|
46
|
+
- `colors` are `#rrggbb` or `#rrggbbaa` values for a fixed list of roles
|
|
47
|
+
(`background`, `surface`, `primary`, the `on-…` colors for text drawn on them,
|
|
48
|
+
`error`, `success`…). A key outside the list is rejected by `pnpm validate`.
|
|
49
|
+
Pair every background with a readable text color.
|
|
50
|
+
- `variables` are optional tokens from a fixed list; here `border-opacity`, a
|
|
51
|
+
number from 0 to 1.
|
|
52
|
+
- `build` of a project with no code writes only `extension.json` to `dist-ext`.
|
|
53
|
+
|
|
54
|
+
## The test
|
|
55
|
+
|
|
56
|
+
File `test/theme.test.ts` (theme):
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import { describe, expect, it } from 'vitest';
|
|
60
|
+
import manifest from '../extension.json';
|
|
61
|
+
|
|
62
|
+
const [theme] = manifest.contributes.themes;
|
|
63
|
+
const colors: Record<string, string> = theme.colors;
|
|
64
|
+
|
|
65
|
+
// WCAG relative luminance of a #rrggbb color
|
|
66
|
+
const luminance = (hex: string): number => {
|
|
67
|
+
const [r, g, b] = [1, 3, 5].map((start) => {
|
|
68
|
+
const channel = Number.parseInt(hex.slice(start, start + 2), 16) / 255;
|
|
69
|
+
return channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
|
|
70
|
+
}) as [number, number, number];
|
|
71
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const contrast = (foreground: string, background: string): number => {
|
|
75
|
+
const [light, dark] = [luminance(foreground), luminance(background)].sort(
|
|
76
|
+
(a, b) => b - a,
|
|
77
|
+
) as [number, number];
|
|
78
|
+
return (light + 0.05) / (dark + 0.05);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
describe('acme.hello: theme', () => {
|
|
82
|
+
it.each([
|
|
83
|
+
['on-surface', 'surface'],
|
|
84
|
+
['on-background', 'background'],
|
|
85
|
+
['on-primary', 'primary'],
|
|
86
|
+
])('%s on %s has a contrast of at least 4.5:1', (foreground, background) => {
|
|
87
|
+
expect(contrast(colors[foreground] as string, colors[background] as string))
|
|
88
|
+
.toBeGreaterThanOrEqual(4.5);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('a dark theme has a dark background and a light text', () => {
|
|
92
|
+
const background = luminance(colors['background'] as string);
|
|
93
|
+
const text = luminance(colors['on-background'] as string);
|
|
94
|
+
expect(theme.dark ? background < text : background > text).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
A project with no code still has something worth testing. The test reads the
|
|
100
|
+
manifest and checks the WCAG contrast of each text color against its background
|
|
101
|
+
(at least 4.5:1) and that `dark` agrees with the colors. A failing contrast is a
|
|
102
|
+
real bug the learner would see, and the test also gives `vitest run` a file to
|
|
103
|
+
run. Keep the test when you change the colors.
|
|
104
|
+
|
|
105
|
+
## Try and ship
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
pnpm install
|
|
109
|
+
pnpm test
|
|
110
|
+
pnpm dev
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
With `DOLPHY_DEV_EXTENSIONS` pointing at `dist-ext` (see the quick start) the
|
|
114
|
+
theme appears as a tile in Settings → Appearance next to System, Light and
|
|
115
|
+
Dark; saving `extension.json` is picked up by the running app. Change the id, the label and the colors; change `tags` and
|
|
116
|
+
`description` too, the catalog shows them.
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Recipe: a panel on the UI kit
|
|
2
|
+
|
|
3
|
+
`@dolphy-app/extension-ui` gives a panel ready, accessible elements, so you do
|
|
4
|
+
not write markup, keyboard handling or dark-theme colours yourself. This recipe
|
|
5
|
+
has no template of its own: start from `blank`, install the kit
|
|
6
|
+
(`pnpm add @dolphy-app/extension-ui`) and replace the three files below, which
|
|
7
|
+
are checked as a whole project. See [quick-start.md](quick-start.md) for the
|
|
8
|
+
commands.
|
|
9
|
+
|
|
10
|
+
## The manifest
|
|
11
|
+
|
|
12
|
+
File `extension.json` (ui-kit):
|
|
13
|
+
|
|
14
|
+
```json
|
|
15
|
+
{
|
|
16
|
+
"$schema": "./node_modules/@dolphy-app/extension-api/dist/extension.schema.json",
|
|
17
|
+
"id": "acme.hello",
|
|
18
|
+
"version": "0.1.0",
|
|
19
|
+
"apiVersion": 1,
|
|
20
|
+
"name": "Hello notes",
|
|
21
|
+
"description": "A panel with a text field, a button and a list built on the UI kit.",
|
|
22
|
+
"author": "your-github-login",
|
|
23
|
+
"tags": ["productivity"],
|
|
24
|
+
"contributes": {
|
|
25
|
+
"panels": [{ "id": "acme.hello.view", "title": "Notes" }]
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## The code
|
|
31
|
+
|
|
32
|
+
File `src/index.ts` (ui-kit):
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { defineExtensionPanel } from '@dolphy-app/extension-sdk';
|
|
36
|
+
import type { ExtensionPanels } from '@dolphy-app/extension-sdk';
|
|
37
|
+
import {
|
|
38
|
+
button,
|
|
39
|
+
card,
|
|
40
|
+
emptyState,
|
|
41
|
+
list,
|
|
42
|
+
textField,
|
|
43
|
+
} from '@dolphy-app/extension-ui';
|
|
44
|
+
|
|
45
|
+
export const panels = {
|
|
46
|
+
'acme.hello.view': defineExtensionPanel({
|
|
47
|
+
mount(container) {
|
|
48
|
+
const notes: string[] = [];
|
|
49
|
+
let draft = '';
|
|
50
|
+
const status = document.createElement('p');
|
|
51
|
+
status.setAttribute('role', 'status');
|
|
52
|
+
const body = document.createElement('div');
|
|
53
|
+
// elements are static: draw the list again with replaceChildren
|
|
54
|
+
const render = () => {
|
|
55
|
+
body.replaceChildren(
|
|
56
|
+
notes.length === 0
|
|
57
|
+
? emptyState({
|
|
58
|
+
title: 'No notes yet',
|
|
59
|
+
description: 'Type a note and press Add.',
|
|
60
|
+
})
|
|
61
|
+
: list({
|
|
62
|
+
label: 'Notes',
|
|
63
|
+
emptyText: 'No notes',
|
|
64
|
+
items: notes.map((note, index) => ({
|
|
65
|
+
id: String(index),
|
|
66
|
+
label: note,
|
|
67
|
+
})),
|
|
68
|
+
onSelect: (id) => {
|
|
69
|
+
status.textContent = `Selected note ${Number(id) + 1}`;
|
|
70
|
+
},
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
};
|
|
74
|
+
render();
|
|
75
|
+
container.append(
|
|
76
|
+
card({
|
|
77
|
+
title: 'Notes',
|
|
78
|
+
children: [
|
|
79
|
+
textField({
|
|
80
|
+
label: 'Note text',
|
|
81
|
+
onInput: (value) => {
|
|
82
|
+
draft = value;
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
85
|
+
button({
|
|
86
|
+
label: 'Add',
|
|
87
|
+
variant: 'primary',
|
|
88
|
+
onClick: () => {
|
|
89
|
+
if (draft.trim() === '') return;
|
|
90
|
+
notes.push(draft.trim());
|
|
91
|
+
status.textContent = `Notes: ${notes.length}`;
|
|
92
|
+
render();
|
|
93
|
+
},
|
|
94
|
+
}),
|
|
95
|
+
body,
|
|
96
|
+
],
|
|
97
|
+
}),
|
|
98
|
+
status,
|
|
99
|
+
);
|
|
100
|
+
},
|
|
101
|
+
}),
|
|
102
|
+
} satisfies ExtensionPanels;
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
- `list`, `button`, `textField`, `select`, `toggle`, `card` and `emptyState`
|
|
106
|
+
return DOM elements you append yourself. Each has a role, a visible name and
|
|
107
|
+
keyboard control (a `list` is one tab stop, arrows move, Enter or Space
|
|
108
|
+
select).
|
|
109
|
+
- Colours and spacing come from the CSS variables of the frame theme, so the
|
|
110
|
+
light and the dark theme work with no code of yours. Text is always set as
|
|
111
|
+
`textContent`; the kit never parses markup.
|
|
112
|
+
- Elements keep no state of their own. To change a list, build a new one and
|
|
113
|
+
call `replaceWith` / `replaceChildren`.
|
|
114
|
+
- The whole kit is under 10 KiB gzipped, and it adds no permission: the panel
|
|
115
|
+
still runs in the isolated frame with no network.
|
|
116
|
+
|
|
117
|
+
## The test
|
|
118
|
+
|
|
119
|
+
File `test/index.test.ts` (ui-kit):
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
// @vitest-environment happy-dom
|
|
123
|
+
import { loadPanel } from '@dolphy-app/extension-sdk/testing';
|
|
124
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
125
|
+
import { panels } from '../src/index.ts';
|
|
126
|
+
|
|
127
|
+
const disposables: { dispose(): unknown }[] = [];
|
|
128
|
+
afterEach(async () => {
|
|
129
|
+
await Promise.all(disposables.splice(0).map((item) => item.dispose()));
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('acme.hello: panel', () => {
|
|
133
|
+
it('starts with an empty state and lists an added note', async () => {
|
|
134
|
+
const panel = await loadPanel(panels, 'acme.hello.view');
|
|
135
|
+
disposables.push(panel);
|
|
136
|
+
expect(panel.container.textContent).toContain('No notes yet');
|
|
137
|
+
|
|
138
|
+
const input = panel.container.querySelector('input') as HTMLInputElement;
|
|
139
|
+
input.value = 'Buy milk';
|
|
140
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
141
|
+
const add = [...panel.container.querySelectorAll('button')].find(
|
|
142
|
+
(element) => element.textContent === 'Add',
|
|
143
|
+
) as HTMLButtonElement;
|
|
144
|
+
add.click();
|
|
145
|
+
|
|
146
|
+
const options = panel.container.querySelectorAll('[role="option"]');
|
|
147
|
+
expect(options).toHaveLength(1);
|
|
148
|
+
expect(options[0]?.textContent).toContain('Buy milk');
|
|
149
|
+
expect(panel.container.querySelector('[role="status"]')?.textContent).toBe(
|
|
150
|
+
'Notes: 1',
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('ignores an empty note', async () => {
|
|
155
|
+
const panel = await loadPanel(panels, 'acme.hello.view');
|
|
156
|
+
disposables.push(panel);
|
|
157
|
+
const add = [...panel.container.querySelectorAll('button')].find(
|
|
158
|
+
(element) => element.textContent === 'Add',
|
|
159
|
+
) as HTMLButtonElement;
|
|
160
|
+
add.click();
|
|
161
|
+
expect(panel.container.textContent).toContain('No notes yet');
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`loadPanel` mounts the panel in happy-dom the way the frame does. The kit's
|
|
167
|
+
own tests check its roles and keys; test what your panel does with them.
|
|
168
|
+
|
|
169
|
+
## Try and ship
|
|
170
|
+
|
|
171
|
+
Run `pnpm dev`, start the app with `DOLPHY_DEV_EXTENSIONS` and open the panel
|
|
172
|
+
from the sidebar. Switch the theme in Settings → Appearance: the panel follows.
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# Recipe: visibility conditions and dependencies
|
|
2
|
+
|
|
3
|
+
Show a command, a panel or a widget only where it makes sense (`when`), and
|
|
4
|
+
require another extension (`dependencies`). Both are manifest data, so this
|
|
5
|
+
recipe is a small project whose test reads the manifest. This recipe has no
|
|
6
|
+
template of its own: start from `blank` and replace the three files below,
|
|
7
|
+
which are checked as a whole project. See [quick-start.md](quick-start.md) for
|
|
8
|
+
the commands.
|
|
9
|
+
|
|
10
|
+
## The manifest
|
|
11
|
+
|
|
12
|
+
File `extension.json` (when-dependencies):
|
|
13
|
+
|
|
14
|
+
```json
|
|
15
|
+
{
|
|
16
|
+
"$schema": "./node_modules/@dolphy-app/extension-api/dist/extension.schema.json",
|
|
17
|
+
"id": "acme.hello",
|
|
18
|
+
"version": "0.1.0",
|
|
19
|
+
"apiVersion": 1,
|
|
20
|
+
"name": "Hello report",
|
|
21
|
+
"description": "A report command that appears only on the Courses screen.",
|
|
22
|
+
"author": "your-github-login",
|
|
23
|
+
"tags": ["productivity"],
|
|
24
|
+
"dependencies": [
|
|
25
|
+
{ "id": "acme.cards", "range": ">=1.0.0 <2.0.0" },
|
|
26
|
+
{ "id": "dolphy.choice" }
|
|
27
|
+
],
|
|
28
|
+
"contributes": {
|
|
29
|
+
"commands": [
|
|
30
|
+
{
|
|
31
|
+
"id": "acme.hello.report",
|
|
32
|
+
"title": "Course report",
|
|
33
|
+
"when": "route == 'courses' && course.active"
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"panels": [
|
|
37
|
+
{
|
|
38
|
+
"id": "acme.hello.board",
|
|
39
|
+
"title": "Course board",
|
|
40
|
+
"when": "course.active && !session.active"
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
- `when` is a boolean expression over five keys of the app window: `route`
|
|
48
|
+
(the name of the current screen), `course.active` (one course is in focus),
|
|
49
|
+
`session.active` (a study session is open), `locale` (`ru` or `en`) and
|
|
50
|
+
`theme.dark`. Operators: `==`, `!=`, `in ('a', 'b')`, `&&`, `||`, `!` and
|
|
51
|
+
parentheses; strings are in single quotes. At most 200 characters. An unknown
|
|
52
|
+
key or value, a wrong type or a syntax error is a manifest error with the
|
|
53
|
+
position, so `pnpm validate` catches a typo before the app does.
|
|
54
|
+
- While the condition is false a command is not in the palette and its keys do
|
|
55
|
+
nothing, a panel's menu item is hidden, and a widget is not drawn. The value
|
|
56
|
+
follows the route, the course, the session, the language and the theme
|
|
57
|
+
without a reload. Your own code still reaches the command (`ctx.call`) and
|
|
58
|
+
the panel (`openPanel`): `when` hides, it does not forbid.
|
|
59
|
+
- `dependencies` lists up to 16 extensions that must be present, enabled,
|
|
60
|
+
loaded and in the version `range` (comparators separated by a space, such as
|
|
61
|
+
`>=1.0.0 <2.0.0`; no `^` or `~`). Otherwise the extension is shown as
|
|
62
|
+
"dependencies not met" in Settings → Extensions, with the missing, disabled
|
|
63
|
+
or mismatched one named, and contributes nothing. Disabling or enabling a
|
|
64
|
+
dependency updates the dependents at once.
|
|
65
|
+
- Nothing installs a dependency for the user, and extensions cannot call each
|
|
66
|
+
other: a dependency only says "this must be there". A repeat and a dependency
|
|
67
|
+
on yourself are manifest errors; extensions that depend on each other in a
|
|
68
|
+
circle are not loaded.
|
|
69
|
+
|
|
70
|
+
## The code
|
|
71
|
+
|
|
72
|
+
File `src/index.ts` (when-dependencies):
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
import { defineExtension, defineExtensionPanel } from '@dolphy-app/extension-sdk';
|
|
76
|
+
import type { ExtensionPanels } from '@dolphy-app/extension-sdk';
|
|
77
|
+
|
|
78
|
+
export const host = defineExtension({
|
|
79
|
+
commands: { 'acme.hello.report': () => ({ courses: 1 }) },
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
export const panels = {
|
|
83
|
+
'acme.hello.board': defineExtensionPanel({
|
|
84
|
+
mount(container) {
|
|
85
|
+
container.textContent = 'Board';
|
|
86
|
+
},
|
|
87
|
+
}),
|
|
88
|
+
} satisfies ExtensionPanels;
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The conditions are in the manifest, so the code is ordinary: it does not check
|
|
92
|
+
where it is shown. The command and the panel are only reachable where the
|
|
93
|
+
condition is true.
|
|
94
|
+
|
|
95
|
+
## The test
|
|
96
|
+
|
|
97
|
+
File `test/when.test.ts` (when-dependencies):
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
import { evaluateWhen, parseWhen } from '@dolphy-app/extension-sdk';
|
|
101
|
+
import type { WhenContext } from '@dolphy-app/extension-sdk';
|
|
102
|
+
import { describe, expect, it } from 'vitest';
|
|
103
|
+
import manifest from '../extension.json';
|
|
104
|
+
|
|
105
|
+
const context = (overrides: Partial<WhenContext> = {}): WhenContext => ({
|
|
106
|
+
route: 'courses',
|
|
107
|
+
'course.active': true,
|
|
108
|
+
'session.active': false,
|
|
109
|
+
locale: 'en',
|
|
110
|
+
'theme.dark': false,
|
|
111
|
+
...overrides,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const [command] = manifest.contributes.commands;
|
|
115
|
+
const [panel] = manifest.contributes.panels;
|
|
116
|
+
|
|
117
|
+
describe('acme.hello: when', () => {
|
|
118
|
+
it('shows the report on the Courses screen with a course in focus', () => {
|
|
119
|
+
const when = parseWhen(command.when);
|
|
120
|
+
expect(evaluateWhen(when, context())).toBe(true);
|
|
121
|
+
expect(evaluateWhen(when, context({ route: 'daily-plan' }))).toBe(
|
|
122
|
+
false,
|
|
123
|
+
);
|
|
124
|
+
expect(
|
|
125
|
+
evaluateWhen(when, context({ 'course.active': false })),
|
|
126
|
+
).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('hides the board during a study session', () => {
|
|
130
|
+
const when = parseWhen(panel.when);
|
|
131
|
+
expect(evaluateWhen(when, context())).toBe(true);
|
|
132
|
+
expect(
|
|
133
|
+
evaluateWhen(when, context({ 'session.active': true })),
|
|
134
|
+
).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('reports a typo in a key with its position', () => {
|
|
138
|
+
expect(() => parseWhen("rout == 'courses'")).toThrow();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('acme.hello: dependencies', () => {
|
|
143
|
+
it('names each required extension once, and not itself', () => {
|
|
144
|
+
const ids = manifest.dependencies.map(({ id }) => id);
|
|
145
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
146
|
+
expect(ids).not.toContain(manifest.id);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
`parseWhen` and `evaluateWhen` are the functions the app and `dolphy-ext
|
|
152
|
+
validate` use. `evaluateWhen` is pure: pass an object with the five keys.
|
|
153
|
+
|
|
154
|
+
## Try and ship
|
|
155
|
+
|
|
156
|
+
Run `pnpm validate`: it fails on a bad `when` or a bad `dependencies` entry.
|
|
157
|
+
In the app the extension's row in Settings → Extensions shows "dependencies not
|
|
158
|
+
met" until `acme.cards` is installed and enabled.
|
package/package.json
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dolphy-app/extension-sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "SDK
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "SDK for extension authors: defineExtension, defineAnswerView, test helpers",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
6
7
|
"exports": {
|
|
7
8
|
".": {
|
|
8
9
|
"types": "./dist/index.d.ts",
|
|
9
10
|
"default": "./dist/index.js"
|
|
10
11
|
},
|
|
12
|
+
"./runtime": {
|
|
13
|
+
"types": "./dist/runtime.d.ts",
|
|
14
|
+
"default": "./dist/runtime.js"
|
|
15
|
+
},
|
|
11
16
|
"./testing": {
|
|
12
17
|
"types": "./dist/testing.d.ts",
|
|
13
18
|
"default": "./dist/testing.js"
|
|
@@ -15,10 +20,11 @@
|
|
|
15
20
|
},
|
|
16
21
|
"types": "./dist/index.d.ts",
|
|
17
22
|
"files": [
|
|
18
|
-
"dist"
|
|
23
|
+
"dist",
|
|
24
|
+
"docs"
|
|
19
25
|
],
|
|
20
26
|
"dependencies": {
|
|
21
|
-
"@dolphy-app/extension-api": "0.
|
|
27
|
+
"@dolphy-app/extension-api": "0.4.0",
|
|
22
28
|
"ajv": "^8"
|
|
23
29
|
},
|
|
24
30
|
"engines": {
|