deck-plugin-sdk 1.12.2 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -94,4 +94,6 @@ needs a newer one, with a sentence rather than an error from inside your module.
94
94
  ## Publishing
95
95
 
96
96
  A plugin is published by being listed in Deck's registry, which points at a release in your own
97
- repository. Until that is open to submissions, ask.
97
+ repository. `deck-plugin publish` makes the release and submits it; `deck-plugin publish --dry-run`
98
+ runs every check the registry will and prints the points the version will get, check by check.
99
+ Everything else is in [Publishing](../docs/guide/publishing.md).
@@ -82,7 +82,8 @@ function copyInto(manifest, defaultTarget) {
82
82
  const { target, dir: plugins } = pluginsDir(defaultTarget);
83
83
  const dest = join(plugins, manifest.id);
84
84
  mkdirSync(dest, { recursive: true });
85
- for (const f of ["plugin.json", "plugin.js", "mcp.js"]) {
85
+ // icon.svg only when plugin.json names it: Deck reads it from beside the manifest.
86
+ for (const f of ["plugin.json", "plugin.js", "mcp.js", ...(manifest.icon === "icon.svg" ? ["icon.svg"] : [])]) {
86
87
  if (existsSync(join(dir, f))) cpSync(join(dir, f), join(dest, f));
87
88
  }
88
89
  return { target, dest };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deck-plugin-sdk",
3
- "version": "1.12.2",
3
+ "version": "1.13.0",
4
4
  "description": "Build a plugin for Deck, outside the Deck repository.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/publish.mjs CHANGED
@@ -8,6 +8,7 @@
8
8
  // the manifest, README, LICENSE and package-lock.json; a clean, pushed git tree; a build from
9
9
  // the lockfile exactly as the registry will repeat it (npm ci, then this SDK's build); the
10
10
  // release's files and sizes; and that its permissions cover what the bundle calls.
11
+ // It also prints the points the registry will give this version (validate.mjs, points()).
11
12
  // Then it tags v<version>, makes the GitHub release with the built files, and opens a pull
12
13
  // request to the registry adding submissions/<id>.json, through the author's fork. The registry's
13
14
  // checks run on that PR and merge it when they pass.
@@ -17,7 +18,7 @@ import { execFileSync } from "node:child_process";
17
18
  import { existsSync, readFileSync, statSync } from "node:fs";
18
19
  import { dirname, join } from "node:path";
19
20
  import { fileURLToPath } from "node:url";
20
- import { checkAssets, checkId, checkLockfile, checkManifest, checkPermissions } from "./validate.mjs";
21
+ import { checkAssets, checkIcon, checkId, checkLockfile, checkManifest, checkPermissions, fileFacts, ICON_FILE, points } from "./validate.mjs";
21
22
 
22
23
  const here = dirname(fileURLToPath(import.meta.url));
23
24
  const REGISTRY = "deck-desktop/deck-registry";
@@ -38,6 +39,10 @@ export async function publish(dir, manifest, { dryRun, buildPlugin }) {
38
39
  // ---- what is in the folder ----
39
40
  errors.push(...checkId(manifest.id ?? ""), ...checkManifest(manifest));
40
41
  if (!existsSync(join(dir, "README.md"))) errors.push("no README.md: it is the plugin's page in Deck and on the website");
42
+ if (manifest.icon === ICON_FILE) {
43
+ if (!existsSync(join(dir, ICON_FILE))) errors.push(`plugin.json's icon is ${ICON_FILE}, and there is none beside it`);
44
+ else errors.push(...checkIcon(readFileSync(join(dir, ICON_FILE), "utf8")));
45
+ }
41
46
  if (!["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING"].some((f) => existsSync(join(dir, f)))) errors.push("no LICENSE: say how others may use it (the scaffold's is MIT)");
42
47
  if (existsSync(join(dir, ".npmrc"))) errors.push("there is an .npmrc: the registry installs from registry.npmjs.org only, so remove it");
43
48
  const lockAt = join(dir, "package-lock.json");
@@ -72,10 +77,10 @@ export async function publish(dir, manifest, { dryRun, buildPlugin }) {
72
77
  if (!npm) { console.error(" ✗ npm was not found beside node"); process.exit(1); }
73
78
  run(process.execPath, [npm, "ci", "--ignore-scripts", "--no-audit", "--no-fund"]);
74
79
  await buildPlugin(dir);
75
- const files = ["plugin.json", "plugin.js", "mcp.js"].filter((f) => existsSync(join(dir, f)));
80
+ const files = ["plugin.json", "plugin.js", "mcp.js", ...(manifest.icon === ICON_FILE ? [ICON_FILE] : [])].filter((f) => existsSync(join(dir, f)));
76
81
  errors.push(...checkAssets("plugin", files.map((f) => ({ name: f, size: statSync(join(dir, f)).size }))));
77
82
  const table = JSON.parse(readFileSync(join(here, "permissions.json"), "utf8"));
78
- const perms = checkPermissions(manifest.permissions, readFileSync(join(dir, "plugin.js"), "utf8"), table);
83
+ const perms = checkPermissions(manifest.permissions, readFileSync(join(dir, "plugin.js"), "utf8"), table, manifest.id);
79
84
  errors.push(...perms.errors);
80
85
  notes.push(...perms.notes);
81
86
  if (errors.length) {
@@ -85,6 +90,17 @@ export async function publish(dir, manifest, { dryRun, buildPlugin }) {
85
90
 
86
91
  say(`\n${manifest.name} ${manifest.version} is ready: ${files.join(", ")}${manifest.permissions.length ? `; it uses ${manifest.permissions.join(", ")}` : ""}.`);
87
92
  for (const n of notes) say(` note: ${n}`);
93
+
94
+ // ---- its points, as the registry will give them ----
95
+ const read = (f) => (existsSync(join(dir, f)) ? readFileSync(join(dir, f), "utf8") : null);
96
+ const tsc = join(dir, "node_modules", "typescript", "bin", "tsc");
97
+ let typechecks = null;
98
+ if (!custom && existsSync(join(dir, "tsconfig.json")) && existsSync(tsc)) typechecks = ok(process.execPath, [tsc, "--noEmit", "-p", "."]);
99
+ let newestSdk = null;
100
+ try { newestSdk = (await (await fetch("https://registry.npmjs.org/deck-plugin-sdk/latest")).json()).version ?? null; } catch { /* offline: scored as not current */ }
101
+ const score = points({ ...fileFacts(read), buildVerified: !custom, typechecks, permissions: manifest.permissions, used: perms.used, newestSdk });
102
+ say(`\nPoints: ${score.total} of 100`);
103
+ for (const c of score.checks) say(` ${c.got ? "✓" : "✗"} ${c.label} (${c.got}/${c.max})${c.fix ? ` — ${c.fix}` : ""}`);
88
104
  if (dryRun) {
89
105
  say(`\nDry run: nothing was tagged, released or submitted. Without --dry-run this tags ${tag}, releases it on ${repo}, and opens a PR to ${REGISTRY}.`);
90
106
  return;
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - First version.
@@ -5,6 +5,6 @@
5
5
  "icon": "Sparkles",
6
6
  "color": "#a78bfa",
7
7
  "version": "0.1.0",
8
- "minDeck": "1.12.2",
8
+ "minDeck": "1.13.0",
9
9
  "permissions": []
10
10
  }
@@ -26,10 +26,20 @@ export interface TextSurface {
26
26
  focus(): void;
27
27
  /** Whether the keyboard is in this text, for the markdown keys (Ctrl+B, Ctrl+I). */
28
28
  hasFocus(): boolean;
29
+ /** Where the selection is on screen (window coordinates), or null when nothing is selected or
30
+ * it is out of view: where a floating toolbar goes. A surface without it never floats. */
31
+ selectionBox?(): {
32
+ left: number;
33
+ right: number;
34
+ top: number;
35
+ bottom: number;
36
+ } | null;
37
+ /** Calls `fn` whenever the selection or the scroll changes; returns the unsubscribe. */
38
+ watch?(fn: () => void): () => void;
29
39
  }
30
40
  /** A TextSurface over a CodeMirror EditorView. */
31
41
  export declare function cmSurface(view: EditorView): TextSurface;
32
- export declare function MarkdownToolbar({ surface, zoom, onZoom, floating, view }: {
42
+ export declare function MarkdownToolbar({ surface, zoom, onZoom, floating }: {
33
43
  /** The editor to drive. Null disables every button — there is nothing to type into. */
34
44
  surface: TextSurface | null;
35
45
  /** Current text size in px. Omit to hide the zoom controls entirely. */
@@ -41,10 +51,11 @@ export declare function MarkdownToolbar({ surface, zoom, onZoom, floating, view
41
51
  *
42
52
  * Omit it and the editor's own "Floating markdown toolbar" setting decides, which is what
43
53
  * every caller should do — this exists only for a surface that has a reason to override it.
44
- * Needs `view`, which is what turns a document offset into screen coordinates.
54
+ * Needs a surface that says where its selection is (selectionBox: both of Deck's do). The bar is
55
+ * placed in its own positioned parent, which should be the box the editor fills.
45
56
  */
46
57
  floating?: boolean;
47
- /** The CodeMirror view, for positioning a floating bar. Ignored otherwise. */
58
+ /** No longer needed: the surface says where its selection is. Kept so plugins passing it still compile. */
48
59
  view?: EditorView | null;
49
60
  }): import("react").JSX.Element;
50
61
  export default MarkdownToolbar;
@@ -844,7 +844,8 @@ export declare const remoteHome: (host: string, sudo: boolean) => Promise<string
844
844
  export declare const sshHosts: () => Promise<string[]>;
845
845
  export declare const fsWrite: (path: string, content: string) => Promise<void>;
846
846
  /** Raw bytes — a PDF, an image, a response — which a string would corrupt. Sent as the raw body,
847
- * the path percent-encoded in a header; a remote path writes over SSH. */
847
+ * the path percent-encoded in a header; a remote path writes over SSH. A local write makes a
848
+ * missing folder first, so a plugin's dataDir() needs no fsCreateDir. */
848
849
  export declare const fsWriteBytes: (path: string, bytes: Uint8Array) => Promise<void>;
849
850
  /** Write bytes without ever clobbering: dedupes the name on collision, returns the path used. */
850
851
  export declare const fsSaveUnique: (saveTo: string, defaultName: string, bytes: Uint8Array) => Promise<string>;
@@ -0,0 +1,3 @@
1
+ // GENERATED by sdk/gen-types.mjs from Deck's source — do not edit.
2
+ /** Once, at startup: the command the buttons run, the buttons, and the tints on every file model. */
3
+ export declare function installConflictLens(): void;
@@ -0,0 +1,23 @@
1
+ // GENERATED by sdk/gen-types.mjs from Deck's source — do not edit.
2
+ /** One block, by the 1-based line numbers of its markers. */
3
+ export interface ConflictBlock {
4
+ start: number;
5
+ /** The `|||||||` line of a diff3 block, else null. */
6
+ base: number | null;
7
+ mid: number;
8
+ end: number;
9
+ }
10
+ export type ConflictPick = "current" | "incoming" | "both";
11
+ /** Every complete block, in order. A block left unclosed is not one: nothing is offered for it. */
12
+ export declare function findConflicts(lines: string[]): ConflictBlock[];
13
+ /** The lines a block resolves to: one side, or both with the current change first. The base never. */
14
+ export declare function resolution(lines: string[], b: ConflictBlock, pick: ConflictPick): string[];
15
+ /**
16
+ * The edit replacing block `b` with `with`, as a 1-based range (start line and column, end line
17
+ * and column) and its text. Whole lines go, end marker included; when nothing is kept the block's
18
+ * lines are removed rather than left as one empty line. `lastCol(n)` is line n's last column.
19
+ */
20
+ export declare function conflictEdit(lineCount: number, lastCol: (n: number) => number, b: ConflictBlock, keep: string[], eol: string): {
21
+ range: readonly [number, number, number, number];
22
+ text: string;
23
+ };
@@ -182,14 +182,13 @@ export declare function setLspOpenFileHandler(fn: OpenFileHandler): void;
182
182
  export declare function stopIdleServers(now?: number): string[];
183
183
  /** Stop all sessions (e.g. on workspace close). */
184
184
  export declare function lspStopAll(): void;
185
- /** Restart one server (UI action from the status popover). */
186
185
  /**
187
186
  * Stop a language server. The next file opened for that root starts a fresh one, which is why
188
- * this is both the Restart action and the memory readout's stop button — there is no difference
189
- * between the two beyond what the caller says about it.
187
+ * this is the Restart action, the popover's × and the memory readout's stop button. `forget`
188
+ * (the last two) shows it as stopped rather than restarting, and lets an edit start it again.
190
189
  *
191
190
  * Deleting the frontend session alongside the process matters: leaving it would send didOpen to
192
191
  * a dead pipe on the next open, and the editor would sit at "ready" with no diagnostics.
193
192
  */
194
- export declare function lspRestart(id: string): Promise<void>;
193
+ export declare function lspRestart(id: string, forget?: boolean): Promise<void>;
195
194
  export {};
@@ -49,6 +49,9 @@ export interface LayoutTab {
49
49
  * the main window. Dropped the moment the tab is moved by hand: then it is one of yours.
50
50
  */
51
51
  transient?: true;
52
+ /** Pinned from its menu (tabops.ts): only its icon, before the unpinned tabs beside it, and left
53
+ * open by Close others, Close to the right and Close all. */
54
+ pinned?: true;
52
55
  }
53
56
  export interface LayoutWorkspace {
54
57
  id: string;
@@ -84,6 +84,12 @@ export declare function reorderTab(tabs: readonly LayoutTab[], srcId: string, ov
84
84
  * (null: none, held over a tab outside any group or over a group's chip). Left out, the tabs
85
85
  * either side decide (settleGroup). */
86
86
  group?: string | null): LayoutTab[];
87
+ /**
88
+ * Each strip's pinned tabs (tabops.ts) before its unpinned ones, each side kept in its order: a drag
89
+ * can neither take a pinned tab among the others nor put another before the pinned ones. Takes a
90
+ * placed list (placeAll), whose zones a reorder of the list cannot change.
91
+ */
92
+ export declare function pinnedFirst(tabs: LayoutTab[]): LayoutTab[];
87
93
  /**
88
94
  * A tab group's tabs sit together in their zone's row: a tab dropped between two of one group's
89
95
  * tabs joins it, and one moved away from every other tab of its own group leaves it. Next to one
@@ -170,9 +176,10 @@ export declare function showActiveTab(w: LayoutWorkspace): LayoutWorkspace;
170
176
  /**
171
177
  * Which tab becomes active when `closing` goes: the one beside it in the same zone (the next, or
172
178
  * the one before when it was last), as a browser does, so closing a tab neither throws you out of
173
- * the zone nor back to its first tab; otherwise the first zone left.
179
+ * the zone nor back to its first tab; otherwise the first zone left. A tab in a folded group is
180
+ * passed over for the nearest one that is not, so closing a tab does not open a group.
174
181
  */
175
- export declare function nextActiveAfterClose(tabs: readonly LayoutTab[], closing: string): string | undefined;
182
+ export declare function nextActiveAfterClose(tabs: readonly LayoutTab[], closing: string, groups?: readonly TabGroup[]): string | undefined;
176
183
  /**
177
184
  * Tear a tab off into window `win`, or (`win` undefined) dock it back. A workspace always keeps
178
185
  * one docked tab: floating its last one leaves a fresh terminal behind, as the main window has
@@ -1,10 +1,13 @@
1
1
  // GENERATED by sdk/gen-types.mjs from Deck's source — do not edit.
2
2
  import { EditorView } from "@codemirror/view";
3
- export declare function MarkdownLive({ value, path, onChange, onSave, onView, flush, fontSize, onWikiLink, resolveWikiLink, wikiLinkOptions, mermaid, resolveEmbed }: {
3
+ export declare function MarkdownLive({ value, path, onChange, onSave, onView, flush, fontSize, onWikiLink, resolveWikiLink, wikiLinkOptions, mermaid, resolveEmbed, onOpenLink }: {
4
4
  value: string;
5
5
  path?: string;
6
6
  onChange: (v: string) => void;
7
7
  onSave?: () => void;
8
+ /** A click on a `[text](url)` link or a bare address, with its address as written. Omit it and a
9
+ * web address opens in Deck's browser, another scheme (mailto:) with the system, a path not at all. */
10
+ onOpenLink?: (href: string) => void;
8
11
  /** Hands the live EditorView out, and null on unmount. For a formatting toolbar, which has to
9
12
  * dispatch into this editor rather than rewrite the string around it. */
10
13
  onView?: (v: EditorView | null) => void;
@@ -0,0 +1,36 @@
1
+ // GENERATED by sdk/gen-types.mjs from Deck's source — do not edit.
2
+ import { type Extension } from "@codemirror/state";
3
+ import { EditorView, WidgetType } from "@codemirror/view";
4
+ /**
5
+ * A rendered mermaid diagram in place of its ```mermaid fence.
6
+ *
7
+ * A StateField rather than part of the ViewPlugin above, because CodeMirror refuses a
8
+ * plugin-supplied decoration that replaces a line break or sets `block: true` — and a fenced
9
+ * block is several lines. A StateField is the documented exception (see decorateTable, which
10
+ * hits the same wall from the other side).
11
+ *
12
+ * Rendering is asynchronous: mermaid parses and lays out, so the widget draws a placeholder of
13
+ * the same width and swaps the SVG in when it resolves, calling requestMeasure so CodeMirror's
14
+ * height map learns the real height rather than staying short by the diagram.
15
+ */
16
+ export declare class MermaidWidget extends WidgetType {
17
+ readonly code: string;
18
+ constructor(code: string);
19
+ eq(o: MermaidWidget): boolean;
20
+ toDOM(view: EditorView): HTMLDivElement;
21
+ destroy(dom: HTMLElement): void;
22
+ ignoreEvent(): boolean;
23
+ get estimatedHeight(): number;
24
+ }
25
+ /** Text from inside a `> ` quote (or a callout) without the quote markers that start each line. */
26
+ export declare const unquote: (s: string) => string;
27
+ /** Load mermaid on first use and render one diagram to SVG. */
28
+ export declare function renderMermaid(id: string, code: string): Promise<string>;
29
+ /**
30
+ * The block-level widgets: a mermaid fence and a `$$maths$$` block.
31
+ *
32
+ * Both live here rather than in the live-preview ViewPlugin because CodeMirror refuses a
33
+ * plugin-supplied decoration that replaces a line break or sets `block: true`, and both span
34
+ * lines. `mermaid` gates only the diagrams — maths is drawn wherever this file is used.
35
+ */
36
+ export declare function blockWidgetField(mermaid: boolean): Extension;
@@ -0,0 +1,9 @@
1
+ // GENERATED by sdk/gen-types.mjs from Deck's source — do not edit.
2
+ import { ViewPlugin, type DecorationSet, type EditorView, type ViewUpdate } from "@codemirror/view";
3
+ /** The code-block colours and copy buttons, as a ViewPlugin beside the live preview's own. */
4
+ export declare function codeBlocksExt(): ViewPlugin<{
5
+ decorations: DecorationSet;
6
+ readonly view: EditorView;
7
+ destroy(): void;
8
+ update(u: ViewUpdate): void;
9
+ }, undefined>;
@@ -0,0 +1,20 @@
1
+ // GENERATED by sdk/gen-types.mjs from Deck's source — do not edit.
2
+ import { Decoration, type DecorationSet, type EditorView } from "@codemirror/view";
3
+ import type { Extension, Range } from "@codemirror/state";
4
+ import type { SyntaxNode } from "@lezer/common";
5
+ export declare const replaceHide: Decoration;
6
+ /**
7
+ * Decorate one GFM Table: hide its syntax and line the columns up.
8
+ *
9
+ * Column widths are applied as `min-width` in `ch` units on the cells, so nothing is written
10
+ * into the document — no padding spaces to undo, and any other editor sees exactly what was typed.
11
+ * That only measures correctly because `.cm-table-l` is monospace.
12
+ *
13
+ * ponytail: assumes leading and trailing pipes (`| a | b |`), which is what the toolbar
14
+ * generates and what Obsidian writes. A borderless `a | b` table still parses, but its first
15
+ * and last columns will not align.
16
+ */
17
+ export declare function decorateTable(view: EditorView, table: SyntaxNode, ranges: Range<Decoration>[]): void;
18
+ /** `trash`: moves a local image's file to the Recycle Bin, for its delete button (MarkdownLive passes bridge.fsTrash). */
19
+ export declare function decorate(view: EditorView, baseDir: string, resolveWiki?: (target: string) => boolean, resolveEmbed?: (name: string) => string | null, trash?: (file: string) => Promise<void>): DecorationSet;
20
+ export declare function livePreviewExt(baseDir: string, resolveWiki?: () => ((target: string) => boolean) | undefined, resolveEmbed?: () => ((name: string) => string | null) | undefined, trash?: (file: string) => Promise<void>): Extension;
@@ -0,0 +1,16 @@
1
+ // GENERATED by sdk/gen-types.mjs from Deck's source — do not edit.
2
+ /** `Pasted image 20261029143005.png`: the time it was pasted, to the second, as Obsidian names them. */
3
+ export declare function pastedImageName(at: Date, mime: string): string;
4
+ /** The link for an image saved beside the note: its name, spaces and all, encoded as a link must be. */
5
+ export declare const imageLink: (savedPath: string) => string;
6
+ export type LinkTarget = {
7
+ url: string;
8
+ } | {
9
+ path: string;
10
+ } | null;
11
+ /**
12
+ * `href` from a note at `notePath`. A web or mail address as it is; a link to a heading in this
13
+ * note (`#part`) is nothing to open; anything else is a file: decoded (`my%20note.md`), its
14
+ * `#heading` dropped, and joined to the note's folder unless it is absolute.
15
+ */
16
+ export declare function linkTarget(href: string, notePath: string): LinkTarget;
@@ -0,0 +1,3 @@
1
+ // GENERATED by sdk/gen-types.mjs from Deck's source — do not edit.
2
+ /** The paste handler, given the note's path (read at the paste, since a new note gets one on its first save). */
3
+ export declare function imagePaste(notePath: () => string | undefined): import("@codemirror/state").Extension;
@@ -0,0 +1,30 @@
1
+ // GENERATED by sdk/gen-types.mjs from Deck's source — do not edit.
2
+ import type { MarkdownConfig } from "@lezer/markdown";
3
+ /**
4
+ * `[[Note]]`, `[[Note|alias]]`, `[[Note#heading]]` as a first-class inline node.
5
+ *
6
+ * A Lezer extension rather than a regex over the text, because CommonMark already claims the
7
+ * brackets: `[[Note]]` parses as a Link whose LinkMark is one bracket per side, and LinkMark is
8
+ * in HIDE_MARKS — so without this a wikilink renders as a literal `[Note]`. Claiming the whole
9
+ * `[[...]]` at PARSE time is what stops that.
10
+ *
11
+ * The alias/heading rule is the one in the Vault plugin's src/vault.ts, which is where it is tested:
12
+ * the alias splits on the LAST pipe, the heading on the FIRST hash of what is left.
13
+ */
14
+ export declare const WikiLinkExt: MarkdownConfig;
15
+ /**
16
+ * `==highlighted==`, Obsidian's marker syntax. Not part of GFM, so it needs its own rule.
17
+ *
18
+ * Two equals signs on each side, no newline between them — the same shape as Strikethrough's
19
+ * `~~`, which is what this is modelled on.
20
+ */
21
+ export declare const HighlightExt: MarkdownConfig;
22
+ /**
23
+ * `$inline$` and `$$block$$` maths, and `#tag`, as Obsidian writes them.
24
+ *
25
+ * Neither is in CommonMark or GFM, so each needs its own inline rule. The dollar rules are
26
+ * deliberately strict about what opens one: a lone `$` in prose ("costs $5") must not start a
27
+ * formula that runs to the end of the paragraph.
28
+ */
29
+ export declare const MathExt: MarkdownConfig;
30
+ export declare const TagExt: MarkdownConfig;
@@ -0,0 +1,20 @@
1
+ // GENERATED by sdk/gen-types.mjs from Deck's source — do not edit.
2
+ /** One table line: its start offset and its text. */
3
+ export interface TableLine {
4
+ from: number;
5
+ text: string;
6
+ }
7
+ /**
8
+ * The edits that add a column at the right of every line, the rule line `---`, the others empty.
9
+ * A line without a closing pipe gets one first, so the new cell is a cell.
10
+ */
11
+ export declare function addColumn(lines: TableLine[], ruleIndex: number): {
12
+ from: number;
13
+ insert: string;
14
+ }[];
15
+ /** The edit that adds an empty row after the last line, as many cells wide as the header; and where its first cell's text starts. */
16
+ export declare function addRow(lines: TableLine[]): {
17
+ from: number;
18
+ insert: string;
19
+ caret: number;
20
+ };
@@ -0,0 +1,110 @@
1
+ // GENERATED by sdk/gen-types.mjs from Deck's source — do not edit.
2
+ import { EditorView, WidgetType } from "@codemirror/view";
3
+ /**
4
+ * Obsidian's callout kinds, folded onto the handful that actually differ in appearance.
5
+ *
6
+ * Obsidian ships around a dozen names, several of which are aliases for the same colour and icon
7
+ * (`error` is `danger`, `tip` is `hint`). Keeping the alias table rather than a class per name
8
+ * means an unknown kind still renders, as a note, instead of falling back to a plain quote.
9
+ */
10
+ export declare const CALLOUT_ALIAS: Record<string, string>;
11
+ /** The glyph for each kind. Text, not an icon font — the editor has no icon set of its own. */
12
+ export declare const CALLOUT_ICON: Record<string, string>;
13
+ /** `[!warning]` with no title shows "Warning". */
14
+ export declare function defaultTitle(raw: string): string;
15
+ /** The icon and, when the callout has no title of its own, the kind's name. */
16
+ export declare class CalloutIcon extends WidgetType {
17
+ readonly kind: string;
18
+ readonly title: string;
19
+ constructor(kind: string, title: string);
20
+ eq(o: CalloutIcon): boolean;
21
+ toDOM(): HTMLSpanElement;
22
+ }
23
+ /** A rendered KaTeX formula in place of its `$...$`. */
24
+ export declare class MathWidget extends WidgetType {
25
+ readonly src: string;
26
+ readonly block: boolean;
27
+ constructor(src: string, block: boolean);
28
+ eq(o: MathWidget): boolean;
29
+ toDOM(view: EditorView): HTMLDivElement | HTMLSpanElement;
30
+ ignoreEvent(): boolean;
31
+ }
32
+ /** Load KaTeX on first use and render one formula. */
33
+ export declare function renderMath(src: string, block: boolean): Promise<string>;
34
+ /** Split `target#heading|alias` the way Obsidian does. See the Vault plugin's src/vault.ts. */
35
+ export declare function splitWikiLink(body: string): {
36
+ target: string;
37
+ heading: string;
38
+ text: string;
39
+ };
40
+ export declare class BulletWidget extends WidgetType {
41
+ readonly ch: string;
42
+ constructor(ch: string);
43
+ eq(o: BulletWidget): boolean;
44
+ toDOM(): HTMLSpanElement;
45
+ }
46
+ export declare class TaskWidget extends WidgetType {
47
+ readonly checked: boolean;
48
+ readonly pos: number;
49
+ constructor(checked: boolean, pos: number);
50
+ eq(o: TaskWidget): boolean;
51
+ toDOM(view: EditorView): HTMLInputElement;
52
+ ignoreEvent(): boolean;
53
+ }
54
+ /**
55
+ * An inline image, with the controls Obsidian puts on one: open it full size, and drag its
56
+ * corner to resize.
57
+ *
58
+ * A resize writes the width back into the link itself (`![[img.png|400]]`), so it travels with
59
+ * the note — Obsidian shows the same width. `from`/`to`
60
+ * are the link's bounds so the widget can rewrite it; `width` is what it currently says.
61
+ */
62
+ export declare class ImageWidget extends WidgetType {
63
+ readonly src: string;
64
+ readonly from: number;
65
+ readonly to: number;
66
+ readonly width: number | null;
67
+ /** Rewrite the link with a new width, or remove the width when null. */
68
+ readonly setWidth: (view: EditorView, from: number, to: number, width: number | null) => void;
69
+ /** Drawn below the revealed link text rather than in place of it. */
70
+ readonly below: boolean;
71
+ /** A file on this PC and how to put it in the Recycle Bin, for the delete button; null for a web image. */
72
+ readonly del: {
73
+ file: string;
74
+ trash: (file: string) => Promise<void>;
75
+ } | null;
76
+ constructor(src: string, from: number, to: number, width: number | null,
77
+ /** Rewrite the link with a new width, or remove the width when null. */
78
+ setWidth: (view: EditorView, from: number, to: number, width: number | null) => void,
79
+ /** Drawn below the revealed link text rather than in place of it. */
80
+ below?: boolean,
81
+ /** A file on this PC and how to put it in the Recycle Bin, for the delete button; null for a web image. */
82
+ del?: {
83
+ file: string;
84
+ trash: (file: string) => Promise<void>;
85
+ } | null);
86
+ eq(o: ImageWidget): boolean;
87
+ toDOM(view: EditorView): HTMLSpanElement;
88
+ }
89
+ /** Show one image full size, over everything in window `win` (the editor's: a floating one too), until it is dismissed. */
90
+ export declare function openLightbox(src: string, win: Window): void;
91
+ /**
92
+ * Rewrite an image link's width in place.
93
+ *
94
+ * Both spellings: `![[file.png|400]]` for an embed and `![alt|400](path)` for a standard link,
95
+ * which is what Obsidian writes for each.
96
+ */
97
+ export declare function writeImageWidth(view: EditorView, from: number, to: number, width: number | null): void;
98
+ /**
99
+ * A table's + : at the right of its header row it adds a column, under its last row a row
100
+ * (tableedit.ts). The table is found from where the button is at the click, so the widget needs no
101
+ * position of its own and is the same widget wherever the table moves.
102
+ */
103
+ export declare class TableAddWidget extends WidgetType {
104
+ readonly kind: "col" | "row";
105
+ readonly edge: boolean;
106
+ /** `edge`: the column + sits on its row's bottom edge, which is the table's middle when it has an even number of rows. */
107
+ constructor(kind: "col" | "row", edge?: boolean);
108
+ eq(o: TableAddWidget): boolean;
109
+ toDOM(view: EditorView): HTMLButtonElement;
110
+ }
package/validate.mjs CHANGED
@@ -11,7 +11,7 @@ export const RESERVED = new Set(["terminals", "editor", "docs", "settings", "dec
11
11
  * Packages the registry's rebuild executes. Each must be the real one from npm, since the rebuild
12
12
  * runs on a machine that can merge; see the SECURITY note in deck-registry's tools/validate.mjs.
13
13
  */
14
- export const BUILD_RUNS = ["deck-plugin-sdk", "esbuild", "react", "lucide-react", "framer-motion"];
14
+ export const BUILD_RUNS = ["deck-plugin-sdk", "esbuild", "react", "lucide-react", "framer-motion", "typescript"];
15
15
  export const MAX_FILE = 10 * 1024 * 1024, MAX_TOTAL = 15 * 1024 * 1024;
16
16
  export const ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
17
17
  export const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
@@ -44,13 +44,41 @@ export function checkManifest(m) {
44
44
  if (typeof m.version !== "string" || !SEMVER.test(m.version)) errors.push("`version` must be a version, such as \"1.0.0\"");
45
45
  if (typeof m.minDeck !== "string" || !SEMVER.test(m.minDeck)) errors.push("`minDeck` must say which Deck it needs, such as \"1.8.0\"");
46
46
  if (m.color !== undefined && !/^#[0-9a-f]{6}$/i.test(m.color)) errors.push("`color` must be #rrggbb");
47
+ if (typeof m.icon === "string" && m.icon.endsWith(".svg") && m.icon !== ICON_FILE) errors.push(`an icon of its own must be named ${ICON_FILE}`);
48
+ return errors;
49
+ }
50
+
51
+ // ---- a plugin's own icon ----
52
+ // `"icon": "icon.svg"` in plugin.json, in place of a lucide name: a file in the package, drawn in
53
+ // one colour (the plugin's) as a mask, the way Deck draws its own icons, so it never runs and
54
+ // follows the theme. Only shapes are allowed in it.
55
+
56
+ /** The one file name a plugin's own icon may have. */
57
+ export const ICON_FILE = "icon.svg";
58
+ /** The largest icon accepted: an icon is a few shapes, and the catalogue carries it inline. */
59
+ export const MAX_ICON = 16 * 1024;
60
+ const ICON_TAGS = new Set(["svg", "g", "path", "circle", "rect", "line", "polyline", "polygon", "ellipse", "title", "desc"]);
61
+
62
+ /** What is wrong with an icon.svg's text: [] when it is only shapes, with a viewBox, small enough. */
63
+ export function checkIcon(svg) {
64
+ const errors = [];
65
+ if (Buffer.byteLength(svg) > MAX_ICON) errors.push(`${ICON_FILE} is ${(Buffer.byteLength(svg) / 1024).toFixed(1)} KB; an icon may be 16 KB at most`);
66
+ const body = svg.replace(/^\uFEFF?\s*(<\?xml[^>]*\?>\s*)?/, "").replace(/<!--[\s\S]*?-->/g, "").trim();
67
+ if (!/^<svg[\s>]/.test(body)) errors.push(`${ICON_FILE} must be an <svg> element`);
68
+ if (!/<svg[^>]*\sviewBox\s*=/.test(body)) errors.push(`${ICON_FILE} needs a viewBox, so it scales to the size it is drawn at`);
69
+ if (/<!|<\?/.test(body)) errors.push(`${ICON_FILE} may not hold a DOCTYPE, an entity or a processing instruction`);
70
+ const tags = [...new Set([...body.matchAll(/<\/?\s*([\w:-]+)/g)].map((m) => m[1]))].filter((t) => !ICON_TAGS.has(t));
71
+ if (tags.length) errors.push(`${ICON_FILE} may hold only shapes; remove ${tags.map((t) => `<${t}>`).join(", ")}`);
72
+ const attrs = [...new Set([...body.matchAll(/\s([\w:-]+)\s*=/g)].map((m) => m[1]))].filter((a) => /^on|^(href|xlink:href|style)$/i.test(a));
73
+ if (attrs.length) errors.push(`${ICON_FILE} may not have ${attrs.join(", ")}: no scripts, links or styles`);
74
+ if (/url\s*\(|javascript:|data:/i.test(body)) errors.push(`${ICON_FILE} may not point at anything outside itself (url(), data: or javascript:)`);
47
75
  return errors;
48
76
  }
49
77
 
50
78
  /** The release's files: the allowed names, the required ones, and the size limits. */
51
79
  export function checkAssets(kind, assets) {
52
80
  const errors = [];
53
- const ok = (n) => kind === "plugin" ? ["plugin.json", "plugin.js", "mcp.js"].includes(n) : n === "language.json" || /^snippets--[\w.-]+\.json$/.test(n);
81
+ const ok = (n) => kind === "plugin" ? ["plugin.json", "plugin.js", "mcp.js", ICON_FILE].includes(n) : n === "language.json" || /^snippets--[\w.-]+\.json$/.test(n);
54
82
  for (const a of assets) if (!ok(a.name)) errors.push(`the release has \`${a.name}\`, which a ${kind} cannot contain`);
55
83
  for (const need of kind === "plugin" ? ["plugin.json", "plugin.js"] : ["language.json"]) {
56
84
  if (!assets.some((a) => a.name === need)) errors.push(`the release has no \`${need}\``);
@@ -86,8 +114,14 @@ export function checkLockfile(lock) {
86
114
  * calls the bridge as `b().fsWrite(`, which minifying keeps, so `().fn(` is the pattern, and a
87
115
  * method of the same name on something else (a RegExp's .exec) does not count. Reaching Deck's
88
116
  * internals directly, around the bridge, is refused outright.
117
+ *
118
+ * With the plugin's `id`, a config named in full (`"state/term-procs"`, another
119
+ * plugin's `data/<id>/…`) needs "deck", as Deck's bridge asks at run time: a plain name is kept in
120
+ * the plugin's own folder, a name with a "/" is one of Deck's or another plugin's. Only names
121
+ * written out whole are seen (a name built at run time is not), and one only compared against, as
122
+ * a config-changed listener does, counts too: such a plugin almost always reads it.
89
123
  */
90
- export function checkPermissions(declared, bundle, table) {
124
+ export function checkPermissions(declared, bundle, table, id) {
91
125
  const errors = [], notes = [];
92
126
  if (!Array.isArray(declared)) {
93
127
  return { errors: ['plugin.json must say what it uses: "permissions": [...], or [] when it needs nothing beyond its own data and settings'], notes };
@@ -104,9 +138,89 @@ export function checkPermissions(declared, bundle, table) {
104
138
  for (const [mod, p] of Object.entries(table.modules)) {
105
139
  if (new RegExp(`__deck\\?\\.${mod}\\b`).test(bundle)) needs.set(p, [...(needs.get(p) ?? []), mod]);
106
140
  }
141
+ if (id) {
142
+ // A minified bundle keeps the name but not the call's, so the names are what is looked for.
143
+ for (const [, name] of bundle.matchAll(/["'`]((?:settings|state|cache|secrets|logs)\/[\w./-]+|data\/[\w-]+\/[\w./-]*)["'`]/g)) {
144
+ const seen = needs.get("deck") ?? [], what = `the config "${name}"`;
145
+ if (!name.startsWith(`data/${id}/`) && !seen.includes(what)) needs.set("deck", [...seen, what]);
146
+ }
147
+ }
107
148
  for (const [p, fns] of needs) {
108
149
  if (!declared.includes(p)) errors.push(`it uses ${fns.slice(0, 4).join(", ")}${fns.length > 4 ? ` and ${fns.length - 4} more` : ""}, which ${fns.length > 1 ? "need" : "needs"} the "${p}" permission; add it to "permissions"`);
109
150
  }
110
151
  if (/\beval\(|new Function\(/.test(bundle)) notes.push("Its bundle uses eval or new Function, which runs code made at run time; a library may do this legitimately.");
111
- return { errors, notes };
152
+ return { errors, notes, used: [...needs.keys()] };
153
+ }
154
+
155
+ // ---- points ----
156
+ // A version's points: advice for whoever is choosing a plugin, never a gate (the checks above are
157
+ // that). Worked out once when it is published, by the registry for a submission and by
158
+ // `deck-plugin publish --dry-run` on the author's machine, from the same facts, so they agree.
159
+
160
+ /** The files a licence may be in, in the order they are looked for. */
161
+ export const LICENSE_FILES = ["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING"];
162
+ /** Open-source licences, each by a phrase its text always has. */
163
+ const LICENSES = [
164
+ ["MIT", /Permission is hereby granted, free of charge/i],
165
+ ["Apache-2.0", /Apache License[\s\S]{0,40}Version 2\.0/i],
166
+ ["BSD", /Redistribution and use in source and binary forms/i],
167
+ ["ISC", /Permission to use, copy, modify, and\/or distribute this software for any\s+purpose/i],
168
+ ["MPL-2.0", /Mozilla Public License,? (?:v\. |Version )2\.0/i],
169
+ ["GPL", /GNU (?:Affero |Lesser )?General Public License/i],
170
+ ["Unlicense", /This is free and unencumbered software released into the public domain/i],
171
+ ];
172
+ export const licenseOf = (text) => LICENSES.find(([, re]) => re.test(text))?.[0] ?? null;
173
+ /** A README that shows the plugin: a markdown or HTML image, or a video. */
174
+ export const showsMedia = (md) => /!\[[^\]]*\]\(\s*[^)\s]|<img\s[^>]*src=|<video\s/i.test(md);
175
+
176
+ /**
177
+ * The facts that need only the package's files. `read(name)` gives a file's text at the tag, or
178
+ * null when there is none.
179
+ */
180
+ export function fileFacts(read) {
181
+ const licence = LICENSE_FILES.map(read).find((t) => t != null);
182
+ let lock = null;
183
+ try { lock = JSON.parse(read("package-lock.json") ?? "null"); } catch { /* not a lockfile, so none */ }
184
+ return {
185
+ license: licence == null ? null : licenseOf(licence),
186
+ changelog: read("CHANGELOG.md") != null,
187
+ readmeMedia: showsMedia(read("README.md") ?? ""),
188
+ lockfile: !!lock && !checkLockfile(lock).length,
189
+ sdk: lock?.packages?.["node_modules/deck-plugin-sdk"]?.version ?? null,
190
+ };
191
+ }
192
+
193
+ /** How many minor versions `v` is behind `newest`: Infinity across a major. */
194
+ function minorsBehind(v, newest) {
195
+ const [a, b] = [v, newest].map((x) => x.split("-")[0].split(".").map(Number));
196
+ if (a[0] !== b[0]) return a[0] > b[0] ? 0 : Infinity;
197
+ return Math.max(0, b[1] - a[1]);
198
+ }
199
+
200
+ /**
201
+ * A version's points out of 100, check by check. `f` is fileFacts plus what the registry learns by
202
+ * building it: buildVerified, typechecks (true, false, or null when it could not be run), the
203
+ * permissions declared (null when plugin.json has none) and used, and the newest SDK on npm.
204
+ * A check not earned says how to earn it.
205
+ */
206
+ export function points(f) {
207
+ const unused = f.permissions ? f.permissions.filter((p) => !f.used.includes(p)) : [];
208
+ const checks = [
209
+ ["readme-media", "The README shows it, with a screenshot or other image", 10, f.readmeMedia,
210
+ "Add a screenshot to README.md"],
211
+ ["changelog", "It has a changelog", 10, f.changelog, "Add a CHANGELOG.md saying what each version changed"],
212
+ ["license", "It has an open-source licence", 10, !!f.license, "Add a LICENSE with an open-source licence, such as MIT"],
213
+ ["lockfile", "Its lockfile installs only from npm, every package with a hash", 10, f.lockfile,
214
+ "Commit a package-lock.json made by npm 7 or newer, with nothing linked or from elsewhere"],
215
+ ["build-verified", "The registry rebuilt it from its source and got the same files", 10, f.buildVerified,
216
+ "Release what `npm ci && npm run build` makes at the tag, with no deck-plugin.config.mjs"],
217
+ ["permissions", "It says what it uses", 10, Array.isArray(f.permissions), "List what it uses in plugin.json's \"permissions\""],
218
+ ["least-privilege", "It asks for nothing it does not use", 10, Array.isArray(f.permissions) && !unused.length,
219
+ unused.length ? `Remove ${unused.join(", ")} from "permissions": its code never uses ${unused.length > 1 ? "them" : "it"}` : "List what it uses in plugin.json's \"permissions\""],
220
+ ["typechecks", "It type-checks", 20, f.typechecks === true,
221
+ f.typechecks === null ? "Let the registry rebuild it, with a tsconfig.json, so it can be type-checked" : "Make `npx tsc --noEmit` pass"],
222
+ ["up-to-date", "It is built with a current SDK", 10, !!(f.sdk && f.newestSdk && minorsBehind(f.sdk, f.newestSdk) <= 1),
223
+ `Build it with deck-plugin-sdk ${f.newestSdk ?? "at its newest"}`],
224
+ ].map(([id, label, max, ok, fix]) => ({ id, label, max, got: ok ? max : 0, ...(ok ? {} : { fix }) }));
225
+ return { total: checks.reduce((n, c) => n + c.got, 0), checks };
112
226
  }