Skip to content

Commit e60895b

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

1 file changed

Lines changed: 26 additions & 5 deletions

File tree

modules/blog.ts

Lines changed: 26 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,10 @@ 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 res = await fetch(profile.avatar)
55+
await writeFile(join(imagesDir, `${hash}.jpg`), res.body!)
56+
avatarMap.set(profile.handle, `/blog/avatar/${hash}.jpg`)
4857
}
4958
}
5059
} catch (error) {
@@ -70,7 +79,7 @@ function resolveAuthors(authors: Author[], avatarMap: Map<string, string>): Reso
7079
* Returns all posts (including drafts) sorted by date descending.
7180
* Resolves Bluesky avatars at build time.
7281
*/
73-
async function loadBlogPosts(blogDir: string): Promise<BlogPostFrontmatter[]> {
82+
async function loadBlogPosts(blogDir: string, imagesDir: string): Promise<BlogPostFrontmatter[]> {
7483
const files: string[] = globSync(join(blogDir, '*.md'))
7584

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

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

109118
// Second pass: validate with raw schema, then enrich authors with avatars
110119
const posts: BlogPostFrontmatter[] = []
@@ -132,12 +141,24 @@ export default defineNuxtModule({
132141
const nuxt = useNuxt()
133142
const resolver = createResolver(import.meta.url)
134143
const blogDir = resolver.resolve('../app/pages/blog')
144+
const blogImagesDir = resolver.resolve('../dist/blog')
135145

136146
nuxt.options.extensions.push('.md')
137147
nuxt.options.vite.vue = defu(nuxt.options.vite.vue, {
138148
include: [/\.vue($|\?)/, /\.(md|markdown)($|\?)/],
139149
})
140150

151+
if (!existsSync(blogImagesDir)) {
152+
await mkdir(blogImagesDir, { recursive: true })
153+
}
154+
155+
nuxt.options.nitro.publicAssets ||= []
156+
nuxt.options.nitro.publicAssets.push({
157+
dir: blogImagesDir,
158+
baseURL: '/blog/avatar/',
159+
maxAge: 60 * 60 * 24, // 1 day
160+
})
161+
141162
addVitePlugin(() =>
142163
Markdown({
143164
include: [/\.(md|markdown)($|\?)/],
@@ -158,7 +179,7 @@ export default defineNuxtModule({
158179
)
159180

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

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

0 commit comments

Comments
 (0)