Skip to content

Commit 32cedb2

Browse files
committed
feat: serve blog avatars directly
1 parent f782b2b commit 32cedb2

2 files changed

Lines changed: 25 additions & 5 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ test-results/
4040
# generated files
4141
shared/types/lexicons
4242
file-tree-sprite.svg
43+
public/blog/avatar
4344

4445
**/__screenshots__/**
4546

modules/blog.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,18 @@ import {
1616
import { globSync } from 'tinyglobby'
1717
import { isProduction } from '../config/env'
1818
import { BLUESKY_API } from '../shared/utils/constants'
19+
import { mkdir, writeFile } from 'node:fs/promises'
20+
import { existsSync } from 'node:fs'
21+
import crypto from 'node:crypto'
1922

2023
/**
2124
* Fetches Bluesky avatars for a set of authors at build time.
2225
* Returns a map of handle → avatar URL.
2326
*/
24-
async function fetchBlueskyAvatars(handles: string[]): Promise<Map<string, string>> {
27+
async function fetchBlueskyAvatars(
28+
imagesDir: string,
29+
handles: string[],
30+
): Promise<Map<string, string>> {
2531
const avatarMap = new Map<string, string>()
2632
if (handles.length === 0) return avatarMap
2733

@@ -44,7 +50,15 @@ async function fetchBlueskyAvatars(handles: string[]): Promise<Map<string, strin
4450

4551
for (const profile of data.profiles) {
4652
if (profile.avatar) {
47-
avatarMap.set(profile.handle, profile.avatar)
53+
const hash = crypto.createHash('sha256').update(profile.avatar).digest('hex')
54+
const dest = join(imagesDir, `${hash}.jpg`)
55+
56+
if (!existsSync(dest)) {
57+
const res = await fetch(profile.avatar)
58+
await writeFile(join(imagesDir, `${hash}.jpg`), res.body!)
59+
}
60+
61+
avatarMap.set(profile.handle, `/blog/avatar/${hash}.jpg`)
4862
}
4963
}
5064
} catch (error) {
@@ -70,7 +84,7 @@ function resolveAuthors(authors: Author[], avatarMap: Map<string, string>): Reso
7084
* Returns all posts (including drafts) sorted by date descending.
7185
* Resolves Bluesky avatars at build time.
7286
*/
73-
async function loadBlogPosts(blogDir: string): Promise<BlogPostFrontmatter[]> {
87+
async function loadBlogPosts(blogDir: string, imagesDir: string): Promise<BlogPostFrontmatter[]> {
7488
const files: string[] = globSync(join(blogDir, '*.md'))
7589

7690
// First pass: extract raw frontmatter and collect all Bluesky handles
@@ -104,7 +118,7 @@ async function loadBlogPosts(blogDir: string): Promise<BlogPostFrontmatter[]> {
104118
}
105119

106120
// Batch-fetch all Bluesky avatars in a single request
107-
const avatarMap = await fetchBlueskyAvatars([...allHandles])
121+
const avatarMap = await fetchBlueskyAvatars(imagesDir, [...allHandles])
108122

109123
// Second pass: validate with raw schema, then enrich authors with avatars
110124
const posts: BlogPostFrontmatter[] = []
@@ -132,12 +146,17 @@ export default defineNuxtModule({
132146
const nuxt = useNuxt()
133147
const resolver = createResolver(import.meta.url)
134148
const blogDir = resolver.resolve('../app/pages/blog')
149+
const blogImagesDir = resolver.resolve('../public/blog/avatar')
135150

136151
nuxt.options.extensions.push('.md')
137152
nuxt.options.vite.vue = defu(nuxt.options.vite.vue, {
138153
include: [/\.vue($|\?)/, /\.(md|markdown)($|\?)/],
139154
})
140155

156+
if (!existsSync(blogImagesDir)) {
157+
await mkdir(blogImagesDir, { recursive: true })
158+
}
159+
141160
addVitePlugin(() =>
142161
Markdown({
143162
include: [/\.(md|markdown)($|\?)/],
@@ -158,7 +177,7 @@ export default defineNuxtModule({
158177
)
159178

160179
// Load posts once with resolved Bluesky avatars (shared across template + route rules)
161-
const allPosts = await loadBlogPosts(blogDir)
180+
const allPosts = await loadBlogPosts(blogDir, blogImagesDir)
162181

163182
// Expose frontmatter for the `/blog` listing page.
164183
const showDrafts = nuxt.options.dev || !isProduction

0 commit comments

Comments
 (0)