margins 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dheeraj Jha
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,225 @@
1
+ # margins
2
+
3
+ [![CI](https://github.com/dheerajjha/inkd/actions/workflows/ci.yml/badge.svg)](https://github.com/dheerajjha/inkd/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
+ [![Node](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](package.json)
6
+ [![Tests](https://img.shields.io/badge/tests-80-brightgreen.svg)](test/)
7
+ [![Dependencies](https://img.shields.io/badge/dependencies-2-brightgreen.svg)](package.json)
8
+
9
+ Open any folder of markdown in your browser, from the terminal. Browse it as a
10
+ tree, read it rendered, follow the links between files — `[[wikilinks]]`
11
+ included — edit with a live preview, and search all of it. Close the tab when
12
+ you are done and it stops.
13
+
14
+ ```bash
15
+ npm install -g margins
16
+
17
+ cd ~/notes
18
+ margins
19
+ ```
20
+
21
+ No vault to create, no app to install, no account, nothing to configure. The
22
+ only thing margins ever writes into the folder is a file you save.
23
+
24
+ ![A folder of team notes open in margins: a file tree on the left, a rendered note in the middle with wikilinks and a diagram, and on the right the note's outline and the notes that link to it](docs/screenshot-reading.png)
25
+
26
+ ## Why
27
+
28
+ A folder of markdown is the most durable way to keep notes and docs there is.
29
+ Every editor opens it, git versions it, and it will still be readable in
30
+ twenty years. What is missing is a good way to *read* one.
31
+
32
+ - **Single-file editors** — MarkEdit, Typora, TextEdit — open one file at a
33
+ time. There is no folder to see, so the links between files go nowhere.
34
+ - **Obsidian** is built around exactly this, but it is an application with a
35
+ vault: it wants the folder registered, it writes a `.obsidian` directory into
36
+ it, and you cannot point it at a repository's `docs/` from the terminal.
37
+ - **Markdown preview servers** — markserv, grip, md-fileserver — show a folder
38
+ in the browser but only read it, and know nothing of `[[wikilinks]]`.
39
+
40
+ margins sits in between: the folder tree and linked notes of Obsidian, the
41
+ lightness of a single-file editor, started from the terminal in whatever folder
42
+ you are standing in — your notes, a project's `docs/`, a colleague's repository.
43
+
44
+ ## What it does
45
+
46
+ - **Browse** the folder as a tree. Markdown first; other text files and images
47
+ are there too.
48
+ - **Read** GitHub-flavoured markdown: tables, task lists, strikethrough, code
49
+ blocks, images from the folder, and the HTML that READMEs use — `<details>`,
50
+ `<kbd>`, `<img width>`.
51
+ - **Follow links** the way GitHub reads them — `[text](other.md#section)` — and
52
+ the way Obsidian does — `[[Other]]`, `[[Other#Section]]`, `[[Other|alias]]`,
53
+ `![[diagram.png]]`.
54
+ - **See what links here.** Every note shows the notes that link to it, by
55
+ either kind of link, with the line that does.
56
+ - **Edit** with the source beside a live preview. `Ctrl/Cmd+S` saves.
57
+ - **Create** a note from the sidebar, or by clicking a `[[link]]` to a note that
58
+ does not exist yet — the way Obsidian does it.
59
+ - **Open a file by name** (`Ctrl/Cmd+P`) and **search every file**
60
+ (`Ctrl/Cmd+Shift+F`).
61
+ - **Keep up with other editors.** Change a file in vim, VS Code or with an
62
+ agent, and the page shows the new version within two seconds.
63
+ - **Never lose an edit to one.** If the file changed on disk after you opened
64
+ it, saving writes nothing and asks you which version to keep.
65
+
66
+ ![Editing a note: the markdown source on the left and the rendered preview on the right, updating as you type](docs/screenshot-editing.png)
67
+
68
+ ![Searching every file: matches grouped by file, with the matched word highlighted on each line](docs/screenshot-search.png)
69
+
70
+ It follows your system's light or dark setting.
71
+
72
+ ![The same view in the dark theme](docs/screenshot-dark.png)
73
+
74
+ ## Usage
75
+
76
+ ```
77
+ margins [path] [options]
78
+ ```
79
+
80
+ `path` is a folder, or a file inside the folder to open first. It defaults to
81
+ the current directory. A folder opens on its `README.md` or `index.md` if it has
82
+ one — the page you would start from on GitHub.
83
+
84
+ | Option | |
85
+ |---|---|
86
+ | `-p, --port <n>` | Port to listen on. Default 4600; if it is taken, a free one is used. |
87
+ | `--hidden` | Show hidden files and folders — names starting with a dot. |
88
+ | `--no-open` | Print the address instead of opening a browser. |
89
+ | `-v, --version` | Print the version. |
90
+ | `-h, --help` | Print the usage. |
91
+
92
+ ```bash
93
+ margins # the folder you are in
94
+ margins ~/notes # another one
95
+ margins docs/guide.md # a repository's docs, opened on one page
96
+ npx margins # without installing it
97
+ ```
98
+
99
+ margins stops when you close its last tab. A reload, or a second tab, keeps it
100
+ running; so does never opening a tab at all, if you would rather open the
101
+ address yourself later. `Ctrl+C` stops it any time.
102
+
103
+ ### Keys
104
+
105
+ | Key | |
106
+ |---|---|
107
+ | `Ctrl/Cmd+P` | Open a file by name |
108
+ | `Ctrl/Cmd+Shift+F` | Search every file |
109
+ | `e` | Edit this file |
110
+ | `Ctrl/Cmd+S` | Save |
111
+ | `Esc` | Close a panel, or stop editing |
112
+ | `[` `]` | Back and forward |
113
+ | `?` | Every shortcut |
114
+
115
+ ## How links are followed
116
+
117
+ A **relative link** is resolved from the file it is in, as GitHub does:
118
+ `[setup](../guide.md#install)` from `notes/a.md` opens `guide.md` at its
119
+ *install* heading. Heading anchors are made the way GitHub makes them, so a
120
+ link written for GitHub lands in the same place here.
121
+
122
+ A **wikilink** names a file, not a path, so it keeps working when notes move.
123
+ `[[Ideas]]` means the file called `Ideas.md` — and when several are, the one in
124
+ the linking note's own folder, then the shallowest, then the first
125
+ alphabetically, so the answer is always the same one. `[[projects/Ideas]]`
126
+ narrows it by path. A wikilink to a note that does not exist yet is shown in a
127
+ different colour, and clicking it creates the note.
128
+
129
+ Links written as examples inside code blocks and code spans are left alone, and
130
+ are not counted as links to anything.
131
+
132
+ ## Safety
133
+
134
+ margins is meant to be pointed at folders you did not write — a cloned
135
+ repository, a colleague's notes — and it can write files. So it assumes the
136
+ folder might be hostile, and so might other websites open in the same browser.
137
+
138
+ - **Nothing in a markdown file can run.** Rendered HTML is sanitised with
139
+ [DOMPurify](https://github.com/cure53/DOMPurify): no scripts, no event
140
+ handlers, no `javascript:` links, no forms, frames, embedded SVG or inline
141
+ styles. A strict Content-Security-Policy is the second lock: the page runs no
142
+ script margins did not ship itself. Images are served in a sandbox, so even an
143
+ SVG with a script in it only draws.
144
+ - **Nothing outside the folder can be read or written** — not with `..`, not
145
+ with an absolute path, and not through a symlink inside the folder that points
146
+ somewhere else.
147
+ - **`.git` and `node_modules` are never opened or written.** Writing
148
+ `.git/hooks/pre-commit` would be running code on your next commit.
149
+ - **Other websites cannot use it.** It listens on `127.0.0.1` only; it answers
150
+ no request addressed to any other name, which stops DNS rebinding; it accepts
151
+ changes only from its own page; and it sends no CORS headers, so no other site
152
+ can read what it serves.
153
+ - **Your own work is not lost to another program's.** Saving checks the file
154
+ still holds what you opened, by content hash rather than timestamp.
155
+
156
+ Remote images — the badges at the top of most READMEs — are loaded, without a
157
+ referrer, so the image host is not told which file or folder they appeared in.
158
+
159
+ Each protection the server enforces has a test that fails without it — checked
160
+ by removing them one at a time. Sanitising happens in the browser, where the
161
+ test suite cannot reach, so it is checked there: against a markdown file that
162
+ tries every trick above, in a real browser, before each release.
163
+
164
+ ## What it does not do
165
+
166
+ It is deliberately small. There is no graph view, no plugins, no sync, no
167
+ database and no index on disk — search and backlinks read the files each time,
168
+ which is fast for a folder of notes and slow for a folder of hundreds of
169
+ thousands of files, where margins stops at 20,000 and says so. Code blocks are
170
+ not syntax-highlighted yet. `![[Note]]` embeds are shown as links, not inlined.
171
+ Renaming and deleting are left to your file manager or editor. `.gitignore` is
172
+ not read.
173
+
174
+ ## How it compares
175
+
176
+ | | Folder tree | Edit | Search | Wikilinks and backlinks | Start from a terminal | Writes into your folder |
177
+ |---|---|---|---|---|---|---|
178
+ | MarkEdit | — | yes | — | — | — | only what you save |
179
+ | Obsidian | yes | yes | yes | yes | — | a `.obsidian` folder |
180
+ | markserv | yes | — | yes | — | yes | nothing |
181
+ | SilverBullet | yes | yes | yes | yes | runs as a server | an index |
182
+ | **margins** | yes | yes | yes | yes | yes | only what you save |
183
+
184
+ ## HTTP API
185
+
186
+ The page is a client of this; nothing is hidden from you.
187
+
188
+ | Endpoint | |
189
+ |---|---|
190
+ | `GET /api/info` | The folder, and the file to open first |
191
+ | `GET /api/tree?path=` | One folder's entries |
192
+ | `GET /api/files` | Every file below the folder |
193
+ | `GET /api/file?path=` | A file's content and version, or what kind of thing it is |
194
+ | `PUT /api/file` | Save `{path, content, version}`; 409 if the file changed |
195
+ | `POST /api/file` | Create `{path, content}`; 409 if it exists |
196
+ | `GET /api/version?path=` | A file's current version, for noticing changes |
197
+ | `GET /api/search?q=` | Matches across every text file |
198
+ | `GET /api/backlinks?path=` | What links to a file |
199
+ | `GET /raw/<path>` | An image from the folder |
200
+ | `GET /api/alive` | Held open by the page; when the last one closes, margins stops |
201
+
202
+ ## Development
203
+
204
+ ```bash
205
+ git clone https://github.com/dheerajjha/inkd.git
206
+ cd margins && npm install
207
+ npm test # 80 tests
208
+ node bin/margins.js ~/notes
209
+ ```
210
+
211
+ The server uses no packages at all — Node's own `http`, `fs` and `path`. The
212
+ two dependencies, [marked](https://github.com/markedjs/marked) and
213
+ [DOMPurify](https://github.com/cure53/DOMPurify), are sent to the browser, where
214
+ the rendering happens. Tests never touch the network, and every file they use is
215
+ written by the test itself.
216
+
217
+ ## Related
218
+
219
+ From the author of [reviewer](https://github.com/dheerajjha/reviewer), which
220
+ does the same for reading a coding agent's changes: `git reviewer` in a
221
+ repository opens its diff for review in the browser.
222
+
223
+ ## License
224
+
225
+ MIT
package/bin/margins.js ADDED
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs/promises');
5
+ const path = require('node:path');
6
+
7
+ const { USAGE, UsageError, parseArgs } = require('../lib/cli');
8
+ const { openInBrowser } = require('../lib/browser');
9
+ const { DEFAULT_HOST, DEFAULT_PORT, displayPath, startServer } = require('../server');
10
+ const { MARKDOWN_EXTENSIONS } = require('../lib/files');
11
+ const { version } = require('../package.json');
12
+
13
+ /** Everything said to the person running it goes to stderr; stdout stays clean. */
14
+ function note(line = '') {
15
+ process.stderr.write(`${line}\n`);
16
+ }
17
+
18
+ /**
19
+ * What to serve and what to open first, from the path given.
20
+ *
21
+ * A file means its folder, opened on that file. A folder means itself,
22
+ * opened on its README or index if it has one -- the page a person would
23
+ * start from on GitHub.
24
+ */
25
+ async function resolveTarget(given) {
26
+ const target = path.resolve(given ?? '.');
27
+ let stat;
28
+ try {
29
+ stat = await fs.stat(target);
30
+ } catch {
31
+ throw new UsageError(`Nothing at ${given ?? target}.`);
32
+ }
33
+
34
+ if (stat.isFile()) {
35
+ return { root: path.dirname(target), initial: path.basename(target) };
36
+ }
37
+
38
+ const names = await fs.readdir(target);
39
+ const start = ['readme', 'index']
40
+ .flatMap(stem => [...MARKDOWN_EXTENSIONS].map(ext => stem + ext))
41
+ .map(wanted => names.find(name => name.toLowerCase() === wanted))
42
+ .find(Boolean);
43
+ return { root: target, initial: start ?? null };
44
+ }
45
+
46
+ async function listen(port, options) {
47
+ try {
48
+ return await startServer({ ...options, port, host: DEFAULT_HOST });
49
+ } catch (error) {
50
+ if (error.code !== 'EADDRINUSE') throw error;
51
+ note(`Port ${port} is in use, picking another.`);
52
+ return startServer({ ...options, port: 0, host: DEFAULT_HOST });
53
+ }
54
+ }
55
+
56
+ async function main(argv) {
57
+ const options = parseArgs(argv);
58
+ if (options.help) {
59
+ process.stdout.write(USAGE);
60
+ return;
61
+ }
62
+ if (options.version) {
63
+ process.stdout.write(`${version}\n`);
64
+ return;
65
+ }
66
+
67
+ const { root, initial } = await resolveTarget(options.path);
68
+
69
+ let server;
70
+ const stop = () => {
71
+ server?.close(() => process.exit(0));
72
+ // Do not wait forever on a browser holding a connection open.
73
+ setTimeout(() => process.exit(0), 1500).unref();
74
+ };
75
+
76
+ server = await listen(options.port ?? (Number(process.env.PORT) || DEFAULT_PORT), {
77
+ root,
78
+ initial,
79
+ hidden: options.hidden,
80
+ onIdle: () => {
81
+ note('\n Browser closed. Stopping.\n');
82
+ stop();
83
+ }
84
+ });
85
+
86
+ const url = `http://${DEFAULT_HOST}:${server.address().port}/`;
87
+ note(`\n margins ${url}`);
88
+ note(` folder ${displayPath(server.realRoot)}`);
89
+ note('\n Close the tab when you are done, or press Ctrl+C.\n');
90
+
91
+ process.on('SIGINT', stop);
92
+ process.on('SIGTERM', stop);
93
+
94
+ if (options.open && !(await openInBrowser(url))) {
95
+ note(' Could not open a browser; open the address above yourself.\n');
96
+ }
97
+ }
98
+
99
+ main(process.argv.slice(2)).catch(error => {
100
+ if (error instanceof UsageError) {
101
+ note(`margins: ${error.message}`);
102
+ note('Run margins --help for usage.');
103
+ process.exit(2);
104
+ }
105
+ note(`margins: ${error.message}`);
106
+ process.exit(1);
107
+ });
package/lib/browser.js ADDED
@@ -0,0 +1,44 @@
1
+ 'use strict';
2
+
3
+ const childProcess = require('node:child_process');
4
+
5
+ /**
6
+ * Opening a URL in the default browser, with no dependency for it.
7
+ * The same approach reviewer uses, and for the same reasons.
8
+ */
9
+
10
+ function openCommand(platform, url) {
11
+ switch (platform) {
12
+ case 'darwin':
13
+ return { command: 'open', args: [url] };
14
+ case 'win32':
15
+ // `start` is a cmd builtin, and its first quoted argument is the window
16
+ // title -- so an empty one comes first, or the URL is swallowed as it.
17
+ return { command: 'cmd', args: ['/c', 'start', '', url] };
18
+ default:
19
+ return { command: 'xdg-open', args: [url] };
20
+ }
21
+ }
22
+
23
+ /**
24
+ * @returns {Promise<boolean>} whether an opener was launched. A missing
25
+ * opener fails asynchronously -- spawn does not throw for it -- so this
26
+ * waits for the 'spawn' event instead of assuming.
27
+ */
28
+ function openInBrowser(url, { platform = process.platform, spawn = childProcess.spawn } = {}) {
29
+ const { command, args } = openCommand(platform, url);
30
+ return new Promise(resolve => {
31
+ try {
32
+ const child = spawn(command, args, { stdio: 'ignore', detached: true });
33
+ child.on('error', () => resolve(false));
34
+ child.on('spawn', () => {
35
+ child.unref();
36
+ resolve(true);
37
+ });
38
+ } catch {
39
+ resolve(false);
40
+ }
41
+ });
42
+ }
43
+
44
+ module.exports = { openCommand, openInBrowser };
package/lib/cli.js ADDED
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Reading the command line. Pure, so it can be tested without starting
5
+ * anything.
6
+ */
7
+
8
+ const USAGE = `margins — open a folder of markdown in your browser
9
+
10
+ Usage:
11
+ margins [path] [options]
12
+
13
+ Browse, read, edit and search the markdown in a folder, and follow the links
14
+ between files, [[wikilinks]] included. Close the tab when you are done and it
15
+ stops. Nothing is written into the folder except the files you save.
16
+
17
+ Arguments:
18
+ path A folder, or a file inside the folder to open first.
19
+ Defaults to the current directory.
20
+
21
+ Options:
22
+ -p, --port <n> Port to listen on (default 4600; if it is taken, a free
23
+ one is picked)
24
+ --hidden Show hidden files and folders (names starting with a dot)
25
+ --no-open Print the address instead of opening a browser
26
+ -v, --version Print the version
27
+ -h, --help Print this
28
+
29
+ Keys, once it is open:
30
+ Ctrl/Cmd+P open a file by name Ctrl/Cmd+Shift+F search every file
31
+ e edit this file Ctrl/Cmd+S save
32
+ ? every shortcut
33
+ `;
34
+
35
+ class UsageError extends Error {
36
+ constructor(message) {
37
+ super(message);
38
+ this.name = 'UsageError';
39
+ }
40
+ }
41
+
42
+ /**
43
+ * @param {string[]} argv the arguments after `margins`
44
+ * @returns {{path: string|null, port: number|null, hidden: boolean, open: boolean, help: boolean, version: boolean}}
45
+ * @throws {UsageError}
46
+ */
47
+ function parseArgs(argv) {
48
+ const options = { path: null, port: null, hidden: false, open: true, help: false, version: false };
49
+
50
+ for (let i = 0; i < argv.length; i++) {
51
+ const arg = argv[i];
52
+ switch (arg) {
53
+ case '-h':
54
+ case '--help':
55
+ options.help = true;
56
+ break;
57
+ case '-v':
58
+ case '--version':
59
+ options.version = true;
60
+ break;
61
+ case '--hidden':
62
+ options.hidden = true;
63
+ break;
64
+ case '--no-open':
65
+ options.open = false;
66
+ break;
67
+ case '-p':
68
+ case '--port': {
69
+ const value = argv[++i];
70
+ const port = Number(value);
71
+ if (value === undefined || !Number.isInteger(port) || port < 0 || port > 65535) {
72
+ throw new UsageError(`${arg} needs a port number between 0 and 65535.`);
73
+ }
74
+ options.port = port;
75
+ break;
76
+ }
77
+ default:
78
+ if (arg.startsWith('-')) throw new UsageError(`Unknown option: ${arg}`);
79
+ if (options.path !== null) throw new UsageError('Give one folder or file, not several.');
80
+ options.path = arg;
81
+ }
82
+ }
83
+ return options;
84
+ }
85
+
86
+ module.exports = { USAGE, UsageError, parseArgs };
package/lib/files.js ADDED
@@ -0,0 +1,194 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const fs = require('node:fs/promises');
5
+ const path = require('node:path');
6
+
7
+ /**
8
+ * Reading files for the page, and writing them back without losing anyone's
9
+ * work.
10
+ */
11
+
12
+ const MARKDOWN_EXTENSIONS = new Set(['.md', '.markdown', '.mdown', '.mkd', '.mkdn', '.mdx']);
13
+ const IMAGE_TYPES = {
14
+ '.png': 'image/png',
15
+ '.jpg': 'image/jpeg',
16
+ '.jpeg': 'image/jpeg',
17
+ '.gif': 'image/gif',
18
+ '.webp': 'image/webp',
19
+ '.avif': 'image/avif',
20
+ '.svg': 'image/svg+xml',
21
+ '.ico': 'image/x-icon',
22
+ '.bmp': 'image/bmp'
23
+ };
24
+
25
+ // Past this a file is not opened as text. A notes folder has no 2 MB files;
26
+ // a repository has lockfiles and generated bundles, and drawing one of those
27
+ // into a textarea would freeze the tab to show nobody anything useful.
28
+ const MAX_TEXT_BYTES = 2 * 1024 * 1024;
29
+
30
+ // How much of a file is looked at to decide whether it is text. Git uses the
31
+ // same test -- a NUL byte in the first 8000 -- and it is right often enough
32
+ // that a folder browser has no reason to be cleverer.
33
+ const SNIFF_BYTES = 8000;
34
+
35
+ class FileTooLargeError extends Error {
36
+ constructor(size) {
37
+ super(`This file is ${(size / 1024 / 1024).toFixed(1)} MB, too large to open as text here.`);
38
+ this.name = 'FileTooLargeError';
39
+ this.status = 413;
40
+ }
41
+ }
42
+
43
+ class NotEditableError extends Error {
44
+ constructor(message) {
45
+ super(message);
46
+ this.name = 'NotEditableError';
47
+ this.status = 400;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * The file changed on disk since the page loaded it. Carries what is there
53
+ * now, so the page can offer to show it rather than only refuse.
54
+ */
55
+ class ConflictError extends Error {
56
+ constructor(current) {
57
+ super('This file changed on disk after you opened it. Your edit was not saved.');
58
+ this.name = 'ConflictError';
59
+ this.status = 409;
60
+ this.current = current;
61
+ }
62
+ }
63
+
64
+ /** 'markdown' | 'image' | 'file', from the name alone -- cheap enough for a listing. */
65
+ function kindOf(name) {
66
+ const ext = path.extname(name).toLowerCase();
67
+ if (MARKDOWN_EXTENSIONS.has(ext)) return 'markdown';
68
+ if (IMAGE_TYPES[ext]) return 'image';
69
+ return 'file';
70
+ }
71
+
72
+ function imageType(name) {
73
+ return IMAGE_TYPES[path.extname(name).toLowerCase()] ?? null;
74
+ }
75
+
76
+ /**
77
+ * A short fingerprint of a file's content, which is what "the version the
78
+ * page has" means.
79
+ *
80
+ * Not the modification time. Two saves inside one millisecond, or a tool
81
+ * that restores mtimes, would both look like "unchanged" to an mtime check
82
+ * and lose an edit silently. A hash cannot be fooled that way, and for a
83
+ * file small enough to edit in a browser it costs nothing.
84
+ */
85
+ function versionOf(content) {
86
+ return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16);
87
+ }
88
+
89
+ function looksBinary(buffer) {
90
+ return buffer.subarray(0, SNIFF_BYTES).includes(0);
91
+ }
92
+
93
+ /**
94
+ * Everything the page needs to show one file.
95
+ *
96
+ * @param {string} abs an absolute path already confined to the root
97
+ * @returns {Promise<{kind: string, size: number, content?: string, version?: string}>}
98
+ */
99
+ async function readForView(abs) {
100
+ const stat = await fs.stat(abs);
101
+ // A link like [docs](docs/) names a folder. The page shows it the way
102
+ // GitHub does -- its contents, then its README -- so this says what it is
103
+ // rather than refusing.
104
+ if (stat.isDirectory()) return { kind: 'folder', size: 0 };
105
+
106
+ const kind = kindOf(abs);
107
+ if (kind === 'image') return { kind, size: stat.size };
108
+ if (stat.size > MAX_TEXT_BYTES) throw new FileTooLargeError(stat.size);
109
+
110
+ const buffer = await fs.readFile(abs);
111
+ if (looksBinary(buffer)) return { kind: 'binary', size: stat.size };
112
+
113
+ return {
114
+ kind: kind === 'markdown' ? 'markdown' : 'text',
115
+ size: stat.size,
116
+ content: buffer.toString('utf8'),
117
+ version: versionOf(buffer)
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Save over an existing file, if it is still the file the page loaded.
123
+ *
124
+ * @param {string} abs
125
+ * @param {string} content
126
+ * @param {string} expectedVersion what versionOf gave when the page opened it
127
+ * @returns {Promise<{version: string}>}
128
+ * @throws {ConflictError} when the file on disk is not that version any more
129
+ */
130
+ async function writeChecked(abs, content, expectedVersion) {
131
+ if (typeof content !== 'string') throw new NotEditableError('Content must be text.');
132
+ if (Buffer.byteLength(content) > MAX_TEXT_BYTES) throw new FileTooLargeError(Buffer.byteLength(content));
133
+
134
+ const stat = await fs.stat(abs);
135
+ if (stat.isDirectory()) throw new NotEditableError('That is a folder.');
136
+ if (kindOf(abs) === 'image') throw new NotEditableError('Images cannot be edited here.');
137
+
138
+ const current = await fs.readFile(abs);
139
+ if (looksBinary(current)) throw new NotEditableError('That file is not text.');
140
+
141
+ // Between this read and the write below another program could still save;
142
+ // that window is a few milliseconds and the alternative, locking files
143
+ // other editors do not know about, is worse.
144
+ if (versionOf(current) !== expectedVersion) {
145
+ throw new ConflictError({ content: current.toString('utf8'), version: versionOf(current) });
146
+ }
147
+
148
+ // In place, not write-to-temp-and-rename. A rename replaces the file with
149
+ // a new one: it drops hard links, resets permissions and ownership on some
150
+ // systems, and breaks anything watching the old inode. An editor in a
151
+ // browser has no business changing what kind of file this is.
152
+ await fs.writeFile(abs, content, 'utf8');
153
+ return { version: versionOf(Buffer.from(content, 'utf8')) };
154
+ }
155
+
156
+ /**
157
+ * Create a file that does not exist yet, making folders on the way.
158
+ *
159
+ * @returns {Promise<{version: string}>}
160
+ * @throws {ConflictError} when something is already there
161
+ */
162
+ async function createNew(abs, content) {
163
+ if (typeof content !== 'string') throw new NotEditableError('Content must be text.');
164
+
165
+ await fs.mkdir(path.dirname(abs), { recursive: true });
166
+ try {
167
+ // 'wx': fail rather than overwrite. Checking for existence first and
168
+ // then writing is the race this flag exists to close.
169
+ await fs.writeFile(abs, content, { encoding: 'utf8', flag: 'wx' });
170
+ } catch (error) {
171
+ if (error.code === 'EEXIST') {
172
+ const err = new ConflictError(null);
173
+ err.message = 'A file with that name already exists.';
174
+ throw err;
175
+ }
176
+ throw error;
177
+ }
178
+ return { version: versionOf(Buffer.from(content, 'utf8')) };
179
+ }
180
+
181
+ module.exports = {
182
+ ConflictError,
183
+ FileTooLargeError,
184
+ MARKDOWN_EXTENSIONS,
185
+ MAX_TEXT_BYTES,
186
+ NotEditableError,
187
+ createNew,
188
+ imageType,
189
+ kindOf,
190
+ looksBinary,
191
+ readForView,
192
+ versionOf,
193
+ writeChecked
194
+ };