Skip to content

Commit 11c2116

Browse files
danielroeghostdevv
andauthored
fix(blog): resolve blog author avatars at build time (#1879)
Co-authored-by: Willow (GHOST) <git@willow.sh>
1 parent 01abe3b commit 11c2116

File tree

8 files changed

+194
-44
lines changed

8 files changed

+194
-44
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ test-results/
4040
# generated files
4141
shared/types/lexicons
4242
file-tree-sprite.svg
43+
public/blog/avatar
4344

4445
**/__screenshots__/**
4546

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,11 +1,14 @@
11
<script setup lang="ts">
2-
import type { BlogPostFrontmatter } from '#shared/schemas/blog'
2+
import type { RawBlogPostFrontmatter } from '#shared/schemas/blog'
33
import { generateBlogTID } from '#shared/utils/atproto'
4+
import { posts } from '#blog/posts'
45
56
const props = defineProps<{
6-
frontmatter: BlogPostFrontmatter
7+
frontmatter: RawBlogPostFrontmatter
78
}>()
89
10+
const post = computed(() => posts.find(p => p.slug === props.frontmatter.slug))
11+
912
useSeoMeta({
1013
title: props.frontmatter.title,
1114
description: props.frontmatter.description || props.frontmatter.excerpt,
@@ -26,7 +29,7 @@ useHead({
2629
2730
defineOgImageComponent('BlogPost', {
2831
title: props.frontmatter.title,
29-
authors: props.frontmatter.authors,
32+
authors: post.value?.authors ?? [],
3033
date: props.frontmatter.date,
3134
})
3235
@@ -50,9 +53,9 @@ const blueskyPostUri = computed(() => blueskyLink.value?.postUri ?? null)
5053
</span>
5154
</div>
5255
</div>
53-
<div v-if="frontmatter.authors" class="mb-12 max-w-prose mx-auto">
56+
<div v-if="post?.authors" class="mb-12 max-w-prose mx-auto">
5457
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
55-
<AuthorList :authors="frontmatter.authors" variant="expanded" />
58+
<AuthorList :authors="post.authors" variant="expanded" />
5659
</div>
5760
</div>
5861
<article class="max-w-prose mx-auto p-2 prose dark:prose-invert">

modules/blog.ts

Lines changed: 112 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,91 @@ 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+
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: [/\.vue($|\?)/, /\.(md|markdown)($|\?)/],
58154
})
59155

156+
if (!existsSync(blogImagesDir)) {
157+
await mkdir(blogImagesDir, { recursive: true })
158+
}
159+
60160
addVitePlugin(() =>
61161
Markdown({
62162
include: [/\.(md|markdown)($|\?)/],
@@ -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

Comments
 (0)