Skip to content

Commit 37b7af4

Browse files
committed
feat: add BlogPostFederatedArticles
1 parent c314403 commit 37b7af4

11 files changed

Lines changed: 310 additions & 97 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<script setup lang="ts">
2+
const props = defineProps<{
3+
headline: string
4+
articles: {
5+
url: string
6+
// WARN: Must not contain @ symbol prefix
7+
authorHandle: string
8+
}[]
9+
}>()
10+
11+
const contentKey = computed(() => props.articles.map(a => a.url).join('-'))
12+
13+
const { data: federatedArticles, status } = await useAsyncData(
14+
`federated-articles-${contentKey.value}`,
15+
() => useFederatedArticles(props.articles),
16+
{
17+
watch: [() => props.articles],
18+
default: () => [],
19+
},
20+
)
21+
</script>
22+
23+
<template>
24+
<article class="px-4 py-2 sm:-mx-6 sm:px-6 sm:-my-3 sm:py-3 sm:rounded-md">
25+
<h2 class="font-mono text-xl font-medium text-fg">
26+
{{ headline }}
27+
</h2>
28+
<div
29+
v-if="federatedArticles?.length"
30+
class="grid gap-4 grid-cols-[repeat(auto-fit,minmax(14rem,1fr))] transition-[grid-template-cols]"
31+
>
32+
<section
33+
v-for="article in federatedArticles"
34+
:key="article.url"
35+
class="rounded-lg border border-border p-2 transition-shadow hover:shadow-lg hover:shadow-gray-500/50"
36+
>
37+
<a
38+
:href="article.url"
39+
target="_blank"
40+
rel="noopener noreferrer"
41+
class="grid grid-cols-[auto_1fr] items-center gap-x-5 no-underline hover:no-underline"
42+
>
43+
<AuthorAvatar
44+
v-if="article?.author"
45+
:author="article.author"
46+
size="md"
47+
class="row-span-2"
48+
/>
49+
<div class="flex flex-col gap-y-4">
50+
<p class="text-lg font-bold text-fg uppercase line-clamp-2 m-0">{{ article.title }}</p>
51+
<p class="text-md font-semibold text-fg-muted m-0">{{ article.author.name }}</p>
52+
<p class="text-xs line-clamp-1 text-fg-subtle m-0">{{ article.description }}</p>
53+
</div>
54+
</a>
55+
</section>
56+
</div>
57+
</article>
58+
</template>

app/components/EmbeddableBlueskyPost.client.vue

Lines changed: 0 additions & 94 deletions
This file was deleted.

app/components/EmbeddableBlueskyPost.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,11 @@ function onPostMessage(event: MessageEvent) {
8787
max-width: 37.5rem;
8888
width: 100%;
8989
margin: 1.5rem auto;
90+
/* ARC: Necessary to remove the white 1px line at the bottom of the embed. Also sets border-radius */
9091
border-radius: 0.75rem;
9192
overflow: hidden;
93+
/* CHROME: Necessary to remove the white 1px line at the bottom of the embed. Also sets border-radius */
94+
clip-path: inset(0 0 0.5px 0 round 0.75rem);
9295
}
9396
9497
.bluesky-embed-container > .loading-spinner {
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type { BlogMetaResponse } from '#shared/schemas/atproto'
2+
import type { ResolvedAuthor } from '#shared/schemas/blog'
3+
import { type AtIdentifierString } from '@atproto/lex'
4+
5+
// Input Interface
6+
export interface FederatedArticleInput {
7+
url: string
8+
authorHandle: string
9+
}
10+
11+
// Output Interface
12+
export type ResolvedFederatedArticle = Omit<BlogMetaResponse, 'author' | '_meta'> & {
13+
url: string
14+
author: ResolvedAuthor
15+
}
16+
17+
export async function useFederatedArticles(
18+
federatedArticles: FederatedArticleInput[],
19+
): Promise<ResolvedFederatedArticle[]> {
20+
if (!federatedArticles || federatedArticles.length === 0) return []
21+
22+
// 1. Prepare batch author request
23+
const authorQueryItems = federatedArticles.map(article => ({
24+
name: article.authorHandle,
25+
blueskyHandle: article.authorHandle,
26+
}))
27+
28+
// 2. Execute Fetches
29+
const [authorResponse, ...blogMetaResponses] = await Promise.all([
30+
// Batch Author Fetch
31+
$fetch<{ authors: any[] }>('/api/atproto/bluesky-author-profiles', {
32+
query: { authors: JSON.stringify(authorQueryItems) },
33+
}).catch(error => {
34+
console.error('Failed to fetch bluesky authors:', error)
35+
return { authors: [] }
36+
}),
37+
38+
// Parallel Blog Meta Fetches
39+
...federatedArticles.map(article =>
40+
$fetch<BlogMetaResponse>('/api/atproto/blog-meta', {
41+
query: { url: article.url },
42+
}).catch(
43+
() =>
44+
({
45+
// Fallback if scraping fails
46+
title: 'Untitled Article',
47+
author: undefined,
48+
description: undefined,
49+
image: undefined,
50+
_meta: {},
51+
_fetchedAt: '',
52+
}) as BlogMetaResponse,
53+
),
54+
),
55+
])
56+
57+
// 3. Merge Data
58+
return federatedArticles.map((article, index) => {
59+
const meta = blogMetaResponses[index]
60+
const authorProfile = authorResponse?.authors?.[index]
61+
62+
const resolvedAuthor: ResolvedAuthor = {
63+
name: meta?.author || authorProfile?.displayName || article.authorHandle,
64+
blueskyHandle: article.authorHandle as AtIdentifierString,
65+
avatar: authorProfile?.avatar || null,
66+
profileUrl: authorProfile?.profileUrl || null,
67+
}
68+
69+
return {
70+
url: article.url,
71+
title: meta?.title || 'Untitled',
72+
description: meta?.description,
73+
image: meta?.image,
74+
author: resolvedAuthor,
75+
}
76+
})
77+
}

app/pages/blog/alpha-release.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,43 @@ 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="Elsewhere..."
120+
:articles="[
121+
{
122+
url: 'https://dholms.leaflet.pub/3meluqcwky22a',
123+
authorHandle: 'dholms.dev'
124+
},
125+
{
126+
url: 'https://whitep4nth3r.com/blog/website-redesign-2026/',
127+
authorHandle: 'whitep4nth3r.com'
128+
},
129+
{
130+
url: 'https://roe.dev/blog/the-golden-thread',
131+
authorHandle: 'danielroe.dev'
132+
},
133+
{
134+
url: 'https://blog.muni.town/village-scale-resilience/',
135+
authorHandle: 'erlend.sh'
136+
},
137+
{
138+
url: 'https://www.pfrazee.com/blog/atmospheric-computing',
139+
authorHandle: 'pfrazee.com'
140+
},
141+
{
142+
url: 'https://marvins-guide.leaflet.pub/3mckm76mfws2h',
143+
authorHandle: 'baileytownsend.dev'
144+
},
145+
{
146+
url: 'https://patak.cat/blog/update.html',
147+
authorHandle: 'patak.cat'
148+
},
149+
{
150+
url: 'https://zeu.dev/blog/rnlive-partykit/',
151+
authorHandle: 'zeu.dev'
152+
}
153+
]"
154+
/>

app/pages/blog/package-registries.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,43 @@ draft: false
1414
# Package Registries
1515

1616
Shortest explanation: Production grade JavaScript is weird.
17+
18+
---
19+
20+
<BlogPostFederatedArticles
21+
headline="Elsewhere..."
22+
:articles="[
23+
{
24+
url: 'https://dholms.leaflet.pub/3meluqcwky22a',
25+
authorHandle: 'dholms.dev'
26+
},
27+
{
28+
url: 'https://whitep4nth3r.com/blog/website-redesign-2026/',
29+
authorHandle: 'whitep4nth3r.com'
30+
},
31+
{
32+
url: 'https://roe.dev/blog/the-golden-thread',
33+
authorHandle: 'danielroe.dev'
34+
},
35+
{
36+
url: 'https://blog.muni.town/village-scale-resilience/',
37+
authorHandle: 'erlend.sh'
38+
},
39+
{
40+
url: 'https://www.pfrazee.com/blog/atmospheric-computing',
41+
authorHandle: 'pfrazee.com'
42+
},
43+
{
44+
url: 'https://marvins-guide.leaflet.pub/3mckm76mfws2h',
45+
authorHandle: 'baileytownsend.dev'
46+
},
47+
{
48+
url: 'https://patak.cat/blog/update.html',
49+
authorHandle: 'patak.cat'
50+
},
51+
{
52+
url: 'https://zeu.dev/blog/rnlive-partykit/',
53+
authorHandle: 'zeu.dev'
54+
}
55+
]"
56+
/>
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import EmbeddableBlueskyPost from '~/components/EmbeddableBlueskyPost.client.vue'
1+
import BlogPostFederatedArticles from '~/components/BlogPostFederatedArticles.vue'
22

33
/**
44
* INFO: .md files are transformed into Vue SFCs by unplugin-vue-markdown during the Vite transform pipeline
55
* That transformation happens before Nuxt's component auto-import scanning can inject the proper imports
66
* Global registration ensures the component is available in the Vue runtime regardless of how the SFC was generated
77
*/
88
export default defineNuxtPlugin(nuxtApp => {
9-
nuxtApp.vueApp.component('EmbeddableBlueskyPost', EmbeddableBlueskyPost)
9+
nuxtApp.vueApp.component('BlogPostFederatedArticles', BlogPostFederatedArticles)
1010
})

modules/standard-site-sync.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { $fetch } from 'ofetch'
1313
const syncedDocuments = new Map<string, string>()
1414
const CLOCK_ID_THREE = 3
1515
const MS_TO_MICROSECONDS = 1000
16+
const ONE_DAY_MILLISECONDS = 86400000
1617

1718
type PDSSession = Pick<PDSSessionResponse, 'did' | 'handle'> & {
1819
accessToken: string
@@ -149,7 +150,7 @@ function generateTID(dateString: string, filePath: string): string {
149150

150151
// If date has no time component (exact midnight), add file-based entropy
151152
// This ensures unique TIDs when multiple posts share the same date
152-
if (timestamp % 86400000 === 0) {
153+
if (timestamp % ONE_DAY_MILLISECONDS === 0) {
153154
// Hash the file path to generate deterministic microseconds offset
154155
const pathHash = createHash('md5').update(filePath).digest('hex')
155156
const offset = parseInt(pathHash.slice(0, 8), 16) % 1000000 // 0-999999 microseconds

0 commit comments

Comments
 (0)