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

Configure Feed

Select the types of activity you want to include in your feed.

Validate cover image is < 1MB

Fixes #25

+53 -19
+6
packages/cli/CHANGELOG.md
··· 1 + ## [0.5.7] 2 + 3 + ### 🚀 Features 4 + 5 + - Validate cover image is < 1MB 6 + 1 7 ## [0.5.6] - 2026-04-25 2 8 3 9 ### 🐛 Bug Fixes
+41 -17
packages/cli/src/commands/publish.ts
··· 2 2 import { command, flag } from "cmd-ts"; 3 3 import { select, spinner, log } from "@clack/prompts"; 4 4 import * as path from "node:path"; 5 - import { loadConfig, loadState, saveState, findConfig } from "../lib/config"; 5 + import { CONFIG_FILENAME, loadConfig, loadState, saveState, findConfig } from "../lib/config"; 6 6 import { 7 7 loadCredentials, 8 8 listAllCredentials, ··· 17 17 resolveImagePath, 18 18 createBlueskyPost, 19 19 addBskyPostRefToDocument, 20 + COVER_IMAGE_MAX_SIZE, 20 21 } from "../lib/atproto"; 21 22 import { 22 23 scanContentDirectory, ··· 52 53 // Load config 53 54 const configPath = await findConfig(); 54 55 if (!configPath) { 55 - log.error("No publisher.config.ts found. Run 'publisher init' first."); 56 + log.error(`No ${CONFIG_FILENAME} found. Run 'sequoia init' first.`); 56 57 process.exit(1); 57 58 } 58 59 ··· 261 262 const cutoffDate = new Date(); 262 263 cutoffDate.setDate(cutoffDate.getDate() - maxAgeDays); 263 264 265 + let isValid = true; 264 266 for (const { post, action, reason } of postsToPublish) { 265 267 const icon = action === "create" ? "+" : "~"; 266 268 const relativeFilePath = path.relative(configDir, post.filePath); 267 269 const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef; 270 + 271 + if (post.frontmatter.ogImage) { 272 + post.coverImagePath = await resolveImagePath( 273 + post.frontmatter.ogImage, 274 + imagesDir, 275 + contentDir, 276 + ); 277 + } 268 278 269 279 let bskyNote = ""; 270 280 if (blueskyEnabled) { ··· 292 302 log.message( 293 303 ` ${icon} ${post.frontmatter.title} (${reason})${bskyNote}${postUrl}`, 294 304 ); 305 + 306 + const postValid = await validatePost(post); 307 + isValid &&= postValid; 308 + } 309 + 310 + if (!isValid) { 311 + return; 295 312 } 296 313 297 314 if (dryRun) { ··· 329 346 try { 330 347 // Handle cover image upload 331 348 let coverImage: BlobObject | undefined; 332 - if (post.frontmatter.ogImage) { 333 - const imagePath = await resolveImagePath( 334 - post.frontmatter.ogImage, 335 - imagesDir, 336 - contentDir, 337 - ); 338 - 339 - if (imagePath) { 340 - log.info(` Uploading cover image: ${path.basename(imagePath)}`); 341 - coverImage = await uploadImage(agent, imagePath); 342 - if (coverImage) { 343 - log.info(` Uploaded image blob: ${coverImage.ref.$link}`); 344 - } 345 - } else { 346 - log.warn(` Cover image not found: ${post.frontmatter.ogImage}`); 349 + if (post.coverImagePath) { 350 + log.info(` Uploading cover image: ${path.basename(post.coverImagePath)}`); 351 + coverImage = await uploadImage(agent, post.coverImagePath); 352 + if (coverImage) { 353 + log.info(` Uploaded image blob: ${coverImage.ref.$link}`); 347 354 } 355 + } else { 356 + log.warn(` Cover image not found: ${post.frontmatter.ogImage}`); 348 357 } 349 358 350 359 // Track atUri, content for state saving, and bskyPostRef ··· 372 381 contentForHash = updatedContent; 373 382 publishedCount++; 374 383 } else { 384 + 385 + // Validate post. 375 386 atUri = post.frontmatter.atUri!; 376 387 await updateDocument(agent, post, atUri, config, coverImage); 377 388 s.stop(`Updated: ${atUri}`); ··· 455 466 } 456 467 }, 457 468 }); 469 + 470 + async function validatePost(post: BlogPost): Promise<boolean> { 471 + if (post.coverImagePath) { 472 + const stat = await fs.stat(post.coverImagePath); 473 + if (stat.size >= COVER_IMAGE_MAX_SIZE) { 474 + log.error(` Cover image "${post.coverImagePath}" must be less than 1MB`); 475 + return false; 476 + } 477 + } 478 + 479 + return true; 480 + } 481 +
+4 -1
packages/cli/src/lib/atproto.ts
··· 14 14 } from "./types"; 15 15 import { isAppPasswordCredentials, isOAuthCredentials } from "./types"; 16 16 17 + // https://standard.site/docs/lexicons/document/#optional-properties 18 + export const COVER_IMAGE_MAX_SIZE = 1024 * 1024 - 1; 19 + 17 20 /** 18 21 * Type guard to check if a record value is a DocumentRecord 19 22 */ ··· 189 192 ogImage: string, 190 193 imagesDir: string | undefined, 191 194 contentDir: string, 192 - ): Promise<string | null> { 195 + ): Promise<string | undefined> { 193 196 // Try multiple resolution strategies 194 197 195 198 // 1. If imagesDir is specified, look there
+1 -1
packages/cli/src/lib/config.ts
··· 7 7 BlueskyConfig, 8 8 } from "./types"; 9 9 10 - const CONFIG_FILENAME = "sequoia.json"; 10 + export const CONFIG_FILENAME = "sequoia.json"; 11 11 const STATE_FILENAME = ".sequoia-state.json"; 12 12 13 13 async function fileExists(filePath: string): Promise<boolean> {
+1
packages/cli/src/lib/types.ts
··· 106 106 content: string; 107 107 rawContent: string; 108 108 rawFrontmatter: Record<string, unknown>; // For accessing custom fields like textContentField 109 + coverImagePath?: string; 109 110 } 110 111 111 112 export interface BlobRef {