A CLI for publishing standard.site documents to ATProto sequoia.pub
standard site lexicon cli publishing

Compare changes

Choose any two refs to compare.

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