@dolphy-app/extension-sdk 0.4.0 → 0.5.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 +28 -14
- package/dist/client.d.ts +40 -0
- package/dist/client.js +67 -0
- package/dist/define-entry-BTz2F3qv.js +22 -0
- package/dist/define-entry-lsuxzKdD.d.ts +77 -0
- package/dist/index-I_CXC6Lj.d.ts +1958 -0
- package/dist/index.d.ts +7 -111
- package/dist/index.js +5 -86
- package/dist/react.d.ts +51 -0
- package/dist/react.js +112 -0
- package/dist/rpc.d.ts +28 -0
- package/dist/rpc.js +37 -0
- package/dist/testing.d.ts +184 -241
- package/dist/testing.js +574 -464
- package/docs/debugging.md +82 -64
- package/docs/no-build.md +84 -48
- package/docs/quick-start.md +78 -56
- package/docs/recipe-command-panel.md +262 -118
- package/docs/recipe-event-storage.md +188 -128
- package/docs/recipe-exercise-type.md +271 -183
- package/docs/recipe-hooks.md +158 -0
- package/docs/recipe-import-export.md +48 -53
- package/docs/recipe-mountable.md +322 -0
- package/docs/recipe-react.md +352 -0
- package/docs/recipe-rpc-and-app.md +366 -0
- package/docs/recipe-settings.md +122 -100
- package/docs/recipe-theme.md +72 -40
- package/docs/recipe-when-dependencies.md +188 -82
- package/package.json +29 -7
- package/dist/answer-element-BOQcuxYh.js +0 -114
- package/dist/answer-view-D6wnyThb.d.ts +0 -28
- package/dist/runtime.d.ts +0 -17
- package/dist/runtime.js +0 -24
- package/docs/recipe-ui-kit.md +0 -172
package/docs/recipe-theme.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# Recipe: a theme
|
|
2
2
|
|
|
3
|
-
A theme is data: no
|
|
4
|
-
|
|
3
|
+
A theme is data that the client part registers: no server part, no `main.mjs`.
|
|
4
|
+
This recipe is the `theme` template
|
|
5
|
+
(`npx @dolphy-app/create-extension <dir> --id acme.hello --template theme`).
|
|
5
6
|
The files below are exactly what the generator writes for the id `acme.hello`.
|
|
6
7
|
See [quick-start.md](quick-start.md) for the commands.
|
|
7
8
|
|
|
@@ -18,49 +19,74 @@ File `extension.json` (theme):
|
|
|
18
19
|
"name": "Midnight",
|
|
19
20
|
"description": "A dark color theme with an amber accent for the Dolphy app.",
|
|
20
21
|
"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
|
-
}
|
|
22
|
+
"tags": ["theme"]
|
|
40
23
|
}
|
|
41
24
|
```
|
|
42
25
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
26
|
+
The manifest holds the identity only; the colors are in the code.
|
|
27
|
+
|
|
28
|
+
## The code
|
|
29
|
+
|
|
30
|
+
File `src/theme.ts` (theme):
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import type { ThemeRegistration } from '@dolphy-app/extension-sdk';
|
|
34
|
+
|
|
35
|
+
// the allowed color and variable keys are `THEME_COLOR_KEYS` and
|
|
36
|
+
// `THEME_VARIABLE_KEYS` of '@dolphy-app/extension-sdk'
|
|
37
|
+
export const midnight: ThemeRegistration = {
|
|
38
|
+
id: 'acme.hello',
|
|
39
|
+
label: 'Midnight',
|
|
40
|
+
dark: true,
|
|
41
|
+
colors: {
|
|
42
|
+
background: '#101820',
|
|
43
|
+
surface: '#1B2733',
|
|
44
|
+
'on-background': '#E6EDF3',
|
|
45
|
+
'on-surface': '#E6EDF3',
|
|
46
|
+
primary: '#FFB000',
|
|
47
|
+
'on-primary': '#101820',
|
|
48
|
+
},
|
|
49
|
+
variables: { 'border-opacity': 0.2 },
|
|
50
|
+
};
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
File `src/index.ts` (theme):
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
import { defineClient } from '@dolphy-app/extension-sdk';
|
|
57
|
+
import { midnight } from './theme.ts';
|
|
58
|
+
|
|
59
|
+
// runs in the app window: a theme is data, there is no server part
|
|
60
|
+
export const client = defineClient((c) => {
|
|
61
|
+
c.addTheme(midnight);
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
- `client.addTheme(registration)` adds a tile to Settings → Appearance next to
|
|
66
|
+
System, Light and Dark. `id` is the extension id or starts with it and a dot,
|
|
67
|
+
and is not `system`, `light` or `dark`; `label` is a `LocalizedText` of 1–60
|
|
68
|
+
characters; `dark` says whether the theme is dark, which picks the base colors
|
|
69
|
+
of the interface that `colors` then override.
|
|
46
70
|
- `colors` are `#rrggbb` or `#rrggbbaa` values for a fixed list of roles
|
|
47
71
|
(`background`, `surface`, `primary`, the `on-…` colors for text drawn on them,
|
|
48
|
-
`error`, `success`…).
|
|
49
|
-
Pair every background with a readable text color.
|
|
50
|
-
- `variables` are optional tokens from
|
|
51
|
-
number from 0 to 1.
|
|
52
|
-
-
|
|
72
|
+
`error`, `success`…). The list is `THEME_COLOR_KEYS` of the SDK; a key outside
|
|
73
|
+
it fails the registration. Pair every background with a readable text color.
|
|
74
|
+
- `variables` are optional tokens from `THEME_VARIABLE_KEYS`; here
|
|
75
|
+
`border-opacity`, a number from 0 to 1.
|
|
76
|
+
- The build of this project writes `extension.json` and `client.mjs` to
|
|
77
|
+
`dist-ext`: there is no `server` export, so no `main.mjs`.
|
|
53
78
|
|
|
54
79
|
## The test
|
|
55
80
|
|
|
56
81
|
File `test/theme.test.ts` (theme):
|
|
57
82
|
|
|
58
83
|
```ts
|
|
84
|
+
import { createTestClient } from '@dolphy-app/extension-sdk/testing';
|
|
59
85
|
import { describe, expect, it } from 'vitest';
|
|
60
|
-
import
|
|
86
|
+
import { client } from '../src/index.ts';
|
|
87
|
+
import { midnight } from '../src/theme.ts';
|
|
61
88
|
|
|
62
|
-
const
|
|
63
|
-
const colors: Record<string, string> = theme.colors;
|
|
89
|
+
const colors = midnight.colors;
|
|
64
90
|
|
|
65
91
|
// WCAG relative luminance of a #rrggbb color
|
|
66
92
|
const luminance = (hex: string): number => {
|
|
@@ -79,6 +105,12 @@ const contrast = (foreground: string, background: string): number => {
|
|
|
79
105
|
};
|
|
80
106
|
|
|
81
107
|
describe('acme.hello: theme', () => {
|
|
108
|
+
it('the client adds the theme', async () => {
|
|
109
|
+
const running = await createTestClient(client, { extensionId: 'acme.hello' });
|
|
110
|
+
expect(running.themes).toEqual([midnight]);
|
|
111
|
+
await running.dispose();
|
|
112
|
+
});
|
|
113
|
+
|
|
82
114
|
it.each([
|
|
83
115
|
['on-surface', 'surface'],
|
|
84
116
|
['on-background', 'background'],
|
|
@@ -91,16 +123,16 @@ describe('acme.hello: theme', () => {
|
|
|
91
123
|
it('a dark theme has a dark background and a light text', () => {
|
|
92
124
|
const background = luminance(colors['background'] as string);
|
|
93
125
|
const text = luminance(colors['on-background'] as string);
|
|
94
|
-
expect(
|
|
126
|
+
expect(midnight.dark ? background < text : background > text).toBe(true);
|
|
95
127
|
});
|
|
96
128
|
});
|
|
97
129
|
```
|
|
98
130
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
131
|
+
`createTestClient(client, { extensionId })` runs `client` on a context that
|
|
132
|
+
records what it adds, so the test sees the theme in `running.themes`. The rest
|
|
133
|
+
checks the WCAG contrast of each text color against its background (at least
|
|
134
|
+
4.5:1) and that `dark` agrees with the colors. A failing contrast is a real bug
|
|
135
|
+
the learner would see. Keep the test when you change the colors.
|
|
104
136
|
|
|
105
137
|
## Try and ship
|
|
106
138
|
|
|
@@ -111,6 +143,6 @@ pnpm dev
|
|
|
111
143
|
```
|
|
112
144
|
|
|
113
145
|
With `DOLPHY_DEV_EXTENSIONS` pointing at `dist-ext` (see the quick start) the
|
|
114
|
-
theme appears as a tile in Settings → Appearance
|
|
115
|
-
|
|
146
|
+
theme appears as a tile in Settings → Appearance; saving a file is picked up by
|
|
147
|
+
the running app. Change the id, the label and the colors; change `tags` and
|
|
116
148
|
`description` too, the catalog shows them.
|
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
# Recipe: visibility conditions and dependencies
|
|
1
|
+
# Recipe: visibility conditions, a widget and dependencies
|
|
2
2
|
|
|
3
|
-
Show a command
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
the commands.
|
|
3
|
+
Show a command or a panel only where it makes sense (`when`), draw a component
|
|
4
|
+
into a screen of the app (an injection), and require another extension
|
|
5
|
+
(`dependencies`). This recipe has no template of its own: start from `blank`
|
|
6
|
+
and replace the files below, which are checked as a whole project. See
|
|
7
|
+
[quick-start.md](quick-start.md) for the commands.
|
|
9
8
|
|
|
10
9
|
## The manifest
|
|
11
10
|
|
|
@@ -24,83 +23,146 @@ File `extension.json` (when-dependencies):
|
|
|
24
23
|
"dependencies": [
|
|
25
24
|
{ "id": "acme.cards", "range": ">=1.0.0 <2.0.0" },
|
|
26
25
|
{ "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
|
-
}
|
|
26
|
+
]
|
|
44
27
|
}
|
|
45
28
|
```
|
|
46
29
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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.
|
|
30
|
+
`dependencies` is the only part of this recipe that lives in the manifest: up to
|
|
31
|
+
16 extensions that must be present, enabled, loaded and in the version `range`
|
|
32
|
+
(comparators separated by a space, such as `>=1.0.0 <2.0.0`; no `^` or `~`).
|
|
33
|
+
Otherwise the extension is shown as "dependencies not met" in Settings →
|
|
34
|
+
Extensions, with the missing, disabled or mismatched one named, and contributes
|
|
35
|
+
nothing. Disabling or enabling a dependency updates the dependents at once.
|
|
36
|
+
|
|
37
|
+
Nothing installs a dependency for the user, and extensions cannot call each
|
|
38
|
+
other: a dependency only says "this must be there". A repeat and a dependency on
|
|
39
|
+
yourself are manifest errors; extensions that depend on each other in a circle
|
|
40
|
+
are not loaded.
|
|
69
41
|
|
|
70
42
|
## The code
|
|
71
43
|
|
|
72
44
|
File `src/index.ts` (when-dependencies):
|
|
73
45
|
|
|
74
46
|
```ts
|
|
75
|
-
|
|
76
|
-
|
|
47
|
+
export { client } from './client.ts';
|
|
48
|
+
export { server } from './server.ts';
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
File `src/server.ts` (when-dependencies):
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { defineServer } from '@dolphy-app/extension-sdk';
|
|
55
|
+
|
|
56
|
+
export const server = defineServer((s) => {
|
|
57
|
+
s.registerCommand({
|
|
58
|
+
id: 'acme.hello.report',
|
|
59
|
+
title: { en: 'Course report', ru: 'Отчёт по курсу' },
|
|
60
|
+
when: "route == 'courses' && course.active",
|
|
61
|
+
run: () => ({ courses: 1 }),
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
File `src/client.ts` (when-dependencies):
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { anchorSelector, defineClient } from '@dolphy-app/extension-sdk';
|
|
70
|
+
import { Board } from './board.ts';
|
|
71
|
+
import { PlanNote } from './plan-note.ts';
|
|
72
|
+
|
|
73
|
+
export const client = defineClient((c) => {
|
|
74
|
+
c.addPanel({
|
|
75
|
+
id: 'acme.hello.board',
|
|
76
|
+
title: { en: 'Course board', ru: 'Доска курса' },
|
|
77
|
+
when: 'course.active && !session.active',
|
|
78
|
+
component: Board,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// drawn inside the "Daily plan" screen, at the anchor the app keeps stable
|
|
82
|
+
c.addInjection({
|
|
83
|
+
id: 'acme.hello.plan-note',
|
|
84
|
+
target: anchorSelector('dailyPlan'),
|
|
85
|
+
component: PlanNote,
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
File `src/board.ts` (when-dependencies):
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
import { defineComponent, h } from 'vue';
|
|
77
94
|
|
|
78
|
-
export const
|
|
79
|
-
|
|
95
|
+
export const Board = defineComponent({
|
|
96
|
+
setup: () => () => h('p', 'Board'),
|
|
80
97
|
});
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
File `src/plan-note.ts` (when-dependencies):
|
|
81
101
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
102
|
+
```ts
|
|
103
|
+
import { useInjection } from '@dolphy-app/extension-sdk/client';
|
|
104
|
+
import { defineComponent, h } from 'vue';
|
|
105
|
+
|
|
106
|
+
// `useInjection()` tells the component where the app drew it
|
|
107
|
+
export const PlanNote = defineComponent({
|
|
108
|
+
setup() {
|
|
109
|
+
const injection = useInjection();
|
|
110
|
+
return () => h('p', `Drawn at the daily plan: ${injection.position}`);
|
|
111
|
+
},
|
|
112
|
+
});
|
|
89
113
|
```
|
|
90
114
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
115
|
+
- `when` is a boolean expression over five keys of the app window: `route`
|
|
116
|
+
(the name of the current screen, one of `WHEN_ROUTES`), `course.active` (one
|
|
117
|
+
course is in focus), `session.active` (a study session is open), `locale`
|
|
118
|
+
(`ru` or `en`) and `theme.dark`. Operators: `==`, `!=`, `in ('a', 'b')`, `&&`,
|
|
119
|
+
`||`, `!` and parentheses; strings are in single quotes. At most 200
|
|
120
|
+
characters. It goes on a command (`server.registerCommand`, also on one of its
|
|
121
|
+
`keybindings`), a client command (`client.addCommand`) or a panel
|
|
122
|
+
(`client.addPanel`). An unknown key or value, a wrong type or a syntax error
|
|
123
|
+
fails the registration with the position in the message.
|
|
124
|
+
- While the condition is false a command is not in the palette and its keys do
|
|
125
|
+
nothing, and a panel's menu item is hidden. The value follows the route, the
|
|
126
|
+
course, the session, the language and the theme without a reload. Your own code
|
|
127
|
+
still reaches the command (`panel.call`) and the panel (`openPanel`): `when`
|
|
128
|
+
hides, it does not forbid.
|
|
129
|
+
- `client.addInjection({ id, target, position?, component })` draws the
|
|
130
|
+
component at every element that matches the CSS selector `target`: `before` or
|
|
131
|
+
`after` it, or inside it as its first (`prepend`) or last (`append`, the
|
|
132
|
+
default) child. The window watches its DOM: the component is mounted when a
|
|
133
|
+
target appears and removed when it goes away or when the extension is
|
|
134
|
+
unloaded. An injection has no `when`: it exists where the target does.
|
|
135
|
+
- `anchorSelector('dailyPlan')` is `[data-ext-anchor="dailyPlan"]`, the place
|
|
136
|
+
the app marks in the "Daily plan" screen and keeps stable. Any other selector
|
|
137
|
+
depends on the markup of the app, which can change between versions, so the
|
|
138
|
+
injection may silently stop finding its target after an update; prefer an
|
|
139
|
+
anchor.
|
|
140
|
+
- The injected component runs in the app's own tree: `inject`, Vuetify, the
|
|
141
|
+
theme and the language work. A failure shows an error card in its place and
|
|
142
|
+
does not touch the rest of the window.
|
|
94
143
|
|
|
95
144
|
## The test
|
|
96
145
|
|
|
97
|
-
File `test/
|
|
146
|
+
File `test/index.test.ts` (when-dependencies):
|
|
98
147
|
|
|
99
148
|
```ts
|
|
100
|
-
|
|
149
|
+
// @vitest-environment happy-dom
|
|
150
|
+
import {
|
|
151
|
+
INJECTION_HANDLE_KEY,
|
|
152
|
+
anchorSelector,
|
|
153
|
+
evaluateWhen,
|
|
154
|
+
parseWhen,
|
|
155
|
+
} from '@dolphy-app/extension-sdk';
|
|
101
156
|
import type { WhenContext } from '@dolphy-app/extension-sdk';
|
|
157
|
+
import {
|
|
158
|
+
createTestClient,
|
|
159
|
+
createTestServer,
|
|
160
|
+
} from '@dolphy-app/extension-sdk/testing';
|
|
102
161
|
import { describe, expect, it } from 'vitest';
|
|
162
|
+
import { createApp, h } from 'vue';
|
|
103
163
|
import manifest from '../extension.json';
|
|
164
|
+
import { client, server } from '../src/index.ts';
|
|
165
|
+
import { PlanNote } from '../src/plan-note.ts';
|
|
104
166
|
|
|
105
167
|
const context = (overrides: Partial<WhenContext> = {}): WhenContext => ({
|
|
106
168
|
route: 'courses',
|
|
@@ -111,27 +173,35 @@ const context = (overrides: Partial<WhenContext> = {}): WhenContext => ({
|
|
|
111
173
|
...overrides,
|
|
112
174
|
});
|
|
113
175
|
|
|
114
|
-
const
|
|
115
|
-
|
|
176
|
+
const parsed = (when: string | null | undefined) => {
|
|
177
|
+
if (when === null || when === undefined) throw new Error('no condition');
|
|
178
|
+
return parseWhen(when);
|
|
179
|
+
};
|
|
116
180
|
|
|
117
181
|
describe('acme.hello: when', () => {
|
|
118
|
-
it('shows the report on the Courses screen with a course in focus', () => {
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
182
|
+
it('shows the report on the Courses screen with a course in focus', async () => {
|
|
183
|
+
const running = await createTestServer(server, {
|
|
184
|
+
extensionId: 'acme.hello',
|
|
185
|
+
});
|
|
186
|
+
const report = running.registration.commands.find(
|
|
187
|
+
({ id }) => id === 'acme.hello.report',
|
|
123
188
|
);
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
).toBe(false);
|
|
189
|
+
const when = parsed(report?.when);
|
|
190
|
+
expect(evaluateWhen(when, context())).toBe(true);
|
|
191
|
+
expect(evaluateWhen(when, context({ route: 'daily-plan' }))).toBe(false);
|
|
192
|
+
expect(evaluateWhen(when, context({ 'course.active': false }))).toBe(false);
|
|
193
|
+
await running.dispose();
|
|
127
194
|
});
|
|
128
195
|
|
|
129
|
-
it('hides the board during a study session', () => {
|
|
130
|
-
const
|
|
196
|
+
it('hides the board during a study session', async () => {
|
|
197
|
+
const running = await createTestClient(client, {
|
|
198
|
+
extensionId: 'acme.hello',
|
|
199
|
+
});
|
|
200
|
+
const board = running.panels.find(({ id }) => id === 'acme.hello.board');
|
|
201
|
+
const when = parsed(board?.when);
|
|
131
202
|
expect(evaluateWhen(when, context())).toBe(true);
|
|
132
|
-
expect(
|
|
133
|
-
|
|
134
|
-
).toBe(false);
|
|
203
|
+
expect(evaluateWhen(when, context({ 'session.active': true }))).toBe(false);
|
|
204
|
+
await running.dispose();
|
|
135
205
|
});
|
|
136
206
|
|
|
137
207
|
it('reports a typo in a key with its position', () => {
|
|
@@ -139,6 +209,35 @@ describe('acme.hello: when', () => {
|
|
|
139
209
|
});
|
|
140
210
|
});
|
|
141
211
|
|
|
212
|
+
describe('acme.hello: injection', () => {
|
|
213
|
+
it('is drawn at the anchor of the daily plan', async () => {
|
|
214
|
+
const running = await createTestClient(client, {
|
|
215
|
+
extensionId: 'acme.hello',
|
|
216
|
+
});
|
|
217
|
+
expect(running.injections).toEqual([
|
|
218
|
+
{
|
|
219
|
+
id: 'acme.hello.plan-note',
|
|
220
|
+
target: anchorSelector('dailyPlan'),
|
|
221
|
+
position: 'append',
|
|
222
|
+
component: PlanNote,
|
|
223
|
+
},
|
|
224
|
+
]);
|
|
225
|
+
await running.dispose();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('the component reads where the app drew it', () => {
|
|
229
|
+
const host = document.createElement('div');
|
|
230
|
+
const app = createApp({ render: () => h(PlanNote) });
|
|
231
|
+
app.provide(INJECTION_HANDLE_KEY, {
|
|
232
|
+
target: document.createElement('div'),
|
|
233
|
+
position: 'append',
|
|
234
|
+
});
|
|
235
|
+
app.mount(host);
|
|
236
|
+
expect(host.textContent).toBe('Drawn at the daily plan: append');
|
|
237
|
+
app.unmount();
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
142
241
|
describe('acme.hello: dependencies', () => {
|
|
143
242
|
it('names each required extension once, and not itself', () => {
|
|
144
243
|
const ids = manifest.dependencies.map(({ id }) => id);
|
|
@@ -148,11 +247,18 @@ describe('acme.hello: dependencies', () => {
|
|
|
148
247
|
});
|
|
149
248
|
```
|
|
150
249
|
|
|
151
|
-
`
|
|
152
|
-
|
|
250
|
+
`createTestServer` and `createTestClient` hand the test what the code
|
|
251
|
+
registered: `running.registration.commands` for the server (with `when` as
|
|
252
|
+
registered) and `running.panels` and `running.injections` for the client. The
|
|
253
|
+
test feeds that text to `parseWhen` and `evaluateWhen`, the functions the app
|
|
254
|
+
uses; `evaluateWhen` is pure, so pass an object with the five keys. The
|
|
255
|
+
dependencies are manifest data, so the test reads `extension.json`.
|
|
153
256
|
|
|
154
257
|
## Try and ship
|
|
155
258
|
|
|
156
|
-
Run `pnpm validate
|
|
157
|
-
|
|
158
|
-
|
|
259
|
+
Run `pnpm build` and `pnpm validate`, which fails on a bad `dependencies`
|
|
260
|
+
entry. The test parses every `when`, so `pnpm test` catches a typo before the
|
|
261
|
+
app does (the app fails the registration with the position). In the app the
|
|
262
|
+
extension's row in Settings → Extensions shows "dependencies not met" until
|
|
263
|
+
`acme.cards` is installed and enabled. Open the Daily plan screen to see the
|
|
264
|
+
injected note.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dolphy-app/extension-sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "SDK for extension authors:
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "SDK for extension authors: defineServer, defineClient, defineRpc, usePanel, useInjection, useApp, useEngine, useRpc, defineMountable, callRpc, reactComponent, test harness",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
7
7
|
"exports": {
|
|
@@ -9,13 +9,21 @@
|
|
|
9
9
|
"types": "./dist/index.d.ts",
|
|
10
10
|
"default": "./dist/index.js"
|
|
11
11
|
},
|
|
12
|
-
"./
|
|
13
|
-
"types": "./dist/
|
|
14
|
-
"default": "./dist/
|
|
12
|
+
"./client": {
|
|
13
|
+
"types": "./dist/client.d.ts",
|
|
14
|
+
"default": "./dist/client.js"
|
|
15
|
+
},
|
|
16
|
+
"./rpc": {
|
|
17
|
+
"types": "./dist/rpc.d.ts",
|
|
18
|
+
"default": "./dist/rpc.js"
|
|
15
19
|
},
|
|
16
20
|
"./testing": {
|
|
17
21
|
"types": "./dist/testing.d.ts",
|
|
18
22
|
"default": "./dist/testing.js"
|
|
23
|
+
},
|
|
24
|
+
"./react": {
|
|
25
|
+
"types": "./dist/react.d.ts",
|
|
26
|
+
"default": "./dist/react.js"
|
|
19
27
|
}
|
|
20
28
|
},
|
|
21
29
|
"types": "./dist/index.d.ts",
|
|
@@ -24,8 +32,22 @@
|
|
|
24
32
|
"docs"
|
|
25
33
|
],
|
|
26
34
|
"dependencies": {
|
|
27
|
-
"@dolphy-app/extension-api": "0.
|
|
28
|
-
"ajv": "^8"
|
|
35
|
+
"@dolphy-app/extension-api": "0.5.0",
|
|
36
|
+
"ajv": "^8",
|
|
37
|
+
"zod": "4.6.5"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"vue": "^3.5",
|
|
41
|
+
"react": "^19",
|
|
42
|
+
"react-dom": "^19"
|
|
43
|
+
},
|
|
44
|
+
"peerDependenciesMeta": {
|
|
45
|
+
"react": {
|
|
46
|
+
"optional": true
|
|
47
|
+
},
|
|
48
|
+
"react-dom": {
|
|
49
|
+
"optional": true
|
|
50
|
+
}
|
|
29
51
|
},
|
|
30
52
|
"engines": {
|
|
31
53
|
"node": ">=22.12"
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
import { ANSWER_EVENT, ELEMENT_NAME_PATTERN } from "@dolphy-app/extension-api";
|
|
2
|
-
|
|
3
|
-
//#region packages/extension-sdk/src/answer-element.ts
|
|
4
|
-
const logFailure = (tag, message, error) => {
|
|
5
|
-
console.error({
|
|
6
|
-
error,
|
|
7
|
-
tag
|
|
8
|
-
}, message);
|
|
9
|
-
};
|
|
10
|
-
const createAnswerElementClass = (tag, answerView) => class AnswerElement extends HTMLElement {
|
|
11
|
-
#root = this.attachShadow({ mode: "open" });
|
|
12
|
-
#props = {
|
|
13
|
-
view: void 0,
|
|
14
|
-
value: void 0,
|
|
15
|
-
disabled: false,
|
|
16
|
-
verdict: null
|
|
17
|
-
};
|
|
18
|
-
#instance = null;
|
|
19
|
-
#isFlushScheduled = false;
|
|
20
|
-
get view() {
|
|
21
|
-
return this.#props.view;
|
|
22
|
-
}
|
|
23
|
-
set view(view) {
|
|
24
|
-
this.#change({ view });
|
|
25
|
-
}
|
|
26
|
-
get value() {
|
|
27
|
-
return this.#props.value;
|
|
28
|
-
}
|
|
29
|
-
set value(value) {
|
|
30
|
-
this.#change({ value });
|
|
31
|
-
}
|
|
32
|
-
get disabled() {
|
|
33
|
-
return this.#props.disabled;
|
|
34
|
-
}
|
|
35
|
-
set disabled(disabled) {
|
|
36
|
-
this.#change({ disabled });
|
|
37
|
-
}
|
|
38
|
-
get verdict() {
|
|
39
|
-
return this.#props.verdict;
|
|
40
|
-
}
|
|
41
|
-
set verdict(verdict) {
|
|
42
|
-
this.#change({ verdict });
|
|
43
|
-
}
|
|
44
|
-
connectedCallback() {
|
|
45
|
-
if (this.#instance !== null) return;
|
|
46
|
-
const readLabel = () => this.getAttribute("aria-label");
|
|
47
|
-
const api = {
|
|
48
|
-
root: this.#root,
|
|
49
|
-
get label() {
|
|
50
|
-
return readLabel();
|
|
51
|
-
},
|
|
52
|
-
setAnswer: (value, complete) => {
|
|
53
|
-
const detail = {
|
|
54
|
-
value,
|
|
55
|
-
complete
|
|
56
|
-
};
|
|
57
|
-
this.#emit(ANSWER_EVENT.change, detail);
|
|
58
|
-
},
|
|
59
|
-
submit: () => this.#emit(ANSWER_EVENT.submit, void 0)
|
|
60
|
-
};
|
|
61
|
-
try {
|
|
62
|
-
this.#instance = answerView.mount(api, Object.freeze({ ...this.#props }));
|
|
63
|
-
} catch (error) {
|
|
64
|
-
logFailure(tag, "answer element failed to mount", error);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
disconnectedCallback() {
|
|
68
|
-
const instance = this.#instance;
|
|
69
|
-
this.#instance = null;
|
|
70
|
-
if (instance === null) return;
|
|
71
|
-
try {
|
|
72
|
-
instance.destroy?.();
|
|
73
|
-
} catch (error) {
|
|
74
|
-
logFailure(tag, "answer element failed to destroy", error);
|
|
75
|
-
}
|
|
76
|
-
this.#root.replaceChildren();
|
|
77
|
-
}
|
|
78
|
-
#change(patch) {
|
|
79
|
-
this.#props = {
|
|
80
|
-
...this.#props,
|
|
81
|
-
...patch
|
|
82
|
-
};
|
|
83
|
-
if (this.#instance === null || this.#isFlushScheduled) return;
|
|
84
|
-
this.#isFlushScheduled = true;
|
|
85
|
-
queueMicrotask(() => this.#flush());
|
|
86
|
-
}
|
|
87
|
-
#flush() {
|
|
88
|
-
this.#isFlushScheduled = false;
|
|
89
|
-
const instance = this.#instance;
|
|
90
|
-
if (instance === null) return;
|
|
91
|
-
try {
|
|
92
|
-
instance.update(Object.freeze({ ...this.#props }));
|
|
93
|
-
} catch (error) {
|
|
94
|
-
logFailure(tag, "answer element failed to update", error);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
#emit(name, detail) {
|
|
98
|
-
this.dispatchEvent(new CustomEvent(name, {
|
|
99
|
-
detail,
|
|
100
|
-
bubbles: true,
|
|
101
|
-
composed: true
|
|
102
|
-
}));
|
|
103
|
-
}
|
|
104
|
-
};
|
|
105
|
-
/** Defines the kind's custom element; calling again with the same tag changes nothing. */
|
|
106
|
-
const registerAnswerView = (tag, view) => {
|
|
107
|
-
if (!ELEMENT_NAME_PATTERN.test(tag)) throw new TypeError(`invalid custom element name '${tag}'`);
|
|
108
|
-
if (typeof view?.mount !== "function") throw new TypeError(`answer view for '${tag}' has no mount()`);
|
|
109
|
-
if (customElements.get(tag) !== void 0) return;
|
|
110
|
-
customElements.define(tag, createAnswerElementClass(tag, view));
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
//#endregion
|
|
114
|
-
export { registerAnswerView as n, createAnswerElementClass as t };
|