Skip to content

Commit b9c20cd

Browse files
committed
fix(blog): resolve blog author avatars at build time
1 parent 2ea6f11 commit b9c20cd

6 files changed

Lines changed: 159 additions & 41 deletions

File tree

app/components/AuthorList.vue

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
11
<script setup lang="ts">
2-
import type { Author } from '#shared/schemas/blog'
2+
import type { ResolvedAuthor } from '#shared/schemas/blog'
33
4-
const props = defineProps<{
5-
authors: Author[]
4+
defineProps<{
5+
authors: ResolvedAuthor[]
66
variant?: 'compact' | 'expanded'
77
}>()
8-
9-
const { resolvedAuthors } = useBlueskyAuthorProfiles(props.authors)
108
</script>
119

1210
<template>
1311
<!-- Expanded variant: vertical list with larger avatars -->
1412
<div v-if="variant === 'expanded'" class="flex flex-wrap items-center gap-4">
15-
<div v-for="author in resolvedAuthors" :key="author.name" class="flex items-center gap-2">
13+
<div v-for="author in authors" :key="author.name" class="flex items-center gap-2">
1614
<AuthorAvatar :author="author" size="md" disable-link />
1715
<div class="flex flex-col">
1816
<span class="text-sm font-medium text-fg">{{ author.name }}</span>
@@ -34,7 +32,7 @@ const { resolvedAuthors } = useBlueskyAuthorProfiles(props.authors)
3432
<div v-else class="flex items-center gap-2 min-w-0">
3533
<div class="flex items-center">
3634
<AuthorAvatar
37-
v-for="(author, index) in resolvedAuthors"
35+
v-for="(author, index) in authors"
3836
:key="author.name"
3937
:author="author"
4038
size="md"
@@ -43,7 +41,7 @@ const { resolvedAuthors } = useBlueskyAuthorProfiles(props.authors)
4341
/>
4442
</div>
4543
<span class="text-xs text-fg-muted font-mono truncate">
46-
{{ resolvedAuthors.map(a => a.name).join(', ') }}
44+
{{ authors.map(a => a.name).join(', ') }}
4745
</span>
4846
</div>
4947
</template>

app/components/BlogPostListCard.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
<script setup lang="ts">
2-
import type { Author } from '#shared/schemas/blog'
2+
import type { ResolvedAuthor } from '#shared/schemas/blog'
33
44
defineProps<{
55
/** Authors of the blog post */
6-
authors: Author[]
6+
authors: ResolvedAuthor[]
77
/** Blog Title */
88
title: string
99
/** Tags such as OpenSource, Architecture, Community, etc. */

app/components/OgImage/BlogPost.vue

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
<script setup lang="ts">
2-
import type { Author } from '#shared/schemas/blog'
2+
import type { ResolvedAuthor } from '#shared/schemas/blog'
33
44
const props = withDefaults(
55
defineProps<{
66
title: string
7-
authors?: Author[]
7+
authors?: ResolvedAuthor[]
88
date?: string
99
primaryColor?: string
1010
}>(),
@@ -15,8 +15,6 @@ const props = withDefaults(
1515
},
1616
)
1717
18-
const { resolvedAuthors } = useBlueskyAuthorProfiles(props.authors)
19-
2018
const formattedDate = computed(() => {
2119
if (!props.date) return ''
2220
try {
@@ -41,17 +39,17 @@ const getInitials = (name: string) =>
4139
.slice(0, 2)
4240
4341
const visibleAuthors = computed(() => {
44-
if (resolvedAuthors.value.length <= 3) return resolvedAuthors.value
45-
return resolvedAuthors.value.slice(0, MAX_VISIBLE_AUTHORS)
42+
if (props.authors.length <= 3) return props.authors
43+
return props.authors.slice(0, MAX_VISIBLE_AUTHORS)
4644
})
4745
4846
const extraCount = computed(() => {
49-
if (resolvedAuthors.value.length <= 3) return 0
50-
return resolvedAuthors.value.length - MAX_VISIBLE_AUTHORS
47+
if (props.authors.length <= 3) return 0
48+
return props.authors.length - MAX_VISIBLE_AUTHORS
5149
})
5250
5351
const formattedAuthorNames = computed(() => {
54-
const allNames = resolvedAuthors.value.map(a => a.name)
52+
const allNames = props.authors.map(a => a.name)
5553
if (allNames.length === 0) return ''
5654
if (allNames.length === 1) return allNames[0]
5755
if (allNames.length === 2) return `${allNames[0]} and ${allNames[1]}`
@@ -96,7 +94,7 @@ const formattedAuthorNames = computed(() => {
9694

9795
<!-- Authors -->
9896
<div
99-
v-if="resolvedAuthors.length"
97+
v-if="authors.length"
10098
class="flex items-center gap-4 self-start justify-start flex-nowrap"
10199
style="font-family: 'Geist', sans-serif"
102100
>

app/components/global/BlogPostWrapper.vue

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
<script setup lang="ts">
2-
import type { BlogPostFrontmatter } from '#shared/schemas/blog'
2+
import type { RawBlogPostFrontmatter } from '#shared/schemas/blog'
3+
import { posts } from '#blog/posts'
34
45
const props = defineProps<{
5-
frontmatter: BlogPostFrontmatter
6+
frontmatter: RawBlogPostFrontmatter
67
}>()
78
9+
const post = computed(() => posts.find(p => p.slug === props.frontmatter.slug))
10+
811
useSeoMeta({
912
title: props.frontmatter.title,
1013
description: props.frontmatter.description || props.frontmatter.excerpt,
@@ -16,7 +19,7 @@ useSeoMeta({
1619
1720
defineOgImageComponent('BlogPost', {
1821
title: props.frontmatter.title,
19-
authors: props.frontmatter.authors,
22+
authors: post.value?.authors ?? [],
2023
date: props.frontmatter.date,
2124
})
2225
@@ -40,9 +43,9 @@ const blueskyPostUri = computed(() => blueskyLink.value?.postUri ?? null)
4043
</span>
4144
</div>
4245
</div>
43-
<div v-if="frontmatter.authors" class="mb-12 max-w-prose mx-auto">
46+
<div v-if="post?.authors" class="mb-12 max-w-prose mx-auto">
4447
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
45-
<AuthorList :authors="frontmatter.authors" variant="expanded" />
48+
<AuthorList :authors="post.authors" variant="expanded" />
4649
</div>
4750
</div>
4851
<article class="max-w-prose mx-auto p-2 prose dark:prose-invert">

modules/blog.ts

Lines changed: 93 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,77 @@ import shiki from '@shikijs/markdown-exit'
55
import MarkdownItAnchor from 'markdown-it-anchor'
66
import { defu } from 'defu'
77
import { 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'
1016
import { globSync } from 'tinyglobby'
1117
import { 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}`] = {

shared/schemas/blog.ts

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
1-
import { array, boolean, custom, isoTimestamp, object, optional, pipe, string } from 'valibot'
1+
import {
2+
array,
3+
boolean,
4+
custom,
5+
isoTimestamp,
6+
nullable,
7+
object,
8+
optional,
9+
pipe,
10+
string,
11+
} from 'valibot'
212
import { isAtIdentifierString, type AtIdentifierString } from '@atproto/lex'
313
import type { InferOutput } from 'valibot'
414

@@ -12,7 +22,20 @@ export const AuthorSchema = object({
1222
),
1323
})
1424

15-
export const BlogPostSchema = object({
25+
export const ResolvedAuthorSchema = object({
26+
name: string(),
27+
blueskyHandle: optional(
28+
pipe(
29+
string(),
30+
custom<AtIdentifierString>(v => typeof v === 'string' && isAtIdentifierString(v)),
31+
),
32+
),
33+
avatar: nullable(string()),
34+
profileUrl: nullable(string()),
35+
})
36+
37+
/** Schema for raw frontmatter as defined in markdown YAML */
38+
export const RawBlogPostSchema = object({
1639
authors: array(AuthorSchema),
1740
title: string(),
1841
date: pipe(string(), isoTimestamp()),
@@ -24,12 +47,25 @@ export const BlogPostSchema = object({
2447
draft: optional(boolean()),
2548
})
2649

50+
/** Schema for blog post frontmatter with resolved author data (avatars, profile URLs) */
51+
export const BlogPostSchema = object({
52+
authors: array(ResolvedAuthorSchema),
53+
title: string(),
54+
date: pipe(string(), isoTimestamp()),
55+
description: string(),
56+
path: string(),
57+
slug: string(),
58+
excerpt: optional(string()),
59+
tags: optional(array(string())),
60+
draft: optional(boolean()),
61+
})
62+
2763
export type Author = InferOutput<typeof AuthorSchema>
2864

29-
export interface ResolvedAuthor extends Author {
30-
avatar: string | null
31-
profileUrl: string | null
32-
}
65+
export type ResolvedAuthor = InferOutput<typeof ResolvedAuthorSchema>
66+
67+
/** Raw frontmatter type (before avatar resolution) */
68+
export type RawBlogPostFrontmatter = InferOutput<typeof RawBlogPostSchema>
3369

3470
/**
3571
* Inferred type for blog post frontmatter

0 commit comments

Comments
 (0)