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]>
Benjivers-bit committed 40c75dc Browse files
27 files changed +4417 −615
README.md +55 −63
@@ -1,25 +1,63 @@
11 # notes
22
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.
45
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:
943
1044 ```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
1356 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
1557 bun run lint:design # no colour but the tokens'
1658 bun run contract:emit # rewrite contract/openapi.json from the routes
1759 ```
1860
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
2361 ## The shape
2462
2563 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.
2967
3068 | file | what it is |
3169 | ---------------------------------- | -------------------------------------------------------------- |
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 |
3373 | `contract.json` | who may do what: roles, and the least role each verb needs |
3474 | `src/contract.ts`, `src/can.ts` | the table, read at boot; the forge's evaluator, verbatim |
3575 | `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.
4686 | `src/server.ts` | boot: open the database, serve |
4787 | `src/tty.ts` | the terminal, over the same actions |
4888 | `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 |
5091
5192 ## What every app stands on
5293
@@ -72,52 +113,3 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
72113 entirely; `/.well-known/security.txt` sends a reporter to the forge
73114 that runs the app.
74115
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 @@
33 "anon": { "above": [], "held": { "by": "everyone" } },
44 "user": { "above": ["anon"], "held": { "by": "identity", "role": "user" } },
55 "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" } }
77 },
88 "resources": {
99 "note": {
1010 "api": "/api/notes",
1111 "can": {
1212 "create": "user",
13 "read": "anon",
13 "read": "author",
1414 "update": "author",
1515 "delete": "author",
16 "list": "anon"
16 "list": "author"
1717 }
1818 },
1919 "audit": {
contract/openapi.json +113 −28
@@ -2,13 +2,13 @@
22 "openapi": "3.1.0",
33 "info": {
44 "title": "notes",
5 "version": "0.1.0",
5 "version": "1.0.0",
66 "description": "A personal linked-notes vault — markdown, [[wikilinks]], backlinks, tags, graph."
77 },
88 "paths": {
99 "/api/notes": {
1010 "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.",
1212 "operationId": "getApiNotes",
1313 "parameters": [
1414 {
@@ -17,14 +17,21 @@
1717 "schema": {
1818 "type": "integer",
1919 "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
2229 }
2330 }
2431 ],
2532 "responses": {
2633 "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.",
2835 "content": {
2936 "application/json": {
3037 "schema": {
@@ -50,11 +57,11 @@
5057 "x-contract": {
5158 "resource": "note",
5259 "verb": "list",
53 "least": "anon"
60 "least": "author"
5461 }
5562 },
5663 "post": {
57 "summary": "Add a note as the person asking.",
64 "summary": "Add a note to your vault.",
5865 "operationId": "postApiNotes",
5966 "requestBody": {
6067 "required": true,
@@ -63,22 +70,24 @@
6370 "schema": {
6471 "type": "object",
6572 "properties": {
73 "title": {
74 "type": "string",
75 "maxLength": 200,
76 "description": "Unique in your vault; “Untitled” when left out."
77 },
6678 "body": {
6779 "type": "string",
68 "minLength": 1,
69 "maxLength": 500
80 "maxLength": 200000,
81 "description": "Markdown."
7082 }
71 },
72 "required": [
73 "body"
74 ]
83 }
7584 }
7685 }
7786 }
7887 },
7988 "responses": {
8089 "201": {
81 "description": "Add a note as the person asking.",
90 "description": "Add a note to your vault.",
8291 "content": {
8392 "application/json": {
8493 "schema": {
@@ -107,7 +116,7 @@
107116 },
108117 "/api/notes/{id}": {
109118 "get": {
110 "summary": "One note.",
119 "summary": "One of your notes.",
111120 "operationId": "getApiNotesId",
112121 "parameters": [
113122 {
@@ -121,7 +130,7 @@
121130 ],
122131 "responses": {
123132 "200": {
124 "description": "One note.",
133 "description": "One of your notes.",
125134 "content": {
126135 "application/json": {
127136 "schema": {
@@ -144,11 +153,11 @@
144153 "x-contract": {
145154 "resource": "note",
146155 "verb": "read",
147 "least": "anon"
156 "least": "author"
148157 }
149158 },
150159 "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.",
152161 "operationId": "patchApiNotesId",
153162 "parameters": [
154163 {
@@ -167,26 +176,90 @@
167176 "schema": {
168177 "type": "object",
169178 "properties": {
170 "body": {
179 "title": {
171180 "type": "string",
172181 "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"
174191 }
175 },
176 "required": [
177 "body"
178 ]
192 }
179193 }
180194 }
181195 }
182196 },
183197 "responses": {
184198 "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.",
186200 "content": {
187201 "application/json": {
188202 "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 ]
190263 }
191264 }
192265 }
@@ -469,10 +542,20 @@
469542 "id": {
470543 "type": "integer"
471544 },
472 "body": {
545 "title": {
473546 "type": "string",
474547 "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 ]
476559 },
477560 "author_id": {
478561 "type": "string"
@@ -491,7 +574,9 @@
491574 },
492575 "required": [
493576 "id",
577 "title",
494578 "body",
579 "pinned",
495580 "author_id",
496581 "author_name",
497582 "created_at",
design/tokens.json +272 −35
@@ -3,93 +3,330 @@
33 "color": {
44 "$type": "color",
55 "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."
813 },
914 "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."
1222 },
1323 "fg": {
14 "$value": "#1c1c1a",
15 "$extensions": { "mode": { "dark": "#ecece7" } }
24 "$value": "#222222",
25 "$extensions": {
26 "mode": {
27 "dark": "#dadada"
28 }
29 }
1630 },
1731 "muted": {
18 "$value": "#6b6b65",
19 "$extensions": { "mode": { "dark": "#9a9a93" } }
32 "$value": "#5e5e5e",
33 "$extensions": {
34 "mode": {
35 "dark": "#a3a3a3"
36 }
37 }
2038 },
2139 "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."
2463 },
2564 "accent": {
26 "$value": "#2f5bea",
27 "$extensions": { "mode": { "dark": "#7c96ff" } }
65 "$value": "#6741d9",
66 "$extensions": {
67 "mode": {
68 "dark": "#a88bfa"
69 }
70 }
2871 },
2972 "accent-fg": {
3073 "$value": "#ffffff",
31 "$extensions": { "mode": { "dark": "#0f1a4d" } }
74 "$extensions": {
75 "mode": {
76 "dark": "#1a1033"
77 }
78 }
3279 },
3380 "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 }
36185 },
37186 "toast-bg": {
38 "$value": "#1c1c1a",
39 "$extensions": { "mode": { "dark": "#ecece7" } }
187 "$value": "#222222",
188 "$extensions": {
189 "mode": {
190 "dark": "#dadada"
191 }
192 }
40193 },
41194 "toast-fg": {
42 "$value": "#f6f6f3",
43 "$extensions": { "mode": { "dark": "#131312" } }
195 "$value": "#ffffff",
196 "$extensions": {
197 "mode": {
198 "dark": "#1e1e1e"
199 }
200 }
44201 },
45202 "shadow": {
46203 "$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 }
48217 }
49218 },
50219 "space": {
51220 "$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 }
58239 },
59240 "radius": {
60241 "$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 }
63254 },
64255 "size": {
65256 "$type": "dimension",
66257 "tap": {
67258 "$value": "44px",
68259 "$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."
69272 }
70273 },
71274 "font": {
72275 "$type": "fontFamily",
73276 "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 ]
75283 },
76284 "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 ]
78299 }
79300 },
80301 "text": {
81302 "$type": "dimension",
82 "sm": { "$value": "0.875rem" },
303 "sm": {
304 "$value": "0.875rem"
305 },
83306 "base": {
84307 "$value": "1rem",
85308 "$description": "Never below 16px: a phone zooms into a smaller input."
86309 },
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 }
89322 },
90323 "leading": {
91324 "$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 }
94331 }
95332 }
package.json +1 −1
@@ -1,6 +1,6 @@
11 {
22 "name": "notes",
3 "version": "0.1.0",
3 "version": "1.0.0",
44 "description": "A personal linked-notes vault — markdown, [[wikilinks]], backlinks, tags, graph.",
55 "private": true,
66 "type": "module",
src/actions.test.ts +116 −33
@@ -2,7 +2,19 @@ import { expect, test } from "bun:test";
22 import { mkdtempSync } from "node:fs";
33 import { tmpdir } from "node:os";
44 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";
618 import { openDb } from "./db.ts";
719 import type { Who } from "./who.ts";
820
@@ -12,47 +24,118 @@ const alice: Who = { role: "user", id: "u1", name: "alice" };
1224 const bob: Who = { role: "user", id: "u2", name: "bob" };
1325 const root: Who = { role: "admin", id: "u0", name: "root" };
1426
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" });
1834 expect(refused.ok).toBe(false);
1935 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);
3548 }
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);
3653 });
3754
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 });
45124 expect(byAnon.ok).toBe(false);
46125 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 });
49128 expect(gone.ok).toBe(false);
50129 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 });
56131 expect(odd.ok).toBe(false);
57132 if (!odd.ok) expect(odd.error).toBe("invalid");
58133 });
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";
1010 import { record, type AuditRow } from "./audit.ts";
1111 import { permit } from "./contract.ts";
1212 import { emit } from "./live.ts";
13 import { relink, search } from "./vault.ts";
1314 import type { Who } from "./who.ts";
1415
1516 export interface Note {
1617 readonly id: number;
18 readonly title: string;
1719 readonly body: string;
20 readonly pinned: number;
1821 readonly author_id: string;
1922 readonly author_name: string;
2023 readonly created_at: string;
@@ -32,7 +35,10 @@ const fail = <T>(error: Failure, message: string): Result<T> => ({
3235 message,
3336 });
3437
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;
3642
3743 /** The refusal for a "no": who is not signed in is asked to; who is, is told. */
3844 const refuse = <T>(who: Who, signIn: string, no: string): Result<T> =>
@@ -61,61 +67,109 @@ const audited = <T extends { readonly id: number }>(
6167 return result;
6268 };
6369
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 */
6499 export function listNotes(
65100 db: Database,
66101 who: Who,
67102 input: { limit?: number } = {},
68103 ): Result<Note[]> {
69104 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,
72107 );
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.
77108 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.");
87111 return ok(
88112 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),
94117 );
95118 }
96119
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
97139 export const addNote = (
98140 db: Database,
99141 who: Who,
100 input: { body: string },
142 input: { title?: string; body?: string },
101143 ): Result<Note> =>
102144 audited(db, who, "note", "create", undefined, add(db, who, input));
103145
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> {
105151 // The table decides; the type only learns it here: whoever may create is
106152 // signed in, and carries a name to write beside the note.
107153 if (who.role === "anon" || permit(who, "note", "create") !== "yes")
108154 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 }
112167 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);
119173 return ok(note);
120174 }
121175
@@ -158,34 +212,111 @@ export function getNote(
158212 });
159213 }
160214
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
161253 export const updateNote = (
162254 db: Database,
163255 who: Who,
164 input: { id: number; body: string },
165 ): Result<Note> =>
256 input: Change,
257 ): Result<Note & { relinked: number }> =>
166258 audited(db, who, "note", "update", input.id, update(db, who, input));
167259
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 */
168265 function update(
169266 db: Database,
170267 who: Who,
171 input: { id: number; body: string },
172 ): Result<Note> {
268 input: Change,
269 ): Result<Note & { relinked: number }> {
173270 const found = noteFor(db, who, "update", input.id, {
174271 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.",
176273 });
177274 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 });
189320 }
190321
191322 export const deleteNote = (
@@ -202,18 +333,76 @@ function remove(
202333 ): Result<{ id: number }> {
203334 const found = noteFor(db, who, "delete", input.id, {
204335 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.",
206337 });
207338 if (!found.ok) return found;
208339 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);
210341 return ok({ id: found.value.id });
211342 }
212343
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
213369 /** Whether `who` may remove `note`: the table's answer, for the page and the terminal to draw from. */
214370 export const canDelete = (who: Who, note: Note): boolean =>
215371 permit(who, "note", "delete", { ...note }) === "yes";
216372
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
217406 /**
218407 * The audit trail, newest first, for whoever the table lets read it (an
219408 * 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 {
3434 return { role, id: actor.id, name: identity };
3535 }
3636
37 let made = 0;
3738 const noteBy = (authorId: string): number =>
3839 db
3940 .query<
4041 { 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;
4445
4546 const auditRow = (): number => {
4647 record(db, {
src/db.ts +19
@@ -38,6 +38,25 @@ export const migrations: string[] = [
3838 allowed INTEGER NOT NULL CHECK (allowed IN (0, 1)),
3939 outcome TEXT NOT NULL
4040 )`,
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)`,
4160 ];
4261
4362 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", () => {
7070 ["color-accent", "color-surface"],
7171 ["color-accent-fg", "color-accent"],
7272 ["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"],
7386 ] as const;
7487 for (const mode of ["light", "dark"] as const)
7588 for (const [fg, bg] of pairs) {
src/http.ts +22 −4
@@ -74,6 +74,19 @@ export const back = (notice?: string): Response =>
7474 },
7575 });
7676
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
7790 /** The notice a redirect left, and the header that clears it. */
7891 export function readNotice(request: Request): {
7992 notice?: string;
@@ -100,6 +113,8 @@ const cookie = (name: string, value: string, maxAge: number): string =>
100113 // cap the rest is drained and dropped so the connection stays usable for
101114 // the next request; past eight times the cap it is cut off instead.
102115 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;
103118 export async function readBody(
104119 request: Request,
105120 max = BODY_MAX,
@@ -135,8 +150,8 @@ export async function readBody(
135150 }
136151
137152 /** 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);
140155 if (body === undefined) return undefined;
141156 return new Request(request.url, {
142157 method: "POST",
@@ -146,8 +161,11 @@ export async function readForm(request: Request) {
146161 }
147162
148163 /** 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);
151169 if (body === undefined) return undefined;
152170 try {
153171 return JSON.parse(new TextDecoder().decode(body));
src/live.ts +16 −4
@@ -5,12 +5,20 @@
55 * `?since=`) hears what it missed. `static/live.js` turns the stream into
66 * DOM events. The bus is this process's: the terminal is another process,
77 * 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.
812 */
913
14 import type { Who } from "./who.ts";
15
1016 export interface LiveEvent {
1117 readonly id: number;
1218 readonly type: string;
1319 readonly data: unknown;
20 /** Whose streams hear it; everyone's when absent. */
21 readonly to?: string;
1422 }
1523
1624 const KEEP = 100;
@@ -19,8 +27,8 @@ const kept: LiveEvent[] = [];
1927 const listeners = new Set<(event: LiveEvent) => void>();
2028 let last = 0;
2129
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 }) };
2432 kept.push(event);
2533 if (kept.length > KEEP) kept.shift();
2634 for (const listen of listeners) listen(event);
@@ -33,7 +41,9 @@ export function subscribe(listen: (event: LiveEvent) => void): () => void {
3341 }
3442
3543 /** `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);
3747 const since = Number(
3848 request.headers.get("last-event-id") ??
3949 new URL(request.url).searchParams.get("since") ??
@@ -50,10 +60,12 @@ export function events(request: Request): Response {
5060 stop();
5161 }
5262 };
53 const send = (event: LiveEvent): void =>
63 const send = (event: LiveEvent): void => {
64 if (!hears(event)) return;
5465 write(
5566 `id: ${event.id}\ndata: ${JSON.stringify({ type: event.type, data: event.data })}\n\n`,
5667 );
68 };
5769 const heartbeat = setInterval(() => write(":\n\n"), HEARTBEAT_MS);
5870 const unsubscribe = subscribe(send);
5971 stop = () => {
src/mcp.ts +112 −16
@@ -7,7 +7,19 @@
77 */
88
99 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";
1123 import { title } from "./env.ts";
1224 import { fields } from "./http.ts";
1325 import type { Who } from "./who.ts";
@@ -23,38 +35,122 @@ interface Tool {
2335 ) => Result<unknown>;
2436 }
2537
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
2651 export const tools: readonly Tool[] = [
2752 {
2853 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.",
3056 inputSchema: {
3157 type: "object",
3258 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." },
3960 },
4061 },
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 },
4371 },
4472 {
45 name: "add_note",
73 name: "search_notes",
4674 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.',
4876 inputSchema: {
4977 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 });
52149 },
53 run: (db, who, args) => addNote(db, who, { body: String(args.body ?? "") }),
54150 },
55151 {
56152 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.",
58154 inputSchema: {
59155 type: "object",
60156 properties: { id: { type: "integer" } },
src/openapi.test.ts +4
@@ -10,6 +10,7 @@ import { join } from "node:path";
1010 import { emit, openapi } from "./openapi.ts";
1111 import { routes } from "./routes.ts";
1212 import { serve } from "./server.ts";
13 import { sign } from "./who.ts";
1314
1415 const committed = readFileSync(
1516 new URL("../contract/openapi.json", import.meta.url),
@@ -66,8 +67,11 @@ test("GET /openapi.json serves the committed file", async () => {
6667 identityKey: "k",
6768 });
6869 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}`;
6972 const response = await fetch(
7073 `http://${server.hostname}:${server.port}/openapi.json`,
74 { headers: { "x-forge-identity": identity, "x-forge-signature": sign(identity, "k") } },
7175 );
7276 expect(response.status).toBe(200);
7377 expect(response.headers.get("content-type")).toContain("application/json");
src/page.ts +284 −82
@@ -1,43 +1,55 @@
11 /**
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.
57 */
68
79 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";
1711 import { asset, escape } from "./http.ts";
1812 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";
1925 import type { Who } from "./who.ts";
2026
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
2133 /**
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.
2537 */
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}`;
3244 return `<!doctype html>
3345 <html lang="en">
3446 <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">
3648 <meta name="color-scheme" content="light dark">
3749 <meta name="theme-color" content="${token("color-bg")}" media="(prefers-color-scheme: light)">
3850 <meta name="theme-color" content="${token("color-bg", "dark")}" media="(prefers-color-scheme: dark)">
3951 <link rel="icon" href="data:,">
40 <title>${escape(title)}</title>
52 <title>${escape(name)}</title>
4153 <meta name="description" content="${escape(description)}">
4254 <meta property="og:type" content="website">
4355 <meta property="og:site_name" content="${escape(title)}">
@@ -47,76 +59,266 @@ export function layout(body: string, who: Who, path = "/"): string {
4759 <meta name="twitter:card" content="summary">
4860 <meta name="twitter:title" content="${escape(title)}">
4961 <meta name="twitter:description" content="${escape(description)}">
62 <script src="${asset("theme.js")}"></script>
5063 <link rel="stylesheet" href="${tokensHref}">
5164 <link rel="stylesheet" href="${asset("app.css")}">
5265 <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}
7169 </body>
7270 </html>
7371 `;
7472 }
7573
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 {
9376 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" },
9889 );
9990 }
10091
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>`;
11293
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" });
122324 };
src/routes.ts +284 −42
@@ -21,16 +21,24 @@ import {
2121 getNote,
2222 listAudit,
2323 listNotes,
24 openOrCreate,
25 searchNotes,
26 TITLE_MAX,
27 today,
2428 updateNote,
29 WELCOME,
30 type Change,
2531 type Failure,
32 type Note,
2633 type Result,
2734 } from "./actions.ts";
2835 import { commit, env, forgeUrl, startedAt, version } from "./env.ts";
2936 import {
30 back,
3137 fields,
38 go,
3239 html,
3340 json,
41 NOTE_BYTES,
3442 readForm,
3543 readJson,
3644 readNotice,
@@ -40,8 +48,16 @@ import {
4048 } from "./http.ts";
4149 import { events } from "./live.ts";
4250 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";
4459 import { tokensCss } from "./tokens.ts";
60 import { search } from "./vault.ts";
4561 import type { Who } from "./who.ts";
4662
4763 export interface Context {
@@ -79,7 +95,9 @@ export const NOTE: Schema = {
7995 type: "object",
8096 properties: {
8197 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] },
83101 author_id: { type: "string" },
84102 author_name: { type: "string" },
85103 created_at: { type: "string", format: "date-time" },
@@ -87,17 +105,38 @@ export const NOTE: Schema = {
87105 },
88106 required: [
89107 "id",
108 "title",
90109 "body",
110 "pinned",
91111 "author_id",
92112 "author_name",
93113 "created_at",
94114 "updated_at",
95115 ],
96116 };
97 const NOTE_BODY: Schema = {
117 const NOTE_NEW: Schema = {
98118 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 },
101140 };
102141 export const AUDIT_ROW: Schema = {
103142 type: "object",
@@ -144,9 +183,34 @@ const answer = <T>(result: Result<T>, status = 200): Response =>
144183 STATUS[result.error],
145184 );
146185
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 }
150214
151215 /** A whole number from the query, or undefined. */
152216 const whole = (value: string | null): number | undefined =>
@@ -157,50 +221,215 @@ const OPENAPI = new URL("../contract/openapi.json", import.meta.url);
157221 const openapi = existsSync(OPENAPI) ? readFileSync(OPENAPI, "utf8") : undefined;
158222
159223 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.
161226 {
162227 method: "GET",
163228 path: "/",
164229 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 }),
171283 );
172284 },
173285 },
174286 {
175287 method: "POST",
176 path: "/notes",
288 path: "/n/:id/pin",
177289 handle: async (c) => {
178290 const form = await readForm(c.request);
179291 if (form === undefined) return text("too large", 413);
292 const id = Number(c.params.id);
180293 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 }),
182297 );
183298 },
184299 },
185300 {
186301 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 },
189407 },
190408
191409 // The JSON API: the same actions, the same identity, plain status codes.
192410 {
193411 method: "GET",
194412 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 },
201426 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 },
204433 answer: list(NOTE),
205434 },
206435 },
@@ -208,16 +437,20 @@ export const routes: readonly Route[] = [
208437 method: "POST",
209438 path: "/api/notes",
210439 handle: async (c) => {
211 const body = await readJson(c.request);
440 const body = await readJson(c.request, NOTE_BYTES);
212441 if (body === undefined) return json({ error: "too_large" }, 413);
442 const f = fields(body);
213443 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 }),
215448 201,
216449 );
217450 },
218451 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,
221454 answer: NOTE,
222455 status: 201,
223456 },
@@ -226,22 +459,31 @@ export const routes: readonly Route[] = [
226459 method: "GET",
227460 path: "/api/notes/:id",
228461 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 },
230463 },
231464 {
232465 method: "PATCH",
233466 path: "/api/notes/:id",
234467 handle: async (c) => {
235 const body = await readJson(c.request);
468 const body = await readJson(c.request, NOTE_BYTES);
236469 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 },
243486 },
244 api: { summary: "Change a note's words.", body: NOTE_BODY, answer: NOTE },
245487 },
246488 {
247489 method: "DELETE",
@@ -284,7 +526,7 @@ export const routes: readonly Route[] = [
284526 },
285527
286528 // 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) },
288530 {
289531 method: "POST",
290532 path: "/mcp",
src/security.test.ts +28 −20
@@ -74,7 +74,9 @@ test("the door refuses only what no row would open", () => {
7474 const anon = { role: "anon" } as const;
7575 const alice = { role: "user", id: "u1", name: "alice" } as const;
7676 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);
7880 expect(mayKnock(anon, "note", "create")).toBe(false);
7981 expect(mayKnock(anon, "note", "delete")).toBe(false);
8082 // 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", () => {
8587 });
8688
8789 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);
88104 // 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);
90113 const refused = await fetch(`${base}/api/notes`, {
91114 method: "POST",
92115 headers: { "content-type": "application/json" },
93116 body: huge,
94117 });
95118 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);
102119 expect(await refused.json()).toEqual({
103120 error: "signin_required",
104121 message: "Sign in first.",
105122 });
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);
115123 });
116124
117125 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
137145 );
138146 if (!closed.ok) throw new Error(closed.problems.join("; "));
139147 expect(openToStrangers(closed.contract)).toBe(false);
140 expect(openToStrangers()).toBe(true);
148 expect(openToStrangers()).toBe(false);
141149 });
142150
143151 test("strangers are rationed at the API, reads too", () => {
@@ -145,8 +153,8 @@ test("strangers are rationed at the API, reads too", () => {
145153 const knock = () =>
146154 door(
147155 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"),
150158 { role: "anon" },
151159 "203.0.113.7",
152160 );
src/server.test.ts +127 −105
@@ -52,116 +52,121 @@ test("the page knows who you are and offers the right door", async () => {
5252 const guest = await (await fetch(base + "/")).text();
5353 expect(guest).toContain("<h1>");
5454 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");
5859 expect(signed).toContain("/__forge/signout");
59 expect(signed).toContain('action="/notes"');
60 expect(signed).toContain('action="/welcome"');
6061 });
6162
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 () => {
6364 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" });
7067 expect(refused.status).toBe(303);
7168 expect(refused.headers.get("set-cookie")).toContain("notice=");
7269 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);
8171 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"));
86103 });
87104
88105 test("the JSON API is the same actions with the same identity", async () => {
89106 const created = await api(
90107 "/api/notes",
91 { method: "POST", ...jsonBody({ body: "over the api" }) },
108 { method: "POST", ...jsonBody({ title: "Over the API", body: "hello #api" }) },
92109 alice,
93110 );
94111 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 };
96113 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 }[];
100115 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);
101136 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,
122138 ).toBe(422);
123139 });
124140
125141 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: {} });
132143 expect(hello.result.capabilities).toEqual({ tools: {} });
133144 const listed = await rpc({ jsonrpc: "2.0", id: 2, method: "tools/list" });
134145 expect(listed.result.tools.map((t: { name: string }) => t.name)).toEqual([
135146 "list_notes",
136 "add_note",
147 "search_notes",
148 "read_note",
149 "create_note",
150 "update_note",
151 "append_to_note",
137152 "delete_note",
138153 ]);
139154 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);
152159 expect(added.result.isError).toBeUndefined();
153160 const note = JSON.parse(added.result.content[0].text);
154161 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);
165170 const notified = await api("/mcp", {
166171 method: "POST",
167172 ...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 () =>
169174 expect(notified.status).toBe(202);
170175 });
171176
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();
177180 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 };
184197 };
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();
191205 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);
199220 expect(replay).toContain(`id: ${heard.id}\n`);
200221 expect(replay).toContain("note.added");
222 expect(JSON.stringify(heard)).not.toContain("private words");
201223 });
202224
203225 test("writes are rationed per person", async () => {
@@ -229,11 +251,11 @@ test("stands on the security floor", async () => {
229251
230252 test("a body is capped by the bytes read, whatever it claims", async () => {
231253 const response = await api(
232 "/notes",
254 "/new",
233255 {
234256 method: "POST",
235257 headers: { "content-type": "application/x-www-form-urlencoded" },
236 body: "body=" + "x".repeat(1024 * 1024),
258 body: "title=" + "x".repeat(1024 * 1024),
237259 },
238260 alice,
239261 );
@@ -248,9 +270,9 @@ test("HEAD sees the app up", async () => {
248270
249271 test("refuses a cross-site post", async () => {
250272 const form = new FormData();
251 form.set("body", "from another site");
273 form.set("title", "from another site");
252274 const posted = await api(
253 "/notes",
275 "/new",
254276 {
255277 method: "POST",
256278 body: form,
src/tty.ts +98 −46
@@ -1,8 +1,9 @@
11 /**
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
33 * `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.
67 */
78
89 import {
@@ -10,6 +11,10 @@ import {
1011 canDelete,
1112 deleteNote,
1213 listNotes,
14 openOrCreate,
15 searchNotes,
16 today,
17 updateNote,
1318 type Note,
1419 } from "./actions.ts";
1520 import { openDb } from "./db.ts";
@@ -26,49 +31,85 @@ const out = (s: string): void => {
2631 };
2732
2833 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 = "";
3038 let said = "";
3139
3240 const notes = (): Note[] => {
3341 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));
3548 };
3649
50 const rows = (): number => process.stdout.rows ?? 24;
51
3752 function draw(): void {
38 const rows = notes();
3953 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)}`,
4155 ssh === undefined ? "" : dim(`connect: ${ssh}`),
4256 "",
4357 ];
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", " "))}`);
5781 });
5882 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`);
6085 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"));
6987 out(`${esc}2J${esc}H${lines.join("\r\n")}`);
7088 }
7189
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
72113 process.stdin.setRawMode?.(true);
73114 process.stdin.resume();
74115 process.stdin.setEncoding("utf8");
@@ -77,13 +118,11 @@ process.stdin.on("data", (key: string) => {
77118 said = "";
78119 if (typing !== undefined) {
79120 if (key === "\r") {
80 const added = addNote(db, me, { body: typing });
81 if (!added.ok) said = added.message;
121 submit(typing.what, typing.text);
82122 typing = undefined;
83 cursor = 0;
84123 } 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;
87126 draw();
88127 return;
89128 }
@@ -91,23 +130,36 @@ process.stdin.on("data", (key: string) => {
91130 out(`${esc}2J${esc}H`);
92131 process.exit(0);
93132 }
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();
101140 }
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: "" };
102155 if (key === "d") {
103 const note = rows[cursor];
156 const note = list[cursor];
104157 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.";
107159 else {
108160 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));
111163 }
112164 }
113165 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 &lt; 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 @@
33 design/tokens.json compiles to at boot; `bun run lint:design` refuses a raw
44 colour here. Light and dark are the tokens' business, not this file's. */
55
6 /* The page. */
6 /* ── The page ───────────────────────────────────────────────────────── */
7 *,
8 *::before,
9 *::after {
10 box-sizing: border-box;
11 }
712 html {
813 -webkit-text-size-adjust: 100%;
914 text-size-adjust: 100%;
@@ -13,27 +18,10 @@ body {
1318 background: var(--color-bg);
1419 color: var(--color-fg);
1520 font: var(--text-base) / var(--leading-body) var(--font-sans);
21 -webkit-font-smoothing: antialiased;
1622 }
17 .page {
18 max-width: 40rem;
19 margin: 0 auto;
20 padding: var(--space-5) var(--space-4) var(--space-6);
21 }
22 .head {
23 display: flex;
24 flex-wrap: wrap;
25 justify-content: space-between;
26 align-items: flex-start;
27 gap: var(--space-3) var(--space-4);
28 margin-bottom: var(--space-5);
29 }
30 h1 {
31 margin: 0;
32 font-size: var(--text-xl);
33 font-weight: 650;
34 letter-spacing: -0.01em;
35 line-height: var(--leading-tight);
36 }
23 h1,
24 h2,
3725 p {
3826 margin: 0;
3927 }
@@ -43,77 +31,61 @@ a {
4331 text-underline-offset: 0.15em;
4432 }
4533 code,
34 kbd,
4635 .mono {
4736 font-family: var(--font-mono);
48 font-size: var(--text-sm);
37 font-size: 0.88em;
38 }
39 kbd {
40 display: inline-block;
41 padding: 0 0.35em;
42 border: 1px solid var(--color-line);
43 border-bottom-width: 2px;
44 border-radius: var(--radius-sm);
45 color: var(--color-muted);
46 font-size: var(--text-xs);
47 line-height: 1.6;
4948 }
5049 :focus-visible {
5150 outline: 2px solid var(--color-accent);
5251 outline-offset: 2px;
5352 }
53 [hidden] {
54 display: none !important;
55 }
5456 .muted {
5557 color: var(--color-muted);
5658 }
57 .you {
58 color: var(--color-muted);
59 white-space: nowrap;
59 .small {
60 font-size: var(--text-sm);
6061 }
61
62 /* Blocks. */
63 .card {
64 background: var(--color-surface);
65 border: 1px solid var(--color-line);
66 border-radius: var(--radius-md);
67 padding: var(--space-4);
68 margin-bottom: var(--space-5);
62 .pad {
63 padding: var(--space-2) var(--space-3);
6964 }
7065 .row {
7166 display: flex;
7267 align-items: center;
7368 gap: var(--space-2);
69 flex-wrap: wrap;
7470 }
75 .list {
76 list-style: none;
77 margin: 0;
78 padding: 0;
71 .row.center {
72 justify-content: center;
7973 }
80 .note {
81 display: flex;
82 justify-content: space-between;
83 align-items: flex-start;
84 gap: var(--space-3);
85 padding: var(--space-3) 0;
86 border-top: 1px solid var(--color-line);
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(--text-sm);
96 }
97 .empty {
98 padding: var(--space-6) var(--space-4);
99 text-align: center;
74 .pill {
75 display: inline-block;
76 padding: 0 0.5rem;
77 font-size: var(--text-xs);
78 font-weight: 600;
10079 color: var(--color-muted);
101 border: 1px dashed var(--color-line);
102 border-radius: var(--radius-md);
80 border: 1px solid var(--color-line);
81 border-radius: var(--radius-pill);
10382 }
104 footer {
105 margin-top: var(--space-6);
106 font-size: var(--text-sm);
107 display: grid;
108 gap: var(--space-1);
109 }
110 footer code {
111 overflow-wrap: anywhere;
83 svg {
84 flex: none;
11285 }
11386
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 ───── */
11588 .input {
116 flex: 1;
11789 min-width: 0;
11890 min-height: var(--size-tap);
11991 padding: 0 var(--space-3);
@@ -141,10 +113,20 @@ footer code {
141113 text-decoration: none;
142114 display: inline-flex;
143115 align-items: center;
116 justify-content: center;
117 gap: var(--space-2);
144118 }
145119 .button:hover {
146120 filter: brightness(1.08);
147121 }
122 .button svg {
123 width: 18px;
124 height: 18px;
125 fill: none;
126 stroke: currentColor;
127 stroke-width: 2;
128 stroke-linecap: round;
129 }
148130 .button-quiet {
149131 color: var(--color-muted);
150132 background: transparent;
@@ -152,26 +134,878 @@ footer code {
152134 }
153135 .button-quiet:hover {
154136 color: var(--color-fg);
155 border-color: var(--color-line);
137 background: var(--color-hover);
156138 filter: none;
157139 }
158 .pill {
159 display: inline-block;
160 padding: 0.1rem 0.6rem;
161 font-size: var(--text-sm);
162 font-weight: 600;
163 color: var(--color-muted);
164 border: 1px solid var(--color-line);
165 border-radius: var(--radius-pill);
140 .button-big {
141 min-height: 3rem;
142 padding: 0 var(--space-5);
143 font-size: var(--text-lg);
166144 }
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(--size-tap);
152 height: var(--size-tap);
153 padding: 0;
154 color: var(--color-muted);
155 background: transparent;
156 border: 0;
157 border-radius: var(--radius-md);
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(--color-fg);
173 background: var(--color-hover);
174 }
175 .icon-button.is-on {
168176 color: var(--color-accent);
169 border-color: currentColor;
177 }
178 .icon-button.danger:hover {
179 color: var(--color-danger);
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(--size-left) minmax(0, 1fr) var(--size-right);
193 height: 100dvh;
194 }
195 .shell[data-has-right="false"] {
196 grid-template-columns: var(--size-left) minmax(0, 1fr);
197 }
198 .left,
199 .right {
200 min-height: 0;
201 background: var(--color-surface);
202 overflow-y: auto;
203 overscroll-behavior: contain;
204 }
205 .left {
206 display: flex;
207 flex-direction: column;
208 border-right: 1px solid var(--color-line);
209 }
210 .right {
211 border-left: 1px solid var(--color-line);
212 padding: var(--space-3) var(--space-4) var(--space-6);
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(--space-2) var(--space-2) 0 var(--space-4);
227 }
228 .brand {
229 display: inline-flex;
230 align-items: center;
231 gap: var(--space-2);
232 color: var(--color-fg);
233 font-weight: 700;
234 letter-spacing: -0.01em;
235 text-decoration: none;
236 min-height: var(--size-tap);
237 }
238 .brand svg {
239 width: 22px;
240 height: 22px;
241 color: var(--color-accent);
242 }
243 .actions {
244 display: flex;
245 gap: var(--space-1);
246 padding: var(--space-2) var(--space-3);
247 }
248 .actions form:first-child {
249 flex: 1;
250 }
251 .find {
252 position: relative;
253 margin: 0 var(--space-3) var(--space-2);
254 }
255 .find > svg {
256 position: absolute;
257 left: var(--space-3);
258 top: 50%;
259 width: 16px;
260 height: 16px;
261 transform: translateY(-50%);
262 fill: none;
263 stroke: var(--color-muted);
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(--color-bg);
272 }
273 .find .hint {
274 position: absolute;
275 right: var(--space-2);
276 top: 50%;
277 transform: translateY(-50%);
278 cursor: pointer;
279 }
280 .files {
281 flex: 1;
282 padding: 0 var(--space-2) var(--space-4);
283 }
284 .section {
285 margin: var(--space-4) var(--space-2) var(--space-1);
286 color: var(--color-muted);
287 font-size: var(--text-xs);
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(--space-2);
307 min-height: 2.25rem;
308 padding: var(--space-1) var(--space-2);
309 border-radius: var(--radius-sm);
310 color: var(--color-fg);
311 text-decoration: none;
312 line-height: var(--leading-tight);
313 align-items: center;
314 }
315 .file:hover {
316 background: var(--color-hover);
317 }
318 .file.is-active {
319 background: var(--color-active);
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(--color-muted);
331 font-size: var(--text-xs);
332 font-variant-numeric: tabular-nums;
333 }
334 .tag-cloud {
335 display: flex;
336 flex-wrap: wrap;
337 gap: var(--space-1);
338 padding: var(--space-1) var(--space-2);
339 }
340 .left-foot {
341 position: sticky;
342 bottom: 0;
343 display: flex;
344 align-items: center;
345 gap: var(--space-1);
346 padding: var(--space-2) var(--space-2) var(--space-2) var(--space-4);
347 border-top: 1px solid var(--color-line);
348 background: var(--color-surface);
349 }
350 .you {
351 flex: 1;
352 min-width: 0;
353 overflow: hidden;
354 text-overflow: ellipsis;
355 white-space: nowrap;
356 color: var(--color-muted);
357 font-size: var(--text-sm);
358 }
359
360 /* The toolbar above the note. */
361 .toolbar {
362 display: flex;
363 align-items: center;
364 gap: var(--space-2);
365 min-height: 3.25rem;
366 padding: var(--space-1) var(--space-3);
367 border-bottom: 1px solid var(--color-line);
368 }
369 .crumbs {
370 display: flex;
371 align-items: center;
372 gap: var(--space-2);
373 flex: 1;
374 min-width: 0;
375 font-size: var(--text-sm);
376 color: var(--color-muted);
377 white-space: nowrap;
378 }
379 .crumbs a {
380 color: var(--color-muted);
381 text-decoration: none;
382 }
383 .crumbs a:hover {
384 color: var(--color-fg);
385 }
386 .crumb-here {
387 color: var(--color-fg);
388 overflow: hidden;
389 text-overflow: ellipsis;
390 }
391 .tools {
392 display: flex;
393 align-items: center;
394 gap: var(--space-1);
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(--color-danger);
406 }
407 .segmented {
408 display: inline-flex;
409 padding: 2px;
410 margin-right: var(--space-2);
411 border: 1px solid var(--color-line);
412 border-radius: var(--radius-md);
413 }
414 .segmented button {
415 min-height: 2.25rem;
416 padding: 0 var(--space-3);
417 font: inherit;
418 font-size: var(--text-sm);
419 color: var(--color-muted);
420 background: transparent;
421 border: 0;
422 border-radius: var(--radius-sm);
423 cursor: pointer;
424 }
425 .segmented button:hover {
426 color: var(--color-fg);
427 }
428 .segmented button[aria-pressed="true"] {
429 color: var(--color-fg);
430 background: var(--color-hover);
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(--space-5) max(var(--space-5), calc((100% - var(--size-read)) / 2)) var(--space-2);
455 font: 700 var(--text-2xl) / var(--leading-tight) var(--font-sans);
456 letter-spacing: -0.02em;
457 color: var(--color-fg);
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(--color-accent);
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(--space-5);
478 }
479 .sheet:has(.panes[data-mode="split"]) .title-input {
480 padding-inline: var(--space-5);
481 }
482 .panes[data-mode="split"] .preview {
483 border-left: 1px solid var(--color-line);
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(--space-3) max(var(--space-5), calc((100% - var(--size-read)) / 2)) 40vh;
494 resize: none;
495 font: 1.0625rem / 1.7 var(--font-sans);
496 tab-size: 2;
497 color: var(--color-fg);
498 background: transparent;
499 border: 0;
500 outline: 0;
501 caret-color: var(--color-accent);
502 }
503 .source:focus-visible {
504 outline: 0;
505 }
506 .source::placeholder {
507 color: var(--color-muted);
508 }
509 .preview {
510 min-height: 0;
511 overflow-y: auto;
512 padding: var(--space-3) max(var(--space-5), calc((100% - var(--size-read)) / 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(--space-4);
532 }
533 .md h1,
534 .md h2,
535 .md h3,
536 .md h4,
537 .md h5,
538 .md h6 {
539 margin: var(--space-5) 0 var(--space-2);
540 line-height: var(--leading-tight);
541 letter-spacing: -0.015em;
542 scroll-margin-top: var(--space-4);
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(--color-muted);
571 }
572 .md li.task {
573 list-style: none;
574 margin-left: -1.5rem;
575 display: flex;
576 flex-wrap: wrap;
577 gap: var(--space-2);
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(--color-muted);
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(--color-accent);
604 border-color: var(--color-accent);
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(--color-accent-fg);
614 border-width: 0 2px 2px 0;
615 transform: rotate(45deg);
616 }
617 .md .done {
618 color: var(--color-muted);
619 text-decoration: line-through;
620 }
621 .md code {
622 padding: 0.1em 0.35em;
623 color: var(--color-code-fg);
624 background: var(--color-code-bg);
625 border-radius: var(--radius-sm);
626 }
627 .md pre {
628 padding: var(--space-3) var(--space-4);
629 overflow-x: auto;
630 background: var(--color-code-bg);
631 border-radius: var(--radius-md);
632 line-height: 1.55;
633 }
634 .md pre code {
635 padding: 0;
636 color: var(--color-fg);
637 background: none;
638 }
639 .md blockquote {
640 padding: 0 0 0 var(--space-4);
641 color: var(--color-muted);
642 border-left: 3px solid var(--color-line);
643 }
644 .md blockquote > :last-child,
645 .md .callout > :last-child {
646 margin-bottom: 0;
647 }
648 .md hr {
649 margin: var(--space-5) 0;
650 border: 0;
651 border-top: 1px solid var(--color-line);
652 }
653 .md mark {
654 padding: 0 0.15em;
655 color: var(--color-fg);
656 background: var(--color-mark-bg);
657 border-radius: 3px;
658 }
659 .md del {
660 color: var(--color-muted);
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(--color-link-missing);
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(--color-tag-fg);
681 background: var(--color-tag-bg);
682 border-radius: var(--radius-pill);
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(--space-2) var(--space-3);
705 border: 1px solid var(--color-line);
706 text-align: left;
707 vertical-align: top;
708 }
709 .md th {
710 background: var(--color-surface);
711 font-weight: 650;
712 }
713 .callout {
714 padding: var(--space-3) var(--space-4);
715 color: var(--color-callout-info);
716 border-left: 3px solid currentColor;
717 border-radius: var(--radius-sm);
718 background: color-mix(in srgb, currentColor 9%, transparent);
719 }
720 .callout > :not(.callout-title) {
721 color: var(--color-fg);
722 }
723 .callout-title {
724 margin: 0 0 var(--space-1) !important;
725 font-weight: 650;
726 }
727 .callout-tip,
728 .callout-success {
729 color: var(--color-callout-tip);
730 }
731 .callout-warning,
732 .callout-question,
733 .callout-todo {
734 color: var(--color-callout-warning);
735 }
736 .callout-danger,
737 .callout-bug,
738 .callout-failure {
739 color: var(--color-callout-danger);
740 }
741 .callout-quote,
742 .callout-example,
743 .callout-abstract {
744 color: var(--color-accent);
745 }
746
747 /* ── The right: graph, backlinks, links, outline ─────────────────────── */
748 .panel {
749 padding: var(--space-2) 0 var(--space-3);
750 border-bottom: 1px solid var(--color-line);
751 }
752 .panel:last-child {
753 border-bottom: 0;
754 }
755 .panel .section {
756 margin: var(--space-2) 0;
757 }
758 .local-graph {
759 display: block;
760 width: 100%;
761 height: 11rem;
762 border-radius: var(--radius-md);
763 background: var(--color-bg);
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(--space-1) 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(--color-muted);
787 font-size: var(--text-sm);
788 line-height: 1.45;
789 }
790 .outline {
791 list-style: none;
792 margin: 0;
793 padding: 0;
794 font-size: var(--text-sm);
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(--color-muted);
811 text-decoration: none;
812 }
813 .outline a:hover {
814 color: var(--color-fg);
815 }
816 .meta p {
817 margin: 0 0 var(--space-1);
818 }
819
820 /* ── Pages without a note ─────────────────────────────────────────────── */
821 .empty-state {
822 margin: auto;
823 max-width: 30rem;
824 padding: var(--space-6) var(--space-4);
825 text-align: center;
826 display: grid;
827 gap: var(--space-4);
828 justify-items: center;
829 }
830 .empty-state h1 {
831 font-size: var(--text-xl);
832 }
833 .mark {
834 color: var(--color-accent);
835 }
836 .mark svg {
837 width: 56px;
838 height: 56px;
839 }
840 .results {
841 overflow-y: auto;
842 padding: var(--space-5) max(var(--space-5), calc((100% - var(--size-read)) / 2)) var(--space-6);
843 }
844 .results-head {
845 font-size: var(--text-xl);
846 margin-bottom: var(--space-4);
847 }
848 .hits {
849 list-style: none;
850 margin: 0;
851 padding: 0;
852 }
853 .hits a {
854 display: grid;
855 gap: var(--space-1);
856 padding: var(--space-3);
857 margin: 0 calc(-1 * var(--space-3));
858 border-radius: var(--radius-md);
859 color: var(--color-fg);
860 text-decoration: none;
861 }
862 .hits a:hover {
863 background: var(--color-hover);
864 }
865 .hits mark {
866 color: var(--color-fg);
867 background: var(--color-mark-bg);
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(--space-4);
884 bottom: var(--space-3);
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(--color-active), transparent 70%), var(--color-bg);
894 }
895 .landing {
896 padding: var(--space-6) var(--space-4);
897 }
898 .landing-card {
899 max-width: 30rem;
900 display: grid;
901 gap: var(--space-4);
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(--text-lg);
911 color: var(--color-muted);
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(--space-4) var(--space-4);
923 background: var(--color-scrim);
924 }
925 .palette-box {
926 width: min(38rem, 100%);
927 overflow: hidden;
928 background: var(--color-surface);
929 border: 1px solid var(--color-line);
930 border-radius: var(--radius-lg);
931 box-shadow: 0 24px 64px var(--color-shadow);
932 }
933 .palette-input {
934 width: 100%;
935 min-height: 3.25rem;
936 padding: 0 var(--space-4);
937 border: 0;
938 border-bottom: 1px solid var(--color-line);
939 border-radius: 0;
940 background: transparent;
941 outline: 0;
942 }
943 .palette-list {
944 list-style: none;
945 margin: 0;
946 padding: var(--space-2);
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(--size-tap);
955 padding: 0 var(--space-3);
956 border-radius: var(--radius-md);
957 cursor: pointer;
958 overflow: hidden;
959 text-overflow: ellipsis;
960 white-space: nowrap;
961 }
962 .palette-item[aria-selected="true"] {
963 background: var(--color-active);
964 }
965 .palette-item.is-create {
966 color: var(--color-accent);
967 font-weight: 600;
968 }
969 .palette-help {
970 display: flex;
971 flex-wrap: wrap;
972 gap: var(--space-1);
973 align-items: center;
974 padding: var(--space-2) var(--space-4);
975 border-top: 1px solid var(--color-line);
976 }
977 .suggest {
978 position: fixed;
979 z-index: 20;
980 list-style: none;
981 margin: 0;
982 padding: var(--space-1);
983 min-width: 14rem;
984 max-width: min(24rem, calc(100vw - 1rem));
985 background: var(--color-surface);
986 border: 1px solid var(--color-line);
987 border-radius: var(--radius-md);
988 box-shadow: 0 12px 32px var(--color-shadow);
989 }
990 .suggest-item {
991 padding: var(--space-2) var(--space-3);
992 border-radius: var(--radius-sm);
993 cursor: pointer;
994 white-space: nowrap;
995 overflow: hidden;
996 text-overflow: ellipsis;
997 }
998 .suggest-item[aria-selected="true"] {
999 background: var(--color-active);
1000 }
1001 .scrim {
1002 display: none;
1701003 }
1711004
1721005 /* A word from the last request, shown once, gone in a moment. */
1731006 .toast {
1741007 position: fixed;
1008 z-index: 40;
1751009 left: 50%;
1761010 bottom: var(--space-5);
1771011 transform: translateX(-50%);
@@ -193,6 +1027,108 @@ footer code {
1931027 visibility: hidden;
1941028 }
1951029 }
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(--size-left) 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(--size-right), 88vw);
1044 transform: translateX(100%);
1045 transition: transform 0.2s ease;
1046 box-shadow: 0 0 40px var(--color-shadow);
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(--color-scrim);
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(--size-left), 88vw);
1077 transform: translateX(-100%);
1078 transition: transform 0.2s ease;
1079 box-shadow: 0 0 40px var(--color-shadow);
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(--space-1);
1102 gap: var(--space-1);
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(--space-4);
1116 }
1117 .title-input {
1118 font-size: var(--text-xl);
1119 padding-top: var(--space-4);
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
1961132 /* Motion is a courtesy: whoever asked for less gets none. */
1971133 @media (prefers-reduced-motion: reduce) {
1981134 *,
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) {
5555 const next = fresh.getElementById(el.id);
5656 if (next) el.replaceWith(next);
5757 }
58 document.dispatchEvent(new CustomEvent("live:swapped"));
5859 } finally {
5960 refreshing = false;
6061 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 = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
16 const UNESC = { "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": '"', "&#39;": "'" };
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 {}
↑↓ navigate ↵ open esc close

Keyboard shortcuts