@dolphy-app/extension-sdk 0.3.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.
@@ -0,0 +1,264 @@
1
+ # Recipe: visibility conditions, a widget and dependencies
2
+
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.
8
+
9
+ ## The manifest
10
+
11
+ File `extension.json` (when-dependencies):
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 report",
20
+ "description": "A report command that appears only on the Courses screen.",
21
+ "author": "your-github-login",
22
+ "tags": ["productivity"],
23
+ "dependencies": [
24
+ { "id": "acme.cards", "range": ">=1.0.0 <2.0.0" },
25
+ { "id": "dolphy.choice" }
26
+ ]
27
+ }
28
+ ```
29
+
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.
41
+
42
+ ## The code
43
+
44
+ File `src/index.ts` (when-dependencies):
45
+
46
+ ```ts
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';
94
+
95
+ export const Board = defineComponent({
96
+ setup: () => () => h('p', 'Board'),
97
+ });
98
+ ```
99
+
100
+ File `src/plan-note.ts` (when-dependencies):
101
+
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
+ });
113
+ ```
114
+
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.
143
+
144
+ ## The test
145
+
146
+ File `test/index.test.ts` (when-dependencies):
147
+
148
+ ```ts
149
+ // @vitest-environment happy-dom
150
+ import {
151
+ INJECTION_HANDLE_KEY,
152
+ anchorSelector,
153
+ evaluateWhen,
154
+ parseWhen,
155
+ } from '@dolphy-app/extension-sdk';
156
+ import type { WhenContext } from '@dolphy-app/extension-sdk';
157
+ import {
158
+ createTestClient,
159
+ createTestServer,
160
+ } from '@dolphy-app/extension-sdk/testing';
161
+ import { describe, expect, it } from 'vitest';
162
+ import { createApp, h } from 'vue';
163
+ import manifest from '../extension.json';
164
+ import { client, server } from '../src/index.ts';
165
+ import { PlanNote } from '../src/plan-note.ts';
166
+
167
+ const context = (overrides: Partial<WhenContext> = {}): WhenContext => ({
168
+ route: 'courses',
169
+ 'course.active': true,
170
+ 'session.active': false,
171
+ locale: 'en',
172
+ 'theme.dark': false,
173
+ ...overrides,
174
+ });
175
+
176
+ const parsed = (when: string | null | undefined) => {
177
+ if (when === null || when === undefined) throw new Error('no condition');
178
+ return parseWhen(when);
179
+ };
180
+
181
+ describe('acme.hello: when', () => {
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',
188
+ );
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();
194
+ });
195
+
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);
202
+ expect(evaluateWhen(when, context())).toBe(true);
203
+ expect(evaluateWhen(when, context({ 'session.active': true }))).toBe(false);
204
+ await running.dispose();
205
+ });
206
+
207
+ it('reports a typo in a key with its position', () => {
208
+ expect(() => parseWhen("rout == 'courses'")).toThrow();
209
+ });
210
+ });
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
+
241
+ describe('acme.hello: dependencies', () => {
242
+ it('names each required extension once, and not itself', () => {
243
+ const ids = manifest.dependencies.map(({ id }) => id);
244
+ expect(new Set(ids).size).toBe(ids.length);
245
+ expect(ids).not.toContain(manifest.id);
246
+ });
247
+ });
248
+ ```
249
+
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`.
256
+
257
+ ## Try and ship
258
+
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.3.0",
4
- "description": "SDK for extension authors: defineExtension, defineAnswerView, test helpers",
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,22 +9,45 @@
9
9
  "types": "./dist/index.d.ts",
10
10
  "default": "./dist/index.js"
11
11
  },
12
- "./runtime": {
13
- "types": "./dist/runtime.d.ts",
14
- "default": "./dist/runtime.js"
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",
22
30
  "files": [
23
- "dist"
31
+ "dist",
32
+ "docs"
24
33
  ],
25
34
  "dependencies": {
26
- "@dolphy-app/extension-api": "0.3.0",
27
- "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
+ }
28
51
  },
29
52
  "engines": {
30
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 };
@@ -1,28 +0,0 @@
1
- import { AnswerElementProps } from "@dolphy-app/extension-api";
2
- //#region packages/extension-sdk/src/answer-view.d.ts
3
- interface AnswerViewApi {
4
- readonly root: ShadowRoot;
5
- /** `aria-label` of the host element set by the app; `null` if none. */
6
- readonly label: string | null;
7
- /** Reports the current answer to the app: the `dolphy-answer-change` event. */
8
- setAnswer(value: unknown, complete: boolean): void;
9
- /** Asks the app to submit the answer: the `dolphy-answer-submit` event. */
10
- submit(): void;
11
- }
12
- interface AnswerViewInstance {
13
- /** Called when `view`/`value`/`disabled`/`verdict` change. */
14
- update(props: AnswerElementProps): void;
15
- destroy?(): void;
16
- }
17
- type MountAnswerView = (api: AnswerViewApi, props: AnswerElementProps) => AnswerViewInstance;
18
- /** Description of an answer input view: side-effect-free data; the build registers the element. */
19
- interface AnswerView {
20
- readonly mount: MountAnswerView;
21
- }
22
- /**
23
- * Entry `views[<exercise kind id>]` in `src/index.ts`. Registers nothing:
24
- * the custom element with the manifest tag is defined by the build's browser file.
25
- */
26
- declare const defineAnswerView: (mount: MountAnswerView) => AnswerView;
27
- //#endregion
28
- export { defineAnswerView as a, MountAnswerView as i, AnswerViewApi as n, AnswerViewInstance as r, AnswerView as t };
package/dist/runtime.d.ts DELETED
@@ -1,14 +0,0 @@
1
- import { t as AnswerView } from "./answer-view-D6wnyThb.js";
2
- import { MarkdownRendererModule, PanelModule } from "@dolphy-app/extension-api";
3
- //#region packages/extension-sdk/src/answer-element.d.ts
4
- /** Defines the kind's custom element; calling again with the same tag changes nothing. */
5
- export declare const registerAnswerView: (tag: string, view: AnswerView) => void;
6
- //#endregion
7
- //#region packages/extension-sdk/src/runtime.d.ts
8
- type PanelEntry = PanelModule<HTMLElement>;
9
- type MarkdownEntry = MarkdownRendererModule<HTMLElement>;
10
- /** Panel module that selects the `panels` entry by `ctx.panelId` (for a file shared by several panels). */
11
- export declare const dispatchPanels: (panels: Readonly<Record<string, PanelEntry>>) => PanelEntry;
12
- /** Renderer module that selects the `markdown` entry by block language. */
13
- export declare const dispatchMarkdown: (renderers: Readonly<Record<string, MarkdownEntry>>) => MarkdownEntry;
14
- //#endregion
package/dist/runtime.js DELETED
@@ -1,18 +0,0 @@
1
- import { n as registerAnswerView } from "./answer-element-BOQcuxYh.js";
2
-
3
- //#region packages/extension-sdk/src/runtime.ts
4
- /** Panel module that selects the `panels` entry by `ctx.panelId` (for a file shared by several panels). */
5
- const dispatchPanels = (panels) => ({ mount(container, context) {
6
- const panel = panels[context.panelId];
7
- if (panel === void 0) throw new Error(`panel '${context.panelId}' is not exported`);
8
- return panel.mount(container, context);
9
- } });
10
- /** Renderer module that selects the `markdown` entry by block language. */
11
- const dispatchMarkdown = (renderers) => ({ render(source, container, context) {
12
- const renderer = renderers[context.language];
13
- if (renderer === void 0) throw new Error(`markdown renderer '${context.language}' is not exported`);
14
- return renderer.render(source, container, context);
15
- } });
16
-
17
- //#endregion
18
- export { dispatchMarkdown, dispatchPanels, registerAnswerView };