From 79ead54ccc4f537b0c550eead21f60d4f695137a Mon Sep 17 00:00:00 2001 From: Heath Stewart Date: Mon, 1 Jun 2026 23:51:47 -0700 Subject: [PATCH] Include cover image in content hash Fixes #47 --- packages/cli/src/commands/publish.ts | 31 +++++++------- packages/cli/src/lib/markdown.ts | 29 +++++++++++-- packages/cli/src/lib/sync.ts | 14 ++++--- packages/cli/test/markdown.test.ts | 61 ++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 25 deletions(-) diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts index 6295237..d8964d5 100644 --- a/packages/cli/src/commands/publish.ts +++ b/packages/cli/src/commands/publish.ts @@ -198,7 +198,7 @@ export const publishCommand = command({ // Scan for posts s.start("Scanning for posts..."); - const posts = await scanContentDirectory(contentDir, { + const posts = await scanContentDirectory(contentDir, imagesDir, { frontmatterMapping: config.frontmatter, ignorePatterns: config.ignore, slugField: config.frontmatter?.slugField, @@ -222,7 +222,16 @@ export const publishCommand = command({ continue; } - const contentHash = await getContentHash(post.rawContent); + // Resolve the cover image to include it in the hash. + if (post.frontmatter.ogImage) { + post.coverImagePath = await resolveImagePath( + post.frontmatter.ogImage, + imagesDir, + contentDir, + ); + } + + const contentHash = await getContentHash(post); const relativeFilePath = path.relative(configDir, post.filePath); const postState = state.posts[relativeFilePath]; @@ -274,14 +283,6 @@ export const publishCommand = command({ 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) { @@ -375,7 +376,6 @@ export const publishCommand = command({ // Track atUri, content for state saving, and bskyPostRef let documentRef: StrongRef; - let contentForHash: string; let bskyPostRef: StrongRef | undefined; const relativeFilePath = path.relative(configDir, post.filePath); @@ -387,15 +387,13 @@ export const publishCommand = command({ s.stop(`Created: ${documentRef.uri}`); // Update frontmatter with atUri - const updatedContent = updateFrontmatterWithAtUri( + post.rawContent = updateFrontmatterWithAtUri( post.rawContent, documentRef.uri, ); - await fs.writeFile(post.filePath, updatedContent); + await fs.writeFile(post.filePath, post.rawContent); log.info(` Updated frontmatter in ${path.basename(post.filePath)}`); - // Use updated content (with atUri) for hash so next run sees matching hash - contentForHash = updatedContent; publishedCount++; } else { // Validate post. @@ -410,7 +408,6 @@ export const publishCommand = command({ s.stop(`Updated: ${documentRef.uri}`); // For updates, rawContent already has atUri - contentForHash = post.rawContent; updatedCount++; } @@ -462,7 +459,7 @@ export const publishCommand = command({ } // Update state (use relative path from config directory) - const contentHash = await getContentHash(contentForHash); + const contentHash = await getContentHash(post); state.posts[relativeFilePath] = { contentHash, atUri: documentRef.uri, diff --git a/packages/cli/src/lib/markdown.ts b/packages/cli/src/lib/markdown.ts index 7536f9b..e66d70b 100644 --- a/packages/cli/src/lib/markdown.ts +++ b/packages/cli/src/lib/markdown.ts @@ -5,6 +5,7 @@ import yaml from "js-yaml"; import { minimatch } from "minimatch"; import * as toml from "smol-toml"; import type { BlogPost, FrontmatterMapping, PostFrontmatter } from "./types"; +import { resolveImagePath } from "./atproto"; export function parseFrontmatter( content: string, @@ -235,9 +236,19 @@ export function resolvePostPath( return prefix ? `${prefix}/${post.slug}` : `/${post.slug}`; } -export async function getContentHash(content: string): Promise { - const encoder = new TextEncoder(); - const data = encoder.encode(content); +export async function getContentHash(post: Partial): Promise { + const encoder = new TextEncoderStream(); + const writer = encoder.writable.getWriter(); + const dataPromise = new Response(encoder.readable).arrayBuffer(); + + await writer.write(post?.rawContent); + if (post.coverImagePath) { + const coverImage = await fs.readFile(post.coverImagePath); + await writer.write(coverImage.toString("base64")); + } + await writer.close(); + + const data = await dataPromise; const hashBuffer = await crypto.subtle.digest("SHA-256", data); const hashArray = Array.from(new Uint8Array(hashBuffer)); return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); @@ -262,6 +273,7 @@ export interface ScanOptions { export async function scanContentDirectory( contentDir: string, + imagesDir: string | undefined, frontmatterMappingOrOptions?: FrontmatterMapping | ScanOptions, ignorePatterns: string[] = [], ): Promise { @@ -321,6 +333,16 @@ export async function scanContentDirectory( stripDatePrefix, }); + // Resolve the cover image to include it in the hash. + let coverImagePath: string | undefined; + if (frontmatter.ogImage) { + coverImagePath = await resolveImagePath( + frontmatter.ogImage, + imagesDir, + contentDir, + ); + } + posts.push({ filePath, slug, @@ -328,6 +350,7 @@ export async function scanContentDirectory( content: body, rawContent, rawFrontmatter, + coverImagePath, }); } catch (error) { console.error(`Error parsing ${relativePath}:`, error); diff --git a/packages/cli/src/lib/sync.ts b/packages/cli/src/lib/sync.ts index daa0c9e..80d314a 100644 --- a/packages/cli/src/lib/sync.ts +++ b/packages/cli/src/lib/sync.ts @@ -58,8 +58,14 @@ export async function syncStateFromPDS( ? config.contentDir : path.join(configDir, config.contentDir); + const imagesDir = config.imagesDir + ? path.isAbsolute(config.imagesDir) + ? config.imagesDir + : path.join(configDir, config.imagesDir) + : undefined; + // Scan local posts - const localPosts = await scanContentDirectory(contentDir, { + const localPosts = await scanContentDirectory(contentDir, imagesDir, { frontmatterMapping: config.frontmatter, ignorePatterns: config.ignore, slugField: config.frontmatter?.slugField, @@ -138,14 +144,12 @@ export async function syncStateFromPDS( // so the state matches what will be on disk after the update let contentHash: string; if (needsFrontmatterUpdate) { - const updatedContent = updateFrontmatterWithAtUri( + localPost.rawContent = updateFrontmatterWithAtUri( localPost.rawContent, doc.uri, ); - contentHash = await getContentHash(updatedContent); - } else { - contentHash = await getContentHash(localPost.rawContent); } + contentHash = await getContentHash(localPost); // Update state (preserve bskyPostRef from prior publishes) const existing = state.posts[relativeFilePath]; diff --git a/packages/cli/test/markdown.test.ts b/packages/cli/test/markdown.test.ts index c1152e9..6967530 100644 --- a/packages/cli/test/markdown.test.ts +++ b/packages/cli/test/markdown.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from "bun:test"; import { + getContentHash, parseFrontmatter, resolvePostPath, stripMarkdownForText, } from "../src/lib/markdown"; import type { BlogPost } from "../src/lib/types"; +import type { BlobOptions } from "node:buffer"; describe("parseFrontmatter", () => { describe("delimiters", () => { @@ -492,3 +494,62 @@ describe("stripMarkdownForText", () => { expect(stripMarkdownForText("\n\nhello\n\n")).toBe("hello"); }); }); + +describe("getContentHash", () => { + it("returns the expected SHA-256 hash as lowercase hex", async () => { + const post: Partial = { + rawContent: "hello world", + }; + expect(await getContentHash(post)).toBe( + "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", + ); + }); + + it("returns the same hash for identical content and different hashes for different content", async () => { + const firstPost: Partial = { + rawContent: "same content", + }; + const secondPost: Partial = { + rawContent: "same content", + }; + const thirdPost: Partial = { + rawContent: "different content", + }; + const first = await getContentHash(firstPost); + const second = await getContentHash(secondPost); + const third = await getContentHash(thirdPost); + + expect(first).toBe(second); + expect(first).not.toBe(third); + expect(first).toMatch(/^[a-f0-9]{64}$/); + }); + + it("returns a different hash with a coverImagePath", async () => { + const textPost: Partial = { + rawContent: "hello world", + }; + const imagePost: Partial = { + rawContent: "hello world", + coverImagePath: __filename, + }; + const textHash = await getContentHash(textPost); + const imageHash = await getContentHash(imagePost); + + expect(textHash).not.toBe(imageHash); + }); + + it("returns a different hash with a different coverImagePath", async () => { + const firstPost: Partial = { + rawContent: "hello world", + coverImagePath: __filename, + }; + const secondPost: Partial = { + rawContent: "hello world", + coverImagePath: `${__dirname}/config.test.ts`, + }; + const firstHash = await getContentHash(firstPost); + const secondHash = await getContentHash(secondPost); + + expect(firstHash).not.toBe(secondHash); + }); +}); -- 2.51.2