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 +21 -0
- package/README.md +225 -0
- package/bin/margins.js +107 -0
- package/lib/browser.js +44 -0
- package/lib/cli.js +86 -0
- package/lib/files.js +194 -0
- package/lib/links.js +314 -0
- package/lib/paths.js +159 -0
- package/lib/search.js +117 -0
- package/lib/security.js +89 -0
- package/lib/tree.js +126 -0
- package/package.json +51 -0
- package/public/app.js +1222 -0
- package/public/favicon.svg +1 -0
- package/public/index.html +119 -0
- package/public/style.css +781 -0
- package/server.js +325 -0
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
|
+
[](https://github.com/dheerajjha/inkd/actions/workflows/ci.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](package.json)
|
|
6
|
+
[](test/)
|
|
7
|
+
[](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
|
+

|
|
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
|
+

|
|
67
|
+
|
|
68
|
+

|
|
69
|
+
|
|
70
|
+
It follows your system's light or dark setting.
|
|
71
|
+
|
|
72
|
+

|
|
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
|
+
};
|