├── .github └── workflows │ └── gh-pages.yml ├── .gitignore ├── .vscode └── settings.json ├── README.md ├── SETTINGS.md ├── _headers ├── bin └── publish.ts ├── build_website.sh ├── deno.jsonc ├── deps.ts ├── fs.ts ├── http_fs.ts ├── http_server.ts ├── import_map.json ├── index.md ├── netlify.toml ├── pub-server.js ├── pub.plug.js ├── pub.plug.yaml ├── publish.ts ├── silverbullet-pub-server.ts ├── space_fs.ts └── template └── page.md /.github/workflows/gh-pages.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main # Set a branch to trigger the deployment 7 | 8 | jobs: 9 | build-and-deploy: 10 | runs-on: ubuntu-latest # You can choose a different runner if you prefer 11 | 12 | steps: 13 | - name: Checkout Repository 14 | uses: actions/checkout@v2 15 | 16 | - name: Setup Deno 17 | uses: denoland/setup-deno@v1 18 | with: 19 | deno-version: v1.41 20 | 21 | - name: Build Static Site 22 | run: | 23 | SB_DB_BACKEND=memory deno run --unstable-kv --unstable-worker-options -A https://edge.silverbullet.md/silverbullet.js plug:run . pub.publishAll 24 | 25 | - name: Deploy to GitHub Pages 26 | uses: peaceiris/actions-gh-pages@v3 27 | with: 28 | github_token: ${{ secrets.GITHUB_TOKEN }} 29 | publish_dir: ./_public -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | _public 2 | .DS_Store 3 | pub.db* 4 | deno.lock 5 | SECRETS.md 6 | *.map 7 | .silverbullet.* -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "editor.formatOnSave": true, 4 | "deno.config": "deno.jsonc", 5 | "deno.unstable": true, 6 | "[markdown]": { 7 | "editor.formatOnSave": false 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Silver Bullet Pub 2 | SilverBullet Pub is a simple tool to publish a a subset of your 3 | [SilverBullet](https://silverbullet.md) space as a static website. 4 | 5 | **Note:** this is still experimental, use at your own risk. 6 | 7 | SilverBullet Pub publishes a subset of a space in two formats: 8 | 9 | - HTML (.html files based on a handlebars template that you can override, see [[template/page|the template used for this site]] and configuration part of [[SETTINGS]]). 10 | - Markdown (.md files) (and an associated `index.json` file for SilverBullet to read via [[!silverbullet.md/Federation]]). 11 | 12 | The tool can be run in two ways: 13 | 14 | 1. From the SB UI, via the (via the {[Pub: Publish All]} command) 15 | 2. As a stand-alone CLI tool (see below) 16 | 17 | After running the _Publish All_ command (from SB, or via the CLI as described below) the resulting website is written into your space folder under `_public` by default (but this is configurable). Note that because SilverBullet does not list pages starting with `_`, this folder will not be visible in the SilverBullet page picker, it’s only visible on disk. 18 | 19 | After this, it’s up to you to deploy these files to any host capable of statically serving files. This repository itself is published to [pub.silverbullet.md](https://pub.silverbullet.md) using _Pub_ combined with [Netlify](https://netlify.com/). [Check the repo](https://github.com/silverbulletmd/silverbullet-pub/blob/main/netlify.toml) to see how this works. 20 | 21 | ## Installation 22 | Run the {[Plugs: Add]} command and add the following plug: 23 | 24 | ```yaml 25 | - github:silverbulletmd/silverbullet-pub/pub.plug.js 26 | ``` 27 | 28 | ## Configuration 29 | SilverBullet Pub is configured through [[SETTINGS]]. 30 | 31 | ## Running from the CLI 32 | First make sure you have the plug installed into your space. Then, from the command line run: 33 | 34 | ```bash 35 | SB_DB_BACKEND=memory silverbullet plug:run <> pub.publishAll 36 | ``` 37 | 38 | ## Running in a CI environment 39 | You may use pub to automatically publish as part of a CI build. For this make sure that you have checked in your `_plug` folder (at least with the `pub.plug.js` file) in your code repository. 40 | 41 | Here are the minimal steps for your CI build: 42 | 43 | 1. Install Deno 44 | 2. Run `SB_DB_BACKEND=memory deno run --unstable-kv --unstable-worker-options -A https://get.silverbullet.md plug:run . pub.publishAll` 45 | 46 | See `.github/workflows/gh-pages.yml` and `netlify.toml` in this repo as examples 47 | 48 | ## Site map 49 | ```query 50 | page render [[!silverbullet.md/Library/Core/Query/Page]] 51 | ``` 52 | -------------------------------------------------------------------------------- /SETTINGS.md: -------------------------------------------------------------------------------- 1 | 2 | ```yaml 3 | indexPage: README 4 | publish: 5 | # indexPage specific to the published site 6 | indexPage: README 7 | # Site title 8 | title: SilverBullet Publish 9 | # publishServer: https://zef-pub.deno.dev 10 | # Page containing the handlebars template to use to render pages 11 | # defaults to "!pub.silverbullet.md/template/page" 12 | template: template/page 13 | # Destination prefix for where to write the files to (has to be inside the space), defaults to public/ 14 | destPrefix: _public/ 15 | # Remove hashtags from the output 16 | removeHashtags: true 17 | # Entirely remove page links to pages that are not published 18 | removeUnpublishedLinks: false 19 | # Publish ALL pages in this space (defaults to false) 20 | publishAll: true 21 | # Publish all pages with a specifix prefix only (assuming publishAll is off) 22 | #prefixes: 23 | #- /public 24 | # Publish all pages with specific tag only (assuming publishAll is off) 25 | tags: 26 | - pub 27 | ``` 28 | -------------------------------------------------------------------------------- /_headers: -------------------------------------------------------------------------------- 1 | /* 2 | access-control-allow-headers: * 3 | access-control-allow-methods: GET,POST,PUT,DELETE,OPTIONS,HEAD 4 | access-control-allow-origin: * 5 | access-control-expose-headers: * -------------------------------------------------------------------------------- /bin/publish.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { 3 | createSandbox, 4 | } from "$silverbullet/plugos/environments/deno_sandbox.ts"; 5 | import { EventHook } from "$silverbullet/plugos/hooks/event.ts"; 6 | import { eventSyscalls } from "$silverbullet/plugos/syscalls/event.ts"; 7 | import fileSystemSyscalls from "$silverbullet/plugos/syscalls/fs.deno.ts"; 8 | import { 9 | ensureFTSTable, 10 | fullTextSearchSyscalls, 11 | } from "$silverbullet/plugos/syscalls/fulltext.sqlite.ts"; 12 | import sandboxSyscalls from "$silverbullet/plugos/syscalls/sandbox.ts"; 13 | import shellSyscalls from "$silverbullet/plugos/syscalls/shell.deno.ts"; 14 | import { 15 | ensureTable as ensureStoreTable, 16 | storeSyscalls, 17 | } from "$silverbullet/plugos/syscalls/store.deno.ts"; 18 | import { System } from "$silverbullet/plugos/system.ts"; 19 | import { Manifest, SilverBulletHooks } from "$silverbullet/common/manifest.ts"; 20 | import { loadMarkdownExtensions } from "$silverbullet/common/markdown_ext.ts"; 21 | import buildMarkdown from "$silverbullet/common/parser.ts"; 22 | import { DiskSpacePrimitives } from "$silverbullet/common/spaces/disk_space_primitives.ts"; 23 | import { EventedSpacePrimitives } from "$silverbullet/common/spaces/evented_space_primitives.ts"; 24 | import { Space } from "$silverbullet/common/spaces/space.ts"; 25 | import { markdownSyscalls } from "$silverbullet/common/syscalls/markdown.ts"; 26 | import { PageNamespaceHook } from "$silverbullet/server/hooks/page_namespace.ts"; 27 | import { PlugSpacePrimitives } from "$silverbullet/server/hooks/plug_space_primitives.ts"; 28 | import { 29 | ensureTable as ensureIndexTable, 30 | pageIndexSyscalls, 31 | } from "$silverbullet/server/syscalls/index.ts"; 32 | import spaceSyscalls from "$silverbullet/server/syscalls/space.ts"; 33 | 34 | import { Command } from "https://deno.land/x/cliffy@v0.25.2/command/command.ts"; 35 | 36 | import globalModules from "https://get.silverbullet.md/global.plug.json" assert { 37 | type: "json", 38 | }; 39 | 40 | import publishPlugManifest from "../publish.plug.json" assert { type: "json" }; 41 | import * as path from "https://deno.land/std@0.159.0/path/mod.ts"; 42 | import { AsyncSQLite } from "../../silverbullet/plugos/sqlite/async_sqlite.ts"; 43 | 44 | await new Command() 45 | .name("silverbullet-publish") 46 | .description("Publish a SilverBullet site") 47 | .arguments("") 48 | .option("--index [type:boolean]", "Index space first", { default: false }) 49 | .option("--watch, -w [type:boolean]", "Watch for changes", { default: false }) 50 | .option("-o ", "Output directory", { default: "web" }) 51 | .action(async (options, pagesPath) => { 52 | // Set up the PlugOS System 53 | const system = new System("server"); 54 | 55 | // Instantiate the event bus hook 56 | const eventHook = new EventHook(); 57 | system.addHook(eventHook); 58 | 59 | // And the page namespace hook 60 | const namespaceHook = new PageNamespaceHook(); 61 | system.addHook(namespaceHook); 62 | 63 | pagesPath = path.resolve(pagesPath); 64 | 65 | // The space 66 | const space = new Space( 67 | new EventedSpacePrimitives( 68 | new PlugSpacePrimitives( 69 | new DiskSpacePrimitives(pagesPath), 70 | namespaceHook, 71 | ), 72 | eventHook, 73 | ), 74 | ); 75 | 76 | await space.updatePageList(); 77 | 78 | // The database used for persistence (SQLite) 79 | const db = new AsyncSQLite(path.join(pagesPath, "publish-data.db")); 80 | db.init().catch((e) => { 81 | console.error("Error initializing database", e); 82 | }); 83 | 84 | // Register syscalls available on the server side 85 | system.registerSyscalls( 86 | [], 87 | pageIndexSyscalls(db), 88 | storeSyscalls(db, "store"), 89 | fullTextSearchSyscalls(db, "fts"), 90 | spaceSyscalls(space), 91 | eventSyscalls(eventHook), 92 | markdownSyscalls(buildMarkdown([])), 93 | sandboxSyscalls(system), 94 | ); 95 | // Danger zone 96 | system.registerSyscalls(["shell"], shellSyscalls(pagesPath)); 97 | system.registerSyscalls(["fs"], fileSystemSyscalls("/")); 98 | 99 | system.on({ 100 | sandboxInitialized: async (sandbox) => { 101 | for ( 102 | const [modName, code] of Object.entries( 103 | globalModules.dependencies, 104 | ) 105 | ) { 106 | await sandbox.loadDependency(modName, code as string); 107 | } 108 | }, 109 | }); 110 | 111 | const plugDir = nodeModulesDir + "/node_modules/$silverbullet/plugs/dist"; 112 | for (let file of await readdir(plugDir)) { 113 | if (file.endsWith(".plug.json")) { 114 | let manifestJson = await readFile(path.join(plugDir, file), "utf8"); 115 | let manifest: Manifest = JSON.parse(manifestJson); 116 | await system.load(manifest, createSandbox); 117 | } 118 | } 119 | 120 | let publishPlug = await system.load(publishPlugManifest, createSandbox); 121 | 122 | system.registerSyscalls( 123 | [], 124 | markdownSyscalls(buildMarkdown(loadMarkdownExtensions(system))), 125 | ); 126 | 127 | await ensureIndexTable(db); 128 | await ensureStoreTable(db, "store"); 129 | await ensureFTSTable(db, "fts"); 130 | 131 | if (args.index) { 132 | console.log("Now indexing space"); 133 | await system.loadedPlugs.get("core")?.invoke("reindexSpace", []); 134 | } 135 | 136 | const outputDir = path.resolve(args.o); 137 | 138 | await mkdir(outputDir, { recursive: true }); 139 | 140 | await publishPlug.invoke("publishAll", [outputDir]); 141 | 142 | if (args.w) { 143 | console.log("Watching for changes"); 144 | watch(pagesPath, { recursive: true }, async () => { 145 | console.log("Change detected, republishing"); 146 | await space.updatePageList(); 147 | await publishPlug.invoke("publishAll", [outputDir]); 148 | }); 149 | } else { 150 | console.log("Done!"); 151 | process.exit(0); 152 | } 153 | }) 154 | .parse(Deno.args); 155 | -------------------------------------------------------------------------------- /build_website.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | if [ "$1" != "local" ]; then 4 | echo "Install Deno" 5 | curl -fsSL https://deno.land/install.sh | sh 6 | export PATH=~/.deno/bin:$PATH 7 | fi 8 | 9 | SB_DB_BACKEND=memory deno run --unstable-kv --unstable-worker-options -A https://edge.silverbullet.md/silverbullet.js plug:run . pub.publishAll 10 | cp _headers _public/ 11 | 12 | deno bundle silverbullet-pub-server.ts > silverbullet-pub-server.js -------------------------------------------------------------------------------- /deno.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "importMap": "import_map.json", 3 | "tasks": { 4 | "build": "silverbullet plug:compile pub.plug.yaml", 5 | "pub": "deno task build && deno task clean && SB_DB_BACKEND=memory silverbullet plug:run . pub.publishAll", 6 | "clean": "rm -rf _public _plug", 7 | "install": "deno install -f -A --unstable-kv --import-map import_map.json silverbullet-pub-server.ts" 8 | }, 9 | "lint": { 10 | "rules": { 11 | "exclude": [ 12 | "no-explicit-any" 13 | ] 14 | } 15 | }, 16 | "fmt": { 17 | "exclude": [ 18 | "*.md" 19 | ] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /deps.ts: -------------------------------------------------------------------------------- 1 | export { 2 | Application, 3 | Request, 4 | Response, 5 | Router, 6 | } from "https://deno.land/x/oak@v12.4.0/mod.ts"; 7 | export { oakCors } from "https://deno.land/x/cors@v1.2.2/mod.ts"; 8 | export { Command } from "https://deno.land/x/cliffy@v1.0.0-rc.3/command/command.ts"; 9 | -------------------------------------------------------------------------------- /fs.ts: -------------------------------------------------------------------------------- 1 | import { FileMeta } from "$silverbullet/plug-api/types.ts"; 2 | 3 | export interface Filesystem { 4 | listFiles(): Promise; 5 | readFile(path: string): Promise; 6 | getFileMeta(path: string): Promise; 7 | writeFile(path: string, data: Uint8Array): Promise; 8 | deleteFile(path: string): Promise; 9 | } 10 | -------------------------------------------------------------------------------- /http_fs.ts: -------------------------------------------------------------------------------- 1 | import "$sb/lib/native_fetch.ts"; 2 | import { FileMeta } from "$sb/types.ts"; 3 | import { Filesystem } from "./fs.ts"; 4 | 5 | export class HttpFilesystem implements Filesystem { 6 | constructor(private url: string, private token: string) { 7 | } 8 | 9 | authenticatedFetch(input: RequestInfo, init?: RequestInit) { 10 | return nativeFetch(input, { 11 | ...init, 12 | headers: { 13 | ...init?.headers, 14 | "X-Sync-Mode": "true", 15 | Authorization: `Bearer ${this.token}`, 16 | }, 17 | }); 18 | } 19 | 20 | async listFiles(): Promise { 21 | const r = await this.authenticatedFetch(`${this.url}/index.json`, { 22 | method: "GET", 23 | }); 24 | if (r.status === 404) { 25 | throw new Error("Not found"); 26 | } 27 | return r.json(); 28 | } 29 | async readFile(path: string): Promise { 30 | const r = await this.authenticatedFetch(`${this.url}/${path}`, { 31 | method: "GET", 32 | }); 33 | return new Uint8Array(await r.arrayBuffer()); 34 | } 35 | async getFileMeta(path: string): Promise { 36 | const r = await this.authenticatedFetch(`${this.url}/${path}`, { 37 | method: "GET", 38 | headers: { 39 | "X-Get-Meta": "true", 40 | }, 41 | }); 42 | return this.headersToFileMeta(path, r.headers); 43 | } 44 | async writeFile(path: string, data: Uint8Array): Promise { 45 | const r = await this.authenticatedFetch(`${this.url}/${path}`, { 46 | method: "PUT", 47 | body: data, 48 | }); 49 | if (r.ok) { 50 | return this.headersToFileMeta(path, r.headers); 51 | } else { 52 | throw new Error(`Failed to write file: ${await r.text()}`); 53 | } 54 | } 55 | async deleteFile(path: string): Promise { 56 | const r = await this.authenticatedFetch(`${this.url}/${path}`, { 57 | method: "DELETE", 58 | }); 59 | if (!r.ok) { 60 | throw new Error(`Failed to delete file: ${path}: ${await r.text()}`); 61 | } 62 | } 63 | 64 | headersToFileMeta(path: string, headers: Headers): FileMeta { 65 | return { 66 | name: path, 67 | contentType: headers.get("Content-Type") || "application/octet-stream", 68 | perm: headers.get("X-Perm") as "ro" | "rw", 69 | created: +(headers.get("X-Created") || "0"), 70 | lastModified: +(headers.get("X-Last-Modified") || "0"), 71 | size: headers.has("X-Content-Length") 72 | ? +headers.get("X-Content-Length")! 73 | : +headers.get("Content-Length")!, 74 | }; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /http_server.ts: -------------------------------------------------------------------------------- 1 | import { Application, oakCors, Request, Response, Router } from "./deps.ts"; 2 | import { SpacePrimitives } from "$silverbullet/common/spaces/space_primitives.ts"; 3 | import { FileMeta } from "$silverbullet/plug-api/types.ts"; 4 | 5 | export type ServerOptions = { 6 | hostname: string; 7 | port: number; 8 | pagesPath: string; 9 | token: string; 10 | }; 11 | 12 | export class HttpServer { 13 | app = new Application(); 14 | abortController?: AbortController; 15 | 16 | constructor( 17 | private spacePrimitives: SpacePrimitives, 18 | private options: ServerOptions, 19 | ) { 20 | } 21 | 22 | start() { 23 | const fsRouter = this.addFsRoutes(); 24 | this.app.use(fsRouter.routes()); 25 | this.app.use(fsRouter.allowedMethods()); 26 | 27 | this.abortController = new AbortController(); 28 | const listenOptions: any = { 29 | hostname: this.options.hostname, 30 | port: this.options.port, 31 | signal: this.abortController.signal, 32 | }; 33 | this.app.listen(listenOptions) 34 | .catch((e: any) => { 35 | console.log("Server listen error:", e.message); 36 | Deno.exit(1); 37 | }); 38 | const visibleHostname = this.options.hostname === "0.0.0.0" 39 | ? "localhost" 40 | : this.options.hostname; 41 | console.log( 42 | `SilverBullet Pub server is now running: http://${visibleHostname}:${this.options.port}`, 43 | ); 44 | } 45 | 46 | private addFsRoutes(): Router { 47 | const fsRouter = new Router(); 48 | const corsMiddleware = oakCors({ 49 | allowedHeaders: "*", 50 | exposedHeaders: "*", 51 | methods: ["GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"], 52 | }); 53 | 54 | fsRouter.use(corsMiddleware); 55 | 56 | // File list 57 | fsRouter.get( 58 | "/index.json", 59 | // corsMiddleware, 60 | async ({ response }) => { 61 | // Only handle direct requests for a JSON representation of the file list 62 | response.headers.set("Content-type", "application/json"); 63 | response.headers.set("X-Space-Path", this.options.pagesPath); 64 | const files = await this.spacePrimitives.fetchFileList(); 65 | files.forEach((f) => { 66 | f.perm = "ro"; 67 | }); 68 | response.body = JSON.stringify(files); 69 | }, 70 | ); 71 | 72 | const filePathRegex = "\/(.*)"; 73 | 74 | fsRouter 75 | .get( 76 | filePathRegex, 77 | async ({ params, response, request }) => { 78 | let name = params[0]; 79 | if (name === "") { 80 | name = "index.html"; 81 | } 82 | console.log("Requested file", name); 83 | if (name.startsWith(".")) { 84 | // Don't expose hidden files 85 | response.status = 404; 86 | response.body = "Not exposed"; 87 | return; 88 | } 89 | try { 90 | if (request.headers.has("X-Get-Meta")) { 91 | // Getting meta via GET request 92 | const fileData = await this.spacePrimitives.getFileMeta(name); 93 | response.status = 200; 94 | this.fileMetaToHeaders(response.headers, fileData); 95 | response.body = ""; 96 | return; 97 | } 98 | let fileData: { meta: FileMeta; data: Uint8Array } | undefined; 99 | 100 | try { 101 | fileData = await this.spacePrimitives.readFile(name); 102 | } catch (e: any) { 103 | // console.error(e); 104 | if (e.message === "Not found") { 105 | fileData = await this.spacePrimitives.readFile( 106 | `${name}/index.html`, 107 | ); 108 | } 109 | } 110 | if (!fileData) { 111 | response.status = 404; 112 | response.body = "Not found"; 113 | return; 114 | } 115 | const lastModifiedHeader = new Date(fileData.meta.lastModified) 116 | .toUTCString(); 117 | if ( 118 | request.headers.get("If-Modified-Since") === lastModifiedHeader 119 | ) { 120 | response.status = 304; 121 | return; 122 | } 123 | response.status = 200; 124 | this.fileMetaToHeaders(response.headers, fileData.meta); 125 | response.headers.set("Last-Modified", lastModifiedHeader); 126 | 127 | response.body = fileData.data; 128 | } catch (e: any) { 129 | console.error("Error GETting file", name, e.message); 130 | response.status = 404; 131 | response.body = "Not found"; 132 | } 133 | }, 134 | ) 135 | .put( 136 | filePathRegex, 137 | async ({ request, response, params }) => { 138 | const name = params[0]; 139 | if (!this.ensureAuth(request, response)) { 140 | return; 141 | } 142 | console.log("Saving file", name); 143 | if (name.startsWith(".")) { 144 | // Don't expose hidden files 145 | response.status = 403; 146 | return; 147 | } 148 | 149 | const body = await request.body({ type: "bytes" }).value; 150 | 151 | try { 152 | const meta = await this.spacePrimitives.writeFile( 153 | name, 154 | body, 155 | ); 156 | response.status = 200; 157 | this.fileMetaToHeaders(response.headers, meta); 158 | response.body = "OK"; 159 | } catch (err) { 160 | console.error("Write failed", err); 161 | response.status = 500; 162 | response.body = "Write failed"; 163 | } 164 | }, 165 | ) 166 | .delete(filePathRegex, async ({ request, response, params }) => { 167 | if (!this.ensureAuth(request, response)) { 168 | return; 169 | } 170 | const name = params[0]; 171 | if (name === "index.json") { 172 | response.status = 200; 173 | response.body = "OK (noop)"; 174 | return; 175 | } 176 | console.log("Deleting file", name); 177 | if (name.startsWith(".")) { 178 | // Don't expose hidden files 179 | response.status = 403; 180 | return; 181 | } 182 | try { 183 | await this.spacePrimitives.deleteFile(name); 184 | response.status = 200; 185 | response.body = "OK"; 186 | } catch (e: any) { 187 | console.error("Error deleting attachment", e); 188 | response.status = 500; 189 | response.body = e.message; 190 | } 191 | }) 192 | .options(filePathRegex, corsMiddleware); 193 | return fsRouter; 194 | } 195 | 196 | ensureAuth(request: Request, response: Response): boolean { 197 | const authHeader = request.headers.get("Authorization"); 198 | if (!authHeader) { 199 | response.status = 401; 200 | response.body = "No Authorization header"; 201 | return false; 202 | } 203 | const token = authHeader.split(" ")[1]; 204 | if (token !== this.options.token) { 205 | response.status = 401; 206 | response.body = "Invalid token"; 207 | return false; 208 | } 209 | 210 | return true; 211 | } 212 | 213 | private fileMetaToHeaders(headers: Headers, fileMeta: FileMeta) { 214 | headers.set("Content-Type", fileMeta.contentType); 215 | headers.set( 216 | "X-Last-Modified", 217 | "" + fileMeta.lastModified, 218 | ); 219 | headers.set("Cache-Control", "no-cache"); 220 | headers.set("X-Permission", "ro"); 221 | headers.set("X-Created", "" + fileMeta.created); 222 | headers.set("X-Content-Length", "" + fileMeta.size); 223 | } 224 | 225 | stop() { 226 | if (this.abortController) { 227 | this.abortController.abort(); 228 | console.log("stopped server"); 229 | } 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /import_map.json: -------------------------------------------------------------------------------- 1 | { 2 | "imports": { 3 | "$sb/": "https://deno.land/x/silverbullet@0.7.5/plug-api/", 4 | "$silverbullet/": "https://deno.land/x/silverbullet@0.7.5/", 5 | "$lib/": "https://deno.land/x/silverbullet@0.7.5/lib/", 6 | "mimetypes": "https://deno.land/x/mimetypes@v1.0.0/mod.ts" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /index.md: -------------------------------------------------------------------------------- 1 | Welcome to your new space! -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | publish = "_public" 3 | command = "./build_website.sh" 4 | -------------------------------------------------------------------------------- /pub.plug.js: -------------------------------------------------------------------------------- 1 | var Y=Object.defineProperty;var p=(e,t)=>{for(var n in t)Y(e,n,{get:t[n],enumerable:!0})};var E=typeof window>"u"&&typeof globalThis.WebSocketPair>"u";typeof Deno>"u"&&(self.Deno={args:[],build:{arch:"x86_64"},env:{get(){}}});var C=new Map,M=0;function x(e){self.postMessage(e)}E&&(globalThis.syscall=async(e,...t)=>await new Promise((n,o)=>{M++,C.set(M,{resolve:n,reject:o}),x({type:"sys",id:M,name:e,args:t})}));function R(e,t){E&&(self.addEventListener("message",n=>{(async()=>{let o=n.data;switch(o.type){case"inv":{let a=e[o.name];if(!a)throw new Error(`Function not loaded: ${o.name}`);try{let s=await Promise.resolve(a(...o.args||[]));x({type:"invr",id:o.id,result:s})}catch(s){console.error("An exception was thrown as a result of invoking function",o.name,"error:",s.message),x({type:"invr",id:o.id,error:s.message})}}break;case"sysr":{let a=o.id,s=C.get(a);if(!s)throw Error("Invalid request id");C.delete(a),o.error?s.reject(new Error(o.error)):s.resolve(o.result)}break}})().catch(console.error)}),x({type:"manifest",manifest:t}))}function Q(e){let t=atob(e),n=t.length,o=new Uint8Array(n);for(let a=0;a0?B(n):void 0;t={method:e.method,headers:Object.fromEntries(e.headers.entries()),base64Body:o},e=e.url}return syscall("sandboxFetch.fetch",e,t)}globalThis.nativeFetch=globalThis.fetch;function X(){globalThis.fetch=async function(e,t){let n=t&&t.body?B(new Uint8Array(await new Response(t.body).arrayBuffer())):void 0,o=await V(e,t&&{method:t.method,headers:t.headers,base64Body:n});return new Response(o.base64Body?Q(o.base64Body):null,{status:o.status,headers:o.headers})}}E&&X();var m={};p(m,{confirm:()=>ve,copyToClipboard:()=>$e,dispatch:()=>Te,downloadFile:()=>pe,filterBox:()=>ge,flashNotification:()=>me,fold:()=>Ce,foldAll:()=>ke,getCurrentPage:()=>_,getCursor:()=>ee,getSelection:()=>te,getText:()=>J,getUiOption:()=>Fe,goHistory:()=>de,hidePanel:()=>he,insertAtCursor:()=>be,insertAtPos:()=>Pe,moveCursor:()=>we,navigate:()=>oe,openCommandPalette:()=>se,openPageNavigator:()=>ie,openSearchPanel:()=>Ne,openUrl:()=>ue,prompt:()=>Ae,reloadPage:()=>ae,reloadSettingsAndCommands:()=>le,reloadUI:()=>ce,replaceRange:()=>xe,save:()=>ne,setPage:()=>z,setSelection:()=>re,setText:()=>Z,setUiOption:()=>Se,showPanel:()=>ye,toggleFold:()=>Ue,unfold:()=>Ee,unfoldAll:()=>Ke,uploadFile:()=>fe,vimEx:()=>Me});typeof self>"u"&&(self={syscall:()=>{throw new Error("Not implemented here")}});var r=globalThis.syscall;function _(){return r("editor.getCurrentPage")}function z(e){return r("editor.setPage",e)}function J(){return r("editor.getText")}function Z(e){return r("editor.setText",e)}function ee(){return r("editor.getCursor")}function te(){return r("editor.getSelection")}function re(e,t){return r("editor.setSelection",e,t)}function ne(){return r("editor.save")}function oe(e,t=!1,n=!1){return r("editor.navigate",e,t,n)}function ie(e="page"){return r("editor.openPageNavigator",e)}function se(){return r("editor.openCommandPalette")}function ae(){return r("editor.reloadPage")}function ce(){return r("editor.reloadUI")}function le(){return r("editor.reloadSettingsAndCommands")}function ue(e,t=!1){return r("editor.openUrl",e,t)}function de(e){return r("editor.goHistory",e)}function pe(e,t){return r("editor.downloadFile",e,t)}function fe(e,t){return r("editor.uploadFile",e,t)}function me(e,t="info"){return r("editor.flashNotification",e,t)}function ge(e,t,n="",o=""){return r("editor.filterBox",e,t,n,o)}function ye(e,t,n,o=""){return r("editor.showPanel",e,t,n,o)}function he(e){return r("editor.hidePanel",e)}function Pe(e,t){return r("editor.insertAtPos",e,t)}function xe(e,t,n){return r("editor.replaceRange",e,t,n)}function we(e,t=!1){return r("editor.moveCursor",e,t)}function be(e){return r("editor.insertAtCursor",e)}function Te(e){return r("editor.dispatch",e)}function Ae(e,t=""){return r("editor.prompt",e,t)}function ve(e){return r("editor.confirm",e)}function Fe(e){return r("editor.getUiOption",e)}function Se(e,t){return r("editor.setUiOption",e,t)}function Me(e){return r("editor.vimEx",e)}function Ce(){return r("editor.fold")}function Ee(){return r("editor.unfold")}function Ue(){return r("editor.toggleFold")}function ke(){return r("editor.foldAll")}function Ke(){return r("editor.unfoldAll")}function Ne(){return r("editor.openSearchPanel")}function $e(e){return r("editor.copyToClipboard",e)}var g={};p(g,{parseMarkdown:()=>Re});function Re(e){return r("markdown.parseMarkdown",e)}var c={};p(c,{deleteAttachment:()=>Ye,deleteFile:()=>ze,deletePage:()=>Oe,getAttachmentMeta:()=>Ie,getFileMeta:()=>Xe,getPageMeta:()=>De,listAttachments:()=>qe,listFiles:()=>Qe,listPages:()=>Be,listPlugs:()=>Ge,readAttachment:()=>He,readFile:()=>Ve,readPage:()=>Le,writeAttachment:()=>je,writeFile:()=>_e,writePage:()=>We});function Be(e=!1){return r("space.listPages",e)}function De(e){return r("space.getPageMeta",e)}function Le(e){return r("space.readPage",e)}function We(e,t){return r("space.writePage",e,t)}function Oe(e){return r("space.deletePage",e)}function Ge(){return r("space.listPlugs")}function qe(){return r("space.listAttachments")}function Ie(e){return r("space.getAttachmentMeta",e)}function He(e){return r("space.readAttachment",e)}function je(e,t){return r("space.writeAttachment",e,t)}function Ye(e){return r("space.deleteAttachment",e)}function Qe(){return r("space.listFiles")}function Ve(e){return r("space.readFile",e)}function Xe(e){return r("space.getFileMeta",e)}function _e(e,t){return r("space.writeFile",e,t)}function ze(e){return r("space.deleteFile",e)}var y={};p(y,{applyAttributeExtractors:()=>nt,getEnv:()=>it,getMode:()=>st,getVersion:()=>at,invokeCommand:()=>Ze,invokeFunction:()=>Je,invokeSpaceFunction:()=>rt,listCommands:()=>et,listSyscalls:()=>tt,reloadPlugs:()=>ot});function Je(e,...t){return r("system.invokeFunction",e,...t)}function Ze(e,t){return r("system.invokeCommand",e,t)}function et(){return r("system.listCommands")}function tt(){return r("system.listSyscalls")}function rt(e,...t){return r("system.invokeSpaceFunction",e,...t)}function nt(e,t,n){return r("system.applyAttributeExtractors",e,t,n)}function ot(){return r("system.reloadPlugs")}function it(){return r("system.getEnv")}function st(){return r("system.getMode")}function at(){return r("system.getVersion")}var w={};p(w,{hasInitialSyncCompleted:()=>ut,isSyncing:()=>lt,scheduleFileSync:()=>dt,scheduleSpaceSync:()=>pt});function lt(){return r("sync.isSyncing")}function ut(){return r("sync.hasInitialSyncCompleted")}function dt(e){return r("sync.scheduleFileSync",e)}function pt(){return r("sync.scheduleSpaceSync")}var b={};p(b,{parseTemplate:()=>yt,renderTemplate:()=>gt});function gt(e,t,n={}){return r("template.renderTemplate",e,t,n)}function yt(e){return r("template.parseTemplate",e)}var P={};p(P,{parse:()=>bt,stringify:()=>Tt});function bt(e){return r("yaml.parse",e)}function Tt(e){return r("yaml.stringify",e)}function D(e,t){return T(e,n=>n.type===t)}function T(e,t){if(t(e))return[e];let n=[];if(e.children)for(let o of e.children)n=[...n,...T(o,t)];return n}function U(e,t){if(e.children){let n=e.children.slice();for(let o of n){let a=t(o);if(a!==void 0){let s=e.children.indexOf(o);a?e.children.splice(s,1,a):e.children.splice(s,1)}else U(o,t)}}}function k(e,t){return T(e,n=>n.type===t)[0]}function L(e,t){T(e,t)}function K(e){if(!e)return"";let t=[];if(e.text!==void 0)return e.text;for(let n of e.children)t.push(K(n));return t.join("")}async function N(e,t){let n=await c.readPage(e),o=await g.parseMarkdown(n),a;return L(o,s=>{if(s.type!=="FencedCode")return!1;let l=k(s,"CodeInfo");if(t&&!l||t&&!t.includes(l.children[0].text))return!1;let d=k(s,"CodeText");return d?(a=d.children[0].text,!0):!1}),a}async function A(e,t=["yaml"]){let n=await N(e,t);if(n!==void 0)try{return P.parse(n)}catch(o){throw console.error("YAML Page parser error",o),new Error(`YAML Error: ${o.message}`)}}async function W(e){try{let n=(await A("SECRETS",["yaml","secrets"]))[e];if(n===void 0)throw new Error(`No such secret: ${e}`);return n}catch(t){throw t.message==="Not found"?new Error(`No such secret: ${e}`):t}}var Ft="SETTINGS";async function O(e,t){try{let o=(await A(Ft,["yaml"])||{})[e];return o===void 0?t:o}catch(n){if(n.message==="Not found")return t;throw n}}var v=class{constructor(t){this.prefix=t}async listFiles(){return(await c.listFiles()).filter(t=>t.name.startsWith(this.prefix)).map(t=>({...t,name:t.name.slice(this.prefix.length)}))}readFile(t){return c.readFile(this.prefix+t)}getFileMeta(t){return c.getFileMeta(this.prefix+t)}writeFile(t,n){return c.writeFile(this.prefix+t,n)}deleteFile(t){return c.deleteFile(this.prefix+t)}};var F=class{constructor(t,n){this.url=t;this.token=n}authenticatedFetch(t,n){return nativeFetch(t,{...n,headers:{...n?.headers,"X-Sync-Mode":"true",Authorization:`Bearer ${this.token}`}})}async listFiles(){let t=await this.authenticatedFetch(`${this.url}/index.json`,{method:"GET"});if(t.status===404)throw new Error("Not found");return t.json()}async readFile(t){let n=await this.authenticatedFetch(`${this.url}/${t}`,{method:"GET"});return new Uint8Array(await n.arrayBuffer())}async getFileMeta(t){let n=await this.authenticatedFetch(`${this.url}/${t}`,{method:"GET",headers:{"X-Get-Meta":"true"}});return this.headersToFileMeta(t,n.headers)}async writeFile(t,n){let o=await this.authenticatedFetch(`${this.url}/${t}`,{method:"PUT",body:n});if(o.ok)return this.headersToFileMeta(t,o.headers);throw new Error(`Failed to write file: ${await o.text()}`)}async deleteFile(t){let n=await this.authenticatedFetch(`${this.url}/${t}`,{method:"DELETE"});if(!n.ok)throw new Error(`Failed to delete file: ${t}: ${await n.text()}`)}headersToFileMeta(t,n){return{name:t,contentType:n.get("Content-Type")||"application/octet-stream",perm:n.get("X-Perm"),created:+(n.get("X-Created")||"0"),lastModified:+(n.get("X-Last-Modified")||"0"),size:n.has("X-Content-Length")?+n.get("X-Content-Length"):+n.get("Content-Length")}}};var G={removeHashtags:!0,template:"!pub.silverbullet.md/template/page",destPrefix:"_public/"};async function q(e,t,n,o,a,s,l){console.log("Writing",t);let d=await c.readPage(t),f=await g.parseMarkdown(d),h=await Mt(f,s,a,t),i=St(f);for(let u of i)try{let S=await c.readAttachment(u);console.log("Writing",u),await e.writeFile(u,S)}catch(S){console.error("Error reading attachment",u,S.message)}await e.writeFile(o,new TextEncoder().encode(h)),await e.writeFile(n,new TextEncoder().encode(await b.renderTemplate(l,{pageName:t,config:s,isIndex:t===s.indexPage,body:await y.invokeFunction("markdown.markdownToHtml",h,{smartHardBreak:!0,attachmentUrlPrefix:"/"})},{})))}async function $(){let e=G;try{let i=await O("publish",{});if(e={...G,...i},e.publishServer)try{let u=await W("publish");e.publishToken=u.token}catch(u){console.error("No publish secret found",u)}}catch(i){console.warn("No SETTINGS page found, using defaults",i.message)}let t=e.destPrefix;if(e.publishServer&&!e.publishToken)throw new Error("publishServer specified, but no matching 'token' under 'publish' found in SECRETS");let n=e.publishServer?new F(e.publishServer,e.publishToken):new v(t);console.log("Publishing to",n);let o=await y.invokeFunction("index.queryObjects","page",{});console.log("All pages",o);let a=new Map(o.map(i=>[i.name,i]));a.delete("SECRETS"),console.log("Cleaning up destination directory");let s=[];try{s=await n.listFiles()}catch(i){i.message==="Not found"&&console.log("Could not fetch file list from remote, assume it has not been initialized yet")}for(let i of s)await n.deleteFile(i.name);o=[...a.values()];let l=new Set;if(e.publishAll)l=new Set(o.map(i=>i.name));else for(let i of o){if(e.tags&&i.tags)for(let u of i.tags)e.tags.includes(u)&&l.add(i.name);if(typeof i.name=="string"){if(e.prefixes)for(let u of e.prefixes)i.name.startsWith(u)&&l.add(i.name);i.$share&&(Array.isArray(i.$share)||(i.$share=[i.$share]),i.$share.includes("pub")&&l.add(i.name))}}console.log("Publishing",[...l]);let d=await N(e.template),f=[...l];for(let i of f)await q(n,i,`${i}/index.html`,`${i}.md`,f,e,d);console.log("Done writing published paegs"),e.indexPage&&(console.log("Writing index page",e.indexPage),await q(n,e.indexPage,"index.html","index.md",f,e,d)),console.log("Publishing index.json");let h=[];for(let i of await n.listFiles())i.contentType!=="text/html"&&h.push({...i,perm:"ro"});await n.writeFile("index.json",new TextEncoder().encode(JSON.stringify(h,null,2)))}async function I(){await m.flashNotification("Publishing..."),await $(),await w.scheduleSpaceSync(),await m.flashNotification("Done!")}function St(e){let t=[];return D(e,"URL").forEach(n=>{let o=n.children[0].text;o.indexOf("://")===-1&&t.push(o)}),t}async function Mt(e,t,n,o){try{e=await y.invokeFunction("markdown.expandCodeWidgets",e,o)}catch(a){console.error("Error expanding code widgets in page",o,a.message)}return U(e,a=>{if(a.type==="WikiLink"){let s=a.children[1].children[0].text;if(s.includes("@")&&(s=s.split("@")[0]),s.startsWith("!"))return{text:`[${s.split("/").pop()}](https://${s.slice(1)})`};if(!n.includes(s)&&!s.startsWith("!"))return{text:`_${s}_`}}if(a.type==="CommentBlock"||a.type==="Comment"||a.type==="Hashtag"&&t.removeHashtags)return null}),K(e).trim()}var H={publishAll:$,publishAllCommand:I},j={name:"pub",functions:{publishAll:{path:"./publish.ts:publishAll"},publishAllCommand:{path:"./publish.ts:publishAllCommand",command:{name:"Pub: Publish All"}}},assets:{}},Tr={manifest:j,functionMapping:H};R(H,j);export{Tr as plug}; 2 | -------------------------------------------------------------------------------- /pub.plug.yaml: -------------------------------------------------------------------------------- 1 | name: pub 2 | functions: 3 | # For use from the CLI 4 | publishAll: 5 | path: "./publish.ts:publishAll" 6 | 7 | # For use from the UI 8 | publishAllCommand: 9 | path: "./publish.ts:publishAllCommand" 10 | command: 11 | name: "Pub: Publish All" 12 | -------------------------------------------------------------------------------- /publish.ts: -------------------------------------------------------------------------------- 1 | import { 2 | editor, 3 | markdown, 4 | space, 5 | sync, 6 | system, 7 | template, 8 | } from "$sb/syscalls.ts"; 9 | import { readCodeBlockPage } from "$sb/lib/yaml_page.ts"; 10 | import { readSecret } from "$sb/lib/secrets_page.ts"; 11 | import { readSetting } from "$sb/lib/settings_page.ts"; 12 | 13 | import { 14 | collectNodesOfType, 15 | ParseTree, 16 | renderToText, 17 | replaceNodesMatching, 18 | } from "$sb/lib/tree.ts"; 19 | import { FileMeta, PageMeta } from "$sb/types.ts"; 20 | import { SpaceFilesystem } from "./space_fs.ts"; 21 | import { HttpFilesystem } from "./http_fs.ts"; 22 | import { Filesystem } from "./fs.ts"; 23 | 24 | type PublishConfig = { 25 | title?: string; 26 | indexPage?: string; 27 | removeHashtags?: boolean; 28 | publishAll?: boolean; 29 | destPrefix?: string; 30 | publishServer?: string; 31 | publishToken?: string; 32 | tags?: string[]; 33 | prefixes?: string[]; 34 | template?: string; 35 | }; 36 | 37 | const defaultPublishConfig: PublishConfig = { 38 | removeHashtags: true, 39 | template: "!pub.silverbullet.md/template/page", 40 | destPrefix: "_public/", 41 | }; 42 | 43 | async function generatePage( 44 | fs: Filesystem, 45 | pageName: string, 46 | htmlPath: string, 47 | mdPath: string, 48 | publishedPages: string[], 49 | publishConfig: PublishConfig, 50 | htmlTemplate: string, 51 | ) { 52 | console.log("Writing", pageName); 53 | const text = await space.readPage(pageName); 54 | const mdTree = await markdown.parseMarkdown(text); 55 | const publishMd = await processMarkdown( 56 | mdTree, 57 | publishConfig, 58 | publishedPages, 59 | pageName, 60 | ); 61 | const attachments = collectAttachments(mdTree); 62 | for (const attachment of attachments) { 63 | try { 64 | const attachmentData = await space.readAttachment(attachment); 65 | console.log("Writing", attachment); 66 | await fs.writeFile(attachment, attachmentData); 67 | } catch (e: any) { 68 | console.error("Error reading attachment", attachment, e.message); 69 | } 70 | } 71 | // Write .md file 72 | await fs.writeFile(mdPath, new TextEncoder().encode(publishMd)); 73 | // Write .html file 74 | await fs.writeFile( 75 | htmlPath, 76 | new TextEncoder().encode( 77 | await template.renderTemplate(htmlTemplate, { 78 | pageName, 79 | config: publishConfig, 80 | isIndex: pageName === publishConfig.indexPage, 81 | body: await system.invokeFunction( 82 | "markdown.markdownToHtml", 83 | publishMd, 84 | { 85 | smartHardBreak: true, 86 | attachmentUrlPrefix: "/", 87 | }, 88 | ), 89 | }, {}), 90 | ), 91 | ); 92 | } 93 | 94 | export async function publishAll() { 95 | let publishConfig = defaultPublishConfig; 96 | try { 97 | const loadedPublishConfig: PublishConfig = await readSetting("publish", {}); 98 | publishConfig = { 99 | ...defaultPublishConfig, 100 | ...loadedPublishConfig, 101 | }; 102 | if (publishConfig.publishServer) { 103 | try { 104 | const publishSecrets = await readSecret("publish"); 105 | publishConfig.publishToken = publishSecrets.token; 106 | } catch (e) { 107 | console.error("No publish secret found", e); 108 | } 109 | } 110 | } catch (e: any) { 111 | console.warn("No SETTINGS page found, using defaults", e.message); 112 | } 113 | const destPrefix = publishConfig.destPrefix!; 114 | 115 | if (publishConfig.publishServer && !publishConfig.publishToken) { 116 | throw new Error( 117 | "publishServer specified, but no matching 'token' under 'publish' found in SECRETS", 118 | ); 119 | } 120 | 121 | const fs = publishConfig.publishServer 122 | ? new HttpFilesystem( 123 | publishConfig.publishServer, 124 | publishConfig.publishToken!, 125 | ) 126 | : new SpaceFilesystem(destPrefix); 127 | 128 | console.log("Publishing to", fs); 129 | let allPages: PageMeta[] = await system.invokeFunction( 130 | "index.queryObjects", 131 | "page", 132 | {}, 133 | ); 134 | console.log("All pages", allPages); 135 | const allPageMap: Map = new Map( 136 | allPages.map((pm) => [pm.name, pm]), 137 | ); 138 | 139 | allPageMap.delete("SECRETS"); 140 | 141 | console.log("Cleaning up destination directory"); 142 | let allFiles: FileMeta[] = []; 143 | try { 144 | allFiles = await fs.listFiles(); 145 | } catch (e: any) { 146 | if (e.message === "Not found") { 147 | console.log( 148 | "Could not fetch file list from remote, assume it has not been initialized yet", 149 | ); 150 | } 151 | } 152 | for (const existingFile of allFiles) { 153 | await fs.deleteFile(existingFile.name); 154 | } 155 | 156 | allPages = [...allPageMap.values()]; 157 | let publishedPages = new Set(); 158 | if (publishConfig.publishAll) { 159 | publishedPages = new Set(allPages.map((p) => p.name)); 160 | } else { 161 | for (const page of allPages) { 162 | if (publishConfig.tags && page.tags) { 163 | for (const tag of page.tags) { 164 | if (publishConfig.tags.includes(tag)) { 165 | publishedPages.add(page.name); 166 | } 167 | } 168 | } 169 | // Some sanity checking 170 | if (typeof page.name !== "string") { 171 | continue; 172 | } 173 | if (publishConfig.prefixes) { 174 | for (const prefix of publishConfig.prefixes) { 175 | if (page.name.startsWith(prefix)) { 176 | publishedPages.add(page.name); 177 | } 178 | } 179 | } 180 | 181 | if (page.$share) { 182 | if (!Array.isArray(page.$share)) { 183 | page.$share = [page.$share]; 184 | } 185 | if (page.$share.includes("pub")) { 186 | publishedPages.add(page.name); 187 | } 188 | } 189 | } 190 | } 191 | console.log("Publishing", [...publishedPages]); 192 | 193 | const pageTemplate = await readCodeBlockPage(publishConfig.template!); 194 | 195 | const publishedPagesArray = [...publishedPages]; 196 | for (const page of publishedPagesArray) { 197 | await generatePage( 198 | fs, 199 | page, 200 | `${page}/index.html`, 201 | `${page}.md`, 202 | publishedPagesArray, 203 | publishConfig, 204 | pageTemplate!, 205 | ); 206 | } 207 | 208 | console.log("Done writing published paegs"); 209 | 210 | if (publishConfig.indexPage) { 211 | console.log("Writing index page", publishConfig.indexPage); 212 | await generatePage( 213 | fs, 214 | publishConfig.indexPage, 215 | `index.html`, 216 | `index.md`, 217 | publishedPagesArray, 218 | publishConfig, 219 | pageTemplate!, 220 | ); 221 | } 222 | 223 | console.log("Publishing index.json"); 224 | const publishedFiles: FileMeta[] = []; 225 | for ( 226 | const fileMeta of await fs 227 | .listFiles() 228 | ) { 229 | if (fileMeta.contentType === "text/html") { 230 | // Skip the generated HTML files 231 | continue; 232 | } 233 | publishedFiles.push({ 234 | ...fileMeta, 235 | perm: "ro", 236 | }); 237 | } 238 | await fs.writeFile( 239 | `index.json`, 240 | new TextEncoder().encode( 241 | JSON.stringify(publishedFiles, null, 2), 242 | ), 243 | ); 244 | } 245 | 246 | export async function publishAllCommand() { 247 | await editor.flashNotification("Publishing..."); 248 | await publishAll(); 249 | await sync.scheduleSpaceSync(); 250 | await editor.flashNotification("Done!"); 251 | } 252 | 253 | function collectAttachments(tree: ParseTree) { 254 | const attachments: string[] = []; 255 | collectNodesOfType(tree, "URL").forEach((node) => { 256 | const url = node.children![0].text!; 257 | if (url.indexOf("://") === -1) { 258 | attachments.push(url); 259 | } 260 | }); 261 | return attachments; 262 | } 263 | 264 | async function processMarkdown( 265 | mdTree: ParseTree, 266 | publishConfig: PublishConfig, 267 | validPages: string[], 268 | pageName: string, 269 | ): Promise { 270 | // Use markdown plug's logic to expand code widgets 271 | try { 272 | mdTree = await system.invokeFunction( 273 | "markdown.expandCodeWidgets", 274 | mdTree, 275 | pageName, 276 | ); 277 | } catch (e: any) { 278 | console.error("Error expanding code widgets in page", pageName, e.message); 279 | } 280 | 281 | replaceNodesMatching(mdTree, (n) => { 282 | if (n.type === "WikiLink") { 283 | let page = n.children![1].children![0].text!; 284 | if (page.includes("@")) { 285 | page = page.split("@")[0]; 286 | } 287 | if (page.startsWith("!")) { 288 | const lastBit = page.split("/").pop(); 289 | return { 290 | text: `[${lastBit}](https://${page.slice(1)})`, 291 | }; 292 | } 293 | if (!validPages.includes(page) && !page.startsWith("!")) { 294 | // Replace with just page text 295 | return { 296 | text: `_${page}_`, 297 | }; 298 | } 299 | } 300 | 301 | // Simply get rid of these 302 | if (n.type === "CommentBlock" || n.type === "Comment") { 303 | return null; 304 | } 305 | if (n.type === "Hashtag") { 306 | if (publishConfig.removeHashtags) { 307 | return null; 308 | } 309 | } 310 | }); 311 | return renderToText(mdTree).trim(); 312 | } 313 | -------------------------------------------------------------------------------- /silverbullet-pub-server.ts: -------------------------------------------------------------------------------- 1 | import { HttpServer } from "./http_server.ts"; 2 | 3 | import type { SpacePrimitives } from "$silverbullet/common/spaces/space_primitives.ts"; 4 | import { DiskSpacePrimitives } from "$silverbullet/common/spaces/disk_space_primitives.ts"; 5 | import { ChunkedKvStoreSpacePrimitives } from "$silverbullet/common/spaces/chunked_datastore_space_primitives.ts"; 6 | import { DenoKvPrimitives } from "$silverbullet/lib/data/deno_kv_primitives.ts"; 7 | import { Command } from "./deps.ts"; 8 | 9 | await new Command() 10 | .name("silverbullet-pub") 11 | .description("SilverBullet Pub Server") 12 | .help({ 13 | colors: false, 14 | }) 15 | .usage(" ") 16 | // Main command 17 | .arguments("[folder:string]") 18 | .option( 19 | "--hostname, -L ", 20 | "Hostname or address to listen on", 21 | ) 22 | .option("-p, --port ", "Port to listen on") 23 | .option( 24 | "--token ", 25 | "Token", 26 | ) 27 | .action(async (options, folder) => { 28 | const hostname = options.hostname || Deno.env.get("SB_HOSTNAME") || 29 | "127.0.0.1"; 30 | const port = options.port || 31 | (Deno.env.get("SB_PORT") && +Deno.env.get("SB_PORT")!) || 8000; 32 | const token = options.token || Deno.env.get("SB_TOKEN"); 33 | if (!token) { 34 | console.error( 35 | "No token specified. Please pass a --token flag, or set SB_TOKEN environment variable.", 36 | ); 37 | Deno.exit(1); 38 | } 39 | 40 | let spacePrimitives: SpacePrimitives | undefined; 41 | if (!folder) { 42 | folder = Deno.env.get("SB_FOLDER"); 43 | } 44 | if (folder) { 45 | spacePrimitives = new DiskSpacePrimitives(folder); 46 | } else { 47 | let dbFile: string | undefined = Deno.env.get("SB_DB_FILE") || "pub.db"; 48 | if (Deno.env.get("DENO_DEPLOYMENT_ID") !== undefined) { // We're running in Deno Deploy 49 | dbFile = undefined; // Deno Deploy will use the default KV store 50 | } 51 | console.info( 52 | "No folder specified. Using Deno KV mode. Storing data in", 53 | dbFile ? dbFile : "the default KV store", 54 | ); 55 | const kv = new DenoKvPrimitives(await Deno.openKv(dbFile)); 56 | 57 | spacePrimitives = new ChunkedKvStoreSpacePrimitives(kv, 65536); 58 | } 59 | 60 | console.log( 61 | "Going to start SilverBullet Pub Server binding to", 62 | `${hostname}:${port}`, 63 | ); 64 | // folder = path.resolve(Deno.cwd(), folder); 65 | 66 | const httpServer = new HttpServer(spacePrimitives, { 67 | hostname, 68 | port, 69 | token, 70 | pagesPath: folder || "kv://", 71 | }); 72 | httpServer.start(); 73 | }) 74 | .parse(Deno.args); 75 | -------------------------------------------------------------------------------- /space_fs.ts: -------------------------------------------------------------------------------- 1 | import { space } from "$sb/syscalls.ts"; 2 | import { FileMeta } from "$sb/types.ts"; 3 | import { Filesystem } from "./fs.ts"; 4 | 5 | export class SpaceFilesystem implements Filesystem { 6 | constructor(private prefix: string) {} 7 | async listFiles(): Promise { 8 | return (await space.listFiles()).filter((f) => 9 | f.name.startsWith(this.prefix) 10 | ).map((f) => ({ 11 | ...f, 12 | name: f.name.slice(this.prefix.length), 13 | })); 14 | } 15 | readFile(path: string): Promise { 16 | return space.readFile(this.prefix + path); 17 | } 18 | getFileMeta(path: string): Promise { 19 | return space.getFileMeta(this.prefix + path); 20 | } 21 | writeFile(path: string, data: Uint8Array): Promise { 22 | return space.writeFile(this.prefix + path, data); 23 | } 24 | deleteFile(path: string): Promise { 25 | return space.deleteFile(this.prefix + path); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /template/page.md: -------------------------------------------------------------------------------- 1 | ```html 2 | 3 | 4 | 5 | 6 | 7 | 8 | {{#if !isIndex}}{{pageName}} — {{config.title}}{{else}}{{config.title}}{{/if}} 9 | 64 | 65 | 66 | 67 | {{#if !isIndex}} 68 |

{{pageName}}

69 | {{/if}} 70 | {{body}} 71 | 72 | 73 | 74 | ``` 75 | --------------------------------------------------------------------------------