@outl/plugin-sdk 0.8.0-beta.130
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/package.json +31 -0
- package/src/index.ts +500 -0
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@outl/plugin-sdk",
|
|
3
|
+
"version": "0.8.0-beta.130",
|
|
4
|
+
"description": "TypeScript SDK for authoring outl plugins. Types-only contract — the host implementation is injected by the outl runtime (Boa/Rust) at load time.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./src/index.ts",
|
|
8
|
+
"module": "./src/index.ts",
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./src/index.ts",
|
|
13
|
+
"import": "./src/index.ts"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src"
|
|
18
|
+
],
|
|
19
|
+
"sideEffects": false,
|
|
20
|
+
"keywords": [
|
|
21
|
+
"outl",
|
|
22
|
+
"plugin",
|
|
23
|
+
"sdk",
|
|
24
|
+
"outliner"
|
|
25
|
+
],
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "https://github.com/avelino/outl",
|
|
29
|
+
"directory": "plugin-sdk"
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @outl/plugin-sdk — the JS-facing contract for outl plugins.
|
|
3
|
+
*
|
|
4
|
+
* This package is **types + one helper** and nothing else. It has zero runtime
|
|
5
|
+
* dependencies and never talks to Tauri, the filesystem, or the network. The
|
|
6
|
+
* real `PluginContext` is injected by the outl runtime (a Boa JS engine living
|
|
7
|
+
* in the Rust `outl-plugins` crate) when it calls the plugin's `activate(ctx)`.
|
|
8
|
+
*
|
|
9
|
+
* Why types-only: a plugin written once must run identically on every client
|
|
10
|
+
* (TUI, desktop, mobile, CLI). Pinning behavior to a host-provided context —
|
|
11
|
+
* instead of importing anything client-specific — is what makes that possible.
|
|
12
|
+
*
|
|
13
|
+
* Mental model for authors: you think in **blocks and ops**, never in pixels,
|
|
14
|
+
* CRDT internals, or `.md` files. Every mutation you trigger (`ctx.blocks.move`,
|
|
15
|
+
* `ctx.blocks.edit`, ...) becomes a host call that routes through `outl-actions`
|
|
16
|
+
* → `Workspace::apply` → the op log, stamped `plugin:<id>@<device>`. The op log
|
|
17
|
+
* stays the single source of truth; the SDK just gives you a typed door to it.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Core identifiers and data shapes
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A block identifier. Opaque on the JS side — it is a `ULID` string under the
|
|
26
|
+
* hood, but plugins must treat it as a token to pass back to the host, never
|
|
27
|
+
* parse or construct it. IDs live only in the sidecar, never in the `.md`.
|
|
28
|
+
*/
|
|
29
|
+
export type BlockId = string;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A page slug — a page's flat, filename-safe id (`pages/<slug>.md`). Pages are
|
|
33
|
+
* **not** nested directories, so `/` is not a slug character; use a separator
|
|
34
|
+
* like `-` (e.g. `ouraring-2025-11-29`). Daily notes use ISO `YYYY-MM-DD`.
|
|
35
|
+
*/
|
|
36
|
+
export type PageSlug = string;
|
|
37
|
+
|
|
38
|
+
/** TODO state of a block, when it has one. */
|
|
39
|
+
export type TodoState = "TODO" | "DONE";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A single materialized block, as the host hands it to a plugin.
|
|
43
|
+
*
|
|
44
|
+
* This is a read projection, not a live handle: mutating these fields does
|
|
45
|
+
* nothing. To change a block, call the `ctx.blocks.*` methods, which submit ops.
|
|
46
|
+
*/
|
|
47
|
+
export interface Block {
|
|
48
|
+
/** Stable id; pass it back to `ctx.blocks.*` to act on this block. */
|
|
49
|
+
id: BlockId;
|
|
50
|
+
/** Raw markdown text of the block (clean — no inline IDs). */
|
|
51
|
+
text: string;
|
|
52
|
+
/** Parent block id, or `null` when the block is a top-level child of a page. */
|
|
53
|
+
parent: BlockId | null;
|
|
54
|
+
/** Slug of the page this block currently lives on. */
|
|
55
|
+
page: PageSlug;
|
|
56
|
+
/** TODO state, or `null` when the block is not a task. */
|
|
57
|
+
todo: TodoState | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Filter passed to `ctx.blocks.query`. All present fields are ANDed together;
|
|
62
|
+
* an empty filter matches every block in the workspace.
|
|
63
|
+
*
|
|
64
|
+
* Kept intentionally small for d0 — extend deliberately as real plugins need
|
|
65
|
+
* more selectors, so the contract does not grow speculative surface.
|
|
66
|
+
*/
|
|
67
|
+
export interface BlockFilter {
|
|
68
|
+
/** Restrict to blocks on this page. */
|
|
69
|
+
page?: PageSlug;
|
|
70
|
+
/** Restrict to blocks in this TODO state. */
|
|
71
|
+
todo?: TodoState;
|
|
72
|
+
/** Substring the block text must contain (case-insensitive on the host). */
|
|
73
|
+
textContains?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Where a block should move to. Exactly one variant is meaningful per call; the
|
|
78
|
+
* host validates and rejects ambiguous or empty targets.
|
|
79
|
+
*
|
|
80
|
+
* `toPage` appends the block to the end of the target page's outline.
|
|
81
|
+
* `toParent` reparents under a block (optionally inserting at `index`).
|
|
82
|
+
*/
|
|
83
|
+
export type MoveTarget =
|
|
84
|
+
| { toPage: PageSlug; toParent?: never; index?: number }
|
|
85
|
+
| { toParent: BlockId; toPage?: never; index?: number };
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A node in a tree passed to `appendTree`: its text plus optional children.
|
|
89
|
+
* Recursive, so you describe a whole nested outline in one value.
|
|
90
|
+
*/
|
|
91
|
+
export interface TreeNode {
|
|
92
|
+
/** Block text (include a `TODO `/`DONE ` prefix to set that state). */
|
|
93
|
+
text: string;
|
|
94
|
+
/** Child nodes, created under this one. Omit or empty for a leaf. */
|
|
95
|
+
children?: TreeNode[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* An op as it appears *after* being applied to the log — what `ctx.ops.onOp`
|
|
100
|
+
* receives. This is the JS-facing projection of the Rust `Op`/log entry, not a
|
|
101
|
+
* 1:1 mirror: it carries just what a hook needs to react.
|
|
102
|
+
*
|
|
103
|
+
* `kind` is the op variant. `node` is the block the op acted on. The remaining
|
|
104
|
+
* fields are populated only for the variants that use them (e.g. `text` on a
|
|
105
|
+
* text update, `target`/`parent` on a move), so they are all optional.
|
|
106
|
+
*/
|
|
107
|
+
export interface LogOp {
|
|
108
|
+
/** Op variant, e.g. `"TextUpdate"`, `"Move"`, `"ToggleTodo"`, `"Insert"`. */
|
|
109
|
+
kind: string;
|
|
110
|
+
/** The block this op acted on. */
|
|
111
|
+
node: BlockId;
|
|
112
|
+
/** New text, for text-bearing ops. */
|
|
113
|
+
text?: string;
|
|
114
|
+
/** New parent, for move/insert ops. */
|
|
115
|
+
parent?: BlockId | null;
|
|
116
|
+
/** Move destination, for move ops. */
|
|
117
|
+
target?: MoveTarget;
|
|
118
|
+
/** TODO state after the op, for todo toggles. */
|
|
119
|
+
todo?: TodoState | null;
|
|
120
|
+
/**
|
|
121
|
+
* Who produced the op. Plugin-originated ops are stamped
|
|
122
|
+
* `plugin:<id>@<device>`, which lets hooks ignore their own writes and avoid
|
|
123
|
+
* re-entrant loops.
|
|
124
|
+
*/
|
|
125
|
+
actor?: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Host API namespaces (the typed `ctx`)
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
/** Options for `ctx.net.fetch`. `timeoutMs` is **required** — the host refuses
|
|
133
|
+
* unbounded network calls, so the type bakes that in rather than defaulting. */
|
|
134
|
+
export interface FetchOptions {
|
|
135
|
+
/** HTTP method. Defaults to `"GET"` on the host when omitted. */
|
|
136
|
+
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD";
|
|
137
|
+
/** Request headers. */
|
|
138
|
+
headers?: Record<string, string>;
|
|
139
|
+
/** Request body for write methods. */
|
|
140
|
+
body?: string;
|
|
141
|
+
/** Hard timeout in milliseconds. Required: no unbounded fetches. */
|
|
142
|
+
timeoutMs: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** A minimal response shape returned by `ctx.net.fetch`. */
|
|
146
|
+
export interface FetchResponse {
|
|
147
|
+
status: number;
|
|
148
|
+
ok: boolean;
|
|
149
|
+
headers: Record<string, string>;
|
|
150
|
+
/** Resolve the body as text. */
|
|
151
|
+
text(): Promise<string>;
|
|
152
|
+
/** Resolve and parse the body as JSON. */
|
|
153
|
+
json<T = unknown>(): Promise<T>;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Op-log hook namespace. Gated by the `read-op-log` permission.
|
|
158
|
+
*/
|
|
159
|
+
export interface OpsApi {
|
|
160
|
+
/**
|
|
161
|
+
* Register a callback fired for every op applied to the log — local edits and
|
|
162
|
+
* ops arriving from sync alike. Filter on `op.actor` to skip your own writes.
|
|
163
|
+
* Hooks run with a host-enforced timeout and re-entrancy depth limit.
|
|
164
|
+
*/
|
|
165
|
+
onOp(cb: (op: LogOp) => void): void;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Block read/write namespace. Reads need `read-page`; writes need `write-page`
|
|
170
|
+
* and `submit-op`.
|
|
171
|
+
*
|
|
172
|
+
* Execution is **describe → apply**: reads (`query` / `get`) see a snapshot
|
|
173
|
+
* taken at the start of the turn, and writes are buffered and applied by the
|
|
174
|
+
* host *after* your handler returns. So a block you `edit`/`create` this turn is
|
|
175
|
+
* NOT visible to a later `query` in the same turn — collect what you need first,
|
|
176
|
+
* then mutate. The methods are async-typed for forward-compatibility; today they
|
|
177
|
+
* resolve synchronously, so `await` is harmless but not required.
|
|
178
|
+
*/
|
|
179
|
+
export interface BlocksApi {
|
|
180
|
+
/** Find blocks matching `filter`. */
|
|
181
|
+
query(filter: BlockFilter): Promise<Block[]>;
|
|
182
|
+
/** Fetch one block by id, or `null` if it no longer exists. */
|
|
183
|
+
get(id: BlockId): Promise<Block | null>;
|
|
184
|
+
/** Replace a block's markdown text (include the `TODO `/`DONE ` prefix to set state). */
|
|
185
|
+
edit(id: BlockId, text: string): Promise<void>;
|
|
186
|
+
/**
|
|
187
|
+
* Create a new block as the last child of `parent`. Does not resolve to the
|
|
188
|
+
* new id: under describe→apply the id does not exist until the host applies
|
|
189
|
+
* the intent, after this turn.
|
|
190
|
+
*/
|
|
191
|
+
create(parent: BlockId, text: string): Promise<void>;
|
|
192
|
+
/** Create a new block as the sibling right after `after`. */
|
|
193
|
+
createAfter(after: BlockId, text: string): Promise<void>;
|
|
194
|
+
/** Move a block to a new page (`{ toPage }`) or under another block (`{ toParent }`). */
|
|
195
|
+
move(id: BlockId, target: MoveTarget): Promise<void>;
|
|
196
|
+
/** Cycle a block's TODO state (None → TODO → DONE → None). */
|
|
197
|
+
toggleTodo(id: BlockId): Promise<void>;
|
|
198
|
+
/** Delete a block (moved to trash; the op stays in the log). */
|
|
199
|
+
delete(id: BlockId): Promise<void>;
|
|
200
|
+
/**
|
|
201
|
+
* Append a nested tree of blocks under `parent`, all in one turn. The host
|
|
202
|
+
* threads the new ids through internally, so — unlike `create` — you don't
|
|
203
|
+
* need any child's id in hand. Use it to build fresh nested content under a
|
|
204
|
+
* block you already have. To seed a page that has no blocks yet, use
|
|
205
|
+
* `ctx.page.appendTree(slug, tree)` instead.
|
|
206
|
+
*/
|
|
207
|
+
appendTree(parent: BlockId, tree: TreeNode[]): Promise<void>;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Page namespace. Reads need `read-page`; `create` needs `write-page`.
|
|
212
|
+
*/
|
|
213
|
+
export interface PageApi {
|
|
214
|
+
/** List every page in the workspace. */
|
|
215
|
+
list(): Promise<Page[]>;
|
|
216
|
+
/** Create a page (idempotent on slug). */
|
|
217
|
+
create(slug: PageSlug): Promise<void>;
|
|
218
|
+
/**
|
|
219
|
+
* Append a nested tree of blocks to a page (created if missing), all in one
|
|
220
|
+
* turn. This is the way to give a **brand-new page** its first blocks:
|
|
221
|
+
* `create` needs a parent block id that a fresh page has no way to hand you
|
|
222
|
+
* mid-turn (describe→apply), and `appendTree` sidesteps that — the host
|
|
223
|
+
* resolves the page's root and threads child ids through as it builds.
|
|
224
|
+
*/
|
|
225
|
+
appendTree(slug: PageSlug, tree: TreeNode[]): Promise<void>;
|
|
226
|
+
/**
|
|
227
|
+
* @roadmap Not wired yet — calling this throws at runtime in the current
|
|
228
|
+
* outl version. Open a page in the active client view.
|
|
229
|
+
*/
|
|
230
|
+
open(slug: PageSlug): Promise<void>;
|
|
231
|
+
/**
|
|
232
|
+
* @roadmap Not wired yet — calling this throws at runtime in the current
|
|
233
|
+
* outl version. Slug of today's daily note (ISO `YYYY-MM-DD`).
|
|
234
|
+
*/
|
|
235
|
+
today(): Promise<PageSlug>;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* A page as the host hands it to a plugin (read projection).
|
|
240
|
+
*/
|
|
241
|
+
export interface Page {
|
|
242
|
+
/** Stable slug. */
|
|
243
|
+
slug: PageSlug;
|
|
244
|
+
/** Human title. */
|
|
245
|
+
title: string;
|
|
246
|
+
/** `"page"` or `"journal"`. */
|
|
247
|
+
kind: "page" | "journal";
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Template namespace. `list` needs `read-page`; `instantiate` needs `write-page`.
|
|
252
|
+
* See [Templates](../docs/templates.md) for the full guide.
|
|
253
|
+
*/
|
|
254
|
+
export interface TemplateApi {
|
|
255
|
+
/** List every template in the workspace. */
|
|
256
|
+
list(): Promise<Template[]>;
|
|
257
|
+
/** Instantiate a structural template under a target block. */
|
|
258
|
+
instantiate(name: string, targetBlockId: BlockId): Promise<void>;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** A template as the host hands it to a plugin (read projection). */
|
|
262
|
+
export interface Template {
|
|
263
|
+
/** Invocation name (the value of `template::`). */
|
|
264
|
+
name: string;
|
|
265
|
+
/** Page slug. */
|
|
266
|
+
slug: PageSlug;
|
|
267
|
+
/** Declared parameter names (empty for structural templates). */
|
|
268
|
+
params?: string[];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Command namespace. No permission gate — commands are declared up front in
|
|
273
|
+
* `plugin.json` under `contributes.commands`, and the id passed here must match
|
|
274
|
+
* one of them. The handler is fired by a slash menu or a keybinding.
|
|
275
|
+
*/
|
|
276
|
+
export interface CommandsApi {
|
|
277
|
+
register(id: string, handler: () => void | Promise<void>): void;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Config namespace. No permission gate. Returns the user's config for this
|
|
282
|
+
* plugin, already validated against `configSchema` by the host, so the value is
|
|
283
|
+
* safe to trust as `T`.
|
|
284
|
+
*/
|
|
285
|
+
export interface ConfigApi {
|
|
286
|
+
get<T>(): T;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Per-plugin key/value storage. Gated by `storage:local`.
|
|
291
|
+
*
|
|
292
|
+
* Persisted to `.outl/plugins/<id>/storage.json`. Reads see what you wrote in
|
|
293
|
+
* an earlier turn; a write this turn is flushed after your handler returns.
|
|
294
|
+
*
|
|
295
|
+
* **Local-only: this does NOT converge across devices.** It is kept out of the
|
|
296
|
+
* op log on purpose (so it can't inflate the log). If a value ever needs to
|
|
297
|
+
* sync, model it as an Op instead — do not lean on this for shared state.
|
|
298
|
+
*/
|
|
299
|
+
export interface StorageApi {
|
|
300
|
+
get<T = unknown>(key: string): Promise<T | null>;
|
|
301
|
+
set<T = unknown>(key: string, value: T): Promise<void>;
|
|
302
|
+
delete(key: string): Promise<void>;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Network namespace. Gated by `network:<domain>` — every request host is
|
|
307
|
+
* checked against the approved domain rules. A host not covered by an approved
|
|
308
|
+
* permission is refused with `{ ok: false, error }` (not thrown), so handle it.
|
|
309
|
+
* The call is blocking under the hood (on the plugin's own thread); keep
|
|
310
|
+
* `timeoutMs` tight.
|
|
311
|
+
*/
|
|
312
|
+
export interface NetApi {
|
|
313
|
+
fetch(url: string, opts: FetchOptions): Promise<FetchResponse>;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Read-only access to the plugin's own secrets. Gated by the `secrets`
|
|
318
|
+
* permission.
|
|
319
|
+
*
|
|
320
|
+
* Unlike `ctx.config` (plaintext in the lockfile) and `ctx.storage` (plaintext
|
|
321
|
+
* on disk, local-only), secrets live in the **OS keychain** — macOS Keychain,
|
|
322
|
+
* Windows Credential Manager, Linux Secret Service — and never touch the
|
|
323
|
+
* workspace on disk. They are namespaced per plugin, so a plugin can only ever
|
|
324
|
+
* read its own.
|
|
325
|
+
*
|
|
326
|
+
* The plugin only **reads**. The value is set out-of-band by the user, through
|
|
327
|
+
* `outl plugin secret set <id> <key>` or a client's plugin settings. Use this
|
|
328
|
+
* for API tokens and anything you would not want synced or committed with the
|
|
329
|
+
* workspace.
|
|
330
|
+
*/
|
|
331
|
+
export interface SecretsApi {
|
|
332
|
+
/**
|
|
333
|
+
* Read a secret by key, resolving to `null` when it was never set (so the
|
|
334
|
+
* plugin can prompt the user to configure it). Async-typed for
|
|
335
|
+
* forward-compatibility; the runtime resolves it synchronously today.
|
|
336
|
+
*/
|
|
337
|
+
get(key: string): Promise<string | null>;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Structured logging — surfaces in the client's plugin log, prefixed by id. */
|
|
341
|
+
export interface LogApi {
|
|
342
|
+
info(msg: string): void;
|
|
343
|
+
warn(msg: string): void;
|
|
344
|
+
error(msg: string): void;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Lightweight user-facing notifications (toast / status line per client). */
|
|
348
|
+
export interface UiApi {
|
|
349
|
+
/** Show a short message (toast / status line, per client). */
|
|
350
|
+
notify(msg: string): void;
|
|
351
|
+
/**
|
|
352
|
+
* Render ephemeral author-written HTML/JS in a sandboxed iframe overlay.
|
|
353
|
+
* Needs the `ui-render` capability and only runs on GUI clients (desktop,
|
|
354
|
+
* mobile) — the TUI/CLI ignore it.
|
|
355
|
+
*
|
|
356
|
+
* The host never interprets the markup: it runs your string in an iframe with
|
|
357
|
+
* `sandbox="allow-scripts"` (no same-origin, no access to the app DOM,
|
|
358
|
+
* cookies, or workspace), positioned as a full-screen, click-through overlay,
|
|
359
|
+
* and torn down shortly after. Write whatever you want — a confetti burst, a
|
|
360
|
+
* toast, an SVG. It is YOUR creativity, not a fixed catalog of effects.
|
|
361
|
+
*
|
|
362
|
+
* Keep it self-contained: the iframe has no network and no imports, so inline
|
|
363
|
+
* everything (a `<canvas>` + a little JS is plenty for confetti).
|
|
364
|
+
*/
|
|
365
|
+
render(html: string): void;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* The full host API handed to `activate`. Each namespace is gated by the
|
|
370
|
+
* permission noted in its doc; calling into a namespace you did not request (or
|
|
371
|
+
* the user did not approve) rejects at the host boundary, it does not silently
|
|
372
|
+
* no-op.
|
|
373
|
+
*/
|
|
374
|
+
export interface PluginContext {
|
|
375
|
+
ops: OpsApi;
|
|
376
|
+
blocks: BlocksApi;
|
|
377
|
+
page: PageApi;
|
|
378
|
+
template: TemplateApi;
|
|
379
|
+
commands: CommandsApi;
|
|
380
|
+
config: ConfigApi;
|
|
381
|
+
content: ContentApi;
|
|
382
|
+
sync: SyncApi;
|
|
383
|
+
storage: StorageApi;
|
|
384
|
+
secrets: SecretsApi;
|
|
385
|
+
net: NetApi;
|
|
386
|
+
log: LogApi;
|
|
387
|
+
ui: UiApi;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* A sync transport the plugin provides (capability `sync-transport`). You only
|
|
392
|
+
* **transport bytes** — the host hands you the JSONL of locally-authored ops to
|
|
393
|
+
* ship in `push`, and applies whatever JSONL you return from `pull` through the
|
|
394
|
+
* CRDT itself (it never trusts your bytes into the tree raw). Talk to your
|
|
395
|
+
* backend with `ctx.net`. The client drives the cadence (push after local
|
|
396
|
+
* edits, pull on a timer).
|
|
397
|
+
*/
|
|
398
|
+
export interface SyncTransport {
|
|
399
|
+
/** Ship this JSONL of local ops to your backend. */
|
|
400
|
+
push(opsJsonl: string): void;
|
|
401
|
+
/** Return JSONL of remote ops to apply, or `null` if none. */
|
|
402
|
+
pull(): string | null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Register the plugin's sync transport (capability `sync-transport`). */
|
|
406
|
+
export interface SyncApi {
|
|
407
|
+
register(transport: SyncTransport): void;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Descriptor a content transformer returns for a block. */
|
|
411
|
+
export interface TransformResult {
|
|
412
|
+
/**
|
|
413
|
+
* `"text"` — `content` is text/markdown rendered on every client.
|
|
414
|
+
* `"rich"` — `content` is HTML run in a sandboxed iframe (GUI clients only).
|
|
415
|
+
*/
|
|
416
|
+
kind: "text" | "rich";
|
|
417
|
+
/** The rendered content (text or HTML, per `kind`). */
|
|
418
|
+
content: string;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Content transformers. Register a function for a code-fence language; when a
|
|
423
|
+
* client renders a ```<lang> fence it asks the host, which runs your function
|
|
424
|
+
* with the fence body and renders the descriptor you return.
|
|
425
|
+
*
|
|
426
|
+
* Declare the same `lang` (and `kind`) under `contributes.transformers` in
|
|
427
|
+
* `plugin.json` so clients can skip languages no plugin handles. Needs
|
|
428
|
+
* capability `content-transformer:text` (for `kind: "text"`) or
|
|
429
|
+
* `content-transformer:rich` (HTML in a sandboxed iframe, GUI only).
|
|
430
|
+
*
|
|
431
|
+
* The transformer is a pure function — return the descriptor, don't mutate.
|
|
432
|
+
*/
|
|
433
|
+
export interface ContentApi {
|
|
434
|
+
register(lang: string, fn: (body: string) => TransformResult | null): void;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
// Plugin definition
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* The object an author returns from `definePlugin`.
|
|
443
|
+
*
|
|
444
|
+
* Behavior only — all metadata (id, version, permissions, contributes, ...)
|
|
445
|
+
* lives in `plugin.json`, never here, so there is exactly one source of truth
|
|
446
|
+
* for each fact.
|
|
447
|
+
*/
|
|
448
|
+
export interface PluginDefinition {
|
|
449
|
+
/**
|
|
450
|
+
* Called once when the plugin is enabled. Wire up `ctx.ops.onOp` hooks and
|
|
451
|
+
* `ctx.commands.register` handlers here. Throwing aborts activation and the
|
|
452
|
+
* host surfaces the error; it never crashes the client.
|
|
453
|
+
*/
|
|
454
|
+
activate(ctx: PluginContext): void;
|
|
455
|
+
/**
|
|
456
|
+
* Optional cleanup on disable/update/uninstall. The host already drops your
|
|
457
|
+
* registered hooks and commands, so only release things the host can't see
|
|
458
|
+
* (timers, in-flight work).
|
|
459
|
+
*/
|
|
460
|
+
deactivate?(): void;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Define a plugin. Validates the shape and returns it unchanged.
|
|
465
|
+
*
|
|
466
|
+
* This is deliberately thin: it exists so authoring is typed and so a malformed
|
|
467
|
+
* default export fails loudly at module-eval time (a clearer error than the
|
|
468
|
+
* host hitting `undefined.activate` later). It does no host work — the runtime
|
|
469
|
+
* imports the returned object and calls `activate(ctx)` with the injected
|
|
470
|
+
* context.
|
|
471
|
+
*
|
|
472
|
+
* @example
|
|
473
|
+
* export default definePlugin({
|
|
474
|
+
* activate(ctx) {
|
|
475
|
+
* ctx.commands.register("my-command", () => ctx.ui.notify("hi"));
|
|
476
|
+
* },
|
|
477
|
+
* });
|
|
478
|
+
*/
|
|
479
|
+
export function definePlugin(def: PluginDefinition): PluginDefinition {
|
|
480
|
+
if (def === null || typeof def !== "object") {
|
|
481
|
+
throw new TypeError("definePlugin: expected a plugin definition object");
|
|
482
|
+
}
|
|
483
|
+
if (typeof def.activate !== "function") {
|
|
484
|
+
throw new TypeError("definePlugin: `activate` must be a function");
|
|
485
|
+
}
|
|
486
|
+
if (def.deactivate !== undefined && typeof def.deactivate !== "function") {
|
|
487
|
+
throw new TypeError(
|
|
488
|
+
"definePlugin: `deactivate` must be a function when provided",
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
// Hand the definition to the host runtime. The outl engine injects
|
|
492
|
+
// `globalThis.__outl_register` before evaluating the bundle, then calls
|
|
493
|
+
// `activate(ctx)` with the real context. Absent in tooling/tests, so this is
|
|
494
|
+
// a no-op there.
|
|
495
|
+
const host = globalThis as {
|
|
496
|
+
__outl_register?: (d: PluginDefinition) => void;
|
|
497
|
+
};
|
|
498
|
+
host.__outl_register?.(def);
|
|
499
|
+
return def;
|
|
500
|
+
}
|