···11-import { command, flag, option, optional, string } from "cmd-ts";
22-import { note, text, password, confirm, select, spinner, log } from "@clack/prompts";
31import { AtpAgent } from "@atproto/api";
42import {
55- saveCredentials,
66- deleteCredentials,
77- listCredentials,
88- getCredentials,
99- getCredentialsPath,
1010-} from "../lib/credentials";
33+ confirm,
44+ log,
55+ note,
66+ password,
77+ select,
88+ spinner,
99+ text,
1010+} from "@clack/prompts";
1111+import { command, flag, option, optional, string } from "cmd-ts";
1112import { resolveHandleToPDS } from "../lib/atproto";
1313+import {
1414+ deleteCredentials,
1515+ getCredentials,
1616+ getCredentialsPath,
1717+ listCredentials,
1818+ saveCredentials,
1919+} from "../lib/credentials";
1220import { exitOnCancel } from "../lib/prompts";
13211422export const authCommand = command({
1515- name: "auth",
1616- description: "Authenticate with your ATProto PDS",
1717- args: {
1818- logout: option({
1919- long: "logout",
2020- description: "Remove credentials for a specific identity (or all if only one exists)",
2121- type: optional(string),
2222- }),
2323- list: flag({
2424- long: "list",
2525- description: "List all stored identities",
2626- }),
2727- },
2828- handler: async ({ logout, list }) => {
2929- // List identities
3030- if (list) {
3131- const identities = await listCredentials();
3232- if (identities.length === 0) {
3333- log.info("No stored identities");
3434- } else {
3535- log.info("Stored identities:");
3636- for (const id of identities) {
3737- console.log(` - ${id}`);
3838- }
3939- }
4040- return;
4141- }
2323+ name: "auth",
2424+ description: "Authenticate with your ATProto PDS",
2525+ args: {
2626+ logout: option({
2727+ long: "logout",
2828+ description:
2929+ "Remove credentials for a specific identity (or all if only one exists)",
3030+ type: optional(string),
3131+ }),
3232+ list: flag({
3333+ long: "list",
3434+ description: "List all stored identities",
3535+ }),
3636+ },
3737+ handler: async ({ logout, list }) => {
3838+ // List identities
3939+ if (list) {
4040+ const identities = await listCredentials();
4141+ if (identities.length === 0) {
4242+ log.info("No stored identities");
4343+ } else {
4444+ log.info("Stored identities:");
4545+ for (const id of identities) {
4646+ console.log(` - ${id}`);
4747+ }
4848+ }
4949+ return;
5050+ }
42514343- // Logout
4444- if (logout !== undefined) {
4545- // If --logout was passed without a value, it will be an empty string
4646- const identifier = logout || undefined;
5252+ // Logout
5353+ if (logout !== undefined) {
5454+ // If --logout was passed without a value, it will be an empty string
5555+ const identifier = logout || undefined;
47564848- if (!identifier) {
4949- // No identifier provided - show available and prompt
5050- const identities = await listCredentials();
5151- if (identities.length === 0) {
5252- log.info("No saved credentials found");
5353- return;
5454- }
5555- if (identities.length === 1) {
5656- const deleted = await deleteCredentials(identities[0]);
5757- if (deleted) {
5858- log.success(`Removed credentials for ${identities[0]}`);
5959- }
6060- return;
6161- }
6262- // Multiple identities - prompt
6363- const selected = exitOnCancel(await select({
6464- message: "Select identity to remove:",
6565- options: identities.map(id => ({ value: id, label: id })),
6666- }));
6767- const deleted = await deleteCredentials(selected);
6868- if (deleted) {
6969- log.success(`Removed credentials for ${selected}`);
7070- }
7171- return;
7272- }
5757+ if (!identifier) {
5858+ // No identifier provided - show available and prompt
5959+ const identities = await listCredentials();
6060+ if (identities.length === 0) {
6161+ log.info("No saved credentials found");
6262+ return;
6363+ }
6464+ if (identities.length === 1) {
6565+ const deleted = await deleteCredentials(identities[0]);
6666+ if (deleted) {
6767+ log.success(`Removed credentials for ${identities[0]}`);
6868+ }
6969+ return;
7070+ }
7171+ // Multiple identities - prompt
7272+ const selected = exitOnCancel(
7373+ await select({
7474+ message: "Select identity to remove:",
7575+ options: identities.map((id) => ({ value: id, label: id })),
7676+ }),
7777+ );
7878+ const deleted = await deleteCredentials(selected);
7979+ if (deleted) {
8080+ log.success(`Removed credentials for ${selected}`);
8181+ }
8282+ return;
8383+ }
73847474- const deleted = await deleteCredentials(identifier);
7575- if (deleted) {
7676- log.success(`Removed credentials for ${identifier}`);
7777- } else {
7878- log.info(`No credentials found for ${identifier}`);
7979- }
8080- return;
8181- }
8585+ const deleted = await deleteCredentials(identifier);
8686+ if (deleted) {
8787+ log.success(`Removed credentials for ${identifier}`);
8888+ } else {
8989+ log.info(`No credentials found for ${identifier}`);
9090+ }
9191+ return;
9292+ }
82938383- note(
8484- "To authenticate, you'll need an App Password.\n\n" +
8585- "Create one at: https://bsky.app/settings/app-passwords\n\n" +
8686- "App Passwords are safer than your main password and can be revoked.",
8787- "Authentication"
8888- );
9494+ note(
9595+ "To authenticate, you'll need an App Password.\n\n" +
9696+ "Create one at: https://bsky.app/settings/app-passwords\n\n" +
9797+ "App Passwords are safer than your main password and can be revoked.",
9898+ "Authentication",
9999+ );
891009090- const identifier = exitOnCancel(await text({
9191- message: "Handle or DID:",
9292- placeholder: "yourhandle.bsky.social",
9393- }));
101101+ const identifier = exitOnCancel(
102102+ await text({
103103+ message: "Handle or DID:",
104104+ placeholder: "yourhandle.bsky.social",
105105+ }),
106106+ );
941079595- const appPassword = exitOnCancel(await password({
9696- message: "App Password:",
9797- }));
108108+ const appPassword = exitOnCancel(
109109+ await password({
110110+ message: "App Password:",
111111+ }),
112112+ );
981139999- if (!identifier || !appPassword) {
100100- log.error("Handle and password are required");
101101- process.exit(1);
102102- }
114114+ if (!identifier || !appPassword) {
115115+ log.error("Handle and password are required");
116116+ process.exit(1);
117117+ }
103118104104- // Check if this identity already exists
105105- const existing = await getCredentials(identifier);
106106- if (existing) {
107107- const overwrite = exitOnCancel(await confirm({
108108- message: `Credentials for ${identifier} already exist. Update?`,
109109- initialValue: false,
110110- }));
111111- if (!overwrite) {
112112- log.info("Keeping existing credentials");
113113- return;
114114- }
115115- }
119119+ // Check if this identity already exists
120120+ const existing = await getCredentials(identifier);
121121+ if (existing) {
122122+ const overwrite = exitOnCancel(
123123+ await confirm({
124124+ message: `Credentials for ${identifier} already exist. Update?`,
125125+ initialValue: false,
126126+ }),
127127+ );
128128+ if (!overwrite) {
129129+ log.info("Keeping existing credentials");
130130+ return;
131131+ }
132132+ }
116133117117- // Resolve PDS from handle
118118- const s = spinner();
119119- s.start("Resolving PDS...");
120120- let pdsUrl: string;
121121- try {
122122- pdsUrl = await resolveHandleToPDS(identifier);
123123- s.stop(`Found PDS: ${pdsUrl}`);
124124- } catch (error) {
125125- s.stop("Failed to resolve PDS");
126126- log.error(`Failed to resolve PDS from handle: ${error}`);
127127- process.exit(1);
128128- }
134134+ // Resolve PDS from handle
135135+ const s = spinner();
136136+ s.start("Resolving PDS...");
137137+ let pdsUrl: string;
138138+ try {
139139+ pdsUrl = await resolveHandleToPDS(identifier);
140140+ s.stop(`Found PDS: ${pdsUrl}`);
141141+ } catch (error) {
142142+ s.stop("Failed to resolve PDS");
143143+ log.error(`Failed to resolve PDS from handle: ${error}`);
144144+ process.exit(1);
145145+ }
129146130130- // Verify credentials
131131- s.start("Verifying credentials...");
147147+ // Verify credentials
148148+ s.start("Verifying credentials...");
132149133133- try {
134134- const agent = new AtpAgent({ service: pdsUrl });
135135- await agent.login({
136136- identifier: identifier,
137137- password: appPassword,
138138- });
150150+ try {
151151+ const agent = new AtpAgent({ service: pdsUrl });
152152+ await agent.login({
153153+ identifier: identifier,
154154+ password: appPassword,
155155+ });
139156140140- s.stop(`Logged in as ${agent.session?.handle}`);
157157+ s.stop(`Logged in as ${agent.session?.handle}`);
141158142142- // Save credentials
143143- await saveCredentials({
144144- pdsUrl,
145145- identifier: identifier,
146146- password: appPassword,
147147- });
159159+ // Save credentials
160160+ await saveCredentials({
161161+ pdsUrl,
162162+ identifier: identifier,
163163+ password: appPassword,
164164+ });
148165149149- log.success(`Credentials saved to ${getCredentialsPath()}`);
150150- } catch (error) {
151151- s.stop("Failed to login");
152152- log.error(`Failed to login: ${error}`);
153153- process.exit(1);
154154- }
155155- },
166166+ log.success(`Credentials saved to ${getCredentialsPath()}`);
167167+ } catch (error) {
168168+ s.stop("Failed to login");
169169+ log.error(`Failed to login: ${error}`);
170170+ process.exit(1);
171171+ }
172172+ },
156173});
+2-2
packages/cli/src/commands/init.ts
···11-import * as fs from "fs/promises";
11+import * as fs from "node:fs/promises";
22import { command } from "cmd-ts";
33import {
44 intro,
···1111 log,
1212 group,
1313} from "@clack/prompts";
1414-import * as path from "path";
1414+import * as path from "node:path";
1515import { findConfig, generateConfigTemplate } from "../lib/config";
1616import { loadCredentials } from "../lib/credentials";
1717import { createAgent, createPublication } from "../lib/atproto";
+4-4
packages/cli/src/commands/inject.ts
···11-import * as fs from "fs/promises";
22-import { command, flag, option, optional, string } from "cmd-ts";
31import { log } from "@clack/prompts";
44-import * as path from "path";
22+import { command, flag, option, optional, string } from "cmd-ts";
53import { glob } from "glob";
66-import { loadConfig, loadState, findConfig } from "../lib/config";
44+import * as fs from "node:fs/promises";
55+import * as path from "node:path";
66+import { findConfig, loadConfig, loadState } from "../lib/config";
7788export const injectCommand = command({
99 name: "inject",
+2-2
packages/cli/src/commands/publish.ts
···11-import * as fs from "fs/promises";
11+import * as fs from "node:fs/promises";
22import { command, flag } from "cmd-ts";
33import { select, spinner, log } from "@clack/prompts";
44-import * as path from "path";
44+import * as path from "node:path";
55import { loadConfig, loadState, saveState, findConfig } from "../lib/config";
66import {
77 loadCredentials,
+172-158
packages/cli/src/commands/sync.ts
···11-import * as fs from "fs/promises";
11+import * as fs from "node:fs/promises";
22import { command, flag } from "cmd-ts";
33import { select, spinner, log } from "@clack/prompts";
44-import * as path from "path";
44+import * as path from "node:path";
55import { loadConfig, loadState, saveState, findConfig } from "../lib/config";
66-import { loadCredentials, listCredentials, getCredentials } from "../lib/credentials";
66+import {
77+ loadCredentials,
88+ listCredentials,
99+ getCredentials,
1010+} from "../lib/credentials";
711import { createAgent, listDocuments } from "../lib/atproto";
88-import { scanContentDirectory, getContentHash, updateFrontmatterWithAtUri } from "../lib/markdown";
1212+import {
1313+ scanContentDirectory,
1414+ getContentHash,
1515+ updateFrontmatterWithAtUri,
1616+} from "../lib/markdown";
917import { exitOnCancel } from "../lib/prompts";
10181119export const syncCommand = command({
1212- name: "sync",
1313- description: "Sync state from ATProto to restore .sequoia-state.json",
1414- args: {
1515- updateFrontmatter: flag({
1616- long: "update-frontmatter",
1717- short: "u",
1818- description: "Update frontmatter atUri fields in local markdown files",
1919- }),
2020- dryRun: flag({
2121- long: "dry-run",
2222- short: "n",
2323- description: "Preview what would be synced without making changes",
2424- }),
2525- },
2626- handler: async ({ updateFrontmatter, dryRun }) => {
2727- // Load config
2828- const configPath = await findConfig();
2929- if (!configPath) {
3030- log.error("No sequoia.json found. Run 'sequoia init' first.");
3131- process.exit(1);
3232- }
2020+ name: "sync",
2121+ description: "Sync state from ATProto to restore .sequoia-state.json",
2222+ args: {
2323+ updateFrontmatter: flag({
2424+ long: "update-frontmatter",
2525+ short: "u",
2626+ description: "Update frontmatter atUri fields in local markdown files",
2727+ }),
2828+ dryRun: flag({
2929+ long: "dry-run",
3030+ short: "n",
3131+ description: "Preview what would be synced without making changes",
3232+ }),
3333+ },
3434+ handler: async ({ updateFrontmatter, dryRun }) => {
3535+ // Load config
3636+ const configPath = await findConfig();
3737+ if (!configPath) {
3838+ log.error("No sequoia.json found. Run 'sequoia init' first.");
3939+ process.exit(1);
4040+ }
33413434- const config = await loadConfig(configPath);
3535- const configDir = path.dirname(configPath);
4242+ const config = await loadConfig(configPath);
4343+ const configDir = path.dirname(configPath);
36443737- log.info(`Site: ${config.siteUrl}`);
3838- log.info(`Publication: ${config.publicationUri}`);
4545+ log.info(`Site: ${config.siteUrl}`);
4646+ log.info(`Publication: ${config.publicationUri}`);
39474040- // Load credentials
4141- let credentials = await loadCredentials(config.identity);
4848+ // Load credentials
4949+ let credentials = await loadCredentials(config.identity);
42504343- if (!credentials) {
4444- const identities = await listCredentials();
4545- if (identities.length === 0) {
4646- log.error("No credentials found. Run 'sequoia auth' first.");
4747- process.exit(1);
4848- }
5151+ if (!credentials) {
5252+ const identities = await listCredentials();
5353+ if (identities.length === 0) {
5454+ log.error("No credentials found. Run 'sequoia auth' first.");
5555+ process.exit(1);
5656+ }
49575050- log.info("Multiple identities found. Select one to use:");
5151- const selected = exitOnCancel(await select({
5252- message: "Identity:",
5353- options: identities.map(id => ({ value: id, label: id })),
5454- }));
5858+ log.info("Multiple identities found. Select one to use:");
5959+ const selected = exitOnCancel(
6060+ await select({
6161+ message: "Identity:",
6262+ options: identities.map((id) => ({ value: id, label: id })),
6363+ }),
6464+ );
55655656- credentials = await getCredentials(selected);
5757- if (!credentials) {
5858- log.error("Failed to load selected credentials.");
5959- process.exit(1);
6060- }
6161- }
6666+ credentials = await getCredentials(selected);
6767+ if (!credentials) {
6868+ log.error("Failed to load selected credentials.");
6969+ process.exit(1);
7070+ }
7171+ }
62726363- // Create agent
6464- const s = spinner();
6565- s.start(`Connecting to ${credentials.pdsUrl}...`);
6666- let agent;
6767- try {
6868- agent = await createAgent(credentials);
6969- s.stop(`Logged in as ${agent.session?.handle}`);
7070- } catch (error) {
7171- s.stop("Failed to login");
7272- log.error(`Failed to login: ${error}`);
7373- process.exit(1);
7474- }
7373+ // Create agent
7474+ const s = spinner();
7575+ s.start(`Connecting to ${credentials.pdsUrl}...`);
7676+ let agent;
7777+ try {
7878+ agent = await createAgent(credentials);
7979+ s.stop(`Logged in as ${agent.session?.handle}`);
8080+ } catch (error) {
8181+ s.stop("Failed to login");
8282+ log.error(`Failed to login: ${error}`);
8383+ process.exit(1);
8484+ }
75857676- // Fetch documents from PDS
7777- s.start("Fetching documents from PDS...");
7878- const documents = await listDocuments(agent, config.publicationUri);
7979- s.stop(`Found ${documents.length} documents on PDS`);
8686+ // Fetch documents from PDS
8787+ s.start("Fetching documents from PDS...");
8888+ const documents = await listDocuments(agent, config.publicationUri);
8989+ s.stop(`Found ${documents.length} documents on PDS`);
80908181- if (documents.length === 0) {
8282- log.info("No documents found for this publication.");
8383- return;
8484- }
9191+ if (documents.length === 0) {
9292+ log.info("No documents found for this publication.");
9393+ return;
9494+ }
85958686- // Resolve content directory
8787- const contentDir = path.isAbsolute(config.contentDir)
8888- ? config.contentDir
8989- : path.join(configDir, config.contentDir);
9696+ // Resolve content directory
9797+ const contentDir = path.isAbsolute(config.contentDir)
9898+ ? config.contentDir
9999+ : path.join(configDir, config.contentDir);
901009191- // Scan local posts
9292- s.start("Scanning local content...");
9393- const localPosts = await scanContentDirectory(contentDir, {
9494- frontmatterMapping: config.frontmatter,
9595- ignorePatterns: config.ignore,
9696- slugSource: config.slugSource,
9797- slugField: config.slugField,
9898- removeIndexFromSlug: config.removeIndexFromSlug,
9999- });
100100- s.stop(`Found ${localPosts.length} local posts`);
101101+ // Scan local posts
102102+ s.start("Scanning local content...");
103103+ const localPosts = await scanContentDirectory(contentDir, {
104104+ frontmatterMapping: config.frontmatter,
105105+ ignorePatterns: config.ignore,
106106+ slugSource: config.slugSource,
107107+ slugField: config.slugField,
108108+ removeIndexFromSlug: config.removeIndexFromSlug,
109109+ });
110110+ s.stop(`Found ${localPosts.length} local posts`);
101111102102- // Build a map of path -> local post for matching
103103- // Document path is like /posts/my-post-slug (or custom pathPrefix)
104104- const pathPrefix = config.pathPrefix || "/posts";
105105- const postsByPath = new Map<string, typeof localPosts[0]>();
106106- for (const post of localPosts) {
107107- const postPath = `${pathPrefix}/${post.slug}`;
108108- postsByPath.set(postPath, post);
109109- }
112112+ // Build a map of path -> local post for matching
113113+ // Document path is like /posts/my-post-slug (or custom pathPrefix)
114114+ const pathPrefix = config.pathPrefix || "/posts";
115115+ const postsByPath = new Map<string, (typeof localPosts)[0]>();
116116+ for (const post of localPosts) {
117117+ const postPath = `${pathPrefix}/${post.slug}`;
118118+ postsByPath.set(postPath, post);
119119+ }
110120111111- // Load existing state
112112- const state = await loadState(configDir);
113113- const originalPostCount = Object.keys(state.posts).length;
121121+ // Load existing state
122122+ const state = await loadState(configDir);
123123+ const originalPostCount = Object.keys(state.posts).length;
114124115115- // Track changes
116116- let matchedCount = 0;
117117- let unmatchedCount = 0;
118118- let frontmatterUpdates: Array<{ filePath: string; atUri: string }> = [];
125125+ // Track changes
126126+ let matchedCount = 0;
127127+ let unmatchedCount = 0;
128128+ const frontmatterUpdates: Array<{ filePath: string; atUri: string }> = [];
119129120120- log.message("\nMatching documents to local files:\n");
130130+ log.message("\nMatching documents to local files:\n");
121131122122- for (const doc of documents) {
123123- const docPath = doc.value.path;
124124- const localPost = postsByPath.get(docPath);
132132+ for (const doc of documents) {
133133+ const docPath = doc.value.path;
134134+ const localPost = postsByPath.get(docPath);
125135126126- if (localPost) {
127127- matchedCount++;
128128- log.message(` ✓ ${doc.value.title}`);
129129- log.message(` Path: ${docPath}`);
130130- log.message(` URI: ${doc.uri}`);
131131- log.message(` File: ${path.basename(localPost.filePath)}`);
136136+ if (localPost) {
137137+ matchedCount++;
138138+ log.message(` ✓ ${doc.value.title}`);
139139+ log.message(` Path: ${docPath}`);
140140+ log.message(` URI: ${doc.uri}`);
141141+ log.message(` File: ${path.basename(localPost.filePath)}`);
132142133133- // Update state (use relative path from config directory)
134134- const contentHash = await getContentHash(localPost.rawContent);
135135- const relativeFilePath = path.relative(configDir, localPost.filePath);
136136- state.posts[relativeFilePath] = {
137137- contentHash,
138138- atUri: doc.uri,
139139- lastPublished: doc.value.publishedAt,
140140- };
143143+ // Update state (use relative path from config directory)
144144+ const contentHash = await getContentHash(localPost.rawContent);
145145+ const relativeFilePath = path.relative(configDir, localPost.filePath);
146146+ state.posts[relativeFilePath] = {
147147+ contentHash,
148148+ atUri: doc.uri,
149149+ lastPublished: doc.value.publishedAt,
150150+ };
141151142142- // Check if frontmatter needs updating
143143- if (updateFrontmatter && localPost.frontmatter.atUri !== doc.uri) {
144144- frontmatterUpdates.push({
145145- filePath: localPost.filePath,
146146- atUri: doc.uri,
147147- });
148148- log.message(` → Will update frontmatter`);
149149- }
150150- } else {
151151- unmatchedCount++;
152152- log.message(` ✗ ${doc.value.title} (no matching local file)`);
153153- log.message(` Path: ${docPath}`);
154154- log.message(` URI: ${doc.uri}`);
155155- }
156156- log.message("");
157157- }
152152+ // Check if frontmatter needs updating
153153+ if (updateFrontmatter && localPost.frontmatter.atUri !== doc.uri) {
154154+ frontmatterUpdates.push({
155155+ filePath: localPost.filePath,
156156+ atUri: doc.uri,
157157+ });
158158+ log.message(` → Will update frontmatter`);
159159+ }
160160+ } else {
161161+ unmatchedCount++;
162162+ log.message(` ✗ ${doc.value.title} (no matching local file)`);
163163+ log.message(` Path: ${docPath}`);
164164+ log.message(` URI: ${doc.uri}`);
165165+ }
166166+ log.message("");
167167+ }
158168159159- // Summary
160160- log.message("---");
161161- log.info(`Matched: ${matchedCount} documents`);
162162- if (unmatchedCount > 0) {
163163- log.warn(`Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`);
164164- }
169169+ // Summary
170170+ log.message("---");
171171+ log.info(`Matched: ${matchedCount} documents`);
172172+ if (unmatchedCount > 0) {
173173+ log.warn(
174174+ `Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`,
175175+ );
176176+ }
165177166166- if (dryRun) {
167167- log.info("\nDry run complete. No changes made.");
168168- return;
169169- }
178178+ if (dryRun) {
179179+ log.info("\nDry run complete. No changes made.");
180180+ return;
181181+ }
170182171171- // Save updated state
172172- await saveState(configDir, state);
173173- const newPostCount = Object.keys(state.posts).length;
174174- log.success(`\nSaved .sequoia-state.json (${originalPostCount} → ${newPostCount} entries)`);
183183+ // Save updated state
184184+ await saveState(configDir, state);
185185+ const newPostCount = Object.keys(state.posts).length;
186186+ log.success(
187187+ `\nSaved .sequoia-state.json (${originalPostCount} → ${newPostCount} entries)`,
188188+ );
175189176176- // Update frontmatter if requested
177177- if (frontmatterUpdates.length > 0) {
178178- s.start(`Updating frontmatter in ${frontmatterUpdates.length} files...`);
179179- for (const { filePath, atUri } of frontmatterUpdates) {
180180- const content = await fs.readFile(filePath, "utf-8");
181181- const updated = updateFrontmatterWithAtUri(content, atUri);
182182- await fs.writeFile(filePath, updated);
183183- log.message(` Updated: ${path.basename(filePath)}`);
184184- }
185185- s.stop("Frontmatter updated");
186186- }
190190+ // Update frontmatter if requested
191191+ if (frontmatterUpdates.length > 0) {
192192+ s.start(`Updating frontmatter in ${frontmatterUpdates.length} files...`);
193193+ for (const { filePath, atUri } of frontmatterUpdates) {
194194+ const content = await fs.readFile(filePath, "utf-8");
195195+ const updated = updateFrontmatterWithAtUri(content, atUri);
196196+ await fs.writeFile(filePath, updated);
197197+ log.message(` Updated: ${path.basename(filePath)}`);
198198+ }
199199+ s.stop("Frontmatter updated");
200200+ }
187201188188- log.success("\nSync complete!");
189189- },
202202+ log.success("\nSync complete!");
203203+ },
190204});
+443-416
packages/cli/src/lib/atproto.ts
···11import { AtpAgent } from "@atproto/api";
22-import * as fs from "fs/promises";
33-import * as path from "path";
42import * as mimeTypes from "mime-types";
55-import type { Credentials, BlogPost, BlobObject, PublisherConfig, StrongRef } from "./types";
33+import * as fs from "node:fs/promises";
44+import * as path from "node:path";
65import { stripMarkdownForText } from "./markdown";
66+import type {
77+ BlobObject,
88+ BlogPost,
99+ Credentials,
1010+ PublisherConfig,
1111+ StrongRef,
1212+} from "./types";
713814async function fileExists(filePath: string): Promise<boolean> {
99- try {
1010- await fs.access(filePath);
1111- return true;
1212- } catch {
1313- return false;
1414- }
1515+ try {
1616+ await fs.access(filePath);
1717+ return true;
1818+ } catch {
1919+ return false;
2020+ }
1521}
16221723export async function resolveHandleToPDS(handle: string): Promise<string> {
1818- // First, resolve the handle to a DID
1919- let did: string;
2424+ // First, resolve the handle to a DID
2525+ let did: string;
20262121- if (handle.startsWith("did:")) {
2222- did = handle;
2323- } else {
2424- // Try to resolve handle via Bluesky API
2525- const resolveUrl = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`;
2626- const resolveResponse = await fetch(resolveUrl);
2727- if (!resolveResponse.ok) {
2828- throw new Error("Could not resolve handle");
2929- }
3030- const resolveData = (await resolveResponse.json()) as { did: string };
3131- did = resolveData.did;
3232- }
2727+ if (handle.startsWith("did:")) {
2828+ did = handle;
2929+ } else {
3030+ // Try to resolve handle via Bluesky API
3131+ const resolveUrl = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`;
3232+ const resolveResponse = await fetch(resolveUrl);
3333+ if (!resolveResponse.ok) {
3434+ throw new Error("Could not resolve handle");
3535+ }
3636+ const resolveData = (await resolveResponse.json()) as { did: string };
3737+ did = resolveData.did;
3838+ }
33393434- // Now resolve the DID to get the PDS URL from the DID document
3535- let pdsUrl: string | undefined;
4040+ // Now resolve the DID to get the PDS URL from the DID document
4141+ let pdsUrl: string | undefined;
36423737- if (did.startsWith("did:plc:")) {
3838- // Fetch DID document from plc.directory
3939- const didDocUrl = `https://plc.directory/${did}`;
4040- const didDocResponse = await fetch(didDocUrl);
4141- if (!didDocResponse.ok) {
4242- throw new Error("Could not fetch DID document");
4343- }
4444- const didDoc = (await didDocResponse.json()) as {
4545- service?: Array<{ id: string; type: string; serviceEndpoint: string }>;
4646- };
4343+ if (did.startsWith("did:plc:")) {
4444+ // Fetch DID document from plc.directory
4545+ const didDocUrl = `https://plc.directory/${did}`;
4646+ const didDocResponse = await fetch(didDocUrl);
4747+ if (!didDocResponse.ok) {
4848+ throw new Error("Could not fetch DID document");
4949+ }
5050+ const didDoc = (await didDocResponse.json()) as {
5151+ service?: Array<{ id: string; type: string; serviceEndpoint: string }>;
5252+ };
47534848- // Find the PDS service endpoint
4949- const pdsService = didDoc.service?.find(
5050- (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
5151- );
5252- pdsUrl = pdsService?.serviceEndpoint;
5353- } else if (did.startsWith("did:web:")) {
5454- // For did:web, fetch the DID document from the domain
5555- const domain = did.replace("did:web:", "");
5656- const didDocUrl = `https://${domain}/.well-known/did.json`;
5757- const didDocResponse = await fetch(didDocUrl);
5858- if (!didDocResponse.ok) {
5959- throw new Error("Could not fetch DID document");
6060- }
6161- const didDoc = (await didDocResponse.json()) as {
6262- service?: Array<{ id: string; type: string; serviceEndpoint: string }>;
6363- };
5454+ // Find the PDS service endpoint
5555+ const pdsService = didDoc.service?.find(
5656+ (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
5757+ );
5858+ pdsUrl = pdsService?.serviceEndpoint;
5959+ } else if (did.startsWith("did:web:")) {
6060+ // For did:web, fetch the DID document from the domain
6161+ const domain = did.replace("did:web:", "");
6262+ const didDocUrl = `https://${domain}/.well-known/did.json`;
6363+ const didDocResponse = await fetch(didDocUrl);
6464+ if (!didDocResponse.ok) {
6565+ throw new Error("Could not fetch DID document");
6666+ }
6767+ const didDoc = (await didDocResponse.json()) as {
6868+ service?: Array<{ id: string; type: string; serviceEndpoint: string }>;
6969+ };
64706565- const pdsService = didDoc.service?.find(
6666- (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
6767- );
6868- pdsUrl = pdsService?.serviceEndpoint;
6969- }
7171+ const pdsService = didDoc.service?.find(
7272+ (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
7373+ );
7474+ pdsUrl = pdsService?.serviceEndpoint;
7575+ }
70767171- if (!pdsUrl) {
7272- throw new Error("Could not find PDS URL for user");
7373- }
7777+ if (!pdsUrl) {
7878+ throw new Error("Could not find PDS URL for user");
7979+ }
74807575- return pdsUrl;
8181+ return pdsUrl;
7682}
77837884export interface CreatePublicationOptions {
7979- url: string;
8080- name: string;
8181- description?: string;
8282- iconPath?: string;
8383- showInDiscover?: boolean;
8585+ url: string;
8686+ name: string;
8787+ description?: string;
8888+ iconPath?: string;
8989+ showInDiscover?: boolean;
8490}
85918692export async function createAgent(credentials: Credentials): Promise<AtpAgent> {
8787- const agent = new AtpAgent({ service: credentials.pdsUrl });
9393+ const agent = new AtpAgent({ service: credentials.pdsUrl });
88948989- await agent.login({
9090- identifier: credentials.identifier,
9191- password: credentials.password,
9292- });
9595+ await agent.login({
9696+ identifier: credentials.identifier,
9797+ password: credentials.password,
9898+ });
93999494- return agent;
100100+ return agent;
95101}
9610297103export async function uploadImage(
9898- agent: AtpAgent,
9999- imagePath: string
104104+ agent: AtpAgent,
105105+ imagePath: string,
100106): Promise<BlobObject | undefined> {
101101- if (!(await fileExists(imagePath))) {
102102- return undefined;
103103- }
107107+ if (!(await fileExists(imagePath))) {
108108+ return undefined;
109109+ }
104110105105- try {
106106- const imageBuffer = await fs.readFile(imagePath);
107107- const mimeType = mimeTypes.lookup(imagePath) || "application/octet-stream";
111111+ try {
112112+ const imageBuffer = await fs.readFile(imagePath);
113113+ const mimeType = mimeTypes.lookup(imagePath) || "application/octet-stream";
108114109109- const response = await agent.com.atproto.repo.uploadBlob(
110110- new Uint8Array(imageBuffer),
111111- {
112112- encoding: mimeType,
113113- }
114114- );
115115+ const response = await agent.com.atproto.repo.uploadBlob(
116116+ new Uint8Array(imageBuffer),
117117+ {
118118+ encoding: mimeType,
119119+ },
120120+ );
115121116116- return {
117117- $type: "blob",
118118- ref: {
119119- $link: response.data.blob.ref.toString(),
120120- },
121121- mimeType,
122122- size: imageBuffer.byteLength,
123123- };
124124- } catch (error) {
125125- console.error(`Error uploading image ${imagePath}:`, error);
126126- return undefined;
127127- }
122122+ return {
123123+ $type: "blob",
124124+ ref: {
125125+ $link: response.data.blob.ref.toString(),
126126+ },
127127+ mimeType,
128128+ size: imageBuffer.byteLength,
129129+ };
130130+ } catch (error) {
131131+ console.error(`Error uploading image ${imagePath}:`, error);
132132+ return undefined;
133133+ }
128134}
129135130136export async function resolveImagePath(
131131- ogImage: string,
132132- imagesDir: string | undefined,
133133- contentDir: string
137137+ ogImage: string,
138138+ imagesDir: string | undefined,
139139+ contentDir: string,
134140): Promise<string | null> {
135135- // Try multiple resolution strategies
136136- const filename = path.basename(ogImage);
141141+ // Try multiple resolution strategies
142142+ const filename = path.basename(ogImage);
137143138138- // 1. If imagesDir is specified, look there
139139- if (imagesDir) {
140140- const imagePath = path.join(imagesDir, filename);
141141- if (await fileExists(imagePath)) {
142142- const stat = await fs.stat(imagePath);
143143- if (stat.size > 0) {
144144- return imagePath;
145145- }
146146- }
147147- }
144144+ // 1. If imagesDir is specified, look there
145145+ if (imagesDir) {
146146+ const imagePath = path.join(imagesDir, filename);
147147+ if (await fileExists(imagePath)) {
148148+ const stat = await fs.stat(imagePath);
149149+ if (stat.size > 0) {
150150+ return imagePath;
151151+ }
152152+ }
153153+ }
148154149149- // 2. Try the ogImage path directly (if it's absolute)
150150- if (path.isAbsolute(ogImage)) {
151151- return ogImage;
152152- }
155155+ // 2. Try the ogImage path directly (if it's absolute)
156156+ if (path.isAbsolute(ogImage)) {
157157+ return ogImage;
158158+ }
153159154154- // 3. Try relative to content directory
155155- const contentRelative = path.join(contentDir, ogImage);
156156- if (await fileExists(contentRelative)) {
157157- const stat = await fs.stat(contentRelative);
158158- if (stat.size > 0) {
159159- return contentRelative;
160160- }
161161- }
160160+ // 3. Try relative to content directory
161161+ const contentRelative = path.join(contentDir, ogImage);
162162+ if (await fileExists(contentRelative)) {
163163+ const stat = await fs.stat(contentRelative);
164164+ if (stat.size > 0) {
165165+ return contentRelative;
166166+ }
167167+ }
162168163163- return null;
169169+ return null;
164170}
165171166172export async function createDocument(
167167- agent: AtpAgent,
168168- post: BlogPost,
169169- config: PublisherConfig,
170170- coverImage?: BlobObject
173173+ agent: AtpAgent,
174174+ post: BlogPost,
175175+ config: PublisherConfig,
176176+ coverImage?: BlobObject,
171177): Promise<string> {
172172- const pathPrefix = config.pathPrefix || "/posts";
173173- const postPath = `${pathPrefix}/${post.slug}`;
174174- const publishDate = new Date(post.frontmatter.publishDate);
178178+ const pathPrefix = config.pathPrefix || "/posts";
179179+ const postPath = `${pathPrefix}/${post.slug}`;
180180+ const publishDate = new Date(post.frontmatter.publishDate);
175181176176- // Determine textContent: use configured field from frontmatter, or fallback to markdown body
177177- let textContent: string;
178178- if (config.textContentField && post.rawFrontmatter?.[config.textContentField]) {
179179- textContent = String(post.rawFrontmatter[config.textContentField]);
180180- } else {
181181- textContent = stripMarkdownForText(post.content);
182182- }
182182+ // Determine textContent: use configured field from frontmatter, or fallback to markdown body
183183+ let textContent: string;
184184+ if (
185185+ config.textContentField &&
186186+ post.rawFrontmatter?.[config.textContentField]
187187+ ) {
188188+ textContent = String(post.rawFrontmatter[config.textContentField]);
189189+ } else {
190190+ textContent = stripMarkdownForText(post.content);
191191+ }
183192184184- const record: Record<string, unknown> = {
185185- $type: "site.standard.document",
186186- title: post.frontmatter.title,
187187- site: config.publicationUri,
188188- path: postPath,
189189- textContent: textContent.slice(0, 10000),
190190- publishedAt: publishDate.toISOString(),
191191- canonicalUrl: `${config.siteUrl}${postPath}`,
192192- };
193193+ const record: Record<string, unknown> = {
194194+ $type: "site.standard.document",
195195+ title: post.frontmatter.title,
196196+ site: config.publicationUri,
197197+ path: postPath,
198198+ textContent: textContent.slice(0, 10000),
199199+ publishedAt: publishDate.toISOString(),
200200+ canonicalUrl: `${config.siteUrl}${postPath}`,
201201+ };
193202194194- if (post.frontmatter.description) {
195195- record.description = post.frontmatter.description;
196196- }
203203+ if (post.frontmatter.description) {
204204+ record.description = post.frontmatter.description;
205205+ }
197206198198- if (coverImage) {
199199- record.coverImage = coverImage;
200200- }
207207+ if (coverImage) {
208208+ record.coverImage = coverImage;
209209+ }
201210202202- if (post.frontmatter.tags && post.frontmatter.tags.length > 0) {
203203- record.tags = post.frontmatter.tags;
204204- }
211211+ if (post.frontmatter.tags && post.frontmatter.tags.length > 0) {
212212+ record.tags = post.frontmatter.tags;
213213+ }
205214206206- const response = await agent.com.atproto.repo.createRecord({
207207- repo: agent.session!.did,
208208- collection: "site.standard.document",
209209- record,
210210- });
215215+ const response = await agent.com.atproto.repo.createRecord({
216216+ repo: agent.session!.did,
217217+ collection: "site.standard.document",
218218+ record,
219219+ });
211220212212- return response.data.uri;
221221+ return response.data.uri;
213222}
214223215224export async function updateDocument(
216216- agent: AtpAgent,
217217- post: BlogPost,
218218- atUri: string,
219219- config: PublisherConfig,
220220- coverImage?: BlobObject
225225+ agent: AtpAgent,
226226+ post: BlogPost,
227227+ atUri: string,
228228+ config: PublisherConfig,
229229+ coverImage?: BlobObject,
221230): Promise<void> {
222222- // Parse the atUri to get the collection and rkey
223223- // Format: at://did:plc:xxx/collection/rkey
224224- const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
225225- if (!uriMatch) {
226226- throw new Error(`Invalid atUri format: ${atUri}`);
227227- }
231231+ // Parse the atUri to get the collection and rkey
232232+ // Format: at://did:plc:xxx/collection/rkey
233233+ const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
234234+ if (!uriMatch) {
235235+ throw new Error(`Invalid atUri format: ${atUri}`);
236236+ }
228237229229- const [, , collection, rkey] = uriMatch;
238238+ const [, , collection, rkey] = uriMatch;
230239231231- const pathPrefix = config.pathPrefix || "/posts";
232232- const postPath = `${pathPrefix}/${post.slug}`;
233233- const publishDate = new Date(post.frontmatter.publishDate);
240240+ const pathPrefix = config.pathPrefix || "/posts";
241241+ const postPath = `${pathPrefix}/${post.slug}`;
242242+ const publishDate = new Date(post.frontmatter.publishDate);
234243235235- // Determine textContent: use configured field from frontmatter, or fallback to markdown body
236236- let textContent: string;
237237- if (config.textContentField && post.rawFrontmatter?.[config.textContentField]) {
238238- textContent = String(post.rawFrontmatter[config.textContentField]);
239239- } else {
240240- textContent = stripMarkdownForText(post.content);
241241- }
244244+ // Determine textContent: use configured field from frontmatter, or fallback to markdown body
245245+ let textContent: string;
246246+ if (
247247+ config.textContentField &&
248248+ post.rawFrontmatter?.[config.textContentField]
249249+ ) {
250250+ textContent = String(post.rawFrontmatter[config.textContentField]);
251251+ } else {
252252+ textContent = stripMarkdownForText(post.content);
253253+ }
242254243243- const record: Record<string, unknown> = {
244244- $type: "site.standard.document",
245245- title: post.frontmatter.title,
246246- site: config.publicationUri,
247247- path: postPath,
248248- textContent: textContent.slice(0, 10000),
249249- publishedAt: publishDate.toISOString(),
250250- canonicalUrl: `${config.siteUrl}${postPath}`,
251251- };
255255+ const record: Record<string, unknown> = {
256256+ $type: "site.standard.document",
257257+ title: post.frontmatter.title,
258258+ site: config.publicationUri,
259259+ path: postPath,
260260+ textContent: textContent.slice(0, 10000),
261261+ publishedAt: publishDate.toISOString(),
262262+ canonicalUrl: `${config.siteUrl}${postPath}`,
263263+ };
252264253253- if (post.frontmatter.description) {
254254- record.description = post.frontmatter.description;
255255- }
265265+ if (post.frontmatter.description) {
266266+ record.description = post.frontmatter.description;
267267+ }
256268257257- if (coverImage) {
258258- record.coverImage = coverImage;
259259- }
269269+ if (coverImage) {
270270+ record.coverImage = coverImage;
271271+ }
260272261261- if (post.frontmatter.tags && post.frontmatter.tags.length > 0) {
262262- record.tags = post.frontmatter.tags;
263263- }
273273+ if (post.frontmatter.tags && post.frontmatter.tags.length > 0) {
274274+ record.tags = post.frontmatter.tags;
275275+ }
264276265265- await agent.com.atproto.repo.putRecord({
266266- repo: agent.session!.did,
267267- collection: collection!,
268268- rkey: rkey!,
269269- record,
270270- });
277277+ await agent.com.atproto.repo.putRecord({
278278+ repo: agent.session!.did,
279279+ collection: collection!,
280280+ rkey: rkey!,
281281+ record,
282282+ });
271283}
272284273273-export function parseAtUri(atUri: string): { did: string; collection: string; rkey: string } | null {
274274- const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
275275- if (!match) return null;
276276- return {
277277- did: match[1]!,
278278- collection: match[2]!,
279279- rkey: match[3]!,
280280- };
285285+export function parseAtUri(
286286+ atUri: string,
287287+): { did: string; collection: string; rkey: string } | null {
288288+ const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
289289+ if (!match) return null;
290290+ return {
291291+ did: match[1]!,
292292+ collection: match[2]!,
293293+ rkey: match[3]!,
294294+ };
281295}
282296283297export interface DocumentRecord {
284284- $type: "site.standard.document";
285285- title: string;
286286- site: string;
287287- path: string;
288288- textContent: string;
289289- publishedAt: string;
290290- canonicalUrl?: string;
291291- description?: string;
292292- coverImage?: BlobObject;
293293- tags?: string[];
294294- location?: string;
298298+ $type: "site.standard.document";
299299+ title: string;
300300+ site: string;
301301+ path: string;
302302+ textContent: string;
303303+ publishedAt: string;
304304+ canonicalUrl?: string;
305305+ description?: string;
306306+ coverImage?: BlobObject;
307307+ tags?: string[];
308308+ location?: string;
295309}
296310297311export interface ListDocumentsResult {
298298- uri: string;
299299- cid: string;
300300- value: DocumentRecord;
312312+ uri: string;
313313+ cid: string;
314314+ value: DocumentRecord;
301315}
302316303317export async function listDocuments(
304304- agent: AtpAgent,
305305- publicationUri?: string
318318+ agent: AtpAgent,
319319+ publicationUri?: string,
306320): Promise<ListDocumentsResult[]> {
307307- const documents: ListDocumentsResult[] = [];
308308- let cursor: string | undefined;
321321+ const documents: ListDocumentsResult[] = [];
322322+ let cursor: string | undefined;
309323310310- do {
311311- const response = await agent.com.atproto.repo.listRecords({
312312- repo: agent.session!.did,
313313- collection: "site.standard.document",
314314- limit: 100,
315315- cursor,
316316- });
324324+ do {
325325+ const response = await agent.com.atproto.repo.listRecords({
326326+ repo: agent.session!.did,
327327+ collection: "site.standard.document",
328328+ limit: 100,
329329+ cursor,
330330+ });
317331318318- for (const record of response.data.records) {
319319- const value = record.value as unknown as DocumentRecord;
332332+ for (const record of response.data.records) {
333333+ const value = record.value as unknown as DocumentRecord;
320334321321- // If publicationUri is specified, only include documents from that publication
322322- if (publicationUri && value.site !== publicationUri) {
323323- continue;
324324- }
335335+ // If publicationUri is specified, only include documents from that publication
336336+ if (publicationUri && value.site !== publicationUri) {
337337+ continue;
338338+ }
325339326326- documents.push({
327327- uri: record.uri,
328328- cid: record.cid,
329329- value,
330330- });
331331- }
340340+ documents.push({
341341+ uri: record.uri,
342342+ cid: record.cid,
343343+ value,
344344+ });
345345+ }
332346333333- cursor = response.data.cursor;
334334- } while (cursor);
347347+ cursor = response.data.cursor;
348348+ } while (cursor);
335349336336- return documents;
350350+ return documents;
337351}
338352339353export async function createPublication(
340340- agent: AtpAgent,
341341- options: CreatePublicationOptions
354354+ agent: AtpAgent,
355355+ options: CreatePublicationOptions,
342356): Promise<string> {
343343- let icon: BlobObject | undefined;
357357+ let icon: BlobObject | undefined;
344358345345- if (options.iconPath) {
346346- icon = await uploadImage(agent, options.iconPath);
347347- }
359359+ if (options.iconPath) {
360360+ icon = await uploadImage(agent, options.iconPath);
361361+ }
348362349349- const record: Record<string, unknown> = {
350350- $type: "site.standard.publication",
351351- url: options.url,
352352- name: options.name,
353353- createdAt: new Date().toISOString(),
354354- };
363363+ const record: Record<string, unknown> = {
364364+ $type: "site.standard.publication",
365365+ url: options.url,
366366+ name: options.name,
367367+ createdAt: new Date().toISOString(),
368368+ };
355369356356- if (options.description) {
357357- record.description = options.description;
358358- }
370370+ if (options.description) {
371371+ record.description = options.description;
372372+ }
359373360360- if (icon) {
361361- record.icon = icon;
362362- }
374374+ if (icon) {
375375+ record.icon = icon;
376376+ }
363377364364- if (options.showInDiscover !== undefined) {
365365- record.preferences = {
366366- showInDiscover: options.showInDiscover,
367367- };
368368- }
378378+ if (options.showInDiscover !== undefined) {
379379+ record.preferences = {
380380+ showInDiscover: options.showInDiscover,
381381+ };
382382+ }
369383370370- const response = await agent.com.atproto.repo.createRecord({
371371- repo: agent.session!.did,
372372- collection: "site.standard.publication",
373373- record,
374374- });
384384+ const response = await agent.com.atproto.repo.createRecord({
385385+ repo: agent.session!.did,
386386+ collection: "site.standard.publication",
387387+ record,
388388+ });
375389376376- return response.data.uri;
390390+ return response.data.uri;
377391}
378392379393// --- Bluesky Post Creation ---
380394381395export interface CreateBlueskyPostOptions {
382382- title: string;
383383- description?: string;
384384- canonicalUrl: string;
385385- coverImage?: BlobObject;
386386- publishedAt: string; // Used as createdAt for the post
396396+ title: string;
397397+ description?: string;
398398+ canonicalUrl: string;
399399+ coverImage?: BlobObject;
400400+ publishedAt: string; // Used as createdAt for the post
387401}
388402389403/**
390404 * Count graphemes in a string (for Bluesky's 300 grapheme limit)
391405 */
392406function countGraphemes(str: string): number {
393393- // Use Intl.Segmenter if available, otherwise fallback to spread operator
394394- if (typeof Intl !== "undefined" && Intl.Segmenter) {
395395- const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
396396- return [...segmenter.segment(str)].length;
397397- }
398398- return [...str].length;
407407+ // Use Intl.Segmenter if available, otherwise fallback to spread operator
408408+ if (typeof Intl !== "undefined" && Intl.Segmenter) {
409409+ const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
410410+ return [...segmenter.segment(str)].length;
411411+ }
412412+ return [...str].length;
399413}
400414401415/**
402416 * Truncate a string to a maximum number of graphemes
403417 */
404418function truncateToGraphemes(str: string, maxGraphemes: number): string {
405405- if (typeof Intl !== "undefined" && Intl.Segmenter) {
406406- const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
407407- const segments = [...segmenter.segment(str)];
408408- if (segments.length <= maxGraphemes) return str;
409409- return segments.slice(0, maxGraphemes - 3).map(s => s.segment).join("") + "...";
410410- }
411411- // Fallback
412412- const chars = [...str];
413413- if (chars.length <= maxGraphemes) return str;
414414- return chars.slice(0, maxGraphemes - 3).join("") + "...";
419419+ if (typeof Intl !== "undefined" && Intl.Segmenter) {
420420+ const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
421421+ const segments = [...segmenter.segment(str)];
422422+ if (segments.length <= maxGraphemes) return str;
423423+ return (
424424+ segments
425425+ .slice(0, maxGraphemes - 3)
426426+ .map((s) => s.segment)
427427+ .join("") + "..."
428428+ );
429429+ }
430430+ // Fallback
431431+ const chars = [...str];
432432+ if (chars.length <= maxGraphemes) return str;
433433+ return chars.slice(0, maxGraphemes - 3).join("") + "...";
415434}
416435417436/**
418437 * Create a Bluesky post with external link embed
419438 */
420439export async function createBlueskyPost(
421421- agent: AtpAgent,
422422- options: CreateBlueskyPostOptions
440440+ agent: AtpAgent,
441441+ options: CreateBlueskyPostOptions,
423442): Promise<StrongRef> {
424424- const { title, description, canonicalUrl, coverImage, publishedAt } = options;
443443+ const { title, description, canonicalUrl, coverImage, publishedAt } = options;
425444426426- // Build post text: title + description + URL
427427- // Max 300 graphemes for Bluesky posts
428428- const MAX_GRAPHEMES = 300;
445445+ // Build post text: title + description + URL
446446+ // Max 300 graphemes for Bluesky posts
447447+ const MAX_GRAPHEMES = 300;
429448430430- let postText: string;
431431- const urlPart = `\n\n${canonicalUrl}`;
432432- const urlGraphemes = countGraphemes(urlPart);
449449+ let postText: string;
450450+ const urlPart = `\n\n${canonicalUrl}`;
451451+ const urlGraphemes = countGraphemes(urlPart);
433452434434- if (description) {
435435- // Try: title + description + URL
436436- const fullText = `${title}\n\n${description}${urlPart}`;
437437- if (countGraphemes(fullText) <= MAX_GRAPHEMES) {
438438- postText = fullText;
439439- } else {
440440- // Truncate description to fit
441441- const availableForDesc = MAX_GRAPHEMES - countGraphemes(title) - countGraphemes("\n\n") - urlGraphemes - countGraphemes("\n\n");
442442- if (availableForDesc > 10) {
443443- const truncatedDesc = truncateToGraphemes(description, availableForDesc);
444444- postText = `${title}\n\n${truncatedDesc}${urlPart}`;
445445- } else {
446446- // Just title + URL
447447- postText = `${title}${urlPart}`;
448448- }
449449- }
450450- } else {
451451- // Just title + URL
452452- postText = `${title}${urlPart}`;
453453- }
453453+ if (description) {
454454+ // Try: title + description + URL
455455+ const fullText = `${title}\n\n${description}${urlPart}`;
456456+ if (countGraphemes(fullText) <= MAX_GRAPHEMES) {
457457+ postText = fullText;
458458+ } else {
459459+ // Truncate description to fit
460460+ const availableForDesc =
461461+ MAX_GRAPHEMES -
462462+ countGraphemes(title) -
463463+ countGraphemes("\n\n") -
464464+ urlGraphemes -
465465+ countGraphemes("\n\n");
466466+ if (availableForDesc > 10) {
467467+ const truncatedDesc = truncateToGraphemes(
468468+ description,
469469+ availableForDesc,
470470+ );
471471+ postText = `${title}\n\n${truncatedDesc}${urlPart}`;
472472+ } else {
473473+ // Just title + URL
474474+ postText = `${title}${urlPart}`;
475475+ }
476476+ }
477477+ } else {
478478+ // Just title + URL
479479+ postText = `${title}${urlPart}`;
480480+ }
454481455455- // Final truncation if still too long (shouldn't happen but safety check)
456456- if (countGraphemes(postText) > MAX_GRAPHEMES) {
457457- postText = truncateToGraphemes(postText, MAX_GRAPHEMES);
458458- }
482482+ // Final truncation if still too long (shouldn't happen but safety check)
483483+ if (countGraphemes(postText) > MAX_GRAPHEMES) {
484484+ postText = truncateToGraphemes(postText, MAX_GRAPHEMES);
485485+ }
459486460460- // Calculate byte indices for the URL facet
461461- const encoder = new TextEncoder();
462462- const urlStartInText = postText.lastIndexOf(canonicalUrl);
463463- const beforeUrl = postText.substring(0, urlStartInText);
464464- const byteStart = encoder.encode(beforeUrl).length;
465465- const byteEnd = byteStart + encoder.encode(canonicalUrl).length;
487487+ // Calculate byte indices for the URL facet
488488+ const encoder = new TextEncoder();
489489+ const urlStartInText = postText.lastIndexOf(canonicalUrl);
490490+ const beforeUrl = postText.substring(0, urlStartInText);
491491+ const byteStart = encoder.encode(beforeUrl).length;
492492+ const byteEnd = byteStart + encoder.encode(canonicalUrl).length;
466493467467- // Build facets for the URL link
468468- const facets = [
469469- {
470470- index: {
471471- byteStart,
472472- byteEnd,
473473- },
474474- features: [
475475- {
476476- $type: "app.bsky.richtext.facet#link",
477477- uri: canonicalUrl,
478478- },
479479- ],
480480- },
481481- ];
494494+ // Build facets for the URL link
495495+ const facets = [
496496+ {
497497+ index: {
498498+ byteStart,
499499+ byteEnd,
500500+ },
501501+ features: [
502502+ {
503503+ $type: "app.bsky.richtext.facet#link",
504504+ uri: canonicalUrl,
505505+ },
506506+ ],
507507+ },
508508+ ];
482509483483- // Build external embed
484484- const embed: Record<string, unknown> = {
485485- $type: "app.bsky.embed.external",
486486- external: {
487487- uri: canonicalUrl,
488488- title: title.substring(0, 500), // Max 500 chars for title
489489- description: (description || "").substring(0, 1000), // Max 1000 chars for description
490490- },
491491- };
510510+ // Build external embed
511511+ const embed: Record<string, unknown> = {
512512+ $type: "app.bsky.embed.external",
513513+ external: {
514514+ uri: canonicalUrl,
515515+ title: title.substring(0, 500), // Max 500 chars for title
516516+ description: (description || "").substring(0, 1000), // Max 1000 chars for description
517517+ },
518518+ };
492519493493- // Add thumbnail if coverImage is available
494494- if (coverImage) {
495495- (embed.external as Record<string, unknown>).thumb = coverImage;
496496- }
520520+ // Add thumbnail if coverImage is available
521521+ if (coverImage) {
522522+ (embed.external as Record<string, unknown>).thumb = coverImage;
523523+ }
497524498498- // Create the post record
499499- const record: Record<string, unknown> = {
500500- $type: "app.bsky.feed.post",
501501- text: postText,
502502- facets,
503503- embed,
504504- createdAt: new Date(publishedAt).toISOString(),
505505- };
525525+ // Create the post record
526526+ const record: Record<string, unknown> = {
527527+ $type: "app.bsky.feed.post",
528528+ text: postText,
529529+ facets,
530530+ embed,
531531+ createdAt: new Date(publishedAt).toISOString(),
532532+ };
506533507507- const response = await agent.com.atproto.repo.createRecord({
508508- repo: agent.session!.did,
509509- collection: "app.bsky.feed.post",
510510- record,
511511- });
534534+ const response = await agent.com.atproto.repo.createRecord({
535535+ repo: agent.session!.did,
536536+ collection: "app.bsky.feed.post",
537537+ record,
538538+ });
512539513513- return {
514514- uri: response.data.uri,
515515- cid: response.data.cid,
516516- };
540540+ return {
541541+ uri: response.data.uri,
542542+ cid: response.data.cid,
543543+ };
517544}
518545519546/**
520547 * Add bskyPostRef to an existing document record
521548 */
522549export async function addBskyPostRefToDocument(
523523- agent: AtpAgent,
524524- documentAtUri: string,
525525- bskyPostRef: StrongRef
550550+ agent: AtpAgent,
551551+ documentAtUri: string,
552552+ bskyPostRef: StrongRef,
526553): Promise<void> {
527527- const parsed = parseAtUri(documentAtUri);
528528- if (!parsed) {
529529- throw new Error(`Invalid document URI: ${documentAtUri}`);
530530- }
554554+ const parsed = parseAtUri(documentAtUri);
555555+ if (!parsed) {
556556+ throw new Error(`Invalid document URI: ${documentAtUri}`);
557557+ }
531558532532- // Fetch existing record
533533- const existingRecord = await agent.com.atproto.repo.getRecord({
534534- repo: parsed.did,
535535- collection: parsed.collection,
536536- rkey: parsed.rkey,
537537- });
559559+ // Fetch existing record
560560+ const existingRecord = await agent.com.atproto.repo.getRecord({
561561+ repo: parsed.did,
562562+ collection: parsed.collection,
563563+ rkey: parsed.rkey,
564564+ });
538565539539- // Add bskyPostRef to the record
540540- const updatedRecord = {
541541- ...(existingRecord.data.value as Record<string, unknown>),
542542- bskyPostRef,
543543- };
566566+ // Add bskyPostRef to the record
567567+ const updatedRecord = {
568568+ ...(existingRecord.data.value as Record<string, unknown>),
569569+ bskyPostRef,
570570+ };
544571545545- // Update the record
546546- await agent.com.atproto.repo.putRecord({
547547- repo: parsed.did,
548548- collection: parsed.collection,
549549- rkey: parsed.rkey,
550550- record: updatedRecord,
551551- });
572572+ // Update the record
573573+ await agent.com.atproto.repo.putRecord({
574574+ repo: parsed.did,
575575+ collection: parsed.collection,
576576+ rkey: parsed.rkey,
577577+ record: updatedRecord,
578578+ });
552579}
+9-3
packages/cli/src/lib/config.ts
···11-import * as fs from "fs/promises";
22-import * as path from "path";
33-import type { PublisherConfig, PublisherState, FrontmatterMapping, BlueskyConfig } from "./types";
11+import * as fs from "node:fs/promises";
22+import * as path from "node:path";
33+import type {
44+ PublisherConfig,
55+ PublisherState,
66+ FrontmatterMapping,
77+ BlueskyConfig,
88+} from "./types";
49510const CONFIG_FILENAME = "sequoia.json";
611const STATE_FILENAME = ".sequoia-state.json";
···131136132137 if (options.textContentField) {
133138 config.textContentField = options.textContentField;
139139+ }
134140 if (options.bluesky) {
135141 config.bluesky = options.bluesky;
136142 }
+90-90
packages/cli/src/lib/credentials.ts
···11-import * as fs from "fs/promises";
22-import * as path from "path";
33-import * as os from "os";
11+import * as fs from "node:fs/promises";
22+import * as os from "node:os";
33+import * as path from "node:path";
44import type { Credentials } from "./types";
5566const CONFIG_DIR = path.join(os.homedir(), ".config", "sequoia");
···1010type CredentialsStore = Record<string, Credentials>;
11111212async function fileExists(filePath: string): Promise<boolean> {
1313- try {
1414- await fs.access(filePath);
1515- return true;
1616- } catch {
1717- return false;
1818- }
1313+ try {
1414+ await fs.access(filePath);
1515+ return true;
1616+ } catch {
1717+ return false;
1818+ }
1919}
20202121/**
2222 * Load all stored credentials
2323 */
2424async function loadCredentialsStore(): Promise<CredentialsStore> {
2525- if (!(await fileExists(CREDENTIALS_FILE))) {
2626- return {};
2727- }
2525+ if (!(await fileExists(CREDENTIALS_FILE))) {
2626+ return {};
2727+ }
28282929- try {
3030- const content = await fs.readFile(CREDENTIALS_FILE, "utf-8");
3131- const parsed = JSON.parse(content);
2929+ try {
3030+ const content = await fs.readFile(CREDENTIALS_FILE, "utf-8");
3131+ const parsed = JSON.parse(content);
32323333- // Handle legacy single-credential format (migrate on read)
3434- if (parsed.identifier && parsed.password) {
3535- const legacy = parsed as Credentials;
3636- return { [legacy.identifier]: legacy };
3737- }
3333+ // Handle legacy single-credential format (migrate on read)
3434+ if (parsed.identifier && parsed.password) {
3535+ const legacy = parsed as Credentials;
3636+ return { [legacy.identifier]: legacy };
3737+ }
38383939- return parsed as CredentialsStore;
4040- } catch {
4141- return {};
4242- }
3939+ return parsed as CredentialsStore;
4040+ } catch {
4141+ return {};
4242+ }
4343}
44444545/**
4646 * Save the entire credentials store
4747 */
4848async function saveCredentialsStore(store: CredentialsStore): Promise<void> {
4949- await fs.mkdir(CONFIG_DIR, { recursive: true });
5050- await fs.writeFile(CREDENTIALS_FILE, JSON.stringify(store, null, 2));
5151- await fs.chmod(CREDENTIALS_FILE, 0o600);
4949+ await fs.mkdir(CONFIG_DIR, { recursive: true });
5050+ await fs.writeFile(CREDENTIALS_FILE, JSON.stringify(store, null, 2));
5151+ await fs.chmod(CREDENTIALS_FILE, 0o600);
5252}
53535454/**
···6262 * 5. Return null (caller should prompt user)
6363 */
6464export async function loadCredentials(
6565- projectIdentity?: string
6565+ projectIdentity?: string,
6666): Promise<Credentials | null> {
6767- // 1. Check environment variables first (full override)
6868- const envIdentifier = process.env.ATP_IDENTIFIER;
6969- const envPassword = process.env.ATP_APP_PASSWORD;
7070- const envPdsUrl = process.env.PDS_URL;
6767+ // 1. Check environment variables first (full override)
6868+ const envIdentifier = process.env.ATP_IDENTIFIER;
6969+ const envPassword = process.env.ATP_APP_PASSWORD;
7070+ const envPdsUrl = process.env.PDS_URL;
71717272- if (envIdentifier && envPassword) {
7373- return {
7474- identifier: envIdentifier,
7575- password: envPassword,
7676- pdsUrl: envPdsUrl || "https://bsky.social",
7777- };
7878- }
7272+ if (envIdentifier && envPassword) {
7373+ return {
7474+ identifier: envIdentifier,
7575+ password: envPassword,
7676+ pdsUrl: envPdsUrl || "https://bsky.social",
7777+ };
7878+ }
79798080- const store = await loadCredentialsStore();
8181- const identifiers = Object.keys(store);
8080+ const store = await loadCredentialsStore();
8181+ const identifiers = Object.keys(store);
82828383- if (identifiers.length === 0) {
8484- return null;
8585- }
8383+ if (identifiers.length === 0) {
8484+ return null;
8585+ }
86868787- // 2. SEQUOIA_PROFILE env var
8888- const profileEnv = process.env.SEQUOIA_PROFILE;
8989- if (profileEnv && store[profileEnv]) {
9090- return store[profileEnv];
9191- }
8787+ // 2. SEQUOIA_PROFILE env var
8888+ const profileEnv = process.env.SEQUOIA_PROFILE;
8989+ if (profileEnv && store[profileEnv]) {
9090+ return store[profileEnv];
9191+ }
92929393- // 3. Project-specific identity (from sequoia.json)
9494- if (projectIdentity && store[projectIdentity]) {
9595- return store[projectIdentity];
9696- }
9393+ // 3. Project-specific identity (from sequoia.json)
9494+ if (projectIdentity && store[projectIdentity]) {
9595+ return store[projectIdentity];
9696+ }
97979898- // 4. If only one identity, use it
9999- if (identifiers.length === 1 && identifiers[0]) {
100100- return store[identifiers[0]] ?? null;
101101- }
9898+ // 4. If only one identity, use it
9999+ if (identifiers.length === 1 && identifiers[0]) {
100100+ return store[identifiers[0]] ?? null;
101101+ }
102102103103- // Multiple identities exist but none selected
104104- return null;
103103+ // Multiple identities exist but none selected
104104+ return null;
105105}
106106107107/**
108108 * Get a specific identity by identifier
109109 */
110110export async function getCredentials(
111111- identifier: string
111111+ identifier: string,
112112): Promise<Credentials | null> {
113113- const store = await loadCredentialsStore();
114114- return store[identifier] || null;
113113+ const store = await loadCredentialsStore();
114114+ return store[identifier] || null;
115115}
116116117117/**
118118 * List all stored identities
119119 */
120120export async function listCredentials(): Promise<string[]> {
121121- const store = await loadCredentialsStore();
122122- return Object.keys(store);
121121+ const store = await loadCredentialsStore();
122122+ return Object.keys(store);
123123}
124124125125/**
126126 * Save credentials for an identity (adds or updates)
127127 */
128128export async function saveCredentials(credentials: Credentials): Promise<void> {
129129- const store = await loadCredentialsStore();
130130- store[credentials.identifier] = credentials;
131131- await saveCredentialsStore(store);
129129+ const store = await loadCredentialsStore();
130130+ store[credentials.identifier] = credentials;
131131+ await saveCredentialsStore(store);
132132}
133133134134/**
135135 * Delete credentials for a specific identity
136136 */
137137export async function deleteCredentials(identifier?: string): Promise<boolean> {
138138- const store = await loadCredentialsStore();
139139- const identifiers = Object.keys(store);
138138+ const store = await loadCredentialsStore();
139139+ const identifiers = Object.keys(store);
140140141141- if (identifiers.length === 0) {
142142- return false;
143143- }
141141+ if (identifiers.length === 0) {
142142+ return false;
143143+ }
144144145145- // If identifier specified, delete just that one
146146- if (identifier) {
147147- if (!store[identifier]) {
148148- return false;
149149- }
150150- delete store[identifier];
151151- await saveCredentialsStore(store);
152152- return true;
153153- }
145145+ // If identifier specified, delete just that one
146146+ if (identifier) {
147147+ if (!store[identifier]) {
148148+ return false;
149149+ }
150150+ delete store[identifier];
151151+ await saveCredentialsStore(store);
152152+ return true;
153153+ }
154154155155- // If only one identity, delete it (backwards compat behavior)
156156- if (identifiers.length === 1 && identifiers[0]) {
157157- delete store[identifiers[0]];
158158- await saveCredentialsStore(store);
159159- return true;
160160- }
155155+ // If only one identity, delete it (backwards compat behavior)
156156+ if (identifiers.length === 1 && identifiers[0]) {
157157+ delete store[identifiers[0]];
158158+ await saveCredentialsStore(store);
159159+ return true;
160160+ }
161161162162- // Multiple identities but none specified
163163- return false;
162162+ // Multiple identities but none specified
163163+ return false;
164164}
165165166166export function getCredentialsPath(): string {
167167- return CREDENTIALS_FILE;
167167+ return CREDENTIALS_FILE;
168168}
+321-289
packages/cli/src/lib/markdown.ts
···11-import * as fs from "fs/promises";
22-import * as path from "path";
11+import * as fs from "node:fs/promises";
22+import * as path from "node:path";
33import { glob } from "glob";
44import { minimatch } from "minimatch";
55-import type { PostFrontmatter, BlogPost, FrontmatterMapping } from "./types";
55+import type { BlogPost, FrontmatterMapping, PostFrontmatter } from "./types";
6677-export function parseFrontmatter(content: string, mapping?: FrontmatterMapping): {
88- frontmatter: PostFrontmatter;
99- body: string;
1010- rawFrontmatter: Record<string, unknown>;
77+export function parseFrontmatter(
88+ content: string,
99+ mapping?: FrontmatterMapping,
1010+): {
1111+ frontmatter: PostFrontmatter;
1212+ body: string;
1313+ rawFrontmatter: Record<string, unknown>;
1114} {
1212- // Support multiple frontmatter delimiters:
1313- // --- (YAML) - Jekyll, Astro, most SSGs
1414- // +++ (TOML) - Hugo
1515- // *** - Alternative format
1616- const frontmatterRegex = /^(---|\+\+\+|\*\*\*)\n([\s\S]*?)\n\1\n([\s\S]*)$/;
1717- const match = content.match(frontmatterRegex);
1515+ // Support multiple frontmatter delimiters:
1616+ // --- (YAML) - Jekyll, Astro, most SSGs
1717+ // +++ (TOML) - Hugo
1818+ // *** - Alternative format
1919+ const frontmatterRegex = /^(---|\+\+\+|\*\*\*)\n([\s\S]*?)\n\1\n([\s\S]*)$/;
2020+ const match = content.match(frontmatterRegex);
18211919- if (!match) {
2020- throw new Error("Could not parse frontmatter");
2121- }
2222+ if (!match) {
2323+ throw new Error("Could not parse frontmatter");
2424+ }
22252323- const delimiter = match[1];
2424- const frontmatterStr = match[2] ?? "";
2525- const body = match[3] ?? "";
2626+ const delimiter = match[1];
2727+ const frontmatterStr = match[2] ?? "";
2828+ const body = match[3] ?? "";
26292727- // Determine format based on delimiter:
2828- // +++ uses TOML (key = value)
2929- // --- and *** use YAML (key: value)
3030- const isToml = delimiter === "+++";
3131- const separator = isToml ? "=" : ":";
3030+ // Determine format based on delimiter:
3131+ // +++ uses TOML (key = value)
3232+ // --- and *** use YAML (key: value)
3333+ const isToml = delimiter === "+++";
3434+ const separator = isToml ? "=" : ":";
32353333- // Parse frontmatter manually
3434- const raw: Record<string, unknown> = {};
3535- const lines = frontmatterStr.split("\n");
3636+ // Parse frontmatter manually
3737+ const raw: Record<string, unknown> = {};
3838+ const lines = frontmatterStr.split("\n");
36393737- let i = 0;
3838- while (i < lines.length) {
3939- const line = lines[i];
4040- if (line === undefined) {
4141- i++;
4242- continue;
4343- }
4444- const sepIndex = line.indexOf(separator);
4545- if (sepIndex === -1) {
4646- i++;
4747- continue;
4848- }
4040+ let i = 0;
4141+ while (i < lines.length) {
4242+ const line = lines[i];
4343+ if (line === undefined) {
4444+ i++;
4545+ continue;
4646+ }
4747+ const sepIndex = line.indexOf(separator);
4848+ if (sepIndex === -1) {
4949+ i++;
5050+ continue;
5151+ }
49525050- const key = line.slice(0, sepIndex).trim();
5151- let value = line.slice(sepIndex + 1).trim();
5353+ const key = line.slice(0, sepIndex).trim();
5454+ let value = line.slice(sepIndex + 1).trim();
52555353- // Handle quoted strings
5454- if (
5555- (value.startsWith('"') && value.endsWith('"')) ||
5656- (value.startsWith("'") && value.endsWith("'"))
5757- ) {
5858- value = value.slice(1, -1);
5959- }
5656+ // Handle quoted strings
5757+ if (
5858+ (value.startsWith('"') && value.endsWith('"')) ||
5959+ (value.startsWith("'") && value.endsWith("'"))
6060+ ) {
6161+ value = value.slice(1, -1);
6262+ }
60636161- // Handle inline arrays (simple case for tags)
6262- if (value.startsWith("[") && value.endsWith("]")) {
6363- const arrayContent = value.slice(1, -1);
6464- raw[key] = arrayContent
6565- .split(",")
6666- .map((item) => item.trim().replace(/^["']|["']$/g, ""));
6767- } else if (value === "" && !isToml) {
6868- // Check for YAML-style multiline array (key with no value followed by - items)
6969- const arrayItems: string[] = [];
7070- let j = i + 1;
7171- while (j < lines.length) {
7272- const nextLine = lines[j];
7373- if (nextLine === undefined) {
7474- j++;
7575- continue;
7676- }
7777- // Check if line is a list item (starts with whitespace and -)
7878- const listMatch = nextLine.match(/^\s+-\s*(.*)$/);
7979- if (listMatch && listMatch[1] !== undefined) {
8080- let itemValue = listMatch[1].trim();
8181- // Remove quotes if present
8282- if (
8383- (itemValue.startsWith('"') && itemValue.endsWith('"')) ||
8484- (itemValue.startsWith("'") && itemValue.endsWith("'"))
8585- ) {
8686- itemValue = itemValue.slice(1, -1);
8787- }
8888- arrayItems.push(itemValue);
8989- j++;
9090- } else if (nextLine.trim() === "") {
9191- // Skip empty lines within the array
9292- j++;
9393- } else {
9494- // Hit a new key or non-list content
9595- break;
9696- }
9797- }
9898- if (arrayItems.length > 0) {
9999- raw[key] = arrayItems;
100100- i = j;
101101- continue;
102102- } else {
103103- raw[key] = value;
104104- }
105105- } else if (value === "true") {
106106- raw[key] = true;
107107- } else if (value === "false") {
108108- raw[key] = false;
109109- } else {
110110- raw[key] = value;
111111- }
112112- i++;
113113- }
6464+ // Handle inline arrays (simple case for tags)
6565+ if (value.startsWith("[") && value.endsWith("]")) {
6666+ const arrayContent = value.slice(1, -1);
6767+ raw[key] = arrayContent
6868+ .split(",")
6969+ .map((item) => item.trim().replace(/^["']|["']$/g, ""));
7070+ } else if (value === "" && !isToml) {
7171+ // Check for YAML-style multiline array (key with no value followed by - items)
7272+ const arrayItems: string[] = [];
7373+ let j = i + 1;
7474+ while (j < lines.length) {
7575+ const nextLine = lines[j];
7676+ if (nextLine === undefined) {
7777+ j++;
7878+ continue;
7979+ }
8080+ // Check if line is a list item (starts with whitespace and -)
8181+ const listMatch = nextLine.match(/^\s+-\s*(.*)$/);
8282+ if (listMatch && listMatch[1] !== undefined) {
8383+ let itemValue = listMatch[1].trim();
8484+ // Remove quotes if present
8585+ if (
8686+ (itemValue.startsWith('"') && itemValue.endsWith('"')) ||
8787+ (itemValue.startsWith("'") && itemValue.endsWith("'"))
8888+ ) {
8989+ itemValue = itemValue.slice(1, -1);
9090+ }
9191+ arrayItems.push(itemValue);
9292+ j++;
9393+ } else if (nextLine.trim() === "") {
9494+ // Skip empty lines within the array
9595+ j++;
9696+ } else {
9797+ // Hit a new key or non-list content
9898+ break;
9999+ }
100100+ }
101101+ if (arrayItems.length > 0) {
102102+ raw[key] = arrayItems;
103103+ i = j;
104104+ continue;
105105+ } else {
106106+ raw[key] = value;
107107+ }
108108+ } else if (value === "true") {
109109+ raw[key] = true;
110110+ } else if (value === "false") {
111111+ raw[key] = false;
112112+ } else {
113113+ raw[key] = value;
114114+ }
115115+ i++;
116116+ }
114117115115- // Apply field mappings to normalize to standard PostFrontmatter fields
116116- const frontmatter: Record<string, unknown> = {};
118118+ // Apply field mappings to normalize to standard PostFrontmatter fields
119119+ const frontmatter: Record<string, unknown> = {};
117120118118- // Title mapping
119119- const titleField = mapping?.title || "title";
120120- frontmatter.title = raw[titleField] || raw.title;
121121+ // Title mapping
122122+ const titleField = mapping?.title || "title";
123123+ frontmatter.title = raw[titleField] || raw.title;
121124122122- // Description mapping
123123- const descField = mapping?.description || "description";
124124- frontmatter.description = raw[descField] || raw.description;
125125+ // Description mapping
126126+ const descField = mapping?.description || "description";
127127+ frontmatter.description = raw[descField] || raw.description;
125128126126- // Publish date mapping - check custom field first, then fallbacks
127127- const dateField = mapping?.publishDate;
128128- if (dateField && raw[dateField]) {
129129- frontmatter.publishDate = raw[dateField];
130130- } else if (raw.publishDate) {
131131- frontmatter.publishDate = raw.publishDate;
132132- } else {
133133- // Fallback to common date field names
134134- const dateFields = ["pubDate", "date", "createdAt", "created_at"];
135135- for (const field of dateFields) {
136136- if (raw[field]) {
137137- frontmatter.publishDate = raw[field];
138138- break;
139139- }
140140- }
141141- }
129129+ // Publish date mapping - check custom field first, then fallbacks
130130+ const dateField = mapping?.publishDate;
131131+ if (dateField && raw[dateField]) {
132132+ frontmatter.publishDate = raw[dateField];
133133+ } else if (raw.publishDate) {
134134+ frontmatter.publishDate = raw.publishDate;
135135+ } else {
136136+ // Fallback to common date field names
137137+ const dateFields = ["pubDate", "date", "createdAt", "created_at"];
138138+ for (const field of dateFields) {
139139+ if (raw[field]) {
140140+ frontmatter.publishDate = raw[field];
141141+ break;
142142+ }
143143+ }
144144+ }
142145143143- // Cover image mapping
144144- const coverField = mapping?.coverImage || "ogImage";
145145- frontmatter.ogImage = raw[coverField] || raw.ogImage;
146146+ // Cover image mapping
147147+ const coverField = mapping?.coverImage || "ogImage";
148148+ frontmatter.ogImage = raw[coverField] || raw.ogImage;
146149147147- // Tags mapping
148148- const tagsField = mapping?.tags || "tags";
149149- frontmatter.tags = raw[tagsField] || raw.tags;
150150+ // Tags mapping
151151+ const tagsField = mapping?.tags || "tags";
152152+ frontmatter.tags = raw[tagsField] || raw.tags;
150153151151- // Draft mapping
152152- const draftField = mapping?.draft || "draft";
153153- const draftValue = raw[draftField] ?? raw.draft;
154154- if (draftValue !== undefined) {
155155- frontmatter.draft = draftValue === true || draftValue === "true";
156156- }
154154+ // Draft mapping
155155+ const draftField = mapping?.draft || "draft";
156156+ const draftValue = raw[draftField] ?? raw.draft;
157157+ if (draftValue !== undefined) {
158158+ frontmatter.draft = draftValue === true || draftValue === "true";
159159+ }
157160158158- // Always preserve atUri (internal field)
159159- frontmatter.atUri = raw.atUri;
161161+ // Always preserve atUri (internal field)
162162+ frontmatter.atUri = raw.atUri;
160163161161- return { frontmatter: frontmatter as unknown as PostFrontmatter, body, rawFrontmatter: raw };
164164+ return {
165165+ frontmatter: frontmatter as unknown as PostFrontmatter,
166166+ body,
167167+ rawFrontmatter: raw,
168168+ };
162169}
163170164171export function getSlugFromFilename(filename: string): string {
165165- return filename
166166- .replace(/\.mdx?$/, "")
167167- .toLowerCase()
168168- .replace(/\s+/g, "-");
172172+ return filename
173173+ .replace(/\.mdx?$/, "")
174174+ .toLowerCase()
175175+ .replace(/\s+/g, "-");
169176}
170177171178export interface SlugOptions {
172172- slugSource?: "filename" | "path" | "frontmatter";
173173- slugField?: string;
174174- removeIndexFromSlug?: boolean;
179179+ slugSource?: "filename" | "path" | "frontmatter";
180180+ slugField?: string;
181181+ removeIndexFromSlug?: boolean;
175182}
176183177184export function getSlugFromOptions(
178178- relativePath: string,
179179- rawFrontmatter: Record<string, unknown>,
180180- options: SlugOptions = {}
185185+ relativePath: string,
186186+ rawFrontmatter: Record<string, unknown>,
187187+ options: SlugOptions = {},
181188): string {
182182- const { slugSource = "filename", slugField = "slug", removeIndexFromSlug = false } = options;
189189+ const {
190190+ slugSource = "filename",
191191+ slugField = "slug",
192192+ removeIndexFromSlug = false,
193193+ } = options;
183194184184- let slug: string;
195195+ let slug: string;
185196186186- switch (slugSource) {
187187- case "path":
188188- // Use full relative path without extension
189189- slug = relativePath
190190- .replace(/\.mdx?$/, "")
191191- .toLowerCase()
192192- .replace(/\s+/g, "-");
193193- break;
197197+ switch (slugSource) {
198198+ case "path":
199199+ // Use full relative path without extension
200200+ slug = relativePath
201201+ .replace(/\.mdx?$/, "")
202202+ .toLowerCase()
203203+ .replace(/\s+/g, "-");
204204+ break;
194205195195- case "frontmatter":
196196- // Use frontmatter field (slug or url)
197197- const frontmatterValue = rawFrontmatter[slugField] || rawFrontmatter.slug || rawFrontmatter.url;
198198- if (frontmatterValue && typeof frontmatterValue === "string") {
199199- // Remove leading slash if present
200200- slug = frontmatterValue.replace(/^\//, "").toLowerCase().replace(/\s+/g, "-");
201201- } else {
202202- // Fallback to filename if frontmatter field not found
203203- slug = getSlugFromFilename(path.basename(relativePath));
204204- }
205205- break;
206206+ case "frontmatter": {
207207+ // Use frontmatter field (slug or url)
208208+ const frontmatterValue =
209209+ rawFrontmatter[slugField] || rawFrontmatter.slug || rawFrontmatter.url;
210210+ if (frontmatterValue && typeof frontmatterValue === "string") {
211211+ // Remove leading slash if present
212212+ slug = frontmatterValue
213213+ .replace(/^\//, "")
214214+ .toLowerCase()
215215+ .replace(/\s+/g, "-");
216216+ } else {
217217+ // Fallback to filename if frontmatter field not found
218218+ slug = getSlugFromFilename(path.basename(relativePath));
219219+ }
220220+ break;
221221+ }
206222207207- case "filename":
208208- default:
209209- slug = getSlugFromFilename(path.basename(relativePath));
210210- break;
211211- }
223223+ case "filename":
224224+ default:
225225+ slug = getSlugFromFilename(path.basename(relativePath));
226226+ break;
227227+ }
212228213213- // Remove /index or /_index suffix if configured
214214- if (removeIndexFromSlug) {
215215- slug = slug.replace(/\/_?index$/, "");
216216- }
229229+ // Remove /index or /_index suffix if configured
230230+ if (removeIndexFromSlug) {
231231+ slug = slug.replace(/\/_?index$/, "");
232232+ }
217233218218- return slug;
234234+ return slug;
219235}
220236221237export async function getContentHash(content: string): Promise<string> {
222222- const encoder = new TextEncoder();
223223- const data = encoder.encode(content);
224224- const hashBuffer = await crypto.subtle.digest("SHA-256", data);
225225- const hashArray = Array.from(new Uint8Array(hashBuffer));
226226- return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
238238+ const encoder = new TextEncoder();
239239+ const data = encoder.encode(content);
240240+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
241241+ const hashArray = Array.from(new Uint8Array(hashBuffer));
242242+ return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
227243}
228244229245function shouldIgnore(relativePath: string, ignorePatterns: string[]): boolean {
230230- for (const pattern of ignorePatterns) {
231231- if (minimatch(relativePath, pattern)) {
232232- return true;
233233- }
234234- }
235235- return false;
246246+ for (const pattern of ignorePatterns) {
247247+ if (minimatch(relativePath, pattern)) {
248248+ return true;
249249+ }
250250+ }
251251+ return false;
236252}
237253238254export interface ScanOptions {
239239- frontmatterMapping?: FrontmatterMapping;
240240- ignorePatterns?: string[];
241241- slugSource?: "filename" | "path" | "frontmatter";
242242- slugField?: string;
243243- removeIndexFromSlug?: boolean;
255255+ frontmatterMapping?: FrontmatterMapping;
256256+ ignorePatterns?: string[];
257257+ slugSource?: "filename" | "path" | "frontmatter";
258258+ slugField?: string;
259259+ removeIndexFromSlug?: boolean;
244260}
245261246262export async function scanContentDirectory(
247247- contentDir: string,
248248- frontmatterMappingOrOptions?: FrontmatterMapping | ScanOptions,
249249- ignorePatterns: string[] = []
263263+ contentDir: string,
264264+ frontmatterMappingOrOptions?: FrontmatterMapping | ScanOptions,
265265+ ignorePatterns: string[] = [],
250266): Promise<BlogPost[]> {
251251- // Handle both old signature (frontmatterMapping, ignorePatterns) and new signature (options)
252252- let options: ScanOptions;
253253- if (frontmatterMappingOrOptions && ('slugSource' in frontmatterMappingOrOptions || 'frontmatterMapping' in frontmatterMappingOrOptions || 'ignorePatterns' in frontmatterMappingOrOptions)) {
254254- options = frontmatterMappingOrOptions as ScanOptions;
255255- } else {
256256- // Old signature: (contentDir, frontmatterMapping?, ignorePatterns?)
257257- options = {
258258- frontmatterMapping: frontmatterMappingOrOptions as FrontmatterMapping | undefined,
259259- ignorePatterns,
260260- };
261261- }
267267+ // Handle both old signature (frontmatterMapping, ignorePatterns) and new signature (options)
268268+ let options: ScanOptions;
269269+ if (
270270+ frontmatterMappingOrOptions &&
271271+ ("slugSource" in frontmatterMappingOrOptions ||
272272+ "frontmatterMapping" in frontmatterMappingOrOptions ||
273273+ "ignorePatterns" in frontmatterMappingOrOptions)
274274+ ) {
275275+ options = frontmatterMappingOrOptions as ScanOptions;
276276+ } else {
277277+ // Old signature: (contentDir, frontmatterMapping?, ignorePatterns?)
278278+ options = {
279279+ frontmatterMapping: frontmatterMappingOrOptions as
280280+ | FrontmatterMapping
281281+ | undefined,
282282+ ignorePatterns,
283283+ };
284284+ }
262285263263- const {
264264- frontmatterMapping,
265265- ignorePatterns: ignore = [],
266266- slugSource,
267267- slugField,
268268- removeIndexFromSlug,
269269- } = options;
286286+ const {
287287+ frontmatterMapping,
288288+ ignorePatterns: ignore = [],
289289+ slugSource,
290290+ slugField,
291291+ removeIndexFromSlug,
292292+ } = options;
270293271271- const patterns = ["**/*.md", "**/*.mdx"];
272272- const posts: BlogPost[] = [];
294294+ const patterns = ["**/*.md", "**/*.mdx"];
295295+ const posts: BlogPost[] = [];
273296274274- for (const pattern of patterns) {
275275- const files = await glob(pattern, {
276276- cwd: contentDir,
277277- absolute: false,
278278- });
297297+ for (const pattern of patterns) {
298298+ const files = await glob(pattern, {
299299+ cwd: contentDir,
300300+ absolute: false,
301301+ });
279302280280- for (const relativePath of files) {
281281- // Skip files matching ignore patterns
282282- if (shouldIgnore(relativePath, ignore)) {
283283- continue;
284284- }
303303+ for (const relativePath of files) {
304304+ // Skip files matching ignore patterns
305305+ if (shouldIgnore(relativePath, ignore)) {
306306+ continue;
307307+ }
285308286286- const filePath = path.join(contentDir, relativePath);
287287- const rawContent = await fs.readFile(filePath, "utf-8");
309309+ const filePath = path.join(contentDir, relativePath);
310310+ const rawContent = await fs.readFile(filePath, "utf-8");
288311289289- try {
290290- const { frontmatter, body, rawFrontmatter } = parseFrontmatter(rawContent, frontmatterMapping);
291291- const slug = getSlugFromOptions(relativePath, rawFrontmatter, {
292292- slugSource,
293293- slugField,
294294- removeIndexFromSlug,
295295- });
312312+ try {
313313+ const { frontmatter, body, rawFrontmatter } = parseFrontmatter(
314314+ rawContent,
315315+ frontmatterMapping,
316316+ );
317317+ const slug = getSlugFromOptions(relativePath, rawFrontmatter, {
318318+ slugSource,
319319+ slugField,
320320+ removeIndexFromSlug,
321321+ });
296322297297- posts.push({
298298- filePath,
299299- slug,
300300- frontmatter,
301301- content: body,
302302- rawContent,
303303- rawFrontmatter,
304304- });
305305- } catch (error) {
306306- console.error(`Error parsing ${relativePath}:`, error);
307307- }
308308- }
309309- }
323323+ posts.push({
324324+ filePath,
325325+ slug,
326326+ frontmatter,
327327+ content: body,
328328+ rawContent,
329329+ rawFrontmatter,
330330+ });
331331+ } catch (error) {
332332+ console.error(`Error parsing ${relativePath}:`, error);
333333+ }
334334+ }
335335+ }
310336311311- // Sort by publish date (newest first)
312312- posts.sort((a, b) => {
313313- const dateA = new Date(a.frontmatter.publishDate);
314314- const dateB = new Date(b.frontmatter.publishDate);
315315- return dateB.getTime() - dateA.getTime();
316316- });
337337+ // Sort by publish date (newest first)
338338+ posts.sort((a, b) => {
339339+ const dateA = new Date(a.frontmatter.publishDate);
340340+ const dateB = new Date(b.frontmatter.publishDate);
341341+ return dateB.getTime() - dateA.getTime();
342342+ });
317343318318- return posts;
344344+ return posts;
319345}
320346321321-export function updateFrontmatterWithAtUri(rawContent: string, atUri: string): string {
322322- // Detect which delimiter is used (---, +++, or ***)
323323- const delimiterMatch = rawContent.match(/^(---|\+\+\+|\*\*\*)/);
324324- const delimiter = delimiterMatch?.[1] ?? "---";
325325- const isToml = delimiter === "+++";
347347+export function updateFrontmatterWithAtUri(
348348+ rawContent: string,
349349+ atUri: string,
350350+): string {
351351+ // Detect which delimiter is used (---, +++, or ***)
352352+ const delimiterMatch = rawContent.match(/^(---|\+\+\+|\*\*\*)/);
353353+ const delimiter = delimiterMatch?.[1] ?? "---";
354354+ const isToml = delimiter === "+++";
326355327327- // Format the atUri entry based on frontmatter type
328328- const atUriEntry = isToml ? `atUri = "${atUri}"` : `atUri: "${atUri}"`;
356356+ // Format the atUri entry based on frontmatter type
357357+ const atUriEntry = isToml ? `atUri = "${atUri}"` : `atUri: "${atUri}"`;
329358330330- // Check if atUri already exists in frontmatter (handle both formats)
331331- if (rawContent.includes("atUri:") || rawContent.includes("atUri =")) {
332332- // Replace existing atUri (match both YAML and TOML formats)
333333- return rawContent.replace(/atUri\s*[=:]\s*["']?[^"'\n]+["']?\n?/, `${atUriEntry}\n`);
334334- }
359359+ // Check if atUri already exists in frontmatter (handle both formats)
360360+ if (rawContent.includes("atUri:") || rawContent.includes("atUri =")) {
361361+ // Replace existing atUri (match both YAML and TOML formats)
362362+ return rawContent.replace(
363363+ /atUri\s*[=:]\s*["']?[^"'\n]+["']?\n?/,
364364+ `${atUriEntry}\n`,
365365+ );
366366+ }
335367336336- // Insert atUri before the closing delimiter
337337- const frontmatterEndIndex = rawContent.indexOf(delimiter, 4);
338338- if (frontmatterEndIndex === -1) {
339339- throw new Error("Could not find frontmatter end");
340340- }
368368+ // Insert atUri before the closing delimiter
369369+ const frontmatterEndIndex = rawContent.indexOf(delimiter, 4);
370370+ if (frontmatterEndIndex === -1) {
371371+ throw new Error("Could not find frontmatter end");
372372+ }
341373342342- const beforeEnd = rawContent.slice(0, frontmatterEndIndex);
343343- const afterEnd = rawContent.slice(frontmatterEndIndex);
374374+ const beforeEnd = rawContent.slice(0, frontmatterEndIndex);
375375+ const afterEnd = rawContent.slice(frontmatterEndIndex);
344376345345- return `${beforeEnd}${atUriEntry}\n${afterEnd}`;
377377+ return `${beforeEnd}${atUriEntry}\n${afterEnd}`;
346378}
347379348380export function stripMarkdownForText(markdown: string): string {
349349- return markdown
350350- .replace(/#{1,6}\s/g, "") // Remove headers
351351- .replace(/\*\*([^*]+)\*\*/g, "$1") // Remove bold
352352- .replace(/\*([^*]+)\*/g, "$1") // Remove italic
353353- .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Remove links, keep text
354354- .replace(/`{3}[\s\S]*?`{3}/g, "") // Remove code blocks
355355- .replace(/`([^`]+)`/g, "$1") // Remove inline code formatting
356356- .replace(/!\[.*?\]\(.*?\)/g, "") // Remove images
357357- .replace(/\n{3,}/g, "\n\n") // Normalize multiple newlines
358358- .trim();
381381+ return markdown
382382+ .replace(/#{1,6}\s/g, "") // Remove headers
383383+ .replace(/\*\*([^*]+)\*\*/g, "$1") // Remove bold
384384+ .replace(/\*([^*]+)\*/g, "$1") // Remove italic
385385+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Remove links, keep text
386386+ .replace(/`{3}[\s\S]*?`{3}/g, "") // Remove code blocks
387387+ .replace(/`([^`]+)`/g, "$1") // Remove inline code formatting
388388+ .replace(/!\[.*?\]\(.*?\)/g, "") // Remove images
389389+ .replace(/\n{3,}/g, "\n\n") // Normalize multiple newlines
390390+ .trim();
359391}
+6-6
packages/cli/src/lib/prompts.ts
···11-import { isCancel, cancel } from "@clack/prompts";
11+import { cancel, isCancel } from "@clack/prompts";
2233export function exitOnCancel<T>(value: T | symbol): T {
44- if (isCancel(value)) {
55- cancel("Cancelled");
66- process.exit(0);
77- }
88- return value as T;
44+ if (isCancel(value)) {
55+ cancel("Cancelled");
66+ process.exit(0);
77+ }
88+ return value as T;
99}