@silverbulletmd/silverbullet 2.11.0 → 2.12.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
@@ -24,7 +24,7 @@ You were told there’s no such thing as a silver bullet. You were told wrong.
24
24
  [Much more detail can be found on silverbullet.md](https://silverbullet.md)
25
25
 
26
26
  ## Installing SilverBullet
27
- Check out the [instructions](https://silverbullet.md/Install).
27
+ Check out the [instructions](https://docs.silverbullet.md/Install).
28
28
 
29
29
  ## Developing SilverBullet
30
30
  SilverBullet's frontend ("client") is written in [TypeScript](https://www.typescriptlang.org/) and built on top of the excellent [CodeMirror 6](https://codemirror.net/) editor component. Additional UI is built using [Preact](https://preactjs.com). [ESBuild](https://esbuild.github.io) is used to build the frontend.
@@ -46,7 +46,7 @@ The server backend is written in [Rust](https://www.rust-lang.org/) (a Cargo wor
46
46
  * `syscalls/`: TypeScript wrappers around syscalls
47
47
  * `types/`: Various (client) types that can be references from plugs
48
48
  * `scripts/`: Useful scripts
49
- * `docs/`: documentation (also serves a silverbullet.md website content)
49
+ * `docs/`: documentation (published as docs.silverbullet.md and bundled as Desktop's Help)
50
50
 
51
51
  ### Requirements
52
52
  * [Node.js](https://nodejs.org/) 24+ and npm 10+: Used to build the frontend (client) and plugs
@@ -4,7 +4,7 @@ export type KvQueryOptions = {
4
4
  prefix?: KvKey;
5
5
  };
6
6
 
7
- export interface KvPrimitives {
7
+ export type KvPrimitives = {
8
8
  batchGet(keys: KvKey[]): Promise<(any | undefined)[]>;
9
9
 
10
10
  batchSet(entries: KV[]): Promise<void>;
@@ -22,4 +22,4 @@ export interface KvPrimitives {
22
22
  clear(): Promise<void>;
23
23
 
24
24
  close(): void;
25
- }
25
+ };
@@ -4,8 +4,10 @@ export const mdLinkRegex =
4
4
  /!?\[(?<title>[^\]\\]*(?:\\.[^\]\\]*)*)\]\((?<url>.+)\)/g;
5
5
  export const tagRegex =
6
6
  /#(?:(?:\d*[^\d\s!@#$%^&*(),.?":{}|<>\\';\/–—][^\s!@#$%^&*(),.?":{}|<>\\]*)|(?:<[^>\n]+>))(?<!-)/;
7
+ // Dots and apostrophes may only join name segments, so `@pete.` and `@Chris'`
8
+ // end before them; a possessive `'s` never joins, so `@Foo's` is `@Foo`.
7
9
  export const atMentionRegex =
8
- /@(?:\d*[^\d\s!@#$%^&*(),.?":{}|<>\\\/][^\s!@#$%^&*(),.?":{}|<>\\\/]*(?:\.[^\s!@#$%^&*(),.?":{}|<>\\\/]+)*)/;
10
+ /@(?:\d*[^\d\s!@#$%^&*(),.?":{}|<>\\\/'’][^\s!@#$%^&*(),.?":{}|<>\\\/'’]*(?:(?:\.|['’](?![sS](?![^\s!@#$%^&*(),.?":{}|<>\\\/'’])))[^\s!@#$%^&*(),.?":{}|<>\\\/'’]+)*)/;
9
11
  export const nakedUrlRegex =
10
12
  /(^https?:\/\/([-a-zA-Z0-9@:%_+~#=]|(?:[.](?!(\s|$)))){1,256})(([-a-zA-Z0-9(@:%_+~#?&=/]|(?:[.,:;)](?!(\s|$))))*)/;
11
13
  export const frontmatterQuotesRegex = /["'].*["']/g;
@@ -19,6 +19,7 @@ export class CommandHook
19
19
  constructor(
20
20
  private readOnly: boolean,
21
21
  private additionalCommands: Map<string, Command>,
22
+ private disableServiceWorker = false,
22
23
  ) {
23
24
  super();
24
25
  }
@@ -65,6 +66,9 @@ export class CommandHook
65
66
  if (cmd.requireMode === "rw" && this.readOnly) {
66
67
  continue;
67
68
  }
69
+ if (cmd.requireServiceWorker && this.disableServiceWorker) {
70
+ continue;
71
+ }
68
72
  commands.set(name, cmd);
69
73
  }
70
74
  if (!this.system) {
@@ -81,6 +85,9 @@ export class CommandHook
81
85
  if (cmd.requireMode === "rw" && this.readOnly) {
82
86
  continue;
83
87
  }
88
+ if (cmd.requireServiceWorker && this.disableServiceWorker) {
89
+ continue;
90
+ }
84
91
  this.mergeCommand(commands, cmd.name, {
85
92
  ...cmd,
86
93
  run: (args?: string[]) => {
@@ -98,7 +105,7 @@ export class CommandHook
98
105
  }
99
106
 
100
107
  public buildAllCommandsAndEmit() {
101
- this.emit("commandsUpdated", this.buildAllCommands());
108
+ void this.emit("commandsUpdated", this.buildAllCommands());
102
109
  }
103
110
 
104
111
  apply(system: System<CommandHookT>): void {
@@ -3,6 +3,10 @@ import type { System } from "../system.ts";
3
3
  import type { EventHookI } from "../eventhook.ts";
4
4
  import type { EventHookT } from "@silverbulletmd/silverbullet/type/manifest";
5
5
  import type { Config } from "../../config.ts";
6
+ import type { Ref } from "@silverbulletmd/silverbullet/lib/ref";
7
+ import { listenerDefinition, type DefinedListener } from "../syscalls/event.ts";
8
+
9
+ export type EventResultWithSource = { value: any; definition: Ref | null };
6
10
 
7
11
  // System events:
8
12
  // - plug:load (plugName: string)
@@ -66,10 +70,33 @@ export class EventHook implements EventHookI {
66
70
  }
67
71
 
68
72
  async dispatchEvent(eventName: string, ...args: any[]): Promise<any[]> {
73
+ return (await this.dispatch(eventName, args, false)).map(
74
+ ({ value }) => value,
75
+ );
76
+ }
77
+
78
+ async dispatchEventStrict(eventName: string, ...args: any[]): Promise<any[]> {
79
+ return (await this.dispatch(eventName, args, true)).map(
80
+ ({ value }) => value,
81
+ );
82
+ }
83
+
84
+ async dispatchEventWithSources(
85
+ eventName: string,
86
+ ...args: any[]
87
+ ): Promise<EventResultWithSource[]> {
88
+ return this.dispatch(eventName, args, false);
89
+ }
90
+
91
+ private async dispatch(
92
+ eventName: string,
93
+ args: any[],
94
+ strict: boolean,
95
+ ): Promise<EventResultWithSource[]> {
69
96
  if (!this.system) {
70
97
  throw new Error("Event hook is not initialized");
71
98
  }
72
- const promises: Promise<any>[] = [];
99
+ const promises: { promise: Promise<any>; definition: Ref | null }[] = [];
73
100
  for (const plug of this.system.loadedPlugs.values()) {
74
101
  const manifest = plug.manifest;
75
102
  for (const [name, functionDef] of Object.entries(manifest!.functions)) {
@@ -80,8 +107,9 @@ export class EventHook implements EventHookI {
80
107
  eventNameToRegex(event).test(eventName)
81
108
  ) {
82
109
  if (plug.canInvoke(name)) {
83
- promises.push(
84
- (async () => {
110
+ promises.push({
111
+ definition: null,
112
+ promise: (async () => {
85
113
  try {
86
114
  return await plug.invoke(name, args);
87
115
  } catch (e: any) {
@@ -91,7 +119,7 @@ export class EventHook implements EventHookI {
91
119
  throw e;
92
120
  }
93
121
  })(),
94
- );
122
+ });
95
123
  }
96
124
  }
97
125
  }
@@ -102,25 +130,25 @@ export class EventHook implements EventHookI {
102
130
  for (const [name, localListeners] of this.localListeners) {
103
131
  if (eventNameToRegex(name).test(eventName)) {
104
132
  for (const localListener of localListeners) {
105
- promises.push(
106
- (async () => {
133
+ promises.push({
134
+ definition: null,
135
+ promise: (async () => {
107
136
  return await Promise.resolve(localListener(...args));
108
137
  })(),
109
- );
138
+ });
110
139
  }
111
140
  }
112
141
  }
113
142
 
114
143
  if (this.config) {
115
- const configListeners: Record<string, Function[]> = this.config.get(
116
- "eventListeners",
117
- {},
118
- );
144
+ const configListeners: Record<string, DefinedListener[]> =
145
+ this.config.get("eventListeners", {});
119
146
  for (const [name, listeners] of Object.entries(configListeners)) {
120
147
  if (eventNameToRegex(name).test(eventName)) {
121
148
  for (const listener of listeners) {
122
- promises.push(
123
- (async () => {
149
+ promises.push({
150
+ definition: listener[listenerDefinition] ?? null,
151
+ promise: (async () => {
124
152
  return await Promise.resolve(
125
153
  listener({
126
154
  name: eventName,
@@ -129,14 +157,25 @@ export class EventHook implements EventHookI {
129
157
  }),
130
158
  );
131
159
  })(),
132
- );
160
+ });
133
161
  }
134
162
  }
135
163
  }
136
164
  }
137
165
 
138
- return (await Promise.allSettled(promises))
139
- .filter((result) => {
166
+ const settled = await Promise.allSettled(
167
+ promises.map(({ promise }) => promise),
168
+ );
169
+ if (strict) {
170
+ const failure = settled.find((result) => result.status === "rejected");
171
+ if (failure?.status === "rejected") throw failure.reason;
172
+ }
173
+ return settled
174
+ .map((result, index) => ({
175
+ result,
176
+ definition: promises[index].definition,
177
+ }))
178
+ .filter(({ result }) => {
140
179
  if (result.status === "rejected") {
141
180
  console.error(
142
181
  "Error while dispatching event",
@@ -147,8 +186,11 @@ export class EventHook implements EventHookI {
147
186
  }
148
187
  return result.status === "fulfilled";
149
188
  })
150
- .map((result) => result.value)
151
- .filter((result) => result != null); // This keeps non-null/undefined results
189
+ .map(({ result, definition }) => ({
190
+ value: result.status === "fulfilled" ? result.value : undefined,
191
+ definition,
192
+ }))
193
+ .filter(({ value }) => value != null); // This keeps non-null/undefined results
152
194
  }
153
195
 
154
196
  apply(system: System<EventHookT>): void {
@@ -14,6 +14,7 @@ import type {
14
14
  SlashCompletionOption,
15
15
  SlashCompletions,
16
16
  } from "@silverbulletmd/silverbullet/type/client";
17
+ import { matchesNodeContexts } from "../../lib/node_contexts.ts";
17
18
 
18
19
  const slashCommandRegexp = /([^\w:]|^)\/[\w#-]*/;
19
20
 
@@ -77,19 +78,7 @@ export class SlashCommandHook implements Hook<SlashCommandHookT> {
77
78
 
78
79
  const parentNodes = this.client.extractParentNodes(ctx.state, currentNode);
79
80
  for (const def of this.slashCommands) {
80
- if (
81
- def.onlyContexts &&
82
- !def.onlyContexts.some((context) =>
83
- parentNodes.some((node) => node.startsWith(context)),
84
- )
85
- ) {
86
- continue;
87
- }
88
- if (
89
- def.exceptContexts?.some((context) =>
90
- parentNodes.some((node) => node.startsWith(context)),
91
- )
92
- ) {
81
+ if (!matchesNodeContexts(def, parentNodes)) {
93
82
  continue;
94
83
  }
95
84
  options.push({
@@ -2,13 +2,13 @@ import type { KvPrimitives } from "../data/kv_primitives.ts";
2
2
  import type { Plug } from "./plug.ts";
3
3
  import type { Manifest } from "./types.ts";
4
4
 
5
- export interface ManifestCache<T> {
5
+ export type ManifestCache<T> = {
6
6
  getManifest(
7
7
  plug: Plug<T>,
8
8
  cacheKey: string,
9
9
  cacheHash: number,
10
10
  ): Promise<Manifest<T>>;
11
- }
11
+ };
12
12
 
13
13
  export class KVPrimitivesManifestCache<T> implements ManifestCache<T> {
14
14
  constructor(
@@ -3,7 +3,7 @@ import type { Manifest } from "../types.ts";
3
3
 
4
4
  export type SandboxFactory<HookT> = (plug: Plug<HookT>) => Sandbox<HookT>;
5
5
 
6
- export interface Sandbox<HookT> {
6
+ export type Sandbox<HookT> = {
7
7
  manifest?: Manifest<HookT>;
8
8
 
9
9
  init(): Promise<void>;
@@ -11,4 +11,4 @@ export interface Sandbox<HookT> {
11
11
  invoke(name: string, args: any[]): Promise<any>;
12
12
 
13
13
  stop(): void;
14
- }
14
+ };
@@ -0,0 +1,104 @@
1
+ import {
2
+ maximumDocumentSize,
3
+ notFoundError,
4
+ } from "@silverbulletmd/silverbullet/constants";
5
+ import { isValidPath } from "@silverbulletmd/silverbullet/lib/ref";
6
+ import type { CaptureInvocationContext } from "../../capture/invocation.ts";
7
+ import type { Client } from "../../client.ts";
8
+ import type { SysCallMapping } from "../system.ts";
9
+
10
+ function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
11
+ return a.length === b.length && a.every((byte, index) => byte === b[index]);
12
+ }
13
+
14
+ export function captureSyscalls(
15
+ context: () => CaptureInvocationContext | undefined,
16
+ client: Client,
17
+ ): SysCallMapping {
18
+ const active = (): CaptureInvocationContext => {
19
+ const current = context();
20
+ if (!current) throw new Error("No active capture action");
21
+ return current;
22
+ };
23
+
24
+ return {
25
+ "capture.readFile": {
26
+ callback: async (_ctx, handle: string): Promise<Uint8Array> =>
27
+ new Uint8Array(await (await active().readFile(handle)).arrayBuffer()),
28
+ description:
29
+ "Reads a staged file belonging to the current capture action.",
30
+ parameters: [
31
+ {
32
+ name: "handle",
33
+ type: "string",
34
+ description: "File handle from the capture.",
35
+ },
36
+ ],
37
+ returns: [{ type: "byteArray", description: "File bytes." }],
38
+ examples: [
39
+ { code: "local bytes = capture.readFile(data.files[1].handle)" },
40
+ ],
41
+ },
42
+ "capture.saveFile": {
43
+ callback: async (
44
+ _ctx,
45
+ handle: string,
46
+ path: string,
47
+ ): Promise<boolean> => {
48
+ if (!isValidPath(path)) throw new Error("Invalid capture file path");
49
+ const blob = await active().readFile(handle);
50
+ const limit = client.config.get<number>(
51
+ "maximumDocumentSize",
52
+ maximumDocumentSize,
53
+ );
54
+ if (typeof limit !== "number" || blob.size > limit * 1024 * 1024) {
55
+ throw new Error("Shared file is too large");
56
+ }
57
+ const bytes = new Uint8Array(await blob.arrayBuffer());
58
+ let existing = false;
59
+ try {
60
+ await client.space.getDocumentMeta(path);
61
+ existing = true;
62
+ } catch (error) {
63
+ if (
64
+ !(error instanceof Error && error.message === notFoundError.message)
65
+ )
66
+ throw error;
67
+ }
68
+ if (existing) {
69
+ const saved = await client.space.readDocument(path);
70
+ if (!sameBytes(saved.data, bytes))
71
+ throw new Error(`Shared file conflict: ${path}`);
72
+ return false;
73
+ }
74
+ await client.space.writeDocument(path, bytes);
75
+ const saved = await client.space.readDocument(path);
76
+ if (!sameBytes(saved.data, bytes))
77
+ throw new Error(`Shared file verification failed: ${path}`);
78
+ return true;
79
+ },
80
+ description:
81
+ "Saves a staged file without overwriting different bytes. Returns true when newly created and false when identical content already exists.",
82
+ parameters: [
83
+ {
84
+ name: "handle",
85
+ type: "string",
86
+ description: "File handle from the capture.",
87
+ },
88
+ { name: "path", type: "string", description: "New document path." },
89
+ ],
90
+ returns: [
91
+ {
92
+ type: "boolean",
93
+ description:
94
+ "true when the file was created, false when identical bytes already exist.",
95
+ },
96
+ ],
97
+ examples: [
98
+ {
99
+ code: 'capture.saveFile(data.files[1].handle, "Inbox/" .. data.files[1].name)',
100
+ },
101
+ ],
102
+ },
103
+ };
104
+ }
@@ -1,4 +1,3 @@
1
- import { copyToClipboard } from "../../clipboard.ts";
2
1
  import {
3
2
  acceptCompletion,
4
3
  closeCompletion,
@@ -70,6 +69,8 @@ import {
70
69
  type Ref,
71
70
  } from "@silverbulletmd/silverbullet/lib/ref";
72
71
  import type {
72
+ DocumentCapability,
73
+ DocumentCapabilityDescriptor,
73
74
  FilterOption,
74
75
  NotificationAction,
75
76
  NotificationType,
@@ -80,8 +81,11 @@ import type { VimConfig } from "@silverbulletmd/silverbullet/type/config";
80
81
  import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";
81
82
  import { updateBakedSections } from "../../baked_sections/bake.ts";
82
83
  import type { Client } from "../../client.ts";
84
+ import { copyToClipboard } from "../../clipboard.ts";
83
85
  import { refreshLintEffect } from "../../codemirror/lint.ts";
86
+ import { resolveDocumentCapability } from "../../document_editor_resolver.ts";
84
87
  import { isMobileDevice, isNarrowScreen } from "../../lib/mobile.ts";
88
+ import { browserMediaCapabilities } from "../../media.ts";
85
89
  import { hide as hideNavigatorSlot } from "../../navigator/navigator.ts";
86
90
  import type { PanelSlot } from "../../types/ui.ts";
87
91
  import { getVimModule } from "../../vim_loader.ts";
@@ -168,7 +172,7 @@ export function editorSyscalls(client: Client): SysCallMapping {
168
172
  ),
169
173
  ];
170
174
  },
171
- description: `Returns the file extensions that have a document editor registered, i.e. the documents this client can actually open. Extensions carry no leading dot. Which editors are loaded depends on the plugs installed, so this is a property of the client rather than of the space.`,
175
+ description: `Returns the file extensions claimed by explicitly registered document editors. Extensions carry no leading dot. Built-in text and media fallbacks are not included.`,
172
176
  returns: [
173
177
  {
174
178
  type: "string[]",
@@ -176,6 +180,34 @@ export function editorSyscalls(client: Client): SysCallMapping {
176
180
  },
177
181
  ],
178
182
  },
183
+ "editor.getDocumentCapabilities": {
184
+ callback: (
185
+ _ctx,
186
+ documents: DocumentCapabilityDescriptor[],
187
+ ): DocumentCapability[] =>
188
+ documents.map((document) =>
189
+ resolveDocumentCapability(
190
+ document,
191
+ client.clientSystem.documentEditorHook.documentEditors,
192
+ browserMediaCapabilities,
193
+ ),
194
+ ),
195
+ description:
196
+ "Resolves the available editor capability for each document from metadata only, in input order.",
197
+ parameters: [
198
+ {
199
+ name: "documents",
200
+ type: "DocumentCapabilityDescriptor[]",
201
+ description: "Document metadata to resolve without reading bodies.",
202
+ },
203
+ ],
204
+ returns: [
205
+ {
206
+ type: "DocumentCapability[]",
207
+ description: "Capabilities in the same order as the input.",
208
+ },
209
+ ],
210
+ },
179
211
  "editor.getText": {
180
212
  callback: () => {
181
213
  return client.editorView.state.sliceDoc();
@@ -466,6 +498,29 @@ export function editorSyscalls(client: Client): SysCallMapping {
466
498
  },
467
499
  ],
468
500
  },
501
+ "editor.downloadSpaceFile": {
502
+ callback: async (_ctx, name: string) => {
503
+ const { data, meta } =
504
+ await client.space.spacePrimitives.readFile(name);
505
+ const url = URL.createObjectURL(
506
+ new Blob([new Uint8Array(data)], {
507
+ type: meta.contentType || "application/octet-stream",
508
+ }),
509
+ );
510
+ try {
511
+ const link = document.createElement("a");
512
+ link.href = url;
513
+ link.download = name.split("/").at(-1)!;
514
+ link.click();
515
+ } finally {
516
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
517
+ }
518
+ },
519
+ description: "Downloads a space file using its current contents.",
520
+ parameters: [
521
+ { name: "name", type: "string", description: "Space file path." },
522
+ ],
523
+ },
469
524
  "editor.uploadFile": {
470
525
  callback: (
471
526
  _ctx,
@@ -483,6 +538,7 @@ export function editorSyscalls(client: Client): SysCallMapping {
483
538
  }
484
539
 
485
540
  input.onchange = () => {
541
+ input.remove();
486
542
  const file = input.files?.item(0);
487
543
  if (!file) {
488
544
  reject(new Error("No file found"));
@@ -509,11 +565,21 @@ export function editorSyscalls(client: Client): SysCallMapping {
509
565
  input.onabort = (e) => {
510
566
  reject(e);
511
567
  };
568
+ input.oncancel = () => {
569
+ input.remove();
570
+ };
512
571
 
513
572
  input.style.display = "none";
514
573
  document.body.appendChild(input);
515
- input.click();
516
- setTimeout(() => document.body.removeChild(input), 1000);
574
+ if (isMobileDevice()) {
575
+ try {
576
+ input.showPicker();
577
+ } catch {
578
+ input.click();
579
+ }
580
+ } else {
581
+ input.click();
582
+ }
517
583
  });
518
584
  },
519
585
  description:
@@ -1571,7 +1637,7 @@ export function editorSyscalls(client: Client): SysCallMapping {
1571
1637
  callback: (_ctx, type: string, data: any) => {
1572
1638
  if (!client.contentManager.isDocumentEditor()) return;
1573
1639
 
1574
- client.contentManager.documentEditor.sendPublicMessage({
1640
+ client.contentManager.documentEditor.sendPublicMessage?.({
1575
1641
  type,
1576
1642
  data,
1577
1643
  });
@@ -3,6 +3,11 @@ import type { EventHookI } from "../eventhook.ts";
3
3
  import type { EventSubscription } from "@silverbulletmd/silverbullet/type/event";
4
4
  import { LuaStackFrame, luaValueToJS } from "../../space_lua/runtime.ts";
5
5
  import type { Client } from "../../client.ts";
6
+ import { luaDefinitionRef } from "../../space_lua.ts";
7
+ import type { Ref } from "@silverbulletmd/silverbullet/lib/ref";
8
+
9
+ export const listenerDefinition = Symbol("listenerDefinition");
10
+ export type DefinedListener = Function & { [listenerDefinition]?: Ref | null };
6
11
 
7
12
  export function eventSyscalls(
8
13
  eventHook: EventHookI,
@@ -38,13 +43,13 @@ export function eventSyscalls(
38
43
  */
39
44
  "event.listen": {
40
45
  callback: (_ctx, def: EventSubscription) => {
41
- client.config.insert(
42
- ["eventListeners", def.name],
43
- async (...args: any[]) => {
44
- const val = await def.run(...args);
45
- return luaValueToJS(val, LuaStackFrame.lostFrame);
46
- },
47
- );
46
+ const definition = luaDefinitionRef(def.run);
47
+ const listener = async (...args: any[]) => {
48
+ const val = await def.run(...args);
49
+ return luaValueToJS(val, LuaStackFrame.lostFrame);
50
+ };
51
+ (listener as DefinedListener)[listenerDefinition] = definition;
52
+ client.config.insert(["eventListeners", def.name], listener);
48
53
  },
49
54
  description: "Registers a Space Lua listener on the event bus.",
50
55
  parameters: [
@@ -60,23 +60,18 @@ export function navigatorSyscalls(): SysCallMapping {
60
60
  ],
61
61
  examples: [{ code: "onMove = view.moveByRename" }],
62
62
  },
63
- "lua:view.new": {
63
+ "lua:widget.newLive": {
64
64
  callback: (_ctx, spec: any) => newView(spec),
65
65
  description:
66
- "Creates a reusable navigator view value without registering or evaluating it.",
66
+ "Internal: builds a live widget for `widget.new` when its spec has `source` or `content`. Call `widget.new` instead.",
67
67
  parameters: [
68
68
  {
69
69
  name: "spec",
70
70
  type: "table",
71
- description: "View content and behavior; see the Navigator API docs.",
72
- },
73
- ],
74
- returns: [{ type: "table", description: "The reusable view value." }],
75
- examples: [
76
- {
77
- code: "local projects = view.new { source = function() return {} end }",
71
+ description: "A `widget.new` spec with `source` or `content`.",
78
72
  },
79
73
  ],
74
+ returns: [{ type: "table", description: "The live widget value." }],
80
75
  },
81
76
  "lua:view.define": {
82
77
  callback: (_ctx, spec: any) => {
@@ -65,6 +65,20 @@ export function syncSyscalls(client: Client): SysCallMapping {
65
65
  }
66
66
 
67
67
  return {
68
+ "sync.isEnabled": {
69
+ callback: (): boolean => {
70
+ return !client.bootConfig.disableServiceWorker;
71
+ },
72
+ description:
73
+ "Whether client Sync is enabled. False when the service worker is off.",
74
+ returns: [
75
+ {
76
+ type: "boolean",
77
+ description:
78
+ "False when SB_DISABLE_SERVICE_WORKER is set or the browser has no service worker.",
79
+ },
80
+ ],
81
+ },
68
82
  "sync.hasInitialSyncCompleted": {
69
83
  callback: (): boolean => {
70
84
  return client.fullSyncCompleted;
@@ -91,6 +91,7 @@ export function systemSyscalls(
91
91
  hide: def.hide,
92
92
  requireMode: def.requireMode,
93
93
  requireEditor: def.requireEditor,
94
+ requireServiceWorker: def.requireServiceWorker,
94
95
  menu: def.menu,
95
96
  menuMac: def.menuMac,
96
97
  menuWindows: def.menuWindows,
@@ -9,9 +9,9 @@ import {
9
9
  } from "../../plugs/builtin_plugs.ts";
10
10
  import type { LuaFunctionDocumentation } from "../../plug-api/types/index.ts";
11
11
 
12
- export interface SysCallMapping {
12
+ export type SysCallMapping = {
13
13
  [key: string]: SyscallSignature | SyscallDefinition;
14
- }
14
+ };
15
15
 
16
16
  export type SystemEvents<HookT> = {
17
17
  plugLoaded: (plug: Plug<HookT>) => void | Promise<void>;
@@ -4,7 +4,7 @@ import type { AssetJson } from "../asset_bundle/bundle.ts";
4
4
  /** The generic top level of a plug manifest file.
5
5
  * Defines plug metadata and functions.
6
6
  */
7
- export interface Manifest<HookT> {
7
+ export type Manifest<HookT> = {
8
8
  /** The plug's name. Typically this is the name of the manifest file, without the file extension. */
9
9
  name: string;
10
10
 
@@ -42,7 +42,7 @@ export interface Manifest<HookT> {
42
42
  * which runs esbuild in bundle mode on `in` and writes the result to `out`.
43
43
  */
44
44
  build?: BuildStep[];
45
- }
45
+ };
46
46
 
47
47
  /** A pre-asset-bundling transformation, e.g. bundling a TypeScript entry point
48
48
  * into a single JS file that is then loaded from plug assets.
@@ -52,14 +52,14 @@ export interface Manifest<HookT> {
52
52
  * - `sass`: compile an SCSS/SASS file at `in` to CSS at `out`.
53
53
  * - `copy`: copy `in` verbatim to `out`.
54
54
  */
55
- export interface BuildStep {
55
+ export type BuildStep = {
56
56
  /** Transformation type. Defaults to "esbuild" when omitted. */
57
57
  type?: "esbuild" | "sass" | "copy";
58
58
  /** Input path relative to the manifest. */
59
59
  in: string;
60
60
  /** Output path relative to the manifest. */
61
61
  out: string;
62
- }
62
+ };
63
63
 
64
64
  /** Associates hooks with a function. This is the generic base structure, that identifies the function. Hooks are defined by the type parameter. */
65
65
  export type FunctionDef<HookT> = {
@@ -78,8 +78,8 @@ export type FunctionDef<HookT> = {
78
78
  env?: string;
79
79
  } & HookT;
80
80
 
81
- export interface Hook<HookT> {
81
+ export type Hook<HookT> = {
82
82
  validateManifest(manifest: Manifest<HookT>): string[];
83
83
 
84
84
  apply(system: System<HookT>): void;
85
- }
85
+ };
@@ -24,13 +24,13 @@ import type { Config } from "../config.ts";
24
24
  import { coerceToNumber, isTaggedFloat } from "./numeric.ts";
25
25
  import YAML from "js-yaml";
26
26
 
27
- export interface AggregateSpec {
27
+ export type AggregateSpec = {
28
28
  name: string;
29
29
  description?: string;
30
30
  initialize: LuaValue; // ILuaFunction
31
31
  iterate: LuaValue; // ILuaFunction
32
32
  finish?: LuaValue; // ILuaFunction | undefined
33
- }
33
+ };
34
34
 
35
35
  // Helper to build an ILuaFunction from a plain function. Equivalent to
36
36
  // LuaBuiltinFunction but without referencing the class.
@@ -54,11 +54,11 @@ function unboxValue(value: LuaValue): LuaValue {
54
54
  }
55
55
 
56
56
  // Welford's online algorithm (for variance and standard deviation)
57
- interface WelfordState {
57
+ type WelfordState = {
58
58
  n: number;
59
59
  mean: number;
60
60
  m2: number;
61
- }
61
+ };
62
62
 
63
63
  function welfordInit(): WelfordState {
64
64
  return { n: 0, mean: 0, m2: 0 };
@@ -119,11 +119,11 @@ type QuantileMethod =
119
119
  | "nearest"
120
120
  | "midpoint";
121
121
 
122
- interface QuantileState {
122
+ type QuantileState = {
123
123
  values: number[];
124
124
  q: number;
125
125
  method: QuantileMethod;
126
- }
126
+ };
127
127
 
128
128
  const quantileNameDefaults: Record<string, QuantileMethod> = {
129
129
  percentile_cont: "linear",
@@ -2,10 +2,10 @@ import type { NumericType } from "./ast.ts";
2
2
  import { luaToNumberDetailed } from "./tonumber.ts";
3
3
  import { luaTypeName } from "./runtime.ts";
4
4
 
5
- export interface LuaTaggedFloat {
5
+ export type LuaTaggedFloat = {
6
6
  readonly value: number;
7
7
  readonly isFloat: true;
8
- }
8
+ };
9
9
 
10
10
  const FLOAT_POS_ZERO: LuaTaggedFloat = { value: 0, isFloat: true };
11
11
  const FLOAT_NEG_ZERO: LuaTaggedFloat = { value: -0, isFloat: true };
@@ -162,14 +162,14 @@ export type LuaCollectionQuery = {
162
162
  having?: LuaExpression;
163
163
  };
164
164
 
165
- export interface LuaQueryCollection {
165
+ export type LuaQueryCollection = {
166
166
  query(
167
167
  query: LuaCollectionQuery,
168
168
  env: LuaEnv,
169
169
  sf: LuaStackFrame,
170
170
  config?: Config,
171
171
  ): Promise<any[]>;
172
- }
172
+ };
173
173
 
174
174
  export class ArrayQueryCollection<T> implements LuaQueryCollection {
175
175
  constructor(private readonly array: T[]) {}
@@ -648,6 +648,18 @@ async function evalSelectExpression(
648
648
  return result;
649
649
  }
650
650
 
651
+ /** Whether `value` passes the query's `where`, as `applyQuery` filters it. */
652
+ export async function matchesWhere(
653
+ value: any,
654
+ query: LuaCollectionQuery,
655
+ env: LuaEnv,
656
+ sf: LuaStackFrame,
657
+ ): Promise<boolean> {
658
+ if (!query.where) return true;
659
+ const itemEnv = buildItemEnvLocal(query.objectVariable, value, env, sf);
660
+ return !!(await evalExpression(query.where, itemEnv, sf));
661
+ }
662
+
651
663
  export async function applyQuery(
652
664
  results: any[],
653
665
  query: LuaCollectionQuery,
@@ -659,8 +671,7 @@ export async function applyQuery(
659
671
  if (query.where) {
660
672
  const filteredResults = [];
661
673
  for (const value of results) {
662
- const itemEnv = buildItemEnvLocal(query.objectVariable, value, env, sf);
663
- if (await evalExpression(query.where, itemEnv, sf)) {
674
+ if (await matchesWhere(value, query, env, sf)) {
664
675
  filteredResults.push(value);
665
676
  }
666
677
  }
@@ -12,6 +12,7 @@ import type {
12
12
  } from "../../plug-api/types/index.ts";
13
13
 
14
14
  let boundaryBudgetFactory: (() => LuaBudget) | undefined;
15
+ export const luaFunctionInfo = Symbol("luaFunctionInfo");
15
16
 
16
17
  export function setBoundaryBudgetFactory(
17
18
  f: (() => LuaBudget) | undefined,
@@ -39,27 +40,27 @@ export type LuaFunctionDefinition<Callback> = {
39
40
  callback: Callback;
40
41
  } & LuaFunctionDefinitionMetadata;
41
42
 
42
- export interface ILuaFunction {
43
+ export type ILuaFunction = {
43
44
  call(sf: LuaStackFrame, ...args: LuaValue[]): Promise<LuaValue> | LuaValue;
44
45
 
45
46
  asString(): string;
46
47
 
47
48
  info?: LuaFunctionInfo;
48
- }
49
+ };
49
50
 
50
- export interface ILuaSettable {
51
+ export type ILuaSettable = {
51
52
  set(
52
53
  key: LuaValue,
53
54
  value: LuaValue,
54
55
  sf?: LuaStackFrame,
55
56
  numType?: NumericType,
56
57
  ): void | Promise<void>;
57
- }
58
+ };
58
59
 
59
- export interface ILuaGettable {
60
+ export type ILuaGettable = {
60
61
  get(key: LuaValue, sf?: LuaStackFrame): LuaValue | Promise<LuaValue> | null;
61
62
  getNumericType?(key: LuaValue): NumericType | undefined;
62
- }
63
+ };
63
64
 
64
65
  export function isILuaFunction(v: unknown): v is ILuaFunction {
65
66
  return !!v && typeof (v as any).call === "function";
@@ -1825,7 +1826,7 @@ export function luaValueToJS(value: any, sf: LuaStackFrame): any {
1825
1826
  value instanceof LuaFunction ||
1826
1827
  value instanceof LuaBuiltinFunction
1827
1828
  ) {
1828
- return (...args: any[]) => {
1829
+ const fn = (...args: any[]) => {
1829
1830
  // A converted closure captures one frame forever; without a fresh
1830
1831
  // thread state per call, every invocation would share one budget.
1831
1832
  const boundaryBudget = boundaryBudgetFactory?.();
@@ -1868,6 +1869,12 @@ export function luaValueToJS(value: any, sf: LuaStackFrame): any {
1868
1869
  throw e;
1869
1870
  }
1870
1871
  };
1872
+ if (value instanceof LuaFunction) {
1873
+ (fn as typeof fn & { [luaFunctionInfo]: LuaFunctionInfo })[
1874
+ luaFunctionInfo
1875
+ ] = value.info;
1876
+ }
1877
+ return fn;
1871
1878
  }
1872
1879
  if (isTaggedFloat(value)) {
1873
1880
  return value.value;
@@ -32,12 +32,12 @@ const MAX_MATCH_DEPTH = 200;
32
32
  const CAP_UNFINISHED = -1;
33
33
  const CAP_POSITION = -2;
34
34
 
35
- interface Capture {
35
+ type Capture = {
36
36
  init: number;
37
37
  len: number;
38
- }
38
+ };
39
39
 
40
- export interface MatchState {
40
+ export type MatchState = {
41
41
  src: string;
42
42
  s: Uint16Array;
43
43
  slen: number;
@@ -46,7 +46,7 @@ export interface MatchState {
46
46
  level: number;
47
47
  capture: Capture[]; // pre-allocated, length `MAX_CAPTURES`
48
48
  matchdepth: number;
49
- }
49
+ };
50
50
 
51
51
  function toCodeUnits(s: string): Uint16Array {
52
52
  const len = s.length;
@@ -434,11 +434,11 @@ function noSpecials(p: string): boolean {
434
434
 
435
435
  export type CaptureResult = { s: string } | { position: number };
436
436
 
437
- interface RawCapture {
437
+ type RawCapture = {
438
438
  kind: 0 | 1; // 0 = string slice, 1 = position
439
439
  start: number;
440
440
  len: number; // kind = 0: substring length; kind = 1: 1-based position
441
- }
441
+ };
442
442
 
443
443
  function getOneRawCapture(
444
444
  ms: MatchState,
@@ -622,13 +622,13 @@ function expandReplacementString(
622
622
  return parts.join("");
623
623
  }
624
624
 
625
- export interface GsubCallbacks {
625
+ export type GsubCallbacks = {
626
626
  replString?: string;
627
627
  replFunction?: (
628
628
  ...captures: CaptureResult[]
629
629
  ) => Promise<string | null | undefined> | string | null | undefined;
630
630
  replTable?: (key: string) => string | null | undefined;
631
- }
631
+ };
632
632
 
633
633
  export async function patternGsub(
634
634
  s: string,
@@ -28,16 +28,16 @@ type KOption =
28
28
  | "paddalign"
29
29
  | "nop";
30
30
 
31
- interface ParsedOption {
31
+ type ParsedOption = {
32
32
  opt: KOption;
33
33
  size: number; // byte width
34
34
  ntoalign: number; // padding bytes before this field
35
- }
35
+ };
36
36
 
37
- interface Header {
37
+ type Header = {
38
38
  islittle: boolean;
39
39
  maxalign: number;
40
- }
40
+ };
41
41
 
42
42
  function makeHeader(): Header {
43
43
  return { islittle: NATIVE_LITTLE, maxalign: NATIVE_MAXALIGN };
@@ -4,7 +4,7 @@
4
4
  import { Command } from "commander";
5
5
 
6
6
  // version.ts
7
- var version = "2.11.0";
7
+ var version = "2.12.0";
8
8
 
9
9
  // client/plugos/plug_compile.ts
10
10
  import * as path from "node:path";
@@ -1330,7 +1330,7 @@ var workerRuntimePlugin = {
1330
1330
  namespace: "worker-runtime"
1331
1331
  }));
1332
1332
  build2.onLoad({ filter: /.*/, namespace: "worker-runtime" }, () => ({
1333
- contents: '// plug-api/lib/crypto.ts\nfunction base64Decode(s) {\n const binString = atob(s);\n const len = binString.length;\n const bytes = new Uint8Array(len);\n for (let i = 0; i < len; i++) {\n bytes[i] = binString.charCodeAt(i);\n }\n return bytes;\n}\nfunction base64Encode(buffer) {\n if (typeof buffer === "string") {\n buffer = new TextEncoder().encode(buffer);\n }\n let binary = "";\n const len = buffer.byteLength;\n for (let i = 0; i < len; i++) {\n binary += String.fromCharCode(buffer[i]);\n }\n return btoa(binary);\n}\nvar fixedCounter = new Uint8Array(16);\n\n// client/lib/logger.ts\nvar Logger = class {\n constructor(prefix = "", maxCaptureSize = 1e3) {\n this.prefix = prefix;\n this.maxCaptureSize = maxCaptureSize;\n this.prefix = prefix;\n this.originalConsole = {\n log: console.log.bind(console),\n info: console.info.bind(console),\n warn: console.warn.bind(console),\n error: console.error.bind(console),\n debug: console.debug.bind(console)\n };\n this.patchConsole();\n }\n originalConsole;\n logBuffer = [];\n patchConsole() {\n const createPatchedMethod = (level) => {\n return (...args) => {\n const prefixedArgs = this.prefix ? [this.prefix, ...args] : args;\n this.originalConsole[level](...prefixedArgs);\n this.captureLog(level, args);\n };\n };\n console.log = createPatchedMethod("log");\n console.info = createPatchedMethod("info");\n console.warn = createPatchedMethod("warn");\n console.error = createPatchedMethod("error");\n console.debug = createPatchedMethod("debug");\n }\n captureLog(level, args) {\n const entry = {\n level,\n timestamp: Date.now(),\n message: args.map((arg) => {\n if (typeof arg === "string") {\n return arg;\n }\n try {\n return JSON.stringify(arg);\n } catch {\n return String(arg);\n }\n }).join(" ")\n };\n this.logBuffer.push(entry);\n if (this.logBuffer.length > this.maxCaptureSize) {\n this.logBuffer.shift();\n }\n }\n /**\n * Posts all buffered logs to a server endpoint\n */\n async postToServer(logEndpoint, source) {\n const logs = this.logBuffer;\n if (logs.length > 0) {\n const logCopy = [...this.logBuffer];\n this.logBuffer = [];\n try {\n const resp = await fetch(logEndpoint, {\n method: "POST",\n headers: {\n "Content-Type": "application/json"\n },\n body: JSON.stringify(logCopy.map((entry) => ({ ...entry, source })))\n });\n if (!resp.ok) {\n throw new Error("Failed to post logs to server");\n }\n } catch (e) {\n console.warn("Could not post logs to server", e.message);\n this.logBuffer.unshift(...logCopy);\n }\n }\n }\n};\nvar globalLogger;\nfunction initLogger(prefix = "") {\n globalLogger = new Logger(prefix);\n return globalLogger;\n}\n\n// client/plugos/worker_runtime.ts\nvar workerPostMessage = (_msg) => {\n throw new Error("Not initialized yet");\n};\nvar runningAsWebWorker = typeof window === "undefined" && // @ts-expect-error: globalThis\ntypeof globalThis.WebSocketPair === "undefined";\nvar pendingRequests = /* @__PURE__ */ new Map();\nvar syscallReqId = 0;\nif (runningAsWebWorker) {\n globalThis.syscall = async (name, ...args) => {\n return await new Promise((resolve, reject) => {\n syscallReqId++;\n pendingRequests.set(syscallReqId, { resolve, reject });\n workerPostMessage({\n type: "sys",\n id: syscallReqId,\n name,\n args\n });\n });\n };\n}\nfunction setupMessageListener(functionMapping, manifest, postMessageFn) {\n if (!runningAsWebWorker) {\n return;\n }\n workerPostMessage = postMessageFn;\n self.addEventListener("message", (event) => {\n (async () => {\n const data = event.data;\n switch (data.type) {\n case "inv":\n {\n const fn = functionMapping[data.name];\n if (!fn) {\n throw new Error(`Function not loaded: ${data.name}`);\n }\n try {\n const result = await Promise.resolve(fn(...data.args || []));\n workerPostMessage({\n type: "invr",\n id: data.id,\n result\n });\n } catch (e) {\n console.error(\n "An exception was thrown as a result of invoking function",\n data.name,\n "error:",\n e.message\n );\n workerPostMessage({\n type: "invr",\n id: data.id,\n error: e.message\n });\n }\n }\n break;\n case "sysr":\n {\n const syscallId = data.id;\n const lookup = pendingRequests.get(syscallId);\n if (!lookup) {\n throw Error("Invalid request id");\n }\n pendingRequests.delete(syscallId);\n if (data.error) {\n lookup.reject(new Error(data.error));\n } else {\n lookup.resolve(data.result);\n }\n }\n break;\n }\n })().catch(console.error);\n });\n workerPostMessage({\n type: "manifest",\n manifest\n });\n initLogger(`[${manifest.name} plug]`);\n}\nasync function sandboxFetch(reqInfo, options) {\n if (typeof reqInfo !== "string") {\n const body = new Uint8Array(await reqInfo.arrayBuffer());\n const encodedBody = body.length > 0 ? base64Encode(body) : void 0;\n options = {\n method: reqInfo.method,\n headers: Object.fromEntries(reqInfo.headers.entries()),\n base64Body: encodedBody\n };\n reqInfo = reqInfo.url;\n }\n return syscall("sandboxFetch.fetch", reqInfo, options);\n}\nglobalThis.nativeFetch = globalThis.fetch;\nfunction monkeyPatchFetch() {\n globalThis.fetch = async (reqInfo, init) => {\n const encodedBody = init?.body ? base64Encode(\n new Uint8Array(await new Response(init.body).arrayBuffer())\n ) : void 0;\n const r = await sandboxFetch(\n reqInfo,\n init && {\n method: init.method,\n headers: init.headers,\n base64Body: encodedBody\n }\n );\n return new Response(\n r.base64Body ? base64Decode(r.base64Body) : null,\n {\n status: r.status,\n headers: r.headers\n }\n );\n };\n}\nif (runningAsWebWorker) {\n monkeyPatchFetch();\n}\nexport {\n monkeyPatchFetch,\n sandboxFetch,\n setupMessageListener\n};\n',
1333
+ contents: '// plug-api/lib/crypto.ts\nfunction base64Decode(s) {\n const binString = atob(s);\n const len = binString.length;\n const bytes = new Uint8Array(len);\n for (let i = 0; i < len; i++) {\n bytes[i] = binString.charCodeAt(i);\n }\n return bytes;\n}\nfunction base64Encode(buffer) {\n if (typeof buffer === "string") {\n buffer = new TextEncoder().encode(buffer);\n }\n let binary = "";\n const len = buffer.byteLength;\n for (let i = 0; i < len; i++) {\n binary += String.fromCharCode(buffer[i]);\n }\n return btoa(binary);\n}\nvar fixedCounter = new Uint8Array(16);\n\n// client/lib/logger.ts\nvar Logger = class {\n constructor(prefix = "", maxCaptureSize = 1e3) {\n this.prefix = prefix;\n this.maxCaptureSize = maxCaptureSize;\n this.prefix = prefix;\n this.originalConsole = {\n log: console.log.bind(console),\n info: console.info.bind(console),\n warn: console.warn.bind(console),\n error: console.error.bind(console),\n debug: console.debug.bind(console)\n };\n this.patchConsole();\n }\n prefix;\n maxCaptureSize;\n originalConsole;\n logBuffer = [];\n patchConsole() {\n const createPatchedMethod = (level) => {\n return (...args) => {\n const prefixedArgs = this.prefix ? [this.prefix, ...args] : args;\n this.originalConsole[level](...prefixedArgs);\n this.captureLog(level, args);\n };\n };\n console.log = createPatchedMethod("log");\n console.info = createPatchedMethod("info");\n console.warn = createPatchedMethod("warn");\n console.error = createPatchedMethod("error");\n console.debug = createPatchedMethod("debug");\n }\n captureLog(level, args) {\n const entry = {\n level,\n timestamp: Date.now(),\n message: args.map((arg) => {\n if (typeof arg === "string") {\n return arg;\n }\n try {\n return JSON.stringify(arg);\n } catch {\n return String(arg);\n }\n }).join(" ")\n };\n this.logBuffer.push(entry);\n if (this.logBuffer.length > this.maxCaptureSize) {\n this.logBuffer.shift();\n }\n }\n /**\n * Posts all buffered logs to a server endpoint\n */\n async postToServer(logEndpoint, source) {\n const logs = this.logBuffer;\n if (logs.length > 0) {\n const logCopy = [...this.logBuffer];\n this.logBuffer = [];\n try {\n const resp = await fetch(logEndpoint, {\n method: "POST",\n headers: {\n "Content-Type": "application/json"\n },\n body: JSON.stringify(logCopy.map((entry) => ({ ...entry, source })))\n });\n if (!resp.ok) {\n throw new Error("Failed to post logs to server");\n }\n } catch (e) {\n console.warn("Could not post logs to server", e.message);\n this.logBuffer.unshift(...logCopy);\n }\n }\n }\n};\nvar globalLogger;\nfunction initLogger(prefix = "") {\n globalLogger = new Logger(prefix);\n return globalLogger;\n}\n\n// client/plugos/worker_runtime.ts\nvar workerPostMessage = (_msg) => {\n throw new Error("Not initialized yet");\n};\nvar runningAsWebWorker = typeof window === "undefined" && // @ts-expect-error: globalThis\ntypeof globalThis.WebSocketPair === "undefined";\nvar pendingRequests = /* @__PURE__ */ new Map();\nvar syscallReqId = 0;\nif (runningAsWebWorker) {\n globalThis.syscall = async (name, ...args) => {\n return await new Promise((resolve, reject) => {\n syscallReqId++;\n pendingRequests.set(syscallReqId, { resolve, reject });\n workerPostMessage({\n type: "sys",\n id: syscallReqId,\n name,\n args\n });\n });\n };\n}\nfunction setupMessageListener(functionMapping, manifest, postMessageFn) {\n if (!runningAsWebWorker) {\n return;\n }\n workerPostMessage = postMessageFn;\n self.addEventListener("message", (event) => {\n (async () => {\n const data = event.data;\n switch (data.type) {\n case "inv":\n {\n const fn = functionMapping[data.name];\n if (!fn) {\n throw new Error(`Function not loaded: ${data.name}`);\n }\n try {\n const result = await Promise.resolve(fn(...data.args || []));\n workerPostMessage({\n type: "invr",\n id: data.id,\n result\n });\n } catch (e) {\n console.error(\n "An exception was thrown as a result of invoking function",\n data.name,\n "error:",\n e.message\n );\n workerPostMessage({\n type: "invr",\n id: data.id,\n error: e.message\n });\n }\n }\n break;\n case "sysr":\n {\n const syscallId = data.id;\n const lookup = pendingRequests.get(syscallId);\n if (!lookup) {\n throw Error("Invalid request id");\n }\n pendingRequests.delete(syscallId);\n if (data.error) {\n lookup.reject(new Error(data.error));\n } else {\n lookup.resolve(data.result);\n }\n }\n break;\n }\n })().catch(console.error);\n });\n workerPostMessage({\n type: "manifest",\n manifest\n });\n initLogger(`[${manifest.name} plug]`);\n}\nasync function sandboxFetch(reqInfo, options) {\n if (typeof reqInfo !== "string") {\n const body = new Uint8Array(await reqInfo.arrayBuffer());\n const encodedBody = body.length > 0 ? base64Encode(body) : void 0;\n options = {\n method: reqInfo.method,\n headers: Object.fromEntries(reqInfo.headers.entries()),\n base64Body: encodedBody\n };\n reqInfo = reqInfo.url;\n }\n return syscall("sandboxFetch.fetch", reqInfo, options);\n}\nglobalThis.nativeFetch = globalThis.fetch;\nfunction monkeyPatchFetch() {\n globalThis.fetch = async (reqInfo, init) => {\n const encodedBody = init?.body ? base64Encode(\n new Uint8Array(await new Response(init.body).arrayBuffer())\n ) : void 0;\n const r = await sandboxFetch(\n reqInfo,\n init && {\n method: init.method,\n headers: init.headers,\n base64Body: encodedBody\n }\n );\n return new Response(\n r.base64Body ? base64Decode(r.base64Body) : null,\n {\n status: r.status,\n headers: r.headers\n }\n );\n };\n}\nif (runningAsWebWorker) {\n monkeyPatchFetch();\n}\nexport {\n monkeyPatchFetch,\n sandboxFetch,\n setupMessageListener\n};\n',
1334
1334
  loader: "js"
1335
1335
  }));
1336
1336
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@silverbulletmd/silverbullet",
3
- "version": "2.11.0",
3
+ "version": "2.12.0",
4
4
  "type": "module",
5
5
  "description": "A self-hosted, web-based note taking app",
6
6
  "publishConfig": {
@@ -80,63 +80,64 @@
80
80
  },
81
81
  "dependencies": {
82
82
  "@cfworker/json-schema": "^4.1.1",
83
- "@codemirror/autocomplete": "6.20.1",
84
- "@codemirror/commands": "6.10.3",
83
+ "@codemirror/autocomplete": "6.20.3",
84
+ "@codemirror/commands": "6.11.1",
85
85
  "@codemirror/lang-css": "6.3.1",
86
- "@codemirror/lang-html": "6.4.11",
86
+ "@codemirror/lang-html": "6.4.12",
87
87
  "@codemirror/lang-javascript": "6.2.5",
88
- "@codemirror/lang-markdown": "6.5.0",
89
- "@codemirror/language": "6.12.3",
90
- "@codemirror/legacy-modes": "6.5.2",
91
- "@codemirror/lint": "6.9.5",
92
- "@codemirror/search": "6.6.0",
93
- "@codemirror/state": "6.6.0",
94
- "@codemirror/view": "6.41.0",
95
- "@joplin/turndown-plugin-gfm": "1.0.64",
96
- "@lezer/common": "1.5.1",
97
- "@lezer/css": "1.3.3",
98
- "@lezer/highlight": "1.2.3",
88
+ "@codemirror/lang-markdown": "6.5.2",
89
+ "@codemirror/language": "6.12.4",
90
+ "@codemirror/legacy-modes": "6.5.4",
91
+ "@codemirror/lint": "6.9.7",
92
+ "@codemirror/search": "6.7.2",
93
+ "@codemirror/state": "6.7.6",
94
+ "@codemirror/view": "6.43.13",
95
+ "@joplin/turndown-plugin-gfm": "1.0.68",
96
+ "@lezer/common": "1.5.2",
97
+ "@lezer/css": "1.3.8",
98
+ "@lezer/highlight": "1.2.4",
99
99
  "@lezer/html": "1.3.13",
100
- "@lezer/javascript": "1.5.4",
101
- "@lezer/lr": "1.4.8",
102
- "@lezer/markdown": "1.6.3",
100
+ "@lezer/javascript": "1.5.5",
101
+ "@lezer/lr": "1.4.10",
102
+ "@lezer/markdown": "1.7.2",
103
103
  "@msgpack/msgpack": "3.1.3",
104
- "@noble/hashes": "2.0.1",
104
+ "@noble/hashes": "2.4.0",
105
105
  "@replit/codemirror-lang-nix": "6.0.1",
106
- "@replit/codemirror-vim": "6.3.0",
106
+ "@replit/codemirror-vim": "6.4.0",
107
107
  "@types/js-yaml": "^4.0.9",
108
- "commander": "^14.0.3",
109
- "crelt": "1.0.6",
108
+ "commander": "^15.0.0",
109
+ "crelt": "1.0.7",
110
110
  "d3-force": "^3.0.0",
111
- "esbuild": "^0.27.3",
111
+ "esbuild": "^0.28.2",
112
112
  "fast-diff": "1.3.0",
113
- "force-graph": "^1.49.0",
114
113
  "fast-glob": "^3.3.3",
114
+ "force-graph": "^1.51.4",
115
115
  "gitignore-parser": "0.0.2",
116
116
  "idb": "8.0.3",
117
- "js-yaml": "^4.1.1",
117
+ "js-yaml": "^4.3.2",
118
118
  "mime": "4.1.0",
119
- "preact": "10.28.2",
120
- "preact-feather": "4.2.1",
121
- "react-icons": "5.5.0",
122
- "sass": "^1.97.3",
123
- "style-mod": "4.1.3",
124
- "turndown": "7.2.2"
119
+ "preact": "10.29.8",
120
+ "preact-feather": "4.3.0",
121
+ "react-icons": "5.7.0",
122
+ "sass": "^1.105.0",
123
+ "style-mod": "4.1.4",
124
+ "turndown": "7.2.4"
125
125
  },
126
126
  "devDependencies": {
127
- "@biomejs/biome": "2.4.6",
128
- "@playwright/test": "^1.58.2",
129
- "@preact/preset-vite": "^2.10.3",
127
+ "@biomejs/biome": "2.5.14",
128
+ "@playwright/test": "1.58.2",
129
+ "@preact/preset-vite": "^2.10.6",
130
130
  "@types/d3-force": "^3.0.10",
131
131
  "@types/gitignore-parser": "^0.0.3",
132
- "@types/node": "^22.0.0",
132
+ "@types/node": "^26.6.2",
133
133
  "@types/turndown": "^5.0.6",
134
- "fake-indexeddb": "6.0.1",
134
+ "@typescript/native": "npm:typescript@^7.0.2",
135
+ "fake-indexeddb": "6.2.5",
135
136
  "preact-render-to-string": "^6.7.0",
136
- "tsx": "^4.19.0",
137
- "typescript": "^5.9.3",
138
- "vite": "^7.3.1",
139
- "vitest": "^4.0.18"
137
+ "tsx": "^4.23.15",
138
+ "typescript": "npm:@typescript/typescript6@^6.0.2",
139
+ "vite": "^8.3.1",
140
+ "vitest": "^4.1.11"
140
141
  },
141
142
  "engines": {
142
143
  "node": ">=24.13.0",
@@ -1,14 +1,7 @@
1
1
  import type { Path } from "@silverbulletmd/silverbullet/lib/ref";
2
2
 
3
- /**
4
- * Determines wether a url points into the world wide web or to the local SB instance
5
- */
6
3
  export function isLocalURL(url: string): boolean {
7
- return (
8
- !url.includes("://") &&
9
- !url.startsWith("mailto:") &&
10
- !url.startsWith("tel:")
11
- );
4
+ return !/^[a-z][a-z0-9+.-]*:/i.test(url);
12
5
  }
13
6
 
14
7
  /**
@@ -1,8 +1,8 @@
1
- export interface YamlPatch {
1
+ export type YamlPatch = {
2
2
  op: "set-key" | "delete-key";
3
3
  path: string; // Top-level key names only
4
4
  value?: any; // Required for set-key, not used for delete-key
5
- }
5
+ };
6
6
 
7
7
  function serializeToYamlScalar(
8
8
  value: string | number | boolean | null,
@@ -1,4 +1,6 @@
1
1
  import type {
2
+ DocumentCapability,
3
+ DocumentCapabilityDescriptor,
2
4
  FilterOption,
3
5
  NotificationType,
4
6
  UploadFile,
@@ -38,13 +40,23 @@ export function getLastOpenedMap(): Promise<Record<string, number>> {
38
40
  }
39
41
 
40
42
  /**
41
- * Returns the file extensions (without a leading dot) that have a document
42
- * editor registered, i.e. the documents this client can open.
43
+ * Returns the file extensions (without a leading dot) claimed by explicitly
44
+ * registered document editors.
43
45
  */
44
46
  export function getViewableExtensions(): Promise<string[]> {
45
47
  return syscall("editor.getViewableExtensions");
46
48
  }
47
49
 
50
+ /**
51
+ * Resolves the editor capability for a batch of documents without reading
52
+ * their bodies.
53
+ */
54
+ export function getDocumentCapabilities(
55
+ documents: DocumentCapabilityDescriptor[],
56
+ ): Promise<DocumentCapability[]> {
57
+ return syscall("editor.getDocumentCapabilities", documents);
58
+ }
59
+
48
60
  export function getCurrentEditor(): Promise<string> {
49
61
  return syscall("editor.getCurrentEditor");
50
62
  }
@@ -187,6 +199,10 @@ export function downloadFile(filename: string, dataUrl: string): Promise<void> {
187
199
  return syscall("editor.downloadFile", filename, dataUrl);
188
200
  }
189
201
 
202
+ export function downloadSpaceFile(name: string): Promise<void> {
203
+ return syscall("editor.downloadSpaceFile", name);
204
+ }
205
+
190
206
  export function uploadFile(
191
207
  accept?: string,
192
208
  capture?: string,
@@ -5,6 +5,14 @@ import { syscall } from "../syscall.ts";
5
5
  * @module
6
6
  */
7
7
 
8
+ /**
9
+ * Whether client Sync is enabled. False when the service worker is off
10
+ * (`SB_DISABLE_SERVICE_WORKER` / `bootConfig.disableServiceWorker`).
11
+ */
12
+ export function isEnabled(): Promise<boolean> {
13
+ return syscall("sync.isEnabled");
14
+ }
15
+
8
16
  /**
9
17
  * Checks if an initial sync has completed
10
18
  */
@@ -1,5 +1,8 @@
1
1
  import type { ParseTree } from "@silverbulletmd/silverbullet/lib/tree";
2
- import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";
2
+ import type {
3
+ DocumentMeta,
4
+ PageMeta,
5
+ } from "@silverbulletmd/silverbullet/type/index";
3
6
 
4
7
  export type FilterOption = {
5
8
  name: string;
@@ -51,6 +54,17 @@ export type DocumentEditorContent = {
51
54
  html: string;
52
55
  };
53
56
 
57
+ export type DocumentCapabilityDescriptor = Pick<
58
+ DocumentMeta,
59
+ "name" | "extension" | "contentType" | "size"
60
+ >;
61
+
62
+ export type DocumentCapability = {
63
+ kind: "plug" | "text" | "media" | "external";
64
+ editor?: string;
65
+ reason?: string;
66
+ };
67
+
54
68
  export type LintDiagnostic = {
55
69
  from: number;
56
70
  to: number;
@@ -95,6 +109,7 @@ export type AppEvent =
95
109
  | "editor:unfold"
96
110
  | "plugs:loaded"
97
111
  | "cron:secondPassed"
112
+ // Deprecated: dock a view at "page-top"/"page-bottom" with view.define instead.
98
113
  | "hooks:renderTopWidgets"
99
114
  | "hooks:renderBottomWidgets";
100
115
 
@@ -77,6 +77,13 @@ export type CommandDef = {
77
77
  hide?: boolean;
78
78
  requireMode?: "rw" | "ro";
79
79
  requireEditor?: "any" | "page" | "notpage" | string;
80
+ /**
81
+ * When true, the command is omitted from the palette, menus, and
82
+ * `buildAllCommands()` while `bootConfig.disableServiceWorker` is set
83
+ * (`SB_DISABLE_SERVICE_WORKER`). Same space-wide drop as `requireMode: rw`
84
+ * in a read-only space.
85
+ */
86
+ requireServiceWorker?: boolean;
80
87
 
81
88
  // When true, this binding is NOT registered while vim mode is active,
82
89
  // so the key falls through to vim.
@@ -245,12 +245,34 @@ export function computeTreeDisplay(
245
245
  state: { expanded: Set<string>; expandAll: boolean },
246
246
  scores?: Map<string, number>,
247
247
  ): TreeDisplay {
248
+ return expandTreeDisplay(
249
+ buildDisplayTree(rows, separator, foldersFirst, scores),
250
+ state,
251
+ scores !== undefined,
252
+ );
253
+ }
254
+
255
+ /** The tree half of `computeTreeDisplay`: kept apart so expanding a folder
256
+ * reuses the same nodes instead of rebuilding them all. */
257
+ export function buildDisplayTree(
258
+ rows: Row[],
259
+ separator: string,
260
+ foldersFirst: boolean,
261
+ scores?: Map<string, number>,
262
+ ): TreeNode {
248
263
  const built = buildTree(rows, separator, foldersFirst);
249
- const tree = scores ? pruneTree(built, scores) : built;
264
+ return scores ? pruneTree(built, scores) : built;
265
+ }
266
+
267
+ export function expandTreeDisplay(
268
+ tree: TreeNode,
269
+ state: { expanded: Set<string>; expandAll: boolean },
270
+ filtering: boolean,
271
+ ): TreeDisplay {
250
272
  // Filtering force-expands the pruned tree either way, so the two readings
251
273
  // converge there and neither set is consulted -- no double bookkeeping.
252
274
  let effectiveExpanded: Set<string>;
253
- if (scores) {
275
+ if (filtering) {
254
276
  effectiveExpanded = allFolderPaths(tree);
255
277
  } else if (state.expandAll) {
256
278
  effectiveExpanded = allFolderPaths(tree);
@@ -12,6 +12,7 @@ export type Decoration = {
12
12
  export type Row = {
13
13
  obj: Record<string, any>;
14
14
  primary: string;
15
+ cells?: unknown[];
15
16
  /**
16
17
  * What a *tree* row shows in place of its last path segment. For a view
17
18
  * whose hierarchy is synthesized rather than read off a name, the path is