jotview 0.2.0 → 0.3.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 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 mcp [--raw] run as an MCP server for AI agents
24
25
 
25
26
  --raw never shorten; always the full /d URL
26
27
  --short always shorten, even when small
@@ -69,6 +70,43 @@ never expire.
69
70
  If the shortener is unreachable or rate-limited, jotview prints the full link
70
71
  and exits 0 — it never fails your pipeline over a shortening problem.
71
72
 
73
+ ## Use with AI agents (MCP)
74
+
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.
78
+
79
+ **Claude Code**
80
+
81
+ ```bash
82
+ claude mcp add jotview -- npx -y jotview mcp
83
+ ```
84
+
85
+ **Claude Desktop, Cursor, and other clients** — add to the MCP config:
86
+
87
+ ```json
88
+ { "mcpServers": { "jotview": { "command": "npx", "args": ["-y", "jotview", "mcp"] } } }
89
+ ```
90
+
91
+ Then just ask: *"draw a diagram of our deploy flow and give me a jotbins link."*
92
+
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.
98
+
99
+ **Uploads.** Every character of a link costs tokens, so MCP shortens sooner
100
+ than the CLI: links over **500** characters go through `surl.jotbins.com`,
101
+ which uploads the document (short links expire after 30 days unused unless
102
+ `keep` is set). To forbid uploads entirely, start the server with `--raw`:
103
+
104
+ ```bash
105
+ claude mcp add jotview -- npx -y jotview mcp --raw
106
+ ```
107
+
108
+ Works with clients on any MCP revision from `2024-11-05` through `2026-07-28`.
109
+
72
110
  ## Output contract
73
111
 
74
112
  **stdout is the URL and nothing else.** Notices, warnings, and errors go to
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "jotview",
3
- "version": "0.2.0",
4
- "description": "Turn a markdown file into a jotbins /d link. Zero dependencies.",
3
+ "version": "0.3.0",
4
+ "description": "Turn markdown into a jotbins /d link — a CLI and an MCP server. Zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "jotview": "bin/jotview.js"
@@ -22,7 +22,10 @@
22
22
  "markdown",
23
23
  "jotbins",
24
24
  "cli",
25
- "share"
25
+ "share",
26
+ "mcp",
27
+ "mcp-server",
28
+ "mermaid"
26
29
  ],
27
30
  "license": "MIT",
28
31
  "repository": {
package/src/cli.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { encode, JotviewError, MAX_DOC_BYTES } from "./codec.js";
3
- import {
4
- shorten, shouldShorten, DEFAULT_BASE_URL, SHORTEN_THRESHOLD, SHORTENER_URL,
5
- } from "./shorten.js";
2
+ import { JotviewError, MAX_DOC_BYTES } from "./codec.js";
3
+ import { DEFAULT_BASE_URL, SHORTENER_URL } from "./shorten.js";
4
+ import { mint } from "./mint.js";
5
+ import { serve } from "./mcp-stdio.js";
6
+ import { renderTool } from "./mcp-tool.js";
6
7
  import { openUrl, copyText } from "./platform.js";
7
8
  export { DEFAULT_BASE_URL };
8
9
 
@@ -15,6 +16,8 @@ export const HELP = `jotview — turn markdown into a jotbins /d link
15
16
  Usage:
16
17
  jotview <file> mint a link from a file
17
18
  jotview read markdown from stdin
19
+ jotview mcp [--raw] run as an MCP server (stdio) for AI agents;
20
+ --raw never uploads, --base-url works too
18
21
 
19
22
  Options:
20
23
  --raw never shorten; always the full /d URL
@@ -98,6 +101,30 @@ export function parseArgs(argv) {
98
101
  return opts;
99
102
  }
100
103
 
104
+ /**
105
+ * Flags after `jotview mcp`. Only the ones that mean something to a server.
106
+ * @param {string[]} argv
107
+ * @returns {{raw: boolean, baseUrl: string}}
108
+ */
109
+ export function parseMcpArgs(argv) {
110
+ const opts = { raw: false, baseUrl: DEFAULT_BASE_URL };
111
+ for (let i = 0; i < argv.length; i++) {
112
+ const arg = argv[i];
113
+ if (arg === "--raw") {
114
+ opts.raw = true;
115
+ } else if (arg === "--base-url") {
116
+ const value = argv[++i];
117
+ if (value === undefined || value.startsWith("-") || !/^https?:\/\//.test(value)) {
118
+ throw new JotviewError("usage", "--base-url needs an absolute http:// or https:// URL");
119
+ }
120
+ opts.baseUrl = value.replace(/\/+$/, "");
121
+ } else {
122
+ throw new JotviewError("usage", `jotview mcp does not take ${arg}`);
123
+ }
124
+ }
125
+ return opts;
126
+ }
127
+
101
128
  /** @param {import("node:stream").Readable} stdin */
102
129
  async function readStdin(stdin) {
103
130
  /** @type {Buffer[]} */
@@ -165,6 +192,8 @@ function applySideEffects(url, opts, io) {
165
192
  * @returns {Promise<number>} exit code
166
193
  */
167
194
  export async function run(argv, io) {
195
+ if (argv[0] === "mcp") return runMcp(argv.slice(1), io);
196
+
168
197
  /** @type {Options} */
169
198
  let opts;
170
199
  try {
@@ -182,29 +211,23 @@ export async function run(argv, io) {
182
211
 
183
212
  try {
184
213
  const md = await readInput(opts, io);
185
- if (md.trim() === "") throw new JotviewError("empty", "input is empty");
186
- const rawUrl = `${opts.baseUrl}/d#${encode(md)}`;
187
-
188
- if (!shouldShorten(rawUrl, opts)) {
189
- // Over the threshold, but the host isn't the shortenable default —
190
- // don't nag about it on documents that were never going to need it.
191
- if (opts.baseUrl !== DEFAULT_BASE_URL && rawUrl.length > SHORTEN_THRESHOLD) {
192
- io.stderr(
193
- `note: shortening skipped — only ${DEFAULT_BASE_URL} links can be ` +
194
- "shortened. using the full link.\n",
195
- );
196
- }
197
- io.stdout(`${rawUrl}\n`);
198
- applySideEffects(rawUrl, opts, io);
199
- return 0;
200
- }
214
+ const minted = await mint(md, {
215
+ baseUrl: opts.baseUrl,
216
+ shorten: opts.raw ? "never" : opts.short ? "always" : "auto",
217
+ keep: opts.keep,
218
+ });
219
+ const printed = minted.url;
201
220
 
202
- const result = await shorten(rawUrl, { permanent: opts.keep });
203
- let printed = rawUrl;
204
- if (result.ok) {
205
- printed = result.short;
221
+ if (minted.skipped === "custom-base") {
222
+ // Only reported when the link was over the threshold — documents that
223
+ // were never going to need shortening don't get nagged about it.
206
224
  io.stderr(
207
- `note: uploaded to jotbins to shorten (${rawUrl.length} → ` +
225
+ `note: shortening skipped — only ${DEFAULT_BASE_URL} links can be ` +
226
+ "shortened. using the full link.\n",
227
+ );
228
+ } else if (minted.shortened) {
229
+ io.stderr(
230
+ `note: uploaded to jotbins to shorten (${minted.rawUrl.length} → ` +
208
231
  `${printed.length} chars). use --raw to keep it local.\n`,
209
232
  );
210
233
  io.stderr(
@@ -212,9 +235,9 @@ export async function run(argv, io) {
212
235
  ? "note: short link kept forever.\n"
213
236
  : "note: short link expires after 30 days unused. use --keep to keep it forever.\n",
214
237
  );
215
- } else {
238
+ } else if (minted.failure) {
216
239
  io.stderr(
217
- `note: upload to the shortener failed (${result.reason}); using the full ` +
240
+ `note: upload to the shortener failed (${minted.failure}); using the full ` +
218
241
  `link. the document may still have reached ${SHORTENER_HOST}.\n`,
219
242
  );
220
243
  }
@@ -226,3 +249,29 @@ export async function run(argv, io) {
226
249
  return 1;
227
250
  }
228
251
  }
252
+
253
+ /**
254
+ * `jotview mcp`: stdout belongs to the protocol from here on, so usage
255
+ * errors go to stderr and nothing else is ever printed.
256
+ * @param {string[]} argv
257
+ * @param {Parameters<typeof run>[1]} io
258
+ * @returns {Promise<number>}
259
+ */
260
+ async function runMcp(argv, io) {
261
+ let opts;
262
+ try {
263
+ opts = parseMcpArgs(argv);
264
+ } catch (err) {
265
+ io.stderr(`jotview: ${/** @type {Error} */ (err).message}\n\n${HELP}`);
266
+ return 2;
267
+ }
268
+ await serve({
269
+ input: io.stdin,
270
+ write: io.stdout,
271
+ ctx: {
272
+ version: io.version,
273
+ tools: [renderTool({ allowUpload: !opts.raw, baseUrl: opts.baseUrl })],
274
+ },
275
+ });
276
+ return 0;
277
+ }
@@ -0,0 +1,39 @@
1
+ import { createInterface } from "node:readline";
2
+ import { handleMessage, parseError } from "./mcp.js";
3
+
4
+ /**
5
+ * Serve MCP over stdio: one JSON-RPC message per line in, one per line out.
6
+ * Requests are handled concurrently — each response carries its own id.
7
+ * Resolves once the client closes stdin and every pending reply is written.
8
+ *
9
+ * @param {{
10
+ * input: import("node:stream").Readable,
11
+ * write: (s: string) => void,
12
+ * ctx: import("./mcp.js").Context,
13
+ * }} opts
14
+ */
15
+ export async function serve({ input, write, ctx }) {
16
+ /** @param {object|null} res */
17
+ const send = (res) => {
18
+ // JSON.stringify never emits a raw newline, so every message stays one line.
19
+ if (res) write(`${JSON.stringify(res)}\n`);
20
+ };
21
+
22
+ /** @type {Promise<void>[]} */
23
+ const pending = [];
24
+ // readline reassembles messages split across chunks and splits chunks that
25
+ // carry several messages.
26
+ for await (const raw of createInterface({ input, crlfDelay: Infinity })) {
27
+ const text = raw.trim();
28
+ if (text === "") continue;
29
+ let msg;
30
+ try {
31
+ msg = JSON.parse(text);
32
+ } catch {
33
+ send(parseError());
34
+ continue;
35
+ }
36
+ pending.push(handleMessage(msg, ctx).then(send));
37
+ }
38
+ await Promise.all(pending);
39
+ }
@@ -0,0 +1,138 @@
1
+ import { mint } from "./mint.js";
2
+ import { shorten, DEFAULT_BASE_URL, SHORTENER_URL } from "./shorten.js";
3
+ import { JotviewError } from "./codec.js";
4
+
5
+ /** Every character of a /d link costs the user tokens twice — once handed
6
+ * back to the model, once written into its reply — so MCP shortens much
7
+ * sooner than the CLI's 2000. */
8
+ export const MCP_SHORTEN_THRESHOLD = 500;
9
+
10
+ const SHORTENER_HOST = new URL(SHORTENER_URL).host;
11
+ const TITLE_MAX = 80;
12
+ const MODES = ["auto", "always", "never"];
13
+
14
+ const DEFINITION = {
15
+ name: "render_markdown",
16
+ title: "Render markdown with jotbins",
17
+ description:
18
+ "Turn markdown (GitHub-flavored, with ```mermaid diagrams) into a link that " +
19
+ "renders it as a page on jotbins.com. Returns a ready-made markdown link to " +
20
+ "show the user. Small documents live entirely in the link; larger ones are " +
21
+ "uploaded to surl.jotbins.com for a short link that expires after 30 days " +
22
+ "unused unless keep is true.",
23
+ inputSchema: {
24
+ type: "object",
25
+ properties: {
26
+ content: { type: "string", description: "The markdown to render." },
27
+ keep: {
28
+ type: "boolean",
29
+ description: "Make a shortened link permanent instead of expiring after 30 days unused.",
30
+ },
31
+ shorten: {
32
+ type: "string",
33
+ enum: MODES,
34
+ description:
35
+ 'Default "auto" shortens long links. "never" keeps the document out of ' +
36
+ 'any upload (long link); "always" shortens even small ones.',
37
+ },
38
+ },
39
+ required: ["content"],
40
+ additionalProperties: false,
41
+ },
42
+ annotations: { readOnlyHint: true, openWorldHint: true },
43
+ };
44
+
45
+ /**
46
+ * The first markdown heading, as safe link text.
47
+ * @param {string} md
48
+ */
49
+ export function docTitle(md) {
50
+ 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}`);
53
+ }
54
+
55
+ /** @param {string} text */
56
+ const toolError = (text) => ({ content: [{ type: "text", text }], isError: true });
57
+
58
+ /**
59
+ * @param {Record<string, unknown>} args
60
+ * @returns {string|null} what's wrong, phrased for the model to fix
61
+ */
62
+ function validate(args) {
63
+ if (typeof args.content !== "string") return "content is required: pass the markdown to render as a string.";
64
+ if (args.content.trim() === "") return "content is empty: pass the markdown to render.";
65
+ if (args.keep !== undefined && typeof args.keep !== "boolean") return "keep must be true or false.";
66
+ if (args.shorten !== undefined && !MODES.includes(/** @type {string} */ (args.shorten))) {
67
+ return 'shorten must be "auto", "always" or "never".';
68
+ }
69
+ return null;
70
+ }
71
+
72
+ /**
73
+ * @param {import("./mint.js").MintResult} r
74
+ * @param {boolean} keepAsked
75
+ */
76
+ function describe(r, keepAsked) {
77
+ if (r.shortened) {
78
+ return r.permanent
79
+ ? `Short link: the document was uploaded to ${SHORTENER_HOST} and the link is permanent.`
80
+ : `Short link: the document was uploaded to ${SHORTENER_HOST}. It is deleted after 30 days ` +
81
+ "without a visit; call again with keep: true for a permanent link.";
82
+ }
83
+ const notes = [];
84
+ if (r.skipped === "uploads-off") {
85
+ notes.push("Full link: uploads are turned off for this server, so it was not shortened. Nothing was uploaded.");
86
+ } else if (r.skipped === "custom-base") {
87
+ notes.push(`Full link: only ${DEFAULT_BASE_URL} links can be shortened. Nothing was uploaded.`);
88
+ } else if (r.failure) {
89
+ notes.push(
90
+ `Full link: shortening failed (${r.failure}). The document may still have reached ${SHORTENER_HOST}.`,
91
+ );
92
+ } else {
93
+ notes.push("Full link: the document lives in the link itself; nothing was uploaded and it never expires.");
94
+ }
95
+ if (keepAsked) notes.push("keep has no effect here: full links never expire.");
96
+ return notes.join(" ");
97
+ }
98
+
99
+ /**
100
+ * @param {{
101
+ * allowUpload?: boolean,
102
+ * baseUrl?: string,
103
+ * shortenImpl?: typeof shorten,
104
+ * }} opts `allowUpload: false` is `jotview mcp --raw`
105
+ * @returns {import("./mcp.js").Tool}
106
+ */
107
+ export function renderTool(opts) {
108
+ return {
109
+ definition: DEFINITION,
110
+ async call(args) {
111
+ const problem = validate(args);
112
+ if (problem) return toolError(problem);
113
+
114
+ const md = /** @type {string} */ (args.content);
115
+ const keep = args.keep === true;
116
+ let r;
117
+ try {
118
+ r = await mint(md, {
119
+ baseUrl: opts.baseUrl,
120
+ shorten: /** @type {import("./mint.js").ShortenMode} */ (args.shorten ?? "auto"),
121
+ keep,
122
+ allowUpload: opts.allowUpload ?? true,
123
+ threshold: MCP_SHORTEN_THRESHOLD,
124
+ shortenImpl: opts.shortenImpl ?? shorten,
125
+ });
126
+ } catch (err) {
127
+ if (err instanceof JotviewError) return toolError(`${err.message}. Shorten or split the document.`);
128
+ throw err;
129
+ }
130
+
131
+ const text =
132
+ `[${docTitle(md)}](${r.url})\n\n` +
133
+ `${describe(r, keep && !r.shortened)}\n` +
134
+ "Show it to the user as the markdown link above, not as a bare URL.";
135
+ return { content: [{ type: "text", text }] };
136
+ },
137
+ };
138
+ }
package/src/mcp.js ADDED
@@ -0,0 +1,159 @@
1
+ /**
2
+ * MCP message handling — pure, no I/O. `mcp-stdio.js` owns the transport.
3
+ *
4
+ * jotview is a dual-era server. Clients on the handshake revisions open with
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
7
+ * both eras are served from the same handlers and only the envelope differs.
8
+ */
9
+
10
+ /** Newest first: `initialize` answers with the first one the client lacks. */
11
+ export const LEGACY_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
12
+ export const MODERN_VERSIONS = ["2026-07-28"];
13
+
14
+ export const INSTRUCTIONS =
15
+ "Use render_markdown to share markdown — including Mermaid diagrams — as a " +
16
+ "rendered jotbins page. Show the returned link to the user as a markdown " +
17
+ "link, [title](url); never paste a bare jotbins.com/d URL, it can be " +
18
+ "thousands of characters. Long documents are uploaded to shorten the link " +
19
+ 'unless the user asked to keep them local (shorten: "never").';
20
+
21
+ const META_VERSION = "io.modelcontextprotocol/protocolVersion";
22
+ const META_CAPABILITIES = "io.modelcontextprotocol/clientCapabilities";
23
+
24
+ const PARSE_ERROR = -32700;
25
+ const INVALID_REQUEST = -32600;
26
+ const METHOD_NOT_FOUND = -32601;
27
+ const INVALID_PARAMS = -32602;
28
+ const INTERNAL_ERROR = -32603;
29
+ const UNSUPPORTED_VERSION = -32022;
30
+
31
+ /**
32
+ * @typedef {object} Tool
33
+ * @property {{name: string} & Record<string, unknown>} definition as listed by tools/list
34
+ * @property {(args: Record<string, unknown>) => Promise<object>} call returns a CallToolResult
35
+ */
36
+
37
+ /**
38
+ * @typedef {object} Context
39
+ * @property {string} version jotview's own version, reported as serverInfo
40
+ * @property {Tool[]} tools
41
+ */
42
+
43
+ /**
44
+ * @param {string|number|null} id
45
+ * @param {number} code
46
+ * @param {string} message
47
+ * @param {unknown} [data]
48
+ */
49
+ export function errorResponse(id, code, message, data) {
50
+ return { jsonrpc: "2.0", id, error: { code, message, ...(data !== undefined && { data }) } };
51
+ }
52
+
53
+ /** For the transport: a line that wasn't JSON at all. */
54
+ export const parseError = () => errorResponse(null, PARSE_ERROR, "Parse error");
55
+
56
+ /** @param {Context} ctx */
57
+ const serverInfo = (ctx) => ({ name: "jotview", version: ctx.version });
58
+
59
+ /**
60
+ * Handle one parsed JSON-RPC message.
61
+ * @param {unknown} msg
62
+ * @param {Context} ctx
63
+ * @returns {Promise<object|null>} the response, or null when none is owed
64
+ */
65
+ export async function handleMessage(msg, ctx) {
66
+ if (Array.isArray(msg)) {
67
+ return errorResponse(null, INVALID_REQUEST, "Batches are not supported");
68
+ }
69
+ if (msg === null || typeof msg !== "object") {
70
+ return errorResponse(null, INVALID_REQUEST, "Invalid request");
71
+ }
72
+
73
+ const m = /** @type {Record<string, any>} */ (msg);
74
+ const hasId = "id" in m;
75
+ const id = hasId ? m.id : null;
76
+
77
+ // A response to something we sent. jotview never sends requests, so there
78
+ // is nothing to match it against.
79
+ if (typeof m.method !== "string" && hasId && ("result" in m || "error" in m)) return null;
80
+ if (m.jsonrpc !== "2.0" || typeof m.method !== "string") {
81
+ return hasId ? errorResponse(id, INVALID_REQUEST, "Invalid request") : null;
82
+ }
83
+ if (!hasId) return null; // notifications never get a reply
84
+
85
+ const params = m.params && typeof m.params === "object" ? m.params : {};
86
+ const meta = params._meta && typeof params._meta === "object" ? params._meta : {};
87
+ const modern = META_VERSION in meta;
88
+
89
+ if (modern) {
90
+ const requested = meta[META_VERSION];
91
+ if (!MODERN_VERSIONS.includes(requested)) {
92
+ return errorResponse(id, UNSUPPORTED_VERSION, "Unsupported protocol version", {
93
+ supported: MODERN_VERSIONS,
94
+ requested,
95
+ });
96
+ }
97
+ if (m.method !== "server/discover" && !(META_CAPABILITIES in meta)) {
98
+ return errorResponse(id, INVALID_PARAMS, `Missing _meta["${META_CAPABILITIES}"]`);
99
+ }
100
+ }
101
+
102
+ /** @param {object} result */
103
+ const ok = (result) => ({
104
+ jsonrpc: "2.0",
105
+ id,
106
+ result: modern ? { resultType: "complete", ...result } : result,
107
+ });
108
+
109
+ switch (m.method) {
110
+ case "initialize": {
111
+ const asked = params.protocolVersion;
112
+ return {
113
+ jsonrpc: "2.0",
114
+ id,
115
+ result: {
116
+ protocolVersion: LEGACY_VERSIONS.includes(asked) ? asked : LEGACY_VERSIONS[0],
117
+ capabilities: { tools: { listChanged: false } },
118
+ serverInfo: serverInfo(ctx),
119
+ instructions: INSTRUCTIONS,
120
+ },
121
+ };
122
+ }
123
+
124
+ case "server/discover":
125
+ return {
126
+ jsonrpc: "2.0",
127
+ id,
128
+ result: {
129
+ resultType: "complete",
130
+ supportedVersions: MODERN_VERSIONS,
131
+ capabilities: { tools: {} },
132
+ _meta: { "io.modelcontextprotocol/serverInfo": serverInfo(ctx) },
133
+ instructions: INSTRUCTIONS,
134
+ ttlMs: 60 * 60 * 1000,
135
+ cacheScope: "public",
136
+ },
137
+ };
138
+
139
+ case "ping":
140
+ return ok({});
141
+
142
+ case "tools/list":
143
+ return ok({ tools: ctx.tools.map((t) => t.definition) });
144
+
145
+ case "tools/call": {
146
+ const tool = ctx.tools.find((t) => t.definition.name === params.name);
147
+ if (!tool) return errorResponse(id, INVALID_PARAMS, `Unknown tool: ${params.name}`);
148
+ const args = params.arguments && typeof params.arguments === "object" ? params.arguments : {};
149
+ try {
150
+ return ok(await tool.call(args));
151
+ } catch (err) {
152
+ return errorResponse(id, INTERNAL_ERROR, /** @type {Error} */ (err).message);
153
+ }
154
+ }
155
+
156
+ default:
157
+ return errorResponse(id, METHOD_NOT_FOUND, `Method not found: ${m.method}`);
158
+ }
159
+ }
package/src/mint.js ADDED
@@ -0,0 +1,62 @@
1
+ import { encode, JotviewError } from "./codec.js";
2
+ import { shorten, DEFAULT_BASE_URL, SHORTEN_THRESHOLD } from "./shorten.js";
3
+
4
+ /** @typedef {"auto" | "always" | "never"} ShortenMode */
5
+
6
+ /**
7
+ * @typedef {object} MintResult
8
+ * @property {string} url what to hand the user — short when shortened
9
+ * @property {string} rawUrl the full /d link, always
10
+ * @property {boolean} shortened
11
+ * @property {boolean} permanent only true for a shortened link made with `keep`
12
+ * @property {string|null} failure the shortener's error, when it was tried and failed
13
+ * @property {"custom-base"|"uploads-off"|null} skipped
14
+ * why a link that would have been shortened was not
15
+ */
16
+
17
+ /**
18
+ * Turn markdown into a jotbins link, shortening it through surl when the
19
+ * mode and size call for it. Shared by the CLI and the MCP server so both
20
+ * make the same decision; each words the outcome for its own audience.
21
+ *
22
+ * @param {string} md
23
+ * @param {{
24
+ * baseUrl?: string,
25
+ * shorten?: ShortenMode,
26
+ * keep?: boolean,
27
+ * allowUpload?: boolean,
28
+ * threshold?: number,
29
+ * shortenImpl?: typeof shorten,
30
+ * }} [opts]
31
+ * @returns {Promise<MintResult>}
32
+ */
33
+ export async function mint(md, opts = {}) {
34
+ const {
35
+ baseUrl = DEFAULT_BASE_URL,
36
+ shorten: mode = "auto",
37
+ keep = false,
38
+ allowUpload = true,
39
+ threshold = SHORTEN_THRESHOLD,
40
+ shortenImpl = shorten,
41
+ } = opts;
42
+
43
+ if (md.trim() === "") throw new JotviewError("empty", "input is empty");
44
+ const rawUrl = `${baseUrl}/d#${encode(md)}`;
45
+
46
+ /** @type {MintResult} */
47
+ const result = {
48
+ url: rawUrl, rawUrl, shortened: false, permanent: false, failure: null, skipped: null,
49
+ };
50
+
51
+ const wanted = mode === "always" || (mode === "auto" && rawUrl.length > threshold);
52
+ if (!wanted) return result;
53
+
54
+ // surl's allowlist accepts only jotbins.com hosts, so a custom base URL can
55
+ // never be shortened. Skip rather than fire a request guaranteed to 400.
56
+ if (baseUrl !== DEFAULT_BASE_URL) return { ...result, skipped: "custom-base" };
57
+ if (!allowUpload) return { ...result, skipped: "uploads-off" };
58
+
59
+ const outcome = await shortenImpl(rawUrl, { permanent: keep });
60
+ if (!outcome.ok) return { ...result, failure: outcome.reason };
61
+ return { ...result, url: outcome.short, shortened: true, permanent: keep };
62
+ }