chore: added linting and formatting
5dd325f9
14 file(s) · +1278 −1126
| 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 | ||
| 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 | + | } |
| 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 | }, |
| 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 | }); |
| 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"; |
|
| 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", |
| 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, |
| 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 | }); |
| 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 | } |
| 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 | } |
|
| 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 | } |
|
| 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 | } |
| 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 | } |
| 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 | } |