@silverbulletmd/silverbullet 2.6.1 → 2.8.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.
Files changed (37) hide show
  1. package/README.md +0 -3
  2. package/client/markdown_parser/constants.ts +1 -0
  3. package/client/plugos/hooks/command.ts +46 -16
  4. package/client/plugos/hooks/slash_command.ts +3 -1
  5. package/client/plugos/plug.ts +5 -2
  6. package/client/plugos/plug_compile.ts +52 -2
  7. package/client/plugos/sandboxes/worker_sandbox.ts +11 -1
  8. package/client/plugos/syscalls/client_code_widget.ts +3 -1
  9. package/client/plugos/syscalls/config.ts +9 -2
  10. package/client/plugos/syscalls/editor.ts +147 -0
  11. package/client/plugos/syscalls/index.ts +8 -1
  12. package/client/plugos/syscalls/jsonschema.ts +1 -19
  13. package/client/plugos/syscalls/system.ts +13 -0
  14. package/client/plugos/system.ts +14 -10
  15. package/client/plugos/types.ts +25 -0
  16. package/client/plugos/util.ts +20 -0
  17. package/client/space_lua/aggregates.ts +2 -2
  18. package/client/space_lua/query_collection.ts +4 -4
  19. package/client/space_lua/render_lua_markdown.ts +1 -1
  20. package/client/space_lua/runtime.ts +1 -1
  21. package/client/space_lua/{liq_null.ts → sliq_null.ts} +2 -2
  22. package/client/space_lua/stdlib/space_lua.ts +1 -1
  23. package/client/space_lua/stdlib.ts +1 -1
  24. package/dist/plug-compile.js +44 -2
  25. package/package.json +1 -1
  26. package/plug-api/constants.ts +20 -0
  27. package/plug-api/lib/ref.ts +9 -2
  28. package/plug-api/lib/shortcut.ts +16 -0
  29. package/plug-api/lib/tree.ts +35 -4
  30. package/plug-api/syscalls/config.ts +37 -0
  31. package/plug-api/syscalls/editor.ts +160 -0
  32. package/plug-api/syscalls/index.ts +30 -0
  33. package/plug-api/syscalls/system.ts +15 -0
  34. package/plug-api/types/client.ts +12 -0
  35. package/plug-api/types/manifest.ts +63 -0
  36. package/plugs/builtin_plugs.ts +1 -1
  37. package/client/plugos/sandboxes/web_worker_sandbox.ts +0 -17
package/README.md CHANGED
@@ -27,8 +27,6 @@ SilverBullet's frontend is written in [TypeScript](https://www.typescriptlang.or
27
27
 
28
28
  The server backend is written in Go.
29
29
 
30
- If you're considering contributing changes, be aware of the [LLM use policy](https://silverbullet.md/LLM%20Use).
31
-
32
30
  ## Code structure
33
31
  * `client/`: The SilverBullet client, implemented with TypeScript
34
32
  * `server/`: The SilverBullet server, written in Go
@@ -107,7 +105,6 @@ docker build -t silverbullet .
107
105
  ```
108
106
 
109
107
  To run:
110
-
111
108
  ```shell
112
109
  docker run -p 3000:3000 -v <PATH-TO-YOUR-SPACE>:/space silverbullet
113
110
  ```
@@ -12,3 +12,4 @@ export const frontmatterWikiLinkRegex =
12
12
  /(?<leadingTrivia>!?\[\[)(?<stringRef>.*?)(?:\|(?<alias>.*?))?(?<trailingTrivia>\]\])/g;
13
13
  export const frontmatterMailtoRegex = /(mailto:[^@\s]+@[^@\s"']+)/gi;
14
14
  export const pWikiLinkRegex = new RegExp(`^${wikiLinkRegex.source}`); // Modified regex used only in parser
15
+ export const anchorRegex = /\$[A-Za-z_][A-Za-z0-9_/:-]*/;
@@ -14,6 +14,8 @@ export class CommandHook
14
14
  this.buildAllCommandsAndEmit();
15
15
  }, 200);
16
16
 
17
+ private registeredCommands = new Map<string, Command>();
18
+
17
19
  constructor(
18
20
  private readOnly: boolean,
19
21
  private additionalCommands: Map<string, Command>,
@@ -21,11 +23,51 @@ export class CommandHook
21
23
  super();
22
24
  }
23
25
 
26
+ /**
27
+ * Register a command directly with the hook. Intended for client-side code
28
+ * that wants to define commands without going through a plug manifest.
29
+ * Overwrites any previously-registered command with the same name.
30
+ */
31
+ registerCommand(command: Command): void {
32
+ this.registeredCommands.set(command.name, command);
33
+ this.throttledBuildAllCommandsAndEmit();
34
+ }
35
+
36
+ private mergeCommand(
37
+ commands: Map<string, Command>,
38
+ name: string,
39
+ cmd: Command,
40
+ ) {
41
+ if (commands.has(name)) {
42
+ // Existing command, let's do some inline patching
43
+ const existingCommand = commands.get(name)!;
44
+ const command: Command = {
45
+ ...existingCommand,
46
+ ...cmd,
47
+ };
48
+ if (cmd.run) {
49
+ command.run = cmd.run;
50
+ }
51
+ commands.set(name, command);
52
+ } else {
53
+ commands.set(name, cmd);
54
+ }
55
+ }
56
+
24
57
  /**
25
58
  * Build the command map
26
59
  */
27
60
  buildAllCommands(): Map<string, Command> {
28
61
  const commands = new Map<string, Command>();
62
+ // Start with directly-registered commands (built-in client commands).
63
+ // These form the base layer and can be overridden by plug commands or
64
+ // script commands further down.
65
+ for (const [name, cmd] of this.registeredCommands) {
66
+ if (cmd.requireMode === "rw" && this.readOnly) {
67
+ continue;
68
+ }
69
+ commands.set(name, cmd);
70
+ }
29
71
  // Add commands from plugs
30
72
  if (!this.system) {
31
73
  // Not initialized yet
@@ -43,7 +85,7 @@ export class CommandHook
43
85
  // Bit hacky, but don't expose commands that require write mode in read-only mode
44
86
  continue;
45
87
  }
46
- commands.set(cmd.name, {
88
+ this.mergeCommand(commands, cmd.name, {
47
89
  ...cmd,
48
90
  run: (args?: string[]) => {
49
91
  return plug.invoke(name, [cmd, ...(args ?? [])]);
@@ -51,22 +93,10 @@ export class CommandHook
51
93
  });
52
94
  }
53
95
  }
96
+ // Script commands come last — they always win over built-ins and plugs,
97
+ // so users can rebind keys / replace run bodies from Lua.
54
98
  for (const [name, cmd] of this.additionalCommands) {
55
- if (commands.has(name)) {
56
- // Existing command, let's do some inline patching
57
- const existingCommand = commands.get(name)!;
58
- const command: Command = {
59
- ...existingCommand,
60
- ...cmd,
61
- };
62
- if (cmd.run) {
63
- command.run = cmd.run;
64
- }
65
- commands.set(name, command);
66
- } else {
67
- // New command, let's just set
68
- commands.set(name, cmd);
69
- }
99
+ this.mergeCommand(commands, name, cmd);
70
100
  }
71
101
  return commands;
72
102
  }
@@ -66,7 +66,9 @@ export class SlashCommandHook implements Hook<SlashCommandHookT> {
66
66
  const options: Completion[] = [];
67
67
 
68
68
  // No slash commands in comment blocks (queries and such) or links
69
- const currentNode = syntaxTree(ctx.state).resolveInner(ctx.pos);
69
+ // Pass -1 so we resolve into the node ending at the cursor: when typing,
70
+ // the user is referring to preceding text, not what comes after.
71
+ const currentNode = syntaxTree(ctx.state).resolveInner(ctx.pos, -1);
70
72
  if (
71
73
  currentNode.type.name === "CommentBlock" ||
72
74
  currentNode.type.name === "Link"
@@ -11,6 +11,8 @@ export class Plug<HookT> {
11
11
 
12
12
  public manifest!: Manifest<HookT>;
13
13
  public assets?: AssetBundle;
14
+ // File path the plug was loaded from; also used as the manifest cache key.
15
+ public path!: string;
14
16
 
15
17
  constructor(
16
18
  readonly system: System<HookT>,
@@ -22,16 +24,17 @@ export class Plug<HookT> {
22
24
 
23
25
  static async createLazily<HookT>(
24
26
  system: System<HookT>,
25
- cacheKey: string,
27
+ path: string,
26
28
  cacheHash: number,
27
29
  sandboxFactory: SandboxFactory<HookT>,
28
30
  ): Promise<Plug<HookT>> {
29
31
  const plug = new Plug(system, sandboxFactory);
32
+ plug.path = path;
30
33
 
31
34
  // Retrieve the manifest, which may either come from a cache or be loaded from the worker
32
35
  plug.manifest = await system.options.manifestCache!.getManifest(
33
36
  plug,
34
- cacheKey,
37
+ path,
35
38
  cacheHash,
36
39
  );
37
40
 
@@ -1,11 +1,19 @@
1
1
  import * as path from "node:path";
2
- import { readFile, writeFile, mkdtemp, rm, mkdir } from "node:fs/promises";
2
+ import {
3
+ copyFile,
4
+ mkdir,
5
+ mkdtemp,
6
+ readFile,
7
+ rm,
8
+ writeFile,
9
+ } from "node:fs/promises";
3
10
  import { tmpdir } from "node:os";
4
11
  import * as YAML from "js-yaml";
5
12
 
6
13
  import * as esbuild from "esbuild";
14
+ import * as sass from "sass";
7
15
  import { bundleAssets } from "../asset_bundle/builder.ts";
8
- import type { Manifest } from "./types.ts";
16
+ import type { BuildStep, Manifest } from "./types.ts";
9
17
 
10
18
  // When bundled by build_plug_compile.ts, this is replaced with the pre-bundled
11
19
  // worker runtime JS. When running from source, it's undefined and esbuild
@@ -53,6 +61,15 @@ export async function compileManifest(
53
61
  throw new Error(`Missing 'name' in ${manifestPath}`);
54
62
  }
55
63
 
64
+ // Build steps (run before asset bundling so produced files can be picked
65
+ // up by the `assets` glob).
66
+ if (manifest.build) {
67
+ await Promise.all(
68
+ manifest.build.map((step) => runBuildStep(step, rootPath, options)),
69
+ );
70
+ delete manifest.build;
71
+ }
72
+
56
73
  // Assets
57
74
  const assetsBundle = await bundleAssets(
58
75
  path.resolve(rootPath),
@@ -177,6 +194,39 @@ export async function compileManifests(
177
194
  await buildAll();
178
195
  }
179
196
 
197
+ async function runBuildStep(
198
+ step: BuildStep,
199
+ rootPath: string,
200
+ options: CompileOptions,
201
+ ) {
202
+ const type = step.type ?? "esbuild";
203
+ const inFile = path.resolve(rootPath, step.in);
204
+ const outFile = path.resolve(rootPath, step.out);
205
+ await mkdir(path.dirname(outFile), { recursive: true });
206
+ if (type === "esbuild") {
207
+ await esbuild.build({
208
+ entryPoints: [inFile],
209
+ bundle: true,
210
+ format: "iife",
211
+ platform: "browser",
212
+ minify: !options.debug,
213
+ outfile: outFile,
214
+ treeShaking: true,
215
+ });
216
+ } else if (type === "sass") {
217
+ const scss = await readFile(inFile, "utf-8");
218
+ const result = sass.compileString(scss, {
219
+ loadPaths: [path.dirname(inFile)],
220
+ style: options.debug ? "expanded" : "compressed",
221
+ });
222
+ await writeFile(outFile, result.css, "utf-8");
223
+ } else if (type === "copy") {
224
+ await copyFile(inFile, outFile);
225
+ } else {
226
+ throw new Error(`Unsupported build step type: ${type}`);
227
+ }
228
+ }
229
+
180
230
  export function patchBundledJS(code: string): string {
181
231
  // One bundled dependency has a lookbehind regex that WebKit can't parse; replace it with a no-op
182
232
  return code.replaceAll("/(?<=\\n)/", "/()/");
@@ -2,8 +2,9 @@ import type { Manifest } from "../types.ts";
2
2
  import type { ControllerMessage, WorkerMessage } from "../protocol.ts";
3
3
  import type { Plug } from "../plug.ts";
4
4
  import { AssetBundle, type AssetJson } from "../../asset_bundle/bundle.ts";
5
- import type { Sandbox } from "./sandbox.ts";
5
+ import type { Sandbox, SandboxFactory } from "./sandbox.ts";
6
6
  import { race, timeout } from "@silverbulletmd/silverbullet/lib/async";
7
+ import { fsEndpoint } from "../../spaces/constants.ts";
7
8
 
8
9
  /**
9
10
  * Represents a "safe" execution environment for plug code
@@ -24,6 +25,15 @@ export class WorkerSandbox<HookT> implements Sandbox<HookT> {
24
25
  private workerOptions = {},
25
26
  ) {}
26
27
 
28
+ /** Factory producing a WorkerSandbox for a plug file at a local space path. */
29
+ static forPath<HookT>(path: string): SandboxFactory<HookT> {
30
+ return (plug) =>
31
+ new WorkerSandbox(
32
+ plug,
33
+ new URL(path, `${document.baseURI.slice(0, -1) + fsEndpoint}/`),
34
+ );
35
+ }
36
+
27
37
  /**
28
38
  * Should only invoked lazily (either by invoke, or by a ManifestCache to load the manifest)
29
39
  */
@@ -1,10 +1,12 @@
1
1
  import type { SysCallMapping } from "../../plugos/system.ts";
2
+ import type { Client } from "../../client.ts";
2
3
  import { reloadAllWidgets } from "../../codemirror/code_widget.ts";
3
4
  import { broadcastReload } from "../../components/widget_sandbox_iframe.ts";
4
5
 
5
- export function clientCodeWidgetSyscalls(): SysCallMapping {
6
+ export function clientCodeWidgetSyscalls(client: Client): SysCallMapping {
6
7
  return {
7
8
  "codeWidget.refreshAll": () => {
9
+ client.widgetCache.clearPrewarm();
8
10
  broadcastReload();
9
11
  return reloadAllWidgets();
10
12
  },
@@ -1,6 +1,7 @@
1
1
  import type { SysCallMapping } from "../system.ts";
2
2
  import type { Config } from "../../config.ts";
3
3
  import { LuaStackFrame, luaValueToJS } from "../../space_lua/runtime.ts";
4
+ import { stripFunctions } from "../util.ts";
4
5
 
5
6
  export function configSyscalls(config: Config): SysCallMapping {
6
7
  return {
@@ -32,11 +33,17 @@ export function configSyscalls(config: Config): SysCallMapping {
32
33
  "config.define": (_ctx, key: string, schema: any) => {
33
34
  config.define(key, schema);
34
35
  },
36
+ "config.defineCategory": (_ctx, definition: any) => {
37
+ config.defineCategory(definition);
38
+ },
35
39
  "config.getValues": () => {
36
- return config.values;
40
+ return stripFunctions(config.values);
37
41
  },
38
42
  "config.getSchemas": () => {
39
- return config.schemas;
43
+ return stripFunctions(config.schemas);
44
+ },
45
+ "config.getCategories": () => {
46
+ return { ...config.categories };
40
47
  },
41
48
  };
42
49
  }
@@ -7,15 +7,59 @@ import {
7
7
  unfoldCode,
8
8
  } from "@codemirror/language";
9
9
  import {
10
+ cursorCharLeft,
11
+ cursorCharRight,
12
+ cursorDocEnd,
13
+ cursorDocStart,
14
+ cursorGroupLeft,
15
+ cursorGroupRight,
16
+ cursorLineBoundaryLeft,
17
+ cursorLineBoundaryRight,
18
+ cursorLineDown,
19
+ cursorLineEnd,
20
+ cursorLineStart,
21
+ cursorLineUp,
22
+ cursorPageDown,
23
+ cursorPageUp,
24
+ deleteCharBackward,
25
+ deleteCharForward,
26
+ deleteGroupBackward,
27
+ deleteGroupForward,
10
28
  deleteLine,
29
+ deleteLineBoundaryBackward,
30
+ deleteLineBoundaryForward,
31
+ indentLess,
32
+ indentMore,
11
33
  insertNewline,
12
34
  insertNewlineAndIndent,
13
35
  moveLineDown,
14
36
  moveLineUp,
15
37
  redo,
38
+ selectAll,
39
+ selectCharLeft,
40
+ selectCharRight,
41
+ selectDocEnd,
42
+ selectDocStart,
43
+ selectGroupLeft,
44
+ selectGroupRight,
45
+ selectLineBoundaryLeft,
46
+ selectLineBoundaryRight,
47
+ selectLineDown,
48
+ selectLineEnd,
49
+ selectLineStart,
50
+ selectLineUp,
51
+ selectPageDown,
52
+ selectPageUp,
16
53
  toggleComment,
54
+ transposeChars,
17
55
  undo,
18
56
  } from "@codemirror/commands";
57
+ import {
58
+ acceptCompletion,
59
+ closeCompletion,
60
+ moveCompletionSelection,
61
+ startCompletion,
62
+ } from "@codemirror/autocomplete";
19
63
  import type { Transaction } from "@codemirror/state";
20
64
  import { EditorView } from "@codemirror/view";
21
65
  import { getVimModule } from "../../vim_loader.ts";
@@ -27,6 +71,8 @@ import type {
27
71
  UploadFile,
28
72
  } from "@silverbulletmd/silverbullet/type/client";
29
73
  import { openSearchPanel } from "@codemirror/search";
74
+ import { forceLinting } from "@codemirror/lint";
75
+ import { refreshLintEffect } from "../../codemirror/lint.ts";
30
76
  import {
31
77
  isValidPath,
32
78
  parseToRef,
@@ -205,6 +251,15 @@ export function editorSyscalls(client: Client): SysCallMapping {
205
251
  "editor.reloadPage": async () => {
206
252
  await client.reloadEditor();
207
253
  },
254
+ "editor.forceLint": () => {
255
+ // `forceLinting` alone is a no-op on unchanged content after the
256
+ // linter's internal "scheduled" flag has been cleared; the effect
257
+ // re-arms it via `needsRefresh`.
258
+ client.editorView.dispatch({
259
+ effects: refreshLintEffect.of(null),
260
+ });
261
+ forceLinting(client.editorView);
262
+ },
208
263
  "editor.reloadUI": () => {
209
264
  location.reload();
210
265
  },
@@ -324,6 +379,9 @@ export function editorSyscalls(client: Client): SysCallMapping {
324
379
  client.editorView.dispatch({});
325
380
  });
326
381
  },
382
+ "editor.focus": () => {
383
+ client.focus();
384
+ },
327
385
  "editor.hidePanel": (_ctx, id: string) => {
328
386
  client.ui.viewDispatch({
329
387
  type: "hide-panel",
@@ -600,6 +658,24 @@ export function editorSyscalls(client: Client): SysCallMapping {
600
658
  "editor.deleteLine": () => {
601
659
  deleteLine(client.editorView);
602
660
  },
661
+ "editor.selectAll": () => {
662
+ return selectAll({
663
+ state: client.editorView.state,
664
+ dispatch: client.editorView.dispatch,
665
+ });
666
+ },
667
+ "editor.indentMore": () => {
668
+ return indentMore({
669
+ state: client.editorView.state,
670
+ dispatch: client.editorView.dispatch,
671
+ });
672
+ },
673
+ "editor.indentLess": () => {
674
+ return indentLess({
675
+ state: client.editorView.state,
676
+ dispatch: client.editorView.dispatch,
677
+ });
678
+ },
603
679
  "editor.toggleComment": () => {
604
680
  return toggleComment({
605
681
  state: client.editorView.state,
@@ -640,6 +716,77 @@ export function editorSyscalls(client: Client): SysCallMapping {
640
716
  "editor.redo": () => {
641
717
  return redo(client.editorView);
642
718
  },
719
+ // Cursor motion syscalls
720
+ "editor.cursorCharLeft": () => cursorCharLeft(client.editorView),
721
+ "editor.cursorCharRight": () => cursorCharRight(client.editorView),
722
+ "editor.cursorGroupLeft": () => cursorGroupLeft(client.editorView),
723
+ "editor.cursorGroupRight": () => cursorGroupRight(client.editorView),
724
+ "editor.cursorLineBoundaryLeft": () =>
725
+ cursorLineBoundaryLeft(client.editorView),
726
+ "editor.cursorLineBoundaryRight": () =>
727
+ cursorLineBoundaryRight(client.editorView),
728
+ "editor.cursorLineStart": () => cursorLineStart(client.editorView),
729
+ "editor.cursorLineEnd": () => cursorLineEnd(client.editorView),
730
+ "editor.cursorDocStart": () => cursorDocStart(client.editorView),
731
+ "editor.cursorDocEnd": () => cursorDocEnd(client.editorView),
732
+ // Cursor motions that also navigate the completion popup if it's open
733
+ "editor.cursorLineUp": () => {
734
+ const view = client.editorView;
735
+ if (moveCompletionSelection(false)(view)) return true;
736
+ return cursorLineUp(view);
737
+ },
738
+ "editor.cursorLineDown": () => {
739
+ const view = client.editorView;
740
+ if (moveCompletionSelection(true)(view)) return true;
741
+ return cursorLineDown(view);
742
+ },
743
+ "editor.cursorPageUp": () => {
744
+ const view = client.editorView;
745
+ if (moveCompletionSelection(false, "page")(view)) return true;
746
+ return cursorPageUp(view);
747
+ },
748
+ "editor.cursorPageDown": () => {
749
+ const view = client.editorView;
750
+ if (moveCompletionSelection(true, "page")(view)) return true;
751
+ return cursorPageDown(view);
752
+ },
753
+ // Selection-extending motions
754
+ "editor.selectCharLeft": () => selectCharLeft(client.editorView),
755
+ "editor.selectCharRight": () => selectCharRight(client.editorView),
756
+ "editor.selectGroupLeft": () => selectGroupLeft(client.editorView),
757
+ "editor.selectGroupRight": () => selectGroupRight(client.editorView),
758
+ "editor.selectLineBoundaryLeft": () =>
759
+ selectLineBoundaryLeft(client.editorView),
760
+ "editor.selectLineBoundaryRight": () =>
761
+ selectLineBoundaryRight(client.editorView),
762
+ "editor.selectLineStart": () => selectLineStart(client.editorView),
763
+ "editor.selectLineEnd": () => selectLineEnd(client.editorView),
764
+ "editor.selectDocStart": () => selectDocStart(client.editorView),
765
+ "editor.selectDocEnd": () => selectDocEnd(client.editorView),
766
+ "editor.selectLineUp": () => selectLineUp(client.editorView),
767
+ "editor.selectLineDown": () => selectLineDown(client.editorView),
768
+ "editor.selectPageUp": () => selectPageUp(client.editorView),
769
+ "editor.selectPageDown": () => selectPageDown(client.editorView),
770
+ // Delete
771
+ "editor.deleteCharBackward": () => deleteCharBackward(client.editorView),
772
+ "editor.deleteCharForward": () => deleteCharForward(client.editorView),
773
+ "editor.deleteGroupBackward": () => deleteGroupBackward(client.editorView),
774
+ "editor.deleteGroupForward": () => deleteGroupForward(client.editorView),
775
+ "editor.deleteLineBoundaryBackward": () =>
776
+ deleteLineBoundaryBackward(client.editorView),
777
+ "editor.deleteLineBoundaryForward": () =>
778
+ deleteLineBoundaryForward(client.editorView),
779
+ "editor.transposeChars": () => transposeChars(client.editorView),
780
+ // Enter: accept completion if popup is open, else newline-and-indent
781
+ "editor.insertNewline": () => {
782
+ const view = client.editorView;
783
+ if (acceptCompletion(view)) return true;
784
+ return insertNewlineAndIndent(view);
785
+ },
786
+ // Completion popup control
787
+ "editor.acceptCompletion": () => acceptCompletion(client.editorView),
788
+ "editor.startCompletion": () => startCompletion(client.editorView),
789
+ "editor.closeCompletion": () => closeCompletion(client.editorView),
643
790
  "editor.openSearchPanel": () => {
644
791
  openSearchPanel(client.editorView);
645
792
  },
@@ -61,13 +61,20 @@ export function indexSyscalls(
61
61
  }
62
62
  }
63
63
  },
64
+ "index.previewProcessedObjects": (
65
+ _ctx,
66
+ page: string,
67
+ objects: ObjectValue[],
68
+ ): Promise<{ tag: string; object: ObjectValue }[]> => {
69
+ return objectIndex.previewProcessedObjects(page, objects);
70
+ },
64
71
  "index.getObjectByRef": (
65
72
  _ctx,
66
73
  page: string,
67
74
  tag: string,
68
75
  ref: string,
69
76
  ): Promise<ObjectValue | undefined> => {
70
- return objectIndex.getObjectByRef(ref, page, tag);
77
+ return objectIndex.getObjectByRef(page, tag, ref);
71
78
  },
72
79
  "index.queryLuaObjects": (
73
80
  _ctx,
@@ -1,5 +1,6 @@
1
1
  import type { SysCallMapping } from "../system.ts";
2
2
  import { type OutputUnit, Validator, format } from "@cfworker/json-schema";
3
+ import { stripFunctions } from "../util.ts";
3
4
 
4
5
  // Register custom formats
5
6
  format.email = (data: string) => data.includes("@");
@@ -8,25 +9,6 @@ format["page-ref"] = (data: string) =>
8
9
 
9
10
  const schemaCache = new Map<string, Validator>();
10
11
 
11
- /**
12
- * Deep-clone a value, replacing any functions with null.
13
- * JSON schema can't validate functions, so we strip them before validation.
14
- */
15
- function stripFunctions(value: any): any {
16
- if (typeof value === "function") return null;
17
- if (value === null || value === undefined || typeof value !== "object") {
18
- return value;
19
- }
20
- if (Array.isArray(value)) {
21
- return value.map(stripFunctions);
22
- }
23
- const result: Record<string, any> = {};
24
- for (const key of Object.keys(value)) {
25
- result[key] = stripFunctions(value[key]);
26
- }
27
- return result;
28
- }
29
-
30
12
  function formatErrors(errors: OutputUnit[]): string {
31
13
  // Filter out "properties" wrapper errors, keep only the specific leaf errors
32
14
  const leafErrors = errors.filter((e) => e.keyword !== "properties");
@@ -60,6 +60,11 @@ export function systemSyscalls(
60
60
  mac: def.mac,
61
61
  hide: def.hide,
62
62
  requireMode: def.requireMode,
63
+ requireEditor: def.requireEditor,
64
+ menu: def.menu,
65
+ menuMac: def.menuMac,
66
+ menuWindows: def.menuWindows,
67
+ menuLinux: def.menuLinux,
63
68
  };
64
69
  }
65
70
  return allCommands;
@@ -82,6 +87,14 @@ export function systemSyscalls(
82
87
  }
83
88
  return client.loadPlugs();
84
89
  },
90
+ "system.loadPlug": async (_ctx, path: string) => {
91
+ const meta = await client.space.spacePrimitives.getFileMeta(path);
92
+ await client.clientSystem.loadPlugFromPath(path, meta.lastModified);
93
+ await client.dispatchAppEvent("plugs:loaded");
94
+ },
95
+ "system.unloadPlug": (_ctx, path: string) => {
96
+ return client.clientSystem.system.unloadByPath(path);
97
+ },
85
98
  "system.reloadConfig": (): Record<string, any> => {
86
99
  console.warn("system.reloadConfig is deprecated, it's now a no-op");
87
100
  return client.config.values;
@@ -133,26 +133,20 @@ export class System<HookT> extends EventEmitter<SystemEvents<HookT>> {
133
133
  }
134
134
 
135
135
  /**
136
- * @param cacheKey Used to cache the manifest of the worker. Should be equal to the filepath
136
+ * @param path The plug file's space path; also used as the manifest cache key.
137
137
  * @param cacheHash Used to determine if the manifest is up to date. Should be `lastModified`
138
138
  */
139
139
  async loadPlug(
140
140
  sandboxFactory: SandboxFactory<HookT>,
141
- cacheKey: string,
141
+ path: string,
142
142
  cacheHash: number = -1,
143
143
  ): Promise<Plug<HookT>> {
144
- const plug = await Plug.createLazily(
145
- this,
146
- cacheKey,
147
- cacheHash,
148
- sandboxFactory,
149
- );
144
+ const plug = await Plug.createLazily(this, path, cacheHash, sandboxFactory);
150
145
 
151
146
  const manifest = plug.manifest;
152
147
 
153
- // This depends on cacheKey being the file path
154
148
  if (
155
- !builtinPlugPaths.includes(cacheKey) &&
149
+ !builtinPlugPaths.includes(path) &&
156
150
  builtinPlugNames.includes(manifest.name)
157
151
  ) {
158
152
  plug.stop();
@@ -177,6 +171,16 @@ export class System<HookT> extends EventEmitter<SystemEvents<HookT>> {
177
171
  return plug;
178
172
  }
179
173
 
174
+ unloadByPath(path: string): boolean {
175
+ for (const [name, plug] of this.plugs) {
176
+ if (plug.path === path) {
177
+ this.unload(name);
178
+ return true;
179
+ }
180
+ }
181
+ return false;
182
+ }
183
+
180
184
  unload(name: string) {
181
185
  const plug = this.plugs.get(name);
182
186
  if (!plug) {
@@ -34,6 +34,31 @@ export interface Manifest<HookT> {
34
34
  * A map of configuration options for the plug (to be merged with the system configuration).
35
35
  */
36
36
  config?: any;
37
+
38
+ /** Build steps run before asset bundling. Each step transforms an `in` file
39
+ * into an `out` file, which is then picked up by the `assets` glob.
40
+ *
41
+ * Currently only `type: "esbuild"` is supported (the default when omitted),
42
+ * which runs esbuild in bundle mode on `in` and writes the result to `out`.
43
+ */
44
+ build?: BuildStep[];
45
+ }
46
+
47
+ /** A pre-asset-bundling transformation, e.g. bundling a TypeScript entry point
48
+ * into a single JS file that is then loaded from plug assets.
49
+ *
50
+ * Supported types:
51
+ * - `esbuild` (default): bundle `in` into `out` as a minified browser IIFE.
52
+ * - `sass`: compile an SCSS/SASS file at `in` to CSS at `out`.
53
+ * - `copy`: copy `in` verbatim to `out`.
54
+ */
55
+ export interface BuildStep {
56
+ /** Transformation type. Defaults to "esbuild" when omitted. */
57
+ type?: "esbuild" | "sass" | "copy";
58
+ /** Input path relative to the manifest. */
59
+ in: string;
60
+ /** Output path relative to the manifest. */
61
+ out: string;
37
62
  }
38
63
 
39
64
  /** Associates hooks with a function. This is the generic base structure, that identifies the function. Hooks are defined by the type parameter. */