diff --git a/bun.lock b/bun.lock index 30cb6c7..8bf2b89 100644 --- a/bun.lock +++ b/bun.lock @@ -24,7 +24,7 @@ }, "packages/cli": { "name": "sequoia-cli", - "version": "0.1.0", + "version": "0.2.0", "bin": { "sequoia": "dist/index.js", }, @@ -37,6 +37,7 @@ "minimatch": "^10.1.1", }, "devDependencies": { + "@biomejs/biome": "^2.3.13", "@types/mime-types": "^3.0.1", "@types/node": "^20", }, @@ -104,6 +105,24 @@ "@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="], + "@biomejs/biome": ["@biomejs/biome@2.3.13", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.13", "@biomejs/cli-darwin-x64": "2.3.13", "@biomejs/cli-linux-arm64": "2.3.13", "@biomejs/cli-linux-arm64-musl": "2.3.13", "@biomejs/cli-linux-x64": "2.3.13", "@biomejs/cli-linux-x64-musl": "2.3.13", "@biomejs/cli-win32-arm64": "2.3.13", "@biomejs/cli-win32-x64": "2.3.13" }, "bin": { "biome": "bin/biome" } }, "sha512-Fw7UsV0UAtWIBIm0M7g5CRerpu1eKyKAXIazzxhbXYUyMkwNrkX/KLkGI7b+uVDQ5cLUMfOC9vR60q9IDYDstA=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0OCwP0/BoKzyJHnFdaTk/i7hIP9JHH9oJJq6hrSCPmJPo8JWcJhprK4gQlhFzrwdTBAW4Bjt/RmCf3ZZe59gwQ=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-AGr8OoemT/ejynbIu56qeil2+F2WLkIjn2d8jGK1JkchxnMUhYOfnqc9sVzcRxpG9Ycvw4weQ5sprRvtb7Yhcw=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-xvOiFkrDNu607MPMBUQ6huHmBG1PZLOrqhtK6pXJW3GjfVqJg0Z/qpTdhXfcqWdSZHcT+Nct2fOgewZvytESkw=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-TUdDCSY+Eo/EHjhJz7P2GnWwfqet+lFxBZzGHldrvULr59AgahamLs/N85SC4+bdF86EhqDuuw9rYLvLFWWlXA=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.13", "", { "os": "linux", "cpu": "x64" }, "sha512-s+YsZlgiXNq8XkgHs6xdvKDFOj/bwTEevqEY6rC2I3cBHbxXYU1LOZstH3Ffw9hE5tE1sqT7U23C00MzkXztMw=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.13", "", { "os": "linux", "cpu": "x64" }, "sha512-0bdwFVSbbM//Sds6OjtnmQGp4eUjOTt6kHvR/1P0ieR9GcTUAlPNvPC3DiavTqq302W34Ae2T6u5VVNGuQtGlQ=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-QweDxY89fq0VvrxME+wS/BXKmqMrOTZlN9SqQ79kQSIc3FrEwvW/PvUegQF6XIVaekncDykB5dzPqjbwSKs9DA=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.13", "", { "os": "win32", "cpu": "x64" }, "sha512-trDw2ogdM2lyav9WFQsdsfdVy1dvZALymRpgmWsvSez0BJzBjulhOT/t+wyKeh3pZWvwP3VMs1SoOKwO3wecMQ=="], + "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.1", "", {}, "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw=="], "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@11.0.3", "", { "dependencies": { "@chevrotain/gast": "11.0.3", "@chevrotain/types": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ=="], diff --git a/packages/cli/biome.json b/packages/cli/biome.json new file mode 100644 index 0000000..003f65c --- /dev/null +++ b/packages/cli/biome.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.13/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": ["**", "!!**/dist"] + }, + "formatter": { + "enabled": true, + "indentStyle": "tab" + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 64c812f..ea508ec 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -14,11 +14,14 @@ ".": "./dist/index.js" }, "scripts": { + "lint": "biome lint --write", + "format": "biome format --write", "build": "bun build src/index.ts --target node --outdir dist", "dev": "bun run build && bun link", "deploy": "bun run build && bun publish" }, "devDependencies": { + "@biomejs/biome": "^2.3.13", "@types/mime-types": "^3.0.1", "@types/node": "^20" }, diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts index 18816e6..3fea184 100644 --- a/packages/cli/src/commands/auth.ts +++ b/packages/cli/src/commands/auth.ts @@ -1,156 +1,173 @@ -import { command, flag, option, optional, string } from "cmd-ts"; -import { note, text, password, confirm, select, spinner, log } from "@clack/prompts"; import { AtpAgent } from "@atproto/api"; import { - saveCredentials, - deleteCredentials, - listCredentials, - getCredentials, - getCredentialsPath, -} from "../lib/credentials"; + confirm, + log, + note, + password, + select, + spinner, + text, +} from "@clack/prompts"; +import { command, flag, option, optional, string } from "cmd-ts"; import { resolveHandleToPDS } from "../lib/atproto"; +import { + deleteCredentials, + getCredentials, + getCredentialsPath, + listCredentials, + saveCredentials, +} from "../lib/credentials"; import { exitOnCancel } from "../lib/prompts"; export const authCommand = command({ - name: "auth", - description: "Authenticate with your ATProto PDS", - args: { - logout: option({ - long: "logout", - description: "Remove credentials for a specific identity (or all if only one exists)", - type: optional(string), - }), - list: flag({ - long: "list", - description: "List all stored identities", - }), - }, - handler: async ({ logout, list }) => { - // List identities - if (list) { - const identities = await listCredentials(); - if (identities.length === 0) { - log.info("No stored identities"); - } else { - log.info("Stored identities:"); - for (const id of identities) { - console.log(` - ${id}`); - } - } - return; - } + name: "auth", + description: "Authenticate with your ATProto PDS", + args: { + logout: option({ + long: "logout", + description: + "Remove credentials for a specific identity (or all if only one exists)", + type: optional(string), + }), + list: flag({ + long: "list", + description: "List all stored identities", + }), + }, + handler: async ({ logout, list }) => { + // List identities + if (list) { + const identities = await listCredentials(); + if (identities.length === 0) { + log.info("No stored identities"); + } else { + log.info("Stored identities:"); + for (const id of identities) { + console.log(` - ${id}`); + } + } + return; + } - // Logout - if (logout !== undefined) { - // If --logout was passed without a value, it will be an empty string - const identifier = logout || undefined; + // Logout + if (logout !== undefined) { + // If --logout was passed without a value, it will be an empty string + const identifier = logout || undefined; - if (!identifier) { - // No identifier provided - show available and prompt - const identities = await listCredentials(); - if (identities.length === 0) { - log.info("No saved credentials found"); - return; - } - if (identities.length === 1) { - const deleted = await deleteCredentials(identities[0]); - if (deleted) { - log.success(`Removed credentials for ${identities[0]}`); - } - return; - } - // Multiple identities - prompt - const selected = exitOnCancel(await select({ - message: "Select identity to remove:", - options: identities.map(id => ({ value: id, label: id })), - })); - const deleted = await deleteCredentials(selected); - if (deleted) { - log.success(`Removed credentials for ${selected}`); - } - return; - } + if (!identifier) { + // No identifier provided - show available and prompt + const identities = await listCredentials(); + if (identities.length === 0) { + log.info("No saved credentials found"); + return; + } + if (identities.length === 1) { + const deleted = await deleteCredentials(identities[0]); + if (deleted) { + log.success(`Removed credentials for ${identities[0]}`); + } + return; + } + // Multiple identities - prompt + const selected = exitOnCancel( + await select({ + message: "Select identity to remove:", + options: identities.map((id) => ({ value: id, label: id })), + }), + ); + const deleted = await deleteCredentials(selected); + if (deleted) { + log.success(`Removed credentials for ${selected}`); + } + return; + } - const deleted = await deleteCredentials(identifier); - if (deleted) { - log.success(`Removed credentials for ${identifier}`); - } else { - log.info(`No credentials found for ${identifier}`); - } - return; - } + const deleted = await deleteCredentials(identifier); + if (deleted) { + log.success(`Removed credentials for ${identifier}`); + } else { + log.info(`No credentials found for ${identifier}`); + } + return; + } - note( - "To authenticate, you'll need an App Password.\n\n" + - "Create one at: https://bsky.app/settings/app-passwords\n\n" + - "App Passwords are safer than your main password and can be revoked.", - "Authentication" - ); + note( + "To authenticate, you'll need an App Password.\n\n" + + "Create one at: https://bsky.app/settings/app-passwords\n\n" + + "App Passwords are safer than your main password and can be revoked.", + "Authentication", + ); - const identifier = exitOnCancel(await text({ - message: "Handle or DID:", - placeholder: "yourhandle.bsky.social", - })); + const identifier = exitOnCancel( + await text({ + message: "Handle or DID:", + placeholder: "yourhandle.bsky.social", + }), + ); - const appPassword = exitOnCancel(await password({ - message: "App Password:", - })); + const appPassword = exitOnCancel( + await password({ + message: "App Password:", + }), + ); - if (!identifier || !appPassword) { - log.error("Handle and password are required"); - process.exit(1); - } + if (!identifier || !appPassword) { + log.error("Handle and password are required"); + process.exit(1); + } - // Check if this identity already exists - const existing = await getCredentials(identifier); - if (existing) { - const overwrite = exitOnCancel(await confirm({ - message: `Credentials for ${identifier} already exist. Update?`, - initialValue: false, - })); - if (!overwrite) { - log.info("Keeping existing credentials"); - return; - } - } + // Check if this identity already exists + const existing = await getCredentials(identifier); + if (existing) { + const overwrite = exitOnCancel( + await confirm({ + message: `Credentials for ${identifier} already exist. Update?`, + initialValue: false, + }), + ); + if (!overwrite) { + log.info("Keeping existing credentials"); + return; + } + } - // Resolve PDS from handle - const s = spinner(); - s.start("Resolving PDS..."); - let pdsUrl: string; - try { - pdsUrl = await resolveHandleToPDS(identifier); - s.stop(`Found PDS: ${pdsUrl}`); - } catch (error) { - s.stop("Failed to resolve PDS"); - log.error(`Failed to resolve PDS from handle: ${error}`); - process.exit(1); - } + // Resolve PDS from handle + const s = spinner(); + s.start("Resolving PDS..."); + let pdsUrl: string; + try { + pdsUrl = await resolveHandleToPDS(identifier); + s.stop(`Found PDS: ${pdsUrl}`); + } catch (error) { + s.stop("Failed to resolve PDS"); + log.error(`Failed to resolve PDS from handle: ${error}`); + process.exit(1); + } - // Verify credentials - s.start("Verifying credentials..."); + // Verify credentials + s.start("Verifying credentials..."); - try { - const agent = new AtpAgent({ service: pdsUrl }); - await agent.login({ - identifier: identifier, - password: appPassword, - }); + try { + const agent = new AtpAgent({ service: pdsUrl }); + await agent.login({ + identifier: identifier, + password: appPassword, + }); - s.stop(`Logged in as ${agent.session?.handle}`); + s.stop(`Logged in as ${agent.session?.handle}`); - // Save credentials - await saveCredentials({ - pdsUrl, - identifier: identifier, - password: appPassword, - }); + // Save credentials + await saveCredentials({ + pdsUrl, + identifier: identifier, + password: appPassword, + }); - log.success(`Credentials saved to ${getCredentialsPath()}`); - } catch (error) { - s.stop("Failed to login"); - log.error(`Failed to login: ${error}`); - process.exit(1); - } - }, + log.success(`Credentials saved to ${getCredentialsPath()}`); + } catch (error) { + s.stop("Failed to login"); + log.error(`Failed to login: ${error}`); + process.exit(1); + } + }, }); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index d37bdf8..8825fc3 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -1,4 +1,4 @@ -import * as fs from "fs/promises"; +import * as fs from "node:fs/promises"; import { command } from "cmd-ts"; import { intro, @@ -11,7 +11,7 @@ import { log, group, } from "@clack/prompts"; -import * as path from "path"; +import * as path from "node:path"; import { findConfig, generateConfigTemplate } from "../lib/config"; import { loadCredentials } from "../lib/credentials"; import { createAgent, createPublication } from "../lib/atproto"; diff --git a/packages/cli/src/commands/inject.ts b/packages/cli/src/commands/inject.ts index fa5e6f6..476e131 100644 --- a/packages/cli/src/commands/inject.ts +++ b/packages/cli/src/commands/inject.ts @@ -1,9 +1,9 @@ -import * as fs from "fs/promises"; -import { command, flag, option, optional, string } from "cmd-ts"; import { log } from "@clack/prompts"; -import * as path from "path"; +import { command, flag, option, optional, string } from "cmd-ts"; import { glob } from "glob"; -import { loadConfig, loadState, findConfig } from "../lib/config"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { findConfig, loadConfig, loadState } from "../lib/config"; export const injectCommand = command({ name: "inject", diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts index 556f09b..1555782 100644 --- a/packages/cli/src/commands/publish.ts +++ b/packages/cli/src/commands/publish.ts @@ -1,7 +1,7 @@ -import * as fs from "fs/promises"; +import * as fs from "node:fs/promises"; import { command, flag } from "cmd-ts"; import { select, spinner, log } from "@clack/prompts"; -import * as path from "path"; +import * as path from "node:path"; import { loadConfig, loadState, saveState, findConfig } from "../lib/config"; import { loadCredentials, diff --git a/packages/cli/src/commands/sync.ts b/packages/cli/src/commands/sync.ts index 6107c4f..7c59ac1 100644 --- a/packages/cli/src/commands/sync.ts +++ b/packages/cli/src/commands/sync.ts @@ -1,190 +1,204 @@ -import * as fs from "fs/promises"; +import * as fs from "node:fs/promises"; import { command, flag } from "cmd-ts"; import { select, spinner, log } from "@clack/prompts"; -import * as path from "path"; +import * as path from "node:path"; import { loadConfig, loadState, saveState, findConfig } from "../lib/config"; -import { loadCredentials, listCredentials, getCredentials } from "../lib/credentials"; +import { + loadCredentials, + listCredentials, + getCredentials, +} from "../lib/credentials"; import { createAgent, listDocuments } from "../lib/atproto"; -import { scanContentDirectory, getContentHash, updateFrontmatterWithAtUri } from "../lib/markdown"; +import { + scanContentDirectory, + getContentHash, + updateFrontmatterWithAtUri, +} from "../lib/markdown"; import { exitOnCancel } from "../lib/prompts"; export const syncCommand = command({ - name: "sync", - description: "Sync state from ATProto to restore .sequoia-state.json", - args: { - updateFrontmatter: flag({ - long: "update-frontmatter", - short: "u", - description: "Update frontmatter atUri fields in local markdown files", - }), - dryRun: flag({ - long: "dry-run", - short: "n", - description: "Preview what would be synced without making changes", - }), - }, - handler: async ({ updateFrontmatter, dryRun }) => { - // Load config - const configPath = await findConfig(); - if (!configPath) { - log.error("No sequoia.json found. Run 'sequoia init' first."); - process.exit(1); - } - - const config = await loadConfig(configPath); - const configDir = path.dirname(configPath); - - log.info(`Site: ${config.siteUrl}`); - log.info(`Publication: ${config.publicationUri}`); - - // Load credentials - let credentials = await loadCredentials(config.identity); - - if (!credentials) { - const identities = await listCredentials(); - if (identities.length === 0) { - log.error("No credentials found. Run 'sequoia auth' first."); - process.exit(1); - } - - log.info("Multiple identities found. Select one to use:"); - const selected = exitOnCancel(await select({ - message: "Identity:", - options: identities.map(id => ({ value: id, label: id })), - })); - - credentials = await getCredentials(selected); - if (!credentials) { - log.error("Failed to load selected credentials."); - process.exit(1); - } - } - - // Create agent - const s = spinner(); - s.start(`Connecting to ${credentials.pdsUrl}...`); - let agent; - try { - agent = await createAgent(credentials); - s.stop(`Logged in as ${agent.session?.handle}`); - } catch (error) { - s.stop("Failed to login"); - log.error(`Failed to login: ${error}`); - process.exit(1); - } - - // Fetch documents from PDS - s.start("Fetching documents from PDS..."); - const documents = await listDocuments(agent, config.publicationUri); - s.stop(`Found ${documents.length} documents on PDS`); - - if (documents.length === 0) { - log.info("No documents found for this publication."); - return; - } - - // Resolve content directory - const contentDir = path.isAbsolute(config.contentDir) - ? config.contentDir - : path.join(configDir, config.contentDir); - - // Scan local posts - s.start("Scanning local content..."); - const localPosts = await scanContentDirectory(contentDir, { - frontmatterMapping: config.frontmatter, - ignorePatterns: config.ignore, - slugSource: config.slugSource, - slugField: config.slugField, - removeIndexFromSlug: config.removeIndexFromSlug, - }); - s.stop(`Found ${localPosts.length} local posts`); - - // Build a map of path -> local post for matching - // Document path is like /posts/my-post-slug (or custom pathPrefix) - const pathPrefix = config.pathPrefix || "/posts"; - const postsByPath = new Map(); - for (const post of localPosts) { - const postPath = `${pathPrefix}/${post.slug}`; - postsByPath.set(postPath, post); - } - - // Load existing state - const state = await loadState(configDir); - const originalPostCount = Object.keys(state.posts).length; - - // Track changes - let matchedCount = 0; - let unmatchedCount = 0; - let frontmatterUpdates: Array<{ filePath: string; atUri: string }> = []; - - log.message("\nMatching documents to local files:\n"); - - for (const doc of documents) { - const docPath = doc.value.path; - const localPost = postsByPath.get(docPath); - - if (localPost) { - matchedCount++; - log.message(` ✓ ${doc.value.title}`); - log.message(` Path: ${docPath}`); - log.message(` URI: ${doc.uri}`); - log.message(` File: ${path.basename(localPost.filePath)}`); - - // Update state (use relative path from config directory) - const contentHash = await getContentHash(localPost.rawContent); - const relativeFilePath = path.relative(configDir, localPost.filePath); - state.posts[relativeFilePath] = { - contentHash, - atUri: doc.uri, - lastPublished: doc.value.publishedAt, - }; - - // Check if frontmatter needs updating - if (updateFrontmatter && localPost.frontmatter.atUri !== doc.uri) { - frontmatterUpdates.push({ - filePath: localPost.filePath, - atUri: doc.uri, - }); - log.message(` → Will update frontmatter`); - } - } else { - unmatchedCount++; - log.message(` ✗ ${doc.value.title} (no matching local file)`); - log.message(` Path: ${docPath}`); - log.message(` URI: ${doc.uri}`); - } - log.message(""); - } - - // Summary - log.message("---"); - log.info(`Matched: ${matchedCount} documents`); - if (unmatchedCount > 0) { - log.warn(`Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`); - } - - if (dryRun) { - log.info("\nDry run complete. No changes made."); - return; - } - - // Save updated state - await saveState(configDir, state); - const newPostCount = Object.keys(state.posts).length; - log.success(`\nSaved .sequoia-state.json (${originalPostCount} → ${newPostCount} entries)`); - - // Update frontmatter if requested - if (frontmatterUpdates.length > 0) { - s.start(`Updating frontmatter in ${frontmatterUpdates.length} files...`); - for (const { filePath, atUri } of frontmatterUpdates) { - const content = await fs.readFile(filePath, "utf-8"); - const updated = updateFrontmatterWithAtUri(content, atUri); - await fs.writeFile(filePath, updated); - log.message(` Updated: ${path.basename(filePath)}`); - } - s.stop("Frontmatter updated"); - } - - log.success("\nSync complete!"); - }, + name: "sync", + description: "Sync state from ATProto to restore .sequoia-state.json", + args: { + updateFrontmatter: flag({ + long: "update-frontmatter", + short: "u", + description: "Update frontmatter atUri fields in local markdown files", + }), + dryRun: flag({ + long: "dry-run", + short: "n", + description: "Preview what would be synced without making changes", + }), + }, + handler: async ({ updateFrontmatter, dryRun }) => { + // Load config + const configPath = await findConfig(); + if (!configPath) { + log.error("No sequoia.json found. Run 'sequoia init' first."); + process.exit(1); + } + + const config = await loadConfig(configPath); + const configDir = path.dirname(configPath); + + log.info(`Site: ${config.siteUrl}`); + log.info(`Publication: ${config.publicationUri}`); + + // Load credentials + let credentials = await loadCredentials(config.identity); + + if (!credentials) { + const identities = await listCredentials(); + if (identities.length === 0) { + log.error("No credentials found. Run 'sequoia auth' first."); + process.exit(1); + } + + log.info("Multiple identities found. Select one to use:"); + const selected = exitOnCancel( + await select({ + message: "Identity:", + options: identities.map((id) => ({ value: id, label: id })), + }), + ); + + credentials = await getCredentials(selected); + if (!credentials) { + log.error("Failed to load selected credentials."); + process.exit(1); + } + } + + // Create agent + const s = spinner(); + s.start(`Connecting to ${credentials.pdsUrl}...`); + let agent; + try { + agent = await createAgent(credentials); + s.stop(`Logged in as ${agent.session?.handle}`); + } catch (error) { + s.stop("Failed to login"); + log.error(`Failed to login: ${error}`); + process.exit(1); + } + + // Fetch documents from PDS + s.start("Fetching documents from PDS..."); + const documents = await listDocuments(agent, config.publicationUri); + s.stop(`Found ${documents.length} documents on PDS`); + + if (documents.length === 0) { + log.info("No documents found for this publication."); + return; + } + + // Resolve content directory + const contentDir = path.isAbsolute(config.contentDir) + ? config.contentDir + : path.join(configDir, config.contentDir); + + // Scan local posts + s.start("Scanning local content..."); + const localPosts = await scanContentDirectory(contentDir, { + frontmatterMapping: config.frontmatter, + ignorePatterns: config.ignore, + slugSource: config.slugSource, + slugField: config.slugField, + removeIndexFromSlug: config.removeIndexFromSlug, + }); + s.stop(`Found ${localPosts.length} local posts`); + + // Build a map of path -> local post for matching + // Document path is like /posts/my-post-slug (or custom pathPrefix) + const pathPrefix = config.pathPrefix || "/posts"; + const postsByPath = new Map(); + for (const post of localPosts) { + const postPath = `${pathPrefix}/${post.slug}`; + postsByPath.set(postPath, post); + } + + // Load existing state + const state = await loadState(configDir); + const originalPostCount = Object.keys(state.posts).length; + + // Track changes + let matchedCount = 0; + let unmatchedCount = 0; + const frontmatterUpdates: Array<{ filePath: string; atUri: string }> = []; + + log.message("\nMatching documents to local files:\n"); + + for (const doc of documents) { + const docPath = doc.value.path; + const localPost = postsByPath.get(docPath); + + if (localPost) { + matchedCount++; + log.message(` ✓ ${doc.value.title}`); + log.message(` Path: ${docPath}`); + log.message(` URI: ${doc.uri}`); + log.message(` File: ${path.basename(localPost.filePath)}`); + + // Update state (use relative path from config directory) + const contentHash = await getContentHash(localPost.rawContent); + const relativeFilePath = path.relative(configDir, localPost.filePath); + state.posts[relativeFilePath] = { + contentHash, + atUri: doc.uri, + lastPublished: doc.value.publishedAt, + }; + + // Check if frontmatter needs updating + if (updateFrontmatter && localPost.frontmatter.atUri !== doc.uri) { + frontmatterUpdates.push({ + filePath: localPost.filePath, + atUri: doc.uri, + }); + log.message(` → Will update frontmatter`); + } + } else { + unmatchedCount++; + log.message(` ✗ ${doc.value.title} (no matching local file)`); + log.message(` Path: ${docPath}`); + log.message(` URI: ${doc.uri}`); + } + log.message(""); + } + + // Summary + log.message("---"); + log.info(`Matched: ${matchedCount} documents`); + if (unmatchedCount > 0) { + log.warn( + `Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`, + ); + } + + if (dryRun) { + log.info("\nDry run complete. No changes made."); + return; + } + + // Save updated state + await saveState(configDir, state); + const newPostCount = Object.keys(state.posts).length; + log.success( + `\nSaved .sequoia-state.json (${originalPostCount} → ${newPostCount} entries)`, + ); + + // Update frontmatter if requested + if (frontmatterUpdates.length > 0) { + s.start(`Updating frontmatter in ${frontmatterUpdates.length} files...`); + for (const { filePath, atUri } of frontmatterUpdates) { + const content = await fs.readFile(filePath, "utf-8"); + const updated = updateFrontmatterWithAtUri(content, atUri); + await fs.writeFile(filePath, updated); + log.message(` Updated: ${path.basename(filePath)}`); + } + s.stop("Frontmatter updated"); + } + + log.success("\nSync complete!"); + }, }); diff --git a/packages/cli/src/lib/atproto.ts b/packages/cli/src/lib/atproto.ts index e61e13b..8e905a9 100644 --- a/packages/cli/src/lib/atproto.ts +++ b/packages/cli/src/lib/atproto.ts @@ -1,552 +1,579 @@ import { AtpAgent } from "@atproto/api"; -import * as fs from "fs/promises"; -import * as path from "path"; import * as mimeTypes from "mime-types"; -import type { Credentials, BlogPost, BlobObject, PublisherConfig, StrongRef } from "./types"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; import { stripMarkdownForText } from "./markdown"; +import type { + BlobObject, + BlogPost, + Credentials, + PublisherConfig, + StrongRef, +} from "./types"; async function fileExists(filePath: string): Promise { - try { - await fs.access(filePath); - return true; - } catch { - return false; - } + try { + await fs.access(filePath); + return true; + } catch { + return false; + } } export async function resolveHandleToPDS(handle: string): Promise { - // First, resolve the handle to a DID - let did: string; - - if (handle.startsWith("did:")) { - did = handle; - } else { - // Try to resolve handle via Bluesky API - const resolveUrl = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; - const resolveResponse = await fetch(resolveUrl); - if (!resolveResponse.ok) { - throw new Error("Could not resolve handle"); - } - const resolveData = (await resolveResponse.json()) as { did: string }; - did = resolveData.did; - } - - // Now resolve the DID to get the PDS URL from the DID document - let pdsUrl: string | undefined; - - if (did.startsWith("did:plc:")) { - // Fetch DID document from plc.directory - const didDocUrl = `https://plc.directory/${did}`; - const didDocResponse = await fetch(didDocUrl); - if (!didDocResponse.ok) { - throw new Error("Could not fetch DID document"); - } - const didDoc = (await didDocResponse.json()) as { - service?: Array<{ id: string; type: string; serviceEndpoint: string }>; - }; - - // Find the PDS service endpoint - const pdsService = didDoc.service?.find( - (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", - ); - pdsUrl = pdsService?.serviceEndpoint; - } else if (did.startsWith("did:web:")) { - // For did:web, fetch the DID document from the domain - const domain = did.replace("did:web:", ""); - const didDocUrl = `https://${domain}/.well-known/did.json`; - const didDocResponse = await fetch(didDocUrl); - if (!didDocResponse.ok) { - throw new Error("Could not fetch DID document"); - } - const didDoc = (await didDocResponse.json()) as { - service?: Array<{ id: string; type: string; serviceEndpoint: string }>; - }; - - const pdsService = didDoc.service?.find( - (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", - ); - pdsUrl = pdsService?.serviceEndpoint; - } - - if (!pdsUrl) { - throw new Error("Could not find PDS URL for user"); - } - - return pdsUrl; + // First, resolve the handle to a DID + let did: string; + + if (handle.startsWith("did:")) { + did = handle; + } else { + // Try to resolve handle via Bluesky API + const resolveUrl = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; + const resolveResponse = await fetch(resolveUrl); + if (!resolveResponse.ok) { + throw new Error("Could not resolve handle"); + } + const resolveData = (await resolveResponse.json()) as { did: string }; + did = resolveData.did; + } + + // Now resolve the DID to get the PDS URL from the DID document + let pdsUrl: string | undefined; + + if (did.startsWith("did:plc:")) { + // Fetch DID document from plc.directory + const didDocUrl = `https://plc.directory/${did}`; + const didDocResponse = await fetch(didDocUrl); + if (!didDocResponse.ok) { + throw new Error("Could not fetch DID document"); + } + const didDoc = (await didDocResponse.json()) as { + service?: Array<{ id: string; type: string; serviceEndpoint: string }>; + }; + + // Find the PDS service endpoint + const pdsService = didDoc.service?.find( + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", + ); + pdsUrl = pdsService?.serviceEndpoint; + } else if (did.startsWith("did:web:")) { + // For did:web, fetch the DID document from the domain + const domain = did.replace("did:web:", ""); + const didDocUrl = `https://${domain}/.well-known/did.json`; + const didDocResponse = await fetch(didDocUrl); + if (!didDocResponse.ok) { + throw new Error("Could not fetch DID document"); + } + const didDoc = (await didDocResponse.json()) as { + service?: Array<{ id: string; type: string; serviceEndpoint: string }>; + }; + + const pdsService = didDoc.service?.find( + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", + ); + pdsUrl = pdsService?.serviceEndpoint; + } + + if (!pdsUrl) { + throw new Error("Could not find PDS URL for user"); + } + + return pdsUrl; } export interface CreatePublicationOptions { - url: string; - name: string; - description?: string; - iconPath?: string; - showInDiscover?: boolean; + url: string; + name: string; + description?: string; + iconPath?: string; + showInDiscover?: boolean; } export async function createAgent(credentials: Credentials): Promise { - const agent = new AtpAgent({ service: credentials.pdsUrl }); + const agent = new AtpAgent({ service: credentials.pdsUrl }); - await agent.login({ - identifier: credentials.identifier, - password: credentials.password, - }); + await agent.login({ + identifier: credentials.identifier, + password: credentials.password, + }); - return agent; + return agent; } export async function uploadImage( - agent: AtpAgent, - imagePath: string + agent: AtpAgent, + imagePath: string, ): Promise { - if (!(await fileExists(imagePath))) { - return undefined; - } - - try { - const imageBuffer = await fs.readFile(imagePath); - const mimeType = mimeTypes.lookup(imagePath) || "application/octet-stream"; - - const response = await agent.com.atproto.repo.uploadBlob( - new Uint8Array(imageBuffer), - { - encoding: mimeType, - } - ); - - return { - $type: "blob", - ref: { - $link: response.data.blob.ref.toString(), - }, - mimeType, - size: imageBuffer.byteLength, - }; - } catch (error) { - console.error(`Error uploading image ${imagePath}:`, error); - return undefined; - } + if (!(await fileExists(imagePath))) { + return undefined; + } + + try { + const imageBuffer = await fs.readFile(imagePath); + const mimeType = mimeTypes.lookup(imagePath) || "application/octet-stream"; + + const response = await agent.com.atproto.repo.uploadBlob( + new Uint8Array(imageBuffer), + { + encoding: mimeType, + }, + ); + + return { + $type: "blob", + ref: { + $link: response.data.blob.ref.toString(), + }, + mimeType, + size: imageBuffer.byteLength, + }; + } catch (error) { + console.error(`Error uploading image ${imagePath}:`, error); + return undefined; + } } export async function resolveImagePath( - ogImage: string, - imagesDir: string | undefined, - contentDir: string + ogImage: string, + imagesDir: string | undefined, + contentDir: string, ): Promise { - // Try multiple resolution strategies - const filename = path.basename(ogImage); - - // 1. If imagesDir is specified, look there - if (imagesDir) { - const imagePath = path.join(imagesDir, filename); - if (await fileExists(imagePath)) { - const stat = await fs.stat(imagePath); - if (stat.size > 0) { - return imagePath; - } - } - } - - // 2. Try the ogImage path directly (if it's absolute) - if (path.isAbsolute(ogImage)) { - return ogImage; - } - - // 3. Try relative to content directory - const contentRelative = path.join(contentDir, ogImage); - if (await fileExists(contentRelative)) { - const stat = await fs.stat(contentRelative); - if (stat.size > 0) { - return contentRelative; - } - } - - return null; + // Try multiple resolution strategies + const filename = path.basename(ogImage); + + // 1. If imagesDir is specified, look there + if (imagesDir) { + const imagePath = path.join(imagesDir, filename); + if (await fileExists(imagePath)) { + const stat = await fs.stat(imagePath); + if (stat.size > 0) { + return imagePath; + } + } + } + + // 2. Try the ogImage path directly (if it's absolute) + if (path.isAbsolute(ogImage)) { + return ogImage; + } + + // 3. Try relative to content directory + const contentRelative = path.join(contentDir, ogImage); + if (await fileExists(contentRelative)) { + const stat = await fs.stat(contentRelative); + if (stat.size > 0) { + return contentRelative; + } + } + + return null; } export async function createDocument( - agent: AtpAgent, - post: BlogPost, - config: PublisherConfig, - coverImage?: BlobObject + agent: AtpAgent, + post: BlogPost, + config: PublisherConfig, + coverImage?: BlobObject, ): Promise { - const pathPrefix = config.pathPrefix || "/posts"; - const postPath = `${pathPrefix}/${post.slug}`; - const publishDate = new Date(post.frontmatter.publishDate); - - // Determine textContent: use configured field from frontmatter, or fallback to markdown body - let textContent: string; - if (config.textContentField && post.rawFrontmatter?.[config.textContentField]) { - textContent = String(post.rawFrontmatter[config.textContentField]); - } else { - textContent = stripMarkdownForText(post.content); - } - - const record: Record = { - $type: "site.standard.document", - title: post.frontmatter.title, - site: config.publicationUri, - path: postPath, - textContent: textContent.slice(0, 10000), - publishedAt: publishDate.toISOString(), - canonicalUrl: `${config.siteUrl}${postPath}`, - }; - - if (post.frontmatter.description) { - record.description = post.frontmatter.description; - } - - if (coverImage) { - record.coverImage = coverImage; - } - - if (post.frontmatter.tags && post.frontmatter.tags.length > 0) { - record.tags = post.frontmatter.tags; - } - - const response = await agent.com.atproto.repo.createRecord({ - repo: agent.session!.did, - collection: "site.standard.document", - record, - }); - - return response.data.uri; + const pathPrefix = config.pathPrefix || "/posts"; + const postPath = `${pathPrefix}/${post.slug}`; + const publishDate = new Date(post.frontmatter.publishDate); + + // Determine textContent: use configured field from frontmatter, or fallback to markdown body + let textContent: string; + if ( + config.textContentField && + post.rawFrontmatter?.[config.textContentField] + ) { + textContent = String(post.rawFrontmatter[config.textContentField]); + } else { + textContent = stripMarkdownForText(post.content); + } + + const record: Record = { + $type: "site.standard.document", + title: post.frontmatter.title, + site: config.publicationUri, + path: postPath, + textContent: textContent.slice(0, 10000), + publishedAt: publishDate.toISOString(), + canonicalUrl: `${config.siteUrl}${postPath}`, + }; + + if (post.frontmatter.description) { + record.description = post.frontmatter.description; + } + + if (coverImage) { + record.coverImage = coverImage; + } + + if (post.frontmatter.tags && post.frontmatter.tags.length > 0) { + record.tags = post.frontmatter.tags; + } + + const response = await agent.com.atproto.repo.createRecord({ + repo: agent.session!.did, + collection: "site.standard.document", + record, + }); + + return response.data.uri; } export async function updateDocument( - agent: AtpAgent, - post: BlogPost, - atUri: string, - config: PublisherConfig, - coverImage?: BlobObject + agent: AtpAgent, + post: BlogPost, + atUri: string, + config: PublisherConfig, + coverImage?: BlobObject, ): Promise { - // Parse the atUri to get the collection and rkey - // Format: at://did:plc:xxx/collection/rkey - const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); - if (!uriMatch) { - throw new Error(`Invalid atUri format: ${atUri}`); - } - - const [, , collection, rkey] = uriMatch; - - const pathPrefix = config.pathPrefix || "/posts"; - const postPath = `${pathPrefix}/${post.slug}`; - const publishDate = new Date(post.frontmatter.publishDate); - - // Determine textContent: use configured field from frontmatter, or fallback to markdown body - let textContent: string; - if (config.textContentField && post.rawFrontmatter?.[config.textContentField]) { - textContent = String(post.rawFrontmatter[config.textContentField]); - } else { - textContent = stripMarkdownForText(post.content); - } - - const record: Record = { - $type: "site.standard.document", - title: post.frontmatter.title, - site: config.publicationUri, - path: postPath, - textContent: textContent.slice(0, 10000), - publishedAt: publishDate.toISOString(), - canonicalUrl: `${config.siteUrl}${postPath}`, - }; - - if (post.frontmatter.description) { - record.description = post.frontmatter.description; - } - - if (coverImage) { - record.coverImage = coverImage; - } - - if (post.frontmatter.tags && post.frontmatter.tags.length > 0) { - record.tags = post.frontmatter.tags; - } - - await agent.com.atproto.repo.putRecord({ - repo: agent.session!.did, - collection: collection!, - rkey: rkey!, - record, - }); + // Parse the atUri to get the collection and rkey + // Format: at://did:plc:xxx/collection/rkey + const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); + if (!uriMatch) { + throw new Error(`Invalid atUri format: ${atUri}`); + } + + const [, , collection, rkey] = uriMatch; + + const pathPrefix = config.pathPrefix || "/posts"; + const postPath = `${pathPrefix}/${post.slug}`; + const publishDate = new Date(post.frontmatter.publishDate); + + // Determine textContent: use configured field from frontmatter, or fallback to markdown body + let textContent: string; + if ( + config.textContentField && + post.rawFrontmatter?.[config.textContentField] + ) { + textContent = String(post.rawFrontmatter[config.textContentField]); + } else { + textContent = stripMarkdownForText(post.content); + } + + const record: Record = { + $type: "site.standard.document", + title: post.frontmatter.title, + site: config.publicationUri, + path: postPath, + textContent: textContent.slice(0, 10000), + publishedAt: publishDate.toISOString(), + canonicalUrl: `${config.siteUrl}${postPath}`, + }; + + if (post.frontmatter.description) { + record.description = post.frontmatter.description; + } + + if (coverImage) { + record.coverImage = coverImage; + } + + if (post.frontmatter.tags && post.frontmatter.tags.length > 0) { + record.tags = post.frontmatter.tags; + } + + await agent.com.atproto.repo.putRecord({ + repo: agent.session!.did, + collection: collection!, + rkey: rkey!, + record, + }); } -export function parseAtUri(atUri: string): { did: string; collection: string; rkey: string } | null { - const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); - if (!match) return null; - return { - did: match[1]!, - collection: match[2]!, - rkey: match[3]!, - }; +export function parseAtUri( + atUri: string, +): { did: string; collection: string; rkey: string } | null { + const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); + if (!match) return null; + return { + did: match[1]!, + collection: match[2]!, + rkey: match[3]!, + }; } export interface DocumentRecord { - $type: "site.standard.document"; - title: string; - site: string; - path: string; - textContent: string; - publishedAt: string; - canonicalUrl?: string; - description?: string; - coverImage?: BlobObject; - tags?: string[]; - location?: string; + $type: "site.standard.document"; + title: string; + site: string; + path: string; + textContent: string; + publishedAt: string; + canonicalUrl?: string; + description?: string; + coverImage?: BlobObject; + tags?: string[]; + location?: string; } export interface ListDocumentsResult { - uri: string; - cid: string; - value: DocumentRecord; + uri: string; + cid: string; + value: DocumentRecord; } export async function listDocuments( - agent: AtpAgent, - publicationUri?: string + agent: AtpAgent, + publicationUri?: string, ): Promise { - const documents: ListDocumentsResult[] = []; - let cursor: string | undefined; - - do { - const response = await agent.com.atproto.repo.listRecords({ - repo: agent.session!.did, - collection: "site.standard.document", - limit: 100, - cursor, - }); - - for (const record of response.data.records) { - const value = record.value as unknown as DocumentRecord; - - // If publicationUri is specified, only include documents from that publication - if (publicationUri && value.site !== publicationUri) { - continue; - } - - documents.push({ - uri: record.uri, - cid: record.cid, - value, - }); - } - - cursor = response.data.cursor; - } while (cursor); - - return documents; + const documents: ListDocumentsResult[] = []; + let cursor: string | undefined; + + do { + const response = await agent.com.atproto.repo.listRecords({ + repo: agent.session!.did, + collection: "site.standard.document", + limit: 100, + cursor, + }); + + for (const record of response.data.records) { + const value = record.value as unknown as DocumentRecord; + + // If publicationUri is specified, only include documents from that publication + if (publicationUri && value.site !== publicationUri) { + continue; + } + + documents.push({ + uri: record.uri, + cid: record.cid, + value, + }); + } + + cursor = response.data.cursor; + } while (cursor); + + return documents; } export async function createPublication( - agent: AtpAgent, - options: CreatePublicationOptions + agent: AtpAgent, + options: CreatePublicationOptions, ): Promise { - let icon: BlobObject | undefined; - - if (options.iconPath) { - icon = await uploadImage(agent, options.iconPath); - } - - const record: Record = { - $type: "site.standard.publication", - url: options.url, - name: options.name, - createdAt: new Date().toISOString(), - }; - - if (options.description) { - record.description = options.description; - } - - if (icon) { - record.icon = icon; - } - - if (options.showInDiscover !== undefined) { - record.preferences = { - showInDiscover: options.showInDiscover, - }; - } - - const response = await agent.com.atproto.repo.createRecord({ - repo: agent.session!.did, - collection: "site.standard.publication", - record, - }); - - return response.data.uri; + let icon: BlobObject | undefined; + + if (options.iconPath) { + icon = await uploadImage(agent, options.iconPath); + } + + const record: Record = { + $type: "site.standard.publication", + url: options.url, + name: options.name, + createdAt: new Date().toISOString(), + }; + + if (options.description) { + record.description = options.description; + } + + if (icon) { + record.icon = icon; + } + + if (options.showInDiscover !== undefined) { + record.preferences = { + showInDiscover: options.showInDiscover, + }; + } + + const response = await agent.com.atproto.repo.createRecord({ + repo: agent.session!.did, + collection: "site.standard.publication", + record, + }); + + return response.data.uri; } // --- Bluesky Post Creation --- export interface CreateBlueskyPostOptions { - title: string; - description?: string; - canonicalUrl: string; - coverImage?: BlobObject; - publishedAt: string; // Used as createdAt for the post + title: string; + description?: string; + canonicalUrl: string; + coverImage?: BlobObject; + publishedAt: string; // Used as createdAt for the post } /** * Count graphemes in a string (for Bluesky's 300 grapheme limit) */ function countGraphemes(str: string): number { - // Use Intl.Segmenter if available, otherwise fallback to spread operator - if (typeof Intl !== "undefined" && Intl.Segmenter) { - const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); - return [...segmenter.segment(str)].length; - } - return [...str].length; + // Use Intl.Segmenter if available, otherwise fallback to spread operator + if (typeof Intl !== "undefined" && Intl.Segmenter) { + const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); + return [...segmenter.segment(str)].length; + } + return [...str].length; } /** * Truncate a string to a maximum number of graphemes */ function truncateToGraphemes(str: string, maxGraphemes: number): string { - if (typeof Intl !== "undefined" && Intl.Segmenter) { - const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); - const segments = [...segmenter.segment(str)]; - if (segments.length <= maxGraphemes) return str; - return segments.slice(0, maxGraphemes - 3).map(s => s.segment).join("") + "..."; - } - // Fallback - const chars = [...str]; - if (chars.length <= maxGraphemes) return str; - return chars.slice(0, maxGraphemes - 3).join("") + "..."; + if (typeof Intl !== "undefined" && Intl.Segmenter) { + const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); + const segments = [...segmenter.segment(str)]; + if (segments.length <= maxGraphemes) return str; + return ( + segments + .slice(0, maxGraphemes - 3) + .map((s) => s.segment) + .join("") + "..." + ); + } + // Fallback + const chars = [...str]; + if (chars.length <= maxGraphemes) return str; + return chars.slice(0, maxGraphemes - 3).join("") + "..."; } /** * Create a Bluesky post with external link embed */ export async function createBlueskyPost( - agent: AtpAgent, - options: CreateBlueskyPostOptions + agent: AtpAgent, + options: CreateBlueskyPostOptions, ): Promise { - const { title, description, canonicalUrl, coverImage, publishedAt } = options; - - // Build post text: title + description + URL - // Max 300 graphemes for Bluesky posts - const MAX_GRAPHEMES = 300; - - let postText: string; - const urlPart = `\n\n${canonicalUrl}`; - const urlGraphemes = countGraphemes(urlPart); - - if (description) { - // Try: title + description + URL - const fullText = `${title}\n\n${description}${urlPart}`; - if (countGraphemes(fullText) <= MAX_GRAPHEMES) { - postText = fullText; - } else { - // Truncate description to fit - const availableForDesc = MAX_GRAPHEMES - countGraphemes(title) - countGraphemes("\n\n") - urlGraphemes - countGraphemes("\n\n"); - if (availableForDesc > 10) { - const truncatedDesc = truncateToGraphemes(description, availableForDesc); - postText = `${title}\n\n${truncatedDesc}${urlPart}`; - } else { - // Just title + URL - postText = `${title}${urlPart}`; - } - } - } else { - // Just title + URL - postText = `${title}${urlPart}`; - } - - // Final truncation if still too long (shouldn't happen but safety check) - if (countGraphemes(postText) > MAX_GRAPHEMES) { - postText = truncateToGraphemes(postText, MAX_GRAPHEMES); - } - - // Calculate byte indices for the URL facet - const encoder = new TextEncoder(); - const urlStartInText = postText.lastIndexOf(canonicalUrl); - const beforeUrl = postText.substring(0, urlStartInText); - const byteStart = encoder.encode(beforeUrl).length; - const byteEnd = byteStart + encoder.encode(canonicalUrl).length; - - // Build facets for the URL link - const facets = [ - { - index: { - byteStart, - byteEnd, - }, - features: [ - { - $type: "app.bsky.richtext.facet#link", - uri: canonicalUrl, - }, - ], - }, - ]; - - // Build external embed - const embed: Record = { - $type: "app.bsky.embed.external", - external: { - uri: canonicalUrl, - title: title.substring(0, 500), // Max 500 chars for title - description: (description || "").substring(0, 1000), // Max 1000 chars for description - }, - }; - - // Add thumbnail if coverImage is available - if (coverImage) { - (embed.external as Record).thumb = coverImage; - } - - // Create the post record - const record: Record = { - $type: "app.bsky.feed.post", - text: postText, - facets, - embed, - createdAt: new Date(publishedAt).toISOString(), - }; - - const response = await agent.com.atproto.repo.createRecord({ - repo: agent.session!.did, - collection: "app.bsky.feed.post", - record, - }); - - return { - uri: response.data.uri, - cid: response.data.cid, - }; + const { title, description, canonicalUrl, coverImage, publishedAt } = options; + + // Build post text: title + description + URL + // Max 300 graphemes for Bluesky posts + const MAX_GRAPHEMES = 300; + + let postText: string; + const urlPart = `\n\n${canonicalUrl}`; + const urlGraphemes = countGraphemes(urlPart); + + if (description) { + // Try: title + description + URL + const fullText = `${title}\n\n${description}${urlPart}`; + if (countGraphemes(fullText) <= MAX_GRAPHEMES) { + postText = fullText; + } else { + // Truncate description to fit + const availableForDesc = + MAX_GRAPHEMES - + countGraphemes(title) - + countGraphemes("\n\n") - + urlGraphemes - + countGraphemes("\n\n"); + if (availableForDesc > 10) { + const truncatedDesc = truncateToGraphemes( + description, + availableForDesc, + ); + postText = `${title}\n\n${truncatedDesc}${urlPart}`; + } else { + // Just title + URL + postText = `${title}${urlPart}`; + } + } + } else { + // Just title + URL + postText = `${title}${urlPart}`; + } + + // Final truncation if still too long (shouldn't happen but safety check) + if (countGraphemes(postText) > MAX_GRAPHEMES) { + postText = truncateToGraphemes(postText, MAX_GRAPHEMES); + } + + // Calculate byte indices for the URL facet + const encoder = new TextEncoder(); + const urlStartInText = postText.lastIndexOf(canonicalUrl); + const beforeUrl = postText.substring(0, urlStartInText); + const byteStart = encoder.encode(beforeUrl).length; + const byteEnd = byteStart + encoder.encode(canonicalUrl).length; + + // Build facets for the URL link + const facets = [ + { + index: { + byteStart, + byteEnd, + }, + features: [ + { + $type: "app.bsky.richtext.facet#link", + uri: canonicalUrl, + }, + ], + }, + ]; + + // Build external embed + const embed: Record = { + $type: "app.bsky.embed.external", + external: { + uri: canonicalUrl, + title: title.substring(0, 500), // Max 500 chars for title + description: (description || "").substring(0, 1000), // Max 1000 chars for description + }, + }; + + // Add thumbnail if coverImage is available + if (coverImage) { + (embed.external as Record).thumb = coverImage; + } + + // Create the post record + const record: Record = { + $type: "app.bsky.feed.post", + text: postText, + facets, + embed, + createdAt: new Date(publishedAt).toISOString(), + }; + + const response = await agent.com.atproto.repo.createRecord({ + repo: agent.session!.did, + collection: "app.bsky.feed.post", + record, + }); + + return { + uri: response.data.uri, + cid: response.data.cid, + }; } /** * Add bskyPostRef to an existing document record */ export async function addBskyPostRefToDocument( - agent: AtpAgent, - documentAtUri: string, - bskyPostRef: StrongRef + agent: AtpAgent, + documentAtUri: string, + bskyPostRef: StrongRef, ): Promise { - const parsed = parseAtUri(documentAtUri); - if (!parsed) { - throw new Error(`Invalid document URI: ${documentAtUri}`); - } - - // Fetch existing record - const existingRecord = await agent.com.atproto.repo.getRecord({ - repo: parsed.did, - collection: parsed.collection, - rkey: parsed.rkey, - }); - - // Add bskyPostRef to the record - const updatedRecord = { - ...(existingRecord.data.value as Record), - bskyPostRef, - }; - - // Update the record - await agent.com.atproto.repo.putRecord({ - repo: parsed.did, - collection: parsed.collection, - rkey: parsed.rkey, - record: updatedRecord, - }); + const parsed = parseAtUri(documentAtUri); + if (!parsed) { + throw new Error(`Invalid document URI: ${documentAtUri}`); + } + + // Fetch existing record + const existingRecord = await agent.com.atproto.repo.getRecord({ + repo: parsed.did, + collection: parsed.collection, + rkey: parsed.rkey, + }); + + // Add bskyPostRef to the record + const updatedRecord = { + ...(existingRecord.data.value as Record), + bskyPostRef, + }; + + // Update the record + await agent.com.atproto.repo.putRecord({ + repo: parsed.did, + collection: parsed.collection, + rkey: parsed.rkey, + record: updatedRecord, + }); } diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index 0d57aa8..175fb17 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -1,6 +1,11 @@ -import * as fs from "fs/promises"; -import * as path from "path"; -import type { PublisherConfig, PublisherState, FrontmatterMapping, BlueskyConfig } from "./types"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import type { + PublisherConfig, + PublisherState, + FrontmatterMapping, + BlueskyConfig, +} from "./types"; const CONFIG_FILENAME = "sequoia.json"; const STATE_FILENAME = ".sequoia-state.json"; @@ -131,6 +136,7 @@ export function generateConfigTemplate(options: { if (options.textContentField) { config.textContentField = options.textContentField; + } if (options.bluesky) { config.bluesky = options.bluesky; } diff --git a/packages/cli/src/lib/credentials.ts b/packages/cli/src/lib/credentials.ts index 5e2ced5..a68b69b 100644 --- a/packages/cli/src/lib/credentials.ts +++ b/packages/cli/src/lib/credentials.ts @@ -1,6 +1,6 @@ -import * as fs from "fs/promises"; -import * as path from "path"; -import * as os from "os"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; import type { Credentials } from "./types"; const CONFIG_DIR = path.join(os.homedir(), ".config", "sequoia"); @@ -10,45 +10,45 @@ const CREDENTIALS_FILE = path.join(CONFIG_DIR, "credentials.json"); type CredentialsStore = Record; async function fileExists(filePath: string): Promise { - try { - await fs.access(filePath); - return true; - } catch { - return false; - } + try { + await fs.access(filePath); + return true; + } catch { + return false; + } } /** * Load all stored credentials */ async function loadCredentialsStore(): Promise { - if (!(await fileExists(CREDENTIALS_FILE))) { - return {}; - } - - try { - const content = await fs.readFile(CREDENTIALS_FILE, "utf-8"); - const parsed = JSON.parse(content); - - // Handle legacy single-credential format (migrate on read) - if (parsed.identifier && parsed.password) { - const legacy = parsed as Credentials; - return { [legacy.identifier]: legacy }; - } - - return parsed as CredentialsStore; - } catch { - return {}; - } + if (!(await fileExists(CREDENTIALS_FILE))) { + return {}; + } + + try { + const content = await fs.readFile(CREDENTIALS_FILE, "utf-8"); + const parsed = JSON.parse(content); + + // Handle legacy single-credential format (migrate on read) + if (parsed.identifier && parsed.password) { + const legacy = parsed as Credentials; + return { [legacy.identifier]: legacy }; + } + + return parsed as CredentialsStore; + } catch { + return {}; + } } /** * Save the entire credentials store */ async function saveCredentialsStore(store: CredentialsStore): Promise { - await fs.mkdir(CONFIG_DIR, { recursive: true }); - await fs.writeFile(CREDENTIALS_FILE, JSON.stringify(store, null, 2)); - await fs.chmod(CREDENTIALS_FILE, 0o600); + await fs.mkdir(CONFIG_DIR, { recursive: true }); + await fs.writeFile(CREDENTIALS_FILE, JSON.stringify(store, null, 2)); + await fs.chmod(CREDENTIALS_FILE, 0o600); } /** @@ -62,107 +62,107 @@ async function saveCredentialsStore(store: CredentialsStore): Promise { * 5. Return null (caller should prompt user) */ export async function loadCredentials( - projectIdentity?: string + projectIdentity?: string, ): Promise { - // 1. Check environment variables first (full override) - const envIdentifier = process.env.ATP_IDENTIFIER; - const envPassword = process.env.ATP_APP_PASSWORD; - const envPdsUrl = process.env.PDS_URL; - - if (envIdentifier && envPassword) { - return { - identifier: envIdentifier, - password: envPassword, - pdsUrl: envPdsUrl || "https://bsky.social", - }; - } - - const store = await loadCredentialsStore(); - const identifiers = Object.keys(store); - - if (identifiers.length === 0) { - return null; - } - - // 2. SEQUOIA_PROFILE env var - const profileEnv = process.env.SEQUOIA_PROFILE; - if (profileEnv && store[profileEnv]) { - return store[profileEnv]; - } - - // 3. Project-specific identity (from sequoia.json) - if (projectIdentity && store[projectIdentity]) { - return store[projectIdentity]; - } - - // 4. If only one identity, use it - if (identifiers.length === 1 && identifiers[0]) { - return store[identifiers[0]] ?? null; - } - - // Multiple identities exist but none selected - return null; + // 1. Check environment variables first (full override) + const envIdentifier = process.env.ATP_IDENTIFIER; + const envPassword = process.env.ATP_APP_PASSWORD; + const envPdsUrl = process.env.PDS_URL; + + if (envIdentifier && envPassword) { + return { + identifier: envIdentifier, + password: envPassword, + pdsUrl: envPdsUrl || "https://bsky.social", + }; + } + + const store = await loadCredentialsStore(); + const identifiers = Object.keys(store); + + if (identifiers.length === 0) { + return null; + } + + // 2. SEQUOIA_PROFILE env var + const profileEnv = process.env.SEQUOIA_PROFILE; + if (profileEnv && store[profileEnv]) { + return store[profileEnv]; + } + + // 3. Project-specific identity (from sequoia.json) + if (projectIdentity && store[projectIdentity]) { + return store[projectIdentity]; + } + + // 4. If only one identity, use it + if (identifiers.length === 1 && identifiers[0]) { + return store[identifiers[0]] ?? null; + } + + // Multiple identities exist but none selected + return null; } /** * Get a specific identity by identifier */ export async function getCredentials( - identifier: string + identifier: string, ): Promise { - const store = await loadCredentialsStore(); - return store[identifier] || null; + const store = await loadCredentialsStore(); + return store[identifier] || null; } /** * List all stored identities */ export async function listCredentials(): Promise { - const store = await loadCredentialsStore(); - return Object.keys(store); + const store = await loadCredentialsStore(); + return Object.keys(store); } /** * Save credentials for an identity (adds or updates) */ export async function saveCredentials(credentials: Credentials): Promise { - const store = await loadCredentialsStore(); - store[credentials.identifier] = credentials; - await saveCredentialsStore(store); + const store = await loadCredentialsStore(); + store[credentials.identifier] = credentials; + await saveCredentialsStore(store); } /** * Delete credentials for a specific identity */ export async function deleteCredentials(identifier?: string): Promise { - const store = await loadCredentialsStore(); - const identifiers = Object.keys(store); - - if (identifiers.length === 0) { - return false; - } - - // If identifier specified, delete just that one - if (identifier) { - if (!store[identifier]) { - return false; - } - delete store[identifier]; - await saveCredentialsStore(store); - return true; - } - - // If only one identity, delete it (backwards compat behavior) - if (identifiers.length === 1 && identifiers[0]) { - delete store[identifiers[0]]; - await saveCredentialsStore(store); - return true; - } - - // Multiple identities but none specified - return false; + const store = await loadCredentialsStore(); + const identifiers = Object.keys(store); + + if (identifiers.length === 0) { + return false; + } + + // If identifier specified, delete just that one + if (identifier) { + if (!store[identifier]) { + return false; + } + delete store[identifier]; + await saveCredentialsStore(store); + return true; + } + + // If only one identity, delete it (backwards compat behavior) + if (identifiers.length === 1 && identifiers[0]) { + delete store[identifiers[0]]; + await saveCredentialsStore(store); + return true; + } + + // Multiple identities but none specified + return false; } export function getCredentialsPath(): string { - return CREDENTIALS_FILE; + return CREDENTIALS_FILE; } diff --git a/packages/cli/src/lib/markdown.ts b/packages/cli/src/lib/markdown.ts index 5631ec7..09ede7d 100644 --- a/packages/cli/src/lib/markdown.ts +++ b/packages/cli/src/lib/markdown.ts @@ -1,359 +1,391 @@ -import * as fs from "fs/promises"; -import * as path from "path"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; import { glob } from "glob"; import { minimatch } from "minimatch"; -import type { PostFrontmatter, BlogPost, FrontmatterMapping } from "./types"; - -export function parseFrontmatter(content: string, mapping?: FrontmatterMapping): { - frontmatter: PostFrontmatter; - body: string; - rawFrontmatter: Record; +import type { BlogPost, FrontmatterMapping, PostFrontmatter } from "./types"; + +export function parseFrontmatter( + content: string, + mapping?: FrontmatterMapping, +): { + frontmatter: PostFrontmatter; + body: string; + rawFrontmatter: Record; } { - // Support multiple frontmatter delimiters: - // --- (YAML) - Jekyll, Astro, most SSGs - // +++ (TOML) - Hugo - // *** - Alternative format - const frontmatterRegex = /^(---|\+\+\+|\*\*\*)\n([\s\S]*?)\n\1\n([\s\S]*)$/; - const match = content.match(frontmatterRegex); - - if (!match) { - throw new Error("Could not parse frontmatter"); - } - - const delimiter = match[1]; - const frontmatterStr = match[2] ?? ""; - const body = match[3] ?? ""; - - // Determine format based on delimiter: - // +++ uses TOML (key = value) - // --- and *** use YAML (key: value) - const isToml = delimiter === "+++"; - const separator = isToml ? "=" : ":"; - - // Parse frontmatter manually - const raw: Record = {}; - const lines = frontmatterStr.split("\n"); - - let i = 0; - while (i < lines.length) { - const line = lines[i]; - if (line === undefined) { - i++; - continue; - } - const sepIndex = line.indexOf(separator); - if (sepIndex === -1) { - i++; - continue; - } - - const key = line.slice(0, sepIndex).trim(); - let value = line.slice(sepIndex + 1).trim(); - - // Handle quoted strings - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - - // Handle inline arrays (simple case for tags) - if (value.startsWith("[") && value.endsWith("]")) { - const arrayContent = value.slice(1, -1); - raw[key] = arrayContent - .split(",") - .map((item) => item.trim().replace(/^["']|["']$/g, "")); - } else if (value === "" && !isToml) { - // Check for YAML-style multiline array (key with no value followed by - items) - const arrayItems: string[] = []; - let j = i + 1; - while (j < lines.length) { - const nextLine = lines[j]; - if (nextLine === undefined) { - j++; - continue; - } - // Check if line is a list item (starts with whitespace and -) - const listMatch = nextLine.match(/^\s+-\s*(.*)$/); - if (listMatch && listMatch[1] !== undefined) { - let itemValue = listMatch[1].trim(); - // Remove quotes if present - if ( - (itemValue.startsWith('"') && itemValue.endsWith('"')) || - (itemValue.startsWith("'") && itemValue.endsWith("'")) - ) { - itemValue = itemValue.slice(1, -1); - } - arrayItems.push(itemValue); - j++; - } else if (nextLine.trim() === "") { - // Skip empty lines within the array - j++; - } else { - // Hit a new key or non-list content - break; - } - } - if (arrayItems.length > 0) { - raw[key] = arrayItems; - i = j; - continue; - } else { - raw[key] = value; - } - } else if (value === "true") { - raw[key] = true; - } else if (value === "false") { - raw[key] = false; - } else { - raw[key] = value; - } - i++; - } - - // Apply field mappings to normalize to standard PostFrontmatter fields - const frontmatter: Record = {}; - - // Title mapping - const titleField = mapping?.title || "title"; - frontmatter.title = raw[titleField] || raw.title; - - // Description mapping - const descField = mapping?.description || "description"; - frontmatter.description = raw[descField] || raw.description; - - // Publish date mapping - check custom field first, then fallbacks - const dateField = mapping?.publishDate; - if (dateField && raw[dateField]) { - frontmatter.publishDate = raw[dateField]; - } else if (raw.publishDate) { - frontmatter.publishDate = raw.publishDate; - } else { - // Fallback to common date field names - const dateFields = ["pubDate", "date", "createdAt", "created_at"]; - for (const field of dateFields) { - if (raw[field]) { - frontmatter.publishDate = raw[field]; - break; - } - } - } - - // Cover image mapping - const coverField = mapping?.coverImage || "ogImage"; - frontmatter.ogImage = raw[coverField] || raw.ogImage; - - // Tags mapping - const tagsField = mapping?.tags || "tags"; - frontmatter.tags = raw[tagsField] || raw.tags; - - // Draft mapping - const draftField = mapping?.draft || "draft"; - const draftValue = raw[draftField] ?? raw.draft; - if (draftValue !== undefined) { - frontmatter.draft = draftValue === true || draftValue === "true"; - } - - // Always preserve atUri (internal field) - frontmatter.atUri = raw.atUri; - - return { frontmatter: frontmatter as unknown as PostFrontmatter, body, rawFrontmatter: raw }; + // Support multiple frontmatter delimiters: + // --- (YAML) - Jekyll, Astro, most SSGs + // +++ (TOML) - Hugo + // *** - Alternative format + const frontmatterRegex = /^(---|\+\+\+|\*\*\*)\n([\s\S]*?)\n\1\n([\s\S]*)$/; + const match = content.match(frontmatterRegex); + + if (!match) { + throw new Error("Could not parse frontmatter"); + } + + const delimiter = match[1]; + const frontmatterStr = match[2] ?? ""; + const body = match[3] ?? ""; + + // Determine format based on delimiter: + // +++ uses TOML (key = value) + // --- and *** use YAML (key: value) + const isToml = delimiter === "+++"; + const separator = isToml ? "=" : ":"; + + // Parse frontmatter manually + const raw: Record = {}; + const lines = frontmatterStr.split("\n"); + + let i = 0; + while (i < lines.length) { + const line = lines[i]; + if (line === undefined) { + i++; + continue; + } + const sepIndex = line.indexOf(separator); + if (sepIndex === -1) { + i++; + continue; + } + + const key = line.slice(0, sepIndex).trim(); + let value = line.slice(sepIndex + 1).trim(); + + // Handle quoted strings + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + // Handle inline arrays (simple case for tags) + if (value.startsWith("[") && value.endsWith("]")) { + const arrayContent = value.slice(1, -1); + raw[key] = arrayContent + .split(",") + .map((item) => item.trim().replace(/^["']|["']$/g, "")); + } else if (value === "" && !isToml) { + // Check for YAML-style multiline array (key with no value followed by - items) + const arrayItems: string[] = []; + let j = i + 1; + while (j < lines.length) { + const nextLine = lines[j]; + if (nextLine === undefined) { + j++; + continue; + } + // Check if line is a list item (starts with whitespace and -) + const listMatch = nextLine.match(/^\s+-\s*(.*)$/); + if (listMatch && listMatch[1] !== undefined) { + let itemValue = listMatch[1].trim(); + // Remove quotes if present + if ( + (itemValue.startsWith('"') && itemValue.endsWith('"')) || + (itemValue.startsWith("'") && itemValue.endsWith("'")) + ) { + itemValue = itemValue.slice(1, -1); + } + arrayItems.push(itemValue); + j++; + } else if (nextLine.trim() === "") { + // Skip empty lines within the array + j++; + } else { + // Hit a new key or non-list content + break; + } + } + if (arrayItems.length > 0) { + raw[key] = arrayItems; + i = j; + continue; + } else { + raw[key] = value; + } + } else if (value === "true") { + raw[key] = true; + } else if (value === "false") { + raw[key] = false; + } else { + raw[key] = value; + } + i++; + } + + // Apply field mappings to normalize to standard PostFrontmatter fields + const frontmatter: Record = {}; + + // Title mapping + const titleField = mapping?.title || "title"; + frontmatter.title = raw[titleField] || raw.title; + + // Description mapping + const descField = mapping?.description || "description"; + frontmatter.description = raw[descField] || raw.description; + + // Publish date mapping - check custom field first, then fallbacks + const dateField = mapping?.publishDate; + if (dateField && raw[dateField]) { + frontmatter.publishDate = raw[dateField]; + } else if (raw.publishDate) { + frontmatter.publishDate = raw.publishDate; + } else { + // Fallback to common date field names + const dateFields = ["pubDate", "date", "createdAt", "created_at"]; + for (const field of dateFields) { + if (raw[field]) { + frontmatter.publishDate = raw[field]; + break; + } + } + } + + // Cover image mapping + const coverField = mapping?.coverImage || "ogImage"; + frontmatter.ogImage = raw[coverField] || raw.ogImage; + + // Tags mapping + const tagsField = mapping?.tags || "tags"; + frontmatter.tags = raw[tagsField] || raw.tags; + + // Draft mapping + const draftField = mapping?.draft || "draft"; + const draftValue = raw[draftField] ?? raw.draft; + if (draftValue !== undefined) { + frontmatter.draft = draftValue === true || draftValue === "true"; + } + + // Always preserve atUri (internal field) + frontmatter.atUri = raw.atUri; + + return { + frontmatter: frontmatter as unknown as PostFrontmatter, + body, + rawFrontmatter: raw, + }; } export function getSlugFromFilename(filename: string): string { - return filename - .replace(/\.mdx?$/, "") - .toLowerCase() - .replace(/\s+/g, "-"); + return filename + .replace(/\.mdx?$/, "") + .toLowerCase() + .replace(/\s+/g, "-"); } export interface SlugOptions { - slugSource?: "filename" | "path" | "frontmatter"; - slugField?: string; - removeIndexFromSlug?: boolean; + slugSource?: "filename" | "path" | "frontmatter"; + slugField?: string; + removeIndexFromSlug?: boolean; } export function getSlugFromOptions( - relativePath: string, - rawFrontmatter: Record, - options: SlugOptions = {} + relativePath: string, + rawFrontmatter: Record, + options: SlugOptions = {}, ): string { - const { slugSource = "filename", slugField = "slug", removeIndexFromSlug = false } = options; - - let slug: string; - - switch (slugSource) { - case "path": - // Use full relative path without extension - slug = relativePath - .replace(/\.mdx?$/, "") - .toLowerCase() - .replace(/\s+/g, "-"); - break; - - case "frontmatter": - // Use frontmatter field (slug or url) - const frontmatterValue = rawFrontmatter[slugField] || rawFrontmatter.slug || rawFrontmatter.url; - if (frontmatterValue && typeof frontmatterValue === "string") { - // Remove leading slash if present - slug = frontmatterValue.replace(/^\//, "").toLowerCase().replace(/\s+/g, "-"); - } else { - // Fallback to filename if frontmatter field not found - slug = getSlugFromFilename(path.basename(relativePath)); - } - break; - - case "filename": - default: - slug = getSlugFromFilename(path.basename(relativePath)); - break; - } - - // Remove /index or /_index suffix if configured - if (removeIndexFromSlug) { - slug = slug.replace(/\/_?index$/, ""); - } - - return slug; + const { + slugSource = "filename", + slugField = "slug", + removeIndexFromSlug = false, + } = options; + + let slug: string; + + switch (slugSource) { + case "path": + // Use full relative path without extension + slug = relativePath + .replace(/\.mdx?$/, "") + .toLowerCase() + .replace(/\s+/g, "-"); + break; + + case "frontmatter": { + // Use frontmatter field (slug or url) + const frontmatterValue = + rawFrontmatter[slugField] || rawFrontmatter.slug || rawFrontmatter.url; + if (frontmatterValue && typeof frontmatterValue === "string") { + // Remove leading slash if present + slug = frontmatterValue + .replace(/^\//, "") + .toLowerCase() + .replace(/\s+/g, "-"); + } else { + // Fallback to filename if frontmatter field not found + slug = getSlugFromFilename(path.basename(relativePath)); + } + break; + } + + case "filename": + default: + slug = getSlugFromFilename(path.basename(relativePath)); + break; + } + + // Remove /index or /_index suffix if configured + if (removeIndexFromSlug) { + slug = slug.replace(/\/_?index$/, ""); + } + + return slug; } export async function getContentHash(content: string): Promise { - const encoder = new TextEncoder(); - const data = encoder.encode(content); - const hashBuffer = await crypto.subtle.digest("SHA-256", data); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); + const encoder = new TextEncoder(); + const data = encoder.encode(content); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); } function shouldIgnore(relativePath: string, ignorePatterns: string[]): boolean { - for (const pattern of ignorePatterns) { - if (minimatch(relativePath, pattern)) { - return true; - } - } - return false; + for (const pattern of ignorePatterns) { + if (minimatch(relativePath, pattern)) { + return true; + } + } + return false; } export interface ScanOptions { - frontmatterMapping?: FrontmatterMapping; - ignorePatterns?: string[]; - slugSource?: "filename" | "path" | "frontmatter"; - slugField?: string; - removeIndexFromSlug?: boolean; + frontmatterMapping?: FrontmatterMapping; + ignorePatterns?: string[]; + slugSource?: "filename" | "path" | "frontmatter"; + slugField?: string; + removeIndexFromSlug?: boolean; } export async function scanContentDirectory( - contentDir: string, - frontmatterMappingOrOptions?: FrontmatterMapping | ScanOptions, - ignorePatterns: string[] = [] + contentDir: string, + frontmatterMappingOrOptions?: FrontmatterMapping | ScanOptions, + ignorePatterns: string[] = [], ): Promise { - // Handle both old signature (frontmatterMapping, ignorePatterns) and new signature (options) - let options: ScanOptions; - if (frontmatterMappingOrOptions && ('slugSource' in frontmatterMappingOrOptions || 'frontmatterMapping' in frontmatterMappingOrOptions || 'ignorePatterns' in frontmatterMappingOrOptions)) { - options = frontmatterMappingOrOptions as ScanOptions; - } else { - // Old signature: (contentDir, frontmatterMapping?, ignorePatterns?) - options = { - frontmatterMapping: frontmatterMappingOrOptions as FrontmatterMapping | undefined, - ignorePatterns, - }; - } - - const { - frontmatterMapping, - ignorePatterns: ignore = [], - slugSource, - slugField, - removeIndexFromSlug, - } = options; - - const patterns = ["**/*.md", "**/*.mdx"]; - const posts: BlogPost[] = []; - - for (const pattern of patterns) { - const files = await glob(pattern, { - cwd: contentDir, - absolute: false, - }); - - for (const relativePath of files) { - // Skip files matching ignore patterns - if (shouldIgnore(relativePath, ignore)) { - continue; - } - - const filePath = path.join(contentDir, relativePath); - const rawContent = await fs.readFile(filePath, "utf-8"); - - try { - const { frontmatter, body, rawFrontmatter } = parseFrontmatter(rawContent, frontmatterMapping); - const slug = getSlugFromOptions(relativePath, rawFrontmatter, { - slugSource, - slugField, - removeIndexFromSlug, - }); - - posts.push({ - filePath, - slug, - frontmatter, - content: body, - rawContent, - rawFrontmatter, - }); - } catch (error) { - console.error(`Error parsing ${relativePath}:`, error); - } - } - } - - // Sort by publish date (newest first) - posts.sort((a, b) => { - const dateA = new Date(a.frontmatter.publishDate); - const dateB = new Date(b.frontmatter.publishDate); - return dateB.getTime() - dateA.getTime(); - }); - - return posts; + // Handle both old signature (frontmatterMapping, ignorePatterns) and new signature (options) + let options: ScanOptions; + if ( + frontmatterMappingOrOptions && + ("slugSource" in frontmatterMappingOrOptions || + "frontmatterMapping" in frontmatterMappingOrOptions || + "ignorePatterns" in frontmatterMappingOrOptions) + ) { + options = frontmatterMappingOrOptions as ScanOptions; + } else { + // Old signature: (contentDir, frontmatterMapping?, ignorePatterns?) + options = { + frontmatterMapping: frontmatterMappingOrOptions as + | FrontmatterMapping + | undefined, + ignorePatterns, + }; + } + + const { + frontmatterMapping, + ignorePatterns: ignore = [], + slugSource, + slugField, + removeIndexFromSlug, + } = options; + + const patterns = ["**/*.md", "**/*.mdx"]; + const posts: BlogPost[] = []; + + for (const pattern of patterns) { + const files = await glob(pattern, { + cwd: contentDir, + absolute: false, + }); + + for (const relativePath of files) { + // Skip files matching ignore patterns + if (shouldIgnore(relativePath, ignore)) { + continue; + } + + const filePath = path.join(contentDir, relativePath); + const rawContent = await fs.readFile(filePath, "utf-8"); + + try { + const { frontmatter, body, rawFrontmatter } = parseFrontmatter( + rawContent, + frontmatterMapping, + ); + const slug = getSlugFromOptions(relativePath, rawFrontmatter, { + slugSource, + slugField, + removeIndexFromSlug, + }); + + posts.push({ + filePath, + slug, + frontmatter, + content: body, + rawContent, + rawFrontmatter, + }); + } catch (error) { + console.error(`Error parsing ${relativePath}:`, error); + } + } + } + + // Sort by publish date (newest first) + posts.sort((a, b) => { + const dateA = new Date(a.frontmatter.publishDate); + const dateB = new Date(b.frontmatter.publishDate); + return dateB.getTime() - dateA.getTime(); + }); + + return posts; } -export function updateFrontmatterWithAtUri(rawContent: string, atUri: string): string { - // Detect which delimiter is used (---, +++, or ***) - const delimiterMatch = rawContent.match(/^(---|\+\+\+|\*\*\*)/); - const delimiter = delimiterMatch?.[1] ?? "---"; - const isToml = delimiter === "+++"; - - // Format the atUri entry based on frontmatter type - const atUriEntry = isToml ? `atUri = "${atUri}"` : `atUri: "${atUri}"`; - - // Check if atUri already exists in frontmatter (handle both formats) - if (rawContent.includes("atUri:") || rawContent.includes("atUri =")) { - // Replace existing atUri (match both YAML and TOML formats) - return rawContent.replace(/atUri\s*[=:]\s*["']?[^"'\n]+["']?\n?/, `${atUriEntry}\n`); - } - - // Insert atUri before the closing delimiter - const frontmatterEndIndex = rawContent.indexOf(delimiter, 4); - if (frontmatterEndIndex === -1) { - throw new Error("Could not find frontmatter end"); - } - - const beforeEnd = rawContent.slice(0, frontmatterEndIndex); - const afterEnd = rawContent.slice(frontmatterEndIndex); - - return `${beforeEnd}${atUriEntry}\n${afterEnd}`; +export function updateFrontmatterWithAtUri( + rawContent: string, + atUri: string, +): string { + // Detect which delimiter is used (---, +++, or ***) + const delimiterMatch = rawContent.match(/^(---|\+\+\+|\*\*\*)/); + const delimiter = delimiterMatch?.[1] ?? "---"; + const isToml = delimiter === "+++"; + + // Format the atUri entry based on frontmatter type + const atUriEntry = isToml ? `atUri = "${atUri}"` : `atUri: "${atUri}"`; + + // Check if atUri already exists in frontmatter (handle both formats) + if (rawContent.includes("atUri:") || rawContent.includes("atUri =")) { + // Replace existing atUri (match both YAML and TOML formats) + return rawContent.replace( + /atUri\s*[=:]\s*["']?[^"'\n]+["']?\n?/, + `${atUriEntry}\n`, + ); + } + + // Insert atUri before the closing delimiter + const frontmatterEndIndex = rawContent.indexOf(delimiter, 4); + if (frontmatterEndIndex === -1) { + throw new Error("Could not find frontmatter end"); + } + + const beforeEnd = rawContent.slice(0, frontmatterEndIndex); + const afterEnd = rawContent.slice(frontmatterEndIndex); + + return `${beforeEnd}${atUriEntry}\n${afterEnd}`; } export function stripMarkdownForText(markdown: string): string { - return markdown - .replace(/#{1,6}\s/g, "") // Remove headers - .replace(/\*\*([^*]+)\*\*/g, "$1") // Remove bold - .replace(/\*([^*]+)\*/g, "$1") // Remove italic - .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Remove links, keep text - .replace(/`{3}[\s\S]*?`{3}/g, "") // Remove code blocks - .replace(/`([^`]+)`/g, "$1") // Remove inline code formatting - .replace(/!\[.*?\]\(.*?\)/g, "") // Remove images - .replace(/\n{3,}/g, "\n\n") // Normalize multiple newlines - .trim(); + return markdown + .replace(/#{1,6}\s/g, "") // Remove headers + .replace(/\*\*([^*]+)\*\*/g, "$1") // Remove bold + .replace(/\*([^*]+)\*/g, "$1") // Remove italic + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Remove links, keep text + .replace(/`{3}[\s\S]*?`{3}/g, "") // Remove code blocks + .replace(/`([^`]+)`/g, "$1") // Remove inline code formatting + .replace(/!\[.*?\]\(.*?\)/g, "") // Remove images + .replace(/\n{3,}/g, "\n\n") // Normalize multiple newlines + .trim(); } diff --git a/packages/cli/src/lib/prompts.ts b/packages/cli/src/lib/prompts.ts index 3e60c51..da7aa7a 100644 --- a/packages/cli/src/lib/prompts.ts +++ b/packages/cli/src/lib/prompts.ts @@ -1,9 +1,9 @@ -import { isCancel, cancel } from "@clack/prompts"; +import { cancel, isCancel } from "@clack/prompts"; export function exitOnCancel(value: T | symbol): T { - if (isCancel(value)) { - cancel("Cancelled"); - process.exit(0); - } - return value as T; + if (isCancel(value)) { + cancel("Cancelled"); + process.exit(0); + } + return value as T; } diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index b4305d1..b42a78c 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -1,22 +1,22 @@ { - "compilerOptions": { - "lib": ["ES2022"], - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "outDir": "./dist", - "rootDir": "./src", - "declaration": true, - "sourceMap": true, - "strict": true, - "skipLibCheck": true, - "esModuleInterop": true, - "resolveJsonModule": true, - "forceConsistentCasingInFileNames": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": false, - "noUnusedParameters": false - }, - "include": ["src"] + "compilerOptions": { + "lib": ["ES2022"], + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "sourceMap": true, + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "include": ["src"] }