From dc1eee3e793c2dbaa40fccfa9a206a0809318704 Mon Sep 17 00:00:00 2001
From: Daniel Roe
Date: Sun, 16 Jul 2023 15:46:52 +0100
Subject: [PATCH] feat: add `bluesky` feed support (#1245)
---
.env.example | 10 ++
budget.json | 4 +-
nuxt.config.ts | 6 +-
package.json | 3 +-
pnpm-lock.yaml | 90 ++++++++++++-
src/components/FeedPost.vue | 29 ++++-
src/components/TheMastodonFeed.server.vue | 36 ------
src/components/TheSocialFeed.server.vue | 36 ++++++
src/modules/mocks/sourcemap-codec.ts | 1 +
src/modules/router.ts | 5 +
src/modules/runtime/route-composables.ts | 18 +++
.../runtime/server/_social/bluesky.get.ts | 121 ++++++++++++++++++
.../runtime/server/_social/mastodon.get.ts | 16 ++-
src/modules/social.ts | 36 +++++-
src/pages/blog/index.vue | 10 +-
src/pages/feed.vue | 4 +-
test/unit/behaviour.spec.ts | 5 +
test/unit/bundle.spec.ts | 7 +-
18 files changed, 367 insertions(+), 70 deletions(-)
create mode 100644 .env.example
delete mode 100644 src/components/TheMastodonFeed.server.vue
create mode 100644 src/components/TheSocialFeed.server.vue
create mode 100644 src/modules/mocks/sourcemap-codec.ts
create mode 100644 src/modules/runtime/server/_social/bluesky.get.ts
diff --git a/.env.example b/.env.example
new file mode 100644
index 00000000..f35353df
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,10 @@
+NUXT_GITHUB_TOKEN=
+NUXT_GITHUB_CLIENT_SECRET=
+NUXT_PUBLIC_GITHUB_CLIENT_ID=
+NUXT_GITHUB_INVITE_TOKEN=
+NUXT_PRIVATE_KEY=
+NUXT_PUBLIC_KEY=
+NUXT_RESEND_API_KEY=
+
+DEVTO_TOKEN=
+NUXT_SOCIAL_NETWORKS_BLUESKY_PASSWORD=
diff --git a/budget.json b/budget.json
index cf038eeb..56057214 100644
--- a/budget.json
+++ b/budget.json
@@ -12,11 +12,11 @@
},
{
"resourceType": "stylesheet",
- "budget": 7
+ "budget": 7.1
},
{
"resourceType": "other",
- "budget": 7.6
+ "budget": 7.8
}
],
"resourceCounts": [
diff --git a/nuxt.config.ts b/nuxt.config.ts
index 6b390201..56336dfe 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -85,8 +85,11 @@ export default defineNuxtConfig({
social: {
networks: {
+ bluesky: {
+ identifier: 'danielroe.dev',
+ },
mastodon: {
- identifier: '@daniel@roe.dev',
+ identifier: 'daniel@roe.dev',
},
},
},
@@ -171,6 +174,7 @@ export default defineNuxtConfig({
domains: [
'avatars.githubusercontent.com',
's3.nl-ams.scw.cloud',
+ 'cdn.bsky.social',
'https://avatars.githubusercontent.com',
],
screens: {
diff --git a/package.json b/package.json
index bac43b99..92a62ba8 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
"*.{css,vue}": "stylelint"
},
"dependencies": {
+ "@atproto/api": "^0.4.1",
"@iconify-json/ri": "^1.1.10",
"@iconify-json/svg-spinners": "^1.1.1",
"@nuxt/content": "2.7.0",
@@ -42,7 +43,7 @@
"jose": "4.14.4",
"knitwork": "^1.0.0",
"magic-regexp": "0.7.0",
- "magic-string": "^0.30.0",
+ "magic-string": "^0.30.1",
"masto": "^5.11.4",
"mlly": "^1.4.0",
"nuxt": "3.6.3",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1b1b7ce6..6c5ecef3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -17,6 +17,9 @@ patchedDependencies:
path: patches/h3@1.7.1.patch
dependencies:
+ '@atproto/api':
+ specifier: ^0.4.1
+ version: 0.4.1
'@iconify-json/ri':
specifier: ^1.1.10
version: 1.1.10
@@ -75,7 +78,7 @@ dependencies:
specifier: 0.7.0
version: 0.7.0
magic-string:
- specifier: ^0.30.0
+ specifier: ^0.30.1
version: 0.30.1
masto:
specifier: ^5.11.4
@@ -235,6 +238,61 @@ packages:
/@antfu/utils@0.7.5:
resolution: {integrity: sha512-dlR6LdS+0SzOAPx/TPRhnoi7hE251OVeT2Snw0RguNbBSbjUHdWr0l3vcUUDg26rEysT89kCbtw1lVorBXLLCg==}
+ /@atproto/api@0.4.1:
+ resolution: {integrity: sha512-aQ+yLFk1PF9FQKB0yCwdTwXaPIwZuvfPbvB1fZlHHv2+3o8ryeCTrMrrw7T9gZ7gyRqI27J9EYcimJbks7SHgw==}
+ dependencies:
+ '@atproto/common-web': 0.2.0
+ '@atproto/uri': 0.1.0
+ '@atproto/xrpc': 0.2.0
+ tlds: 1.240.0
+ typed-emitter: 2.1.0
+ dev: false
+
+ /@atproto/common-web@0.2.0:
+ resolution: {integrity: sha512-ugKrT8CWf6PDsZ29VOhOCs5K4z9BAFIV7SxPXA+MHC7pINqQ+wyjIq+DtUI8kmUSce1dTqc/lMN1akf/W5whVQ==}
+ dependencies:
+ graphemer: 1.4.0
+ multiformats: 9.9.0
+ uint8arrays: 3.0.0
+ zod: 3.21.4
+ dev: false
+
+ /@atproto/identifier@0.2.0:
+ resolution: {integrity: sha512-5zzlozQEWPMDzyl0D0zSpuNbnc1gtBg/Ov5wbcfieZzbjxgJ/eJHXEBlmxNE1uN91QqpGX5FCEE36jX8tKl9Ag==}
+ dependencies:
+ '@atproto/common-web': 0.2.0
+ dev: false
+
+ /@atproto/lexicon@0.2.0:
+ resolution: {integrity: sha512-0OpLZ4o4pbRj2e19h2fO69781wPmL0S8WJaXey3y9FLjV19lj18IEbnlE/OQG8qZNDyGmgezs00L/o7qNCJorw==}
+ dependencies:
+ '@atproto/common-web': 0.2.0
+ '@atproto/identifier': 0.2.0
+ '@atproto/nsid': 0.1.0
+ '@atproto/uri': 0.1.0
+ iso-datestring-validator: 2.2.2
+ multiformats: 9.9.0
+ zod: 3.21.4
+ dev: false
+
+ /@atproto/nsid@0.1.0:
+ resolution: {integrity: sha512-Pp+tHrGtPVHUi3ainR5yPgHbuqxrZrT0vAMbsNonL+NpWRB82szZSWNThPAgUz4v73Fav6y0WcTIXHzRv2D2Yg==}
+ dev: false
+
+ /@atproto/uri@0.1.0:
+ resolution: {integrity: sha512-ZaiOYHsakB98HrXfqiF6m7xgahSn9yXxKnPm0EaN0Breax6jtTl+lblWtduonAmyWVLJJAQJbvF+ud0i/3+wBw==}
+ dependencies:
+ '@atproto/identifier': 0.2.0
+ '@atproto/nsid': 0.1.0
+ dev: false
+
+ /@atproto/xrpc@0.2.0:
+ resolution: {integrity: sha512-xsyJDPG0aVfNmBvjvVkjD4YdETCkOC/xhW+4BIzKucKIH8YftcE78ANZg5rdfXjIAnsQNPP2nDf13EKcfFHpKw==}
+ dependencies:
+ '@atproto/lexicon': 0.2.0
+ zod: 3.21.4
+ dev: false
+
/@babel/code-frame@7.22.5:
resolution: {integrity: sha512-Xmwn266vad+6DAqEB2A6V/CcZVp62BbwVmcOJc2RPuwih1kw02TjQvWVWlcKGbBPd+8/0V5DEkOcizRGYsspYQ==}
engines: {node: '>=6.9.0'}
@@ -6594,6 +6652,10 @@ packages:
/isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+ /iso-datestring-validator@2.2.2:
+ resolution: {integrity: sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==}
+ dev: false
+
/isomorphic-ws@5.0.0(ws@8.13.0):
resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==}
peerDependencies:
@@ -7783,6 +7845,10 @@ packages:
/muggle-string@0.3.1:
resolution: {integrity: sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==}
+ /multiformats@9.9.0:
+ resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==}
+ dev: false
+
/nanoid@3.3.6:
resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -9590,7 +9656,6 @@ packages:
resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==}
dependencies:
tslib: 2.6.0
- dev: true
/sade@1.8.1:
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
@@ -10458,6 +10523,11 @@ packages:
engines: {node: '>=12'}
dev: true
+ /tlds@1.240.0:
+ resolution: {integrity: sha512-1OYJQenswGZSOdRw7Bql5Qu7uf75b+F3HFBXbqnG/ifHa0fev1XcG+3pJf3pA/KC6RtHQzfKgIf1vkMlMG7mtQ==}
+ hasBin: true
+ dev: false
+
/to-buffer@1.1.1:
resolution: {integrity: sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg==}
dev: true
@@ -10640,6 +10710,12 @@ packages:
is-typed-array: 1.1.10
dev: true
+ /typed-emitter@2.1.0:
+ resolution: {integrity: sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==}
+ optionalDependencies:
+ rxjs: 7.8.1
+ dev: false
+
/typescript@5.1.6:
resolution: {integrity: sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==}
engines: {node: '>=14.17'}
@@ -10648,6 +10724,12 @@ packages:
/ufo@1.1.2:
resolution: {integrity: sha512-TrY6DsjTQQgyS3E3dBaOXf0TpPD8u9FVrVYmKVegJuFw51n/YB9XPt+U6ydzFG5ZIN7+DIjPbNmXoBj9esYhgQ==}
+ /uint8arrays@3.0.0:
+ resolution: {integrity: sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==}
+ dependencies:
+ multiformats: 9.9.0
+ dev: false
+
/ultrahtml@1.3.0:
resolution: {integrity: sha512-xmXvE8tC8t4PVqy0/g1fe7H9USY/Brr425q4dD/0QbQMQit7siCtb06+SCqE4GfU24nwsZz8Th1g7L7mm1lL5g==}
@@ -11792,6 +11874,10 @@ packages:
compress-commons: 4.1.1
readable-stream: 3.6.2
+ /zod@3.21.4:
+ resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==}
+ dev: false
+
/zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
dev: false
diff --git a/src/components/FeedPost.vue b/src/components/FeedPost.vue
index d983956e..529c3edb 100644
--- a/src/components/FeedPost.vue
+++ b/src/components/FeedPost.vue
@@ -1,6 +1,10 @@
-
-
diff --git a/src/components/TheSocialFeed.server.vue b/src/components/TheSocialFeed.server.vue
new file mode 100644
index 00000000..6795b1fc
--- /dev/null
+++ b/src/components/TheSocialFeed.server.vue
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
diff --git a/src/modules/mocks/sourcemap-codec.ts b/src/modules/mocks/sourcemap-codec.ts
new file mode 100644
index 00000000..84e54adc
--- /dev/null
+++ b/src/modules/mocks/sourcemap-codec.ts
@@ -0,0 +1 @@
+export const encode = () => {}
diff --git a/src/modules/router.ts b/src/modules/router.ts
index e44184ab..11503966 100644
--- a/src/modules/router.ts
+++ b/src/modules/router.ts
@@ -57,6 +57,11 @@ export default defineNuxtModule({
priority: 20,
})
+ addImports({
+ name: 'handleNavigationClicks',
+ from: resolver.resolve('./runtime/route-composables'),
+ })
+
addComponent({
filePath: resolver.resolve('./runtime/nuxt-link'),
name: 'NuxtLink',
diff --git a/src/modules/runtime/route-composables.ts b/src/modules/runtime/route-composables.ts
index 76c7e85c..93e8e5f5 100644
--- a/src/modules/runtime/route-composables.ts
+++ b/src/modules/runtime/route-composables.ts
@@ -1,4 +1,22 @@
+import { parseURL } from 'ufo'
+
/*! @__NO_SIDE_EFFECTS__ */
export const definePageMeta = (_args: any) => {}
export const useRoute = (_path?: string) => useNuxtApp().$route
+
+export function handleNavigationClicks(e: MouseEvent | KeyboardEvent) {
+ const anchor = (e.target as HTMLElement).closest('a')
+ if (anchor) {
+ const href = anchor.getAttribute('href')
+ if (href) {
+ e.preventDefault()
+ const url = parseURL(href)
+ if (!url.host || url.host === 'roe.dev') {
+ return navigateTo(url.pathname)
+ }
+
+ return navigateTo(href, { external: true })
+ }
+ }
+}
diff --git a/src/modules/runtime/server/_social/bluesky.get.ts b/src/modules/runtime/server/_social/bluesky.get.ts
new file mode 100644
index 00000000..a7fe50f7
--- /dev/null
+++ b/src/modules/runtime/server/_social/bluesky.get.ts
@@ -0,0 +1,121 @@
+import BskyAPI from '@atproto/api'
+import MagicString from 'magic-string'
+
+const { BskyAgent } = BskyAPI as unknown as typeof import('@atproto/api')
+
+interface PostRecord {
+ $type: 'app.bsky.feed.post'
+ createdAt: string
+ facets: {
+ features: Array<
+ | {
+ $type: 'app.bsky.richtext.facet#link'
+ uri: string
+ }
+ | {
+ $type: 'app.bsky.richtext.facet#mention'
+ did: string
+ }
+ >
+ index: {
+ byteEnd: number
+ byteStart: number
+ }
+ }[]
+ embed?: {
+ $type: 'app.bsky.embed.images'
+ images: {
+ alt: string
+ image: {
+ $type: 'blob'
+ mimeType: 'image/jpeg'
+ ref: {
+ $link: string
+ }
+ size: number
+ }
+ }[]
+ }
+ text: string
+}
+
+interface PostEmbed {
+ $type: 'app.bsky.embed.images#view'
+ images: {
+ alt: string
+ fullsize: string
+ thumb: string
+ }[]
+}
+
+export default defineLazyEventHandler(async () => {
+ const { identifier, password } = useRuntimeConfig().social.networks.bluesky
+ const agent = new BskyAgent({ service: 'https://bsky.social' })
+
+ await agent.login({
+ identifier,
+ password,
+ })
+
+ return defineEventHandler(async () => {
+ const posts = await agent.getAuthorFeed({ actor: identifier })
+ return Promise.all(
+ posts.data.feed
+ .filter(
+ p =>
+ '$type' in p.post.record &&
+ p.post.record.$type === 'app.bsky.feed.post' &&
+ p.post.author.handle === identifier &&
+ p.post.embed?.$type !== 'app.bsky.embed.record#view' &&
+ !p.reply
+ )
+ .map(p => {
+ const post = p.post.record as PostRecord
+ const embed = p.post.embed as PostEmbed | undefined
+ const text = new MagicString(post.text)
+ for (const facet of post.facets || []) {
+ const startIndex = [...post.text].findIndex(
+ (_, i) =>
+ Buffer.byteLength(post.text.slice(0, i + 1)) >
+ facet.index.byteStart
+ )
+ const endIndex = [...post.text, 'z'].findIndex(
+ (_, i) =>
+ Buffer.byteLength(post.text.slice(0, i + 1)) >=
+ facet.index.byteEnd
+ )
+ for (const feature of facet.features) {
+ if (feature.$type === 'app.bsky.richtext.facet#link') {
+ text.appendRight(startIndex, ``)
+ text.appendLeft(endIndex + 1, '')
+ continue
+ }
+ }
+ }
+
+ for (const match of post.text.matchAll(/\n\n/g)) {
+ text.overwrite(match.index!, match.index! + 2, '
')
+ }
+
+ text.prepend('
')
+ text.append('
')
+
+ return {
+ network: 'bluesky' as const,
+ accountLink: `https://staging.bsky.app/profile/${p.post.author.handle}`,
+ avatar: p.post.author.avatar,
+ handle: p.post.author.displayName,
+ createdAt: post.createdAt,
+ permalink:
+ `https://staging.bsky.app/profile/${p.post.author.handle}/post` +
+ p.post.uri.match(/(\/[^/]+)$/)?.[1],
+ html: text.toString(),
+ media: embed?.images?.map(i => ({
+ url: i.fullsize,
+ alt: i.alt,
+ })),
+ }
+ })
+ )
+ })
+})
diff --git a/src/modules/runtime/server/_social/mastodon.get.ts b/src/modules/runtime/server/_social/mastodon.get.ts
index 9f802d0a..09811be1 100644
--- a/src/modules/runtime/server/_social/mastodon.get.ts
+++ b/src/modules/runtime/server/_social/mastodon.get.ts
@@ -2,7 +2,7 @@ import { createClient } from 'masto'
import { parseURL, withProtocol } from 'ufo'
export default defineLazyEventHandler(async () => {
- const acct = 'daniel@roe.dev'
+ const acct = useRuntimeConfig().social.networks.mastodon.identifier
const data = await $fetch<{ subject: string; aliases: string[] }>(
'.well-known/webfinger',
@@ -27,8 +27,20 @@ export default defineLazyEventHandler(async () => {
return Promise.all(
posts
.filter(p => p.content && !p.inReplyToId)
- // .slice(0, 1)
.map(p => ({
+ network: 'mastodon' as const,
+ accountLink: `https://elk.zone/${host}/@${p.account.acct}`,
+ avatar: p.account.avatar,
+ handle: p.account.displayName.replace(
+ /:([a-z-]+):/g,
+ (string, shortcode) => {
+ const emoji = p.account.emojis.find(
+ e => e.shortcode === shortcode
+ )
+ if (!emoji) return string
+ return `
`
+ }
+ ),
createdAt: p.createdAt,
permalink: p.url?.replace('https://', 'https://elk.zone/') ?? p.uri,
media: p.mediaAttachments.map(m => ({
diff --git a/src/modules/social.ts b/src/modules/social.ts
index 9f398619..27432f4e 100644
--- a/src/modules/social.ts
+++ b/src/modules/social.ts
@@ -7,13 +7,20 @@ import {
import { defu } from 'defu'
const networks = {
+ bluesky: 'bluesky',
mastodon: 'mastodon',
}
type Network = keyof typeof networks
type NetworkOptions = {
- identifier: string
+ bluesky?: {
+ identifier: string
+ password?: string
+ }
+ mastodon?: {
+ identifier: string
+ }
}
export default defineNuxtModule({
@@ -22,15 +29,27 @@ export default defineNuxtModule({
configKey: 'social',
},
defaults: {
- networks: {} as Partial>,
+ networks: {} as NetworkOptions,
},
setup(options) {
const nuxt = useNuxt()
const resolver = createResolver(import.meta.url)
+ if (options.networks.bluesky) {
+ options.networks.bluesky.password ||= ''
+ }
+
+ nuxt.options.runtimeConfig.social = {
+ networks: options.networks as any,
+ }
+
// Prevent all the extra stuff `masto` will add
nuxt.options.alias['node-fetch'] = 'node-fetch-native'
- nuxt.options.build.transpile.push('masto', '@mastojs/ponyfills')
+ nuxt.options.build.transpile.push(
+ 'masto',
+ '@mastojs/ponyfills',
+ 'magic-string'
+ )
nuxt.options.nitro = defu(nuxt.options.nitro, {
alias: {
@@ -40,6 +59,9 @@ export default defineNuxtModule({
})
nuxt.options.alias = defu(nuxt.options.alias, {
+ '@jridgewell/sourcemap-codec': resolver.resolve(
+ './mocks/sourcemap-codec'
+ ),
qs: 'rollup-plugin-node-polyfills/polyfills/qs',
'change-case': 'scule',
semver: resolver.resolve('./mocks/semver'),
@@ -56,9 +78,11 @@ export default defineNuxtModule({
})
}
- nuxt.options.nitro.routeRules ||= {}
- nuxt.options.nitro.routeRules['/_social/**'] = {
- swr: 60,
+ if (!nuxt.options.dev) {
+ nuxt.options.nitro.routeRules ||= {}
+ nuxt.options.nitro.routeRules['/_social/**'] = {
+ swr: 60,
+ }
}
},
})
diff --git a/src/pages/blog/index.vue b/src/pages/blog/index.vue
index 44820b08..1ca302de 100644
--- a/src/pages/blog/index.vue
+++ b/src/pages/blog/index.vue
@@ -3,7 +3,7 @@
-
+
@@ -12,14 +12,6 @@