@@ -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,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 : [ / \. v u e ( $ | \? ) / , / \. ( m d | m a r k d o w n ) ( $ | \? ) / ] ,
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 : [ / \. ( m d | m a r k d o w n ) ( $ | \? ) / ] ,
@@ -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