jotview 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 +81 -0
- package/bin/jotview.js +34 -0
- package/package.json +15 -0
- package/src/cli.js +214 -0
- package/src/codec.js +42 -0
- package/src/platform.js +72 -0
- package/src/shorten.js +58 -0
- package/vectors.json +10 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mark Kevin Besinga
|
|
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,81 @@
|
|
|
1
|
+
# jotview
|
|
2
|
+
|
|
3
|
+
Turn a markdown file into a [jotbins](https://jotbins.com) link that renders it.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx jotview notes.md
|
|
7
|
+
# https://jotbins.com/d#z:U1bwSM3JyecCAA
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Zero dependencies. No install, no account, no config.
|
|
11
|
+
|
|
12
|
+
## How it works
|
|
13
|
+
|
|
14
|
+
The document is compressed and packed into the URL's **fragment** — the part
|
|
15
|
+
after `#`. Fragments are never sent to a server, so for links 2000 characters
|
|
16
|
+
or fewer nothing you write leaves your machine. Opening the link renders it
|
|
17
|
+
entirely in the browser.
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
jotview <file> mint a link from a file
|
|
23
|
+
jotview read markdown from stdin
|
|
24
|
+
|
|
25
|
+
--raw never shorten; always the full /d URL
|
|
26
|
+
--short always shorten, even when small
|
|
27
|
+
--open open the link in your browser
|
|
28
|
+
--copy copy the link to your clipboard
|
|
29
|
+
--base-url <url> override https://jotbins.com
|
|
30
|
+
-h, --help
|
|
31
|
+
-v, --version
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Examples:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
jotview README.md --open
|
|
38
|
+
cat notes.md | jotview
|
|
39
|
+
jotview notes.md | pbcopy
|
|
40
|
+
LINK=$(jotview notes.md --raw)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Shortening, and what it costs you
|
|
44
|
+
|
|
45
|
+
A document's link grows with the document — roughly half the markdown's size in
|
|
46
|
+
characters. A 20 KB file produces an ~8,700-character URL, which Discord, X, and
|
|
47
|
+
most chat clients will not accept.
|
|
48
|
+
|
|
49
|
+
So links over **2000 characters are shortened** via `surl.jotbins.com`, and
|
|
50
|
+
jotview tells you on stderr when that happens:
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
note: uploaded to jotbins to shorten (8768 → 30 chars). use --raw to keep it local.
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**Shortening uploads your document.** The fragment *is* the markdown, so storing
|
|
57
|
+
the URL stores the text. Below the threshold nothing is uploaded and no network
|
|
58
|
+
request is made at all. Use `--raw` to keep a long document local, accepting a
|
|
59
|
+
URL too long to paste into most places.
|
|
60
|
+
|
|
61
|
+
If the shortener is unreachable or rate-limited, jotview prints the full link
|
|
62
|
+
and exits 0 — it never fails your pipeline over a shortening problem.
|
|
63
|
+
|
|
64
|
+
## Output contract
|
|
65
|
+
|
|
66
|
+
**stdout is the URL and nothing else.** Notices, warnings, and errors go to
|
|
67
|
+
stderr, so `$(jotview x.md)` and `jotview x.md | pbcopy` stay clean.
|
|
68
|
+
|
|
69
|
+
Exit codes: `0` success, `1` runtime error, `2` usage error.
|
|
70
|
+
|
|
71
|
+
## Limits
|
|
72
|
+
|
|
73
|
+
Input is capped at 1 MB, matching what the `/d` renderer will open.
|
|
74
|
+
|
|
75
|
+
## Requirements
|
|
76
|
+
|
|
77
|
+
Node 18 or newer.
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
MIT
|
package/bin/jotview.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { run } from "../src/cli.js";
|
|
4
|
+
|
|
5
|
+
const MIN_MAJOR = 18;
|
|
6
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
7
|
+
if (major < MIN_MAJOR) {
|
|
8
|
+
process.stderr.write(
|
|
9
|
+
`jotview requires Node ${MIN_MAJOR} or newer (found ${process.versions.node}).\n`,
|
|
10
|
+
);
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const { version } = JSON.parse(
|
|
15
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
// A downstream reader that exits early (`| head`, `| less` then `q`) closes
|
|
19
|
+
// its end of the pipe. If the URL is still being flushed when that happens,
|
|
20
|
+
// Node's default behavior is to throw on the stream's next write, crashing
|
|
21
|
+
// the process after the URL was already delivered successfully. Treat EPIPE
|
|
22
|
+
// as the benign "reader stopped listening" signal it is.
|
|
23
|
+
process.stdout.on("error", (err) => {
|
|
24
|
+
if (err.code === "EPIPE") process.exit(0);
|
|
25
|
+
throw err;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
process.exitCode = await run(process.argv.slice(2), {
|
|
29
|
+
stdout: (s) => process.stdout.write(s),
|
|
30
|
+
stderr: (s) => process.stderr.write(s),
|
|
31
|
+
stdin: process.stdin,
|
|
32
|
+
isTTY: Boolean(process.stdin.isTTY),
|
|
33
|
+
version,
|
|
34
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "jotview",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Turn a markdown file into a jotbins /d link. Zero dependencies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "jotview": "bin/jotview.js" },
|
|
7
|
+
"files": ["bin", "src", "vectors.json", "README.md"],
|
|
8
|
+
"engines": { "node": ">=18" },
|
|
9
|
+
"scripts": { "test": "node --test" },
|
|
10
|
+
"keywords": ["markdown", "jotbins", "cli", "share"],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"repository": { "type": "git", "url": "git+https://github.com/jotbins/jotview.git" },
|
|
13
|
+
"homepage": "https://github.com/jotbins/jotview#readme",
|
|
14
|
+
"bugs": { "url": "https://github.com/jotbins/jotview/issues" }
|
|
15
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
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";
|
|
6
|
+
import { openUrl, copyText } from "./platform.js";
|
|
7
|
+
export { DEFAULT_BASE_URL };
|
|
8
|
+
|
|
9
|
+
/** Host the failed-shorten disclosure points to — derived from SHORTENER_URL
|
|
10
|
+
* so the two never drift apart. */
|
|
11
|
+
const SHORTENER_HOST = new URL(SHORTENER_URL).host;
|
|
12
|
+
|
|
13
|
+
export const HELP = `jotview — turn markdown into a jotbins /d link
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
jotview <file> mint a link from a file
|
|
17
|
+
jotview read markdown from stdin
|
|
18
|
+
|
|
19
|
+
Options:
|
|
20
|
+
--raw never shorten; always the full /d URL
|
|
21
|
+
--short always shorten, even when small
|
|
22
|
+
--open open the link in your browser
|
|
23
|
+
--copy copy the link to your clipboard
|
|
24
|
+
--base-url <url> override ${DEFAULT_BASE_URL}
|
|
25
|
+
-h, --help show this help
|
|
26
|
+
-v, --version show the version
|
|
27
|
+
|
|
28
|
+
Links 2000 characters or fewer are printed as-is and never leave your machine.
|
|
29
|
+
Longer ones are shortened via surl.jotbins.com, which uploads the document.
|
|
30
|
+
Use --raw to keep it local regardless of size.
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @typedef {object} Options
|
|
35
|
+
* @property {string|null} file
|
|
36
|
+
* @property {boolean} raw
|
|
37
|
+
* @property {boolean} short
|
|
38
|
+
* @property {boolean} open
|
|
39
|
+
* @property {boolean} copy
|
|
40
|
+
* @property {string} baseUrl
|
|
41
|
+
* @property {boolean} help
|
|
42
|
+
* @property {boolean} version
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {string[]} argv
|
|
47
|
+
* @returns {Options}
|
|
48
|
+
*/
|
|
49
|
+
export function parseArgs(argv) {
|
|
50
|
+
/** @type {Options} */
|
|
51
|
+
const opts = {
|
|
52
|
+
file: null, raw: false, short: false, open: false, copy: false,
|
|
53
|
+
baseUrl: DEFAULT_BASE_URL, help: false, version: false,
|
|
54
|
+
};
|
|
55
|
+
for (let i = 0; i < argv.length; i++) {
|
|
56
|
+
const arg = argv[i];
|
|
57
|
+
switch (arg) {
|
|
58
|
+
case "--raw": opts.raw = true; break;
|
|
59
|
+
case "--short": opts.short = true; break;
|
|
60
|
+
case "--open": opts.open = true; break;
|
|
61
|
+
case "--copy": opts.copy = true; break;
|
|
62
|
+
case "-h": case "--help": opts.help = true; break;
|
|
63
|
+
case "-v": case "--version": opts.version = true; break;
|
|
64
|
+
case "--base-url": {
|
|
65
|
+
const value = argv[++i];
|
|
66
|
+
if (
|
|
67
|
+
value === undefined || value.startsWith("-") || !/^https?:\/\//.test(value)
|
|
68
|
+
) {
|
|
69
|
+
throw new JotviewError(
|
|
70
|
+
"usage",
|
|
71
|
+
"--base-url needs an absolute http:// or https:// URL",
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
opts.baseUrl = value.replace(/\/+$/, "");
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
default:
|
|
78
|
+
if (arg.startsWith("-")) throw new JotviewError("usage", `unknown flag: ${arg}`);
|
|
79
|
+
if (opts.file !== null) throw new JotviewError("usage", "pass at most one file");
|
|
80
|
+
opts.file = arg;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (opts.raw && opts.short) {
|
|
84
|
+
throw new JotviewError("usage", "--raw and --short cannot be combined");
|
|
85
|
+
}
|
|
86
|
+
if (opts.short && opts.baseUrl !== DEFAULT_BASE_URL) {
|
|
87
|
+
throw new JotviewError("usage", `--short only works with ${DEFAULT_BASE_URL}`);
|
|
88
|
+
}
|
|
89
|
+
return opts;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** @param {import("node:stream").Readable} stdin */
|
|
93
|
+
async function readStdin(stdin) {
|
|
94
|
+
/** @type {Buffer[]} */
|
|
95
|
+
const chunks = [];
|
|
96
|
+
let total = 0;
|
|
97
|
+
for await (const chunk of stdin) {
|
|
98
|
+
total += chunk.length;
|
|
99
|
+
// Check before buffering the chunk so a runaway pipe fails fast instead
|
|
100
|
+
// of accumulating unbounded memory on its way to the same rejection
|
|
101
|
+
// `encode()` would eventually throw anyway.
|
|
102
|
+
if (total > MAX_DOC_BYTES) {
|
|
103
|
+
throw new JotviewError(
|
|
104
|
+
"too-large",
|
|
105
|
+
`document is at least ${total} bytes; the limit is ${MAX_DOC_BYTES}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
chunks.push(chunk);
|
|
109
|
+
}
|
|
110
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @param {Options} opts
|
|
115
|
+
* @param {{stdin: import("node:stream").Readable}} io
|
|
116
|
+
*/
|
|
117
|
+
async function readInput(opts, io) {
|
|
118
|
+
if (opts.file === null) return readStdin(io.stdin);
|
|
119
|
+
try {
|
|
120
|
+
return readFileSync(opts.file, "utf8");
|
|
121
|
+
} catch (err) {
|
|
122
|
+
const reason = /** @type {NodeJS.ErrnoException} */ (err).code === "ENOENT"
|
|
123
|
+
? "no such file"
|
|
124
|
+
: /** @type {Error} */ (err).message;
|
|
125
|
+
throw new JotviewError("read-failed", `cannot read ${opts.file}: ${reason}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Side effects that must never change the exit code — the URL is already on
|
|
131
|
+
* stdout, so a missing `xclip` is a warning, not a failure.
|
|
132
|
+
* @param {string} url
|
|
133
|
+
* @param {Options} opts
|
|
134
|
+
* @param {{stderr: (s: string) => void, spawnImpl?: Function}} io
|
|
135
|
+
*/
|
|
136
|
+
function applySideEffects(url, opts, io) {
|
|
137
|
+
if (opts.copy) {
|
|
138
|
+
const result = copyText(url, { spawnImpl: io.spawnImpl });
|
|
139
|
+
if (!result.ok) io.stderr(`note: could not copy (${result.reason}).\n`);
|
|
140
|
+
}
|
|
141
|
+
if (opts.open) {
|
|
142
|
+
const result = openUrl(url, { spawnImpl: io.spawnImpl });
|
|
143
|
+
if (!result.ok) io.stderr(`note: could not open a browser (${result.reason}).\n`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* @param {string[]} argv
|
|
149
|
+
* @param {{stdout: (s: string) => void, stderr: (s: string) => void,
|
|
150
|
+
* stdin: import("node:stream").Readable, isTTY: boolean,
|
|
151
|
+
* version: string, spawnImpl?: Function}} io
|
|
152
|
+
* `spawnImpl` is optional and test-only — it is threaded down to
|
|
153
|
+
* `openUrl`/`copyText` so tests can assert on the command that would have
|
|
154
|
+
* been spawned without touching a real browser or clipboard. `bin/jotview.js`
|
|
155
|
+
* never sets it, so real usage is unaffected.
|
|
156
|
+
* @returns {Promise<number>} exit code
|
|
157
|
+
*/
|
|
158
|
+
export async function run(argv, io) {
|
|
159
|
+
/** @type {Options} */
|
|
160
|
+
let opts;
|
|
161
|
+
try {
|
|
162
|
+
opts = parseArgs(argv);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
io.stderr(`jotview: ${/** @type {Error} */ (err).message}\n\n${HELP}`);
|
|
165
|
+
return 2;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (opts.help) { io.stdout(HELP); return 0; }
|
|
169
|
+
if (opts.version) { io.stdout(`${io.version}\n`); return 0; }
|
|
170
|
+
|
|
171
|
+
// Nothing piped in and no file named: block forever, or help. Help.
|
|
172
|
+
if (opts.file === null && io.isTTY) { io.stdout(HELP); return 0; }
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
const md = await readInput(opts, io);
|
|
176
|
+
if (md.trim() === "") throw new JotviewError("empty", "input is empty");
|
|
177
|
+
const rawUrl = `${opts.baseUrl}/d#${encode(md)}`;
|
|
178
|
+
|
|
179
|
+
if (!shouldShorten(rawUrl, opts)) {
|
|
180
|
+
// Over the threshold, but the host isn't the shortenable default —
|
|
181
|
+
// don't nag about it on documents that were never going to need it.
|
|
182
|
+
if (opts.baseUrl !== DEFAULT_BASE_URL && rawUrl.length > SHORTEN_THRESHOLD) {
|
|
183
|
+
io.stderr(
|
|
184
|
+
`note: shortening skipped — only ${DEFAULT_BASE_URL} links can be ` +
|
|
185
|
+
"shortened. using the full link.\n",
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
io.stdout(`${rawUrl}\n`);
|
|
189
|
+
applySideEffects(rawUrl, opts, io);
|
|
190
|
+
return 0;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const result = await shorten(rawUrl);
|
|
194
|
+
let printed = rawUrl;
|
|
195
|
+
if (result.ok) {
|
|
196
|
+
printed = result.short;
|
|
197
|
+
io.stderr(
|
|
198
|
+
`note: uploaded to jotbins to shorten (${rawUrl.length} → ` +
|
|
199
|
+
`${printed.length} chars). use --raw to keep it local.\n`,
|
|
200
|
+
);
|
|
201
|
+
} else {
|
|
202
|
+
io.stderr(
|
|
203
|
+
`note: upload to the shortener failed (${result.reason}); using the full ` +
|
|
204
|
+
`link. the document may still have reached ${SHORTENER_HOST}.\n`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
io.stdout(`${printed}\n`);
|
|
208
|
+
applySideEffects(printed, opts, io);
|
|
209
|
+
return 0;
|
|
210
|
+
} catch (err) {
|
|
211
|
+
io.stderr(`jotview: ${/** @type {Error} */ (err).message}\n`);
|
|
212
|
+
return 1;
|
|
213
|
+
}
|
|
214
|
+
}
|
package/src/codec.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { deflateRawSync } from "node:zlib";
|
|
2
|
+
|
|
3
|
+
/** Payload prefix. Versions the encoding; must match jotbins' fragmentDoc.ts. */
|
|
4
|
+
export const PREFIX = "z:";
|
|
5
|
+
|
|
6
|
+
/** Mirrors MAX_DOC_BYTES in jotbins' fragmentDoc.ts. Larger docs mint links
|
|
7
|
+
* the browser refuses to open, so reject at encode time instead. */
|
|
8
|
+
export const MAX_DOC_BYTES = 1_000_000;
|
|
9
|
+
|
|
10
|
+
/** @typedef {"too-large" | "empty" | "not-found" | "usage" | "read-failed"} ErrorCode */
|
|
11
|
+
|
|
12
|
+
export class JotviewError extends Error {
|
|
13
|
+
/** @param {ErrorCode} code @param {string} message */
|
|
14
|
+
constructor(code, message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "JotviewError";
|
|
17
|
+
this.code = code;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** @param {Buffer} buf */
|
|
22
|
+
function toBase64Url(buf) {
|
|
23
|
+
return buf.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Encode markdown into a `z:` fragment payload.
|
|
28
|
+
* @param {string} md
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
export function encode(md) {
|
|
32
|
+
const bytes = Buffer.from(md, "utf8");
|
|
33
|
+
if (bytes.length > MAX_DOC_BYTES) {
|
|
34
|
+
throw new JotviewError(
|
|
35
|
+
"too-large",
|
|
36
|
+
`document is ${bytes.length} bytes; the limit is ${MAX_DOC_BYTES}`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
// No `level` option: stay on zlib's default so output matches what browsers
|
|
40
|
+
// produce via CompressionStream("deflate-raw").
|
|
41
|
+
return PREFIX + toBase64Url(deflateRawSync(bytes));
|
|
42
|
+
}
|
package/src/platform.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {string} platform a `process.platform` value
|
|
5
|
+
* @returns {{cmd: string, args: string[]}}
|
|
6
|
+
*/
|
|
7
|
+
export function openCommand(platform) {
|
|
8
|
+
switch (platform) {
|
|
9
|
+
case "darwin": return { cmd: "open", args: [] };
|
|
10
|
+
case "linux": return { cmd: "xdg-open", args: [] };
|
|
11
|
+
// The empty string is `start`'s title argument; without it a URL
|
|
12
|
+
// containing spaces or quotes is treated as the window title.
|
|
13
|
+
case "win32": return { cmd: "cmd", args: ["/c", "start", ""] };
|
|
14
|
+
default: throw new Error(`unsupported platform: ${platform}`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {string} platform
|
|
20
|
+
* @returns {{cmd: string, args: string[]}}
|
|
21
|
+
*/
|
|
22
|
+
export function copyCommand(platform) {
|
|
23
|
+
switch (platform) {
|
|
24
|
+
case "darwin": return { cmd: "pbcopy", args: [] };
|
|
25
|
+
case "win32": return { cmd: "clip", args: [] };
|
|
26
|
+
case "linux": return { cmd: "xclip", args: ["-selection", "clipboard"] };
|
|
27
|
+
default: throw new Error(`unsupported platform: ${platform}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {{status: number|null, error?: Error}} result
|
|
33
|
+
* @param {string} cmd
|
|
34
|
+
* @returns {{ok: boolean, reason?: string}}
|
|
35
|
+
*/
|
|
36
|
+
function outcome(result, cmd) {
|
|
37
|
+
if (result.error) return { ok: false, reason: result.error.message };
|
|
38
|
+
if (result.status !== 0) return { ok: false, reason: `${cmd} exited with ${result.status}` };
|
|
39
|
+
return { ok: true };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {string} url
|
|
44
|
+
* @param {{platform?: string, spawnImpl?: typeof spawnSync}} [options]
|
|
45
|
+
* @returns {{ok: boolean, reason?: string}}
|
|
46
|
+
*/
|
|
47
|
+
export function openUrl(url, options = {}) {
|
|
48
|
+
const platform = options.platform ?? process.platform;
|
|
49
|
+
const run = options.spawnImpl ?? spawnSync;
|
|
50
|
+
try {
|
|
51
|
+
const { cmd, args } = openCommand(platform);
|
|
52
|
+
return outcome(run(cmd, [...args, url]), cmd);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
return { ok: false, reason: /** @type {Error} */ (err).message };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {string} text
|
|
60
|
+
* @param {{platform?: string, spawnImpl?: typeof spawnSync}} [options]
|
|
61
|
+
* @returns {{ok: boolean, reason?: string}}
|
|
62
|
+
*/
|
|
63
|
+
export function copyText(text, options = {}) {
|
|
64
|
+
const platform = options.platform ?? process.platform;
|
|
65
|
+
const run = options.spawnImpl ?? spawnSync;
|
|
66
|
+
try {
|
|
67
|
+
const { cmd, args } = copyCommand(platform);
|
|
68
|
+
return outcome(run(cmd, args, { input: text }), cmd);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
return { ok: false, reason: /** @type {Error} */ (err).message };
|
|
71
|
+
}
|
|
72
|
+
}
|
package/src/shorten.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Canonical jotbins host. Lives here rather than in cli.js so the two modules
|
|
2
|
+
* do not import each other: shortenability is this module's concern. */
|
|
3
|
+
export const DEFAULT_BASE_URL = "https://jotbins.com";
|
|
4
|
+
|
|
5
|
+
/** Where chat clients start refusing content — Discord's hard message cap.
|
|
6
|
+
* Not a server-side limit: the fragment never reaches a server. */
|
|
7
|
+
export const SHORTEN_THRESHOLD = 2000;
|
|
8
|
+
|
|
9
|
+
export const SHORTENER_URL = "https://surl.jotbins.com/api/create";
|
|
10
|
+
|
|
11
|
+
/** Milliseconds before giving up and emitting the raw URL instead. */
|
|
12
|
+
const TIMEOUT_MS = 5000;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} url
|
|
16
|
+
* @param {{raw: boolean, short: boolean, baseUrl: string}} opts
|
|
17
|
+
* @returns {boolean}
|
|
18
|
+
*/
|
|
19
|
+
export function shouldShorten(url, opts) {
|
|
20
|
+
if (opts.raw) return false;
|
|
21
|
+
// surl's allowlist accepts only jotbins.com hosts, so a custom base URL can
|
|
22
|
+
// never be shortened. Skip rather than fire a request guaranteed to 400.
|
|
23
|
+
if (opts.baseUrl !== DEFAULT_BASE_URL) return false;
|
|
24
|
+
if (opts.short) return true;
|
|
25
|
+
return url.length > SHORTEN_THRESHOLD;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Shorten a URL. Never throws — the caller falls back to the raw URL.
|
|
30
|
+
* @param {string} url
|
|
31
|
+
* @param {{endpoint?: string}} [options]
|
|
32
|
+
* @returns {Promise<{ok: true, short: string} | {ok: false, reason: string}>}
|
|
33
|
+
*/
|
|
34
|
+
export async function shorten(url, options = {}) {
|
|
35
|
+
const endpoint = options.endpoint ?? SHORTENER_URL;
|
|
36
|
+
try {
|
|
37
|
+
const response = await fetch(endpoint, {
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: { "content-type": "application/json" },
|
|
40
|
+
body: JSON.stringify({ url }),
|
|
41
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
42
|
+
});
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
return { ok: false, reason: `shortener returned ${response.status}` };
|
|
45
|
+
}
|
|
46
|
+
const data = await response.json();
|
|
47
|
+
// The value ends up on stdout and, with --open, as an argv element handed
|
|
48
|
+
// to `spawnSync("open", [value])`. Requiring an https:// scheme rules out
|
|
49
|
+
// a misbehaving or compromised shortener returning something like "-a" or
|
|
50
|
+
// a `javascript:` URL that a browser or shell would treat specially.
|
|
51
|
+
if (typeof data?.short_url !== "string" || !data.short_url.startsWith("https://")) {
|
|
52
|
+
return { ok: false, reason: "shortener returned an unexpected response" };
|
|
53
|
+
}
|
|
54
|
+
return { ok: true, short: data.short_url };
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return { ok: false, reason: /** @type {Error} */ (err).message };
|
|
57
|
+
}
|
|
58
|
+
}
|
package/vectors.json
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"note": "Shared with jotbins (src/lib/fragmentDoc.ts). Each entry pins a payload and the markdown it must decode to. Assert DECODE, not encode: DEFLATE is not canonical, so two conforming encoders may emit different valid byte streams for the same input.",
|
|
3
|
+
"vectors": [
|
|
4
|
+
{ "name": "empty", "markdown": "", "payload": "z:AwA" },
|
|
5
|
+
{ "name": "ascii", "markdown": "# Hello\n", "payload": "z:U1bwSM3JyecCAA" },
|
|
6
|
+
{ "name": "unicode", "markdown": "# 日本語 — café 🎉\n", "payload": "z:ARsA5P8jIOaXpeacrOiqniDigJQgY2Fmw6kg8J-OiQo" },
|
|
7
|
+
{ "name": "mermaid-fence", "markdown": "```mermaid\ngraph TD;\nA-->B;\n```\n", "payload": "z:S0hIyE0tyk3MTOFKL0osyFAIcbHmctTVtXOy5kpISOACAA" },
|
|
8
|
+
{ "name": "multiline", "markdown": "# Title\n\nPara one.\n\n- a\n- b\n", "payload": "z:U1YIySzJSeXiCkgsSlTIz0vV4-LSVUjk0lVI4gIA" }
|
|
9
|
+
]
|
|
10
|
+
}
|