Skip to content
Open
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
94 changes: 94 additions & 0 deletions app/components/EmbeddableBlueskyPost.client.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { BLUESKY_EMBED_BASE_ROUTE } from '#shared/utils/constants'
import type { BlueskyOEmbedResponse } from '#shared/schemas/atproto'

const { url } = defineProps<{
url: string
}>()

const embeddedId = String(Math.random()).slice(2)
const iframeHeight = ref(300)

// INFO: Strictly eager client-side fetch (server: false & lazy: true)
const { data: embedData, status } = useLazyAsyncData<BlueskyOEmbedResponse>(
`bluesky-embed-${embeddedId}`,
() =>
$fetch('/api/atproto/bluesky-oembed', {
query: { url, colorMode: 'system' },
}),
{
// INFO: Redundant with .client.vue but included for surety that SSR is not attempted
server: false,
immediate: true,
},
)

// INFO: Computed URL with Unique ID appended for postMessage handshake, must be stable per component instance
const embedUrl = computed<string | null>(() => {
if (!embedData.value?.embedUrl) return null
return `${embedData.value.embedUrl}&id=${embeddedId}`
})

const isLoading = computed(() => status.value === 'pending')

// 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
onMounted(() => {
window.addEventListener('message', onPostMessage)
})

onUnmounted(() => {
window.removeEventListener('message', onPostMessage)
})

function onPostMessage(event: MessageEvent) {
if (event.origin !== BLUESKY_EMBED_BASE_ROUTE) return
if (event.data?.id !== embeddedId) return
if (typeof event.data?.height === 'number') {
iframeHeight.value = event.data.height
}
}
</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
: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>
</template>

<style scoped>
.bluesky-embed-container {
/* INFO: Matches Bluesky's internal max-width */
max-width: 37.5rem;
width: 100%;
margin: 1.5rem 0;
/* INFO: Necessary to remove the white 1px line at the bottom of the embed. Also sets border-radius */
clip-path: inset(0 0 1px 0 round 0.75rem);
}

.bluesky-embed-container > .loading-spinner {
margin: 0 auto;
}
</style>
1 change: 1 addition & 0 deletions app/composables/useBlogPostBlueskyLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export function useBlogPostBlueskyLink(slug: MaybeRefOrGetter<string | null | un
}
}
} catch (error: unknown) {
// TODO: Will need to remove this console error to satisfy linting scan
// Constellation unavailable or error - fail silently
// But during dev we will get an error
if (import.meta.dev) console.error('[Bluesky] Constellation error:', error)
Expand Down
2 changes: 2 additions & 0 deletions app/pages/blog/nuxt.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@ draft: false
# Nuxt

What a great meta-framework!!

<EmbeddableBlueskyPost url="https://bsky.app/profile/danielroe.dev/post/3md3cmrg56k2r" />
10 changes: 10 additions & 0 deletions app/plugins/bluesky-embed.client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import EmbeddableBlueskyPost from '~/components/EmbeddableBlueskyPost.client.vue'

/**
* INFO: .md files are transformed into Vue SFCs by unplugin-vue-markdown during the Vite transform pipeline
* That transformation happens before Nuxt's component auto-import scanning can inject the proper imports
* Global registration ensures the component is available in the Vue runtime regardless of how the SFC was generated
*/
export default defineNuxtPlugin(nuxtApp => {
nuxtApp.vueApp.component('EmbeddableBlueskyPost', EmbeddableBlueskyPost)
})
4 changes: 4 additions & 0 deletions lunaria/files/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@
"title": "Blog",
"heading": "blog",
"meta_description": "Insights and updates from the npmx community",
"atproto": {
"loading_bluesky_post": "Loading Bluesky post...",
"view_on_bluesky": "View this post on Bluesky."
},
"author": {
"view_profile": "View {name}'s profile on Bluesky"
}
Expand Down
45 changes: 36 additions & 9 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,29 @@ export default defineNuxtConfig({
routeRules: {
// API routes
'/api/**': { isr: 60 },
'/api/registry/docs/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
'/api/registry/file/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
'/api/registry/provenance/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
'/api/registry/files/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
'/api/registry/docs/**': {
isr: true,
cache: { maxAge: 365 * 24 * 60 * 60 },
},
'/api/registry/file/**': {
isr: true,
cache: { maxAge: 365 * 24 * 60 * 60 },
},
'/api/registry/provenance/**': {
isr: true,
cache: { maxAge: 365 * 24 * 60 * 60 },
},
'/api/registry/files/**': {
isr: true,
cache: { maxAge: 365 * 24 * 60 * 60 },
},
'/:pkg/.well-known/skills/**': { isr: 3600 },
'/:scope/:pkg/.well-known/skills/**': { isr: 3600 },
'/__og-image__/**': { isr: getISRConfig(60) },
'/_avatar/**': { isr: 3600, proxy: 'https://www.gravatar.com/avatar/**' },
'/_avatar/**': {
isr: 3600,
proxy: 'https://www.gravatar.com/avatar/**',
},
'/opensearch.xml': { isr: true },
'/oauth-client-metadata.json': { prerender: true },
// never cache
Expand All @@ -116,9 +131,18 @@ export default defineNuxtConfig({
'/package/:org/:name': { isr: getISRConfig(60, true) },
'/package/:org/:name/v/:version': { isr: getISRConfig(60, true) },
// infinite cache (versioned - doesn't change)
'/package-code/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
'/package-docs/:name/v/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
'/package-docs/:org/:name/v/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
'/package-code/**': {
isr: true,
cache: { maxAge: 365 * 24 * 60 * 60 },
},
'/package-docs/:name/v/**': {
isr: true,
cache: { maxAge: 365 * 24 * 60 * 60 },
},
'/package-docs/:org/:name/v/**': {
isr: true,
cache: { maxAge: 365 * 24 * 60 * 60 },
},
// static pages
'/': { prerender: true },
'/200.html': { prerender: true },
Expand All @@ -128,7 +152,9 @@ export default defineNuxtConfig({
'/settings': { prerender: true },
// proxy for insights
'/blog/**': { isr: true, prerender: true },
'/_v/script.js': { proxy: 'https://npmx.dev/_vercel/insights/script.js' },
'/_v/script.js': {
proxy: 'https://npmx.dev/_vercel/insights/script.js',
},
'/_v/view': { proxy: 'https://npmx.dev/_vercel/insights/view' },
'/_v/event': { proxy: 'https://npmx.dev/_vercel/insights/event' },
'/_v/session': { proxy: 'https://npmx.dev/_vercel/insights/session' },
Expand Down Expand Up @@ -289,6 +315,7 @@ export default defineNuxtConfig({
},
}),
],

optimizeDeps: {
include: [
'@vueuse/core',
Expand Down
58 changes: 58 additions & 0 deletions server/api/atproto/bluesky-oembed.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { parse } from 'valibot'
import { handleApiError } from '#server/utils/error-handler'
import {
CACHE_MAX_AGE_ONE_MINUTE,
BLUESKY_API,
BLUESKY_EMBED_BASE_ROUTE,
ERROR_BLUESKY_EMBED_FAILED,
BLUESKY_URL_EXTRACT_REGEX,
} from '#shared/utils/constants'
import { type BlueskyOEmbedResponse, BlueskyOEmbedRequestSchema } from '#shared/schemas/atproto'

export default defineCachedEventHandler(
async (event): Promise<BlueskyOEmbedResponse> => {
try {
const query = getQuery(event)
const { url, colorMode } = parse(BlueskyOEmbedRequestSchema, query)

/**
* INFO: Extract handle and post ID from https://bsky.app/profile/HANDLE/post/POST_ID
* Casting type here because the schema has already validated the URL format before this line runs.
* 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]

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

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

return {
embedUrl,
did,
postId,
handle,
}
} catch (error) {
handleApiError(error, {
statusCode: 502,
message: ERROR_BLUESKY_EMBED_FAILED,
})
}
},
{
name: 'bluesky-oembed',
maxAge: CACHE_MAX_AGE_ONE_MINUTE,
getKey: event => {
const { url, colorMode } = getQuery(event)
return `oembed:${url}:${colorMode ?? 'system'}`
},
},
)
42 changes: 40 additions & 2 deletions shared/schemas/atproto.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
import { object, string, startsWith, minLength, regex, pipe } from 'valibot'
import {
object,
string,
startsWith,
minLength,
regex,
pipe,
nonEmpty,
optional,
picklist,
} from 'valibot'
import type { InferOutput } from 'valibot'
import { AT_URI_REGEX } from '#shared/utils/constants'
import { AT_URI_REGEX, BLUESKY_URL_REGEX, ERROR_BLUESKY_URL_FAILED } from '#shared/utils/constants'

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

export type BlueSkyUri = InferOutput<typeof BlueSkyUriSchema>

/**
* INFO: Validates query parameters for Bluesky oEmbed generation.
* - url: Must be a valid bsky.app profile post URL
* - colorMode: Optional theme preference (defaults to 'system')
*/
export const BlueskyOEmbedRequestSchema = object({
url: pipe(string(), nonEmpty(), regex(BLUESKY_URL_REGEX, ERROR_BLUESKY_URL_FAILED)),
colorMode: optional(picklist(['system', 'dark', 'light']), 'system'),
})

export type BlueskyOEmbedRequest = InferOutput<typeof BlueskyOEmbedRequestSchema>

/**
* INFO: Explicit type generation for the response.
*/
export const BlueskyOEmbedResponseSchema = object({
embedUrl: string(),
did: string(),
postId: string(),
handle: string(),
})

export type BlueskyOEmbedResponse = InferOutput<typeof BlueskyOEmbedResponseSchema>
7 changes: 7 additions & 0 deletions shared/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,16 @@ export const CACHE_MAX_AGE_ONE_YEAR = 60 * 60 * 24 * 365
// API Strings
export const NPMX_SITE = 'https://npmx.dev'
export const BLUESKY_API = 'https://public.api.bsky.app/xrpc/'
export const BLUESKY_EMBED_BASE_ROUTE = 'https://embed.bsky.app'
export const BLUESKY_COMMENTS_REQUEST = '/api/atproto/bluesky-comments'
export const NPM_REGISTRY = 'https://registry.npmjs.org'
export const ERROR_PACKAGE_ANALYSIS_FAILED = 'Failed to analyze package.'
export const ERROR_PACKAGE_VERSION_AND_FILE_FAILED = 'Version and file path are required.'
export const ERROR_PACKAGE_REQUIREMENTS_FAILED =
'Package name, version, and file path are required.'
export const ERROR_BLUESKY_URL_FAILED =
'Invalid Bluesky URL format. Expected: https://bsky.app/profile/HANDLE/post/POST_ID'
export const ERROR_BLUESKY_EMBED_FAILED = 'Failed to generate Bluesky embed.'
export const ERROR_FILE_LIST_FETCH_FAILED = 'Failed to fetch file list.'
export const ERROR_CALC_INSTALL_SIZE_FAILED = 'Failed to calculate install size.'
export const NPM_MISSING_README_SENTINEL = 'ERROR: No README data found!'
Expand Down Expand Up @@ -74,3 +78,6 @@ export const BACKGROUND_THEMES = {

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