@@ -16,12 +16,18 @@ import {
1616import { globSync } from 'tinyglobby'
1717import { isProduction } from '../config/env'
1818import { 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 : [ / \. v u e ( $ | \? ) / , / \. ( m d | m a r k d o w n ) ( $ | \? ) / ] ,
139154 } )
140155
156+ if ( ! existsSync ( blogImagesDir ) ) {
157+ await mkdir ( blogImagesDir , { recursive : true } )
158+ }
159+
141160 addVitePlugin ( ( ) =>
142161 Markdown ( {
143162 include : [ / \. ( m d | m a r k d o w n ) ( $ | \? ) / ] ,
@@ -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