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 83b2d32..cd81bb4 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 bbdef95..c5c57f7 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 c76a801..0000000 --- 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 4b73433..feff165 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 0000000..5ccdf5a --- /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 0000000..f0939b5 --- /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 0000000..ca01f62 --- /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 02e1bcb..61d2af5 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 c72f97a..4501370 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 fd852a2..03900e4 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 f2c1f37..17982eb 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 0102fb9..f5ce7fc 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 bda6e4a..0000000 --- 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 9f4cd9a..c76699f 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 a8c8e74..ad8d773 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 4296e43..9f2f3c1 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 bbaa25b..50fd59d 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 7074bed..7627cc6 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 b567784..9ec3588 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 edf2466..64fe31a 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 1de185c..4301e3a 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 438adf3..b0d5dad 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 40e9e79..fd00e4e 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 071bef7..fb58be2 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 8a815cb..635c2aa 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 179ff31..7090f5d 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 1eb19a4..f271c07 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 b285793..9591e43 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 fcd8732..c4942bd 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 9261dbd..24312bb 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 563aa04..9493da4 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 f4e0a70..68c56f0 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 09de015..2425b0e 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 bab6545..f7fdd2d 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 312010b..57e0c7d 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 43f5716..40ed0e1 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 0000000..7817675 --- /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 94aeb8f..e813347 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 0000000..767ec53 --- /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 369710f..7aed935 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 231ea1f..3d0f84e 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 230868d..0000000 --- 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 1b2936d..8ddb537 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 5631a3a..0000000 --- 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 ac200f1..18c8e95 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 7a54130..fd0792a 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 dd118f3..ad302a2 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 69efac7..559bd4f 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 0000000..2081652 --- /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 72f38cd..97f976e 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 0000000..25328b1 --- /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 35287b8..990a903 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 d928cd3..34e491e 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 4b3c329..cc42440 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):