From 9a005f26b5b72c83a46954a83c13eac3e777b9aa Mon Sep 17 00:00:00 2001 From: Zef Hemel Date: Tue, 29 Aug 2023 21:17:29 +0200 Subject: [PATCH] Work on client modes --- cli/plug_run.ts | 2 +- cmd/server.ts | 36 +++--- common/async_util.ts | 32 ----- common/spaces/evented_space_primitives.ts | 6 +- plug-api/lib/async.test.ts | 26 ++++ plug-api/lib/async.ts | 69 +++++++++++ plug-api/lib/async_util.test.ts | 20 ++++ plugos/lib/mq.deno_kv.test.ts | 2 +- plugos/lib/mq.deno_kv.ts | 14 ++- plugos/lib/mq.dexie.test.ts | 2 +- plugs/directive/command.ts | 2 +- plugs/editor/broken_links.ts | 4 +- plugs/editor/client.ts | 7 -- plugs/editor/editor.plug.yaml | 24 +--- plugs/index/index.plug.yaml | 2 - plugs/index/page.ts | 6 +- plugs/search/search.plug.yaml | 2 - plugs/search/search.ts | 20 +++- server/http_server.ts | 6 +- server/server_system.ts | 47 ++++---- silverbullet.ts | 8 +- web/boot.ts | 7 +- web/client.ts | 55 +++++---- web/client_system.ts | 52 ++++---- web/components/top_bar.tsx | 2 + web/deps.ts | 1 + web/editor_ui.tsx | 36 ++++++ web/index.html | 4 +- web/service_worker.ts | 12 +- web/space.ts | 3 +- web/styles/colors.scss | 4 + web/styles/theme.scss | 2 + web/sync_service.ts | 112 ++++++++++-------- web/syscalls/editor.ts | 11 -- web/syscalls/system.ts | 24 +++- website/CHANGELOG.md | 9 +- website/Client Modes.md | 30 +++++ website/Metadata.md | 2 +- website/PlugOS.md | 35 ++++++ website/SilverBullet.md | 4 +- website/{πŸ”Œ Core => }/Tags.md | 10 +- website/πŸ”Œ Core.md | 16 --- website/πŸ”Œ Core/Edit Commands.md | 2 +- website/πŸ”Œ Core/Indexing.md | 7 -- website/πŸ”Œ Core/Plug Management.md | 4 +- website/πŸ”Œ Core/Slash Commands.md | 4 +- website/πŸ”Œ Directive.md | 2 +- website/πŸ”Œ Directive/Query.md | 2 +- website/πŸ”Œ Editor.md | 50 ++++++++ website/{πŸ”Œ Core => πŸ”Œ Editor}/Link Unfurl.md | 2 +- website/πŸ”Œ Index.md | 22 ++++ website/πŸ”Œ Plugs.md | 9 +- website/πŸ”Œ Tasks.md | 6 +- .../{πŸ”Œ Core/Templates.md => πŸ”Œ Template.md} | 18 ++- 54 files changed, 594 insertions(+), 302 deletions(-) delete mode 100644 common/async_util.ts create mode 100644 plug-api/lib/async.test.ts create mode 100644 plug-api/lib/async.ts create mode 100644 plug-api/lib/async_util.test.ts delete mode 100644 plugs/editor/client.ts create mode 100644 website/Client Modes.md create mode 100644 website/PlugOS.md rename website/{πŸ”Œ Core => }/Tags.md (74%) delete mode 100644 website/πŸ”Œ Core.md delete mode 100644 website/πŸ”Œ Core/Indexing.md create mode 100644 website/πŸ”Œ Editor.md rename website/{πŸ”Œ Core => πŸ”Œ Editor}/Link Unfurl.md (84%) create mode 100644 website/πŸ”Œ Index.md rename website/{πŸ”Œ Core/Templates.md => πŸ”Œ Template.md} (90%) diff --git a/cli/plug_run.ts b/cli/plug_run.ts index 83b2d321..cd81bb42 100644 --- a/cli/plug_run.ts +++ b/cli/plug_run.ts @@ -3,7 +3,7 @@ import { DiskSpacePrimitives } from "../common/spaces/disk_space_primitives.ts"; import { AssetBundle } from "../plugos/asset_bundle/bundle.ts"; import { Application } from "../server/deps.ts"; -import { sleep } from "../common/async_util.ts"; +import { sleep } from "$sb/lib/async.ts"; import { ServerSystem } from "../server/server_system.ts"; import { AssetBundlePlugSpacePrimitives } from "../common/spaces/asset_bundle_space_primitives.ts"; diff --git a/cmd/server.ts b/cmd/server.ts index bbdef952..c5c57f70 100644 --- a/cmd/server.ts +++ b/cmd/server.ts @@ -13,8 +13,8 @@ import { SpacePrimitives } from "../common/spaces/space_primitives.ts"; import { S3SpacePrimitives } from "../server/spaces/s3_space_primitives.ts"; import { Authenticator } from "../server/auth.ts"; import { JSONKVStore } from "../plugos/lib/kv_store.json_file.ts"; -import { sleep } from "../common/async_util.ts"; import { ServerSystem } from "../server/server_system.ts"; +import { sleep } from "$sb/lib/async.ts"; import { SilverBulletHooks } from "../common/manifest.ts"; import { System } from "../plugos/system.ts"; @@ -26,10 +26,9 @@ export async function serveCommand( auth?: string; cert?: string; key?: string; - // Thin client mode - thinClient?: boolean; reindex?: boolean; db?: string; + serverProcessing?: boolean; }, folder?: string, ) { @@ -38,7 +37,6 @@ export async function serveCommand( const port = options.port || (Deno.env.get("SB_PORT") && +Deno.env.get("SB_PORT")!) || 3000; - const thinClientMode = options.thinClient || Deno.env.has("SB_THIN_CLIENT"); let dbFile = options.db || Deno.env.get("SB_DB_FILE") || ".silverbullet.db"; const app = new Application(); @@ -86,9 +84,12 @@ To allow outside connections, pass -L 0.0.0.0 as a flag, and put a TLS terminato ); let system: System | undefined; - if (thinClientMode) { + if (options.serverProcessing) { + // Enable server-side processing dbFile = path.resolve(folder, dbFile); - console.log(`Running in thin client mode, keeping state in ${dbFile}`); + console.log( + `Running in server-processing mode, keeping state in ${dbFile}`, + ); const serverSystem = new ServerSystem(spacePrimitives, dbFile, app); await serverSystem.init(); spacePrimitives = serverSystem.spacePrimitives; @@ -132,15 +133,20 @@ To allow outside connections, pass -L 0.0.0.0 as a flag, and put a TLS terminato authStore.loadString(envAuth); } - const httpServer = new HttpServer(spacePrimitives!, app, system, { - hostname, - port: port, - pagesPath: folder!, - clientAssetBundle: new AssetBundle(clientAssetBundle as AssetJson), - authenticator, - keyFile: options.key, - certFile: options.cert, - }); + const httpServer = new HttpServer( + spacePrimitives!, + app, + system, + { + hostname, + port: port, + pagesPath: folder!, + clientAssetBundle: new AssetBundle(clientAssetBundle as AssetJson), + authenticator, + keyFile: options.key, + certFile: options.cert, + }, + ); await httpServer.start(); // Wait in an infinite loop (to keep the HTTP server running, only cancelable via Ctrl+C or other signal) diff --git a/common/async_util.ts b/common/async_util.ts deleted file mode 100644 index c76a8010..00000000 --- a/common/async_util.ts +++ /dev/null @@ -1,32 +0,0 @@ -export function throttle(func: () => void, limit: number) { - let timer: any = null; - return function () { - if (!timer) { - timer = setTimeout(() => { - func(); - timer = null; - }, limit); - } - }; -} - -// race for promises returns first promise that resolves -export function race(promises: Promise[]): Promise { - return new Promise((resolve, reject) => { - for (const p of promises) { - p.then(resolve, reject); - } - }); -} - -export function timeout(ms: number): Promise { - return new Promise((_resolve, reject) => - setTimeout(() => { - reject(new Error("timeout")); - }, ms) - ); -} - -export function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} diff --git a/common/spaces/evented_space_primitives.ts b/common/spaces/evented_space_primitives.ts index 4b734338..feff1654 100644 --- a/common/spaces/evented_space_primitives.ts +++ b/common/spaces/evented_space_primitives.ts @@ -115,9 +115,9 @@ export class EventedSpacePrimitives implements SpacePrimitives { console.error("Error dispatching page:saved event", e); }); } - if (name.startsWith("_plug/") && name.endsWith(".plug.js")) { - await this.dispatchEvent("plug:changed", name); - } + // if (name.startsWith("_plug/") && name.endsWith(".plug.js")) { + // await this.dispatchEvent("plug:changed", name); + // } return newMeta; } diff --git a/plug-api/lib/async.test.ts b/plug-api/lib/async.test.ts new file mode 100644 index 00000000..5ccdf5aa --- /dev/null +++ b/plug-api/lib/async.test.ts @@ -0,0 +1,26 @@ +import { assertEquals } from "../../test_deps.ts"; +import { PromiseQueue, sleep } from "./async.ts"; + +Deno.test("PromiseQueue test", async () => { + const q = new PromiseQueue(); + let r1RanFirst = false; + const r1 = q.runInQueue(async () => { + await sleep(10); + r1RanFirst = true; + // console.log("1"); + return 1; + }); + const r2 = q.runInQueue(async () => { + // console.log("2"); + await sleep(4); + return 2; + }); + assertEquals(await Promise.all([r1, r2]), [1, 2]); + assertEquals(r1RanFirst, true); + let wasRun = false; + await q.runInQueue(async () => { + await sleep(4); + wasRun = true; + }); + assertEquals(wasRun, true); +}); diff --git a/plug-api/lib/async.ts b/plug-api/lib/async.ts new file mode 100644 index 00000000..f0939b5c --- /dev/null +++ b/plug-api/lib/async.ts @@ -0,0 +1,69 @@ +export function throttle(func: () => void, limit: number) { + let timer: any = null; + return function () { + if (!timer) { + timer = setTimeout(() => { + func(); + timer = null; + }, limit); + } + }; +} + +// race for promises returns first promise that resolves +export function race(promises: Promise[]): Promise { + return new Promise((resolve, reject) => { + for (const p of promises) { + p.then(resolve, reject); + } + }); +} + +export function timeout(ms: number): Promise { + return new Promise((_resolve, reject) => + setTimeout(() => { + reject(new Error("timeout")); + }, ms) + ); +} + +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export class PromiseQueue { + private queue: { + fn: () => Promise; + resolve: (value: any) => void; + reject: (error: any) => void; + }[] = []; + private running = false; + + runInQueue(fn: () => Promise): Promise { + return new Promise((resolve, reject) => { + this.queue.push({ fn, resolve, reject }); + if (!this.running) { + this.run(); + } + }); + } + + private async run(): Promise { + if (this.queue.length === 0) { + this.running = false; + return; + } + + this.running = true; + const { fn, resolve, reject } = this.queue.shift()!; + + try { + const result = await fn(); + resolve(result); + } catch (error) { + reject(error); + } + + this.run(); // Continue processing the next promise in the queue + } +} diff --git a/plug-api/lib/async_util.test.ts b/plug-api/lib/async_util.test.ts new file mode 100644 index 00000000..ca01f62f --- /dev/null +++ b/plug-api/lib/async_util.test.ts @@ -0,0 +1,20 @@ +import { assertEquals } from "../../test_deps.ts"; +import { PromiseQueue, sleep } from "./async.ts"; + +Deno.test("PromiseQueue test", async () => { + const q = new PromiseQueue(); + let r1RanFirst = false; + const r1 = q.runInQueue(async () => { + await sleep(10); + r1RanFirst = true; + console.log("1"); + return 1; + }); + const r2 = q.runInQueue(async () => { + console.log("2"); + await sleep(4); + return 2; + }); + console.log(await Promise.all([r1, r2])); + assertEquals(r1RanFirst, true); +}); diff --git a/plugos/lib/mq.deno_kv.test.ts b/plugos/lib/mq.deno_kv.test.ts index 02e1bcb0..61d2af53 100644 --- a/plugos/lib/mq.deno_kv.test.ts +++ b/plugos/lib/mq.deno_kv.test.ts @@ -1,4 +1,4 @@ -import { sleep } from "../../common/async_util.ts"; +import { sleep } from "$sb/lib/async.ts"; import { DenoKvMQ } from "./mq.deno_kv.ts"; Deno.test("Deno MQ", async () => { diff --git a/plugos/lib/mq.deno_kv.ts b/plugos/lib/mq.deno_kv.ts index c72f97af..4501370e 100644 --- a/plugos/lib/mq.deno_kv.ts +++ b/plugos/lib/mq.deno_kv.ts @@ -41,14 +41,20 @@ export class DenoKvMQ implements MessageQueue { } async batchSend(queue: string, bodies: any[]): Promise { - const results = await Promise.all( - bodies.map((body) => this.kv.enqueue([queue, body])), - ); - for (const result of results) { + for (const body of bodies) { + const result = await this.kv.enqueue([queue, body]); if (!result.ok) { throw result; } } + // const results = await Promise.all( + // bodies.map((body) => this.kv.enqueue([queue, body])), + // ); + // for (const result of results) { + // if (!result.ok) { + // throw result; + // } + // } } async send(queue: string, body: any): Promise { const result = await this.kv.enqueue([queue, body]); diff --git a/plugos/lib/mq.dexie.test.ts b/plugos/lib/mq.dexie.test.ts index fd852a20..03900e4a 100644 --- a/plugos/lib/mq.dexie.test.ts +++ b/plugos/lib/mq.dexie.test.ts @@ -1,7 +1,7 @@ import { IDBKeyRange, indexedDB } from "https://esm.sh/fake-indexeddb@4.0.2"; import { DexieMQ } from "./mq.dexie.ts"; import { assertEquals } from "../../test_deps.ts"; -import { sleep } from "../../common/async_util.ts"; +import { sleep } from "$sb/lib/async.ts"; Deno.test("Dexie MQ", async () => { const mq = new DexieMQ("test", indexedDB, IDBKeyRange); diff --git a/plugs/directive/command.ts b/plugs/directive/command.ts index f2c1f376..17982eb4 100644 --- a/plugs/directive/command.ts +++ b/plugs/directive/command.ts @@ -10,7 +10,7 @@ import { extractFrontmatter } from "$sb/lib/frontmatter.ts"; import type { PageMeta } from "../../web/types.ts"; import { isFederationPath } from "$sb/lib/resolve.ts"; import { MQMessage } from "$sb/types.ts"; -import { sleep } from "../../common/async_util.ts"; +import { sleep } from "$sb/lib/async.ts"; const directiveUpdateQueueName = "directiveUpdateQueue"; diff --git a/plugs/editor/broken_links.ts b/plugs/editor/broken_links.ts index 0102fb97..f5ce7fcc 100644 --- a/plugs/editor/broken_links.ts +++ b/plugs/editor/broken_links.ts @@ -14,7 +14,7 @@ export async function brokenLinksCommand() { if (tree.type === "WikiLinkPage") { // Add the prefix in the link text const [pageName] = tree.children![0].text!.split("@"); - if (pageName.startsWith("πŸ’­ ")) { + if (pageName.startsWith("!")) { return true; } if ( @@ -31,7 +31,7 @@ export async function brokenLinksCommand() { } if (tree.type === "PageRef") { const pageName = tree.children![0].text!.slice(2, -2); - if (pageName.startsWith("πŸ’­ ")) { + if (pageName.startsWith("!")) { return true; } if (!allPagesMap.has(pageName)) { diff --git a/plugs/editor/client.ts b/plugs/editor/client.ts deleted file mode 100644 index bda6e4af..00000000 --- a/plugs/editor/client.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { editor } from "$sb/syscalls.ts"; - -export async function setThinClient(def: any) { - console.log("Setting thin client to", def.value); - await editor.setUiOption("thinClientMode", def.value); - await editor.reloadUI(); -} diff --git a/plugs/editor/editor.plug.yaml b/plugs/editor/editor.plug.yaml index 9f4cd9a3..c76699f5 100644 --- a/plugs/editor/editor.plug.yaml +++ b/plugs/editor/editor.plug.yaml @@ -1,6 +1,4 @@ name: editor -requiredPermissions: - - fetch syntax: NakedURL: firstCharacters: @@ -59,6 +57,10 @@ functions: name: "Navigate: Home" key: "Alt-h" page: "" + moveToPos: + path: "./editor.ts:moveToPosCommand" + command: + name: "Navigate: Move Cursor to Position" # Text editing commands quoteSelectionCommand: @@ -113,12 +115,8 @@ functions: centerCursor: path: "./editor.ts:centerCursorCommand" command: - name: "Editor: Center Cursor" + name: "Navigate: Center Cursor" key: "Ctrl-Alt-l" - moveToPos: - path: "./editor.ts:moveToPosCommand" - command: - name: "Editor: Move Cursor to Position" # Debug commands parseCommand: @@ -197,18 +195,6 @@ functions: command: name: "Broken Links: Show" - # Client mode - enableThinClient: - path: ./client.ts:setThinClient - command: - name: "Client: Enable Thin Client" - value: true - disableThinClient: - path: ./client.ts:setThinClient - command: - name: "Client: Disable Thin Client" - value: false - # Random stuff statsCommand: path: ./stats.ts:statsCommand diff --git a/plugs/index/index.plug.yaml b/plugs/index/index.plug.yaml index a8c8e74f..ad8d7735 100644 --- a/plugs/index/index.plug.yaml +++ b/plugs/index/index.plug.yaml @@ -96,8 +96,6 @@ functions: events: - editor:complete - - # Hashtags indexTags: path: "./tags.ts:indexTags" diff --git a/plugs/index/page.ts b/plugs/index/page.ts index 4296e435..9f2f3c1b 100644 --- a/plugs/index/page.ts +++ b/plugs/index/page.ts @@ -11,7 +11,7 @@ import { import { applyQuery } from "$sb/lib/query.ts"; import type { MQMessage } from "$sb/types.ts"; -import { sleep } from "../../common/async_util.ts"; +import { sleep } from "$sb/lib/async.ts"; // Key space: // meta: => metaJson @@ -53,9 +53,7 @@ export async function processIndexQueue(messages: MQMessage[]) { const name: string = message.body; console.log(`Indexing page ${name}`); const text = await space.readPage(name); - // console.log("Going to parse markdown"); const parsed = await markdown.parseMarkdown(text); - // console.log("Dispatching ;age:index"); await events.dispatchEvent("page:index", { name, tree: parsed, @@ -69,7 +67,7 @@ export async function clearPageIndex(page: string) { } export async function parseIndexTextRepublish({ name, text }: IndexEvent) { - console.log("Reindexing", name); + // console.log("Reindexing", name); await events.dispatchEvent("page:index", { name, tree: await markdown.parseMarkdown(text), diff --git a/plugs/search/search.plug.yaml b/plugs/search/search.plug.yaml index bbaa25b7..50fd59dd 100644 --- a/plugs/search/search.plug.yaml +++ b/plugs/search/search.plug.yaml @@ -2,8 +2,6 @@ name: search functions: indexPage: path: search.ts:indexPage - # Only enable in client for now - # env: client events: - page:index diff --git a/plugs/search/search.ts b/plugs/search/search.ts index 7074bed9..7627cc64 100644 --- a/plugs/search/search.ts +++ b/plugs/search/search.ts @@ -4,6 +4,7 @@ import { applyQuery } from "$sb/lib/query.ts"; import { editor, index, store } from "$sb/syscalls.ts"; import { BatchKVStore, SimpleSearchEngine } from "./engine.ts"; import { FileMeta } from "$sb/types.ts"; +import { PromiseQueue } from "$sb/lib/async.ts"; const searchPrefix = "πŸ” "; @@ -30,11 +31,16 @@ const ftsRevKvStore = new StoreKVStore("fts_rev:"); const engine = new SimpleSearchEngine(ftsKvStore, ftsRevKvStore); -export async function indexPage({ name, tree }: IndexTreeEvent) { +// Search indexing is prone to concurrency issues, so we queue all write operations +const promiseQueue = new PromiseQueue(); + +export function indexPage({ name, tree }: IndexTreeEvent) { const text = renderToText(tree); - // console.log("Now FTS indexing", name); - await engine.deleteDocument(name); - await engine.indexDocument({ id: name, text }); + return promiseQueue.runInQueue(async () => { + // console.log("Now FTS indexing", name); + await engine.deleteDocument(name); + await engine.indexDocument({ id: name, text }); + }); } export async function clearIndex() { @@ -42,8 +48,10 @@ export async function clearIndex() { await store.deletePrefix("fts_rev:"); } -export async function pageUnindex(pageName: string) { - await engine.deleteDocument(pageName); +export function pageUnindex(pageName: string) { + return promiseQueue.runInQueue(() => { + return engine.deleteDocument(pageName); + }); } export async function queryProvider({ diff --git a/server/http_server.ts b/server/http_server.ts index b5677841..9ec3588c 100644 --- a/server/http_server.ts +++ b/server/http_server.ts @@ -68,7 +68,11 @@ export class HttpServer { .replaceAll( "{{SPACE_PATH}}", this.options.pagesPath.replaceAll("\\", "\\\\"), - ).replaceAll("{{THIN_CLIENT_MODE}}", this.system ? "on" : "off"); + // ); + ).replaceAll( + "{{SUPPORT_ONLINE_MODE}}", + this.system ? "true" : "false", + ); } async start() { diff --git a/server/server_system.ts b/server/server_system.ts index edf24664..64fe31a8 100644 --- a/server/server_system.ts +++ b/server/server_system.ts @@ -23,21 +23,22 @@ import { markdownSyscalls } from "../web/syscalls/markdown.ts"; import { spaceSyscalls } from "./syscalls/space.ts"; import { systemSyscalls } from "../web/syscalls/system.ts"; import { yamlSyscalls } from "../web/syscalls/yaml.ts"; -import { Application, path } from "./deps.ts"; +import { Application } from "./deps.ts"; import { sandboxFetchSyscalls } from "../plugos/syscalls/fetch.ts"; import { shellSyscalls } from "../plugos/syscalls/shell.deno.ts"; import { SpacePrimitives } from "../common/spaces/space_primitives.ts"; import { DenoKvMQ } from "../plugos/lib/mq.deno_kv.ts"; +import { base64EncodedDataUrl } from "../plugos/asset_bundle/base64.ts"; +import { Plug } from "../plugos/plug.ts"; const fileListInterval = 30 * 1000; // 30s export class ServerSystem { system: System = new System("server"); spacePrimitives!: SpacePrimitives; - private requeueInterval?: number; - kvStore?: DenoKVStore; - listInterval?: number; denoKv!: Deno.Kv; + kvStore!: DenoKVStore; + listInterval?: number; constructor( private baseSpacePrimitives: SpacePrimitives, @@ -93,7 +94,7 @@ export class ServerSystem { assetSyscalls(this.system), yamlSyscalls(), storeSyscalls(this.kvStore), - systemSyscalls(undefined as any, this.system), + systemSyscalls(this.system), mqSyscalls(mq), pageIndexCalls, debugSyscalls(), @@ -136,34 +137,34 @@ export class ServerSystem { text: new TextDecoder().decode(data.data), }); } + + if (path.startsWith("_plug/") && path.endsWith(".plug.js")) { + console.log("Plug updated, reloading:", path); + this.system.unload(path); + await this.loadPlugFromSpace(path); + } })().catch(console.error); }); } async loadPlugs() { - const tempDir = await Deno.makeTempDir(); - try { - for (const { name } of await this.spacePrimitives.fetchFileList()) { - if (name.endsWith(".plug.js")) { - const plugPath = path.join(tempDir, name); - await Deno.mkdir(path.dirname(plugPath), { recursive: true }); - await Deno.writeFile( - plugPath, - (await this.spacePrimitives.readFile(name)).data, - ); - await this.system.load( - new URL(`file://${plugPath}`), - createSandbox, - ); - } + for (const { name } of await this.spacePrimitives.fetchFileList()) { + if (name.endsWith(".plug.js")) { + await this.loadPlugFromSpace(name); } - } finally { - await Deno.remove(tempDir, { recursive: true }); } } + async loadPlugFromSpace(path: string): Promise> { + const plugJS = (await this.spacePrimitives.readFile(path)).data; + return this.system.load( + // Base64 encoding this to support `deno compile` mode + new URL(base64EncodedDataUrl("application/javascript", plugJS)), + createSandbox, + ); + } + async close() { - clearInterval(this.requeueInterval); clearInterval(this.listInterval); await this.system.unloadAll(); } diff --git a/silverbullet.ts b/silverbullet.ts index 1de185c3..4301e3a5 100755 --- a/silverbullet.ts +++ b/silverbullet.ts @@ -45,16 +45,16 @@ await new Command() "Path to TLS key", ) .option( - "-t [type:boolean], --thin-client [type:boolean]", - "Enable thin-client mode", + "--no-server-processing [type:boolean]", + "Disable online mode (no server-side processing)", ) .option( "--reindex [type:boolean]", - "Reindex space on startup (applies to thin-mode only)", + "Reindex space on startup", ) .option( "--db ", - "Path to database file (applies to thin-mode only)", + "Path to database file", ) .action(serveCommand) // plug:compile diff --git a/web/boot.ts b/web/boot.ts index 438adf34..b0d5dad6 100644 --- a/web/boot.ts +++ b/web/boot.ts @@ -1,14 +1,15 @@ import { safeRun } from "../common/util.ts"; import { Client } from "./client.ts"; -const thinClientMode = !!localStorage.getItem("thinClientMode"); +const syncMode = window.silverBulletConfig.supportOnlineMode !== "true" || + !!localStorage.getItem("syncMode"); safeRun(async () => { console.log("Booting SilverBullet..."); const client = new Client( document.getElementById("sb-root")!, - thinClientMode, + syncMode, ); await client.init(); window.client = client; @@ -22,7 +23,7 @@ if (navigator.serviceWorker) { .then(() => { console.log("Service worker registered..."); }); - if (!thinClientMode) { + if (syncMode) { navigator.serviceWorker.ready.then((registration) => { registration.active!.postMessage({ type: "config", diff --git a/web/client.ts b/web/client.ts index 40e9e797..fd00e4e4 100644 --- a/web/client.ts +++ b/web/client.ts @@ -16,12 +16,17 @@ import { PathPageNavigator } from "./navigator.ts"; import { AppViewState, BuiltinSettings } from "./types.ts"; import type { AppEvent, CompleteEvent } from "../plug-api/app_event.ts"; -import { throttle } from "../common/async_util.ts"; +import { throttle } from "$sb/lib/async.ts"; import { PlugSpacePrimitives } from "../common/spaces/plug_space_primitives.ts"; import { IndexedDBSpacePrimitives } from "../common/spaces/indexeddb_space_primitives.ts"; import { FileMetaSpacePrimitives } from "../common/spaces/file_meta_space_primitives.ts"; import { EventedSpacePrimitives } from "../common/spaces/evented_space_primitives.ts"; -import { pageSyncInterval, SyncService } from "./sync_service.ts"; +import { + ISyncService, + NoSyncSyncService, + pageSyncInterval, + SyncService, +} from "./sync_service.ts"; import { simpleHash } from "../common/crypto.ts"; import { DexieKVStore } from "../plugos/lib/kv_store.dexie.ts"; import { SyncStatus } from "../common/spaces/sync.ts"; @@ -47,6 +52,7 @@ declare global { // Injected via index.html silverBulletConfig: { spaceFolderPath: string; + supportOnlineMode: string; }; client: Client; } @@ -75,7 +81,7 @@ export class Client { // Track if plugs have been updated since sync cycle fullSyncCompleted = false; - syncService: SyncService; + syncService: ISyncService; settings!: BuiltinSettings; kvStore: DexieKVStore; mq: DexieMQ; @@ -88,7 +94,7 @@ export class Client { constructor( parent: Element, - private thinClientMode = false, + public syncMode = false, ) { // Generate a semi-unique prefix for the database so not to reuse databases for different space paths this.dbPrefix = "" + simpleHash(window.silverBulletConfig.spaceFolderPath); @@ -117,26 +123,26 @@ export class Client { this.mq, this.dbPrefix, this.eventHook, - this.thinClientMode, ); const localSpacePrimitives = this.initSpace(); - this.syncService = new SyncService( - localSpacePrimitives, - this.plugSpaceRemotePrimitives, - this.kvStore, - this.eventHook, - (path) => { - // TODO: At some point we should remove the data.db exception here - return path !== "data.db" && - // Exclude all plug space primitives paths - !this.plugSpaceRemotePrimitives.isLikelyHandled(path) || - // Except federated ones - path.startsWith("!"); - }, - !this.thinClientMode, - ); + this.syncService = this.syncMode + ? new SyncService( + localSpacePrimitives, + this.plugSpaceRemotePrimitives, + this.kvStore, + this.eventHook, + (path) => { + // TODO: At some point we should remove the data.db exception here + return path !== "data.db" && + // Exclude all plug space primitives paths + !this.plugSpaceRemotePrimitives.isLikelyHandled(path) || + // Except federated ones + path.startsWith("!"); + }, + ) + : new NoSyncSyncService(this.space); this.ui = new MainUI(this); this.ui.render(parent); @@ -243,14 +249,15 @@ export class Client { Math.round(status.filesProcessed / status.totalFiles * 100), ); }); - this.syncService.spaceSync.on({ - fileSynced: (meta, direction) => { + this.eventHook.addLocalListener( + "file:synced", + (meta: FileMeta, direction: string) => { if (meta.name.endsWith(".md") && direction === "secondary->primary") { // We likely polled the currently open page which trigggered a local update, let's update the editor accordingly this.space.getPageMeta(meta.name.slice(0, -3)); } }, - }); + ); } private initNavigator() { @@ -337,7 +344,7 @@ export class Client { let localSpacePrimitives: SpacePrimitives | undefined; - if (!this.thinClientMode) { + if (this.syncMode) { localSpacePrimitives = new FilteredSpacePrimitives( new FileMetaSpacePrimitives( new EventedSpacePrimitives( diff --git a/web/client_system.ts b/web/client_system.ts index 071bef74..fb58be24 100644 --- a/web/client_system.ts +++ b/web/client_system.ts @@ -53,10 +53,9 @@ export class ClientSystem { private mq: DexieMQ, dbPrefix: string, private eventHook: EventHook, - private thinClientMode: boolean, ) { // Only set environment to "client" when running in thin client mode, otherwise we run everything locally (hybrid) - this.system = new System(thinClientMode ? "client" : undefined); + this.system = new System(client.syncMode ? undefined : "client"); this.system.addHook(this.eventHook); @@ -68,9 +67,11 @@ export class ClientSystem { const cronHook = new CronHook(this.system); this.system.addHook(cronHook); - if (thinClientMode) { + if (!client.syncMode) { + // In non-sync mode, proxy these to the server this.indexSyscalls = indexProxySyscalls(client); } else { + // In sync mode, run them locally this.indexSyscalls = pageIndexSyscalls( `${dbPrefix}_page_index`, globalThis.indexedDB, @@ -83,7 +84,8 @@ export class ClientSystem { this.system.addHook(this.codeWidgetHook); // MQ hook - if (!this.thinClientMode) { + if (client.syncMode) { + // Process MQ messages locally this.system.addHook(new MQHook(this.system, this.mq)); } @@ -103,19 +105,21 @@ export class ClientSystem { this.slashCommandHook = new SlashCommandHook(this.client); this.system.addHook(this.slashCommandHook); - this.eventHook.addLocalListener("plug:changed", async (fileName) => { - console.log("Plug updated, reloading:", fileName); - this.system.unload(fileName); - const plug = await this.system.load( - new URL(`/${fileName}`, location.href), - createSandbox, - this.client.settings.plugOverrides, - ); - if ((plug.manifest! as Manifest).syntax) { - // If there are syntax extensions, rebuild the markdown parser immediately - this.updateMarkdownParser(); + this.eventHook.addLocalListener("file:changed", async (path: string) => { + if (path.startsWith("_plug/") && path.endsWith(".plug.js")) { + console.log("Plug updated, reloading:", path); + this.system.unload(path); + const plug = await this.system.load( + new URL(`/${path}`, location.href), + createSandbox, + this.client.settings.plugOverrides, + ); + if ((plug.manifest! as Manifest).syntax) { + // If there are syntax extensions, rebuild the markdown parser immediately + this.updateMarkdownParser(); + } + this.plugsUpdated = true; } - this.plugsUpdated = true; }); // Debugging @@ -139,9 +143,11 @@ export class ClientSystem { } registerSyscalls() { - const storeCalls = this.thinClientMode - ? storeProxySyscalls(this.client) - : storeSyscalls(this.kvStore); + const storeCalls = this.client.syncMode + // In sync mode handle locally + ? storeSyscalls(this.kvStore) + // In non-sync mode proxy to server + : storeProxySyscalls(this.client); // Slash command hook this.slashCommandHook = new SlashCommandHook(this.client); @@ -153,11 +159,15 @@ export class ClientSystem { eventSyscalls(this.eventHook), editorSyscalls(this.client), spaceSyscalls(this.client), - systemSyscalls(this.client, this.system), + systemSyscalls(this.system, this.client), markdownSyscalls(buildMarkdown(this.mdExtensions)), assetSyscalls(this.system), yamlSyscalls(), - this.thinClientMode ? mqProxySyscalls(this.client) : mqSyscalls(this.mq), + this.client.syncMode + // In sync mode handle locally + ? mqSyscalls(this.mq) + // In non-sync mode proxy to server + : mqProxySyscalls(this.client), storeCalls, this.indexSyscalls, debugSyscalls(), diff --git a/web/components/top_bar.tsx b/web/components/top_bar.tsx index 8a815cb2..635c2aae 100644 --- a/web/components/top_bar.tsx +++ b/web/components/top_bar.tsx @@ -12,6 +12,7 @@ import { MiniEditor } from "./mini_editor.tsx"; export type ActionButton = { icon: FunctionalComponent; description: string; + class?: string; callback: () => void; href?: string; }; @@ -141,6 +142,7 @@ export function TopBar({ e.stopPropagation(); }} title={actionButton.description} + className={actionButton.class} > diff --git a/web/deps.ts b/web/deps.ts index 179ff312..7090f5dd 100644 --- a/web/deps.ts +++ b/web/deps.ts @@ -12,6 +12,7 @@ export { export { Book as BookIcon, Home as HomeIcon, + RefreshCw as RefreshCwIcon, Terminal as TerminalIcon, } from "https://esm.sh/preact-feather@4.2.1?external=preact"; diff --git a/web/editor_ui.tsx b/web/editor_ui.tsx index 1eb19a46..f271c076 100644 --- a/web/editor_ui.tsx +++ b/web/editor_ui.tsx @@ -10,6 +10,7 @@ import { BookIcon, HomeIcon, preactRender, + RefreshCwIcon, runScopeHandlers, TerminalIcon, useEffect, @@ -18,6 +19,7 @@ import { import type { Client } from "./client.ts"; import { Panel } from "./components/panel.tsx"; import { h } from "./deps.ts"; +import { async } from "https://cdn.skypack.dev/-/regenerator-runtime@v0.13.9-4Dxus9nU31cBsHxnWq2H/dist=es2020,mode=imports/optimized/regenerator-runtime.js"; export class MainUI { viewState: AppViewState = initialViewState; @@ -202,6 +204,40 @@ export class MainUI { editor.focus(); }} actionButtons={[ + ...window.silverBulletConfig.supportOnlineMode === "true" + ? [{ + icon: RefreshCwIcon, + description: this.editor.syncMode + ? "Currently in sync mode: switch to online mode" + : "Currently in online mode: switch to sync mode", + class: this.editor.syncMode ? "sb-enabled" : undefined, + callback: () => { + (async () => { + const newValue = !this.editor.syncMode; + + if (newValue) { + if ( + await this.editor.confirm( + "This will enable local sync. Are you sure?", + ) + ) { + localStorage.setItem("syncMode", "true"); + location.reload(); + } + } else { + if ( + await this.editor.confirm( + "This will disable local sync. Are you sure?", + ) + ) { + localStorage.removeItem("syncMode"); + location.reload(); + } + } + })().catch(console.error); + }, + }] + : [], { icon: HomeIcon, description: `Go home (Alt-h)`, diff --git a/web/index.html b/web/index.html index b2857933..9591e439 100644 --- a/web/index.html +++ b/web/index.html @@ -34,12 +34,14 @@ }; window.silverBulletConfig = { // These {{VARIABLES}} are replaced by http_server.ts - spaceFolderPath: "{{SPACE_PATH}}" + spaceFolderPath: "{{SPACE_PATH}}", + supportOnlineMode: "{{SUPPORT_ONLINE_MODE}}", }; // But in case these variables aren't replaced by the server, fall back fully static mode (no sync) if (window.silverBulletConfig.spaceFolderPath.includes("{{")) { window.silverBulletConfig = { spaceFolderPath: "", + supportOnlineMode: false, }; } diff --git a/web/service_worker.ts b/web/service_worker.ts index fcd87327..c4942bd7 100644 --- a/web/service_worker.ts +++ b/web/service_worker.ts @@ -89,6 +89,11 @@ self.addEventListener("fetch", (event: any) => { return cachedResponse; } + if (!fileContentTable) { + // Not initialzed yet, or in thin client mode, let's just proxy + return fetch(request); + } + const requestUrl = new URL(request.url); const pathname = requestUrl.pathname; @@ -119,17 +124,12 @@ async function handleLocalFileRequest( request: Request, pathname: string, ): Promise { - if (!fileContentTable) { - // Not initialzed yet, or explicitly in sync mode (so direct server communication requested) - return fetch(request); - } - if (!db?.isOpen()) { console.log("Detected that the DB was closed, reopening"); await db!.open(); } const path = decodeURIComponent(pathname.slice(1)); - const data = await fileContentTable.get(path); + const data = await fileContentTable!.get(path); if (data) { // console.log("Serving from space", path); if (!data.meta) { diff --git a/web/space.ts b/web/space.ts index 9261dbda..24312bbe 100644 --- a/web/space.ts +++ b/web/space.ts @@ -2,10 +2,11 @@ import { SpacePrimitives } from "../common/spaces/space_primitives.ts"; import { plugPrefix } from "../common/spaces/constants.ts"; import { safeRun } from "../common/util.ts"; import { AttachmentMeta, PageMeta } from "./types.ts"; -import { throttle } from "../common/async_util.ts"; + import { KVStore } from "../plugos/lib/kv_store.ts"; import { FileMeta } from "$sb/types.ts"; import { EventHook } from "../plugos/hooks/event.ts"; +import { throttle } from "$sb/lib/async.ts"; const pageWatchInterval = 5000; diff --git a/web/styles/colors.scss b/web/styles/colors.scss index 563aa040..9493da4e 100644 --- a/web/styles/colors.scss +++ b/web/styles/colors.scss @@ -71,6 +71,10 @@ cursor: pointer; } +.sb-actions button.sb-enabled { + color: var(--action-button-active-color); +} + .sb-actions button:hover { color: var(--action-button-hover-color); } diff --git a/web/styles/theme.scss b/web/styles/theme.scss index f4e0a703..68c56f0b 100644 --- a/web/styles/theme.scss +++ b/web/styles/theme.scss @@ -45,6 +45,7 @@ html { --action-button-background-color: transparent; --action-button-color: #292929; --action-button-hover-color: #0772be; + --action-button-active-color: #0772be; --editor-caret-color: black; --editor-selection-background-color: #d7e1f6; @@ -159,6 +160,7 @@ html[data-theme="dark"] { --action-button-background-color: transparent; --action-button-color: #adadad; --action-button-hover-color: #37a1ed; + --action-button-active-color: #37a1ed; --editor-caret-color: #fff; --editor-selection-background-color: #d7e1f630; diff --git a/web/sync_service.ts b/web/sync_service.ts index 09de0152..2425b0ef 100644 --- a/web/sync_service.ts +++ b/web/sync_service.ts @@ -1,4 +1,4 @@ -import { sleep } from "../common/async_util.ts"; +import { sleep } from "$sb/lib/async.ts"; import type { SpacePrimitives } from "../common/spaces/space_primitives.ts"; import { SpaceSync, @@ -7,6 +7,7 @@ import { } from "../common/spaces/sync.ts"; import { EventHook } from "../plugos/hooks/event.ts"; import { KVStore } from "../plugos/lib/kv_store.ts"; +import { Space } from "./space.ts"; // Keeps the current sync snapshot const syncSnapshotKey = "syncSnapshot"; @@ -31,11 +32,21 @@ const spaceSyncInterval = 17 * 1000; // Every 17s or so // Used from Client export const pageSyncInterval = 6000; +export interface ISyncService { + start(): void; + isSyncing(): Promise; + hasInitialSyncCompleted(): Promise; + noOngoingSync(_timeout: number): Promise; + syncFile(name: string): Promise; + scheduleFileSync(_path: string): Promise; + scheduleSpaceSync(): Promise; +} + /** * The SyncService primarily wraps the SpaceSync engine but also coordinates sync between * different browser tabs. It is using the KVStore to keep track of sync state. */ -export class SyncService { +export class SyncService implements ISyncService { spaceSync: SpaceSync; lastReportedSyncStatus = Date.now(); @@ -45,7 +56,6 @@ export class SyncService { private kvStore: KVStore, private eventHook: EventHook, private isSyncCandidate: (path: string) => boolean, - private enabled: boolean, ) { this.spaceSync = new SpaceSync( this.localSpacePrimitives, @@ -72,12 +82,15 @@ export class SyncService { const path = `${name}.md`; this.scheduleFileSync(path).catch(console.error); }); + + this.spaceSync.on({ + fileSynced: (meta, direction) => { + eventHook.dispatchEvent("file:synced", meta, direction); + }, + }); } async isSyncing(): Promise { - if (!this.enabled) { - return false; - } const startTime = await this.kvStore.get(syncStartTimeKey); if (!startTime) { return false; @@ -95,19 +108,11 @@ export class SyncService { } hasInitialSyncCompleted(): Promise { - if (!this.enabled) { - return Promise.resolve(true); - } - // Initial sync has happened when sync progress has been reported at least once, but the syncStartTime has been reset (which happens after sync finishes) return this.kvStore.has(syncInitialFullSyncCompletedKey); } async registerSyncStart(fullSync: boolean): Promise { - if (!this.enabled) { - return; - } - // Assumption: this is called after an isSyncing() check await this.kvStore.batchSet([ { @@ -128,10 +133,6 @@ export class SyncService { } async registerSyncProgress(status?: SyncStatus): Promise { - if (!this.enabled) { - return; - } - // Emit a sync event at most every 2s if (status && this.lastReportedSyncStatus < Date.now() - 2000) { this.eventHook.dispatchEvent("sync:progress", status); @@ -142,10 +143,6 @@ export class SyncService { } async registerSyncStop(isFullSync: boolean): Promise { - if (!this.enabled) { - return; - } - await this.registerSyncProgress(); await this.kvStore.del(syncStartTimeKey); if (isFullSync) { @@ -162,10 +159,6 @@ export class SyncService { // Await a moment when the sync is no longer running async noOngoingSync(timeout: number): Promise { - if (!this.enabled) { - return; - } - // Not completely safe, could have race condition on setting the syncStartTimeKey const startTime = Date.now(); while (await this.isSyncing()) { @@ -179,10 +172,6 @@ export class SyncService { filesScheduledForSync = new Set(); async scheduleFileSync(path: string): Promise { - if (!this.enabled) { - return; - } - if (this.filesScheduledForSync.has(path)) { // Already scheduled, no need to duplicate console.info(`File ${path} already scheduled for sync`); @@ -195,19 +184,11 @@ export class SyncService { } async scheduleSpaceSync(): Promise { - if (!this.enabled) { - return; - } - await this.noOngoingSync(5000); await this.syncSpace(); } start() { - if (!this.enabled) { - return; - } - this.syncSpace().catch(console.error); setInterval(async () => { @@ -227,10 +208,6 @@ export class SyncService { } async syncSpace(): Promise { - if (!this.enabled) { - return 0; - } - if (await this.isSyncing()) { console.log("Aborting space sync: already syncing"); return 0; @@ -258,10 +235,6 @@ export class SyncService { // Syncs a single file async syncFile(name: string) { - if (!this.enabled) { - return; - } - // console.log("Checking if we can sync file", name); if (!this.isSyncCandidate(name)) { console.info("Requested sync, but not a sync candidate", name); @@ -326,10 +299,6 @@ export class SyncService { } await this.saveSnapshot(snapshot); await this.registerSyncStop(false); - // HEAD - // console.log("And done with file sync for", name); - // - //main } async saveSnapshot(snapshot: Map) { @@ -383,3 +352,46 @@ export class SyncService { return 1; } } + +/** + * A no-op sync service that doesn't do anything used when running in thin client mode + */ +export class NoSyncSyncService implements ISyncService { + constructor(private space: Space) { + } + + isSyncing(): Promise { + return Promise.resolve(false); + } + + hasInitialSyncCompleted(): Promise { + return Promise.resolve(true); + } + + noOngoingSync(_timeout: number): Promise { + return Promise.resolve(); + } + + scheduleFileSync(_path: string): Promise { + return Promise.resolve(); + } + + scheduleSpaceSync(): Promise { + return Promise.resolve(); + } + + start() { + setInterval(() => { + // Trigger a page upload for change events + this.space.updatePageList().catch(console.error); + }, spaceSyncInterval); + } + + syncSpace(): Promise { + return Promise.resolve(0); + } + + syncFile(_name: string): Promise { + return Promise.resolve(); + } +} diff --git a/web/syscalls/editor.ts b/web/syscalls/editor.ts index bab65450..f7fdd2d1 100644 --- a/web/syscalls/editor.ts +++ b/web/syscalls/editor.ts @@ -171,20 +171,9 @@ export function editorSyscalls(editor: Client): SysCallMapping { return editor.confirm(message); }, "editor.getUiOption": (_ctx, key: string): any => { - if (key === "thinClientMode") { - return !!localStorage.getItem("thinClientMode"); - } return (editor.ui.viewState.uiOptions as any)[key]; }, "editor.setUiOption": (_ctx, key: string, value: any) => { - if (key === "thinClientMode") { - if (value) { - localStorage.setItem("thinClientMode", "true"); - } else { - localStorage.removeItem("thinClientMode"); - } - return; - } editor.ui.viewDispatch({ type: "set-ui-option", key, diff --git a/web/syscalls/system.ts b/web/syscalls/system.ts index 312010bc..57e0c7d3 100644 --- a/web/syscalls/system.ts +++ b/web/syscalls/system.ts @@ -5,8 +5,8 @@ import { CommandDef } from "../hooks/command.ts"; import { proxySyscall } from "./util.ts"; export function systemSyscalls( - editor: Client, system: System, + client?: Client, ): SysCallMapping { const api: SysCallMapping = { "system.invokeFunction": ( @@ -38,24 +38,36 @@ export function systemSyscalls( if (!functionDef) { throw Error(`Function ${name} not found`); } - if (functionDef.env && system.env && functionDef.env !== system.env) { + if ( + client && functionDef.env && system.env && + functionDef.env !== system.env + ) { // Proxy to another environment - return proxySyscall(ctx, editor.remoteSpacePrimitives, name, args); + return proxySyscall(ctx, client.remoteSpacePrimitives, name, args); } return plug.invoke(name, args); }, "system.invokeCommand": (_ctx, name: string) => { - return editor.runCommandByName(name); + if (!client) { + throw new Error("Not supported"); + } + return client.runCommandByName(name); }, "system.listCommands": (): { [key: string]: CommandDef } => { + if (!client) { + throw new Error("Not supported"); + } const allCommands: { [key: string]: CommandDef } = {}; - for (const [cmd, def] of editor.system.commandHook.editorCommands) { + for (const [cmd, def] of client.system.commandHook.editorCommands) { allCommands[cmd] = def.command; } return allCommands; }, "system.reloadPlugs": () => { - return editor.loadPlugs(); + if (!client) { + throw new Error("Not supported"); + } + return client.loadPlugs(); }, "system.getEnv": () => { return system.env; diff --git a/website/CHANGELOG.md b/website/CHANGELOG.md index 43f57162..40ed0e19 100644 --- a/website/CHANGELOG.md +++ b/website/CHANGELOG.md @@ -4,7 +4,10 @@ release. --- ## Next -* Another heavy behind-the-scenes refactoring release, refactoring the large β€œcore” plug into multiple smaller ones, documentation to be updated to reflect this. +This release brings a new default [[Client Modes|client mode]] to SilverBullet: online mode, which does not sync content to the client but keeps it all at the server. More information: [[Client Modes]]. + +Other notable changes: +* Massive reshuffling of built-in [[πŸ”Œ Plugs]], splitting the old β€œcore” plug into [[πŸ”Œ Editor]], [[πŸ”Œ Template]] and [[πŸ”Œ Index]]. * Removed [[Cloud Links]] support in favor of [[Federation]] --- @@ -63,7 +66,7 @@ release. * Initial work on [[Attributes]] (inline [[Metadata]]) such as this [importance:: high] * Added {[Debug: Reset Client]} command that flushes the local databases and caches (and service worker) for debugging purposes. * Added {[Editor: Center Cursor]} command. -* New template helper `replaceRegexp`, see [[πŸ”Œ Core/Templates@vars]] +* New template helper `replaceRegexp`, see [[πŸ”Œ Template@vars]] * **Bug fix**: Renaming of pages now works again on iOS * Big internal code refactor @@ -80,7 +83,7 @@ release. ## 0.3.4 -* **Breaking change (for some templates):** Template in various places allowed you to use `{{variables}}` and various handlebars functions. There also used to be a magic `{{page}}` variable that you could use in various places, but not everywhere. This has now been unified. And the magical `{{page}}` now has been replaced with the global `@page` which does not just expose the page’s name, but any page meta data. More information here: [[πŸ”Œ Core/Templates@vars]]. You will now get completion for built-in handlebars helpers after typing `{{`. +* **Breaking change (for some templates):** Template in various places allowed you to use `{{variables}}` and various handlebars functions. There also used to be a magic `{{page}}` variable that you could use in various places, but not everywhere. This has now been unified. And the magical `{{page}}` now has been replaced with the global `@page` which does not just expose the page’s name, but any page meta data. More information here: [[πŸ”Œ Template@vars]]. You will now get completion for built-in handlebars helpers after typing `{{`. * **Breaking change** (for [[STYLES]] users). The [[STYLES]] page is now no longer β€œmagic” and hardcoded. It can (and must) now be specified in [[SETTINGS]] (see example on that page) for styles to be loaded from it. * Folding is here (at least with commands, not much UI): {[Fold: Fold]}, {[Fold: Unfold]}, {[Fold: Toggle Fold]}, {[Fold: Fold All]} and {[Fold: Unfold All]}. * {[Broken Links: Show]} command (not complete yet, but already useful) diff --git a/website/Client Modes.md b/website/Client Modes.md new file mode 100644 index 00000000..78176755 --- /dev/null +++ b/website/Client Modes.md @@ -0,0 +1,30 @@ +SilverBullet currently supports two modes for its client: + +1. _Online mode_ (the default): keeps all content on the server +2. _Synced mode_ (offline capable): syncs all content to the client + +You can toggle between these two modes by clicking the πŸ”„ button in the top bar. + +You can switch modes at any time, so try them both to decide what works best for you. + +## Online mode +In online mode, all content in your space is kept on the server, and a lot of the heavy lifting (such as indexing of pages) happens on the server. + +Advantages: +* **Keeps content on the server**: this mode not synchronize all your content to your client (browser), making this a better fit for large spaces. +* **Lighter-weight** in terms of memory and CPU use of the client + +Disadvantages: +* **Requires a working network connection** to the server. +* **Higher latency**, since more interactions require calls to the server, this may be notable e.g. when completing page names. + +## Synced mode +In this mode, all content is synchronized to the client, and all processing happens there. The server effectively acts as β€œdumb data store.” All SilverBullet functionality is available even when there is no network connection available. + +Advantages: +* **100% offline capable**: disconnect your client from the network, shutdown the server, everything still works. Changes synchronize automatically once a network connection is re-established. +* **Lower latency**: all actions are performed locally in the client, which in most cases will be faster + +Disadvantages: +* **Synchronizes all content onto your client**: using disk space and an initially large bulk of network traffic to download everything. + diff --git a/website/Metadata.md b/website/Metadata.md index 94aeb8fc..e813347e 100644 --- a/website/Metadata.md +++ b/website/Metadata.md @@ -24,6 +24,6 @@ Metadata is data about data. There are a few entities you can add meta data to: In addition, this metadata can be augmented in a few additional ways: -* [[πŸ”Œ Core/Tags]]: adds to the `tags` attribute +* [[Tags]]: adds to the `tags` attribute * [[Frontmatter]]: at the top of pages, a [[YAML]] encoded block can be used to define additional attributes to a page * [[Attributes]] \ No newline at end of file diff --git a/website/PlugOS.md b/website/PlugOS.md new file mode 100644 index 00000000..767ec538 --- /dev/null +++ b/website/PlugOS.md @@ -0,0 +1,35 @@ +So here’s a secret β€”Β [[SilverBullet]] is really just a trojan horse to test a potentially much more widely applicable idea, the idea to _make applications extensible at different levels of its stack_ in a controlled manner. + +## Background +I’ve long appreciated the simplicity and flexibility of [AWS’s lambda functions](https://aws.amazon.com/lambda/). The idea is simple: you write a function using some language (JavaScript, Python, Java or whatever floats your boat), package it up, and ship it to AWS (think: zip file). Then, you configure the triggers that invoke those functions (such as certain events) and that’s it. The rest is managed for you. + +The AWS infrastructure fully manages the lifecycle of these functions: it ensures there are sufficient servers ready to invoke them, runs the code, recycles the processes when appropriate, and kills them when they misbehave. All this machinery is completely hidden from the user. It is referred to as **serverless** because it abstracts away the concept of a server. + +Of course, this requires functions to be written in a specific way: + +* **Stateless:** while the runtime may keep functions running and reuse an instance to perform multiple invocations, functions have to be written without this assumption. Therefore any state needs to be maintained outside of the function. +* **Self contained:** they make limited assumptions on the environment other than a language runtime, typically. +* **Short lived:** the assumption is that functions run for a limited amount of time, usually a few milliseconds, perhaps seconds, but a minute at most. + +While they can perform arbitrary computations, they do have constraints: + +1. They have to be stateless: while the runtime may keep functions running and reuse an instance to perform multiple invocations, they cannot assume this is the case. They have to assume that every invocation happens in a fresh environment. +2. They have limited access to the host machine, such as no direct access to a (persistent) file system. + +What can these functions do? In principle, anything, while being limited to access to the host. They generally cannot write to the host’s filesystem for instance. They also tend to be constrained in allocated run time and memory. All communication with the outside world tends to happen + +Then, you configure when it should be triggered. + +This concept is not only interesting in terms of **scalability** β€”Β such a function can quickly scale to millions of invocations per second when necessary, and down to zero when that demand vanishesΒ β€” but also in terms of **portability**. Couldn’t such functions conceptually run _everywhere_? And indeed, recently such functions have been moving to what’s called β€œthe edge” as well, such as [Lambda@Edge](https://aws.amazon.com/lambda/edge/), [Vercel’s Edge Functions](https://vercel.com/blog/edge-functions-generally-available), or [Netlify’s Edge Functions](https://docs.netlify.com/edge-functions/overview/). What is the β€œedge” here? Generally, the closest data center these providers offer near the user. The goal? Lower latency. + +But is that is as _edgy_ as we can get? What about the _real_ edge: the user’s device? + +## Introducing PlugOS +PlugOS is a JavaScript (TypeScript) library that brings these concepts to _applications_: allowing applications, [[SilverBullet]] to be extended in a safe way, by allowing plugins β€”Β named β€œplugs” β€”Β to _hook_ into various aspects of the application, run custom code as a result, which in turn can affect the application again via _syscalls_. + +## Concepts +* _Functions_: are pieces of code, written in JavaScript or TypeScript that add custom functionality to a hosting application. +* _Hooks_: are application-specific extension points, they can range from defining new commands, to timer based hooks (cron-like), to HTTP endpoints to be defined. +* _Syscalls_: expose (often) application-specific functionality to functions, allowing it to e.g. manipulate the UI, access various data stores etc. +* _Manifests_: wire the whole thing together, they are [[YAML]] files that define the functions and what they hook into. +* _Sandbox_: each plug is run in its own sandbox, in the browser this is a [Web Worker](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API), on the server as well (although Deno enables [deeper sandboxing](https://deno.land/manual@v1.36.3/runtime/workers#instantiation-permissions) than the browser). Sandboxes can, in principle, be flushed out and restarted at any time. In fact, this is how _hot reloading_ of plugs is implemented. \ No newline at end of file diff --git a/website/SilverBullet.md b/website/SilverBullet.md index 369710ff..7aed935c 100644 --- a/website/SilverBullet.md +++ b/website/SilverBullet.md @@ -1,4 +1,4 @@ -SilverBullet is an extensible, [open source](https://github.com/silverbulletmd/silverbullet), **personal knowledge management** system. Indeed, that’s fancy talk for β€œa note-taking app with links.” However, SilverBullet goes a bit beyond _just_ that. +SilverBullet is an extensible, [open source](https://github.com/silverbulletmd/silverbullet), **personal knowledge management** system. Indeed, that’s fancy talk for β€œa note-taking app with links.” However, SilverBullet goes _a bit_ beyond just that. You’ve been told there is _no such thing_ as a [silver bullet](https://en.wikipedia.org/wiki/Silver_bullet). You were told wrong. @@ -7,7 +7,7 @@ Before we get to the nitty gritty, some _quick links_ for the impatient reader: Now that we got that out of the way let’s have a look at some of SilverBullet’s features. ## Features -* Runs in any modern browser (including on mobile) as an **offline-first [[PWA]],** keeping the primary copy of your content in the browser, syncing back to the server when a network connection is available. +* Runs in any modern browser (including on mobile) as a [[PWA]] in two potential [[Client Modes]] (_online_ and _synced_ mode), where the _synced mode_ enables **100% offline operation**, keeping a copy of content in the browser, syncing back to the server when a network connection is available. * Provides an enjoyable [[Markdown]] writing experience with a clean UI, rendering text using [[Live Preview|live preview]], further **reducing visual noise** while still providing direct access to the underlying markdown syntax. * Supports wiki-style **page linking** using the `[[page link]]` syntax, even keeping links up-to-date when pages are renamed. * Optimized for **keyboard-based operation**: diff --git a/website/πŸ”Œ Core/Tags.md b/website/Tags.md similarity index 74% rename from website/πŸ”Œ Core/Tags.md rename to website/Tags.md index 231ea1fc..3d0f84e3 100644 --- a/website/πŸ”Œ Core/Tags.md +++ b/website/Tags.md @@ -6,7 +6,7 @@ Tags in SilverBullet can be added in two ways: For instance, by using the #core-tag in this page, it has been tagged and can be used in a [[πŸ”Œ Directive/Query]]: -* [[πŸ”Œ Core/Tags]] +* [[Tags]] Similarly, tags can be applied to list **items**: @@ -16,9 +16,9 @@ Similarly, tags can be applied to list **items**: and be queried: -|name |tags |page |pos| -|-------------------------------|--------|------------|---| -|This is a tagged item #core-tag|core-tag|πŸ”Œ Core/Tags|493| +|name |tags |page|pos| +|-------------------------------|--------|----|---| +|This is a tagged item #core-tag|core-tag|Tags|494| and **tags**: @@ -28,5 +28,5 @@ and **tags**: And they can be queried this way: -* [ ] [[πŸ”Œ Core/Tags@804]] This is a tagged task #core-tag +* [ ] [[Tags@808]] This is a tagged task #core-tag diff --git a/website/πŸ”Œ Core.md b/website/πŸ”Œ Core.md deleted file mode 100644 index 230868d9..00000000 --- a/website/πŸ”Œ Core.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -type: plug -repo: https://github.com/silverbulletmd/silverbullet ---- - -The core plug implements foundational functionality for SilverBullet. It covers the following areas: - -* [[πŸ”Œ Core/Indexing]] -* [[πŸ”Œ Core/Templates]] -* [[πŸ”Œ Core/Tags]] -* [[πŸ”Œ Core/Full Text Search]] -* [[πŸ”Œ Core/Slash Commands]] -* [[πŸ”Œ Core/Edit Commands]] -* [[πŸ”Œ Core/Plug Management]] -* [[πŸ”Œ Core/Link Unfurl]] - diff --git a/website/πŸ”Œ Core/Edit Commands.md b/website/πŸ”Œ Core/Edit Commands.md index 1b2936d5..8ddb537a 100644 --- a/website/πŸ”Œ Core/Edit Commands.md +++ b/website/πŸ”Œ Core/Edit Commands.md @@ -1,4 +1,4 @@ -The [[πŸ”Œ Core]] plug provides various useful edit commands, such as: +The [[πŸ”Œ Editor]] plug provides various useful edit commands, such as: * {[Text: Bold]} {[Text: Italic]} {[Text: Marker]} to respectively make text bold, italic or mark it. * {[Text: Listify Selection]} to turn each line in the selection into a (bullet) list diff --git a/website/πŸ”Œ Core/Indexing.md b/website/πŸ”Œ Core/Indexing.md deleted file mode 100644 index 5631a3a9..00000000 --- a/website/πŸ”Œ Core/Indexing.md +++ /dev/null @@ -1,7 +0,0 @@ -SilverBullet has a generic indexing infrastructure. Pages are reindexed upon saving, so about every second. Manual reindexing can be done running the {[Space: Reindex]} command. - -The [[πŸ”Œ Core]] plug indexes the following: - -* Page metadata encoded in [[Frontmatter]] (queryable via the `page` query source) -* Page backlinks (queryable via the `link` query source), this information is used when renaming a page (automatically updating pages that link to it). Renaming can be done either by editing the page name in the header and hitting `Enter`, or using the {[Page: Rename]} command. -* List items, such as bulleted and numbered lists (queryable via the `item` query source) \ No newline at end of file diff --git a/website/πŸ”Œ Core/Plug Management.md b/website/πŸ”Œ Core/Plug Management.md index ac200f17..18c8e954 100644 --- a/website/πŸ”Œ Core/Plug Management.md +++ b/website/πŸ”Œ Core/Plug Management.md @@ -1,10 +1,10 @@ -Plug management using the [[PLUGS]] file is also implemented in the [[πŸ”Œ Core]] plug. +Plug management using the [[PLUGS]] file is also implemented in the [[πŸ”Œ Editor]] plug. The optional [[PLUGS]] file is only processed when running the {[Plugs: Update]} command, in which case it will fetch all the listed plugs and copy them into the (hidden) `_plug/` folder in the user’s space. SilverBullet loads these files on boot (or on demand after running the {[Plugs: Update]} command). You can also use the {[Plugs: Add]} to add a plug, which will automatically create a [[PLUGS]] if it does not yet exist. -The [[πŸ”Œ Core]] plug has support for the following URI prefixes for plugs: +The [[πŸ”Œ Editor]] plug has support for the following URI prefixes for plugs: * `https:` loading plugs via HTTPS, e.g. `[https://](https://raw.githubusercontent.com/silverbulletmd/silverbullet-github/main/github.plug.json)` * `github:org/repo/file.plug.json` internally rewritten to a `https` url as above. diff --git a/website/πŸ”Œ Core/Slash Commands.md b/website/πŸ”Œ Core/Slash Commands.md index 7a54130c..fd0792a0 100644 --- a/website/πŸ”Œ Core/Slash Commands.md +++ b/website/πŸ”Œ Core/Slash Commands.md @@ -1,10 +1,10 @@ Slash commands are built-in to SilverBullet. You can trigger them by typing a `/` in your text (after whitespace). -The [[πŸ”Œ Core]] plug provides a few helpful ones: +The [[πŸ”Œ Editor]] plug provides a few helpful ones: * `/h1` through `/h4` to turn the current line into a header * `/hr` to insert a horizontal rule (`---`) * `/table` to insert a markdown table (whoever can remember this syntax without it) -* `/snippet` see [[πŸ”Œ Core/Templates@snippets]] +* `/snippet` see [[πŸ”Œ Template@snippets]] * `/today` to insert today’s date * `/tomorrow` to insert tomorrow’s date diff --git a/website/πŸ”Œ Directive.md b/website/πŸ”Œ Directive.md index dd118f38..ad302a2c 100644 --- a/website/πŸ”Œ Directive.md +++ b/website/πŸ”Œ Directive.md @@ -47,7 +47,7 @@ So, for instance, a template can take a tag name as an argument: $eval The `#eval` directive can be used to evaluate arbitrary JavaScript expressions. It’s also possible to invoke arbitrary plug functions this way. -**Note:** This feature is experimental and will likely evolve. +**Note:** ==This feature is experimental== and will likely evolve. A simple example is multiplying numbers: diff --git a/website/πŸ”Œ Directive/Query.md b/website/πŸ”Œ Directive/Query.md index 69efac71..559bd4f0 100644 --- a/website/πŸ”Œ Directive/Query.md +++ b/website/πŸ”Œ Directive/Query.md @@ -51,7 +51,7 @@ The best part about data sources: there is auto-completion. πŸŽ‰ Start writing `` diff --git a/website/πŸ”Œ Editor.md b/website/πŸ”Œ Editor.md new file mode 100644 index 00000000..2081652e --- /dev/null +++ b/website/πŸ”Œ Editor.md @@ -0,0 +1,50 @@ +--- +type: plug +repo: https://github.com/silverbulletmd/silverbullet +--- + +The `editor` plug implements foundational editor functionality for SilverBullet. + +## Commands + +* {[Editor: Toggle Dark Mode]}: toggles dark mode +* {[Editor: Toggle Vim Mode]}: toggle vim mode, see: [[Vim]] +* {[Stats: Show]}: shows some stats about the current page (word count, reading time etc.) +* {[Help: Getting Started]}: Open getting started guide +* {[Help: Version]}: Show version number + +### Pages +* {[Page: New]}: Create a new (untitled) page. Note that usually you would create a new page simply by navigating to a page name that does not yet exist. +* {[Page: Delete]}: delete the current page +* {[Page: Copy]}: copy the current page + +### Navigation +* {[Navigate: Home]}: navigate to the home (index) page +* {[Navigate To page]}: navigate to the page under the cursor +* {[Navigate: Center Cursor]}: center the cursor at the center of the screen +* {[Navigate: Move Cursor to Position]}: move cursor to a specific (numeric) cursor position (# of characters from the start of the document) + +### Text editing +* {[Text: Quote Selection]}: turns the selection into a blockquote (`>` prefix) +* {[Text: Listify Selection]}: turns the lines in the selection into a bulleted list +* {[Text: Number Listify Selection]}: turns the lines in the selection into a numbered list +* {[Text: Link Selection]}: turns the selection into a link. + #ProTip You can can also select text and paste a URL on it via `Ctrl-v`/`Cmd-v` to turn it into a link) +* {[Text: Bold]}: make text **bold** +* {[Text: Italic]}: make text _italic_ +* {[Text: Marker]}: mark text with a ==marker color== +* {[Link: Unfurl]}: β€œUnfurl” a link, see [[πŸ”Œ Editor/Link Unfurl]] + +### Folding commands +* {[Fold: Fold]}: fold current section (list, header) +* {[Fold: Unfold]}: unfold current section +* {[Fold: Fold All]}: fold all sections +* {[Fold: Unfold All]}: unfold all sections + +## Debug +Commands you shouldn’t need, but are nevertheless there: + +* {[Debug: Reset Client]}: clean out all cached data on the client and reload +* {[Debug: Reload UI]}: reload the UI (same as refreshing the page) +* {[Account: Logout]}: (when using built-in [[Authentication]]) Logout + diff --git a/website/πŸ”Œ Core/Link Unfurl.md b/website/πŸ”Œ Editor/Link Unfurl.md similarity index 84% rename from website/πŸ”Œ Core/Link Unfurl.md rename to website/πŸ”Œ Editor/Link Unfurl.md index 72f38cd7..97f976e9 100644 --- a/website/πŸ”Œ Core/Link Unfurl.md +++ b/website/πŸ”Œ Editor/Link Unfurl.md @@ -2,4 +2,4 @@ SilverBullet has infrastructure to β€œunfurl” β€”Β that is: replace with somet Plugs can provide custom unfurls for specific URL patterns. For instance the [[πŸ”Œ Twitter]] plug provides the ability to unfurl tweets, and pull in their content. -[[πŸ”Œ Core]] provides a generic URL unfurl, adding a title for a url. \ No newline at end of file +[[πŸ”Œ Editor]] provides a generic URL unfurl, adding a title for a url. \ No newline at end of file diff --git a/website/πŸ”Œ Index.md b/website/πŸ”Œ Index.md new file mode 100644 index 00000000..25328b11 --- /dev/null +++ b/website/πŸ”Œ Index.md @@ -0,0 +1,22 @@ +--- +type: plug +repo: https://github.com/silverbulletmd/silverbullet +--- +SilverBullet has a generic indexing infrastructure. Pages are reindexed upon saving, so about every second. + +The [[πŸ”Œ Index]] plug also defines syntax for [[Tags]] + +## Content indexing +The [[πŸ”Œ Index]] plug indexes the following: + +* [[Metadata]] +* [[Tags]] +* Page backlinks (queryable via the `link` query source), this information is used when renaming a page (automatically updating pages that link to it). +* List items, such as bulleted and numbered lists (queryable via the `item` query source) + +## Commands +* {[Space: Reindex]}: reindex the entire +* {[Page: Rename]}: Rename a page + #ProTip Renaming is more conveniently done by editing the page name in the header and hitting `Enter`. +* {[Page: Batch Rename Prefix]}: Rename a page prefix across the entire space +* {[Page: Extract]}: Extract the selected text into its own page diff --git a/website/πŸ”Œ Plugs.md b/website/πŸ”Œ Plugs.md index 35287b83..990a9031 100644 --- a/website/πŸ”Œ Plugs.md +++ b/website/πŸ”Œ Plugs.md @@ -1,6 +1,6 @@ SilverBullet at its core is bare bones in terms of functionality, most of its power it gains from **plugs**. -Plugs are an extension mechanism (implemented using a library called PlugOS that’s part of the silverbullet repo) that runs β€œplug” code in the browser using [web workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers). +Plugs are an extension mechanism (implemented using a library called [[PlugOS]] that’s part of the silverbullet repo) that runs β€œplug” code in the browser using [web workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers). Plugs can hook into SB in various ways: @@ -17,12 +17,14 @@ Plugs are distributed as self-contained JavaScript bundles (ending with `.plug.j ## Core plugs These plugs are distributed with SilverBullet and are automatically enabled: -* [[πŸ”Œ Core]] * [[πŸ”Œ Directive]] +* [[πŸ”Œ Editor]] * [[πŸ”Œ Emoji]] +* [[πŸ”Œ Index]] * [[πŸ”Œ Markdown]] * [[πŸ”Œ Share]] -* [[πŸ”Œ Tasks]] +* [[πŸ”Œ Tasks]] +* [[πŸ”Œ Template]] ## Third-party plugs @@ -85,7 +87,6 @@ Within seconds (watch your browser’s JavaScript console), your plug should be Since plugs run in your browser, you can use the usual browser debugging tools. When you console.log things, these logs will appear in your browser’s JavaScript console. ## Distribution - Once you’re happy with your plug, you can distribute it in various ways: - You can put it on github by simply committing the resulting `.plug.js` file there and instructing users to point to by adding diff --git a/website/πŸ”Œ Tasks.md b/website/πŸ”Œ Tasks.md index d928cd34..34e491e8 100644 --- a/website/πŸ”Œ Tasks.md +++ b/website/πŸ”Œ Tasks.md @@ -9,7 +9,7 @@ Tasks in SilverBullet are written using semi-standard task syntax: * [ ] This is a task -Tasks can also be annotated with [[πŸ”Œ Core/Tags]]: +Tasks can also be annotated with [[Tags]]: * [ ] This is a tagged task #my-tag @@ -27,6 +27,6 @@ This metadata is extracted and available via the `task` query source to [[πŸ”Œ D |name |done |page |pos|tags |deadline | |-----------------------------|-----|--------|---|------|----------| |This is a task |false|πŸ”Œ Tasks|213| | | -|This is a tagged task #my-tag|false|πŸ”Œ Tasks|287|my-tag| | -|This is due |false|πŸ”Œ Tasks|573| |2022-11-26| +|This is a tagged task #my-tag|false|πŸ”Œ Tasks|279|my-tag| | +|This is due |false|πŸ”Œ Tasks|565| |2022-11-26| diff --git a/website/πŸ”Œ Core/Templates.md b/website/πŸ”Œ Template.md similarity index 90% rename from website/πŸ”Œ Core/Templates.md rename to website/πŸ”Œ Template.md index 4b3c329d..cc424409 100644 --- a/website/πŸ”Œ Core/Templates.md +++ b/website/πŸ”Œ Template.md @@ -1,7 +1,11 @@ -The core plug implements a few templating mechanisms. +--- +type: plug +repo: https://github.com/silverbulletmd/silverbullet +--- + +The [[πŸ”Œ Template]] plug implements a few templating mechanisms. ### Page Templates - The {[Template: Instantiate Page]} command enables you to create a new page based on a page template. Page templates, by default, are looked for in the `template/page/` prefix. So creating e.g. a `template/page/Meeting Notes` page will create a β€œMeeting Notes” template. You can override this prefix by setting the `pageTemplatePrefix` in `SETTINGS`. @@ -64,6 +68,16 @@ with a πŸ—“οΈ emoji by default, but this is configurable via the `weeklyNotePre The {[Quick Note]} command will navigate to an empty page named with the current date and time prefixed with a πŸ“₯ emoji, but this is configurable via the `quickNotePrefix` in `SETTINGS`. The use case is to take a quick note outside of your current context. +## Slash commands +* `/front-matter`: Insert [[Frontmatter]] +* `/h1` - `/h4`: turn the current line into a header +* `/code`: insert a fenced code block +* `/hr`: insert a horizontal rule +* `/table`: insert a table +* `/page-template`: insert a page template +* `/today`: insert today’s date +* `/tomorrow`: insert tomorrow’s date + ### Template helpers $vars Currently supported (hardcoded in the code):