diff --git a/app/components/EmbeddableBlueskyPost.client.vue b/app/components/EmbeddableBlueskyPost.client.vue
new file mode 100644
index 000000000..b23146c5e
--- /dev/null
+++ b/app/components/EmbeddableBlueskyPost.client.vue
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('blog.atproto.view_on_bluesky') }}
+
+
+
+
+
diff --git a/app/composables/useBlogPostBlueskyLink.ts b/app/composables/useBlogPostBlueskyLink.ts
index f02308273..09d324ae9 100644
--- a/app/composables/useBlogPostBlueskyLink.ts
+++ b/app/composables/useBlogPostBlueskyLink.ts
@@ -75,6 +75,7 @@ export function useBlogPostBlueskyLink(slug: MaybeRefOrGetter
diff --git a/app/plugins/bluesky-embed.client.ts b/app/plugins/bluesky-embed.client.ts
new file mode 100644
index 000000000..5972fc7e3
--- /dev/null
+++ b/app/plugins/bluesky-embed.client.ts
@@ -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)
+})
diff --git a/lunaria/files/en-US.json b/lunaria/files/en-US.json
index dbda9466e..b5226c240 100644
--- a/lunaria/files/en-US.json
+++ b/lunaria/files/en-US.json
@@ -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"
}
diff --git a/nuxt.config.ts b/nuxt.config.ts
index b5747b56a..629789155 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -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
@@ -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 },
@@ -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' },
@@ -289,6 +315,7 @@ export default defineNuxtConfig({
},
}),
],
+
optimizeDeps: {
include: [
'@vueuse/core',
diff --git a/server/api/atproto/bluesky-oembed.get.ts b/server/api/atproto/bluesky-oembed.get.ts
new file mode 100644
index 000000000..352f34e36
--- /dev/null
+++ b/server/api/atproto/bluesky-oembed.get.ts
@@ -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 => {
+ 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'}`
+ },
+ },
+)
diff --git a/shared/schemas/atproto.ts b/shared/schemas/atproto.ts
index 16a80f13e..68357869a 100644
--- a/shared/schemas/atproto.ts
+++ b/shared/schemas/atproto.ts
@@ -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(),
@@ -12,3 +26,27 @@ export const BlueSkyUriSchema = object({
})
export type BlueSkyUri = InferOutput
+
+/**
+ * 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
+
+/**
+ * INFO: Explicit type generation for the response.
+ */
+export const BlueskyOEmbedResponseSchema = object({
+ embedUrl: string(),
+ did: string(),
+ postId: string(),
+ handle: string(),
+})
+
+export type BlueskyOEmbedResponse = InferOutput
diff --git a/shared/utils/constants.ts b/shared/utils/constants.ts
index 1229a2d50..978f9905c 100644
--- a/shared/utils/constants.ts
+++ b/shared/utils/constants.ts
@@ -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!'
@@ -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\/([^/]+)/