From 0c92b25ae8011da7fc10a6d4c8e4006f8f85e24e Mon Sep 17 00:00:00 2001 From: Heath Stewart Date: Sun, 3 May 2026 21:09:27 -0700 Subject: [PATCH] Validate cover image is < 1MB Fixes #25 --- packages/cli/CHANGELOG.md | 6 +++ packages/cli/src/commands/publish.ts | 58 ++++++++++++++++++++-------- packages/cli/src/lib/atproto.ts | 5 ++- packages/cli/src/lib/config.ts | 2 +- packages/cli/src/lib/types.ts | 1 + 5 files changed, 53 insertions(+), 19 deletions(-) diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index ccc7c61..086d977 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,3 +1,9 @@ +## [0.5.7] + +### 🚀 Features + +- Validate cover image is < 1MB + ## [0.5.6] - 2026-04-25 ### 🐛 Bug Fixes diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts index 5e92af0..2c31c84 100644 --- a/packages/cli/src/commands/publish.ts +++ b/packages/cli/src/commands/publish.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs/promises"; import { command, flag } from "cmd-ts"; import { select, spinner, log } from "@clack/prompts"; import * as path from "node:path"; -import { loadConfig, loadState, saveState, findConfig } from "../lib/config"; +import { CONFIG_FILENAME, loadConfig, loadState, saveState, findConfig } from "../lib/config"; import { loadCredentials, listAllCredentials, @@ -17,6 +17,7 @@ import { resolveImagePath, createBlueskyPost, addBskyPostRefToDocument, + COVER_IMAGE_MAX_SIZE, } from "../lib/atproto"; import { scanContentDirectory, @@ -52,7 +53,7 @@ export const publishCommand = command({ // Load config const configPath = await findConfig(); if (!configPath) { - log.error("No publisher.config.ts found. Run 'publisher init' first."); + log.error(`No ${CONFIG_FILENAME} found. Run 'sequoia init' first.`); process.exit(1); } @@ -261,11 +262,20 @@ export const publishCommand = command({ const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - maxAgeDays); + let isValid = true; for (const { post, action, reason } of postsToPublish) { const icon = action === "create" ? "+" : "~"; const relativeFilePath = path.relative(configDir, post.filePath); const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef; + if (post.frontmatter.ogImage) { + post.coverImagePath = await resolveImagePath( + post.frontmatter.ogImage, + imagesDir, + contentDir, + ); + } + let bskyNote = ""; if (blueskyEnabled) { if (existingBskyPostRef) { @@ -292,6 +302,13 @@ export const publishCommand = command({ log.message( ` ${icon} ${post.frontmatter.title} (${reason})${bskyNote}${postUrl}`, ); + + const postValid = await validatePost(post); + isValid &&= postValid; + } + + if (!isValid) { + return; } if (dryRun) { @@ -329,22 +346,14 @@ export const publishCommand = command({ try { // Handle cover image upload let coverImage: BlobObject | undefined; - if (post.frontmatter.ogImage) { - const imagePath = await resolveImagePath( - post.frontmatter.ogImage, - imagesDir, - contentDir, - ); - - if (imagePath) { - log.info(` Uploading cover image: ${path.basename(imagePath)}`); - coverImage = await uploadImage(agent, imagePath); - if (coverImage) { - log.info(` Uploaded image blob: ${coverImage.ref.$link}`); - } - } else { - log.warn(` Cover image not found: ${post.frontmatter.ogImage}`); + if (post.coverImagePath) { + log.info(` Uploading cover image: ${path.basename(post.coverImagePath)}`); + coverImage = await uploadImage(agent, post.coverImagePath); + if (coverImage) { + log.info(` Uploaded image blob: ${coverImage.ref.$link}`); } + } else { + log.warn(` Cover image not found: ${post.frontmatter.ogImage}`); } // Track atUri, content for state saving, and bskyPostRef @@ -372,6 +381,8 @@ export const publishCommand = command({ contentForHash = updatedContent; publishedCount++; } else { + + // Validate post. atUri = post.frontmatter.atUri!; await updateDocument(agent, post, atUri, config, coverImage); s.stop(`Updated: ${atUri}`); @@ -455,3 +466,16 @@ export const publishCommand = command({ } }, }); + +async function validatePost(post: BlogPost): Promise { + if (post.coverImagePath) { + const stat = await fs.stat(post.coverImagePath); + if (stat.size >= COVER_IMAGE_MAX_SIZE) { + log.error(` Cover image "${post.coverImagePath}" must be less than 1MB`); + return false; + } + } + + return true; +} + diff --git a/packages/cli/src/lib/atproto.ts b/packages/cli/src/lib/atproto.ts index 99ca253..bb7476d 100644 --- a/packages/cli/src/lib/atproto.ts +++ b/packages/cli/src/lib/atproto.ts @@ -14,6 +14,9 @@ import type { } from "./types"; import { isAppPasswordCredentials, isOAuthCredentials } from "./types"; +// https://standard.site/docs/lexicons/document/#optional-properties +export const COVER_IMAGE_MAX_SIZE = 1024 * 1024 - 1; + /** * Type guard to check if a record value is a DocumentRecord */ @@ -189,7 +192,7 @@ export async function resolveImagePath( ogImage: string, imagesDir: string | undefined, contentDir: string, -): Promise { +): Promise { // Try multiple resolution strategies // 1. If imagesDir is specified, look there diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index 40a8b8d..720f4f2 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -7,7 +7,7 @@ import type { BlueskyConfig, } from "./types"; -const CONFIG_FILENAME = "sequoia.json"; +export const CONFIG_FILENAME = "sequoia.json"; const STATE_FILENAME = ".sequoia-state.json"; async function fileExists(filePath: string): Promise { diff --git a/packages/cli/src/lib/types.ts b/packages/cli/src/lib/types.ts index 2cda25f..fa2c4a6 100644 --- a/packages/cli/src/lib/types.ts +++ b/packages/cli/src/lib/types.ts @@ -106,6 +106,7 @@ export interface BlogPost { content: string; rawContent: string; rawFrontmatter: Record; // For accessing custom fields like textContentField + coverImagePath?: string; } export interface BlobRef { -- 2.51.2