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 `${shortcode} emoji` + } + ), 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 @@

Articles

-
+
@@ -12,14 +12,6 @@