this repo has no description

chore: added linting and formatting

+1278 -1126
+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
+34
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 + } 20 + }, 21 + "javascript": { 22 + "formatter": { 23 + "quoteStyle": "double" 24 + } 25 + }, 26 + "assist": { 27 + "enabled": true, 28 + "actions": { 29 + "source": { 30 + "organizeImports": "on" 31 + } 32 + } 33 + } 34 + }
+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 });
+2 -2
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";
+4 -4
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",
+2 -2
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 6 import { 7 7 loadCredentials,
+172 -158
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; 77 + try { 78 + agent = await createAgent(credentials); 79 + s.stop(`Logged in as ${agent.session?.handle}`); 80 + } catch (error) { 81 + s.stop("Failed to login"); 82 + log.error(`Failed to login: ${error}`); 83 + process.exit(1); 84 + } 75 85 76 - // Fetch documents from PDS 77 - s.start("Fetching documents from PDS..."); 78 - const documents = await listDocuments(agent, config.publicationUri); 79 - s.stop(`Found ${documents.length} documents on PDS`); 86 + // Fetch documents from PDS 87 + s.start("Fetching documents from PDS..."); 88 + const documents = await listDocuments(agent, config.publicationUri); 89 + s.stop(`Found ${documents.length} documents on PDS`); 80 90 81 - if (documents.length === 0) { 82 - log.info("No documents found for this publication."); 83 - return; 84 - } 91 + if (documents.length === 0) { 92 + log.info("No documents found for this publication."); 93 + return; 94 + } 85 95 86 - // Resolve content directory 87 - const contentDir = path.isAbsolute(config.contentDir) 88 - ? config.contentDir 89 - : path.join(configDir, config.contentDir); 96 + // Resolve content directory 97 + const contentDir = path.isAbsolute(config.contentDir) 98 + ? config.contentDir 99 + : path.join(configDir, config.contentDir); 90 100 91 - // Scan local posts 92 - s.start("Scanning local content..."); 93 - const localPosts = await scanContentDirectory(contentDir, { 94 - frontmatterMapping: config.frontmatter, 95 - ignorePatterns: config.ignore, 96 - slugSource: config.slugSource, 97 - slugField: config.slugField, 98 - removeIndexFromSlug: config.removeIndexFromSlug, 99 - }); 100 - s.stop(`Found ${localPosts.length} local posts`); 101 + // Scan local posts 102 + s.start("Scanning local content..."); 103 + const localPosts = await scanContentDirectory(contentDir, { 104 + frontmatterMapping: config.frontmatter, 105 + ignorePatterns: config.ignore, 106 + slugSource: config.slugSource, 107 + slugField: config.slugField, 108 + removeIndexFromSlug: config.removeIndexFromSlug, 109 + }); 110 + s.stop(`Found ${localPosts.length} local posts`); 101 111 102 - // Build a map of path -> local post for matching 103 - // Document path is like /posts/my-post-slug (or custom pathPrefix) 104 - const pathPrefix = config.pathPrefix || "/posts"; 105 - const postsByPath = new Map<string, typeof localPosts[0]>(); 106 - for (const post of localPosts) { 107 - const postPath = `${pathPrefix}/${post.slug}`; 108 - postsByPath.set(postPath, post); 109 - } 112 + // Build a map of path -> local post for matching 113 + // Document path is like /posts/my-post-slug (or custom pathPrefix) 114 + const pathPrefix = config.pathPrefix || "/posts"; 115 + const postsByPath = new Map<string, (typeof localPosts)[0]>(); 116 + for (const post of localPosts) { 117 + const postPath = `${pathPrefix}/${post.slug}`; 118 + postsByPath.set(postPath, post); 119 + } 110 120 111 - // Load existing state 112 - const state = await loadState(configDir); 113 - const originalPostCount = Object.keys(state.posts).length; 121 + // Load existing state 122 + const state = await loadState(configDir); 123 + const originalPostCount = Object.keys(state.posts).length; 114 124 115 - // Track changes 116 - let matchedCount = 0; 117 - let unmatchedCount = 0; 118 - let frontmatterUpdates: Array<{ filePath: string; atUri: string }> = []; 125 + // Track changes 126 + let matchedCount = 0; 127 + let unmatchedCount = 0; 128 + const frontmatterUpdates: Array<{ filePath: string; atUri: string }> = []; 119 129 120 - log.message("\nMatching documents to local files:\n"); 130 + log.message("\nMatching documents to local files:\n"); 121 131 122 - for (const doc of documents) { 123 - const docPath = doc.value.path; 124 - const localPost = postsByPath.get(docPath); 132 + for (const doc of documents) { 133 + const docPath = doc.value.path; 134 + const localPost = postsByPath.get(docPath); 125 135 126 - if (localPost) { 127 - matchedCount++; 128 - log.message(` ✓ ${doc.value.title}`); 129 - log.message(` Path: ${docPath}`); 130 - log.message(` URI: ${doc.uri}`); 131 - log.message(` File: ${path.basename(localPost.filePath)}`); 136 + if (localPost) { 137 + matchedCount++; 138 + log.message(` ✓ ${doc.value.title}`); 139 + log.message(` Path: ${docPath}`); 140 + log.message(` URI: ${doc.uri}`); 141 + log.message(` File: ${path.basename(localPost.filePath)}`); 132 142 133 - // Update state (use relative path from config directory) 134 - const contentHash = await getContentHash(localPost.rawContent); 135 - const relativeFilePath = path.relative(configDir, localPost.filePath); 136 - state.posts[relativeFilePath] = { 137 - contentHash, 138 - atUri: doc.uri, 139 - lastPublished: doc.value.publishedAt, 140 - }; 143 + // Update state (use relative path from config directory) 144 + const contentHash = await getContentHash(localPost.rawContent); 145 + const relativeFilePath = path.relative(configDir, localPost.filePath); 146 + state.posts[relativeFilePath] = { 147 + contentHash, 148 + atUri: doc.uri, 149 + lastPublished: doc.value.publishedAt, 150 + }; 141 151 142 - // Check if frontmatter needs updating 143 - if (updateFrontmatter && localPost.frontmatter.atUri !== doc.uri) { 144 - frontmatterUpdates.push({ 145 - filePath: localPost.filePath, 146 - atUri: doc.uri, 147 - }); 148 - log.message(` → Will update frontmatter`); 149 - } 150 - } else { 151 - unmatchedCount++; 152 - log.message(` ✗ ${doc.value.title} (no matching local file)`); 153 - log.message(` Path: ${docPath}`); 154 - log.message(` URI: ${doc.uri}`); 155 - } 156 - log.message(""); 157 - } 152 + // Check if frontmatter needs updating 153 + if (updateFrontmatter && localPost.frontmatter.atUri !== doc.uri) { 154 + frontmatterUpdates.push({ 155 + filePath: localPost.filePath, 156 + atUri: doc.uri, 157 + }); 158 + log.message(` → Will update frontmatter`); 159 + } 160 + } else { 161 + unmatchedCount++; 162 + log.message(` ✗ ${doc.value.title} (no matching local file)`); 163 + log.message(` Path: ${docPath}`); 164 + log.message(` URI: ${doc.uri}`); 165 + } 166 + log.message(""); 167 + } 158 168 159 - // Summary 160 - log.message("---"); 161 - log.info(`Matched: ${matchedCount} documents`); 162 - if (unmatchedCount > 0) { 163 - log.warn(`Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`); 164 - } 169 + // Summary 170 + log.message("---"); 171 + log.info(`Matched: ${matchedCount} documents`); 172 + if (unmatchedCount > 0) { 173 + log.warn( 174 + `Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`, 175 + ); 176 + } 165 177 166 - if (dryRun) { 167 - log.info("\nDry run complete. No changes made."); 168 - return; 169 - } 178 + if (dryRun) { 179 + log.info("\nDry run complete. No changes made."); 180 + return; 181 + } 170 182 171 - // Save updated state 172 - await saveState(configDir, state); 173 - const newPostCount = Object.keys(state.posts).length; 174 - log.success(`\nSaved .sequoia-state.json (${originalPostCount} → ${newPostCount} entries)`); 183 + // Save updated state 184 + await saveState(configDir, state); 185 + const newPostCount = Object.keys(state.posts).length; 186 + log.success( 187 + `\nSaved .sequoia-state.json (${originalPostCount} → ${newPostCount} entries)`, 188 + ); 175 189 176 - // Update frontmatter if requested 177 - if (frontmatterUpdates.length > 0) { 178 - s.start(`Updating frontmatter in ${frontmatterUpdates.length} files...`); 179 - for (const { filePath, atUri } of frontmatterUpdates) { 180 - const content = await fs.readFile(filePath, "utf-8"); 181 - const updated = updateFrontmatterWithAtUri(content, atUri); 182 - await fs.writeFile(filePath, updated); 183 - log.message(` Updated: ${path.basename(filePath)}`); 184 - } 185 - s.stop("Frontmatter updated"); 186 - } 190 + // Update frontmatter if requested 191 + if (frontmatterUpdates.length > 0) { 192 + s.start(`Updating frontmatter in ${frontmatterUpdates.length} files...`); 193 + for (const { filePath, atUri } of frontmatterUpdates) { 194 + const content = await fs.readFile(filePath, "utf-8"); 195 + const updated = updateFrontmatterWithAtUri(content, atUri); 196 + await fs.writeFile(filePath, updated); 197 + log.message(` Updated: ${path.basename(filePath)}`); 198 + } 199 + s.stop("Frontmatter updated"); 200 + } 187 201 188 - log.success("\nSync complete!"); 189 - }, 202 + log.success("\nSync complete!"); 203 + }, 190 204 });
+443 -416
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 publishDate = new Date(post.frontmatter.publishDate); 178 + const pathPrefix = config.pathPrefix || "/posts"; 179 + const postPath = `${pathPrefix}/${post.slug}`; 180 + const publishDate = new Date(post.frontmatter.publishDate); 175 181 176 - // Determine textContent: use configured field from frontmatter, or fallback to markdown body 177 - let textContent: string; 178 - if (config.textContentField && post.rawFrontmatter?.[config.textContentField]) { 179 - textContent = String(post.rawFrontmatter[config.textContentField]); 180 - } else { 181 - textContent = stripMarkdownForText(post.content); 182 - } 182 + // Determine textContent: use configured field from frontmatter, or fallback to markdown body 183 + let textContent: string; 184 + if ( 185 + config.textContentField && 186 + post.rawFrontmatter?.[config.textContentField] 187 + ) { 188 + textContent = String(post.rawFrontmatter[config.textContentField]); 189 + } else { 190 + textContent = stripMarkdownForText(post.content); 191 + } 183 192 184 - const record: Record<string, unknown> = { 185 - $type: "site.standard.document", 186 - title: post.frontmatter.title, 187 - site: config.publicationUri, 188 - path: postPath, 189 - textContent: textContent.slice(0, 10000), 190 - publishedAt: publishDate.toISOString(), 191 - canonicalUrl: `${config.siteUrl}${postPath}`, 192 - }; 193 + const record: Record<string, unknown> = { 194 + $type: "site.standard.document", 195 + title: post.frontmatter.title, 196 + site: config.publicationUri, 197 + path: postPath, 198 + textContent: textContent.slice(0, 10000), 199 + publishedAt: publishDate.toISOString(), 200 + canonicalUrl: `${config.siteUrl}${postPath}`, 201 + }; 193 202 194 - if (post.frontmatter.description) { 195 - record.description = post.frontmatter.description; 196 - } 203 + if (post.frontmatter.description) { 204 + record.description = post.frontmatter.description; 205 + } 197 206 198 - if (coverImage) { 199 - record.coverImage = coverImage; 200 - } 207 + if (coverImage) { 208 + record.coverImage = coverImage; 209 + } 201 210 202 - if (post.frontmatter.tags && post.frontmatter.tags.length > 0) { 203 - record.tags = post.frontmatter.tags; 204 - } 211 + if (post.frontmatter.tags && post.frontmatter.tags.length > 0) { 212 + record.tags = post.frontmatter.tags; 213 + } 205 214 206 - const response = await agent.com.atproto.repo.createRecord({ 207 - repo: agent.session!.did, 208 - collection: "site.standard.document", 209 - record, 210 - }); 215 + const response = await agent.com.atproto.repo.createRecord({ 216 + repo: agent.session!.did, 217 + collection: "site.standard.document", 218 + record, 219 + }); 211 220 212 - return response.data.uri; 221 + return response.data.uri; 213 222 } 214 223 215 224 export async function updateDocument( 216 - agent: AtpAgent, 217 - post: BlogPost, 218 - atUri: string, 219 - config: PublisherConfig, 220 - coverImage?: BlobObject 225 + agent: AtpAgent, 226 + post: BlogPost, 227 + atUri: string, 228 + config: PublisherConfig, 229 + coverImage?: BlobObject, 221 230 ): Promise<void> { 222 - // Parse the atUri to get the collection and rkey 223 - // Format: at://did:plc:xxx/collection/rkey 224 - const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 225 - if (!uriMatch) { 226 - throw new Error(`Invalid atUri format: ${atUri}`); 227 - } 231 + // Parse the atUri to get the collection and rkey 232 + // Format: at://did:plc:xxx/collection/rkey 233 + const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 234 + if (!uriMatch) { 235 + throw new Error(`Invalid atUri format: ${atUri}`); 236 + } 228 237 229 - const [, , collection, rkey] = uriMatch; 238 + const [, , collection, rkey] = uriMatch; 230 239 231 - const pathPrefix = config.pathPrefix || "/posts"; 232 - const postPath = `${pathPrefix}/${post.slug}`; 233 - const publishDate = new Date(post.frontmatter.publishDate); 240 + const pathPrefix = config.pathPrefix || "/posts"; 241 + const postPath = `${pathPrefix}/${post.slug}`; 242 + const publishDate = new Date(post.frontmatter.publishDate); 234 243 235 - // Determine textContent: use configured field from frontmatter, or fallback to markdown body 236 - let textContent: string; 237 - if (config.textContentField && post.rawFrontmatter?.[config.textContentField]) { 238 - textContent = String(post.rawFrontmatter[config.textContentField]); 239 - } else { 240 - textContent = stripMarkdownForText(post.content); 241 - } 244 + // Determine textContent: use configured field from frontmatter, or fallback to markdown body 245 + let textContent: string; 246 + if ( 247 + config.textContentField && 248 + post.rawFrontmatter?.[config.textContentField] 249 + ) { 250 + textContent = String(post.rawFrontmatter[config.textContentField]); 251 + } else { 252 + textContent = stripMarkdownForText(post.content); 253 + } 242 254 243 - const record: Record<string, unknown> = { 244 - $type: "site.standard.document", 245 - title: post.frontmatter.title, 246 - site: config.publicationUri, 247 - path: postPath, 248 - textContent: textContent.slice(0, 10000), 249 - publishedAt: publishDate.toISOString(), 250 - canonicalUrl: `${config.siteUrl}${postPath}`, 251 - }; 255 + const record: Record<string, unknown> = { 256 + $type: "site.standard.document", 257 + title: post.frontmatter.title, 258 + site: config.publicationUri, 259 + path: postPath, 260 + textContent: textContent.slice(0, 10000), 261 + publishedAt: publishDate.toISOString(), 262 + canonicalUrl: `${config.siteUrl}${postPath}`, 263 + }; 252 264 253 - if (post.frontmatter.description) { 254 - record.description = post.frontmatter.description; 255 - } 265 + if (post.frontmatter.description) { 266 + record.description = post.frontmatter.description; 267 + } 256 268 257 - if (coverImage) { 258 - record.coverImage = coverImage; 259 - } 269 + if (coverImage) { 270 + record.coverImage = coverImage; 271 + } 260 272 261 - if (post.frontmatter.tags && post.frontmatter.tags.length > 0) { 262 - record.tags = post.frontmatter.tags; 263 - } 273 + if (post.frontmatter.tags && post.frontmatter.tags.length > 0) { 274 + record.tags = post.frontmatter.tags; 275 + } 264 276 265 - await agent.com.atproto.repo.putRecord({ 266 - repo: agent.session!.did, 267 - collection: collection!, 268 - rkey: rkey!, 269 - record, 270 - }); 277 + await agent.com.atproto.repo.putRecord({ 278 + repo: agent.session!.did, 279 + collection: collection!, 280 + rkey: rkey!, 281 + record, 282 + }); 271 283 } 272 284 273 - export function parseAtUri(atUri: string): { did: string; collection: string; rkey: string } | null { 274 - const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 275 - if (!match) return null; 276 - return { 277 - did: match[1]!, 278 - collection: match[2]!, 279 - rkey: match[3]!, 280 - }; 285 + export function parseAtUri( 286 + atUri: string, 287 + ): { did: string; collection: string; rkey: string } | null { 288 + const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 289 + if (!match) return null; 290 + return { 291 + did: match[1]!, 292 + collection: match[2]!, 293 + rkey: match[3]!, 294 + }; 281 295 } 282 296 283 297 export interface DocumentRecord { 284 - $type: "site.standard.document"; 285 - title: string; 286 - site: string; 287 - path: string; 288 - textContent: string; 289 - publishedAt: string; 290 - canonicalUrl?: string; 291 - description?: string; 292 - coverImage?: BlobObject; 293 - tags?: string[]; 294 - location?: string; 298 + $type: "site.standard.document"; 299 + title: string; 300 + site: string; 301 + path: string; 302 + textContent: string; 303 + publishedAt: string; 304 + canonicalUrl?: string; 305 + description?: string; 306 + coverImage?: BlobObject; 307 + tags?: string[]; 308 + location?: string; 295 309 } 296 310 297 311 export interface ListDocumentsResult { 298 - uri: string; 299 - cid: string; 300 - value: DocumentRecord; 312 + uri: string; 313 + cid: string; 314 + value: DocumentRecord; 301 315 } 302 316 303 317 export async function listDocuments( 304 - agent: AtpAgent, 305 - publicationUri?: string 318 + agent: AtpAgent, 319 + publicationUri?: string, 306 320 ): Promise<ListDocumentsResult[]> { 307 - const documents: ListDocumentsResult[] = []; 308 - let cursor: string | undefined; 321 + const documents: ListDocumentsResult[] = []; 322 + let cursor: string | undefined; 309 323 310 - do { 311 - const response = await agent.com.atproto.repo.listRecords({ 312 - repo: agent.session!.did, 313 - collection: "site.standard.document", 314 - limit: 100, 315 - cursor, 316 - }); 324 + do { 325 + const response = await agent.com.atproto.repo.listRecords({ 326 + repo: agent.session!.did, 327 + collection: "site.standard.document", 328 + limit: 100, 329 + cursor, 330 + }); 317 331 318 - for (const record of response.data.records) { 319 - const value = record.value as unknown as DocumentRecord; 332 + for (const record of response.data.records) { 333 + const value = record.value as unknown as DocumentRecord; 320 334 321 - // If publicationUri is specified, only include documents from that publication 322 - if (publicationUri && value.site !== publicationUri) { 323 - continue; 324 - } 335 + // If publicationUri is specified, only include documents from that publication 336 + if (publicationUri && value.site !== publicationUri) { 337 + continue; 338 + } 325 339 326 - documents.push({ 327 - uri: record.uri, 328 - cid: record.cid, 329 - value, 330 - }); 331 - } 340 + documents.push({ 341 + uri: record.uri, 342 + cid: record.cid, 343 + value, 344 + }); 345 + } 332 346 333 - cursor = response.data.cursor; 334 - } while (cursor); 347 + cursor = response.data.cursor; 348 + } while (cursor); 335 349 336 - return documents; 350 + return documents; 337 351 } 338 352 339 353 export async function createPublication( 340 - agent: AtpAgent, 341 - options: CreatePublicationOptions 354 + agent: AtpAgent, 355 + options: CreatePublicationOptions, 342 356 ): Promise<string> { 343 - let icon: BlobObject | undefined; 357 + let icon: BlobObject | undefined; 344 358 345 - if (options.iconPath) { 346 - icon = await uploadImage(agent, options.iconPath); 347 - } 359 + if (options.iconPath) { 360 + icon = await uploadImage(agent, options.iconPath); 361 + } 348 362 349 - const record: Record<string, unknown> = { 350 - $type: "site.standard.publication", 351 - url: options.url, 352 - name: options.name, 353 - createdAt: new Date().toISOString(), 354 - }; 363 + const record: Record<string, unknown> = { 364 + $type: "site.standard.publication", 365 + url: options.url, 366 + name: options.name, 367 + createdAt: new Date().toISOString(), 368 + }; 355 369 356 - if (options.description) { 357 - record.description = options.description; 358 - } 370 + if (options.description) { 371 + record.description = options.description; 372 + } 359 373 360 - if (icon) { 361 - record.icon = icon; 362 - } 374 + if (icon) { 375 + record.icon = icon; 376 + } 363 377 364 - if (options.showInDiscover !== undefined) { 365 - record.preferences = { 366 - showInDiscover: options.showInDiscover, 367 - }; 368 - } 378 + if (options.showInDiscover !== undefined) { 379 + record.preferences = { 380 + showInDiscover: options.showInDiscover, 381 + }; 382 + } 369 383 370 - const response = await agent.com.atproto.repo.createRecord({ 371 - repo: agent.session!.did, 372 - collection: "site.standard.publication", 373 - record, 374 - }); 384 + const response = await agent.com.atproto.repo.createRecord({ 385 + repo: agent.session!.did, 386 + collection: "site.standard.publication", 387 + record, 388 + }); 375 389 376 - return response.data.uri; 390 + return response.data.uri; 377 391 } 378 392 379 393 // --- Bluesky Post Creation --- 380 394 381 395 export interface CreateBlueskyPostOptions { 382 - title: string; 383 - description?: string; 384 - canonicalUrl: string; 385 - coverImage?: BlobObject; 386 - publishedAt: string; // Used as createdAt for the post 396 + title: string; 397 + description?: string; 398 + canonicalUrl: string; 399 + coverImage?: BlobObject; 400 + publishedAt: string; // Used as createdAt for the post 387 401 } 388 402 389 403 /** 390 404 * Count graphemes in a string (for Bluesky's 300 grapheme limit) 391 405 */ 392 406 function countGraphemes(str: string): number { 393 - // Use Intl.Segmenter if available, otherwise fallback to spread operator 394 - if (typeof Intl !== "undefined" && Intl.Segmenter) { 395 - const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); 396 - return [...segmenter.segment(str)].length; 397 - } 398 - return [...str].length; 407 + // Use Intl.Segmenter if available, otherwise fallback to spread operator 408 + if (typeof Intl !== "undefined" && Intl.Segmenter) { 409 + const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); 410 + return [...segmenter.segment(str)].length; 411 + } 412 + return [...str].length; 399 413 } 400 414 401 415 /** 402 416 * Truncate a string to a maximum number of graphemes 403 417 */ 404 418 function truncateToGraphemes(str: string, maxGraphemes: number): string { 405 - if (typeof Intl !== "undefined" && Intl.Segmenter) { 406 - const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); 407 - const segments = [...segmenter.segment(str)]; 408 - if (segments.length <= maxGraphemes) return str; 409 - return segments.slice(0, maxGraphemes - 3).map(s => s.segment).join("") + "..."; 410 - } 411 - // Fallback 412 - const chars = [...str]; 413 - if (chars.length <= maxGraphemes) return str; 414 - return chars.slice(0, maxGraphemes - 3).join("") + "..."; 419 + if (typeof Intl !== "undefined" && Intl.Segmenter) { 420 + const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); 421 + const segments = [...segmenter.segment(str)]; 422 + if (segments.length <= maxGraphemes) return str; 423 + return ( 424 + segments 425 + .slice(0, maxGraphemes - 3) 426 + .map((s) => s.segment) 427 + .join("") + "..." 428 + ); 429 + } 430 + // Fallback 431 + const chars = [...str]; 432 + if (chars.length <= maxGraphemes) return str; 433 + return chars.slice(0, maxGraphemes - 3).join("") + "..."; 415 434 } 416 435 417 436 /** 418 437 * Create a Bluesky post with external link embed 419 438 */ 420 439 export async function createBlueskyPost( 421 - agent: AtpAgent, 422 - options: CreateBlueskyPostOptions 440 + agent: AtpAgent, 441 + options: CreateBlueskyPostOptions, 423 442 ): Promise<StrongRef> { 424 - const { title, description, canonicalUrl, coverImage, publishedAt } = options; 443 + const { title, description, canonicalUrl, coverImage, publishedAt } = options; 425 444 426 - // Build post text: title + description + URL 427 - // Max 300 graphemes for Bluesky posts 428 - const MAX_GRAPHEMES = 300; 445 + // Build post text: title + description + URL 446 + // Max 300 graphemes for Bluesky posts 447 + const MAX_GRAPHEMES = 300; 429 448 430 - let postText: string; 431 - const urlPart = `\n\n${canonicalUrl}`; 432 - const urlGraphemes = countGraphemes(urlPart); 449 + let postText: string; 450 + const urlPart = `\n\n${canonicalUrl}`; 451 + const urlGraphemes = countGraphemes(urlPart); 433 452 434 - if (description) { 435 - // Try: title + description + URL 436 - const fullText = `${title}\n\n${description}${urlPart}`; 437 - if (countGraphemes(fullText) <= MAX_GRAPHEMES) { 438 - postText = fullText; 439 - } else { 440 - // Truncate description to fit 441 - const availableForDesc = MAX_GRAPHEMES - countGraphemes(title) - countGraphemes("\n\n") - urlGraphemes - countGraphemes("\n\n"); 442 - if (availableForDesc > 10) { 443 - const truncatedDesc = truncateToGraphemes(description, availableForDesc); 444 - postText = `${title}\n\n${truncatedDesc}${urlPart}`; 445 - } else { 446 - // Just title + URL 447 - postText = `${title}${urlPart}`; 448 - } 449 - } 450 - } else { 451 - // Just title + URL 452 - postText = `${title}${urlPart}`; 453 - } 453 + if (description) { 454 + // Try: title + description + URL 455 + const fullText = `${title}\n\n${description}${urlPart}`; 456 + if (countGraphemes(fullText) <= MAX_GRAPHEMES) { 457 + postText = fullText; 458 + } else { 459 + // Truncate description to fit 460 + const availableForDesc = 461 + MAX_GRAPHEMES - 462 + countGraphemes(title) - 463 + countGraphemes("\n\n") - 464 + urlGraphemes - 465 + countGraphemes("\n\n"); 466 + if (availableForDesc > 10) { 467 + const truncatedDesc = truncateToGraphemes( 468 + description, 469 + availableForDesc, 470 + ); 471 + postText = `${title}\n\n${truncatedDesc}${urlPart}`; 472 + } else { 473 + // Just title + URL 474 + postText = `${title}${urlPart}`; 475 + } 476 + } 477 + } else { 478 + // Just title + URL 479 + postText = `${title}${urlPart}`; 480 + } 454 481 455 - // Final truncation if still too long (shouldn't happen but safety check) 456 - if (countGraphemes(postText) > MAX_GRAPHEMES) { 457 - postText = truncateToGraphemes(postText, MAX_GRAPHEMES); 458 - } 482 + // Final truncation if still too long (shouldn't happen but safety check) 483 + if (countGraphemes(postText) > MAX_GRAPHEMES) { 484 + postText = truncateToGraphemes(postText, MAX_GRAPHEMES); 485 + } 459 486 460 - // Calculate byte indices for the URL facet 461 - const encoder = new TextEncoder(); 462 - const urlStartInText = postText.lastIndexOf(canonicalUrl); 463 - const beforeUrl = postText.substring(0, urlStartInText); 464 - const byteStart = encoder.encode(beforeUrl).length; 465 - const byteEnd = byteStart + encoder.encode(canonicalUrl).length; 487 + // Calculate byte indices for the URL facet 488 + const encoder = new TextEncoder(); 489 + const urlStartInText = postText.lastIndexOf(canonicalUrl); 490 + const beforeUrl = postText.substring(0, urlStartInText); 491 + const byteStart = encoder.encode(beforeUrl).length; 492 + const byteEnd = byteStart + encoder.encode(canonicalUrl).length; 466 493 467 - // Build facets for the URL link 468 - const facets = [ 469 - { 470 - index: { 471 - byteStart, 472 - byteEnd, 473 - }, 474 - features: [ 475 - { 476 - $type: "app.bsky.richtext.facet#link", 477 - uri: canonicalUrl, 478 - }, 479 - ], 480 - }, 481 - ]; 494 + // Build facets for the URL link 495 + const facets = [ 496 + { 497 + index: { 498 + byteStart, 499 + byteEnd, 500 + }, 501 + features: [ 502 + { 503 + $type: "app.bsky.richtext.facet#link", 504 + uri: canonicalUrl, 505 + }, 506 + ], 507 + }, 508 + ]; 482 509 483 - // Build external embed 484 - const embed: Record<string, unknown> = { 485 - $type: "app.bsky.embed.external", 486 - external: { 487 - uri: canonicalUrl, 488 - title: title.substring(0, 500), // Max 500 chars for title 489 - description: (description || "").substring(0, 1000), // Max 1000 chars for description 490 - }, 491 - }; 510 + // Build external embed 511 + const embed: Record<string, unknown> = { 512 + $type: "app.bsky.embed.external", 513 + external: { 514 + uri: canonicalUrl, 515 + title: title.substring(0, 500), // Max 500 chars for title 516 + description: (description || "").substring(0, 1000), // Max 1000 chars for description 517 + }, 518 + }; 492 519 493 - // Add thumbnail if coverImage is available 494 - if (coverImage) { 495 - (embed.external as Record<string, unknown>).thumb = coverImage; 496 - } 520 + // Add thumbnail if coverImage is available 521 + if (coverImage) { 522 + (embed.external as Record<string, unknown>).thumb = coverImage; 523 + } 497 524 498 - // Create the post record 499 - const record: Record<string, unknown> = { 500 - $type: "app.bsky.feed.post", 501 - text: postText, 502 - facets, 503 - embed, 504 - createdAt: new Date(publishedAt).toISOString(), 505 - }; 525 + // Create the post record 526 + const record: Record<string, unknown> = { 527 + $type: "app.bsky.feed.post", 528 + text: postText, 529 + facets, 530 + embed, 531 + createdAt: new Date(publishedAt).toISOString(), 532 + }; 506 533 507 - const response = await agent.com.atproto.repo.createRecord({ 508 - repo: agent.session!.did, 509 - collection: "app.bsky.feed.post", 510 - record, 511 - }); 534 + const response = await agent.com.atproto.repo.createRecord({ 535 + repo: agent.session!.did, 536 + collection: "app.bsky.feed.post", 537 + record, 538 + }); 512 539 513 - return { 514 - uri: response.data.uri, 515 - cid: response.data.cid, 516 - }; 540 + return { 541 + uri: response.data.uri, 542 + cid: response.data.cid, 543 + }; 517 544 } 518 545 519 546 /** 520 547 * Add bskyPostRef to an existing document record 521 548 */ 522 549 export async function addBskyPostRefToDocument( 523 - agent: AtpAgent, 524 - documentAtUri: string, 525 - bskyPostRef: StrongRef 550 + agent: AtpAgent, 551 + documentAtUri: string, 552 + bskyPostRef: StrongRef, 526 553 ): Promise<void> { 527 - const parsed = parseAtUri(documentAtUri); 528 - if (!parsed) { 529 - throw new Error(`Invalid document URI: ${documentAtUri}`); 530 - } 554 + const parsed = parseAtUri(documentAtUri); 555 + if (!parsed) { 556 + throw new Error(`Invalid document URI: ${documentAtUri}`); 557 + } 531 558 532 - // Fetch existing record 533 - const existingRecord = await agent.com.atproto.repo.getRecord({ 534 - repo: parsed.did, 535 - collection: parsed.collection, 536 - rkey: parsed.rkey, 537 - }); 559 + // Fetch existing record 560 + const existingRecord = await agent.com.atproto.repo.getRecord({ 561 + repo: parsed.did, 562 + collection: parsed.collection, 563 + rkey: parsed.rkey, 564 + }); 538 565 539 - // Add bskyPostRef to the record 540 - const updatedRecord = { 541 - ...(existingRecord.data.value as Record<string, unknown>), 542 - bskyPostRef, 543 - }; 566 + // Add bskyPostRef to the record 567 + const updatedRecord = { 568 + ...(existingRecord.data.value as Record<string, unknown>), 569 + bskyPostRef, 570 + }; 544 571 545 - // Update the record 546 - await agent.com.atproto.repo.putRecord({ 547 - repo: parsed.did, 548 - collection: parsed.collection, 549 - rkey: parsed.rkey, 550 - record: updatedRecord, 551 - }); 572 + // Update the record 573 + await agent.com.atproto.repo.putRecord({ 574 + repo: parsed.did, 575 + collection: parsed.collection, 576 + rkey: parsed.rkey, 577 + record: updatedRecord, 578 + }); 552 579 }
+9 -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"; ··· 131 136 132 137 if (options.textContentField) { 133 138 config.textContentField = options.textContentField; 139 + } 134 140 if (options.bluesky) { 135 141 config.bluesky = options.bluesky; 136 142 }
+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 }
+321 -289
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; 10 - rawFrontmatter: Record<string, unknown>; 7 + export function parseFrontmatter( 8 + content: string, 9 + mapping?: FrontmatterMapping, 10 + ): { 11 + frontmatter: PostFrontmatter; 12 + body: string; 13 + rawFrontmatter: Record<string, unknown>; 11 14 } { 12 - // Support multiple frontmatter delimiters: 13 - // --- (YAML) - Jekyll, Astro, most SSGs 14 - // +++ (TOML) - Hugo 15 - // *** - Alternative format 16 - const frontmatterRegex = /^(---|\+\+\+|\*\*\*)\n([\s\S]*?)\n\1\n([\s\S]*)$/; 17 - const match = content.match(frontmatterRegex); 15 + // Support multiple frontmatter delimiters: 16 + // --- (YAML) - Jekyll, Astro, most SSGs 17 + // +++ (TOML) - Hugo 18 + // *** - Alternative format 19 + const frontmatterRegex = /^(---|\+\+\+|\*\*\*)\n([\s\S]*?)\n\1\n([\s\S]*)$/; 20 + const match = content.match(frontmatterRegex); 18 21 19 - if (!match) { 20 - throw new Error("Could not parse frontmatter"); 21 - } 22 + if (!match) { 23 + throw new Error("Could not parse frontmatter"); 24 + } 22 25 23 - const delimiter = match[1]; 24 - const frontmatterStr = match[2] ?? ""; 25 - const body = match[3] ?? ""; 26 + const delimiter = match[1]; 27 + const frontmatterStr = match[2] ?? ""; 28 + const body = match[3] ?? ""; 26 29 27 - // Determine format based on delimiter: 28 - // +++ uses TOML (key = value) 29 - // --- and *** use YAML (key: value) 30 - const isToml = delimiter === "+++"; 31 - const separator = isToml ? "=" : ":"; 30 + // Determine format based on delimiter: 31 + // +++ uses TOML (key = value) 32 + // --- and *** use YAML (key: value) 33 + const isToml = delimiter === "+++"; 34 + const separator = isToml ? "=" : ":"; 32 35 33 - // Parse frontmatter manually 34 - const raw: Record<string, unknown> = {}; 35 - const lines = frontmatterStr.split("\n"); 36 + // Parse frontmatter manually 37 + const raw: Record<string, unknown> = {}; 38 + const lines = frontmatterStr.split("\n"); 36 39 37 - let i = 0; 38 - while (i < lines.length) { 39 - const line = lines[i]; 40 - if (line === undefined) { 41 - i++; 42 - continue; 43 - } 44 - const sepIndex = line.indexOf(separator); 45 - if (sepIndex === -1) { 46 - i++; 47 - continue; 48 - } 40 + let i = 0; 41 + while (i < lines.length) { 42 + const line = lines[i]; 43 + if (line === undefined) { 44 + i++; 45 + continue; 46 + } 47 + const sepIndex = line.indexOf(separator); 48 + if (sepIndex === -1) { 49 + i++; 50 + continue; 51 + } 49 52 50 - const key = line.slice(0, sepIndex).trim(); 51 - let value = line.slice(sepIndex + 1).trim(); 53 + const key = line.slice(0, sepIndex).trim(); 54 + let value = line.slice(sepIndex + 1).trim(); 52 55 53 - // Handle quoted strings 54 - if ( 55 - (value.startsWith('"') && value.endsWith('"')) || 56 - (value.startsWith("'") && value.endsWith("'")) 57 - ) { 58 - value = value.slice(1, -1); 59 - } 56 + // Handle quoted strings 57 + if ( 58 + (value.startsWith('"') && value.endsWith('"')) || 59 + (value.startsWith("'") && value.endsWith("'")) 60 + ) { 61 + value = value.slice(1, -1); 62 + } 60 63 61 - // Handle inline arrays (simple case for tags) 62 - if (value.startsWith("[") && value.endsWith("]")) { 63 - const arrayContent = value.slice(1, -1); 64 - raw[key] = arrayContent 65 - .split(",") 66 - .map((item) => item.trim().replace(/^["']|["']$/g, "")); 67 - } else if (value === "" && !isToml) { 68 - // Check for YAML-style multiline array (key with no value followed by - items) 69 - const arrayItems: string[] = []; 70 - let j = i + 1; 71 - while (j < lines.length) { 72 - const nextLine = lines[j]; 73 - if (nextLine === undefined) { 74 - j++; 75 - continue; 76 - } 77 - // Check if line is a list item (starts with whitespace and -) 78 - const listMatch = nextLine.match(/^\s+-\s*(.*)$/); 79 - if (listMatch && listMatch[1] !== undefined) { 80 - let itemValue = listMatch[1].trim(); 81 - // Remove quotes if present 82 - if ( 83 - (itemValue.startsWith('"') && itemValue.endsWith('"')) || 84 - (itemValue.startsWith("'") && itemValue.endsWith("'")) 85 - ) { 86 - itemValue = itemValue.slice(1, -1); 87 - } 88 - arrayItems.push(itemValue); 89 - j++; 90 - } else if (nextLine.trim() === "") { 91 - // Skip empty lines within the array 92 - j++; 93 - } else { 94 - // Hit a new key or non-list content 95 - break; 96 - } 97 - } 98 - if (arrayItems.length > 0) { 99 - raw[key] = arrayItems; 100 - i = j; 101 - continue; 102 - } else { 103 - raw[key] = value; 104 - } 105 - } else if (value === "true") { 106 - raw[key] = true; 107 - } else if (value === "false") { 108 - raw[key] = false; 109 - } else { 110 - raw[key] = value; 111 - } 112 - i++; 113 - } 64 + // Handle inline arrays (simple case for tags) 65 + if (value.startsWith("[") && value.endsWith("]")) { 66 + const arrayContent = value.slice(1, -1); 67 + raw[key] = arrayContent 68 + .split(",") 69 + .map((item) => item.trim().replace(/^["']|["']$/g, "")); 70 + } else if (value === "" && !isToml) { 71 + // Check for YAML-style multiline array (key with no value followed by - items) 72 + const arrayItems: string[] = []; 73 + let j = i + 1; 74 + while (j < lines.length) { 75 + const nextLine = lines[j]; 76 + if (nextLine === undefined) { 77 + j++; 78 + continue; 79 + } 80 + // Check if line is a list item (starts with whitespace and -) 81 + const listMatch = nextLine.match(/^\s+-\s*(.*)$/); 82 + if (listMatch && listMatch[1] !== undefined) { 83 + let itemValue = listMatch[1].trim(); 84 + // Remove quotes if present 85 + if ( 86 + (itemValue.startsWith('"') && itemValue.endsWith('"')) || 87 + (itemValue.startsWith("'") && itemValue.endsWith("'")) 88 + ) { 89 + itemValue = itemValue.slice(1, -1); 90 + } 91 + arrayItems.push(itemValue); 92 + j++; 93 + } else if (nextLine.trim() === "") { 94 + // Skip empty lines within the array 95 + j++; 96 + } else { 97 + // Hit a new key or non-list content 98 + break; 99 + } 100 + } 101 + if (arrayItems.length > 0) { 102 + raw[key] = arrayItems; 103 + i = j; 104 + continue; 105 + } else { 106 + raw[key] = value; 107 + } 108 + } else if (value === "true") { 109 + raw[key] = true; 110 + } else if (value === "false") { 111 + raw[key] = false; 112 + } else { 113 + raw[key] = value; 114 + } 115 + i++; 116 + } 114 117 115 - // Apply field mappings to normalize to standard PostFrontmatter fields 116 - const frontmatter: Record<string, unknown> = {}; 118 + // Apply field mappings to normalize to standard PostFrontmatter fields 119 + const frontmatter: Record<string, unknown> = {}; 117 120 118 - // Title mapping 119 - const titleField = mapping?.title || "title"; 120 - frontmatter.title = raw[titleField] || raw.title; 121 + // Title mapping 122 + const titleField = mapping?.title || "title"; 123 + frontmatter.title = raw[titleField] || raw.title; 121 124 122 - // Description mapping 123 - const descField = mapping?.description || "description"; 124 - frontmatter.description = raw[descField] || raw.description; 125 + // Description mapping 126 + const descField = mapping?.description || "description"; 127 + frontmatter.description = raw[descField] || raw.description; 125 128 126 - // Publish date mapping - check custom field first, then fallbacks 127 - const dateField = mapping?.publishDate; 128 - if (dateField && raw[dateField]) { 129 - frontmatter.publishDate = raw[dateField]; 130 - } else if (raw.publishDate) { 131 - frontmatter.publishDate = raw.publishDate; 132 - } else { 133 - // Fallback to common date field names 134 - const dateFields = ["pubDate", "date", "createdAt", "created_at"]; 135 - for (const field of dateFields) { 136 - if (raw[field]) { 137 - frontmatter.publishDate = raw[field]; 138 - break; 139 - } 140 - } 141 - } 129 + // Publish date mapping - check custom field first, then fallbacks 130 + const dateField = mapping?.publishDate; 131 + if (dateField && raw[dateField]) { 132 + frontmatter.publishDate = raw[dateField]; 133 + } else if (raw.publishDate) { 134 + frontmatter.publishDate = raw.publishDate; 135 + } else { 136 + // Fallback to common date field names 137 + const dateFields = ["pubDate", "date", "createdAt", "created_at"]; 138 + for (const field of dateFields) { 139 + if (raw[field]) { 140 + frontmatter.publishDate = raw[field]; 141 + break; 142 + } 143 + } 144 + } 142 145 143 - // Cover image mapping 144 - const coverField = mapping?.coverImage || "ogImage"; 145 - frontmatter.ogImage = raw[coverField] || raw.ogImage; 146 + // Cover image mapping 147 + const coverField = mapping?.coverImage || "ogImage"; 148 + frontmatter.ogImage = raw[coverField] || raw.ogImage; 146 149 147 - // Tags mapping 148 - const tagsField = mapping?.tags || "tags"; 149 - frontmatter.tags = raw[tagsField] || raw.tags; 150 + // Tags mapping 151 + const tagsField = mapping?.tags || "tags"; 152 + frontmatter.tags = raw[tagsField] || raw.tags; 150 153 151 - // Draft mapping 152 - const draftField = mapping?.draft || "draft"; 153 - const draftValue = raw[draftField] ?? raw.draft; 154 - if (draftValue !== undefined) { 155 - frontmatter.draft = draftValue === true || draftValue === "true"; 156 - } 154 + // Draft mapping 155 + const draftField = mapping?.draft || "draft"; 156 + const draftValue = raw[draftField] ?? raw.draft; 157 + if (draftValue !== undefined) { 158 + frontmatter.draft = draftValue === true || draftValue === "true"; 159 + } 157 160 158 - // Always preserve atUri (internal field) 159 - frontmatter.atUri = raw.atUri; 161 + // Always preserve atUri (internal field) 162 + frontmatter.atUri = raw.atUri; 160 163 161 - return { frontmatter: frontmatter as unknown as PostFrontmatter, body, rawFrontmatter: raw }; 164 + return { 165 + frontmatter: frontmatter as unknown as PostFrontmatter, 166 + body, 167 + rawFrontmatter: raw, 168 + }; 162 169 } 163 170 164 171 export function getSlugFromFilename(filename: string): string { 165 - return filename 166 - .replace(/\.mdx?$/, "") 167 - .toLowerCase() 168 - .replace(/\s+/g, "-"); 172 + return filename 173 + .replace(/\.mdx?$/, "") 174 + .toLowerCase() 175 + .replace(/\s+/g, "-"); 169 176 } 170 177 171 178 export interface SlugOptions { 172 - slugSource?: "filename" | "path" | "frontmatter"; 173 - slugField?: string; 174 - removeIndexFromSlug?: boolean; 179 + slugSource?: "filename" | "path" | "frontmatter"; 180 + slugField?: string; 181 + removeIndexFromSlug?: boolean; 175 182 } 176 183 177 184 export function getSlugFromOptions( 178 - relativePath: string, 179 - rawFrontmatter: Record<string, unknown>, 180 - options: SlugOptions = {} 185 + relativePath: string, 186 + rawFrontmatter: Record<string, unknown>, 187 + options: SlugOptions = {}, 181 188 ): string { 182 - const { slugSource = "filename", slugField = "slug", removeIndexFromSlug = false } = options; 189 + const { 190 + slugSource = "filename", 191 + slugField = "slug", 192 + removeIndexFromSlug = false, 193 + } = options; 183 194 184 - let slug: string; 195 + let slug: string; 185 196 186 - switch (slugSource) { 187 - case "path": 188 - // Use full relative path without extension 189 - slug = relativePath 190 - .replace(/\.mdx?$/, "") 191 - .toLowerCase() 192 - .replace(/\s+/g, "-"); 193 - break; 197 + switch (slugSource) { 198 + case "path": 199 + // Use full relative path without extension 200 + slug = relativePath 201 + .replace(/\.mdx?$/, "") 202 + .toLowerCase() 203 + .replace(/\s+/g, "-"); 204 + break; 194 205 195 - case "frontmatter": 196 - // Use frontmatter field (slug or url) 197 - const frontmatterValue = rawFrontmatter[slugField] || rawFrontmatter.slug || rawFrontmatter.url; 198 - if (frontmatterValue && typeof frontmatterValue === "string") { 199 - // Remove leading slash if present 200 - slug = frontmatterValue.replace(/^\//, "").toLowerCase().replace(/\s+/g, "-"); 201 - } else { 202 - // Fallback to filename if frontmatter field not found 203 - slug = getSlugFromFilename(path.basename(relativePath)); 204 - } 205 - break; 206 + case "frontmatter": { 207 + // Use frontmatter field (slug or url) 208 + const frontmatterValue = 209 + rawFrontmatter[slugField] || rawFrontmatter.slug || rawFrontmatter.url; 210 + if (frontmatterValue && typeof frontmatterValue === "string") { 211 + // Remove leading slash if present 212 + slug = frontmatterValue 213 + .replace(/^\//, "") 214 + .toLowerCase() 215 + .replace(/\s+/g, "-"); 216 + } else { 217 + // Fallback to filename if frontmatter field not found 218 + slug = getSlugFromFilename(path.basename(relativePath)); 219 + } 220 + break; 221 + } 206 222 207 - case "filename": 208 - default: 209 - slug = getSlugFromFilename(path.basename(relativePath)); 210 - break; 211 - } 223 + case "filename": 224 + default: 225 + slug = getSlugFromFilename(path.basename(relativePath)); 226 + break; 227 + } 212 228 213 - // Remove /index or /_index suffix if configured 214 - if (removeIndexFromSlug) { 215 - slug = slug.replace(/\/_?index$/, ""); 216 - } 229 + // Remove /index or /_index suffix if configured 230 + if (removeIndexFromSlug) { 231 + slug = slug.replace(/\/_?index$/, ""); 232 + } 217 233 218 - return slug; 234 + return slug; 219 235 } 220 236 221 237 export async function getContentHash(content: string): Promise<string> { 222 - const encoder = new TextEncoder(); 223 - const data = encoder.encode(content); 224 - const hashBuffer = await crypto.subtle.digest("SHA-256", data); 225 - const hashArray = Array.from(new Uint8Array(hashBuffer)); 226 - return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); 238 + const encoder = new TextEncoder(); 239 + const data = encoder.encode(content); 240 + const hashBuffer = await crypto.subtle.digest("SHA-256", data); 241 + const hashArray = Array.from(new Uint8Array(hashBuffer)); 242 + return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); 227 243 } 228 244 229 245 function shouldIgnore(relativePath: string, ignorePatterns: string[]): boolean { 230 - for (const pattern of ignorePatterns) { 231 - if (minimatch(relativePath, pattern)) { 232 - return true; 233 - } 234 - } 235 - return false; 246 + for (const pattern of ignorePatterns) { 247 + if (minimatch(relativePath, pattern)) { 248 + return true; 249 + } 250 + } 251 + return false; 236 252 } 237 253 238 254 export interface ScanOptions { 239 - frontmatterMapping?: FrontmatterMapping; 240 - ignorePatterns?: string[]; 241 - slugSource?: "filename" | "path" | "frontmatter"; 242 - slugField?: string; 243 - removeIndexFromSlug?: boolean; 255 + frontmatterMapping?: FrontmatterMapping; 256 + ignorePatterns?: string[]; 257 + slugSource?: "filename" | "path" | "frontmatter"; 258 + slugField?: string; 259 + removeIndexFromSlug?: boolean; 244 260 } 245 261 246 262 export async function scanContentDirectory( 247 - contentDir: string, 248 - frontmatterMappingOrOptions?: FrontmatterMapping | ScanOptions, 249 - ignorePatterns: string[] = [] 263 + contentDir: string, 264 + frontmatterMappingOrOptions?: FrontmatterMapping | ScanOptions, 265 + ignorePatterns: string[] = [], 250 266 ): Promise<BlogPost[]> { 251 - // Handle both old signature (frontmatterMapping, ignorePatterns) and new signature (options) 252 - let options: ScanOptions; 253 - if (frontmatterMappingOrOptions && ('slugSource' in frontmatterMappingOrOptions || 'frontmatterMapping' in frontmatterMappingOrOptions || 'ignorePatterns' in frontmatterMappingOrOptions)) { 254 - options = frontmatterMappingOrOptions as ScanOptions; 255 - } else { 256 - // Old signature: (contentDir, frontmatterMapping?, ignorePatterns?) 257 - options = { 258 - frontmatterMapping: frontmatterMappingOrOptions as FrontmatterMapping | undefined, 259 - ignorePatterns, 260 - }; 261 - } 267 + // Handle both old signature (frontmatterMapping, ignorePatterns) and new signature (options) 268 + let options: ScanOptions; 269 + if ( 270 + frontmatterMappingOrOptions && 271 + ("slugSource" in frontmatterMappingOrOptions || 272 + "frontmatterMapping" in frontmatterMappingOrOptions || 273 + "ignorePatterns" in frontmatterMappingOrOptions) 274 + ) { 275 + options = frontmatterMappingOrOptions as ScanOptions; 276 + } else { 277 + // Old signature: (contentDir, frontmatterMapping?, ignorePatterns?) 278 + options = { 279 + frontmatterMapping: frontmatterMappingOrOptions as 280 + | FrontmatterMapping 281 + | undefined, 282 + ignorePatterns, 283 + }; 284 + } 262 285 263 - const { 264 - frontmatterMapping, 265 - ignorePatterns: ignore = [], 266 - slugSource, 267 - slugField, 268 - removeIndexFromSlug, 269 - } = options; 286 + const { 287 + frontmatterMapping, 288 + ignorePatterns: ignore = [], 289 + slugSource, 290 + slugField, 291 + removeIndexFromSlug, 292 + } = options; 270 293 271 - const patterns = ["**/*.md", "**/*.mdx"]; 272 - const posts: BlogPost[] = []; 294 + const patterns = ["**/*.md", "**/*.mdx"]; 295 + const posts: BlogPost[] = []; 273 296 274 - for (const pattern of patterns) { 275 - const files = await glob(pattern, { 276 - cwd: contentDir, 277 - absolute: false, 278 - }); 297 + for (const pattern of patterns) { 298 + const files = await glob(pattern, { 299 + cwd: contentDir, 300 + absolute: false, 301 + }); 279 302 280 - for (const relativePath of files) { 281 - // Skip files matching ignore patterns 282 - if (shouldIgnore(relativePath, ignore)) { 283 - continue; 284 - } 303 + for (const relativePath of files) { 304 + // Skip files matching ignore patterns 305 + if (shouldIgnore(relativePath, ignore)) { 306 + continue; 307 + } 285 308 286 - const filePath = path.join(contentDir, relativePath); 287 - const rawContent = await fs.readFile(filePath, "utf-8"); 309 + const filePath = path.join(contentDir, relativePath); 310 + const rawContent = await fs.readFile(filePath, "utf-8"); 288 311 289 - try { 290 - const { frontmatter, body, rawFrontmatter } = parseFrontmatter(rawContent, frontmatterMapping); 291 - const slug = getSlugFromOptions(relativePath, rawFrontmatter, { 292 - slugSource, 293 - slugField, 294 - removeIndexFromSlug, 295 - }); 312 + try { 313 + const { frontmatter, body, rawFrontmatter } = parseFrontmatter( 314 + rawContent, 315 + frontmatterMapping, 316 + ); 317 + const slug = getSlugFromOptions(relativePath, rawFrontmatter, { 318 + slugSource, 319 + slugField, 320 + removeIndexFromSlug, 321 + }); 296 322 297 - posts.push({ 298 - filePath, 299 - slug, 300 - frontmatter, 301 - content: body, 302 - rawContent, 303 - rawFrontmatter, 304 - }); 305 - } catch (error) { 306 - console.error(`Error parsing ${relativePath}:`, error); 307 - } 308 - } 309 - } 323 + posts.push({ 324 + filePath, 325 + slug, 326 + frontmatter, 327 + content: body, 328 + rawContent, 329 + rawFrontmatter, 330 + }); 331 + } catch (error) { 332 + console.error(`Error parsing ${relativePath}:`, error); 333 + } 334 + } 335 + } 310 336 311 - // Sort by publish date (newest first) 312 - posts.sort((a, b) => { 313 - const dateA = new Date(a.frontmatter.publishDate); 314 - const dateB = new Date(b.frontmatter.publishDate); 315 - return dateB.getTime() - dateA.getTime(); 316 - }); 337 + // Sort by publish date (newest first) 338 + posts.sort((a, b) => { 339 + const dateA = new Date(a.frontmatter.publishDate); 340 + const dateB = new Date(b.frontmatter.publishDate); 341 + return dateB.getTime() - dateA.getTime(); 342 + }); 317 343 318 - return posts; 344 + return posts; 319 345 } 320 346 321 - export function updateFrontmatterWithAtUri(rawContent: string, atUri: string): string { 322 - // Detect which delimiter is used (---, +++, or ***) 323 - const delimiterMatch = rawContent.match(/^(---|\+\+\+|\*\*\*)/); 324 - const delimiter = delimiterMatch?.[1] ?? "---"; 325 - const isToml = delimiter === "+++"; 347 + export function updateFrontmatterWithAtUri( 348 + rawContent: string, 349 + atUri: string, 350 + ): string { 351 + // Detect which delimiter is used (---, +++, or ***) 352 + const delimiterMatch = rawContent.match(/^(---|\+\+\+|\*\*\*)/); 353 + const delimiter = delimiterMatch?.[1] ?? "---"; 354 + const isToml = delimiter === "+++"; 326 355 327 - // Format the atUri entry based on frontmatter type 328 - const atUriEntry = isToml ? `atUri = "${atUri}"` : `atUri: "${atUri}"`; 356 + // Format the atUri entry based on frontmatter type 357 + const atUriEntry = isToml ? `atUri = "${atUri}"` : `atUri: "${atUri}"`; 329 358 330 - // Check if atUri already exists in frontmatter (handle both formats) 331 - if (rawContent.includes("atUri:") || rawContent.includes("atUri =")) { 332 - // Replace existing atUri (match both YAML and TOML formats) 333 - return rawContent.replace(/atUri\s*[=:]\s*["']?[^"'\n]+["']?\n?/, `${atUriEntry}\n`); 334 - } 359 + // Check if atUri already exists in frontmatter (handle both formats) 360 + if (rawContent.includes("atUri:") || rawContent.includes("atUri =")) { 361 + // Replace existing atUri (match both YAML and TOML formats) 362 + return rawContent.replace( 363 + /atUri\s*[=:]\s*["']?[^"'\n]+["']?\n?/, 364 + `${atUriEntry}\n`, 365 + ); 366 + } 335 367 336 - // Insert atUri before the closing delimiter 337 - const frontmatterEndIndex = rawContent.indexOf(delimiter, 4); 338 - if (frontmatterEndIndex === -1) { 339 - throw new Error("Could not find frontmatter end"); 340 - } 368 + // Insert atUri before the closing delimiter 369 + const frontmatterEndIndex = rawContent.indexOf(delimiter, 4); 370 + if (frontmatterEndIndex === -1) { 371 + throw new Error("Could not find frontmatter end"); 372 + } 341 373 342 - const beforeEnd = rawContent.slice(0, frontmatterEndIndex); 343 - const afterEnd = rawContent.slice(frontmatterEndIndex); 374 + const beforeEnd = rawContent.slice(0, frontmatterEndIndex); 375 + const afterEnd = rawContent.slice(frontmatterEndIndex); 344 376 345 - return `${beforeEnd}${atUriEntry}\n${afterEnd}`; 377 + return `${beforeEnd}${atUriEntry}\n${afterEnd}`; 346 378 } 347 379 348 380 export function stripMarkdownForText(markdown: string): string { 349 - return markdown 350 - .replace(/#{1,6}\s/g, "") // Remove headers 351 - .replace(/\*\*([^*]+)\*\*/g, "$1") // Remove bold 352 - .replace(/\*([^*]+)\*/g, "$1") // Remove italic 353 - .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Remove links, keep text 354 - .replace(/`{3}[\s\S]*?`{3}/g, "") // Remove code blocks 355 - .replace(/`([^`]+)`/g, "$1") // Remove inline code formatting 356 - .replace(/!\[.*?\]\(.*?\)/g, "") // Remove images 357 - .replace(/\n{3,}/g, "\n\n") // Normalize multiple newlines 358 - .trim(); 381 + return markdown 382 + .replace(/#{1,6}\s/g, "") // Remove headers 383 + .replace(/\*\*([^*]+)\*\*/g, "$1") // Remove bold 384 + .replace(/\*([^*]+)\*/g, "$1") // Remove italic 385 + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Remove links, keep text 386 + .replace(/`{3}[\s\S]*?`{3}/g, "") // Remove code blocks 387 + .replace(/`([^`]+)`/g, "$1") // Remove inline code formatting 388 + .replace(/!\[.*?\]\(.*?\)/g, "") // Remove images 389 + .replace(/\n{3,}/g, "\n\n") // Normalize multiple newlines 390 + .trim(); 359 391 }
+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 }
+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 }