@@ -5,19 +5,91 @@ import shiki from '@shikijs/markdown-exit'
55import MarkdownItAnchor from 'markdown-it-anchor'
66import { defu } from 'defu'
77import { read } from 'gray-matter'
8- import { safeParse } from 'valibot'
9- import { BlogPostSchema , type BlogPostFrontmatter } from '../shared/schemas/blog'
8+ import { array , safeParse } from 'valibot'
9+ import {
10+ AuthorSchema ,
11+ RawBlogPostSchema ,
12+ type Author ,
13+ type BlogPostFrontmatter ,
14+ type ResolvedAuthor ,
15+ } from '../shared/schemas/blog'
1016import { globSync } from 'tinyglobby'
1117import { isProduction } from '../config/env'
18+ 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'
22+
23+ /**
24+ * Fetches Bluesky avatars for a set of authors at build time.
25+ * Returns a map of handle → avatar URL.
26+ */
27+ async function fetchBlueskyAvatars (
28+ imagesDir : string ,
29+ handles : string [ ] ,
30+ ) : Promise < Map < string , string > > {
31+ const avatarMap = new Map < string , string > ( )
32+ if ( handles . length === 0 ) return avatarMap
33+
34+ try {
35+ const params = new URLSearchParams ( )
36+ for ( const handle of handles ) {
37+ params . append ( 'actors' , handle )
38+ }
39+
40+ const response = await fetch (
41+ `${ BLUESKY_API } /xrpc/app.bsky.actor.getProfiles?${ params . toString ( ) } ` ,
42+ )
43+
44+ if ( ! response . ok ) {
45+ console . warn ( `[blog] Failed to fetch Bluesky profiles: ${ response . status } ` )
46+ return avatarMap
47+ }
48+
49+ const data = ( await response . json ( ) ) as { profiles : Array < { handle : string ; avatar ?: string } > }
50+
51+ for ( const profile of data . profiles ) {
52+ if ( 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` )
62+ }
63+ }
64+ } catch ( error ) {
65+ console . warn ( `[blog] Failed to fetch Bluesky avatars:` , error )
66+ }
67+
68+ return avatarMap
69+ }
70+
71+ /**
72+ * Resolves authors with their Bluesky avatars and profile URLs.
73+ */
74+ function resolveAuthors ( authors : Author [ ] , avatarMap : Map < string , string > ) : ResolvedAuthor [ ] {
75+ return authors . map ( author => ( {
76+ ...author ,
77+ avatar : author . blueskyHandle ? ( avatarMap . get ( author . blueskyHandle ) ?? null ) : null ,
78+ profileUrl : author . blueskyHandle ? `https://bsky.app/profile/${ author . blueskyHandle } ` : null ,
79+ } ) )
80+ }
1281
1382/**
1483 * Scans the blog directory for .md files and extracts validated frontmatter.
1584 * Returns all posts (including drafts) sorted by date descending.
85+ * Resolves Bluesky avatars at build time.
1686 */
17- function loadBlogPosts ( blogDir : string ) : BlogPostFrontmatter [ ] {
87+ async function loadBlogPosts ( blogDir : string , imagesDir : string ) : Promise < BlogPostFrontmatter [ ] > {
1888 const files : string [ ] = globSync ( join ( blogDir , '*.md' ) )
1989
20- const posts : BlogPostFrontmatter [ ] = [ ]
90+ // First pass: extract raw frontmatter and collect all Bluesky handles
91+ const rawPosts : Array < { frontmatter : Record < string , unknown > } > = [ ]
92+ const allHandles = new Set < string > ( )
2193
2294 for ( const file of files ) {
2395 const { data : frontmatter } = read ( file )
@@ -32,10 +104,33 @@ function loadBlogPosts(blogDir: string): BlogPostFrontmatter[] {
32104 frontmatter . date = new Date ( raw instanceof Date ? raw : String ( raw ) ) . toISOString ( )
33105 }
34106
35- const result = safeParse ( BlogPostSchema , frontmatter )
107+ // Validate authors before resolving so we can extract handles
108+ const authorsResult = safeParse ( array ( AuthorSchema ) , frontmatter . authors )
109+ if ( authorsResult . success ) {
110+ for ( const author of authorsResult . output ) {
111+ if ( author . blueskyHandle ) {
112+ allHandles . add ( author . blueskyHandle )
113+ }
114+ }
115+ }
116+
117+ rawPosts . push ( { frontmatter } )
118+ }
119+
120+ // Batch-fetch all Bluesky avatars in a single request
121+ const avatarMap = await fetchBlueskyAvatars ( imagesDir , [ ...allHandles ] )
122+
123+ // Second pass: validate with raw schema, then enrich authors with avatars
124+ const posts : BlogPostFrontmatter [ ] = [ ]
125+
126+ for ( const { frontmatter } of rawPosts ) {
127+ const result = safeParse ( RawBlogPostSchema , frontmatter )
36128 if ( ! result . success ) continue
37129
38- posts . push ( result . output )
130+ posts . push ( {
131+ ...result . output ,
132+ authors : resolveAuthors ( result . output . authors , avatarMap ) ,
133+ } )
39134 }
40135
41136 // Sort newest first
@@ -47,16 +142,21 @@ export default defineNuxtModule({
47142 meta : {
48143 name : 'blog' ,
49144 } ,
50- setup ( ) {
145+ async setup ( ) {
51146 const nuxt = useNuxt ( )
52147 const resolver = createResolver ( import . meta. url )
53148 const blogDir = resolver . resolve ( '../app/pages/blog' )
149+ const blogImagesDir = resolver . resolve ( '../public/blog/avatar' )
54150
55151 nuxt . options . extensions . push ( '.md' )
56152 nuxt . options . vite . vue = defu ( nuxt . options . vite . vue , {
57153 include : [ / \. v u e ( $ | \? ) / , / \. ( m d | m a r k d o w n ) ( $ | \? ) / ] ,
58154 } )
59155
156+ if ( ! existsSync ( blogImagesDir ) ) {
157+ await mkdir ( blogImagesDir , { recursive : true } )
158+ }
159+
60160 addVitePlugin ( ( ) =>
61161 Markdown ( {
62162 include : [ / \. ( m d | m a r k d o w n ) ( $ | \? ) / ] ,
@@ -76,13 +176,16 @@ export default defineNuxtModule({
76176 } ) ,
77177 )
78178
179+ // Load posts once with resolved Bluesky avatars (shared across template + route rules)
180+ const allPosts = await loadBlogPosts ( blogDir , blogImagesDir )
181+
79182 // Expose frontmatter for the `/blog` listing page.
80183 const showDrafts = nuxt . options . dev || ! isProduction
81184 addTemplate ( {
82185 filename : 'blog/posts.ts' ,
83186 write : true ,
84187 getContents : ( ) => {
85- const posts = loadBlogPosts ( blogDir ) . filter ( p => showDrafts || ! p . draft )
188+ const posts = allPosts . filter ( p => showDrafts || ! p . draft )
86189 return [
87190 `import type { BlogPostFrontmatter } from '#shared/schemas/blog'` ,
88191 `` ,
@@ -94,8 +197,7 @@ export default defineNuxtModule({
94197 nuxt . options . alias [ '#blog/posts' ] = join ( nuxt . options . buildDir , 'blog/posts' )
95198
96199 // Add X-Robots-Tag header for draft posts to prevent indexing
97- const posts = loadBlogPosts ( blogDir )
98- for ( const post of posts ) {
200+ for ( const post of allPosts ) {
99201 if ( post . draft ) {
100202 nuxt . options . routeRules ||= { }
101203 nuxt . options . routeRules [ `/blog/${ post . slug } ` ] = {
0 commit comments