···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});
+6-6
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";
···199199200200 const s = spinner();
201201 s.start("Connecting to ATProto...");
202202- let agent;
202202+ let agent: Awaited<ReturnType<typeof createAgent>> | undefined;
203203 try {
204204 agent = await createAgent(credentials);
205205 s.stop("Connected!");
206206- } catch (error) {
206206+ } catch (_error) {
207207 s.stop("Failed to connect");
208208 log.error(
209209 "Failed to connect. Check your credentials with 'sequoia auth'.",
···288288 placeholder: "7",
289289 validate: (value) => {
290290 const num = parseInt(value, 10);
291291- if (isNaN(num) || num < 1) {
291291+ if (Number.isNaN(num) || num < 1) {
292292 return "Please enter a positive number";
293293 }
294294 },
···351351 if (!gitignoreContent.includes(stateFilename)) {
352352 await fs.writeFile(
353353 gitignorePath,
354354- gitignoreContent + `\n${stateFilename}\n`,
354354+ `${gitignoreContent}\n${stateFilename}\n`,
355355 );
356356 log.info(`Added ${stateFilename} to .gitignore`);
357357 }
+32-56
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",
1010- description:
1111- "Inject site.standard.document link tags into built HTML files",
1010+ description: "Inject site.standard.document link tags into built HTML files",
1211 args: {
1312 outputDir: option({
1413 long: "output",
···4443 // Load state to get atUri mappings
4544 const state = await loadState(configDir);
46454747- // Generic filenames where the slug is the parent directory, not the filename
4848- // Covers: SvelteKit (+page), Astro/Hugo (index), Next.js (page), etc.
4949- const genericFilenames = new Set([
5050- "+page",
5151- "index",
5252- "_index",
5353- "page",
5454- "readme",
5555- ]);
5656-5757- // Build a map of slug/path to atUri from state
5858- const pathToAtUri = new Map<string, string>();
4646+ // Build a map of slug to atUri from state
4747+ // The slug is stored in state by the publish command, using the configured slug options
4848+ const slugToAtUri = new Map<string, string>();
5949 for (const [filePath, postState] of Object.entries(state.posts)) {
6060- if (postState.atUri) {
6161- // Extract slug from file path (e.g., ./content/blog/my-post.md -> my-post)
6262- let basename = path.basename(filePath, path.extname(filePath));
6363-6464- // If the filename is a generic convention name, use the parent directory as slug
6565- if (genericFilenames.has(basename.toLowerCase())) {
6666- // Split path and filter out route groups like (blog-article)
6767- const pathParts = filePath
6868- .split(/[/\\]/)
6969- .filter((p) => p && !(p.startsWith("(") && p.endsWith(")")));
7070- // The slug should be the second-to-last part (last is the filename)
7171- if (pathParts.length >= 2) {
7272- const slug = pathParts[pathParts.length - 2];
7373- if (slug && slug !== "." && slug !== "content" && slug !== "routes" && slug !== "src") {
7474- basename = slug;
7575- }
7676- }
7777- }
7878-7979- pathToAtUri.set(basename, postState.atUri);
5050+ if (postState.atUri && postState.slug) {
5151+ // Use the slug stored in state (computed by publish with config options)
5252+ slugToAtUri.set(postState.slug, postState.atUri);
80538181- // Also add variations that might match HTML file paths
8282- // e.g., /blog/my-post, /posts/my-post, my-post/index
8383- const dirName = path.basename(path.dirname(filePath));
8484- // Skip route groups and common directory names
8585- if (dirName !== "." && dirName !== "content" && !(dirName.startsWith("(") && dirName.endsWith(")"))) {
8686- pathToAtUri.set(`${dirName}/${basename}`, postState.atUri);
5454+ // Also add the last segment for simpler matching
5555+ // e.g., "other/my-other-post" -> also map "my-other-post"
5656+ const lastSegment = postState.slug.split("/").pop();
5757+ if (lastSegment && lastSegment !== postState.slug) {
5858+ slugToAtUri.set(lastSegment, postState.atUri);
8759 }
6060+ } else if (postState.atUri) {
6161+ // Fallback for older state files without slug field
6262+ // Extract slug from file path (e.g., ./content/blog/my-post.md -> my-post)
6363+ const basename = path.basename(filePath, path.extname(filePath));
6464+ slugToAtUri.set(basename.toLowerCase(), postState.atUri);
8865 }
8966 }
90679191- if (pathToAtUri.size === 0) {
6868+ if (slugToAtUri.size === 0) {
9269 log.warn(
9370 "No published posts found in state. Run 'sequoia publish' first.",
9471 );
9572 return;
9673 }
97749898- log.info(`Found ${pathToAtUri.size} published posts in state`);
7575+ log.info(`Found ${slugToAtUri.size} slug mappings from published posts`);
997610077 // Scan for HTML files
10178 const htmlFiles = await glob("**/*.html", {
···125102 let atUri: string | undefined;
126103127104 // Strategy 1: Direct basename match (e.g., my-post.html -> my-post)
128128- atUri = pathToAtUri.get(htmlBasename);
105105+ atUri = slugToAtUri.get(htmlBasename);
129106130130- // Strategy 2: Directory name for index.html (e.g., my-post/index.html -> my-post)
107107+ // Strategy 2: For index.html, try the directory path
108108+ // e.g., posts/40th-puzzle-box/what-a-gift/index.html -> 40th-puzzle-box/what-a-gift
131109 if (!atUri && htmlBasename === "index" && htmlDir !== ".") {
132132- const slug = path.basename(htmlDir);
133133- atUri = pathToAtUri.get(slug);
110110+ // Try full directory path (for nested subdirectories)
111111+ atUri = slugToAtUri.get(htmlDir);
134112135135- // Also try parent/slug pattern
113113+ // Also try just the last directory segment
136114 if (!atUri) {
137137- const parentDir = path.dirname(htmlDir);
138138- if (parentDir !== ".") {
139139- atUri = pathToAtUri.get(`${path.basename(parentDir)}/${slug}`);
140140- }
115115+ const lastDir = path.basename(htmlDir);
116116+ atUri = slugToAtUri.get(lastDir);
141117 }
142118 }
143119144120 // Strategy 3: Full path match (e.g., blog/my-post.html -> blog/my-post)
145121 if (!atUri && htmlDir !== ".") {
146146- atUri = pathToAtUri.get(`${htmlDir}/${htmlBasename}`);
122122+ atUri = slugToAtUri.get(`${htmlDir}/${htmlBasename}`);
147123 }
148124149125 if (!atUri) {
+306-270
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";
66-import { loadCredentials, listCredentials, getCredentials } from "../lib/credentials";
77-import { createAgent, createDocument, updateDocument, uploadImage, resolveImagePath, createBlueskyPost, addBskyPostRefToDocument } from "../lib/atproto";
86import {
99- scanContentDirectory,
1010- getContentHash,
1111- updateFrontmatterWithAtUri,
77+ loadCredentials,
88+ listCredentials,
99+ getCredentials,
1010+} from "../lib/credentials";
1111+import {
1212+ createAgent,
1313+ createDocument,
1414+ updateDocument,
1515+ uploadImage,
1616+ resolveImagePath,
1717+ createBlueskyPost,
1818+ addBskyPostRefToDocument,
1919+} from "../lib/atproto";
2020+import {
2121+ scanContentDirectory,
2222+ getContentHash,
2323+ updateFrontmatterWithAtUri,
1224} from "../lib/markdown";
1325import type { BlogPost, BlobObject, StrongRef } from "../lib/types";
1426import { exitOnCancel } from "../lib/prompts";
15271628export const publishCommand = command({
1717- name: "publish",
1818- description: "Publish content to ATProto",
1919- args: {
2020- force: flag({
2121- long: "force",
2222- short: "f",
2323- description: "Force publish all posts, ignoring change detection",
2424- }),
2525- dryRun: flag({
2626- long: "dry-run",
2727- short: "n",
2828- description: "Preview what would be published without making changes",
2929- }),
3030- },
3131- handler: async ({ force, dryRun }) => {
3232- // Load config
3333- const configPath = await findConfig();
3434- if (!configPath) {
3535- log.error("No publisher.config.ts found. Run 'publisher init' first.");
3636- process.exit(1);
3737- }
2929+ name: "publish",
3030+ description: "Publish content to ATProto",
3131+ args: {
3232+ force: flag({
3333+ long: "force",
3434+ short: "f",
3535+ description: "Force publish all posts, ignoring change detection",
3636+ }),
3737+ dryRun: flag({
3838+ long: "dry-run",
3939+ short: "n",
4040+ description: "Preview what would be published without making changes",
4141+ }),
4242+ },
4343+ handler: async ({ force, dryRun }) => {
4444+ // Load config
4545+ const configPath = await findConfig();
4646+ if (!configPath) {
4747+ log.error("No publisher.config.ts found. Run 'publisher init' first.");
4848+ process.exit(1);
4949+ }
38503939- const config = await loadConfig(configPath);
4040- const configDir = path.dirname(configPath);
5151+ const config = await loadConfig(configPath);
5252+ const configDir = path.dirname(configPath);
41534242- log.info(`Site: ${config.siteUrl}`);
4343- log.info(`Content directory: ${config.contentDir}`);
5454+ log.info(`Site: ${config.siteUrl}`);
5555+ log.info(`Content directory: ${config.contentDir}`);
44564545- // Load credentials
4646- let credentials = await loadCredentials(config.identity);
5757+ // Load credentials
5858+ let credentials = await loadCredentials(config.identity);
47594848- // If no credentials resolved, check if we need to prompt for identity selection
4949- if (!credentials) {
5050- const identities = await listCredentials();
5151- if (identities.length === 0) {
5252- log.error("No credentials found. Run 'sequoia auth' first.");
5353- log.info("Or set ATP_IDENTIFIER and ATP_APP_PASSWORD environment variables.");
5454- process.exit(1);
5555- }
6060+ // If no credentials resolved, check if we need to prompt for identity selection
6161+ if (!credentials) {
6262+ const identities = await listCredentials();
6363+ if (identities.length === 0) {
6464+ log.error("No credentials found. Run 'sequoia auth' first.");
6565+ log.info(
6666+ "Or set ATP_IDENTIFIER and ATP_APP_PASSWORD environment variables.",
6767+ );
6868+ process.exit(1);
6969+ }
56705757- // Multiple identities exist but none selected - prompt user
5858- log.info("Multiple identities found. Select one to use:");
5959- const selected = exitOnCancel(await select({
6060- message: "Identity:",
6161- options: identities.map(id => ({ value: id, label: id })),
6262- }));
7171+ // Multiple identities exist but none selected - prompt user
7272+ log.info("Multiple identities found. Select one to use:");
7373+ const selected = exitOnCancel(
7474+ await select({
7575+ message: "Identity:",
7676+ options: identities.map((id) => ({ value: id, label: id })),
7777+ }),
7878+ );
63796464- credentials = await getCredentials(selected);
6565- if (!credentials) {
6666- log.error("Failed to load selected credentials.");
6767- process.exit(1);
6868- }
8080+ credentials = await getCredentials(selected);
8181+ if (!credentials) {
8282+ log.error("Failed to load selected credentials.");
8383+ process.exit(1);
8484+ }
69857070- log.info(`Tip: Add "identity": "${selected}" to sequoia.json to use this by default.`);
7171- }
8686+ log.info(
8787+ `Tip: Add "identity": "${selected}" to sequoia.json to use this by default.`,
8888+ );
8989+ }
72907373- // Resolve content directory
7474- const contentDir = path.isAbsolute(config.contentDir)
7575- ? config.contentDir
7676- : path.join(configDir, config.contentDir);
9191+ // Resolve content directory
9292+ const contentDir = path.isAbsolute(config.contentDir)
9393+ ? config.contentDir
9494+ : path.join(configDir, config.contentDir);
77957878- const imagesDir = config.imagesDir
7979- ? path.isAbsolute(config.imagesDir)
8080- ? config.imagesDir
8181- : path.join(configDir, config.imagesDir)
8282- : undefined;
9696+ const imagesDir = config.imagesDir
9797+ ? path.isAbsolute(config.imagesDir)
9898+ ? config.imagesDir
9999+ : path.join(configDir, config.imagesDir)
100100+ : undefined;
831018484- // Load state
8585- const state = await loadState(configDir);
102102+ // Load state
103103+ const state = await loadState(configDir);
861048787- // Scan for posts
8888- const s = spinner();
8989- s.start("Scanning for posts...");
9090- const posts = await scanContentDirectory(contentDir, config.frontmatter, config.ignore);
9191- s.stop(`Found ${posts.length} posts`);
105105+ // Scan for posts
106106+ const s = spinner();
107107+ s.start("Scanning for posts...");
108108+ const posts = await scanContentDirectory(contentDir, {
109109+ frontmatterMapping: config.frontmatter,
110110+ ignorePatterns: config.ignore,
111111+ slugSource: config.slugSource,
112112+ slugField: config.slugField,
113113+ removeIndexFromSlug: config.removeIndexFromSlug,
114114+ });
115115+ s.stop(`Found ${posts.length} posts`);
921169393- // Determine which posts need publishing
9494- const postsToPublish: Array<{
9595- post: BlogPost;
9696- action: "create" | "update";
9797- reason: string;
9898- }> = [];
9999- const draftPosts: BlogPost[] = [];
117117+ // Determine which posts need publishing
118118+ const postsToPublish: Array<{
119119+ post: BlogPost;
120120+ action: "create" | "update";
121121+ reason: string;
122122+ }> = [];
123123+ const draftPosts: BlogPost[] = [];
100124101101- for (const post of posts) {
102102- // Skip draft posts
103103- if (post.frontmatter.draft) {
104104- draftPosts.push(post);
105105- continue;
106106- }
125125+ for (const post of posts) {
126126+ // Skip draft posts
127127+ if (post.frontmatter.draft) {
128128+ draftPosts.push(post);
129129+ continue;
130130+ }
107131108108- const contentHash = await getContentHash(post.rawContent);
109109- const relativeFilePath = path.relative(configDir, post.filePath);
110110- const postState = state.posts[relativeFilePath];
132132+ const contentHash = await getContentHash(post.rawContent);
133133+ const relativeFilePath = path.relative(configDir, post.filePath);
134134+ const postState = state.posts[relativeFilePath];
111135112112- if (force) {
113113- postsToPublish.push({
114114- post,
115115- action: post.frontmatter.atUri ? "update" : "create",
116116- reason: "forced",
117117- });
118118- } else if (!postState) {
119119- // New post
120120- postsToPublish.push({
121121- post,
122122- action: "create",
123123- reason: "new post",
124124- });
125125- } else if (postState.contentHash !== contentHash) {
126126- // Changed post
127127- postsToPublish.push({
128128- post,
129129- action: post.frontmatter.atUri ? "update" : "create",
130130- reason: "content changed",
131131- });
132132- }
133133- }
136136+ if (force) {
137137+ postsToPublish.push({
138138+ post,
139139+ action: post.frontmatter.atUri ? "update" : "create",
140140+ reason: "forced",
141141+ });
142142+ } else if (!postState) {
143143+ // New post
144144+ postsToPublish.push({
145145+ post,
146146+ action: "create",
147147+ reason: "new post",
148148+ });
149149+ } else if (postState.contentHash !== contentHash) {
150150+ // Changed post
151151+ postsToPublish.push({
152152+ post,
153153+ action: post.frontmatter.atUri ? "update" : "create",
154154+ reason: "content changed",
155155+ });
156156+ }
157157+ }
134158135135- if (draftPosts.length > 0) {
136136- log.info(`Skipping ${draftPosts.length} draft post${draftPosts.length === 1 ? "" : "s"}`);
137137- }
159159+ if (draftPosts.length > 0) {
160160+ log.info(
161161+ `Skipping ${draftPosts.length} draft post${draftPosts.length === 1 ? "" : "s"}`,
162162+ );
163163+ }
138164139139- if (postsToPublish.length === 0) {
140140- log.success("All posts are up to date. Nothing to publish.");
141141- return;
142142- }
165165+ if (postsToPublish.length === 0) {
166166+ log.success("All posts are up to date. Nothing to publish.");
167167+ return;
168168+ }
143169144144- log.info(`\n${postsToPublish.length} posts to publish:\n`);
170170+ log.info(`\n${postsToPublish.length} posts to publish:\n`);
145171146146- // Bluesky posting configuration
147147- const blueskyEnabled = config.bluesky?.enabled ?? false;
148148- const maxAgeDays = config.bluesky?.maxAgeDays ?? 7;
149149- const cutoffDate = new Date();
150150- cutoffDate.setDate(cutoffDate.getDate() - maxAgeDays);
172172+ // Bluesky posting configuration
173173+ const blueskyEnabled = config.bluesky?.enabled ?? false;
174174+ const maxAgeDays = config.bluesky?.maxAgeDays ?? 7;
175175+ const cutoffDate = new Date();
176176+ cutoffDate.setDate(cutoffDate.getDate() - maxAgeDays);
151177152152- for (const { post, action, reason } of postsToPublish) {
153153- const icon = action === "create" ? "+" : "~";
154154- const relativeFilePath = path.relative(configDir, post.filePath);
155155- const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef;
178178+ for (const { post, action, reason } of postsToPublish) {
179179+ const icon = action === "create" ? "+" : "~";
180180+ const relativeFilePath = path.relative(configDir, post.filePath);
181181+ const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef;
156182157157- let bskyNote = "";
158158- if (blueskyEnabled) {
159159- if (existingBskyPostRef) {
160160- bskyNote = " [bsky: exists]";
161161- } else {
162162- const publishDate = new Date(post.frontmatter.publishDate);
163163- if (publishDate < cutoffDate) {
164164- bskyNote = ` [bsky: skipped, older than ${maxAgeDays} days]`;
165165- } else {
166166- bskyNote = " [bsky: will post]";
167167- }
168168- }
169169- }
183183+ let bskyNote = "";
184184+ if (blueskyEnabled) {
185185+ if (existingBskyPostRef) {
186186+ bskyNote = " [bsky: exists]";
187187+ } else {
188188+ const publishDate = new Date(post.frontmatter.publishDate);
189189+ if (publishDate < cutoffDate) {
190190+ bskyNote = ` [bsky: skipped, older than ${maxAgeDays} days]`;
191191+ } else {
192192+ bskyNote = " [bsky: will post]";
193193+ }
194194+ }
195195+ }
170196171171- log.message(` ${icon} ${post.frontmatter.title} (${reason})${bskyNote}`);
172172- }
197197+ log.message(` ${icon} ${post.frontmatter.title} (${reason})${bskyNote}`);
198198+ }
173199174174- if (dryRun) {
175175- if (blueskyEnabled) {
176176- log.info(`\nBluesky posting: enabled (max age: ${maxAgeDays} days)`);
177177- }
178178- log.info("\nDry run complete. No changes made.");
179179- return;
180180- }
200200+ if (dryRun) {
201201+ if (blueskyEnabled) {
202202+ log.info(`\nBluesky posting: enabled (max age: ${maxAgeDays} days)`);
203203+ }
204204+ log.info("\nDry run complete. No changes made.");
205205+ return;
206206+ }
181207182182- // Create agent
183183- s.start(`Connecting to ${credentials.pdsUrl}...`);
184184- let agent;
185185- try {
186186- agent = await createAgent(credentials);
187187- s.stop(`Logged in as ${agent.session?.handle}`);
188188- } catch (error) {
189189- s.stop("Failed to login");
190190- log.error(`Failed to login: ${error}`);
191191- process.exit(1);
192192- }
208208+ // Create agent
209209+ s.start(`Connecting to ${credentials.pdsUrl}...`);
210210+ let agent: Awaited<ReturnType<typeof createAgent>> | undefined;
211211+ try {
212212+ agent = await createAgent(credentials);
213213+ s.stop(`Logged in as ${agent.session?.handle}`);
214214+ } catch (error) {
215215+ s.stop("Failed to login");
216216+ log.error(`Failed to login: ${error}`);
217217+ process.exit(1);
218218+ }
193219194194- // Publish posts
195195- let publishedCount = 0;
196196- let updatedCount = 0;
197197- let errorCount = 0;
198198- let bskyPostCount = 0;
220220+ // Publish posts
221221+ let publishedCount = 0;
222222+ let updatedCount = 0;
223223+ let errorCount = 0;
224224+ let bskyPostCount = 0;
199225200200- for (const { post, action } of postsToPublish) {
201201- s.start(`Publishing: ${post.frontmatter.title}`);
226226+ for (const { post, action } of postsToPublish) {
227227+ s.start(`Publishing: ${post.frontmatter.title}`);
202228203203- try {
204204- // Handle cover image upload
205205- let coverImage: BlobObject | undefined;
206206- if (post.frontmatter.ogImage) {
207207- const imagePath = await resolveImagePath(
208208- post.frontmatter.ogImage,
209209- imagesDir,
210210- contentDir
211211- );
229229+ try {
230230+ // Handle cover image upload
231231+ let coverImage: BlobObject | undefined;
232232+ if (post.frontmatter.ogImage) {
233233+ const imagePath = await resolveImagePath(
234234+ post.frontmatter.ogImage,
235235+ imagesDir,
236236+ contentDir,
237237+ );
212238213213- if (imagePath) {
214214- log.info(` Uploading cover image: ${path.basename(imagePath)}`);
215215- coverImage = await uploadImage(agent, imagePath);
216216- if (coverImage) {
217217- log.info(` Uploaded image blob: ${coverImage.ref.$link}`);
218218- }
219219- } else {
220220- log.warn(` Cover image not found: ${post.frontmatter.ogImage}`);
221221- }
222222- }
239239+ if (imagePath) {
240240+ log.info(` Uploading cover image: ${path.basename(imagePath)}`);
241241+ coverImage = await uploadImage(agent, imagePath);
242242+ if (coverImage) {
243243+ log.info(` Uploaded image blob: ${coverImage.ref.$link}`);
244244+ }
245245+ } else {
246246+ log.warn(` Cover image not found: ${post.frontmatter.ogImage}`);
247247+ }
248248+ }
223249224224- // Track atUri, content for state saving, and bskyPostRef
225225- let atUri: string;
226226- let contentForHash: string;
227227- let bskyPostRef: StrongRef | undefined;
228228- const relativeFilePath = path.relative(configDir, post.filePath);
250250+ // Track atUri, content for state saving, and bskyPostRef
251251+ let atUri: string;
252252+ let contentForHash: string;
253253+ let bskyPostRef: StrongRef | undefined;
254254+ const relativeFilePath = path.relative(configDir, post.filePath);
229255230230- // Check if bskyPostRef already exists in state
231231- const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef;
256256+ // Check if bskyPostRef already exists in state
257257+ const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef;
232258233233- if (action === "create") {
234234- atUri = await createDocument(agent, post, config, coverImage);
235235- s.stop(`Created: ${atUri}`);
259259+ if (action === "create") {
260260+ atUri = await createDocument(agent, post, config, coverImage);
261261+ s.stop(`Created: ${atUri}`);
236262237237- // Update frontmatter with atUri
238238- const updatedContent = updateFrontmatterWithAtUri(post.rawContent, atUri);
239239- await fs.writeFile(post.filePath, updatedContent);
240240- log.info(` Updated frontmatter in ${path.basename(post.filePath)}`);
263263+ // Update frontmatter with atUri
264264+ const updatedContent = updateFrontmatterWithAtUri(
265265+ post.rawContent,
266266+ atUri,
267267+ );
268268+ await fs.writeFile(post.filePath, updatedContent);
269269+ log.info(` Updated frontmatter in ${path.basename(post.filePath)}`);
241270242242- // Use updated content (with atUri) for hash so next run sees matching hash
243243- contentForHash = updatedContent;
244244- publishedCount++;
245245- } else {
246246- atUri = post.frontmatter.atUri!;
247247- await updateDocument(agent, post, atUri, config, coverImage);
248248- s.stop(`Updated: ${atUri}`);
271271+ // Use updated content (with atUri) for hash so next run sees matching hash
272272+ contentForHash = updatedContent;
273273+ publishedCount++;
274274+ } else {
275275+ atUri = post.frontmatter.atUri!;
276276+ await updateDocument(agent, post, atUri, config, coverImage);
277277+ s.stop(`Updated: ${atUri}`);
249278250250- // For updates, rawContent already has atUri
251251- contentForHash = post.rawContent;
252252- updatedCount++;
253253- }
279279+ // For updates, rawContent already has atUri
280280+ contentForHash = post.rawContent;
281281+ updatedCount++;
282282+ }
254283255255- // Create Bluesky post if enabled and conditions are met
256256- if (blueskyEnabled) {
257257- if (existingBskyPostRef) {
258258- log.info(` Bluesky post already exists, skipping`);
259259- bskyPostRef = existingBskyPostRef;
260260- } else {
261261- const publishDate = new Date(post.frontmatter.publishDate);
284284+ // Create Bluesky post if enabled and conditions are met
285285+ if (blueskyEnabled) {
286286+ if (existingBskyPostRef) {
287287+ log.info(` Bluesky post already exists, skipping`);
288288+ bskyPostRef = existingBskyPostRef;
289289+ } else {
290290+ const publishDate = new Date(post.frontmatter.publishDate);
262291263263- if (publishDate < cutoffDate) {
264264- log.info(` Post is older than ${maxAgeDays} days, skipping Bluesky post`);
265265- } else {
266266- // Create Bluesky post
267267- try {
268268- const pathPrefix = config.pathPrefix || "/posts";
269269- const canonicalUrl = `${config.siteUrl}${pathPrefix}/${post.slug}`;
292292+ if (publishDate < cutoffDate) {
293293+ log.info(
294294+ ` Post is older than ${maxAgeDays} days, skipping Bluesky post`,
295295+ );
296296+ } else {
297297+ // Create Bluesky post
298298+ try {
299299+ const pathPrefix = config.pathPrefix || "/posts";
300300+ const canonicalUrl = `${config.siteUrl}${pathPrefix}/${post.slug}`;
270301271271- bskyPostRef = await createBlueskyPost(agent, {
272272- title: post.frontmatter.title,
273273- description: post.frontmatter.description,
274274- canonicalUrl,
275275- coverImage,
276276- publishedAt: post.frontmatter.publishDate,
277277- });
302302+ bskyPostRef = await createBlueskyPost(agent, {
303303+ title: post.frontmatter.title,
304304+ description: post.frontmatter.description,
305305+ canonicalUrl,
306306+ coverImage,
307307+ publishedAt: post.frontmatter.publishDate,
308308+ });
278309279279- // Update document record with bskyPostRef
280280- await addBskyPostRefToDocument(agent, atUri, bskyPostRef);
281281- log.info(` Created Bluesky post: ${bskyPostRef.uri}`);
282282- bskyPostCount++;
283283- } catch (bskyError) {
284284- const errorMsg = bskyError instanceof Error ? bskyError.message : String(bskyError);
285285- log.warn(` Failed to create Bluesky post: ${errorMsg}`);
286286- }
287287- }
288288- }
289289- }
310310+ // Update document record with bskyPostRef
311311+ await addBskyPostRefToDocument(agent, atUri, bskyPostRef);
312312+ log.info(` Created Bluesky post: ${bskyPostRef.uri}`);
313313+ bskyPostCount++;
314314+ } catch (bskyError) {
315315+ const errorMsg =
316316+ bskyError instanceof Error
317317+ ? bskyError.message
318318+ : String(bskyError);
319319+ log.warn(` Failed to create Bluesky post: ${errorMsg}`);
320320+ }
321321+ }
322322+ }
323323+ }
290324291291- // Update state (use relative path from config directory)
292292- const contentHash = await getContentHash(contentForHash);
293293- state.posts[relativeFilePath] = {
294294- contentHash,
295295- atUri,
296296- lastPublished: new Date().toISOString(),
297297- bskyPostRef,
298298- };
299299- } catch (error) {
300300- const errorMessage = error instanceof Error ? error.message : String(error);
301301- s.stop(`Error publishing "${path.basename(post.filePath)}"`);
302302- log.error(` ${errorMessage}`);
303303- errorCount++;
304304- }
305305- }
325325+ // Update state (use relative path from config directory)
326326+ const contentHash = await getContentHash(contentForHash);
327327+ state.posts[relativeFilePath] = {
328328+ contentHash,
329329+ atUri,
330330+ lastPublished: new Date().toISOString(),
331331+ slug: post.slug,
332332+ bskyPostRef,
333333+ };
334334+ } catch (error) {
335335+ const errorMessage =
336336+ error instanceof Error ? error.message : String(error);
337337+ s.stop(`Error publishing "${path.basename(post.filePath)}"`);
338338+ log.error(` ${errorMessage}`);
339339+ errorCount++;
340340+ }
341341+ }
306342307307- // Save state
308308- await saveState(configDir, state);
343343+ // Save state
344344+ await saveState(configDir, state);
309345310310- // Summary
311311- log.message("\n---");
312312- log.info(`Published: ${publishedCount}`);
313313- log.info(`Updated: ${updatedCount}`);
314314- if (bskyPostCount > 0) {
315315- log.info(`Bluesky posts: ${bskyPostCount}`);
316316- }
317317- if (errorCount > 0) {
318318- log.warn(`Errors: ${errorCount}`);
319319- }
320320- },
346346+ // Summary
347347+ log.message("\n---");
348348+ log.info(`Published: ${publishedCount}`);
349349+ log.info(`Updated: ${updatedCount}`);
350350+ if (bskyPostCount > 0) {
351351+ log.info(`Bluesky posts: ${bskyPostCount}`);
352352+ }
353353+ if (errorCount > 0) {
354354+ log.warn(`Errors: ${errorCount}`);
355355+ }
356356+ },
321357});
+172-151
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: Awaited<ReturnType<typeof createAgent>> | undefined;
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, config.frontmatter);
9494- 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`);
951119696- // Build a map of path -> local post for matching
9797- // Document path is like /posts/my-post-slug
9898- const postsByPath = new Map<string, typeof localPosts[0]>();
9999- for (const post of localPosts) {
100100- const postPath = `/posts/${post.slug}`;
101101- postsByPath.set(postPath, post);
102102- }
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+ }
103120104104- // Load existing state
105105- const state = await loadState(configDir);
106106- 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;
107124108108- // Track changes
109109- let matchedCount = 0;
110110- let unmatchedCount = 0;
111111- 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 }> = [];
112129113113- log.message("\nMatching documents to local files:\n");
130130+ log.message("\nMatching documents to local files:\n");
114131115115- for (const doc of documents) {
116116- const docPath = doc.value.path;
117117- const localPost = postsByPath.get(docPath);
132132+ for (const doc of documents) {
133133+ const docPath = doc.value.path;
134134+ const localPost = postsByPath.get(docPath);
118135119119- if (localPost) {
120120- matchedCount++;
121121- log.message(` โ ${doc.value.title}`);
122122- log.message(` Path: ${docPath}`);
123123- log.message(` URI: ${doc.uri}`);
124124- 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)}`);
125142126126- // Update state (use relative path from config directory)
127127- const contentHash = await getContentHash(localPost.rawContent);
128128- const relativeFilePath = path.relative(configDir, localPost.filePath);
129129- state.posts[relativeFilePath] = {
130130- contentHash,
131131- atUri: doc.uri,
132132- lastPublished: doc.value.publishedAt,
133133- };
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+ };
134151135135- // Check if frontmatter needs updating
136136- if (updateFrontmatter && localPost.frontmatter.atUri !== doc.uri) {
137137- frontmatterUpdates.push({
138138- filePath: localPost.filePath,
139139- atUri: doc.uri,
140140- });
141141- log.message(` โ Will update frontmatter`);
142142- }
143143- } else {
144144- unmatchedCount++;
145145- log.message(` โ ${doc.value.title} (no matching local file)`);
146146- log.message(` Path: ${docPath}`);
147147- log.message(` URI: ${doc.uri}`);
148148- }
149149- log.message("");
150150- }
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+ }
151168152152- // Summary
153153- log.message("---");
154154- log.info(`Matched: ${matchedCount} documents`);
155155- if (unmatchedCount > 0) {
156156- log.warn(`Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`);
157157- }
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+ }
158177159159- if (dryRun) {
160160- log.info("\nDry run complete. No changes made.");
161161- return;
162162- }
178178+ if (dryRun) {
179179+ log.info("\nDry run complete. No changes made.");
180180+ return;
181181+ }
163182164164- // Save updated state
165165- await saveState(configDir, state);
166166- const newPostCount = Object.keys(state.posts).length;
167167- 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+ );
168189169169- // Update frontmatter if requested
170170- if (frontmatterUpdates.length > 0) {
171171- s.start(`Updating frontmatter in ${frontmatterUpdates.length} files...`);
172172- for (const { filePath, atUri } of frontmatterUpdates) {
173173- const content = await fs.readFile(filePath, "utf-8");
174174- const updated = updateFrontmatterWithAtUri(content, atUri);
175175- await fs.writeFile(filePath, updated);
176176- log.message(` Updated: ${path.basename(filePath)}`);
177177- }
178178- s.stop("Frontmatter updated");
179179- }
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+ }
180201181181- log.success("\nSync complete!");
182182- },
202202+ log.success("\nSync complete!");
203203+ },
183204});
+445-397
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 textContent = stripMarkdownForText(post.content);
175175- 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);
176181177177- const record: Record<string, unknown> = {
178178- $type: "site.standard.document",
179179- title: post.frontmatter.title,
180180- site: config.publicationUri,
181181- path: postPath,
182182- textContent: textContent.slice(0, 10000),
183183- publishedAt: publishDate.toISOString(),
184184- canonicalUrl: `${config.siteUrl}${postPath}`,
185185- };
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+ }
186192187187- if (coverImage) {
188188- record.coverImage = coverImage;
189189- }
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+ };
190202191191- if (post.frontmatter.tags && post.frontmatter.tags.length > 0) {
192192- record.tags = post.frontmatter.tags;
193193- }
203203+ if (post.frontmatter.description) {
204204+ record.description = post.frontmatter.description;
205205+ }
194206195195- const response = await agent.com.atproto.repo.createRecord({
196196- repo: agent.session!.did,
197197- collection: "site.standard.document",
198198- record,
199199- });
207207+ if (coverImage) {
208208+ record.coverImage = coverImage;
209209+ }
200210201201- return response.data.uri;
211211+ if (post.frontmatter.tags && post.frontmatter.tags.length > 0) {
212212+ record.tags = post.frontmatter.tags;
213213+ }
214214+215215+ const response = await agent.com.atproto.repo.createRecord({
216216+ repo: agent.session!.did,
217217+ collection: "site.standard.document",
218218+ record,
219219+ });
220220+221221+ return response.data.uri;
202222}
203223204224export async function updateDocument(
205205- agent: AtpAgent,
206206- post: BlogPost,
207207- atUri: string,
208208- config: PublisherConfig,
209209- coverImage?: BlobObject
225225+ agent: AtpAgent,
226226+ post: BlogPost,
227227+ atUri: string,
228228+ config: PublisherConfig,
229229+ coverImage?: BlobObject,
210230): Promise<void> {
211211- // Parse the atUri to get the collection and rkey
212212- // Format: at://did:plc:xxx/collection/rkey
213213- const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
214214- if (!uriMatch) {
215215- throw new Error(`Invalid atUri format: ${atUri}`);
216216- }
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+ }
237237+238238+ const [, , collection, rkey] = uriMatch;
217239218218- const [, , collection, rkey] = uriMatch;
240240+ const pathPrefix = config.pathPrefix || "/posts";
241241+ const postPath = `${pathPrefix}/${post.slug}`;
242242+ const publishDate = new Date(post.frontmatter.publishDate);
219243220220- const pathPrefix = config.pathPrefix || "/posts";
221221- const postPath = `${pathPrefix}/${post.slug}`;
222222- const textContent = stripMarkdownForText(post.content);
223223- const publishDate = new Date(post.frontmatter.publishDate);
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+ }
254254+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+ };
224264225225- const record: Record<string, unknown> = {
226226- $type: "site.standard.document",
227227- title: post.frontmatter.title,
228228- site: config.publicationUri,
229229- path: postPath,
230230- textContent: textContent.slice(0, 10000),
231231- publishedAt: publishDate.toISOString(),
232232- canonicalUrl: `${config.siteUrl}${postPath}`,
233233- };
265265+ if (post.frontmatter.description) {
266266+ record.description = post.frontmatter.description;
267267+ }
234268235235- if (coverImage) {
236236- record.coverImage = coverImage;
237237- }
269269+ if (coverImage) {
270270+ record.coverImage = coverImage;
271271+ }
238272239239- if (post.frontmatter.tags && post.frontmatter.tags.length > 0) {
240240- record.tags = post.frontmatter.tags;
241241- }
273273+ if (post.frontmatter.tags && post.frontmatter.tags.length > 0) {
274274+ record.tags = post.frontmatter.tags;
275275+ }
242276243243- await agent.com.atproto.repo.putRecord({
244244- repo: agent.session!.did,
245245- collection: collection!,
246246- rkey: rkey!,
247247- record,
248248- });
277277+ await agent.com.atproto.repo.putRecord({
278278+ repo: agent.session!.did,
279279+ collection: collection!,
280280+ rkey: rkey!,
281281+ record,
282282+ });
249283}
250284251251-export function parseAtUri(atUri: string): { did: string; collection: string; rkey: string } | null {
252252- const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
253253- if (!match) return null;
254254- return {
255255- did: match[1]!,
256256- collection: match[2]!,
257257- rkey: match[3]!,
258258- };
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+ };
259295}
260296261297export interface DocumentRecord {
262262- $type: "site.standard.document";
263263- title: string;
264264- site: string;
265265- path: string;
266266- textContent: string;
267267- publishedAt: string;
268268- canonicalUrl?: string;
269269- coverImage?: BlobObject;
270270- tags?: string[];
271271- 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;
272309}
273310274311export interface ListDocumentsResult {
275275- uri: string;
276276- cid: string;
277277- value: DocumentRecord;
312312+ uri: string;
313313+ cid: string;
314314+ value: DocumentRecord;
278315}
279316280317export async function listDocuments(
281281- agent: AtpAgent,
282282- publicationUri?: string
318318+ agent: AtpAgent,
319319+ publicationUri?: string,
283320): Promise<ListDocumentsResult[]> {
284284- const documents: ListDocumentsResult[] = [];
285285- let cursor: string | undefined;
321321+ const documents: ListDocumentsResult[] = [];
322322+ let cursor: string | undefined;
286323287287- do {
288288- const response = await agent.com.atproto.repo.listRecords({
289289- repo: agent.session!.did,
290290- collection: "site.standard.document",
291291- limit: 100,
292292- cursor,
293293- });
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+ });
294331295295- for (const record of response.data.records) {
296296- 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;
297334298298- // If publicationUri is specified, only include documents from that publication
299299- if (publicationUri && value.site !== publicationUri) {
300300- continue;
301301- }
335335+ // If publicationUri is specified, only include documents from that publication
336336+ if (publicationUri && value.site !== publicationUri) {
337337+ continue;
338338+ }
302339303303- documents.push({
304304- uri: record.uri,
305305- cid: record.cid,
306306- value,
307307- });
308308- }
340340+ documents.push({
341341+ uri: record.uri,
342342+ cid: record.cid,
343343+ value,
344344+ });
345345+ }
309346310310- cursor = response.data.cursor;
311311- } while (cursor);
347347+ cursor = response.data.cursor;
348348+ } while (cursor);
312349313313- return documents;
350350+ return documents;
314351}
315352316353export async function createPublication(
317317- agent: AtpAgent,
318318- options: CreatePublicationOptions
354354+ agent: AtpAgent,
355355+ options: CreatePublicationOptions,
319356): Promise<string> {
320320- let icon: BlobObject | undefined;
357357+ let icon: BlobObject | undefined;
321358322322- if (options.iconPath) {
323323- icon = await uploadImage(agent, options.iconPath);
324324- }
359359+ if (options.iconPath) {
360360+ icon = await uploadImage(agent, options.iconPath);
361361+ }
325362326326- const record: Record<string, unknown> = {
327327- $type: "site.standard.publication",
328328- url: options.url,
329329- name: options.name,
330330- createdAt: new Date().toISOString(),
331331- };
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+ };
332369333333- if (options.description) {
334334- record.description = options.description;
335335- }
370370+ if (options.description) {
371371+ record.description = options.description;
372372+ }
336373337337- if (icon) {
338338- record.icon = icon;
339339- }
374374+ if (icon) {
375375+ record.icon = icon;
376376+ }
340377341341- if (options.showInDiscover !== undefined) {
342342- record.preferences = {
343343- showInDiscover: options.showInDiscover,
344344- };
345345- }
378378+ if (options.showInDiscover !== undefined) {
379379+ record.preferences = {
380380+ showInDiscover: options.showInDiscover,
381381+ };
382382+ }
346383347347- const response = await agent.com.atproto.repo.createRecord({
348348- repo: agent.session!.did,
349349- collection: "site.standard.publication",
350350- record,
351351- });
384384+ const response = await agent.com.atproto.repo.createRecord({
385385+ repo: agent.session!.did,
386386+ collection: "site.standard.publication",
387387+ record,
388388+ });
352389353353- return response.data.uri;
390390+ return response.data.uri;
354391}
355392356393// --- Bluesky Post Creation ---
357394358395export interface CreateBlueskyPostOptions {
359359- title: string;
360360- description?: string;
361361- canonicalUrl: string;
362362- coverImage?: BlobObject;
363363- 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
364401}
365402366403/**
367404 * Count graphemes in a string (for Bluesky's 300 grapheme limit)
368405 */
369406function countGraphemes(str: string): number {
370370- // Use Intl.Segmenter if available, otherwise fallback to spread operator
371371- if (typeof Intl !== "undefined" && Intl.Segmenter) {
372372- const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
373373- return [...segmenter.segment(str)].length;
374374- }
375375- 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;
376413}
377414378415/**
379416 * Truncate a string to a maximum number of graphemes
380417 */
381418function truncateToGraphemes(str: string, maxGraphemes: number): string {
382382- if (typeof Intl !== "undefined" && Intl.Segmenter) {
383383- const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
384384- const segments = [...segmenter.segment(str)];
385385- if (segments.length <= maxGraphemes) return str;
386386- return segments.slice(0, maxGraphemes - 3).map(s => s.segment).join("") + "...";
387387- }
388388- // Fallback
389389- const chars = [...str];
390390- if (chars.length <= maxGraphemes) return str;
391391- 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 `${segments
424424+ .slice(0, maxGraphemes - 3)
425425+ .map((s) => s.segment)
426426+ .join("")}...`;
427427+ }
428428+ // Fallback
429429+ const chars = [...str];
430430+ if (chars.length <= maxGraphemes) return str;
431431+ return `${chars.slice(0, maxGraphemes - 3).join("")}...`;
392432}
393433394434/**
395435 * Create a Bluesky post with external link embed
396436 */
397437export async function createBlueskyPost(
398398- agent: AtpAgent,
399399- options: CreateBlueskyPostOptions
438438+ agent: AtpAgent,
439439+ options: CreateBlueskyPostOptions,
400440): Promise<StrongRef> {
401401- const { title, description, canonicalUrl, coverImage, publishedAt } = options;
441441+ const { title, description, canonicalUrl, coverImage, publishedAt } = options;
402442403403- // Build post text: title + description + URL
404404- // Max 300 graphemes for Bluesky posts
405405- const MAX_GRAPHEMES = 300;
443443+ // Build post text: title + description + URL
444444+ // Max 300 graphemes for Bluesky posts
445445+ const MAX_GRAPHEMES = 300;
406446407407- let postText: string;
408408- const urlPart = `\n\n${canonicalUrl}`;
409409- const urlGraphemes = countGraphemes(urlPart);
447447+ let postText: string;
448448+ const urlPart = `\n\n${canonicalUrl}`;
449449+ const urlGraphemes = countGraphemes(urlPart);
410450411411- if (description) {
412412- // Try: title + description + URL
413413- const fullText = `${title}\n\n${description}${urlPart}`;
414414- if (countGraphemes(fullText) <= MAX_GRAPHEMES) {
415415- postText = fullText;
416416- } else {
417417- // Truncate description to fit
418418- const availableForDesc = MAX_GRAPHEMES - countGraphemes(title) - countGraphemes("\n\n") - urlGraphemes - countGraphemes("\n\n");
419419- if (availableForDesc > 10) {
420420- const truncatedDesc = truncateToGraphemes(description, availableForDesc);
421421- postText = `${title}\n\n${truncatedDesc}${urlPart}`;
422422- } else {
423423- // Just title + URL
424424- postText = `${title}${urlPart}`;
425425- }
426426- }
427427- } else {
428428- // Just title + URL
429429- postText = `${title}${urlPart}`;
430430- }
451451+ if (description) {
452452+ // Try: title + description + URL
453453+ const fullText = `${title}\n\n${description}${urlPart}`;
454454+ if (countGraphemes(fullText) <= MAX_GRAPHEMES) {
455455+ postText = fullText;
456456+ } else {
457457+ // Truncate description to fit
458458+ const availableForDesc =
459459+ MAX_GRAPHEMES -
460460+ countGraphemes(title) -
461461+ countGraphemes("\n\n") -
462462+ urlGraphemes -
463463+ countGraphemes("\n\n");
464464+ if (availableForDesc > 10) {
465465+ const truncatedDesc = truncateToGraphemes(
466466+ description,
467467+ availableForDesc,
468468+ );
469469+ postText = `${title}\n\n${truncatedDesc}${urlPart}`;
470470+ } else {
471471+ // Just title + URL
472472+ postText = `${title}${urlPart}`;
473473+ }
474474+ }
475475+ } else {
476476+ // Just title + URL
477477+ postText = `${title}${urlPart}`;
478478+ }
431479432432- // Final truncation if still too long (shouldn't happen but safety check)
433433- if (countGraphemes(postText) > MAX_GRAPHEMES) {
434434- postText = truncateToGraphemes(postText, MAX_GRAPHEMES);
435435- }
480480+ // Final truncation if still too long (shouldn't happen but safety check)
481481+ if (countGraphemes(postText) > MAX_GRAPHEMES) {
482482+ postText = truncateToGraphemes(postText, MAX_GRAPHEMES);
483483+ }
436484437437- // Calculate byte indices for the URL facet
438438- const encoder = new TextEncoder();
439439- const urlStartInText = postText.lastIndexOf(canonicalUrl);
440440- const beforeUrl = postText.substring(0, urlStartInText);
441441- const byteStart = encoder.encode(beforeUrl).length;
442442- const byteEnd = byteStart + encoder.encode(canonicalUrl).length;
485485+ // Calculate byte indices for the URL facet
486486+ const encoder = new TextEncoder();
487487+ const urlStartInText = postText.lastIndexOf(canonicalUrl);
488488+ const beforeUrl = postText.substring(0, urlStartInText);
489489+ const byteStart = encoder.encode(beforeUrl).length;
490490+ const byteEnd = byteStart + encoder.encode(canonicalUrl).length;
443491444444- // Build facets for the URL link
445445- const facets = [
446446- {
447447- index: {
448448- byteStart,
449449- byteEnd,
450450- },
451451- features: [
452452- {
453453- $type: "app.bsky.richtext.facet#link",
454454- uri: canonicalUrl,
455455- },
456456- ],
457457- },
458458- ];
492492+ // Build facets for the URL link
493493+ const facets = [
494494+ {
495495+ index: {
496496+ byteStart,
497497+ byteEnd,
498498+ },
499499+ features: [
500500+ {
501501+ $type: "app.bsky.richtext.facet#link",
502502+ uri: canonicalUrl,
503503+ },
504504+ ],
505505+ },
506506+ ];
459507460460- // Build external embed
461461- const embed: Record<string, unknown> = {
462462- $type: "app.bsky.embed.external",
463463- external: {
464464- uri: canonicalUrl,
465465- title: title.substring(0, 500), // Max 500 chars for title
466466- description: (description || "").substring(0, 1000), // Max 1000 chars for description
467467- },
468468- };
508508+ // Build external embed
509509+ const embed: Record<string, unknown> = {
510510+ $type: "app.bsky.embed.external",
511511+ external: {
512512+ uri: canonicalUrl,
513513+ title: title.substring(0, 500), // Max 500 chars for title
514514+ description: (description || "").substring(0, 1000), // Max 1000 chars for description
515515+ },
516516+ };
469517470470- // Add thumbnail if coverImage is available
471471- if (coverImage) {
472472- (embed.external as Record<string, unknown>).thumb = coverImage;
473473- }
518518+ // Add thumbnail if coverImage is available
519519+ if (coverImage) {
520520+ (embed.external as Record<string, unknown>).thumb = coverImage;
521521+ }
474522475475- // Create the post record
476476- const record: Record<string, unknown> = {
477477- $type: "app.bsky.feed.post",
478478- text: postText,
479479- facets,
480480- embed,
481481- createdAt: new Date(publishedAt).toISOString(),
482482- };
523523+ // Create the post record
524524+ const record: Record<string, unknown> = {
525525+ $type: "app.bsky.feed.post",
526526+ text: postText,
527527+ facets,
528528+ embed,
529529+ createdAt: new Date(publishedAt).toISOString(),
530530+ };
483531484484- const response = await agent.com.atproto.repo.createRecord({
485485- repo: agent.session!.did,
486486- collection: "app.bsky.feed.post",
487487- record,
488488- });
532532+ const response = await agent.com.atproto.repo.createRecord({
533533+ repo: agent.session!.did,
534534+ collection: "app.bsky.feed.post",
535535+ record,
536536+ });
489537490490- return {
491491- uri: response.data.uri,
492492- cid: response.data.cid,
493493- };
538538+ return {
539539+ uri: response.data.uri,
540540+ cid: response.data.cid,
541541+ };
494542}
495543496544/**
497545 * Add bskyPostRef to an existing document record
498546 */
499547export async function addBskyPostRefToDocument(
500500- agent: AtpAgent,
501501- documentAtUri: string,
502502- bskyPostRef: StrongRef
548548+ agent: AtpAgent,
549549+ documentAtUri: string,
550550+ bskyPostRef: StrongRef,
503551): Promise<void> {
504504- const parsed = parseAtUri(documentAtUri);
505505- if (!parsed) {
506506- throw new Error(`Invalid document URI: ${documentAtUri}`);
507507- }
552552+ const parsed = parseAtUri(documentAtUri);
553553+ if (!parsed) {
554554+ throw new Error(`Invalid document URI: ${documentAtUri}`);
555555+ }
508556509509- // Fetch existing record
510510- const existingRecord = await agent.com.atproto.repo.getRecord({
511511- repo: parsed.did,
512512- collection: parsed.collection,
513513- rkey: parsed.rkey,
514514- });
557557+ // Fetch existing record
558558+ const existingRecord = await agent.com.atproto.repo.getRecord({
559559+ repo: parsed.did,
560560+ collection: parsed.collection,
561561+ rkey: parsed.rkey,
562562+ });
515563516516- // Add bskyPostRef to the record
517517- const updatedRecord = {
518518- ...(existingRecord.data.value as Record<string, unknown>),
519519- bskyPostRef,
520520- };
564564+ // Add bskyPostRef to the record
565565+ const updatedRecord = {
566566+ ...(existingRecord.data.value as Record<string, unknown>),
567567+ bskyPostRef,
568568+ };
521569522522- // Update the record
523523- await agent.com.atproto.repo.putRecord({
524524- repo: parsed.did,
525525- collection: parsed.collection,
526526- rkey: parsed.rkey,
527527- record: updatedRecord,
528528- });
570570+ // Update the record
571571+ await agent.com.atproto.repo.putRecord({
572572+ repo: parsed.did,
573573+ collection: parsed.collection,
574574+ rkey: parsed.rkey,
575575+ record: updatedRecord,
576576+ });
529577}
+27-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";
···7681 pdsUrl?: string;
7782 frontmatter?: FrontmatterMapping;
7883 ignore?: string[];
8484+ slugSource?: "filename" | "path" | "frontmatter";
8585+ slugField?: string;
8686+ removeIndexFromSlug?: boolean;
8787+ textContentField?: string;
7988 bluesky?: BlueskyConfig;
8089}): string {
8190 const config: Record<string, unknown> = {
···113122 config.ignore = options.ignore;
114123 }
115124125125+ if (options.slugSource && options.slugSource !== "filename") {
126126+ config.slugSource = options.slugSource;
127127+ }
128128+129129+ if (options.slugField && options.slugField !== "slug") {
130130+ config.slugField = options.slugField;
131131+ }
132132+133133+ if (options.removeIndexFromSlug) {
134134+ config.removeIndexFromSlug = options.removeIndexFromSlug;
135135+ }
136136+137137+ if (options.textContentField) {
138138+ config.textContentField = options.textContentField;
139139+ }
116140 if (options.bluesky) {
117141 config.bluesky = options.bluesky;
118142 }
+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}
+338-176
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;
77+export function parseFrontmatter(
88+ content: string,
99+ mapping?: FrontmatterMapping,
1010+): {
1111+ frontmatter: PostFrontmatter;
1212+ body: string;
1313+ rawFrontmatter: Record<string, unknown>;
1014} {
1111- // Support multiple frontmatter delimiters:
1212- // --- (YAML) - Jekyll, Astro, most SSGs
1313- // +++ (TOML) - Hugo
1414- // *** - Alternative format
1515- const frontmatterRegex = /^(---|\+\+\+|\*\*\*)\n([\s\S]*?)\n\1\n([\s\S]*)$/;
1616- 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);
17211818- if (!match) {
1919- throw new Error("Could not parse frontmatter");
2020- }
2222+ if (!match) {
2323+ throw new Error("Could not parse frontmatter");
2424+ }
21252222- const delimiter = match[1];
2323- const frontmatterStr = match[2] ?? "";
2424- const body = match[3] ?? "";
2626+ const delimiter = match[1];
2727+ const frontmatterStr = match[2] ?? "";
2828+ const body = match[3] ?? "";
25292626- // Determine format based on delimiter:
2727- // +++ uses TOML (key = value)
2828- // --- and *** use YAML (key: value)
2929- const isToml = delimiter === "+++";
3030- 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 ? "=" : ":";
31353232- // Parse frontmatter manually
3333- const raw: Record<string, unknown> = {};
3434- const lines = frontmatterStr.split("\n");
3636+ // Parse frontmatter manually
3737+ const raw: Record<string, unknown> = {};
3838+ const lines = frontmatterStr.split("\n");
35393636- for (const line of lines) {
3737- const sepIndex = line.indexOf(separator);
3838- if (sepIndex === -1) continue;
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+ }
39524040- const key = line.slice(0, sepIndex).trim();
4141- let value = line.slice(sepIndex + 1).trim();
5353+ const key = line.slice(0, sepIndex).trim();
5454+ let value = line.slice(sepIndex + 1).trim();
42554343- // Handle quoted strings
4444- if (
4545- (value.startsWith('"') && value.endsWith('"')) ||
4646- (value.startsWith("'") && value.endsWith("'"))
4747- ) {
4848- value = value.slice(1, -1);
4949- }
5656+ // Handle quoted strings
5757+ if (
5858+ (value.startsWith('"') && value.endsWith('"')) ||
5959+ (value.startsWith("'") && value.endsWith("'"))
6060+ ) {
6161+ value = value.slice(1, -1);
6262+ }
50635151- // Handle arrays (simple case for tags)
5252- if (value.startsWith("[") && value.endsWith("]")) {
5353- const arrayContent = value.slice(1, -1);
5454- raw[key] = arrayContent
5555- .split(",")
5656- .map((item) => item.trim().replace(/^["']|["']$/g, ""));
5757- } else if (value === "true") {
5858- raw[key] = true;
5959- } else if (value === "false") {
6060- raw[key] = false;
6161- } else {
6262- raw[key] = value;
6363- }
6464- }
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+ }
651176666- // Apply field mappings to normalize to standard PostFrontmatter fields
6767- const frontmatter: Record<string, unknown> = {};
118118+ // Apply field mappings to normalize to standard PostFrontmatter fields
119119+ const frontmatter: Record<string, unknown> = {};
681206969- // Title mapping
7070- const titleField = mapping?.title || "title";
7171- frontmatter.title = raw[titleField] || raw.title;
121121+ // Title mapping
122122+ const titleField = mapping?.title || "title";
123123+ frontmatter.title = raw[titleField] || raw.title;
721247373- // Description mapping
7474- const descField = mapping?.description || "description";
7575- frontmatter.description = raw[descField] || raw.description;
125125+ // Description mapping
126126+ const descField = mapping?.description || "description";
127127+ frontmatter.description = raw[descField] || raw.description;
761287777- // Publish date mapping - check custom field first, then fallbacks
7878- const dateField = mapping?.publishDate;
7979- if (dateField && raw[dateField]) {
8080- frontmatter.publishDate = raw[dateField];
8181- } else if (raw.publishDate) {
8282- frontmatter.publishDate = raw.publishDate;
8383- } else {
8484- // Fallback to common date field names
8585- const dateFields = ["pubDate", "date", "createdAt", "created_at"];
8686- for (const field of dateFields) {
8787- if (raw[field]) {
8888- frontmatter.publishDate = raw[field];
8989- break;
9090- }
9191- }
9292- }
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+ }
931459494- // Cover image mapping
9595- const coverField = mapping?.coverImage || "ogImage";
9696- frontmatter.ogImage = raw[coverField] || raw.ogImage;
146146+ // Cover image mapping
147147+ const coverField = mapping?.coverImage || "ogImage";
148148+ frontmatter.ogImage = raw[coverField] || raw.ogImage;
971499898- // Tags mapping
9999- const tagsField = mapping?.tags || "tags";
100100- frontmatter.tags = raw[tagsField] || raw.tags;
150150+ // Tags mapping
151151+ const tagsField = mapping?.tags || "tags";
152152+ frontmatter.tags = raw[tagsField] || raw.tags;
101153102102- // Draft mapping
103103- const draftField = mapping?.draft || "draft";
104104- const draftValue = raw[draftField] ?? raw.draft;
105105- if (draftValue !== undefined) {
106106- frontmatter.draft = draftValue === true || draftValue === "true";
107107- }
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+ }
108160109109- // Always preserve atUri (internal field)
110110- frontmatter.atUri = raw.atUri;
161161+ // Always preserve atUri (internal field)
162162+ frontmatter.atUri = raw.atUri;
111163112112- return { frontmatter: frontmatter as unknown as PostFrontmatter, body };
164164+ return {
165165+ frontmatter: frontmatter as unknown as PostFrontmatter,
166166+ body,
167167+ rawFrontmatter: raw,
168168+ };
113169}
114170115171export function getSlugFromFilename(filename: string): string {
116116- return filename
117117- .replace(/\.mdx?$/, "")
118118- .toLowerCase()
119119- .replace(/\s+/g, "-");
172172+ return filename
173173+ .replace(/\.mdx?$/, "")
174174+ .toLowerCase()
175175+ .replace(/\s+/g, "-");
176176+}
177177+178178+export interface SlugOptions {
179179+ slugSource?: "filename" | "path" | "frontmatter";
180180+ slugField?: string;
181181+ removeIndexFromSlug?: boolean;
182182+}
183183+184184+export function getSlugFromOptions(
185185+ relativePath: string,
186186+ rawFrontmatter: Record<string, unknown>,
187187+ options: SlugOptions = {},
188188+): string {
189189+ const {
190190+ slugSource = "filename",
191191+ slugField = "slug",
192192+ removeIndexFromSlug = false,
193193+ } = options;
194194+195195+ let slug: string;
196196+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;
205205+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+ }
222222+223223+ default:
224224+ slug = getSlugFromFilename(path.basename(relativePath));
225225+ break;
226226+ }
227227+228228+ // Remove /index or /_index suffix if configured
229229+ if (removeIndexFromSlug) {
230230+ slug = slug.replace(/\/_?index$/, "");
231231+ }
232232+233233+ return slug;
120234}
121235122236export async function getContentHash(content: string): Promise<string> {
123123- const encoder = new TextEncoder();
124124- const data = encoder.encode(content);
125125- const hashBuffer = await crypto.subtle.digest("SHA-256", data);
126126- const hashArray = Array.from(new Uint8Array(hashBuffer));
127127- return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
237237+ const encoder = new TextEncoder();
238238+ const data = encoder.encode(content);
239239+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
240240+ const hashArray = Array.from(new Uint8Array(hashBuffer));
241241+ return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
128242}
129243130244function shouldIgnore(relativePath: string, ignorePatterns: string[]): boolean {
131131- for (const pattern of ignorePatterns) {
132132- if (minimatch(relativePath, pattern)) {
133133- return true;
134134- }
135135- }
136136- return false;
245245+ for (const pattern of ignorePatterns) {
246246+ if (minimatch(relativePath, pattern)) {
247247+ return true;
248248+ }
249249+ }
250250+ return false;
251251+}
252252+253253+export interface ScanOptions {
254254+ frontmatterMapping?: FrontmatterMapping;
255255+ ignorePatterns?: string[];
256256+ slugSource?: "filename" | "path" | "frontmatter";
257257+ slugField?: string;
258258+ removeIndexFromSlug?: boolean;
137259}
138260139261export async function scanContentDirectory(
140140- contentDir: string,
141141- frontmatterMapping?: FrontmatterMapping,
142142- ignorePatterns: string[] = []
262262+ contentDir: string,
263263+ frontmatterMappingOrOptions?: FrontmatterMapping | ScanOptions,
264264+ ignorePatterns: string[] = [],
143265): Promise<BlogPost[]> {
144144- const patterns = ["**/*.md", "**/*.mdx"];
145145- const posts: BlogPost[] = [];
266266+ // Handle both old signature (frontmatterMapping, ignorePatterns) and new signature (options)
267267+ let options: ScanOptions;
268268+ if (
269269+ frontmatterMappingOrOptions &&
270270+ ("slugSource" in frontmatterMappingOrOptions ||
271271+ "frontmatterMapping" in frontmatterMappingOrOptions ||
272272+ "ignorePatterns" in frontmatterMappingOrOptions)
273273+ ) {
274274+ options = frontmatterMappingOrOptions as ScanOptions;
275275+ } else {
276276+ // Old signature: (contentDir, frontmatterMapping?, ignorePatterns?)
277277+ options = {
278278+ frontmatterMapping: frontmatterMappingOrOptions as
279279+ | FrontmatterMapping
280280+ | undefined,
281281+ ignorePatterns,
282282+ };
283283+ }
146284147147- for (const pattern of patterns) {
148148- const files = await glob(pattern, {
149149- cwd: contentDir,
150150- absolute: false,
151151- });
285285+ const {
286286+ frontmatterMapping,
287287+ ignorePatterns: ignore = [],
288288+ slugSource,
289289+ slugField,
290290+ removeIndexFromSlug,
291291+ } = options;
152292153153- for (const relativePath of files) {
154154- // Skip files matching ignore patterns
155155- if (shouldIgnore(relativePath, ignorePatterns)) {
156156- continue;
157157- }
293293+ const patterns = ["**/*.md", "**/*.mdx"];
294294+ const posts: BlogPost[] = [];
295295+296296+ for (const pattern of patterns) {
297297+ const files = await glob(pattern, {
298298+ cwd: contentDir,
299299+ absolute: false,
300300+ });
301301+302302+ for (const relativePath of files) {
303303+ // Skip files matching ignore patterns
304304+ if (shouldIgnore(relativePath, ignore)) {
305305+ continue;
306306+ }
158307159159- const filePath = path.join(contentDir, relativePath);
160160- const rawContent = await fs.readFile(filePath, "utf-8");
308308+ const filePath = path.join(contentDir, relativePath);
309309+ const rawContent = await fs.readFile(filePath, "utf-8");
161310162162- try {
163163- const { frontmatter, body } = parseFrontmatter(rawContent, frontmatterMapping);
164164- const filename = path.basename(relativePath);
165165- const slug = getSlugFromFilename(filename);
311311+ try {
312312+ const { frontmatter, body, rawFrontmatter } = parseFrontmatter(
313313+ rawContent,
314314+ frontmatterMapping,
315315+ );
316316+ const slug = getSlugFromOptions(relativePath, rawFrontmatter, {
317317+ slugSource,
318318+ slugField,
319319+ removeIndexFromSlug,
320320+ });
166321167167- posts.push({
168168- filePath,
169169- slug,
170170- frontmatter,
171171- content: body,
172172- rawContent,
173173- });
174174- } catch (error) {
175175- console.error(`Error parsing ${relativePath}:`, error);
176176- }
177177- }
178178- }
322322+ posts.push({
323323+ filePath,
324324+ slug,
325325+ frontmatter,
326326+ content: body,
327327+ rawContent,
328328+ rawFrontmatter,
329329+ });
330330+ } catch (error) {
331331+ console.error(`Error parsing ${relativePath}:`, error);
332332+ }
333333+ }
334334+ }
179335180180- // Sort by publish date (newest first)
181181- posts.sort((a, b) => {
182182- const dateA = new Date(a.frontmatter.publishDate);
183183- const dateB = new Date(b.frontmatter.publishDate);
184184- return dateB.getTime() - dateA.getTime();
185185- });
336336+ // Sort by publish date (newest first)
337337+ posts.sort((a, b) => {
338338+ const dateA = new Date(a.frontmatter.publishDate);
339339+ const dateB = new Date(b.frontmatter.publishDate);
340340+ return dateB.getTime() - dateA.getTime();
341341+ });
186342187187- return posts;
343343+ return posts;
188344}
189345190190-export function updateFrontmatterWithAtUri(rawContent: string, atUri: string): string {
191191- // Detect which delimiter is used (---, +++, or ***)
192192- const delimiterMatch = rawContent.match(/^(---|\+\+\+|\*\*\*)/);
193193- const delimiter = delimiterMatch?.[1] ?? "---";
194194- const isToml = delimiter === "+++";
346346+export function updateFrontmatterWithAtUri(
347347+ rawContent: string,
348348+ atUri: string,
349349+): string {
350350+ // Detect which delimiter is used (---, +++, or ***)
351351+ const delimiterMatch = rawContent.match(/^(---|\+\+\+|\*\*\*)/);
352352+ const delimiter = delimiterMatch?.[1] ?? "---";
353353+ const isToml = delimiter === "+++";
195354196196- // Format the atUri entry based on frontmatter type
197197- const atUriEntry = isToml ? `atUri = "${atUri}"` : `atUri: "${atUri}"`;
355355+ // Format the atUri entry based on frontmatter type
356356+ const atUriEntry = isToml ? `atUri = "${atUri}"` : `atUri: "${atUri}"`;
198357199199- // Check if atUri already exists in frontmatter (handle both formats)
200200- if (rawContent.includes("atUri:") || rawContent.includes("atUri =")) {
201201- // Replace existing atUri (match both YAML and TOML formats)
202202- return rawContent.replace(/atUri\s*[=:]\s*["']?[^"'\n]+["']?\n?/, `${atUriEntry}\n`);
203203- }
358358+ // Check if atUri already exists in frontmatter (handle both formats)
359359+ if (rawContent.includes("atUri:") || rawContent.includes("atUri =")) {
360360+ // Replace existing atUri (match both YAML and TOML formats)
361361+ return rawContent.replace(
362362+ /atUri\s*[=:]\s*["']?[^"'\n]+["']?\n?/,
363363+ `${atUriEntry}\n`,
364364+ );
365365+ }
204366205205- // Insert atUri before the closing delimiter
206206- const frontmatterEndIndex = rawContent.indexOf(delimiter, 4);
207207- if (frontmatterEndIndex === -1) {
208208- throw new Error("Could not find frontmatter end");
209209- }
367367+ // Insert atUri before the closing delimiter
368368+ const frontmatterEndIndex = rawContent.indexOf(delimiter, 4);
369369+ if (frontmatterEndIndex === -1) {
370370+ throw new Error("Could not find frontmatter end");
371371+ }
210372211211- const beforeEnd = rawContent.slice(0, frontmatterEndIndex);
212212- const afterEnd = rawContent.slice(frontmatterEndIndex);
373373+ const beforeEnd = rawContent.slice(0, frontmatterEndIndex);
374374+ const afterEnd = rawContent.slice(frontmatterEndIndex);
213375214214- return `${beforeEnd}${atUriEntry}\n${afterEnd}`;
376376+ return `${beforeEnd}${atUriEntry}\n${afterEnd}`;
215377}
216378217379export function stripMarkdownForText(markdown: string): string {
218218- return markdown
219219- .replace(/#{1,6}\s/g, "") // Remove headers
220220- .replace(/\*\*([^*]+)\*\*/g, "$1") // Remove bold
221221- .replace(/\*([^*]+)\*/g, "$1") // Remove italic
222222- .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Remove links, keep text
223223- .replace(/`{3}[\s\S]*?`{3}/g, "") // Remove code blocks
224224- .replace(/`([^`]+)`/g, "$1") // Remove inline code formatting
225225- .replace(/!\[.*?\]\(.*?\)/g, "") // Remove images
226226- .replace(/\n{3,}/g, "\n\n") // Normalize multiple newlines
227227- .trim();
380380+ return markdown
381381+ .replace(/#{1,6}\s/g, "") // Remove headers
382382+ .replace(/\*\*([^*]+)\*\*/g, "$1") // Remove bold
383383+ .replace(/\*([^*]+)\*/g, "$1") // Remove italic
384384+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Remove links, keep text
385385+ .replace(/`{3}[\s\S]*?`{3}/g, "") // Remove code blocks
386386+ .replace(/`([^`]+)`/g, "$1") // Remove inline code formatting
387387+ .replace(/!\[.*?\]\(.*?\)/g, "") // Remove images
388388+ .replace(/\n{3,}/g, "\n\n") // Normalize multiple newlines
389389+ .trim();
228390}
+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}
+6
packages/cli/src/lib/types.ts
···3131 identity?: string; // Which stored identity to use (matches identifier)
3232 frontmatter?: FrontmatterMapping; // Custom frontmatter field mappings
3333 ignore?: string[]; // Glob patterns for files to ignore (e.g., ["_index.md", "**/drafts/**"])
3434+ slugSource?: "filename" | "path" | "frontmatter"; // How to generate slugs (default: "filename")
3535+ slugField?: string; // Frontmatter field to use when slugSource is "frontmatter" (default: "slug")
3636+ removeIndexFromSlug?: boolean; // Remove "/index" or "/_index" suffix from paths (default: false)
3737+ textContentField?: string; // Frontmatter field to use for textContent instead of markdown body
3438 bluesky?: BlueskyConfig; // Optional Bluesky posting configuration
3539}
3640···5660 frontmatter: PostFrontmatter;
5761 content: string;
5862 rawContent: string;
6363+ rawFrontmatter: Record<string, unknown>; // For accessing custom fields like textContentField
5964}
60656166export interface BlobRef {
···7782 contentHash: string;
7883 atUri?: string;
7984 lastPublished?: string;
8585+ slug?: string; // The generated slug for this post (used by inject command)
8086 bskyPostRef?: StrongRef; // Reference to corresponding Bluesky post
8187}
8288