Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/components/AuthorList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const props = defineProps<{
variant?: 'compact' | 'expanded'
}>()

const { resolvedAuthors } = useAuthorProfiles(props.authors)
const { resolvedAuthors } = useBlueskyAuthorProfiles(props.authors)
</script>

<template>
Expand Down
Copy link
Contributor Author

@jonathanyeong jonathanyeong Feb 10, 2026

Choose a reason for hiding this comment

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

By removing .client this component will now appear during SSR for unplugin-vue-markdown to pick up when it's compiling the blog posts. But the <ClientOnly> tag should mean it's only rendered by the browser.

Copy link
Contributor

@Kai-ros Kai-ros Feb 10, 2026

Choose a reason for hiding this comment

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

  • Feel free to correct my Nuxt/Vue ignorance on anything I say.
  • My understanding is that Nuxt's .client.vue suffix is equivalent to the Nuxt <ClientOnly> wrapper component, so they're interchangeable in this regard.
    https://nuxt.com/docs/4.x/directory-structure/app/components#client-components
  • Is the only reason to move the component to server-side to ensure it is picked up by unplugin-vue-markdown ?
  • The app/plugins/bluesky-embed.ts below should already be doing the work of registering the component during the build process.

Copy link
Contributor Author

@jonathanyeong jonathanyeong Feb 10, 2026

Choose a reason for hiding this comment

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

My understanding is that Nuxt's .client.vue suffix is equivalent to the Vue wrapper component, so they're interchangeable in this regard.

They are very similar, but adding .client to the component means the entire component won't be available during SSR. While when we specify ClientOnly tag it's only applied at the usage site. So the component is still available during SSR with ClientOnly.

Is the only reason to move the component to server-side to ensure it is picked up by unplugin-vue-markdown ?

Yup!

The app/plugins/bluesky-embed.ts below should already be doing the work of registering the component during the build process.

That's true, my change doesn't do anything regarding registering the component. But we still need to import the plugin during ssr rather than only client side.

Side note: I was looking at unplugin-vue-components and this might be something we can add in the future so we don't have to explicitly import a global component.

Copy link
Contributor

Choose a reason for hiding this comment

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

  • I'm not sure I understand the distinction well enough to see the value add here, but if it satisfies a need or resolves a bug, then send it!
  • I tried to make it work with unplugin-vue-components in an earlier attempt but it was unsuccessful so more power to you if you get it to.

Original file line number Diff line number Diff line change
Expand Up @@ -50,33 +50,35 @@ function onPostMessage(event: MessageEvent) {
</script>

<template>
<article class="bluesky-embed-container">
<!-- Loading state -->
<LoadingSpinner
v-if="isLoading"
:text="$t('blog.atproto.loading_bluesky_post')"
aria-label="Loading Bluesky post..."
class="loading-spinner"
/>

<!-- Success state -->
<div v-else-if="embedUrl" class="bluesky-embed-container">
<iframe
title="Bluesky Post"
:data-bluesky-id="embeddedId"
:src="embedUrl"
width="100%"
:height="iframeHeight"
frameborder="0"
scrolling="no"
<ClientOnly>
<article class="bluesky-embed-container">
<!-- Loading state -->
<LoadingSpinner
v-if="isLoading"
:text="$t('blog.atproto.loading_bluesky_post')"
aria-label="Loading Bluesky post..."
class="loading-spinner"
/>
</div>

<!-- Fallback state -->
<a v-else :href="url" target="_blank" rel="noopener noreferrer">
{{ $t('blog.atproto.view_on_bluesky') }}
</a>
</article>
<!-- Success state -->
<div v-else-if="embedUrl" class="bluesky-embed-container">
<iframe
title="Bluesky Post"
:data-bluesky-id="embeddedId"
:src="embedUrl"
width="100%"
:height="iframeHeight"
frameborder="0"
scrolling="no"
/>
</div>

<!-- Fallback state -->
<a v-else :href="url" target="_blank" rel="noopener noreferrer">
{{ $t('blog.atproto.view_on_bluesky') }}
</a>
</article>
</ClientOnly>
</template>

<style scoped>
Expand Down
2 changes: 1 addition & 1 deletion app/components/OgImage/BlogPost.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const props = withDefaults(
},
)

const { resolvedAuthors } = useAuthorProfiles(props.authors)
const { resolvedAuthors } = useBlueskyAuthorProfiles(props.authors)

const formattedDate = computed(() => {
if (!props.date) return ''
Expand Down
30 changes: 0 additions & 30 deletions app/composables/useAuthorProfiles.ts

This file was deleted.

36 changes: 36 additions & 0 deletions app/composables/useBlueskyAuthorProfiles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { Author, ResolvedAuthor } from '#shared/schemas/blog'

/**
* Fetches author avatar URLs and profile links from the Bluesky API (AT Protocol).
*
* Makes a server-side request to `/api/atproto/bluesky-author-profiles`, which looks up
* each author's Bluesky profile to retrieve their avatar. Results are cached for 1 day.
*
* While the fetch is pending (or if it fails), returns authors with `avatar: null`
* and a constructed profile URL as fallback.
*/
export function useBlueskyAuthorProfiles(authors: Author[]) {
const authorsJson = JSON.stringify(authors)

const { data } = useFetch('/api/atproto/bluesky-author-profiles', {
query: {
authors: authorsJson,
},
})

const resolvedAuthors = computed<ResolvedAuthor[]>(
() => data.value?.authors ?? withoutBlueskyData(authors),
)

return {
resolvedAuthors,
}
}

function withoutBlueskyData(authors: Author[]): ResolvedAuthor[] {
return authors.map(author => ({
...author,
avatar: null,
profileUrl: author.blueskyHandle ? `https://bsky.app/profile/${author.blueskyHandle}` : null,
}))
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import EmbeddableBlueskyPost from '~/components/EmbeddableBlueskyPost.client.vue'
import EmbeddableBlueskyPost from '~/components/EmbeddableBlueskyPost.vue'

/**
* INFO: .md files are transformed into Vue SFCs by unplugin-vue-markdown during the Vite transform pipeline
Expand Down
5 changes: 5 additions & 0 deletions lexicons.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"app.bsky.feed.getPostThread",
"app.bsky.feed.getPosts",
"app.bsky.feed.post",
"com.atproto.identity.resolveHandle",
"com.bad-example.identity.resolveMiniDoc",
"site.standard.document"
],
Expand Down Expand Up @@ -98,6 +99,10 @@
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.richtext.facet",
"cid": "bafyreidg56eo7zynf6ihz4xb627vwoqf5idnevkmwp7sxc4tijg6xngbu4"
},
"com.atproto.identity.resolveHandle": {
"uri": "at://did:plc:6msi3pj7krzih5qxqtryxlzw/com.atproto.lexicon.schema/com.atproto.identity.resolveHandle",
"cid": "bafyreigckmqtt3jrtzd7tvigjatnz6ajqyafs26h5pwcudwag2anedxnmu"
},
"com.atproto.label.defs": {
"uri": "at://did:plc:6msi3pj7krzih5qxqtryxlzw/com.atproto.lexicon.schema/com.atproto.label.defs",
"cid": "bafyreig4hmnb2xkecyg4aaqfhr2rrcxxb3gsr4xks4rqb7rscrycalbrji"
Expand Down
41 changes: 41 additions & 0 deletions lexicons/com/atproto/identity/resolveHandle.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"id": "com.atproto.identity.resolveHandle",
"defs": {
"main": {
"type": "query",
"errors": [
{
"name": "HandleNotFound",
"description": "The resolution process confirmed that the handle does not resolve to any DID."
}
],
"output": {
"schema": {
"type": "object",
"required": ["did"],
"properties": {
"did": {
"type": "string",
"format": "did"
}
}
},
"encoding": "application/json"
},
"parameters": {
"type": "params",
"required": ["handle"],
"properties": {
"handle": {
"type": "string",
"format": "handle",
"description": "The handle to resolve."
}
}
},
"description": "Resolves an atproto handle (hostname) to a DID. Does not necessarily bi-directionally verify against the the DID document."
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}
17 changes: 9 additions & 8 deletions server/api/atproto/bluesky-oembed.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
BLUESKY_URL_EXTRACT_REGEX,
} from '#shared/utils/constants'
import { type BlueskyOEmbedResponse, BlueskyOEmbedRequestSchema } from '#shared/schemas/atproto'
import { Client } from '@atproto/lex'
import * as com from '#shared/types/lexicons/com'

export default defineCachedEventHandler(
async (event): Promise<BlueskyOEmbedResponse> => {
Expand All @@ -21,15 +23,14 @@ export default defineCachedEventHandler(
* If the schema passes, this regex is mathematically guaranteed to match and contain both capture groups.
* Match returns ["profile/danielroe.dev/post/123", "danielroe.dev", "123"] — only want the two capture groups, the full match string is discarded.
*/
const [, handle, postId] = url.match(BLUESKY_URL_EXTRACT_REGEX)! as [string, string, string]
const [, handle, postId] = url.match(BLUESKY_URL_EXTRACT_REGEX)! as [
string,
`${string}.${string}`,
string,
]

// INFO: Resolve handle to DID using Bluesky's public API
const { did } = await $fetch<{ did: string }>(
`${BLUESKY_API}com.atproto.identity.resolveHandle`,
{
query: { handle },
},
)
const client = new Client({ service: BLUESKY_API })
const { did } = await client.call(com.atproto.identity.resolveHandle, { handle })

// INFO: Construct the embed URL with the DID
const embedUrl = `${BLUESKY_EMBED_BASE_ROUTE}/embed/${did}/app.bsky.feed.post/${postId}?colorMode=${colorMode}`
Expand Down
Loading