Skip to content

Commit d55c67f

Browse files
authored
feat: blog federated links section (#1699)
1 parent 52f8ea0 commit d55c67f

File tree

12 files changed

+489
-63
lines changed

12 files changed

+489
-63
lines changed
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<script setup lang="ts">
2+
import type { FederatedArticleInput } from '#shared/types/blog-post'
3+
4+
const props = defineProps<{
5+
headline: string
6+
articles: FederatedArticleInput[]
7+
}>()
8+
9+
const contentKey = computed(() => props.articles.map(a => a.url).join('-'))
10+
11+
const { data: federatedArticles, status } = await useAsyncData(
12+
`federated-articles-${contentKey.value}`,
13+
() => useFederatedArticles(props.articles),
14+
{
15+
watch: [() => props.articles],
16+
default: () => [],
17+
},
18+
)
19+
</script>
20+
21+
<template>
22+
<article class="px-4 py-2 sm:-mx-6 sm:px-6 sm:-my-3 sm:py-3 sm:rounded-md">
23+
<h2 class="font-mono text-xl font-medium text-fg">
24+
{{ headline }}
25+
</h2>
26+
<section
27+
v-if="federatedArticles?.length"
28+
class="grid gap-4 grid-cols-[repeat(auto-fit,minmax(14rem,1fr))] transition-[grid-template-cols]"
29+
>
30+
<a
31+
:href="article.url"
32+
target="_blank"
33+
rel="noopener noreferrer"
34+
v-for="article in federatedArticles"
35+
:key="article.url"
36+
class="grid grid-cols-[auto_1fr] gap-x-5 no-underline hover:no-underline rounded-lg border border-border p-4 transition-shadow hover:shadow-lg hover:shadow-gray-500/50"
37+
>
38+
<AuthorAvatar
39+
v-if="article?.author"
40+
:author="article.author"
41+
size="md"
42+
class="row-span-2"
43+
/>
44+
<div class="flex flex-col">
45+
<p class="text-lg text-fg uppercase leading-tight m-0">
46+
{{ article.title }}
47+
</p>
48+
<p class="text-md font-semibold text-fg-muted leading-none mt-2">
49+
{{ article.author.name }}
50+
</p>
51+
<p class="text-xs text-fg-subtle leading-snug m-0">
52+
{{ article.description }}
53+
</p>
54+
</div>
55+
</a>
56+
</section>
57+
</article>
58+
</template>
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import type { BlogMetaResponse } from '#shared/schemas/atproto'
2+
import type { ResolvedAuthor } from '#shared/schemas/blog'
3+
import type { FederatedArticleInput, ResolvedFederatedArticle } from '#shared/types/blog-post'
4+
import { type AtIdentifierString } from '@atproto/lex'
5+
6+
export async function useFederatedArticles(
7+
federatedArticles: FederatedArticleInput[],
8+
): Promise<ResolvedFederatedArticle[]> {
9+
if (!federatedArticles || federatedArticles.length === 0) return []
10+
11+
// 1. Prepare batch author request
12+
const authorQueryItems = federatedArticles.map(article => ({
13+
name: article.authorHandle,
14+
blueskyHandle: article.authorHandle,
15+
}))
16+
17+
// 2. Execute Fetches
18+
const [authorResponse, ...blogMetaResponses] = await Promise.all([
19+
// Batch Author Fetch
20+
$fetch<{ authors: any[] }>('/api/atproto/bluesky-author-profiles', {
21+
query: { authors: JSON.stringify(authorQueryItems) },
22+
}).catch(error => {
23+
console.error('Failed to fetch bluesky authors:', error)
24+
return { authors: [] }
25+
}),
26+
27+
// Parallel Blog Meta Fetches
28+
...federatedArticles.map(article =>
29+
$fetch<BlogMetaResponse>('/api/atproto/blog-meta', {
30+
query: { url: article.url },
31+
}).catch(
32+
() =>
33+
({
34+
// Fallback if scraping fails
35+
title: 'Untitled Article',
36+
author: undefined,
37+
description: undefined,
38+
image: undefined,
39+
_meta: {},
40+
_fetchedAt: '',
41+
}) as BlogMetaResponse,
42+
),
43+
),
44+
])
45+
46+
// 3. Merge Data
47+
return federatedArticles.map((article, index) => {
48+
const meta = blogMetaResponses[index]
49+
const authorProfile = authorResponse?.authors?.[index]
50+
51+
const resolvedAuthor: ResolvedAuthor = {
52+
name: meta?.author || authorProfile?.displayName || article.authorHandle,
53+
blueskyHandle: article.authorHandle as AtIdentifierString,
54+
avatar: authorProfile?.avatar || null,
55+
profileUrl: authorProfile?.profileUrl || null,
56+
}
57+
58+
return {
59+
url: article.url,
60+
title: meta?.title || 'Untitled',
61+
// INFO: Prefer input description, otherwise fallback to fetched meta
62+
description: article.description || meta?.description,
63+
image: meta?.image,
64+
author: resolvedAuthor,
65+
}
66+
})
67+
}

app/pages/blog/alpha-release.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,51 @@ project from a wide variety of perspectives and experiences.
112112
The alpha release of npmx is intentionally early. We want real-world feedback from _you_ to guide our roadmap and priorities. Try [npmx](https://npmx.dev) today, let us know what you think and share your use-cases and missing features at [chat.npmx.dev](https://chat.npmx.dev). [Open an issue on GitHub](https://github.com/npmx-dev/npmx.dev/issues) or submit a pull request, and [follow npmx.dev on Bluesky](https://bsky.app/profile/npmx.dex) to keep up to date with what we're working on.
113113

114114
And thank you: thank you to every single human who has contributed to npmx so far, whether that's in the form of code, documentation, testing, community activities, and more. You are the people building npmx; you are the people building the future we want.
115+
116+
---
117+
118+
<BlogPostFederatedArticles
119+
headline="Read more from the community"
120+
:articles="[
121+
{
122+
url: 'https://dholms.leaflet.pub/3meluqcwky22a',
123+
authorHandle: 'dholms.dev',
124+
description: ''
125+
},
126+
{
127+
url: 'https://whitep4nth3r.com/blog/website-redesign-2026/',
128+
authorHandle: 'whitep4nth3r.com',
129+
description: 'I am a hardcoded description of Salma\'s lovely article!!'
130+
},
131+
{
132+
url: 'https://roe.dev/blog/the-golden-thread',
133+
authorHandle: 'danielroe.dev',
134+
description: ''
135+
},
136+
{
137+
url: 'https://blog.muni.town/village-scale-resilience/',
138+
authorHandle: 'erlend.sh',
139+
description: ''
140+
},
141+
{
142+
url: 'https://www.pfrazee.com/blog/atmospheric-computing',
143+
authorHandle: 'pfrazee.com',
144+
description: ''
145+
},
146+
{
147+
url: 'https://marvins-guide.leaflet.pub/3mckm76mfws2h',
148+
authorHandle: 'baileytownsend.dev',
149+
description: ''
150+
},
151+
{
152+
url: 'https://patak.cat/blog/update.html',
153+
authorHandle: 'patak.cat',
154+
description: ''
155+
},
156+
{
157+
url: 'https://zeu.dev/blog/rnlive-partykit/',
158+
authorHandle: 'zeu.dev',
159+
description: ''
160+
}
161+
]"
162+
/>

app/pages/blog/first-post.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ authors:
77
title: 'Hello World'
88
tags: ['OpenSource', 'Nuxt']
99
excerpt: 'My first post'
10-
date: '2026-01-28'
10+
date: '2026-01-28T15:30:00Z'
1111
slug: 'first-post'
1212
description: 'My first post on the blog'
1313
draft: true
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import BlogPostFederatedArticles from '~/components/BlogPostFederatedArticles.vue'
2+
3+
/**
4+
* INFO: .md files are transformed into Vue SFCs by unplugin-vue-markdown during the Vite transform pipeline
5+
* That transformation happens before Nuxt's component auto-import scanning can inject the proper imports
6+
* Global registration ensures the component is available in the Vue runtime regardless of how the SFC was generated
7+
*/
8+
export default defineNuxtPlugin(nuxtApp => {
9+
nuxtApp.vueApp.component('BlogPostFederatedArticles', BlogPostFederatedArticles)
10+
})

lunaria/files/en-US.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@
8080
"title": "Blog",
8181
"heading": "blog",
8282
"meta_description": "Insights and updates from the npmx community",
83+
"atproto": {
84+
"loading_bluesky_post": "Loading Bluesky post...",
85+
"view_on_bluesky": "View this post on Bluesky"
86+
},
8387
"author": {
8488
"view_profile": "View {name}'s profile on Bluesky"
8589
},

0 commit comments

Comments
 (0)