jotview 0.4.0 → 0.5.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/README.md +63 -9
- package/package.json +5 -2
- package/src/cli.js +47 -5
- package/src/codec.js +28 -2
- package/src/files.js +87 -0
- package/src/landing.js +89 -0
- package/src/mcp-http.js +2 -13
- package/src/mcp-read-tool.js +46 -0
- package/src/mcp-tool.js +69 -10
- package/src/mcp.js +3 -2
- package/src/read.js +89 -0
package/README.md
CHANGED
|
@@ -21,6 +21,7 @@ entirely in the browser.
|
|
|
21
21
|
```
|
|
22
22
|
jotview <file> mint a link from a file
|
|
23
23
|
jotview read markdown from stdin
|
|
24
|
+
jotview decode <url> print the markdown behind a /d or short link
|
|
24
25
|
jotview mcp [--raw] run as an MCP server for AI agents
|
|
25
26
|
|
|
26
27
|
--raw never shorten; always the full /d URL
|
|
@@ -28,6 +29,7 @@ jotview mcp [--raw] run as an MCP server for AI agents
|
|
|
28
29
|
--keep make a shortened link permanent
|
|
29
30
|
--open open the link in your browser
|
|
30
31
|
--copy copy the link to your clipboard
|
|
32
|
+
--json print the result as one JSON line (see below)
|
|
31
33
|
--base-url <url> override https://jotbins.com
|
|
32
34
|
-h, --help
|
|
33
35
|
-v, --version
|
|
@@ -40,8 +42,13 @@ jotview README.md --open
|
|
|
40
42
|
cat notes.md | jotview
|
|
41
43
|
jotview notes.md | pbcopy
|
|
42
44
|
LINK=$(jotview notes.md --raw)
|
|
45
|
+
jotview decode https://surl.jotbins.com/abc123 > notes.md
|
|
43
46
|
```
|
|
44
47
|
|
|
48
|
+
`decode` accepts `jotbins.com/d` links (decoded locally) and
|
|
49
|
+
`surl.jotbins.com` short links (fetched; like a click, that keeps an active
|
|
50
|
+
short link alive).
|
|
51
|
+
|
|
45
52
|
## Shortening, and what it costs you
|
|
46
53
|
|
|
47
54
|
A document's link grows with the document — roughly half the markdown's size in
|
|
@@ -72,9 +79,10 @@ and exits 0 — it never fails your pipeline over a shortening problem.
|
|
|
72
79
|
|
|
73
80
|
## Use with AI agents (MCP)
|
|
74
81
|
|
|
75
|
-
`jotview mcp` is a stdio [MCP](https://modelcontextprotocol.io) server with
|
|
76
|
-
|
|
77
|
-
diagram — and gets back a jotbins link to hand you
|
|
82
|
+
`jotview mcp` is a stdio [MCP](https://modelcontextprotocol.io) server with two
|
|
83
|
+
tools. With `render_markdown` an AI writes markdown — notes, a report, a
|
|
84
|
+
Mermaid diagram — and gets back a jotbins link to hand you; with `read_link`
|
|
85
|
+
it reads a jotbins link you share.
|
|
78
86
|
|
|
79
87
|
**Claude Code**
|
|
80
88
|
|
|
@@ -82,7 +90,11 @@ diagram — and gets back a jotbins link to hand you.
|
|
|
82
90
|
claude mcp add jotview -- npx -y jotview mcp
|
|
83
91
|
```
|
|
84
92
|
|
|
85
|
-
**Claude Desktop
|
|
93
|
+
**Claude Desktop** — download `jotview.mcpb` from the
|
|
94
|
+
[latest release](https://github.com/jotbins/jotview/releases/latest) and
|
|
95
|
+
double-click it. Optionally pick a docs folder it may read files from.
|
|
96
|
+
|
|
97
|
+
**Cursor and other clients** — add to the MCP config:
|
|
86
98
|
|
|
87
99
|
```json
|
|
88
100
|
{ "mcpServers": { "jotview": { "command": "npx", "args": ["-y", "jotview", "mcp"] } } }
|
|
@@ -90,11 +102,29 @@ claude mcp add jotview -- npx -y jotview mcp
|
|
|
90
102
|
|
|
91
103
|
Then just ask: *"draw a diagram of our deploy flow and give me a jotbins link."*
|
|
92
104
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
105
|
+
**`render_markdown`** takes `content` (the markdown), plus optional `title`
|
|
106
|
+
(link text instead of the first heading), `keep` (make a short link
|
|
107
|
+
permanent) and `shorten` (`"auto"`, `"always"` or `"never"`). It returns a
|
|
108
|
+
ready-made markdown link, `[First heading](url)`, and a note on whether
|
|
109
|
+
anything was uploaded. It never reads files — the AI passes text it already
|
|
110
|
+
has.
|
|
111
|
+
|
|
112
|
+
**Rendering files.** The local server can also take a `path` instead of
|
|
113
|
+
`content`, so the AI doesn't have to paste a whole file into the tool call.
|
|
114
|
+
It reads only `.md`, `.markdown`, `.mdx` and `.txt` files, and only inside
|
|
115
|
+
the folder the server runs in (Claude Code starts it in your project) or a
|
|
116
|
+
folder you allow with `--allow-dir`:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
claude mcp add -s user jotview -- npx -y jotview mcp --allow-dir ~/notes
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
If the server runs in `/` or your home folder and no `--allow-dir` is given,
|
|
123
|
+
`path` is switched off. The remote server never reads files.
|
|
124
|
+
|
|
125
|
+
**`read_link`** takes a `url` (a `/d` link or a short link) and returns the
|
|
126
|
+
markdown behind it, so you can paste a link and say *"fix the typos and give
|
|
127
|
+
me a new link."* Links never change; an edit is a new link.
|
|
98
128
|
|
|
99
129
|
**Uploads.** Every character of a link costs tokens, so MCP shortens sooner
|
|
100
130
|
than the CLI: links over **500** characters go through `surl.jotbins.com`,
|
|
@@ -129,6 +159,15 @@ The Worker lives in [`worker/`](worker/) and deploys with
|
|
|
129
159
|
**stdout is the URL and nothing else.** Notices, warnings, and errors go to
|
|
130
160
|
stderr, so `$(jotview x.md)` and `jotview x.md | pbcopy` stay clean.
|
|
131
161
|
|
|
162
|
+
With `--json`, stdout is one JSON line instead, for scripts and CI:
|
|
163
|
+
|
|
164
|
+
```json
|
|
165
|
+
{"url":"https://surl.jotbins.com/abc123","rawUrl":"https://jotbins.com/d#z:…","shortened":true,"permanent":false,"failure":null}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`failure` is the shortener's error when shortening was tried and failed
|
|
169
|
+
(`url` is then the full link).
|
|
170
|
+
|
|
132
171
|
Exit codes: `0` success, `1` runtime error, `2` usage error.
|
|
133
172
|
|
|
134
173
|
## Limits
|
|
@@ -139,6 +178,21 @@ Input is capped at 1 MB, matching what the `/d` renderer will open.
|
|
|
139
178
|
|
|
140
179
|
Node 18 or newer.
|
|
141
180
|
|
|
181
|
+
## Releasing
|
|
182
|
+
|
|
183
|
+
Releases run from GitHub Actions. On an up-to-date `main`:
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
npm version minor
|
|
187
|
+
git push --follow-tags
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`npm version` also updates `manifest.json` and `server.json`. The `v*` tag
|
|
191
|
+
then tests, publishes to npm (trusted publishing, with provenance), attaches
|
|
192
|
+
`jotview.mcpb` to a GitHub release, and updates the MCP Registry entry. Move
|
|
193
|
+
the `Unreleased` notes in `CHANGELOG.md` under the new version first (commit that before `npm version`). The
|
|
194
|
+
Worker deploys separately: `cd worker && npx wrangler deploy`.
|
|
195
|
+
|
|
142
196
|
## License
|
|
143
197
|
|
|
144
198
|
MIT
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jotview",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Turn markdown into a jotbins /d link — a CLI and an MCP server. Zero dependencies.",
|
|
5
|
+
"mcpName": "io.github.jotbins/jotview",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"bin": {
|
|
7
8
|
"jotview": "bin/jotview.js"
|
|
@@ -16,7 +17,9 @@
|
|
|
16
17
|
"node": ">=18"
|
|
17
18
|
},
|
|
18
19
|
"scripts": {
|
|
19
|
-
"test": "node --test"
|
|
20
|
+
"test": "node --test",
|
|
21
|
+
"version": "node scripts/sync-version.js && git add manifest.json server.json",
|
|
22
|
+
"bundle": "npx -y @anthropic-ai/mcpb pack . jotview.mcpb"
|
|
20
23
|
},
|
|
21
24
|
"keywords": [
|
|
22
25
|
"markdown",
|
package/src/cli.js
CHANGED
|
@@ -2,8 +2,11 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { JotviewError, MAX_DOC_BYTES } from "./codec.js";
|
|
3
3
|
import { DEFAULT_BASE_URL, SHORTENER_URL } from "./shorten.js";
|
|
4
4
|
import { mint } from "./mint.js";
|
|
5
|
+
import { readLink } from "./read.js";
|
|
6
|
+
import { allowedRoots } from "./files.js";
|
|
5
7
|
import { serve } from "./mcp-stdio.js";
|
|
6
8
|
import { renderTool } from "./mcp-tool.js";
|
|
9
|
+
import { readTool } from "./mcp-read-tool.js";
|
|
7
10
|
import { openUrl, copyText } from "./platform.js";
|
|
8
11
|
export { DEFAULT_BASE_URL };
|
|
9
12
|
|
|
@@ -16,8 +19,10 @@ export const HELP = `jotview — turn markdown into a jotbins /d link
|
|
|
16
19
|
Usage:
|
|
17
20
|
jotview <file> mint a link from a file
|
|
18
21
|
jotview read markdown from stdin
|
|
22
|
+
jotview decode <url> print the markdown behind a /d or short link
|
|
19
23
|
jotview mcp [--raw] run as an MCP server (stdio) for AI agents;
|
|
20
|
-
--raw never uploads, --base-url works too
|
|
24
|
+
--raw never uploads, --base-url works too,
|
|
25
|
+
--allow-dir <dir> lets it read files there
|
|
21
26
|
|
|
22
27
|
Options:
|
|
23
28
|
--raw never shorten; always the full /d URL
|
|
@@ -25,6 +30,7 @@ Options:
|
|
|
25
30
|
--keep make a shortened link permanent
|
|
26
31
|
--open open the link in your browser
|
|
27
32
|
--copy copy the link to your clipboard
|
|
33
|
+
--json print {url, rawUrl, shortened, permanent, failure}
|
|
28
34
|
--base-url <url> override ${DEFAULT_BASE_URL}
|
|
29
35
|
-h, --help show this help
|
|
30
36
|
-v, --version show the version
|
|
@@ -45,6 +51,7 @@ saved from the link's waiting page. Full /d links never expire.
|
|
|
45
51
|
* @property {boolean} keep
|
|
46
52
|
* @property {boolean} open
|
|
47
53
|
* @property {boolean} copy
|
|
54
|
+
* @property {boolean} json
|
|
48
55
|
* @property {string} baseUrl
|
|
49
56
|
* @property {boolean} help
|
|
50
57
|
* @property {boolean} version
|
|
@@ -57,7 +64,7 @@ saved from the link's waiting page. Full /d links never expire.
|
|
|
57
64
|
export function parseArgs(argv) {
|
|
58
65
|
/** @type {Options} */
|
|
59
66
|
const opts = {
|
|
60
|
-
file: null, raw: false, short: false, keep: false, open: false, copy: false,
|
|
67
|
+
file: null, raw: false, short: false, keep: false, open: false, copy: false, json: false,
|
|
61
68
|
baseUrl: DEFAULT_BASE_URL, help: false, version: false,
|
|
62
69
|
};
|
|
63
70
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -68,6 +75,7 @@ export function parseArgs(argv) {
|
|
|
68
75
|
case "--keep": opts.keep = true; break;
|
|
69
76
|
case "--open": opts.open = true; break;
|
|
70
77
|
case "--copy": opts.copy = true; break;
|
|
78
|
+
case "--json": opts.json = true; break;
|
|
71
79
|
case "-h": case "--help": opts.help = true; break;
|
|
72
80
|
case "-v": case "--version": opts.version = true; break;
|
|
73
81
|
case "--base-url": {
|
|
@@ -107,11 +115,15 @@ export function parseArgs(argv) {
|
|
|
107
115
|
* @returns {{raw: boolean, baseUrl: string}}
|
|
108
116
|
*/
|
|
109
117
|
export function parseMcpArgs(argv) {
|
|
110
|
-
const opts = { raw: false, baseUrl: DEFAULT_BASE_URL };
|
|
118
|
+
const opts = { raw: false, baseUrl: DEFAULT_BASE_URL, allowDirs: /** @type {string[]} */ ([]) };
|
|
111
119
|
for (let i = 0; i < argv.length; i++) {
|
|
112
120
|
const arg = argv[i];
|
|
113
121
|
if (arg === "--raw") {
|
|
114
122
|
opts.raw = true;
|
|
123
|
+
} else if (arg === "--allow-dir") {
|
|
124
|
+
const value = argv[++i];
|
|
125
|
+
if (value === undefined) throw new JotviewError("usage", "--allow-dir needs a folder");
|
|
126
|
+
opts.allowDirs.push(value);
|
|
115
127
|
} else if (arg === "--base-url") {
|
|
116
128
|
const value = argv[++i];
|
|
117
129
|
if (value === undefined || value.startsWith("-") || !/^https?:\/\//.test(value)) {
|
|
@@ -193,6 +205,7 @@ function applySideEffects(url, opts, io) {
|
|
|
193
205
|
*/
|
|
194
206
|
export async function run(argv, io) {
|
|
195
207
|
if (argv[0] === "mcp") return runMcp(argv.slice(1), io);
|
|
208
|
+
if (argv[0] === "decode") return runDecode(argv.slice(1), io);
|
|
196
209
|
|
|
197
210
|
/** @type {Options} */
|
|
198
211
|
let opts;
|
|
@@ -241,7 +254,12 @@ export async function run(argv, io) {
|
|
|
241
254
|
`link. the document may still have reached ${SHORTENER_HOST}.\n`,
|
|
242
255
|
);
|
|
243
256
|
}
|
|
244
|
-
|
|
257
|
+
if (opts.json) {
|
|
258
|
+
const { url, rawUrl, shortened, permanent, failure } = minted;
|
|
259
|
+
io.stdout(`${JSON.stringify({ url, rawUrl, shortened, permanent, failure })}\n`);
|
|
260
|
+
} else {
|
|
261
|
+
io.stdout(`${printed}\n`);
|
|
262
|
+
}
|
|
245
263
|
applySideEffects(printed, opts, io);
|
|
246
264
|
return 0;
|
|
247
265
|
} catch (err) {
|
|
@@ -250,6 +268,28 @@ export async function run(argv, io) {
|
|
|
250
268
|
}
|
|
251
269
|
}
|
|
252
270
|
|
|
271
|
+
/**
|
|
272
|
+
* `jotview decode <url>`: the markdown, byte for byte, on stdout — so
|
|
273
|
+
* `jotview decode URL > doc.md` and `jotview decode URL | jotview` round-trip.
|
|
274
|
+
* @param {string[]} argv
|
|
275
|
+
* @param {Parameters<typeof run>[1]} io
|
|
276
|
+
* @returns {Promise<number>}
|
|
277
|
+
*/
|
|
278
|
+
async function runDecode(argv, io) {
|
|
279
|
+
if (argv.length !== 1 || argv[0].startsWith("-")) {
|
|
280
|
+
io.stderr(`jotview: decode takes exactly one link\n\n${HELP}`);
|
|
281
|
+
return 2;
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
const { markdown } = await readLink(argv[0]);
|
|
285
|
+
io.stdout(markdown);
|
|
286
|
+
return 0;
|
|
287
|
+
} catch (err) {
|
|
288
|
+
io.stderr(`jotview: ${/** @type {Error} */ (err).message}\n`);
|
|
289
|
+
return 1;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
253
293
|
/**
|
|
254
294
|
* `jotview mcp`: stdout belongs to the protocol from here on, so usage
|
|
255
295
|
* errors go to stderr and nothing else is ever printed.
|
|
@@ -259,8 +299,10 @@ export async function run(argv, io) {
|
|
|
259
299
|
*/
|
|
260
300
|
async function runMcp(argv, io) {
|
|
261
301
|
let opts;
|
|
302
|
+
let roots;
|
|
262
303
|
try {
|
|
263
304
|
opts = parseMcpArgs(argv);
|
|
305
|
+
roots = allowedRoots({ cwd: process.cwd(), extra: opts.allowDirs });
|
|
264
306
|
} catch (err) {
|
|
265
307
|
io.stderr(`jotview: ${/** @type {Error} */ (err).message}\n\n${HELP}`);
|
|
266
308
|
return 2;
|
|
@@ -270,7 +312,7 @@ async function runMcp(argv, io) {
|
|
|
270
312
|
write: io.stdout,
|
|
271
313
|
ctx: {
|
|
272
314
|
version: io.version,
|
|
273
|
-
tools: [renderTool({ allowUpload: !opts.raw, baseUrl: opts.baseUrl })],
|
|
315
|
+
tools: [renderTool({ allowUpload: !opts.raw, baseUrl: opts.baseUrl, roots }), readTool({})],
|
|
274
316
|
},
|
|
275
317
|
});
|
|
276
318
|
return 0;
|
package/src/codec.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { deflateRawSync } from "node:zlib";
|
|
1
|
+
import { deflateRawSync, inflateRawSync } from "node:zlib";
|
|
2
2
|
|
|
3
3
|
/** Payload prefix. Versions the encoding; must match jotbins' fragmentDoc.ts. */
|
|
4
4
|
export const PREFIX = "z:";
|
|
@@ -7,7 +7,7 @@ export const PREFIX = "z:";
|
|
|
7
7
|
* the browser refuses to open, so reject at encode time instead. */
|
|
8
8
|
export const MAX_DOC_BYTES = 1_000_000;
|
|
9
9
|
|
|
10
|
-
/** @typedef {"too-large" | "empty" | "not-found" | "usage" | "read-failed"} ErrorCode */
|
|
10
|
+
/** @typedef {"too-large" | "empty" | "not-found" | "usage" | "read-failed" | "bad-link" | "cleaned-up" | "not-allowed"} ErrorCode */
|
|
11
11
|
|
|
12
12
|
export class JotviewError extends Error {
|
|
13
13
|
/** @param {ErrorCode} code @param {string} message */
|
|
@@ -40,3 +40,29 @@ export function encode(md) {
|
|
|
40
40
|
// produce via CompressionStream("deflate-raw").
|
|
41
41
|
return PREFIX + toBase64Url(deflateRawSync(bytes));
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
const BASE64URL = /^[A-Za-z0-9_-]*$/;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Decode a `z:` fragment payload back into markdown.
|
|
48
|
+
* @param {string} payload the part after `#`
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
export function decode(payload) {
|
|
52
|
+
if (!payload.startsWith(PREFIX)) {
|
|
53
|
+
throw new JotviewError("bad-link", `not a jotbins doc payload (expected it to start with ${PREFIX})`);
|
|
54
|
+
}
|
|
55
|
+
const body = payload.slice(PREFIX.length);
|
|
56
|
+
// Buffer's base64 decoder silently skips junk, so check the alphabet first.
|
|
57
|
+
if (!BASE64URL.test(body)) throw new JotviewError("bad-link", "the link's document data is corrupted");
|
|
58
|
+
try {
|
|
59
|
+
// Capped so a tiny link can't inflate into gigabytes.
|
|
60
|
+
return inflateRawSync(Buffer.from(body, "base64url"), { maxOutputLength: MAX_DOC_BYTES })
|
|
61
|
+
.toString("utf8");
|
|
62
|
+
} catch (err) {
|
|
63
|
+
if (/** @type {{code?: string}} */ (err).code === "ERR_BUFFER_TOO_LARGE") {
|
|
64
|
+
throw new JotviewError("too-large", `the document is over the ${MAX_DOC_BYTES}-byte limit`);
|
|
65
|
+
}
|
|
66
|
+
throw new JotviewError("bad-link", "the link's document data is corrupted");
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/files.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { extname, isAbsolute, parse, resolve, sep } from "node:path";
|
|
4
|
+
import { JotviewError, MAX_DOC_BYTES } from "./codec.js";
|
|
5
|
+
|
|
6
|
+
/** The only files the MCP `path` input reads: a prompt-injected model must
|
|
7
|
+
* not be able to render (and upload) keys, .env files and the like. */
|
|
8
|
+
export const READABLE_EXTENSIONS = [".md", ".markdown", ".mdx", ".txt"];
|
|
9
|
+
|
|
10
|
+
/** @param {string} p */
|
|
11
|
+
const isFilesystemRoot = (p) => parse(p).root === p;
|
|
12
|
+
|
|
13
|
+
/** @param {string} dir */
|
|
14
|
+
function realDir(dir) {
|
|
15
|
+
try {
|
|
16
|
+
const real = realpathSync(dir);
|
|
17
|
+
if (statSync(real).isDirectory()) return real;
|
|
18
|
+
} catch {
|
|
19
|
+
// fall through
|
|
20
|
+
}
|
|
21
|
+
throw new JotviewError("usage", `--allow-dir: not a folder: ${dir}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Folders the MCP server may read from: its working folder (Claude Code
|
|
26
|
+
* starts it in the project) unless that is `/` or the home folder, plus any
|
|
27
|
+
* `--allow-dir`. Blank values and unfilled `${…}` placeholders (an unset
|
|
28
|
+
* Desktop bundle setting) are ignored.
|
|
29
|
+
*
|
|
30
|
+
* @param {{cwd: string, extra?: string[], home?: string}} opts
|
|
31
|
+
* @returns {string[]} real paths, deduplicated
|
|
32
|
+
*/
|
|
33
|
+
export function allowedRoots({ cwd, extra = [], home = homedir() }) {
|
|
34
|
+
const roots = [];
|
|
35
|
+
const realCwd = realpathSync(cwd);
|
|
36
|
+
let realHome = home;
|
|
37
|
+
try { realHome = realpathSync(home); } catch { /* keep as given */ }
|
|
38
|
+
if (!isFilesystemRoot(realCwd) && realCwd !== realHome) roots.push(realCwd);
|
|
39
|
+
for (const dir of extra) {
|
|
40
|
+
if (dir.trim() === "" || dir.startsWith("${")) continue;
|
|
41
|
+
roots.push(realDir(resolve(cwd, dir)));
|
|
42
|
+
}
|
|
43
|
+
return [...new Set(roots)];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** @param {string} p */
|
|
47
|
+
const readable = (p) => READABLE_EXTENSIONS.includes(extname(p).toLowerCase());
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Read a markdown or text file inside one of `roots`. Relative paths resolve
|
|
51
|
+
* against the first root. Symlinks are resolved before the checks.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} path
|
|
54
|
+
* @param {string[]} roots from allowedRoots
|
|
55
|
+
* @returns {string}
|
|
56
|
+
*/
|
|
57
|
+
export function readAllowedFile(path, roots) {
|
|
58
|
+
if (roots.length === 0) {
|
|
59
|
+
throw new JotviewError("not-allowed", "file access is off for this server; pass the markdown as content");
|
|
60
|
+
}
|
|
61
|
+
const target = isAbsolute(path) ? path : resolve(roots[0], path);
|
|
62
|
+
const allowedTypes = READABLE_EXTENSIONS.join(" ");
|
|
63
|
+
if (!readable(target)) {
|
|
64
|
+
throw new JotviewError("not-allowed", `only ${allowedTypes} files can be read`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let real;
|
|
68
|
+
try {
|
|
69
|
+
real = realpathSync(target);
|
|
70
|
+
} catch {
|
|
71
|
+
throw new JotviewError("not-found", `no such file: ${path}`);
|
|
72
|
+
}
|
|
73
|
+
const inside = roots.some((r) => real.startsWith(r.endsWith(sep) ? r : r + sep));
|
|
74
|
+
if (!inside) {
|
|
75
|
+
throw new JotviewError("not-allowed", `${path} is outside the folders this server may read (${roots.join(", ")})`);
|
|
76
|
+
}
|
|
77
|
+
if (!readable(real)) {
|
|
78
|
+
throw new JotviewError("not-allowed", `only ${allowedTypes} files can be read`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const stat = statSync(real);
|
|
82
|
+
if (!stat.isFile()) throw new JotviewError("not-found", `not a file: ${path}`);
|
|
83
|
+
if (stat.size > MAX_DOC_BYTES) {
|
|
84
|
+
throw new JotviewError("too-large", `${path} is ${stat.size} bytes; the limit is ${MAX_DOC_BYTES}`);
|
|
85
|
+
}
|
|
86
|
+
return readFileSync(real, "utf8");
|
|
87
|
+
}
|
package/src/landing.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page a person sees opening the MCP server in a browser: what it is and
|
|
3
|
+
* how to connect it to each common AI tool. Plain text, 80 columns.
|
|
4
|
+
* @param {string} endpoint e.g. https://mcp.jotbins.com/mcp
|
|
5
|
+
*/
|
|
6
|
+
export const landingText = (endpoint) => `jotview MCP — rendered jotbins links from any AI tool
|
|
7
|
+
=====================================================
|
|
8
|
+
|
|
9
|
+
Connect this server and your AI gets one tool, render_markdown. Ask it for
|
|
10
|
+
notes, a report or a Mermaid diagram "as a jotbins link" and it hands back a
|
|
11
|
+
link that opens the page, fully rendered, on jotbins.com.
|
|
12
|
+
|
|
13
|
+
Endpoint: ${endpoint}
|
|
14
|
+
Transport: Streamable HTTP. No sign-in, no API key.
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
CONNECT IT
|
|
18
|
+
----------
|
|
19
|
+
|
|
20
|
+
claude.ai, Claude Desktop and the Claude mobile apps
|
|
21
|
+
1. Settings → Connectors → Add custom connector.
|
|
22
|
+
2. Name it jotview and paste the endpoint URL above.
|
|
23
|
+
3. In a chat, make sure jotview is switched on in the tools menu.
|
|
24
|
+
Connectors added on the web also show up on mobile. Custom connectors may
|
|
25
|
+
need a paid Claude plan; on Team and Enterprise an owner adds them.
|
|
26
|
+
|
|
27
|
+
Claude Code
|
|
28
|
+
claude mcp add -s user --transport http jotview ${endpoint}
|
|
29
|
+
|
|
30
|
+
Cursor — ~/.cursor/mcp.json
|
|
31
|
+
{
|
|
32
|
+
"mcpServers": {
|
|
33
|
+
"jotview": { "url": "${endpoint}" }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
VS Code (Copilot agent mode) — .vscode/mcp.json
|
|
38
|
+
{
|
|
39
|
+
"servers": {
|
|
40
|
+
"jotview": { "type": "http", "url": "${endpoint}" }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
Windsurf — ~/.codeium/windsurf/mcp_config.json
|
|
45
|
+
{
|
|
46
|
+
"mcpServers": {
|
|
47
|
+
"jotview": { "serverUrl": "${endpoint}" }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
Gemini CLI — ~/.gemini/settings.json
|
|
52
|
+
{
|
|
53
|
+
"mcpServers": {
|
|
54
|
+
"jotview": { "httpUrl": "${endpoint}" }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
Anything else
|
|
59
|
+
Add a remote MCP server with the endpoint URL. If your tool can only run
|
|
60
|
+
local servers, use the npm package instead (needs Node 18+):
|
|
61
|
+
npx -y jotview mcp
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
TRY IT
|
|
65
|
+
------
|
|
66
|
+
|
|
67
|
+
"Write up our deploy checklist and give me a jotbins link."
|
|
68
|
+
"Draw the login flow as a Mermaid sequence diagram, as a jotbins link."
|
|
69
|
+
"Here's my jotbins link — fix the typos and give me a new one."
|
|
70
|
+
|
|
71
|
+
The AI answers with a link named after the document's first heading. It can
|
|
72
|
+
also read any jotbins link you paste; an edit gives you a new link, the old
|
|
73
|
+
one never changes.
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
WHAT HAPPENS TO YOUR DOCUMENT
|
|
77
|
+
-----------------------------
|
|
78
|
+
|
|
79
|
+
Small documents live entirely inside the link, after the #. They never reach
|
|
80
|
+
a server and never expire.
|
|
81
|
+
|
|
82
|
+
Larger ones (a link over 500 characters) are uploaded to surl.jotbins.com
|
|
83
|
+
for a short link. Short links are deleted after 30 days without a visit.
|
|
84
|
+
Ask the AI to "keep" the link to make it permanent, or "don't upload" to
|
|
85
|
+
always get the full link.
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
Source, CLI and docs: https://github.com/jotbins/jotview
|
|
89
|
+
`;
|
package/src/mcp-http.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { handleMessage, parseError, LEGACY_VERSIONS, MODERN_VERSIONS } from "./mcp.js";
|
|
2
|
+
import { landingText } from "./landing.js";
|
|
2
3
|
|
|
3
4
|
/** Docs cap at 1 MB (MAX_DOC_BYTES); leave room for JSON escaping. */
|
|
4
5
|
export const MAX_BODY_BYTES = 2_000_000;
|
|
@@ -22,18 +23,6 @@ const json = (body, status = 200) =>
|
|
|
22
23
|
const plain = (text, status, headers = {}) =>
|
|
23
24
|
new Response(text, { status, headers: { ...CORS, "content-type": "text/plain; charset=utf-8", ...headers } });
|
|
24
25
|
|
|
25
|
-
/** @param {string} endpoint */
|
|
26
|
-
const landing = (endpoint) => `jotview — an MCP server that turns markdown into rendered jotbins links.
|
|
27
|
-
|
|
28
|
-
MCP endpoint (Streamable HTTP, no sign-in):
|
|
29
|
-
${endpoint}
|
|
30
|
-
|
|
31
|
-
claude.ai: Settings → Connectors → Add custom connector → paste the URL above.
|
|
32
|
-
Claude Code: claude mcp add -s user --transport http jotview ${endpoint}
|
|
33
|
-
|
|
34
|
-
Source: https://github.com/jotbins/jotview
|
|
35
|
-
`;
|
|
36
|
-
|
|
37
26
|
/**
|
|
38
27
|
* Serve MCP over Streamable HTTP. Every request gets exactly one JSON reply —
|
|
39
28
|
* no SSE stream and no sessions, since the only tool answers immediately.
|
|
@@ -48,7 +37,7 @@ export async function handleHttp(request, ctx) {
|
|
|
48
37
|
|
|
49
38
|
if (url.pathname === "/") {
|
|
50
39
|
if (request.method !== "GET") return plain("Method not allowed\n", 405, { allow: "GET" });
|
|
51
|
-
return plain(
|
|
40
|
+
return plain(landingText(`${url.origin}/mcp`), 200);
|
|
52
41
|
}
|
|
53
42
|
if (url.pathname !== "/mcp") return plain("Not found\n", 404);
|
|
54
43
|
if (request.method !== "POST") return plain("Method not allowed\n", 405, { allow: "POST, OPTIONS" });
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { readLink } from "./read.js";
|
|
2
|
+
import { JotviewError } from "./codec.js";
|
|
3
|
+
|
|
4
|
+
const DEFINITION = {
|
|
5
|
+
name: "read_link",
|
|
6
|
+
title: "Read a jotbins link",
|
|
7
|
+
description:
|
|
8
|
+
"Get the markdown behind a jotbins link — a jotbins.com/d link or a " +
|
|
9
|
+
"surl.jotbins.com short link — so you can read, quote or edit it. Links " +
|
|
10
|
+
"never change: to edit, change the markdown and call render_markdown for " +
|
|
11
|
+
"a new link. Reading a short link counts as a visit and keeps it alive.",
|
|
12
|
+
inputSchema: {
|
|
13
|
+
type: "object",
|
|
14
|
+
properties: {
|
|
15
|
+
url: { type: "string", description: "The jotbins.com/d or surl.jotbins.com link." },
|
|
16
|
+
},
|
|
17
|
+
required: ["url"],
|
|
18
|
+
additionalProperties: false,
|
|
19
|
+
},
|
|
20
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** @param {string} text */
|
|
24
|
+
const toolError = (text) => ({ content: [{ type: "text", text }], isError: true });
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {{fetchImpl?: typeof fetch}} opts the Worker passes its surl binding
|
|
28
|
+
* @returns {import("./mcp.js").Tool}
|
|
29
|
+
*/
|
|
30
|
+
export function readTool(opts) {
|
|
31
|
+
return {
|
|
32
|
+
definition: DEFINITION,
|
|
33
|
+
async call(args) {
|
|
34
|
+
if (typeof args.url !== "string" || args.url.trim() === "") {
|
|
35
|
+
return toolError("url is required: pass the jotbins link to read.");
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const { markdown } = await readLink(args.url, { fetchImpl: opts.fetchImpl });
|
|
39
|
+
return { content: [{ type: "text", text: markdown }] };
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if (err instanceof JotviewError) return toolError(`Can't read that link: ${err.message}`);
|
|
42
|
+
throw err;
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
package/src/mcp-tool.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { mint } from "./mint.js";
|
|
2
2
|
import { shorten, DEFAULT_BASE_URL, SHORTENER_URL } from "./shorten.js";
|
|
3
3
|
import { JotviewError } from "./codec.js";
|
|
4
|
+
import { readAllowedFile, READABLE_EXTENSIONS } from "./files.js";
|
|
5
|
+
import { basename } from "node:path";
|
|
4
6
|
|
|
5
7
|
/** Every character of a /d link costs the user tokens twice — once handed
|
|
6
8
|
* back to the model, once written into its reply — so MCP shortens much
|
|
@@ -28,6 +30,10 @@ const DEFINITION = {
|
|
|
28
30
|
type: "boolean",
|
|
29
31
|
description: "Make a shortened link permanent instead of expiring after 30 days unused.",
|
|
30
32
|
},
|
|
33
|
+
title: {
|
|
34
|
+
type: "string",
|
|
35
|
+
description: "Link text to show instead of the document's first heading.",
|
|
36
|
+
},
|
|
31
37
|
shorten: {
|
|
32
38
|
type: "string",
|
|
33
39
|
enum: MODES,
|
|
@@ -42,16 +48,45 @@ const DEFINITION = {
|
|
|
42
48
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
43
49
|
};
|
|
44
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The definition, plus a `path` input when the server may read files.
|
|
53
|
+
* @param {string[]} roots
|
|
54
|
+
*/
|
|
55
|
+
function definitionFor(roots) {
|
|
56
|
+
if (roots.length === 0) return DEFINITION;
|
|
57
|
+
const { required: _required, ...schema } = DEFINITION.inputSchema;
|
|
58
|
+
return {
|
|
59
|
+
...DEFINITION,
|
|
60
|
+
inputSchema: {
|
|
61
|
+
...schema,
|
|
62
|
+
properties: {
|
|
63
|
+
...schema.properties,
|
|
64
|
+
content: { type: "string", description: "The markdown to render. Pass this or path." },
|
|
65
|
+
path: {
|
|
66
|
+
type: "string",
|
|
67
|
+
description:
|
|
68
|
+
`A ${READABLE_EXTENSIONS.join("/")} file to render instead of content — saves ` +
|
|
69
|
+
`sending the text. Relative to ${roots[0]}; only files under ` +
|
|
70
|
+
`${roots.join(", ")} can be read.`,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
45
77
|
/**
|
|
46
78
|
* The first markdown heading, as safe link text.
|
|
47
79
|
* @param {string} md
|
|
80
|
+
* @param {string} [fallback] used when there is no heading
|
|
48
81
|
*/
|
|
49
|
-
export function docTitle(md) {
|
|
82
|
+
export function docTitle(md, fallback = "jotbins doc") {
|
|
50
83
|
const match = md.match(/^#{1,6}[ \t]+(.+?)[ \t#]*$/m);
|
|
51
|
-
|
|
52
|
-
return title.replace(/[\\[\]]/g, (c) => `\\${c}`);
|
|
84
|
+
return linkText(match ? match[1] : fallback);
|
|
53
85
|
}
|
|
54
86
|
|
|
87
|
+
/** @param {string} text */
|
|
88
|
+
const linkText = (text) => text.trim().slice(0, TITLE_MAX).replace(/[\\[\]]/g, (c) => `\\${c}`);
|
|
89
|
+
|
|
55
90
|
/** @param {string} text */
|
|
56
91
|
const toolError = (text) => ({ content: [{ type: "text", text }], isError: true });
|
|
57
92
|
|
|
@@ -60,9 +95,17 @@ const toolError = (text) => ({ content: [{ type: "text", text }], isError: true
|
|
|
60
95
|
* @returns {string|null} what's wrong, phrased for the model to fix
|
|
61
96
|
*/
|
|
62
97
|
function validate(args) {
|
|
63
|
-
if (
|
|
64
|
-
|
|
98
|
+
if (args.path !== undefined) {
|
|
99
|
+
if (args.content !== undefined) return "pass content or path, not both.";
|
|
100
|
+
if (typeof args.path !== "string" || args.path.trim() === "") return "path must be a file path.";
|
|
101
|
+
} else {
|
|
102
|
+
if (typeof args.content !== "string") return "content is required: pass the markdown to render as a string.";
|
|
103
|
+
if (args.content.trim() === "") return "content is empty: pass the markdown to render.";
|
|
104
|
+
}
|
|
65
105
|
if (args.keep !== undefined && typeof args.keep !== "boolean") return "keep must be true or false.";
|
|
106
|
+
if (args.title !== undefined && (typeof args.title !== "string" || args.title.trim() === "")) {
|
|
107
|
+
return "title must be a non-empty string, or left out to use the first heading.";
|
|
108
|
+
}
|
|
66
109
|
if (args.shorten !== undefined && !MODES.includes(/** @type {string} */ (args.shorten))) {
|
|
67
110
|
return 'shorten must be "auto", "always" or "never".';
|
|
68
111
|
}
|
|
@@ -101,17 +144,31 @@ function describe(r, keepAsked) {
|
|
|
101
144
|
* allowUpload?: boolean,
|
|
102
145
|
* baseUrl?: string,
|
|
103
146
|
* shortenImpl?: typeof shorten,
|
|
104
|
-
*
|
|
147
|
+
* roots?: string[],
|
|
148
|
+
* }} opts `allowUpload: false` is `jotview mcp --raw`; `roots` (from
|
|
149
|
+
* allowedRoots) enables the `path` input
|
|
105
150
|
* @returns {import("./mcp.js").Tool}
|
|
106
151
|
*/
|
|
107
152
|
export function renderTool(opts) {
|
|
153
|
+
const roots = opts.roots ?? [];
|
|
108
154
|
return {
|
|
109
|
-
definition:
|
|
155
|
+
definition: definitionFor(roots),
|
|
110
156
|
async call(args) {
|
|
111
157
|
const problem = validate(args);
|
|
112
158
|
if (problem) return toolError(problem);
|
|
113
159
|
|
|
114
|
-
const
|
|
160
|
+
const path = /** @type {string|undefined} */ (args.path);
|
|
161
|
+
let md;
|
|
162
|
+
if (path === undefined) {
|
|
163
|
+
md = /** @type {string} */ (args.content);
|
|
164
|
+
} else {
|
|
165
|
+
try {
|
|
166
|
+
md = readAllowedFile(path, roots);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
if (err instanceof JotviewError) return toolError(`Can't read that file: ${err.message}.`);
|
|
169
|
+
throw err;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
115
172
|
const keep = args.keep === true;
|
|
116
173
|
let r;
|
|
117
174
|
try {
|
|
@@ -124,12 +181,14 @@ export function renderTool(opts) {
|
|
|
124
181
|
shortenImpl: opts.shortenImpl ?? shorten,
|
|
125
182
|
});
|
|
126
183
|
} catch (err) {
|
|
127
|
-
if (err instanceof JotviewError)
|
|
184
|
+
if (err instanceof JotviewError) {
|
|
185
|
+
return toolError(err.code === "too-large" ? `${err.message}. Shorten or split the document.` : `${err.message}.`);
|
|
186
|
+
}
|
|
128
187
|
throw err;
|
|
129
188
|
}
|
|
130
189
|
|
|
131
190
|
const text =
|
|
132
|
-
`[${docTitle(md)}](${r.url})\n\n` +
|
|
191
|
+
`[${typeof args.title === "string" ? linkText(args.title) : docTitle(md, path && basename(path))}](${r.url})\n\n` +
|
|
133
192
|
`${describe(r, keep && !r.shortened)}\n` +
|
|
134
193
|
"Show it to the user as the markdown link above, not as a bare URL.";
|
|
135
194
|
return { content: [{ type: "text", text }] };
|
package/src/mcp.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* jotview is a dual-era server. Clients on the handshake revisions open with
|
|
5
5
|
* `initialize`; clients on 2026-07-28 open with `server/discover` and put the
|
|
6
|
-
* protocol version in every request's `_meta`. The
|
|
6
|
+
* protocol version in every request's `_meta`. The tools are stateless, so
|
|
7
7
|
* both eras are served from the same handlers and only the envelope differs.
|
|
8
8
|
*/
|
|
9
9
|
|
|
@@ -16,7 +16,8 @@ export const INSTRUCTIONS =
|
|
|
16
16
|
"rendered jotbins page. Show the returned link to the user as a markdown " +
|
|
17
17
|
"link, [title](url); never paste a bare jotbins.com/d URL, it can be " +
|
|
18
18
|
"thousands of characters. Long documents are uploaded to shorten the link " +
|
|
19
|
-
'unless the user asked to keep them local (shorten: "never").'
|
|
19
|
+
'unless the user asked to keep them local (shorten: "never"). Use read_link ' +
|
|
20
|
+
"to get the markdown behind a jotbins link the user shares.";
|
|
20
21
|
|
|
21
22
|
const META_VERSION = "io.modelcontextprotocol/protocolVersion";
|
|
22
23
|
const META_CAPABILITIES = "io.modelcontextprotocol/clientCapabilities";
|
package/src/read.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { decode, JotviewError } from "./codec.js";
|
|
2
|
+
import { SHORTENER_URL } from "./shorten.js";
|
|
3
|
+
|
|
4
|
+
const SHORTENER_HOST = new URL(SHORTENER_URL).host;
|
|
5
|
+
const DOC_HOSTS = ["jotbins.com", "www.jotbins.com"];
|
|
6
|
+
const SHORT_PATH = /^\/[A-Za-z0-9]+$/;
|
|
7
|
+
|
|
8
|
+
/** Milliseconds before giving up on surl. */
|
|
9
|
+
const TIMEOUT_MS = 5000;
|
|
10
|
+
|
|
11
|
+
// Pinned by surl's own page tests (src/pages.rs): the waiting page links its
|
|
12
|
+
// target from #continue; the cleaned-up page has #deleted and #purge.
|
|
13
|
+
const CONTINUE_HREF = /id="continue" href="([^"]*)"/;
|
|
14
|
+
const DELETED = /id="deleted"/;
|
|
15
|
+
const PURGE_TEXT = /id="purge">([^<]*)</;
|
|
16
|
+
|
|
17
|
+
/** @param {string} s */
|
|
18
|
+
const unescapeHtml = (s) =>
|
|
19
|
+
s.replaceAll(""", '"').replaceAll("'", "'").replaceAll("<", "<")
|
|
20
|
+
.replaceAll(">", ">").replaceAll("&", "&");
|
|
21
|
+
|
|
22
|
+
const notJotbins = () =>
|
|
23
|
+
new JotviewError("bad-link", `only jotbins.com/d links and ${SHORTENER_HOST} short links can be read`);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Decode a jotbins `/d` URL; anything else is a bad link.
|
|
27
|
+
* @param {string} raw
|
|
28
|
+
* @returns {{markdown: string, url: string}}
|
|
29
|
+
*/
|
|
30
|
+
function decodeDocUrl(raw) {
|
|
31
|
+
let url;
|
|
32
|
+
try { url = new URL(raw); } catch { throw notJotbins(); }
|
|
33
|
+
if (url.protocol !== "https:" || !DOC_HOSTS.includes(url.host) || url.pathname !== "/d") {
|
|
34
|
+
throw notJotbins();
|
|
35
|
+
}
|
|
36
|
+
return { markdown: decode(url.hash.slice(1)), url: raw };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Turn a jotbins link back into its markdown. `/d` links decode locally;
|
|
41
|
+
* surl short links are fetched (and, like a click, keep an active link alive).
|
|
42
|
+
* Only surl is ever fetched.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} input
|
|
45
|
+
* @param {{fetchImpl?: typeof fetch}} [opts]
|
|
46
|
+
* @returns {Promise<{markdown: string, url: string}>} `url` is the /d link
|
|
47
|
+
*/
|
|
48
|
+
export async function readLink(input, opts = {}) {
|
|
49
|
+
const raw = input.trim();
|
|
50
|
+
let url;
|
|
51
|
+
try { url = new URL(raw); } catch { throw notJotbins(); }
|
|
52
|
+
if (url.host !== SHORTENER_HOST) return decodeDocUrl(raw);
|
|
53
|
+
if (url.protocol !== "https:" || !SHORT_PATH.test(url.pathname)) throw notJotbins();
|
|
54
|
+
|
|
55
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
56
|
+
let response;
|
|
57
|
+
try {
|
|
58
|
+
response = await doFetch(`https://${SHORTENER_HOST}${url.pathname}`, {
|
|
59
|
+
redirect: "manual",
|
|
60
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
61
|
+
});
|
|
62
|
+
} catch (err) {
|
|
63
|
+
throw new JotviewError("read-failed", `couldn't reach ${SHORTENER_HOST}: ${/** @type {Error} */ (err).message}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (response.status === 404) {
|
|
67
|
+
throw new JotviewError("not-found", "that short link doesn't exist (or was deleted for good)");
|
|
68
|
+
}
|
|
69
|
+
if (response.status >= 300 && response.status < 400) {
|
|
70
|
+
return decodeDocUrl(response.headers.get("location") ?? "");
|
|
71
|
+
}
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
throw new JotviewError("read-failed", `${SHORTENER_HOST} returned ${response.status}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const html = await response.text();
|
|
77
|
+
const target = html.match(CONTINUE_HREF);
|
|
78
|
+
if (target) return decodeDocUrl(unescapeHtml(target[1]));
|
|
79
|
+
if (DELETED.test(html)) {
|
|
80
|
+
const purge = html.match(PURGE_TEXT)?.[1];
|
|
81
|
+
throw new JotviewError(
|
|
82
|
+
"cleaned-up",
|
|
83
|
+
"that short link was cleaned up after 30 days without a visit" +
|
|
84
|
+
(purge ? ` and is gone for good on ${unescapeHtml(purge)}` : "") +
|
|
85
|
+
". Open it in a browser and press Save to bring it back.",
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
throw new JotviewError("read-failed", `unexpected page from ${SHORTENER_HOST}`);
|
|
89
|
+
}
|