benjivers/notes Public Public: anyone can read this; nobody can change it.
A linked-notes vault: markdown, [[wikilinks]], backlinks, tags, graph
Notes are titled markdown pages, private to their author (contract: every note verb is the row's author's; admins no longer reach other vaults). Server-rendered three-pane workspace with autosave, edit/split/read views, [[ completion, a quick switcher, daily notes, rename-relinking, search, a force-directed graph, export, MCP tools and a terminal. One markdown renderer (static/md.js) serves both the first paint and the live preview. Live events are now addressed to their owner only. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
27 files
changed +4417
−615
README.md +55 −63
| @@ -1,25 +1,63 @@ | ||
| 1 | 1 | # notes |
| 2 | 2 | |
| 3 | A personal linked-notes vault — markdown, [[wikilinks]], backlinks, tags, graph. | |
| 3 | A personal linked-notes vault, in the spirit of Obsidian — markdown, | |
| 4 | `[[wikilinks]]`, backlinks, tags and a graph — kept on the forge. | |
| 4 | 5 | |
| 5 | This is an app: the forge runs it. Production follows the default branch; | |
| 6 | every open pull gets a preview at its own address with its own copy of the | |
| 7 | data. Everything the app keeps lives under `FORGE_APP_DATA`, which is what | |
| 8 | makes "production data" and "preview data" two different things. | |
| 6 | ## Using it | |
| 7 | ||
| 8 | Open the app and sign in with your forge account. Your notes are **yours | |
| 9 | alone**: the contract gives every verb on a note to its author, so nobody | |
| 10 | else who can open the app — admins included — can list, read or change them. | |
| 11 | ||
| 12 | - **Write** in markdown. It saves as you type (and with ⌘S). Edit, Split | |
| 13 | (side by side) and Read views; ⌘E flips between reading and writing. | |
| 14 | - **Link** with `[[Another note]]`, `[[Note|shown text]]` or | |
| 15 | `[[Note#Heading]]`. Typing `[[` suggests titles. A link to a note not | |
| 16 | written yet is drawn dashed; following it creates the note. | |
| 17 | - **Backlinks**: the right panel shows every note that links here, with the | |
| 18 | line it said it in, plus links out, the outline, tags and a local graph. | |
| 19 | - **Rename** a note and every link to it in your vault is rewritten. | |
| 20 | - **Tags**: `#tag`, nested as `#area/topic`. Click one to see every note | |
| 21 | carrying it (`#area` also finds `#area/topic`). | |
| 22 | - **Search**: words, `"a phrase"`, `#tag` or `tag:name`, combined. | |
| 23 | - **Graph**: every note and link, force-laid-out. Drag, zoom, click to open. | |
| 24 | - **Daily note**: the calendar button (⌘⇧D) opens today's `YYYY-MM-DD` note. | |
| 25 | - **Quick switcher**: ⌘K / Ctrl K — jump to a note, or ⇧↵ to create one. | |
| 26 | - Editor keys: ⌘B bold, ⌘I italic, ⌘⇧H highlight, ⌘↵ cycles a line | |
| 27 | through task / done / plain, Enter continues a list, Tab indents it. | |
| 28 | - Markdown: headings, emphasis, `==highlight==`, `~~strike~~`, code, | |
| 29 | fenced blocks, lists, `- [ ]` tasks (tick them in the reading view), | |
| 30 | quotes, Obsidian callouts (`> [!tip] Title`), tables, links. | |
| 31 | - **Your data**: every note downloads as `.md`; the sidebar's download | |
| 32 | button exports the whole vault as JSON. | |
| 33 | ||
| 34 | Everything is real: notes live in SQLite under `FORGE_APP_DATA`. A preview | |
| 35 | of a pull gets a copy of production data; production is the default branch. | |
| 36 | ||
| 37 | ## Claude can use it too | |
| 38 | ||
| 39 | `POST /mcp` is an MCP server with `list_notes`, `search_notes`, | |
| 40 | `read_note`, `create_note`, `update_note`, `append_to_note` (with no | |
| 41 | title: today's daily note) and `delete_note` — as whoever the forge grant | |
| 42 | belongs to: | |
| 9 | 43 | |
| 10 | 44 | sh |
| 11 | PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts # run it here | |
| 12 | bun test # what `forge.json`'s app.test runs on every push | |
| 45 | claude mcp add --transport http notes https://notes--forge-apps.benjivers.com/mcp | |
| 46 | ||
| 47 | ||
| 48 | The terminal (`FORGE_APP_SSH`) lists, reads and searches the vault, adds a | |
| 49 | line to today's note (`a`) and makes new notes (`n`). | |
| 50 | ||
| 51 | ## Running it here | |
| 52 | ||
| 53 | sh | |
| 54 | PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts # signed out without FORGE_APP_IDENTITY_KEY | |
| 55 | bun test # what forge.json's app.test runs on every push | |
| 13 | 56 | FORGE_APP_DATA=./data bun run src/tty.ts # the terminal |
| 14 | FORGE_APP_DATA=./data bun run src/db.ts --check # schema version, integrity | |
| 15 | 57 | bun run lint:design # no colour but the tokens' |
| 16 | 58 | bun run contract:emit # rewrite contract/openapi.json from the routes |
| 17 | 59 | |
| 18 | 60 | |
| 19 | `app.test` in `forge.json` runs beside `app.run` in every deploy, not before | |
| 20 | it: the preview comes up the moment the app answers, and says `live`, or | |
| 21 | `serving · tests failed` the moment the suite disagrees. | |
| 22 | ||
| 23 | 61 | ## The shape |
| 24 | 62 | |
| 25 | 63 | One implementation, many doors. `src/actions.ts` is every operation as a |
| @@ -29,7 +67,9 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 29 | 67 | |
| 30 | 68 | | file | what it is | |
| 31 | 69 | | ---------------------------------- | -------------------------------------------------------------- | |
| 32 | | `src/actions.ts` | list, add, delete; the audit trail, read | | |
| 70 | | `src/actions.ts` | notes: list, add, rename (relinks), pin, delete, search; audit | | |
| 71 | | `src/vault.ts` | links, backlinks, tags, graph, search — pure, over notes | | |
| 72 | | `static/md.js` | the markdown renderer, shared by the server and the browser | | |
| 33 | 73 | | `contract.json` | who may do what: roles, and the least role each verb needs | |
| 34 | 74 | | `src/contract.ts`, `src/can.ts` | the table, read at boot; the forge's evaluator, verbatim | |
| 35 | 75 | | `src/security.ts` | the door: rationing, same origin, the table before the body | |
| @@ -46,7 +86,8 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 46 | 86 | | `src/server.ts` | boot: open the database, serve | |
| 47 | 87 | | `src/tty.ts` | the terminal, over the same actions | |
| 48 | 88 | | `src/env.ts` | what the forge told this process | |
| 49 | | `static/app.css`, `static/live.js` | the look in the tokens' words, and the list that stays current | | |
| 89 | | `static/app.css`, `static/live.js` | the look in the tokens' words, and the panels that stay current | | |
| 90 | | `static/app.js`, `static/graph.js` | the editor, switcher and keys; the force-directed graph | | |
| 50 | 91 | |
| 51 | 92 | ## What every app stands on |
| 52 | 93 | |
| @@ -72,52 +113,3 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 72 | 113 | entirely; `/.well-known/security.txt` sends a reporter to the forge |
| 73 | 114 | that runs the app. |
| 74 | 115 | |
| 75 | ## Who you are | |
| 76 | ||
| 77 | The forge says who is asking on every request it proxies, and the app | |
| 78 | verifies the signature (`src/who.ts`); nothing else is trusted. Three tiers: | |
| 79 | ||
| 80 | - **anon** — anyone. Reads. | |
| 81 | - **user** — signed in to the forge and able to see this app. Adds notes, as | |
| 82 | themselves, and removes their own. | |
| 83 | - **admin** — able to write this repository. Removes any note. | |
| 84 | ||
| 85 | "Sign in" is `/__forge/signin?to=<path>` and "Sign out" is | |
| 86 | `/__forge/signout`; the forge answers both on the app's own host. | |
| 87 | ||
| 88 | ## Any interface | |
| 89 | ||
| 90 | The same actions, the same rules, at every door. A script is the person | |
| 91 | whose forge grant it carries; a bare script is anonymous. | |
| 92 | ||
| 93 | sh | |
| 94 | curl https://<app>/api/notes | |
| 95 | curl -X POST https://<app>/api/notes -H 'content-type: application/json' -d '{"body":"hi"}' | |
| 96 | curl -X DELETE https://<app>/api/notes/1 | |
| 97 | ||
| 98 | ||
| 99 | As an MCP server, for Claude Code or Claude Desktop — `list_notes`, | |
| 100 | `add_note`, `delete_note`: | |
| 101 | ||
| 102 | sh | |
| 103 | claude mcp add --transport http __TITLE__ https://<app>/mcp | |
| 104 | ||
| 105 | ||
| 106 | json | |
| 107 | { | |
| 108 | "mcpServers": { "__TITLE__": { "type": "http", "url": "https://<app>/mcp" } } | |
| 109 | } | |
| 110 | ||
| 111 | ||
| 112 | The page stays current over `/events`: a note someone else adds or removes | |
| 113 | appears without a reload. Without JavaScript it is the same page, one | |
| 114 | reload behind. | |
| 115 | ||
| 116 | The terminal opens with the exact command in `FORGE_APP_SSH` when the app | |
| 117 | runs (`ssh -p 2222 <name>@<forge>` — the app is the login name, the forge | |
| 118 | the host; `<name>--<pull>@<forge>` for a preview's). It is `src/tty.ts` on | |
| 119 | a real pty over the same data, as the account at the keyboard. | |
| 120 | ||
| 121 | The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` (`production` or | |
| 122 | `preview`), `FORGE_APP_URL`, `FORGE_APP_COMMIT`, `FORGE_APP_SSH` and | |
| 123 | `FORGE_APP_IDENTITY_KEY` when it starts the app. `forge.json` says how. |
contract.json +3 −3
| @@ -3,17 +3,17 @@ | ||
| 3 | 3 | "anon": { "above": [], "held": { "by": "everyone" } }, |
| 4 | 4 | "user": { "above": ["anon"], "held": { "by": "identity", "role": "user" } }, |
| 5 | 5 | "author": { "above": ["user"], "held": { "by": "row", "column": "author_id" } }, |
| 6 | "admin": { "above": ["author"], "held": { "by": "identity", "role": "admin" } } | |
| 6 | "admin": { "above": ["user"], "held": { "by": "identity", "role": "admin" } } | |
| 7 | 7 | }, |
| 8 | 8 | "resources": { |
| 9 | 9 | "note": { |
| 10 | 10 | "api": "/api/notes", |
| 11 | 11 | "can": { |
| 12 | 12 | "create": "user", |
| 13 | "read": "anon", | |
| 13 | "read": "author", | |
| 14 | 14 | "update": "author", |
| 15 | 15 | "delete": "author", |
| 16 | "list": "anon" | |
| 16 | "list": "author" | |
| 17 | 17 | } |
| 18 | 18 | }, |
| 19 | 19 | "audit": { |
contract/openapi.json +113 −28
| @@ -2,13 +2,13 @@ | ||
| 2 | 2 | "openapi": "3.1.0", |
| 3 | 3 | "info": { |
| 4 | 4 | "title": "notes", |
| 5 | "version": "0.1.0", | |
| 5 | "version": "1.0.0", | |
| 6 | 6 | "description": "A personal linked-notes vault — markdown, [[wikilinks]], backlinks, tags, graph." |
| 7 | 7 | }, |
| 8 | 8 | "paths": { |
| 9 | 9 | "/api/notes": { |
| 10 | 10 | "get": { |
| 11 | "summary": "The notes, newest first.", | |
| 11 | "summary": "Your notes, pinned first then most recently changed; with `q`, the ones a search finds (words, \"a phrase\", #tag), best first.", | |
| 12 | 12 | "operationId": "getApiNotes", |
| 13 | 13 | "parameters": [ |
| 14 | 14 | { |
| @@ -17,14 +17,21 @@ | ||
| 17 | 17 | "schema": { |
| 18 | 18 | "type": "integer", |
| 19 | 19 | "minimum": 1, |
| 20 | "maximum": 500, | |
| 21 | "default": 100 | |
| 20 | "maximum": 5000 | |
| 21 | } | |
| 22 | }, | |
| 23 | { | |
| 24 | "name": "q", | |
| 25 | "in": "query", | |
| 26 | "schema": { | |
| 27 | "type": "string", | |
| 28 | "maxLength": 500 | |
| 22 | 29 | } |
| 23 | 30 | } |
| 24 | 31 | ], |
| 25 | 32 | "responses": { |
| 26 | 33 | "200": { |
| 27 | "description": "The notes, newest first.", | |
| 34 | "description": "Your notes, pinned first then most recently changed; with `q`, the ones a search finds (words, \"a phrase\", #tag), best first.", | |
| 28 | 35 | "content": { |
| 29 | 36 | "application/json": { |
| 30 | 37 | "schema": { |
| @@ -50,11 +57,11 @@ | ||
| 50 | 57 | "x-contract": { |
| 51 | 58 | "resource": "note", |
| 52 | 59 | "verb": "list", |
| 53 | "least": "anon" | |
| 60 | "least": "author" | |
| 54 | 61 | } |
| 55 | 62 | }, |
| 56 | 63 | "post": { |
| 57 | "summary": "Add a note as the person asking.", | |
| 64 | "summary": "Add a note to your vault.", | |
| 58 | 65 | "operationId": "postApiNotes", |
| 59 | 66 | "requestBody": { |
| 60 | 67 | "required": true, |
| @@ -63,22 +70,24 @@ | ||
| 63 | 70 | "schema": { |
| 64 | 71 | "type": "object", |
| 65 | 72 | "properties": { |
| 73 | "title": { | |
| 74 | "type": "string", | |
| 75 | "maxLength": 200, | |
| 76 | "description": "Unique in your vault; “Untitled” when left out." | |
| 77 | }, | |
| 66 | 78 | "body": { |
| 67 | 79 | "type": "string", |
| 68 | "minLength": 1, | |
| 69 | "maxLength": 500 | |
| 80 | "maxLength": 200000, | |
| 81 | "description": "Markdown." | |
| 70 | 82 | } |
| 71 | }, | |
| 72 | "required": [ | |
| 73 | "body" | |
| 74 | ] | |
| 83 | } | |
| 75 | 84 | } |
| 76 | 85 | } |
| 77 | 86 | } |
| 78 | 87 | }, |
| 79 | 88 | "responses": { |
| 80 | 89 | "201": { |
| 81 | "description": "Add a note as the person asking.", | |
| 90 | "description": "Add a note to your vault.", | |
| 82 | 91 | "content": { |
| 83 | 92 | "application/json": { |
| 84 | 93 | "schema": { |
| @@ -107,7 +116,7 @@ | ||
| 107 | 116 | }, |
| 108 | 117 | "/api/notes/{id}": { |
| 109 | 118 | "get": { |
| 110 | "summary": "One note.", | |
| 119 | "summary": "One of your notes.", | |
| 111 | 120 | "operationId": "getApiNotesId", |
| 112 | 121 | "parameters": [ |
| 113 | 122 | { |
| @@ -121,7 +130,7 @@ | ||
| 121 | 130 | ], |
| 122 | 131 | "responses": { |
| 123 | 132 | "200": { |
| 124 | "description": "One note.", | |
| 133 | "description": "One of your notes.", | |
| 125 | 134 | "content": { |
| 126 | 135 | "application/json": { |
| 127 | 136 | "schema": { |
| @@ -144,11 +153,11 @@ | ||
| 144 | 153 | "x-contract": { |
| 145 | 154 | "resource": "note", |
| 146 | 155 | "verb": "read", |
| 147 | "least": "anon" | |
| 156 | "least": "author" | |
| 148 | 157 | } |
| 149 | 158 | }, |
| 150 | 159 | "patch": { |
| 151 | "summary": "Change a note's words.", | |
| 160 | "summary": "Change a note's title, words or pin; a rename rewrites the links to it. `relinked` says how many notes that touched.", | |
| 152 | 161 | "operationId": "patchApiNotesId", |
| 153 | 162 | "parameters": [ |
| 154 | 163 | { |
| @@ -167,26 +176,90 @@ | ||
| 167 | 176 | "schema": { |
| 168 | 177 | "type": "object", |
| 169 | 178 | "properties": { |
| 170 | "body": { | |
| 179 | "title": { | |
| 171 | 180 | "type": "string", |
| 172 | 181 | "minLength": 1, |
| 173 | "maxLength": 500 | |
| 182 | "maxLength": 200, | |
| 183 | "description": "A rename carries every [[link]] to the note with it." | |
| 184 | }, | |
| 185 | "body": { | |
| 186 | "type": "string", | |
| 187 | "maxLength": 200000 | |
| 188 | }, | |
| 189 | "pinned": { | |
| 190 | "type": "boolean" | |
| 174 | 191 | } |
| 175 | }, | |
| 176 | "required": [ | |
| 177 | "body" | |
| 178 | ] | |
| 192 | } | |
| 179 | 193 | } |
| 180 | 194 | } |
| 181 | 195 | } |
| 182 | 196 | }, |
| 183 | 197 | "responses": { |
| 184 | 198 | "200": { |
| 185 | "description": "Change a note's words.", | |
| 199 | "description": "Change a note's title, words or pin; a rename rewrites the links to it. `relinked` says how many notes that touched.", | |
| 186 | 200 | "content": { |
| 187 | 201 | "application/json": { |
| 188 | 202 | "schema": { |
| 189 | "$ref": "#/components/schemas/Note" | |
| 203 | "allOf": [ | |
| 204 | { | |
| 205 | "type": "object", | |
| 206 | "properties": { | |
| 207 | "id": { | |
| 208 | "type": "integer" | |
| 209 | }, | |
| 210 | "title": { | |
| 211 | "type": "string", | |
| 212 | "minLength": 1, | |
| 213 | "maxLength": 200 | |
| 214 | }, | |
| 215 | "body": { | |
| 216 | "type": "string", | |
| 217 | "maxLength": 200000 | |
| 218 | }, | |
| 219 | "pinned": { | |
| 220 | "enum": [ | |
| 221 | 0, | |
| 222 | 1 | |
| 223 | ] | |
| 224 | }, | |
| 225 | "author_id": { | |
| 226 | "type": "string" | |
| 227 | }, | |
| 228 | "author_name": { | |
| 229 | "type": "string" | |
| 230 | }, | |
| 231 | "created_at": { | |
| 232 | "type": "string", | |
| 233 | "format": "date-time" | |
| 234 | }, | |
| 235 | "updated_at": { | |
| 236 | "type": "string", | |
| 237 | "format": "date-time" | |
| 238 | } | |
| 239 | }, | |
| 240 | "required": [ | |
| 241 | "id", | |
| 242 | "title", | |
| 243 | "body", | |
| 244 | "pinned", | |
| 245 | "author_id", | |
| 246 | "author_name", | |
| 247 | "created_at", | |
| 248 | "updated_at" | |
| 249 | ] | |
| 250 | }, | |
| 251 | { | |
| 252 | "type": "object", | |
| 253 | "properties": { | |
| 254 | "relinked": { | |
| 255 | "type": "integer" | |
| 256 | } | |
| 257 | }, | |
| 258 | "required": [ | |
| 259 | "relinked" | |
| 260 | ] | |
| 261 | } | |
| 262 | ] | |
| 190 | 263 | } |
| 191 | 264 | } |
| 192 | 265 | } |
| @@ -469,10 +542,20 @@ | ||
| 469 | 542 | "id": { |
| 470 | 543 | "type": "integer" |
| 471 | 544 | }, |
| 472 | "body": { | |
| 545 | "title": { | |
| 473 | 546 | "type": "string", |
| 474 | 547 | "minLength": 1, |
| 475 | "maxLength": 500 | |
| 548 | "maxLength": 200 | |
| 549 | }, | |
| 550 | "body": { | |
| 551 | "type": "string", | |
| 552 | "maxLength": 200000 | |
| 553 | }, | |
| 554 | "pinned": { | |
| 555 | "enum": [ | |
| 556 | 0, | |
| 557 | 1 | |
| 558 | ] | |
| 476 | 559 | }, |
| 477 | 560 | "author_id": { |
| 478 | 561 | "type": "string" |
| @@ -491,7 +574,9 @@ | ||
| 491 | 574 | }, |
| 492 | 575 | "required": [ |
| 493 | 576 | "id", |
| 577 | "title", | |
| 494 | 578 | "body", |
| 579 | "pinned", | |
| 495 | 580 | "author_id", |
| 496 | 581 | "author_name", |
| 497 | 582 | "created_at", |
design/tokens.json +272 −35
| @@ -3,93 +3,330 @@ | ||
| 3 | 3 | "color": { |
| 4 | 4 | "$type": "color", |
| 5 | 5 | "bg": { |
| 6 | "$value": "#f6f6f3", | |
| 7 | "$extensions": { "mode": { "dark": "#131312" } } | |
| 6 | "$value": "#ffffff", | |
| 7 | "$extensions": { | |
| 8 | "mode": { | |
| 9 | "dark": "#1e1e1e" | |
| 10 | } | |
| 11 | }, | |
| 12 | "$description": "The page and the note." | |
| 8 | 13 | }, |
| 9 | 14 | "surface": { |
| 10 | "$value": "#ffffff", | |
| 11 | "$extensions": { "mode": { "dark": "#1d1d1b" } } | |
| 15 | "$value": "#f6f6f6", | |
| 16 | "$extensions": { | |
| 17 | "mode": { | |
| 18 | "dark": "#262626" | |
| 19 | } | |
| 20 | }, | |
| 21 | "$description": "The sidebars." | |
| 12 | 22 | }, |
| 13 | 23 | "fg": { |
| 14 | "$value": "#1c1c1a", | |
| 15 | "$extensions": { "mode": { "dark": "#ecece7" } } | |
| 24 | "$value": "#222222", | |
| 25 | "$extensions": { | |
| 26 | "mode": { | |
| 27 | "dark": "#dadada" | |
| 28 | } | |
| 29 | } | |
| 16 | 30 | }, |
| 17 | 31 | "muted": { |
| 18 | "$value": "#6b6b65", | |
| 19 | "$extensions": { "mode": { "dark": "#9a9a93" } } | |
| 32 | "$value": "#5e5e5e", | |
| 33 | "$extensions": { | |
| 34 | "mode": { | |
| 35 | "dark": "#a3a3a3" | |
| 36 | } | |
| 37 | } | |
| 20 | 38 | }, |
| 21 | 39 | "line": { |
| 22 | "$value": "#e3e3dd", | |
| 23 | "$extensions": { "mode": { "dark": "#2c2c29" } } | |
| 40 | "$value": "#e3e3e3", | |
| 41 | "$extensions": { | |
| 42 | "mode": { | |
| 43 | "dark": "#363636" | |
| 44 | } | |
| 45 | } | |
| 46 | }, | |
| 47 | "hover": { | |
| 48 | "$value": "#ececec", | |
| 49 | "$extensions": { | |
| 50 | "mode": { | |
| 51 | "dark": "#2f2f2f" | |
| 52 | } | |
| 53 | } | |
| 54 | }, | |
| 55 | "active": { | |
| 56 | "$value": "#e9e3fc", | |
| 57 | "$extensions": { | |
| 58 | "mode": { | |
| 59 | "dark": "#332e47" | |
| 60 | } | |
| 61 | }, | |
| 62 | "$description": "The open note in the list." | |
| 24 | 63 | }, |
| 25 | 64 | "accent": { |
| 26 | "$value": "#2f5bea", | |
| 27 | "$extensions": { "mode": { "dark": "#7c96ff" } } | |
| 65 | "$value": "#6741d9", | |
| 66 | "$extensions": { | |
| 67 | "mode": { | |
| 68 | "dark": "#a88bfa" | |
| 69 | } | |
| 70 | } | |
| 28 | 71 | }, |
| 29 | 72 | "accent-fg": { |
| 30 | 73 | "$value": "#ffffff", |
| 31 | "$extensions": { "mode": { "dark": "#0f1a4d" } } | |
| 74 | "$extensions": { | |
| 75 | "mode": { | |
| 76 | "dark": "#1a1033" | |
| 77 | } | |
| 78 | } | |
| 32 | 79 | }, |
| 33 | 80 | "danger": { |
| 34 | "$value": "#c2410c", | |
| 35 | "$extensions": { "mode": { "dark": "#fb923c" } } | |
| 81 | "$value": "#b42318", | |
| 82 | "$extensions": { | |
| 83 | "mode": { | |
| 84 | "dark": "#f87171" | |
| 85 | } | |
| 86 | } | |
| 87 | }, | |
| 88 | "mark-bg": { | |
| 89 | "$value": "#fff3a3", | |
| 90 | "$extensions": { | |
| 91 | "mode": { | |
| 92 | "dark": "#5e5017" | |
| 93 | } | |
| 94 | }, | |
| 95 | "$description": "==Highlights==." | |
| 96 | }, | |
| 97 | "code-bg": { | |
| 98 | "$value": "#f1f1f1", | |
| 99 | "$extensions": { | |
| 100 | "mode": { | |
| 101 | "dark": "#2b2b2b" | |
| 102 | } | |
| 103 | } | |
| 104 | }, | |
| 105 | "code-fg": { | |
| 106 | "$value": "#a1246f", | |
| 107 | "$extensions": { | |
| 108 | "mode": { | |
| 109 | "dark": "#f2a5cf" | |
| 110 | } | |
| 111 | } | |
| 112 | }, | |
| 113 | "tag-bg": { | |
| 114 | "$value": "#ede8fd", | |
| 115 | "$extensions": { | |
| 116 | "mode": { | |
| 117 | "dark": "#2f2a45" | |
| 118 | } | |
| 119 | } | |
| 120 | }, | |
| 121 | "tag-fg": { | |
| 122 | "$value": "#5130b8", | |
| 123 | "$extensions": { | |
| 124 | "mode": { | |
| 125 | "dark": "#c9b8ff" | |
| 126 | } | |
| 127 | } | |
| 128 | }, | |
| 129 | "link-missing": { | |
| 130 | "$value": "#7a5dc7", | |
| 131 | "$extensions": { | |
| 132 | "mode": { | |
| 133 | "dark": "#8f7bc4" | |
| 134 | } | |
| 135 | }, | |
| 136 | "$description": "A [[link]] to a note not written yet." | |
| 137 | }, | |
| 138 | "graph-node": { | |
| 139 | "$value": "#8f8f8f", | |
| 140 | "$extensions": { | |
| 141 | "mode": { | |
| 142 | "dark": "#8c8c8c" | |
| 143 | } | |
| 144 | } | |
| 145 | }, | |
| 146 | "graph-edge": { | |
| 147 | "$value": "#d2d2d2", | |
| 148 | "$extensions": { | |
| 149 | "mode": { | |
| 150 | "dark": "#474747" | |
| 151 | } | |
| 152 | } | |
| 153 | }, | |
| 154 | "callout-info": { | |
| 155 | "$value": "#1d5fd1", | |
| 156 | "$extensions": { | |
| 157 | "mode": { | |
| 158 | "dark": "#6aa8ff" | |
| 159 | } | |
| 160 | } | |
| 161 | }, | |
| 162 | "callout-tip": { | |
| 163 | "$value": "#0b7350", | |
| 164 | "$extensions": { | |
| 165 | "mode": { | |
| 166 | "dark": "#3ed1a0" | |
| 167 | } | |
| 168 | } | |
| 169 | }, | |
| 170 | "callout-warning": { | |
| 171 | "$value": "#9a5300", | |
| 172 | "$extensions": { | |
| 173 | "mode": { | |
| 174 | "dark": "#f7b84b" | |
| 175 | } | |
| 176 | } | |
| 177 | }, | |
| 178 | "callout-danger": { | |
| 179 | "$value": "#c2262e", | |
| 180 | "$extensions": { | |
| 181 | "mode": { | |
| 182 | "dark": "#ff7a7a" | |
| 183 | } | |
| 184 | } | |
| 36 | 185 | }, |
| 37 | 186 | "toast-bg": { |
| 38 | "$value": "#1c1c1a", | |
| 39 | "$extensions": { "mode": { "dark": "#ecece7" } } | |
| 187 | "$value": "#222222", | |
| 188 | "$extensions": { | |
| 189 | "mode": { | |
| 190 | "dark": "#dadada" | |
| 191 | } | |
| 192 | } | |
| 40 | 193 | }, |
| 41 | 194 | "toast-fg": { |
| 42 | "$value": "#f6f6f3", | |
| 43 | "$extensions": { "mode": { "dark": "#131312" } } | |
| 195 | "$value": "#ffffff", | |
| 196 | "$extensions": { | |
| 197 | "mode": { | |
| 198 | "dark": "#1e1e1e" | |
| 199 | } | |
| 200 | } | |
| 44 | 201 | }, |
| 45 | 202 | "shadow": { |
| 46 | 203 | "$value": "#0000002e", |
| 47 | "$extensions": { "mode": { "dark": "#00000080" } } | |
| 204 | "$extensions": { | |
| 205 | "mode": { | |
| 206 | "dark": "#00000099" | |
| 207 | } | |
| 208 | } | |
| 209 | }, | |
| 210 | "scrim": { | |
| 211 | "$value": "#00000052", | |
| 212 | "$extensions": { | |
| 213 | "mode": { | |
| 214 | "dark": "#000000a3" | |
| 215 | } | |
| 216 | } | |
| 48 | 217 | } |
| 49 | 218 | }, |
| 50 | 219 | "space": { |
| 51 | 220 | "$type": "dimension", |
| 52 | "1": { "$value": "0.25rem" }, | |
| 53 | "2": { "$value": "0.5rem" }, | |
| 54 | "3": { "$value": "0.75rem" }, | |
| 55 | "4": { "$value": "1rem" }, | |
| 56 | "5": { "$value": "1.5rem" }, | |
| 57 | "6": { "$value": "2.5rem" } | |
| 221 | "1": { | |
| 222 | "$value": "0.25rem" | |
| 223 | }, | |
| 224 | "2": { | |
| 225 | "$value": "0.5rem" | |
| 226 | }, | |
| 227 | "3": { | |
| 228 | "$value": "0.75rem" | |
| 229 | }, | |
| 230 | "4": { | |
| 231 | "$value": "1rem" | |
| 232 | }, | |
| 233 | "5": { | |
| 234 | "$value": "1.5rem" | |
| 235 | }, | |
| 236 | "6": { | |
| 237 | "$value": "2.5rem" | |
| 238 | } | |
| 58 | 239 | }, |
| 59 | 240 | "radius": { |
| 60 | 241 | "$type": "dimension", |
| 61 | "md": { "$value": "10px" }, | |
| 62 | "pill": { "$value": "999px" } | |
| 242 | "sm": { | |
| 243 | "$value": "6px" | |
| 244 | }, | |
| 245 | "md": { | |
| 246 | "$value": "10px" | |
| 247 | }, | |
| 248 | "lg": { | |
| 249 | "$value": "14px" | |
| 250 | }, | |
| 251 | "pill": { | |
| 252 | "$value": "999px" | |
| 253 | } | |
| 63 | 254 | }, |
| 64 | 255 | "size": { |
| 65 | 256 | "$type": "dimension", |
| 66 | 257 | "tap": { |
| 67 | 258 | "$value": "44px", |
| 68 | 259 | "$description": "The least a control measures on a side." |
| 260 | }, | |
| 261 | "left": { | |
| 262 | "$value": "17rem", | |
| 263 | "$description": "The notes sidebar." | |
| 264 | }, | |
| 265 | "right": { | |
| 266 | "$value": "18rem", | |
| 267 | "$description": "The links sidebar." | |
| 268 | }, | |
| 269 | "read": { | |
| 270 | "$value": "46rem", | |
| 271 | "$description": "The widest a line of a note runs." | |
| 69 | 272 | } |
| 70 | 273 | }, |
| 71 | 274 | "font": { |
| 72 | 275 | "$type": "fontFamily", |
| 73 | 276 | "sans": { |
| 74 | "$value": ["system-ui", "-apple-system", "Segoe UI", "sans-serif"] | |
| 277 | "$value": [ | |
| 278 | "system-ui", | |
| 279 | "-apple-system", | |
| 280 | "Segoe UI", | |
| 281 | "sans-serif" | |
| 282 | ] | |
| 75 | 283 | }, |
| 76 | 284 | "mono": { |
| 77 | "$value": ["ui-monospace", "SFMono-Regular", "Menlo", "monospace"] | |
| 285 | "$value": [ | |
| 286 | "ui-monospace", | |
| 287 | "SFMono-Regular", | |
| 288 | "Menlo", | |
| 289 | "monospace" | |
| 290 | ] | |
| 291 | }, | |
| 292 | "serif": { | |
| 293 | "$value": [ | |
| 294 | "Iowan Old Style", | |
| 295 | "Palatino Linotype", | |
| 296 | "Georgia", | |
| 297 | "serif" | |
| 298 | ] | |
| 78 | 299 | } |
| 79 | 300 | }, |
| 80 | 301 | "text": { |
| 81 | 302 | "$type": "dimension", |
| 82 | "sm": { "$value": "0.875rem" }, | |
| 303 | "sm": { | |
| 304 | "$value": "0.875rem" | |
| 305 | }, | |
| 83 | 306 | "base": { |
| 84 | 307 | "$value": "1rem", |
| 85 | 308 | "$description": "Never below 16px: a phone zooms into a smaller input." |
| 86 | 309 | }, |
| 87 | "lg": { "$value": "1.125rem" }, | |
| 88 | "xl": { "$value": "1.5rem" } | |
| 310 | "lg": { | |
| 311 | "$value": "1.125rem" | |
| 312 | }, | |
| 313 | "xl": { | |
| 314 | "$value": "1.5rem" | |
| 315 | }, | |
| 316 | "xs": { | |
| 317 | "$value": "0.75rem" | |
| 318 | }, | |
| 319 | "2xl": { | |
| 320 | "$value": "2rem" | |
| 321 | } | |
| 89 | 322 | }, |
| 90 | 323 | "leading": { |
| 91 | 324 | "$type": "number", |
| 92 | "body": { "$value": 1.55 }, | |
| 93 | "tight": { "$value": 1.2 } | |
| 325 | "body": { | |
| 326 | "$value": 1.55 | |
| 327 | }, | |
| 328 | "tight": { | |
| 329 | "$value": 1.2 | |
| 330 | } | |
| 94 | 331 | } |
| 95 | 332 | } |
package.json +1 −1
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "notes", |
| 3 | "version": "0.1.0", | |
| 3 | "version": "1.0.0", | |
| 4 | 4 | "description": "A personal linked-notes vault — markdown, [[wikilinks]], backlinks, tags, graph.", |
| 5 | 5 | "private": true, |
| 6 | 6 | "type": "module", |
src/actions.test.ts +116 −33
| @@ -2,7 +2,19 @@ import { expect, test } from "bun:test"; | ||
| 2 | 2 | import { mkdtempSync } from "node:fs"; |
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | import { addNote, deleteNote, listNotes } from "./actions.ts"; | |
| 5 | import { | |
| 6 | addNote, | |
| 7 | BODY_MAX, | |
| 8 | cleanTitle, | |
| 9 | deleteNote, | |
| 10 | findNote, | |
| 11 | getNote, | |
| 12 | listNotes, | |
| 13 | openOrCreate, | |
| 14 | searchNotes, | |
| 15 | today, | |
| 16 | updateNote, | |
| 17 | } from "./actions.ts"; | |
| 6 | 18 | import { openDb } from "./db.ts"; |
| 7 | 19 | import type { Who } from "./who.ts"; |
| 8 | 20 | |
| @@ -12,47 +24,118 @@ const alice: Who = { role: "user", id: "u1", name: "alice" }; | ||
| 12 | 24 | const bob: Who = { role: "user", id: "u2", name: "bob" }; |
| 13 | 25 | const root: Who = { role: "admin", id: "u0", name: "root" }; |
| 14 | 26 | |
| 15 | test("anyone reads; only a signed-in person adds, as themselves", () => { | |
| 16 | expect(listNotes(db, anon)).toEqual({ ok: true, value: [] }); | |
| 17 | const refused = addNote(db, anon, { body: "hello" }); | |
| 27 | const must = <T>(r: { ok: true; value: T } | { ok: false; message: string }): T => { | |
| 28 | if (!r.ok) throw new Error(r.message); | |
| 29 | return r.value; | |
| 30 | }; | |
| 31 | ||
| 32 | test("a vault is its author's: nobody else lists or reads it, admins included", () => { | |
| 33 | const refused = addNote(db, anon, { title: "x" }); | |
| 18 | 34 | expect(refused.ok).toBe(false); |
| 19 | 35 | if (!refused.ok) expect(refused.error).toBe("signin_required"); |
| 20 | const added = addNote(db, alice, { body: " hello " }); | |
| 21 | expect(added.ok).toBe(true); | |
| 22 | if (!added.ok) return; | |
| 23 | expect(added.value.body).toBe("hello"); | |
| 24 | expect(added.value.author_id).toBe("u1"); | |
| 25 | expect(added.value.author_name).toBe("alice"); | |
| 26 | const all = listNotes(db, anon); | |
| 27 | expect(all.ok && all.value.length).toBe(1); | |
| 28 | }); | |
| 29 | ||
| 30 | test("a note is one to five hundred characters", () => { | |
| 31 | for (const body of ["", " ", "x".repeat(501)]) { | |
| 32 | const r = addNote(db, alice, { body }); | |
| 33 | expect(r.ok).toBe(false); | |
| 34 | if (!r.ok) expect(r.error).toBe("invalid"); | |
| 36 | const mine = must(addNote(db, alice, { title: " Secret plans ", body: "the plan" })); | |
| 37 | expect(mine.title).toBe("Secret plans"); | |
| 38 | expect(mine.author_name).toBe("alice"); | |
| 39 | expect(must(listNotes(db, alice)).map((n) => n.id)).toContain(mine.id); | |
| 40 | expect(must(listNotes(db, bob))).toEqual([]); | |
| 41 | expect(must(listNotes(db, root))).toEqual([]); | |
| 42 | for (const who of [bob, root]) { | |
| 43 | const read = getNote(db, who, { id: mine.id }); | |
| 44 | expect(read.ok).toBe(false); | |
| 45 | if (!read.ok) expect(read.error).toBe("forbidden"); | |
| 46 | expect(updateNote(db, who, { id: mine.id, body: "mine now" }).ok).toBe(false); | |
| 47 | expect(deleteNote(db, who, { id: mine.id }).ok).toBe(false); | |
| 35 | 48 | } |
| 49 | const listed = listNotes(db, anon); | |
| 50 | expect(listed.ok).toBe(false); | |
| 51 | // Two people may each have a note of the same name. | |
| 52 | expect(addNote(db, bob, { title: "secret plans" }).ok).toBe(true); | |
| 36 | 53 | }); |
| 37 | 54 | |
| 38 | test("only the author or an admin removes a note", () => { | |
| 39 | const mine = addNote(db, alice, { body: "mine" }); | |
| 40 | if (!mine.ok) throw new Error(mine.message); | |
| 41 | const byBob = deleteNote(db, bob, { id: mine.value.id }); | |
| 42 | expect(byBob.ok).toBe(false); | |
| 43 | if (!byBob.ok) expect(byBob.error).toBe("forbidden"); | |
| 44 | const byAnon = deleteNote(db, anon, { id: mine.value.id }); | |
| 55 | test("titles are unique in a vault, any case; blank means the next Untitled", () => { | |
| 56 | expect(must(addNote(db, alice, {})).title).toBe("Untitled"); | |
| 57 | expect(must(addNote(db, alice, { title: "" })).title).toBe("Untitled 2"); | |
| 58 | const dup = addNote(db, alice, { title: "SECRET PLANS" }); | |
| 59 | expect(dup.ok).toBe(false); | |
| 60 | if (!dup.ok) expect(dup.error).toBe("invalid"); | |
| 61 | for (const bad of ["a [b]", "x|y", "a#b", "^", "x".repeat(201), " "]) | |
| 62 | expect([bad, cleanTitle(bad).ok]).toEqual([bad, false]); | |
| 63 | expect(cleanTitle("2026-10-02").ok).toBe(true); | |
| 64 | const big = addNote(db, alice, { title: "big", body: "x".repeat(BODY_MAX + 1) }); | |
| 65 | expect(big.ok).toBe(false); | |
| 66 | }); | |
| 67 | ||
| 68 | test("renaming a note carries every link to it, keeping aliases and headings", () => { | |
| 69 | const target = must(addNote(db, alice, { title: "Garden" })); | |
| 70 | const a = must(addNote(db, alice, { title: "Links A", body: "see [[Garden]] and [[garden#Beds|the beds]]" })); | |
| 71 | const b = must(addNote(db, alice, { title: "Links B", body: "no link, just garden" })); | |
| 72 | // Bob's note links a Garden of his own: a rename in alice's vault leaves it be. | |
| 73 | const bobs = must(addNote(db, bob, { title: "Bob's", body: "[[Garden]]" })); | |
| 74 | const renamed = must(updateNote(db, alice, { id: target.id, title: "Allotment" })); | |
| 75 | expect(renamed.title).toBe("Allotment"); | |
| 76 | expect(renamed.relinked).toBe(1); | |
| 77 | expect(must(getNote(db, alice, { id: a.id })).body).toBe( | |
| 78 | "see [[Allotment]] and [[Allotment#Beds|the beds]]", | |
| 79 | ); | |
| 80 | expect(must(getNote(db, alice, { id: b.id })).body).toBe("no link, just garden"); | |
| 81 | expect(must(getNote(db, bob, { id: bobs.id })).body).toBe("[[Garden]]"); | |
| 82 | // A rename onto a title in use is refused, and changes nothing. | |
| 83 | const clash = updateNote(db, alice, { id: target.id, title: "links a" }); | |
| 84 | expect(clash.ok).toBe(false); | |
| 85 | expect(must(getNote(db, alice, { id: target.id })).title).toBe("Allotment"); | |
| 86 | }); | |
| 87 | ||
| 88 | test("pinning floats a note to the top", () => { | |
| 89 | const pinned = must(addNote(db, alice, { title: "Pin me" })); | |
| 90 | must(addNote(db, alice, { title: "Newer" })); | |
| 91 | must(updateNote(db, alice, { id: pinned.id, pinned: true })); | |
| 92 | expect(must(listNotes(db, alice))[0]?.id).toBe(pinned.id); | |
| 93 | must(updateNote(db, alice, { id: pinned.id, pinned: false })); | |
| 94 | expect(must(listNotes(db, alice))[0]?.id).not.toBe(pinned.id); | |
| 95 | }); | |
| 96 | ||
| 97 | test("following a link opens the note, or makes it", () => { | |
| 98 | const made = must(openOrCreate(db, alice, { title: "Reading list" })); | |
| 99 | expect(made.created).toBe(true); | |
| 100 | const again = must(openOrCreate(db, alice, { title: "reading LIST" })); | |
| 101 | expect(again.created).toBe(false); | |
| 102 | expect(again.id).toBe(made.id); | |
| 103 | expect(must(findNote(db, alice, { title: "Reading list" })).id).toBe(made.id); | |
| 104 | expect(findNote(db, bob, { title: "Reading list" }).ok).toBe(false); | |
| 105 | }); | |
| 106 | ||
| 107 | test("search finds words, phrases and tags, in your vault only", () => { | |
| 108 | must(addNote(db, alice, { title: "Recipes", body: "Bread with #cooking/baking\nsour dough starter" })); | |
| 109 | must(addNote(db, alice, { title: "Groceries", body: "flour, #cooking" })); | |
| 110 | must(addNote(db, bob, { title: "Bob recipes", body: "#cooking" })); | |
| 111 | const titles = (q: string) => must(searchNotes(db, alice, { q })).map((h) => h.title); | |
| 112 | expect(titles("#cooking").sort()).toEqual(["Groceries", "Recipes"]); | |
| 113 | expect(titles("tag:cooking/baking")).toEqual(["Recipes"]); | |
| 114 | expect(titles('"sour dough"')).toEqual(["Recipes"]); | |
| 115 | expect(titles("dough flour")).toEqual([]); | |
| 116 | expect(titles("recipes")).toEqual(["Recipes"]); | |
| 117 | expect(must(searchNotes(db, alice, { q: "sour" }))[0]?.snippet).toContain("sour dough"); | |
| 118 | expect(searchNotes(db, anon, { q: "cooking" }).ok).toBe(false); | |
| 119 | }); | |
| 120 | ||
| 121 | test("only the author removes a note", () => { | |
| 122 | const mine = must(addNote(db, alice, { title: "Ephemeral" })); | |
| 123 | const byAnon = deleteNote(db, anon, { id: mine.id }); | |
| 45 | 124 | expect(byAnon.ok).toBe(false); |
| 46 | 125 | if (!byAnon.ok) expect(byAnon.error).toBe("signin_required"); |
| 47 | expect(deleteNote(db, alice, { id: mine.value.id }).ok).toBe(true); | |
| 48 | const gone = deleteNote(db, alice, { id: mine.value.id }); | |
| 126 | expect(deleteNote(db, alice, { id: mine.id }).ok).toBe(true); | |
| 127 | const gone = deleteNote(db, alice, { id: mine.id }); | |
| 49 | 128 | expect(gone.ok).toBe(false); |
| 50 | 129 | if (!gone.ok) expect(gone.error).toBe("not_found"); |
| 51 | ||
| 52 | const bobs = addNote(db, bob, { body: "bob's" }); | |
| 53 | if (!bobs.ok) throw new Error(bobs.message); | |
| 54 | expect(deleteNote(db, root, { id: bobs.value.id }).ok).toBe(true); | |
| 55 | const odd = deleteNote(db, root, { id: 1.5 }); | |
| 130 | const odd = deleteNote(db, alice, { id: 1.5 }); | |
| 56 | 131 | expect(odd.ok).toBe(false); |
| 57 | 132 | if (!odd.ok) expect(odd.error).toBe("invalid"); |
| 58 | 133 | }); |
| 134 | ||
| 135 | test("today is the date where you are", () => { | |
| 136 | const at = new Date("2026-10-02T02:00:00Z"); | |
| 137 | expect(today(0, at)).toBe("2026-10-02"); | |
| 138 | // Chicago in October is UTC-5: getTimezoneOffset() says 300. | |
| 139 | expect(today(300, at)).toBe("2026-10-01"); | |
| 140 | expect(today(-600, at)).toBe("2026-10-02"); | |
| 141 | }); |
src/actions.ts +239 −50
| @@ -10,11 +10,14 @@ import type { Database } from "bun:sqlite"; | ||
| 10 | 10 | import { record, type AuditRow } from "./audit.ts"; |
| 11 | 11 | import { permit } from "./contract.ts"; |
| 12 | 12 | import { emit } from "./live.ts"; |
| 13 | import { relink, search } from "./vault.ts"; | |
| 13 | 14 | import type { Who } from "./who.ts"; |
| 14 | 15 | |
| 15 | 16 | export interface Note { |
| 16 | 17 | readonly id: number; |
| 18 | readonly title: string; | |
| 17 | 19 | readonly body: string; |
| 20 | readonly pinned: number; | |
| 18 | 21 | readonly author_id: string; |
| 19 | 22 | readonly author_name: string; |
| 20 | 23 | readonly created_at: string; |
| @@ -32,7 +35,10 @@ const fail = <T>(error: Failure, message: string): Result<T> => ({ | ||
| 32 | 35 | message, |
| 33 | 36 | }); |
| 34 | 37 | |
| 35 | export const BODY_MAX = 500; | |
| 38 | export const TITLE_MAX = 200; | |
| 39 | export const BODY_MAX = 200_000; | |
| 40 | /** The vault, at most: a list asks for no more than this many. */ | |
| 41 | export const VAULT_MAX = 5000; | |
| 36 | 42 | |
| 37 | 43 | /** The refusal for a "no": who is not signed in is asked to; who is, is told. */ |
| 38 | 44 | const refuse = <T>(who: Who, signIn: string, no: string): Result<T> => |
| @@ -61,61 +67,109 @@ const audited = <T extends { readonly id: number }>( | ||
| 61 | 67 | return result; |
| 62 | 68 | }; |
| 63 | 69 | |
| 70 | /** Whose vault a change belongs to: its live events reach only them. */ | |
| 71 | const tell = (type: string, note: Note): void => { | |
| 72 | emit(type, { id: note.id, title: note.title, updated_at: note.updated_at }, note.author_id); | |
| 73 | }; | |
| 74 | ||
| 75 | /** | |
| 76 | * A title, as the vault keeps it: trimmed, one line, and free of the | |
| 77 | * characters a [[link]] uses to say something else (`[ ] | # ^`). | |
| 78 | */ | |
| 79 | export function cleanTitle(raw: string): Result<string> { | |
| 80 | const title = raw.replace(/\s+/g, " ").trim(); | |
| 81 | if (title.length === 0 || title.length > TITLE_MAX) | |
| 82 | return fail("invalid", `A title is 1 to ${TITLE_MAX} characters.`); | |
| 83 | if (/[\[\]|#^\\]/.test(title)) | |
| 84 | return fail("invalid", "A title cannot hold [ ] | # ^ or \\ — links use them."); | |
| 85 | return ok(title); | |
| 86 | } | |
| 87 | ||
| 88 | const checkBody = (body: string): Result<string> => | |
| 89 | body.length > BODY_MAX | |
| 90 | ? fail("invalid", `A note is at most ${BODY_MAX.toLocaleString("en")} characters.`) | |
| 91 | : ok(body); | |
| 92 | ||
| 93 | /** | |
| 94 | * The vault: the notes of whoever asks, pinned first, then the most | |
| 95 | * recently changed. A vault is its author's — the table gives `list` to the | |
| 96 | * row's author — so the list is always the asker's own notes, and nobody | |
| 97 | * else's, whatever role they hold. | |
| 98 | */ | |
| 64 | 99 | export function listNotes( |
| 65 | 100 | db: Database, |
| 66 | 101 | who: Who, |
| 67 | 102 | input: { limit?: number } = {}, |
| 68 | 103 | ): Result<Note[]> { |
| 69 | 104 | const limit = Math.min( |
| 70 | Math.max(Math.trunc(input.limit ?? 100) || 100, 1), | |
| 71 | 500, | |
| 105 | Math.max(Math.trunc(input.limit ?? VAULT_MAX) || VAULT_MAX, 1), | |
| 106 | VAULT_MAX, | |
| 72 | 107 | ); |
| 73 | // "yes" is every note. When the table gives `list` to a role a row holds | |
| 74 | // (`author`), the answer names the columns instead, and the list is the | |
| 75 | // notes that name whoever asks. The names are the contract's, checked when | |
| 76 | // it was read; the value is bound. | |
| 77 | 108 | const may = permit(who, "note", "list"); |
| 78 | if (may === "no") | |
| 79 | return refuse(who, "Sign in to see notes.", "You may not see these notes."); | |
| 80 | if (may === "yes") | |
| 81 | return ok( | |
| 82 | db | |
| 83 | .query<Note, [number]>("SELECT * FROM note ORDER BY id DESC LIMIT ?1") | |
| 84 | .all(limit), | |
| 85 | ); | |
| 86 | const mine = may.where.map((column) => `${column} = ?2`).join(" OR "); | |
| 109 | if (may === "no" || who.role === "anon") | |
| 110 | return refuse(who, "Sign in to open your notes.", "You may not see these notes."); | |
| 87 | 111 | return ok( |
| 88 | 112 | db |
| 89 | .query< | |
| 90 | Note, | |
| 91 | [number, string] | |
| 92 | >(`SELECT * FROM note WHERE ${mine} ORDER BY id DESC LIMIT ?1`) | |
| 93 | .all(limit, who.role === "anon" ? "" : who.id), | |
| 113 | .query<Note, [string, number]>( | |
| 114 | "SELECT * FROM note WHERE author_id = ?1 ORDER BY pinned DESC, updated_at DESC LIMIT ?2", | |
| 115 | ) | |
| 116 | .all(who.id, limit), | |
| 94 | 117 | ); |
| 95 | 118 | } |
| 96 | 119 | |
| 120 | /** The next free "Untitled", "Untitled 2", … in a vault. */ | |
| 121 | function freeTitle(db: Database, authorId: string, base: string): string { | |
| 122 | const taken = (t: string) => | |
| 123 | db | |
| 124 | .query<{ id: number }, [string, string]>( | |
| 125 | "SELECT id FROM note WHERE author_id = ?1 AND title = ?2 COLLATE NOCASE", | |
| 126 | ) | |
| 127 | .get(authorId, t) !== null; | |
| 128 | if (!taken(base)) return base; | |
| 129 | for (let n = 2; ; n++) if (!taken(`${base} ${n}`)) return `${base} ${n}`; | |
| 130 | } | |
| 131 | ||
| 132 | const titleTaken = (db: Database, authorId: string, title: string, except = -1): boolean => | |
| 133 | db | |
| 134 | .query<{ id: number }, [string, string, number]>( | |
| 135 | "SELECT id FROM note WHERE author_id = ?1 AND title = ?2 COLLATE NOCASE AND id != ?3", | |
| 136 | ) | |
| 137 | .get(authorId, title, except) !== null; | |
| 138 | ||
| 97 | 139 | export const addNote = ( |
| 98 | 140 | db: Database, |
| 99 | 141 | who: Who, |
| 100 | input: { body: string }, | |
| 142 | input: { title?: string; body?: string }, | |
| 101 | 143 | ): Result<Note> => |
| 102 | 144 | audited(db, who, "note", "create", undefined, add(db, who, input)); |
| 103 | 145 | |
| 104 | function add(db: Database, who: Who, input: { body: string }): Result<Note> { | |
| 146 | function add( | |
| 147 | db: Database, | |
| 148 | who: Who, | |
| 149 | input: { title?: string; body?: string }, | |
| 150 | ): Result<Note> { | |
| 105 | 151 | // The table decides; the type only learns it here: whoever may create is |
| 106 | 152 | // signed in, and carries a name to write beside the note. |
| 107 | 153 | if (who.role === "anon" || permit(who, "note", "create") !== "yes") |
| 108 | 154 | return refuse(who, "Sign in to add a note.", "You may not add a note."); |
| 109 | const body = input.body.trim(); | |
| 110 | if (body.length === 0 || body.length > BODY_MAX) | |
| 111 | return fail("invalid", `A note is 1 to ${BODY_MAX} characters.`); | |
| 155 | const body = checkBody(input.body ?? ""); | |
| 156 | if (!body.ok) return body; | |
| 157 | let title: string; | |
| 158 | if (input.title === undefined || input.title.trim() === "") | |
| 159 | title = freeTitle(db, who.id, "Untitled"); | |
| 160 | else { | |
| 161 | const clean = cleanTitle(input.title); | |
| 162 | if (!clean.ok) return clean; | |
| 163 | if (titleTaken(db, who.id, clean.value)) | |
| 164 | return fail("invalid", `A note called “${clean.value}” already exists.`); | |
| 165 | title = clean.value; | |
| 166 | } | |
| 112 | 167 | const note = db |
| 113 | .query< | |
| 114 | Note, | |
| 115 | [string, string, string] | |
| 116 | >("INSERT INTO note (body, author_id, author_name) VALUES (?1, ?2, ?3) RETURNING *") | |
| 117 | .get(body, who.id, who.name)!; | |
| 118 | emit("note.added", note); | |
| 168 | .query<Note, [string, string, string, string]>( | |
| 169 | "INSERT INTO note (title, body, author_id, author_name) VALUES (?1, ?2, ?3, ?4) RETURNING *", | |
| 170 | ) | |
| 171 | .get(title, body.value, who.id, who.name)!; | |
| 172 | tell("note.added", note); | |
| 119 | 173 | return ok(note); |
| 120 | 174 | } |
| 121 | 175 | |
| @@ -158,34 +212,111 @@ export function getNote( | ||
| 158 | 212 | }); |
| 159 | 213 | } |
| 160 | 214 | |
| 215 | /** A note in the asker's vault by its title, any case. */ | |
| 216 | export function findNote( | |
| 217 | db: Database, | |
| 218 | who: Who, | |
| 219 | input: { title: string }, | |
| 220 | ): Result<Note> { | |
| 221 | if (who.role === "anon") | |
| 222 | return fail("signin_required", "Sign in to open your notes."); | |
| 223 | const title = input.title.replace(/\s+/g, " ").trim(); | |
| 224 | const note = db | |
| 225 | .query<Note, [string, string]>( | |
| 226 | "SELECT * FROM note WHERE author_id = ?1 AND title = ?2 COLLATE NOCASE", | |
| 227 | ) | |
| 228 | .get(who.id, title); | |
| 229 | if (note === null) return fail("not_found", `No note called “${title}”.`); | |
| 230 | return getNote(db, who, { id: note.id }); | |
| 231 | } | |
| 232 | ||
| 233 | /** The note with this title, made (empty, or from `body`) when there is none yet: following a link, opening today. */ | |
| 234 | export function openOrCreate( | |
| 235 | db: Database, | |
| 236 | who: Who, | |
| 237 | input: { title: string; body?: string }, | |
| 238 | ): Result<Note & { created: boolean }> { | |
| 239 | const found = findNote(db, who, input); | |
| 240 | if (found.ok) return ok({ ...found.value, created: false }); | |
| 241 | if (found.error !== "not_found") return found; | |
| 242 | const made = addNote(db, who, { title: input.title, body: input.body ?? "" }); | |
| 243 | return made.ok ? ok({ ...made.value, created: true }) : made; | |
| 244 | } | |
| 245 | ||
| 246 | export interface Change { | |
| 247 | readonly id: number; | |
| 248 | readonly title?: string; | |
| 249 | readonly body?: string; | |
| 250 | readonly pinned?: boolean; | |
| 251 | } | |
| 252 | ||
| 161 | 253 | export const updateNote = ( |
| 162 | 254 | db: Database, |
| 163 | 255 | who: Who, |
| 164 | input: { id: number; body: string }, | |
| 165 | ): Result<Note> => | |
| 256 | input: Change, | |
| 257 | ): Result<Note & { relinked: number }> => | |
| 166 | 258 | audited(db, who, "note", "update", input.id, update(db, who, input)); |
| 167 | 259 | |
| 260 | /** | |
| 261 | * Change a note's title, words or pin. A new title is carried into every | |
| 262 | * note of the vault that linked to the old one, in the same transaction, | |
| 263 | * so a rename never leaves a link pointing at nothing. | |
| 264 | */ | |
| 168 | 265 | function update( |
| 169 | 266 | db: Database, |
| 170 | 267 | who: Who, |
| 171 | input: { id: number; body: string }, | |
| 172 | ): Result<Note> { | |
| 268 | input: Change, | |
| 269 | ): Result<Note & { relinked: number }> { | |
| 173 | 270 | const found = noteFor(db, who, "update", input.id, { |
| 174 | 271 | signIn: "Sign in to edit a note.", |
| 175 | no: "Only the author or an admin can edit a note.", | |
| 272 | no: "Only the author can edit a note.", | |
| 176 | 273 | }); |
| 177 | 274 | if (!found.ok) return found; |
| 178 | const body = input.body.trim(); | |
| 179 | if (body.length === 0 || body.length > BODY_MAX) | |
| 180 | return fail("invalid", `A note is 1 to ${BODY_MAX} characters.`); | |
| 181 | const note = db | |
| 182 | .query< | |
| 183 | Note, | |
| 184 | [string, number] | |
| 185 | >("UPDATE note SET body = ?1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?2 RETURNING *") | |
| 186 | .get(body, found.value.id)!; | |
| 187 | emit("note.changed", note); | |
| 188 | return ok(note); | |
| 275 | const before = found.value; | |
| 276 | let title = before.title; | |
| 277 | if (input.title !== undefined) { | |
| 278 | const clean = cleanTitle(input.title); | |
| 279 | if (!clean.ok) return clean; | |
| 280 | if (titleTaken(db, before.author_id, clean.value, before.id)) | |
| 281 | return fail("invalid", `A note called “${clean.value}” already exists.`); | |
| 282 | title = clean.value; | |
| 283 | } | |
| 284 | let body = before.body; | |
| 285 | if (input.body !== undefined) { | |
| 286 | const checked = checkBody(input.body); | |
| 287 | if (!checked.ok) return checked; | |
| 288 | body = checked.value; | |
| 289 | } | |
| 290 | const pinned = input.pinned === undefined ? before.pinned : input.pinned ? 1 : 0; | |
| 291 | if (title === before.title && body === before.body && pinned === before.pinned) | |
| 292 | return ok({ ...before, relinked: 0 }); | |
| 293 | const renamed = title.toLowerCase() !== before.title.toLowerCase(); | |
| 294 | const touched: Note[] = []; | |
| 295 | const note = db.transaction(() => { | |
| 296 | const saved = db | |
| 297 | .query<Note, [string, string, number, number]>( | |
| 298 | "UPDATE note SET title = ?1, body = ?2, pinned = ?3, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?4 RETURNING *", | |
| 299 | ) | |
| 300 | .get(title, body, pinned, before.id)!; | |
| 301 | if (renamed || title !== before.title) | |
| 302 | for (const other of db | |
| 303 | .query<Note, [string, number]>("SELECT * FROM note WHERE author_id = ?1 AND id != ?2") | |
| 304 | .all(before.author_id, before.id)) { | |
| 305 | const next = relink(other.body, before.title, title); | |
| 306 | if (next === other.body) continue; | |
| 307 | touched.push( | |
| 308 | db | |
| 309 | .query<Note, [string, number]>( | |
| 310 | "UPDATE note SET body = ?1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?2 RETURNING *", | |
| 311 | ) | |
| 312 | .get(next, other.id)!, | |
| 313 | ); | |
| 314 | } | |
| 315 | return saved; | |
| 316 | })(); | |
| 317 | tell("note.changed", note); | |
| 318 | for (const other of touched) tell("note.changed", other); | |
| 319 | return ok({ ...note, relinked: touched.length }); | |
| 189 | 320 | } |
| 190 | 321 | |
| 191 | 322 | export const deleteNote = ( |
| @@ -202,18 +333,76 @@ function remove( | ||
| 202 | 333 | ): Result<{ id: number }> { |
| 203 | 334 | const found = noteFor(db, who, "delete", input.id, { |
| 204 | 335 | signIn: "Sign in to remove a note.", |
| 205 | no: "Only the author or an admin can remove a note.", | |
| 336 | no: "Only the author can remove a note.", | |
| 206 | 337 | }); |
| 207 | 338 | if (!found.ok) return found; |
| 208 | 339 | db.query("DELETE FROM note WHERE id = ?1").run(found.value.id); |
| 209 | emit("note.removed", { id: found.value.id }); | |
| 340 | emit("note.removed", { id: found.value.id }, found.value.author_id); | |
| 210 | 341 | return ok({ id: found.value.id }); |
| 211 | 342 | } |
| 212 | 343 | |
| 344 | /** Search the asker's vault (see `vault.search` for the language). */ | |
| 345 | export function searchNotes( | |
| 346 | db: Database, | |
| 347 | who: Who, | |
| 348 | input: { q: string; limit?: number }, | |
| 349 | ): Result<{ id: number; title: string; snippet: string; updated_at: string }[]> { | |
| 350 | const vault = listNotes(db, who); | |
| 351 | if (!vault.ok) return vault; | |
| 352 | const limit = Math.min(Math.max(Math.trunc(input.limit ?? 50) || 50, 1), 500); | |
| 353 | return ok( | |
| 354 | search(vault.value, input.q.slice(0, 500)) | |
| 355 | .slice(0, limit) | |
| 356 | .map(({ note, snippet }) => ({ | |
| 357 | id: note.id, | |
| 358 | title: note.title, | |
| 359 | snippet, | |
| 360 | updated_at: note.updated_at, | |
| 361 | })), | |
| 362 | ); | |
| 363 | } | |
| 364 | ||
| 365 | /** Today's daily note title, `YYYY-MM-DD`, in the given time zone offset (minutes, as `Date#getTimezoneOffset`). */ | |
| 366 | export const today = (offsetMinutes = 0, now = new Date()): string => | |
| 367 | new Date(now.getTime() - offsetMinutes * 60_000).toISOString().slice(0, 10); | |
| 368 | ||
| 213 | 369 | /** Whether `who` may remove `note`: the table's answer, for the page and the terminal to draw from. */ |
| 214 | 370 | export const canDelete = (who: Who, note: Note): boolean => |
| 215 | 371 | permit(who, "note", "delete", { ...note }) === "yes"; |
| 216 | 372 | |
| 373 | /** A welcome note: what the vault can do, written in what it can do. */ | |
| 374 | export const WELCOME = `# Welcome to your vault | |
| 375 | ||
| 376 | This is a note. Every note is **markdown**, saved as you type, and private to you. | |
| 377 | ||
| 378 | ## Linking thoughts | |
| 379 | Write [[double brackets]] around a title to link notes — that one doesn't exist yet, so following it creates it. Links work both ways: open a note and its **backlinks** show everything pointing at it. Rename a note and every link to it follows. | |
| 380 | ||
| 381 | Try: [[Ideas]] · [[Reading list]] · [[Ideas|an alias]] | |
| 382 | ||
| 383 | ## Tags | |
| 384 | Add #tags anywhere. Nest them like #projects/notes. Click one to see every note carrying it. | |
| 385 | ||
| 386 | ## The rest of markdown | |
| 387 | - ==highlights==, ~~strikes~~, \`code\`, *emphasis* | |
| 388 | - [x] tasks you've done | |
| 389 | - [ ] tasks you haven't | |
| 390 | ||
| 391 | > [!tip] Keys | |
| 392 | > **⌘K** / **Ctrl K** — jump to any note, or create one | |
| 393 | > **⌘E** / **Ctrl E** — switch between editing and reading | |
| 394 | > **⌘⇧D** / **Ctrl ⇧ D** — today's daily note | |
| 395 | > **⌘G** / **Ctrl G** — the graph | |
| 396 | ||
| 397 | | Where | What | | |
| 398 | | --- | --- | | |
| 399 | | Left | your notes, pinned first, and your tags | | |
| 400 | | Middle | the note | | |
| 401 | | Right | outline, links and backlinks | | |
| 402 | ||
| 403 | Open the **graph** to see how everything connects. | |
| 404 | `; | |
| 405 | ||
| 217 | 406 | /** |
| 218 | 407 | * The audit trail, newest first, for whoever the table lets read it (an |
| 219 | 408 | * admin, as the scaffold has it). `before` pages back by id. Audit rows name |
src/contract.test.ts +4 −3
| @@ -34,13 +34,14 @@ function whoIs(identity: string): Who { | ||
| 34 | 34 | return { role, id: actor.id, name: identity }; |
| 35 | 35 | } |
| 36 | 36 | |
| 37 | let made = 0; | |
| 37 | 38 | const noteBy = (authorId: string): number => |
| 38 | 39 | db |
| 39 | 40 | .query< |
| 40 | 41 | { id: number }, |
| 41 | [string] | |
| 42 | >("INSERT INTO note (body, author_id, author_name) VALUES ('n', ?1, ?1) RETURNING id") | |
| 43 | .get(authorId)!.id; | |
| 42 | [string, string] | |
| 43 | >("INSERT INTO note (title, body, author_id, author_name) VALUES (?2, 'n', ?1, ?1) RETURNING id") | |
| 44 | .get(authorId, `n${++made}`)!.id; | |
| 44 | 45 | |
| 45 | 46 | const auditRow = (): number => { |
| 46 | 47 | record(db, { |
src/db.ts +19
| @@ -38,6 +38,25 @@ export const migrations: string[] = [ | ||
| 38 | 38 | allowed INTEGER NOT NULL CHECK (allowed IN (0, 1)), |
| 39 | 39 | outcome TEXT NOT NULL |
| 40 | 40 | )`, |
| 41 | // The vault: a note is a titled markdown page, its title unique in its | |
| 42 | // author's vault (any case), so a [[link]] names exactly one. The | |
| 43 | // scaffold's 500-character notes become the bodies of titled ones. | |
| 44 | `CREATE TABLE page ( | |
| 45 | id INTEGER PRIMARY KEY, | |
| 46 | title TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 200), | |
| 47 | body TEXT NOT NULL DEFAULT '' CHECK (length(body) <= 200000), | |
| 48 | pinned INTEGER NOT NULL DEFAULT 0 CHECK (pinned IN (0, 1)), | |
| 49 | author_id TEXT NOT NULL, | |
| 50 | author_name TEXT NOT NULL, | |
| 51 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), | |
| 52 | updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) | |
| 53 | )`, | |
| 54 | `INSERT INTO page (id, title, body, author_id, author_name, created_at, updated_at) | |
| 55 | SELECT id, 'Note ' || id, body, author_id, author_name, created_at, updated_at FROM note`, | |
| 56 | `DROP TABLE note`, | |
| 57 | `ALTER TABLE page RENAME TO note`, | |
| 58 | `CREATE UNIQUE INDEX note_title ON note (author_id, title COLLATE NOCASE)`, | |
| 59 | `CREATE INDEX note_recent ON note (author_id, updated_at)`, | |
| 41 | 60 | ]; |
| 42 | 61 | |
| 43 | 62 | export function openDb(dir = dataDir): Database { |
src/design.test.ts +13
| @@ -70,6 +70,19 @@ test("every pair a person reads is at least 4.5:1, in both themes", () => { | ||
| 70 | 70 | ["color-accent", "color-surface"], |
| 71 | 71 | ["color-accent-fg", "color-accent"], |
| 72 | 72 | ["color-toast-fg", "color-toast-bg"], |
| 73 | ["color-fg", "color-hover"], | |
| 74 | ["color-fg", "color-active"], | |
| 75 | ["color-muted", "color-active"], | |
| 76 | ["color-fg", "color-mark-bg"], | |
| 77 | ["color-code-fg", "color-code-bg"], | |
| 78 | ["color-tag-fg", "color-tag-bg"], | |
| 79 | ["color-accent", "color-active"], | |
| 80 | ["color-danger", "color-bg"], | |
| 81 | ["color-link-missing", "color-bg"], | |
| 82 | ["color-callout-info", "color-bg"], | |
| 83 | ["color-callout-tip", "color-bg"], | |
| 84 | ["color-callout-warning", "color-bg"], | |
| 85 | ["color-callout-danger", "color-bg"], | |
| 73 | 86 | ] as const; |
| 74 | 87 | for (const mode of ["light", "dark"] as const) |
| 75 | 88 | for (const [fg, bg] of pairs) { |
src/http.ts +22 −4
| @@ -74,6 +74,19 @@ export const back = (notice?: string): Response => | ||
| 74 | 74 | }, |
| 75 | 75 | }); |
| 76 | 76 | |
| 77 | /** To one of the app's own pages, with a word for it to show once. Only a path on this site: never an address a request supplied. */ | |
| 78 | export const go = (path: string, notice?: string): Response => | |
| 79 | new Response(null, { | |
| 80 | status: 303, | |
| 81 | headers: { | |
| 82 | location: path.startsWith("/") && !path.startsWith("//") ? path : "/", | |
| 83 | ...SECURITY, | |
| 84 | ...(notice === undefined | |
| 85 | ? {} | |
| 86 | : { "set-cookie": cookie("notice", encodeURIComponent(notice), 30) }), | |
| 87 | }, | |
| 88 | }); | |
| 89 | ||
| 77 | 90 | /** The notice a redirect left, and the header that clears it. */ |
| 78 | 91 | export function readNotice(request: Request): { |
| 79 | 92 | notice?: string; |
| @@ -100,6 +113,8 @@ const cookie = (name: string, value: string, maxAge: number): string => | ||
| 100 | 113 | // cap the rest is drained and dropped so the connection stays usable for |
| 101 | 114 | // the next request; past eight times the cap it is cut off instead. |
| 102 | 115 | export const BODY_MAX = 64 * 1024; |
| 116 | /** What a whole note may weigh on the wire: 200,000 characters, any script. */ | |
| 117 | export const NOTE_BYTES = 1024 * 1024; | |
| 103 | 118 | export async function readBody( |
| 104 | 119 | request: Request, |
| 105 | 120 | max = BODY_MAX, |
| @@ -135,8 +150,8 @@ export async function readBody( | ||
| 135 | 150 | } |
| 136 | 151 | |
| 137 | 152 | /** The form fields of a capped body, decoded the way the browser sent them. */ |
| 138 | export async function readForm(request: Request) { | |
| 139 | const body = await readBody(request); | |
| 153 | export async function readForm(request: Request, max = BODY_MAX) { | |
| 154 | const body = await readBody(request, max); | |
| 140 | 155 | if (body === undefined) return undefined; |
| 141 | 156 | return new Request(request.url, { |
| 142 | 157 | method: "POST", |
| @@ -146,8 +161,11 @@ export async function readForm(request: Request) { | ||
| 146 | 161 | } |
| 147 | 162 | |
| 148 | 163 | /** The JSON of a capped body: `undefined` when too large, `null` when not JSON. */ |
| 149 | export async function readJson(request: Request): Promise<unknown> { | |
| 150 | const body = await readBody(request); | |
| 164 | export async function readJson( | |
| 165 | request: Request, | |
| 166 | max = BODY_MAX, | |
| 167 | ): Promise<unknown> { | |
| 168 | const body = await readBody(request, max); | |
| 151 | 169 | if (body === undefined) return undefined; |
| 152 | 170 | try { |
| 153 | 171 | return JSON.parse(new TextDecoder().decode(body)); |
src/live.ts +16 −4
| @@ -5,12 +5,20 @@ | ||
| 5 | 5 | * `?since=`) hears what it missed. `static/live.js` turns the stream into |
| 6 | 6 | * DOM events. The bus is this process's: the terminal is another process, |
| 7 | 7 | * and its writes show on the next load rather than live. |
| 8 | * | |
| 9 | * A vault is private, so an event can be addressed: `emit(type, data, to)` | |
| 10 | * reaches only the streams opened by the person whose id is `to`. An event | |
| 11 | * with no `to` reaches everyone. | |
| 8 | 12 | */ |
| 9 | 13 | |
| 14 | import type { Who } from "./who.ts"; | |
| 15 | ||
| 10 | 16 | export interface LiveEvent { |
| 11 | 17 | readonly id: number; |
| 12 | 18 | readonly type: string; |
| 13 | 19 | readonly data: unknown; |
| 20 | /** Whose streams hear it; everyone's when absent. */ | |
| 21 | readonly to?: string; | |
| 14 | 22 | } |
| 15 | 23 | |
| 16 | 24 | const KEEP = 100; |
| @@ -19,8 +27,8 @@ const kept: LiveEvent[] = []; | ||
| 19 | 27 | const listeners = new Set<(event: LiveEvent) => void>(); |
| 20 | 28 | let last = 0; |
| 21 | 29 | |
| 22 | export function emit(type: string, data: unknown): LiveEvent { | |
| 23 | const event: LiveEvent = { id: ++last, type, data }; | |
| 30 | export function emit(type: string, data: unknown, to?: string): LiveEvent { | |
| 31 | const event: LiveEvent = { id: ++last, type, data, ...(to === undefined ? {} : { to }) }; | |
| 24 | 32 | kept.push(event); |
| 25 | 33 | if (kept.length > KEEP) kept.shift(); |
| 26 | 34 | for (const listen of listeners) listen(event); |
| @@ -33,7 +41,9 @@ export function subscribe(listen: (event: LiveEvent) => void): () => void { | ||
| 33 | 41 | } |
| 34 | 42 | |
| 35 | 43 | /** `GET /events`: a stream that replays what `since` missed, then follows. */ |
| 36 | export function events(request: Request): Response { | |
| 44 | export function events(request: Request, who: Who): Response { | |
| 45 | const hears = (event: LiveEvent): boolean => | |
| 46 | event.to === undefined || (who.role !== "anon" && event.to === who.id); | |
| 37 | 47 | const since = Number( |
| 38 | 48 | request.headers.get("last-event-id") ?? |
| 39 | 49 | new URL(request.url).searchParams.get("since") ?? |
| @@ -50,10 +60,12 @@ export function events(request: Request): Response { | ||
| 50 | 60 | stop(); |
| 51 | 61 | } |
| 52 | 62 | }; |
| 53 | const send = (event: LiveEvent): void => | |
| 63 | const send = (event: LiveEvent): void => { | |
| 64 | if (!hears(event)) return; | |
| 54 | 65 | write( |
| 55 | 66 | `id: ${event.id}\ndata: ${JSON.stringify({ type: event.type, data: event.data })}\n\n`, |
| 56 | 67 | ); |
| 68 | }; | |
| 57 | 69 | const heartbeat = setInterval(() => write(":\n\n"), HEARTBEAT_MS); |
| 58 | 70 | const unsubscribe = subscribe(send); |
| 59 | 71 | stop = () => { |
src/mcp.ts +112 −16
| @@ -7,7 +7,19 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | import { addNote, deleteNote, listNotes, type Result } from "./actions.ts"; | |
| 10 | import { | |
| 11 | addNote, | |
| 12 | BODY_MAX, | |
| 13 | deleteNote, | |
| 14 | findNote, | |
| 15 | getNote, | |
| 16 | listNotes, | |
| 17 | openOrCreate, | |
| 18 | searchNotes, | |
| 19 | today, | |
| 20 | updateNote, | |
| 21 | type Result, | |
| 22 | } from "./actions.ts"; | |
| 11 | 23 | import { title } from "./env.ts"; |
| 12 | 24 | import { fields } from "./http.ts"; |
| 13 | 25 | import type { Who } from "./who.ts"; |
| @@ -23,38 +35,122 @@ interface Tool { | ||
| 23 | 35 | ) => Result<unknown>; |
| 24 | 36 | } |
| 25 | 37 | |
| 38 | const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined); | |
| 39 | ||
| 40 | /** A note by `id`, or by `title` in the caller's vault. */ | |
| 41 | const which = (db: Database, who: Who, args: Record<string, unknown>) => | |
| 42 | args.id !== undefined | |
| 43 | ? getNote(db, who, { id: Number(args.id) }) | |
| 44 | : findNote(db, who, { title: str(args.title) ?? "" }); | |
| 45 | ||
| 46 | const NOTE_REF = { | |
| 47 | id: { type: "integer", description: "The note's id." }, | |
| 48 | title: { type: "string", description: "Or its title (any case)." }, | |
| 49 | }; | |
| 50 | ||
| 26 | 51 | export const tools: readonly Tool[] = [ |
| 27 | 52 | { |
| 28 | 53 | name: "list_notes", |
| 29 | description: "The notes, newest first.", | |
| 54 | description: | |
| 55 | "The titles in your vault (id, title, pinned, updated_at), pinned first then most recently changed. Read one with read_note.", | |
| 30 | 56 | inputSchema: { |
| 31 | 57 | type: "object", |
| 32 | 58 | properties: { |
| 33 | limit: { | |
| 34 | type: "integer", | |
| 35 | minimum: 1, | |
| 36 | maximum: 500, | |
| 37 | description: "At most this many (100).", | |
| 38 | }, | |
| 59 | limit: { type: "integer", minimum: 1, maximum: 5000, description: "At most this many." }, | |
| 39 | 60 | }, |
| 40 | 61 | }, |
| 41 | run: (db, who, args) => | |
| 42 | listNotes(db, who, { limit: Number(args.limit ?? 100) }), | |
| 62 | run: (db, who, args) => { | |
| 63 | const listed = listNotes(db, who, args.limit === undefined ? {} : { limit: Number(args.limit) }); | |
| 64 | return listed.ok | |
| 65 | ? { | |
| 66 | ok: true, | |
| 67 | value: listed.value.map(({ id, title, pinned, updated_at }) => ({ id, title, pinned: pinned === 1, updated_at })), | |
| 68 | } | |
| 69 | : listed; | |
| 70 | }, | |
| 43 | 71 | }, |
| 44 | 72 | { |
| 45 | name: "add_note", | |
| 73 | name: "search_notes", | |
| 46 | 74 | description: |
| 47 | "Add a note as the signed-in person. Anonymous callers cannot.", | |
| 75 | 'Search your vault: every word must appear; "a phrase" matches whole; #tag (or tag:name) must be on the note. Title matches rank first.', | |
| 48 | 76 | inputSchema: { |
| 49 | 77 | type: "object", |
| 50 | properties: { body: { type: "string", maxLength: 500 } }, | |
| 51 | required: ["body"], | |
| 78 | properties: { q: { type: "string", maxLength: 500 }, limit: { type: "integer", minimum: 1, maximum: 500 } }, | |
| 79 | required: ["q"], | |
| 80 | }, | |
| 81 | run: (db, who, args) => | |
| 82 | searchNotes(db, who, { q: str(args.q) ?? "", ...(args.limit === undefined ? {} : { limit: Number(args.limit) }) }), | |
| 83 | }, | |
| 84 | { | |
| 85 | name: "read_note", | |
| 86 | description: "One note's markdown, by id or title.", | |
| 87 | inputSchema: { type: "object", properties: NOTE_REF }, | |
| 88 | run: (db, who, args) => which(db, who, args), | |
| 89 | }, | |
| 90 | { | |
| 91 | name: "create_note", | |
| 92 | description: | |
| 93 | "Write a new note in markdown. Link other notes with [[Their title]] and tag with #tags. Titles are unique in the vault.", | |
| 94 | inputSchema: { | |
| 95 | type: "object", | |
| 96 | properties: { | |
| 97 | title: { type: "string", maxLength: 200 }, | |
| 98 | body: { type: "string", maxLength: BODY_MAX }, | |
| 99 | }, | |
| 100 | required: ["title"], | |
| 101 | }, | |
| 102 | run: (db, who, args) => | |
| 103 | addNote(db, who, { title: str(args.title) ?? "", body: str(args.body) ?? "" }), | |
| 104 | }, | |
| 105 | { | |
| 106 | name: "update_note", | |
| 107 | description: | |
| 108 | "Replace a note's body, rename it (links to it follow), or pin it. Give only what changes.", | |
| 109 | inputSchema: { | |
| 110 | type: "object", | |
| 111 | properties: { | |
| 112 | id: { type: "integer" }, | |
| 113 | title: { type: "string", maxLength: 200 }, | |
| 114 | body: { type: "string", maxLength: BODY_MAX }, | |
| 115 | pinned: { type: "boolean" }, | |
| 116 | }, | |
| 117 | required: ["id"], | |
| 118 | }, | |
| 119 | run: (db, who, args) => | |
| 120 | updateNote(db, who, { | |
| 121 | id: Number(args.id), | |
| 122 | ...(str(args.title) === undefined ? {} : { title: str(args.title)! }), | |
| 123 | ...(str(args.body) === undefined ? {} : { body: str(args.body)! }), | |
| 124 | ...(typeof args.pinned === "boolean" ? { pinned: args.pinned } : {}), | |
| 125 | }), | |
| 126 | }, | |
| 127 | { | |
| 128 | name: "append_to_note", | |
| 129 | description: | |
| 130 | "Add text to the end of a note, made if it does not exist yet. With no title, today's daily note (YYYY-MM-DD, UTC).", | |
| 131 | inputSchema: { | |
| 132 | type: "object", | |
| 133 | properties: { | |
| 134 | title: { type: "string", maxLength: 200 }, | |
| 135 | text: { type: "string", maxLength: BODY_MAX }, | |
| 136 | }, | |
| 137 | required: ["text"], | |
| 138 | }, | |
| 139 | run: (db, who, args) => { | |
| 140 | const day = today(); | |
| 141 | const name = str(args.title) ?? day; | |
| 142 | const opened = openOrCreate(db, who, { title: name, body: name === day ? `# ${day}\n` : "" }); | |
| 143 | if (!opened.ok) return opened; | |
| 144 | const body = opened.value.body.replace(/\s*$/, ""); | |
| 145 | return updateNote(db, who, { | |
| 146 | id: opened.value.id, | |
| 147 | body: `${body}${body === "" ? "" : "\n\n"}${str(args.text) ?? ""}\n`, | |
| 148 | }); | |
| 52 | 149 | }, |
| 53 | run: (db, who, args) => addNote(db, who, { body: String(args.body ?? "") }), | |
| 54 | 150 | }, |
| 55 | 151 | { |
| 56 | 152 | name: "delete_note", |
| 57 | description: "Remove a note by id. Only its author or an admin can.", | |
| 153 | description: "Remove a note by id. This cannot be undone.", | |
| 58 | 154 | inputSchema: { |
| 59 | 155 | type: "object", |
| 60 | 156 | properties: { id: { type: "integer" } }, |
src/openapi.test.ts +4
| @@ -10,6 +10,7 @@ import { join } from "node:path"; | ||
| 10 | 10 | import { emit, openapi } from "./openapi.ts"; |
| 11 | 11 | import { routes } from "./routes.ts"; |
| 12 | 12 | import { serve } from "./server.ts"; |
| 13 | import { sign } from "./who.ts"; | |
| 13 | 14 | |
| 14 | 15 | const committed = readFileSync( |
| 15 | 16 | new URL("../contract/openapi.json", import.meta.url), |
| @@ -66,8 +67,11 @@ test("GET /openapi.json serves the committed file", async () => { | ||
| 66 | 67 | identityKey: "k", |
| 67 | 68 | }); |
| 68 | 69 | afterAll(() => server.stop(true)); |
| 70 | // The table opens strangers no door, so the description is for the signed in. | |
| 71 | const identity = `u1|alice|user|${Date.now() + 60_000}`; | |
| 69 | 72 | const response = await fetch( |
| 70 | 73 | `http://${server.hostname}:${server.port}/openapi.json`, |
| 74 | { headers: { "x-forge-identity": identity, "x-forge-signature": sign(identity, "k") } }, | |
| 71 | 75 | ); |
| 72 | 76 | expect(response.status).toBe(200); |
| 73 | 77 | expect(response.headers.get("content-type")).toContain("application/json"); |
src/page.ts +284 −82
| @@ -1,43 +1,55 @@ | ||
| 1 | 1 | /** |
| 2 | * The page, rendered on the server: the first paint is complete HTML, and | |
| 3 | * `static/live.js` keeps the notes fresh afterwards. When a second page | |
| 4 | * arrives, move `layout` to `src/pages/layout.ts` and give each page a file. | |
| 2 | * The pages, rendered on the server: the first paint is the whole vault — | |
| 3 | * your notes on the left, the note in the middle, what it links to and | |
| 4 | * what links back on the right — and `static/app.js` makes it an editor | |
| 5 | * afterwards (autosave, live preview, the quick switcher, the keys). | |
| 6 | * Without JavaScript every page still reads, and the editor still saves. | |
| 5 | 7 | */ |
| 6 | 8 | |
| 7 | 9 | import { canDelete, type Note } from "./actions.ts"; |
| 8 | import { | |
| 9 | commit, | |
| 10 | description, | |
| 11 | env, | |
| 12 | publicOrigin, | |
| 13 | ssh, | |
| 14 | title, | |
| 15 | where, | |
| 16 | } from "./env.ts"; | |
| 10 | import { commit, description, env, publicOrigin, title, where } from "./env.ts"; | |
| 17 | 11 | import { asset, escape } from "./http.ts"; |
| 18 | 12 | import { token, tokensHref } from "./tokens.ts"; |
| 13 | import { | |
| 14 | backlinks, | |
| 15 | excerpt, | |
| 16 | graph, | |
| 17 | headings, | |
| 18 | hrefOf, | |
| 19 | outgoing, | |
| 20 | renderNote, | |
| 21 | tagCounts, | |
| 22 | tags, | |
| 23 | type Hit, | |
| 24 | } from "./vault.ts"; | |
| 19 | 25 | import type { Who } from "./who.ts"; |
| 20 | 26 | |
| 27 | /** Data for the browser, inside a page: JSON that can never close its <script>. */ | |
| 28 | const data = (id: string, value: unknown, live = ""): string => | |
| 29 | `<script type="application/json" id="${id}"${live ? ` data-live="${live}"` : ""}>${JSON.stringify(value).replace(/</g, "\\u003c")}</script>`; | |
| 30 | ||
| 31 | const CHANGES = "note.added note.removed note.changed"; | |
| 32 | ||
| 21 | 33 | /** |
| 22 | * The frame every page shares: the tokens and the look, who you are, and | |
| 23 | * what a link to the page says when it is shared — a title, a sentence | |
| 24 | * and the address, for Open Graph and the card a chat app unfurls. | |
| 34 | * The frame every page shares: the tokens and the look, and what a link | |
| 35 | * to the page says when it is shared — a title, a sentence and the | |
| 36 | * address, for Open Graph and the card a chat app unfurls. | |
| 25 | 37 | */ |
| 26 | export function layout(body: string, who: Who, path = "/"): string { | |
| 27 | const door = | |
| 28 | who.role === "anon" | |
| 29 | ? `<a class="button button-quiet" href="/__forge/signin?to=/">Sign in</a>` | |
| 30 | : `<span class="you"><b>${escape(who.name)}</b>${who.role === "admin" ? " · admin" : ""}</span> | |
| 31 | <a class="button button-quiet" href="/__forge/signout">Sign out</a>`; | |
| 38 | export function layout( | |
| 39 | body: string, | |
| 40 | path = "/", | |
| 41 | options: { name?: string; kind?: string; scripts?: string[] } = {}, | |
| 42 | ): string { | |
| 43 | const name = options.name === undefined ? title : `${options.name} · ${title}`; | |
| 32 | 44 | return `<!doctype html> |
| 33 | 45 | <html lang="en"> |
| 34 | 46 | <meta charset="utf-8"> |
| 35 | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 47 | <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> | |
| 36 | 48 | <meta name="color-scheme" content="light dark"> |
| 37 | 49 | <meta name="theme-color" content="${token("color-bg")}" media="(prefers-color-scheme: light)"> |
| 38 | 50 | <meta name="theme-color" content="${token("color-bg", "dark")}" media="(prefers-color-scheme: dark)"> |
| 39 | 51 | <link rel="icon" href="data:,"> |
| 40 | <title>${escape(title)}</title> | |
| 52 | <title>${escape(name)}</title> | |
| 41 | 53 | <meta name="description" content="${escape(description)}"> |
| 42 | 54 | <meta property="og:type" content="website"> |
| 43 | 55 | <meta property="og:site_name" content="${escape(title)}"> |
| @@ -47,76 +59,266 @@ export function layout(body: string, who: Who, path = "/"): string { | ||
| 47 | 59 | <meta name="twitter:card" content="summary"> |
| 48 | 60 | <meta name="twitter:title" content="${escape(title)}"> |
| 49 | 61 | <meta name="twitter:description" content="${escape(description)}"> |
| 62 | <script src="${asset("theme.js")}"></script> | |
| 50 | 63 | <link rel="stylesheet" href="${tokensHref}"> |
| 51 | 64 | <link rel="stylesheet" href="${asset("app.css")}"> |
| 52 | 65 | <script type="module" src="${asset("live.js")}"></script> |
| 53 | <body> | |
| 54 | <div class="page"> | |
| 55 | <header class="head"> | |
| 56 | <div> | |
| 57 | <h1>${escape(title)}</h1> | |
| 58 | <p class="muted">${escape(description)}</p> | |
| 59 | </div> | |
| 60 | <nav class="row"> | |
| 61 | <span class="pill" data-env="${escape(env)}">${escape(where)}</span>${commit ? `<span class="muted mono">${escape(commit)}</span>` : ""} | |
| 62 | ${door} | |
| 63 | </nav> | |
| 64 | </header> | |
| 65 | <main>${body}</main> | |
| 66 | <footer class="muted"> | |
| 67 | ${ssh === undefined ? "" : `<p>Terminal: <code>${escape(ssh)}</code></p>`} | |
| 68 | <p>Also at <a href="/api/notes">/api/notes</a> (<a href="/openapi.json">described</a>) and <code>POST /mcp</code>, with the same rules.</p> | |
| 69 | </footer> | |
| 70 | </div> | |
| 66 | ${(options.scripts ?? []).map((s) => `<script type="module" src="${asset(s)}"></script>`).join("\n")} | |
| 67 | <body class="${escape(options.kind ?? "page")}"> | |
| 68 | ${body} | |
| 71 | 69 | </body> |
| 72 | 70 | </html> |
| 73 | 71 | `; |
| 74 | 72 | } |
| 75 | 73 | |
| 76 | export function home(input: { | |
| 77 | who: Who; | |
| 78 | notes: readonly Note[]; | |
| 79 | notice?: string | undefined; | |
| 80 | }): string { | |
| 81 | const { who, notes, notice } = input; | |
| 82 | const composer = | |
| 83 | who.role === "anon" | |
| 84 | ? `<p class="muted">Reading as a guest. <a href="/__forge/signin?to=/">Sign in</a> to add a note.</p>` | |
| 85 | : `<form class="row" method="post" action="/notes"> | |
| 86 | <input class="input" name="body" placeholder="Write a note" aria-label="New note" required maxlength="500" autocomplete="off" autofocus> | |
| 87 | <button class="button">Add</button> | |
| 88 | </form>`; | |
| 89 | const list = | |
| 90 | notes.length === 0 | |
| 91 | ? `<p class="empty">No notes yet. Notes written here stay in ${escape(where)} data.</p>` | |
| 92 | : `<ul class="list">${notes.map((note) => item(note, who)).join("")}</ul>`; | |
| 74 | /** Signed out: what this is, and the way in. */ | |
| 75 | export function landing(notice?: string): string { | |
| 93 | 76 | return layout( |
| 94 | `<section class="card">${composer}</section> | |
| 95 | <section id="notes" data-live="note.added note.removed">${list}</section> | |
| 96 | ${notice === undefined ? "" : `<p class="toast" role="status">${escape(notice)}</p>`}`, | |
| 97 | who, | |
| 77 | `<div class="landing"> | |
| 78 | <div class="landing-card"> | |
| 79 | <div class="mark" aria-hidden="true">${glyph}</div> | |
| 80 | <h1>${escape(title)}</h1> | |
| 81 | <p class="lede">A private vault for your thinking. Markdown notes, linked with <code>[[double brackets]]</code>, with backlinks, tags and a graph of how it all connects.</p> | |
| 82 | <a class="button button-big" href="/__forge/signin?to=/">Sign in to open your vault</a> | |
| 83 | <p class="muted small">Your notes are yours alone — nobody else who can open this app can read them.</p> | |
| 84 | </div> | |
| 85 | ${toast(notice)} | |
| 86 | </div>`, | |
| 87 | "/", | |
| 88 | { kind: "page landing-page" }, | |
| 98 | 89 | ); |
| 99 | 90 | } |
| 100 | 91 | |
| 101 | const item = (note: Note, who: Who): string => `<li class="note"> | |
| 102 | <div> | |
| 103 | <p>${escape(note.body)}</p> | |
| 104 | <small class="muted">${escape(note.author_name)} · <time datetime="${escape(note.created_at)}">${ago(note.created_at)}</time></small> | |
| 105 | </div> | |
| 106 | ${ | |
| 107 | canDelete(who, note) | |
| 108 | ? `<form method="post" action="/notes/${note.id}/delete"><button class="button button-quiet" aria-label="Remove this note">Remove</button></form>` | |
| 109 | : "" | |
| 110 | } | |
| 111 | </li>`; | |
| 92 | const glyph = `<svg viewBox="0 0 32 32" width="40" height="40"><path d="M16 2 28 9v14l-12 7L4 23V9z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M16 9v16M10 12.5l12 7M22 12.5l-12 7" stroke="currentColor" stroke-width="1.5" opacity=".55"/><circle cx="16" cy="16" r="2.6" fill="currentColor"/></svg>`; | |
| 112 | 93 | |
| 113 | const ago = (iso: string): string => { | |
| 114 | const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000); | |
| 115 | if (s < 60) return "just now"; | |
| 116 | if (s < 3600) return `${Math.floor(s / 60)}m ago`; | |
| 117 | if (s < 86400) return `${Math.floor(s / 3600)}h ago`; | |
| 118 | return new Date(iso).toLocaleDateString("en", { | |
| 119 | month: "short", | |
| 120 | day: "numeric", | |
| 121 | }); | |
| 94 | const toast = (notice?: string): string => | |
| 95 | notice === undefined ? "" : `<p class="toast" role="status">${escape(notice)}</p>`; | |
| 96 | ||
| 97 | const icon = { | |
| 98 | plus: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>`, | |
| 99 | today: `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="5" width="16" height="15" rx="2"/><path d="M4 10h16M9 3v4M15 3v4"/></svg>`, | |
| 100 | graph: `<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="6" cy="7" r="2.2"/><circle cx="18" cy="6" r="2.2"/><circle cx="12" cy="17" r="2.2"/><path d="M8 8l3 7M16 7.6l-3 7.5M8.2 7h7.6"/></svg>`, | |
| 101 | search: `<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="6"/><path d="m20 20-4.5-4.5"/></svg>`, | |
| 102 | left: `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/></svg>`, | |
| 103 | right: `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M15 4v16"/></svg>`, | |
| 104 | pin: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 4h6l-1 6 4 3v2H6v-2l4-3zM12 15v6"/></svg>`, | |
| 105 | trash: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3"/></svg>`, | |
| 106 | theme: `<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8"/><path d="M12 4a8 8 0 0 0 0 16z" fill="currentColor"/></svg>`, | |
| 107 | download: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4v11M7 10l5 5 5-5M5 20h14"/></svg>`, | |
| 108 | }; | |
| 109 | ||
| 110 | interface Shell { | |
| 111 | readonly who: Who & { role: "user" | "admin" }; | |
| 112 | readonly vault: readonly Note[]; | |
| 113 | readonly active?: Note | undefined; | |
| 114 | readonly notice?: string | undefined; | |
| 115 | readonly query?: string; | |
| 116 | } | |
| 117 | ||
| 118 | /** The left: making notes, finding them, and every one of them. */ | |
| 119 | function left(s: Shell): string { | |
| 120 | const pinned = s.vault.filter((n) => n.pinned === 1); | |
| 121 | const rest = s.vault.filter((n) => n.pinned !== 1); | |
| 122 | const row = (n: Note) => | |
| 123 | `<li><a class="file${s.active?.id === n.id ? " is-active" : ""}" href="${hrefOf(n)}"${s.active?.id === n.id ? ' aria-current="page"' : ""} data-title="${escape(n.title.toLowerCase())}"><span class="file-title">${escape(n.title)}</span><time class="file-when" datetime="${escape(n.updated_at)}">${ago(n.updated_at)}</time></a></li>`; | |
| 124 | const tagList = tagCounts(s.vault); | |
| 125 | return `<aside class="left" id="left" aria-label="Your notes"> | |
| 126 | <div class="left-head"> | |
| 127 | <a class="brand" href="/">${glyph}<span>${escape(title)}</span></a> | |
| 128 | <button type="button" class="icon-button" data-action="theme" aria-label="Switch theme" title="Theme">${icon.theme}</button> | |
| 129 | </div> | |
| 130 | <div class="actions"> | |
| 131 | <form method="post" action="/new"><button class="button button-wide" title="New note (Alt N)">${icon.plus}<span>New note</span></button></form> | |
| 132 | <form method="post" action="/daily"><input type="hidden" name="tz" value="0"><button class="icon-button" aria-label="Today's note" title="Today's note (⌘⇧D)">${icon.today}</button></form> | |
| 133 | <a class="icon-button" href="/graph" aria-label="Graph" title="Graph (⌘G)">${icon.graph}</a> | |
| 134 | </div> | |
| 135 | <form class="find" method="get" action="/search" role="search"> | |
| 136 | ${icon.search} | |
| 137 | <input class="input input-find" type="search" name="q" value="${escape(s.query ?? "")}" placeholder="Search or filter…" aria-label="Search notes" autocomplete="off" data-filter> | |
| 138 | <kbd class="hint">⌘K</kbd> | |
| 139 | </form> | |
| 140 | <nav class="files" id="files" data-live="${CHANGES}"> | |
| 141 | ${s.vault.length === 0 ? `<p class="muted small pad">No notes yet.</p>` : ""} | |
| 142 | ${pinned.length > 0 ? `<p class="section">Pinned</p><ul class="file-list">${pinned.map(row).join("")}</ul>` : ""} | |
| 143 | ${rest.length > 0 ? `<p class="section">${pinned.length > 0 ? "Notes" : `Notes <span class="count">${rest.length}</span>`}</p><ul class="file-list">${rest.map(row).join("")}</ul>` : ""} | |
| 144 | ${tagList.length > 0 ? `<p class="section">Tags</p><div class="tag-cloud">${tagList.map((t) => `<a class="tag" href="/search?q=${encodeURIComponent(`#${t.tag}`)}">#${escape(t.tag)}<span class="count">${t.count}</span></a>`).join("")}</div>` : ""} | |
| 145 | </nav> | |
| 146 | <div class="left-foot"> | |
| 147 | <span class="you" title="${escape(where)}${commit ? ` · ${escape(commit)}` : ""}">${escape(s.who.name)}${env === "production" ? "" : ` · <span class="pill">${escape(where)}</span>`}</span> | |
| 148 | <a class="icon-button" href="/export" aria-label="Download every note" title="Download everything">${icon.download}</a> | |
| 149 | <a class="button button-quiet" href="/__forge/signout">Sign out</a> | |
| 150 | </div> | |
| 151 | </aside>`; | |
| 152 | } | |
| 153 | ||
| 154 | /** The right: the note's shape and its neighbours. */ | |
| 155 | function right(note: Note, vault: readonly Note[]): string { | |
| 156 | const outline = headings(note.body); | |
| 157 | const back = backlinks(note, vault); | |
| 158 | const out = outgoing(note, vault); | |
| 159 | const mine = tags(note.body); | |
| 160 | const backTitle = note.title.toLowerCase(); | |
| 161 | const context = (n: Note): string => { | |
| 162 | const line = n.body | |
| 163 | .split("\n") | |
| 164 | .find((l) => l.toLowerCase().includes(`[[${backTitle}`)); | |
| 165 | return line === undefined ? "" : `<span class="context">${escape(excerpt(line, 110))}</span>`; | |
| 166 | }; | |
| 167 | return `<aside class="right" id="context" aria-label="About this note" data-live="${CHANGES}"> | |
| 168 | <section class="panel"> | |
| 169 | <h2 class="section">Graph</h2> | |
| 170 | <canvas class="local-graph" data-graph="local" data-focus="${note.id}" aria-label="This note and its neighbours"></canvas> | |
| 171 | </section> | |
| 172 | <section class="panel"> | |
| 173 | <h2 class="section">Backlinks <span class="count">${back.length}</span></h2> | |
| 174 | ${back.length === 0 ? `<p class="muted small">Nothing links here yet. Write <code>[[${escape(note.title)}]]</code> in another note.</p>` : `<ul class="link-list">${back.map((n) => `<li><a href="${hrefOf(n)}">${escape(n.title)}</a>${context(n)}</li>`).join("")}</ul>`} | |
| 175 | </section> | |
| 176 | <section class="panel"> | |
| 177 | <h2 class="section">Links <span class="count">${out.found.length + out.missing.length}</span></h2> | |
| 178 | ${out.found.length + out.missing.length === 0 ? `<p class="muted small">No links out.</p>` : `<ul class="link-list">${out.found.map((n) => `<li><a href="${hrefOf(n)}">${escape(n.title)}</a></li>`).join("")}${out.missing.map((t) => `<li><a class="unresolved" href="/open?title=${encodeURIComponent(t)}" data-title="${escape(t)}">${escape(t)}</a></li>`).join("")}</ul>`} | |
| 179 | </section> | |
| 180 | ${outline.length > 0 ? `<section class="panel"><h2 class="section">Outline</h2><ol class="outline">${outline.map((h) => `<li class="depth-${Math.min(h.level - 1, 5)}"><a href="#${escape(h.id)}">${escape(h.text)}</a></li>`).join("")}</ol></section>` : ""} | |
| 181 | ${mine.length > 0 ? `<section class="panel"><h2 class="section">Tags</h2><div class="tag-cloud">${mine.map((t) => `<a class="tag" href="/search?q=${encodeURIComponent(`#${t}`)}">#${escape(t)}</a>`).join("")}</div></section>` : ""} | |
| 182 | <section class="panel meta muted small"> | |
| 183 | <p>Created <time datetime="${escape(note.created_at)}">${long(note.created_at)}</time></p> | |
| 184 | <p>${words(note.body)} words · ${note.body.length.toLocaleString("en")} characters</p> | |
| 185 | </section> | |
| 186 | </aside>`; | |
| 187 | } | |
| 188 | ||
| 189 | /** Everything around a center: the sidebars, the toolbar's toggles, the switcher, the vault's titles for the browser. */ | |
| 190 | function shell(s: Shell, center: string, rightSide = ""): string { | |
| 191 | return `<div class="shell" data-has-right="${rightSide === "" ? "false" : "true"}"> | |
| 192 | ${left(s)} | |
| 193 | <div class="scrim" data-action="close-panels" hidden></div> | |
| 194 | <main class="center" id="main">${center}</main> | |
| 195 | ${rightSide} | |
| 196 | </div> | |
| 197 | <div class="palette" id="palette" hidden> | |
| 198 | <div class="palette-box" role="dialog" aria-modal="true" aria-label="Go to a note"> | |
| 199 | <input class="input palette-input" id="palette-input" placeholder="Find or create a note…" aria-label="Note title" autocomplete="off" role="combobox" aria-expanded="true" aria-controls="palette-list"> | |
| 200 | <ul class="palette-list" id="palette-list" role="listbox"></ul> | |
| 201 | <p class="palette-help muted small"><kbd>↑</kbd><kbd>↓</kbd> move · <kbd>↵</kbd> open · <kbd>⇧↵</kbd> create · <kbd>esc</kbd> close</p> | |
| 202 | </div> | |
| 203 | </div> | |
| 204 | <ul class="suggest" id="suggest" role="listbox" hidden></ul> | |
| 205 | ${data("vault", s.vault.map((n) => ({ id: n.id, title: n.title, updated_at: n.updated_at })), CHANGES)} | |
| 206 | ${toast(s.notice)}`; | |
| 207 | } | |
| 208 | ||
| 209 | const toolbar = (s: Shell, crumbs: string, tools = ""): string => `<header class="toolbar"> | |
| 210 | <button type="button" class="icon-button only-narrow" data-action="toggle-left" aria-label="Show notes">${icon.left}</button> | |
| 211 | <div class="crumbs">${crumbs}</div> | |
| 212 | <div class="tools">${tools}</div> | |
| 213 | ${s.active === undefined ? "" : `<button type="button" class="icon-button only-narrow-right" data-action="toggle-right" aria-label="Show links">${icon.right}</button>`} | |
| 214 | </header>`; | |
| 215 | ||
| 216 | /** One note, open: the editor and the reading view side by side. */ | |
| 217 | export function notePage(s: Shell & { active: Note }): string { | |
| 218 | const note = s.active; | |
| 219 | const tools = `<div class="segmented" role="group" aria-label="View"> | |
| 220 | <button type="button" data-mode="edit" aria-pressed="false" title="Edit">Edit</button> | |
| 221 | <button type="button" data-mode="split" aria-pressed="false" title="Edit and read side by side" class="only-wide">Split</button> | |
| 222 | <button type="button" data-mode="read" aria-pressed="false" title="Read (⌘E)">Read</button> | |
| 223 | </div> | |
| 224 | <span class="status muted small" id="status" aria-live="polite">Saved</span> | |
| 225 | <form method="post" action="/n/${note.id}/pin"><input type="hidden" name="pinned" value="${note.pinned === 1 ? "0" : "1"}"><button class="icon-button${note.pinned === 1 ? " is-on" : ""}" aria-label="${note.pinned === 1 ? "Unpin" : "Pin"}" title="${note.pinned === 1 ? "Unpin" : "Pin to the top"}" aria-pressed="${note.pinned === 1}">${icon.pin}</button></form> | |
| 226 | <a class="icon-button" href="/n/${note.id}/raw" aria-label="Download as markdown" title="Download .md">${icon.download}</a> | |
| 227 | ${canDelete(s.who, note) ? `<form method="post" action="/n/${note.id}/delete" data-confirm="Delete “${escape(note.title)}”? This cannot be undone."><button class="icon-button danger" aria-label="Delete note" title="Delete">${icon.trash}</button></form>` : ""}`; | |
| 228 | const center = `${toolbar(s, `<a href="/">Vault</a><span aria-hidden="true">/</span><span class="crumb-here">${escape(note.title)}</span>`, tools)} | |
| 229 | <form class="editor" id="editor" method="post" action="/n/${note.id}" data-id="${note.id}" data-updated="${escape(note.updated_at)}"> | |
| 230 | <div class="sheet"> | |
| 231 | <input class="title-input" id="title" name="title" value="${escape(note.title)}" aria-label="Title" maxlength="200" autocomplete="off" spellcheck="false"> | |
| 232 | <div class="panes" id="panes" data-mode="${note.body.trim() === "" ? "edit" : "split"}"> | |
| 233 | <textarea class="source" id="source" name="body" aria-label="Note, in markdown" placeholder="Start writing. [[Link]] other notes, #tag this one…" spellcheck="true">${escape(note.body)}</textarea> | |
| 234 | <article class="md preview" id="preview" aria-label="Reading view">${renderNote(note.body, s.vault) || `<p class="muted">Nothing here yet.</p>`}</article> | |
| 235 | </div> | |
| 236 | <noscript><p class="row"><button class="button">Save</button></p></noscript> | |
| 237 | </div> | |
| 238 | </form> | |
| 239 | ${graphData(s.vault)}`; | |
| 240 | return layout(shell(s, center, right(note, s.vault)), hrefOf(note), { | |
| 241 | name: note.title, | |
| 242 | kind: "page app-page", | |
| 243 | scripts: ["app.js", "graph.js"], | |
| 244 | }); | |
| 245 | } | |
| 246 | ||
| 247 | /** An empty vault: two ways to begin. */ | |
| 248 | export function emptyPage(s: Shell): string { | |
| 249 | const center = `${toolbar(s, `<span class="crumb-here">Vault</span>`)} | |
| 250 | <div class="empty-state"> | |
| 251 | <div class="mark" aria-hidden="true">${glyph}</div> | |
| 252 | <h1>Your vault is empty</h1> | |
| 253 | <p class="muted">Every note you write here is markdown, saved as you type, and private to you.</p> | |
| 254 | <div class="row center"> | |
| 255 | <form method="post" action="/welcome"><button class="button button-big">Start with a welcome note</button></form> | |
| 256 | <form method="post" action="/new"><button class="button button-big button-quiet">Blank note</button></form> | |
| 257 | </div> | |
| 258 | </div>`; | |
| 259 | return layout(shell(s, center), "/", { kind: "page app-page", scripts: ["app.js"] }); | |
| 260 | } | |
| 261 | ||
| 262 | /** A link to a note that isn't written yet. */ | |
| 263 | export function missingPage(s: Shell & { title: string }): string { | |
| 264 | const center = `${toolbar(s, `<a href="/">Vault</a><span aria-hidden="true">/</span><span class="crumb-here">${escape(s.title)}</span>`)} | |
| 265 | <div class="empty-state"> | |
| 266 | <h1>“${escape(s.title)}” doesn't exist yet</h1> | |
| 267 | <p class="muted">A link points here, but nothing's been written.</p> | |
| 268 | <form method="post" action="/open"><input type="hidden" name="title" value="${escape(s.title)}"><button class="button button-big">Create it</button></form> | |
| 269 | </div>`; | |
| 270 | return layout(shell(s, center), "/open", { name: s.title, kind: "page app-page", scripts: ["app.js"] }); | |
| 271 | } | |
| 272 | ||
| 273 | /** What a search found. */ | |
| 274 | export function searchPage(s: Shell & { query: string; hits: readonly Hit<Note>[] }): string { | |
| 275 | const words = s.query | |
| 276 | .toLowerCase() | |
| 277 | .match(/"[^"]+"|\S+/g) | |
| 278 | ?.map((w) => w.replace(/^"|"$/g, "")) | |
| 279 | .filter((w) => !/^(#|tag:)/.test(w)) ?? []; | |
| 280 | const mark = (text: string): string => { | |
| 281 | let html = escape(text); | |
| 282 | for (const w of words) { | |
| 283 | const e = escape(w).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | |
| 284 | if (e !== "") html = html.replace(new RegExp(`(${e})`, "gi"), "<mark>$1</mark>"); | |
| 285 | } | |
| 286 | return html; | |
| 287 | }; | |
| 288 | const center = `${toolbar(s, `<a href="/">Vault</a><span aria-hidden="true">/</span><span class="crumb-here">Search</span>`)} | |
| 289 | <div class="sheet results"> | |
| 290 | <h1 class="results-head">${s.hits.length} ${s.hits.length === 1 ? "note" : "notes"} for <q>${escape(s.query)}</q></h1> | |
| 291 | ${s.hits.length === 0 ? `<p class="muted">Nothing matches. Try fewer words, or <code>#tag</code>.</p>` : `<ul class="hits">${s.hits.map((h) => `<li><a href="${hrefOf(h.note)}"><b>${mark(h.note.title)}</b><span class="muted">${mark(h.snippet)}</span></a></li>`).join("")}</ul>`} | |
| 292 | </div>`; | |
| 293 | return layout(shell(s, center), "/search", { name: `Search: ${s.query}`, kind: "page app-page", scripts: ["app.js"] }); | |
| 294 | } | |
| 295 | ||
| 296 | /** The whole vault, as a graph. */ | |
| 297 | export function graphPage(s: Shell): string { | |
| 298 | const g = graph(s.vault); | |
| 299 | if (g.nodes.length === 0) return emptyPage(s); | |
| 300 | const center = `${toolbar(s, `<a href="/">Vault</a><span aria-hidden="true">/</span><span class="crumb-here">Graph</span>`, `<span class="muted small">${g.nodes.length} notes · ${g.edges.length} links</span>`)} | |
| 301 | <div class="graph-stage"> | |
| 302 | <canvas class="big-graph" data-graph="all" aria-label="Every note, and the links between them"></canvas> | |
| 303 | <p class="graph-help muted small">Drag to move · scroll or pinch to zoom · click a note to open it</p> | |
| 304 | </div> | |
| 305 | ${graphData(s.vault)}`; | |
| 306 | return layout(shell(s, center), "/graph", { name: "Graph", kind: "page app-page graph-page", scripts: ["app.js", "graph.js"] }); | |
| 307 | } | |
| 308 | ||
| 309 | /** The graph's data, for any page that draws one. */ | |
| 310 | export const graphData = (vault: readonly Note[]): string => data("graph", graph(vault), CHANGES); | |
| 311 | ||
| 312 | const words = (body: string): number => body.split(/\s+/).filter((w) => /\w/.test(w)).length; | |
| 313 | ||
| 314 | const long = (iso: string): string => | |
| 315 | new Date(iso).toLocaleDateString("en", { year: "numeric", month: "short", day: "numeric" }); | |
| 316 | ||
| 317 | export const ago = (iso: string): string => { | |
| 318 | const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000); | |
| 319 | if (s < 60) return "now"; | |
| 320 | if (s < 3600) return `${Math.floor(s / 60)}m`; | |
| 321 | if (s < 86400) return `${Math.floor(s / 3600)}h`; | |
| 322 | if (s < 86400 * 7) return `${Math.floor(s / 86400)}d`; | |
| 323 | return new Date(iso).toLocaleDateString("en", { month: "short", day: "numeric" }); | |
| 122 | 324 | }; |
src/routes.ts +284 −42
| @@ -21,16 +21,24 @@ import { | ||
| 21 | 21 | getNote, |
| 22 | 22 | listAudit, |
| 23 | 23 | listNotes, |
| 24 | openOrCreate, | |
| 25 | searchNotes, | |
| 26 | TITLE_MAX, | |
| 27 | today, | |
| 24 | 28 | updateNote, |
| 29 | WELCOME, | |
| 30 | type Change, | |
| 25 | 31 | type Failure, |
| 32 | type Note, | |
| 26 | 33 | type Result, |
| 27 | 34 | } from "./actions.ts"; |
| 28 | 35 | import { commit, env, forgeUrl, startedAt, version } from "./env.ts"; |
| 29 | 36 | import { |
| 30 | back, | |
| 31 | 37 | fields, |
| 38 | go, | |
| 32 | 39 | html, |
| 33 | 40 | json, |
| 41 | NOTE_BYTES, | |
| 34 | 42 | readForm, |
| 35 | 43 | readJson, |
| 36 | 44 | readNotice, |
| @@ -40,8 +48,16 @@ import { | ||
| 40 | 48 | } from "./http.ts"; |
| 41 | 49 | import { events } from "./live.ts"; |
| 42 | 50 | import { rpc } from "./mcp.ts"; |
| 43 | import { home } from "./page.ts"; | |
| 51 | import { | |
| 52 | emptyPage, | |
| 53 | graphPage, | |
| 54 | landing, | |
| 55 | missingPage, | |
| 56 | notePage, | |
| 57 | searchPage, | |
| 58 | } from "./page.ts"; | |
| 44 | 59 | import { tokensCss } from "./tokens.ts"; |
| 60 | import { search } from "./vault.ts"; | |
| 45 | 61 | import type { Who } from "./who.ts"; |
| 46 | 62 | |
| 47 | 63 | export interface Context { |
| @@ -79,7 +95,9 @@ export const NOTE: Schema = { | ||
| 79 | 95 | type: "object", |
| 80 | 96 | properties: { |
| 81 | 97 | id: { type: "integer" }, |
| 82 | body: { type: "string", minLength: 1, maxLength: BODY_MAX }, | |
| 98 | title: { type: "string", minLength: 1, maxLength: TITLE_MAX }, | |
| 99 | body: { type: "string", maxLength: BODY_MAX }, | |
| 100 | pinned: { enum: [0, 1] }, | |
| 83 | 101 | author_id: { type: "string" }, |
| 84 | 102 | author_name: { type: "string" }, |
| 85 | 103 | created_at: { type: "string", format: "date-time" }, |
| @@ -87,17 +105,38 @@ export const NOTE: Schema = { | ||
| 87 | 105 | }, |
| 88 | 106 | required: [ |
| 89 | 107 | "id", |
| 108 | "title", | |
| 90 | 109 | "body", |
| 110 | "pinned", | |
| 91 | 111 | "author_id", |
| 92 | 112 | "author_name", |
| 93 | 113 | "created_at", |
| 94 | 114 | "updated_at", |
| 95 | 115 | ], |
| 96 | 116 | }; |
| 97 | const NOTE_BODY: Schema = { | |
| 117 | const NOTE_NEW: Schema = { | |
| 98 | 118 | type: "object", |
| 99 | properties: { body: { type: "string", minLength: 1, maxLength: BODY_MAX } }, | |
| 100 | required: ["body"], | |
| 119 | properties: { | |
| 120 | title: { | |
| 121 | type: "string", | |
| 122 | maxLength: TITLE_MAX, | |
| 123 | description: "Unique in your vault; “Untitled” when left out.", | |
| 124 | }, | |
| 125 | body: { type: "string", maxLength: BODY_MAX, description: "Markdown." }, | |
| 126 | }, | |
| 127 | }; | |
| 128 | const NOTE_CHANGE: Schema = { | |
| 129 | type: "object", | |
| 130 | properties: { | |
| 131 | title: { | |
| 132 | type: "string", | |
| 133 | minLength: 1, | |
| 134 | maxLength: TITLE_MAX, | |
| 135 | description: "A rename carries every [[link]] to the note with it.", | |
| 136 | }, | |
| 137 | body: { type: "string", maxLength: BODY_MAX }, | |
| 138 | pinned: { type: "boolean" }, | |
| 139 | }, | |
| 101 | 140 | }; |
| 102 | 141 | export const AUDIT_ROW: Schema = { |
| 103 | 142 | type: "object", |
| @@ -144,9 +183,34 @@ const answer = <T>(result: Result<T>, status = 200): Response => | ||
| 144 | 183 | STATUS[result.error], |
| 145 | 184 | ); |
| 146 | 185 | |
| 147 | /** A result for a form: back to the page, with what went wrong if it did. */ | |
| 148 | const done = <T>(result: Result<T>): Response => | |
| 149 | result.ok ? back() : back(result.message); | |
| 186 | /** A result for a form: on to where it leads, or back with what went wrong. */ | |
| 187 | const done = <T>(result: Result<T>, to: (value: T) => string, back = "/"): Response => | |
| 188 | result.ok ? go(to(result.value)) : go(back, result.message); | |
| 189 | ||
| 190 | const toNote = (n: { readonly id: number }): string => `/n/${n.id}`; | |
| 191 | ||
| 192 | /** The fields of a JSON body that change a note, and only those. */ | |
| 193 | const change = (id: number, body: Record<string, unknown>): Change => ({ | |
| 194 | id, | |
| 195 | ...(typeof body.title === "string" ? { title: body.title } : {}), | |
| 196 | ...(typeof body.body === "string" ? { body: body.body } : {}), | |
| 197 | ...(typeof body.pinned === "boolean" ? { pinned: body.pinned } : {}), | |
| 198 | }); | |
| 199 | ||
| 200 | /** The vault around a page: who is signed in, and every note of theirs. */ | |
| 201 | function vaultOf(c: Context): | |
| 202 | | { who: Who & { role: "user" | "admin" }; vault: Note[]; notice?: string; clear: Record<string, string> } | |
| 203 | | Response { | |
| 204 | const { notice, clear } = readNotice(c.request); | |
| 205 | if (c.who.role === "anon") return html(landing(notice), 200, clear); | |
| 206 | const vault = listNotes(c.db, c.who); | |
| 207 | return { | |
| 208 | who: c.who, | |
| 209 | vault: vault.ok ? vault.value : [], | |
| 210 | ...(notice === undefined ? {} : { notice }), | |
| 211 | clear, | |
| 212 | }; | |
| 213 | } | |
| 150 | 214 | |
| 151 | 215 | /** A whole number from the query, or undefined. */ |
| 152 | 216 | const whole = (value: string | null): number | undefined => |
| @@ -157,50 +221,215 @@ const OPENAPI = new URL("../contract/openapi.json", import.meta.url); | ||
| 157 | 221 | const openapi = existsSync(OPENAPI) ? readFileSync(OPENAPI, "utf8") : undefined; |
| 158 | 222 | |
| 159 | 223 | export const routes: readonly Route[] = [ |
| 160 | // The page. Forms post and come back; the list stays live over /events. | |
| 224 | // The pages. Signed out, the way in; signed in, the vault. Forms post and | |
| 225 | // land where they lead, so every page works without JavaScript. | |
| 161 | 226 | { |
| 162 | 227 | method: "GET", |
| 163 | 228 | path: "/", |
| 164 | 229 | handle: (c) => { |
| 165 | const { notice, clear } = readNotice(c.request); | |
| 166 | const notes = listNotes(c.db, c.who); | |
| 167 | return html( | |
| 168 | home({ who: c.who, notes: notes.ok ? notes.value : [], notice }), | |
| 169 | 200, | |
| 170 | clear, | |
| 230 | const v = vaultOf(c); | |
| 231 | if (v instanceof Response) return v; | |
| 232 | const first = v.vault.reduce<Note | undefined>( | |
| 233 | (latest, n) => (latest === undefined || n.updated_at > latest.updated_at ? n : latest), | |
| 234 | undefined, | |
| 235 | ); | |
| 236 | // The vault opens on the note you were last writing. | |
| 237 | if (first !== undefined && v.notice === undefined) return go(toNote(first)); | |
| 238 | if (first !== undefined) return html(notePage({ ...v, active: first }), 200, v.clear); | |
| 239 | return html(emptyPage(v), 200, v.clear); | |
| 240 | }, | |
| 241 | }, | |
| 242 | { | |
| 243 | method: "GET", | |
| 244 | path: "/n/:id", | |
| 245 | handle: (c) => { | |
| 246 | const v = vaultOf(c); | |
| 247 | if (v instanceof Response) return v; | |
| 248 | const note = getNote(c.db, c.who, { id: Number(c.params.id) }); | |
| 249 | if (!note.ok) return go("/", note.message); | |
| 250 | return html(notePage({ ...v, active: note.value }), 200, v.clear); | |
| 251 | }, | |
| 252 | }, | |
| 253 | { | |
| 254 | method: "GET", | |
| 255 | path: "/n/:id/raw", | |
| 256 | handle: (c) => { | |
| 257 | const note = getNote(c.db, c.who, { id: Number(c.params.id) }); | |
| 258 | if (!note.ok) return answer(note); | |
| 259 | return new Response(note.value.body, { | |
| 260 | headers: { | |
| 261 | "content-type": "text/markdown; charset=utf-8", | |
| 262 | "content-disposition": `attachment; filename="${note.value.title.replace(/[^\w .-]+/g, "_")}.md"; filename*=UTF-8''${encodeURIComponent(`${note.value.title}.md`)}`, | |
| 263 | ...SECURITY, | |
| 264 | }, | |
| 265 | }); | |
| 266 | }, | |
| 267 | }, | |
| 268 | { | |
| 269 | method: "POST", | |
| 270 | path: "/n/:id", | |
| 271 | handle: async (c) => { | |
| 272 | const form = await readForm(c.request, NOTE_BYTES); | |
| 273 | if (form === undefined) return text("too large", 413); | |
| 274 | const id = Number(c.params.id); | |
| 275 | return done( | |
| 276 | updateNote(c.db, c.who, { | |
| 277 | id, | |
| 278 | title: String(form.get("title") ?? ""), | |
| 279 | body: String(form.get("body") ?? ""), | |
| 280 | }), | |
| 281 | toNote, | |
| 282 | toNote({ id }), | |
| 171 | 283 | ); |
| 172 | 284 | }, |
| 173 | 285 | }, |
| 174 | 286 | { |
| 175 | 287 | method: "POST", |
| 176 | path: "/notes", | |
| 288 | path: "/n/:id/pin", | |
| 177 | 289 | handle: async (c) => { |
| 178 | 290 | const form = await readForm(c.request); |
| 179 | 291 | if (form === undefined) return text("too large", 413); |
| 292 | const id = Number(c.params.id); | |
| 180 | 293 | return done( |
| 181 | addNote(c.db, c.who, { body: String(form.get("body") ?? "") }), | |
| 294 | updateNote(c.db, c.who, { id, pinned: form.get("pinned") === "1" }), | |
| 295 | toNote, | |
| 296 | toNote({ id }), | |
| 182 | 297 | ); |
| 183 | 298 | }, |
| 184 | 299 | }, |
| 185 | 300 | { |
| 186 | 301 | method: "POST", |
| 187 | path: "/notes/:id/delete", | |
| 188 | handle: (c) => done(deleteNote(c.db, c.who, { id: Number(c.params.id) })), | |
| 302 | path: "/n/:id/delete", | |
| 303 | handle: (c) => | |
| 304 | done(deleteNote(c.db, c.who, { id: Number(c.params.id) }), () => "/"), | |
| 305 | }, | |
| 306 | { | |
| 307 | method: "POST", | |
| 308 | path: "/new", | |
| 309 | handle: async (c) => { | |
| 310 | const form = await readForm(c.request); | |
| 311 | if (form === undefined) return text("too large", 413); | |
| 312 | const title = String(form.get("title") ?? ""); | |
| 313 | return done(addNote(c.db, c.who, title === "" ? {} : { title }), toNote); | |
| 314 | }, | |
| 315 | }, | |
| 316 | { | |
| 317 | method: "POST", | |
| 318 | path: "/daily", | |
| 319 | handle: async (c) => { | |
| 320 | const form = await readForm(c.request); | |
| 321 | if (form === undefined) return text("too large", 413); | |
| 322 | const tz = Number(form.get("tz") ?? 0); | |
| 323 | const day = today(Number.isFinite(tz) && Math.abs(tz) <= 840 ? tz : 0); | |
| 324 | return done(openOrCreate(c.db, c.who, { title: day, body: `# ${day}\n\n` }), toNote); | |
| 325 | }, | |
| 326 | }, | |
| 327 | { | |
| 328 | method: "POST", | |
| 329 | path: "/welcome", | |
| 330 | handle: (c) => | |
| 331 | done(openOrCreate(c.db, c.who, { title: "Welcome", body: WELCOME }), toNote), | |
| 332 | }, | |
| 333 | { | |
| 334 | method: "GET", | |
| 335 | path: "/open", | |
| 336 | handle: (c) => { | |
| 337 | const v = vaultOf(c); | |
| 338 | if (v instanceof Response) return v; | |
| 339 | const title = (c.url.searchParams.get("title") ?? "").trim(); | |
| 340 | const found = v.vault.find((n) => n.title.toLowerCase() === title.toLowerCase()); | |
| 341 | if (found !== undefined) return go(toNote(found)); | |
| 342 | if (title === "") return go("/"); | |
| 343 | return html(missingPage({ ...v, title }), 200, v.clear); | |
| 344 | }, | |
| 345 | }, | |
| 346 | { | |
| 347 | method: "POST", | |
| 348 | path: "/open", | |
| 349 | handle: async (c) => { | |
| 350 | const form = await readForm(c.request); | |
| 351 | if (form === undefined) return text("too large", 413); | |
| 352 | return done(openOrCreate(c.db, c.who, { title: String(form.get("title") ?? "") }), toNote); | |
| 353 | }, | |
| 354 | }, | |
| 355 | { | |
| 356 | method: "GET", | |
| 357 | path: "/search", | |
| 358 | handle: (c) => { | |
| 359 | const v = vaultOf(c); | |
| 360 | if (v instanceof Response) return v; | |
| 361 | const query = (c.url.searchParams.get("q") ?? "").slice(0, 500).trim(); | |
| 362 | if (query === "") return go("/"); | |
| 363 | return html(searchPage({ ...v, query, hits: search(v.vault, query) }), 200, v.clear); | |
| 364 | }, | |
| 365 | }, | |
| 366 | { | |
| 367 | method: "GET", | |
| 368 | path: "/graph", | |
| 369 | handle: (c) => { | |
| 370 | const v = vaultOf(c); | |
| 371 | if (v instanceof Response) return v; | |
| 372 | return html(graphPage(v), 200, v.clear); | |
| 373 | }, | |
| 374 | }, | |
| 375 | { | |
| 376 | method: "GET", | |
| 377 | path: "/export", | |
| 378 | handle: (c) => { | |
| 379 | const notes = listNotes(c.db, c.who); | |
| 380 | if (!notes.ok) return answer(notes); | |
| 381 | const day = new Date().toISOString().slice(0, 10); | |
| 382 | return new Response( | |
| 383 | JSON.stringify( | |
| 384 | { | |
| 385 | exported_at: new Date().toISOString(), | |
| 386 | notes: notes.value.map(({ title, body, pinned, created_at, updated_at }) => ({ | |
| 387 | title, | |
| 388 | body, | |
| 389 | pinned: pinned === 1, | |
| 390 | created_at, | |
| 391 | updated_at, | |
| 392 | })), | |
| 393 | }, | |
| 394 | null, | |
| 395 | 2, | |
| 396 | ), | |
| 397 | { | |
| 398 | headers: { | |
| 399 | "content-type": "application/json; charset=utf-8", | |
| 400 | "content-disposition": `attachment; filename="notes-${day}.json"`, | |
| 401 | "cache-control": "no-store", | |
| 402 | ...SECURITY, | |
| 403 | }, | |
| 404 | }, | |
| 405 | ); | |
| 406 | }, | |
| 189 | 407 | }, |
| 190 | 408 | |
| 191 | 409 | // The JSON API: the same actions, the same identity, plain status codes. |
| 192 | 410 | { |
| 193 | 411 | method: "GET", |
| 194 | 412 | path: "/api/notes", |
| 195 | handle: (c) => | |
| 196 | answer( | |
| 197 | listNotes(c.db, c.who, { | |
| 198 | limit: Number(c.url.searchParams.get("limit") ?? 100), | |
| 199 | }), | |
| 200 | ), | |
| 413 | handle: (c) => { | |
| 414 | const q = c.url.searchParams.get("q"); | |
| 415 | const limit = whole(c.url.searchParams.get("limit")); | |
| 416 | if (q === null || q.trim() === "") | |
| 417 | return answer(listNotes(c.db, c.who, limit === undefined ? {} : { limit })); | |
| 418 | // A search answers the same notes, best match first. | |
| 419 | const hits = searchNotes(c.db, c.who, { q, ...(limit === undefined ? {} : { limit }) }); | |
| 420 | if (!hits.ok) return answer(hits); | |
| 421 | const notes = hits.value | |
| 422 | .map((h) => getNote(c.db, c.who, { id: h.id })) | |
| 423 | .flatMap((r) => (r.ok ? [r.value] : [])); | |
| 424 | return json(notes); | |
| 425 | }, | |
| 201 | 426 | api: { |
| 202 | summary: "The notes, newest first.", | |
| 203 | query: { limit: LIMIT }, | |
| 427 | summary: | |
| 428 | "Your notes, pinned first then most recently changed; with `q`, the ones a search finds (words, \"a phrase\", #tag), best first.", | |
| 429 | query: { | |
| 430 | limit: { type: "integer", minimum: 1, maximum: 5000 }, | |
| 431 | q: { type: "string", maxLength: 500 }, | |
| 432 | }, | |
| 204 | 433 | answer: list(NOTE), |
| 205 | 434 | }, |
| 206 | 435 | }, |
| @@ -208,16 +437,20 @@ export const routes: readonly Route[] = [ | ||
| 208 | 437 | method: "POST", |
| 209 | 438 | path: "/api/notes", |
| 210 | 439 | handle: async (c) => { |
| 211 | const body = await readJson(c.request); | |
| 440 | const body = await readJson(c.request, NOTE_BYTES); | |
| 212 | 441 | if (body === undefined) return json({ error: "too_large" }, 413); |
| 442 | const f = fields(body); | |
| 213 | 443 | return answer( |
| 214 | addNote(c.db, c.who, { body: String(fields(body).body ?? "") }), | |
| 444 | addNote(c.db, c.who, { | |
| 445 | ...(typeof f.title === "string" ? { title: f.title } : {}), | |
| 446 | ...(typeof f.body === "string" ? { body: f.body } : {}), | |
| 447 | }), | |
| 215 | 448 | 201, |
| 216 | 449 | ); |
| 217 | 450 | }, |
| 218 | 451 | api: { |
| 219 | summary: "Add a note as the person asking.", | |
| 220 | body: NOTE_BODY, | |
| 452 | summary: "Add a note to your vault.", | |
| 453 | body: NOTE_NEW, | |
| 221 | 454 | answer: NOTE, |
| 222 | 455 | status: 201, |
| 223 | 456 | }, |
| @@ -226,22 +459,31 @@ export const routes: readonly Route[] = [ | ||
| 226 | 459 | method: "GET", |
| 227 | 460 | path: "/api/notes/:id", |
| 228 | 461 | handle: (c) => answer(getNote(c.db, c.who, { id: Number(c.params.id) })), |
| 229 | api: { summary: "One note.", answer: NOTE }, | |
| 462 | api: { summary: "One of your notes.", answer: NOTE }, | |
| 230 | 463 | }, |
| 231 | 464 | { |
| 232 | 465 | method: "PATCH", |
| 233 | 466 | path: "/api/notes/:id", |
| 234 | 467 | handle: async (c) => { |
| 235 | const body = await readJson(c.request); | |
| 468 | const body = await readJson(c.request, NOTE_BYTES); | |
| 236 | 469 | if (body === undefined) return json({ error: "too_large" }, 413); |
| 237 | return answer( | |
| 238 | updateNote(c.db, c.who, { | |
| 239 | id: Number(c.params.id), | |
| 240 | body: String(fields(body).body ?? ""), | |
| 241 | }), | |
| 242 | ); | |
| 470 | return answer(updateNote(c.db, c.who, change(Number(c.params.id), fields(body)))); | |
| 471 | }, | |
| 472 | api: { | |
| 473 | summary: | |
| 474 | "Change a note's title, words or pin; a rename rewrites the links to it. `relinked` says how many notes that touched.", | |
| 475 | body: NOTE_CHANGE, | |
| 476 | answer: { | |
| 477 | allOf: [ | |
| 478 | NOTE, | |
| 479 | { | |
| 480 | type: "object", | |
| 481 | properties: { relinked: { type: "integer" } }, | |
| 482 | required: ["relinked"], | |
| 483 | }, | |
| 484 | ], | |
| 485 | }, | |
| 243 | 486 | }, |
| 244 | api: { summary: "Change a note's words.", body: NOTE_BODY, answer: NOTE }, | |
| 245 | 487 | }, |
| 246 | 488 | { |
| 247 | 489 | method: "DELETE", |
| @@ -284,7 +526,7 @@ export const routes: readonly Route[] = [ | ||
| 284 | 526 | }, |
| 285 | 527 | |
| 286 | 528 | // Live events, and the MCP server (the tools are the actions too). |
| 287 | { method: "GET", path: "/events", handle: (c) => events(c.request) }, | |
| 529 | { method: "GET", path: "/events", handle: (c) => events(c.request, c.who) }, | |
| 288 | 530 | { |
| 289 | 531 | method: "POST", |
| 290 | 532 | path: "/mcp", |
src/security.test.ts +28 −20
| @@ -74,7 +74,9 @@ test("the door refuses only what no row would open", () => { | ||
| 74 | 74 | const anon = { role: "anon" } as const; |
| 75 | 75 | const alice = { role: "user", id: "u1", name: "alice" } as const; |
| 76 | 76 | const root = { role: "admin", id: "u0", name: "root" } as const; |
| 77 | expect(mayKnock(anon, "note", "list")).toBe(true); | |
| 77 | // A vault is its author's: a stranger has no door to any of it. | |
| 78 | expect(mayKnock(anon, "note", "list")).toBe(false); | |
| 79 | expect(mayKnock(anon, "note", "read")).toBe(false); | |
| 78 | 80 | expect(mayKnock(anon, "note", "create")).toBe(false); |
| 79 | 81 | expect(mayKnock(anon, "note", "delete")).toBe(false); |
| 80 | 82 | // alice may delete a note of her own, so the door lets her ask; the action decides which. |
| @@ -85,33 +87,39 @@ test("the door refuses only what no row would open", () => { | ||
| 85 | 87 | }); |
| 86 | 88 | |
| 87 | 89 | test("a stranger is refused /api before the body is read, when the contract closes it", async () => { |
| 90 | expect((await fetch(`${base}/api/audit`)).status).toBe(401); | |
| 91 | expect((await fetch(`${base}/api/audit/1`)).status).toBe(401); | |
| 92 | expect( | |
| 93 | (await fetch(`${base}/api/audit`, { headers: as("u1", "user") })).status, | |
| 94 | ).toBe(403); | |
| 95 | // A stranger reads no note, and — the table opening them no door — not the API's description either. | |
| 96 | expect((await fetch(`${base}/api/notes`)).status).toBe(401); | |
| 97 | expect((await fetch(`${base}/api/notes/0`)).status).toBe(401); | |
| 98 | expect((await fetch(`${base}/openapi.json`)).status).toBe(401); | |
| 99 | // What the table opens to a person stays open to them. | |
| 100 | const alice = as("u1", "user"); | |
| 101 | expect((await fetch(`${base}/api/notes`, { headers: alice })).status).toBe(200); | |
| 102 | expect((await fetch(`${base}/api/notes/0`, { headers: alice })).status).toBe(404); | |
| 103 | expect((await fetch(`${base}/openapi.json`, { headers: alice })).status).toBe(200); | |
| 88 | 104 | // A body past the cap: read, it would be 413. The door answers first. |
| 89 | const huge = "x".repeat(128 * 1024); | |
| 105 | // (Last, because the refusal leaves the body unread on its connection.) | |
| 106 | const huge = "x".repeat(2 * 1024 * 1024); | |
| 107 | const signedIn = await fetch(`${base}/api/notes`, { | |
| 108 | method: "POST", | |
| 109 | headers: { "content-type": "application/json", ...alice }, | |
| 110 | body: huge, | |
| 111 | }); | |
| 112 | expect(signedIn.status).toBe(413); | |
| 90 | 113 | const refused = await fetch(`${base}/api/notes`, { |
| 91 | 114 | method: "POST", |
| 92 | 115 | headers: { "content-type": "application/json" }, |
| 93 | 116 | body: huge, |
| 94 | 117 | }); |
| 95 | 118 | expect(refused.status).toBe(401); |
| 96 | const signedIn = await fetch(`${base}/api/notes`, { | |
| 97 | method: "POST", | |
| 98 | headers: { "content-type": "application/json", ...as("u1", "user") }, | |
| 99 | body: huge, | |
| 100 | }); | |
| 101 | expect(signedIn.status).toBe(413); | |
| 102 | 119 | expect(await refused.json()).toEqual({ |
| 103 | 120 | error: "signin_required", |
| 104 | 121 | message: "Sign in first.", |
| 105 | 122 | }); |
| 106 | expect((await fetch(`${base}/api/audit`)).status).toBe(401); | |
| 107 | expect((await fetch(`${base}/api/audit/1`)).status).toBe(401); | |
| 108 | expect( | |
| 109 | (await fetch(`${base}/api/audit`, { headers: as("u1", "user") })).status, | |
| 110 | ).toBe(403); | |
| 111 | // What the table opens stays open. | |
| 112 | expect((await fetch(`${base}/api/notes`)).status).toBe(200); | |
| 113 | expect((await fetch(`${base}/api/notes/0`)).status).toBe(404); | |
| 114 | expect((await fetch(`${base}/openapi.json`)).status).toBe(200); | |
| 115 | 123 | }); |
| 116 | 124 | |
| 117 | 125 | test("the API's description is shut to strangers when the table opens them no door", () => { |
| @@ -137,7 +145,7 @@ test("the API's description is shut to strangers when the table opens them no do | ||
| 137 | 145 | ); |
| 138 | 146 | if (!closed.ok) throw new Error(closed.problems.join("; ")); |
| 139 | 147 | expect(openToStrangers(closed.contract)).toBe(false); |
| 140 | expect(openToStrangers()).toBe(true); | |
| 148 | expect(openToStrangers()).toBe(false); | |
| 141 | 149 | }); |
| 142 | 150 | |
| 143 | 151 | test("strangers are rationed at the API, reads too", () => { |
| @@ -145,8 +153,8 @@ test("strangers are rationed at the API, reads too", () => { | ||
| 145 | 153 | const knock = () => |
| 146 | 154 | door( |
| 147 | 155 | db, |
| 148 | new Request("http://app/api/notes"), | |
| 149 | new URL("http://app/api/notes"), | |
| 156 | new Request("http://app/mcp"), | |
| 157 | new URL("http://app/mcp"), | |
| 150 | 158 | { role: "anon" }, |
| 151 | 159 | "203.0.113.7", |
| 152 | 160 | ); |
src/server.test.ts +127 −105
| @@ -52,116 +52,121 @@ test("the page knows who you are and offers the right door", async () => { | ||
| 52 | 52 | const guest = await (await fetch(base + "/")).text(); |
| 53 | 53 | expect(guest).toContain("<h1>"); |
| 54 | 54 | expect(guest).toContain("/__forge/signin?to=/"); |
| 55 | expect(guest).not.toContain('action="/notes"'); | |
| 56 | const signed = await (await api("/", {}, alice)).text(); | |
| 57 | expect(signed).toContain("<b>alice</b>"); | |
| 55 | expect(guest).not.toContain('action="/new"'); | |
| 56 | const signed = await (await api("/", {}, as("u5", "erin"))).text(); | |
| 57 | expect(signed).toContain("Your vault is empty"); | |
| 58 | expect(signed).toContain("erin"); | |
| 58 | 59 | expect(signed).toContain("/__forge/signout"); |
| 59 | expect(signed).toContain('action="/notes"'); | |
| 60 | expect(signed).toContain('action="/welcome"'); | |
| 60 | 61 | }); |
| 61 | 62 | |
| 62 | test("a form post adds as the person; a guest is sent to sign in", async () => { | |
| 63 | test("a form post writes as the person and lands on the note; a guest is sent to sign in", async () => { | |
| 63 | 64 | const form = new FormData(); |
| 64 | form.set("body", "hello from a form"); | |
| 65 | const refused = await api("/notes", { | |
| 66 | method: "POST", | |
| 67 | body: form, | |
| 68 | redirect: "manual", | |
| 69 | }); | |
| 65 | form.set("title", "From a form"); | |
| 66 | const refused = await api("/new", { method: "POST", body: form, redirect: "manual" }); | |
| 70 | 67 | expect(refused.status).toBe(303); |
| 71 | 68 | expect(refused.headers.get("set-cookie")).toContain("notice="); |
| 72 | 69 | expect(refused.headers.get("set-cookie")).toContain("SameSite=Lax"); |
| 73 | expect(await (await fetch(base + "/")).text()).not.toContain( | |
| 74 | "hello from a form", | |
| 75 | ); | |
| 76 | const posted = await api( | |
| 77 | "/notes", | |
| 78 | { method: "POST", body: form, redirect: "manual" }, | |
| 79 | alice, | |
| 80 | ); | |
| 70 | const posted = await api("/new", { method: "POST", body: form, redirect: "manual" }, alice); | |
| 81 | 71 | expect(posted.status).toBe(303); |
| 82 | expect(posted.headers.get("set-cookie")).toBeNull(); | |
| 83 | const page = await (await api("/", {}, alice)).text(); | |
| 84 | expect(page).toContain("hello from a form"); | |
| 85 | expect(page).toContain("alice ·"); | |
| 72 | const at = posted.headers.get("location")!; | |
| 73 | expect(at).toMatch(/^\/n\/\d+$/); | |
| 74 | const page = await (await api(at, {}, alice)).text(); | |
| 75 | expect(page).toContain('value="From a form"'); | |
| 76 | expect(page).toContain("<title>From a form · "); | |
| 77 | // The vault opens on the note last written. | |
| 78 | const home = await api("/", { redirect: "manual" }, alice); | |
| 79 | expect(home.headers.get("location")).toBe(at); | |
| 80 | // Saving without JavaScript: the form posts the whole note. | |
| 81 | const save = new FormData(); | |
| 82 | save.set("title", "From a form"); | |
| 83 | save.set("body", "Now with [[Words]] and #tags"); | |
| 84 | expect((await api(at, { method: "POST", body: save, redirect: "manual" }, alice)).status).toBe(303); | |
| 85 | const saved = await (await api(at, {}, alice)).text(); | |
| 86 | expect(saved).toContain('class="wikilink unresolved"'); | |
| 87 | expect(saved).toContain('href="/search?q=%23tags"'); | |
| 88 | // Another person's note is not theirs to open. | |
| 89 | const peek = await api(at, { redirect: "manual" }, bob); | |
| 90 | expect(peek.status).toBe(303); | |
| 91 | expect(peek.headers.get("location")).toBe("/"); | |
| 92 | }); | |
| 93 | ||
| 94 | test("a link to a note not written yet offers to write it", async () => { | |
| 95 | const page = await (await api("/open?title=Someday", {}, alice)).text(); | |
| 96 | expect(page).toContain("doesn't exist yet"); | |
| 97 | const form = new FormData(); | |
| 98 | form.set("title", "Someday"); | |
| 99 | const made = await api("/open", { method: "POST", body: form, redirect: "manual" }, alice); | |
| 100 | expect(made.headers.get("location")).toMatch(/^\/n\/\d+$/); | |
| 101 | const again = await api("/open?title=someday", { redirect: "manual" }, alice); | |
| 102 | expect(again.headers.get("location")).toBe(made.headers.get("location")); | |
| 86 | 103 | }); |
| 87 | 104 | |
| 88 | 105 | test("the JSON API is the same actions with the same identity", async () => { |
| 89 | 106 | const created = await api( |
| 90 | 107 | "/api/notes", |
| 91 | { method: "POST", ...jsonBody({ body: "over the api" }) }, | |
| 108 | { method: "POST", ...jsonBody({ title: "Over the API", body: "hello #api" }) }, | |
| 92 | 109 | alice, |
| 93 | 110 | ); |
| 94 | 111 | expect(created.status).toBe(201); |
| 95 | const note = (await created.json()) as { id: number; author_name: string }; | |
| 112 | const note = (await created.json()) as { id: number; author_name: string; title: string }; | |
| 96 | 113 | expect(note.author_name).toBe("alice"); |
| 97 | const listed = (await (await fetch(base + "/api/notes")).json()) as { | |
| 98 | id: number; | |
| 99 | }[]; | |
| 114 | const listed = (await (await api("/api/notes", {}, alice)).json()) as { id: number }[]; | |
| 100 | 115 | expect(listed.map((n) => n.id)).toContain(note.id); |
| 116 | const found = (await (await api("/api/notes?q=%23api", {}, alice)).json()) as { id: number }[]; | |
| 117 | expect(found.map((n) => n.id)).toEqual([note.id]); | |
| 118 | expect(((await (await api("/api/notes", {}, bob)).json()) as unknown[]).length).toBe(0); | |
| 119 | const renamed = await api( | |
| 120 | `/api/notes/${note.id}`, | |
| 121 | { method: "PATCH", ...jsonBody({ title: "Renamed", pinned: true }) }, | |
| 122 | alice, | |
| 123 | ); | |
| 124 | expect(renamed.status).toBe(200); | |
| 125 | expect(((await renamed.json()) as { pinned: number }).pinned).toBe(1); | |
| 126 | expect((await api("/api/notes", { method: "POST", ...jsonBody({ title: "anon" }) })).status).toBe(401); | |
| 127 | expect((await api(`/api/notes/${note.id}`, {}, bob)).status).toBe(403); | |
| 128 | expect((await api(`/api/notes/${note.id}`, { method: "DELETE" }, bob)).status).toBe(403); | |
| 129 | const raw = await api(`/n/${note.id}/raw`, {}, alice); | |
| 130 | expect(raw.headers.get("content-type")).toContain("text/markdown"); | |
| 131 | expect(await raw.text()).toBe("hello #api"); | |
| 132 | const dump = (await (await api("/export", {}, alice)).json()) as { notes: { title: string }[] }; | |
| 133 | expect(dump.notes.map((n) => n.title)).toContain("Renamed"); | |
| 134 | expect((await api(`/api/notes/${note.id}`, { method: "DELETE" }, alice)).status).toBe(200); | |
| 135 | expect((await api(`/api/notes/${note.id}`, { method: "DELETE" }, alice)).status).toBe(404); | |
| 101 | 136 | expect( |
| 102 | (await api("/api/notes", { method: "POST", ...jsonBody({ body: "anon" }) })) | |
| 103 | .status, | |
| 104 | ).toBe(401); | |
| 105 | expect( | |
| 106 | (await api(`/api/notes/${note.id}`, { method: "DELETE" }, bob)).status, | |
| 107 | ).toBe(403); | |
| 108 | expect( | |
| 109 | (await api(`/api/notes/${note.id}`, { method: "DELETE" }, alice)).status, | |
| 110 | ).toBe(200); | |
| 111 | expect( | |
| 112 | (await api(`/api/notes/${note.id}`, { method: "DELETE" }, alice)).status, | |
| 113 | ).toBe(404); | |
| 114 | expect( | |
| 115 | ( | |
| 116 | await api( | |
| 117 | "/api/notes", | |
| 118 | { method: "POST", ...jsonBody({ body: "" }) }, | |
| 119 | alice, | |
| 120 | ) | |
| 121 | ).status, | |
| 137 | (await api("/api/notes", { method: "POST", ...jsonBody({ title: "a|b" }) }, alice)).status, | |
| 122 | 138 | ).toBe(422); |
| 123 | 139 | }); |
| 124 | 140 | |
| 125 | 141 | test("the MCP server lists the tools and calls them as the caller", async () => { |
| 126 | const hello = await rpc({ | |
| 127 | jsonrpc: "2.0", | |
| 128 | id: 1, | |
| 129 | method: "initialize", | |
| 130 | params: {}, | |
| 131 | }); | |
| 142 | const hello = await rpc({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }); | |
| 132 | 143 | expect(hello.result.capabilities).toEqual({ tools: {} }); |
| 133 | 144 | const listed = await rpc({ jsonrpc: "2.0", id: 2, method: "tools/list" }); |
| 134 | 145 | expect(listed.result.tools.map((t: { name: string }) => t.name)).toEqual([ |
| 135 | 146 | "list_notes", |
| 136 | "add_note", | |
| 147 | "search_notes", | |
| 148 | "read_note", | |
| 149 | "create_note", | |
| 150 | "update_note", | |
| 151 | "append_to_note", | |
| 137 | 152 | "delete_note", |
| 138 | 153 | ]); |
| 139 | 154 | const call = (name: string, args: unknown, who = {}) => |
| 140 | rpc( | |
| 141 | { | |
| 142 | jsonrpc: "2.0", | |
| 143 | id: 3, | |
| 144 | method: "tools/call", | |
| 145 | params: { name, arguments: args }, | |
| 146 | }, | |
| 147 | who, | |
| 148 | ); | |
| 149 | const refused = await call("add_note", { body: "as nobody" }); | |
| 150 | expect(refused.result.isError).toBe(true); | |
| 151 | const added = await call("add_note", { body: "over mcp" }, root); | |
| 155 | rpc({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name, arguments: args } }, who); | |
| 156 | expect((await call("create_note", { title: "as nobody" })).result.isError).toBe(true); | |
| 157 | expect((await call("list_notes", {})).result.isError).toBe(true); | |
| 158 | const added = await call("create_note", { title: "Over MCP", body: "first" }, root); | |
| 152 | 159 | expect(added.result.isError).toBeUndefined(); |
| 153 | 160 | const note = JSON.parse(added.result.content[0].text); |
| 154 | 161 | expect(note.author_name).toBe("root"); |
| 155 | const notes = JSON.parse( | |
| 156 | (await call("list_notes", {})).result.content[0].text, | |
| 157 | ); | |
| 158 | expect(notes.map((n: { id: number }) => n.id)).toContain(note.id); | |
| 159 | expect( | |
| 160 | (await call("delete_note", { id: note.id }, root)).result.isError, | |
| 161 | ).toBeUndefined(); | |
| 162 | expect( | |
| 163 | (await rpc({ jsonrpc: "2.0", id: 4, method: "nothing" })).error.code, | |
| 164 | ).toBe(-32601); | |
| 162 | await call("append_to_note", { title: "Over MCP", text: "second" }, root); | |
| 163 | const read = JSON.parse((await call("read_note", { title: "over mcp" }, root)).result.content[0].text); | |
| 164 | expect(read.body).toBe("first\n\nsecond\n"); | |
| 165 | const titles = JSON.parse((await call("list_notes", {}, root)).result.content[0].text); | |
| 166 | expect(titles.map((n: { id: number }) => n.id)).toContain(note.id); | |
| 167 | expect(JSON.parse((await call("search_notes", { q: "second" }, root)).result.content[0].text)[0].id).toBe(note.id); | |
| 168 | expect((await call("delete_note", { id: note.id }, root)).result.isError).toBeUndefined(); | |
| 169 | expect((await rpc({ jsonrpc: "2.0", id: 4, method: "nothing" })).error.code).toBe(-32601); | |
| 165 | 170 | const notified = await api("/mcp", { |
| 166 | 171 | method: "POST", |
| 167 | 172 | ...jsonBody({ jsonrpc: "2.0", method: "notifications/initialized" }), |
| @@ -169,35 +174,52 @@ test("the MCP server lists the tools and calls them as the caller", async () => | ||
| 169 | 174 | expect(notified.status).toBe(202); |
| 170 | 175 | }); |
| 171 | 176 | |
| 172 | test("a change is on the event stream, and a late client hears what it missed", async () => { | |
| 173 | const stream = await fetch(base + "/events"); | |
| 174 | expect(stream.headers.get("content-type")).toBe("text/event-stream"); | |
| 175 | const reader = stream.body!.getReader(); | |
| 176 | const next = async (): Promise<{ id: string; type: string }> => { | |
| 177 | test("a change is on its author's event stream only, and a late client hears what it missed", async () => { | |
| 178 | const read = (stream: Response) => { | |
| 179 | const reader = stream.body!.getReader(); | |
| 177 | 180 | let buffer = ""; |
| 178 | for (;;) { | |
| 179 | const { value } = await reader.read(); | |
| 180 | buffer += new TextDecoder().decode(value); | |
| 181 | const frame = /id: (\d+)\ndata: (.*)\n\n/.exec(buffer); | |
| 182 | if (frame) return { id: frame[1]!, type: JSON.parse(frame[2]!).type }; | |
| 183 | } | |
| 181 | return { | |
| 182 | reader, | |
| 183 | next: async (): Promise<{ id: string; type: string; data: any }> => { | |
| 184 | for (;;) { | |
| 185 | const frame = /id: (\d+)\ndata: (.*)\n\n/.exec(buffer); | |
| 186 | if (frame) { | |
| 187 | buffer = buffer.slice(frame.index + frame[0].length); | |
| 188 | const { type, data } = JSON.parse(frame[2]!); | |
| 189 | return { id: frame[1]!, type, data }; | |
| 190 | } | |
| 191 | const { value, done } = await reader.read(); | |
| 192 | if (done) throw new Error("the stream ended"); | |
| 193 | buffer += new TextDecoder().decode(value); | |
| 194 | } | |
| 195 | }, | |
| 196 | }; | |
| 184 | 197 | }; |
| 185 | await api( | |
| 186 | "/api/notes", | |
| 187 | { method: "POST", ...jsonBody({ body: "live" }) }, | |
| 188 | alice, | |
| 189 | ); | |
| 190 | const heard = await next(); | |
| 198 | const mine = read(await api("/events", {}, alice)); | |
| 199 | const stranger = read(await fetch(base + "/events")); | |
| 200 | expect((await fetch(base + "/events")).headers.get("content-type")).toBe("text/event-stream"); | |
| 201 | await api("/api/notes", { method: "POST", ...jsonBody({ title: "Live one", body: "private words" }) }, alice); | |
| 202 | // A new stream first replays what this person missed; read to the new note. | |
| 203 | let heard = await mine.next(); | |
| 204 | while (heard.data?.title !== "Live one") heard = await mine.next(); | |
| 191 | 205 | expect(heard.type).toBe("note.added"); |
| 192 | await reader.cancel(); | |
| 193 | const late = await fetch(base + "/events", { | |
| 194 | headers: { "last-event-id": String(Number(heard.id) - 1) }, | |
| 195 | }); | |
| 196 | const replay = new TextDecoder().decode( | |
| 197 | (await late.body!.getReader().read()).value, | |
| 198 | ); | |
| 206 | expect(heard.data.title).toBe("Live one"); | |
| 207 | // Nobody else's stream hears it — not even that it happened. | |
| 208 | const nothing = await Promise.race([ | |
| 209 | stranger.next().then( | |
| 210 | () => "heard", | |
| 211 | () => "silence", | |
| 212 | ), | |
| 213 | Bun.sleep(300).then(() => "silence"), | |
| 214 | ]); | |
| 215 | expect(nothing).toBe("silence"); | |
| 216 | await mine.reader.cancel(); | |
| 217 | await stranger.reader.cancel(); | |
| 218 | const late = await api("/events", { headers: { "last-event-id": String(Number(heard.id) - 1) } }, alice); | |
| 219 | const replay = new TextDecoder().decode((await late.body!.getReader().read()).value); | |
| 199 | 220 | expect(replay).toContain(`id: ${heard.id}\n`); |
| 200 | 221 | expect(replay).toContain("note.added"); |
| 222 | expect(JSON.stringify(heard)).not.toContain("private words"); | |
| 201 | 223 | }); |
| 202 | 224 | |
| 203 | 225 | test("writes are rationed per person", async () => { |
| @@ -229,11 +251,11 @@ test("stands on the security floor", async () => { | ||
| 229 | 251 | |
| 230 | 252 | test("a body is capped by the bytes read, whatever it claims", async () => { |
| 231 | 253 | const response = await api( |
| 232 | "/notes", | |
| 254 | "/new", | |
| 233 | 255 | { |
| 234 | 256 | method: "POST", |
| 235 | 257 | headers: { "content-type": "application/x-www-form-urlencoded" }, |
| 236 | body: "body=" + "x".repeat(1024 * 1024), | |
| 258 | body: "title=" + "x".repeat(1024 * 1024), | |
| 237 | 259 | }, |
| 238 | 260 | alice, |
| 239 | 261 | ); |
| @@ -248,9 +270,9 @@ test("HEAD sees the app up", async () => { | ||
| 248 | 270 | |
| 249 | 271 | test("refuses a cross-site post", async () => { |
| 250 | 272 | const form = new FormData(); |
| 251 | form.set("body", "from another site"); | |
| 273 | form.set("title", "from another site"); | |
| 252 | 274 | const posted = await api( |
| 253 | "/notes", | |
| 275 | "/new", | |
| 254 | 276 | { |
| 255 | 277 | method: "POST", |
| 256 | 278 | body: form, |
src/tty.ts +98 −46
| @@ -1,8 +1,9 @@ | ||
| 1 | 1 | /** |
| 2 | * The app in a terminal. The forge runs this on a pty when someone | |
| 2 | * The vault in a terminal. The forge runs this on a pty when someone | |
| 3 | 3 | * `ssh`es to the app, over the same data as the page and through the same |
| 4 | * actions, as the account at the keyboard. j/k move, a adds, d removes the | |
| 5 | * note under the cursor, q leaves. Plain ANSI, no dependencies. | |
| 4 | * actions, as the account at the keyboard. j/k move, enter reads the note | |
| 5 | * under the cursor, / searches, a adds a line to today's note, n makes a | |
| 6 | * new note, d removes one, q leaves. Plain ANSI, no dependencies. | |
| 6 | 7 | */ |
| 7 | 8 | |
| 8 | 9 | import { |
| @@ -10,6 +11,10 @@ import { | ||
| 10 | 11 | canDelete, |
| 11 | 12 | deleteNote, |
| 12 | 13 | listNotes, |
| 14 | openOrCreate, | |
| 15 | searchNotes, | |
| 16 | today, | |
| 17 | updateNote, | |
| 13 | 18 | type Note, |
| 14 | 19 | } from "./actions.ts"; |
| 15 | 20 | import { openDb } from "./db.ts"; |
| @@ -26,49 +31,85 @@ const out = (s: string): void => { | ||
| 26 | 31 | }; |
| 27 | 32 | |
| 28 | 33 | let cursor = 0; |
| 29 | let typing: string | undefined; | |
| 34 | let reading: Note | undefined; | |
| 35 | let scroll = 0; | |
| 36 | let typing: { what: "append" | "new" | "search"; text: string } | undefined; | |
| 37 | let filter = ""; | |
| 30 | 38 | let said = ""; |
| 31 | 39 | |
| 32 | 40 | const notes = (): Note[] => { |
| 33 | 41 | const listed = listNotes(db, me); |
| 34 | return listed.ok ? listed.value : []; | |
| 42 | if (!listed.ok) return []; | |
| 43 | if (filter === "") return listed.value; | |
| 44 | const hits = searchNotes(db, me, { q: filter, limit: 500 }); | |
| 45 | if (!hits.ok) return []; | |
| 46 | const ids = hits.value.map((h) => h.id); | |
| 47 | return ids.flatMap((id) => listed.value.filter((n) => n.id === id)); | |
| 35 | 48 | }; |
| 36 | 49 | |
| 50 | const rows = (): number => process.stdout.rows ?? 24; | |
| 51 | ||
| 37 | 52 | function draw(): void { |
| 38 | const rows = notes(); | |
| 39 | 53 | const lines = [ |
| 40 | `${bold(title)} ${dim(where)} ${dim(me.role === "anon" ? "guest" : `${me.name}${me.role === "admin" ? " (admin)" : ""}`)}`, | |
| 54 | `${bold(title)} ${dim(where)} ${dim(me.role === "anon" ? "guest" : me.name)}`, | |
| 41 | 55 | ssh === undefined ? "" : dim(`connect: ${ssh}`), |
| 42 | 56 | "", |
| 43 | 57 | ]; |
| 44 | if (rows.length === 0) | |
| 45 | lines.push( | |
| 46 | dim( | |
| 47 | me.role === "anon" | |
| 48 | ? "No notes yet." | |
| 49 | : "No notes yet. Press a to add one.", | |
| 50 | ), | |
| 51 | ); | |
| 52 | rows.forEach((note, i) => { | |
| 53 | const body = i === cursor ? bold(note.body) : note.body; | |
| 54 | lines.push( | |
| 55 | `${i === cursor ? "▸" : " "} ${body} ${dim(`${note.author_name} · ${note.created_at.slice(0, 16).replace("T", " ")}`)}`, | |
| 56 | ); | |
| 58 | if (me.role === "anon") { | |
| 59 | lines.push("Sign in with a key the forge knows to open your vault.", "", dim("q quit")); | |
| 60 | out(`${esc}2J${esc}H${lines.join("\r\n")}`); | |
| 61 | return; | |
| 62 | } | |
| 63 | if (reading !== undefined) { | |
| 64 | const body = reading.body.split("\n"); | |
| 65 | const room = Math.max(3, rows() - 6); | |
| 66 | lines.push(bold(reading.title), ""); | |
| 67 | lines.push(...body.slice(scroll, scroll + room)); | |
| 68 | lines.push("", dim(`j/k scroll · esc back · ${scroll + 1}-${Math.min(body.length, scroll + room)} of ${body.length}`)); | |
| 69 | out(`${esc}2J${esc}H${lines.join("\r\n")}`); | |
| 70 | return; | |
| 71 | } | |
| 72 | const list = notes(); | |
| 73 | if (filter !== "") lines.push(dim(`search: ${filter} (esc clears)`)); | |
| 74 | if (list.length === 0) lines.push(dim(filter === "" ? "No notes yet. Press n to write one." : "Nothing matches.")); | |
| 75 | const room = Math.max(3, rows() - 8); | |
| 76 | const start = Math.max(0, Math.min(cursor - Math.floor(room / 2), list.length - room)); | |
| 77 | list.slice(start, start + room).forEach((note, k) => { | |
| 78 | const i = start + k; | |
| 79 | const name = `${note.pinned === 1 ? "★ " : ""}${note.title}`; | |
| 80 | lines.push(`${i === cursor ? "▸" : " "} ${i === cursor ? bold(name) : name} ${dim(note.updated_at.slice(0, 16).replace("T", " "))}`); | |
| 57 | 81 | }); |
| 58 | 82 | lines.push(""); |
| 59 | if (typing !== undefined) lines.push(`New note: ${typing}${esc}7m ${esc}0m`); | |
| 83 | if (typing !== undefined) | |
| 84 | lines.push(`${{ append: `Add to ${today()}`, new: "New note title", search: "Search" }[typing.what]}: ${typing.text}${esc}7m ${esc}0m`); | |
| 60 | 85 | else if (said !== "") lines.push(said); |
| 61 | else | |
| 62 | lines.push( | |
| 63 | dim( | |
| 64 | me.role === "anon" | |
| 65 | ? "j/k move · q quit · sign in with a key the forge knows to add" | |
| 66 | : "j/k move · a add · d delete · q quit", | |
| 67 | ), | |
| 68 | ); | |
| 86 | else lines.push(dim("j/k move · enter read · / search · a add to today · n new · d delete · q quit")); | |
| 69 | 87 | out(`${esc}2J${esc}H${lines.join("\r\n")}`); |
| 70 | 88 | } |
| 71 | 89 | |
| 90 | function submit(what: "append" | "new" | "search", text: string): void { | |
| 91 | if (what === "search") { | |
| 92 | filter = text.trim(); | |
| 93 | cursor = 0; | |
| 94 | return; | |
| 95 | } | |
| 96 | if (text.trim() === "") return; | |
| 97 | if (what === "new") { | |
| 98 | const added = addNote(db, me, { title: text }); | |
| 99 | said = added.ok ? `Wrote “${added.value.title}”.` : added.message; | |
| 100 | return; | |
| 101 | } | |
| 102 | const day = today(); | |
| 103 | const opened = openOrCreate(db, me, { title: day, body: `# ${day}\n` }); | |
| 104 | if (!opened.ok) { | |
| 105 | said = opened.message; | |
| 106 | return; | |
| 107 | } | |
| 108 | const body = opened.value.body.replace(/\s*$/, ""); | |
| 109 | const saved = updateNote(db, me, { id: opened.value.id, body: `${body}\n\n- ${text.trim()}\n` }); | |
| 110 | said = saved.ok ? `Added to ${day}.` : saved.message; | |
| 111 | } | |
| 112 | ||
| 72 | 113 | process.stdin.setRawMode?.(true); |
| 73 | 114 | process.stdin.resume(); |
| 74 | 115 | process.stdin.setEncoding("utf8"); |
| @@ -77,13 +118,11 @@ process.stdin.on("data", (key: string) => { | ||
| 77 | 118 | said = ""; |
| 78 | 119 | if (typing !== undefined) { |
| 79 | 120 | if (key === "\r") { |
| 80 | const added = addNote(db, me, { body: typing }); | |
| 81 | if (!added.ok) said = added.message; | |
| 121 | submit(typing.what, typing.text); | |
| 82 | 122 | typing = undefined; |
| 83 | cursor = 0; | |
| 84 | 123 | } else if (key === "\x1b") typing = undefined; |
| 85 | else if (key === "\x7f") typing = typing.slice(0, -1); | |
| 86 | else if (key >= " ") typing += key; | |
| 124 | else if (key === "\x7f") typing.text = typing.text.slice(0, -1); | |
| 125 | else if (key >= " ") typing.text += key; | |
| 87 | 126 | draw(); |
| 88 | 127 | return; |
| 89 | 128 | } |
| @@ -91,23 +130,36 @@ process.stdin.on("data", (key: string) => { | ||
| 91 | 130 | out(`${esc}2J${esc}H`); |
| 92 | 131 | process.exit(0); |
| 93 | 132 | } |
| 94 | const rows = notes(); | |
| 95 | if (key === "j" || key === "\x1b[B") | |
| 96 | cursor = Math.min(cursor + 1, Math.max(0, rows.length - 1)); | |
| 97 | if (key === "k" || key === "\x1b[A") cursor = Math.max(cursor - 1, 0); | |
| 98 | if (key === "a") { | |
| 99 | if (me.role === "anon") said = "Sign in to add a note."; | |
| 100 | else typing = ""; | |
| 133 | if (me.role === "anon") return draw(); | |
| 134 | if (reading !== undefined) { | |
| 135 | const max = Math.max(0, reading.body.split("\n").length - 1); | |
| 136 | if (key === "j" || key === "\x1b[B") scroll = Math.min(scroll + 1, max); | |
| 137 | if (key === "k" || key === "\x1b[A") scroll = Math.max(scroll - 1, 0); | |
| 138 | if (key === "\x1b" || key === "h") reading = undefined; | |
| 139 | return draw(); | |
| 101 | 140 | } |
| 141 | const list = notes(); | |
| 142 | if (key === "j" || key === "\x1b[B") cursor = Math.min(cursor + 1, Math.max(0, list.length - 1)); | |
| 143 | if (key === "k" || key === "\x1b[A") cursor = Math.max(cursor - 1, 0); | |
| 144 | if (key === "\r" && list[cursor] !== undefined) { | |
| 145 | reading = list[cursor]; | |
| 146 | scroll = 0; | |
| 147 | } | |
| 148 | if (key === "\x1b" && filter !== "") { | |
| 149 | filter = ""; | |
| 150 | cursor = 0; | |
| 151 | } | |
| 152 | if (key === "/") typing = { what: "search", text: filter }; | |
| 153 | if (key === "a") typing = { what: "append", text: "" }; | |
| 154 | if (key === "n") typing = { what: "new", text: "" }; | |
| 102 | 155 | if (key === "d") { |
| 103 | const note = rows[cursor]; | |
| 156 | const note = list[cursor]; | |
| 104 | 157 | if (note === undefined) said = "Nothing to remove."; |
| 105 | else if (!canDelete(me, note)) | |
| 106 | said = "Only the author or an admin can remove a note."; | |
| 158 | else if (!canDelete(me, note)) said = "Only the author can remove a note."; | |
| 107 | 159 | else { |
| 108 | 160 | const removed = deleteNote(db, me, { id: note.id }); |
| 109 | if (!removed.ok) said = removed.message; | |
| 110 | cursor = Math.min(cursor, Math.max(0, rows.length - 2)); | |
| 161 | said = removed.ok ? `Removed “${note.title}”.` : removed.message; | |
| 162 | cursor = Math.min(cursor, Math.max(0, list.length - 2)); | |
| 111 | 163 | } |
| 112 | 164 | } |
| 113 | 165 | draw(); |
src/vault.test.ts added +127
| @@ -0,0 +1,127 @@ | ||
| 1 | /** | |
| 2 | * The vault's own sense of itself — links, backlinks, tags, the graph — | |
| 3 | * and the markdown it is written in, which the browser renders with the | |
| 4 | * very same file. | |
| 5 | */ | |
| 6 | import { expect, test } from "bun:test"; | |
| 7 | // @ts-ignore — plain JS, shared with the browser. | |
| 8 | import { render } from "../static/md.js"; | |
| 9 | import { | |
| 10 | backlinks, | |
| 11 | excerpt, | |
| 12 | graph, | |
| 13 | headings, | |
| 14 | links, | |
| 15 | outgoing, | |
| 16 | relink, | |
| 17 | renderNote, | |
| 18 | search, | |
| 19 | tagCounts, | |
| 20 | tags, | |
| 21 | type VaultNote, | |
| 22 | } from "./vault.ts"; | |
| 23 | ||
| 24 | const note = (id: number, title: string, body: string, updated_at = `2026-10-0${id}`): VaultNote => ({ | |
| 25 | id, | |
| 26 | title, | |
| 27 | body, | |
| 28 | pinned: 0, | |
| 29 | updated_at, | |
| 30 | }); | |
| 31 | ||
| 32 | test("links and tags are found in prose, never in code", () => { | |
| 33 | const body = "See [[Alpha]], [[beta|B]] and [[Alpha#Part]].\n`[[Not]] #nope`\n```\n[[Fenced]] #fenced\n```\n#real and #nested/tag, not #123 or a#b"; | |
| 34 | expect(links(body)).toEqual(["Alpha", "beta"]); | |
| 35 | expect(tags(body)).toEqual(["real", "nested/tag"]); | |
| 36 | expect(headings("# One\ntext\n## Two ##\n```\n# not\n```")).toEqual([ | |
| 37 | { level: 1, text: "One", id: "one" }, | |
| 38 | { level: 2, text: "Two", id: "two" }, | |
| 39 | ]); | |
| 40 | }); | |
| 41 | ||
| 42 | test("backlinks, links out, tag counts and the graph agree", () => { | |
| 43 | const a = note(1, "Alpha", "to [[Beta]] and [[Missing]] #x"); | |
| 44 | const b = note(2, "Beta", "back to [[alpha]] #x #y"); | |
| 45 | const c = note(3, "Gamma", "to [[BETA]]"); | |
| 46 | const vault = [a, b, c]; | |
| 47 | expect(backlinks(b, vault).map((n) => n.id)).toEqual([1, 3]); | |
| 48 | expect(backlinks(c, vault)).toEqual([]); | |
| 49 | expect(outgoing(a, vault)).toEqual({ found: [b], missing: ["Missing"] }); | |
| 50 | expect(tagCounts(vault)).toEqual([ | |
| 51 | { tag: "x", count: 2 }, | |
| 52 | { tag: "y", count: 1 }, | |
| 53 | ]); | |
| 54 | const g = graph(vault); | |
| 55 | expect(g.edges).toEqual([ | |
| 56 | [1, 2], | |
| 57 | [3, 2], | |
| 58 | ]); | |
| 59 | expect(g.nodes.find((n) => n.id === 2)?.links).toBe(2); | |
| 60 | }); | |
| 61 | ||
| 62 | test("relink rewrites only links to the old title", () => { | |
| 63 | expect(relink("[[Old]] [[old|alias]] [[Old#h]] ![[Old]] [[Older]] Old", "Old", "New")).toBe( | |
| 64 | "[[New]] [[New|alias]] [[New#h]] ![[New]] [[Older]] Old", | |
| 65 | ); | |
| 66 | }); | |
| 67 | ||
| 68 | test("search ranks title matches first, then the most recent", () => { | |
| 69 | const vault = [note(1, "Plant", "water"), note(2, "Garden", "plant beds"), note(3, "Shed", "plant pots")]; | |
| 70 | expect(search(vault, "plant").map((h) => h.note.id)).toEqual([1, 3, 2]); | |
| 71 | expect(search(vault, "")).toEqual([]); | |
| 72 | expect(excerpt("# Head\n**bold** [[A|alias]] text")).toBe("Head bold alias text"); | |
| 73 | }); | |
| 74 | ||
| 75 | test("markdown renders the everyday core", () => { | |
| 76 | const html = render( | |
| 77 | "# Title\n\nSome **bold**, *em*, ~~gone~~, ==lit== and `code`.\n\n- one\n - nested\n- [x] done\n- [ ] todo\n\n1. first\n2. second\n\n> quoted\n\n---\n\n| a | b |\n|---|--:|\n| 1 | 2 |\n\n```js\nlet x = 1 < 2;\n```", | |
| 78 | ); | |
| 79 | for (const piece of [ | |
| 80 | '<h1 id="title">Title</h1>', | |
| 81 | "<strong>bold</strong>", | |
| 82 | "<em>em</em>", | |
| 83 | "<del>gone</del>", | |
| 84 | "<mark>lit</mark>", | |
| 85 | "<code>code</code>", | |
| 86 | "<ul><li>one<ul><li>nested</li></ul></li>", | |
| 87 | 'aria-checked="true"', | |
| 88 | 'aria-checked="false"', | |
| 89 | "<ol><li>first</li><li>second</li></ol>", | |
| 90 | "<blockquote><p>quoted</p>", | |
| 91 | "<hr>", | |
| 92 | '<td style="text-align:right">2</td>', | |
| 93 | '<pre><code class="lang-js">let x = 1 < 2;</code></pre>', | |
| 94 | ]) | |
| 95 | expect([piece, html.includes(piece)]).toEqual([piece, true]); | |
| 96 | }); | |
| 97 | ||
| 98 | test("wikilinks point at notes, or at making them; callouts and tags are Obsidian's", () => { | |
| 99 | const vault = [note(7, "Known", "")]; | |
| 100 | const html = renderNote("[[Known]] [[known#Part B|that part]] [[Unknown]]\n\n> [!warning] Careful\n> body\n\n#tag", vault); | |
| 101 | expect(html).toContain('<a class="wikilink" href="/n/7" data-title="Known">Known</a>'); | |
| 102 | expect(html).toContain('href="/n/7#part-b"'); | |
| 103 | expect(html).toContain(">that part</a>"); | |
| 104 | expect(html).toContain('class="wikilink unresolved" href="/open?title=Unknown"'); | |
| 105 | expect(html).toContain('<div class="callout callout-warning"><p class="callout-title">Careful</p><p>body</p></div>'); | |
| 106 | expect(html).toContain('<a class="tag" href="/search?q=%23tag">#tag</a>'); | |
| 107 | }); | |
| 108 | ||
| 109 | test("nothing a note says becomes markup the renderer did not write", () => { | |
| 110 | const evil = [ | |
| 111 | '<script>alert(1)</script>', | |
| 112 | '<img src=x onerror=alert(1)>', | |
| 113 | "[click](javascript:alert(1))", | |
| 114 | '[x](https://a.example/" onmouseover="alert(1))', | |
| 115 | '[[x" onmouseover="alert(1)]]', | |
| 116 | "[[<b>bold</b>]]", | |
| 117 | "| <i>a</i> |\n|---|\n| <svg onload=alert(1)> |", | |
| 118 | "> [!<img src=x onerror=1>] t", | |
| 119 | "```\n</code></pre><script>x</script>\n```", | |
| 120 | ].join("\n\n"); | |
| 121 | const html = render(evil); | |
| 122 | expect(html).not.toMatch(/<(script|img|svg|i|b)\b/i); | |
| 123 | expect(html).not.toMatch(/href="(?!https?:|\/|mailto:)/); | |
| 124 | expect(html).not.toMatch(/\son\w+="/i); | |
| 125 | // The safe link kinds still work. | |
| 126 | expect(render("[a](https://x.example) [b](/n/1) [c](mailto:[email protected])")).toContain('href="https://x.example"'); | |
| 127 | }); |
src/vault.ts added +203
| @@ -0,0 +1,203 @@ | ||
| 1 | /** | |
| 2 | * What a vault knows about itself, from its notes alone: which note links | |
| 3 | * to which, what links back, which tags are in use, how the whole thing | |
| 4 | * hangs together as a graph, and what a search finds. Pure functions over a | |
| 5 | * list of notes — no database, no identity — so every door that shows a | |
| 6 | * vault shows the same one, and a test needs nothing but notes. | |
| 7 | * | |
| 8 | * Links are by title, the way a person writes them: `[[Some note]]`, | |
| 9 | * matched without regard to case. The markdown itself is `static/md.js`, | |
| 10 | * shared with the browser. | |
| 11 | */ | |
| 12 | ||
| 13 | // @ts-ignore — plain JS module, shared verbatim with the browser. | |
| 14 | import * as md from "../static/md.js"; | |
| 15 | ||
| 16 | export interface VaultNote { | |
| 17 | readonly id: number; | |
| 18 | readonly title: string; | |
| 19 | readonly body: string; | |
| 20 | readonly pinned: number; | |
| 21 | readonly updated_at: string; | |
| 22 | } | |
| 23 | ||
| 24 | export interface Heading { | |
| 25 | readonly level: number; | |
| 26 | readonly text: string; | |
| 27 | readonly id: string; | |
| 28 | } | |
| 29 | ||
| 30 | export const links = (body: string): string[] => md.links(body); | |
| 31 | export const tags = (body: string): string[] => md.tags(body); | |
| 32 | export const headings = (body: string): Heading[] => md.headings(body); | |
| 33 | ||
| 34 | const key = (title: string): string => title.trim().toLowerCase(); | |
| 35 | ||
| 36 | /** Title (any case) → note. */ | |
| 37 | export const byTitle = <T extends VaultNote>(notes: readonly T[]) => | |
| 38 | new Map(notes.map((n) => [key(n.title), n])); | |
| 39 | ||
| 40 | /** A note's address. */ | |
| 41 | export const hrefOf = (note: { readonly id: number }): string => `/n/${note.id}`; | |
| 42 | ||
| 43 | /** A note's markdown as HTML, its links pointed at the notes they name. */ | |
| 44 | export function renderNote( | |
| 45 | body: string, | |
| 46 | notes: readonly VaultNote[], | |
| 47 | ): string { | |
| 48 | const titles = byTitle(notes); | |
| 49 | return md.render(body, { | |
| 50 | resolve: (title: string) => { | |
| 51 | const found = titles.get(key(title)); | |
| 52 | return found === undefined ? null : hrefOf(found); | |
| 53 | }, | |
| 54 | }); | |
| 55 | } | |
| 56 | ||
| 57 | /** The notes that link to `note`, by title. */ | |
| 58 | export function backlinks<T extends VaultNote>( | |
| 59 | note: VaultNote, | |
| 60 | notes: readonly T[], | |
| 61 | ): T[] { | |
| 62 | const me = key(note.title); | |
| 63 | return notes.filter( | |
| 64 | (n) => n.id !== note.id && links(n.body).some((l) => key(l) === me), | |
| 65 | ); | |
| 66 | } | |
| 67 | ||
| 68 | /** What `note` links to: the notes that exist, and the titles that do not yet. */ | |
| 69 | export function outgoing<T extends VaultNote>( | |
| 70 | note: VaultNote, | |
| 71 | notes: readonly T[], | |
| 72 | ): { found: T[]; missing: string[] } { | |
| 73 | const titles = byTitle(notes); | |
| 74 | const found: T[] = []; | |
| 75 | const missing: string[] = []; | |
| 76 | for (const title of links(note.body)) { | |
| 77 | const hit = titles.get(key(title)); | |
| 78 | if (hit === undefined) missing.push(title); | |
| 79 | else if (hit.id !== note.id) found.push(hit); | |
| 80 | } | |
| 81 | return { found, missing }; | |
| 82 | } | |
| 83 | ||
| 84 | /** Every tag in the vault with how many notes carry it, most used first. */ | |
| 85 | export function tagCounts(notes: readonly VaultNote[]): { tag: string; count: number }[] { | |
| 86 | const counts = new Map<string, number>(); | |
| 87 | for (const n of notes) | |
| 88 | for (const t of tags(n.body)) counts.set(t, (counts.get(t) ?? 0) + 1); | |
| 89 | return [...counts] | |
| 90 | .map(([tag, count]) => ({ tag, count })) | |
| 91 | .sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag)); | |
| 92 | } | |
| 93 | ||
| 94 | export interface Graph { | |
| 95 | readonly nodes: { id: number; title: string; links: number }[]; | |
| 96 | readonly edges: [number, number][]; | |
| 97 | } | |
| 98 | ||
| 99 | /** The vault as nodes and undirected edges, for the graph view. */ | |
| 100 | export function graph(notes: readonly VaultNote[]): Graph { | |
| 101 | const titles = byTitle(notes); | |
| 102 | const seen = new Set<string>(); | |
| 103 | const edges: [number, number][] = []; | |
| 104 | const degree = new Map<number, number>(); | |
| 105 | for (const n of notes) | |
| 106 | for (const title of links(n.body)) { | |
| 107 | const to = titles.get(key(title)); | |
| 108 | if (to === undefined || to.id === n.id) continue; | |
| 109 | const pair = n.id < to.id ? `${n.id}-${to.id}` : `${to.id}-${n.id}`; | |
| 110 | if (seen.has(pair)) continue; | |
| 111 | seen.add(pair); | |
| 112 | edges.push([n.id, to.id]); | |
| 113 | degree.set(n.id, (degree.get(n.id) ?? 0) + 1); | |
| 114 | degree.set(to.id, (degree.get(to.id) ?? 0) + 1); | |
| 115 | } | |
| 116 | return { | |
| 117 | nodes: notes.map((n) => ({ id: n.id, title: n.title, links: degree.get(n.id) ?? 0 })), | |
| 118 | edges, | |
| 119 | }; | |
| 120 | } | |
| 121 | ||
| 122 | export interface Hit<T> { | |
| 123 | readonly note: T; | |
| 124 | /** A line of the body around the first match, plain text. */ | |
| 125 | readonly snippet: string; | |
| 126 | } | |
| 127 | ||
| 128 | /** | |
| 129 | * Search: every word must appear in the title or body (any case); `#tag` | |
| 130 | * or `tag:name` must be a tag the note carries; a phrase in quotes is | |
| 131 | * matched whole. Title matches rank first, then the most recently changed. | |
| 132 | */ | |
| 133 | export function search<T extends VaultNote>( | |
| 134 | notes: readonly T[], | |
| 135 | query: string, | |
| 136 | ): Hit<T>[] { | |
| 137 | const wanted: string[] = []; | |
| 138 | const tagged: string[] = []; | |
| 139 | for (const m of query.matchAll(/"([^"]+)"|(\S+)/g)) { | |
| 140 | const word = (m[1] ?? m[2] ?? "").toLowerCase(); | |
| 141 | const tag = /^(?:#|tag:)(.+)$/.exec(word)?.[1]; | |
| 142 | if (tag !== undefined && m[1] === undefined) tagged.push(tag); | |
| 143 | else if (word !== "") wanted.push(word); | |
| 144 | } | |
| 145 | if (wanted.length === 0 && tagged.length === 0) return []; | |
| 146 | const hits: (Hit<T> & { title: boolean })[] = []; | |
| 147 | for (const note of notes) { | |
| 148 | const title = note.title.toLowerCase(); | |
| 149 | const body = note.body.toLowerCase(); | |
| 150 | if (!wanted.every((w) => title.includes(w) || body.includes(w))) continue; | |
| 151 | if (tagged.length > 0) { | |
| 152 | const has = tags(note.body); | |
| 153 | // A tag matches itself and its children: #work finds #work/acme. | |
| 154 | if (!tagged.every((t) => has.some((h) => h === t || h.startsWith(`${t}/`)))) continue; | |
| 155 | } | |
| 156 | hits.push({ | |
| 157 | note, | |
| 158 | snippet: snippet(note.body, wanted[0] ?? (tagged[0] === undefined ? "" : `#${tagged[0]}`)), | |
| 159 | title: wanted.length > 0 && wanted.every((w) => title.includes(w)), | |
| 160 | }); | |
| 161 | } | |
| 162 | hits.sort( | |
| 163 | (a, b) => | |
| 164 | Number(b.title) - Number(a.title) || | |
| 165 | b.note.updated_at.localeCompare(a.note.updated_at), | |
| 166 | ); | |
| 167 | return hits.map(({ note, snippet }) => ({ note, snippet })); | |
| 168 | } | |
| 169 | ||
| 170 | function snippet(body: string, word: string): string { | |
| 171 | const at = word === "" ? -1 : body.toLowerCase().indexOf(word); | |
| 172 | if (at < 0) return body.split("\n").find((l) => l.trim() !== "")?.trim().slice(0, 160) ?? ""; | |
| 173 | const start = Math.max(0, body.lastIndexOf("\n", at) + 1, at - 70); | |
| 174 | const endLine = body.indexOf("\n", at); | |
| 175 | const end = Math.min(endLine < 0 ? body.length : endLine, at + 90); | |
| 176 | return `${start > 0 && body[start - 1] !== "\n" ? "…" : ""}${body.slice(start, end).trim()}${end < (endLine < 0 ? body.length : endLine) ? "…" : ""}`; | |
| 177 | } | |
| 178 | ||
| 179 | /** | |
| 180 | * The body with every link to `from` pointed at `to` instead, keeping any | |
| 181 | * alias and heading: what a rename does to the notes that link to it. | |
| 182 | */ | |
| 183 | export function relink(body: string, from: string, to: string): string { | |
| 184 | const old = key(from); | |
| 185 | return body.replace( | |
| 186 | /(!?)\[\[([^\[\]\n|#]+?)((?:#[^\[\]\n|]*)?)((?:\|[^\[\]\n]+?)?)\]\]/g, | |
| 187 | (whole, embed: string, title: string, heading: string, alias: string) => | |
| 188 | key(title) === old ? `${embed}[[${to}${heading}${alias}]]` : whole, | |
| 189 | ); | |
| 190 | } | |
| 191 | ||
| 192 | /** A plain-text preview of a note's first words, for lists. */ | |
| 193 | export function excerpt(body: string, max = 120): string { | |
| 194 | const text = body | |
| 195 | .replace(/^(```|~~~)[\s\S]*?^\1/gm, "") | |
| 196 | .replace(/^#{1,6}\s+/gm, "") | |
| 197 | .replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, "$2") | |
| 198 | .replace(/\[\[([^\]]+)\]\]/g, "$1") | |
| 199 | .replace(/[*_=~`>]/g, "") | |
| 200 | .replace(/\s+/g, " ") | |
| 201 | .trim(); | |
| 202 | return text.length > max ? `${text.slice(0, max - 1)}…` : text; | |
| 203 | } |
static/app.css +1016 −80
| @@ -3,7 +3,12 @@ | ||
| 3 | 3 | design/tokens.json compiles to at boot; `bun run lint:design` refuses a raw |
| 4 | 4 | colour here. Light and dark are the tokens' business, not this file's. */ |
| 5 | 5 | |
| 6 | /* The page. */ | |
| 6 | /* ── The page ───────────────────────────────────────────────────────── */ | |
| 7 | *, | |
| 8 | *::before, | |
| 9 | *::after { | |
| 10 | box-sizing: border-box; | |
| 11 | } | |
| 7 | 12 | html { |
| 8 | 13 | -webkit-text-size-adjust: 100%; |
| 9 | 14 | text-size-adjust: 100%; |
| @@ -13,27 +18,10 @@ body { | ||
| 13 | 18 | background: var(); |
| 14 | 19 | color: var(); |
| 15 | 20 | font: var() / var() var(); |
| 21 | -webkit-font-smoothing: antialiased; | |
| 16 | 22 | } |
| 17 | .page { | |
| 18 | max-width: 40rem; | |
| 19 | margin: 0 auto; | |
| 20 | padding: var() var() var(); | |
| 21 | } | |
| 22 | .head { | |
| 23 | display: flex; | |
| 24 | flex-wrap: wrap; | |
| 25 | justify-content: space-between; | |
| 26 | align-items: flex-start; | |
| 27 | gap: var() var(); | |
| 28 | margin-bottom: var(); | |
| 29 | } | |
| 30 | h1 { | |
| 31 | margin: 0; | |
| 32 | font-size: var(); | |
| 33 | font-weight: 650; | |
| 34 | letter-spacing: -0.01em; | |
| 35 | line-height: var(); | |
| 36 | } | |
| 23 | h1, | |
| 24 | h2, | |
| 37 | 25 | p { |
| 38 | 26 | margin: 0; |
| 39 | 27 | } |
| @@ -43,77 +31,61 @@ a { | ||
| 43 | 31 | text-underline-offset: 0.15em; |
| 44 | 32 | } |
| 45 | 33 | code, |
| 34 | kbd, | |
| 46 | 35 | .mono { |
| 47 | 36 | font-family: var(); |
| 48 | font-size: var(); | |
| 37 | font-size: 0.88em; | |
| 38 | } | |
| 39 | kbd { | |
| 40 | display: inline-block; | |
| 41 | padding: 0 0.35em; | |
| 42 | border: 1px solid var(); | |
| 43 | border-bottom-width: 2px; | |
| 44 | border-radius: var(); | |
| 45 | color: var(); | |
| 46 | font-size: var(); | |
| 47 | line-height: 1.6; | |
| 49 | 48 | } |
| 50 | 49 | :focus-visible { |
| 51 | 50 | outline: 2px solid var(); |
| 52 | 51 | outline-offset: 2px; |
| 53 | 52 | } |
| 53 | [hidden] { | |
| 54 | display: none ; | |
| 55 | } | |
| 54 | 56 | .muted { |
| 55 | 57 | color: var(); |
| 56 | 58 | } |
| 57 | .you { | |
| 58 | color: var(); | |
| 59 | white-space: nowrap; | |
| 59 | .small { | |
| 60 | font-size: var(); | |
| 60 | 61 | } |
| 61 | ||
| 62 | /* Blocks. */ | |
| 63 | .card { | |
| 64 | background: var(); | |
| 65 | border: 1px solid var(); | |
| 66 | border-radius: var(); | |
| 67 | padding: var(); | |
| 68 | margin-bottom: var(); | |
| 62 | .pad { | |
| 63 | padding: var() var(); | |
| 69 | 64 | } |
| 70 | 65 | .row { |
| 71 | 66 | display: flex; |
| 72 | 67 | align-items: center; |
| 73 | 68 | gap: var(); |
| 69 | flex-wrap: wrap; | |
| 74 | 70 | } |
| 75 | .list { | |
| 76 | list-style: none; | |
| 77 | margin: 0; | |
| 78 | padding: 0; | |
| 71 | .row.center { | |
| 72 | justify-content: center; | |
| 79 | 73 | } |
| 80 | .note { | |
| 81 | display: flex; | |
| 82 | justify-content: space-between; | |
| 83 | align-items: flex-start; | |
| 84 | gap: var(); | |
| 85 | padding: var() 0; | |
| 86 | border-top: 1px solid var(); | |
| 87 | } | |
| 88 | .note:first-child { | |
| 89 | border-top: 0; | |
| 90 | } | |
| 91 | .note p { | |
| 92 | overflow-wrap: anywhere; | |
| 93 | } | |
| 94 | .note small { | |
| 95 | font-size: var(); | |
| 96 | } | |
| 97 | .empty { | |
| 98 | padding: var() var(); | |
| 99 | text-align: center; | |
| 74 | .pill { | |
| 75 | display: inline-block; | |
| 76 | padding: 0 0.5rem; | |
| 77 | font-size: var(); | |
| 78 | font-weight: 600; | |
| 100 | 79 | color: var(); |
| 101 | border: 1px dashed var(); | |
| 102 | border-radius: var(); | |
| 80 | border: 1px solid var(); | |
| 81 | border-radius: var(); | |
| 103 | 82 | } |
| 104 | footer { | |
| 105 | margin-top: var(); | |
| 106 | font-size: var(); | |
| 107 | display: grid; | |
| 108 | gap: var(); | |
| 109 | } | |
| 110 | footer code { | |
| 111 | overflow-wrap: anywhere; | |
| 83 | svg { | |
| 84 | flex: none; | |
| 112 | 85 | } |
| 113 | 86 | |
| 114 | /* Controls: at least 44px on a side, keyboard and mouse alike. */ | |
| 87 | /* ── Controls: at least 44px on a side where a finger meets them ───── */ | |
| 115 | 88 | .input { |
| 116 | flex: 1; | |
| 117 | 89 | min-width: 0; |
| 118 | 90 | min-height: var(); |
| 119 | 91 | padding: 0 var(); |
| @@ -141,10 +113,20 @@ footer code { | ||
| 141 | 113 | text-decoration: none; |
| 142 | 114 | display: inline-flex; |
| 143 | 115 | align-items: center; |
| 116 | justify-content: center; | |
| 117 | gap: var(); | |
| 144 | 118 | } |
| 145 | 119 | .button:hover { |
| 146 | 120 | filter: brightness(1.08); |
| 147 | 121 | } |
| 122 | .button svg { | |
| 123 | width: 18px; | |
| 124 | height: 18px; | |
| 125 | fill: none; | |
| 126 | stroke: currentColor; | |
| 127 | stroke-width: 2; | |
| 128 | stroke-linecap: round; | |
| 129 | } | |
| 148 | 130 | .button-quiet { |
| 149 | 131 | color: var(); |
| 150 | 132 | background: transparent; |
| @@ -152,26 +134,878 @@ footer code { | ||
| 152 | 134 | } |
| 153 | 135 | .button-quiet:hover { |
| 154 | 136 | color: var(); |
| 155 | border-color: var(); | |
| 137 | background: var(); | |
| 156 | 138 | filter: none; |
| 157 | 139 | } |
| 158 | .pill { | |
| 159 | display: inline-block; | |
| 160 | padding: 0.1rem 0.6rem; | |
| 161 | font-size: var(); | |
| 162 | font-weight: 600; | |
| 163 | color: var(); | |
| 164 | border: 1px solid var(); | |
| 165 | border-radius: var(); | |
| 140 | .button-big { | |
| 141 | min-height: 3rem; | |
| 142 | padding: 0 var(); | |
| 143 | font-size: var(); | |
| 166 | 144 | } |
| 167 | .pill[data-env="production"] { | |
| 145 | .button-wide { | |
| 146 | width: 100%; | |
| 147 | } | |
| 148 | .icon-button { | |
| 149 | display: inline-grid; | |
| 150 | place-items: center; | |
| 151 | width: var(); | |
| 152 | height: var(); | |
| 153 | padding: 0; | |
| 154 | color: var(); | |
| 155 | background: transparent; | |
| 156 | border: 0; | |
| 157 | border-radius: var(); | |
| 158 | cursor: pointer; | |
| 159 | text-decoration: none; | |
| 160 | } | |
| 161 | .icon-button svg { | |
| 162 | width: 20px; | |
| 163 | height: 20px; | |
| 164 | fill: none; | |
| 165 | stroke: currentColor; | |
| 166 | stroke-width: 1.8; | |
| 167 | stroke-linecap: round; | |
| 168 | stroke-linejoin: round; | |
| 169 | } | |
| 170 | .icon-button:hover, | |
| 171 | .icon-button.is-on { | |
| 172 | color: var(); | |
| 173 | background: var(); | |
| 174 | } | |
| 175 | .icon-button.is-on { | |
| 168 | 176 | color: var(); |
| 169 | border-color: currentColor; | |
| 177 | } | |
| 178 | .icon-button.danger:hover { | |
| 179 | color: var(); | |
| 180 | } | |
| 181 | form { | |
| 182 | margin: 0; | |
| 183 | } | |
| 184 | ||
| 185 | /* ── The shell: notes | the note | its links ─────────────────────────── */ | |
| 186 | .app-page { | |
| 187 | height: 100dvh; | |
| 188 | overflow: hidden; | |
| 189 | } | |
| 190 | .shell { | |
| 191 | display: grid; | |
| 192 | grid-template-columns: var() minmax(0, 1fr) var(); | |
| 193 | height: 100dvh; | |
| 194 | } | |
| 195 | .shell[data-has-right="false"] { | |
| 196 | grid-template-columns: var() minmax(0, 1fr); | |
| 197 | } | |
| 198 | .left, | |
| 199 | .right { | |
| 200 | min-height: 0; | |
| 201 | background: var(); | |
| 202 | overflow-y: auto; | |
| 203 | overscroll-behavior: contain; | |
| 204 | } | |
| 205 | .left { | |
| 206 | display: flex; | |
| 207 | flex-direction: column; | |
| 208 | border-right: 1px solid var(); | |
| 209 | } | |
| 210 | .right { | |
| 211 | border-left: 1px solid var(); | |
| 212 | padding: var() var() var(); | |
| 213 | } | |
| 214 | .center { | |
| 215 | display: flex; | |
| 216 | flex-direction: column; | |
| 217 | min-width: 0; | |
| 218 | min-height: 0; | |
| 219 | } | |
| 220 | ||
| 221 | /* The left. */ | |
| 222 | .left-head { | |
| 223 | display: flex; | |
| 224 | align-items: center; | |
| 225 | justify-content: space-between; | |
| 226 | padding: var() var() 0 var(); | |
| 227 | } | |
| 228 | .brand { | |
| 229 | display: inline-flex; | |
| 230 | align-items: center; | |
| 231 | gap: var(); | |
| 232 | color: var(); | |
| 233 | font-weight: 700; | |
| 234 | letter-spacing: -0.01em; | |
| 235 | text-decoration: none; | |
| 236 | min-height: var(); | |
| 237 | } | |
| 238 | .brand svg { | |
| 239 | width: 22px; | |
| 240 | height: 22px; | |
| 241 | color: var(); | |
| 242 | } | |
| 243 | .actions { | |
| 244 | display: flex; | |
| 245 | gap: var(); | |
| 246 | padding: var() var(); | |
| 247 | } | |
| 248 | .actions form:first-child { | |
| 249 | flex: 1; | |
| 250 | } | |
| 251 | .find { | |
| 252 | position: relative; | |
| 253 | margin: 0 var() var(); | |
| 254 | } | |
| 255 | .find > svg { | |
| 256 | position: absolute; | |
| 257 | left: var(); | |
| 258 | top: 50%; | |
| 259 | width: 16px; | |
| 260 | height: 16px; | |
| 261 | transform: translateY(-50%); | |
| 262 | fill: none; | |
| 263 | stroke: var(); | |
| 264 | stroke-width: 2; | |
| 265 | pointer-events: none; | |
| 266 | } | |
| 267 | .input-find { | |
| 268 | width: 100%; | |
| 269 | padding-left: 2.1rem; | |
| 270 | padding-right: 3rem; | |
| 271 | background: var(); | |
| 272 | } | |
| 273 | .find .hint { | |
| 274 | position: absolute; | |
| 275 | right: var(); | |
| 276 | top: 50%; | |
| 277 | transform: translateY(-50%); | |
| 278 | cursor: pointer; | |
| 279 | } | |
| 280 | .files { | |
| 281 | flex: 1; | |
| 282 | padding: 0 var() var(); | |
| 283 | } | |
| 284 | .section { | |
| 285 | margin: var() var() var(); | |
| 286 | color: var(); | |
| 287 | font-size: var(); | |
| 288 | font-weight: 650; | |
| 289 | letter-spacing: 0.06em; | |
| 290 | text-transform: uppercase; | |
| 291 | } | |
| 292 | .count { | |
| 293 | margin-left: 0.3em; | |
| 294 | font-weight: 500; | |
| 295 | letter-spacing: 0; | |
| 296 | opacity: 0.8; | |
| 297 | } | |
| 298 | .file-list { | |
| 299 | list-style: none; | |
| 300 | margin: 0; | |
| 301 | padding: 0; | |
| 302 | } | |
| 303 | .file { | |
| 304 | display: flex; | |
| 305 | align-items: baseline; | |
| 306 | gap: var(); | |
| 307 | min-height: 2.25rem; | |
| 308 | padding: var() var(); | |
| 309 | border-radius: var(); | |
| 310 | color: var(); | |
| 311 | text-decoration: none; | |
| 312 | line-height: var(); | |
| 313 | align-items: center; | |
| 314 | } | |
| 315 | .file:hover { | |
| 316 | background: var(); | |
| 317 | } | |
| 318 | .file.is-active { | |
| 319 | background: var(); | |
| 320 | font-weight: 600; | |
| 321 | } | |
| 322 | .file-title { | |
| 323 | flex: 1; | |
| 324 | min-width: 0; | |
| 325 | overflow: hidden; | |
| 326 | text-overflow: ellipsis; | |
| 327 | white-space: nowrap; | |
| 328 | } | |
| 329 | .file-when { | |
| 330 | color: var(); | |
| 331 | font-size: var(); | |
| 332 | font-variant-numeric: tabular-nums; | |
| 333 | } | |
| 334 | .tag-cloud { | |
| 335 | display: flex; | |
| 336 | flex-wrap: wrap; | |
| 337 | gap: var(); | |
| 338 | padding: var() var(); | |
| 339 | } | |
| 340 | .left-foot { | |
| 341 | position: sticky; | |
| 342 | bottom: 0; | |
| 343 | display: flex; | |
| 344 | align-items: center; | |
| 345 | gap: var(); | |
| 346 | padding: var() var() var() var(); | |
| 347 | border-top: 1px solid var(); | |
| 348 | background: var(); | |
| 349 | } | |
| 350 | .you { | |
| 351 | flex: 1; | |
| 352 | min-width: 0; | |
| 353 | overflow: hidden; | |
| 354 | text-overflow: ellipsis; | |
| 355 | white-space: nowrap; | |
| 356 | color: var(); | |
| 357 | font-size: var(); | |
| 358 | } | |
| 359 | ||
| 360 | /* The toolbar above the note. */ | |
| 361 | .toolbar { | |
| 362 | display: flex; | |
| 363 | align-items: center; | |
| 364 | gap: var(); | |
| 365 | min-height: 3.25rem; | |
| 366 | padding: var() var(); | |
| 367 | border-bottom: 1px solid var(); | |
| 368 | } | |
| 369 | .crumbs { | |
| 370 | display: flex; | |
| 371 | align-items: center; | |
| 372 | gap: var(); | |
| 373 | flex: 1; | |
| 374 | min-width: 0; | |
| 375 | font-size: var(); | |
| 376 | color: var(); | |
| 377 | white-space: nowrap; | |
| 378 | } | |
| 379 | .crumbs a { | |
| 380 | color: var(); | |
| 381 | text-decoration: none; | |
| 382 | } | |
| 383 | .crumbs a:hover { | |
| 384 | color: var(); | |
| 385 | } | |
| 386 | .crumb-here { | |
| 387 | color: var(); | |
| 388 | overflow: hidden; | |
| 389 | text-overflow: ellipsis; | |
| 390 | } | |
| 391 | .tools { | |
| 392 | display: flex; | |
| 393 | align-items: center; | |
| 394 | gap: var(); | |
| 395 | } | |
| 396 | .status { | |
| 397 | min-width: 4.5rem; | |
| 398 | text-align: right; | |
| 399 | white-space: nowrap; | |
| 400 | overflow: hidden; | |
| 401 | text-overflow: ellipsis; | |
| 402 | max-width: 16rem; | |
| 403 | } | |
| 404 | .status[data-kind="error"] { | |
| 405 | color: var(); | |
| 406 | } | |
| 407 | .segmented { | |
| 408 | display: inline-flex; | |
| 409 | padding: 2px; | |
| 410 | margin-right: var(); | |
| 411 | border: 1px solid var(); | |
| 412 | border-radius: var(); | |
| 413 | } | |
| 414 | .segmented button { | |
| 415 | min-height: 2.25rem; | |
| 416 | padding: 0 var(); | |
| 417 | font: inherit; | |
| 418 | font-size: var(); | |
| 419 | color: var(); | |
| 420 | background: transparent; | |
| 421 | border: 0; | |
| 422 | border-radius: var(); | |
| 423 | cursor: pointer; | |
| 424 | } | |
| 425 | .segmented button:hover { | |
| 426 | color: var(); | |
| 427 | } | |
| 428 | .segmented button[aria-pressed="true"] { | |
| 429 | color: var(); | |
| 430 | background: var(); | |
| 431 | font-weight: 600; | |
| 432 | } | |
| 433 | .only-narrow, | |
| 434 | .only-narrow-right { | |
| 435 | display: none; | |
| 436 | } | |
| 437 | ||
| 438 | /* ── The note: a title, then the words and the reading view ──────────── */ | |
| 439 | .editor { | |
| 440 | flex: 1; | |
| 441 | min-height: 0; | |
| 442 | display: flex; | |
| 443 | } | |
| 444 | .sheet { | |
| 445 | flex: 1; | |
| 446 | min-width: 0; | |
| 447 | min-height: 0; | |
| 448 | display: flex; | |
| 449 | flex-direction: column; | |
| 450 | } | |
| 451 | .title-input { | |
| 452 | width: 100%; | |
| 453 | margin: 0; | |
| 454 | padding: var() max(var(), calc((100% - var()) / 2)) var(); | |
| 455 | font: 700 var() / var() var(); | |
| 456 | letter-spacing: -0.02em; | |
| 457 | color: var(); | |
| 458 | background: transparent; | |
| 459 | border: 0; | |
| 460 | outline: 0; | |
| 461 | } | |
| 462 | .title-input:focus-visible { | |
| 463 | outline: 0; | |
| 464 | box-shadow: inset 3px 0 0 var(); | |
| 465 | } | |
| 466 | .panes { | |
| 467 | flex: 1; | |
| 468 | min-height: 0; | |
| 469 | display: grid; | |
| 470 | grid-template-columns: minmax(0, 1fr); | |
| 471 | } | |
| 472 | .panes[data-mode="split"] { | |
| 473 | grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); | |
| 474 | } | |
| 475 | .panes[data-mode="split"] .source, | |
| 476 | .panes[data-mode="split"] .preview { | |
| 477 | padding-inline: var(); | |
| 478 | } | |
| 479 | .sheet:has(.panes[data-mode="split"]) .title-input { | |
| 480 | padding-inline: var(); | |
| 481 | } | |
| 482 | .panes[data-mode="split"] .preview { | |
| 483 | border-left: 1px solid var(); | |
| 484 | } | |
| 485 | .panes[data-mode="read"] .source, | |
| 486 | .panes[data-mode="edit"] .preview { | |
| 487 | display: none; | |
| 488 | } | |
| 489 | .source { | |
| 490 | width: 100%; | |
| 491 | height: 100%; | |
| 492 | margin: 0; | |
| 493 | padding: var() max(var(), calc((100% - var()) / 2)) 40vh; | |
| 494 | resize: none; | |
| 495 | font: 1.0625rem / 1.7 var(); | |
| 496 | tab-size: 2; | |
| 497 | color: var(); | |
| 498 | background: transparent; | |
| 499 | border: 0; | |
| 500 | outline: 0; | |
| 501 | caret-color: var(); | |
| 502 | } | |
| 503 | .source:focus-visible { | |
| 504 | outline: 0; | |
| 505 | } | |
| 506 | .source::placeholder { | |
| 507 | color: var(); | |
| 508 | } | |
| 509 | .preview { | |
| 510 | min-height: 0; | |
| 511 | overflow-y: auto; | |
| 512 | padding: var() max(var(), calc((100% - var()) / 2)) 40vh; | |
| 513 | } | |
| 514 | ||
| 515 | /* ── Markdown, read ───────────────────────────────────────────────────── */ | |
| 516 | .md { | |
| 517 | font-size: 1.0625rem; | |
| 518 | line-height: 1.7; | |
| 519 | overflow-wrap: anywhere; | |
| 520 | } | |
| 521 | .md > :first-child { | |
| 522 | margin-top: 0; | |
| 523 | } | |
| 524 | .md p, | |
| 525 | .md ul, | |
| 526 | .md ol, | |
| 527 | .md pre, | |
| 528 | .md blockquote, | |
| 529 | .md .callout, | |
| 530 | .md .table { | |
| 531 | margin: 0 0 var(); | |
| 532 | } | |
| 533 | .md h1, | |
| 534 | .md h2, | |
| 535 | .md h3, | |
| 536 | .md h4, | |
| 537 | .md h5, | |
| 538 | .md h6 { | |
| 539 | margin: var() 0 var(); | |
| 540 | line-height: var(); | |
| 541 | letter-spacing: -0.015em; | |
| 542 | scroll-margin-top: var(); | |
| 543 | } | |
| 544 | .md h1 { | |
| 545 | font-size: 1.85rem; | |
| 546 | } | |
| 547 | .md h2 { | |
| 548 | font-size: 1.45rem; | |
| 549 | } | |
| 550 | .md h3 { | |
| 551 | font-size: 1.2rem; | |
| 552 | } | |
| 553 | .md h4, | |
| 554 | .md h5, | |
| 555 | .md h6 { | |
| 556 | font-size: 1rem; | |
| 557 | } | |
| 558 | .md ul, | |
| 559 | .md ol { | |
| 560 | padding-left: 1.5rem; | |
| 561 | } | |
| 562 | .md li { | |
| 563 | margin: 0.15rem 0; | |
| 564 | } | |
| 565 | .md li > ul, | |
| 566 | .md li > ol { | |
| 567 | margin: 0.15rem 0 0; | |
| 568 | } | |
| 569 | .md li::marker { | |
| 570 | color: var(); | |
| 571 | } | |
| 572 | .md li.task { | |
| 573 | list-style: none; | |
| 574 | margin-left: -1.5rem; | |
| 575 | display: flex; | |
| 576 | flex-wrap: wrap; | |
| 577 | gap: var(); | |
| 578 | align-items: baseline; | |
| 579 | } | |
| 580 | .md li.task > ul, | |
| 581 | .md li.task > ol { | |
| 582 | flex-basis: 100%; | |
| 583 | padding-left: 3rem; | |
| 584 | } | |
| 585 | .check { | |
| 586 | flex: none; | |
| 587 | position: relative; | |
| 588 | top: 0.18em; | |
| 589 | width: 1.05rem; | |
| 590 | height: 1.05rem; | |
| 591 | padding: 0; | |
| 592 | border: 1.5px solid var(); | |
| 593 | border-radius: 4px; | |
| 594 | background: transparent; | |
| 595 | cursor: pointer; | |
| 596 | } | |
| 597 | .check::after { | |
| 598 | content: ""; | |
| 599 | position: absolute; | |
| 600 | inset: -0.7rem; | |
| 601 | } | |
| 602 | .check[aria-checked="true"] { | |
| 603 | background: var(); | |
| 604 | border-color: var(); | |
| 605 | } | |
| 606 | .check[aria-checked="true"]::before { | |
| 607 | content: ""; | |
| 608 | position: absolute; | |
| 609 | left: 0.3rem; | |
| 610 | top: 0.08rem; | |
| 611 | width: 0.3rem; | |
| 612 | height: 0.58rem; | |
| 613 | border: solid var(); | |
| 614 | border-width: 0 2px 2px 0; | |
| 615 | transform: rotate(45deg); | |
| 616 | } | |
| 617 | .md .done { | |
| 618 | color: var(); | |
| 619 | text-decoration: line-through; | |
| 620 | } | |
| 621 | .md code { | |
| 622 | padding: 0.1em 0.35em; | |
| 623 | color: var(); | |
| 624 | background: var(); | |
| 625 | border-radius: var(); | |
| 626 | } | |
| 627 | .md pre { | |
| 628 | padding: var() var(); | |
| 629 | overflow-x: auto; | |
| 630 | background: var(); | |
| 631 | border-radius: var(); | |
| 632 | line-height: 1.55; | |
| 633 | } | |
| 634 | .md pre code { | |
| 635 | padding: 0; | |
| 636 | color: var(); | |
| 637 | background: none; | |
| 638 | } | |
| 639 | .md blockquote { | |
| 640 | padding: 0 0 0 var(); | |
| 641 | color: var(); | |
| 642 | border-left: 3px solid var(); | |
| 643 | } | |
| 644 | .md blockquote > :last-child, | |
| 645 | .md .callout > :last-child { | |
| 646 | margin-bottom: 0; | |
| 647 | } | |
| 648 | .md hr { | |
| 649 | margin: var() 0; | |
| 650 | border: 0; | |
| 651 | border-top: 1px solid var(); | |
| 652 | } | |
| 653 | .md mark { | |
| 654 | padding: 0 0.15em; | |
| 655 | color: var(); | |
| 656 | background: var(); | |
| 657 | border-radius: 3px; | |
| 658 | } | |
| 659 | .md del { | |
| 660 | color: var(); | |
| 661 | } | |
| 662 | .md a { | |
| 663 | text-decoration: none; | |
| 664 | } | |
| 665 | .md a:hover { | |
| 666 | text-decoration: underline; | |
| 667 | } | |
| 668 | .wikilink { | |
| 669 | font-weight: 550; | |
| 670 | } | |
| 671 | .unresolved { | |
| 672 | color: var(); | |
| 673 | text-decoration: underline dashed 1px; | |
| 674 | text-underline-offset: 0.2em; | |
| 675 | } | |
| 676 | .tag { | |
| 677 | display: inline-flex; | |
| 678 | align-items: center; | |
| 679 | padding: 0.05em 0.55em; | |
| 680 | color: var(); | |
| 681 | background: var(); | |
| 682 | border-radius: var(); | |
| 683 | font-size: 0.875em; | |
| 684 | text-decoration: none; | |
| 685 | line-height: 1.6; | |
| 686 | } | |
| 687 | .tag:hover { | |
| 688 | text-decoration: none; | |
| 689 | filter: brightness(1.06); | |
| 690 | } | |
| 691 | .tag-cloud .tag { | |
| 692 | min-height: 1.75rem; | |
| 693 | } | |
| 694 | .md .table { | |
| 695 | overflow-x: auto; | |
| 696 | } | |
| 697 | .md table { | |
| 698 | border-collapse: collapse; | |
| 699 | min-width: 50%; | |
| 700 | font-size: 0.95em; | |
| 701 | } | |
| 702 | .md th, | |
| 703 | .md td { | |
| 704 | padding: var() var(); | |
| 705 | border: 1px solid var(); | |
| 706 | text-align: left; | |
| 707 | vertical-align: top; | |
| 708 | } | |
| 709 | .md th { | |
| 710 | background: var(); | |
| 711 | font-weight: 650; | |
| 712 | } | |
| 713 | .callout { | |
| 714 | padding: var() var(); | |
| 715 | color: var(); | |
| 716 | border-left: 3px solid currentColor; | |
| 717 | border-radius: var(); | |
| 718 | background: color-mix(in srgb, currentColor 9%, transparent); | |
| 719 | } | |
| 720 | .callout > :not(.callout-title) { | |
| 721 | color: var(); | |
| 722 | } | |
| 723 | .callout-title { | |
| 724 | margin: 0 0 var() ; | |
| 725 | font-weight: 650; | |
| 726 | } | |
| 727 | .callout-tip, | |
| 728 | .callout-success { | |
| 729 | color: var(); | |
| 730 | } | |
| 731 | .callout-warning, | |
| 732 | .callout-question, | |
| 733 | .callout-todo { | |
| 734 | color: var(); | |
| 735 | } | |
| 736 | .callout-danger, | |
| 737 | .callout-bug, | |
| 738 | .callout-failure { | |
| 739 | color: var(); | |
| 740 | } | |
| 741 | .callout-quote, | |
| 742 | .callout-example, | |
| 743 | .callout-abstract { | |
| 744 | color: var(); | |
| 745 | } | |
| 746 | ||
| 747 | /* ── The right: graph, backlinks, links, outline ─────────────────────── */ | |
| 748 | .panel { | |
| 749 | padding: var() 0 var(); | |
| 750 | border-bottom: 1px solid var(); | |
| 751 | } | |
| 752 | .panel:last-child { | |
| 753 | border-bottom: 0; | |
| 754 | } | |
| 755 | .panel .section { | |
| 756 | margin: var() 0; | |
| 757 | } | |
| 758 | .local-graph { | |
| 759 | display: block; | |
| 760 | width: 100%; | |
| 761 | height: 11rem; | |
| 762 | border-radius: var(); | |
| 763 | background: var(); | |
| 764 | touch-action: none; | |
| 765 | } | |
| 766 | .link-list { | |
| 767 | list-style: none; | |
| 768 | margin: 0; | |
| 769 | padding: 0; | |
| 770 | } | |
| 771 | .link-list li { | |
| 772 | display: grid; | |
| 773 | padding: var() 0; | |
| 774 | } | |
| 775 | .link-list a { | |
| 776 | text-decoration: none; | |
| 777 | font-weight: 550; | |
| 778 | min-height: 1.75rem; | |
| 779 | display: inline-flex; | |
| 780 | align-items: center; | |
| 781 | } | |
| 782 | .link-list a:hover { | |
| 783 | text-decoration: underline; | |
| 784 | } | |
| 785 | .context { | |
| 786 | color: var(); | |
| 787 | font-size: var(); | |
| 788 | line-height: 1.45; | |
| 789 | } | |
| 790 | .outline { | |
| 791 | list-style: none; | |
| 792 | margin: 0; | |
| 793 | padding: 0; | |
| 794 | font-size: var(); | |
| 795 | } | |
| 796 | .outline .depth-1 { | |
| 797 | padding-left: 0.9rem; | |
| 798 | } | |
| 799 | .outline .depth-2 { | |
| 800 | padding-left: 1.8rem; | |
| 801 | } | |
| 802 | .outline .depth-3, | |
| 803 | .outline .depth-4, | |
| 804 | .outline .depth-5 { | |
| 805 | padding-left: 2.7rem; | |
| 806 | } | |
| 807 | .outline a { | |
| 808 | display: block; | |
| 809 | padding: 0.2rem 0; | |
| 810 | color: var(); | |
| 811 | text-decoration: none; | |
| 812 | } | |
| 813 | .outline a:hover { | |
| 814 | color: var(); | |
| 815 | } | |
| 816 | .meta p { | |
| 817 | margin: 0 0 var(); | |
| 818 | } | |
| 819 | ||
| 820 | /* ── Pages without a note ─────────────────────────────────────────────── */ | |
| 821 | .empty-state { | |
| 822 | margin: auto; | |
| 823 | max-width: 30rem; | |
| 824 | padding: var() var(); | |
| 825 | text-align: center; | |
| 826 | display: grid; | |
| 827 | gap: var(); | |
| 828 | justify-items: center; | |
| 829 | } | |
| 830 | .empty-state h1 { | |
| 831 | font-size: var(); | |
| 832 | } | |
| 833 | .mark { | |
| 834 | color: var(); | |
| 835 | } | |
| 836 | .mark svg { | |
| 837 | width: 56px; | |
| 838 | height: 56px; | |
| 839 | } | |
| 840 | .results { | |
| 841 | overflow-y: auto; | |
| 842 | padding: var() max(var(), calc((100% - var()) / 2)) var(); | |
| 843 | } | |
| 844 | .results-head { | |
| 845 | font-size: var(); | |
| 846 | margin-bottom: var(); | |
| 847 | } | |
| 848 | .hits { | |
| 849 | list-style: none; | |
| 850 | margin: 0; | |
| 851 | padding: 0; | |
| 852 | } | |
| 853 | .hits a { | |
| 854 | display: grid; | |
| 855 | gap: var(); | |
| 856 | padding: var(); | |
| 857 | margin: 0 calc(-1 * var()); | |
| 858 | border-radius: var(); | |
| 859 | color: var(); | |
| 860 | text-decoration: none; | |
| 861 | } | |
| 862 | .hits a:hover { | |
| 863 | background: var(); | |
| 864 | } | |
| 865 | .hits mark { | |
| 866 | color: var(); | |
| 867 | background: var(); | |
| 868 | border-radius: 3px; | |
| 869 | } | |
| 870 | .graph-stage { | |
| 871 | position: relative; | |
| 872 | flex: 1; | |
| 873 | min-height: 0; | |
| 874 | } | |
| 875 | .big-graph { | |
| 876 | display: block; | |
| 877 | width: 100%; | |
| 878 | height: 100%; | |
| 879 | touch-action: none; | |
| 880 | } | |
| 881 | .graph-help { | |
| 882 | position: absolute; | |
| 883 | left: var(); | |
| 884 | bottom: var(); | |
| 885 | pointer-events: none; | |
| 886 | } | |
| 887 | ||
| 888 | /* Signed out. */ | |
| 889 | .landing-page { | |
| 890 | min-height: 100dvh; | |
| 891 | display: grid; | |
| 892 | place-items: center; | |
| 893 | background: radial-gradient(60rem 30rem at 50% -10%, var(), transparent 70%), var(); | |
| 894 | } | |
| 895 | .landing { | |
| 896 | padding: var() var(); | |
| 897 | } | |
| 898 | .landing-card { | |
| 899 | max-width: 30rem; | |
| 900 | display: grid; | |
| 901 | gap: var(); | |
| 902 | justify-items: center; | |
| 903 | text-align: center; | |
| 904 | } | |
| 905 | .landing-card h1 { | |
| 906 | font-size: 2.5rem; | |
| 907 | letter-spacing: -0.03em; | |
| 908 | } | |
| 909 | .lede { | |
| 910 | font-size: var(); | |
| 911 | color: var(); | |
| 912 | } | |
| 913 | ||
| 914 | /* ── Overlays: the switcher, [[ completion, the toast ─────────────────── */ | |
| 915 | .palette { | |
| 916 | position: fixed; | |
| 917 | inset: 0; | |
| 918 | z-index: 30; | |
| 919 | display: grid; | |
| 920 | justify-items: center; | |
| 921 | align-items: start; | |
| 922 | padding: 12vh var() var(); | |
| 923 | background: var(); | |
| 924 | } | |
| 925 | .palette-box { | |
| 926 | width: min(38rem, 100%); | |
| 927 | overflow: hidden; | |
| 928 | background: var(); | |
| 929 | border: 1px solid var(); | |
| 930 | border-radius: var(); | |
| 931 | box-shadow: 0 24px 64px var(); | |
| 932 | } | |
| 933 | .palette-input { | |
| 934 | width: 100%; | |
| 935 | min-height: 3.25rem; | |
| 936 | padding: 0 var(); | |
| 937 | border: 0; | |
| 938 | border-bottom: 1px solid var(); | |
| 939 | border-radius: 0; | |
| 940 | background: transparent; | |
| 941 | outline: 0; | |
| 942 | } | |
| 943 | .palette-list { | |
| 944 | list-style: none; | |
| 945 | margin: 0; | |
| 946 | padding: var(); | |
| 947 | max-height: min(24rem, 55vh); | |
| 948 | overflow-y: auto; | |
| 949 | } | |
| 950 | .palette-item, | |
| 951 | .palette-empty { | |
| 952 | display: flex; | |
| 953 | align-items: center; | |
| 954 | min-height: var(); | |
| 955 | padding: 0 var(); | |
| 956 | border-radius: var(); | |
| 957 | cursor: pointer; | |
| 958 | overflow: hidden; | |
| 959 | text-overflow: ellipsis; | |
| 960 | white-space: nowrap; | |
| 961 | } | |
| 962 | .palette-item[aria-selected="true"] { | |
| 963 | background: var(); | |
| 964 | } | |
| 965 | .palette-item.is-create { | |
| 966 | color: var(); | |
| 967 | font-weight: 600; | |
| 968 | } | |
| 969 | .palette-help { | |
| 970 | display: flex; | |
| 971 | flex-wrap: wrap; | |
| 972 | gap: var(); | |
| 973 | align-items: center; | |
| 974 | padding: var() var(); | |
| 975 | border-top: 1px solid var(); | |
| 976 | } | |
| 977 | .suggest { | |
| 978 | position: fixed; | |
| 979 | z-index: 20; | |
| 980 | list-style: none; | |
| 981 | margin: 0; | |
| 982 | padding: var(); | |
| 983 | min-width: 14rem; | |
| 984 | max-width: min(24rem, calc(100vw - 1rem)); | |
| 985 | background: var(); | |
| 986 | border: 1px solid var(); | |
| 987 | border-radius: var(); | |
| 988 | box-shadow: 0 12px 32px var(); | |
| 989 | } | |
| 990 | .suggest-item { | |
| 991 | padding: var() var(); | |
| 992 | border-radius: var(); | |
| 993 | cursor: pointer; | |
| 994 | white-space: nowrap; | |
| 995 | overflow: hidden; | |
| 996 | text-overflow: ellipsis; | |
| 997 | } | |
| 998 | .suggest-item[aria-selected="true"] { | |
| 999 | background: var(); | |
| 1000 | } | |
| 1001 | .scrim { | |
| 1002 | display: none; | |
| 170 | 1003 | } |
| 171 | 1004 | |
| 172 | 1005 | /* A word from the last request, shown once, gone in a moment. */ |
| 173 | 1006 | .toast { |
| 174 | 1007 | position: fixed; |
| 1008 | z-index: 40; | |
| 175 | 1009 | left: 50%; |
| 176 | 1010 | bottom: var(); |
| 177 | 1011 | transform: translateX(-50%); |
| @@ -193,6 +1027,108 @@ footer code { | ||
| 193 | 1027 | visibility: hidden; |
| 194 | 1028 | } |
| 195 | 1029 | } |
| 1030 | ||
| 1031 | /* ── Narrower: the links slide in from the right ──────────────────────── */ | |
| 1032 | @media (max-width: 1250px) { | |
| 1033 | .shell, | |
| 1034 | .shell[data-has-right="true"] { | |
| 1035 | grid-template-columns: var() minmax(0, 1fr); | |
| 1036 | } | |
| 1037 | .right { | |
| 1038 | position: fixed; | |
| 1039 | z-index: 15; | |
| 1040 | top: 0; | |
| 1041 | right: 0; | |
| 1042 | bottom: 0; | |
| 1043 | width: min(var(), 88vw); | |
| 1044 | transform: translateX(100%); | |
| 1045 | transition: transform 0.2s ease; | |
| 1046 | box-shadow: 0 0 40px var(); | |
| 1047 | } | |
| 1048 | .shell[data-open="right"] .right { | |
| 1049 | transform: none; | |
| 1050 | } | |
| 1051 | .only-narrow-right { | |
| 1052 | display: inline-grid; | |
| 1053 | } | |
| 1054 | .scrim:not([hidden]) { | |
| 1055 | display: block; | |
| 1056 | position: fixed; | |
| 1057 | inset: 0; | |
| 1058 | z-index: 12; | |
| 1059 | background: var(); | |
| 1060 | } | |
| 1061 | } | |
| 1062 | ||
| 1063 | /* ── A phone: the notes slide in from the left, one pane at a time ────── */ | |
| 1064 | @media (max-width: 900px) { | |
| 1065 | .shell, | |
| 1066 | .shell[data-has-right="true"], | |
| 1067 | .shell[data-has-right="false"] { | |
| 1068 | grid-template-columns: minmax(0, 1fr); | |
| 1069 | } | |
| 1070 | .left { | |
| 1071 | position: fixed; | |
| 1072 | z-index: 15; | |
| 1073 | top: 0; | |
| 1074 | left: 0; | |
| 1075 | bottom: 0; | |
| 1076 | width: min(var(), 88vw); | |
| 1077 | transform: translateX(-100%); | |
| 1078 | transition: transform 0.2s ease; | |
| 1079 | box-shadow: 0 0 40px var(); | |
| 1080 | } | |
| 1081 | .shell[data-open="left"] .left { | |
| 1082 | transform: none; | |
| 1083 | } | |
| 1084 | .only-narrow { | |
| 1085 | display: inline-grid; | |
| 1086 | } | |
| 1087 | .only-wide { | |
| 1088 | display: none; | |
| 1089 | } | |
| 1090 | .panes[data-mode="split"] { | |
| 1091 | grid-template-columns: minmax(0, 1fr); | |
| 1092 | } | |
| 1093 | .panes[data-mode="split"] .preview { | |
| 1094 | display: none; | |
| 1095 | } | |
| 1096 | .status { | |
| 1097 | min-width: 0; | |
| 1098 | max-width: 6rem; | |
| 1099 | } | |
| 1100 | .toolbar { | |
| 1101 | padding: var(); | |
| 1102 | gap: var(); | |
| 1103 | } | |
| 1104 | .segmented { | |
| 1105 | margin-right: 0; | |
| 1106 | } | |
| 1107 | .segmented button { | |
| 1108 | min-height: 2.5rem; | |
| 1109 | } | |
| 1110 | .title-input, | |
| 1111 | .sheet:has(.panes[data-mode="split"]) .title-input, | |
| 1112 | .panes[data-mode] .source, | |
| 1113 | .panes[data-mode] .preview, | |
| 1114 | .results { | |
| 1115 | padding-inline: var(); | |
| 1116 | } | |
| 1117 | .title-input { | |
| 1118 | font-size: var(); | |
| 1119 | padding-top: var(); | |
| 1120 | } | |
| 1121 | } | |
| 1122 | @media (max-width: 520px) { | |
| 1123 | .toolbar .crumbs { | |
| 1124 | display: none; | |
| 1125 | } | |
| 1126 | .tools { | |
| 1127 | flex: 1; | |
| 1128 | justify-content: flex-end; | |
| 1129 | } | |
| 1130 | } | |
| 1131 | ||
| 196 | 1132 | /* Motion is a courtesy: whoever asked for less gets none. */ |
| 197 | 1133 | @media (prefers-reduced-motion: reduce) { |
| 198 | 1134 | *, |
static/app.js added +649
| @@ -0,0 +1,649 @@ | ||
| 1 | // The vault, made an editor. The page arrives whole from the server; this | |
| 2 | // adds what only a browser can: saving as you type, the reading view kept | |
| 3 | // in step with the words, [[link]] completion, the quick switcher (⌘K), | |
| 4 | // the keys, and the panels on a phone. Without it every page still reads | |
| 5 | // and the editor still saves with its button. | |
| 6 | ||
| 7 | import { render } from "./md.js"; | |
| 8 | ||
| 9 | const $ = (sel, root = document) => root.querySelector(sel); | |
| 10 | const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; | |
| 11 | const mod = (e) => e.metaKey || e.ctrlKey; | |
| 12 | const narrow = () => matchMedia("(max-width: 900px)").matches; | |
| 13 | const store = { | |
| 14 | get(k) { | |
| 15 | try { | |
| 16 | return localStorage.getItem(k); | |
| 17 | } catch { | |
| 18 | return null; | |
| 19 | } | |
| 20 | }, | |
| 21 | set(k, v) { | |
| 22 | try { | |
| 23 | localStorage.setItem(k, v); | |
| 24 | } catch {} | |
| 25 | }, | |
| 26 | }; | |
| 27 | ||
| 28 | /** The vault's titles, as the page last heard them. */ | |
| 29 | function vault() { | |
| 30 | try { | |
| 31 | return JSON.parse($("#vault")?.textContent ?? "[]"); | |
| 32 | } catch { | |
| 33 | return []; | |
| 34 | } | |
| 35 | } | |
| 36 | const resolver = () => { | |
| 37 | const map = new Map(vault().map((n) => [n.title.toLowerCase(), n])); | |
| 38 | return (title) => { | |
| 39 | const n = map.get(title.trim().toLowerCase()); | |
| 40 | return n ? `/n/${n.id}` : null; | |
| 41 | }; | |
| 42 | }; | |
| 43 | ||
| 44 | async function createNote(title) { | |
| 45 | const r = await fetch("/api/notes", { | |
| 46 | method: "POST", | |
| 47 | headers: { "content-type": "application/json" }, | |
| 48 | body: JSON.stringify(title ? { title } : {}), | |
| 49 | }); | |
| 50 | if (r.ok) { | |
| 51 | const note = await r.json(); | |
| 52 | await go(`/n/${note.id}`); | |
| 53 | } else if (title) location.href = `/open?title=${encodeURIComponent(title)}`; | |
| 54 | } | |
| 55 | ||
| 56 | // ── Theme ───────────────────────────────────────────────────────────── | |
| 57 | document.addEventListener("click", (e) => { | |
| 58 | if (!e.target.closest?.('[data-action="theme"]')) return; | |
| 59 | const root = document.documentElement; | |
| 60 | const dark = root.dataset.theme | |
| 61 | ? root.dataset.theme === "dark" | |
| 62 | : matchMedia("(prefers-color-scheme: dark)").matches; | |
| 63 | root.dataset.theme = dark ? "light" : "dark"; | |
| 64 | store.set("theme", root.dataset.theme); | |
| 65 | document.dispatchEvent(new CustomEvent("theme")); | |
| 66 | }); | |
| 67 | ||
| 68 | // ── Panels, on a narrow screen ──────────────────────────────────────── | |
| 69 | const shell = $(".shell"); | |
| 70 | function panel(which) { | |
| 71 | if (!shell) return; | |
| 72 | const open = shell.dataset.open === which ? "" : which; | |
| 73 | shell.dataset.open = open; | |
| 74 | $(".scrim").hidden = open === ""; | |
| 75 | } | |
| 76 | document.addEventListener("click", (e) => { | |
| 77 | const a = e.target.closest?.("[data-action]")?.dataset.action; | |
| 78 | if (a === "toggle-left") panel("left"); | |
| 79 | if (a === "toggle-right") panel("right"); | |
| 80 | if (a === "close-panels") panel(shell?.dataset.open ?? ""); | |
| 81 | }); | |
| 82 | ||
| 83 | // ── Small things every page does ───────────────────────────────────── | |
| 84 | for (const tz of $$('input[name="tz"]')) tz.value = String(new Date().getTimezoneOffset()); | |
| 85 | document.addEventListener("submit", (e) => { | |
| 86 | const message = e.target.dataset?.confirm; | |
| 87 | if (message && !confirm(message)) e.preventDefault(); | |
| 88 | }); | |
| 89 | ||
| 90 | // The sidebar's box filters the list as you type; Enter searches the words. | |
| 91 | function filterFiles() { | |
| 92 | const box = $("[data-filter]"); | |
| 93 | const q = (box?.value ?? "").trim().toLowerCase(); | |
| 94 | const words = q.split(/\s+/).filter((w) => w && !w.startsWith("#")); | |
| 95 | for (const a of $$(".file")) { | |
| 96 | const show = words.every((w) => a.dataset.title.includes(w)); | |
| 97 | a.parentElement.hidden = !show; | |
| 98 | } | |
| 99 | } | |
| 100 | $("[data-filter]")?.addEventListener("input", filterFiles); | |
| 101 | document.addEventListener("live:swapped", () => { | |
| 102 | filterFiles(); | |
| 103 | for (const tz of $$('input[name="tz"]')) tz.value = String(new Date().getTimezoneOffset()); | |
| 104 | }); | |
| 105 | ||
| 106 | // Links to notes not written yet make the note, then open it. | |
| 107 | document.addEventListener("click", (e) => { | |
| 108 | const a = e.target.closest?.("a.unresolved"); | |
| 109 | if (!a || mod(e) || e.button !== 0) return; | |
| 110 | e.preventDefault(); | |
| 111 | createNote(a.dataset.title); | |
| 112 | }); | |
| 113 | ||
| 114 | // ── The quick switcher ──────────────────────────────────────────────── | |
| 115 | const palette = $("#palette"); | |
| 116 | const pinput = $("#palette-input"); | |
| 117 | const plist = $("#palette-list"); | |
| 118 | let pitems = []; | |
| 119 | let psel = 0; | |
| 120 | ||
| 121 | /** A fuzzy score: every letter in order, more for runs and word starts. Null when it does not match. */ | |
| 122 | function score(title, q) { | |
| 123 | if (q === "") return 0; | |
| 124 | const t = title.toLowerCase(); | |
| 125 | if (t === q) return 1000; | |
| 126 | if (t.startsWith(q)) return 500 - t.length; | |
| 127 | const at = t.indexOf(q); | |
| 128 | if (at >= 0) return 300 - at; | |
| 129 | let s = 0; | |
| 130 | let j = 0; | |
| 131 | let run = 0; | |
| 132 | for (let i = 0; i < t.length && j < q.length; i++) { | |
| 133 | if (t[i] === q[j]) { | |
| 134 | run++; | |
| 135 | s += run * 2 + (i === 0 || /[\s\-_/]/.test(t[i - 1]) ? 6 : 0); | |
| 136 | j++; | |
| 137 | } else run = 0; | |
| 138 | } | |
| 139 | return j === q.length ? s : null; | |
| 140 | } | |
| 141 | ||
| 142 | function drawPalette() { | |
| 143 | const q = pinput.value.trim(); | |
| 144 | const ql = q.toLowerCase(); | |
| 145 | const notes = vault() | |
| 146 | .map((n) => ({ n, s: score(n.title, ql) })) | |
| 147 | .filter((x) => x.s !== null) | |
| 148 | .sort((a, b) => b.s - a.s || b.n.updated_at.localeCompare(a.n.updated_at)) | |
| 149 | .slice(0, 12); | |
| 150 | pitems = notes.map(({ n }) => ({ kind: "open", title: n.title, id: n.id })); | |
| 151 | if (q !== "" && !vault().some((n) => n.title.toLowerCase() === ql)) pitems.push({ kind: "create", title: q }); | |
| 152 | psel = Math.min(psel, Math.max(0, pitems.length - 1)); | |
| 153 | plist.innerHTML = ""; | |
| 154 | pitems.forEach((item, i) => { | |
| 155 | const li = document.createElement("li"); | |
| 156 | li.setAttribute("role", "option"); | |
| 157 | li.id = `p-${i}`; | |
| 158 | li.className = `palette-item${item.kind === "create" ? " is-create" : ""}`; | |
| 159 | li.setAttribute("aria-selected", String(i === psel)); | |
| 160 | li.textContent = item.kind === "create" ? `Create “${item.title}”` : item.title; | |
| 161 | li.addEventListener("mousedown", (e) => { | |
| 162 | e.preventDefault(); | |
| 163 | psel = i; | |
| 164 | choose(false); | |
| 165 | }); | |
| 166 | plist.append(li); | |
| 167 | }); | |
| 168 | if (pitems.length === 0) { | |
| 169 | const li = document.createElement("li"); | |
| 170 | li.className = "palette-empty muted"; | |
| 171 | li.textContent = "Type a title to create your first note"; | |
| 172 | plist.append(li); | |
| 173 | } | |
| 174 | pinput.setAttribute("aria-activedescendant", pitems.length ? `p-${psel}` : ""); | |
| 175 | $(`#p-${psel}`)?.scrollIntoView({ block: "nearest" }); | |
| 176 | } | |
| 177 | function openPalette() { | |
| 178 | if (!palette) return; | |
| 179 | palette.hidden = false; | |
| 180 | pinput.value = ""; | |
| 181 | psel = 0; | |
| 182 | drawPalette(); | |
| 183 | pinput.focus(); | |
| 184 | } | |
| 185 | function closePalette() { | |
| 186 | if (!palette || palette.hidden) return; | |
| 187 | palette.hidden = true; | |
| 188 | editorEl?.querySelector("#source")?.focus({ preventScroll: true }); | |
| 189 | } | |
| 190 | function choose(create) { | |
| 191 | const q = pinput.value.trim(); | |
| 192 | const item = create ? { kind: "create", title: q } : pitems[psel]; | |
| 193 | if (!item || !item.title) return; | |
| 194 | closePalette(); | |
| 195 | if (item.kind === "open") go(`/n/${item.id}`); | |
| 196 | else createNote(item.title); | |
| 197 | } | |
| 198 | pinput?.addEventListener("input", () => { | |
| 199 | psel = 0; | |
| 200 | drawPalette(); | |
| 201 | }); | |
| 202 | pinput?.addEventListener("keydown", (e) => { | |
| 203 | if (e.key === "ArrowDown" || (e.ctrlKey && e.key === "n")) { | |
| 204 | e.preventDefault(); | |
| 205 | psel = (psel + 1) % Math.max(1, pitems.length); | |
| 206 | drawPalette(); | |
| 207 | } else if (e.key === "ArrowUp" || (e.ctrlKey && e.key === "p")) { | |
| 208 | e.preventDefault(); | |
| 209 | psel = (psel - 1 + pitems.length) % Math.max(1, pitems.length); | |
| 210 | drawPalette(); | |
| 211 | } else if (e.key === "Enter") { | |
| 212 | e.preventDefault(); | |
| 213 | choose(e.shiftKey); | |
| 214 | } else if (e.key === "Escape") { | |
| 215 | e.preventDefault(); | |
| 216 | closePalette(); | |
| 217 | } | |
| 218 | }); | |
| 219 | palette?.addEventListener("mousedown", (e) => { | |
| 220 | if (e.target === palette) closePalette(); | |
| 221 | }); | |
| 222 | $(".find .hint")?.addEventListener("click", openPalette); | |
| 223 | ||
| 224 | // ── Keys, everywhere ────────────────────────────────────────────────── | |
| 225 | document.addEventListener("keydown", (e) => { | |
| 226 | if (mod(e) && !e.shiftKey && (e.key === "k" || e.key === "o")) { | |
| 227 | e.preventDefault(); | |
| 228 | palette?.hidden === false ? closePalette() : openPalette(); | |
| 229 | } else if (mod(e) && e.shiftKey && e.key.toLowerCase() === "d") { | |
| 230 | e.preventDefault(); | |
| 231 | $('form[action="/daily"]')?.requestSubmit(); | |
| 232 | } else if (mod(e) && !e.shiftKey && e.key === "g") { | |
| 233 | e.preventDefault(); | |
| 234 | go("/graph"); | |
| 235 | } else if (e.altKey && e.code === "KeyN") { | |
| 236 | e.preventDefault(); | |
| 237 | createNote(); | |
| 238 | } else if (e.key === "Escape" && shell?.dataset.open) panel(shell.dataset.open); | |
| 239 | }); | |
| 240 | ||
| 241 | // ── The editor ──────────────────────────────────────────────────────── | |
| 242 | const editorEl = $("#editor"); | |
| 243 | let dirty = false; | |
| 244 | let flush = async () => {}; | |
| 245 | ||
| 246 | /** Leave the page — once whatever is unsaved is saved. */ | |
| 247 | async function go(href) { | |
| 248 | if (dirty) await flush(); | |
| 249 | location.href = href; | |
| 250 | } | |
| 251 | document.addEventListener("click", (e) => { | |
| 252 | const a = e.target.closest?.("a[href]"); | |
| 253 | if (!a || !dirty || mod(e) || a.target === "_blank" || a.classList.contains("unresolved")) return; | |
| 254 | const url = new URL(a.href, location.href); | |
| 255 | if (url.origin !== location.origin || (url.pathname === location.pathname && url.hash)) return; | |
| 256 | e.preventDefault(); | |
| 257 | go(a.href); | |
| 258 | }); | |
| 259 | ||
| 260 | if (editorEl) editor(editorEl); | |
| 261 | ||
| 262 | function editor(form) { | |
| 263 | const id = form.dataset.id; | |
| 264 | const source = $("#source", form); | |
| 265 | const titleEl = $("#title", form); | |
| 266 | const preview = $("#preview", form); | |
| 267 | const panes = $("#panes", form); | |
| 268 | const status = $("#status"); | |
| 269 | let saved = { title: titleEl.value, body: source.value, updated: form.dataset.updated }; | |
| 270 | let timer; | |
| 271 | let saving = null; | |
| 272 | let frame = 0; | |
| 273 | ||
| 274 | // Views: edit, split (side by side, on a wide screen), read. ⌘E flips | |
| 275 | // between reading and the last way you were writing. | |
| 276 | const empty = source.value.trim() === ""; | |
| 277 | let writing = store.get("mode-writing") ?? "split"; | |
| 278 | function mode(m, remember = true) { | |
| 279 | if (m !== "read") writing = m; | |
| 280 | panes.dataset.mode = m; | |
| 281 | for (const b of $$(".segmented [data-mode]")) b.setAttribute("aria-pressed", String(b.dataset.mode === m || (m === "split" && narrow() && b.dataset.mode === "edit"))); | |
| 282 | if (remember) { | |
| 283 | store.set("mode", m); | |
| 284 | if (m !== "read") store.set("mode-writing", m); | |
| 285 | } | |
| 286 | if (m !== "read") draw(); | |
| 287 | } | |
| 288 | mode(empty ? (writing === "read" ? "edit" : writing) : (store.get("mode") ?? "split"), false); | |
| 289 | for (const b of $$(".segmented [data-mode]")) b.addEventListener("click", () => mode(b.dataset.mode)); | |
| 290 | preview.addEventListener("dblclick", (e) => { | |
| 291 | if (panes.dataset.mode !== "read" || e.target.closest("a, button")) return; | |
| 292 | mode(writing); | |
| 293 | source.focus(); | |
| 294 | }); | |
| 295 | document.addEventListener("keydown", (e) => { | |
| 296 | if (mod(e) && !e.shiftKey && e.key === "e") { | |
| 297 | e.preventDefault(); | |
| 298 | if (panes.dataset.mode === "read") { | |
| 299 | mode(writing); | |
| 300 | source.focus(); | |
| 301 | } else mode("read"); | |
| 302 | } | |
| 303 | if (mod(e) && e.key === "s") { | |
| 304 | e.preventDefault(); | |
| 305 | save(); | |
| 306 | } | |
| 307 | }); | |
| 308 | ||
| 309 | // The reading view follows the words, a frame at a time. | |
| 310 | function draw() { | |
| 311 | cancelAnimationFrame(frame); | |
| 312 | frame = requestAnimationFrame(() => { | |
| 313 | preview.innerHTML = render(source.value, { resolve: resolver() }) || '<p class="muted">Nothing here yet.</p>'; | |
| 314 | }); | |
| 315 | } | |
| 316 | ||
| 317 | // Saving: a moment after you stop, and before you leave. | |
| 318 | function say(text, kind = "") { | |
| 319 | if (!status) return; | |
| 320 | status.textContent = text; | |
| 321 | status.dataset.kind = kind; | |
| 322 | } | |
| 323 | function changed() { | |
| 324 | dirty = titleEl.value !== saved.title || source.value !== saved.body; | |
| 325 | say(dirty ? "Editing…" : "Saved"); | |
| 326 | clearTimeout(timer); | |
| 327 | if (dirty) timer = setTimeout(save, 600); | |
| 328 | } | |
| 329 | async function save() { | |
| 330 | clearTimeout(timer); | |
| 331 | if (saving) await saving; | |
| 332 | const body = {}; | |
| 333 | const t = titleEl.value.trim(); | |
| 334 | if (t !== saved.title && t !== "") body.title = t; | |
| 335 | if (source.value !== saved.body) body.body = source.value; | |
| 336 | if (Object.keys(body).length === 0) { | |
| 337 | dirty = titleEl.value.trim() !== saved.title && titleEl.value.trim() !== ""; | |
| 338 | return; | |
| 339 | } | |
| 340 | say("Saving…"); | |
| 341 | saving = (async () => { | |
| 342 | try { | |
| 343 | const r = await fetch(`/api/notes/${id}`, { | |
| 344 | method: "PATCH", | |
| 345 | headers: { "content-type": "application/json" }, | |
| 346 | body: JSON.stringify(body), | |
| 347 | }); | |
| 348 | const answer = await r.json().catch(() => ({})); | |
| 349 | if (r.ok) { | |
| 350 | saved = { title: answer.title, body: answer.body, updated: answer.updated_at }; | |
| 351 | dirty = titleEl.value.trim() !== saved.title || source.value !== saved.body; | |
| 352 | if (body.title !== undefined) renamed(answer); | |
| 353 | say(dirty ? "Editing…" : answer.relinked ? `Saved · updated links in ${answer.relinked} ${answer.relinked === 1 ? "note" : "notes"}` : "Saved"); | |
| 354 | if (dirty) timer = setTimeout(save, 600); | |
| 355 | } else if (r.status === 422 && body.title !== undefined) { | |
| 356 | say(answer.message ?? "That title can't be used.", "error"); | |
| 357 | // Keep the words: save them under the old title. | |
| 358 | if (body.body !== undefined) { | |
| 359 | titleEl.value = saved.title; | |
| 360 | saving = null; | |
| 361 | return save(); | |
| 362 | } | |
| 363 | } else { | |
| 364 | say(answer.message ?? `Not saved (${r.status}) — retrying`, "error"); | |
| 365 | timer = setTimeout(save, 3000); | |
| 366 | } | |
| 367 | } catch { | |
| 368 | say("Offline — will retry", "error"); | |
| 369 | timer = setTimeout(save, 3000); | |
| 370 | } finally { | |
| 371 | saving = null; | |
| 372 | } | |
| 373 | })(); | |
| 374 | return saving; | |
| 375 | } | |
| 376 | flush = save; | |
| 377 | function renamed(note) { | |
| 378 | const here = $(".crumb-here"); | |
| 379 | if (here) here.textContent = note.title; | |
| 380 | document.title = document.title.replace(/^.*? · /, `${note.title} · `); | |
| 381 | } | |
| 382 | titleEl.addEventListener("input", changed); | |
| 383 | titleEl.addEventListener("keydown", (e) => { | |
| 384 | if (e.key === "Enter" || e.key === "ArrowDown") { | |
| 385 | e.preventDefault(); | |
| 386 | source.focus(); | |
| 387 | source.setSelectionRange(0, 0); | |
| 388 | } | |
| 389 | }); | |
| 390 | titleEl.addEventListener("blur", () => { | |
| 391 | if (titleEl.value.trim() === "") titleEl.value = saved.title; | |
| 392 | }); | |
| 393 | form.addEventListener("submit", (e) => { | |
| 394 | e.preventDefault(); | |
| 395 | save(); | |
| 396 | }); | |
| 397 | addEventListener("beforeunload", (e) => { | |
| 398 | if (!dirty) return; | |
| 399 | // Best effort on the way out; the prompt covers what keepalive cannot carry. | |
| 400 | const body = JSON.stringify({ body: source.value, ...(titleEl.value.trim() !== saved.title && titleEl.value.trim() ? { title: titleEl.value.trim() } : {}) }); | |
| 401 | if (body.length < 60_000) | |
| 402 | fetch(`/api/notes/${id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body, keepalive: true }); | |
| 403 | else e.preventDefault(); | |
| 404 | }); | |
| 405 | ||
| 406 | // Someone (another tab, a script, Claude over MCP) changed this note: | |
| 407 | // take their words if you have none of your own in flight. | |
| 408 | document.addEventListener("live:note.changed", async (e) => { | |
| 409 | if (String(e.detail.id) !== id || e.detail.updated_at === saved.updated || dirty || saving) return; | |
| 410 | const r = await fetch(`/api/notes/${id}`); | |
| 411 | if (!r.ok) return; | |
| 412 | const note = await r.json(); | |
| 413 | if (dirty || note.updated_at === saved.updated) return; | |
| 414 | const at = source.selectionStart; | |
| 415 | saved = { title: note.title, body: note.body, updated: note.updated_at }; | |
| 416 | source.value = note.body; | |
| 417 | titleEl.value = note.title; | |
| 418 | renamed(note); | |
| 419 | source.setSelectionRange(at, at); | |
| 420 | draw(); | |
| 421 | say("Updated elsewhere"); | |
| 422 | }); | |
| 423 | document.addEventListener("live:note.removed", (e) => { | |
| 424 | if (String(e.detail.id) === id) { | |
| 425 | dirty = false; | |
| 426 | location.href = "/"; | |
| 427 | } | |
| 428 | }); | |
| 429 | document.addEventListener("live:swapped", () => draw()); | |
| 430 | ||
| 431 | // Typing: the words, the view, the save, and [[ completion. | |
| 432 | source.addEventListener("input", (e) => { | |
| 433 | if (e.inputType === "insertText" && e.data === "[") pairBrackets(); | |
| 434 | changed(); | |
| 435 | draw(); | |
| 436 | suggest(); | |
| 437 | }); | |
| 438 | source.addEventListener("keydown", keys); | |
| 439 | source.addEventListener("blur", () => setTimeout(hideSuggest, 120)); | |
| 440 | source.addEventListener("click", hideSuggest); | |
| 441 | ||
| 442 | // The split view scrolls together. | |
| 443 | source.addEventListener("scroll", () => { | |
| 444 | if (panes.dataset.mode !== "split" || narrow()) return; | |
| 445 | const ratio = source.scrollTop / Math.max(1, source.scrollHeight - source.clientHeight); | |
| 446 | preview.scrollTop = ratio * (preview.scrollHeight - preview.clientHeight); | |
| 447 | }); | |
| 448 | ||
| 449 | // A task's box, ticked in the reading view, ticks it in the words. | |
| 450 | preview.addEventListener("click", (e) => { | |
| 451 | const box = e.target.closest("button.check"); | |
| 452 | if (!box) return; | |
| 453 | const n = $$("button.check", preview).indexOf(box); | |
| 454 | const lines = source.value.split("\n"); | |
| 455 | let seen = -1; | |
| 456 | let fence = false; | |
| 457 | for (let i = 0; i < lines.length; i++) { | |
| 458 | if (/^(```|~~~)/.test(lines[i])) fence = !fence; | |
| 459 | if (fence) continue; | |
| 460 | const m = /^((?:\s*>)*\s*(?:[-*+]|\d+[.)])\s+\[)([ xX])(\]\s)/.exec(lines[i]); | |
| 461 | if (m && ++seen === n) { | |
| 462 | lines[i] = m[1] + (m[2] === " " ? "x" : " ") + m[3] + lines[i].slice(m[0].length); | |
| 463 | source.value = lines.join("\n"); | |
| 464 | changed(); | |
| 465 | draw(); | |
| 466 | return; | |
| 467 | } | |
| 468 | } | |
| 469 | }); | |
| 470 | ||
| 471 | /** Put text in as if typed, so ⌘Z undoes it. */ | |
| 472 | function type(text, start = source.selectionStart, end = source.selectionEnd) { | |
| 473 | source.setSelectionRange(start, end); | |
| 474 | if (!document.execCommand?.("insertText", false, text)) { | |
| 475 | source.setRangeText(text, start, end, "end"); | |
| 476 | source.dispatchEvent(new Event("input")); | |
| 477 | } | |
| 478 | } | |
| 479 | ||
| 480 | function pairBrackets() { | |
| 481 | const at = source.selectionStart; | |
| 482 | const v = source.value; | |
| 483 | if (v.slice(at - 2, at) === "[[" && v.slice(at, at + 2) !== "]]") { | |
| 484 | source.setRangeText("]]", at, at, "start"); | |
| 485 | } | |
| 486 | } | |
| 487 | ||
| 488 | function wrap(mark) { | |
| 489 | const { selectionStart: a, selectionEnd: b, value } = source; | |
| 490 | const inner = value.slice(a, b); | |
| 491 | type(`${mark}${inner}${mark}`, a, b); | |
| 492 | source.setSelectionRange(a + mark.length, b + mark.length); | |
| 493 | } | |
| 494 | ||
| 495 | function keys(e) { | |
| 496 | if (suggestKeys(e)) return; | |
| 497 | const { selectionStart: a, selectionEnd: b, value } = source; | |
| 498 | const lineStart = value.lastIndexOf("\n", a - 1) + 1; | |
| 499 | const line = value.slice(lineStart, value.indexOf("\n", a) < 0 ? value.length : value.indexOf("\n", a)); | |
| 500 | if (mod(e) && e.key === "b") { | |
| 501 | e.preventDefault(); | |
| 502 | wrap("**"); | |
| 503 | } else if (mod(e) && e.key === "i") { | |
| 504 | e.preventDefault(); | |
| 505 | wrap("*"); | |
| 506 | } else if (mod(e) && e.shiftKey && e.key.toLowerCase() === "h") { | |
| 507 | e.preventDefault(); | |
| 508 | wrap("=="); | |
| 509 | } else if (mod(e) && e.key === "Enter") { | |
| 510 | // ⌘↵ cycles a line: plain → task → done → plain. | |
| 511 | e.preventDefault(); | |
| 512 | const m = /^(\s*)(?:([-*+]|\d+[.)])\s+)?(?:\[([ xX])\]\s+)?/.exec(line); | |
| 513 | const rest = line.slice(m[0].length); | |
| 514 | const next = m[3] === undefined ? `${m[1]}- [ ] ${rest}` : m[3] === " " ? `${m[1]}${m[2] ?? "-"} [x] ${rest}` : `${m[1]}${m[2] ?? "-"} ${rest}`; | |
| 515 | type(next, lineStart, lineStart + line.length); | |
| 516 | } else if (e.key === "Enter" && !e.shiftKey && a === b) { | |
| 517 | const m = /^(\s*(?:>\s*)*)([-*+]|(\d+)([.)]))\s+(\[[ xX]\]\s+)?(.*)$/.exec(line); | |
| 518 | if (!m || a - lineStart < m[0].length - m[6].length) return; | |
| 519 | e.preventDefault(); | |
| 520 | if (m[6].trim() === "" && a === lineStart + line.length) { | |
| 521 | type("", lineStart, lineStart + line.length); // An empty item ends the list. | |
| 522 | return; | |
| 523 | } | |
| 524 | const marker = m[3] !== undefined ? `${Number(m[3]) + 1}${m[4]}` : m[2]; | |
| 525 | type(`\n${m[1]}${marker} ${m[5] ? "[ ] " : ""}`); | |
| 526 | } else if (e.key === "Tab" && !mod(e)) { | |
| 527 | e.preventDefault(); | |
| 528 | const listy = /^\s*([-*+]|\d+[.)])\s/.test(line); | |
| 529 | if (e.shiftKey) { | |
| 530 | const cut = line.match(/^( {1,2}|\t)/)?.[0].length ?? 0; | |
| 531 | if (cut) { | |
| 532 | type(line.slice(cut), lineStart, lineStart + line.length); | |
| 533 | source.setSelectionRange(Math.max(lineStart, a - cut), Math.max(lineStart, b - cut)); | |
| 534 | } | |
| 535 | } else if (listy) { | |
| 536 | type(` ${line}`, lineStart, lineStart + line.length); | |
| 537 | source.setSelectionRange(a + 2, b + 2); | |
| 538 | } else type(" "); | |
| 539 | } else if (e.key === "Escape") source.blur(); | |
| 540 | } | |
| 541 | ||
| 542 | // [[ completion: the titles that fit what you have typed so far. | |
| 543 | const box = $("#suggest"); | |
| 544 | let items = []; | |
| 545 | let sel = 0; | |
| 546 | function query() { | |
| 547 | const at = source.selectionStart; | |
| 548 | const m = /\[\[([^\[\]\n|#]*)$/.exec(source.value.slice(Math.max(0, at - 200), at)); | |
| 549 | return m ? { q: m[1], from: at - m[1].length } : null; | |
| 550 | } | |
| 551 | function suggest() { | |
| 552 | const found = query(); | |
| 553 | if (!found) return hideSuggest(); | |
| 554 | const q = found.q.toLowerCase(); | |
| 555 | const mine = saved.title.toLowerCase(); | |
| 556 | items = vault() | |
| 557 | .filter((n) => n.title.toLowerCase() !== mine) | |
| 558 | .map((n) => ({ title: n.title, s: score(n.title, q) })) | |
| 559 | .filter((x) => x.s !== null) | |
| 560 | .sort((x, y) => y.s - x.s) | |
| 561 | .slice(0, 8) | |
| 562 | .map((x) => x.title); | |
| 563 | if (found.q.trim() !== "" && !items.some((t) => t.toLowerCase() === q)) items.push(found.q.trim()); | |
| 564 | if (items.length === 0) return hideSuggest(); | |
| 565 | sel = Math.min(sel, items.length - 1); | |
| 566 | box.innerHTML = ""; | |
| 567 | const known = new Set(vault().map((n) => n.title.toLowerCase())); | |
| 568 | items.forEach((t, i) => { | |
| 569 | const li = document.createElement("li"); | |
| 570 | li.setAttribute("role", "option"); | |
| 571 | li.className = "suggest-item"; | |
| 572 | li.setAttribute("aria-selected", String(i === sel)); | |
| 573 | li.textContent = known.has(t.toLowerCase()) ? t : `${t} — new`; | |
| 574 | li.addEventListener("mousedown", (ev) => { | |
| 575 | ev.preventDefault(); | |
| 576 | sel = i; | |
| 577 | accept(); | |
| 578 | }); | |
| 579 | box.append(li); | |
| 580 | }); | |
| 581 | const { x, y } = caret(); | |
| 582 | box.hidden = false; | |
| 583 | const w = box.offsetWidth; | |
| 584 | box.style.left = `${Math.max(8, Math.min(x, innerWidth - w - 8))}px`; | |
| 585 | box.style.top = `${y + 4}px`; | |
| 586 | } | |
| 587 | function hideSuggest() { | |
| 588 | if (box) box.hidden = true; | |
| 589 | sel = 0; | |
| 590 | } | |
| 591 | function accept() { | |
| 592 | const found = query(); | |
| 593 | const title = items[sel]; | |
| 594 | if (!found || title === undefined) return; | |
| 595 | const at = source.selectionStart; | |
| 596 | const closes = source.value.slice(at, at + 2) === "]]"; | |
| 597 | type(`${title}${closes ? "" : "]]"}`, found.from, at); | |
| 598 | const end = found.from + title.length + 2; | |
| 599 | source.setSelectionRange(end, end); | |
| 600 | hideSuggest(); | |
| 601 | changed(); | |
| 602 | draw(); | |
| 603 | } | |
| 604 | function suggestKeys(e) { | |
| 605 | if (!box || box.hidden) return false; | |
| 606 | if (e.key === "ArrowDown" || e.key === "ArrowUp") { | |
| 607 | e.preventDefault(); | |
| 608 | sel = (sel + (e.key === "ArrowDown" ? 1 : items.length - 1)) % items.length; | |
| 609 | suggest(); | |
| 610 | return true; | |
| 611 | } | |
| 612 | if (e.key === "Enter" || e.key === "Tab") { | |
| 613 | e.preventDefault(); | |
| 614 | accept(); | |
| 615 | return true; | |
| 616 | } | |
| 617 | if (e.key === "Escape") { | |
| 618 | e.preventDefault(); | |
| 619 | hideSuggest(); | |
| 620 | return true; | |
| 621 | } | |
| 622 | return false; | |
| 623 | } | |
| 624 | /** Where the caret is on screen: a hidden copy of the textarea, measured. */ | |
| 625 | function caret() { | |
| 626 | const style = getComputedStyle(source); | |
| 627 | const mirror = document.createElement("div"); | |
| 628 | for (const p of ["boxSizing", "width", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft", "borderTopWidth", "borderLeftWidth", "fontFamily", "fontSize", "fontWeight", "lineHeight", "letterSpacing", "tabSize", "wordSpacing"]) | |
| 629 | mirror.style[p] = style[p]; | |
| 630 | Object.assign(mirror.style, { position: "absolute", visibility: "hidden", whiteSpace: "pre-wrap", overflowWrap: "break-word", top: "0", left: "-9999px" }); | |
| 631 | mirror.textContent = source.value.slice(0, source.selectionStart); | |
| 632 | const mark = document.createElement("span"); | |
| 633 | mark.textContent = ""; | |
| 634 | mirror.append(mark); | |
| 635 | document.body.append(mirror); | |
| 636 | const r = source.getBoundingClientRect(); | |
| 637 | const x = r.left + mark.offsetLeft - source.scrollLeft; | |
| 638 | const y = r.top + mark.offsetTop - source.scrollTop + parseFloat(style.lineHeight || "24"); | |
| 639 | mirror.remove(); | |
| 640 | return { x, y }; | |
| 641 | } | |
| 642 | ||
| 643 | if (empty && document.activeElement === document.body) { | |
| 644 | if (/^Untitled( \d+)?$/.test(titleEl.value)) { | |
| 645 | titleEl.focus(); | |
| 646 | titleEl.select(); | |
| 647 | } else source.focus(); | |
| 648 | } | |
| 649 | } |
static/graph.js added +370
| @@ -0,0 +1,370 @@ | ||
| 1 | // The graph: every note a point, every [[link]] a line, laid out by a | |
| 2 | // small force simulation — notes push each other apart, links pull their | |
| 3 | // ends together, and the whole drifts to the middle. Drag a note to move | |
| 4 | // it, drag the space to pan, scroll or pinch to zoom, click to open. | |
| 5 | // A canvas with data-graph="local" and data-focus="<id>" draws only that | |
| 6 | // note and its neighbours. Colours are the tokens', read from the page. | |
| 7 | ||
| 8 | const charts = new WeakSet(); | |
| 9 | ||
| 10 | function data() { | |
| 11 | try { | |
| 12 | return JSON.parse(document.getElementById("graph")?.textContent ?? ""); | |
| 13 | } catch { | |
| 14 | return { nodes: [], edges: [] }; | |
| 15 | } | |
| 16 | } | |
| 17 | ||
| 18 | function colours() { | |
| 19 | const css = getComputedStyle(document.documentElement); | |
| 20 | const v = (name) => css.getPropertyValue(name).trim(); | |
| 21 | return { | |
| 22 | node: v("--color-graph-node"), | |
| 23 | edge: v("--color-graph-edge"), | |
| 24 | accent: v("--color-accent"), | |
| 25 | label: v("--color-fg"), | |
| 26 | quiet: v("--color-muted"), | |
| 27 | bg: v("--color-bg"), | |
| 28 | }; | |
| 29 | } | |
| 30 | ||
| 31 | /** A small deterministic spread, so the same vault opens the same way. */ | |
| 32 | const jitter = (id, k) => { | |
| 33 | const x = Math.sin(id * 12.9898 + k * 78.233) * 43758.5453; | |
| 34 | return x - Math.floor(x) - 0.5; | |
| 35 | }; | |
| 36 | ||
| 37 | function chart(canvas) { | |
| 38 | if (charts.has(canvas)) return; | |
| 39 | charts.add(canvas); | |
| 40 | const all = data(); | |
| 41 | const focus = canvas.dataset.focus ? Number(canvas.dataset.focus) : null; | |
| 42 | let nodes = all.nodes; | |
| 43 | let edges = all.edges; | |
| 44 | if (focus !== null) { | |
| 45 | const near = new Set([focus]); | |
| 46 | for (const [a, b] of edges) { | |
| 47 | if (a === focus) near.add(b); | |
| 48 | if (b === focus) near.add(a); | |
| 49 | } | |
| 50 | nodes = nodes.filter((n) => near.has(n.id)); | |
| 51 | edges = edges.filter(([a, b]) => near.has(a) && near.has(b)); | |
| 52 | } | |
| 53 | const local = focus !== null; | |
| 54 | const byId = new Map(); | |
| 55 | const ps = nodes.map((n, i) => { | |
| 56 | const angle = (i / Math.max(1, nodes.length)) * Math.PI * 2; | |
| 57 | const r = 40 + Math.sqrt(nodes.length) * 18; | |
| 58 | const p = { | |
| 59 | ...n, | |
| 60 | x: n.id === focus ? 0 : Math.cos(angle) * r + jitter(n.id, 1) * 30, | |
| 61 | y: n.id === focus ? 0 : Math.sin(angle) * r + jitter(n.id, 2) * 30, | |
| 62 | vx: 0, | |
| 63 | vy: 0, | |
| 64 | r: (local ? 4 : 3) + Math.sqrt(n.links) * 1.7, | |
| 65 | fixed: false, | |
| 66 | }; | |
| 67 | byId.set(n.id, p); | |
| 68 | return p; | |
| 69 | }); | |
| 70 | const links = edges.map(([a, b]) => [byId.get(a), byId.get(b)]).filter(([a, b]) => a && b); | |
| 71 | const neighbours = new Map(ps.map((p) => [p.id, new Set()])); | |
| 72 | for (const [a, b] of links) { | |
| 73 | neighbours.get(a.id).add(b.id); | |
| 74 | neighbours.get(b.id).add(a.id); | |
| 75 | } | |
| 76 | ||
| 77 | const ctx = canvas.getContext("2d"); | |
| 78 | let c = colours(); | |
| 79 | let w = 0; | |
| 80 | let h = 0; | |
| 81 | let view = { x: 0, y: 0, k: local ? 1.1 : 1 }; | |
| 82 | let hover = null; | |
| 83 | let alpha = 1; | |
| 84 | let running = false; | |
| 85 | const still = matchMedia("(prefers-reduced-motion: reduce)").matches; | |
| 86 | ||
| 87 | function size() { | |
| 88 | const r = canvas.getBoundingClientRect(); | |
| 89 | const dpr = devicePixelRatio || 1; | |
| 90 | w = r.width; | |
| 91 | h = r.height; | |
| 92 | canvas.width = Math.round(w * dpr); | |
| 93 | canvas.height = Math.round(h * dpr); | |
| 94 | ctx.setTransform(dpr, 0, 0, dpr, 0, 0); | |
| 95 | draw(); | |
| 96 | } | |
| 97 | ||
| 98 | function tick() { | |
| 99 | const n = ps.length; | |
| 100 | const repel = local ? 900 : 1400; | |
| 101 | for (let i = 0; i < n; i++) { | |
| 102 | const a = ps[i]; | |
| 103 | for (let j = i + 1; j < n; j++) { | |
| 104 | const b = ps[j]; | |
| 105 | let dx = a.x - b.x; | |
| 106 | let dy = a.y - b.y; | |
| 107 | let d2 = dx * dx + dy * dy; | |
| 108 | if (d2 < 0.01) { | |
| 109 | dx = jitter(a.id, j); | |
| 110 | dy = jitter(b.id, i); | |
| 111 | d2 = 0.01; | |
| 112 | } | |
| 113 | if (d2 > 250000) continue; | |
| 114 | const f = (repel * alpha) / d2; | |
| 115 | const d = Math.sqrt(d2); | |
| 116 | a.vx += (dx / d) * f; | |
| 117 | a.vy += (dy / d) * f; | |
| 118 | b.vx -= (dx / d) * f; | |
| 119 | b.vy -= (dy / d) * f; | |
| 120 | } | |
| 121 | } | |
| 122 | const rest = local ? 70 : 60; | |
| 123 | for (const [a, b] of links) { | |
| 124 | const dx = b.x - a.x; | |
| 125 | const dy = b.y - a.y; | |
| 126 | const d = Math.sqrt(dx * dx + dy * dy) || 1; | |
| 127 | const f = (d - rest) * 0.04 * alpha; | |
| 128 | a.vx += (dx / d) * f; | |
| 129 | a.vy += (dy / d) * f; | |
| 130 | b.vx -= (dx / d) * f; | |
| 131 | b.vy -= (dy / d) * f; | |
| 132 | } | |
| 133 | for (const p of ps) { | |
| 134 | p.vx -= p.x * 0.012 * alpha; | |
| 135 | p.vy -= p.y * 0.012 * alpha; | |
| 136 | if (p.fixed || (local && p.id === focus)) { | |
| 137 | p.vx = p.vy = 0; | |
| 138 | if (local && p.id === focus && !p.fixed) p.x = p.y = 0; | |
| 139 | continue; | |
| 140 | } | |
| 141 | p.vx *= 0.82; | |
| 142 | p.vy *= 0.82; | |
| 143 | p.x += Math.max(-30, Math.min(30, p.vx)); | |
| 144 | p.y += Math.max(-30, Math.min(30, p.vy)); | |
| 145 | } | |
| 146 | alpha = Math.max(0, alpha * 0.985 - 0.0005); | |
| 147 | } | |
| 148 | ||
| 149 | // Once the layout has mostly settled, frame it: the whole vault in view, | |
| 150 | // never blown up past a comfortable size. Only once — after that the | |
| 151 | // view is yours. | |
| 152 | let framed = false; | |
| 153 | function fit() { | |
| 154 | framed = true; | |
| 155 | if (ps.length === 0 || w === 0) return; | |
| 156 | let [x0, y0, x1, y1] = [Infinity, Infinity, -Infinity, -Infinity]; | |
| 157 | for (const p of ps) { | |
| 158 | x0 = Math.min(x0, p.x - p.r); | |
| 159 | y0 = Math.min(y0, p.y - p.r); | |
| 160 | x1 = Math.max(x1, p.x + p.r); | |
| 161 | y1 = Math.max(y1, p.y + p.r + 18); | |
| 162 | } | |
| 163 | const k = Math.min((w * 0.8) / Math.max(1, x1 - x0), (h * 0.75) / Math.max(1, y1 - y0), local ? 1.6 : 2.4); | |
| 164 | view = { x: -(x0 + x1) / 2, y: -(y0 + y1) / 2, k: Math.max(0.25, k) }; | |
| 165 | } | |
| 166 | ||
| 167 | function loop() { | |
| 168 | if (!framed && alpha < 0.3 && !drag) fit(); | |
| 169 | if (alpha <= 0.002) { | |
| 170 | running = false; | |
| 171 | draw(); | |
| 172 | return; | |
| 173 | } | |
| 174 | tick(); | |
| 175 | draw(); | |
| 176 | requestAnimationFrame(loop); | |
| 177 | } | |
| 178 | function heat(to = 0.6) { | |
| 179 | alpha = Math.max(alpha, to); | |
| 180 | if (still) { | |
| 181 | for (let i = 0; i < 400 && alpha > 0.002; i++) tick(); | |
| 182 | if (!framed) fit(); | |
| 183 | draw(); | |
| 184 | return; | |
| 185 | } | |
| 186 | if (!running) { | |
| 187 | running = true; | |
| 188 | requestAnimationFrame(loop); | |
| 189 | } | |
| 190 | } | |
| 191 | ||
| 192 | const screen = (p) => ({ x: w / 2 + (p.x + view.x) * view.k, y: h / 2 + (p.y + view.y) * view.k }); | |
| 193 | const world = (x, y) => ({ x: (x - w / 2) / view.k - view.x, y: (y - h / 2) / view.k - view.y }); | |
| 194 | ||
| 195 | function draw() { | |
| 196 | ctx.clearRect(0, 0, w, h); | |
| 197 | const lit = hover ?? (local ? byId.get(focus) : null); | |
| 198 | const near = lit ? neighbours.get(lit.id) : null; | |
| 199 | ctx.lineWidth = 1; | |
| 200 | for (const [a, b] of links) { | |
| 201 | const on = lit && (a === lit || b === lit); | |
| 202 | ctx.strokeStyle = on ? c.accent : c.edge; | |
| 203 | ctx.globalAlpha = lit && !on ? 0.35 : on ? 0.9 : 0.75; | |
| 204 | const p = screen(a); | |
| 205 | const q = screen(b); | |
| 206 | ctx.beginPath(); | |
| 207 | ctx.moveTo(p.x, p.y); | |
| 208 | ctx.lineTo(q.x, q.y); | |
| 209 | ctx.stroke(); | |
| 210 | } | |
| 211 | const labels = local || view.k > 1.15 || ps.length <= 30; | |
| 212 | ctx.font = `${local ? 11 : 12}px system-ui, -apple-system, sans-serif`; | |
| 213 | ctx.textAlign = "center"; | |
| 214 | ctx.textBaseline = "top"; | |
| 215 | for (const p of ps) { | |
| 216 | const s = screen(p); | |
| 217 | const r = p.r * Math.sqrt(view.k); | |
| 218 | const on = lit && (p === lit || near.has(p.id)); | |
| 219 | ctx.globalAlpha = lit && !on ? 0.35 : 1; | |
| 220 | ctx.fillStyle = p === lit || p.id === focus ? c.accent : c.node; | |
| 221 | ctx.beginPath(); | |
| 222 | ctx.arc(s.x, s.y, r, 0, Math.PI * 2); | |
| 223 | ctx.fill(); | |
| 224 | if (labels || on) { | |
| 225 | const t = p.title.length > 28 ? `${p.title.slice(0, 27)}…` : p.title; | |
| 226 | ctx.globalAlpha = lit && !on ? 0.3 : 1; | |
| 227 | ctx.fillStyle = p === lit ? c.label : c.quiet; | |
| 228 | ctx.fillText(t, s.x, s.y + r + 4); | |
| 229 | } | |
| 230 | } | |
| 231 | ctx.globalAlpha = 1; | |
| 232 | } | |
| 233 | ||
| 234 | function at(x, y) { | |
| 235 | let best = null; | |
| 236 | let bestD = Infinity; | |
| 237 | for (const p of ps) { | |
| 238 | const s = screen(p); | |
| 239 | const d = Math.hypot(s.x - x, s.y - y); | |
| 240 | const reach = Math.max(10, p.r * Math.sqrt(view.k) + 4); | |
| 241 | if (d < reach && d < bestD) { | |
| 242 | best = p; | |
| 243 | bestD = d; | |
| 244 | } | |
| 245 | } | |
| 246 | return best; | |
| 247 | } | |
| 248 | ||
| 249 | // Pointers: one drags a note or pans; two pinch. | |
| 250 | const pointers = new Map(); | |
| 251 | let drag = null; | |
| 252 | let moved = 0; | |
| 253 | let pinch = null; | |
| 254 | const local2 = (e) => { | |
| 255 | const r = canvas.getBoundingClientRect(); | |
| 256 | return { x: e.clientX - r.left, y: e.clientY - r.top }; | |
| 257 | }; | |
| 258 | canvas.addEventListener("pointerdown", (e) => { | |
| 259 | canvas.setPointerCapture(e.pointerId); | |
| 260 | const p = local2(e); | |
| 261 | pointers.set(e.pointerId, p); | |
| 262 | moved = 0; | |
| 263 | if (pointers.size === 2) { | |
| 264 | const [a, b] = [...pointers.values()]; | |
| 265 | pinch = { d: Math.hypot(a.x - b.x, a.y - b.y), k: view.k }; | |
| 266 | drag = null; | |
| 267 | return; | |
| 268 | } | |
| 269 | const node = at(p.x, p.y); | |
| 270 | drag = node ? { node, ...p } : { pan: true, ...p, vx: view.x, vy: view.y }; | |
| 271 | if (node) { | |
| 272 | node.fixed = true; | |
| 273 | heat(0.3); | |
| 274 | } | |
| 275 | }); | |
| 276 | canvas.addEventListener("pointermove", (e) => { | |
| 277 | const p = local2(e); | |
| 278 | if (pointers.has(e.pointerId)) pointers.set(e.pointerId, p); | |
| 279 | if (pinch && pointers.size === 2) { | |
| 280 | const [a, b] = [...pointers.values()]; | |
| 281 | view.k = Math.max(0.2, Math.min(5, (pinch.k * Math.hypot(a.x - b.x, a.y - b.y)) / pinch.d)); | |
| 282 | draw(); | |
| 283 | return; | |
| 284 | } | |
| 285 | if (drag) { | |
| 286 | moved += Math.abs(e.movementX) + Math.abs(e.movementY); | |
| 287 | if (drag.node) { | |
| 288 | const q = world(p.x, p.y); | |
| 289 | drag.node.x = q.x; | |
| 290 | drag.node.y = q.y; | |
| 291 | heat(0.3); | |
| 292 | } else { | |
| 293 | view.x = drag.vx + (p.x - drag.x) / view.k; | |
| 294 | view.y = drag.vy + (p.y - drag.y) / view.k; | |
| 295 | } | |
| 296 | draw(); | |
| 297 | return; | |
| 298 | } | |
| 299 | const over = at(p.x, p.y); | |
| 300 | if (over !== hover) { | |
| 301 | hover = over; | |
| 302 | canvas.style.cursor = over ? "pointer" : "grab"; | |
| 303 | draw(); | |
| 304 | } | |
| 305 | }); | |
| 306 | const up = (e) => { | |
| 307 | pointers.delete(e.pointerId); | |
| 308 | if (pointers.size < 2) pinch = null; | |
| 309 | if (!drag) return; | |
| 310 | if (drag.node) { | |
| 311 | drag.node.fixed = false; | |
| 312 | if (moved < 6) location.href = `/n/${drag.node.id}`; | |
| 313 | } | |
| 314 | drag = null; | |
| 315 | }; | |
| 316 | canvas.addEventListener("pointerup", up); | |
| 317 | canvas.addEventListener("pointercancel", up); | |
| 318 | canvas.addEventListener("pointerleave", () => { | |
| 319 | if (hover && !drag) { | |
| 320 | hover = null; | |
| 321 | draw(); | |
| 322 | } | |
| 323 | }); | |
| 324 | canvas.addEventListener( | |
| 325 | "wheel", | |
| 326 | (e) => { | |
| 327 | e.preventDefault(); | |
| 328 | const p = local2(e); | |
| 329 | const before = world(p.x, p.y); | |
| 330 | view.k = Math.max(0.2, Math.min(5, view.k * Math.exp(-e.deltaY * 0.0015))); | |
| 331 | const after = world(p.x, p.y); | |
| 332 | view.x += after.x - before.x; | |
| 333 | view.y += after.y - before.y; | |
| 334 | draw(); | |
| 335 | }, | |
| 336 | { passive: false }, | |
| 337 | ); | |
| 338 | // The keyboard reaches what the mouse does: Tab focuses the canvas, arrows | |
| 339 | // pan, + and - zoom. | |
| 340 | canvas.tabIndex = 0; | |
| 341 | canvas.addEventListener("keydown", (e) => { | |
| 342 | const step = 40 / view.k; | |
| 343 | const moves = { ArrowLeft: [step, 0], ArrowRight: [-step, 0], ArrowUp: [0, step], ArrowDown: [0, -step] }; | |
| 344 | if (moves[e.key]) { | |
| 345 | view.x += moves[e.key][0]; | |
| 346 | view.y += moves[e.key][1]; | |
| 347 | } else if (e.key === "+" || e.key === "=") view.k = Math.min(5, view.k * 1.2); | |
| 348 | else if (e.key === "-") view.k = Math.max(0.2, view.k / 1.2); | |
| 349 | else return; | |
| 350 | e.preventDefault(); | |
| 351 | draw(); | |
| 352 | }); | |
| 353 | canvas.style.cursor = "grab"; | |
| 354 | ||
| 355 | new ResizeObserver(size).observe(canvas); | |
| 356 | document.addEventListener("theme", () => { | |
| 357 | c = colours(); | |
| 358 | draw(); | |
| 359 | }); | |
| 360 | matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { | |
| 361 | c = colours(); | |
| 362 | draw(); | |
| 363 | }); | |
| 364 | size(); | |
| 365 | heat(1); | |
| 366 | } | |
| 367 | ||
| 368 | const start = () => document.querySelectorAll("canvas[data-graph]").forEach(chart); | |
| 369 | start(); | |
| 370 | document.addEventListener("live:swapped", start); |
static/live.js +1
| @@ -55,6 +55,7 @@ async function refresh(types) { | ||
| 55 | 55 | const next = fresh.getElementById(el.id); |
| 56 | 56 | if (next) el.replaceWith(next); |
| 57 | 57 | } |
| 58 | document.dispatchEvent(new CustomEvent("live:swapped")); | |
| 58 | 59 | } finally { |
| 59 | 60 | refreshing = false; |
| 60 | 61 | if (again) { |
static/md.js added +234
| @@ -0,0 +1,234 @@ | ||
| 1 | // Markdown, the vault's flavour of it, in one file that runs in two places: | |
| 2 | // the server imports it to render the first paint, and the browser loads | |
| 3 | // it to render the live preview while you type. One renderer, so what you | |
| 4 | // see while writing is what the page shows afterwards. | |
| 5 | // | |
| 6 | // Safe by construction: the text is escaped first, and every tag comes | |
| 7 | // from a pattern matched on the escaped text — nothing a note says can | |
| 8 | // open a tag the renderer did not write. Links go only to http(s), mailto | |
| 9 | // and this site's own paths. | |
| 10 | // | |
| 11 | // Beyond CommonMark's everyday core: [[wikilinks]] (with |alias and | |
| 12 | // #heading), #tags, ==highlights==, ~~strikes~~, - [ ] tasks, tables, | |
| 13 | // and Obsidian's callouts (> [!note] Title). | |
| 14 | ||
| 15 | const ESC = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; | |
| 16 | const UNESC = { "&": "&", "<": "<", ">": ">", """: '"', "'": "'" }; | |
| 17 | ||
| 18 | /** @param {string} s */ | |
| 19 | export const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ESC[c] ?? c); | |
| 20 | /** @param {string} s */ | |
| 21 | const unescapeHtml = (s) => s.replace(/&(?:amp|lt|gt|quot|#39);/g, (e) => UNESC[e] ?? e); | |
| 22 | ||
| 23 | /** A heading's anchor: lower case, words joined by dashes. @param {string} s */ | |
| 24 | export const slug = (s) => | |
| 25 | s | |
| 26 | .toLowerCase() | |
| 27 | .replace(/[^\p{L}\p{N}\s-]/gu, "") | |
| 28 | .trim() | |
| 29 | .replace(/\s+/g, "-"); | |
| 30 | ||
| 31 | /** The text with fenced code and inline code removed, for finding links and tags. @param {string} md */ | |
| 32 | const prose = (md) => md.replace(/^(```|~~~)[^\n]*\n[\s\S]*?(?:^\1[^\n]*$|(?![\s\S]))/gm, "").replace(/`[^`\n]*`/g, ""); | |
| 33 | ||
| 34 | const WIKI = /(!?)\[\[([^\[\]\n|]+?)(?:\|([^\[\]\n]+?))?\]\]/g; | |
| 35 | const TAG = /(^|[\s(])#([\p{L}\p{N}_/-]*[\p{L}_/-][\p{L}\p{N}_/-]*)/gu; | |
| 36 | ||
| 37 | /** The note titles a note links to, in order, once each (no #heading). @param {string} md */ | |
| 38 | export function links(md) { | |
| 39 | const seen = new Set(); | |
| 40 | const out = []; | |
| 41 | for (const m of prose(md).matchAll(WIKI)) { | |
| 42 | const target = m[2].split("#")[0].trim(); | |
| 43 | const key = target.toLowerCase(); | |
| 44 | if (target !== "" && !seen.has(key)) { | |
| 45 | seen.add(key); | |
| 46 | out.push(target); | |
| 47 | } | |
| 48 | } | |
| 49 | return out; | |
| 50 | } | |
| 51 | ||
| 52 | /** The tags a note carries, lower-cased, once each, without the #. @param {string} md */ | |
| 53 | export function tags(md) { | |
| 54 | const out = new Set(); | |
| 55 | for (const m of prose(md).matchAll(TAG)) out.add(m[2].toLowerCase()); | |
| 56 | return [...out]; | |
| 57 | } | |
| 58 | ||
| 59 | /** The note's headings, for an outline. @param {string} md */ | |
| 60 | export function headings(md) { | |
| 61 | const out = []; | |
| 62 | for (const m of prose(md).matchAll(/^(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$/gm)) { | |
| 63 | const level = m[1].length; | |
| 64 | const text = m[2].trim(); | |
| 65 | if (text === "") continue; | |
| 66 | out.push({ level, text, id: slug(text) }); | |
| 67 | } | |
| 68 | return out; | |
| 69 | } | |
| 70 | ||
| 71 | const SAFE_URL = /^(?:https?:\/\/|mailto:|\/(?!\/)|#)/i; | |
| 72 | ||
| 73 | /** | |
| 74 | * One line's inline marks, on text that is already escaped. | |
| 75 | * @param {string} text escaped | |
| 76 | * @param {Options} o | |
| 77 | */ | |
| 78 | function inline(text, o) { | |
| 79 | // Code spans first: what is inside them is no mark. | |
| 80 | const parts = text.split(/(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/); | |
| 81 | let out = ""; | |
| 82 | for (let i = 0; i < parts.length; i++) { | |
| 83 | if (i % 3 === 0) out += marks(parts[i], o); | |
| 84 | else if (i % 3 === 2) out += `<code>${parts[i]}</code>`; | |
| 85 | } | |
| 86 | return out; | |
| 87 | } | |
| 88 | ||
| 89 | /** @param {string} s escaped @param {Options} o */ | |
| 90 | function marks(s, o) { | |
| 91 | const held = []; | |
| 92 | const hold = (html) => `\u0000${held.push(html) - 1}\u0000`; | |
| 93 | s = s | |
| 94 | .replace(WIKI, (_, embed, target, alias) => { | |
| 95 | const [title, heading] = unescapeHtml(target).split("#"); | |
| 96 | const name = title.trim(); | |
| 97 | const shown = alias ?? target; | |
| 98 | const href = o.resolve(name); | |
| 99 | const anchor = heading ? `#${slug(heading)}` : ""; | |
| 100 | return hold( | |
| 101 | href === null | |
| 102 | ? `<a class="wikilink unresolved" href="/open?title=${encodeURIComponent(name)}" data-title="${escapeHtml(name)}">${shown}</a>` | |
| 103 | : `<a class="wikilink${embed ? " embed" : ""}" href="${escapeHtml(href)}${anchor}" data-title="${escapeHtml(name)}">${shown}</a>`, | |
| 104 | ); | |
| 105 | }) | |
| 106 | .replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (whole, label, url) => | |
| 107 | SAFE_URL.test(unescapeHtml(url)) | |
| 108 | ? hold(`<a href="${url}"${/^https?:/i.test(url) ? ' rel="noopener noreferrer" target="_blank"' : ""}>${label}</a>`) | |
| 109 | : whole, | |
| 110 | ) | |
| 111 | .replace(/(^|[\s(])(https?:\/\/(?:(?!&(?:quot|#39|lt|gt);)[^\s<])*[^\s<.,;:!?)'"&])/g, (_, lead, url) => | |
| 112 | lead + hold(`<a href="${url}" rel="noopener noreferrer" target="_blank">${url}</a>`), | |
| 113 | ) | |
| 114 | .replace(TAG, (_, lead, tag) => | |
| 115 | lead + hold(`<a class="tag" href="/search?q=${encodeURIComponent(`#${unescapeHtml(tag)}`)}">#${tag}</a>`), | |
| 116 | ); | |
| 117 | s = s | |
| 118 | .replace(/\*\*(?=\S)([^*]*?\S)\*\*/g, "<strong>$1</strong>") | |
| 119 | .replace(/__(?=\S)([^_]*?\S)__/g, "<strong>$1</strong>") | |
| 120 | .replace(/(^|[^*\w])\*(?=\S)([^*]*?\S)\*(?!\w)/g, "$1<em>$2</em>") | |
| 121 | .replace(/(^|[^_\w])_(?=\S)([^_]*?\S)_(?!\w)/g, "$1<em>$2</em>") | |
| 122 | .replace(/~~(?=\S)([^~]*?\S)~~/g, "<del>$1</del>") | |
| 123 | .replace(/==(?=\S)([^=]*?\S)==/g, "<mark>$1</mark>"); | |
| 124 | return s.replace(/\u0000(\d+)\u0000/g, (_, i) => held[Number(i)]); | |
| 125 | } | |
| 126 | ||
| 127 | /** | |
| 128 | * @typedef {{ resolve: (title: string) => string | null }} Options | |
| 129 | * `resolve` turns a linked note's title into its address, or null when no | |
| 130 | * such note exists yet (the link is drawn as one to create). | |
| 131 | */ | |
| 132 | ||
| 133 | const CALLOUTS = new Set(["note", "info", "tip", "success", "question", "warning", "danger", "bug", "example", "quote", "abstract", "todo", "failure"]); | |
| 134 | ||
| 135 | /** | |
| 136 | * A whole note, as HTML. | |
| 137 | * @param {string} md | |
| 138 | * @param {Partial<Options>} [options] | |
| 139 | */ | |
| 140 | export function render(md, options = {}) { | |
| 141 | const o = { resolve: options.resolve ?? (() => null) }; | |
| 142 | return blocks(md.replace(/\r\n?/g, "\n").split("\n"), o); | |
| 143 | } | |
| 144 | ||
| 145 | /** @param {string[]} lines @param {Options} o */ | |
| 146 | function blocks(lines, o) { | |
| 147 | let out = ""; | |
| 148 | let i = 0; | |
| 149 | const para = []; | |
| 150 | const flush = () => { | |
| 151 | if (para.length > 0) out += `<p>${para.map((l) => inline(escapeHtml(l), o)).join("<br>")}</p>\n`; | |
| 152 | para.length = 0; | |
| 153 | }; | |
| 154 | while (i < lines.length) { | |
| 155 | const line = lines[i]; | |
| 156 | let m; | |
| 157 | if (/^\s*$/.test(line)) { | |
| 158 | flush(); | |
| 159 | i++; | |
| 160 | } else if ((m = /^(```|~~~)\s*([\w+-]*)/.exec(line))) { | |
| 161 | flush(); | |
| 162 | const fence = m[1]; | |
| 163 | const code = []; | |
| 164 | i++; | |
| 165 | while (i < lines.length && !lines[i].startsWith(fence)) code.push(lines[i++]); | |
| 166 | i++; | |
| 167 | out += `<pre><code${m[2] ? ` class="lang-${escapeHtml(m[2])}"` : ""}>${escapeHtml(code.join("\n"))}</code></pre>\n`; | |
| 168 | } else if ((m = /^(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$/.exec(line))) { | |
| 169 | flush(); | |
| 170 | const n = m[1].length; | |
| 171 | out += `<h${n} id="${escapeHtml(slug(m[2]))}">${inline(escapeHtml(m[2]), o)}</h${n}>\n`; | |
| 172 | i++; | |
| 173 | } else if (/^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/.test(line)) { | |
| 174 | flush(); | |
| 175 | out += "<hr>\n"; | |
| 176 | i++; | |
| 177 | } else if (/^ {0,3}>/.test(line)) { | |
| 178 | flush(); | |
| 179 | const quoted = []; | |
| 180 | while (i < lines.length && /^ {0,3}>/.test(lines[i])) quoted.push(lines[i++].replace(/^ {0,3}> ?/, "")); | |
| 181 | const callout = /^\[!(\w+)\]([+-]?)[ \t]*(.*)$/.exec(quoted[0] ?? ""); | |
| 182 | if (callout) { | |
| 183 | const kind = callout[1].toLowerCase(); | |
| 184 | const cls = CALLOUTS.has(kind) ? kind : "note"; | |
| 185 | const heading = callout[3] || kind.charAt(0).toUpperCase() + kind.slice(1); | |
| 186 | out += `<div class="callout callout-${cls}"><p class="callout-title">${inline(escapeHtml(heading), o)}</p>${blocks(quoted.slice(1), o).trimEnd()}</div>\n`; | |
| 187 | } else out += `<blockquote>${blocks(quoted, o).trimEnd()}</blockquote>\n`; | |
| 188 | } else if (/^\s*([-*+]|\d+[.)])\s/.test(line)) { | |
| 189 | flush(); | |
| 190 | const items = []; | |
| 191 | while (i < lines.length && (/^\s*([-*+]|\d+[.)])\s/.test(lines[i]) || (/^\s{2,}\S/.test(lines[i]) && items.length > 0))) items.push(lines[i++]); | |
| 192 | out += list(items, o); | |
| 193 | } else if (/^\s*\|.*\|\s*$/.test(line) && /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/.test(lines[i + 1] ?? "")) { | |
| 194 | flush(); | |
| 195 | const cells = (l) => l.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim()); | |
| 196 | const head = cells(line); | |
| 197 | const align = cells(lines[i + 1]).map((c) => (c.startsWith(":") && c.endsWith(":") ? "center" : c.endsWith(":") ? "right" : "")); | |
| 198 | const style = (k) => (align[k] ? ` style="text-align:${align[k]}"` : ""); | |
| 199 | i += 2; | |
| 200 | let body = ""; | |
| 201 | while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) { | |
| 202 | body += `<tr>${cells(lines[i++]).map((c, k) => `<td${style(k)}>${inline(escapeHtml(c), o)}</td>`).join("")}</tr>`; | |
| 203 | } | |
| 204 | out += `<div class="table"><table><thead><tr>${head.map((c, k) => `<th${style(k)}>${inline(escapeHtml(c), o)}</th>`).join("")}</tr></thead><tbody>${body}</tbody></table></div>\n`; | |
| 205 | } else { | |
| 206 | para.push(line); | |
| 207 | i++; | |
| 208 | } | |
| 209 | } | |
| 210 | flush(); | |
| 211 | return out; | |
| 212 | } | |
| 213 | ||
| 214 | /** Lines of a list, nested by indentation. @param {string[]} lines @param {Options} o */ | |
| 215 | function list(lines, o) { | |
| 216 | const indent = (l) => l.match(/^\s*/)[0].replace(/\t/g, " ").length; | |
| 217 | const base = indent(lines[0]); | |
| 218 | const ordered = /^\s*\d+[.)]\s/.test(lines[0]); | |
| 219 | let html = ""; | |
| 220 | let i = 0; | |
| 221 | while (i < lines.length) { | |
| 222 | const m = /^\s*(?:[-*+]|\d+[.)])\s+(.*)$/.exec(lines[i]); | |
| 223 | const text = m ? m[1] : lines[i].trim(); | |
| 224 | i++; | |
| 225 | const child = []; | |
| 226 | while (i < lines.length && indent(lines[i]) > base) child.push(lines[i++]); | |
| 227 | const task = /^\[([ xX])\]\s+(.*)$/.exec(text); | |
| 228 | const body = task | |
| 229 | ? `<button type="button" class="check" role="checkbox" aria-checked="${task[1] !== " "}" aria-label="Done"></button><span${task[1] !== " " ? ' class="done"' : ""}>${inline(escapeHtml(task[2]), o)}</span>` | |
| 230 | : inline(escapeHtml(text), o); | |
| 231 | html += `<li${task ? ' class="task"' : ""}>${body}${child.length > 0 ? list(child, o).trimEnd() : ""}</li>`; | |
| 232 | } | |
| 233 | return ordered ? `<ol>${html}</ol>\n` : `<ul>${html}</ul>\n`; | |
| 234 | } |
static/theme.js added +7
| @@ -0,0 +1,7 @@ | ||
| 1 | // Before the first paint: the theme you chose, if you chose one. Light or | |
| 2 | // dark otherwise follows the system (the tokens' own media query). | |
| 3 | try { | |
| 4 | const theme = localStorage.getItem("theme"); | |
| 5 | if (theme === "light" || theme === "dark") | |
| 6 | document.documentElement.dataset.theme = theme; | |
| 7 | } catch {} |