jotview 0.3.0 → 0.4.1

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 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
@@ -40,8 +41,13 @@ jotview README.md --open
40
41
  cat notes.md | jotview
41
42
  jotview notes.md | pbcopy
42
43
  LINK=$(jotview notes.md --raw)
44
+ jotview decode https://surl.jotbins.com/abc123 > notes.md
43
45
  ```
44
46
 
47
+ `decode` accepts `jotbins.com/d` links (decoded locally) and
48
+ `surl.jotbins.com` short links (fetched; like a click, that keeps an active
49
+ short link alive).
50
+
45
51
  ## Shortening, and what it costs you
46
52
 
47
53
  A document's link grows with the document — roughly half the markdown's size in
@@ -72,9 +78,10 @@ and exits 0 — it never fails your pipeline over a shortening problem.
72
78
 
73
79
  ## Use with AI agents (MCP)
74
80
 
75
- `jotview mcp` is a stdio [MCP](https://modelcontextprotocol.io) server with one
76
- tool, `render_markdown`. An AI writes markdown — notes, a report, a Mermaid
77
- diagram — and gets back a jotbins link to hand you.
81
+ `jotview mcp` is a stdio [MCP](https://modelcontextprotocol.io) server with two
82
+ tools. With `render_markdown` an AI writes markdown — notes, a report, a
83
+ Mermaid diagram — and gets back a jotbins link to hand you; with `read_link`
84
+ it reads a jotbins link you share.
78
85
 
79
86
  **Claude Code**
80
87
 
@@ -90,11 +97,16 @@ claude mcp add jotview -- npx -y jotview mcp
90
97
 
91
98
  Then just ask: *"draw a diagram of our deploy flow and give me a jotbins link."*
92
99
 
93
- **The tool** takes `content` (the markdown), plus optional `keep` (make a
94
- short link permanent) and `shorten` (`"auto"`, `"always"` or `"never"`). It
95
- returns a ready-made markdown link, `[First heading](url)`, and a note on
96
- whether anything was uploaded. It never reads files — the AI passes text it
97
- already has.
100
+ **`render_markdown`** takes `content` (the markdown), plus optional `title`
101
+ (link text instead of the first heading), `keep` (make a short link
102
+ permanent) and `shorten` (`"auto"`, `"always"` or `"never"`). It returns a
103
+ ready-made markdown link, `[First heading](url)`, and a note on whether
104
+ anything was uploaded. It never reads files — the AI passes text it already
105
+ has.
106
+
107
+ **`read_link`** takes a `url` (a `/d` link or a short link) and returns the
108
+ markdown behind it, so you can paste a link and say *"fix the typos and give
109
+ me a new link."* Links never change; an edit is a new link.
98
110
 
99
111
  **Uploads.** Every character of a link costs tokens, so MCP shortens sooner
100
112
  than the CLI: links over **500** characters go through `surl.jotbins.com`,
@@ -107,6 +119,23 @@ claude mcp add jotview -- npx -y jotview mcp --raw
107
119
 
108
120
  Works with clients on any MCP revision from `2024-11-05` through `2026-07-28`.
109
121
 
122
+ ### Remote server — no install
123
+
124
+ The same tool runs at **`https://mcp.jotbins.com/mcp`** (Streamable HTTP, no
125
+ sign-in), for clients that can't run local commands.
126
+
127
+ - **claude.ai (web and mobile):** Settings → Connectors → Add custom
128
+ connector → paste `https://mcp.jotbins.com/mcp`.
129
+ - **Claude Code:** `claude mcp add -s user --transport http jotview https://mcp.jotbins.com/mcp`
130
+
131
+ Same rules as the local server, except it can't forbid uploads: use
132
+ `shorten: "never"` per call, or the local `--raw` server, to keep a document
133
+ off `surl.jotbins.com`. Uploads are rate-limited per client; over the limit
134
+ you still get the full link.
135
+
136
+ The Worker lives in [`worker/`](worker/) and deploys with
137
+ `cd worker && npx wrangler deploy`.
138
+
110
139
  ## Output contract
111
140
 
112
141
  **stdout is the URL and nothing else.** Notices, warnings, and errors go to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jotview",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Turn markdown into a jotbins /d link — a CLI and an MCP server. Zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -2,8 +2,10 @@ 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";
5
6
  import { serve } from "./mcp-stdio.js";
6
7
  import { renderTool } from "./mcp-tool.js";
8
+ import { readTool } from "./mcp-read-tool.js";
7
9
  import { openUrl, copyText } from "./platform.js";
8
10
  export { DEFAULT_BASE_URL };
9
11
 
@@ -16,6 +18,7 @@ export const HELP = `jotview — turn markdown into a jotbins /d link
16
18
  Usage:
17
19
  jotview <file> mint a link from a file
18
20
  jotview read markdown from stdin
21
+ jotview decode <url> print the markdown behind a /d or short link
19
22
  jotview mcp [--raw] run as an MCP server (stdio) for AI agents;
20
23
  --raw never uploads, --base-url works too
21
24
 
@@ -193,6 +196,7 @@ function applySideEffects(url, opts, io) {
193
196
  */
194
197
  export async function run(argv, io) {
195
198
  if (argv[0] === "mcp") return runMcp(argv.slice(1), io);
199
+ if (argv[0] === "decode") return runDecode(argv.slice(1), io);
196
200
 
197
201
  /** @type {Options} */
198
202
  let opts;
@@ -250,6 +254,28 @@ export async function run(argv, io) {
250
254
  }
251
255
  }
252
256
 
257
+ /**
258
+ * `jotview decode <url>`: the markdown, byte for byte, on stdout — so
259
+ * `jotview decode URL > doc.md` and `jotview decode URL | jotview` round-trip.
260
+ * @param {string[]} argv
261
+ * @param {Parameters<typeof run>[1]} io
262
+ * @returns {Promise<number>}
263
+ */
264
+ async function runDecode(argv, io) {
265
+ if (argv.length !== 1 || argv[0].startsWith("-")) {
266
+ io.stderr(`jotview: decode takes exactly one link\n\n${HELP}`);
267
+ return 2;
268
+ }
269
+ try {
270
+ const { markdown } = await readLink(argv[0]);
271
+ io.stdout(markdown);
272
+ return 0;
273
+ } catch (err) {
274
+ io.stderr(`jotview: ${/** @type {Error} */ (err).message}\n`);
275
+ return 1;
276
+ }
277
+ }
278
+
253
279
  /**
254
280
  * `jotview mcp`: stdout belongs to the protocol from here on, so usage
255
281
  * errors go to stderr and nothing else is ever printed.
@@ -270,7 +296,7 @@ async function runMcp(argv, io) {
270
296
  write: io.stdout,
271
297
  ctx: {
272
298
  version: io.version,
273
- tools: [renderTool({ allowUpload: !opts.raw, baseUrl: opts.baseUrl })],
299
+ tools: [renderTool({ allowUpload: !opts.raw, baseUrl: opts.baseUrl }), readTool({})],
274
300
  },
275
301
  });
276
302
  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"} 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/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
+ `;
@@ -0,0 +1,64 @@
1
+ import { handleMessage, parseError, LEGACY_VERSIONS, MODERN_VERSIONS } from "./mcp.js";
2
+ import { landingText } from "./landing.js";
3
+
4
+ /** Docs cap at 1 MB (MAX_DOC_BYTES); leave room for JSON escaping. */
5
+ export const MAX_BODY_BYTES = 2_000_000;
6
+
7
+ const KNOWN_VERSIONS = [...LEGACY_VERSIONS, ...MODERN_VERSIONS];
8
+
9
+ // No cookies or credentials are involved, so any origin may call — that lets
10
+ // browser-based clients such as MCP Inspector connect directly.
11
+ const CORS = {
12
+ "access-control-allow-origin": "*",
13
+ "access-control-allow-methods": "POST, OPTIONS",
14
+ "access-control-allow-headers": "content-type, accept, mcp-protocol-version, mcp-session-id",
15
+ "access-control-max-age": "86400",
16
+ };
17
+
18
+ /** @param {unknown} body @param {number} [status] */
19
+ const json = (body, status = 200) =>
20
+ new Response(JSON.stringify(body), { status, headers: { ...CORS, "content-type": "application/json" } });
21
+
22
+ /** @param {string} text @param {number} status @param {Record<string,string>} [headers] */
23
+ const plain = (text, status, headers = {}) =>
24
+ new Response(text, { status, headers: { ...CORS, "content-type": "text/plain; charset=utf-8", ...headers } });
25
+
26
+ /**
27
+ * Serve MCP over Streamable HTTP. Every request gets exactly one JSON reply —
28
+ * no SSE stream and no sessions, since the only tool answers immediately.
29
+ *
30
+ * @param {Request} request
31
+ * @param {import("./mcp.js").Context} ctx
32
+ * @returns {Promise<Response>}
33
+ */
34
+ export async function handleHttp(request, ctx) {
35
+ const url = new URL(request.url);
36
+ if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS });
37
+
38
+ if (url.pathname === "/") {
39
+ if (request.method !== "GET") return plain("Method not allowed\n", 405, { allow: "GET" });
40
+ return plain(landingText(`${url.origin}/mcp`), 200);
41
+ }
42
+ if (url.pathname !== "/mcp") return plain("Not found\n", 404);
43
+ if (request.method !== "POST") return plain("Method not allowed\n", 405, { allow: "POST, OPTIONS" });
44
+
45
+ const version = request.headers.get("mcp-protocol-version");
46
+ if (version !== null && !KNOWN_VERSIONS.includes(version)) {
47
+ return plain(`Unsupported MCP-Protocol-Version: ${version}\n`, 400);
48
+ }
49
+
50
+ const declared = Number(request.headers.get("content-length"));
51
+ if (declared > MAX_BODY_BYTES) return plain("Request too large\n", 413);
52
+ const bytes = await request.arrayBuffer();
53
+ if (bytes.byteLength > MAX_BODY_BYTES) return plain("Request too large\n", 413);
54
+
55
+ let msg;
56
+ try {
57
+ msg = JSON.parse(new TextDecoder().decode(bytes));
58
+ } catch {
59
+ return json(parseError(), 400);
60
+ }
61
+
62
+ const reply = await handleMessage(msg, ctx);
63
+ return reply ? json(reply) : new Response(null, { status: 202, headers: CORS });
64
+ }
@@ -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
@@ -28,6 +28,10 @@ const DEFINITION = {
28
28
  type: "boolean",
29
29
  description: "Make a shortened link permanent instead of expiring after 30 days unused.",
30
30
  },
31
+ title: {
32
+ type: "string",
33
+ description: "Link text to show instead of the document's first heading.",
34
+ },
31
35
  shorten: {
32
36
  type: "string",
33
37
  enum: MODES,
@@ -48,10 +52,12 @@ const DEFINITION = {
48
52
  */
49
53
  export function docTitle(md) {
50
54
  const match = md.match(/^#{1,6}[ \t]+(.+?)[ \t#]*$/m);
51
- const title = match ? match[1].trim().slice(0, TITLE_MAX) : "jotbins doc";
52
- return title.replace(/[\\[\]]/g, (c) => `\\${c}`);
55
+ return linkText(match ? match[1] : "jotbins doc");
53
56
  }
54
57
 
58
+ /** @param {string} text */
59
+ const linkText = (text) => text.trim().slice(0, TITLE_MAX).replace(/[\\[\]]/g, (c) => `\\${c}`);
60
+
55
61
  /** @param {string} text */
56
62
  const toolError = (text) => ({ content: [{ type: "text", text }], isError: true });
57
63
 
@@ -63,6 +69,9 @@ function validate(args) {
63
69
  if (typeof args.content !== "string") return "content is required: pass the markdown to render as a string.";
64
70
  if (args.content.trim() === "") return "content is empty: pass the markdown to render.";
65
71
  if (args.keep !== undefined && typeof args.keep !== "boolean") return "keep must be true or false.";
72
+ if (args.title !== undefined && (typeof args.title !== "string" || args.title.trim() === "")) {
73
+ return "title must be a non-empty string, or left out to use the first heading.";
74
+ }
66
75
  if (args.shorten !== undefined && !MODES.includes(/** @type {string} */ (args.shorten))) {
67
76
  return 'shorten must be "auto", "always" or "never".';
68
77
  }
@@ -129,7 +138,7 @@ export function renderTool(opts) {
129
138
  }
130
139
 
131
140
  const text =
132
- `[${docTitle(md)}](${r.url})\n\n` +
141
+ `[${typeof args.title === "string" ? linkText(args.title) : docTitle(md)}](${r.url})\n\n` +
133
142
  `${describe(r, keep && !r.shortened)}\n` +
134
143
  "Show it to the user as the markdown link above, not as a bare URL.";
135
144
  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 one tool is stateless, so
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("&quot;", '"').replaceAll("&#39;", "'").replaceAll("&lt;", "<")
20
+ .replaceAll("&gt;", ">").replaceAll("&amp;", "&");
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
+ }
package/src/shorten.js CHANGED
@@ -28,15 +28,17 @@ export function shouldShorten(url, opts) {
28
28
  /**
29
29
  * Shorten a URL. Never throws — the caller falls back to the raw URL.
30
30
  * @param {string} url
31
- * @param {{endpoint?: string, permanent?: boolean}} [options]
31
+ * @param {{endpoint?: string, permanent?: boolean, fetchImpl?: typeof fetch}} [options]
32
32
  * `permanent` asks surl for a link that never expires. Left out of the body
33
33
  * when false, so the request matches what older surl versions expect.
34
+ * `fetchImpl` lets the Worker route through its surl service binding.
34
35
  * @returns {Promise<{ok: true, short: string} | {ok: false, reason: string}>}
35
36
  */
36
37
  export async function shorten(url, options = {}) {
37
38
  const endpoint = options.endpoint ?? SHORTENER_URL;
39
+ const doFetch = options.fetchImpl ?? fetch;
38
40
  try {
39
- const response = await fetch(endpoint, {
41
+ const response = await doFetch(endpoint, {
40
42
  method: "POST",
41
43
  headers: { "content-type": "application/json" },
42
44
  body: JSON.stringify(options.permanent ? { url, permanent: true } : { url }),