Skip to content

Commit fc87f65

Browse files
committed
feat: Added embedded bluesky post for markdown files
1 parent 2052c21 commit fc87f65

File tree

9 files changed

+252
-11
lines changed

9 files changed

+252
-11
lines changed
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<script setup lang="ts">
2+
import { BLUESKY_EMBED_BASE_ROUTE } from '#shared/utils/constants'
3+
import type { BlueskyOEmbedResponse } from '#shared/schemas/atproto'
4+
5+
const { url } = defineProps<{
6+
url: string
7+
}>()
8+
9+
const embeddedId = String(Math.random()).slice(2)
10+
const iframeHeight = ref(300)
11+
12+
// INFO: Strictly eager client-side fetch (server: false & lazy: true)
13+
const { data: embedData, status } = useLazyAsyncData<BlueskyOEmbedResponse>(
14+
`bluesky-embed-${embeddedId}`,
15+
() =>
16+
$fetch('/api/atproto/bluesky-oembed', {
17+
query: { url, colorMode: 'system' },
18+
}),
19+
{
20+
// INFO: Redundant with .client.vue but included for surety that SSR is not attempted
21+
server: false,
22+
immediate: true,
23+
},
24+
)
25+
26+
// INFO: Computed URL with Unique ID appended for postMessage handshake, must be stable per component instance
27+
const embedUrl = computed<string | null>(() => {
28+
if (!embedData.value?.embedUrl) return null
29+
return `${embedData.value.embedUrl}&id=${embeddedId}`
30+
})
31+
32+
const isLoading = computed(() => status.value === 'pending')
33+
34+
// INFO: REQUIRED - listener must attach after mount b/c window.postMessage only exists in the browser and the random ID must match between hydration and mount
35+
onMounted(() => {
36+
window.addEventListener('message', onPostMessage)
37+
})
38+
39+
onUnmounted(() => {
40+
window.removeEventListener('message', onPostMessage)
41+
})
42+
43+
function onPostMessage(event: MessageEvent) {
44+
if (event.origin !== BLUESKY_EMBED_BASE_ROUTE) return
45+
if (event.data?.id !== embeddedId) return
46+
if (typeof event.data?.height === 'number') {
47+
iframeHeight.value = event.data.height
48+
}
49+
}
50+
</script>
51+
52+
<template>
53+
<article class="bluesky-embed-container">
54+
<!-- Loading state -->
55+
<LoadingSpinner
56+
v-if="isLoading"
57+
:text="$t('blog.atproto.loading_bluesky_post')"
58+
aria-label="Loading Bluesky post..."
59+
class="loading-spinner"
60+
/>
61+
62+
<!-- Success state -->
63+
<div v-else-if="embedUrl" class="bluesky-embed-container">
64+
<iframe
65+
:data-bluesky-id="embeddedId"
66+
:src="embedUrl"
67+
width="100%"
68+
:height="iframeHeight"
69+
frameborder="0"
70+
scrolling="no"
71+
/>
72+
</div>
73+
74+
<!-- Fallback state -->
75+
<a v-else :href="url" target="_blank" rel="noopener noreferrer">
76+
{{ $t('blog.atproto.view_on_bluesky') }}
77+
</a>
78+
</article>
79+
</template>
80+
81+
<style scoped>
82+
.bluesky-embed-container {
83+
/* INFO: Matches Bluesky's internal max-width */
84+
max-width: 37.5rem;
85+
width: 100%;
86+
margin: 1.5rem 0;
87+
/* INFO: Necessary to remove the white 1px line at the bottom of the embed. Also sets border-radius */
88+
clip-path: inset(0 0 1px 0 round 0.75rem);
89+
}
90+
91+
.bluesky-embed-container > .loading-spinner {
92+
margin: 0 auto;
93+
}
94+
</style>

app/composables/useBlogPostBlueskyLink.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export function useBlogPostBlueskyLink(slug: MaybeRefOrGetter<string | null | un
7575
}
7676
}
7777
} catch (error: unknown) {
78+
// TODO: Will need to remove this console error to satisfy linting scan
7879
// Constellation unavailable or error - fail silently
7980
// But during dev we will get an error
8081
if (import.meta.dev) console.error('[Bluesky] Constellation error:', error)

app/pages/blog/nuxt.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,5 @@ draft: false
1414
# Nuxt
1515

1616
What a great meta-framework!!
17+
18+
<EmbeddableBlueskyPost url="https://bsky.app/profile/danielroe.dev/post/3md3cmrg56k2r" />
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import EmbeddableBlueskyPost from '~/components/EmbeddableBlueskyPost.client.vue'
2+
3+
/**
4+
* INFO: .md files are transformed into Vue SFCs by unplugin-vue-markdown during the Vite transform pipeline
5+
* That transformation happens before Nuxt's component auto-import scanning can inject the proper imports
6+
* Global registration ensures the component is available in the Vue runtime regardless of how the SFC was generated
7+
*/
8+
export default defineNuxtPlugin(nuxtApp => {
9+
nuxtApp.vueApp.component('EmbeddableBlueskyPost', EmbeddableBlueskyPost)
10+
})

lunaria/files/en-US.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@
6363
"title": "Blog",
6464
"heading": "blog",
6565
"meta_description": "Insights and updates from the npmx community",
66+
"atproto": {
67+
"loading_bluesky_post": "Loading Bluesky post...",
68+
"view_on_bluesky": "View this post on Bluesky."
69+
},
6670
"author": {
6771
"view_profile": "View {name}'s profile on Bluesky"
6872
}

nuxt.config.ts

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -90,14 +90,29 @@ export default defineNuxtConfig({
9090
routeRules: {
9191
// API routes
9292
'/api/**': { isr: 60 },
93-
'/api/registry/docs/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
94-
'/api/registry/file/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
95-
'/api/registry/provenance/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
96-
'/api/registry/files/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
93+
'/api/registry/docs/**': {
94+
isr: true,
95+
cache: { maxAge: 365 * 24 * 60 * 60 },
96+
},
97+
'/api/registry/file/**': {
98+
isr: true,
99+
cache: { maxAge: 365 * 24 * 60 * 60 },
100+
},
101+
'/api/registry/provenance/**': {
102+
isr: true,
103+
cache: { maxAge: 365 * 24 * 60 * 60 },
104+
},
105+
'/api/registry/files/**': {
106+
isr: true,
107+
cache: { maxAge: 365 * 24 * 60 * 60 },
108+
},
97109
'/:pkg/.well-known/skills/**': { isr: 3600 },
98110
'/:scope/:pkg/.well-known/skills/**': { isr: 3600 },
99111
'/__og-image__/**': { isr: getISRConfig(60) },
100-
'/_avatar/**': { isr: 3600, proxy: 'https://www.gravatar.com/avatar/**' },
112+
'/_avatar/**': {
113+
isr: 3600,
114+
proxy: 'https://www.gravatar.com/avatar/**',
115+
},
101116
'/opensearch.xml': { isr: true },
102117
'/oauth-client-metadata.json': { prerender: true },
103118
// never cache
@@ -116,9 +131,18 @@ export default defineNuxtConfig({
116131
'/package/:org/:name': { isr: getISRConfig(60, true) },
117132
'/package/:org/:name/v/:version': { isr: getISRConfig(60, true) },
118133
// infinite cache (versioned - doesn't change)
119-
'/package-code/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
120-
'/package-docs/:name/v/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
121-
'/package-docs/:org/:name/v/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
134+
'/package-code/**': {
135+
isr: true,
136+
cache: { maxAge: 365 * 24 * 60 * 60 },
137+
},
138+
'/package-docs/:name/v/**': {
139+
isr: true,
140+
cache: { maxAge: 365 * 24 * 60 * 60 },
141+
},
142+
'/package-docs/:org/:name/v/**': {
143+
isr: true,
144+
cache: { maxAge: 365 * 24 * 60 * 60 },
145+
},
122146
// static pages
123147
'/': { prerender: true },
124148
'/200.html': { prerender: true },
@@ -128,7 +152,9 @@ export default defineNuxtConfig({
128152
'/settings': { prerender: true },
129153
// proxy for insights
130154
'/blog/**': { isr: true, prerender: true },
131-
'/_v/script.js': { proxy: 'https://npmx.dev/_vercel/insights/script.js' },
155+
'/_v/script.js': {
156+
proxy: 'https://npmx.dev/_vercel/insights/script.js',
157+
},
132158
'/_v/view': { proxy: 'https://npmx.dev/_vercel/insights/view' },
133159
'/_v/event': { proxy: 'https://npmx.dev/_vercel/insights/event' },
134160
'/_v/session': { proxy: 'https://npmx.dev/_vercel/insights/session' },
@@ -289,6 +315,7 @@ export default defineNuxtConfig({
289315
},
290316
}),
291317
],
318+
292319
optimizeDeps: {
293320
include: [
294321
'@vueuse/core',
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { parse } from 'valibot'
2+
import { handleApiError } from '#server/utils/error-handler'
3+
import {
4+
CACHE_MAX_AGE_ONE_MINUTE,
5+
BLUESKY_API,
6+
BLUESKY_EMBED_BASE_ROUTE,
7+
ERROR_BLUESKY_EMBED_FAILED,
8+
BLUESKY_URL_EXTRACT_REGEX,
9+
} from '#shared/utils/constants'
10+
import { type BlueskyOEmbedResponse, BlueskyOEmbedRequestSchema } from '#shared/schemas/atproto'
11+
12+
export default defineCachedEventHandler(
13+
async (event): Promise<BlueskyOEmbedResponse> => {
14+
try {
15+
const query = getQuery(event)
16+
const { url, colorMode } = parse(BlueskyOEmbedRequestSchema, query)
17+
18+
/**
19+
* INFO: Extract handle and post ID from https://bsky.app/profile/HANDLE/post/POST_ID
20+
* Casting type here because the schema has already validated the URL format before this line runs.
21+
* If the schema passes, this regex is mathematically guaranteed to match and contain both capture groups.
22+
* Match returns ["profile/danielroe.dev/post/123", "danielroe.dev", "123"] — only want the two capture groups, the full match string is discarded.
23+
*/
24+
const [, handle, postId] = url.match(BLUESKY_URL_EXTRACT_REGEX)! as [string, string, string]
25+
26+
// INFO: Resolve handle to DID using Bluesky's public API
27+
const { did } = await $fetch<{ did: string }>(
28+
`${BLUESKY_API}com.atproto.identity.resolveHandle`,
29+
{
30+
query: { handle },
31+
},
32+
)
33+
34+
// INFO: Construct the embed URL with the DID
35+
const embedUrl = `${BLUESKY_EMBED_BASE_ROUTE}/embed/${did}/app.bsky.feed.post/${postId}?colorMode=${colorMode}`
36+
37+
return {
38+
embedUrl,
39+
did,
40+
postId,
41+
handle,
42+
}
43+
} catch (error) {
44+
handleApiError(error, {
45+
statusCode: 502,
46+
message: ERROR_BLUESKY_EMBED_FAILED,
47+
})
48+
}
49+
},
50+
{
51+
name: 'bluesky-oembed',
52+
maxAge: CACHE_MAX_AGE_ONE_MINUTE,
53+
getKey: event => {
54+
const { url, colorMode } = getQuery(event)
55+
return `oembed:${url}:${colorMode ?? 'system'}`
56+
},
57+
},
58+
)

shared/schemas/atproto.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,21 @@
1-
import { object, string, startsWith, minLength, regex, pipe } from 'valibot'
1+
import {
2+
object,
3+
string,
4+
startsWith,
5+
minLength,
6+
regex,
7+
pipe,
8+
nonEmpty,
9+
optional,
10+
picklist,
11+
} from 'valibot'
212
import type { InferOutput } from 'valibot'
3-
import { AT_URI_REGEX } from '#shared/utils/constants'
13+
import { AT_URI_REGEX, BLUESKY_URL_REGEX, ERROR_BLUESKY_URL_FAILED } from '#shared/utils/constants'
414

15+
/**
16+
* INFO: Validates AT Protocol URI format (at://did:plc:.../app.bsky.feed.post/...)
17+
* Used for referencing Bluesky posts in our database and API routes.
18+
*/
519
export const BlueSkyUriSchema = object({
620
uri: pipe(
721
string(),
@@ -12,3 +26,27 @@ export const BlueSkyUriSchema = object({
1226
})
1327

1428
export type BlueSkyUri = InferOutput<typeof BlueSkyUriSchema>
29+
30+
/**
31+
* INFO: Validates query parameters for Bluesky oEmbed generation.
32+
* - url: Must be a valid bsky.app profile post URL
33+
* - colorMode: Optional theme preference (defaults to 'system')
34+
*/
35+
export const BlueskyOEmbedRequestSchema = object({
36+
url: pipe(string(), nonEmpty(), regex(BLUESKY_URL_REGEX, ERROR_BLUESKY_URL_FAILED)),
37+
colorMode: optional(picklist(['system', 'dark', 'light']), 'system'),
38+
})
39+
40+
export type BlueskyOEmbedRequest = InferOutput<typeof BlueskyOEmbedRequestSchema>
41+
42+
/**
43+
* INFO: Explicit type generation for the response.
44+
*/
45+
export const BlueskyOEmbedResponseSchema = object({
46+
embedUrl: string(),
47+
did: string(),
48+
postId: string(),
49+
handle: string(),
50+
})
51+
52+
export type BlueskyOEmbedResponse = InferOutput<typeof BlueskyOEmbedResponseSchema>

shared/utils/constants.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,16 @@ export const CACHE_MAX_AGE_ONE_YEAR = 60 * 60 * 24 * 365
1010
// API Strings
1111
export const NPMX_SITE = 'https://npmx.dev'
1212
export const BLUESKY_API = 'https://public.api.bsky.app/xrpc/'
13+
export const BLUESKY_EMBED_BASE_ROUTE = 'https://embed.bsky.app'
1314
export const BLUESKY_COMMENTS_REQUEST = '/api/atproto/bluesky-comments'
1415
export const NPM_REGISTRY = 'https://registry.npmjs.org'
1516
export const ERROR_PACKAGE_ANALYSIS_FAILED = 'Failed to analyze package.'
1617
export const ERROR_PACKAGE_VERSION_AND_FILE_FAILED = 'Version and file path are required.'
1718
export const ERROR_PACKAGE_REQUIREMENTS_FAILED =
1819
'Package name, version, and file path are required.'
20+
export const ERROR_BLUESKY_URL_FAILED =
21+
'Invalid Bluesky URL format. Expected: https://bsky.app/profile/HANDLE/post/POST_ID'
22+
export const ERROR_BLUESKY_EMBED_FAILED = 'Failed to generate Bluesky embed.'
1923
export const ERROR_FILE_LIST_FETCH_FAILED = 'Failed to fetch file list.'
2024
export const ERROR_CALC_INSTALL_SIZE_FAILED = 'Failed to calculate install size.'
2125
export const NPM_MISSING_README_SENTINEL = 'ERROR: No README data found!'
@@ -74,3 +78,6 @@ export const BACKGROUND_THEMES = {
7478

7579
// Regex
7680
export const AT_URI_REGEX = /^at:\/\/(did:plc:[a-z0-9]+)\/app\.bsky\.feed\.post\/([a-z0-9]+)$/
81+
export const BLUESKY_URL_REGEX = /^https:\/\/bsky\.app\/profile\/[^/]+\/post\/[^/]+$/
82+
// INFO: For capture groups
83+
export const BLUESKY_URL_EXTRACT_REGEX = /profile\/([^/]+)\/post\/([^/]+)/

0 commit comments

Comments
 (0)