@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.
Files changed (2) hide show
  1. package/package.json +31 -0
  2. 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
+ }