@appsoftwareltd/etherpk-extension-api 0.0.0-stage → 0.9.6
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/LICENSE +21 -0
- package/README.md +73 -2
- package/dist/context.d.ts +291 -0
- package/dist/context.js +1 -0
- package/dist/events.d.ts +61 -0
- package/dist/events.js +5 -0
- package/dist/graph-index.d.ts +103 -0
- package/dist/graph-index.js +6 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +15 -0
- package/dist/manifest.d.ts +153 -0
- package/dist/manifest.js +173 -0
- package/dist/menus.d.ts +161 -0
- package/dist/menus.js +5 -0
- package/dist/views.d.ts +69 -0
- package/dist/views.js +5 -0
- package/package.json +39 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 App Software Ltd
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,74 @@
|
|
|
1
|
-
#
|
|
1
|
+
# EtherPK Extension API
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The types an [EtherPK](https://etherpk.com) extension is written against, and the function that
|
|
4
|
+
checks an extension's manifest the way the EtherPK Client does. MIT licensed.
|
|
5
|
+
|
|
6
|
+
An extension is an npm package. Its package.json carries an `etherpk` manifest, and its main
|
|
7
|
+
module exports `activate(context)`, which the Client calls as each graph opens:
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import type { ExtensionContext } from '@appsoftwareltd/etherpk-extension-api'
|
|
11
|
+
|
|
12
|
+
export function activate(context: ExtensionContext): void {
|
|
13
|
+
context.commands.register('hello.greet', () => context.notify('Hello from an extension.'))
|
|
14
|
+
}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"name": "etherpk-extension-hello",
|
|
20
|
+
"version": "1.0.0",
|
|
21
|
+
"etherpk": {
|
|
22
|
+
"id": "hello",
|
|
23
|
+
"displayName": "Hello",
|
|
24
|
+
"publisher": "You",
|
|
25
|
+
"minClientVersion": "0.8.54",
|
|
26
|
+
"main": "./dist/main.js"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Everything here is Proposed (`@beta` in the type declarations) and may change in any release until
|
|
32
|
+
extensions from other publishers can be installed. From then on, a Stable declaration (`@public`)
|
|
33
|
+
never breaks. `etc/` holds the API reports CI checks every change against.
|
|
34
|
+
|
|
35
|
+
## Settings
|
|
36
|
+
|
|
37
|
+
A value the person sets, such as a key for a service, is declared in the manifest, and the Client
|
|
38
|
+
draws it under the extension's entry in Settings and Extensions → Extensions:
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
"settings": [
|
|
42
|
+
{
|
|
43
|
+
"id": "api-key",
|
|
44
|
+
"type": "secret",
|
|
45
|
+
"title": "Weather service key",
|
|
46
|
+
"description": "Forecasts come from your own account with the weather service.",
|
|
47
|
+
"placeholder": "wk_…",
|
|
48
|
+
"link": { "title": "Getting a key", "url": "https://weather.example.com/keys" }
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`type` is `text`, `secret` or `boolean`. A secret stays masked until the person shows it. A
|
|
54
|
+
`boolean` is a switch, which may name its starting position with `"default": true`. The person can
|
|
55
|
+
set them while the extension is switched off. The extension reads the value with
|
|
56
|
+
`context.settings.get('api-key')`, which is undefined until it is set, and hears every change with
|
|
57
|
+
`context.settings.subscribe`. A switch always reads `'true'` or `'false'`, its default until the
|
|
58
|
+
person changes it. Settings belong to the person rather than a graph. Where their
|
|
59
|
+
account syncs a graph, they follow the person to their other devices, encrypted so only those
|
|
60
|
+
devices can read them.
|
|
61
|
+
|
|
62
|
+
## Writing to the page
|
|
63
|
+
|
|
64
|
+
The Client enforces [Trusted Types](https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API),
|
|
65
|
+
and extension code runs under it:
|
|
66
|
+
|
|
67
|
+
- **An extension cannot create a Trusted Types policy.** The Client's Content Security Policy names
|
|
68
|
+
the policies it allows, and each belongs to the Client or a library it ships.
|
|
69
|
+
- **Write no HTML.** Build elements with the DOM (`createElement`, `createElementNS`,
|
|
70
|
+
`textContent`). A string written to an HTML sink such as `innerHTML` is sanitized by the Client's
|
|
71
|
+
default policy, which may remove what you wrote.
|
|
72
|
+
- **Draw icons with `context.icons.svg`.** It returns `IconMarkup`, which Svelte's `{@html}` takes
|
|
73
|
+
as it is. The icons your manifest adds are sanitized as SVG before they are drawn, so they can
|
|
74
|
+
hold only SVG.
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The context an extension is started with: one open graph, as far as an extension may reach it.
|
|
3
|
+
*
|
|
4
|
+
* Everything an extension adds through it sits under the extension's id and is removed when the
|
|
5
|
+
* graph closes or the extension is switched off, so an extension never has to undo its own
|
|
6
|
+
* registrations. It holds no document store, no keys and no sync: an extension reads documents
|
|
7
|
+
* only through the index's answers, and changes them only by running the Client's Commands.
|
|
8
|
+
*/
|
|
9
|
+
import type { ExtensionEvents } from './events';
|
|
10
|
+
import type { ExtensionIndex } from './graph-index';
|
|
11
|
+
import type { CommandMenuItem, ContextMenuItem, GraphSidebarButton } from './menus';
|
|
12
|
+
import type { ViewContribution, ViewRef } from './views';
|
|
13
|
+
/**
|
|
14
|
+
* A Command's handler. What it returns, or resolves to, is what `execute` resolves to.
|
|
15
|
+
*
|
|
16
|
+
* @beta
|
|
17
|
+
*/
|
|
18
|
+
export type CommandHandler = (arg?: unknown) => unknown;
|
|
19
|
+
/**
|
|
20
|
+
* Running and adding Commands.
|
|
21
|
+
*
|
|
22
|
+
* @beta
|
|
23
|
+
*/
|
|
24
|
+
export interface ExtensionCommands {
|
|
25
|
+
/** Add a Command, its id under the extension's id. Throws if the id is taken or outside it. */
|
|
26
|
+
register(id: string, handler: CommandHandler): () => void;
|
|
27
|
+
/** Run any Command, the Client's or an extension's. Rejects if no Command has the id. */
|
|
28
|
+
execute(id: string, arg?: unknown): Promise<unknown>;
|
|
29
|
+
/** Whether any Command has the id. */
|
|
30
|
+
has(id: string): boolean;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Supplying the Views the manifest declares.
|
|
34
|
+
*
|
|
35
|
+
* @beta
|
|
36
|
+
*/
|
|
37
|
+
export interface ExtensionViews {
|
|
38
|
+
/**
|
|
39
|
+
* Supply the View for a kind the manifest declares, so its tabs draw. Throws for a kind the
|
|
40
|
+
* manifest does not declare.
|
|
41
|
+
*/
|
|
42
|
+
register(kind: string, view: ViewContribution): () => void;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Adding Context Menu rows.
|
|
46
|
+
*
|
|
47
|
+
* @beta
|
|
48
|
+
*/
|
|
49
|
+
export interface ExtensionContextMenu {
|
|
50
|
+
/** Add a row, its id under the extension's id. */
|
|
51
|
+
register(item: ContextMenuItem): () => void;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Adding Command Menu rows.
|
|
55
|
+
*
|
|
56
|
+
* @beta
|
|
57
|
+
*/
|
|
58
|
+
export interface ExtensionCommandMenu {
|
|
59
|
+
/** Add a row, its id under the extension's id. */
|
|
60
|
+
register(item: CommandMenuItem): () => void;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Adding buttons to the Graph Sidebar, under Today's journal.
|
|
64
|
+
*
|
|
65
|
+
* @beta
|
|
66
|
+
*/
|
|
67
|
+
export interface ExtensionGraphSidebar {
|
|
68
|
+
/** Add a button, its id under the extension's id. */
|
|
69
|
+
register(button: GraphSidebarButton): () => void;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* A key chord that runs a Command.
|
|
73
|
+
*
|
|
74
|
+
* @beta
|
|
75
|
+
*/
|
|
76
|
+
export interface Keybinding {
|
|
77
|
+
/** `Alt+M`, `Mod+Shift+K`: `Mod` is Cmd on Apple devices and Ctrl elsewhere. */
|
|
78
|
+
key: string;
|
|
79
|
+
/** The Command it runs. */
|
|
80
|
+
command: string;
|
|
81
|
+
/** What the keyboard shortcuts card lists it as. */
|
|
82
|
+
label: string;
|
|
83
|
+
/** The card's heading it is listed under. */
|
|
84
|
+
group?: string;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Adding key chords.
|
|
88
|
+
*
|
|
89
|
+
* @beta
|
|
90
|
+
*/
|
|
91
|
+
export interface ExtensionKeybindings {
|
|
92
|
+
/** Add a chord. Throws if the Client or another extension already binds it. */
|
|
93
|
+
register(binding: Keybinding): () => void;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Opening Views and documents in the open graph's Layout.
|
|
97
|
+
*
|
|
98
|
+
* @beta
|
|
99
|
+
*/
|
|
100
|
+
export interface ExtensionLayout {
|
|
101
|
+
/**
|
|
102
|
+
* Open a View. With `inPaneOf`, a panel id, it opens as a tab in that panel's Pane. With
|
|
103
|
+
* `beside`, it opens in another Pane beside that panel, so both show at once. Otherwise it
|
|
104
|
+
* opens where its kind belongs, or comes to the front if it is open already.
|
|
105
|
+
*/
|
|
106
|
+
openView(view: ViewRef, options?: {
|
|
107
|
+
inPaneOf?: string;
|
|
108
|
+
beside?: string;
|
|
109
|
+
}): void;
|
|
110
|
+
/** Bring a Sidebar resident to the front of its expanded Sidebar. */
|
|
111
|
+
reveal(kind: string): void;
|
|
112
|
+
/**
|
|
113
|
+
* Open the document of a concept, under the name it resolves to, as following a wikilink
|
|
114
|
+
* does. `line` scrolls to and selects that line, `inPaneOf` and `beside` place it as for
|
|
115
|
+
* {@link ExtensionLayout.openView}.
|
|
116
|
+
*/
|
|
117
|
+
openDocument(concept: string, options?: {
|
|
118
|
+
line?: number;
|
|
119
|
+
inPaneOf?: string;
|
|
120
|
+
beside?: string;
|
|
121
|
+
}): void;
|
|
122
|
+
/** The document the user is working in, or null. */
|
|
123
|
+
activeDocument(): string | null;
|
|
124
|
+
/** True while the desktop presenter is showing, 1024 pixels wide and more. */
|
|
125
|
+
isDesktop(): boolean;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The rules for naming a concept, which every index answer follows.
|
|
129
|
+
*
|
|
130
|
+
* @beta
|
|
131
|
+
*/
|
|
132
|
+
export interface ExtensionConcepts {
|
|
133
|
+
/** The case-insensitive key of a name. */
|
|
134
|
+
key(name: string): string;
|
|
135
|
+
/** The name a concept resolves to: an alias's page, a page's own title, or the name itself. */
|
|
136
|
+
canonicalName(name: string): string;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The extension's own storage on this device, under `etherpk-<extension id>:` in the browser's
|
|
140
|
+
* localStorage. Values are stored as JSON.
|
|
141
|
+
*
|
|
142
|
+
* @beta
|
|
143
|
+
*/
|
|
144
|
+
export interface ExtensionStorage {
|
|
145
|
+
/** The value stored under `key`, or undefined when there is none or it cannot be read. */
|
|
146
|
+
get<T>(key: string): T | undefined;
|
|
147
|
+
/** Store a value. A browser that refuses storage (a private window) keeps nothing, silently. */
|
|
148
|
+
set(key: string, value: unknown): void;
|
|
149
|
+
/** Forget the value under `key`. */
|
|
150
|
+
remove(key: string): void;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The person's answers to the settings the extension's manifest declares (`settings`, ADR 0134).
|
|
154
|
+
* The Client draws them under the extension's entry in Settings and Extensions → Extensions, and
|
|
155
|
+
* keeps them with the person: on this device, and in their account where it syncs a graph, so they
|
|
156
|
+
* follow the person to their other devices. The extension only reads them.
|
|
157
|
+
*
|
|
158
|
+
* @beta
|
|
159
|
+
*/
|
|
160
|
+
export interface ExtensionSettings {
|
|
161
|
+
/**
|
|
162
|
+
* A setting's value, by its id in the manifest, or undefined when the person has not set it. A
|
|
163
|
+
* `boolean` setting is always `'true'` or `'false'`: its manifest's `default` until the person
|
|
164
|
+
* changes it.
|
|
165
|
+
*/
|
|
166
|
+
get(id: string): string | undefined;
|
|
167
|
+
/**
|
|
168
|
+
* Hear every change to the person's settings, made on this device or arriving from another.
|
|
169
|
+
* Returns the way to stop.
|
|
170
|
+
*/
|
|
171
|
+
subscribe(listener: () => void): () => void;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* The Client's light or dark theme.
|
|
175
|
+
*
|
|
176
|
+
* @beta
|
|
177
|
+
*/
|
|
178
|
+
export interface ExtensionTheme {
|
|
179
|
+
/** True while the dark theme shows. The `theme:changed` Event reports a switch. */
|
|
180
|
+
isDark(): boolean;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* SVG markup the Client made, for an HTML sink such as Svelte's `{@html}`. It is not a string, so a
|
|
184
|
+
* string cannot stand in for it. The Client enforces Trusted Types, and this is trusted markup there:
|
|
185
|
+
* it goes into the page as it is. A string built from it is a plain string again, which the
|
|
186
|
+
* Client's default policy sanitizes.
|
|
187
|
+
*
|
|
188
|
+
* @beta
|
|
189
|
+
*/
|
|
190
|
+
export interface IconMarkup {
|
|
191
|
+
/** Never present at run time. It marks the type, so a plain string is not one. */
|
|
192
|
+
readonly __brand: 'IconMarkup';
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Drawing the Client's icons and the extension's own.
|
|
196
|
+
*
|
|
197
|
+
* @beta
|
|
198
|
+
*/
|
|
199
|
+
export interface ExtensionIcons {
|
|
200
|
+
/**
|
|
201
|
+
* An icon as SVG markup: the extension's own, by the name its manifest gives it, or one of the
|
|
202
|
+
* Client's. An unknown name draws a small dot. The Client sanitizes an extension's own icon
|
|
203
|
+
* before it draws it, so its markup can hold only SVG.
|
|
204
|
+
*/
|
|
205
|
+
svg(name: string, options?: {
|
|
206
|
+
size?: number;
|
|
207
|
+
strokeWidth?: number;
|
|
208
|
+
label?: string;
|
|
209
|
+
}): IconMarkup;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* One registry of every Contribution Point, by kind, for the kinds the typed parts of the context
|
|
213
|
+
* do not cover. Only a Built-in Extension uses it.
|
|
214
|
+
*
|
|
215
|
+
* @beta
|
|
216
|
+
*/
|
|
217
|
+
export interface ExtensionContributions {
|
|
218
|
+
/** Add a contribution. Throws if the kind already holds the id. */
|
|
219
|
+
register(kind: string, id: string, value: unknown): () => void;
|
|
220
|
+
/** Remove one of the extension's own contributions. */
|
|
221
|
+
unregister(kind: string, id: string): void;
|
|
222
|
+
/** The contribution at (`kind`, `id`), anyone's, or undefined. */
|
|
223
|
+
get(kind: string, id: string): unknown;
|
|
224
|
+
/** Whether anyone has a contribution at (`kind`, `id`). */
|
|
225
|
+
has(kind: string, id: string): boolean;
|
|
226
|
+
/** Every contribution of a kind, the extension's and everyone else's, in the order added. */
|
|
227
|
+
list(kind: string): {
|
|
228
|
+
id: string;
|
|
229
|
+
value: unknown;
|
|
230
|
+
}[];
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* What an extension is started with, once for each graph that opens.
|
|
234
|
+
*
|
|
235
|
+
* @beta
|
|
236
|
+
*/
|
|
237
|
+
export interface ExtensionContext {
|
|
238
|
+
/** The extension's id, from its manifest. */
|
|
239
|
+
readonly extensionId: string;
|
|
240
|
+
/** The open graph. */
|
|
241
|
+
readonly graphId: string;
|
|
242
|
+
/** The Client's version, `x.y.z`. */
|
|
243
|
+
readonly clientVersion: string;
|
|
244
|
+
/** True in a development build of the Client, where tests may reach in. */
|
|
245
|
+
readonly dev: boolean;
|
|
246
|
+
/** Supplying the Views the manifest declares. */
|
|
247
|
+
readonly views: ExtensionViews;
|
|
248
|
+
/** Running and adding Commands. */
|
|
249
|
+
readonly commands: ExtensionCommands;
|
|
250
|
+
/** Adding Context Menu rows. */
|
|
251
|
+
readonly contextMenu: ExtensionContextMenu;
|
|
252
|
+
/** Adding Command Menu rows. */
|
|
253
|
+
readonly commandMenu: ExtensionCommandMenu;
|
|
254
|
+
/** Adding buttons to the Graph Sidebar. */
|
|
255
|
+
readonly graphSidebar: ExtensionGraphSidebar;
|
|
256
|
+
/** Adding key chords. */
|
|
257
|
+
readonly keybindings: ExtensionKeybindings;
|
|
258
|
+
/** Every Contribution Point by kind, for the kinds the parts above do not cover. */
|
|
259
|
+
readonly contributions: ExtensionContributions;
|
|
260
|
+
/** Listening to Events. */
|
|
261
|
+
readonly events: ExtensionEvents;
|
|
262
|
+
/** Questions to the open graph's index. */
|
|
263
|
+
readonly index: ExtensionIndex;
|
|
264
|
+
/** Opening Views and documents. */
|
|
265
|
+
readonly layout: ExtensionLayout;
|
|
266
|
+
/** The rules for naming a concept. */
|
|
267
|
+
readonly concepts: ExtensionConcepts;
|
|
268
|
+
/** The extension's own storage on this device. */
|
|
269
|
+
readonly storage: ExtensionStorage;
|
|
270
|
+
/** The person's answers to the settings the manifest declares. */
|
|
271
|
+
readonly settings: ExtensionSettings;
|
|
272
|
+
/** The Client's light or dark theme. */
|
|
273
|
+
readonly theme: ExtensionTheme;
|
|
274
|
+
/** The Client's icons and the extension's own. */
|
|
275
|
+
readonly icons: ExtensionIcons;
|
|
276
|
+
/** Say something in passing, as the Client's own notices do: "Coordinates copied." */
|
|
277
|
+
notify(text: string): void;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* What an extension's main module exports.
|
|
281
|
+
*
|
|
282
|
+
* @beta
|
|
283
|
+
*/
|
|
284
|
+
export interface ExtensionModule {
|
|
285
|
+
/**
|
|
286
|
+
* Called as each graph opens, or when the extension is switched on with a graph open. What the
|
|
287
|
+
* extension registered through the context is removed by the Client. A returned function is
|
|
288
|
+
* called after that, for anything else the extension holds.
|
|
289
|
+
*/
|
|
290
|
+
activate(context: ExtensionContext): void | (() => void);
|
|
291
|
+
}
|
package/dist/context.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/events.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Events an extension may listen to. An Event reports something that happened in the Client.
|
|
3
|
+
* A listener reacts to it but cannot change it, and a listener that throws harms nothing else.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Each Event's name and what it carries. Every Event belongs to the open graph, named by
|
|
7
|
+
* `graphId`.
|
|
8
|
+
*
|
|
9
|
+
* @beta
|
|
10
|
+
*/
|
|
11
|
+
export interface ExtensionEventPayloads {
|
|
12
|
+
/** The document the user is working in changed, or there is none (`documentId` is null). */
|
|
13
|
+
'document:active-changed': {
|
|
14
|
+
graphId: string;
|
|
15
|
+
documentId: string | null;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* A concept was renamed: its page, when it has one, and every wikilink naming it. The Client's
|
|
19
|
+
* own tabs, and the extension's Views about a concept, follow by themselves; this is for state
|
|
20
|
+
* an extension keeps under the old name, and it is reported before those Views move.
|
|
21
|
+
*
|
|
22
|
+
* A rename that moves concepts scoped by the renamed one reports each concept it moved, once,
|
|
23
|
+
* under the exact name it now lives on as: the scoped concepts first and the renamed concept
|
|
24
|
+
* last. A listener that moves what it keeps by exact name, Event by Event, moves each thing
|
|
25
|
+
* once, even when the new name is one the old name scopes (`Acme` to `[[Acme]] Archive`).
|
|
26
|
+
*/
|
|
27
|
+
'concept:renamed': {
|
|
28
|
+
graphId: string;
|
|
29
|
+
from: string;
|
|
30
|
+
to: string;
|
|
31
|
+
};
|
|
32
|
+
/** A document was deleted. */
|
|
33
|
+
'document:deleted': {
|
|
34
|
+
graphId: string;
|
|
35
|
+
documentId: string;
|
|
36
|
+
};
|
|
37
|
+
/** The set of documents changed: one was added, removed or renamed. */
|
|
38
|
+
'documents:changed': {
|
|
39
|
+
graphId: string;
|
|
40
|
+
};
|
|
41
|
+
/** The light or dark theme was switched on. */
|
|
42
|
+
'theme:changed': {
|
|
43
|
+
graphId: string;
|
|
44
|
+
dark: boolean;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* An Event's name.
|
|
49
|
+
*
|
|
50
|
+
* @beta
|
|
51
|
+
*/
|
|
52
|
+
export type ExtensionEventName = keyof ExtensionEventPayloads;
|
|
53
|
+
/**
|
|
54
|
+
* Listening to Events.
|
|
55
|
+
*
|
|
56
|
+
* @beta
|
|
57
|
+
*/
|
|
58
|
+
export interface ExtensionEvents {
|
|
59
|
+
/** Listen to one Event until the returned function is called or the extension stops. */
|
|
60
|
+
on<K extends ExtensionEventName>(name: K, listener: (payload: ExtensionEventPayloads[K]) => void): () => void;
|
|
61
|
+
}
|
package/dist/events.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an extension may ask the Derived Index, the open graph's searchable copy of its documents.
|
|
3
|
+
* Every question is read-only, and a Protected Document contributes only what it does everywhere:
|
|
4
|
+
* its name, never its content.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* What kind of name a concept is.
|
|
8
|
+
*
|
|
9
|
+
* @beta
|
|
10
|
+
*/
|
|
11
|
+
export type ConceptKind = 'page' | 'journal' | 'alias' | 'pageless';
|
|
12
|
+
/**
|
|
13
|
+
* One name the index knows: a page, a journal entry, an alias, or a Pageless Concept (a name
|
|
14
|
+
* wikilinks use that has no page).
|
|
15
|
+
*
|
|
16
|
+
* @beta
|
|
17
|
+
*/
|
|
18
|
+
export interface ConceptCandidate {
|
|
19
|
+
/** The name as written. */
|
|
20
|
+
display: string;
|
|
21
|
+
/** The case-insensitive key every index answer uses for it. */
|
|
22
|
+
key: string;
|
|
23
|
+
/** What kind of name it is. */
|
|
24
|
+
kind: ConceptKind;
|
|
25
|
+
/** For an alias: the name of the page it resolves to. */
|
|
26
|
+
canonical?: string;
|
|
27
|
+
/** For a Pageless Concept: how many wikilinks name it. */
|
|
28
|
+
references?: number;
|
|
29
|
+
/** Present, and true, when the name opens a Protected Document. */
|
|
30
|
+
protected?: true;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* One concept in the link graph.
|
|
34
|
+
*
|
|
35
|
+
* @beta
|
|
36
|
+
*/
|
|
37
|
+
export interface LinkGraphConcept {
|
|
38
|
+
/** The case-insensitive key every other answer uses. */
|
|
39
|
+
key: string;
|
|
40
|
+
/** The page's title, or for a Pageless Concept the casing most of its mentions use. */
|
|
41
|
+
name: string;
|
|
42
|
+
/** A page, a journal entry, or a name only wikilinks use. */
|
|
43
|
+
kind: 'page' | 'journal' | 'pageless';
|
|
44
|
+
/** For a journal entry named for a day that exists: that day, `YYYY-MM-DD`. */
|
|
45
|
+
day?: string;
|
|
46
|
+
/** Present, and true, only for a Protected Document. */
|
|
47
|
+
protected?: true;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* One line, from the document whose text holds the wikilinks to the concept they name. Both ends
|
|
51
|
+
* are positions in {@link LinkGraph.concepts}.
|
|
52
|
+
*
|
|
53
|
+
* @beta
|
|
54
|
+
*/
|
|
55
|
+
export interface LinkGraphLink {
|
|
56
|
+
/** The document whose text holds the wikilinks, by its position in `concepts`. */
|
|
57
|
+
source: number;
|
|
58
|
+
/** The concept they name, by its position in `concepts`. */
|
|
59
|
+
target: number;
|
|
60
|
+
/** How many wikilinks in the source name the target, under its name or any alias. */
|
|
61
|
+
mentions: number;
|
|
62
|
+
/** Present, and true, when the source's own name is scoped by the target. */
|
|
63
|
+
inTitle?: true;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Every concept and every line a wikilink draws between two of them.
|
|
67
|
+
*
|
|
68
|
+
* @beta
|
|
69
|
+
*/
|
|
70
|
+
export interface LinkGraph {
|
|
71
|
+
/** Sorted by key, so two answers over the same index are identical. */
|
|
72
|
+
concepts: LinkGraphConcept[];
|
|
73
|
+
/** Sorted by source, then target. A pair linked both ways is two links. */
|
|
74
|
+
links: LinkGraphLink[];
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* What changed in one update the index committed.
|
|
78
|
+
*
|
|
79
|
+
* @beta
|
|
80
|
+
*/
|
|
81
|
+
export interface IndexUpdate {
|
|
82
|
+
/** The whole index was read again, so anything may have changed. */
|
|
83
|
+
full: boolean;
|
|
84
|
+
/** The keys of the concepts whose documents changed. */
|
|
85
|
+
changedConceptKeys: ReadonlySet<string>;
|
|
86
|
+
/** The keys of the concepts whose backlinks changed. */
|
|
87
|
+
backlinkTargetsChanged: ReadonlySet<string>;
|
|
88
|
+
/** A place or route may have changed. Always true for a full update. */
|
|
89
|
+
mapsChanged: boolean;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The questions an extension may ask the open graph's index.
|
|
93
|
+
*
|
|
94
|
+
* @beta
|
|
95
|
+
*/
|
|
96
|
+
export interface ExtensionIndex {
|
|
97
|
+
/** Every name the index knows, aliases and Pageless Concepts included. */
|
|
98
|
+
allConcepts(): readonly ConceptCandidate[];
|
|
99
|
+
/** Every concept, and every pair a wikilink joins. */
|
|
100
|
+
linkGraph(): Promise<LinkGraph>;
|
|
101
|
+
/** Called after each update the index commits. Returns the unsubscribe. */
|
|
102
|
+
onUpdated(listener: (update: IndexUpdate) => void): () => void;
|
|
103
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The API an EtherPK extension is written against.
|
|
3
|
+
*
|
|
4
|
+
* An extension is an npm package whose package.json carries an `etherpk` manifest and whose main
|
|
5
|
+
* module exports `activate(context)`. The Client starts it as each graph opens, with an
|
|
6
|
+
* {@link ExtensionContext} for that graph. This package holds the types of that context, of the
|
|
7
|
+
* manifest and of the contract a View follows, and {@link readExtensionPackage}, which checks a
|
|
8
|
+
* manifest the way the Client does. It holds none of the Client's implementation.
|
|
9
|
+
*
|
|
10
|
+
* Every declaration is Proposed (`@beta`) until extensions from other publishers can be installed:
|
|
11
|
+
* until then it may change in any release. Stable declarations (`@public`) will never break.
|
|
12
|
+
*
|
|
13
|
+
* @packageDocumentation
|
|
14
|
+
*/
|
|
15
|
+
export type { CommandHandler, ExtensionCommandMenu, ExtensionCommands, ExtensionConcepts, ExtensionContext, ExtensionContextMenu, ExtensionContributions, ExtensionGraphSidebar, ExtensionIcons, ExtensionKeybindings, ExtensionLayout, ExtensionModule, ExtensionSettings, ExtensionStorage, ExtensionTheme, ExtensionViews, IconMarkup, Keybinding, } from './context';
|
|
16
|
+
export type { ExtensionEventName, ExtensionEventPayloads, ExtensionEvents } from './events';
|
|
17
|
+
export type { ConceptCandidate, ConceptKind, ExtensionIndex, IndexUpdate, LinkGraph, LinkGraphConcept, LinkGraphLink } from './graph-index';
|
|
18
|
+
export type { ExtensionManifest, ExtensionPackage, ReadExtensionPackageResult, SettingDeclaration, ViewDeclaration, ViewRegion, } from './manifest';
|
|
19
|
+
export { readExtensionPackage } from './manifest';
|
|
20
|
+
export type { CommandMenuContext, CommandMenuItem, ContextMenuItem, DocumentMenuTarget, ExtensionMenuTarget, GraphSidebarButton, MenuTarget, TabMenuTarget, WikilinkMenuTarget, } from './menus';
|
|
21
|
+
export type { MountedView, ViewContribution, ViewMountProps, ViewRef, ViewVisibility } from './views';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The API an EtherPK extension is written against.
|
|
3
|
+
*
|
|
4
|
+
* An extension is an npm package whose package.json carries an `etherpk` manifest and whose main
|
|
5
|
+
* module exports `activate(context)`. The Client starts it as each graph opens, with an
|
|
6
|
+
* {@link ExtensionContext} for that graph. This package holds the types of that context, of the
|
|
7
|
+
* manifest and of the contract a View follows, and {@link readExtensionPackage}, which checks a
|
|
8
|
+
* manifest the way the Client does. It holds none of the Client's implementation.
|
|
9
|
+
*
|
|
10
|
+
* Every declaration is Proposed (`@beta`) until extensions from other publishers can be installed:
|
|
11
|
+
* until then it may change in any release. Stable declarations (`@public`) will never break.
|
|
12
|
+
*
|
|
13
|
+
* @packageDocumentation
|
|
14
|
+
*/
|
|
15
|
+
export { readExtensionPackage } from './manifest';
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An extension's manifest: the `etherpk` field of its package.json (ADR 0121), and the one helper
|
|
3
|
+
* that reads and checks it. The Client reads the same field with the same function, so an author
|
|
4
|
+
* and the Client never disagree about what a manifest means.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Where a View kind opens when nothing says otherwise.
|
|
8
|
+
*
|
|
9
|
+
* @beta
|
|
10
|
+
*/
|
|
11
|
+
export type ViewRegion = 'main' | 'left-sidebar' | 'right-sidebar';
|
|
12
|
+
/**
|
|
13
|
+
* One View kind an extension adds, declared so the Client can show its tabs, titles and loading
|
|
14
|
+
* state before any of the extension's code has run.
|
|
15
|
+
*
|
|
16
|
+
* @beta
|
|
17
|
+
*/
|
|
18
|
+
export interface ViewDeclaration {
|
|
19
|
+
/** The kind, `<extension id>.<name>`: the key every saved Layout stores it under. */
|
|
20
|
+
kind: string;
|
|
21
|
+
/**
|
|
22
|
+
* The target of a kind with one View per graph, a Sidebar resident or a whole-graph View
|
|
23
|
+
* (`"whole"`). A kind whose Views differ by target, one board per concept, leaves it out.
|
|
24
|
+
*/
|
|
25
|
+
target?: string;
|
|
26
|
+
/**
|
|
27
|
+
* The tab's title. `{target}` stands for the View's target, so `"Kanban: {target}"` titles a
|
|
28
|
+
* board by its concept. The text before `{target}` is a fixed label the tab keeps room for.
|
|
29
|
+
*/
|
|
30
|
+
title: string;
|
|
31
|
+
/** An icon drawn before the title: one of the extension's own icons, or one of the Client's. */
|
|
32
|
+
icon?: string;
|
|
33
|
+
/** Where the kind opens when nothing says otherwise. */
|
|
34
|
+
region: ViewRegion;
|
|
35
|
+
/**
|
|
36
|
+
* The View lives in a Sidebar from the moment a graph opens, beside the Client's own residents,
|
|
37
|
+
* and the Sidebar's toggle brings it back if it was closed. `desktopOnly` keeps it off a phone.
|
|
38
|
+
*/
|
|
39
|
+
resident?: {
|
|
40
|
+
side: 'left' | 'right';
|
|
41
|
+
desktopOnly?: boolean;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* A short address of the View's own: `/g/<graph>/<segment>`, followed by the target when
|
|
45
|
+
* `target` is `concept`. Only a Built-in Extension may declare one. A View kind whose address
|
|
46
|
+
* takes a concept follows that concept through a rename.
|
|
47
|
+
*/
|
|
48
|
+
address?: {
|
|
49
|
+
segment: string;
|
|
50
|
+
target?: 'concept';
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* One thing an extension asks the person to set (ADR 0134). The Client draws it under the
|
|
55
|
+
* extension's entry in Settings and Extensions → Extensions, where it can be set while the extension
|
|
56
|
+
* is off, and keeps the answer with the person: on this device, and in their account where it syncs
|
|
57
|
+
* a graph, so it follows them to their other devices. The extension reads it through
|
|
58
|
+
* `context.settings`.
|
|
59
|
+
*
|
|
60
|
+
* @beta
|
|
61
|
+
*/
|
|
62
|
+
export interface SettingDeclaration {
|
|
63
|
+
/** Unique within the extension: lowercase letters, digits and hyphens, such as `mapbox-token`. */
|
|
64
|
+
id: string;
|
|
65
|
+
/**
|
|
66
|
+
* `text`; `secret` for a key or a password, which is shown masked until the person asks to see
|
|
67
|
+
* it; or `boolean`, a switch, which `context.settings` reads as `'true'` or `'false'`.
|
|
68
|
+
*/
|
|
69
|
+
type: 'text' | 'secret' | 'boolean';
|
|
70
|
+
/** The field's label, or for a switch what it turns on. */
|
|
71
|
+
title: string;
|
|
72
|
+
/** A switch's position until the person changes it. Only on a `boolean` setting, and off when left out. */
|
|
73
|
+
default?: boolean;
|
|
74
|
+
/** Plain text under the field: what the setting does, and what the person needs for it. */
|
|
75
|
+
description?: string;
|
|
76
|
+
/** Example text shown in the empty field. */
|
|
77
|
+
placeholder?: string;
|
|
78
|
+
/** A page that explains the setting, linked under its description. An `https` address. */
|
|
79
|
+
link?: {
|
|
80
|
+
title: string;
|
|
81
|
+
url: string;
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The `etherpk` field of an extension's package.json.
|
|
86
|
+
*
|
|
87
|
+
* @beta
|
|
88
|
+
*/
|
|
89
|
+
export interface ExtensionManifest {
|
|
90
|
+
/**
|
|
91
|
+
* Lowercase letters, digits and hyphens, starting with a letter. It prefixes every View kind,
|
|
92
|
+
* Command id, icon name and storage key the extension owns, so it is short.
|
|
93
|
+
*/
|
|
94
|
+
id: string;
|
|
95
|
+
/** The name people see, in Settings and wherever the extension is listed. */
|
|
96
|
+
displayName: string;
|
|
97
|
+
/** Who made the extension. */
|
|
98
|
+
publisher: string;
|
|
99
|
+
/** The lowest Client version the extension runs on, `x.y.z`. Required of a loaded extension. */
|
|
100
|
+
minClientVersion?: string;
|
|
101
|
+
/** The module that exports `activate`, relative to the package's root. */
|
|
102
|
+
main: string;
|
|
103
|
+
/**
|
|
104
|
+
* Stylesheets adopted into the shadow root of each of the extension's Views, relative to the
|
|
105
|
+
* package's root. Only a loaded extension has them: a compiled-in one shares the Client's.
|
|
106
|
+
*/
|
|
107
|
+
styles?: string[];
|
|
108
|
+
/** Icons by name: the inner markup of a 16 by 16 SVG drawn with `currentColor` strokes. */
|
|
109
|
+
icons?: Record<string, string>;
|
|
110
|
+
/** The View kinds the extension adds. */
|
|
111
|
+
views?: ViewDeclaration[];
|
|
112
|
+
/** What the extension asks the person to set, in the order it is shown. */
|
|
113
|
+
settings?: SettingDeclaration[];
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* An extension's package as the Client reads it: the standard package.json fields it uses and the
|
|
117
|
+
* manifest.
|
|
118
|
+
*
|
|
119
|
+
* @beta
|
|
120
|
+
*/
|
|
121
|
+
export interface ExtensionPackage {
|
|
122
|
+
/** The npm package name. */
|
|
123
|
+
name: string;
|
|
124
|
+
/** The package version, which is the extension's version. */
|
|
125
|
+
version: string;
|
|
126
|
+
/** The package's description, when it has one. */
|
|
127
|
+
description?: string;
|
|
128
|
+
/** The package's licence, an SPDX identifier, when it names one. */
|
|
129
|
+
license?: string;
|
|
130
|
+
/** The `etherpk` field. */
|
|
131
|
+
manifest: ExtensionManifest;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The answer of {@link readExtensionPackage}: the package, or every way its manifest is wrong.
|
|
135
|
+
*
|
|
136
|
+
* @beta
|
|
137
|
+
*/
|
|
138
|
+
export type ReadExtensionPackageResult = {
|
|
139
|
+
ok: true;
|
|
140
|
+
extension: ExtensionPackage;
|
|
141
|
+
} | {
|
|
142
|
+
ok: false;
|
|
143
|
+
errors: string[];
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* Read an extension's package.json, already parsed, and check its manifest.
|
|
147
|
+
*
|
|
148
|
+
* Checks what the Client depends on: names it uses as keys, paths it resolves, and declarations
|
|
149
|
+
* it registers before any of the extension's code runs. It does not check that files exist.
|
|
150
|
+
*
|
|
151
|
+
* @beta
|
|
152
|
+
*/
|
|
153
|
+
export declare function readExtensionPackage(packageJson: unknown): ReadExtensionPackageResult;
|
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An extension's manifest: the `etherpk` field of its package.json (ADR 0121), and the one helper
|
|
3
|
+
* that reads and checks it. The Client reads the same field with the same function, so an author
|
|
4
|
+
* and the Client never disagree about what a manifest means.
|
|
5
|
+
*/
|
|
6
|
+
const NAME = /^[a-z][a-z0-9-]*$/;
|
|
7
|
+
const VERSION = /^\d+\.\d+\.\d+$/;
|
|
8
|
+
const REGIONS = ['main', 'left-sidebar', 'right-sidebar'];
|
|
9
|
+
/**
|
|
10
|
+
* Read an extension's package.json, already parsed, and check its manifest.
|
|
11
|
+
*
|
|
12
|
+
* Checks what the Client depends on: names it uses as keys, paths it resolves, and declarations
|
|
13
|
+
* it registers before any of the extension's code runs. It does not check that files exist.
|
|
14
|
+
*
|
|
15
|
+
* @beta
|
|
16
|
+
*/
|
|
17
|
+
export function readExtensionPackage(packageJson) {
|
|
18
|
+
const errors = [];
|
|
19
|
+
const json = isRecord(packageJson) ? packageJson : {};
|
|
20
|
+
const name = json.name;
|
|
21
|
+
const version = json.version;
|
|
22
|
+
if (typeof name !== 'string' || name === '')
|
|
23
|
+
errors.push('package.json has no "name".');
|
|
24
|
+
if (typeof version !== 'string' || !VERSION.test(version))
|
|
25
|
+
errors.push('package.json\'s "version" is not x.y.z.');
|
|
26
|
+
const field = json.etherpk;
|
|
27
|
+
if (!isRecord(field)) {
|
|
28
|
+
errors.push('package.json has no "etherpk" field, so it is not an EtherPK extension.');
|
|
29
|
+
return { ok: false, errors };
|
|
30
|
+
}
|
|
31
|
+
const id = field.id;
|
|
32
|
+
if (typeof id !== 'string' || !NAME.test(id))
|
|
33
|
+
errors.push('"etherpk.id" must be lowercase letters, digits and hyphens, starting with a letter.');
|
|
34
|
+
const ownId = typeof id === 'string' ? id : '';
|
|
35
|
+
for (const key of ['displayName', 'publisher', 'main']) {
|
|
36
|
+
if (typeof field[key] !== 'string' || field[key].trim() === '')
|
|
37
|
+
errors.push(`"etherpk.${key}" must be a non-empty string.`);
|
|
38
|
+
}
|
|
39
|
+
if (typeof field.main === 'string' && field.main.trim() !== '' && !relativePath(field.main))
|
|
40
|
+
errors.push('"etherpk.main" must be a path inside the package.');
|
|
41
|
+
if (field.minClientVersion !== undefined && (typeof field.minClientVersion !== 'string' || !VERSION.test(field.minClientVersion))) {
|
|
42
|
+
errors.push('"etherpk.minClientVersion" must be x.y.z.');
|
|
43
|
+
}
|
|
44
|
+
if (field.styles !== undefined && (!Array.isArray(field.styles) || !field.styles.every((path) => typeof path === 'string' && relativePath(path)))) {
|
|
45
|
+
errors.push('"etherpk.styles" must be a list of paths inside the package.');
|
|
46
|
+
}
|
|
47
|
+
if (field.icons !== undefined) {
|
|
48
|
+
if (!isRecord(field.icons))
|
|
49
|
+
errors.push('"etherpk.icons" must map names to SVG markup.');
|
|
50
|
+
else
|
|
51
|
+
for (const [iconName, markup] of Object.entries(field.icons)) {
|
|
52
|
+
if (!NAME.test(iconName))
|
|
53
|
+
errors.push(`Icon "${iconName}" must be lowercase letters, digits and hyphens.`);
|
|
54
|
+
if (typeof markup !== 'string' || markup.trim() === '')
|
|
55
|
+
errors.push(`Icon "${iconName}" has no markup.`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const views = field.views === undefined ? [] : field.views;
|
|
59
|
+
if (!Array.isArray(views))
|
|
60
|
+
errors.push('"etherpk.views" must be a list.');
|
|
61
|
+
else {
|
|
62
|
+
const kinds = new Set();
|
|
63
|
+
const segments = new Set();
|
|
64
|
+
views.forEach((view, index) => checkView(view, index, ownId, kinds, segments, errors));
|
|
65
|
+
}
|
|
66
|
+
const settings = field.settings === undefined ? [] : field.settings;
|
|
67
|
+
if (!Array.isArray(settings))
|
|
68
|
+
errors.push('"etherpk.settings" must be a list.');
|
|
69
|
+
else {
|
|
70
|
+
const ids = new Set();
|
|
71
|
+
settings.forEach((setting, index) => checkSetting(setting, index, ids, errors));
|
|
72
|
+
}
|
|
73
|
+
if (errors.length > 0)
|
|
74
|
+
return { ok: false, errors };
|
|
75
|
+
return {
|
|
76
|
+
ok: true,
|
|
77
|
+
extension: {
|
|
78
|
+
name: name,
|
|
79
|
+
version: version,
|
|
80
|
+
...(typeof json.description === 'string' ? { description: json.description } : {}),
|
|
81
|
+
...(typeof json.license === 'string' ? { license: json.license } : {}),
|
|
82
|
+
manifest: field,
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const SETTING_TYPES = ['text', 'secret', 'boolean'];
|
|
87
|
+
function checkSetting(setting, index, ids, errors) {
|
|
88
|
+
const where = `"etherpk.settings[${index}]"`;
|
|
89
|
+
if (!isRecord(setting)) {
|
|
90
|
+
errors.push(`${where} must be an object.`);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (typeof setting.id !== 'string' || !NAME.test(setting.id))
|
|
94
|
+
errors.push(`${where}.id must be lowercase letters, digits and hyphens, starting with a letter.`);
|
|
95
|
+
else if (ids.has(setting.id))
|
|
96
|
+
errors.push(`${where}.id "${setting.id}" is declared twice.`);
|
|
97
|
+
else
|
|
98
|
+
ids.add(setting.id);
|
|
99
|
+
if (!SETTING_TYPES.includes(setting.type))
|
|
100
|
+
errors.push(`${where}.type must be one of ${SETTING_TYPES.join(', ')}.`);
|
|
101
|
+
if (typeof setting.title !== 'string' || setting.title.trim() === '')
|
|
102
|
+
errors.push(`${where}.title must be a non-empty string.`);
|
|
103
|
+
for (const key of ['description', 'placeholder']) {
|
|
104
|
+
if (setting[key] !== undefined && typeof setting[key] !== 'string')
|
|
105
|
+
errors.push(`${where}.${key} must be a string.`);
|
|
106
|
+
}
|
|
107
|
+
if (setting.default !== undefined && (setting.type !== 'boolean' || typeof setting.default !== 'boolean')) {
|
|
108
|
+
errors.push(`${where}.default must be true or false, and only on a boolean setting.`);
|
|
109
|
+
}
|
|
110
|
+
if (setting.link !== undefined) {
|
|
111
|
+
const link = setting.link;
|
|
112
|
+
if (!isRecord(link) || typeof link.title !== 'string' || link.title.trim() === '' || typeof link.url !== 'string' || !httpsUrl(link.url)) {
|
|
113
|
+
errors.push(`${where}.link must be { title, url }, the url an https address.`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function httpsUrl(value) {
|
|
118
|
+
try {
|
|
119
|
+
return new URL(value).protocol === 'https:';
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function checkView(view, index, id, kinds, segments, errors) {
|
|
126
|
+
const where = `"etherpk.views[${index}]"`;
|
|
127
|
+
if (!isRecord(view)) {
|
|
128
|
+
errors.push(`${where} must be an object.`);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const kind = view.kind;
|
|
132
|
+
const prefix = `${id}.`;
|
|
133
|
+
if (typeof kind !== 'string' || !kind.startsWith(prefix) || !NAME.test(kind.slice(prefix.length))) {
|
|
134
|
+
errors.push(`${where}.kind must be "${prefix}<name>", the name in lowercase letters, digits and hyphens.`);
|
|
135
|
+
}
|
|
136
|
+
else if (kinds.has(kind))
|
|
137
|
+
errors.push(`${where}.kind "${kind}" is declared twice.`);
|
|
138
|
+
else
|
|
139
|
+
kinds.add(kind);
|
|
140
|
+
if (view.target !== undefined && (typeof view.target !== 'string' || view.target === ''))
|
|
141
|
+
errors.push(`${where}.target must be a non-empty string.`);
|
|
142
|
+
if (typeof view.title !== 'string' || view.title.trim() === '')
|
|
143
|
+
errors.push(`${where}.title must be a non-empty string.`);
|
|
144
|
+
if (view.icon !== undefined && typeof view.icon !== 'string')
|
|
145
|
+
errors.push(`${where}.icon must be an icon's name.`);
|
|
146
|
+
if (!REGIONS.includes(view.region))
|
|
147
|
+
errors.push(`${where}.region must be one of ${REGIONS.join(', ')}.`);
|
|
148
|
+
if (view.resident !== undefined) {
|
|
149
|
+
const resident = view.resident;
|
|
150
|
+
if (!isRecord(resident) || (resident.side !== 'left' && resident.side !== 'right') || (resident.desktopOnly !== undefined && typeof resident.desktopOnly !== 'boolean')) {
|
|
151
|
+
errors.push(`${where}.resident must be { side: "left" | "right", desktopOnly?: boolean }.`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (view.address !== undefined) {
|
|
155
|
+
const address = view.address;
|
|
156
|
+
if (!isRecord(address) || typeof address.segment !== 'string' || !NAME.test(address.segment)) {
|
|
157
|
+
errors.push(`${where}.address.segment must be lowercase letters, digits and hyphens.`);
|
|
158
|
+
}
|
|
159
|
+
else if (segments.has(address.segment))
|
|
160
|
+
errors.push(`${where}.address.segment "${address.segment}" is declared twice.`);
|
|
161
|
+
else
|
|
162
|
+
segments.add(address.segment);
|
|
163
|
+
if (isRecord(address) && address.target !== undefined && address.target !== 'concept')
|
|
164
|
+
errors.push(`${where}.address.target may only be "concept".`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/** A path that stays inside the package: relative, and never climbing out of it. */
|
|
168
|
+
function relativePath(path) {
|
|
169
|
+
return path !== '' && !path.startsWith('/') && !path.includes('\\') && !path.split('/').includes('..');
|
|
170
|
+
}
|
|
171
|
+
function isRecord(value) {
|
|
172
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
173
|
+
}
|
package/dist/menus.d.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rows an extension adds to the Client's menus. A row runs a Command, so every row is reachable
|
|
3
|
+
* from code, keybindings and other menus as well.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* A document, reached from its tab, a Favourite, Recents or an All Documents row.
|
|
7
|
+
*
|
|
8
|
+
* @beta
|
|
9
|
+
*/
|
|
10
|
+
export interface DocumentMenuTarget {
|
|
11
|
+
/** Where the menu was raised: the document's tab, or a row naming it. */
|
|
12
|
+
kind: 'document-tab' | 'favourite' | 'recent' | 'document-row';
|
|
13
|
+
/** The concept the document is about. */
|
|
14
|
+
concept: string;
|
|
15
|
+
/** The panel it is open in, when the target is a tab. */
|
|
16
|
+
panelId?: string;
|
|
17
|
+
/** The tab is a row in a vertical list, as a phone draws its open documents. */
|
|
18
|
+
vertical?: boolean;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A tab that is not a document's.
|
|
22
|
+
*
|
|
23
|
+
* @beta
|
|
24
|
+
*/
|
|
25
|
+
export interface TabMenuTarget {
|
|
26
|
+
/** Any tab that is not a document's. */
|
|
27
|
+
kind: 'tab';
|
|
28
|
+
/** The panel the tab belongs to. */
|
|
29
|
+
panelId: string;
|
|
30
|
+
/** The tab is a row in a vertical list, as a phone draws its open documents. */
|
|
31
|
+
vertical?: boolean;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* A wikilink in an editor.
|
|
35
|
+
*
|
|
36
|
+
* @beta
|
|
37
|
+
*/
|
|
38
|
+
export interface WikilinkMenuTarget {
|
|
39
|
+
/** A wikilink in an editor. */
|
|
40
|
+
kind: 'wikilink';
|
|
41
|
+
/** The concept the link names. */
|
|
42
|
+
concept: string;
|
|
43
|
+
/** The editor's panel. */
|
|
44
|
+
panelId?: string;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* A thing an extension raises a menu on, of a kind the extension names under its own id
|
|
48
|
+
* (`kanban.card`). Only rows that check for that kind apply to it.
|
|
49
|
+
*
|
|
50
|
+
* @beta
|
|
51
|
+
*/
|
|
52
|
+
export interface ExtensionMenuTarget {
|
|
53
|
+
/** `<extension id>.<name>`. */
|
|
54
|
+
kind: `${string}.${string}`;
|
|
55
|
+
/** Whatever the extension's own rows need to know about the thing. */
|
|
56
|
+
[field: string]: unknown;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* What a Context Menu can be raised on that an extension may add rows to. The Client also raises
|
|
60
|
+
* menus on things this list does not name, an Asset or a misspelt word, so a row's `when` answers
|
|
61
|
+
* false for any kind it does not check for.
|
|
62
|
+
*
|
|
63
|
+
* @beta
|
|
64
|
+
*/
|
|
65
|
+
export type MenuTarget = DocumentMenuTarget | TabMenuTarget | WikilinkMenuTarget | ExtensionMenuTarget;
|
|
66
|
+
/**
|
|
67
|
+
* One Context Menu row.
|
|
68
|
+
*
|
|
69
|
+
* @beta
|
|
70
|
+
*/
|
|
71
|
+
export interface ContextMenuItem {
|
|
72
|
+
/** Under the extension's id: `graph-view.showConcept`. */
|
|
73
|
+
id: string;
|
|
74
|
+
/** The row's text, which may read the target. */
|
|
75
|
+
label: string | ((target: MenuTarget) => string);
|
|
76
|
+
/** Lower sorts earlier. Rows with equal values keep the order they were added in. */
|
|
77
|
+
order?: number;
|
|
78
|
+
/** The Command the row runs, given the target. */
|
|
79
|
+
command: string;
|
|
80
|
+
/** An icon drawn left of the label: the extension's own or one of the Client's. */
|
|
81
|
+
icon?: string;
|
|
82
|
+
/** A separator above the row. */
|
|
83
|
+
separatorBefore?: boolean | ((target: MenuTarget) => boolean);
|
|
84
|
+
/** Whether the row applies to a target. Absent means every target. */
|
|
85
|
+
when?: (target: MenuTarget) => boolean;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* What a Command Menu row's `when` may read about the editor it was opened in.
|
|
89
|
+
*
|
|
90
|
+
* @beta
|
|
91
|
+
*/
|
|
92
|
+
export interface CommandMenuContext {
|
|
93
|
+
/** The caret is in a table. */
|
|
94
|
+
inTable: boolean;
|
|
95
|
+
/** A table may be inserted at the caret. */
|
|
96
|
+
tableInsertable: boolean;
|
|
97
|
+
/** The document's body may be written: false in a locked Protected Document. */
|
|
98
|
+
bodyWritable: boolean;
|
|
99
|
+
/**
|
|
100
|
+
* The document is a Protected Document, locked or not: where a row whose widget draws no
|
|
101
|
+
* protected content, such as `/map`, is not offered. Absent reads as false.
|
|
102
|
+
*/
|
|
103
|
+
protectedDocument?: boolean;
|
|
104
|
+
/**
|
|
105
|
+
* The concepts a View about one concept can be opened for from the caret, nearest first: the
|
|
106
|
+
* caret line's links, the links above it in the outline, the document's own concept, then the
|
|
107
|
+
* scopes in its name. Names are as written, an alias as the alias. Worked out only when a row
|
|
108
|
+
* reads it. Absent reads as none.
|
|
109
|
+
*/
|
|
110
|
+
readonly conceptsAtCaret?: readonly string[];
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* One row of the Command Menu, the menu `/` opens in the editor.
|
|
114
|
+
*
|
|
115
|
+
* @beta
|
|
116
|
+
*/
|
|
117
|
+
export interface CommandMenuItem {
|
|
118
|
+
/** Under the extension's id. */
|
|
119
|
+
id: string;
|
|
120
|
+
/** The row's text. */
|
|
121
|
+
title: string;
|
|
122
|
+
/**
|
|
123
|
+
* A line under the title, or a function that works it out from the editor the menu opened in,
|
|
124
|
+
* each time the menu opens. A function that throws shows no line.
|
|
125
|
+
*/
|
|
126
|
+
detail?: string | ((context: CommandMenuContext) => string | undefined);
|
|
127
|
+
/** An icon drawn before the title: the extension's own or one of the Client's. */
|
|
128
|
+
icon?: string;
|
|
129
|
+
/** Rows are grouped under this heading. */
|
|
130
|
+
group?: string;
|
|
131
|
+
/** Lower sorts earlier within the group. */
|
|
132
|
+
order?: number;
|
|
133
|
+
/** More words the menu's filter matches. */
|
|
134
|
+
keywords?: string[];
|
|
135
|
+
/** Whether the row is offered here. Absent means everywhere. */
|
|
136
|
+
when?: (context: CommandMenuContext) => boolean;
|
|
137
|
+
/** The Command the row runs. */
|
|
138
|
+
command: string;
|
|
139
|
+
/** What the Command is given. */
|
|
140
|
+
args?: unknown;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* A button in the Graph Sidebar, under Today's journal: a way in to what the extension shows for
|
|
144
|
+
* the whole graph, such as the Graph Map View. Pressing it runs `command`.
|
|
145
|
+
*
|
|
146
|
+
* @beta
|
|
147
|
+
*/
|
|
148
|
+
export interface GraphSidebarButton {
|
|
149
|
+
/** Under the extension's id. */
|
|
150
|
+
id: string;
|
|
151
|
+
/** The button's text. */
|
|
152
|
+
title: string;
|
|
153
|
+
/** An icon drawn before the title: the extension's own or one of the Client's. */
|
|
154
|
+
icon?: string;
|
|
155
|
+
/** Lower lists earlier. A button without one lists after every button that has one. */
|
|
156
|
+
order?: number;
|
|
157
|
+
/** The Command the button runs. */
|
|
158
|
+
command: string;
|
|
159
|
+
/** What the Command is given. */
|
|
160
|
+
args?: unknown;
|
|
161
|
+
}
|
package/dist/menus.js
ADDED
package/dist/views.d.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contract an extension's View follows (ADR 0121): a function that draws into an element the
|
|
3
|
+
* Client gives it, tied to no framework, so no Svelte or React version is ever part of the API.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Which View: its kind and what it is about. A View with no subject has an empty target.
|
|
7
|
+
*
|
|
8
|
+
* @beta
|
|
9
|
+
*/
|
|
10
|
+
export interface ViewRef {
|
|
11
|
+
/** The View kind, `<extension id>.<name>` for an extension's. */
|
|
12
|
+
kind: string;
|
|
13
|
+
/** What the View is about: a concept's name for a board or a map, empty for a View with none. */
|
|
14
|
+
target: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Whether a View can be seen: its tab in front, its Pane given room, and the browser tab showing.
|
|
18
|
+
* The desktop keeps every tab mounted, so a View that does work while shown (drawing, reading the
|
|
19
|
+
* index) stops it while this is false.
|
|
20
|
+
*
|
|
21
|
+
* @beta
|
|
22
|
+
*/
|
|
23
|
+
export interface ViewVisibility {
|
|
24
|
+
/** True while the View can be seen. */
|
|
25
|
+
readonly onScreen: boolean;
|
|
26
|
+
/** Called with each new value of `onScreen`, never with a repeat. Returns the unsubscribe. */
|
|
27
|
+
subscribe(listener: (onScreen: boolean) => void): () => void;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* What a View is mounted with.
|
|
31
|
+
*
|
|
32
|
+
* @beta
|
|
33
|
+
*/
|
|
34
|
+
export interface ViewMountProps {
|
|
35
|
+
/** The View being mounted. */
|
|
36
|
+
view: ViewRef;
|
|
37
|
+
/**
|
|
38
|
+
* The id of the panel the View is open in, on a desktop: what a Layout call names to open
|
|
39
|
+
* something in the same Pane or beside it. A phone passes none.
|
|
40
|
+
*/
|
|
41
|
+
panelId?: string;
|
|
42
|
+
/** Whether the View can be seen right now, and when that changes. */
|
|
43
|
+
visibility: ViewVisibility;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A View the extension has drawn, as the Client drives it.
|
|
47
|
+
*
|
|
48
|
+
* @beta
|
|
49
|
+
*/
|
|
50
|
+
export interface MountedView {
|
|
51
|
+
/** The same View with new props: a rename re-keyed its target, or it moved to another Pane. */
|
|
52
|
+
update?(props: ViewMountProps): void;
|
|
53
|
+
/** The tab closed, the graph closed, or the extension was switched off. */
|
|
54
|
+
destroy(): void;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* What an extension registers for a View kind its manifest declares.
|
|
58
|
+
*
|
|
59
|
+
* @beta
|
|
60
|
+
*/
|
|
61
|
+
export interface ViewContribution {
|
|
62
|
+
/**
|
|
63
|
+
* Draw the View into `element`, which is empty and is the extension's alone. For a loaded
|
|
64
|
+
* extension it sits inside a shadow root that holds the extension's own stylesheets, so its CSS
|
|
65
|
+
* reaches nothing outside, and nothing of the Client's reaches in but the `--gk-*` tokens and
|
|
66
|
+
* inherited text styles.
|
|
67
|
+
*/
|
|
68
|
+
mount(element: HTMLElement, props: ViewMountProps): MountedView;
|
|
69
|
+
}
|
package/dist/views.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appsoftwareltd/etherpk-extension-api",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
3
|
+
"version": "0.9.6",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "The API an EtherPK extension is written against: the context it is started with, its manifest, and the contract its Views follow.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"LICENSE",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/appsoftwareltd/etherpk-client.git",
|
|
25
|
+
"directory": "packages/extension-api"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@microsoft/api-extractor": "^7.59.2",
|
|
29
|
+
"@vitest/coverage-v8": "^4.1.4",
|
|
30
|
+
"typescript": "^6.0.2",
|
|
31
|
+
"vitest": "^4.1.4"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"check": "tsc --noEmit && pnpm api:check",
|
|
35
|
+
"build": "tsc -p tsconfig.build.json",
|
|
36
|
+
"api": "pnpm build && api-extractor run --local",
|
|
37
|
+
"api:check": "pnpm build && api-extractor run",
|
|
38
|
+
"test": "vitest run",
|
|
39
|
+
"test:watch": "vitest",
|
|
40
|
+
"test:coverage": "vitest run --coverage"
|
|
41
|
+
}
|
|
6
42
|
}
|