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