+ {{ title }} +
++ {{ excerpt }} +
++ {{ excerpt }} +
++ + {{ segment.text }} + {{ segment.text }} + +
+ + +No comments yet.
+ + Reply on Bluesky + +
+
+### Code Blocks
+
+Pre-formatted code blocks are used for writing about programming or
+markup source code. Rather than forming normal paragraphs, the lines
+of a code block are interpreted literally. Markdown wraps a code block
+in both `` and `` tags.
+
+To produce a code block in Markdown, simply indent every line of the
+block by at least 4 spaces or 1 tab.
+
+This is a normal paragraph:
+
+ This is a code block.
+
+Here is an example of AppleScript:
+
+ tell application "Foo"
+ beep
+ end tell
+
+A code block continues until it reaches a line that is not indented
+(or the end of the article).
+
+Within a code block, ampersands (`&`) and angle brackets (`<` and `>`)
+are automatically converted into HTML entities. This makes it very
+easy to include example HTML source code using Markdown -- just paste
+it and indent it, and Markdown will handle the hassle of encoding the
+ampersands and angle brackets. For example, this:
+
+
+
+Regular Markdown syntax is not processed within code blocks. E.g.,
+asterisks are just literal asterisks within a code block. This means
+it's also easy to use Markdown to write about Markdown's own syntax.
+
+```
+tell application "Foo"
+ beep
+end tell
+```
+
+## Span Elements
+
+### Links
+
+Markdown supports two style of links: _inline_ and _reference_.
+
+In both styles, the link text is delimited by [square brackets].
+
+To create an inline link, use a set of regular parentheses immediately
+after the link text's closing square bracket. Inside the parentheses,
+put the URL where you want the link to point, along with an _optional_
+title for the link, surrounded in quotes. For example:
+
+This is [an example](http://example.com/) inline link.
+
+[This link](http://example.net/) has no title attribute.
+
+### Emphasis
+
+Markdown treats asterisks (`*`) and underscores (`_`) as indicators of
+emphasis. Text wrapped with one `*` or `_` will be wrapped with an
+HTML `` tag; double `*`'s or `_`'s will be wrapped with an HTML
+`` tag. E.g., this input:
+
+_single asterisks_
+
+_single underscores_
+
+**double asterisks**
+
+**double underscores**
+
+### Code
+
+To indicate a span of code, wrap it with backtick quotes (`` ` ``).
+Unlike a pre-formatted code block, a code span indicates code within a
+normal paragraph. For example:
+
+Use the `printf()` function.
+
+
diff --git a/app/pages/blog/index.vue b/app/pages/blog/index.vue
new file mode 100644
index 000000000..3bed70129
--- /dev/null
+++ b/app/pages/blog/index.vue
@@ -0,0 +1,156 @@
+
+
+
+
+
+
+
+ {{ $t('blog.heading') }}
+
+
+ {{ $t('tagline') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No posts found.
+
+
+
+
+
diff --git a/app/plugins/blog-wrapper.ts b/app/plugins/blog-wrapper.ts
new file mode 100644
index 000000000..fff0ff571
--- /dev/null
+++ b/app/plugins/blog-wrapper.ts
@@ -0,0 +1,5 @@
+import BlogPostWrapper from '~/components/BlogPostWrapper.vue'
+
+export default defineNuxtPlugin(nuxtApp => {
+ nuxtApp.vueApp.component('BlogPostWrapper', BlogPostWrapper)
+})
diff --git a/app/plugins/bluesky-embed.ts b/app/plugins/bluesky-embed.ts
new file mode 100644
index 000000000..ac05b53ed
--- /dev/null
+++ b/app/plugins/bluesky-embed.ts
@@ -0,0 +1,10 @@
+import EmbeddableBlueskyPost from '~/components/EmbeddableBlueskyPost.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/app/utils/bluesky.ts b/app/utils/bluesky.ts
new file mode 100644
index 000000000..5c41e9896
--- /dev/null
+++ b/app/utils/bluesky.ts
@@ -0,0 +1,8 @@
+export function atUriToWebUrl(atUri: string): string | null {
+ // Convert AT URI to bsky.app URL
+ // at://did:plc:xxx/app.bsky.feed.post/rkey -> https://bsky.app/profile/did:plc:xxx/post/rkey
+ const match = atUri.match(AT_URI_REGEX)
+ if (!match) return null
+ const [, did, rkey] = match
+ return `https://bsky.app/profile/${did}/post/${rkey}`
+}
diff --git a/i18n/locales/en.json b/i18n/locales/en.json
index e3f07511e..d145fc57f 100644
--- a/i18n/locales/en.json
+++ b/i18n/locales/en.json
@@ -13,6 +13,7 @@
"trademark_disclaimer": "npm is a registered trademark of npm, Inc. This site is not affiliated with npm, Inc.",
"footer": {
"about": "about",
+ "blog": "blog",
"docs": "docs",
"source": "source",
"social": "social",
@@ -76,6 +77,18 @@
"links": "Links",
"tap_to_search": "Tap to search"
},
+ "blog": {
+ "title": "Blog",
+ "heading": "blog",
+ "meta_description": "Insights and updates from the npmx community",
+ "author": {
+ "view_profile": "View {name}'s profile on Bluesky"
+ },
+ "atproto": {
+ "loading_bluesky_post": "Loading Bluesky post...",
+ "view_on_bluesky": "View this post on Bluesky"
+ }
+ },
"settings": {
"title": "settings",
"tagline": "customize your npmx experience",
diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json
index dc3fc9f86..17d42d0f0 100644
--- a/i18n/locales/fr-FR.json
+++ b/i18n/locales/fr-FR.json
@@ -76,6 +76,10 @@
"links": "Liens",
"tap_to_search": "Toucher pour rechercher"
},
+ "blog": {
+ "title": "Blog",
+ "author": {}
+ },
"settings": {
"title": "paramètres",
"tagline": "personnalisez votre expérience npmx",
diff --git a/lexicons.json b/lexicons.json
index c13112e7a..af3d3f232 100644
--- a/lexicons.json
+++ b/lexicons.json
@@ -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"
],
@@ -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"
diff --git a/lexicons/com/atproto/identity/resolveHandle.json b/lexicons/com/atproto/identity/resolveHandle.json
new file mode 100644
index 000000000..3b7bff82b
--- /dev/null
+++ b/lexicons/com/atproto/identity/resolveHandle.json
@@ -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
+}
diff --git a/lunaria/files/en-GB.json b/lunaria/files/en-GB.json
index 458a5f8f7..ff8293383 100644
--- a/lunaria/files/en-GB.json
+++ b/lunaria/files/en-GB.json
@@ -12,6 +12,7 @@
"trademark_disclaimer": "npm is a registered trademark of npm, Inc. This site is not affiliated with npm, Inc.",
"footer": {
"about": "about",
+ "blog": "blog",
"docs": "docs",
"source": "source",
"social": "social",
@@ -75,6 +76,18 @@
"links": "Links",
"tap_to_search": "Tap to search"
},
+ "blog": {
+ "title": "Blog",
+ "heading": "blog",
+ "meta_description": "Insights and updates from the npmx community",
+ "author": {
+ "view_profile": "View {name}'s profile on Bluesky"
+ },
+ "atproto": {
+ "loading_bluesky_post": "Loading Bluesky post...",
+ "view_on_bluesky": "View this post on Bluesky"
+ }
+ },
"settings": {
"title": "settings",
"tagline": "customise your npmx experience",
diff --git a/lunaria/files/en-US.json b/lunaria/files/en-US.json
index a4424e083..3d90a3e69 100644
--- a/lunaria/files/en-US.json
+++ b/lunaria/files/en-US.json
@@ -12,6 +12,7 @@
"trademark_disclaimer": "npm is a registered trademark of npm, Inc. This site is not affiliated with npm, Inc.",
"footer": {
"about": "about",
+ "blog": "blog",
"docs": "docs",
"source": "source",
"social": "social",
@@ -75,6 +76,18 @@
"links": "Links",
"tap_to_search": "Tap to search"
},
+ "blog": {
+ "title": "Blog",
+ "heading": "blog",
+ "meta_description": "Insights and updates from the npmx community",
+ "author": {
+ "view_profile": "View {name}'s profile on Bluesky"
+ },
+ "atproto": {
+ "loading_bluesky_post": "Loading Bluesky post...",
+ "view_on_bluesky": "View this post on Bluesky"
+ }
+ },
"settings": {
"title": "settings",
"tagline": "customize your npmx experience",
diff --git a/lunaria/files/fr-FR.json b/lunaria/files/fr-FR.json
index 56caacd0a..b5df3a774 100644
--- a/lunaria/files/fr-FR.json
+++ b/lunaria/files/fr-FR.json
@@ -75,6 +75,10 @@
"links": "Liens",
"tap_to_search": "Toucher pour rechercher"
},
+ "blog": {
+ "title": "Blog",
+ "author": {}
+ },
"settings": {
"title": "paramètres",
"tagline": "personnalisez votre expérience npmx",
diff --git a/nuxt.config.ts b/nuxt.config.ts
index 5965c351e..22af42cfe 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -1,8 +1,10 @@
import process from 'node:process'
import { currentLocales } from './config/i18n'
+import Markdown from 'unplugin-vue-markdown/vite'
import { isCI, provider } from 'std-env'
export default defineNuxtConfig({
+ extensions: ['.md'],
modules: [
'@unocss/nuxt',
'@nuxtjs/html-validator',
@@ -150,7 +152,10 @@ export default defineNuxtConfig({
'/search': { isr: false, cache: false }, // never cache
'/settings': { prerender: true },
// proxy for insights
- '/_v/script.js': { proxy: 'https://npmx.dev/_vercel/insights/script.js' },
+ '/blog/**': { isr: true, prerender: true },
+ '/_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' },
@@ -295,6 +300,28 @@ export default defineNuxtConfig({
},
vite: {
+ vue: {
+ include: [/\.vue($|\?)/, /\.(md|markdown)($|\?)/],
+ },
+ plugins: [
+ Markdown({
+ include: [/\.(md|markdown)($|\?)/],
+ wrapperComponent: 'BlogPostWrapper',
+ wrapperClasses: 'text-fg-muted leading-relaxed',
+ async markdownItSetup(md) {
+ const shiki = await import('@shikijs/markdown-it')
+ md.use(
+ await shiki.default({
+ themes: {
+ dark: 'github-dark',
+ light: 'github-light',
+ },
+ }),
+ )
+ },
+ }),
+ ],
+
optimizeDeps: {
include: [
'@vueuse/core',
diff --git a/package.json b/package.json
index 12ea115e5..1fe8f1d38 100644
--- a/package.json
+++ b/package.json
@@ -48,6 +48,8 @@
"start:playwright:webserver": "NODE_ENV=test pnpm preview --port 5678"
},
"dependencies": {
+ "@atcute/bluesky-richtext-segmenter": "3.0.0",
+ "@atproto/api": "^0.18.17",
"@atproto/common": "0.5.10",
"@atproto/lex": "0.0.13",
"@atproto/oauth-client-node": "^0.3.15",
@@ -115,10 +117,12 @@
"@intlify/core-base": "11.2.8",
"@npm/types": "2.1.0",
"@playwright/test": "1.58.1",
+ "@shikijs/markdown-it": "^3.21.0",
"@types/node": "24.10.9",
"@types/sanitize-html": "2.16.0",
"@types/semver": "7.7.1",
"@types/validate-npm-package-name": "4.0.2",
+ "@valibot/to-json-schema": "^1.5.0",
"@vitest/browser-playwright": "4.0.18",
"@vitest/coverage-v8": "4.0.18",
"@vue/test-utils": "2.4.6",
@@ -134,6 +138,7 @@
"schema-dts": "1.1.5",
"simple-git-hooks": "2.13.1",
"typescript": "5.9.3",
+ "unplugin-vue-markdown": "^29.2.0",
"vitest": "npm:@voidzero-dev/vite-plus-test@0.0.0-833c515fa25cef20905a7f9affb156dfa6f151ab",
"vitest-environment-nuxt": "1.0.1",
"vue-i18n-extract": "2.0.7",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 21602824a..b0f32c2cc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -20,6 +20,12 @@ importers:
.:
dependencies:
+ '@atcute/bluesky-richtext-segmenter':
+ specifier: 3.0.0
+ version: 3.0.0
+ '@atproto/api':
+ specifier: ^0.18.17
+ version: 0.18.20
'@atproto/common':
specifier: 0.5.10
version: 0.5.10
@@ -216,6 +222,9 @@ importers:
'@playwright/test':
specifier: 1.58.1
version: 1.58.1
+ '@shikijs/markdown-it':
+ specifier: ^3.21.0
+ version: 3.22.0(markdown-it-async@2.2.0)
'@types/node':
specifier: 24.10.9
version: 24.10.9
@@ -228,6 +237,9 @@ importers:
'@types/validate-npm-package-name':
specifier: 4.0.2
version: 4.0.2
+ '@valibot/to-json-schema':
+ specifier: ^1.5.0
+ version: 1.5.0(valibot@1.2.0(typescript@5.9.3))
'@vitest/browser-playwright':
specifier: 4.0.18
version: 4.0.18(@voidzero-dev/vite-plus-test@0.0.0-833c515fa25cef20905a7f9affb156dfa6f151ab(@types/node@24.10.9)(esbuild@0.27.3)(happy-dom@20.4.0)(jiti@2.6.1)(terser@5.46.0)(typescript@5.9.3)(yaml@2.8.2))(playwright@1.58.1)(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))
@@ -270,6 +282,9 @@ importers:
typescript:
specifier: 5.9.3
version: 5.9.3
+ unplugin-vue-markdown:
+ specifier: ^29.2.0
+ version: 29.2.0(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))
vitest:
specifier: npm:@voidzero-dev/vite-plus-test@0.0.0-833c515fa25cef20905a7f9affb156dfa6f151ab
version: '@voidzero-dev/vite-plus-test@0.0.0-833c515fa25cef20905a7f9affb156dfa6f151ab(@types/node@24.10.9)(esbuild@0.27.3)(happy-dom@20.4.0)(jiti@2.6.1)(terser@5.46.0)(typescript@5.9.3)(yaml@2.8.2)'
@@ -330,7 +345,7 @@ importers:
version: 12.5.0
docus:
specifier: 5.4.4
- version: 5.4.4(0a5f208c5e5e09cca0eb8d9e13c93aa6)
+ version: 5.4.4(07bea562f86d8cbb55a41040dc791771)
nuxt:
specifier: 4.3.1
version: 4.3.1(@parcel/watcher@2.5.6)(@types/node@24.10.9)(@upstash/redis@1.36.1)(@vercel/kv@3.0.0)(@vue/compiler-sfc@3.5.27)(better-sqlite3@12.5.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.5.0))(eslint@9.39.2(jiti@2.6.1))(ioredis@5.9.2)(magicast@0.5.1)(optionator@0.9.4)(oxlint@1.42.0(oxlint-tsgolint@0.11.3))(rolldown@1.0.0-rc.1)(rollup@4.57.0)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@3.2.4(typescript@5.9.3))(yaml@2.8.2)
@@ -410,6 +425,9 @@ packages:
resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==}
engines: {node: '>= 16'}
+ '@atcute/bluesky-richtext-segmenter@3.0.0':
+ resolution: {integrity: sha512-NhZTUKtFpeBBbILwAcxj5u4RobIoHOmGw3CAaaEFNebKYSvmTecrXJ7XufHw5DFOUdr8SiKXQVRQxGAxulMNWg==}
+
'@atproto-labs/did-resolver@0.2.6':
resolution: {integrity: sha512-2K1bC04nI2fmgNcvof+yA28IhGlpWn2JKYlPa7To9JTKI45FINCGkQSGiL2nyXlyzDJJ34fZ1aq6/IRFIOIiqg==}
@@ -439,6 +457,9 @@ packages:
'@atproto-labs/simple-store@0.3.0':
resolution: {integrity: sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ==}
+ '@atproto/api@0.18.20':
+ resolution: {integrity: sha512-BZYZkh2VJIFCXEnc/vzKwAwWjAQQTgbNJ8FBxpBK+z+KYh99O0uPCsRYKoCQsRrnkgrhzdU9+g2G+7zanTIGbw==}
+
'@atproto/common-web@0.4.15':
resolution: {integrity: sha512-A4l9gyqUNez8CjZp/Trypz/D3WIQsNj8dN05WR6+RoBbvwc9JhWjKPrm+WoVYc/F16RPdXHLkE3BEJlGIyYIiA==}
@@ -1885,6 +1906,18 @@ packages:
engines: {node: '>=18'}
hasBin: true
+ '@mdit-vue/plugin-component@3.0.2':
+ resolution: {integrity: sha512-Fu53MajrZMOAjOIPGMTdTXgHLgGU9KwTqKtYc6WNYtFZNKw04euSfJ/zFg8eBY/2MlciVngkF7Gyc2IL7e8Bsw==}
+ engines: {node: '>=20.0.0'}
+
+ '@mdit-vue/plugin-frontmatter@3.0.2':
+ resolution: {integrity: sha512-QKKgIva31YtqHgSAz7S7hRcL7cHXiqdog4wxTfxeQCHo+9IP4Oi5/r1Y5E93nTPccpadDWzAwr3A0F+kAEnsVQ==}
+ engines: {node: '>=20.0.0'}
+
+ '@mdit-vue/types@3.0.2':
+ resolution: {integrity: sha512-00aAZ0F0NLik6I6Yba2emGbHLxv+QYrPH00qQ5dFKXlAo1Ll2RHDXwY7nN2WAfrx2pP+WrvSRFTGFCNGdzBDHw==}
+ engines: {node: '>=20.0.0'}
+
'@miyaneee/rollup-plugin-json5@1.2.0':
resolution: {integrity: sha512-JjTIaXZp9WzhUHpElrqPnl1AzBi/rvRs065F71+aTmlqvTMVkdbjZ8vfFl4nRlgJy+TPBw69ZK4pwFdmOAt4aA==}
peerDependencies:
@@ -3533,24 +3566,50 @@ packages:
'@shikijs/core@3.21.0':
resolution: {integrity: sha512-AXSQu/2n1UIQekY8euBJlvFYZIw0PHY63jUzGbrOma4wPxzznJXTXkri+QcHeBNaFxiiOljKxxJkVSoB3PjbyA==}
+ '@shikijs/core@3.22.0':
+ resolution: {integrity: sha512-iAlTtSDDbJiRpvgL5ugKEATDtHdUVkqgHDm/gbD2ZS9c88mx7G1zSYjjOxp5Qa0eaW0MAQosFRmJSk354PRoQA==}
+
'@shikijs/engine-javascript@3.21.0':
resolution: {integrity: sha512-ATwv86xlbmfD9n9gKRiwuPpWgPENAWCLwYCGz9ugTJlsO2kOzhOkvoyV/UD+tJ0uT7YRyD530x6ugNSffmvIiQ==}
+ '@shikijs/engine-javascript@3.22.0':
+ resolution: {integrity: sha512-jdKhfgW9CRtj3Tor0L7+yPwdG3CgP7W+ZEqSsojrMzCjD1e0IxIbwUMDDpYlVBlC08TACg4puwFGkZfLS+56Tw==}
+
'@shikijs/engine-oniguruma@3.21.0':
resolution: {integrity: sha512-OYknTCct6qiwpQDqDdf3iedRdzj6hFlOPv5hMvI+hkWfCKs5mlJ4TXziBG9nyabLwGulrUjHiCq3xCspSzErYQ==}
+ '@shikijs/engine-oniguruma@3.22.0':
+ resolution: {integrity: sha512-DyXsOG0vGtNtl7ygvabHd7Mt5EY8gCNqR9Y7Lpbbd/PbJvgWrqaKzH1JW6H6qFkuUa8aCxoiYVv8/YfFljiQxA==}
+
'@shikijs/langs@3.21.0':
resolution: {integrity: sha512-g6mn5m+Y6GBJ4wxmBYqalK9Sp0CFkUqfNzUy2pJglUginz6ZpWbaWjDB4fbQ/8SHzFjYbtU6Ddlp1pc+PPNDVA==}
+ '@shikijs/langs@3.22.0':
+ resolution: {integrity: sha512-x/42TfhWmp6H00T6uwVrdTJGKgNdFbrEdhaDwSR5fd5zhQ1Q46bHq9EO61SCEWJR0HY7z2HNDMaBZp8JRmKiIA==}
+
+ '@shikijs/markdown-it@3.22.0':
+ resolution: {integrity: sha512-l0DehGhJN+JkkzGwULVhE57S9o7zQA2lVfpEjcvpSkxTbhzTEuoKHjuQQUN0VNbYeyj4obZwB4CTVrMijvYaeg==}
+ peerDependencies:
+ markdown-it-async: ^2.2.0
+ peerDependenciesMeta:
+ markdown-it-async:
+ optional: true
+
'@shikijs/themes@3.21.0':
resolution: {integrity: sha512-BAE4cr9EDiZyYzwIHEk7JTBJ9CzlPuM4PchfcA5ao1dWXb25nv6hYsoDiBq2aZK9E3dlt3WB78uI96UESD+8Mw==}
+ '@shikijs/themes@3.22.0':
+ resolution: {integrity: sha512-o+tlOKqsr6FE4+mYJG08tfCFDS+3CG20HbldXeVoyP+cYSUxDhrFf3GPjE60U55iOkkjbpY2uC3It/eeja35/g==}
+
'@shikijs/transformers@3.21.0':
resolution: {integrity: sha512-CZwvCWWIiRRiFk9/JKzdEooakAP8mQDtBOQ1TKiCaS2E1bYtyBCOkUzS8akO34/7ufICQ29oeSfkb3tT5KtrhA==}
'@shikijs/types@3.21.0':
resolution: {integrity: sha512-zGrWOxZ0/+0ovPY7PvBU2gIS9tmhSUUt30jAcNV0Bq0gb2S98gwfjIs1vxlmH5zM7/4YxLamT6ChlqqAJmPPjA==}
+ '@shikijs/types@3.22.0':
+ resolution: {integrity: sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg==}
+
'@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
@@ -4157,6 +4216,11 @@ packages:
'@upstash/redis@1.36.1':
resolution: {integrity: sha512-N6SjDcgXdOcTAF+7uNoY69o7hCspe9BcA7YjQdxVu5d25avljTwyLaHBW3krWjrP0FfocgMk94qyVtQbeDp39A==}
+ '@valibot/to-json-schema@1.5.0':
+ resolution: {integrity: sha512-GE7DmSr1C2UCWPiV0upRH6mv0cCPsqYGs819fb6srCS1tWhyXrkGGe+zxUiwzn/L1BOfADH4sNjY/YHCuP8phQ==}
+ peerDependencies:
+ valibot: ^1.2.0
+
'@vercel/kv@3.0.0':
resolution: {integrity: sha512-pKT8fRnfyYk2MgvyB6fn6ipJPCdfZwiKDdw7vB+HL50rjboEBHDVBEcnwfkEpVSp2AjNtoaOUH7zG+bVC/rvSg==}
engines: {node: '>=14.6'}
@@ -4753,6 +4817,9 @@ packages:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
+ await-lock@2.2.2:
+ resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==}
+
axe-core@4.11.1:
resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==}
engines: {node: '>=4'}
@@ -6970,6 +7037,9 @@ packages:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
+ markdown-it-async@2.2.0:
+ resolution: {integrity: sha512-sITME+kf799vMeO/ww/CjH6q+c05f6TLpn6VOmmWCGNqPJzSh+uFgZoMB9s0plNtW6afy63qglNAC3MhrhP/gg==}
+
markdown-it@14.1.0:
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
hasBin: true
@@ -8406,6 +8476,9 @@ packages:
shiki@3.21.0:
resolution: {integrity: sha512-N65B/3bqL/TI2crrXr+4UivctrAGEjmsib5rPMMPpFp1xAx/w03v8WZ9RDDFYteXoEgY7qZ4HGgl5KBIu1153w==}
+ shiki@3.22.0:
+ resolution: {integrity: sha512-LBnhsoYEe0Eou4e1VgJACes+O6S6QC0w71fCSp5Oya79inkwkm15gQ1UF6VtQ8j/taMDh79hAB49WUk8ALQW3g==}
+
side-channel-list@1.0.0:
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
engines: {node: '>= 0.4'}
@@ -8780,6 +8853,10 @@ packages:
resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==}
engines: {node: '>=14.0.0'}
+ tlds@1.261.0:
+ resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==}
+ hasBin: true
+
to-buffer@1.2.2:
resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==}
engines: {node: '>= 0.4'}
@@ -9068,6 +9145,12 @@ packages:
'@nuxt/kit':
optional: true
+ unplugin-vue-markdown@29.2.0:
+ resolution: {integrity: sha512-/x2hFgQ6cWN1Kls+yK5mAI9YDmeTofftynVGgOy1llBlDX1ifaXsQBls/bpORaiwn7cxA7HkOo0wn/xKcrXBHA==}
+ engines: {node: '>=20'}
+ peerDependencies:
+ vite: ^2.0.0 || ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0 || ^7.0.0
+
unplugin-vue-router@0.16.2:
resolution: {integrity: sha512-lE6ZjnHaXfS2vFI/PSEwdKcdOo5RwAbCKUnPBIN9YwLgSWas3x+qivzQvJa/uxhKzJldE6WK43aDKjGj9Rij9w==}
peerDependencies:
@@ -9817,6 +9900,8 @@ snapshots:
'@types/json-schema': 7.0.15
js-yaml: 4.1.1
+ '@atcute/bluesky-richtext-segmenter@3.0.0': {}
+
'@atproto-labs/did-resolver@0.2.6':
dependencies:
'@atproto-labs/fetch': 0.2.3
@@ -9864,6 +9949,17 @@ snapshots:
'@atproto-labs/simple-store@0.3.0': {}
+ '@atproto/api@0.18.20':
+ dependencies:
+ '@atproto/common-web': 0.4.15
+ '@atproto/lexicon': 0.6.1
+ '@atproto/syntax': 0.4.3
+ '@atproto/xrpc': 0.7.7
+ await-lock: 2.2.2
+ multiformats: 9.9.0
+ tlds: 1.261.0
+ zod: 3.25.76
+
'@atproto/common-web@0.4.15':
dependencies:
'@atproto/lex-data': 0.0.10
@@ -11442,6 +11538,20 @@ snapshots:
- encoding
- supports-color
+ '@mdit-vue/plugin-component@3.0.2':
+ dependencies:
+ '@types/markdown-it': 14.1.2
+ markdown-it: 14.1.0
+
+ '@mdit-vue/plugin-frontmatter@3.0.2':
+ dependencies:
+ '@mdit-vue/types': 3.0.2
+ '@types/markdown-it': 14.1.2
+ gray-matter: 4.0.3
+ markdown-it: 14.1.0
+
+ '@mdit-vue/types@3.0.2': {}
+
'@miyaneee/rollup-plugin-json5@1.2.0(rollup@4.57.0)':
dependencies:
'@rollup/pluginutils': 5.3.0(rollup@4.57.0)
@@ -11545,7 +11655,7 @@ snapshots:
- magicast
- supports-color
- '@nuxt/content@3.11.0(better-sqlite3@12.5.0)(magicast@0.5.1)(valibot@1.2.0(typescript@5.9.3))':
+ '@nuxt/content@3.11.0(@valibot/to-json-schema@1.5.0(valibot@1.2.0(typescript@5.9.3)))(better-sqlite3@12.5.0)(magicast@0.5.1)(valibot@1.2.0(typescript@5.9.3))':
dependencies:
'@nuxt/kit': 4.3.1(magicast@0.5.1)
'@nuxtjs/mdc': 0.20.1(magicast@0.5.1)
@@ -11596,6 +11706,7 @@ snapshots:
zod: 3.25.76
zod-to-json-schema: 3.25.1(zod@3.25.76)
optionalDependencies:
+ '@valibot/to-json-schema': 1.5.0(valibot@1.2.0(typescript@5.9.3))
better-sqlite3: 12.5.0
valibot: 1.2.0(typescript@5.9.3)
transitivePeerDependencies:
@@ -12110,7 +12221,7 @@ snapshots:
- typescript
- vite
- '@nuxt/ui@4.4.0(@nuxt/content@3.11.0(better-sqlite3@12.5.0)(magicast@0.5.1)(valibot@1.2.0(typescript@5.9.3)))(@tiptap/extensions@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(@upstash/redis@1.36.1)(@vercel/kv@3.0.0)(db0@0.3.4(better-sqlite3@12.5.0))(embla-carousel@8.6.0)(focus-trap@7.8.0)(ioredis@5.9.2)(magicast@0.5.1)(tailwindcss@4.1.18)(typescript@5.9.3)(valibot@1.2.0(typescript@5.9.3))(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))(vue-router@4.6.4(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3))(yjs@13.6.29)(zod@4.3.6)':
+ '@nuxt/ui@4.4.0(@nuxt/content@3.11.0(@valibot/to-json-schema@1.5.0(valibot@1.2.0(typescript@5.9.3)))(better-sqlite3@12.5.0)(magicast@0.5.1)(valibot@1.2.0(typescript@5.9.3)))(@tiptap/extensions@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(@upstash/redis@1.36.1)(@vercel/kv@3.0.0)(db0@0.3.4(better-sqlite3@12.5.0))(embla-carousel@8.6.0)(focus-trap@7.8.0)(ioredis@5.9.2)(magicast@0.5.1)(tailwindcss@4.1.18)(typescript@5.9.3)(valibot@1.2.0(typescript@5.9.3))(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))(vue-router@4.6.4(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3))(yjs@13.6.29)(zod@4.3.6)':
dependencies:
'@floating-ui/dom': 1.7.5
'@iconify/vue': 5.0.0(vue@3.5.27(typescript@5.9.3))
@@ -12179,7 +12290,7 @@ snapshots:
vaul-vue: 0.4.1(reka-ui@2.7.0(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3))
vue-component-type-helpers: 3.2.4
optionalDependencies:
- '@nuxt/content': 3.11.0(better-sqlite3@12.5.0)(magicast@0.5.1)(valibot@1.2.0(typescript@5.9.3))
+ '@nuxt/content': 3.11.0(@valibot/to-json-schema@1.5.0(valibot@1.2.0(typescript@5.9.3)))(better-sqlite3@12.5.0)(magicast@0.5.1)(valibot@1.2.0(typescript@5.9.3))
valibot: 1.2.0(typescript@5.9.3)
vue-router: 4.6.4(vue@3.5.27(typescript@5.9.3))
zod: 4.3.6
@@ -13406,25 +13517,58 @@ snapshots:
'@types/hast': 3.0.4
hast-util-to-html: 9.0.5
+ '@shikijs/core@3.22.0':
+ dependencies:
+ '@shikijs/types': 3.22.0
+ '@shikijs/vscode-textmate': 10.0.2
+ '@types/hast': 3.0.4
+ hast-util-to-html: 9.0.5
+
'@shikijs/engine-javascript@3.21.0':
dependencies:
'@shikijs/types': 3.21.0
'@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 4.3.4
+ '@shikijs/engine-javascript@3.22.0':
+ dependencies:
+ '@shikijs/types': 3.22.0
+ '@shikijs/vscode-textmate': 10.0.2
+ oniguruma-to-es: 4.3.4
+
'@shikijs/engine-oniguruma@3.21.0':
dependencies:
'@shikijs/types': 3.21.0
'@shikijs/vscode-textmate': 10.0.2
+ '@shikijs/engine-oniguruma@3.22.0':
+ dependencies:
+ '@shikijs/types': 3.22.0
+ '@shikijs/vscode-textmate': 10.0.2
+
'@shikijs/langs@3.21.0':
dependencies:
'@shikijs/types': 3.21.0
+ '@shikijs/langs@3.22.0':
+ dependencies:
+ '@shikijs/types': 3.22.0
+
+ '@shikijs/markdown-it@3.22.0(markdown-it-async@2.2.0)':
+ dependencies:
+ markdown-it: 14.1.0
+ shiki: 3.22.0
+ optionalDependencies:
+ markdown-it-async: 2.2.0
+
'@shikijs/themes@3.21.0':
dependencies:
'@shikijs/types': 3.21.0
+ '@shikijs/themes@3.22.0':
+ dependencies:
+ '@shikijs/types': 3.22.0
+
'@shikijs/transformers@3.21.0':
dependencies:
'@shikijs/core': 3.21.0
@@ -13435,6 +13579,11 @@ snapshots:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
+ '@shikijs/types@3.22.0':
+ dependencies:
+ '@shikijs/vscode-textmate': 10.0.2
+ '@types/hast': 3.0.4
+
'@shikijs/vscode-textmate@10.0.2': {}
'@shuding/opentype.js@1.4.0-beta.0':
@@ -14127,6 +14276,10 @@ snapshots:
dependencies:
uncrypto: 0.1.3
+ '@valibot/to-json-schema@1.5.0(valibot@1.2.0(typescript@5.9.3))':
+ dependencies:
+ valibot: 1.2.0(typescript@5.9.3)
+
'@vercel/kv@3.0.0':
dependencies:
'@upstash/redis': 1.36.1
@@ -14846,6 +14999,8 @@ snapshots:
dependencies:
possible-typed-array-names: 1.1.0
+ await-lock@2.2.2: {}
+
axe-core@4.11.1: {}
b4a@1.7.3: {}
@@ -15412,15 +15567,15 @@ snapshots:
diff@8.0.3: {}
- docus@5.4.4(0a5f208c5e5e09cca0eb8d9e13c93aa6):
+ docus@5.4.4(07bea562f86d8cbb55a41040dc791771):
dependencies:
'@iconify-json/lucide': 1.2.87
'@iconify-json/simple-icons': 1.2.68
'@iconify-json/vscode-icons': 1.2.40
- '@nuxt/content': 3.11.0(better-sqlite3@12.5.0)(magicast@0.5.1)(valibot@1.2.0(typescript@5.9.3))
+ '@nuxt/content': 3.11.0(@valibot/to-json-schema@1.5.0(valibot@1.2.0(typescript@5.9.3)))(better-sqlite3@12.5.0)(magicast@0.5.1)(valibot@1.2.0(typescript@5.9.3))
'@nuxt/image': 2.0.0(@upstash/redis@1.36.1)(@vercel/kv@3.0.0)(db0@0.3.4(better-sqlite3@12.5.0))(ioredis@5.9.2)(magicast@0.5.1)
'@nuxt/kit': 4.3.1(magicast@0.5.1)
- '@nuxt/ui': 4.4.0(@nuxt/content@3.11.0(better-sqlite3@12.5.0)(magicast@0.5.1)(valibot@1.2.0(typescript@5.9.3)))(@tiptap/extensions@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(@upstash/redis@1.36.1)(@vercel/kv@3.0.0)(db0@0.3.4(better-sqlite3@12.5.0))(embla-carousel@8.6.0)(focus-trap@7.8.0)(ioredis@5.9.2)(magicast@0.5.1)(tailwindcss@4.1.18)(typescript@5.9.3)(valibot@1.2.0(typescript@5.9.3))(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))(vue-router@4.6.4(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3))(yjs@13.6.29)(zod@4.3.6)
+ '@nuxt/ui': 4.4.0(@nuxt/content@3.11.0(@valibot/to-json-schema@1.5.0(valibot@1.2.0(typescript@5.9.3)))(better-sqlite3@12.5.0)(magicast@0.5.1)(valibot@1.2.0(typescript@5.9.3)))(@tiptap/extensions@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(@upstash/redis@1.36.1)(@vercel/kv@3.0.0)(db0@0.3.4(better-sqlite3@12.5.0))(embla-carousel@8.6.0)(focus-trap@7.8.0)(ioredis@5.9.2)(magicast@0.5.1)(tailwindcss@4.1.18)(typescript@5.9.3)(valibot@1.2.0(typescript@5.9.3))(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))(vue-router@4.6.4(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3))(yjs@13.6.29)(zod@4.3.6)
'@nuxtjs/i18n': 10.2.3(@upstash/redis@1.36.1)(@vercel/kv@3.0.0)(@vue/compiler-dom@3.5.27)(db0@0.3.4(better-sqlite3@12.5.0))(eslint@9.39.2(jiti@2.6.1))(ioredis@5.9.2)(magicast@0.5.1)(rollup@4.57.0)(vue@3.5.27(typescript@5.9.3))
'@nuxtjs/mcp-toolkit': 0.6.2(hono@4.11.7)(magicast@0.5.1)(zod@4.3.6)
'@nuxtjs/mdc': 0.20.1(magicast@0.5.1)
@@ -17429,6 +17584,11 @@ snapshots:
dependencies:
semver: 7.7.3
+ markdown-it-async@2.2.0:
+ dependencies:
+ '@types/markdown-it': 14.1.2
+ markdown-it: 14.1.0
+
markdown-it@14.1.0:
dependencies:
argparse: 2.0.1
@@ -19886,6 +20046,17 @@ snapshots:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
+ shiki@3.22.0:
+ dependencies:
+ '@shikijs/core': 3.22.0
+ '@shikijs/engine-javascript': 3.22.0
+ '@shikijs/engine-oniguruma': 3.22.0
+ '@shikijs/langs': 3.22.0
+ '@shikijs/themes': 3.22.0
+ '@shikijs/types': 3.22.0
+ '@shikijs/vscode-textmate': 10.0.2
+ '@types/hast': 3.0.4
+
side-channel-list@1.0.0:
dependencies:
es-errors: 1.3.0
@@ -20284,6 +20455,8 @@ snapshots:
tinyrainbow@3.0.3: {}
+ tlds@1.261.0: {}
+
to-buffer@1.2.2:
dependencies:
isarray: 2.0.5
@@ -20631,6 +20804,18 @@ snapshots:
optionalDependencies:
'@nuxt/kit': 4.3.1(magicast@0.5.1)
+ unplugin-vue-markdown@29.2.0(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2)):
+ dependencies:
+ '@mdit-vue/plugin-component': 3.0.2
+ '@mdit-vue/plugin-frontmatter': 3.0.2
+ '@mdit-vue/types': 3.0.2
+ '@types/markdown-it': 14.1.2
+ markdown-it: 14.1.0
+ markdown-it-async: 2.2.0
+ unplugin: 2.3.11
+ unplugin-utils: 0.3.1
+ vite: 7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2)
+
unplugin-vue-router@0.16.2(@vue/compiler-sfc@3.5.27)(vue-router@4.6.4(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3)):
dependencies:
'@babel/generator': 7.29.1
diff --git a/server/api/atproto/author-profiles.get.ts b/server/api/atproto/bluesky-author-profiles.get.ts
similarity index 99%
rename from server/api/atproto/author-profiles.get.ts
rename to server/api/atproto/bluesky-author-profiles.get.ts
index e0e405196..1122376d8 100644
--- a/server/api/atproto/author-profiles.get.ts
+++ b/server/api/atproto/bluesky-author-profiles.get.ts
@@ -52,6 +52,7 @@ export default defineCachedEventHandler(
.catch(() => ({ profiles: [] }))
const avatarMap = new Map()
+
for (const profile of response.profiles) {
if (profile.avatar) {
avatarMap.set(profile.handle, profile.avatar)
diff --git a/server/api/atproto/bluesky-oembed.get.ts b/server/api/atproto/bluesky-oembed.get.ts
new file mode 100644
index 000000000..497bcf66a
--- /dev/null
+++ b/server/api/atproto/bluesky-oembed.get.ts
@@ -0,0 +1,59 @@
+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'
+import { Client } from '@atproto/lex'
+import * as com from '#shared/types/lexicons/com'
+
+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}`,
+ string,
+ ]
+
+ 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}`
+
+ 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
new file mode 100644
index 000000000..68357869a
--- /dev/null
+++ b/shared/schemas/atproto.ts
@@ -0,0 +1,52 @@
+import {
+ object,
+ string,
+ startsWith,
+ minLength,
+ regex,
+ pipe,
+ nonEmpty,
+ optional,
+ picklist,
+} from 'valibot'
+import type { InferOutput } from 'valibot'
+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(),
+ startsWith('at://'),
+ minLength(10),
+ regex(AT_URI_REGEX, 'Must be a valid at:// URI'),
+ ),
+})
+
+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 4d940ce93..28f232a40 100644
--- a/shared/utils/constants.ts
+++ b/shared/utils/constants.ts
@@ -9,7 +9,8 @@ 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'
+export const BLUESKY_EMBED_BASE_ROUTE = 'https://embed.bsky.app'
+export const BLUESKY_API = 'https://public.api.bsky.app/'
export const BLUESKY_COMMENTS_REQUEST = '/api/atproto/bluesky-comments'
export const NPM_REGISTRY = 'https://registry.npmjs.org'
export const NPM_API = 'https://api.npmjs.org'
@@ -17,6 +18,9 @@ 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,5 +78,9 @@ export const BACKGROUND_THEMES = {
} as const
// 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\/([^/]+)/
export const BSKY_POST_AT_URI_REGEX =
/^at:\/\/(did:plc:[a-z0-9]+)\/app\.bsky\.feed\.post\/([a-z0-9]+)$/
diff --git a/uno.config.ts b/uno.config.ts
index b75446763..8ee3209fe 100644
--- a/uno.config.ts
+++ b/uno.config.ts
@@ -1,6 +1,7 @@
import {
defineConfig,
presetIcons,
+ presetTypography,
presetWind4,
transformerDirectives,
transformerVariantGroup,
@@ -32,6 +33,7 @@ export default defineConfig({
custom: customIcons,
},
}),
+ presetTypography(),
// keep this preset last
...(process.env.CI ? [] : [presetRtl(), presetA11y()]),
].filter(Boolean),