deck-plugin-sdk 1.12.3 → 1.14.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
@@ -62,8 +62,22 @@ actually exposes rather than a hand-written approximation that has drifted.
62
62
  Any other npm package is `npm install` and import: the build bundles it into `plugin.js`, with the
63
63
  images, fonts and CSS it brings. Writing a plugin, in the Docs, says what cannot work that way.
64
64
 
65
- When the build needs more than that (a worker bundled into the plugin, a dependency's locales left
66
- out), export esbuild plugins from `deck-plugin.config.mjs` beside `plugin.json`:
65
+ Two things a build often needs are settings, which the registry can still rebuild:
66
+
67
+ - **A worker of your own:** `import code from "./job.worker.ts?worker"` bundles that file and what
68
+ it imports into one script and gives its text, to start with
69
+ `new Worker(URL.createObjectURL(new Blob([code])))`.
70
+ - **Files a package brings that you never use** (a library's translations into every language):
71
+ regular expressions in `package.json`, matched against each file's path, and each match is
72
+ bundled empty:
73
+
74
+ ```json
75
+ "deckPlugin": { "leaveOut": ["/locales/(?!en-)[^/]+\\.js$"] }
76
+ ```
77
+
78
+ When the build needs more than that, export esbuild plugins from `deck-plugin.config.mjs` beside
79
+ `plugin.json`. The registry does not run your code, so it cannot rebuild a plugin that has one:
80
+ it is listed as build not verified, and misses those points.
67
81
 
68
82
  ```js
69
83
  export const esbuildPlugins = [{ name: "my-step", setup(build) { /* ... */ } }];
@@ -94,4 +108,6 @@ needs a newer one, with a sentence rather than an error from inside your module.
94
108
  ## Publishing
95
109
 
96
110
  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.
111
+ repository. `deck-plugin publish` makes the release and submits it; `deck-plugin publish --dry-run`
112
+ runs every check the registry will and prints the points the version will get, check by check.
113
+ 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/build-plugin.mjs CHANGED
@@ -17,7 +17,7 @@ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, write
17
17
  import { createRequire } from "node:module";
18
18
  import { dirname, join } from "node:path";
19
19
  import { fileURLToPath, pathToFileURL } from "node:url";
20
- import { assetLoaders, fromPackage, packageCss, rawImports } from "./packages.mjs";
20
+ import { assetLoaders, fromPackage, leaveOut, packageCss, rawImports, workerImports } from "./packages.mjs";
21
21
 
22
22
  const sdk = dirname(fileURLToPath(import.meta.url));
23
23
  const require = createRequire(import.meta.url);
@@ -58,11 +58,15 @@ export async function buildPlugin(dir, { entry = "plugin.tsx" } = {}) {
58
58
  }
59
59
  const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
60
60
 
61
- // A plugin that needs more from esbuild than this build gives (a worker bundled into it, a
62
- // dependency's locales left out) exports `esbuildPlugins` from deck-plugin.config.mjs beside
63
- // plugin.json. They run after Deck's own, so they never take over react or the shim.
61
+ // What a plugin needs beyond the plain build is a setting where it can be: `?worker` imports, and
62
+ // `"deckPlugin": { "leaveOut": [...] }` in package.json (packages.mjs). Those run none of the
63
+ // plugin's code, so the registry can rebuild it. Anything more exports `esbuildPlugins` from
64
+ // deck-plugin.config.mjs beside plugin.json, which runs after Deck's own, so it never takes over
65
+ // react or the shim, and which the registry will not run.
64
66
  const configPath = join(dir, "deck-plugin.config.mjs");
65
67
  const config = existsSync(configPath) ? await import(pathToFileURL(configPath).href) : {};
68
+ const pkgPath = join(dir, "package.json");
69
+ const settings = (existsSync(pkgPath) ? JSON.parse(readFileSync(pkgPath, "utf8")).deckPlugin : null) ?? {};
66
70
 
67
71
  const sources = readdirSync(join(dir, "src"), { recursive: true })
68
72
  .filter((f) => typeof f === "string" && /\.tsx?$/.test(f))
@@ -150,7 +154,7 @@ ${names.map((n) => `export const ${n} = __src.${n};`).join("\n")}
150
154
  .map((k) => k.replace(/[/\\^$*+?.()|[\]{}]/g, "\\$&")).join("|")})$`);
151
155
  b.onResolve({ filter }, (a) => (fromPackage(a, dir) ? undefined : { path: shims[a.path] }));
152
156
  },
153
- }, rawImports, packageCss(manifest.id, dir), ...(config.esbuildPlugins ?? [])],
157
+ }, rawImports, workerImports, leaveOut(settings.leaveOut), packageCss(manifest.id, dir), ...(config.esbuildPlugins ?? [])],
154
158
  logLevel: "info",
155
159
  });
156
160
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deck-plugin-sdk",
3
- "version": "1.12.3",
3
+ "version": "1.14.0",
4
4
  "description": "Build a plugin for Deck, outside the Deck repository.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/packages.mjs CHANGED
@@ -105,3 +105,47 @@ export const rawImports = {
105
105
  b.onLoad({ filter: /.*/, namespace: "deck-raw" }, (args) => ({ contents: readFileSync(args.path), loader: args.pluginData?.as === "base64" ? "base64" : "text" }));
106
106
  },
107
107
  };
108
+
109
+ /**
110
+ * `import code from "./x.worker.ts?worker"` bundles that file and what it imports into one script
111
+ * and gives its text, for a Worker started from a Blob URL: a plugin is one plugin.js, so the
112
+ * worker cannot be a file beside it. Only esbuild runs, as for the plugin itself.
113
+ */
114
+ export const workerImports = {
115
+ name: "deck-worker",
116
+ setup(b) {
117
+ b.onResolve({ filter: /\?worker$/ }, async (args) => {
118
+ const r = await b.resolve(args.path.slice(0, -"?worker".length), { kind: args.kind, resolveDir: args.resolveDir, importer: args.importer });
119
+ if (r.errors.length) return { errors: r.errors };
120
+ return { path: r.path, namespace: "deck-worker" };
121
+ });
122
+ b.onLoad({ filter: /.*/, namespace: "deck-worker" }, async (args) => {
123
+ const out = await b.esbuild.build({ entryPoints: [args.path], bundle: true, write: false, format: "iife", target: "es2022", minify: true, metafile: true });
124
+ return { contents: out.outputFiles[0].text, loader: "text", watchFiles: Object.keys(out.metafile.inputs) };
125
+ });
126
+ },
127
+ };
128
+
129
+ /**
130
+ * `"deckPlugin": { "leaveOut": [...] }` in package.json: regular expressions, and a file whose path
131
+ * (with forward slashes) matches one is bundled as an empty module. For what a package bundles but
132
+ * the plugin never uses, such as a library's translations into every language. Matched on load
133
+ * rather than on import, so a file reached by a pattern import (`import(\`./locales/${l}.js\`)`) is
134
+ * left out too. A setting rather than code, so the registry can still rebuild the plugin.
135
+ */
136
+ export function leaveOut(patterns = []) {
137
+ if (!Array.isArray(patterns) || patterns.some((p) => typeof p !== "string" || p.length > 200)) {
138
+ throw new Error('package.json\'s "deckPlugin.leaveOut" must be a list of regular expressions, each under 200 characters');
139
+ }
140
+ const res = patterns.map((p) => new RegExp(p));
141
+ return {
142
+ name: "deck-leave-out",
143
+ setup(b) {
144
+ if (!res.length) return;
145
+ b.onLoad({ filter: /.*/ }, (a) => {
146
+ const path = a.path.replace(/\\/g, "/");
147
+ return res.some((r) => r.test(path)) ? { contents: "export default {};", loader: "js" } : undefined;
148
+ });
149
+ },
150
+ };
151
+ }
package/permissions.json CHANGED
@@ -112,6 +112,7 @@
112
112
  "fsTrash": "files",
113
113
  "fsWrite": "files",
114
114
  "fsWriteBytes": "files",
115
+ "ghAvatar": "network",
115
116
  "ghPrLineComment": "network",
116
117
  "ghPrThreads": "network",
117
118
  "ghRun": "processes",
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;
package/shim/bridge.js CHANGED
@@ -121,6 +121,7 @@ export const fsTrash = (...a) => b().fsTrash(...a);
121
121
  export const fsWrite = (...a) => b().fsWrite(...a);
122
122
  export const fsWriteBytes = (...a) => b().fsWriteBytes(...a);
123
123
  export const getAutostart = (...a) => b().getAutostart(...a);
124
+ export const ghAvatar = (...a) => b().ghAvatar(...a);
124
125
  export const ghPrLineComment = (...a) => b().ghPrLineComment(...a);
125
126
  export const ghPrThreads = (...a) => b().ghPrThreads(...a);
126
127
  export const ghRun = (...a) => b().ghRun(...a);
package/shim/p2p.js CHANGED
@@ -10,7 +10,7 @@ const t = () => {
10
10
  return x;
11
11
  };
12
12
 
13
- export const cleanCode = (...a) => t().cleanCode(...a);
14
- export const newCode = (...a) => t().newCode(...a);
15
- export const selfId = (...a) => t().selfId(...a);
16
- export const share = (...a) => t().share(...a);
13
+ export const myTeamId = (...a) => t().myTeamId(...a);
14
+ export const subscribeTeams = (...a) => t().subscribeTeams(...a);
15
+ export const teamChannel = (...a) => t().teamChannel(...a);
16
+ export const teamsView = (...a) => t().teamsView(...a);
@@ -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.3",
8
+ "minDeck": "1.14.0",
9
9
  "permissions": []
10
10
  }
@@ -14,3 +14,5 @@ declare module "*.wasm" { const bytes: Uint8Array; export default bytes; }
14
14
  declare module "*?raw" { const text: string; export default text; }
15
15
  // `import data from "some/file?base64"`: any file's bytes, as a base64 string.
16
16
  declare module "*?base64" { const base64: string; export default base64; }
17
+ // `import code from "./job.worker.ts?worker"`: that worker, bundled, as the text of a script.
18
+ declare module "*?worker" { const code: string; export default code; }
@@ -1,117 +1,3 @@
1
1
  // GENERATED by sdk/gen-types.mjs from Deck's source — do not edit.
2
- /** What can travel: anything JSON can carry. */
3
- export type Json = string | number | boolean | null | Json[] | {
4
- [k: string]: Json;
5
- };
6
- /** Whether someone may change what is shared, or only watch. */
7
- export type Mode = "edit" | "view";
8
- /** Who may change things: a default for everyone, and a choice per person that wins over it. */
9
- export interface Roles {
10
- def: Mode;
11
- per: Record<string, Mode>;
12
- }
13
- /** Why a share ended for a joiner: the host stopped, vanished, removed this Deck, turned it away,
14
- * or speaks another version. */
15
- export type Gone = "ended" | "lost" | "removed" | "denied" | "version";
16
- export interface Person {
17
- id: string;
18
- name: string; /** Round trip in ms, once measured. */
19
- ms?: number;
20
- }
21
- /** What a share's bar and dialogs show. Replaced, never mutated, so React sees a change. */
22
- export interface ShareView {
23
- /** Who is in and connected: on the host, everyone let in; on a joiner, the host and the others. */
24
- peers: Person[];
25
- /** On the host: who is asking to be let in. */
26
- knocking: Person[];
27
- /** On a joiner: the host is there but has not let this Deck in yet. */
28
- waiting: boolean;
29
- /** On a joiner: no host heard from for a while (wrong code, or a network that blocks it). */
30
- stuck: boolean;
31
- gone: Gone | null;
32
- hostName: string;
33
- /** A passing line: someone on another version, or whatever the plugin `say`s. */
34
- note: string;
35
- roles: Roles;
36
- /** Whether this Deck may change things. */
37
- canEdit: boolean;
38
- }
39
- export interface ShareOptions {
40
- /** What the share is for, e.g. "canvas-share". Decks only meet others using the same one. */
41
- app: string;
42
- /** The share code, which names the room (see newCode). */
43
- code: string;
44
- /** Optional extra secret, needed with the code to connect at all. Sharing works without one. */
45
- password?: string;
46
- /** The plugin's message channel, at most 12 bytes (Trystero's cap). Defaults to "msg". */
47
- channel?: string;
48
- /** Whether this Deck is the one sharing. */
49
- host: boolean;
50
- /** Shown to the others: in the host's knock, on cursors, in the list of who is here. */
51
- name: string;
52
- /** The plugin's own protocol version. Decks on different versions do not share. */
53
- version: number;
54
- /** The host's default for who may change things. Joiners may until the host says otherwise. */
55
- mode?: Mode;
56
- }
57
- export interface ShareHandlers<T> {
58
- /** A plugin message, only ever from someone let in (and the host). Check `mayEdit(peer)` before
59
- * applying a change: a view-only person's edits are to be dropped by every Deck. */
60
- message: (msg: T, peer: string) => void;
61
- /** On the host: someone was just let in. Send them what they need to start, e.g. the board. */
62
- welcome?: (peer: string) => void;
63
- /** Someone left or was removed: forget what was kept for them (a cursor). */
64
- left?: (peer: string) => void;
65
- }
66
- export interface P2pShare<T> {
67
- readonly host: boolean;
68
- /** Send a plugin message to everyone let in, or to one of them. Sends nothing before this Deck is
69
- * let in. Settles once handed to the connection, so awaiting it paces the sender. */
70
- send(msg: T, to?: string): Promise<void>;
71
- /** Whether a peer may change things: the host always, anyone else by the roles. */
72
- mayEdit(peer: string): boolean;
73
- /** The host: let someone in, or turn them away. */
74
- allow(peer: string): void;
75
- deny(peer: string): void;
76
- /** The host: remove someone who was let in. They cannot come back with the same Deck. */
77
- kick(peer: string): void;
78
- /** The host: everyone's default, and one person's own choice (`null` follows the default). */
79
- setDefault(mode: Mode): void;
80
- setPerson(peer: string, mode: Mode | null): void;
81
- /** Show a passing line in the view for a few seconds. */
82
- say(note: string): void;
83
- /** Leave. With `announce` (the host ending on purpose) everyone is told first, so they end at
84
- * once rather than when the connection times out. Safe to call twice. */
85
- close(announce?: boolean): Promise<void>;
86
- /** For useSyncExternalStore. */
87
- subscribe(fn: () => void): () => void;
88
- getView(): ShareView;
89
- }
90
- /** The part of a Trystero room a share uses: a seam, so the check can run a share without a network. */
91
- export interface RoomLike {
92
- /** This Deck's id in the room; Trystero's own is `selfId()`. */
93
- selfId?: string;
94
- makeAction(name: string, config: {
95
- onMessage: (data: any, ctx: {
96
- peerId: string;
97
- }) => void;
98
- }): {
99
- send(data: any, options?: {
100
- target?: string | string[];
101
- }): Promise<void>;
102
- };
103
- ping(peer: string): Promise<number>;
104
- leave(): Promise<void>;
105
- getPeers(): Record<string, unknown>;
106
- onPeerJoin: ((peer: string) => void) | null;
107
- onPeerLeave: ((peer: string) => void) | null;
108
- }
109
- /** This Deck's id in every share: the one the others know it by. The same for the whole session,
110
- * so someone let in who rejoins after a dropped connection is let straight back in. */
111
- export declare function selfId(): string;
112
- /** Start sharing (`host`), or ask to join someone's share. */
113
- export declare function share<T extends Json>(opts: ShareOptions, on: ShareHandlers<T>, room?: RoomLike): P2pShare<T>;
114
- /** A share code: 16 random characters in four groups, easy to read out or paste. */
115
- export declare function newCode(random?: (n: number) => Uint8Array): string;
116
- /** A pasted code, tidied: spaces dropped, case folded. Empty when it is not a code. */
117
- export declare function cleanCode(text: string): string;
2
+ export type { Json } from "./p2pshare";
3
+ export { teamChannel, teamsView, subscribeTeams, myTeamId, type Member, type TeamView, type TeamChannel, type ChannelHandlers } from "./teams";
@@ -0,0 +1,131 @@
1
+ // GENERATED by sdk/gen-types.mjs from Deck's source — do not edit.
2
+ /** What can travel: anything JSON can carry. */
3
+ export type Json = string | number | boolean | null | Json[] | {
4
+ [k: string]: Json;
5
+ };
6
+ /** Whether someone may change what is shared, or only watch. */
7
+ export type Mode = "edit" | "view";
8
+ /** Who may change things: a default for everyone, and a choice per person that wins over it. */
9
+ export interface Roles {
10
+ def: Mode;
11
+ per: Record<string, Mode>;
12
+ }
13
+ /** Why a share ended for a joiner: the host stopped, vanished, removed this Deck, turned it away,
14
+ * or speaks another version. */
15
+ export type Gone = "ended" | "lost" | "removed" | "denied" | "version";
16
+ export interface Person {
17
+ id: string;
18
+ name: string; /** Round trip in ms, once measured. */
19
+ ms?: number;
20
+ }
21
+ /** What a share's bar and dialogs show. Replaced, never mutated, so React sees a change. */
22
+ export interface ShareView {
23
+ /** Who is in and connected: on the host, everyone let in; on a joiner, the host and the others. */
24
+ peers: Person[];
25
+ /** On the host: who is asking to be let in. */
26
+ knocking: Person[];
27
+ /** On a joiner: the host is there but has not let this Deck in yet. */
28
+ waiting: boolean;
29
+ /** On a joiner: no host heard from for a while (wrong code, or a network that blocks it). */
30
+ stuck: boolean;
31
+ gone: Gone | null;
32
+ hostName: string;
33
+ /** A passing line: someone on another version, or whatever the plugin `say`s. */
34
+ note: string;
35
+ roles: Roles;
36
+ /** Whether this Deck may change things. */
37
+ canEdit: boolean;
38
+ }
39
+ export interface ShareOptions {
40
+ /** What the share is for, e.g. "canvas-share". Decks only meet others using the same one. */
41
+ app: string;
42
+ /** The share code, which names the room (see newCode). */
43
+ code: string;
44
+ /** Optional extra secret, needed with the code to connect at all. Sharing works without one. */
45
+ password?: string;
46
+ /** The plugin's message channel, at most 12 bytes (Trystero's cap). Defaults to "msg". */
47
+ channel?: string;
48
+ /** Whether this Deck is the one sharing. */
49
+ host: boolean;
50
+ /** Shown to the others: in the host's knock, on cursors, in the list of who is here. */
51
+ name: string;
52
+ /** The plugin's own protocol version. Decks on different versions do not share. */
53
+ version: number;
54
+ /** The host's default for who may change things. Joiners may until the host says otherwise. */
55
+ mode?: Mode;
56
+ /**
57
+ * The host: who is let in, decided here rather than by a knock. Asked for each Deck that says
58
+ * hello: true lets it straight in, false turns it away (no knock at all). For a team share
59
+ * (teams.ts): only a Deck checked as a current member of the team, so a code and password that
60
+ * reached anyone else, or a member removed since, let no one in.
61
+ */
62
+ admit?: (peer: string) => boolean;
63
+ /**
64
+ * A joiner: who may be the host. Without it, the first Deck that says it hosts is believed. For a
65
+ * team share: only the member who sent the invite.
66
+ */
67
+ trustHost?: (peer: string) => boolean;
68
+ /** The largest message taken from anyone, in bytes (12 MB unless set). */
69
+ maxBytes?: number;
70
+ }
71
+ export interface ShareHandlers<T> {
72
+ /** A plugin message, only ever from someone let in (and the host). Check `mayEdit(peer)` before
73
+ * applying a change: a view-only person's edits are to be dropped by every Deck. */
74
+ message: (msg: T, peer: string) => void;
75
+ /** On the host: someone was just let in. Send them what they need to start, e.g. the board. */
76
+ welcome?: (peer: string) => void;
77
+ /** Someone left or was removed: forget what was kept for them (a cursor). */
78
+ left?: (peer: string) => void;
79
+ }
80
+ export interface P2pShare<T> {
81
+ readonly host: boolean;
82
+ /** Send a plugin message to everyone let in, or to one of them. Sends nothing before this Deck is
83
+ * let in. Settles once handed to the connection, so awaiting it paces the sender. */
84
+ send(msg: T, to?: string): Promise<void>;
85
+ /** Whether a peer may change things: the host always, anyone else by the roles. */
86
+ mayEdit(peer: string): boolean;
87
+ /** The host: let someone in, or turn them away. */
88
+ allow(peer: string): void;
89
+ deny(peer: string): void;
90
+ /** The host: remove someone who was let in. They cannot come back with the same Deck. */
91
+ kick(peer: string): void;
92
+ /** The host: everyone's default, and one person's own choice (`null` follows the default). */
93
+ setDefault(mode: Mode): void;
94
+ setPerson(peer: string, mode: Mode | null): void;
95
+ /** Show a passing line in the view for a few seconds. */
96
+ say(note: string): void;
97
+ /** Leave. With `announce` (the host ending on purpose) everyone is told first, so they end at
98
+ * once rather than when the connection times out. Safe to call twice. */
99
+ close(announce?: boolean): Promise<void>;
100
+ /** For useSyncExternalStore. */
101
+ subscribe(fn: () => void): () => void;
102
+ getView(): ShareView;
103
+ }
104
+ /** The part of a Trystero room a share uses: a seam, so the check can run a share without a network. */
105
+ export interface RoomLike {
106
+ /** This Deck's id in the room; Trystero's own is `selfId()`. */
107
+ selfId?: string;
108
+ makeAction(name: string, config: {
109
+ onMessage: (data: any, ctx: {
110
+ peerId: string;
111
+ }) => void;
112
+ }): {
113
+ send(data: any, options?: {
114
+ target?: string | string[];
115
+ }): Promise<void>;
116
+ };
117
+ ping(peer: string): Promise<number>;
118
+ leave(): Promise<void>;
119
+ getPeers(): Record<string, unknown>;
120
+ onPeerJoin: ((peer: string) => void) | null;
121
+ onPeerLeave: ((peer: string) => void) | null;
122
+ }
123
+ /** This Deck's id in every share: the one the others know it by. The same for the whole session,
124
+ * so someone let in who rejoins after a dropped connection is let straight back in. */
125
+ export declare function selfId(): string;
126
+ /** Start sharing (`host`), or ask to join someone's share. */
127
+ export declare function share<T extends Json>(opts: ShareOptions, on: ShareHandlers<T>, room?: RoomLike): P2pShare<T>;
128
+ /** A share code: 16 random characters in four groups, easy to read out or paste. */
129
+ export declare function newCode(random?: (n: number) => Uint8Array): string;
130
+ /** A pasted code, tidied: spaces dropped, case folded. Empty when it is not a code. */
131
+ export declare function cleanCode(text: string): string;
@@ -23,4 +23,6 @@ export declare function stepZoom(dir: -1 | 0 | 1): void;
23
23
  /** Current settings for non-React callers (the palette's providers). */
24
24
  export declare const getSettings: () => Settings;
25
25
  export declare function userName(): Promise<string>;
26
+ /** Told after every change to the settings, from anywhere (Settings, a file edited elsewhere). */
27
+ export declare const subscribeSettings: (cb: () => void) => () => void;
26
28
  export declare function useSettings(): Settings;
@@ -0,0 +1,122 @@
1
+ // GENERATED by sdk/gen-types.mjs from Deck's source — do not edit.
2
+ import { share, type Json, type RoomLike, type P2pShare } from "./p2pshare";
3
+ export type Member = {
4
+ id: string;
5
+ pub: string;
6
+ name: string;
7
+ };
8
+ /** A team's members, as the owner signed them. */
9
+ export type MemberList = {
10
+ team: string;
11
+ name: string;
12
+ owner: string;
13
+ ownerPub: string;
14
+ rev: number;
15
+ members: Member[];
16
+ sig: string; /** The owner ended the team. */
17
+ ended?: boolean;
18
+ };
19
+ /** What this Deck keeps for a team. The key is secret: it names and encrypts the room. */
20
+ export type Team = {
21
+ id: string;
22
+ key: string;
23
+ list: MemberList; /** Each member's name as last heard from them: theirs to change. */
24
+ seen?: Record<string, string>; /** When this Deck heard the team was ended: kept as a tombstone until ENDED_DAYS after. */
25
+ endedAt?: number;
26
+ };
27
+ /** A team as the Teams page shows it. */
28
+ export interface TeamView {
29
+ id: string;
30
+ name: string;
31
+ owner: boolean;
32
+ ownerId: string;
33
+ members: (Member & {
34
+ me: boolean;
35
+ online: boolean;
36
+ })[];
37
+ }
38
+ export interface TeamChannel<T> {
39
+ /** Send to every member online, or to one (`to`: a member id). Nothing goes to anyone else. */
40
+ send(msg: T, to?: string): Promise<void>;
41
+ /** The members connected and checked right now, this Deck not included. */
42
+ online(): Member[];
43
+ close(): void;
44
+ }
45
+ export interface ChannelHandlers<T> {
46
+ message: (msg: T, from: Member) => void;
47
+ /** A member connected and was checked: send them what they need to catch up. */
48
+ joined?: (m: Member) => void;
49
+ left?: (m: Member) => void;
50
+ }
51
+ /** Where teams keep things and how they reach the network: a seam, so the check runs without either. */
52
+ export interface TeamDeps {
53
+ read(name: string): Promise<string>;
54
+ write(name: string, text: string): Promise<void>;
55
+ /** This Deck's name, as the others see it. */
56
+ name(): Promise<string>;
57
+ /** A team's room: Trystero's, or a fake one. */
58
+ room(key: string, id: string): RoomLike;
59
+ /** Told when this Deck's name may have changed (the settings changed). */
60
+ watchName(cb: () => void): void;
61
+ share: typeof share;
62
+ }
63
+ /** For the check: replace the storage, the name and the network. */
64
+ export declare function _setTeamDeps(d: Partial<TeamDeps>): void;
65
+ /** A short code for an identity, to read out and compare: whoever invites shows theirs, whoever joins sees it. */
66
+ export declare const fingerprint: (id: string) => string;
67
+ /** A member's id: the first half of the hash of their public key. */
68
+ export declare function idOf(pub: string): Promise<string>;
69
+ /** Whether a list is the owner's own: signed by them, for this team, with ids that match the keys. */
70
+ export declare function validList(l: MemberList, ownerPub?: string): Promise<boolean>;
71
+ /** A plugin's line to a team: its messages, only to and from checked members. */
72
+ export declare function teamChannel<T extends Json>(teamId: string, app: string, on: ChannelHandlers<T>): TeamChannel<T>;
73
+ export declare function teamsView(): TeamView[];
74
+ export declare function subscribeTeams(fn: () => void): () => void;
75
+ /** This Deck's member id. */
76
+ export declare const myTeamId: () => Promise<string>;
77
+ /** Start a team, with this Deck as its owner and only member. */
78
+ export declare function createTeam(name: string): Promise<string>;
79
+ /** The owner takes someone off the team. */
80
+ export declare function removeMember(teamId: string, memberId: string): Promise<void>;
81
+ /**
82
+ * Leave a team: this Deck forgets it, and the others still list it until the owner removes it. The
83
+ * owner leaving ends the team for everyone: a last signed list says so, sent to those online now
84
+ * and, through the tombstones, to the others when they come back.
85
+ */
86
+ export declare function leaveTeam(teamId: string): Promise<void>;
87
+ type InviteMsg = {
88
+ t: "join";
89
+ id: string;
90
+ pub: string;
91
+ name: string;
92
+ } | {
93
+ t: "team";
94
+ team: Team;
95
+ };
96
+ /**
97
+ * The owner invites: a code to give someone, and a share where they knock and are let in, as a
98
+ * live share's people are (`share.allow`). Whoever is let in is added to the team and sent it.
99
+ */
100
+ export declare function inviteToTeam(teamId: string): Promise<{
101
+ code: string;
102
+ share: P2pShare<InviteMsg>;
103
+ fingerprint: string;
104
+ joined: Promise<string>;
105
+ }>;
106
+ /**
107
+ * Join a team with the code its owner gave you: knock, and once let in, say who you are and take
108
+ * the team the owner sends. Resolves with the team's id.
109
+ */
110
+ /** A team offered by an invite: who owns it, to compare, and keeping it. */
111
+ export interface TeamOffer {
112
+ id: string;
113
+ name: string;
114
+ owner: string;
115
+ fingerprint: string;
116
+ keep: () => Promise<string>;
117
+ }
118
+ export declare function joinTeam(code: string): Promise<{
119
+ share: P2pShare<InviteMsg>;
120
+ done: Promise<TeamOffer>;
121
+ }>;
122
+ export {};
@@ -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>;
@@ -1076,6 +1077,8 @@ export declare const gitAiCommitMsg: (dir: string, command: string, args: string
1076
1077
  export declare const ghRun: (dir: string, args: string[]) => Promise<string>;
1077
1078
  /** Pull request N's review threads, as GitHub's GraphQL JSON (parsed by prs.ts parseThreads). */
1078
1079
  export declare const ghPrThreads: (dir: string, number: number) => Promise<string>;
1080
+ /** The GitHub profile picture's address of whoever commits as `email` in this repo, "" when none. */
1081
+ export declare const ghAvatar: (dir: string, email: string) => Promise<string>;
1079
1082
  /** Comment on one line of a PR's diff: side "RIGHT" is the new version, "LEFT" the old. */
1080
1083
  export declare const ghPrLineComment: (dir: string, number: number, commit: string, path: string, line: number, side: "LEFT" | "RIGHT", body: string) => Promise<string>;
1081
1084
  export declare const ghThreadReply: (dir: string, thread: string, body: string) => Promise<string>;
@@ -1083,8 +1086,8 @@ export declare const ghThreadResolve: (dir: string, thread: string, resolve: boo
1083
1086
  /** The GitLab CLI, `glab mr …` only. Rejects with "glab-missing" when glab is not installed. */
1084
1087
  export declare const glabRun: (dir: string, args: string[]) => Promise<string>;
1085
1088
  /**
1086
- * One GitLab REST request about this repo's merge requests (`projects/:id/merge_requests…` or
1087
- * `user`). `fields` go as strings; `json` values must be objects or arrays.
1089
+ * One GitLab REST request about this repo's merge requests (`projects/:id/merge_requests…`, `user`,
1090
+ * or `avatar?email=` for someone's picture). `fields` go as strings; `json` values must be objects or arrays.
1088
1091
  */
1089
1092
  export declare const glabApi: (dir: string, method: "GET" | "POST" | "PUT", path: string, fields?: [string, string][], json?: Record<string, object>) => Promise<string>;
1090
1093
  /** The files that differ between two revisions. */
@@ -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
+ };
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
  }