[READ-ONLY] Mirror of https://github.com/danielroe/roe.dev. This is the code and content for my personal website, built in Nuxt. roe.dev
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: implement runtime discovery of post (#1796)

+299 -171
+17 -96
app/components/BlueskyComment.vue
··· 1 1 <script setup lang="ts"> 2 2 import type { 3 - AppBskyRichtextFacet, 4 3 AppBskyEmbedImages, 5 4 AppBskyEmbedExternal, 6 5 } from '@atproto/api' 7 - 8 - type Facet = AppBskyRichtextFacet.Main 6 + import type { Facet, BlueskyFacetFeature } from '#shared/utils/bluesky' 7 + import { segmentize, atUriToWebUrl } from '#shared/utils/bluesky' 9 8 10 9 interface CommentEmbed { 11 10 type: 'images' | 'external' ··· 23 22 avatar?: string 24 23 } 25 24 text: string 26 - facets?: Facet[] 25 + facets?: Facet<BlueskyFacetFeature>[] 27 26 embed?: CommentEmbed 28 27 createdAt: string 29 28 likeCount: number ··· 32 31 replies: Comment[] 33 32 } 34 33 35 - // Rich text segment for rendering 36 - interface TextSegment { 37 - text: string 38 - type: 'text' | 'link' | 'mention' | 'tag' 39 - url?: string 34 + function getCommentUrl (comment: Comment): string { 35 + return atUriToWebUrl(comment.uri) ?? '#' 40 36 } 41 37 42 - function parseRichText (text: string, facets?: Facet[]): TextSegment[] { 43 - if (!facets || facets.length === 0) { 44 - return [{ text, type: 'text' }] 45 - } 46 - 47 - // Convert string to bytes for proper indexing (Bluesky uses byte offsets) 48 - const encoder = new TextEncoder() 49 - const decoder = new TextDecoder() 50 - const bytes = encoder.encode(text) 51 - 52 - // Sort facets by start index 53 - const sortedFacets = [...facets].sort((a, b) => a.index.byteStart - b.index.byteStart) 54 - 55 - const segments: TextSegment[] = [] 56 - let lastEnd = 0 57 - 58 - for (const facet of sortedFacets) { 59 - const { byteStart, byteEnd } = facet.index 60 - 61 - // Add plain text before this facet 62 - if (byteStart > lastEnd) { 63 - segments.push({ 64 - text: decoder.decode(bytes.slice(lastEnd, byteStart)), 65 - type: 'text', 66 - }) 67 - } 68 - 69 - const facetText = decoder.decode(bytes.slice(byteStart, byteEnd)) 70 - const feature = facet.features[0] as 71 - | { $type: 'app.bsky.richtext.facet#link', uri: string } 72 - | { $type: 'app.bsky.richtext.facet#mention', did: string } 73 - | { $type: 'app.bsky.richtext.facet#tag', tag: string } 74 - | undefined 75 - 76 - if (feature?.$type === 'app.bsky.richtext.facet#link') { 77 - segments.push({ 78 - text: facetText, 79 - type: 'link', 80 - url: feature.uri, 81 - }) 82 - } 83 - else if (feature?.$type === 'app.bsky.richtext.facet#mention') { 84 - segments.push({ 85 - text: facetText, 86 - type: 'mention', 87 - url: `https://bsky.app/profile/${feature.did}`, 88 - }) 89 - } 90 - else if (feature?.$type === 'app.bsky.richtext.facet#tag') { 91 - segments.push({ 92 - text: facetText, 93 - type: 'tag', 94 - url: `https://bsky.app/hashtag/${feature.tag}`, 95 - }) 96 - } 97 - else { 98 - segments.push({ text: facetText, type: 'text' }) 99 - } 100 - 101 - lastEnd = byteEnd 102 - } 103 - 104 - // Add remaining text after last facet 105 - if (lastEnd < bytes.length) { 106 - segments.push({ 107 - text: decoder.decode(bytes.slice(lastEnd)), 108 - type: 'text', 109 - }) 110 - } 111 - 112 - return segments 113 - } 114 - 115 - function getCommentUrl (comment: Comment): string { 116 - const match = comment.uri.match(/at:\/\/([^/]+)\/app\.bsky\.feed\.post\/(.+)/) 117 - if (match) { 118 - const [, did, rkey] = match 119 - return `https://bsky.app/profile/${did}/post/${rkey}` 120 - } 121 - return '#' 38 + function getFeatureUrl (feature: BlueskyFacetFeature): string | undefined { 39 + if (feature.$type === 'app.bsky.richtext.facet#link') return feature.uri 40 + if (feature.$type === 'app.bsky.richtext.facet#mention') return `https://bsky.app/profile/${feature.did}` 41 + if (feature.$type === 'app.bsky.richtext.facet#tag') return `https://bsky.app/hashtag/${feature.tag}` 42 + return undefined 122 43 } 123 44 124 45 const props = defineProps<{ ··· 127 48 }>() 128 49 129 50 const commentUrl = computed(() => getCommentUrl(props.comment)) 130 - const richText = computed(() => parseRichText(props.comment.text, props.comment.facets)) 51 + const segments = computed(() => segmentize(props.comment.text, props.comment.facets)) 131 52 const maxDepth = 4 132 53 </script> 133 54 ··· 190 111 191 112 <p class="mt-1 whitespace-pre-wrap break-words"> 192 113 <template 193 - v-for="(segment, i) in richText" 114 + v-for="(segment, i) in segments" 194 115 :key="i" 195 116 > 196 117 <a 197 - v-if="segment.type === 'link' || segment.type === 'mention' || segment.type === 'tag'" 198 - :href="segment.url" 118 + v-if="segment.features?.[0] && getFeatureUrl(segment.features[0])" 119 + :href="getFeatureUrl(segment.features[0])" 199 120 target="_blank" 200 121 rel="noopener" 201 122 class="text-blue-400 hover:underline" ··· 335 256 336 257 <p class="mt-1 whitespace-pre-wrap break-words"> 337 258 <template 338 - v-for="(segment, i) in richText" 259 + v-for="(segment, i) in segments" 339 260 :key="i" 340 261 > 341 262 <a 342 - v-if="segment.type === 'link' || segment.type === 'mention' || segment.type === 'tag'" 343 - :href="segment.url" 263 + v-if="segment.features?.[0] && getFeatureUrl(segment.features[0])" 264 + :href="getFeatureUrl(segment.features[0])" 344 265 target="_blank" 345 266 rel="noopener" 346 267 class="text-blue-400 hover:underline"
+16 -3
app/pages/blog/[article].vue
··· 48 48 :path="path" 49 49 /> 50 50 </section> 51 - <BlueskyComments 52 - v-if="page?.bluesky" 53 - :uri="page.bluesky" 51 + <LazyBlueskyComments 52 + v-if="blueskyUri" 53 + :uri="blueskyUri" 54 54 /> 55 55 </main> 56 56 </template> 57 57 58 58 <script lang="ts" setup> 59 + import { needsRuntimeDiscovery, newestPostPath } from '#build/bsky-runtime-discovery.mjs' 60 + 59 61 const route = useRoute('blog-article') 60 62 const slug = route.params.article 61 63 if (!slug) navigateTo('/blog') ··· 96 98 97 99 if (import.meta.server) { 98 100 useRoute().meta.description = page.value.description 101 + } 102 + 103 + const blueskyUri = shallowRef(page.value.bluesky) 104 + 105 + if (import.meta.client && needsRuntimeDiscovery && !blueskyUri.value && newestPostPath === path.value) { 106 + onMounted(async () => { 107 + const { uri } = await $fetch<{ uri: string | null }>('/api/discover-bluesky-post').catch(() => ({ uri: null })) 108 + if (uri) { 109 + blueskyUri.value = uri 110 + } 111 + }) 99 112 } 100 113 </script> 101 114
+131 -70
modules/bsky-comments.ts
··· 1 - import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs' 1 + import { readFile, writeFile, mkdir } from 'node:fs/promises' 2 + import { existsSync } from 'node:fs' 2 3 3 - import { defineNuxtModule, useNuxt } from 'nuxt/kit' 4 + import { addServerHandler, addServerTemplate, addTemplate, addTypeTemplate, createResolver, defineNuxtModule, useNuxt } from 'nuxt/kit' 4 5 import { joinURL, withoutTrailingSlash } from 'ufo' 5 6 import { join } from 'pathe' 7 + import { filename } from 'pathe/utils' 8 + import { glob } from 'tinyglobby' 9 + import grayMatter from 'gray-matter' 6 10 import { AtpAgent, AppBskyFeedPost } from '@atproto/api' 7 11 8 12 // when I created my Bluesky account - don't judge me for hard coding it! ··· 14 18 links: string[] // URLs found in facets and embeds 15 19 } 16 20 21 + interface BlogPost { 22 + slug: string 23 + path: string 24 + url: string 25 + date: Date 26 + blueskyUri: string | null 27 + } 28 + 17 29 export default defineNuxtModule({ 18 30 meta: { 19 31 name: 'bsky-comments', 20 32 }, 21 - setup () { 33 + async setup () { 22 34 const nuxt = useNuxt() 35 + 36 + addTypeTemplate({ 37 + filename: 'types/bsky-runtime-discovery.d.ts', 38 + getContents: () => ` 39 + declare module '#build/bsky-runtime-discovery.mjs' { 40 + export const needsRuntimeDiscovery: boolean 41 + export const newestPostPath: string | null 42 + } 43 + `, 44 + }, { nuxt: true }) 45 + 23 46 if (nuxt.options._prepare) { 24 47 return 25 48 } 26 49 50 + const resolver = createResolver(import.meta.url) 27 51 const agent = new AtpAgent({ service: 'https://public.api.bsky.app' }) 28 52 29 53 const cacheDir = join(nuxt.options.rootDir, 'node_modules', '.cache', 'bluesky-comments') 30 54 if (!existsSync(cacheDir)) { 31 - mkdirSync(cacheDir, { recursive: true }) 55 + await mkdir(cacheDir, { recursive: true }) 32 56 } 33 57 const blueskyHandle = nuxt.options.social.networks.bluesky!.identifier 34 58 35 - // Shared feed iterator - fetches posts on demand and grows as needed 59 + // Read all blog posts from markdown files 60 + const files = await glob('./content/blog/**/*.md', { cwd: nuxt.options.rootDir, absolute: true }) 61 + const blogPosts: BlogPost[] = [] 36 62 const feedIterator = createFeedIterator(agent, blueskyHandle) 37 63 38 - nuxt.hook('content:file:afterParse', async ctx => { 39 - const content = ctx.content as { path?: string, date?: string | Date, bluesky?: string } 64 + for (const filePath of files) { 65 + const contents = await readFile(filePath, 'utf-8') 66 + const { data } = grayMatter(contents) 40 67 41 - if (!content.path?.startsWith('/blog/')) return 68 + if (!data.date) continue 42 69 43 - const blogPath = content.path 70 + const slug = filename(filePath)! 71 + const blogPath = `/blog/${slug}` 44 72 const blogUrl = withoutTrailingSlash(joinURL(nuxt.options.site.url, blogPath)) 73 + const blogDate = new Date(data.date) 74 + const cacheFile = join(cacheDir, `${slug}.json`) 75 + 76 + const blueskyUri = await discoverBlueskyUri() 77 + blogPosts.push({ slug, path: blogPath, url: blogUrl, date: blogDate, blueskyUri }) 45 78 46 - const safeName = blogPath.replace(/[^a-z0-9]/gi, '-') 47 - const cacheFile = join(cacheDir, `${safeName}.json`) 79 + async function discoverBlueskyUri (): Promise<string | null> { 80 + // Check cache first 81 + if (existsSync(cacheFile)) { 82 + try { 83 + const cacheData = JSON.parse(await readFile(cacheFile, 'utf-8')) as { uri: string | null } 84 + return cacheData.uri 85 + } 86 + catch { 87 + // Continue to discover 88 + } 89 + } 48 90 49 - if (existsSync(cacheFile)) { 50 - try { 51 - const cached = JSON.parse(readFileSync(cacheFile, 'utf-8')) as { uri: string | null } 52 - if (cached.uri) { 53 - content.bluesky = cached.uri 91 + // 1. explicit bluesky URL in frontmatter 92 + if (data.bluesky?.startsWith('https://bsky.app/')) { 93 + const match = data.bluesky.match(/bsky\.app\/profile\/([^/]+)\/post\/([^/]+)/) 94 + if (match) { 95 + const [, handle, rkey] = match 96 + try { 97 + const { data: resolved } = await agent.resolveHandle({ handle: handle! }) 98 + const uri = `at://${resolved.did}/app.bsky.feed.post/${rkey}` 99 + await saveCache(cacheFile, uri) 100 + return uri 101 + } 102 + catch (error) { 103 + console.warn(`Failed to resolve Bluesky handle for ${blogPath}:`, error) 104 + } 54 105 } 55 - return 106 + await saveCache(cacheFile, null) 107 + return null 56 108 } 57 - catch { 58 - // Continue to fetch 109 + 110 + // 2. blog post date is before Bluesky account creation 111 + if (blogDate < BLUESKY_ACCOUNT_CREATED) { 112 + await saveCache(cacheFile, null) 113 + return null 59 114 } 60 - } 61 115 62 - let discoveredUri: string | null = null 116 + // 3. auto-discover posts linking to this blog URL 117 + const searchCutoff = new Date(blogDate.getTime() - 24 * 60 * 60 * 1000) 63 118 64 - // 1. explicit bluesky URL in frontmatter - simply resolve 65 - if (content.bluesky?.startsWith('https://bsky.app/')) { 66 - const match = content.bluesky.match(/bsky\.app\/profile\/([^/]+)\/post\/([^/]+)/) 67 - if (match) { 68 - const [, handle, rkey] = match 69 - try { 70 - const { data } = await agent.resolveHandle({ handle: handle! }) 71 - content.bluesky = `at://${data.did}/app.bsky.feed.post/${rkey}` 72 - discoveredUri = content.bluesky 119 + while (!feedIterator.isExhausted()) { 120 + const oldestFetchedDate = feedIterator.getOldestPostDate() 121 + if (oldestFetchedDate && oldestFetchedDate < searchCutoff) { 122 + break 73 123 } 74 - catch (error) { 75 - console.warn(`Failed to resolve Bluesky handle for ${blogPath}:`, error) 76 - } 124 + await feedIterator.fetchMore() 77 125 } 78 - saveCache(cacheDir, cacheFile, discoveredUri) 79 - } 80 126 81 - // 2. auto-discover posts linking to this blog URL 82 - const blogDate = content.date ? new Date(content.date) : null 127 + const matchingPosts = feedIterator.posts 128 + .filter(post => { 129 + const postDate = new Date(post.createdAt) 130 + if (postDate < searchCutoff) return false 131 + return post.links.some(link => withoutTrailingSlash(link) === blogUrl) 132 + }) 133 + .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) 83 134 84 - if (blogDate && blogDate < BLUESKY_ACCOUNT_CREATED) { 85 - saveCache(cacheDir, cacheFile, null) 86 - return 135 + const uri = matchingPosts[0]?.uri ?? null 136 + if (uri) { 137 + console.log(`Auto-discovered Bluesky post for ${blogPath}`) 138 + } 139 + await saveCache(cacheFile, uri) 140 + return uri 87 141 } 142 + } 88 143 89 - const searchCutoff = blogDate 90 - ? new Date(blogDate.getTime() - 24 * 60 * 60 * 1000) 91 - : BLUESKY_ACCOUNT_CREATED 144 + // Sort by date descending to find newest 145 + blogPosts.sort((a, b) => b.date.getTime() - a.date.getTime()) 146 + const newestPost = blogPosts[0] 147 + const needsRuntimeDiscovery = newestPost && !newestPost.blueskyUri 92 148 93 - while (!feedIterator.isExhausted()) { 94 - const oldestFetchedDate = feedIterator.getOldestPostDate() 95 - if (oldestFetchedDate && oldestFetchedDate < searchCutoff) { 96 - break 97 - } 98 - await feedIterator.fetchMore() 99 - } 149 + // Add virtual file for runtime discovery flag (client-side) 150 + addTemplate({ 151 + filename: 'bsky-runtime-discovery.mjs', 152 + getContents: () => `export const needsRuntimeDiscovery = ${!!needsRuntimeDiscovery} 153 + export const newestPostPath = ${newestPost ? JSON.stringify(newestPost.path) : 'null'}`, 154 + write: true, 155 + }) 100 156 101 - const matchingPosts: ParsedPost[] = [] 102 - for (const post of feedIterator.posts) { 103 - const postDate = new Date(post.createdAt) 104 - const hasMatchingLink = post.links.some(link => withoutTrailingSlash(link) === blogUrl) 105 - if (postDate >= searchCutoff && hasMatchingLink) { 106 - matchingPosts.push(post) 107 - } 108 - } 157 + // Add server template with the blog URL and date 158 + addServerTemplate({ 159 + filename: 'bsky-runtime-discovery-server.mjs', 160 + getContents: () => ` 161 + export const newestPostUrl = ${needsRuntimeDiscovery ? JSON.stringify(newestPost.url) : 'null'} 162 + export const newestPostDate = ${needsRuntimeDiscovery ? JSON.stringify(newestPost.date.toISOString()) : 'null'}`, 163 + }) 109 164 110 - if (matchingPosts.length > 0) { 111 - // Sort by `createdAt` ascending to get the first announcement) 112 - matchingPosts.sort((a, b) => 113 - new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), 114 - ) 115 - content.bluesky = matchingPosts[0]!.uri 116 - discoveredUri = content.bluesky 117 - console.log(`Auto-discovered Bluesky post for ${blogPath}`) 165 + // Conditionally add the server endpoint only when needed 166 + if (needsRuntimeDiscovery) { 167 + addServerHandler({ 168 + route: '/api/discover-bluesky-post', 169 + handler: resolver.resolve('./runtime/server/discover-bluesky-post.get'), 170 + }) 171 + } 172 + 173 + // Still use the content hook to inject the bluesky URI into content 174 + nuxt.hook('content:file:afterParse', ctx => { 175 + const content = ctx.content as { path?: string, bluesky?: string } 176 + if (!content.path?.startsWith('/blog/')) return 177 + 178 + const post = blogPosts.find(p => p.path === content.path) 179 + if (post?.blueskyUri) { 180 + content.bluesky = post.blueskyUri 118 181 } 119 - 120 - saveCache(cacheDir, cacheFile, discoveredUri) 121 182 }) 122 183 }, 123 184 }) 124 185 125 - function saveCache (cacheDir: string, cacheFile: string, uri: string | null) { 186 + async function saveCache (cacheFile: string, uri: string | null) { 126 187 try { 127 - writeFileSync(cacheFile, JSON.stringify({ uri })) 188 + await writeFile(cacheFile, JSON.stringify({ uri })) 128 189 } 129 190 catch { 130 191 // Ignore cache write errors
+46
modules/runtime/server/discover-bluesky-post.get.ts
··· 1 + // @ts-expect-error virtual file generated by bsky-comments module 2 + import { newestPostUrl, newestPostDate } from 'bsky-runtime-discovery-server.mjs' 3 + import { BLUESKY_API, postLinksToUrl, type BlueskyFeedResponse } from '#shared/utils/bluesky' 4 + 5 + export default defineEventHandler(async event => { 6 + if (!newestPostUrl) { 7 + return { uri: null } 8 + } 9 + 10 + const bskyHandle = useRuntimeConfig(event).social.networks.bluesky.identifier 11 + const searchCutoff = newestPostDate ? new Date(new Date(newestPostDate).getTime() - 24 * 60 * 60 * 1000) : null 12 + 13 + let cursor: string | undefined 14 + 15 + // Iterate through feed until we find a match or reach the day before the post 16 + do { 17 + const response = await $fetch<BlueskyFeedResponse>('/xrpc/app.bsky.feed.getAuthorFeed', { 18 + baseURL: BLUESKY_API, 19 + query: { 20 + actor: bskyHandle, 21 + limit: 100, 22 + cursor, 23 + }, 24 + }) 25 + 26 + for (const item of response.feed) { 27 + if (item.reason) continue 28 + 29 + // Check if we've gone past the search cutoff 30 + if (searchCutoff) { 31 + const postDate = new Date(item.post.record.createdAt) 32 + if (postDate < searchCutoff) { 33 + return { uri: null } 34 + } 35 + } 36 + 37 + if (postLinksToUrl(item.post, newestPostUrl)) { 38 + return { uri: item.post.uri } 39 + } 40 + } 41 + 42 + cursor = response.cursor 43 + } while (cursor) 44 + 45 + return { uri: null } 46 + })
+1
package.json
··· 29 29 "*.{css,vue}": "stylelint" 30 30 }, 31 31 "dependencies": { 32 + "@atcute/bluesky-richtext-segmenter": "^3.0.0", 32 33 "@atproto/api": "^0.18.0", 33 34 "@iconify-json/ri": "^1.2.6", 34 35 "@iconify-json/svg-spinners": "^1.2.4",
+8
pnpm-lock.yaml
··· 11 11 12 12 .: 13 13 dependencies: 14 + '@atcute/bluesky-richtext-segmenter': 15 + specifier: ^3.0.0 16 + version: 3.0.0 14 17 '@atproto/api': 15 18 specifier: ^0.18.0 16 19 version: 0.18.16 ··· 331 334 332 335 '@asamuzakjp/css-color@3.2.0': 333 336 resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} 337 + 338 + '@atcute/bluesky-richtext-segmenter@3.0.0': 339 + resolution: {integrity: sha512-NhZTUKtFpeBBbILwAcxj5u4RobIoHOmGw3CAaaEFNebKYSvmTecrXJ7XufHw5DFOUdr8SiKXQVRQxGAxulMNWg==} 334 340 335 341 '@atproto/api@0.18.16': 336 342 resolution: {integrity: sha512-tRGKSWr83pP5CQpSboePU21pE+GqLDYy1XHae4HH4hjaT0pr5V8wNgu70kbKB0B02GVUumeDRpJnlHKD+eMzLg==} ··· 11132 11138 '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) 11133 11139 '@csstools/css-tokenizer': 3.0.4 11134 11140 lru-cache: 10.4.3 11141 + 11142 + '@atcute/bluesky-richtext-segmenter@3.0.0': {} 11135 11143 11136 11144 '@atproto/api@0.18.16': 11137 11145 dependencies:
+76
shared/utils/bluesky.ts
··· 1 + import { withoutTrailingSlash } from 'ufo' 2 + 3 + export const BLUESKY_API = 'https://public.api.bsky.app' 4 + 5 + export interface BlueskyFacetFeature { 6 + $type?: string 7 + uri?: string 8 + did?: string 9 + tag?: string 10 + } 11 + 12 + export interface BlueskyFacet { 13 + index: { byteStart: number, byteEnd: number } 14 + features: BlueskyFacetFeature[] 15 + } 16 + 17 + export interface BlueskyPostRecord { 18 + createdAt: string 19 + text?: string 20 + facets?: BlueskyFacet[] 21 + } 22 + 23 + export interface BlueskyEmbed { 24 + external?: { uri?: string } 25 + } 26 + 27 + export interface BlueskyFeedPost { 28 + uri: string 29 + record: BlueskyPostRecord 30 + embed?: BlueskyEmbed 31 + } 32 + 33 + export interface BlueskyFeedItem { 34 + post: BlueskyFeedPost 35 + reason?: unknown 36 + } 37 + 38 + export interface BlueskyFeedResponse { 39 + feed: BlueskyFeedItem[] 40 + cursor?: string 41 + } 42 + 43 + export function extractLinksFromPost (post: BlueskyFeedPost): string[] { 44 + const links: string[] = [] 45 + 46 + if (post.record.facets) { 47 + for (const facet of post.record.facets) { 48 + for (const feature of facet.features) { 49 + if (feature.$type === 'app.bsky.richtext.facet#link' && feature.uri) { 50 + links.push(feature.uri) 51 + } 52 + } 53 + } 54 + } 55 + 56 + if (post.embed?.external?.uri) { 57 + links.push(post.embed.external.uri) 58 + } 59 + 60 + return links 61 + } 62 + 63 + export function postLinksToUrl (post: BlueskyFeedPost, targetUrl: string): boolean { 64 + const normalizedTarget = withoutTrailingSlash(targetUrl) 65 + return extractLinksFromPost(post).some(link => withoutTrailingSlash(link) === normalizedTarget) 66 + } 67 + 68 + export function atUriToWebUrl (atUri: string): string | null { 69 + const match = atUri.match(/at:\/\/([^/]+)\/app\.bsky\.feed\.post\/(.+)/) 70 + if (!match) return null 71 + const [, did, rkey] = match 72 + return `https://bsky.app/profile/${did}/post/${rkey}` 73 + } 74 + 75 + export type { RichtextSegment, Facet } from '@atcute/bluesky-richtext-segmenter' 76 + export { segmentize } from '@atcute/bluesky-richtext-segmenter'
+4 -2
test/unit/bundle.spec.ts
··· 40 40 expect.soft(stats.client.files.map(f => f.replace(/\..*\.js/, '.js').replace(/_scripts\/.*\.js/, '_scripts/script.js')).sort()) 41 41 .toMatchInlineSnapshot(` 42 42 [ 43 + "_nuxt/BlueskyComments.js", 43 44 "_nuxt/CalSchedule.js", 44 45 "_nuxt/ProseA.js", 45 46 "_nuxt/ProseBlockquote.js", ··· 77 78 stats.server = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir) 78 79 expect 79 80 .soft(roundToKilobytes(stats.server.totalBytes)) 80 - .toMatchInlineSnapshot(`"929k"`) 81 + .toMatchInlineSnapshot(`"930k"`) 81 82 82 83 const modules = await analyzeSizes('node_modules/**/*', serverDir) 83 84 expect 84 85 .soft(roundToKilobytes(modules.totalBytes)) 85 - .toMatchInlineSnapshot(`"10985k"`) 86 + .toMatchInlineSnapshot(`"10989k"`) 86 87 87 88 const packages = modules.files 88 89 .filter(m => m.endsWith('package.json')) ··· 90 91 .sort() 91 92 expect.soft(packages).toMatchInlineSnapshot(` 92 93 [ 94 + "@atcute/bluesky-richtext-segmenter", 93 95 "@atproto/api", 94 96 "@atproto/common-web", 95 97 "@atproto/lex-data",