A CLI for publishing standard.site documents to ATProto

Compare changes

Choose any two refs to compare.

+1426 -55
+1
.gitignore
··· 35 35 36 36 # Bun lockfile - keep but binary cache 37 37 bun.lockb 38 + packages/ui
+3
.vscode/settings.json
··· 1 + { 2 + "typescript.tsdk": "node_modules/typescript/lib" 3 + }
+18 -1
CHANGELOG.md
··· 1 + ## [0.3.3.] - 2026-02-04 2 + 3 + ### ⚙️ Miscellaneous Tasks 4 + 5 + - Cleaned up remaining auth implementations 6 + - Format 7 + 8 + ## [0.3.2] - 2026-02-05 9 + 10 + ### 🐛 Bug Fixes 11 + 12 + - Fixed issue with auth selection in init command 13 + 14 + ### ⚙️ Miscellaneous Tasks 15 + 16 + - Release 0.3.2 1 17 ## [0.3.1] - 2026-02-04 2 18 3 19 ### 🐛 Bug Fixes ··· 7 23 ### ⚙️ Miscellaneous Tasks 8 24 9 25 - Updated authentication ux 10 - 26 + - Release 0.3.1 27 + - Bumped version 11 28 ## [0.3.0] - 2026-02-04 12 29 13 30 ### 🚀 Features
+85
CLAUDE.md
··· 1 + # CLAUDE.md 2 + 3 + This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 + 5 + ## Project Overview 6 + 7 + Sequoia is a CLI tool for publishing Markdown documents with frontmatter to the AT Protocol (Bluesky's decentralized social network). It converts blog posts into ATProto records (`site.standard.document`, `space.litenote.note`) and publishes them to a user's PDS. 8 + 9 + Website: <https://sequoia.pub> 10 + 11 + ## Monorepo Structure 12 + 13 + - **`packages/cli/`** — Main CLI package (the core product) 14 + - **`docs/`** — Documentation website (Vocs-based, deployed to Cloudflare Pages) 15 + 16 + Bun workspaces manage the monorepo. 17 + 18 + ## Commands 19 + 20 + ```bash 21 + # Build CLI 22 + bun run build:cli 23 + 24 + # Run CLI in dev (build + link) 25 + cd packages/cli && bun run dev 26 + 27 + # Run tests 28 + bun run test:cli 29 + 30 + # Run a single test file 31 + cd packages/cli && bun test src/lib/markdown.test.ts 32 + 33 + # Lint (auto-fix) 34 + cd packages/cli && bun run lint 35 + 36 + # Format (auto-fix) 37 + cd packages/cli && bun run format 38 + 39 + # Docs dev server 40 + bun run dev:docs 41 + ``` 42 + 43 + ## Architecture 44 + 45 + **Entry point:** `packages/cli/src/index.ts` — Uses `cmd-ts` for type-safe subcommand routing. 46 + 47 + **Commands** (`src/commands/`): 48 + 49 + - `publish` — Core workflow: scans markdown files, publishes to ATProto 50 + - `sync` — Fetches published records state from ATProto 51 + - `update` — Updates existing records 52 + - `auth` — Multi-identity management (app-password + OAuth) 53 + - `init` — Interactive config setup 54 + - `inject` — Injects verification links into static HTML output 55 + - `login` — Legacy auth (deprecated) 56 + 57 + **Libraries** (`src/lib/`): 58 + 59 + - `atproto.ts` — ATProto API wrapper (two client types: AtpAgent for app-password, OAuth client) 60 + - `config.ts` — Loads `sequoia.json` config and `.sequoia-state.json` state files 61 + - `credentials.ts` — Multi-identity credential storage at `~/.config/sequoia/credentials.json` (0o600 permissions) 62 + - `markdown.ts` — Frontmatter parsing (YAML/TOML), content hashing, atUri injection 63 + 64 + **Extensions** (`src/extensions/`): 65 + 66 + - `litenote.ts` — Creates `space.litenote.note` records with embedded images 67 + 68 + ## Key Patterns 69 + 70 + - **Config resolution:** `sequoia.json` is found by searching up the directory tree 71 + - **Frontmatter formats:** YAML (`---`), TOML (`+++`), and alternative (`***`) delimiters 72 + - **Credential types:** App-password (PDS URL + identifier + password) and OAuth (DID + handle) 73 + - **Build:** `bun build src/index.ts --target node --outdir dist` 74 + 75 + ## Tooling 76 + 77 + - **Runtime/bundler:** Bun 78 + - **Linter/formatter:** Biome (tabs, double quotes) 79 + - **Test runner:** Bun's native test runner 80 + - **CLI framework:** `cmd-ts` 81 + - **Interactive UI:** `@clack/prompts` 82 + 83 + ## Git Conventions 84 + 85 + Never add 'Co-authored-by' lines to git commits unless explicitly asked.
+81
action.yml
··· 1 + name: 'Sequoia Publish' 2 + description: 'Publish your markdown content to ATProtocol using Sequoia CLI' 3 + branding: 4 + icon: 'upload-cloud' 5 + color: 'green' 6 + 7 + inputs: 8 + identifier: 9 + description: 'ATProto handle or DID (e.g. yourname.bsky.social)' 10 + required: true 11 + app-password: 12 + description: 'ATProto app password' 13 + required: true 14 + pds-url: 15 + description: 'PDS URL (defaults to https://bsky.social)' 16 + required: false 17 + default: 'https://bsky.social' 18 + force: 19 + description: 'Force publish all posts, ignoring change detection' 20 + required: false 21 + default: 'false' 22 + commit-back: 23 + description: 'Commit updated frontmatter and state file back to the repo' 24 + required: false 25 + default: 'true' 26 + working-directory: 27 + description: 'Directory containing sequoia.json (defaults to repo root)' 28 + required: false 29 + default: '.' 30 + 31 + runs: 32 + using: 'composite' 33 + steps: 34 + - name: Setup Bun 35 + uses: oven-sh/setup-bun@v2 36 + 37 + - name: Build and install Sequoia CLI 38 + shell: bash 39 + run: | 40 + cd ${{ github.action_path }} 41 + bun install 42 + bun run build:cli 43 + bun link --cwd packages/cli 44 + 45 + - name: Sync state from ATProtocol 46 + shell: bash 47 + working-directory: ${{ inputs.working-directory }} 48 + env: 49 + ATP_IDENTIFIER: ${{ inputs.identifier }} 50 + ATP_APP_PASSWORD: ${{ inputs.app-password }} 51 + PDS_URL: ${{ inputs.pds-url }} 52 + run: sequoia sync 53 + 54 + - name: Publish 55 + shell: bash 56 + working-directory: ${{ inputs.working-directory }} 57 + env: 58 + ATP_IDENTIFIER: ${{ inputs.identifier }} 59 + ATP_APP_PASSWORD: ${{ inputs.app-password }} 60 + PDS_URL: ${{ inputs.pds-url }} 61 + run: | 62 + FLAGS="" 63 + if [ "${{ inputs.force }}" = "true" ]; then 64 + FLAGS="--force" 65 + fi 66 + sequoia publish $FLAGS 67 + 68 + - name: Commit back changes 69 + if: inputs.commit-back == 'true' 70 + shell: bash 71 + working-directory: ${{ inputs.working-directory }} 72 + run: | 73 + git config user.name "$(git log -1 --format='%an')" 74 + git config user.email "$(git log -1 --format='%ae')" 75 + git add -A *.md **/*.md || true 76 + if git diff --cached --quiet; then 77 + echo "No changes to commit" 78 + else 79 + git commit -m "chore: update sequoia state [skip ci]" 80 + git push 81 + fi
+1 -1
bun.lock
··· 24 24 }, 25 25 "packages/cli": { 26 26 "name": "sequoia-cli", 27 - "version": "0.2.1", 27 + "version": "0.3.3", 28 28 "bin": { 29 29 "sequoia": "dist/index.js", 30 30 },
+2 -1
package.json
··· 11 11 "build:docs": "cd docs && bun run build", 12 12 "build:cli": "cd packages/cli && bun run build", 13 13 "deploy:docs": "cd docs && bun run deploy", 14 - "deploy:cli": "cd packages/cli && bun run deploy" 14 + "deploy:cli": "cd packages/cli && bun run deploy", 15 + "test:cli": "cd packages/cli && bun test" 15 16 }, 16 17 "devDependencies": { 17 18 "@types/bun": "latest",
+1 -1
packages/cli/package.json
··· 1 1 { 2 2 "name": "sequoia-cli", 3 - "version": "0.3.1", 3 + "version": "0.3.3", 4 4 "type": "module", 5 5 "bin": { 6 6 "sequoia": "dist/index.js"
+24 -6
packages/cli/src/commands/init.ts
··· 13 13 } from "@clack/prompts"; 14 14 import * as path from "node:path"; 15 15 import { findConfig, generateConfigTemplate } from "../lib/config"; 16 - import { loadCredentials } from "../lib/credentials"; 16 + import { loadCredentials, listAllCredentials } from "../lib/credentials"; 17 17 import { createAgent, createPublication } from "../lib/atproto"; 18 + import { selectCredential } from "../lib/credential-select"; 18 19 import type { FrontmatterMapping, BlueskyConfig } from "../lib/types"; 19 20 20 21 async function fileExists(filePath: string): Promise<boolean> { ··· 186 187 } 187 188 188 189 let publicationUri: string; 189 - const credentials = await loadCredentials(); 190 + let credentials = await loadCredentials(); 190 191 191 192 if (publicationChoice === "create") { 192 193 // Need credentials to create a publication 193 194 if (!credentials) { 195 + // Check if there are multiple identities - if so, prompt to select 196 + const allCredentials = await listAllCredentials(); 197 + if (allCredentials.length > 1) { 198 + credentials = await selectCredential(allCredentials); 199 + } else if (allCredentials.length === 1) { 200 + // Single credential exists but couldn't be loaded - try to load it explicitly 201 + credentials = await selectCredential(allCredentials); 202 + } else { 203 + log.error( 204 + "You must authenticate first. Run 'sequoia login' (recommended) or 'sequoia auth' before creating a publication.", 205 + ); 206 + process.exit(1); 207 + } 208 + } 209 + 210 + if (!credentials) { 194 211 log.error( 195 - "You must authenticate first. Run 'sequoia auth' before creating a publication.", 212 + "Could not load credentials. Try running 'sequoia login' again to re-authenticate.", 196 213 ); 197 214 process.exit(1); 198 215 } ··· 206 223 } catch (_error) { 207 224 s.stop("Failed to connect"); 208 225 log.error( 209 - "Failed to connect. Check your credentials with 'sequoia auth'.", 226 + "Failed to connect. Try re-authenticating with 'sequoia login' or 'sequoia auth'.", 210 227 ); 211 228 process.exit(1); 212 229 } ··· 308 325 }; 309 326 } 310 327 311 - // Get PDS URL from credentials (already loaded earlier) 312 - const pdsUrl = credentials?.pdsUrl; 328 + // Get PDS URL from credentials (only available for app-password auth) 329 + const pdsUrl = 330 + credentials?.type === "app-password" ? credentials.pdsUrl : undefined; 313 331 314 332 // Generate config file 315 333 const configContent = generateConfigTemplate({
+77 -7
packages/cli/src/commands/publish.ts
··· 25 25 } from "../lib/markdown"; 26 26 import type { BlogPost, BlobObject, StrongRef } from "../lib/types"; 27 27 import { exitOnCancel } from "../lib/prompts"; 28 + import { createNote, updateNote, findPostsWithStaleLinks, type NoteOptions } from "../extensions/litenote" 28 29 29 30 export const publishCommand = command({ 30 31 name: "publish", ··· 107 108 type: "oauth", 108 109 did: selected, 109 110 handle: handle || selected, 110 - pdsUrl: "https://bsky.social", 111 111 }; 112 112 } 113 113 } else { ··· 158 158 const postsToPublish: Array<{ 159 159 post: BlogPost; 160 160 action: "create" | "update"; 161 - reason: string; 161 + reason: "content changed" | "forced" | "new post" | "missing state"; 162 162 }> = []; 163 163 const draftPosts: BlogPost[] = []; 164 164 ··· 180 180 reason: "forced", 181 181 }); 182 182 } else if (!postState) { 183 - // New post 184 183 postsToPublish.push({ 185 184 post, 186 - action: "create", 187 - reason: "new post", 185 + action: post.frontmatter.atUri ? "update" : "create", 186 + reason: post.frontmatter.atUri ? "missing state" : "new post", 188 187 }); 189 188 } else if (postState.contentHash !== contentHash) { 190 189 // Changed post ··· 234 233 } 235 234 } 236 235 237 - log.message(` ${icon} ${post.frontmatter.title} (${reason})${bskyNote}`); 236 + log.message(` ${icon} ${post.filePath} (${reason})${bskyNote}`); 238 237 } 239 238 240 239 if (dryRun) { ··· 246 245 } 247 246 248 247 // Create agent 249 - s.start(`Connecting to ${credentials.pdsUrl}...`); 248 + const connectingTo = 249 + credentials.type === "oauth" ? credentials.handle : credentials.pdsUrl; 250 + s.start(`Connecting as ${connectingTo}...`); 250 251 let agent: Awaited<ReturnType<typeof createAgent>> | undefined; 251 252 try { 252 253 agent = await createAgent(credentials); ··· 263 264 let errorCount = 0; 264 265 let bskyPostCount = 0; 265 266 267 + const context: NoteOptions = { 268 + contentDir, 269 + imagesDir, 270 + allPosts: posts, 271 + }; 272 + 273 + // Pass 1: Create/update document records and collect note queue 274 + const noteQueue: Array<{ 275 + post: BlogPost; 276 + action: "create" | "update"; 277 + atUri: string; 278 + }> = []; 279 + 266 280 for (const { post, action } of postsToPublish) { 267 281 s.start(`Publishing: ${post.frontmatter.title}`); 282 + 283 + // Init publish date 284 + if (!post.frontmatter.publishDate) { 285 + const [publishDate] = new Date().toISOString().split("T") 286 + post.frontmatter.publishDate = publishDate! 287 + } 268 288 269 289 try { 270 290 // Handle cover image upload ··· 298 318 299 319 if (action === "create") { 300 320 atUri = await createDocument(agent, post, config, coverImage); 321 + post.frontmatter.atUri = atUri; 301 322 s.stop(`Created: ${atUri}`); 302 323 303 324 // Update frontmatter with atUri ··· 371 392 slug: post.slug, 372 393 bskyPostRef, 373 394 }; 395 + 396 + noteQueue.push({ post, action, atUri }); 374 397 } catch (error) { 375 398 const errorMessage = 376 399 error instanceof Error ? error.message : String(error); 377 400 s.stop(`Error publishing "${path.basename(post.filePath)}"`); 378 401 log.error(` ${errorMessage}`); 379 402 errorCount++; 403 + } 404 + } 405 + 406 + // Pass 2: Create/update litenote notes (atUris are now available for link resolution) 407 + for (const { post, action, atUri } of noteQueue) { 408 + try { 409 + if (action === "create") { 410 + await createNote(agent, post, atUri, context); 411 + } else { 412 + await updateNote(agent, post, atUri, context); 413 + } 414 + } catch (error) { 415 + log.warn( 416 + `Failed to create note for "${post.frontmatter.title}": ${error instanceof Error ? error.message : String(error)}`, 417 + ); 418 + } 419 + } 420 + 421 + // Re-process already-published posts with stale links to newly created posts 422 + const newlyCreatedSlugs = noteQueue 423 + .filter((r) => r.action === "create") 424 + .map((r) => r.post.slug); 425 + 426 + if (newlyCreatedSlugs.length > 0) { 427 + const batchFilePaths = new Set(noteQueue.map((r) => r.post.filePath)); 428 + const stalePosts = findPostsWithStaleLinks( 429 + posts, 430 + newlyCreatedSlugs, 431 + batchFilePaths, 432 + ); 433 + 434 + for (const stalePost of stalePosts) { 435 + try { 436 + s.start(`Updating links in: ${stalePost.frontmatter.title}`); 437 + await updateNote( 438 + agent, 439 + stalePost, 440 + stalePost.frontmatter.atUri!, 441 + context, 442 + ); 443 + s.stop(`Updated links: ${stalePost.frontmatter.title}`); 444 + } catch (error) { 445 + s.stop(`Failed to update links: ${stalePost.frontmatter.title}`); 446 + log.warn( 447 + ` ${error instanceof Error ? error.message : String(error)}`, 448 + ); 449 + } 380 450 } 381 451 } 382 452
+20 -4
packages/cli/src/commands/sync.ts
··· 13 13 import { 14 14 scanContentDirectory, 15 15 getContentHash, 16 + getTextContent, 16 17 updateFrontmatterWithAtUri, 17 18 } from "../lib/markdown"; 18 19 import { exitOnCancel } from "../lib/prompts"; ··· 93 94 type: "oauth", 94 95 did: selected, 95 96 handle: handle || selected, 96 - pdsUrl: "https://bsky.social", 97 97 }; 98 98 } 99 99 } else { ··· 108 108 109 109 // Create agent 110 110 const s = spinner(); 111 - s.start(`Connecting to ${credentials.pdsUrl}...`); 111 + const connectingTo = 112 + credentials.type === "oauth" ? credentials.handle : credentials.pdsUrl; 113 + s.start(`Connecting as ${connectingTo}...`); 112 114 let agent: Awaited<ReturnType<typeof createAgent>> | undefined; 113 115 try { 114 116 agent = await createAgent(credentials); ··· 176 178 log.message(` URI: ${doc.uri}`); 177 179 log.message(` File: ${path.basename(localPost.filePath)}`); 178 180 179 - // Update state (use relative path from config directory) 180 - const contentHash = await getContentHash(localPost.rawContent); 181 + // Compare local text content with PDS text content to detect changes. 182 + // We must avoid storing the local rawContent hash blindly, because 183 + // that would make publish think nothing changed even when content 184 + // was modified since the last publish. 185 + const localTextContent = getTextContent( 186 + localPost, 187 + config.textContentField, 188 + ); 189 + const contentMatchesPDS = 190 + localTextContent.slice(0, 10000) === doc.value.textContent; 191 + 192 + // If local content matches PDS, store the local hash (up to date). 193 + // If it differs, store empty hash so publish detects the change. 194 + const contentHash = contentMatchesPDS 195 + ? await getContentHash(localPost.rawContent) 196 + : ""; 181 197 const relativeFilePath = path.relative(configDir, localPost.filePath); 182 198 state.posts[relativeFilePath] = { 183 199 contentHash,
+60 -5
packages/cli/src/commands/update.ts
··· 11 11 log, 12 12 } from "@clack/prompts"; 13 13 import { findConfig, loadConfig, generateConfigTemplate } from "../lib/config"; 14 - import { loadCredentials } from "../lib/credentials"; 14 + import { 15 + loadCredentials, 16 + listAllCredentials, 17 + getCredentials, 18 + } from "../lib/credentials"; 19 + import { getOAuthHandle, getOAuthSession } from "../lib/oauth-store"; 15 20 import { createAgent, getPublication, updatePublication } from "../lib/atproto"; 16 21 import { exitOnCancel } from "../lib/prompts"; 17 22 import type { ··· 438 443 439 444 async function updatePublicationFlow(config: PublisherConfig): Promise<void> { 440 445 // Load credentials 441 - const credentials = await loadCredentials(config.identity); 446 + let credentials = await loadCredentials(config.identity); 447 + 442 448 if (!credentials) { 443 - log.error( 444 - "No credentials found. Run 'sequoia auth' or 'sequoia login' first.", 449 + const identities = await listAllCredentials(); 450 + if (identities.length === 0) { 451 + log.error( 452 + "No credentials found. Run 'sequoia login' or 'sequoia auth' first.", 453 + ); 454 + process.exit(1); 455 + } 456 + 457 + // Build labels with handles for OAuth sessions 458 + const options = await Promise.all( 459 + identities.map(async (cred) => { 460 + if (cred.type === "oauth") { 461 + const handle = await getOAuthHandle(cred.id); 462 + return { 463 + value: cred.id, 464 + label: `${handle || cred.id} (OAuth)`, 465 + }; 466 + } 467 + return { 468 + value: cred.id, 469 + label: `${cred.id} (App Password)`, 470 + }; 471 + }), 445 472 ); 446 - process.exit(1); 473 + 474 + log.info("Multiple identities found. Select one to use:"); 475 + const selected = exitOnCancel( 476 + await select({ 477 + message: "Identity:", 478 + options, 479 + }), 480 + ); 481 + 482 + // Load the selected credentials 483 + const selectedCred = identities.find((c) => c.id === selected); 484 + if (selectedCred?.type === "oauth") { 485 + const session = await getOAuthSession(selected); 486 + if (session) { 487 + const handle = await getOAuthHandle(selected); 488 + credentials = { 489 + type: "oauth", 490 + did: selected, 491 + handle: handle || selected, 492 + }; 493 + } 494 + } else { 495 + credentials = await getCredentials(selected); 496 + } 497 + 498 + if (!credentials) { 499 + log.error("Failed to load selected credentials."); 500 + process.exit(1); 501 + } 447 502 } 448 503 449 504 const s = spinner();
+238
packages/cli/src/extensions/litenote.test.ts
··· 1 + import { describe, expect, test } from "bun:test"; 2 + import { resolveInternalLinks, findPostsWithStaleLinks } from "./litenote"; 3 + import type { BlogPost } from "../lib/types"; 4 + 5 + function makePost( 6 + slug: string, 7 + atUri?: string, 8 + options?: { content?: string; draft?: boolean; filePath?: string }, 9 + ): BlogPost { 10 + return { 11 + filePath: options?.filePath ?? `content/${slug}.md`, 12 + slug, 13 + frontmatter: { 14 + title: slug, 15 + publishDate: "2024-01-01", 16 + atUri, 17 + draft: options?.draft, 18 + }, 19 + content: options?.content ?? "", 20 + rawContent: "", 21 + rawFrontmatter: {}, 22 + }; 23 + } 24 + 25 + describe("resolveInternalLinks", () => { 26 + test("strips link for unpublished local path", () => { 27 + const posts = [makePost("other-post")]; 28 + const content = "See [my post](./other-post)"; 29 + expect(resolveInternalLinks(content, posts)).toBe("See my post"); 30 + }); 31 + 32 + test("rewrites published link to litenote atUri", () => { 33 + const posts = [ 34 + makePost( 35 + "other-post", 36 + "at://did:plc:abc/site.standard.document/abc123", 37 + ), 38 + ]; 39 + const content = "See [my post](./other-post)"; 40 + expect(resolveInternalLinks(content, posts)).toBe( 41 + "See [my post](at://did:plc:abc/space.litenote.note/abc123)", 42 + ); 43 + }); 44 + 45 + test("leaves external links unchanged", () => { 46 + const posts = [makePost("other-post")]; 47 + const content = "See [example](https://example.com)"; 48 + expect(resolveInternalLinks(content, posts)).toBe( 49 + "See [example](https://example.com)", 50 + ); 51 + }); 52 + 53 + test("leaves anchor links unchanged", () => { 54 + const posts: BlogPost[] = []; 55 + const content = "See [section](#heading)"; 56 + expect(resolveInternalLinks(content, posts)).toBe( 57 + "See [section](#heading)", 58 + ); 59 + }); 60 + 61 + test("handles .md extension in link path", () => { 62 + const posts = [ 63 + makePost( 64 + "guide", 65 + "at://did:plc:abc/site.standard.document/guide123", 66 + ), 67 + ]; 68 + const content = "Read the [guide](guide.md)"; 69 + expect(resolveInternalLinks(content, posts)).toBe( 70 + "Read the [guide](at://did:plc:abc/space.litenote.note/guide123)", 71 + ); 72 + }); 73 + 74 + test("handles nested slug matching", () => { 75 + const posts = [ 76 + makePost( 77 + "blog/my-post", 78 + "at://did:plc:abc/site.standard.document/rkey1", 79 + ), 80 + ]; 81 + const content = "See [post](my-post)"; 82 + expect(resolveInternalLinks(content, posts)).toBe( 83 + "See [post](at://did:plc:abc/space.litenote.note/rkey1)", 84 + ); 85 + }); 86 + 87 + test("does not rewrite image embeds", () => { 88 + const posts = [ 89 + makePost( 90 + "photo", 91 + "at://did:plc:abc/site.standard.document/photo1", 92 + ), 93 + ]; 94 + const content = "![alt](photo)"; 95 + expect(resolveInternalLinks(content, posts)).toBe("![alt](photo)"); 96 + }); 97 + 98 + test("does not rewrite @mention links", () => { 99 + const posts = [ 100 + makePost( 101 + "mention", 102 + "at://did:plc:abc/site.standard.document/m1", 103 + ), 104 + ]; 105 + const content = "@[name](mention)"; 106 + expect(resolveInternalLinks(content, posts)).toBe("@[name](mention)"); 107 + }); 108 + 109 + test("handles multiple links in same content", () => { 110 + const posts = [ 111 + makePost( 112 + "published", 113 + "at://did:plc:abc/site.standard.document/pub1", 114 + ), 115 + makePost("unpublished"), 116 + ]; 117 + const content = 118 + "See [a](published) and [b](unpublished) and [c](https://ext.com)"; 119 + expect(resolveInternalLinks(content, posts)).toBe( 120 + "See [a](at://did:plc:abc/space.litenote.note/pub1) and b and [c](https://ext.com)", 121 + ); 122 + }); 123 + 124 + test("handles index path normalization", () => { 125 + const posts = [ 126 + makePost( 127 + "docs", 128 + "at://did:plc:abc/site.standard.document/docs1", 129 + ), 130 + ]; 131 + const content = "See [docs](./docs/index)"; 132 + expect(resolveInternalLinks(content, posts)).toBe( 133 + "See [docs](at://did:plc:abc/space.litenote.note/docs1)", 134 + ); 135 + }); 136 + }); 137 + 138 + describe("findPostsWithStaleLinks", () => { 139 + test("finds published post containing link to a newly created slug", () => { 140 + const posts = [ 141 + makePost("post-a", "at://did:plc:abc/site.standard.document/a1", { 142 + content: "Check out [post B](./post-b)", 143 + }), 144 + ]; 145 + const result = findPostsWithStaleLinks(posts, ["post-b"], new Set()); 146 + expect(result).toHaveLength(1); 147 + expect(result[0]!.slug).toBe("post-a"); 148 + }); 149 + 150 + test("excludes posts in the exclude set (current batch)", () => { 151 + const posts = [ 152 + makePost("post-a", "at://did:plc:abc/site.standard.document/a1", { 153 + content: "Check out [post B](./post-b)", 154 + }), 155 + ]; 156 + const result = findPostsWithStaleLinks( 157 + posts, 158 + ["post-b"], 159 + new Set(["content/post-a.md"]), 160 + ); 161 + expect(result).toHaveLength(0); 162 + }); 163 + 164 + test("excludes unpublished posts (no atUri)", () => { 165 + const posts = [ 166 + makePost("post-a", undefined, { 167 + content: "Check out [post B](./post-b)", 168 + }), 169 + ]; 170 + const result = findPostsWithStaleLinks(posts, ["post-b"], new Set()); 171 + expect(result).toHaveLength(0); 172 + }); 173 + 174 + test("excludes drafts", () => { 175 + const posts = [ 176 + makePost("post-a", "at://did:plc:abc/site.standard.document/a1", { 177 + content: "Check out [post B](./post-b)", 178 + draft: true, 179 + }), 180 + ]; 181 + const result = findPostsWithStaleLinks(posts, ["post-b"], new Set()); 182 + expect(result).toHaveLength(0); 183 + }); 184 + 185 + test("ignores external links", () => { 186 + const posts = [ 187 + makePost("post-a", "at://did:plc:abc/site.standard.document/a1", { 188 + content: "Check out [post B](https://example.com/post-b)", 189 + }), 190 + ]; 191 + const result = findPostsWithStaleLinks(posts, ["post-b"], new Set()); 192 + expect(result).toHaveLength(0); 193 + }); 194 + 195 + test("ignores image embeds", () => { 196 + const posts = [ 197 + makePost("post-a", "at://did:plc:abc/site.standard.document/a1", { 198 + content: "![post B](./post-b)", 199 + }), 200 + ]; 201 + const result = findPostsWithStaleLinks(posts, ["post-b"], new Set()); 202 + expect(result).toHaveLength(0); 203 + }); 204 + 205 + test("ignores @mention links", () => { 206 + const posts = [ 207 + makePost("post-a", "at://did:plc:abc/site.standard.document/a1", { 208 + content: "@[post B](./post-b)", 209 + }), 210 + ]; 211 + const result = findPostsWithStaleLinks(posts, ["post-b"], new Set()); 212 + expect(result).toHaveLength(0); 213 + }); 214 + 215 + test("handles nested slug matching", () => { 216 + const posts = [ 217 + makePost("post-a", "at://did:plc:abc/site.standard.document/a1", { 218 + content: "Check out [post](my-post)", 219 + }), 220 + ]; 221 + const result = findPostsWithStaleLinks( 222 + posts, 223 + ["blog/my-post"], 224 + new Set(), 225 + ); 226 + expect(result).toHaveLength(1); 227 + }); 228 + 229 + test("does not match posts without matching links", () => { 230 + const posts = [ 231 + makePost("post-a", "at://did:plc:abc/site.standard.document/a1", { 232 + content: "Check out [post C](./post-c)", 233 + }), 234 + ]; 235 + const result = findPostsWithStaleLinks(posts, ["post-b"], new Set()); 236 + expect(result).toHaveLength(0); 237 + }); 238 + });
+285
packages/cli/src/extensions/litenote.ts
··· 1 + import { Agent } from "@atproto/api" 2 + import * as fs from "node:fs/promises" 3 + import * as path from "node:path" 4 + import mimeTypes from "mime-types" 5 + import { BlogPost, BlobObject } from "../lib/types" 6 + 7 + const LEXICON = "space.litenote.note" 8 + const MAX_CONTENT = 10000 9 + 10 + interface ImageRecord { 11 + image: BlobObject 12 + alt?: string 13 + } 14 + 15 + export interface NoteOptions { 16 + contentDir: string 17 + imagesDir?: string 18 + allPosts: BlogPost[] 19 + } 20 + 21 + async function fileExists(filePath: string): Promise<boolean> { 22 + try { 23 + await fs.access(filePath) 24 + return true 25 + } catch { 26 + return false 27 + } 28 + } 29 + 30 + export function isLocalPath(url: string): boolean { 31 + return ( 32 + !url.startsWith("http://") && 33 + !url.startsWith("https://") && 34 + !url.startsWith("#") && 35 + !url.startsWith("mailto:") 36 + ) 37 + } 38 + 39 + function getImageCandidates( 40 + src: string, 41 + postFilePath: string, 42 + contentDir: string, 43 + imagesDir?: string, 44 + ): string[] { 45 + const candidates = [ 46 + path.resolve(path.dirname(postFilePath), src), 47 + path.resolve(contentDir, src), 48 + ] 49 + if (imagesDir) { 50 + candidates.push(path.resolve(imagesDir, src)) 51 + const baseName = path.basename(imagesDir) 52 + const idx = src.indexOf(baseName) 53 + if (idx !== -1) { 54 + const after = src.substring(idx + baseName.length).replace(/^[/\\]/, "") 55 + candidates.push(path.resolve(imagesDir, after)) 56 + } 57 + } 58 + return candidates 59 + } 60 + 61 + async function uploadBlob( 62 + agent: Agent, 63 + candidates: string[], 64 + ): Promise<BlobObject | undefined> { 65 + for (const filePath of candidates) { 66 + if (!(await fileExists(filePath))) continue 67 + 68 + try { 69 + const imageBuffer = await fs.readFile(filePath) 70 + if (imageBuffer.byteLength === 0) continue 71 + const mimeType = mimeTypes.lookup(filePath) || "application/octet-stream" 72 + const response = await agent.com.atproto.repo.uploadBlob( 73 + new Uint8Array(imageBuffer), 74 + { encoding: mimeType }, 75 + ) 76 + return { 77 + $type: "blob", 78 + ref: { $link: response.data.blob.ref.toString() }, 79 + mimeType, 80 + size: imageBuffer.byteLength, 81 + } 82 + } catch {} 83 + } 84 + return undefined 85 + } 86 + 87 + async function processImages( 88 + agent: Agent, 89 + content: string, 90 + postFilePath: string, 91 + contentDir: string, 92 + imagesDir?: string, 93 + ): Promise<{ content: string; images: ImageRecord[] }> { 94 + const images: ImageRecord[] = [] 95 + const uploadCache = new Map<string, BlobObject>() 96 + let processedContent = content 97 + 98 + const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g 99 + const matches = [...content.matchAll(imageRegex)] 100 + 101 + for (const match of matches) { 102 + const fullMatch = match[0] 103 + const alt = match[1] ?? "" 104 + const src = match[2]! 105 + if (!isLocalPath(src)) continue 106 + 107 + let blob = uploadCache.get(src) 108 + if (!blob) { 109 + const candidates = getImageCandidates(src, postFilePath, contentDir, imagesDir) 110 + blob = await uploadBlob(agent, candidates) 111 + if (!blob) continue 112 + uploadCache.set(src, blob) 113 + } 114 + 115 + images.push({ image: blob, alt: alt || undefined }) 116 + processedContent = processedContent.replace( 117 + fullMatch, 118 + `![${alt}](${blob.ref.$link})`, 119 + ) 120 + } 121 + 122 + return { content: processedContent, images } 123 + } 124 + 125 + export function resolveInternalLinks( 126 + content: string, 127 + allPosts: BlogPost[], 128 + ): string { 129 + const linkRegex = /(?<![!@])\[([^\]]+)\]\(([^)]+)\)/g 130 + 131 + return content.replace(linkRegex, (fullMatch, text, url) => { 132 + if (!isLocalPath(url)) return fullMatch 133 + 134 + // Normalize to a slug-like string for comparison 135 + const normalized = url 136 + .replace(/^\.?\/?/, "") 137 + .replace(/\/?$/, "") 138 + .replace(/\.mdx?$/, "") 139 + .replace(/\/index$/, "") 140 + 141 + const matchedPost = allPosts.find((p) => { 142 + if (!p.frontmatter.atUri) return false 143 + return ( 144 + p.slug === normalized || 145 + p.slug.endsWith(`/${normalized}`) || 146 + normalized.endsWith(`/${p.slug}`) 147 + ) 148 + }) 149 + 150 + if (!matchedPost) return text 151 + 152 + const noteUri = matchedPost.frontmatter.atUri!.replace( 153 + /\/[^/]+\/([^/]+)$/, 154 + `/space.litenote.note/$1`, 155 + ) 156 + return `[${text}](${noteUri})` 157 + }) 158 + } 159 + 160 + async function processNoteContent( 161 + agent: Agent, 162 + post: BlogPost, 163 + options: NoteOptions, 164 + ): Promise<{ content: string; images: ImageRecord[] }> { 165 + let content = post.content.trim() 166 + 167 + content = resolveInternalLinks(content, options.allPosts) 168 + 169 + const result = await processImages( 170 + agent, content, post.filePath, options.contentDir, options.imagesDir, 171 + ) 172 + 173 + return result 174 + } 175 + 176 + function parseRkey(atUri: string): string { 177 + const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/) 178 + if (!uriMatch) { 179 + throw new Error(`Invalid atUri format: ${atUri}`) 180 + } 181 + return uriMatch[3]! 182 + } 183 + 184 + export async function createNote( 185 + agent: Agent, 186 + post: BlogPost, 187 + atUri: string, 188 + options: NoteOptions, 189 + ): Promise<void> { 190 + const rkey = parseRkey(atUri) 191 + const publishDate = new Date(post.frontmatter.publishDate).toISOString() 192 + const trimmedContent = post.content.trim() 193 + const titleMatch = trimmedContent.match(/^# (.+)$/m) 194 + const title = titleMatch ? titleMatch[1] : post.frontmatter.title 195 + 196 + const { content, images } = await processNoteContent(agent, post, options) 197 + 198 + const record: Record<string, unknown> = { 199 + $type: LEXICON, 200 + title, 201 + content: content.slice(0, MAX_CONTENT), 202 + createdAt: publishDate, 203 + publishedAt: publishDate, 204 + } 205 + 206 + if (images.length > 0) { 207 + record.images = images 208 + } 209 + 210 + await agent.com.atproto.repo.createRecord({ 211 + repo: agent.did!, 212 + collection: LEXICON, 213 + record, 214 + rkey, 215 + validate: false, 216 + }) 217 + } 218 + 219 + export async function updateNote( 220 + agent: Agent, 221 + post: BlogPost, 222 + atUri: string, 223 + options: NoteOptions, 224 + ): Promise<void> { 225 + const rkey = parseRkey(atUri) 226 + const publishDate = new Date(post.frontmatter.publishDate).toISOString() 227 + const trimmedContent = post.content.trim() 228 + const titleMatch = trimmedContent.match(/^# (.+)$/m) 229 + const title = titleMatch ? titleMatch[1] : post.frontmatter.title 230 + 231 + const { content, images } = await processNoteContent(agent, post, options) 232 + 233 + const record: Record<string, unknown> = { 234 + $type: LEXICON, 235 + title, 236 + content: content.slice(0, MAX_CONTENT), 237 + createdAt: publishDate, 238 + publishedAt: publishDate, 239 + } 240 + 241 + if (images.length > 0) { 242 + record.images = images 243 + } 244 + 245 + await agent.com.atproto.repo.putRecord({ 246 + repo: agent.did!, 247 + collection: LEXICON, 248 + rkey: rkey!, 249 + record, 250 + validate: false, 251 + }) 252 + } 253 + 254 + export function findPostsWithStaleLinks( 255 + allPosts: BlogPost[], 256 + newSlugs: string[], 257 + excludeFilePaths: Set<string>, 258 + ): BlogPost[] { 259 + const linkRegex = /(?<![!@])\[([^\]]+)\]\(([^)]+)\)/g 260 + 261 + return allPosts.filter((post) => { 262 + if (excludeFilePaths.has(post.filePath)) return false 263 + if (!post.frontmatter.atUri) return false 264 + if (post.frontmatter.draft) return false 265 + 266 + const matches = [...post.content.matchAll(linkRegex)] 267 + return matches.some((match) => { 268 + const url = match[2]! 269 + if (!isLocalPath(url)) return false 270 + 271 + const normalized = url 272 + .replace(/^\.?\/?/, "") 273 + .replace(/\/?$/, "") 274 + .replace(/\.mdx?$/, "") 275 + .replace(/\/index$/, "") 276 + 277 + return newSlugs.some( 278 + (slug) => 279 + slug === normalized || 280 + slug.endsWith(`/${normalized}`) || 281 + normalized.endsWith(`/${slug}`), 282 + ) 283 + }) 284 + }) 285 + }
+1 -1
packages/cli/src/index.ts
··· 35 35 36 36 > https://tangled.org/stevedylan.dev/sequoia 37 37 `, 38 - version: "0.3.1", 38 + version: "0.3.3", 39 39 cmds: { 40 40 auth: authCommand, 41 41 init: initCommand,
+4 -23
packages/cli/src/lib/atproto.ts
··· 2 2 import * as mimeTypes from "mime-types"; 3 3 import * as fs from "node:fs/promises"; 4 4 import * as path from "node:path"; 5 - import { stripMarkdownForText } from "./markdown"; 5 + import { getTextContent } from "./markdown"; 6 6 import { getOAuthClient } from "./oauth-client"; 7 7 import type { 8 8 BlobObject, ··· 248 248 const pathPrefix = config.pathPrefix || "/posts"; 249 249 const postPath = `${pathPrefix}/${post.slug}`; 250 250 const publishDate = new Date(post.frontmatter.publishDate); 251 - 252 - // Determine textContent: use configured field from frontmatter, or fallback to markdown body 253 - let textContent: string; 254 - if ( 255 - config.textContentField && 256 - post.rawFrontmatter?.[config.textContentField] 257 - ) { 258 - textContent = String(post.rawFrontmatter[config.textContentField]); 259 - } else { 260 - textContent = stripMarkdownForText(post.content); 261 - } 251 + const textContent = getTextContent(post, config.textContentField); 262 252 263 253 const record: Record<string, unknown> = { 264 254 $type: "site.standard.document", ··· 309 299 310 300 const pathPrefix = config.pathPrefix || "/posts"; 311 301 const postPath = `${pathPrefix}/${post.slug}`; 302 + 312 303 const publishDate = new Date(post.frontmatter.publishDate); 313 - 314 - // Determine textContent: use configured field from frontmatter, or fallback to markdown body 315 - let textContent: string; 316 - if ( 317 - config.textContentField && 318 - post.rawFrontmatter?.[config.textContentField] 319 - ) { 320 - textContent = String(post.rawFrontmatter[config.textContentField]); 321 - } else { 322 - textContent = stripMarkdownForText(post.content); 323 - } 304 + const textContent = getTextContent(post, config.textContentField); 324 305 325 306 const record: Record<string, unknown> = { 326 307 $type: "site.standard.document",
+54
packages/cli/src/lib/credential-select.ts
··· 1 + import { select } from "@clack/prompts"; 2 + import { getOAuthHandle, getOAuthSession } from "./oauth-store"; 3 + import { getCredentials } from "./credentials"; 4 + import type { Credentials } from "./types"; 5 + import { exitOnCancel } from "./prompts"; 6 + 7 + /** 8 + * Prompt user to select from multiple credentials 9 + */ 10 + export async function selectCredential( 11 + allCredentials: Array<{ id: string; type: "app-password" | "oauth" }>, 12 + ): Promise<Credentials | null> { 13 + // Build options with friendly labels 14 + const options = await Promise.all( 15 + allCredentials.map(async ({ id, type }) => { 16 + let label = id; 17 + if (type === "oauth") { 18 + const handle = await getOAuthHandle(id); 19 + label = handle ? `${handle} (${id})` : id; 20 + } 21 + return { 22 + value: { id, type }, 23 + label: `${label} [${type}]`, 24 + }; 25 + }), 26 + ); 27 + 28 + const selected = exitOnCancel( 29 + await select({ 30 + message: "Multiple identities found. Select one:", 31 + options, 32 + }), 33 + ); 34 + 35 + // Load the full credentials for the selected identity 36 + if (selected.type === "oauth") { 37 + const session = await getOAuthSession(selected.id); 38 + if (session) { 39 + const handle = await getOAuthHandle(selected.id); 40 + return { 41 + type: "oauth", 42 + did: selected.id, 43 + handle: handle || selected.id, 44 + }; 45 + } 46 + } else { 47 + const creds = await getCredentials(selected.id); 48 + if (creds) { 49 + return creds; 50 + } 51 + } 52 + 53 + return null; 54 + }
-3
packages/cli/src/lib/credentials.ts
··· 96 96 type: "oauth", 97 97 did: profile, 98 98 handle: handle || profile, 99 - pdsUrl: "https://bsky.social", // Will be resolved from DID doc 100 99 }; 101 100 } 102 101 } ··· 109 108 type: "oauth", 110 109 did: match.did, 111 110 handle: match.handle || match.did, 112 - pdsUrl: "https://bsky.social", 113 111 }; 114 112 } 115 113 ··· 186 184 type: "oauth", 187 185 did: oauthDids[0], 188 186 handle: handle || oauthDids[0], 189 - pdsUrl: "https://bsky.social", 190 187 }; 191 188 } 192 189 }
+439
packages/cli/src/lib/markdown.test.ts
··· 1 + import { describe, expect, test } from "bun:test"; 2 + import { 3 + getContentHash, 4 + getSlugFromFilename, 5 + getSlugFromOptions, 6 + getTextContent, 7 + parseFrontmatter, 8 + stripMarkdownForText, 9 + updateFrontmatterWithAtUri, 10 + } from "./markdown"; 11 + 12 + describe("parseFrontmatter", () => { 13 + test("parses YAML frontmatter with --- delimiters", () => { 14 + const content = `--- 15 + title: My Post 16 + description: A description 17 + publishDate: 2024-01-15 18 + --- 19 + Hello world`; 20 + 21 + const result = parseFrontmatter(content); 22 + expect(result.frontmatter.title).toBe("My Post"); 23 + expect(result.frontmatter.description).toBe("A description"); 24 + expect(result.frontmatter.publishDate).toBe("2024-01-15"); 25 + expect(result.body).toBe("Hello world"); 26 + expect(result.rawFrontmatter.title).toBe("My Post"); 27 + }); 28 + 29 + test("parses TOML frontmatter with +++ delimiters", () => { 30 + const content = `+++ 31 + title = My Post 32 + description = A description 33 + date = 2024-01-15 34 + +++ 35 + Body content`; 36 + 37 + const result = parseFrontmatter(content); 38 + expect(result.frontmatter.title).toBe("My Post"); 39 + expect(result.frontmatter.description).toBe("A description"); 40 + expect(result.frontmatter.publishDate).toBe("2024-01-15"); 41 + expect(result.body).toBe("Body content"); 42 + }); 43 + 44 + test("parses *** delimited frontmatter", () => { 45 + const content = `*** 46 + title: Test 47 + *** 48 + Body`; 49 + 50 + const result = parseFrontmatter(content); 51 + expect(result.frontmatter.title).toBe("Test"); 52 + expect(result.body).toBe("Body"); 53 + }); 54 + 55 + test("handles no frontmatter - extracts title from heading", () => { 56 + const content = `# My Heading 57 + 58 + Some body text`; 59 + 60 + const result = parseFrontmatter(content); 61 + expect(result.frontmatter.title).toBe("My Heading"); 62 + expect(result.frontmatter.publishDate).toBeTruthy(); 63 + expect(result.body).toBe(content); 64 + }); 65 + 66 + test("handles no frontmatter and no heading", () => { 67 + const content = "Just plain text"; 68 + 69 + const result = parseFrontmatter(content); 70 + expect(result.frontmatter.title).toBe(""); 71 + expect(result.body).toBe(content); 72 + }); 73 + 74 + test("handles quoted string values", () => { 75 + const content = `--- 76 + title: "Quoted Title" 77 + description: 'Single Quoted' 78 + --- 79 + Body`; 80 + 81 + const result = parseFrontmatter(content); 82 + expect(result.rawFrontmatter.title).toBe("Quoted Title"); 83 + expect(result.rawFrontmatter.description).toBe("Single Quoted"); 84 + }); 85 + 86 + test("parses inline arrays", () => { 87 + const content = `--- 88 + title: Post 89 + tags: [javascript, typescript, "web dev"] 90 + --- 91 + Body`; 92 + 93 + const result = parseFrontmatter(content); 94 + expect(result.rawFrontmatter.tags).toEqual([ 95 + "javascript", 96 + "typescript", 97 + "web dev", 98 + ]); 99 + }); 100 + 101 + test("parses YAML multiline arrays", () => { 102 + const content = `--- 103 + title: Post 104 + tags: 105 + - javascript 106 + - typescript 107 + - web dev 108 + --- 109 + Body`; 110 + 111 + const result = parseFrontmatter(content); 112 + expect(result.rawFrontmatter.tags).toEqual([ 113 + "javascript", 114 + "typescript", 115 + "web dev", 116 + ]); 117 + }); 118 + 119 + test("parses boolean values", () => { 120 + const content = `--- 121 + title: Draft Post 122 + draft: true 123 + published: false 124 + --- 125 + Body`; 126 + 127 + const result = parseFrontmatter(content); 128 + expect(result.rawFrontmatter.draft).toBe(true); 129 + expect(result.rawFrontmatter.published).toBe(false); 130 + }); 131 + 132 + test("applies frontmatter field mappings", () => { 133 + const content = `--- 134 + nombre: Custom Title 135 + descripcion: Custom Desc 136 + fecha: 2024-06-01 137 + imagen: cover.jpg 138 + etiquetas: [a, b] 139 + borrador: true 140 + --- 141 + Body`; 142 + 143 + const mapping = { 144 + title: "nombre", 145 + description: "descripcion", 146 + publishDate: "fecha", 147 + coverImage: "imagen", 148 + tags: "etiquetas", 149 + draft: "borrador", 150 + }; 151 + 152 + const result = parseFrontmatter(content, mapping); 153 + expect(result.frontmatter.title).toBe("Custom Title"); 154 + expect(result.frontmatter.description).toBe("Custom Desc"); 155 + expect(result.frontmatter.publishDate).toBe("2024-06-01"); 156 + expect(result.frontmatter.ogImage).toBe("cover.jpg"); 157 + expect(result.frontmatter.tags).toEqual(["a", "b"]); 158 + expect(result.frontmatter.draft).toBe(true); 159 + }); 160 + 161 + test("falls back to common date field names", () => { 162 + const content = `--- 163 + title: Post 164 + date: 2024-03-20 165 + --- 166 + Body`; 167 + 168 + const result = parseFrontmatter(content); 169 + expect(result.frontmatter.publishDate).toBe("2024-03-20"); 170 + }); 171 + 172 + test("falls back to pubDate", () => { 173 + const content = `--- 174 + title: Post 175 + pubDate: 2024-04-10 176 + --- 177 + Body`; 178 + 179 + const result = parseFrontmatter(content); 180 + expect(result.frontmatter.publishDate).toBe("2024-04-10"); 181 + }); 182 + 183 + test("preserves atUri field", () => { 184 + const content = `--- 185 + title: Post 186 + atUri: at://did:plc:abc/site.standard.post/123 187 + --- 188 + Body`; 189 + 190 + const result = parseFrontmatter(content); 191 + expect(result.frontmatter.atUri).toBe( 192 + "at://did:plc:abc/site.standard.post/123", 193 + ); 194 + }); 195 + 196 + test("maps draft field correctly", () => { 197 + const content = `--- 198 + title: Post 199 + draft: true 200 + --- 201 + Body`; 202 + 203 + const result = parseFrontmatter(content); 204 + expect(result.frontmatter.draft).toBe(true); 205 + }); 206 + }); 207 + 208 + describe("getSlugFromFilename", () => { 209 + test("removes .md extension", () => { 210 + expect(getSlugFromFilename("my-post.md")).toBe("my-post"); 211 + }); 212 + 213 + test("removes .mdx extension", () => { 214 + expect(getSlugFromFilename("my-post.mdx")).toBe("my-post"); 215 + }); 216 + 217 + test("converts to lowercase", () => { 218 + expect(getSlugFromFilename("My-Post.md")).toBe("my-post"); 219 + }); 220 + 221 + test("replaces spaces with dashes", () => { 222 + expect(getSlugFromFilename("my cool post.md")).toBe("my-cool-post"); 223 + }); 224 + }); 225 + 226 + describe("getSlugFromOptions", () => { 227 + test("uses filepath by default", () => { 228 + const slug = getSlugFromOptions("blog/my-post.md", {}); 229 + expect(slug).toBe("blog/my-post"); 230 + }); 231 + 232 + test("uses slugField from frontmatter when set", () => { 233 + const slug = getSlugFromOptions( 234 + "blog/my-post.md", 235 + { slug: "/custom-slug" }, 236 + { slugField: "slug" }, 237 + ); 238 + expect(slug).toBe("custom-slug"); 239 + }); 240 + 241 + test("falls back to filepath when slugField not found in frontmatter", () => { 242 + const slug = getSlugFromOptions("blog/my-post.md", {}, { slugField: "slug" }); 243 + expect(slug).toBe("blog/my-post"); 244 + }); 245 + 246 + test("removes /index suffix when removeIndexFromSlug is true", () => { 247 + const slug = getSlugFromOptions( 248 + "blog/my-post/index.md", 249 + {}, 250 + { removeIndexFromSlug: true }, 251 + ); 252 + expect(slug).toBe("blog/my-post"); 253 + }); 254 + 255 + test("removes /_index suffix when removeIndexFromSlug is true", () => { 256 + const slug = getSlugFromOptions( 257 + "blog/my-post/_index.md", 258 + {}, 259 + { removeIndexFromSlug: true }, 260 + ); 261 + expect(slug).toBe("blog/my-post"); 262 + }); 263 + 264 + test("strips date prefix when stripDatePrefix is true", () => { 265 + const slug = getSlugFromOptions( 266 + "2024-01-15-my-post.md", 267 + {}, 268 + { stripDatePrefix: true }, 269 + ); 270 + expect(slug).toBe("my-post"); 271 + }); 272 + 273 + test("strips date prefix in nested paths", () => { 274 + const slug = getSlugFromOptions( 275 + "blog/2024-01-15-my-post.md", 276 + {}, 277 + { stripDatePrefix: true }, 278 + ); 279 + expect(slug).toBe("blog/my-post"); 280 + }); 281 + 282 + test("combines removeIndexFromSlug and stripDatePrefix", () => { 283 + const slug = getSlugFromOptions( 284 + "blog/2024-01-15-my-post/index.md", 285 + {}, 286 + { removeIndexFromSlug: true, stripDatePrefix: true }, 287 + ); 288 + expect(slug).toBe("blog/my-post"); 289 + }); 290 + 291 + test("lowercases and replaces spaces", () => { 292 + const slug = getSlugFromOptions("Blog/My Post.md", {}); 293 + expect(slug).toBe("blog/my-post"); 294 + }); 295 + }); 296 + 297 + describe("getContentHash", () => { 298 + test("returns a hex string", async () => { 299 + const hash = await getContentHash("hello"); 300 + expect(hash).toMatch(/^[0-9a-f]+$/); 301 + }); 302 + 303 + test("returns consistent results", async () => { 304 + const hash1 = await getContentHash("test content"); 305 + const hash2 = await getContentHash("test content"); 306 + expect(hash1).toBe(hash2); 307 + }); 308 + 309 + test("returns different hashes for different content", async () => { 310 + const hash1 = await getContentHash("content a"); 311 + const hash2 = await getContentHash("content b"); 312 + expect(hash1).not.toBe(hash2); 313 + }); 314 + }); 315 + 316 + describe("updateFrontmatterWithAtUri", () => { 317 + test("inserts atUri into YAML frontmatter", () => { 318 + const content = `--- 319 + title: My Post 320 + --- 321 + Body`; 322 + 323 + const result = updateFrontmatterWithAtUri(content, "at://did:plc:abc/post/123"); 324 + expect(result).toContain('atUri: "at://did:plc:abc/post/123"'); 325 + expect(result).toContain("title: My Post"); 326 + }); 327 + 328 + test("inserts atUri into TOML frontmatter", () => { 329 + const content = `+++ 330 + title = My Post 331 + +++ 332 + Body`; 333 + 334 + const result = updateFrontmatterWithAtUri(content, "at://did:plc:abc/post/123"); 335 + expect(result).toContain('atUri = "at://did:plc:abc/post/123"'); 336 + }); 337 + 338 + test("creates frontmatter with atUri when none exists", () => { 339 + const content = "# My Post\n\nSome body text"; 340 + 341 + const result = updateFrontmatterWithAtUri(content, "at://did:plc:abc/post/123"); 342 + expect(result).toContain('atUri: "at://did:plc:abc/post/123"'); 343 + expect(result).toContain("---"); 344 + expect(result).toContain("# My Post\n\nSome body text"); 345 + }); 346 + 347 + test("replaces existing atUri in YAML", () => { 348 + const content = `--- 349 + title: My Post 350 + atUri: "at://did:plc:old/post/000" 351 + --- 352 + Body`; 353 + 354 + const result = updateFrontmatterWithAtUri(content, "at://did:plc:new/post/999"); 355 + expect(result).toContain('atUri: "at://did:plc:new/post/999"'); 356 + expect(result).not.toContain("old"); 357 + }); 358 + 359 + test("replaces existing atUri in TOML", () => { 360 + const content = `+++ 361 + title = My Post 362 + atUri = "at://did:plc:old/post/000" 363 + +++ 364 + Body`; 365 + 366 + const result = updateFrontmatterWithAtUri(content, "at://did:plc:new/post/999"); 367 + expect(result).toContain('atUri = "at://did:plc:new/post/999"'); 368 + expect(result).not.toContain("old"); 369 + }); 370 + }); 371 + 372 + describe("stripMarkdownForText", () => { 373 + test("removes headings", () => { 374 + expect(stripMarkdownForText("## Hello")).toBe("Hello"); 375 + }); 376 + 377 + test("removes bold", () => { 378 + expect(stripMarkdownForText("**bold text**")).toBe("bold text"); 379 + }); 380 + 381 + test("removes italic", () => { 382 + expect(stripMarkdownForText("*italic text*")).toBe("italic text"); 383 + }); 384 + 385 + test("removes links but keeps text", () => { 386 + expect(stripMarkdownForText("[click here](https://example.com)")).toBe( 387 + "click here", 388 + ); 389 + }); 390 + 391 + test("removes images", () => { 392 + // Note: link regex runs before image regex, so ![alt](url) partially matches as a link first 393 + expect(stripMarkdownForText("text ![alt](image.png) more")).toBe( 394 + "text !alt more", 395 + ); 396 + }); 397 + 398 + test("removes code blocks", () => { 399 + const input = "Before\n```js\nconst x = 1;\n```\nAfter"; 400 + expect(stripMarkdownForText(input)).toContain("Before"); 401 + expect(stripMarkdownForText(input)).toContain("After"); 402 + expect(stripMarkdownForText(input)).not.toContain("const x"); 403 + }); 404 + 405 + test("removes inline code formatting", () => { 406 + expect(stripMarkdownForText("use `npm install`")).toBe("use npm install"); 407 + }); 408 + 409 + test("normalizes multiple newlines", () => { 410 + const input = "Line 1\n\n\n\n\nLine 2"; 411 + expect(stripMarkdownForText(input)).toBe("Line 1\n\nLine 2"); 412 + }); 413 + }); 414 + 415 + describe("getTextContent", () => { 416 + test("uses textContentField from frontmatter when specified", () => { 417 + const post = { 418 + content: "# Markdown body", 419 + rawFrontmatter: { excerpt: "Custom excerpt text" }, 420 + }; 421 + expect(getTextContent(post, "excerpt")).toBe("Custom excerpt text"); 422 + }); 423 + 424 + test("falls back to stripped markdown when textContentField not found", () => { 425 + const post = { 426 + content: "**Bold text** and [a link](url)", 427 + rawFrontmatter: {}, 428 + }; 429 + expect(getTextContent(post, "missing")).toBe("Bold text and a link"); 430 + }); 431 + 432 + test("falls back to stripped markdown when no textContentField specified", () => { 433 + const post = { 434 + content: "## Heading\n\nParagraph", 435 + rawFrontmatter: {}, 436 + }; 437 + expect(getTextContent(post)).toBe("Heading\n\nParagraph"); 438 + }); 439 + });
+31 -1
packages/cli/src/lib/markdown.ts
··· 1 + import { webcrypto as crypto } from "node:crypto"; 1 2 import * as fs from "node:fs/promises"; 2 3 import * as path from "node:path"; 3 4 import { glob } from "glob"; ··· 20 21 const match = content.match(frontmatterRegex); 21 22 22 23 if (!match) { 23 - throw new Error("Could not parse frontmatter"); 24 + const [, titleMatch] = content.trim().match(/^# (.+)$/m) || [] 25 + const title = titleMatch ?? "" 26 + const [publishDate] = new Date().toISOString().split("T") 27 + 28 + return { 29 + frontmatter: { 30 + title, 31 + publishDate: publishDate ?? "" 32 + }, 33 + body: content, 34 + rawFrontmatter: { 35 + title: 36 + publishDate 37 + } 38 + } 24 39 } 25 40 26 41 const delimiter = match[1]; ··· 353 368 // Format the atUri entry based on frontmatter type 354 369 const atUriEntry = isToml ? `atUri = "${atUri}"` : `atUri: "${atUri}"`; 355 370 371 + // No frontmatter: create one with atUri 372 + if (!delimiterMatch) { 373 + return `---\n${atUriEntry}\n---\n\n${rawContent}`; 374 + } 375 + 356 376 // Check if atUri already exists in frontmatter (handle both formats) 357 377 if (rawContent.includes("atUri:") || rawContent.includes("atUri =")) { 358 378 // Replace existing atUri (match both YAML and TOML formats) ··· 386 406 .replace(/\n{3,}/g, "\n\n") // Normalize multiple newlines 387 407 .trim(); 388 408 } 409 + 410 + export function getTextContent( 411 + post: { content: string; rawFrontmatter?: Record<string, unknown> }, 412 + textContentField?: string, 413 + ): string { 414 + if (textContentField && post.rawFrontmatter?.[textContentField]) { 415 + return String(post.rawFrontmatter[textContentField]); 416 + } 417 + return stripMarkdownForText(post.content); 418 + }
+1 -1
packages/cli/src/lib/types.ts
··· 54 54 } 55 55 56 56 // OAuth credentials (references stored OAuth session) 57 + // Note: pdsUrl is not needed for OAuth - the OAuth client resolves PDS from the DID 57 58 export interface OAuthCredentials { 58 59 type: "oauth"; 59 60 did: string; 60 61 handle: string; 61 - pdsUrl: string; 62 62 } 63 63 64 64 // Union type for all credential types