chore: added linting and formatting 5dd325f9
Steve · 2026-02-01 05:49 14 file(s) · +1278 −1126
bun.lock +20 −1
24 24
    },
25 25
    "packages/cli": {
26 26
      "name": "sequoia-cli",
27 -
      "version": "0.1.0",
27 +
      "version": "0.2.0",
28 28
      "bin": {
29 29
        "sequoia": "dist/index.js",
30 30
      },
37 37
        "minimatch": "^10.1.1",
38 38
      },
39 39
      "devDependencies": {
40 +
        "@biomejs/biome": "^2.3.13",
40 41
        "@types/mime-types": "^3.0.1",
41 42
        "@types/node": "^20",
42 43
      },
103 104
    "@babel/traverse": ["@babel/traverse@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="],
104 105
105 106
    "@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="],
107 +
108 +
    "@biomejs/biome": ["@biomejs/biome@2.3.13", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.13", "@biomejs/cli-darwin-x64": "2.3.13", "@biomejs/cli-linux-arm64": "2.3.13", "@biomejs/cli-linux-arm64-musl": "2.3.13", "@biomejs/cli-linux-x64": "2.3.13", "@biomejs/cli-linux-x64-musl": "2.3.13", "@biomejs/cli-win32-arm64": "2.3.13", "@biomejs/cli-win32-x64": "2.3.13" }, "bin": { "biome": "bin/biome" } }, "sha512-Fw7UsV0UAtWIBIm0M7g5CRerpu1eKyKAXIazzxhbXYUyMkwNrkX/KLkGI7b+uVDQ5cLUMfOC9vR60q9IDYDstA=="],
109 +
110 +
    "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0OCwP0/BoKzyJHnFdaTk/i7hIP9JHH9oJJq6hrSCPmJPo8JWcJhprK4gQlhFzrwdTBAW4Bjt/RmCf3ZZe59gwQ=="],
111 +
112 +
    "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-AGr8OoemT/ejynbIu56qeil2+F2WLkIjn2d8jGK1JkchxnMUhYOfnqc9sVzcRxpG9Ycvw4weQ5sprRvtb7Yhcw=="],
113 +
114 +
    "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-xvOiFkrDNu607MPMBUQ6huHmBG1PZLOrqhtK6pXJW3GjfVqJg0Z/qpTdhXfcqWdSZHcT+Nct2fOgewZvytESkw=="],
115 +
116 +
    "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-TUdDCSY+Eo/EHjhJz7P2GnWwfqet+lFxBZzGHldrvULr59AgahamLs/N85SC4+bdF86EhqDuuw9rYLvLFWWlXA=="],
117 +
118 +
    "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.13", "", { "os": "linux", "cpu": "x64" }, "sha512-s+YsZlgiXNq8XkgHs6xdvKDFOj/bwTEevqEY6rC2I3cBHbxXYU1LOZstH3Ffw9hE5tE1sqT7U23C00MzkXztMw=="],
119 +
120 +
    "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.13", "", { "os": "linux", "cpu": "x64" }, "sha512-0bdwFVSbbM//Sds6OjtnmQGp4eUjOTt6kHvR/1P0ieR9GcTUAlPNvPC3DiavTqq302W34Ae2T6u5VVNGuQtGlQ=="],
121 +
122 +
    "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-QweDxY89fq0VvrxME+wS/BXKmqMrOTZlN9SqQ79kQSIc3FrEwvW/PvUegQF6XIVaekncDykB5dzPqjbwSKs9DA=="],
123 +
124 +
    "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.13", "", { "os": "win32", "cpu": "x64" }, "sha512-trDw2ogdM2lyav9WFQsdsfdVy1dvZALymRpgmWsvSez0BJzBjulhOT/t+wyKeh3pZWvwP3VMs1SoOKwO3wecMQ=="],
106 125
107 126
    "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.1", "", {}, "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw=="],
108 127
packages/cli/biome.json (added) +34 −0
1 +
{
2 +
	"$schema": "https://biomejs.dev/schemas/2.3.13/schema.json",
3 +
	"vcs": {
4 +
		"enabled": true,
5 +
		"clientKind": "git",
6 +
		"useIgnoreFile": true
7 +
	},
8 +
	"files": {
9 +
		"includes": ["**", "!!**/dist"]
10 +
	},
11 +
	"formatter": {
12 +
		"enabled": true,
13 +
		"indentStyle": "tab"
14 +
	},
15 +
	"linter": {
16 +
		"enabled": true,
17 +
		"rules": {
18 +
			"recommended": true
19 +
		}
20 +
	},
21 +
	"javascript": {
22 +
		"formatter": {
23 +
			"quoteStyle": "double"
24 +
		}
25 +
	},
26 +
	"assist": {
27 +
		"enabled": true,
28 +
		"actions": {
29 +
			"source": {
30 +
				"organizeImports": "on"
31 +
			}
32 +
		}
33 +
	}
34 +
}
packages/cli/package.json +3 −0
14 14
		".": "./dist/index.js"
15 15
	},
16 16
	"scripts": {
17 +
		"lint": "biome lint --write",
18 +
		"format": "biome format --write",
17 19
		"build": "bun build src/index.ts --target node --outdir dist",
18 20
		"dev": "bun run build && bun link",
19 21
		"deploy": "bun run build && bun publish"
20 22
	},
21 23
	"devDependencies": {
24 +
		"@biomejs/biome": "^2.3.13",
22 25
		"@types/mime-types": "^3.0.1",
23 26
		"@types/node": "^20"
24 27
	},
packages/cli/src/commands/auth.ts +152 −135
1 -
import { command, flag, option, optional, string } from "cmd-ts";
2 -
import { note, text, password, confirm, select, spinner, log } from "@clack/prompts";
3 1
import { AtpAgent } from "@atproto/api";
4 2
import {
5 -
  saveCredentials,
6 -
  deleteCredentials,
7 -
  listCredentials,
8 -
  getCredentials,
9 -
  getCredentialsPath,
10 -
} from "../lib/credentials";
3 +
	confirm,
4 +
	log,
5 +
	note,
6 +
	password,
7 +
	select,
8 +
	spinner,
9 +
	text,
10 +
} from "@clack/prompts";
11 +
import { command, flag, option, optional, string } from "cmd-ts";
11 12
import { resolveHandleToPDS } from "../lib/atproto";
13 +
import {
14 +
	deleteCredentials,
15 +
	getCredentials,
16 +
	getCredentialsPath,
17 +
	listCredentials,
18 +
	saveCredentials,
19 +
} from "../lib/credentials";
12 20
import { exitOnCancel } from "../lib/prompts";
13 21
14 22
export const authCommand = command({
15 -
  name: "auth",
16 -
  description: "Authenticate with your ATProto PDS",
17 -
  args: {
18 -
    logout: option({
19 -
      long: "logout",
20 -
      description: "Remove credentials for a specific identity (or all if only one exists)",
21 -
      type: optional(string),
22 -
    }),
23 -
    list: flag({
24 -
      long: "list",
25 -
      description: "List all stored identities",
26 -
    }),
27 -
  },
28 -
  handler: async ({ logout, list }) => {
29 -
    // List identities
30 -
    if (list) {
31 -
      const identities = await listCredentials();
32 -
      if (identities.length === 0) {
33 -
        log.info("No stored identities");
34 -
      } else {
35 -
        log.info("Stored identities:");
36 -
        for (const id of identities) {
37 -
          console.log(`  - ${id}`);
38 -
        }
39 -
      }
40 -
      return;
41 -
    }
23 +
	name: "auth",
24 +
	description: "Authenticate with your ATProto PDS",
25 +
	args: {
26 +
		logout: option({
27 +
			long: "logout",
28 +
			description:
29 +
				"Remove credentials for a specific identity (or all if only one exists)",
30 +
			type: optional(string),
31 +
		}),
32 +
		list: flag({
33 +
			long: "list",
34 +
			description: "List all stored identities",
35 +
		}),
36 +
	},
37 +
	handler: async ({ logout, list }) => {
38 +
		// List identities
39 +
		if (list) {
40 +
			const identities = await listCredentials();
41 +
			if (identities.length === 0) {
42 +
				log.info("No stored identities");
43 +
			} else {
44 +
				log.info("Stored identities:");
45 +
				for (const id of identities) {
46 +
					console.log(`  - ${id}`);
47 +
				}
48 +
			}
49 +
			return;
50 +
		}
42 51
43 -
    // Logout
44 -
    if (logout !== undefined) {
45 -
      // If --logout was passed without a value, it will be an empty string
46 -
      const identifier = logout || undefined;
52 +
		// Logout
53 +
		if (logout !== undefined) {
54 +
			// If --logout was passed without a value, it will be an empty string
55 +
			const identifier = logout || undefined;
47 56
48 -
      if (!identifier) {
49 -
        // No identifier provided - show available and prompt
50 -
        const identities = await listCredentials();
51 -
        if (identities.length === 0) {
52 -
          log.info("No saved credentials found");
53 -
          return;
54 -
        }
55 -
        if (identities.length === 1) {
56 -
          const deleted = await deleteCredentials(identities[0]);
57 -
          if (deleted) {
58 -
            log.success(`Removed credentials for ${identities[0]}`);
59 -
          }
60 -
          return;
61 -
        }
62 -
        // Multiple identities - prompt
63 -
        const selected = exitOnCancel(await select({
64 -
          message: "Select identity to remove:",
65 -
          options: identities.map(id => ({ value: id, label: id })),
66 -
        }));
67 -
        const deleted = await deleteCredentials(selected);
68 -
        if (deleted) {
69 -
          log.success(`Removed credentials for ${selected}`);
70 -
        }
71 -
        return;
72 -
      }
57 +
			if (!identifier) {
58 +
				// No identifier provided - show available and prompt
59 +
				const identities = await listCredentials();
60 +
				if (identities.length === 0) {
61 +
					log.info("No saved credentials found");
62 +
					return;
63 +
				}
64 +
				if (identities.length === 1) {
65 +
					const deleted = await deleteCredentials(identities[0]);
66 +
					if (deleted) {
67 +
						log.success(`Removed credentials for ${identities[0]}`);
68 +
					}
69 +
					return;
70 +
				}
71 +
				// Multiple identities - prompt
72 +
				const selected = exitOnCancel(
73 +
					await select({
74 +
						message: "Select identity to remove:",
75 +
						options: identities.map((id) => ({ value: id, label: id })),
76 +
					}),
77 +
				);
78 +
				const deleted = await deleteCredentials(selected);
79 +
				if (deleted) {
80 +
					log.success(`Removed credentials for ${selected}`);
81 +
				}
82 +
				return;
83 +
			}
73 84
74 -
      const deleted = await deleteCredentials(identifier);
75 -
      if (deleted) {
76 -
        log.success(`Removed credentials for ${identifier}`);
77 -
      } else {
78 -
        log.info(`No credentials found for ${identifier}`);
79 -
      }
80 -
      return;
81 -
    }
85 +
			const deleted = await deleteCredentials(identifier);
86 +
			if (deleted) {
87 +
				log.success(`Removed credentials for ${identifier}`);
88 +
			} else {
89 +
				log.info(`No credentials found for ${identifier}`);
90 +
			}
91 +
			return;
92 +
		}
82 93
83 -
    note(
84 -
      "To authenticate, you'll need an App Password.\n\n" +
85 -
        "Create one at: https://bsky.app/settings/app-passwords\n\n" +
86 -
        "App Passwords are safer than your main password and can be revoked.",
87 -
      "Authentication"
88 -
    );
94 +
		note(
95 +
			"To authenticate, you'll need an App Password.\n\n" +
96 +
				"Create one at: https://bsky.app/settings/app-passwords\n\n" +
97 +
				"App Passwords are safer than your main password and can be revoked.",
98 +
			"Authentication",
99 +
		);
89 100
90 -
    const identifier = exitOnCancel(await text({
91 -
      message: "Handle or DID:",
92 -
      placeholder: "yourhandle.bsky.social",
93 -
    }));
101 +
		const identifier = exitOnCancel(
102 +
			await text({
103 +
				message: "Handle or DID:",
104 +
				placeholder: "yourhandle.bsky.social",
105 +
			}),
106 +
		);
94 107
95 -
    const appPassword = exitOnCancel(await password({
96 -
      message: "App Password:",
97 -
    }));
108 +
		const appPassword = exitOnCancel(
109 +
			await password({
110 +
				message: "App Password:",
111 +
			}),
112 +
		);
98 113
99 -
    if (!identifier || !appPassword) {
100 -
      log.error("Handle and password are required");
101 -
      process.exit(1);
102 -
    }
114 +
		if (!identifier || !appPassword) {
115 +
			log.error("Handle and password are required");
116 +
			process.exit(1);
117 +
		}
103 118
104 -
    // Check if this identity already exists
105 -
    const existing = await getCredentials(identifier);
106 -
    if (existing) {
107 -
      const overwrite = exitOnCancel(await confirm({
108 -
        message: `Credentials for ${identifier} already exist. Update?`,
109 -
        initialValue: false,
110 -
      }));
111 -
      if (!overwrite) {
112 -
        log.info("Keeping existing credentials");
113 -
        return;
114 -
      }
115 -
    }
119 +
		// Check if this identity already exists
120 +
		const existing = await getCredentials(identifier);
121 +
		if (existing) {
122 +
			const overwrite = exitOnCancel(
123 +
				await confirm({
124 +
					message: `Credentials for ${identifier} already exist. Update?`,
125 +
					initialValue: false,
126 +
				}),
127 +
			);
128 +
			if (!overwrite) {
129 +
				log.info("Keeping existing credentials");
130 +
				return;
131 +
			}
132 +
		}
116 133
117 -
    // Resolve PDS from handle
118 -
    const s = spinner();
119 -
    s.start("Resolving PDS...");
120 -
    let pdsUrl: string;
121 -
    try {
122 -
      pdsUrl = await resolveHandleToPDS(identifier);
123 -
      s.stop(`Found PDS: ${pdsUrl}`);
124 -
    } catch (error) {
125 -
      s.stop("Failed to resolve PDS");
126 -
      log.error(`Failed to resolve PDS from handle: ${error}`);
127 -
      process.exit(1);
128 -
    }
134 +
		// Resolve PDS from handle
135 +
		const s = spinner();
136 +
		s.start("Resolving PDS...");
137 +
		let pdsUrl: string;
138 +
		try {
139 +
			pdsUrl = await resolveHandleToPDS(identifier);
140 +
			s.stop(`Found PDS: ${pdsUrl}`);
141 +
		} catch (error) {
142 +
			s.stop("Failed to resolve PDS");
143 +
			log.error(`Failed to resolve PDS from handle: ${error}`);
144 +
			process.exit(1);
145 +
		}
129 146
130 -
    // Verify credentials
131 -
    s.start("Verifying credentials...");
147 +
		// Verify credentials
148 +
		s.start("Verifying credentials...");
132 149
133 -
    try {
134 -
      const agent = new AtpAgent({ service: pdsUrl });
135 -
      await agent.login({
136 -
        identifier: identifier,
137 -
        password: appPassword,
138 -
      });
150 +
		try {
151 +
			const agent = new AtpAgent({ service: pdsUrl });
152 +
			await agent.login({
153 +
				identifier: identifier,
154 +
				password: appPassword,
155 +
			});
139 156
140 -
      s.stop(`Logged in as ${agent.session?.handle}`);
157 +
			s.stop(`Logged in as ${agent.session?.handle}`);
141 158
142 -
      // Save credentials
143 -
      await saveCredentials({
144 -
        pdsUrl,
145 -
        identifier: identifier,
146 -
        password: appPassword,
147 -
      });
159 +
			// Save credentials
160 +
			await saveCredentials({
161 +
				pdsUrl,
162 +
				identifier: identifier,
163 +
				password: appPassword,
164 +
			});
148 165
149 -
      log.success(`Credentials saved to ${getCredentialsPath()}`);
150 -
    } catch (error) {
151 -
      s.stop("Failed to login");
152 -
      log.error(`Failed to login: ${error}`);
153 -
      process.exit(1);
154 -
    }
155 -
  },
166 +
			log.success(`Credentials saved to ${getCredentialsPath()}`);
167 +
		} catch (error) {
168 +
			s.stop("Failed to login");
169 +
			log.error(`Failed to login: ${error}`);
170 +
			process.exit(1);
171 +
		}
172 +
	},
156 173
});
packages/cli/src/commands/init.ts +2 −2
1 -
import * as fs from "fs/promises";
1 +
import * as fs from "node:fs/promises";
2 2
import { command } from "cmd-ts";
3 3
import {
4 4
	intro,
11 11
	log,
12 12
	group,
13 13
} from "@clack/prompts";
14 -
import * as path from "path";
14 +
import * as path from "node:path";
15 15
import { findConfig, generateConfigTemplate } from "../lib/config";
16 16
import { loadCredentials } from "../lib/credentials";
17 17
import { createAgent, createPublication } from "../lib/atproto";
packages/cli/src/commands/inject.ts +4 −4
1 -
import * as fs from "fs/promises";
2 -
import { command, flag, option, optional, string } from "cmd-ts";
3 1
import { log } from "@clack/prompts";
4 -
import * as path from "path";
2 +
import { command, flag, option, optional, string } from "cmd-ts";
5 3
import { glob } from "glob";
6 -
import { loadConfig, loadState, findConfig } from "../lib/config";
4 +
import * as fs from "node:fs/promises";
5 +
import * as path from "node:path";
6 +
import { findConfig, loadConfig, loadState } from "../lib/config";
7 7
8 8
export const injectCommand = command({
9 9
	name: "inject",
packages/cli/src/commands/publish.ts +2 −2
1 -
import * as fs from "fs/promises";
1 +
import * as fs from "node:fs/promises";
2 2
import { command, flag } from "cmd-ts";
3 3
import { select, spinner, log } from "@clack/prompts";
4 -
import * as path from "path";
4 +
import * as path from "node:path";
5 5
import { loadConfig, loadState, saveState, findConfig } from "../lib/config";
6 6
import {
7 7
	loadCredentials,
packages/cli/src/commands/sync.ts +172 −158
1 -
import * as fs from "fs/promises";
1 +
import * as fs from "node:fs/promises";
2 2
import { command, flag } from "cmd-ts";
3 3
import { select, spinner, log } from "@clack/prompts";
4 -
import * as path from "path";
4 +
import * as path from "node:path";
5 5
import { loadConfig, loadState, saveState, findConfig } from "../lib/config";
6 -
import { loadCredentials, listCredentials, getCredentials } from "../lib/credentials";
6 +
import {
7 +
	loadCredentials,
8 +
	listCredentials,
9 +
	getCredentials,
10 +
} from "../lib/credentials";
7 11
import { createAgent, listDocuments } from "../lib/atproto";
8 -
import { scanContentDirectory, getContentHash, updateFrontmatterWithAtUri } from "../lib/markdown";
12 +
import {
13 +
	scanContentDirectory,
14 +
	getContentHash,
15 +
	updateFrontmatterWithAtUri,
16 +
} from "../lib/markdown";
9 17
import { exitOnCancel } from "../lib/prompts";
10 18
11 19
export const syncCommand = command({
12 -
  name: "sync",
13 -
  description: "Sync state from ATProto to restore .sequoia-state.json",
14 -
  args: {
15 -
    updateFrontmatter: flag({
16 -
      long: "update-frontmatter",
17 -
      short: "u",
18 -
      description: "Update frontmatter atUri fields in local markdown files",
19 -
    }),
20 -
    dryRun: flag({
21 -
      long: "dry-run",
22 -
      short: "n",
23 -
      description: "Preview what would be synced without making changes",
24 -
    }),
25 -
  },
26 -
  handler: async ({ updateFrontmatter, dryRun }) => {
27 -
    // Load config
28 -
    const configPath = await findConfig();
29 -
    if (!configPath) {
30 -
      log.error("No sequoia.json found. Run 'sequoia init' first.");
31 -
      process.exit(1);
32 -
    }
20 +
	name: "sync",
21 +
	description: "Sync state from ATProto to restore .sequoia-state.json",
22 +
	args: {
23 +
		updateFrontmatter: flag({
24 +
			long: "update-frontmatter",
25 +
			short: "u",
26 +
			description: "Update frontmatter atUri fields in local markdown files",
27 +
		}),
28 +
		dryRun: flag({
29 +
			long: "dry-run",
30 +
			short: "n",
31 +
			description: "Preview what would be synced without making changes",
32 +
		}),
33 +
	},
34 +
	handler: async ({ updateFrontmatter, dryRun }) => {
35 +
		// Load config
36 +
		const configPath = await findConfig();
37 +
		if (!configPath) {
38 +
			log.error("No sequoia.json found. Run 'sequoia init' first.");
39 +
			process.exit(1);
40 +
		}
33 41
34 -
    const config = await loadConfig(configPath);
35 -
    const configDir = path.dirname(configPath);
42 +
		const config = await loadConfig(configPath);
43 +
		const configDir = path.dirname(configPath);
36 44
37 -
    log.info(`Site: ${config.siteUrl}`);
38 -
    log.info(`Publication: ${config.publicationUri}`);
45 +
		log.info(`Site: ${config.siteUrl}`);
46 +
		log.info(`Publication: ${config.publicationUri}`);
39 47
40 -
    // Load credentials
41 -
    let credentials = await loadCredentials(config.identity);
48 +
		// Load credentials
49 +
		let credentials = await loadCredentials(config.identity);
42 50
43 -
    if (!credentials) {
44 -
      const identities = await listCredentials();
45 -
      if (identities.length === 0) {
46 -
        log.error("No credentials found. Run 'sequoia auth' first.");
47 -
        process.exit(1);
48 -
      }
51 +
		if (!credentials) {
52 +
			const identities = await listCredentials();
53 +
			if (identities.length === 0) {
54 +
				log.error("No credentials found. Run 'sequoia auth' first.");
55 +
				process.exit(1);
56 +
			}
49 57
50 -
      log.info("Multiple identities found. Select one to use:");
51 -
      const selected = exitOnCancel(await select({
52 -
        message: "Identity:",
53 -
        options: identities.map(id => ({ value: id, label: id })),
54 -
      }));
58 +
			log.info("Multiple identities found. Select one to use:");
59 +
			const selected = exitOnCancel(
60 +
				await select({
61 +
					message: "Identity:",
62 +
					options: identities.map((id) => ({ value: id, label: id })),
63 +
				}),
64 +
			);
55 65
56 -
      credentials = await getCredentials(selected);
57 -
      if (!credentials) {
58 -
        log.error("Failed to load selected credentials.");
59 -
        process.exit(1);
60 -
      }
61 -
    }
66 +
			credentials = await getCredentials(selected);
67 +
			if (!credentials) {
68 +
				log.error("Failed to load selected credentials.");
69 +
				process.exit(1);
70 +
			}
71 +
		}
62 72
63 -
    // Create agent
64 -
    const s = spinner();
65 -
    s.start(`Connecting to ${credentials.pdsUrl}...`);
66 -
    let agent;
67 -
    try {
68 -
      agent = await createAgent(credentials);
69 -
      s.stop(`Logged in as ${agent.session?.handle}`);
70 -
    } catch (error) {
71 -
      s.stop("Failed to login");
72 -
      log.error(`Failed to login: ${error}`);
73 -
      process.exit(1);
74 -
    }
73 +
		// Create agent
74 +
		const s = spinner();
75 +
		s.start(`Connecting to ${credentials.pdsUrl}...`);
76 +
		let agent;
77 +
		try {
78 +
			agent = await createAgent(credentials);
79 +
			s.stop(`Logged in as ${agent.session?.handle}`);
80 +
		} catch (error) {
81 +
			s.stop("Failed to login");
82 +
			log.error(`Failed to login: ${error}`);
83 +
			process.exit(1);
84 +
		}
75 85
76 -
    // Fetch documents from PDS
77 -
    s.start("Fetching documents from PDS...");
78 -
    const documents = await listDocuments(agent, config.publicationUri);
79 -
    s.stop(`Found ${documents.length} documents on PDS`);
86 +
		// Fetch documents from PDS
87 +
		s.start("Fetching documents from PDS...");
88 +
		const documents = await listDocuments(agent, config.publicationUri);
89 +
		s.stop(`Found ${documents.length} documents on PDS`);
80 90
81 -
    if (documents.length === 0) {
82 -
      log.info("No documents found for this publication.");
83 -
      return;
84 -
    }
91 +
		if (documents.length === 0) {
92 +
			log.info("No documents found for this publication.");
93 +
			return;
94 +
		}
85 95
86 -
    // Resolve content directory
87 -
    const contentDir = path.isAbsolute(config.contentDir)
88 -
      ? config.contentDir
89 -
      : path.join(configDir, config.contentDir);
96 +
		// Resolve content directory
97 +
		const contentDir = path.isAbsolute(config.contentDir)
98 +
			? config.contentDir
99 +
			: path.join(configDir, config.contentDir);
90 100
91 -
    // Scan local posts
92 -
    s.start("Scanning local content...");
93 -
    const localPosts = await scanContentDirectory(contentDir, {
94 -
      frontmatterMapping: config.frontmatter,
95 -
      ignorePatterns: config.ignore,
96 -
      slugSource: config.slugSource,
97 -
      slugField: config.slugField,
98 -
      removeIndexFromSlug: config.removeIndexFromSlug,
99 -
    });
100 -
    s.stop(`Found ${localPosts.length} local posts`);
101 +
		// Scan local posts
102 +
		s.start("Scanning local content...");
103 +
		const localPosts = await scanContentDirectory(contentDir, {
104 +
			frontmatterMapping: config.frontmatter,
105 +
			ignorePatterns: config.ignore,
106 +
			slugSource: config.slugSource,
107 +
			slugField: config.slugField,
108 +
			removeIndexFromSlug: config.removeIndexFromSlug,
109 +
		});
110 +
		s.stop(`Found ${localPosts.length} local posts`);
101 111
102 -
    // Build a map of path -> local post for matching
103 -
    // Document path is like /posts/my-post-slug (or custom pathPrefix)
104 -
    const pathPrefix = config.pathPrefix || "/posts";
105 -
    const postsByPath = new Map<string, typeof localPosts[0]>();
106 -
    for (const post of localPosts) {
107 -
      const postPath = `${pathPrefix}/${post.slug}`;
108 -
      postsByPath.set(postPath, post);
109 -
    }
112 +
		// Build a map of path -> local post for matching
113 +
		// Document path is like /posts/my-post-slug (or custom pathPrefix)
114 +
		const pathPrefix = config.pathPrefix || "/posts";
115 +
		const postsByPath = new Map<string, (typeof localPosts)[0]>();
116 +
		for (const post of localPosts) {
117 +
			const postPath = `${pathPrefix}/${post.slug}`;
118 +
			postsByPath.set(postPath, post);
119 +
		}
110 120
111 -
    // Load existing state
112 -
    const state = await loadState(configDir);
113 -
    const originalPostCount = Object.keys(state.posts).length;
121 +
		// Load existing state
122 +
		const state = await loadState(configDir);
123 +
		const originalPostCount = Object.keys(state.posts).length;
114 124
115 -
    // Track changes
116 -
    let matchedCount = 0;
117 -
    let unmatchedCount = 0;
118 -
    let frontmatterUpdates: Array<{ filePath: string; atUri: string }> = [];
125 +
		// Track changes
126 +
		let matchedCount = 0;
127 +
		let unmatchedCount = 0;
128 +
		const frontmatterUpdates: Array<{ filePath: string; atUri: string }> = [];
119 129
120 -
    log.message("\nMatching documents to local files:\n");
130 +
		log.message("\nMatching documents to local files:\n");
121 131
122 -
    for (const doc of documents) {
123 -
      const docPath = doc.value.path;
124 -
      const localPost = postsByPath.get(docPath);
132 +
		for (const doc of documents) {
133 +
			const docPath = doc.value.path;
134 +
			const localPost = postsByPath.get(docPath);
125 135
126 -
      if (localPost) {
127 -
        matchedCount++;
128 -
        log.message(`  ✓ ${doc.value.title}`);
129 -
        log.message(`    Path: ${docPath}`);
130 -
        log.message(`    URI: ${doc.uri}`);
131 -
        log.message(`    File: ${path.basename(localPost.filePath)}`);
136 +
			if (localPost) {
137 +
				matchedCount++;
138 +
				log.message(`  ✓ ${doc.value.title}`);
139 +
				log.message(`    Path: ${docPath}`);
140 +
				log.message(`    URI: ${doc.uri}`);
141 +
				log.message(`    File: ${path.basename(localPost.filePath)}`);
132 142
133 -
        // Update state (use relative path from config directory)
134 -
        const contentHash = await getContentHash(localPost.rawContent);
135 -
        const relativeFilePath = path.relative(configDir, localPost.filePath);
136 -
        state.posts[relativeFilePath] = {
137 -
          contentHash,
138 -
          atUri: doc.uri,
139 -
          lastPublished: doc.value.publishedAt,
140 -
        };
143 +
				// Update state (use relative path from config directory)
144 +
				const contentHash = await getContentHash(localPost.rawContent);
145 +
				const relativeFilePath = path.relative(configDir, localPost.filePath);
146 +
				state.posts[relativeFilePath] = {
147 +
					contentHash,
148 +
					atUri: doc.uri,
149 +
					lastPublished: doc.value.publishedAt,
150 +
				};
141 151
142 -
        // Check if frontmatter needs updating
143 -
        if (updateFrontmatter && localPost.frontmatter.atUri !== doc.uri) {
144 -
          frontmatterUpdates.push({
145 -
            filePath: localPost.filePath,
146 -
            atUri: doc.uri,
147 -
          });
148 -
          log.message(`    → Will update frontmatter`);
149 -
        }
150 -
      } else {
151 -
        unmatchedCount++;
152 -
        log.message(`  ✗ ${doc.value.title} (no matching local file)`);
153 -
        log.message(`    Path: ${docPath}`);
154 -
        log.message(`    URI: ${doc.uri}`);
155 -
      }
156 -
      log.message("");
157 -
    }
152 +
				// Check if frontmatter needs updating
153 +
				if (updateFrontmatter && localPost.frontmatter.atUri !== doc.uri) {
154 +
					frontmatterUpdates.push({
155 +
						filePath: localPost.filePath,
156 +
						atUri: doc.uri,
157 +
					});
158 +
					log.message(`    → Will update frontmatter`);
159 +
				}
160 +
			} else {
161 +
				unmatchedCount++;
162 +
				log.message(`  ✗ ${doc.value.title} (no matching local file)`);
163 +
				log.message(`    Path: ${docPath}`);
164 +
				log.message(`    URI: ${doc.uri}`);
165 +
			}
166 +
			log.message("");
167 +
		}
158 168
159 -
    // Summary
160 -
    log.message("---");
161 -
    log.info(`Matched: ${matchedCount} documents`);
162 -
    if (unmatchedCount > 0) {
163 -
      log.warn(`Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`);
164 -
    }
169 +
		// Summary
170 +
		log.message("---");
171 +
		log.info(`Matched: ${matchedCount} documents`);
172 +
		if (unmatchedCount > 0) {
173 +
			log.warn(
174 +
				`Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`,
175 +
			);
176 +
		}
165 177
166 -
    if (dryRun) {
167 -
      log.info("\nDry run complete. No changes made.");
168 -
      return;
169 -
    }
178 +
		if (dryRun) {
179 +
			log.info("\nDry run complete. No changes made.");
180 +
			return;
181 +
		}
170 182
171 -
    // Save updated state
172 -
    await saveState(configDir, state);
173 -
    const newPostCount = Object.keys(state.posts).length;
174 -
    log.success(`\nSaved .sequoia-state.json (${originalPostCount} → ${newPostCount} entries)`);
183 +
		// Save updated state
184 +
		await saveState(configDir, state);
185 +
		const newPostCount = Object.keys(state.posts).length;
186 +
		log.success(
187 +
			`\nSaved .sequoia-state.json (${originalPostCount} → ${newPostCount} entries)`,
188 +
		);
175 189
176 -
    // Update frontmatter if requested
177 -
    if (frontmatterUpdates.length > 0) {
178 -
      s.start(`Updating frontmatter in ${frontmatterUpdates.length} files...`);
179 -
      for (const { filePath, atUri } of frontmatterUpdates) {
180 -
        const content = await fs.readFile(filePath, "utf-8");
181 -
        const updated = updateFrontmatterWithAtUri(content, atUri);
182 -
        await fs.writeFile(filePath, updated);
183 -
        log.message(`  Updated: ${path.basename(filePath)}`);
184 -
      }
185 -
      s.stop("Frontmatter updated");
186 -
    }
190 +
		// Update frontmatter if requested
191 +
		if (frontmatterUpdates.length > 0) {
192 +
			s.start(`Updating frontmatter in ${frontmatterUpdates.length} files...`);
193 +
			for (const { filePath, atUri } of frontmatterUpdates) {
194 +
				const content = await fs.readFile(filePath, "utf-8");
195 +
				const updated = updateFrontmatterWithAtUri(content, atUri);
196 +
				await fs.writeFile(filePath, updated);
197 +
				log.message(`  Updated: ${path.basename(filePath)}`);
198 +
			}
199 +
			s.stop("Frontmatter updated");
200 +
		}
187 201
188 -
    log.success("\nSync complete!");
189 -
  },
202 +
		log.success("\nSync complete!");
203 +
	},
190 204
});
packages/cli/src/lib/atproto.ts +443 −416
1 1
import { AtpAgent } from "@atproto/api";
2 -
import * as fs from "fs/promises";
3 -
import * as path from "path";
4 2
import * as mimeTypes from "mime-types";
5 -
import type { Credentials, BlogPost, BlobObject, PublisherConfig, StrongRef } from "./types";
3 +
import * as fs from "node:fs/promises";
4 +
import * as path from "node:path";
6 5
import { stripMarkdownForText } from "./markdown";
6 +
import type {
7 +
	BlobObject,
8 +
	BlogPost,
9 +
	Credentials,
10 +
	PublisherConfig,
11 +
	StrongRef,
12 +
} from "./types";
7 13
8 14
async function fileExists(filePath: string): Promise<boolean> {
9 -
  try {
10 -
    await fs.access(filePath);
11 -
    return true;
12 -
  } catch {
13 -
    return false;
14 -
  }
15 +
	try {
16 +
		await fs.access(filePath);
17 +
		return true;
18 +
	} catch {
19 +
		return false;
20 +
	}
15 21
}
16 22
17 23
export async function resolveHandleToPDS(handle: string): Promise<string> {
18 -
  // First, resolve the handle to a DID
19 -
  let did: string;
24 +
	// First, resolve the handle to a DID
25 +
	let did: string;
20 26
21 -
  if (handle.startsWith("did:")) {
22 -
    did = handle;
23 -
  } else {
24 -
    // Try to resolve handle via Bluesky API
25 -
    const resolveUrl = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`;
26 -
    const resolveResponse = await fetch(resolveUrl);
27 -
    if (!resolveResponse.ok) {
28 -
      throw new Error("Could not resolve handle");
29 -
    }
30 -
    const resolveData = (await resolveResponse.json()) as { did: string };
31 -
    did = resolveData.did;
32 -
  }
27 +
	if (handle.startsWith("did:")) {
28 +
		did = handle;
29 +
	} else {
30 +
		// Try to resolve handle via Bluesky API
31 +
		const resolveUrl = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`;
32 +
		const resolveResponse = await fetch(resolveUrl);
33 +
		if (!resolveResponse.ok) {
34 +
			throw new Error("Could not resolve handle");
35 +
		}
36 +
		const resolveData = (await resolveResponse.json()) as { did: string };
37 +
		did = resolveData.did;
38 +
	}
33 39
34 -
  // Now resolve the DID to get the PDS URL from the DID document
35 -
  let pdsUrl: string | undefined;
40 +
	// Now resolve the DID to get the PDS URL from the DID document
41 +
	let pdsUrl: string | undefined;
36 42
37 -
  if (did.startsWith("did:plc:")) {
38 -
    // Fetch DID document from plc.directory
39 -
    const didDocUrl = `https://plc.directory/${did}`;
40 -
    const didDocResponse = await fetch(didDocUrl);
41 -
    if (!didDocResponse.ok) {
42 -
      throw new Error("Could not fetch DID document");
43 -
    }
44 -
    const didDoc = (await didDocResponse.json()) as {
45 -
      service?: Array<{ id: string; type: string; serviceEndpoint: string }>;
46 -
    };
43 +
	if (did.startsWith("did:plc:")) {
44 +
		// Fetch DID document from plc.directory
45 +
		const didDocUrl = `https://plc.directory/${did}`;
46 +
		const didDocResponse = await fetch(didDocUrl);
47 +
		if (!didDocResponse.ok) {
48 +
			throw new Error("Could not fetch DID document");
49 +
		}
50 +
		const didDoc = (await didDocResponse.json()) as {
51 +
			service?: Array<{ id: string; type: string; serviceEndpoint: string }>;
52 +
		};
47 53
48 -
    // Find the PDS service endpoint
49 -
    const pdsService = didDoc.service?.find(
50 -
      (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
51 -
    );
52 -
    pdsUrl = pdsService?.serviceEndpoint;
53 -
  } else if (did.startsWith("did:web:")) {
54 -
    // For did:web, fetch the DID document from the domain
55 -
    const domain = did.replace("did:web:", "");
56 -
    const didDocUrl = `https://${domain}/.well-known/did.json`;
57 -
    const didDocResponse = await fetch(didDocUrl);
58 -
    if (!didDocResponse.ok) {
59 -
      throw new Error("Could not fetch DID document");
60 -
    }
61 -
    const didDoc = (await didDocResponse.json()) as {
62 -
      service?: Array<{ id: string; type: string; serviceEndpoint: string }>;
63 -
    };
54 +
		// Find the PDS service endpoint
55 +
		const pdsService = didDoc.service?.find(
56 +
			(s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
57 +
		);
58 +
		pdsUrl = pdsService?.serviceEndpoint;
59 +
	} else if (did.startsWith("did:web:")) {
60 +
		// For did:web, fetch the DID document from the domain
61 +
		const domain = did.replace("did:web:", "");
62 +
		const didDocUrl = `https://${domain}/.well-known/did.json`;
63 +
		const didDocResponse = await fetch(didDocUrl);
64 +
		if (!didDocResponse.ok) {
65 +
			throw new Error("Could not fetch DID document");
66 +
		}
67 +
		const didDoc = (await didDocResponse.json()) as {
68 +
			service?: Array<{ id: string; type: string; serviceEndpoint: string }>;
69 +
		};
64 70
65 -
    const pdsService = didDoc.service?.find(
66 -
      (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
67 -
    );
68 -
    pdsUrl = pdsService?.serviceEndpoint;
69 -
  }
71 +
		const pdsService = didDoc.service?.find(
72 +
			(s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
73 +
		);
74 +
		pdsUrl = pdsService?.serviceEndpoint;
75 +
	}
70 76
71 -
  if (!pdsUrl) {
72 -
    throw new Error("Could not find PDS URL for user");
73 -
  }
77 +
	if (!pdsUrl) {
78 +
		throw new Error("Could not find PDS URL for user");
79 +
	}
74 80
75 -
  return pdsUrl;
81 +
	return pdsUrl;
76 82
}
77 83
78 84
export interface CreatePublicationOptions {
79 -
  url: string;
80 -
  name: string;
81 -
  description?: string;
82 -
  iconPath?: string;
83 -
  showInDiscover?: boolean;
85 +
	url: string;
86 +
	name: string;
87 +
	description?: string;
88 +
	iconPath?: string;
89 +
	showInDiscover?: boolean;
84 90
}
85 91
86 92
export async function createAgent(credentials: Credentials): Promise<AtpAgent> {
87 -
  const agent = new AtpAgent({ service: credentials.pdsUrl });
93 +
	const agent = new AtpAgent({ service: credentials.pdsUrl });
88 94
89 -
  await agent.login({
90 -
    identifier: credentials.identifier,
91 -
    password: credentials.password,
92 -
  });
95 +
	await agent.login({
96 +
		identifier: credentials.identifier,
97 +
		password: credentials.password,
98 +
	});
93 99
94 -
  return agent;
100 +
	return agent;
95 101
}
96 102
97 103
export async function uploadImage(
98 -
  agent: AtpAgent,
99 -
  imagePath: string
104 +
	agent: AtpAgent,
105 +
	imagePath: string,
100 106
): Promise<BlobObject | undefined> {
101 -
  if (!(await fileExists(imagePath))) {
102 -
    return undefined;
103 -
  }
107 +
	if (!(await fileExists(imagePath))) {
108 +
		return undefined;
109 +
	}
104 110
105 -
  try {
106 -
    const imageBuffer = await fs.readFile(imagePath);
107 -
    const mimeType = mimeTypes.lookup(imagePath) || "application/octet-stream";
111 +
	try {
112 +
		const imageBuffer = await fs.readFile(imagePath);
113 +
		const mimeType = mimeTypes.lookup(imagePath) || "application/octet-stream";
108 114
109 -
    const response = await agent.com.atproto.repo.uploadBlob(
110 -
      new Uint8Array(imageBuffer),
111 -
      {
112 -
        encoding: mimeType,
113 -
      }
114 -
    );
115 +
		const response = await agent.com.atproto.repo.uploadBlob(
116 +
			new Uint8Array(imageBuffer),
117 +
			{
118 +
				encoding: mimeType,
119 +
			},
120 +
		);
115 121
116 -
    return {
117 -
      $type: "blob",
118 -
      ref: {
119 -
        $link: response.data.blob.ref.toString(),
120 -
      },
121 -
      mimeType,
122 -
      size: imageBuffer.byteLength,
123 -
    };
124 -
  } catch (error) {
125 -
    console.error(`Error uploading image ${imagePath}:`, error);
126 -
    return undefined;
127 -
  }
122 +
		return {
123 +
			$type: "blob",
124 +
			ref: {
125 +
				$link: response.data.blob.ref.toString(),
126 +
			},
127 +
			mimeType,
128 +
			size: imageBuffer.byteLength,
129 +
		};
130 +
	} catch (error) {
131 +
		console.error(`Error uploading image ${imagePath}:`, error);
132 +
		return undefined;
133 +
	}
128 134
}
129 135
130 136
export async function resolveImagePath(
131 -
  ogImage: string,
132 -
  imagesDir: string | undefined,
133 -
  contentDir: string
137 +
	ogImage: string,
138 +
	imagesDir: string | undefined,
139 +
	contentDir: string,
134 140
): Promise<string | null> {
135 -
  // Try multiple resolution strategies
136 -
  const filename = path.basename(ogImage);
141 +
	// Try multiple resolution strategies
142 +
	const filename = path.basename(ogImage);
137 143
138 -
  // 1. If imagesDir is specified, look there
139 -
  if (imagesDir) {
140 -
    const imagePath = path.join(imagesDir, filename);
141 -
    if (await fileExists(imagePath)) {
142 -
      const stat = await fs.stat(imagePath);
143 -
      if (stat.size > 0) {
144 -
        return imagePath;
145 -
      }
146 -
    }
147 -
  }
144 +
	// 1. If imagesDir is specified, look there
145 +
	if (imagesDir) {
146 +
		const imagePath = path.join(imagesDir, filename);
147 +
		if (await fileExists(imagePath)) {
148 +
			const stat = await fs.stat(imagePath);
149 +
			if (stat.size > 0) {
150 +
				return imagePath;
151 +
			}
152 +
		}
153 +
	}
148 154
149 -
  // 2. Try the ogImage path directly (if it's absolute)
150 -
  if (path.isAbsolute(ogImage)) {
151 -
    return ogImage;
152 -
  }
155 +
	// 2. Try the ogImage path directly (if it's absolute)
156 +
	if (path.isAbsolute(ogImage)) {
157 +
		return ogImage;
158 +
	}
153 159
154 -
  // 3. Try relative to content directory
155 -
  const contentRelative = path.join(contentDir, ogImage);
156 -
  if (await fileExists(contentRelative)) {
157 -
    const stat = await fs.stat(contentRelative);
158 -
    if (stat.size > 0) {
159 -
      return contentRelative;
160 -
    }
161 -
  }
160 +
	// 3. Try relative to content directory
161 +
	const contentRelative = path.join(contentDir, ogImage);
162 +
	if (await fileExists(contentRelative)) {
163 +
		const stat = await fs.stat(contentRelative);
164 +
		if (stat.size > 0) {
165 +
			return contentRelative;
166 +
		}
167 +
	}
162 168
163 -
  return null;
169 +
	return null;
164 170
}
165 171
166 172
export async function createDocument(
167 -
  agent: AtpAgent,
168 -
  post: BlogPost,
169 -
  config: PublisherConfig,
170 -
  coverImage?: BlobObject
173 +
	agent: AtpAgent,
174 +
	post: BlogPost,
175 +
	config: PublisherConfig,
176 +
	coverImage?: BlobObject,
171 177
): Promise<string> {
172 -
  const pathPrefix = config.pathPrefix || "/posts";
173 -
  const postPath = `${pathPrefix}/${post.slug}`;
174 -
  const publishDate = new Date(post.frontmatter.publishDate);
178 +
	const pathPrefix = config.pathPrefix || "/posts";
179 +
	const postPath = `${pathPrefix}/${post.slug}`;
180 +
	const publishDate = new Date(post.frontmatter.publishDate);
175 181
176 -
  // Determine textContent: use configured field from frontmatter, or fallback to markdown body
177 -
  let textContent: string;
178 -
  if (config.textContentField && post.rawFrontmatter?.[config.textContentField]) {
179 -
    textContent = String(post.rawFrontmatter[config.textContentField]);
180 -
  } else {
181 -
    textContent = stripMarkdownForText(post.content);
182 -
  }
182 +
	// Determine textContent: use configured field from frontmatter, or fallback to markdown body
183 +
	let textContent: string;
184 +
	if (
185 +
		config.textContentField &&
186 +
		post.rawFrontmatter?.[config.textContentField]
187 +
	) {
188 +
		textContent = String(post.rawFrontmatter[config.textContentField]);
189 +
	} else {
190 +
		textContent = stripMarkdownForText(post.content);
191 +
	}
183 192
184 -
  const record: Record<string, unknown> = {
185 -
    $type: "site.standard.document",
186 -
    title: post.frontmatter.title,
187 -
    site: config.publicationUri,
188 -
    path: postPath,
189 -
    textContent: textContent.slice(0, 10000),
190 -
    publishedAt: publishDate.toISOString(),
191 -
    canonicalUrl: `${config.siteUrl}${postPath}`,
192 -
  };
193 +
	const record: Record<string, unknown> = {
194 +
		$type: "site.standard.document",
195 +
		title: post.frontmatter.title,
196 +
		site: config.publicationUri,
197 +
		path: postPath,
198 +
		textContent: textContent.slice(0, 10000),
199 +
		publishedAt: publishDate.toISOString(),
200 +
		canonicalUrl: `${config.siteUrl}${postPath}`,
201 +
	};
193 202
194 -
  if (post.frontmatter.description) {
195 -
    record.description = post.frontmatter.description;
196 -
  }
203 +
	if (post.frontmatter.description) {
204 +
		record.description = post.frontmatter.description;
205 +
	}
197 206
198 -
  if (coverImage) {
199 -
    record.coverImage = coverImage;
200 -
  }
207 +
	if (coverImage) {
208 +
		record.coverImage = coverImage;
209 +
	}
201 210
202 -
  if (post.frontmatter.tags && post.frontmatter.tags.length > 0) {
203 -
    record.tags = post.frontmatter.tags;
204 -
  }
211 +
	if (post.frontmatter.tags && post.frontmatter.tags.length > 0) {
212 +
		record.tags = post.frontmatter.tags;
213 +
	}
205 214
206 -
  const response = await agent.com.atproto.repo.createRecord({
207 -
    repo: agent.session!.did,
208 -
    collection: "site.standard.document",
209 -
    record,
210 -
  });
215 +
	const response = await agent.com.atproto.repo.createRecord({
216 +
		repo: agent.session!.did,
217 +
		collection: "site.standard.document",
218 +
		record,
219 +
	});
211 220
212 -
  return response.data.uri;
221 +
	return response.data.uri;
213 222
}
214 223
215 224
export async function updateDocument(
216 -
  agent: AtpAgent,
217 -
  post: BlogPost,
218 -
  atUri: string,
219 -
  config: PublisherConfig,
220 -
  coverImage?: BlobObject
225 +
	agent: AtpAgent,
226 +
	post: BlogPost,
227 +
	atUri: string,
228 +
	config: PublisherConfig,
229 +
	coverImage?: BlobObject,
221 230
): Promise<void> {
222 -
  // Parse the atUri to get the collection and rkey
223 -
  // Format: at://did:plc:xxx/collection/rkey
224 -
  const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
225 -
  if (!uriMatch) {
226 -
    throw new Error(`Invalid atUri format: ${atUri}`);
227 -
  }
231 +
	// Parse the atUri to get the collection and rkey
232 +
	// Format: at://did:plc:xxx/collection/rkey
233 +
	const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
234 +
	if (!uriMatch) {
235 +
		throw new Error(`Invalid atUri format: ${atUri}`);
236 +
	}
228 237
229 -
  const [, , collection, rkey] = uriMatch;
238 +
	const [, , collection, rkey] = uriMatch;
230 239
231 -
  const pathPrefix = config.pathPrefix || "/posts";
232 -
  const postPath = `${pathPrefix}/${post.slug}`;
233 -
  const publishDate = new Date(post.frontmatter.publishDate);
240 +
	const pathPrefix = config.pathPrefix || "/posts";
241 +
	const postPath = `${pathPrefix}/${post.slug}`;
242 +
	const publishDate = new Date(post.frontmatter.publishDate);
234 243
235 -
  // Determine textContent: use configured field from frontmatter, or fallback to markdown body
236 -
  let textContent: string;
237 -
  if (config.textContentField && post.rawFrontmatter?.[config.textContentField]) {
238 -
    textContent = String(post.rawFrontmatter[config.textContentField]);
239 -
  } else {
240 -
    textContent = stripMarkdownForText(post.content);
241 -
  }
244 +
	// Determine textContent: use configured field from frontmatter, or fallback to markdown body
245 +
	let textContent: string;
246 +
	if (
247 +
		config.textContentField &&
248 +
		post.rawFrontmatter?.[config.textContentField]
249 +
	) {
250 +
		textContent = String(post.rawFrontmatter[config.textContentField]);
251 +
	} else {
252 +
		textContent = stripMarkdownForText(post.content);
253 +
	}
242 254
243 -
  const record: Record<string, unknown> = {
244 -
    $type: "site.standard.document",
245 -
    title: post.frontmatter.title,
246 -
    site: config.publicationUri,
247 -
    path: postPath,
248 -
    textContent: textContent.slice(0, 10000),
249 -
    publishedAt: publishDate.toISOString(),
250 -
    canonicalUrl: `${config.siteUrl}${postPath}`,
251 -
  };
255 +
	const record: Record<string, unknown> = {
256 +
		$type: "site.standard.document",
257 +
		title: post.frontmatter.title,
258 +
		site: config.publicationUri,
259 +
		path: postPath,
260 +
		textContent: textContent.slice(0, 10000),
261 +
		publishedAt: publishDate.toISOString(),
262 +
		canonicalUrl: `${config.siteUrl}${postPath}`,
263 +
	};
252 264
253 -
  if (post.frontmatter.description) {
254 -
    record.description = post.frontmatter.description;
255 -
  }
265 +
	if (post.frontmatter.description) {
266 +
		record.description = post.frontmatter.description;
267 +
	}
256 268
257 -
  if (coverImage) {
258 -
    record.coverImage = coverImage;
259 -
  }
269 +
	if (coverImage) {
270 +
		record.coverImage = coverImage;
271 +
	}
260 272
261 -
  if (post.frontmatter.tags && post.frontmatter.tags.length > 0) {
262 -
    record.tags = post.frontmatter.tags;
263 -
  }
273 +
	if (post.frontmatter.tags && post.frontmatter.tags.length > 0) {
274 +
		record.tags = post.frontmatter.tags;
275 +
	}
264 276
265 -
  await agent.com.atproto.repo.putRecord({
266 -
    repo: agent.session!.did,
267 -
    collection: collection!,
268 -
    rkey: rkey!,
269 -
    record,
270 -
  });
277 +
	await agent.com.atproto.repo.putRecord({
278 +
		repo: agent.session!.did,
279 +
		collection: collection!,
280 +
		rkey: rkey!,
281 +
		record,
282 +
	});
271 283
}
272 284
273 -
export function parseAtUri(atUri: string): { did: string; collection: string; rkey: string } | null {
274 -
  const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
275 -
  if (!match) return null;
276 -
  return {
277 -
    did: match[1]!,
278 -
    collection: match[2]!,
279 -
    rkey: match[3]!,
280 -
  };
285 +
export function parseAtUri(
286 +
	atUri: string,
287 +
): { did: string; collection: string; rkey: string } | null {
288 +
	const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
289 +
	if (!match) return null;
290 +
	return {
291 +
		did: match[1]!,
292 +
		collection: match[2]!,
293 +
		rkey: match[3]!,
294 +
	};
281 295
}
282 296
283 297
export interface DocumentRecord {
284 -
  $type: "site.standard.document";
285 -
  title: string;
286 -
  site: string;
287 -
  path: string;
288 -
  textContent: string;
289 -
  publishedAt: string;
290 -
  canonicalUrl?: string;
291 -
  description?: string;
292 -
  coverImage?: BlobObject;
293 -
  tags?: string[];
294 -
  location?: string;
298 +
	$type: "site.standard.document";
299 +
	title: string;
300 +
	site: string;
301 +
	path: string;
302 +
	textContent: string;
303 +
	publishedAt: string;
304 +
	canonicalUrl?: string;
305 +
	description?: string;
306 +
	coverImage?: BlobObject;
307 +
	tags?: string[];
308 +
	location?: string;
295 309
}
296 310
297 311
export interface ListDocumentsResult {
298 -
  uri: string;
299 -
  cid: string;
300 -
  value: DocumentRecord;
312 +
	uri: string;
313 +
	cid: string;
314 +
	value: DocumentRecord;
301 315
}
302 316
303 317
export async function listDocuments(
304 -
  agent: AtpAgent,
305 -
  publicationUri?: string
318 +
	agent: AtpAgent,
319 +
	publicationUri?: string,
306 320
): Promise<ListDocumentsResult[]> {
307 -
  const documents: ListDocumentsResult[] = [];
308 -
  let cursor: string | undefined;
321 +
	const documents: ListDocumentsResult[] = [];
322 +
	let cursor: string | undefined;
309 323
310 -
  do {
311 -
    const response = await agent.com.atproto.repo.listRecords({
312 -
      repo: agent.session!.did,
313 -
      collection: "site.standard.document",
314 -
      limit: 100,
315 -
      cursor,
316 -
    });
324 +
	do {
325 +
		const response = await agent.com.atproto.repo.listRecords({
326 +
			repo: agent.session!.did,
327 +
			collection: "site.standard.document",
328 +
			limit: 100,
329 +
			cursor,
330 +
		});
317 331
318 -
    for (const record of response.data.records) {
319 -
      const value = record.value as unknown as DocumentRecord;
332 +
		for (const record of response.data.records) {
333 +
			const value = record.value as unknown as DocumentRecord;
320 334
321 -
      // If publicationUri is specified, only include documents from that publication
322 -
      if (publicationUri && value.site !== publicationUri) {
323 -
        continue;
324 -
      }
335 +
			// If publicationUri is specified, only include documents from that publication
336 +
			if (publicationUri && value.site !== publicationUri) {
337 +
				continue;
338 +
			}
325 339
326 -
      documents.push({
327 -
        uri: record.uri,
328 -
        cid: record.cid,
329 -
        value,
330 -
      });
331 -
    }
340 +
			documents.push({
341 +
				uri: record.uri,
342 +
				cid: record.cid,
343 +
				value,
344 +
			});
345 +
		}
332 346
333 -
    cursor = response.data.cursor;
334 -
  } while (cursor);
347 +
		cursor = response.data.cursor;
348 +
	} while (cursor);
335 349
336 -
  return documents;
350 +
	return documents;
337 351
}
338 352
339 353
export async function createPublication(
340 -
  agent: AtpAgent,
341 -
  options: CreatePublicationOptions
354 +
	agent: AtpAgent,
355 +
	options: CreatePublicationOptions,
342 356
): Promise<string> {
343 -
  let icon: BlobObject | undefined;
357 +
	let icon: BlobObject | undefined;
344 358
345 -
  if (options.iconPath) {
346 -
    icon = await uploadImage(agent, options.iconPath);
347 -
  }
359 +
	if (options.iconPath) {
360 +
		icon = await uploadImage(agent, options.iconPath);
361 +
	}
348 362
349 -
  const record: Record<string, unknown> = {
350 -
    $type: "site.standard.publication",
351 -
    url: options.url,
352 -
    name: options.name,
353 -
    createdAt: new Date().toISOString(),
354 -
  };
363 +
	const record: Record<string, unknown> = {
364 +
		$type: "site.standard.publication",
365 +
		url: options.url,
366 +
		name: options.name,
367 +
		createdAt: new Date().toISOString(),
368 +
	};
355 369
356 -
  if (options.description) {
357 -
    record.description = options.description;
358 -
  }
370 +
	if (options.description) {
371 +
		record.description = options.description;
372 +
	}
359 373
360 -
  if (icon) {
361 -
    record.icon = icon;
362 -
  }
374 +
	if (icon) {
375 +
		record.icon = icon;
376 +
	}
363 377
364 -
  if (options.showInDiscover !== undefined) {
365 -
    record.preferences = {
366 -
      showInDiscover: options.showInDiscover,
367 -
    };
368 -
  }
378 +
	if (options.showInDiscover !== undefined) {
379 +
		record.preferences = {
380 +
			showInDiscover: options.showInDiscover,
381 +
		};
382 +
	}
369 383
370 -
  const response = await agent.com.atproto.repo.createRecord({
371 -
    repo: agent.session!.did,
372 -
    collection: "site.standard.publication",
373 -
    record,
374 -
  });
384 +
	const response = await agent.com.atproto.repo.createRecord({
385 +
		repo: agent.session!.did,
386 +
		collection: "site.standard.publication",
387 +
		record,
388 +
	});
375 389
376 -
  return response.data.uri;
390 +
	return response.data.uri;
377 391
}
378 392
379 393
// --- Bluesky Post Creation ---
380 394
381 395
export interface CreateBlueskyPostOptions {
382 -
  title: string;
383 -
  description?: string;
384 -
  canonicalUrl: string;
385 -
  coverImage?: BlobObject;
386 -
  publishedAt: string; // Used as createdAt for the post
396 +
	title: string;
397 +
	description?: string;
398 +
	canonicalUrl: string;
399 +
	coverImage?: BlobObject;
400 +
	publishedAt: string; // Used as createdAt for the post
387 401
}
388 402
389 403
/**
390 404
 * Count graphemes in a string (for Bluesky's 300 grapheme limit)
391 405
 */
392 406
function countGraphemes(str: string): number {
393 -
  // Use Intl.Segmenter if available, otherwise fallback to spread operator
394 -
  if (typeof Intl !== "undefined" && Intl.Segmenter) {
395 -
    const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
396 -
    return [...segmenter.segment(str)].length;
397 -
  }
398 -
  return [...str].length;
407 +
	// Use Intl.Segmenter if available, otherwise fallback to spread operator
408 +
	if (typeof Intl !== "undefined" && Intl.Segmenter) {
409 +
		const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
410 +
		return [...segmenter.segment(str)].length;
411 +
	}
412 +
	return [...str].length;
399 413
}
400 414
401 415
/**
402 416
 * Truncate a string to a maximum number of graphemes
403 417
 */
404 418
function truncateToGraphemes(str: string, maxGraphemes: number): string {
405 -
  if (typeof Intl !== "undefined" && Intl.Segmenter) {
406 -
    const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
407 -
    const segments = [...segmenter.segment(str)];
408 -
    if (segments.length <= maxGraphemes) return str;
409 -
    return segments.slice(0, maxGraphemes - 3).map(s => s.segment).join("") + "...";
410 -
  }
411 -
  // Fallback
412 -
  const chars = [...str];
413 -
  if (chars.length <= maxGraphemes) return str;
414 -
  return chars.slice(0, maxGraphemes - 3).join("") + "...";
419 +
	if (typeof Intl !== "undefined" && Intl.Segmenter) {
420 +
		const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
421 +
		const segments = [...segmenter.segment(str)];
422 +
		if (segments.length <= maxGraphemes) return str;
423 +
		return (
424 +
			segments
425 +
				.slice(0, maxGraphemes - 3)
426 +
				.map((s) => s.segment)
427 +
				.join("") + "..."
428 +
		);
429 +
	}
430 +
	// Fallback
431 +
	const chars = [...str];
432 +
	if (chars.length <= maxGraphemes) return str;
433 +
	return chars.slice(0, maxGraphemes - 3).join("") + "...";
415 434
}
416 435
417 436
/**
418 437
 * Create a Bluesky post with external link embed
419 438
 */
420 439
export async function createBlueskyPost(
421 -
  agent: AtpAgent,
422 -
  options: CreateBlueskyPostOptions
440 +
	agent: AtpAgent,
441 +
	options: CreateBlueskyPostOptions,
423 442
): Promise<StrongRef> {
424 -
  const { title, description, canonicalUrl, coverImage, publishedAt } = options;
443 +
	const { title, description, canonicalUrl, coverImage, publishedAt } = options;
425 444
426 -
  // Build post text: title + description + URL
427 -
  // Max 300 graphemes for Bluesky posts
428 -
  const MAX_GRAPHEMES = 300;
445 +
	// Build post text: title + description + URL
446 +
	// Max 300 graphemes for Bluesky posts
447 +
	const MAX_GRAPHEMES = 300;
429 448
430 -
  let postText: string;
431 -
  const urlPart = `\n\n${canonicalUrl}`;
432 -
  const urlGraphemes = countGraphemes(urlPart);
449 +
	let postText: string;
450 +
	const urlPart = `\n\n${canonicalUrl}`;
451 +
	const urlGraphemes = countGraphemes(urlPart);
433 452
434 -
  if (description) {
435 -
    // Try: title + description + URL
436 -
    const fullText = `${title}\n\n${description}${urlPart}`;
437 -
    if (countGraphemes(fullText) <= MAX_GRAPHEMES) {
438 -
      postText = fullText;
439 -
    } else {
440 -
      // Truncate description to fit
441 -
      const availableForDesc = MAX_GRAPHEMES - countGraphemes(title) - countGraphemes("\n\n") - urlGraphemes - countGraphemes("\n\n");
442 -
      if (availableForDesc > 10) {
443 -
        const truncatedDesc = truncateToGraphemes(description, availableForDesc);
444 -
        postText = `${title}\n\n${truncatedDesc}${urlPart}`;
445 -
      } else {
446 -
        // Just title + URL
447 -
        postText = `${title}${urlPart}`;
448 -
      }
449 -
    }
450 -
  } else {
451 -
    // Just title + URL
452 -
    postText = `${title}${urlPart}`;
453 -
  }
453 +
	if (description) {
454 +
		// Try: title + description + URL
455 +
		const fullText = `${title}\n\n${description}${urlPart}`;
456 +
		if (countGraphemes(fullText) <= MAX_GRAPHEMES) {
457 +
			postText = fullText;
458 +
		} else {
459 +
			// Truncate description to fit
460 +
			const availableForDesc =
461 +
				MAX_GRAPHEMES -
462 +
				countGraphemes(title) -
463 +
				countGraphemes("\n\n") -
464 +
				urlGraphemes -
465 +
				countGraphemes("\n\n");
466 +
			if (availableForDesc > 10) {
467 +
				const truncatedDesc = truncateToGraphemes(
468 +
					description,
469 +
					availableForDesc,
470 +
				);
471 +
				postText = `${title}\n\n${truncatedDesc}${urlPart}`;
472 +
			} else {
473 +
				// Just title + URL
474 +
				postText = `${title}${urlPart}`;
475 +
			}
476 +
		}
477 +
	} else {
478 +
		// Just title + URL
479 +
		postText = `${title}${urlPart}`;
480 +
	}
454 481
455 -
  // Final truncation if still too long (shouldn't happen but safety check)
456 -
  if (countGraphemes(postText) > MAX_GRAPHEMES) {
457 -
    postText = truncateToGraphemes(postText, MAX_GRAPHEMES);
458 -
  }
482 +
	// Final truncation if still too long (shouldn't happen but safety check)
483 +
	if (countGraphemes(postText) > MAX_GRAPHEMES) {
484 +
		postText = truncateToGraphemes(postText, MAX_GRAPHEMES);
485 +
	}
459 486
460 -
  // Calculate byte indices for the URL facet
461 -
  const encoder = new TextEncoder();
462 -
  const urlStartInText = postText.lastIndexOf(canonicalUrl);
463 -
  const beforeUrl = postText.substring(0, urlStartInText);
464 -
  const byteStart = encoder.encode(beforeUrl).length;
465 -
  const byteEnd = byteStart + encoder.encode(canonicalUrl).length;
487 +
	// Calculate byte indices for the URL facet
488 +
	const encoder = new TextEncoder();
489 +
	const urlStartInText = postText.lastIndexOf(canonicalUrl);
490 +
	const beforeUrl = postText.substring(0, urlStartInText);
491 +
	const byteStart = encoder.encode(beforeUrl).length;
492 +
	const byteEnd = byteStart + encoder.encode(canonicalUrl).length;
466 493
467 -
  // Build facets for the URL link
468 -
  const facets = [
469 -
    {
470 -
      index: {
471 -
        byteStart,
472 -
        byteEnd,
473 -
      },
474 -
      features: [
475 -
        {
476 -
          $type: "app.bsky.richtext.facet#link",
477 -
          uri: canonicalUrl,
478 -
        },
479 -
      ],
480 -
    },
481 -
  ];
494 +
	// Build facets for the URL link
495 +
	const facets = [
496 +
		{
497 +
			index: {
498 +
				byteStart,
499 +
				byteEnd,
500 +
			},
501 +
			features: [
502 +
				{
503 +
					$type: "app.bsky.richtext.facet#link",
504 +
					uri: canonicalUrl,
505 +
				},
506 +
			],
507 +
		},
508 +
	];
482 509
483 -
  // Build external embed
484 -
  const embed: Record<string, unknown> = {
485 -
    $type: "app.bsky.embed.external",
486 -
    external: {
487 -
      uri: canonicalUrl,
488 -
      title: title.substring(0, 500), // Max 500 chars for title
489 -
      description: (description || "").substring(0, 1000), // Max 1000 chars for description
490 -
    },
491 -
  };
510 +
	// Build external embed
511 +
	const embed: Record<string, unknown> = {
512 +
		$type: "app.bsky.embed.external",
513 +
		external: {
514 +
			uri: canonicalUrl,
515 +
			title: title.substring(0, 500), // Max 500 chars for title
516 +
			description: (description || "").substring(0, 1000), // Max 1000 chars for description
517 +
		},
518 +
	};
492 519
493 -
  // Add thumbnail if coverImage is available
494 -
  if (coverImage) {
495 -
    (embed.external as Record<string, unknown>).thumb = coverImage;
496 -
  }
520 +
	// Add thumbnail if coverImage is available
521 +
	if (coverImage) {
522 +
		(embed.external as Record<string, unknown>).thumb = coverImage;
523 +
	}
497 524
498 -
  // Create the post record
499 -
  const record: Record<string, unknown> = {
500 -
    $type: "app.bsky.feed.post",
501 -
    text: postText,
502 -
    facets,
503 -
    embed,
504 -
    createdAt: new Date(publishedAt).toISOString(),
505 -
  };
525 +
	// Create the post record
526 +
	const record: Record<string, unknown> = {
527 +
		$type: "app.bsky.feed.post",
528 +
		text: postText,
529 +
		facets,
530 +
		embed,
531 +
		createdAt: new Date(publishedAt).toISOString(),
532 +
	};
506 533
507 -
  const response = await agent.com.atproto.repo.createRecord({
508 -
    repo: agent.session!.did,
509 -
    collection: "app.bsky.feed.post",
510 -
    record,
511 -
  });
534 +
	const response = await agent.com.atproto.repo.createRecord({
535 +
		repo: agent.session!.did,
536 +
		collection: "app.bsky.feed.post",
537 +
		record,
538 +
	});
512 539
513 -
  return {
514 -
    uri: response.data.uri,
515 -
    cid: response.data.cid,
516 -
  };
540 +
	return {
541 +
		uri: response.data.uri,
542 +
		cid: response.data.cid,
543 +
	};
517 544
}
518 545
519 546
/**
520 547
 * Add bskyPostRef to an existing document record
521 548
 */
522 549
export async function addBskyPostRefToDocument(
523 -
  agent: AtpAgent,
524 -
  documentAtUri: string,
525 -
  bskyPostRef: StrongRef
550 +
	agent: AtpAgent,
551 +
	documentAtUri: string,
552 +
	bskyPostRef: StrongRef,
526 553
): Promise<void> {
527 -
  const parsed = parseAtUri(documentAtUri);
528 -
  if (!parsed) {
529 -
    throw new Error(`Invalid document URI: ${documentAtUri}`);
530 -
  }
554 +
	const parsed = parseAtUri(documentAtUri);
555 +
	if (!parsed) {
556 +
		throw new Error(`Invalid document URI: ${documentAtUri}`);
557 +
	}
531 558
532 -
  // Fetch existing record
533 -
  const existingRecord = await agent.com.atproto.repo.getRecord({
534 -
    repo: parsed.did,
535 -
    collection: parsed.collection,
536 -
    rkey: parsed.rkey,
537 -
  });
559 +
	// Fetch existing record
560 +
	const existingRecord = await agent.com.atproto.repo.getRecord({
561 +
		repo: parsed.did,
562 +
		collection: parsed.collection,
563 +
		rkey: parsed.rkey,
564 +
	});
538 565
539 -
  // Add bskyPostRef to the record
540 -
  const updatedRecord = {
541 -
    ...(existingRecord.data.value as Record<string, unknown>),
542 -
    bskyPostRef,
543 -
  };
566 +
	// Add bskyPostRef to the record
567 +
	const updatedRecord = {
568 +
		...(existingRecord.data.value as Record<string, unknown>),
569 +
		bskyPostRef,
570 +
	};
544 571
545 -
  // Update the record
546 -
  await agent.com.atproto.repo.putRecord({
547 -
    repo: parsed.did,
548 -
    collection: parsed.collection,
549 -
    rkey: parsed.rkey,
550 -
    record: updatedRecord,
551 -
  });
572 +
	// Update the record
573 +
	await agent.com.atproto.repo.putRecord({
574 +
		repo: parsed.did,
575 +
		collection: parsed.collection,
576 +
		rkey: parsed.rkey,
577 +
		record: updatedRecord,
578 +
	});
552 579
}
packages/cli/src/lib/config.ts +9 −3
1 -
import * as fs from "fs/promises";
2 -
import * as path from "path";
3 -
import type { PublisherConfig, PublisherState, FrontmatterMapping, BlueskyConfig } from "./types";
1 +
import * as fs from "node:fs/promises";
2 +
import * as path from "node:path";
3 +
import type {
4 +
	PublisherConfig,
5 +
	PublisherState,
6 +
	FrontmatterMapping,
7 +
	BlueskyConfig,
8 +
} from "./types";
4 9
5 10
const CONFIG_FILENAME = "sequoia.json";
6 11
const STATE_FILENAME = ".sequoia-state.json";
131 136
132 137
	if (options.textContentField) {
133 138
		config.textContentField = options.textContentField;
139 +
	}
134 140
	if (options.bluesky) {
135 141
		config.bluesky = options.bluesky;
136 142
	}
packages/cli/src/lib/credentials.ts +90 −90
1 -
import * as fs from "fs/promises";
2 -
import * as path from "path";
3 -
import * as os from "os";
1 +
import * as fs from "node:fs/promises";
2 +
import * as os from "node:os";
3 +
import * as path from "node:path";
4 4
import type { Credentials } from "./types";
5 5
6 6
const CONFIG_DIR = path.join(os.homedir(), ".config", "sequoia");
10 10
type CredentialsStore = Record<string, Credentials>;
11 11
12 12
async function fileExists(filePath: string): Promise<boolean> {
13 -
  try {
14 -
    await fs.access(filePath);
15 -
    return true;
16 -
  } catch {
17 -
    return false;
18 -
  }
13 +
	try {
14 +
		await fs.access(filePath);
15 +
		return true;
16 +
	} catch {
17 +
		return false;
18 +
	}
19 19
}
20 20
21 21
/**
22 22
 * Load all stored credentials
23 23
 */
24 24
async function loadCredentialsStore(): Promise<CredentialsStore> {
25 -
  if (!(await fileExists(CREDENTIALS_FILE))) {
26 -
    return {};
27 -
  }
25 +
	if (!(await fileExists(CREDENTIALS_FILE))) {
26 +
		return {};
27 +
	}
28 28
29 -
  try {
30 -
    const content = await fs.readFile(CREDENTIALS_FILE, "utf-8");
31 -
    const parsed = JSON.parse(content);
29 +
	try {
30 +
		const content = await fs.readFile(CREDENTIALS_FILE, "utf-8");
31 +
		const parsed = JSON.parse(content);
32 32
33 -
    // Handle legacy single-credential format (migrate on read)
34 -
    if (parsed.identifier && parsed.password) {
35 -
      const legacy = parsed as Credentials;
36 -
      return { [legacy.identifier]: legacy };
37 -
    }
33 +
		// Handle legacy single-credential format (migrate on read)
34 +
		if (parsed.identifier && parsed.password) {
35 +
			const legacy = parsed as Credentials;
36 +
			return { [legacy.identifier]: legacy };
37 +
		}
38 38
39 -
    return parsed as CredentialsStore;
40 -
  } catch {
41 -
    return {};
42 -
  }
39 +
		return parsed as CredentialsStore;
40 +
	} catch {
41 +
		return {};
42 +
	}
43 43
}
44 44
45 45
/**
46 46
 * Save the entire credentials store
47 47
 */
48 48
async function saveCredentialsStore(store: CredentialsStore): Promise<void> {
49 -
  await fs.mkdir(CONFIG_DIR, { recursive: true });
50 -
  await fs.writeFile(CREDENTIALS_FILE, JSON.stringify(store, null, 2));
51 -
  await fs.chmod(CREDENTIALS_FILE, 0o600);
49 +
	await fs.mkdir(CONFIG_DIR, { recursive: true });
50 +
	await fs.writeFile(CREDENTIALS_FILE, JSON.stringify(store, null, 2));
51 +
	await fs.chmod(CREDENTIALS_FILE, 0o600);
52 52
}
53 53
54 54
/**
62 62
 * 5. Return null (caller should prompt user)
63 63
 */
64 64
export async function loadCredentials(
65 -
  projectIdentity?: string
65 +
	projectIdentity?: string,
66 66
): Promise<Credentials | null> {
67 -
  // 1. Check environment variables first (full override)
68 -
  const envIdentifier = process.env.ATP_IDENTIFIER;
69 -
  const envPassword = process.env.ATP_APP_PASSWORD;
70 -
  const envPdsUrl = process.env.PDS_URL;
67 +
	// 1. Check environment variables first (full override)
68 +
	const envIdentifier = process.env.ATP_IDENTIFIER;
69 +
	const envPassword = process.env.ATP_APP_PASSWORD;
70 +
	const envPdsUrl = process.env.PDS_URL;
71 71
72 -
  if (envIdentifier && envPassword) {
73 -
    return {
74 -
      identifier: envIdentifier,
75 -
      password: envPassword,
76 -
      pdsUrl: envPdsUrl || "https://bsky.social",
77 -
    };
78 -
  }
72 +
	if (envIdentifier && envPassword) {
73 +
		return {
74 +
			identifier: envIdentifier,
75 +
			password: envPassword,
76 +
			pdsUrl: envPdsUrl || "https://bsky.social",
77 +
		};
78 +
	}
79 79
80 -
  const store = await loadCredentialsStore();
81 -
  const identifiers = Object.keys(store);
80 +
	const store = await loadCredentialsStore();
81 +
	const identifiers = Object.keys(store);
82 82
83 -
  if (identifiers.length === 0) {
84 -
    return null;
85 -
  }
83 +
	if (identifiers.length === 0) {
84 +
		return null;
85 +
	}
86 86
87 -
  // 2. SEQUOIA_PROFILE env var
88 -
  const profileEnv = process.env.SEQUOIA_PROFILE;
89 -
  if (profileEnv && store[profileEnv]) {
90 -
    return store[profileEnv];
91 -
  }
87 +
	// 2. SEQUOIA_PROFILE env var
88 +
	const profileEnv = process.env.SEQUOIA_PROFILE;
89 +
	if (profileEnv && store[profileEnv]) {
90 +
		return store[profileEnv];
91 +
	}
92 92
93 -
  // 3. Project-specific identity (from sequoia.json)
94 -
  if (projectIdentity && store[projectIdentity]) {
95 -
    return store[projectIdentity];
96 -
  }
93 +
	// 3. Project-specific identity (from sequoia.json)
94 +
	if (projectIdentity && store[projectIdentity]) {
95 +
		return store[projectIdentity];
96 +
	}
97 97
98 -
  // 4. If only one identity, use it
99 -
  if (identifiers.length === 1 && identifiers[0]) {
100 -
    return store[identifiers[0]] ?? null;
101 -
  }
98 +
	// 4. If only one identity, use it
99 +
	if (identifiers.length === 1 && identifiers[0]) {
100 +
		return store[identifiers[0]] ?? null;
101 +
	}
102 102
103 -
  // Multiple identities exist but none selected
104 -
  return null;
103 +
	// Multiple identities exist but none selected
104 +
	return null;
105 105
}
106 106
107 107
/**
108 108
 * Get a specific identity by identifier
109 109
 */
110 110
export async function getCredentials(
111 -
  identifier: string
111 +
	identifier: string,
112 112
): Promise<Credentials | null> {
113 -
  const store = await loadCredentialsStore();
114 -
  return store[identifier] || null;
113 +
	const store = await loadCredentialsStore();
114 +
	return store[identifier] || null;
115 115
}
116 116
117 117
/**
118 118
 * List all stored identities
119 119
 */
120 120
export async function listCredentials(): Promise<string[]> {
121 -
  const store = await loadCredentialsStore();
122 -
  return Object.keys(store);
121 +
	const store = await loadCredentialsStore();
122 +
	return Object.keys(store);
123 123
}
124 124
125 125
/**
126 126
 * Save credentials for an identity (adds or updates)
127 127
 */
128 128
export async function saveCredentials(credentials: Credentials): Promise<void> {
129 -
  const store = await loadCredentialsStore();
130 -
  store[credentials.identifier] = credentials;
131 -
  await saveCredentialsStore(store);
129 +
	const store = await loadCredentialsStore();
130 +
	store[credentials.identifier] = credentials;
131 +
	await saveCredentialsStore(store);
132 132
}
133 133
134 134
/**
135 135
 * Delete credentials for a specific identity
136 136
 */
137 137
export async function deleteCredentials(identifier?: string): Promise<boolean> {
138 -
  const store = await loadCredentialsStore();
139 -
  const identifiers = Object.keys(store);
138 +
	const store = await loadCredentialsStore();
139 +
	const identifiers = Object.keys(store);
140 140
141 -
  if (identifiers.length === 0) {
142 -
    return false;
143 -
  }
141 +
	if (identifiers.length === 0) {
142 +
		return false;
143 +
	}
144 144
145 -
  // If identifier specified, delete just that one
146 -
  if (identifier) {
147 -
    if (!store[identifier]) {
148 -
      return false;
149 -
    }
150 -
    delete store[identifier];
151 -
    await saveCredentialsStore(store);
152 -
    return true;
153 -
  }
145 +
	// If identifier specified, delete just that one
146 +
	if (identifier) {
147 +
		if (!store[identifier]) {
148 +
			return false;
149 +
		}
150 +
		delete store[identifier];
151 +
		await saveCredentialsStore(store);
152 +
		return true;
153 +
	}
154 154
155 -
  // If only one identity, delete it (backwards compat behavior)
156 -
  if (identifiers.length === 1 && identifiers[0]) {
157 -
    delete store[identifiers[0]];
158 -
    await saveCredentialsStore(store);
159 -
    return true;
160 -
  }
155 +
	// If only one identity, delete it (backwards compat behavior)
156 +
	if (identifiers.length === 1 && identifiers[0]) {
157 +
		delete store[identifiers[0]];
158 +
		await saveCredentialsStore(store);
159 +
		return true;
160 +
	}
161 161
162 -
  // Multiple identities but none specified
163 -
  return false;
162 +
	// Multiple identities but none specified
163 +
	return false;
164 164
}
165 165
166 166
export function getCredentialsPath(): string {
167 -
  return CREDENTIALS_FILE;
167 +
	return CREDENTIALS_FILE;
168 168
}
packages/cli/src/lib/markdown.ts +321 −289
1 -
import * as fs from "fs/promises";
2 -
import * as path from "path";
1 +
import * as fs from "node:fs/promises";
2 +
import * as path from "node:path";
3 3
import { glob } from "glob";
4 4
import { minimatch } from "minimatch";
5 -
import type { PostFrontmatter, BlogPost, FrontmatterMapping } from "./types";
5 +
import type { BlogPost, FrontmatterMapping, PostFrontmatter } from "./types";
6 6
7 -
export function parseFrontmatter(content: string, mapping?: FrontmatterMapping): {
8 -
  frontmatter: PostFrontmatter;
9 -
  body: string;
10 -
  rawFrontmatter: Record<string, unknown>;
7 +
export function parseFrontmatter(
8 +
	content: string,
9 +
	mapping?: FrontmatterMapping,
10 +
): {
11 +
	frontmatter: PostFrontmatter;
12 +
	body: string;
13 +
	rawFrontmatter: Record<string, unknown>;
11 14
} {
12 -
  // Support multiple frontmatter delimiters:
13 -
  // --- (YAML) - Jekyll, Astro, most SSGs
14 -
  // +++ (TOML) - Hugo
15 -
  // *** - Alternative format
16 -
  const frontmatterRegex = /^(---|\+\+\+|\*\*\*)\n([\s\S]*?)\n\1\n([\s\S]*)$/;
17 -
  const match = content.match(frontmatterRegex);
15 +
	// Support multiple frontmatter delimiters:
16 +
	// --- (YAML) - Jekyll, Astro, most SSGs
17 +
	// +++ (TOML) - Hugo
18 +
	// *** - Alternative format
19 +
	const frontmatterRegex = /^(---|\+\+\+|\*\*\*)\n([\s\S]*?)\n\1\n([\s\S]*)$/;
20 +
	const match = content.match(frontmatterRegex);
18 21
19 -
  if (!match) {
20 -
    throw new Error("Could not parse frontmatter");
21 -
  }
22 +
	if (!match) {
23 +
		throw new Error("Could not parse frontmatter");
24 +
	}
22 25
23 -
  const delimiter = match[1];
24 -
  const frontmatterStr = match[2] ?? "";
25 -
  const body = match[3] ?? "";
26 +
	const delimiter = match[1];
27 +
	const frontmatterStr = match[2] ?? "";
28 +
	const body = match[3] ?? "";
26 29
27 -
  // Determine format based on delimiter:
28 -
  // +++ uses TOML (key = value)
29 -
  // --- and *** use YAML (key: value)
30 -
  const isToml = delimiter === "+++";
31 -
  const separator = isToml ? "=" : ":";
30 +
	// Determine format based on delimiter:
31 +
	// +++ uses TOML (key = value)
32 +
	// --- and *** use YAML (key: value)
33 +
	const isToml = delimiter === "+++";
34 +
	const separator = isToml ? "=" : ":";
32 35
33 -
  // Parse frontmatter manually
34 -
  const raw: Record<string, unknown> = {};
35 -
  const lines = frontmatterStr.split("\n");
36 +
	// Parse frontmatter manually
37 +
	const raw: Record<string, unknown> = {};
38 +
	const lines = frontmatterStr.split("\n");
36 39
37 -
  let i = 0;
38 -
  while (i < lines.length) {
39 -
    const line = lines[i];
40 -
    if (line === undefined) {
41 -
      i++;
42 -
      continue;
43 -
    }
44 -
    const sepIndex = line.indexOf(separator);
45 -
    if (sepIndex === -1) {
46 -
      i++;
47 -
      continue;
48 -
    }
40 +
	let i = 0;
41 +
	while (i < lines.length) {
42 +
		const line = lines[i];
43 +
		if (line === undefined) {
44 +
			i++;
45 +
			continue;
46 +
		}
47 +
		const sepIndex = line.indexOf(separator);
48 +
		if (sepIndex === -1) {
49 +
			i++;
50 +
			continue;
51 +
		}
49 52
50 -
    const key = line.slice(0, sepIndex).trim();
51 -
    let value = line.slice(sepIndex + 1).trim();
53 +
		const key = line.slice(0, sepIndex).trim();
54 +
		let value = line.slice(sepIndex + 1).trim();
52 55
53 -
    // Handle quoted strings
54 -
    if (
55 -
      (value.startsWith('"') && value.endsWith('"')) ||
56 -
      (value.startsWith("'") && value.endsWith("'"))
57 -
    ) {
58 -
      value = value.slice(1, -1);
59 -
    }
56 +
		// Handle quoted strings
57 +
		if (
58 +
			(value.startsWith('"') && value.endsWith('"')) ||
59 +
			(value.startsWith("'") && value.endsWith("'"))
60 +
		) {
61 +
			value = value.slice(1, -1);
62 +
		}
60 63
61 -
    // Handle inline arrays (simple case for tags)
62 -
    if (value.startsWith("[") && value.endsWith("]")) {
63 -
      const arrayContent = value.slice(1, -1);
64 -
      raw[key] = arrayContent
65 -
        .split(",")
66 -
        .map((item) => item.trim().replace(/^["']|["']$/g, ""));
67 -
    } else if (value === "" && !isToml) {
68 -
      // Check for YAML-style multiline array (key with no value followed by - items)
69 -
      const arrayItems: string[] = [];
70 -
      let j = i + 1;
71 -
      while (j < lines.length) {
72 -
        const nextLine = lines[j];
73 -
        if (nextLine === undefined) {
74 -
          j++;
75 -
          continue;
76 -
        }
77 -
        // Check if line is a list item (starts with whitespace and -)
78 -
        const listMatch = nextLine.match(/^\s+-\s*(.*)$/);
79 -
        if (listMatch && listMatch[1] !== undefined) {
80 -
          let itemValue = listMatch[1].trim();
81 -
          // Remove quotes if present
82 -
          if (
83 -
            (itemValue.startsWith('"') && itemValue.endsWith('"')) ||
84 -
            (itemValue.startsWith("'") && itemValue.endsWith("'"))
85 -
          ) {
86 -
            itemValue = itemValue.slice(1, -1);
87 -
          }
88 -
          arrayItems.push(itemValue);
89 -
          j++;
90 -
        } else if (nextLine.trim() === "") {
91 -
          // Skip empty lines within the array
92 -
          j++;
93 -
        } else {
94 -
          // Hit a new key or non-list content
95 -
          break;
96 -
        }
97 -
      }
98 -
      if (arrayItems.length > 0) {
99 -
        raw[key] = arrayItems;
100 -
        i = j;
101 -
        continue;
102 -
      } else {
103 -
        raw[key] = value;
104 -
      }
105 -
    } else if (value === "true") {
106 -
      raw[key] = true;
107 -
    } else if (value === "false") {
108 -
      raw[key] = false;
109 -
    } else {
110 -
      raw[key] = value;
111 -
    }
112 -
    i++;
113 -
  }
64 +
		// Handle inline arrays (simple case for tags)
65 +
		if (value.startsWith("[") && value.endsWith("]")) {
66 +
			const arrayContent = value.slice(1, -1);
67 +
			raw[key] = arrayContent
68 +
				.split(",")
69 +
				.map((item) => item.trim().replace(/^["']|["']$/g, ""));
70 +
		} else if (value === "" && !isToml) {
71 +
			// Check for YAML-style multiline array (key with no value followed by - items)
72 +
			const arrayItems: string[] = [];
73 +
			let j = i + 1;
74 +
			while (j < lines.length) {
75 +
				const nextLine = lines[j];
76 +
				if (nextLine === undefined) {
77 +
					j++;
78 +
					continue;
79 +
				}
80 +
				// Check if line is a list item (starts with whitespace and -)
81 +
				const listMatch = nextLine.match(/^\s+-\s*(.*)$/);
82 +
				if (listMatch && listMatch[1] !== undefined) {
83 +
					let itemValue = listMatch[1].trim();
84 +
					// Remove quotes if present
85 +
					if (
86 +
						(itemValue.startsWith('"') && itemValue.endsWith('"')) ||
87 +
						(itemValue.startsWith("'") && itemValue.endsWith("'"))
88 +
					) {
89 +
						itemValue = itemValue.slice(1, -1);
90 +
					}
91 +
					arrayItems.push(itemValue);
92 +
					j++;
93 +
				} else if (nextLine.trim() === "") {
94 +
					// Skip empty lines within the array
95 +
					j++;
96 +
				} else {
97 +
					// Hit a new key or non-list content
98 +
					break;
99 +
				}
100 +
			}
101 +
			if (arrayItems.length > 0) {
102 +
				raw[key] = arrayItems;
103 +
				i = j;
104 +
				continue;
105 +
			} else {
106 +
				raw[key] = value;
107 +
			}
108 +
		} else if (value === "true") {
109 +
			raw[key] = true;
110 +
		} else if (value === "false") {
111 +
			raw[key] = false;
112 +
		} else {
113 +
			raw[key] = value;
114 +
		}
115 +
		i++;
116 +
	}
114 117
115 -
  // Apply field mappings to normalize to standard PostFrontmatter fields
116 -
  const frontmatter: Record<string, unknown> = {};
118 +
	// Apply field mappings to normalize to standard PostFrontmatter fields
119 +
	const frontmatter: Record<string, unknown> = {};
117 120
118 -
  // Title mapping
119 -
  const titleField = mapping?.title || "title";
120 -
  frontmatter.title = raw[titleField] || raw.title;
121 +
	// Title mapping
122 +
	const titleField = mapping?.title || "title";
123 +
	frontmatter.title = raw[titleField] || raw.title;
121 124
122 -
  // Description mapping
123 -
  const descField = mapping?.description || "description";
124 -
  frontmatter.description = raw[descField] || raw.description;
125 +
	// Description mapping
126 +
	const descField = mapping?.description || "description";
127 +
	frontmatter.description = raw[descField] || raw.description;
125 128
126 -
  // Publish date mapping - check custom field first, then fallbacks
127 -
  const dateField = mapping?.publishDate;
128 -
  if (dateField && raw[dateField]) {
129 -
    frontmatter.publishDate = raw[dateField];
130 -
  } else if (raw.publishDate) {
131 -
    frontmatter.publishDate = raw.publishDate;
132 -
  } else {
133 -
    // Fallback to common date field names
134 -
    const dateFields = ["pubDate", "date", "createdAt", "created_at"];
135 -
    for (const field of dateFields) {
136 -
      if (raw[field]) {
137 -
        frontmatter.publishDate = raw[field];
138 -
        break;
139 -
      }
140 -
    }
141 -
  }
129 +
	// Publish date mapping - check custom field first, then fallbacks
130 +
	const dateField = mapping?.publishDate;
131 +
	if (dateField && raw[dateField]) {
132 +
		frontmatter.publishDate = raw[dateField];
133 +
	} else if (raw.publishDate) {
134 +
		frontmatter.publishDate = raw.publishDate;
135 +
	} else {
136 +
		// Fallback to common date field names
137 +
		const dateFields = ["pubDate", "date", "createdAt", "created_at"];
138 +
		for (const field of dateFields) {
139 +
			if (raw[field]) {
140 +
				frontmatter.publishDate = raw[field];
141 +
				break;
142 +
			}
143 +
		}
144 +
	}
142 145
143 -
  // Cover image mapping
144 -
  const coverField = mapping?.coverImage || "ogImage";
145 -
  frontmatter.ogImage = raw[coverField] || raw.ogImage;
146 +
	// Cover image mapping
147 +
	const coverField = mapping?.coverImage || "ogImage";
148 +
	frontmatter.ogImage = raw[coverField] || raw.ogImage;
146 149
147 -
  // Tags mapping
148 -
  const tagsField = mapping?.tags || "tags";
149 -
  frontmatter.tags = raw[tagsField] || raw.tags;
150 +
	// Tags mapping
151 +
	const tagsField = mapping?.tags || "tags";
152 +
	frontmatter.tags = raw[tagsField] || raw.tags;
150 153
151 -
  // Draft mapping
152 -
  const draftField = mapping?.draft || "draft";
153 -
  const draftValue = raw[draftField] ?? raw.draft;
154 -
  if (draftValue !== undefined) {
155 -
    frontmatter.draft = draftValue === true || draftValue === "true";
156 -
  }
154 +
	// Draft mapping
155 +
	const draftField = mapping?.draft || "draft";
156 +
	const draftValue = raw[draftField] ?? raw.draft;
157 +
	if (draftValue !== undefined) {
158 +
		frontmatter.draft = draftValue === true || draftValue === "true";
159 +
	}
157 160
158 -
  // Always preserve atUri (internal field)
159 -
  frontmatter.atUri = raw.atUri;
161 +
	// Always preserve atUri (internal field)
162 +
	frontmatter.atUri = raw.atUri;
160 163
161 -
  return { frontmatter: frontmatter as unknown as PostFrontmatter, body, rawFrontmatter: raw };
164 +
	return {
165 +
		frontmatter: frontmatter as unknown as PostFrontmatter,
166 +
		body,
167 +
		rawFrontmatter: raw,
168 +
	};
162 169
}
163 170
164 171
export function getSlugFromFilename(filename: string): string {
165 -
  return filename
166 -
    .replace(/\.mdx?$/, "")
167 -
    .toLowerCase()
168 -
    .replace(/\s+/g, "-");
172 +
	return filename
173 +
		.replace(/\.mdx?$/, "")
174 +
		.toLowerCase()
175 +
		.replace(/\s+/g, "-");
169 176
}
170 177
171 178
export interface SlugOptions {
172 -
  slugSource?: "filename" | "path" | "frontmatter";
173 -
  slugField?: string;
174 -
  removeIndexFromSlug?: boolean;
179 +
	slugSource?: "filename" | "path" | "frontmatter";
180 +
	slugField?: string;
181 +
	removeIndexFromSlug?: boolean;
175 182
}
176 183
177 184
export function getSlugFromOptions(
178 -
  relativePath: string,
179 -
  rawFrontmatter: Record<string, unknown>,
180 -
  options: SlugOptions = {}
185 +
	relativePath: string,
186 +
	rawFrontmatter: Record<string, unknown>,
187 +
	options: SlugOptions = {},
181 188
): string {
182 -
  const { slugSource = "filename", slugField = "slug", removeIndexFromSlug = false } = options;
189 +
	const {
190 +
		slugSource = "filename",
191 +
		slugField = "slug",
192 +
		removeIndexFromSlug = false,
193 +
	} = options;
183 194
184 -
  let slug: string;
195 +
	let slug: string;
185 196
186 -
  switch (slugSource) {
187 -
    case "path":
188 -
      // Use full relative path without extension
189 -
      slug = relativePath
190 -
        .replace(/\.mdx?$/, "")
191 -
        .toLowerCase()
192 -
        .replace(/\s+/g, "-");
193 -
      break;
197 +
	switch (slugSource) {
198 +
		case "path":
199 +
			// Use full relative path without extension
200 +
			slug = relativePath
201 +
				.replace(/\.mdx?$/, "")
202 +
				.toLowerCase()
203 +
				.replace(/\s+/g, "-");
204 +
			break;
194 205
195 -
    case "frontmatter":
196 -
      // Use frontmatter field (slug or url)
197 -
      const frontmatterValue = rawFrontmatter[slugField] || rawFrontmatter.slug || rawFrontmatter.url;
198 -
      if (frontmatterValue && typeof frontmatterValue === "string") {
199 -
        // Remove leading slash if present
200 -
        slug = frontmatterValue.replace(/^\//, "").toLowerCase().replace(/\s+/g, "-");
201 -
      } else {
202 -
        // Fallback to filename if frontmatter field not found
203 -
        slug = getSlugFromFilename(path.basename(relativePath));
204 -
      }
205 -
      break;
206 +
		case "frontmatter": {
207 +
			// Use frontmatter field (slug or url)
208 +
			const frontmatterValue =
209 +
				rawFrontmatter[slugField] || rawFrontmatter.slug || rawFrontmatter.url;
210 +
			if (frontmatterValue && typeof frontmatterValue === "string") {
211 +
				// Remove leading slash if present
212 +
				slug = frontmatterValue
213 +
					.replace(/^\//, "")
214 +
					.toLowerCase()
215 +
					.replace(/\s+/g, "-");
216 +
			} else {
217 +
				// Fallback to filename if frontmatter field not found
218 +
				slug = getSlugFromFilename(path.basename(relativePath));
219 +
			}
220 +
			break;
221 +
		}
206 222
207 -
    case "filename":
208 -
    default:
209 -
      slug = getSlugFromFilename(path.basename(relativePath));
210 -
      break;
211 -
  }
223 +
		case "filename":
224 +
		default:
225 +
			slug = getSlugFromFilename(path.basename(relativePath));
226 +
			break;
227 +
	}
212 228
213 -
  // Remove /index or /_index suffix if configured
214 -
  if (removeIndexFromSlug) {
215 -
    slug = slug.replace(/\/_?index$/, "");
216 -
  }
229 +
	// Remove /index or /_index suffix if configured
230 +
	if (removeIndexFromSlug) {
231 +
		slug = slug.replace(/\/_?index$/, "");
232 +
	}
217 233
218 -
  return slug;
234 +
	return slug;
219 235
}
220 236
221 237
export async function getContentHash(content: string): Promise<string> {
222 -
  const encoder = new TextEncoder();
223 -
  const data = encoder.encode(content);
224 -
  const hashBuffer = await crypto.subtle.digest("SHA-256", data);
225 -
  const hashArray = Array.from(new Uint8Array(hashBuffer));
226 -
  return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
238 +
	const encoder = new TextEncoder();
239 +
	const data = encoder.encode(content);
240 +
	const hashBuffer = await crypto.subtle.digest("SHA-256", data);
241 +
	const hashArray = Array.from(new Uint8Array(hashBuffer));
242 +
	return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
227 243
}
228 244
229 245
function shouldIgnore(relativePath: string, ignorePatterns: string[]): boolean {
230 -
  for (const pattern of ignorePatterns) {
231 -
    if (minimatch(relativePath, pattern)) {
232 -
      return true;
233 -
    }
234 -
  }
235 -
  return false;
246 +
	for (const pattern of ignorePatterns) {
247 +
		if (minimatch(relativePath, pattern)) {
248 +
			return true;
249 +
		}
250 +
	}
251 +
	return false;
236 252
}
237 253
238 254
export interface ScanOptions {
239 -
  frontmatterMapping?: FrontmatterMapping;
240 -
  ignorePatterns?: string[];
241 -
  slugSource?: "filename" | "path" | "frontmatter";
242 -
  slugField?: string;
243 -
  removeIndexFromSlug?: boolean;
255 +
	frontmatterMapping?: FrontmatterMapping;
256 +
	ignorePatterns?: string[];
257 +
	slugSource?: "filename" | "path" | "frontmatter";
258 +
	slugField?: string;
259 +
	removeIndexFromSlug?: boolean;
244 260
}
245 261
246 262
export async function scanContentDirectory(
247 -
  contentDir: string,
248 -
  frontmatterMappingOrOptions?: FrontmatterMapping | ScanOptions,
249 -
  ignorePatterns: string[] = []
263 +
	contentDir: string,
264 +
	frontmatterMappingOrOptions?: FrontmatterMapping | ScanOptions,
265 +
	ignorePatterns: string[] = [],
250 266
): Promise<BlogPost[]> {
251 -
  // Handle both old signature (frontmatterMapping, ignorePatterns) and new signature (options)
252 -
  let options: ScanOptions;
253 -
  if (frontmatterMappingOrOptions && ('slugSource' in frontmatterMappingOrOptions || 'frontmatterMapping' in frontmatterMappingOrOptions || 'ignorePatterns' in frontmatterMappingOrOptions)) {
254 -
    options = frontmatterMappingOrOptions as ScanOptions;
255 -
  } else {
256 -
    // Old signature: (contentDir, frontmatterMapping?, ignorePatterns?)
257 -
    options = {
258 -
      frontmatterMapping: frontmatterMappingOrOptions as FrontmatterMapping | undefined,
259 -
      ignorePatterns,
260 -
    };
261 -
  }
267 +
	// Handle both old signature (frontmatterMapping, ignorePatterns) and new signature (options)
268 +
	let options: ScanOptions;
269 +
	if (
270 +
		frontmatterMappingOrOptions &&
271 +
		("slugSource" in frontmatterMappingOrOptions ||
272 +
			"frontmatterMapping" in frontmatterMappingOrOptions ||
273 +
			"ignorePatterns" in frontmatterMappingOrOptions)
274 +
	) {
275 +
		options = frontmatterMappingOrOptions as ScanOptions;
276 +
	} else {
277 +
		// Old signature: (contentDir, frontmatterMapping?, ignorePatterns?)
278 +
		options = {
279 +
			frontmatterMapping: frontmatterMappingOrOptions as
280 +
				| FrontmatterMapping
281 +
				| undefined,
282 +
			ignorePatterns,
283 +
		};
284 +
	}
262 285
263 -
  const {
264 -
    frontmatterMapping,
265 -
    ignorePatterns: ignore = [],
266 -
    slugSource,
267 -
    slugField,
268 -
    removeIndexFromSlug,
269 -
  } = options;
286 +
	const {
287 +
		frontmatterMapping,
288 +
		ignorePatterns: ignore = [],
289 +
		slugSource,
290 +
		slugField,
291 +
		removeIndexFromSlug,
292 +
	} = options;
270 293
271 -
  const patterns = ["**/*.md", "**/*.mdx"];
272 -
  const posts: BlogPost[] = [];
294 +
	const patterns = ["**/*.md", "**/*.mdx"];
295 +
	const posts: BlogPost[] = [];
273 296
274 -
  for (const pattern of patterns) {
275 -
    const files = await glob(pattern, {
276 -
      cwd: contentDir,
277 -
      absolute: false,
278 -
    });
297 +
	for (const pattern of patterns) {
298 +
		const files = await glob(pattern, {
299 +
			cwd: contentDir,
300 +
			absolute: false,
301 +
		});
279 302
280 -
    for (const relativePath of files) {
281 -
      // Skip files matching ignore patterns
282 -
      if (shouldIgnore(relativePath, ignore)) {
283 -
        continue;
284 -
      }
303 +
		for (const relativePath of files) {
304 +
			// Skip files matching ignore patterns
305 +
			if (shouldIgnore(relativePath, ignore)) {
306 +
				continue;
307 +
			}
285 308
286 -
      const filePath = path.join(contentDir, relativePath);
287 -
      const rawContent = await fs.readFile(filePath, "utf-8");
309 +
			const filePath = path.join(contentDir, relativePath);
310 +
			const rawContent = await fs.readFile(filePath, "utf-8");
288 311
289 -
      try {
290 -
        const { frontmatter, body, rawFrontmatter } = parseFrontmatter(rawContent, frontmatterMapping);
291 -
        const slug = getSlugFromOptions(relativePath, rawFrontmatter, {
292 -
          slugSource,
293 -
          slugField,
294 -
          removeIndexFromSlug,
295 -
        });
312 +
			try {
313 +
				const { frontmatter, body, rawFrontmatter } = parseFrontmatter(
314 +
					rawContent,
315 +
					frontmatterMapping,
316 +
				);
317 +
				const slug = getSlugFromOptions(relativePath, rawFrontmatter, {
318 +
					slugSource,
319 +
					slugField,
320 +
					removeIndexFromSlug,
321 +
				});
296 322
297 -
        posts.push({
298 -
          filePath,
299 -
          slug,
300 -
          frontmatter,
301 -
          content: body,
302 -
          rawContent,
303 -
          rawFrontmatter,
304 -
        });
305 -
      } catch (error) {
306 -
        console.error(`Error parsing ${relativePath}:`, error);
307 -
      }
308 -
    }
309 -
  }
323 +
				posts.push({
324 +
					filePath,
325 +
					slug,
326 +
					frontmatter,
327 +
					content: body,
328 +
					rawContent,
329 +
					rawFrontmatter,
330 +
				});
331 +
			} catch (error) {
332 +
				console.error(`Error parsing ${relativePath}:`, error);
333 +
			}
334 +
		}
335 +
	}
310 336
311 -
  // Sort by publish date (newest first)
312 -
  posts.sort((a, b) => {
313 -
    const dateA = new Date(a.frontmatter.publishDate);
314 -
    const dateB = new Date(b.frontmatter.publishDate);
315 -
    return dateB.getTime() - dateA.getTime();
316 -
  });
337 +
	// Sort by publish date (newest first)
338 +
	posts.sort((a, b) => {
339 +
		const dateA = new Date(a.frontmatter.publishDate);
340 +
		const dateB = new Date(b.frontmatter.publishDate);
341 +
		return dateB.getTime() - dateA.getTime();
342 +
	});
317 343
318 -
  return posts;
344 +
	return posts;
319 345
}
320 346
321 -
export function updateFrontmatterWithAtUri(rawContent: string, atUri: string): string {
322 -
  // Detect which delimiter is used (---, +++, or ***)
323 -
  const delimiterMatch = rawContent.match(/^(---|\+\+\+|\*\*\*)/);
324 -
  const delimiter = delimiterMatch?.[1] ?? "---";
325 -
  const isToml = delimiter === "+++";
347 +
export function updateFrontmatterWithAtUri(
348 +
	rawContent: string,
349 +
	atUri: string,
350 +
): string {
351 +
	// Detect which delimiter is used (---, +++, or ***)
352 +
	const delimiterMatch = rawContent.match(/^(---|\+\+\+|\*\*\*)/);
353 +
	const delimiter = delimiterMatch?.[1] ?? "---";
354 +
	const isToml = delimiter === "+++";
326 355
327 -
  // Format the atUri entry based on frontmatter type
328 -
  const atUriEntry = isToml ? `atUri = "${atUri}"` : `atUri: "${atUri}"`;
356 +
	// Format the atUri entry based on frontmatter type
357 +
	const atUriEntry = isToml ? `atUri = "${atUri}"` : `atUri: "${atUri}"`;
329 358
330 -
  // Check if atUri already exists in frontmatter (handle both formats)
331 -
  if (rawContent.includes("atUri:") || rawContent.includes("atUri =")) {
332 -
    // Replace existing atUri (match both YAML and TOML formats)
333 -
    return rawContent.replace(/atUri\s*[=:]\s*["']?[^"'\n]+["']?\n?/, `${atUriEntry}\n`);
334 -
  }
359 +
	// Check if atUri already exists in frontmatter (handle both formats)
360 +
	if (rawContent.includes("atUri:") || rawContent.includes("atUri =")) {
361 +
		// Replace existing atUri (match both YAML and TOML formats)
362 +
		return rawContent.replace(
363 +
			/atUri\s*[=:]\s*["']?[^"'\n]+["']?\n?/,
364 +
			`${atUriEntry}\n`,
365 +
		);
366 +
	}
335 367
336 -
  // Insert atUri before the closing delimiter
337 -
  const frontmatterEndIndex = rawContent.indexOf(delimiter, 4);
338 -
  if (frontmatterEndIndex === -1) {
339 -
    throw new Error("Could not find frontmatter end");
340 -
  }
368 +
	// Insert atUri before the closing delimiter
369 +
	const frontmatterEndIndex = rawContent.indexOf(delimiter, 4);
370 +
	if (frontmatterEndIndex === -1) {
371 +
		throw new Error("Could not find frontmatter end");
372 +
	}
341 373
342 -
  const beforeEnd = rawContent.slice(0, frontmatterEndIndex);
343 -
  const afterEnd = rawContent.slice(frontmatterEndIndex);
374 +
	const beforeEnd = rawContent.slice(0, frontmatterEndIndex);
375 +
	const afterEnd = rawContent.slice(frontmatterEndIndex);
344 376
345 -
  return `${beforeEnd}${atUriEntry}\n${afterEnd}`;
377 +
	return `${beforeEnd}${atUriEntry}\n${afterEnd}`;
346 378
}
347 379
348 380
export function stripMarkdownForText(markdown: string): string {
349 -
  return markdown
350 -
    .replace(/#{1,6}\s/g, "") // Remove headers
351 -
    .replace(/\*\*([^*]+)\*\*/g, "$1") // Remove bold
352 -
    .replace(/\*([^*]+)\*/g, "$1") // Remove italic
353 -
    .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Remove links, keep text
354 -
    .replace(/`{3}[\s\S]*?`{3}/g, "") // Remove code blocks
355 -
    .replace(/`([^`]+)`/g, "$1") // Remove inline code formatting
356 -
    .replace(/!\[.*?\]\(.*?\)/g, "") // Remove images
357 -
    .replace(/\n{3,}/g, "\n\n") // Normalize multiple newlines
358 -
    .trim();
381 +
	return markdown
382 +
		.replace(/#{1,6}\s/g, "") // Remove headers
383 +
		.replace(/\*\*([^*]+)\*\*/g, "$1") // Remove bold
384 +
		.replace(/\*([^*]+)\*/g, "$1") // Remove italic
385 +
		.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Remove links, keep text
386 +
		.replace(/`{3}[\s\S]*?`{3}/g, "") // Remove code blocks
387 +
		.replace(/`([^`]+)`/g, "$1") // Remove inline code formatting
388 +
		.replace(/!\[.*?\]\(.*?\)/g, "") // Remove images
389 +
		.replace(/\n{3,}/g, "\n\n") // Normalize multiple newlines
390 +
		.trim();
359 391
}
packages/cli/src/lib/prompts.ts +6 −6
1 -
import { isCancel, cancel } from "@clack/prompts";
1 +
import { cancel, isCancel } from "@clack/prompts";
2 2
3 3
export function exitOnCancel<T>(value: T | symbol): T {
4 -
  if (isCancel(value)) {
5 -
    cancel("Cancelled");
6 -
    process.exit(0);
7 -
  }
8 -
  return value as T;
4 +
	if (isCancel(value)) {
5 +
		cancel("Cancelled");
6 +
		process.exit(0);
7 +
	}
8 +
	return value as T;
9 9
}
packages/cli/tsconfig.json +20 −20
1 1
{
2 -
  "compilerOptions": {
3 -
    "lib": ["ES2022"],
4 -
    "target": "ES2022",
5 -
    "module": "ESNext",
6 -
    "moduleResolution": "bundler",
7 -
    "outDir": "./dist",
8 -
    "rootDir": "./src",
9 -
    "declaration": true,
10 -
    "sourceMap": true,
11 -
    "strict": true,
12 -
    "skipLibCheck": true,
13 -
    "esModuleInterop": true,
14 -
    "resolveJsonModule": true,
15 -
    "forceConsistentCasingInFileNames": true,
16 -
    "noFallthroughCasesInSwitch": true,
17 -
    "noUncheckedIndexedAccess": true,
18 -
    "noUnusedLocals": false,
19 -
    "noUnusedParameters": false
20 -
  },
21 -
  "include": ["src"]
2 +
	"compilerOptions": {
3 +
		"lib": ["ES2022"],
4 +
		"target": "ES2022",
5 +
		"module": "ESNext",
6 +
		"moduleResolution": "bundler",
7 +
		"outDir": "./dist",
8 +
		"rootDir": "./src",
9 +
		"declaration": true,
10 +
		"sourceMap": true,
11 +
		"strict": true,
12 +
		"skipLibCheck": true,
13 +
		"esModuleInterop": true,
14 +
		"resolveJsonModule": true,
15 +
		"forceConsistentCasingInFileNames": true,
16 +
		"noFallthroughCasesInSwitch": true,
17 +
		"noUncheckedIndexedAccess": true,
18 +
		"noUnusedLocals": false,
19 +
		"noUnusedParameters": false
20 +
	},
21 +
	"include": ["src"]
22 22
}