Skip to content

fix(blog): resolve blog author avatars at build time#1879

Merged
danielroe merged 4 commits intomainfrom
perf/build-authors
Mar 4, 2026
Merged

fix(blog): resolve blog author avatars at build time#1879
danielroe merged 4 commits intomainfrom
perf/build-authors

Conversation

@danielroe
Copy link
Member

🔗 Linked issue

🧭 Context

📚 Description

this is a slight perf improvement, hopefully!

@vercel
Copy link

vercel bot commented Mar 3, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
npmx.dev Ready Ready Preview, Comment Mar 4, 2026 8:10pm
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Mar 4, 2026 8:10pm
npmx-lunaria Ignored Ignored Mar 4, 2026 8:10pm

Request Review

@codecov
Copy link

codecov bot commented Mar 3, 2026

Codecov Report

❌ Patch coverage is 75.00000% with 1 line in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
app/components/AuthorList.vue 50.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 3, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds a ResolvedAuthor schema and a RawBlogPost frontmatter schema, updates components to accept ResolvedAuthor[], and replaces local prop object usage with implicit defineProps() where applied. modules/blog.ts now preloads posts, resolves Bluesky handles, downloads/caches avatar assets, and exposes a generated posts export used by BlogPostWrapper to source authors. Template and test fixtures were updated so author objects include avatar and profileUrl fields. Minor .gitignore and public asset path changes were added for cached avatars.

Possibly related PRs

🚥 Pre-merge checks | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Description check ❓ Inconclusive The description is vaguely related to the changeset but lacks meaningful detail about what was actually implemented. Expand the description to detail the build-time avatar resolution implementation, avatar caching strategy, and the performance improvements achieved.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch perf/build-authors

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
modules/blog.ts (1)

88-91: ⚠️ Potential issue | 🔴 Critical

Guard date normalisation to prevent build-breaking RangeError.

new Date(...).toISOString() throws RangeError: Invalid time value on invalid dates, bypassing schema validation entirely and breaking the build. Invalid dates should fail validation gracefully instead of crashing the build process.

Suggested fix
-    if (frontmatter.date) {
-      const raw = frontmatter.date
-      frontmatter.date = new Date(raw instanceof Date ? raw : String(raw)).toISOString()
-    }
+    if (frontmatter.date) {
+      const parsedDate = frontmatter.date instanceof Date
+        ? frontmatter.date
+        : new Date(String(frontmatter.date))
+      if (!Number.isNaN(parsedDate.getTime())) {
+        frontmatter.date = parsedDate.toISOString()
+      }
+    }

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2ea6f11 and b9c20cd.

📒 Files selected for processing (6)
  • app/components/AuthorList.vue
  • app/components/BlogPostListCard.vue
  • app/components/OgImage/BlogPost.vue
  • app/components/global/BlogPostWrapper.vue
  • modules/blog.ts
  • shared/schemas/blog.ts

Comment on lines +46 to +48
<div v-if="post?.authors" class="mb-12 max-w-prose mx-auto">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<AuthorList :authors="frontmatter.authors" variant="expanded" />
<AuthorList :authors="post.authors" variant="expanded" />
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard on author count, not just array existence.

post?.authors is truthy even when empty, so this section can render with no visible content.

Suggested fix
-    <div v-if="post?.authors" class="mb-12 max-w-prose mx-auto">
+    <div v-if="post?.authors?.length" class="mb-12 max-w-prose mx-auto">
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div v-if="post?.authors" class="mb-12 max-w-prose mx-auto">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<AuthorList :authors="frontmatter.authors" variant="expanded" />
<AuthorList :authors="post.authors" variant="expanded" />
<div v-if="post?.authors?.length" class="mb-12 max-w-prose mx-auto">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<AuthorList :authors="post.authors" variant="expanded" />

Comment on lines +34 to +36
const response = await fetch(
`${BLUESKY_API}/xrpc/app.bsky.actor.getProfiles?${params.toString()}`,
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify fetch call currently lacks timeout/abort signal wiring
rg -nP "getProfiles\?" modules/blog.ts -C5
rg -nP "AbortController|signal:" modules/blog.ts -C3

Repository: npmx-dev/npmx.dev

Length of output: 421


🏁 Script executed:

#!/bin/bash
# Get broader context around the fetch call to understand the function scope
sed -n '1,50p' modules/blog.ts

Repository: npmx-dev/npmx.dev

Length of output: 1658


🏁 Script executed:

#!/bin/bash
# Get the rest of the function and where it's called
sed -n '45,100p' modules/blog.ts

Repository: npmx-dev/npmx.dev

Length of output: 2038


🏁 Script executed:

#!/bin/bash
# Find where loadBlogPosts is called and if it's in module setup
sed -n '100,200p' modules/blog.ts

Repository: npmx-dev/npmx.dev

Length of output: 2657


Add an abort timeout for the Bluesky fetch call.

A hanging Bluesky request can stall the full build because this runs during module setup (line 141 in the defineNuxtModule.setup() function).

Suggested fix
-    const response = await fetch(
-      `${BLUESKY_API}/xrpc/app.bsky.actor.getProfiles?${params.toString()}`,
-    )
+    const controller = new AbortController()
+    const timeout = setTimeout(() => controller.abort(), 8000)
+    let response: Response
+    try {
+      response = await fetch(
+        `${BLUESKY_API}/xrpc/app.bsky.actor.getProfiles?${params.toString()}`,
+        { signal: controller.signal },
+      )
+    } finally {
+      clearTimeout(timeout)
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const response = await fetch(
`${BLUESKY_API}/xrpc/app.bsky.actor.getProfiles?${params.toString()}`,
)
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 8000)
let response: Response
try {
response = await fetch(
`${BLUESKY_API}/xrpc/app.bsky.actor.getProfiles?${params.toString()}`,
{ signal: controller.signal },
)
} finally {
clearTimeout(timeout)
}

@43081j
Copy link
Contributor

43081j commented Mar 4, 2026

looks like CI is sad:

Error: Can't load image https://cdn.bsky.app/img/avatar/plain/did:plc:jbeaa5kdaladzwq3r7f5xgwe/bafkreif4d7wtmzqppbpnwhjulf3d36ltbeg5wzu3i2mhq6wxb4f6nh5uo4@jpeg: Unsupported image type: unknown

but this image (of you) certainly does exist 😄

@ghostdevv
Copy link
Contributor

looks like CI is sad:

Error: Can't load image cdn.bsky.app/img/avatar/plain/did:plc:jbeaa5kdaladzwq3r7f5xgwe/bafkreif4d7wtmzqppbpnwhjulf3d36ltbeg5wzu3i2mhq6wxb4f6nh5uo4@jpeg: Unsupported image type: unknown

but this image (of you) certainly does exist 😄

I wonder if it's because the file doesn't end with .jpeg - will take a look 👀

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9c7bfc7e-0bad-4f14-8eaf-48a79e4a849b

📥 Commits

Reviewing files that changed from the base of the PR and between cd0d3bd and f782b2b.

📒 Files selected for processing (2)
  • app/components/BlogPostListCard.vue
  • app/components/global/BlogPostWrapper.vue
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/components/BlogPostListCard.vue

defineOgImageComponent('BlogPost', {
title: props.frontmatter.title,
authors: props.frontmatter.authors,
authors: post.value?.authors ?? [],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether OG BlogPost rendering currently consumes avatar URLs without a guard.
fd 'BlogPost.vue' app/components --exec sh -c '
  for f in "$@"; do
    echo "== $f ==";
    rg -n -C2 "avatar|NuxtImg|<img|src=" "$f";
  done
' sh {}

echo "== wrapper callsite =="
rg -n -C3 "defineOgImageComponent|authors:\\s*post\\.value\\?\\.authors" app/components/global/BlogPostWrapper.vue

Repository: npmx-dev/npmx.dev

Length of output: 721


🏁 Script executed:

#!/bin/bash
# Get full context of BlogPostWrapper.vue to understand component structure
echo "=== BlogPostWrapper.vue (full context) ==="
cat -n app/components/global/BlogPostWrapper.vue | head -50

echo ""
echo "=== Check for type definitions ==="
rg -n "interface.*[Aa]uthor|type.*[Aa]uthor" --type ts --type tsx --type vue app/components app/types 2>/dev/null | head -20

echo ""
echo "=== Check OgImage BlogPost component more broadly ==="
cat -n app/components/OgImage/BlogPost.vue | head -120

Repository: npmx-dev/npmx.dev

Length of output: 6500


🏁 Script executed:

#!/bin/bash
# Verify the ResolvedAuthor type definition
echo "=== ResolvedAuthor schema ==="
fd -e ts -e tsx -e vue . app/types -o -path "*/shared/*" | head -20
rg -n "ResolvedAuthor|interface.*Author|type.*Author" --type ts --type tsx -B2 -A5 2>/dev/null | head -40

# Check if avatar URLs are used elsewhere
echo ""
echo "=== Other avatar URL usages ==="
rg -n "author\.avatar|\.avatar\s*:" --type vue --type ts --type tsx 2>/dev/null | grep -v node_modules | head -20

Repository: npmx-dev/npmx.dev

Length of output: 257


🏁 Script executed:

#!/bin/bash
# Search for ResolvedAuthor type definition
echo "=== ResolvedAuthor schema ==="
rg "ResolvedAuthor|interface.*Author|type.*Author" -B2 -A8 --type ts --type tsx 2>/dev/null | head -60

echo ""
echo "=== Avatar field usages ==="
rg "avatar" --type vue --type ts --type tsx -B1 -A1 2>/dev/null | grep -v node_modules | head -40

Repository: npmx-dev/npmx.dev

Length of output: 119


🏁 Script executed:

#!/bin/bash
# Find schema files
echo "=== Locating blog schema files ==="
fd -e ts -e tsx "schema|blog" . --type f | grep -i schema | grep -i blog | head -10

echo ""
echo "=== Search in shared directory ==="
find . -type d -name shared 2>/dev/null | head -5

echo ""
echo "=== Files in potential schema locations ==="
find . -path "*/shared/schemas/*" -type f 2>/dev/null | head -20

Repository: npmx-dev/npmx.dev

Length of output: 427


🏁 Script executed:

#!/bin/bash
# Read the blog schema file to find ResolvedAuthor definition
echo "=== Checking blog schema file size ==="
wc -l ./shared/schemas/blog.ts

echo ""
echo "=== Blog schema content ==="
cat -n ./shared/schemas/blog.ts

Repository: npmx-dev/npmx.dev

Length of output: 2532


Sanitise unsupported avatar URLs before OG rendering to prevent build failures.

The authors prop at line 32 currently passes avatar URLs directly to the OG component without validating format support. The OG component renders these in <img :src="author.avatar"> with only an existence check, causing failures when encountering unsupported image types (e.g., "Unsupported image type: unknown").

Add type validation to filter out unsupported avatar URLs:

Suggested fix
 const post = computed(() => posts.find(p => p.slug === props.frontmatter.slug))
+
+function isSupportedOgAvatarUrl(url: string | null): boolean {
+  if (!url)
+    return false
+  // Accept common image extensions and Bluesky's `@jpeg` suffix format
+  return /(\.(png|jpe?g|webp|gif|svg)(\?.*)?$)|(`@jpe`?g(\?.*)?$)/i.test(url)
+}
+
+const ogAuthors = computed(() =>
+  (post.value?.authors ?? []).map(author => ({
+    ...author,
+    avatar: isSupportedOgAvatarUrl(author.avatar) ? author.avatar : null,
+  })),
+)
 
 defineOgImageComponent('BlogPost', {
   title: props.frontmatter.title,
-  authors: post.value?.authors ?? [],
+  authors: ogAuthors.value,
   date: props.frontmatter.date,
 })

@danielroe
Copy link
Member Author

yes, I think it is. satori is likely choking on it

this may also be fixed when we remake the nuxt-og-image v6 upgrade (cc: @harlan-zw)

@ghostdevv
Copy link
Contributor

ghostdevv commented Mar 4, 2026

@danielroe what do you think about 32cedb2? Feel free to revert if you don't like it, but I think this is a nice side effect of the satori issue because it removes an origin connection for users

(but the build still needs some fixes to work xD)

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f336bf9d-905a-4bfb-bc29-26e0f8f3e338

📥 Commits

Reviewing files that changed from the base of the PR and between f782b2b and e60895b.

📒 Files selected for processing (1)
  • modules/blog.ts

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
modules/blog.ts (1)

102-105: ⚠️ Potential issue | 🟠 Major

Guard date normalisation before calling toISOString().

Line 104 can throw on invalid date input, which aborts blog loading before safeParse can reject the post cleanly.

Suggested fix
-    if (frontmatter.date) {
-      const raw = frontmatter.date
-      frontmatter.date = new Date(raw instanceof Date ? raw : String(raw)).toISOString()
-    }
+    if (frontmatter.date) {
+      const parsedDate = new Date(
+        frontmatter.date instanceof Date ? frontmatter.date : String(frontmatter.date),
+      )
+      if (Number.isNaN(parsedDate.getTime())) {
+        console.warn(`[blog] Skipping post with invalid date: ${file}`)
+        continue
+      }
+      frontmatter.date = parsedDate.toISOString()
+    }
As per coding guidelines: "Use error handling patterns consistently".
♻️ Duplicate comments (2)
modules/blog.ts (2)

40-42: ⚠️ Potential issue | 🟠 Major

Add an abort timeout for the Bluesky profiles request.

Line 40 performs build-time network I/O without an upper bound; if the upstream hangs, module setup can hang CI/builds.

Suggested fix
-    const response = await fetch(
-      `${BLUESKY_API}/xrpc/app.bsky.actor.getProfiles?${params.toString()}`,
-    )
+    const controller = new AbortController()
+    const timeout = setTimeout(() => controller.abort(), 8000)
+    let response: Response
+    try {
+      response = await fetch(
+        `${BLUESKY_API}/xrpc/app.bsky.actor.getProfiles?${params.toString()}`,
+        { signal: controller.signal },
+      )
+    } finally {
+      clearTimeout(timeout)
+    }

56-61: ⚠️ Potential issue | 🔴 Critical

Validate avatar payloads and keep the correct file type when caching.

Lines 56-61 cache responses as .jpg without checking status/type first. Failed/non-image payloads can be persisted and later break OG image rendering.

Suggested fix
-          const res = await fetch(profile.avatar)
-          await writeFile(join(imagesDir, `${hash}.jpg`), res.body!)
-        }
-
-        avatarMap.set(profile.handle, `/blog/avatar/${hash}.jpg`)
+          const res = await fetch(profile.avatar)
+          const contentType = res.headers.get('content-type') ?? ''
+          const ext = contentType.includes('png')
+            ? 'png'
+            : contentType.includes('webp')
+              ? 'webp'
+              : contentType.includes('jpeg') || contentType.includes('jpg')
+                ? 'jpg'
+                : null
+
+          if (!res.ok || !ext) {
+            console.warn(`[blog] Skipping avatar for ${profile.handle}: status=${res.status}, type=${contentType}`)
+            continue
+          }
+
+          const fileName = `${hash}.${ext}`
+          await writeFile(join(imagesDir, fileName), Buffer.from(await res.arrayBuffer()))
+          avatarMap.set(profile.handle, `/blog/avatar/${fileName}`)
+          continue
+        }
+
+        avatarMap.set(profile.handle, `/blog/avatar/${hash}.jpg`)

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f56d02a3-5a39-4c0d-b1e2-9672e6f4488b

📥 Commits

Reviewing files that changed from the base of the PR and between e60895b and 32cedb2.

📒 Files selected for processing (2)
  • .gitignore
  • modules/blog.ts
✅ Files skipped from review due to trivial changes (1)
  • .gitignore

@danielroe
Copy link
Member Author

nice 👌

@danielroe danielroe added this pull request to the merge queue Mar 4, 2026
Merged via the queue into main with commit 11c2116 Mar 4, 2026
23 checks passed
@danielroe danielroe deleted the perf/build-authors branch March 4, 2026 20:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants