@@ -5,19 +5,77 @@ 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+
20+ /**
21+ * Fetches Bluesky avatars for a set of authors at build time.
22+ * Returns a map of handle → avatar URL.
23+ */
24+ async function fetchBlueskyAvatars ( handles : string [ ] ) : Promise < Map < string , string > > {
25+ const avatarMap = new Map < string , string > ( )
26+ if ( handles . length === 0 ) return avatarMap
27+
28+ try {
29+ const params = new URLSearchParams ( )
30+ for ( const handle of handles ) {
31+ params . append ( 'actors' , handle )
32+ }
33+
34+ const response = await fetch (
35+ `${ BLUESKY_API } /xrpc/app.bsky.actor.getProfiles?${ params . toString ( ) } ` ,
36+ )
37+
38+ if ( ! response . ok ) {
39+ console . warn ( `[blog] Failed to fetch Bluesky profiles: ${ response . status } ` )
40+ return avatarMap
41+ }
42+
43+ const data = ( await response . json ( ) ) as { profiles : Array < { handle : string ; avatar ?: string } > }
44+
45+ for ( const profile of data . profiles ) {
46+ if ( profile . avatar ) {
47+ avatarMap . set ( profile . handle , profile . avatar )
48+ }
49+ }
50+ } catch ( error ) {
51+ console . warn ( `[blog] Failed to fetch Bluesky avatars:` , error )
52+ }
53+
54+ return avatarMap
55+ }
56+
57+ /**
58+ * Resolves authors with their Bluesky avatars and profile URLs.
59+ */
60+ function resolveAuthors ( authors : Author [ ] , avatarMap : Map < string , string > ) : ResolvedAuthor [ ] {
61+ return authors . map ( author => ( {
62+ ...author ,
63+ avatar : author . blueskyHandle ? ( avatarMap . get ( author . blueskyHandle ) ?? null ) : null ,
64+ profileUrl : author . blueskyHandle ? `https://bsky.app/profile/${ author . blueskyHandle } ` : null ,
65+ } ) )
66+ }
1267
1368/**
1469 * Scans the blog directory for .md files and extracts validated frontmatter.
1570 * Returns all posts (including drafts) sorted by date descending.
71+ * Resolves Bluesky avatars at build time.
1672 */
17- function loadBlogPosts ( blogDir : string ) : BlogPostFrontmatter [ ] {
73+ async function loadBlogPosts ( blogDir : string ) : Promise < BlogPostFrontmatter [ ] > {
1874 const files : string [ ] = globSync ( join ( blogDir , '*.md' ) )
1975
20- const posts : BlogPostFrontmatter [ ] = [ ]
76+ // First pass: extract raw frontmatter and collect all Bluesky handles
77+ const rawPosts : Array < { frontmatter : Record < string , unknown > } > = [ ]
78+ const allHandles = new Set < string > ( )
2179
2280 for ( const file of files ) {
2381 const { data : frontmatter } = read ( file )
@@ -32,10 +90,33 @@ function loadBlogPosts(blogDir: string): BlogPostFrontmatter[] {
3290 frontmatter . date = new Date ( raw instanceof Date ? raw : String ( raw ) ) . toISOString ( )
3391 }
3492
35- const result = safeParse ( BlogPostSchema , frontmatter )
93+ // Validate authors before resolving so we can extract handles
94+ const authorsResult = safeParse ( array ( AuthorSchema ) , frontmatter . authors )
95+ if ( authorsResult . success ) {
96+ for ( const author of authorsResult . output ) {
97+ if ( author . blueskyHandle ) {
98+ allHandles . add ( author . blueskyHandle )
99+ }
100+ }
101+ }
102+
103+ rawPosts . push ( { frontmatter } )
104+ }
105+
106+ // Batch-fetch all Bluesky avatars in a single request
107+ const avatarMap = await fetchBlueskyAvatars ( [ ...allHandles ] )
108+
109+ // Second pass: validate with raw schema, then enrich authors with avatars
110+ const posts : BlogPostFrontmatter [ ] = [ ]
111+
112+ for ( const { frontmatter } of rawPosts ) {
113+ const result = safeParse ( RawBlogPostSchema , frontmatter )
36114 if ( ! result . success ) continue
37115
38- posts . push ( result . output )
116+ posts . push ( {
117+ ...result . output ,
118+ authors : resolveAuthors ( result . output . authors , avatarMap ) ,
119+ } )
39120 }
40121
41122 // Sort newest first
@@ -47,7 +128,7 @@ export default defineNuxtModule({
47128 meta : {
48129 name : 'blog' ,
49130 } ,
50- setup ( ) {
131+ async setup ( ) {
51132 const nuxt = useNuxt ( )
52133 const resolver = createResolver ( import . meta. url )
53134 const blogDir = resolver . resolve ( '../app/pages/blog' )
@@ -76,13 +157,16 @@ export default defineNuxtModule({
76157 } ) ,
77158 )
78159
160+ // Load posts once with resolved Bluesky avatars (shared across template + route rules)
161+ const allPosts = await loadBlogPosts ( blogDir )
162+
79163 // Expose frontmatter for the `/blog` listing page.
80164 const showDrafts = nuxt . options . dev || ! isProduction
81165 addTemplate ( {
82166 filename : 'blog/posts.ts' ,
83167 write : true ,
84168 getContents : ( ) => {
85- const posts = loadBlogPosts ( blogDir ) . filter ( p => showDrafts || ! p . draft )
169+ const posts = allPosts . filter ( p => showDrafts || ! p . draft )
86170 return [
87171 `import type { BlogPostFrontmatter } from '#shared/schemas/blog'` ,
88172 `` ,
@@ -94,8 +178,7 @@ export default defineNuxtModule({
94178 nuxt . options . alias [ '#blog/posts' ] = join ( nuxt . options . buildDir , 'blog/posts' )
95179
96180 // Add X-Robots-Tag header for draft posts to prevent indexing
97- const posts = loadBlogPosts ( blogDir )
98- for ( const post of posts ) {
181+ for ( const post of allPosts ) {
99182 if ( post . draft ) {
100183 nuxt . options . routeRules ||= { }
101184 nuxt . options . routeRules [ `/blog/${ post . slug } ` ] = {
0 commit comments