jotbird 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 JotBird LLC
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,94 @@
1
+ # JotBird
2
+
3
+ Publish Markdown from the command line. Three commands to go from file to shareable link.
4
+
5
+ ```
6
+ $ jotbird publish README.md
7
+ Published: https://share.jotbird.com/bright-calm-meadow
8
+ ```
9
+
10
+ Every published page gets a responsive URL — no ads, no tracking, no clutter, just your content. Unlike gists, pastebins, and wikis, JotBird links are readable, unlisted, and designed to be shared — not browsed. Noindex by default.
11
+
12
+ ## Install
13
+
14
+ Requires Node.js 18+.
15
+
16
+ ```bash
17
+ npm install -g jotbird
18
+ ```
19
+
20
+ ## Quick start
21
+
22
+ ```bash
23
+ # 1. Log in (one-time setup)
24
+ jotbird login
25
+
26
+ # 2. Publish a file
27
+ jotbird publish notes.md
28
+
29
+ # 3. Update it — same URL, fresh content
30
+ jotbird publish notes.md
31
+ ```
32
+
33
+ ## Commands
34
+
35
+ | Command | Description |
36
+ |---------|-------------|
37
+ | `jotbird login` | Authenticate with your JotBird account |
38
+ | `jotbird publish <file>` | Publish or update a Markdown file |
39
+ | `jotbird publish` | Read Markdown from stdin |
40
+ | `jotbird list` | List your published documents (also visible in the [web app](https://www.jotbird.com/app) as read-only) |
41
+ | `jotbird unpublish <file\|slug>` | Take down the public URL (keeps document in account) |
42
+ | `jotbird remove <file\|slug>` | Permanently delete a document |
43
+ | `jotbird help` | Show help |
44
+
45
+ ## How it works
46
+
47
+ The CLI tracks file-to-slug mappings in a `.jotbird` file in your working directory. When you publish the same file again, it updates the existing document at the same URL.
48
+
49
+ ```bash
50
+ $ jotbird publish README.md
51
+ Published: https://share.jotbird.com/bright-calm-meadow
52
+
53
+ $ jotbird publish README.md
54
+ Updated: https://share.jotbird.com/bright-calm-meadow
55
+ ```
56
+
57
+ Pipe from stdin for scripts and CI:
58
+
59
+ ```bash
60
+ cat notes.md | jotbird publish
61
+ echo "# Hello" | jotbird publish
62
+ ```
63
+
64
+ When publishing from stdin, no file mapping is created — each publish creates a new document.
65
+
66
+ ## Authentication
67
+
68
+ Run `jotbird login` to open your browser and authenticate. The CLI will automatically receive your API key once you sign in — no copy-pasting required. If the browser doesn't open, the CLI displays a URL to visit manually and falls back to a paste prompt.
69
+
70
+ The key is stored locally at `~/.config/jotbird/credentials` with `0600` permissions.
71
+
72
+ ## Image uploads
73
+
74
+ Image uploads are not supported. Markdown image references (e.g. `![alt](url)`) will render only if they point to externally-hosted images.
75
+
76
+ ## Free vs Pro
77
+
78
+ | | Free | Pro |
79
+ |---|---|---|
80
+ | Published links | 90 days expiration | Permanent |
81
+ | Active documents | 10 | Unlimited |
82
+ | Rate limit | 10 publishes/hour | 100 publishes/hour |
83
+
84
+ Upgrade at [jotbird.com/pro](https://www.jotbird.com/pro).
85
+
86
+ ## Links
87
+
88
+ - [JotBird](https://www.jotbird.com/)
89
+ - [CLI docs](https://www.jotbird.com/cli)
90
+ - [API docs](https://www.jotbird.com/docs/api)
91
+
92
+ ## License
93
+
94
+ MIT
package/dist/api.js ADDED
@@ -0,0 +1,91 @@
1
+ import { getApiKey, API_BASE } from "./config.js";
2
+
3
+ /**
4
+ * Make an authenticated API request to the JotBird CLI API.
5
+ */
6
+ async function apiRequest(path, body = null) {
7
+ const apiKey = getApiKey();
8
+ if (!apiKey) {
9
+ throw new Error("Not logged in. Run `jotbird login` first.");
10
+ }
11
+
12
+ const resp = await fetch(`${API_BASE}${path}`, {
13
+ method: "POST",
14
+ headers: {
15
+ "Content-Type": "application/json",
16
+ Authorization: `Bearer ${apiKey}`,
17
+ },
18
+ body: body ? JSON.stringify(body) : "{}",
19
+ });
20
+
21
+ const data = await resp.json().catch(() => null);
22
+
23
+ if (!resp.ok) {
24
+ const msg = data?.error || `HTTP ${resp.status}`;
25
+ throw new Error(msg);
26
+ }
27
+
28
+ return data;
29
+ }
30
+
31
+ /**
32
+ * Publish or update a document.
33
+ */
34
+ export async function publish({ markdown, title, slug }) {
35
+ const body = { markdown };
36
+ if (title) body.title = title;
37
+ if (slug) body.slug = slug;
38
+ return apiRequest("/api/v1/publish", body);
39
+ }
40
+
41
+ /**
42
+ * List all published documents.
43
+ */
44
+ export async function listDocuments() {
45
+ const apiKey = getApiKey();
46
+ if (!apiKey) {
47
+ throw new Error("Not logged in. Run `jotbird login` first.");
48
+ }
49
+
50
+ const resp = await fetch(`${API_BASE}/api/v1/documents`, {
51
+ method: "GET",
52
+ headers: {
53
+ Authorization: `Bearer ${apiKey}`,
54
+ },
55
+ });
56
+
57
+ const data = await resp.json().catch(() => null);
58
+
59
+ if (!resp.ok) {
60
+ const msg = data?.error || `HTTP ${resp.status}`;
61
+ throw new Error(msg);
62
+ }
63
+
64
+ return data;
65
+ }
66
+
67
+ /**
68
+ * Permanently remove a document (deletes from database and public URL).
69
+ */
70
+ export async function removeDocument(slug) {
71
+ const apiKey = getApiKey();
72
+ if (!apiKey) {
73
+ throw new Error("Not logged in. Run `jotbird login` first.");
74
+ }
75
+
76
+ const resp = await fetch(`${API_BASE}/api/v1/documents?slug=${encodeURIComponent(slug)}`, {
77
+ method: "DELETE",
78
+ headers: {
79
+ Authorization: `Bearer ${apiKey}`,
80
+ },
81
+ });
82
+
83
+ const data = await resp.json().catch(() => null);
84
+
85
+ if (!resp.ok) {
86
+ const msg = data?.error || `HTTP ${resp.status}`;
87
+ throw new Error(msg);
88
+ }
89
+
90
+ return data;
91
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,321 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync } from "node:fs";
4
+ import { basename } from "node:path";
5
+ import { createInterface } from "node:readline";
6
+ import { getApiKey, saveApiKey, getCredentialsPath, API_BASE } from "./config.js";
7
+ import { publish, listDocuments, removeDocument } from "./api.js";
8
+ import { readMappings, setMapping, removeMapping } from "./mapping.js";
9
+ import { startCallbackServer, openBrowser } from "./login.js";
10
+ import { ALLOWED_EXTENSIONS, isAllowedFile } from "./files.js";
11
+
12
+ const args = process.argv.slice(2);
13
+ const command = args[0];
14
+
15
+ async function main() {
16
+ switch (command) {
17
+ case "login":
18
+ return cmdLogin();
19
+ case "publish":
20
+ return cmdPublish(args.slice(1));
21
+ case "remove":
22
+ return cmdRemove(args.slice(1));
23
+ case "list":
24
+ return cmdList();
25
+ case "help":
26
+ case "--help":
27
+ case "-h":
28
+ return cmdHelp();
29
+ case "version":
30
+ case "--version":
31
+ case "-v":
32
+ return cmdVersion();
33
+ default:
34
+ if (!command) {
35
+ cmdHelp();
36
+ process.exit(1);
37
+ }
38
+ console.error(`Unknown command: ${command}`);
39
+ console.error('Run "jotbird help" for usage.');
40
+ process.exit(1);
41
+ }
42
+ }
43
+
44
+ // ---- Commands ----
45
+
46
+ async function cmdLogin() {
47
+ const existing = getApiKey();
48
+ if (existing) {
49
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
50
+ const answer = await new Promise((resolve) => {
51
+ rl.question("You are already logged in. Replace existing token? [y/N] ", resolve);
52
+ });
53
+ rl.close();
54
+ if (answer.toLowerCase() !== "y") {
55
+ console.log("Cancelled.");
56
+ return;
57
+ }
58
+ }
59
+
60
+ // Try to start a local callback server for automatic token capture
61
+ let server = null;
62
+ try {
63
+ server = await startCallbackServer();
64
+ } catch {
65
+ // Fall through to manual flow
66
+ }
67
+
68
+ const baseUrl = `${API_BASE}/account/api-key`;
69
+ const loginUrl = server
70
+ ? `${baseUrl}?callback=${encodeURIComponent(`http://127.0.0.1:${server.port}/callback`)}`
71
+ : baseUrl;
72
+
73
+ const opened = await openBrowser(loginUrl);
74
+
75
+ if (server) {
76
+ if (opened) {
77
+ console.log("\nOpening browser to log in...");
78
+ } else {
79
+ console.log("\nTo log in, open this URL in your browser:\n");
80
+ console.log(` ${loginUrl}`);
81
+ }
82
+ console.log("\nWaiting for browser authentication...");
83
+ console.log("Or paste your API token here and press Enter:\n");
84
+
85
+ // Race: callback server vs manual paste — first one wins
86
+ const rl2 = createInterface({ input: process.stdin, output: process.stdout });
87
+ const manualPromise = new Promise((resolve) => {
88
+ rl2.question("> ", (answer) => resolve(answer.trim()));
89
+ });
90
+
91
+ let token = null;
92
+ try {
93
+ token = await Promise.race([
94
+ server.tokenPromise,
95
+ manualPromise,
96
+ ]);
97
+ } catch {
98
+ // Timeout — fall through
99
+ } finally {
100
+ rl2.close();
101
+ server.close();
102
+ }
103
+
104
+ if (token && token.startsWith("jb_")) {
105
+ saveApiKey(token);
106
+ console.log(`\n✓ Logged in! Token saved to ${getCredentialsPath()}`);
107
+ return;
108
+ }
109
+
110
+ if (token) {
111
+ console.error("\nInvalid token format. Token should start with jb_");
112
+ process.exit(1);
113
+ }
114
+
115
+ console.log("\nLogin timed out. Please try again.");
116
+ process.exit(1);
117
+ } else {
118
+ console.log("\nTo log in, generate an API token in your browser:\n");
119
+ console.log(` ${loginUrl}\n`);
120
+ console.log("Sign in if needed, then copy the token shown on the page.\n");
121
+ }
122
+
123
+ // Manual fallback (only when callback server couldn't start)
124
+ const rl2 = createInterface({ input: process.stdin, output: process.stdout });
125
+ const manualToken = await new Promise((resolve) => {
126
+ rl2.question("Paste your API token: ", resolve);
127
+ });
128
+ rl2.close();
129
+
130
+ const trimmed = manualToken.trim();
131
+ if (!trimmed.startsWith("jb_")) {
132
+ console.error("Invalid token format. Token should start with jb_");
133
+ process.exit(1);
134
+ }
135
+
136
+ saveApiKey(trimmed);
137
+ console.log(`\n✓ Logged in! Token saved to ${getCredentialsPath()}`);
138
+ }
139
+
140
+ async function cmdPublish(fileArgs) {
141
+ const apiKey = getApiKey();
142
+ if (!apiKey) {
143
+ console.error("✗ Not logged in. Run `jotbird login` first.");
144
+ process.exit(1);
145
+ }
146
+
147
+ let markdown;
148
+ let filename = null;
149
+
150
+ if (fileArgs.length === 0 || fileArgs[0] === "-") {
151
+ // Read from stdin
152
+ markdown = await readStdin();
153
+ if (!markdown.trim()) {
154
+ console.error("✗ No input received from stdin.");
155
+ process.exit(1);
156
+ }
157
+ } else {
158
+ filename = fileArgs[0];
159
+
160
+ if (!isAllowedFile(filename)) {
161
+ const extensions = [...ALLOWED_EXTENSIONS].join(", ");
162
+ console.error(`✗ Unsupported file type. Allowed extensions: ${extensions}`);
163
+ process.exit(1);
164
+ }
165
+
166
+ try {
167
+ markdown = readFileSync(filename, "utf-8");
168
+ } catch (err) {
169
+ console.error(`✗ Cannot read file: ${filename}`);
170
+ process.exit(1);
171
+ }
172
+ }
173
+
174
+ // Check for existing mapping
175
+ let slug = null;
176
+ if (filename) {
177
+ const mappings = readMappings();
178
+ slug = mappings.get(filename) || mappings.get(basename(filename)) || null;
179
+ }
180
+
181
+ try {
182
+ let result;
183
+ try {
184
+ result = await publish({ markdown, slug });
185
+ } catch (err) {
186
+ // If the slug was not found, drop the stale mapping and retry as new
187
+ if (slug && err.message && err.message.includes("not found")) {
188
+ console.error(` Slug "${slug}" no longer exists — publishing as new document.`);
189
+ if (filename) removeMapping(filename);
190
+ result = await publish({ markdown, slug: null });
191
+ } else {
192
+ throw err;
193
+ }
194
+ }
195
+
196
+ if (filename) {
197
+ setMapping(filename, result.slug);
198
+ }
199
+
200
+ if (result.created) {
201
+ console.log(`\n✨ Published → ${result.url}`);
202
+ } else {
203
+ console.log(`\n✓ Updated → ${result.url}`);
204
+ }
205
+
206
+ if (result.expiresAt) {
207
+ const date = new Date(result.expiresAt);
208
+ console.log(` Expires ${date.toLocaleDateString()}`);
209
+ }
210
+ } catch (err) {
211
+ console.error(`\n✗ Publish failed: ${err.message}`);
212
+ process.exit(1);
213
+ }
214
+ }
215
+
216
+ async function cmdRemove(removeArgs) {
217
+ const apiKey = getApiKey();
218
+ if (!apiKey) {
219
+ console.error("✗ Not logged in. Run `jotbird login` first.");
220
+ process.exit(1);
221
+ }
222
+
223
+ if (removeArgs.length === 0) {
224
+ console.error("Usage: jotbird remove <file.md|slug>");
225
+ process.exit(1);
226
+ }
227
+
228
+ const target = removeArgs[0];
229
+
230
+ // Resolve slug: check .jotbird mapping first, otherwise treat as slug directly
231
+ const mappings = readMappings();
232
+ const slug = mappings.get(target) || mappings.get(basename(target)) || target;
233
+
234
+ try {
235
+ await removeDocument(slug);
236
+ removeMapping(target);
237
+ console.log(`\n✓ Removed ${slug}`);
238
+ } catch (err) {
239
+ console.error(`\n✗ Remove failed: ${err.message}`);
240
+ process.exit(1);
241
+ }
242
+ }
243
+
244
+ async function cmdList() {
245
+ const apiKey = getApiKey();
246
+ if (!apiKey) {
247
+ console.error("✗ Not logged in. Run `jotbird login` first.");
248
+ process.exit(1);
249
+ }
250
+
251
+ try {
252
+ const result = await listDocuments();
253
+ const docs = result.documents || [];
254
+
255
+ if (docs.length === 0) {
256
+ console.log("No published documents yet.");
257
+ return;
258
+ }
259
+
260
+ console.log("");
261
+ for (const doc of docs) {
262
+ const title = doc.title || "(untitled)";
263
+ const source = doc.source === "api" || doc.source === "cli" ? " [api]" : "";
264
+ console.log(` ${doc.slug} ${title}${source}`);
265
+ console.log(` ${doc.url}`);
266
+ }
267
+
268
+ console.log(`\n ${docs.length} document${docs.length === 1 ? "" : "s"}`);
269
+ } catch (err) {
270
+ console.error(`\n✗ Failed to list documents: ${err.message}`);
271
+ process.exit(1);
272
+ }
273
+ }
274
+
275
+ function cmdHelp() {
276
+ console.log(`
277
+ jotbird - Publish Markdown from the command line
278
+
279
+ Usage:
280
+ jotbird login Authenticate with JotBird
281
+ jotbird publish <file.md> Publish or update a Markdown/text file
282
+ jotbird publish Read Markdown from stdin
283
+ jotbird remove <file.md|slug> Permanently delete a document
284
+ jotbird list List your published documents
285
+ jotbird help Show this help message
286
+
287
+ Examples:
288
+ jotbird publish README.md
289
+ cat notes.md | jotbird publish
290
+ echo "# Hello" | jotbird publish
291
+ jotbird remove my-old-post
292
+
293
+ Files are tracked via a .jotbird mapping file in the current directory.
294
+ If a mapping exists, publish updates the existing URL.
295
+ `.trim());
296
+ }
297
+
298
+ function cmdVersion() {
299
+ console.log("jotbird 0.1.0");
300
+ }
301
+
302
+ // ---- Helpers ----
303
+
304
+ function readStdin() {
305
+ return new Promise((resolve) => {
306
+ let data = "";
307
+ process.stdin.setEncoding("utf-8");
308
+ process.stdin.on("data", (chunk) => { data += chunk; });
309
+ process.stdin.on("end", () => resolve(data));
310
+
311
+ // If stdin is a TTY (no pipe), show a hint and wait
312
+ if (process.stdin.isTTY) {
313
+ console.error("Reading from stdin... (Ctrl+D to finish)");
314
+ }
315
+ });
316
+ }
317
+
318
+ main().catch((err) => {
319
+ console.error(err.message || err);
320
+ process.exit(1);
321
+ });
package/dist/config.js ADDED
@@ -0,0 +1,25 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ const CONFIG_DIR = join(homedir(), ".config", "jotbird");
6
+ const CREDENTIALS_FILE = join(CONFIG_DIR, "credentials");
7
+
8
+ export function getApiKey() {
9
+ try {
10
+ return readFileSync(CREDENTIALS_FILE, "utf-8").trim();
11
+ } catch {
12
+ return null;
13
+ }
14
+ }
15
+
16
+ export function saveApiKey(key) {
17
+ mkdirSync(CONFIG_DIR, { recursive: true });
18
+ writeFileSync(CREDENTIALS_FILE, key + "\n", { mode: 0o600 });
19
+ }
20
+
21
+ export function getCredentialsPath() {
22
+ return CREDENTIALS_FILE;
23
+ }
24
+
25
+ export const API_BASE = process.env.JOTBIRD_API_URL || "https://www.jotbird.com";
package/dist/files.js ADDED
@@ -0,0 +1,12 @@
1
+ export const ALLOWED_EXTENSIONS = new Set([
2
+ ".md", ".markdown", ".mdx", ".txt", ".text",
3
+ ]);
4
+
5
+ export function isAllowedFile(filename) {
6
+ const base = filename.includes("/") ? filename.slice(filename.lastIndexOf("/") + 1) : filename;
7
+ const dotIndex = base.lastIndexOf(".");
8
+ // No extension (e.g. "README") — allow it
9
+ if (dotIndex <= 0) return true;
10
+ const ext = base.slice(dotIndex).toLowerCase();
11
+ return ALLOWED_EXTENSIONS.has(ext);
12
+ }
package/dist/login.js ADDED
@@ -0,0 +1,78 @@
1
+ import { createServer } from "node:http";
2
+
3
+ /**
4
+ * Start a temporary HTTP server on 127.0.0.1 that waits for the web app
5
+ * to redirect back with an API token via query parameter.
6
+ *
7
+ * Resolves with { port, tokenPromise, close }.
8
+ * - port: the randomly-assigned port the server is listening on
9
+ * - tokenPromise: resolves with the token string once /callback?token=... is hit
10
+ * - close: shuts down the server and clears the timeout
11
+ *
12
+ * The server automatically rejects tokenPromise after `timeoutMs` (default 5 min).
13
+ */
14
+ export function startCallbackServer(timeoutMs = 300_000) {
15
+ return new Promise((resolve, reject) => {
16
+ let resolveToken, rejectToken;
17
+ const tokenPromise = new Promise((res, rej) => {
18
+ resolveToken = res;
19
+ rejectToken = rej;
20
+ });
21
+
22
+ const timeout = setTimeout(() => rejectToken(new Error("timeout")), timeoutMs);
23
+
24
+ const server = createServer((req, res) => {
25
+ const url = new URL(req.url, "http://127.0.0.1");
26
+
27
+ if (url.pathname === "/callback") {
28
+ const token = url.searchParams.get("token");
29
+
30
+ res.writeHead(200, { "Content-Type": "text/html", Connection: "close" });
31
+ res.end([
32
+ "<!DOCTYPE html><html><head><title>JotBird CLI</title></head>",
33
+ '<body style="font-family:system-ui,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f8fafc;color:#0f172a">',
34
+ '<div style="text-align:center;max-width:400px;padding:2rem">',
35
+ '<h1 style="font-size:1.5rem;font-weight:700;margin:0 0 .5rem">Logged in</h1>',
36
+ '<p style="color:#64748b;margin:0">You can close this tab and return to your terminal.</p>',
37
+ "</div></body></html>",
38
+ ].join(""));
39
+
40
+ if (token) {
41
+ clearTimeout(timeout);
42
+ resolveToken(token);
43
+ }
44
+ } else {
45
+ res.writeHead(404);
46
+ res.end();
47
+ }
48
+ });
49
+
50
+ server.listen(0, "127.0.0.1", () => {
51
+ resolve({
52
+ port: server.address().port,
53
+ tokenPromise,
54
+ close: () => { clearTimeout(timeout); server.close(); },
55
+ });
56
+ });
57
+
58
+ server.on("error", reject);
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Try to open a URL in the user's default browser.
64
+ * Returns true if the command succeeded, false otherwise.
65
+ */
66
+ export async function openBrowser(url) {
67
+ try {
68
+ const { execFile } = await import("node:child_process");
69
+ const cmd = process.platform === "darwin" ? "open"
70
+ : process.platform === "win32" ? "start"
71
+ : "xdg-open";
72
+ return new Promise((resolve) => {
73
+ execFile(cmd, [url], (err) => resolve(!err));
74
+ });
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
@@ -0,0 +1,74 @@
1
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ const MAPPING_FILE = ".jotbird";
5
+
6
+ function getMappingPath() {
7
+ return join(process.cwd(), MAPPING_FILE);
8
+ }
9
+
10
+ /**
11
+ * Read the .jotbird mapping file.
12
+ * Returns a Map of filename → slug.
13
+ */
14
+ export function readMappings() {
15
+ const path = getMappingPath();
16
+ const map = new Map();
17
+ if (!existsSync(path)) return map;
18
+
19
+ const content = readFileSync(path, "utf-8");
20
+ for (const line of content.split("\n")) {
21
+ const trimmed = line.trim();
22
+ if (!trimmed || trimmed.startsWith("#")) continue;
23
+ const eqIndex = trimmed.indexOf("=");
24
+ if (eqIndex === -1) continue;
25
+ const file = trimmed.slice(0, eqIndex).trim();
26
+ const slug = trimmed.slice(eqIndex + 1).trim();
27
+ if (file && slug) map.set(file, slug);
28
+ }
29
+ return map;
30
+ }
31
+
32
+ /**
33
+ * Write the .jotbird mapping file.
34
+ */
35
+ export function writeMappings(map) {
36
+ const path = getMappingPath();
37
+ const lines = [];
38
+ for (const [file, slug] of map) {
39
+ lines.push(`${file} = ${slug}`);
40
+ }
41
+ writeFileSync(path, lines.join("\n") + "\n");
42
+ }
43
+
44
+ /**
45
+ * Update a single mapping and write to disk.
46
+ */
47
+ export function setMapping(filename, slug) {
48
+ const map = readMappings();
49
+ map.set(filename, slug);
50
+ writeMappings(map);
51
+ }
52
+
53
+ /**
54
+ * Remove a mapping by filename or slug and write to disk.
55
+ * Returns true if a mapping was removed.
56
+ */
57
+ export function removeMapping(filenameOrSlug) {
58
+ const map = readMappings();
59
+ // Try removing by filename first
60
+ if (map.has(filenameOrSlug)) {
61
+ map.delete(filenameOrSlug);
62
+ writeMappings(map);
63
+ return true;
64
+ }
65
+ // Try removing by slug value
66
+ for (const [file, slug] of map) {
67
+ if (slug === filenameOrSlug) {
68
+ map.delete(file);
69
+ writeMappings(map);
70
+ return true;
71
+ }
72
+ }
73
+ return false;
74
+ }
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "jotbird",
3
+ "version": "0.1.0",
4
+ "description": "Publish Markdown from the command line with JotBird",
5
+ "type": "module",
6
+ "bin": {
7
+ "jotbird": "./dist/cli.js"
8
+ },
9
+ "scripts": {
10
+ "build": "node build.js",
11
+ "dev": "node src/cli.js"
12
+ },
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "keywords": ["markdown", "publish", "jotbird", "cli"],
17
+ "license": "MIT"
18
+ }