diff --git a/app/components/EmbeddableBlueskyPost.vue b/app/components/global/BlueskyPostEmbed.vue similarity index 91% rename from app/components/EmbeddableBlueskyPost.vue rename to app/components/global/BlueskyPostEmbed.vue index 2cd6b65ec..d98ec51c3 100644 --- a/app/components/EmbeddableBlueskyPost.vue +++ b/app/components/global/BlueskyPostEmbed.vue @@ -87,8 +87,11 @@ function onPostMessage(event: MessageEvent) { max-width: 37.5rem; width: 100%; margin: 1.5rem auto; + /* ARC: Necessary to remove the thin white line at the bottom of the embed. Also sets border-radius */ border-radius: 0.75rem; overflow: hidden; + /* CHROME: Necessary to remove the thin white line at the bottom of the embed. Also sets border-radius */ + clip-path: inset(0 0 1px 0 round 0.75rem); } .bluesky-embed-container > .loading-spinner { diff --git a/app/pages/blog/alpha-release.md b/app/pages/blog/alpha-release.md index 4c4a4079a..72eb9de76 100644 --- a/app/pages/blog/alpha-release.md +++ b/app/pages/blog/alpha-release.md @@ -43,7 +43,7 @@ people's frustrations with the npm experience on the web and the CLI. The questi as they voiced frustrations with the user experience including code browsing, missing data, trust signals, surfacing dependencies, and the friction surrounding the publishing experience. - + It was clear there was a huge opportunity to build a fast, modern browser for the npm registry with an improved developer experience, and that there were people willing to work with Daniel to build it. diff --git a/app/pages/blog/atproto.md b/app/pages/blog/atproto.md new file mode 100644 index 000000000..70b535463 --- /dev/null +++ b/app/pages/blog/atproto.md @@ -0,0 +1,28 @@ +--- +authors: + - name: Daniel Roe + blueskyHandle: danielroe.dev + - name: Salma Alam-Naylor + blueskyHandle: whitep4nth3r.com + - name: Matias Capeletto + blueskyHandle: patak.dev +title: 'ATProto' +tags: ['OpenSource', 'Nuxt'] +excerpt: 'ATProto is very cool' +date: '2026-01-28T14:30:00Z' +slug: 'atproto' +description: 'ATProto Adjacency Agenda' +draft: false +--- + +# Atmosphere Apps + +All the cool kids are doing Software Decentralization. + +This post is all about atmosphere. How it's something that we need to live. Without atmosphere we may end up like Arnie in Total Recall. We don't want that. +But thankfully, we have atmosphere. This beautiful concept is used for other things as well. Atmos is a Fellow product that is a vacuum canister used to store coffee. +This keeps your coffee fresh. But arguably, if you drink a lot of coffee you don't need to store it in a vacuum canister. But if you like to be super fancy. There is an +automated vacuum canister that sucks out the air for you. You don't need to twist and turn like a human machine. You press a button and it sucks the air out. Automation. +It's a wonderful thing. We use automation on this blog post. One automation is getting the Bluesky comments. These are fetched during build time and also run time. This means +posts will always have bluesky comments. Whether you like it or not. Under the hood we do fancy ATProto stuff. And that brings us back to Atmosphere. Because it's something +we need to live. diff --git a/app/pages/blog/first-post.md b/app/pages/blog/first-post.md index 1dbbee080..c63264d4a 100644 --- a/app/pages/blog/first-post.md +++ b/app/pages/blog/first-post.md @@ -7,7 +7,7 @@ authors: title: 'Hello World' tags: ['OpenSource', 'Nuxt'] excerpt: 'My first post' -date: '2026-01-28' +date: '2026-01-28T15:30:00Z' slug: 'first-post' description: 'My first post on the blog' draft: true diff --git a/app/pages/blog/nuxt.md b/app/pages/blog/nuxt.md new file mode 100644 index 000000000..d694fc767 --- /dev/null +++ b/app/pages/blog/nuxt.md @@ -0,0 +1,18 @@ +--- +authors: + - name: Daniel Roe + blueskyHandle: danielroe.dev +title: 'Nuxted' +tags: ['OpenSource', 'Nuxt'] +excerpt: 'Nuxting' +date: '2026-01-28T13:30:00Z' +slug: 'nuxt' +description: 'Nuxter' +draft: false +--- + +# Nuxt + +What a great meta-framework!! + + diff --git a/app/pages/blog/open-source.md b/app/pages/blog/open-source.md new file mode 100644 index 000000000..ccb6802b0 --- /dev/null +++ b/app/pages/blog/open-source.md @@ -0,0 +1,16 @@ +--- +authors: + - name: Daniel Roe + blueskyHandle: danielroe.dev +title: 'OSS' +tags: ['OpenSource', 'Nuxt'] +excerpt: 'OSS Things' +date: '2026-01-28T16:30:00Z' +slug: 'open-source' +description: 'Talking about Open Source Software' +draft: false +--- + +# OSS + +This is about Open Source Software. diff --git a/app/pages/blog/package-registries.md b/app/pages/blog/package-registries.md new file mode 100644 index 000000000..5274341dd --- /dev/null +++ b/app/pages/blog/package-registries.md @@ -0,0 +1,16 @@ +--- +authors: + - name: Daniel Roe + blueskyHandle: danielroe.dev +title: 'Package Registries' +tags: ['OpenSource', 'Nuxt'] +excerpt: 'Package Registries need fixing' +date: '2026-01-28T12:30:00Z' +slug: 'package-registries' +description: 'Package Registries Reimagined' +draft: false +--- + +# Package Registries + +Shortest explanation: Production grade JavaScript is weird. diff --git a/app/pages/blog/server-components.md b/app/pages/blog/server-components.md new file mode 100644 index 000000000..3206cc2eb --- /dev/null +++ b/app/pages/blog/server-components.md @@ -0,0 +1,15 @@ +--- +authors: + - name: Daniel Roe + blueskyHandle: danielroe.dev +title: 'Server Components' +date: '2026-01-28T11:30:00Z' +slug: 'server-components' +description: 'My first post on the blog' +excerpt: 'Zero JS' +draft: false +--- + +# Server components + +Here is some server component razzle dazzle. Hello there! diff --git a/app/pages/blog/test-fail.md b/app/pages/blog/test-fail.md new file mode 100644 index 000000000..c41ec1014 --- /dev/null +++ b/app/pages/blog/test-fail.md @@ -0,0 +1,14 @@ +--- +authors: + - name: Daniel Roe + blueskyHandle: danielroe.dev +title: 'TEST FAIL' +tags: ['OpenSource', 'Nuxt'] +excerpt: 'My first post' +date: '2026-01-28T10:30:00Z' +slug: 'first-post' +description: 'I was made to test this nuxt module' +draft: false +--- + +# TEST FAIL diff --git a/app/plugins/bluesky-embed.ts b/app/plugins/bluesky-embed.ts deleted file mode 100644 index ac05b53ed..000000000 --- a/app/plugins/bluesky-embed.ts +++ /dev/null @@ -1,10 +0,0 @@ -import EmbeddableBlueskyPost from '~/components/EmbeddableBlueskyPost.vue' - -/** - * INFO: .md files are transformed into Vue SFCs by unplugin-vue-markdown during the Vite transform pipeline - * That transformation happens before Nuxt's component auto-import scanning can inject the proper imports - * Global registration ensures the component is available in the Vue runtime regardless of how the SFC was generated - */ -export default defineNuxtPlugin(nuxtApp => { - nuxtApp.vueApp.component('EmbeddableBlueskyPost', EmbeddableBlueskyPost) -}) diff --git a/lunaria/files/en-US.json b/lunaria/files/en-US.json index 3d90a3e69..cfba2375a 100644 --- a/lunaria/files/en-US.json +++ b/lunaria/files/en-US.json @@ -80,6 +80,10 @@ "title": "Blog", "heading": "blog", "meta_description": "Insights and updates from the npmx community", + "atproto": { + "loading_bluesky_post": "Loading Bluesky post...", + "view_on_bluesky": "View this post on Bluesky" + }, "author": { "view_profile": "View {name}'s profile on Bluesky" }, diff --git a/modules/standard-site-sync.ts b/modules/standard-site-sync.ts index f182489f3..53cb6de8c 100644 --- a/modules/standard-site-sync.ts +++ b/modules/standard-site-sync.ts @@ -3,17 +3,31 @@ import { createHash } from 'node:crypto' import { defineNuxtModule, useNuxt, createResolver } from 'nuxt/kit' import { safeParse } from 'valibot' import * as site from '../shared/types/lexicons/site' -import { BlogPostSchema } from '../shared/schemas/blog' +import { PDSSessionSchema, type PDSSessionResponse } from '../shared/schemas/atproto' +import { BlogPostSchema, type BlogPostFrontmatter } from '../shared/schemas/blog' import { NPMX_SITE } from '../shared/utils/constants' import { read } from 'gray-matter' import { TID } from '@atproto/common' -import { Client } from '@atproto/lex' +import { $fetch } from 'ofetch' const syncedDocuments = new Map() const CLOCK_ID_THREE = 3 -const DATE_TO_MICROSECONDS = 1000 +const MS_TO_MICROSECONDS = 1000 + +type PDSSession = Pick & { + accessToken: string +} + +type BlogPostDocument = Pick< + BlogPostFrontmatter, + 'title' | 'date' | 'path' | 'tags' | 'draft' | 'description' | 'excerpt' +> // TODO: Currently logging quite a lot, can remove some later if we want +/** + * INFO: Performs all necessary steps to synchronize with atproto for blog uploads + * All module setup logic is encapsulated in this file so as to make it available during nuxt build-time. + */ export default defineNuxtModule({ meta: { name: 'standard-site-sync' }, async setup() { @@ -21,17 +35,25 @@ export default defineNuxtModule({ const { resolve } = createResolver(import.meta.url) const contentDir = resolve('../app/pages/blog') - // Authentication with PDS using an app password - const pdsUrl = process.env.NPMX_PDS_URL - if (!pdsUrl) { - console.warn('[standard-site-sync] NPMX_PDS_URL not set, skipping sync') - return - } - // Instantiate a single new client instance that is reused for every file - const client = new Client(pdsUrl) + const config = getPDSConfig() + if (!config) return + + const { pdsUrl, handle, password } = config + // Skip auth during prepare phase (nuxt prepare, nuxt generate --prepare, etc) if (nuxt.options._prepare) return + let session: PDSSession + + // Login to get session + try { + session = await authenticatePDS(pdsUrl, handle, password) + console.log(`[standard-site-sync] Logged in as ${session.handle} (${session.did})`) + } catch (error) { + console.error('[standard-site-sync] Authentication failed:', error) + return + } + nuxt.hook('build:before', async () => { const { glob } = await import('tinyglobby') const files: string[] = await glob(`${contentDir}/**/*.md`) @@ -43,7 +65,7 @@ export default defineNuxtModule({ // Process files in parallel await Promise.all( batch.map(file => - syncFile(file, NPMX_SITE, client).catch(error => + syncFile(file, NPMX_SITE, pdsUrl, session.accessToken, session.did).catch(error => console.error(`[standard-site-sync] Error in ${file}:` + error), ), ), @@ -61,28 +83,126 @@ export default defineNuxtModule({ } // Process add/change events only - await syncFile(resolve(nuxt.options.rootDir, path), NPMX_SITE, client).catch(err => - console.error(`[standard-site-sync] Failed ${path}:`, err), - ) + await syncFile( + resolve(nuxt.options.rootDir, path), + NPMX_SITE, + pdsUrl, + session.accessToken, + session.did, + ).catch(err => console.error(`[standard-site-sync] Failed ${path}:`, err)) }) }, }) +// Get config from env vars +function getPDSConfig(): { pdsUrl: string; handle: string; password: string } | undefined { + const pdsUrl = process.env.NPMX_PDS_URL + if (!pdsUrl) { + console.warn('[standard-site-sync] NPMX_PDS_URL not set, skipping sync') + return + } + + // TODO: Update to better env var names for production + const handle = process.env.NPMX_TEST_HANDLE + const password = process.env.NPMX_TEST_PASSWORD + + if (!handle || !password) { + console.warn( + '[standard-site-sync] NPMX_TEST_HANDLE or NPMX_TEST_PASSWORD not set, skipping sync', + ) + return + } + + return { + pdsUrl, + handle, + password, + } +} + +// Authenticate PDS with creds +async function authenticatePDS( + pdsUrl: string, + handle: string, + password: string, +): Promise { + const sessionResponse = await $fetch(`${pdsUrl}/xrpc/com.atproto.server.createSession`, { + method: 'POST', + body: { identifier: handle, password }, + }) + + const result = safeParse(PDSSessionSchema, sessionResponse) + if (!result.success) { + throw new Error(`PDS response validation failed: ${result.issues[0].message}`) + } + + return { + accessToken: result.output.accessJwt, + did: result.output.did, + handle: result.output.handle, + } +} + +// Parse date from frontmatter, add file-path entropy for same-date collision resolution +function generateTID(dateString: string, filePath: string): string { + let timestamp = new Date(dateString).getTime() + + // If date has no time component (exact midnight), add file-based entropy + // This ensures unique TIDs when multiple posts share the same date + if (timestamp % 86400000 === 0) { + // Hash the file path to generate deterministic microseconds offset + const pathHash = createHash('md5').update(filePath).digest('hex') + const offset = parseInt(pathHash.slice(0, 8), 16) % 1000000 // 0-999999 microseconds + timestamp += offset + } + + // Clock id(3) needs to be the same everytime to get the same TID from a timestamp + return TID.fromTime(timestamp * MS_TO_MICROSECONDS, CLOCK_ID_THREE).str +} + +// Schema expects 'path' & frontmatter provides 'slug' +function normalizeBlogFrontmatter(frontmatter: Record): Record { + return { + ...frontmatter, + path: typeof frontmatter.slug === 'string' ? `/blog/${frontmatter.slug}` : frontmatter.path, + } +} + +// Keys are sorted to provide a more stable hash +function createContentHash(data: unknown): string { + return createHash('sha256') + .update(JSON.stringify(data, Object.keys(data as object).sort())) + .digest('hex') +} + +function buildATProtoDocument(siteUrl: string, data: BlogPostDocument) { + return site.standard.document.$build({ + site: siteUrl as `${string}:${string}`, + path: data.path, + title: data.title, + description: data.description ?? data.excerpt, + tags: data.tags, + publishedAt: new Date(data.date).toISOString(), + }) +} + /* - * INFO: Loads record to atproto and ensures uniqueness by checking the date the article is published + * Loads a record to atproto and ensures uniqueness by checking the date the article is published * publishedAt is an id that does not change * Atomicity is enforced with upsert using publishedAt so we always update existing records instead of creating new ones * Clock id(3) provides a deterministic ID * WARN: DOES NOT CATCH ERRORS, THIS MUST BE HANDLED */ -const syncFile = async (filePath: string, siteUrl: string, client: Client) => { +const syncFile = async ( + filePath: string, + siteUrl: string, + pdsUrl: string, + accessToken: string, + did: string, +) => { const { data: frontmatter } = read(filePath) - // Schema expects 'path' & frontmatter provides 'slug' - const normalizedFrontmatter = { - ...frontmatter, - path: typeof frontmatter.slug === 'string' ? `/blog/${frontmatter.slug}` : frontmatter.path, - } + const normalizedFrontmatter = normalizeBlogFrontmatter(frontmatter) const result = safeParse(BlogPostSchema, normalizedFrontmatter) if (!result.success) { @@ -100,34 +220,30 @@ const syncFile = async (filePath: string, siteUrl: string, client: Client) => { return } - // Keys are sorted to provide a more stable hash - const hash = createHash('sha256') - .update(JSON.stringify(data, Object.keys(data).sort())) - .digest('hex') + const hash = createContentHash(data) if (syncedDocuments.get(data.path) === hash) { return } - const document = site.standard.document.$build({ - site: siteUrl as `${string}:${string}`, - path: data.path, - title: data.title, - description: data.description ?? data.excerpt, - tags: data.tags, - // This can be extended to update the site.standard.document .updatedAt if it is changed and use the posts date here - publishedAt: new Date(data.date).toISOString(), - }) - - const dateInMicroSeconds = new Date(result.output.date).getTime() * DATE_TO_MICROSECONDS - - // Clock id(3) needs to be the same everytime to get the same TID from a timestamp - const tid = TID.fromTime(dateInMicroSeconds, CLOCK_ID_THREE) - - // client.put is async and needs to be awaited - await client.put(site.standard.document, document, { - rkey: tid.str, + const document = buildATProtoDocument(siteUrl, data) + + const tid = generateTID(data.date, filePath) + + await $fetch(`${pdsUrl}/xrpc/com.atproto.repo.putRecord`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + body: { + // Pass object directly, not JSON.stringify + repo: did, + collection: 'site.standard.document', + rkey: tid, + record: document, + }, }) syncedDocuments.set(data.path, hash) + console.log(`[standard-site-sync] Synced ${data.path} (rkey: ${tid})`) } diff --git a/shared/schemas/atproto.ts b/shared/schemas/atproto.ts index 68357869a..84e8f3a5d 100644 --- a/shared/schemas/atproto.ts +++ b/shared/schemas/atproto.ts @@ -1,17 +1,33 @@ import { - object, - string, - startsWith, + boolean, minLength, - regex, - pipe, nonEmpty, + object, optional, picklist, + pipe, + regex, + startsWith, + string, } from 'valibot' import type { InferOutput } from 'valibot' import { AT_URI_REGEX, BLUESKY_URL_REGEX, ERROR_BLUESKY_URL_FAILED } from '#shared/utils/constants' +/** + * INFO: Validates AT Protocol createSession response + * Used for authenticating PDS sessions. + */ +export const PDSSessionSchema = object({ + did: string(), + handle: string(), + accessJwt: string(), + refreshJwt: string(), + email: string(), + emailConfirmed: boolean(), +}) + +export type PDSSessionResponse = InferOutput + /** * INFO: Validates AT Protocol URI format (at://did:plc:.../app.bsky.feed.post/...) * Used for referencing Bluesky posts in our database and API routes. diff --git a/shared/schemas/blog.ts b/shared/schemas/blog.ts index 5b1670d12..9269cc6e3 100644 --- a/shared/schemas/blog.ts +++ b/shared/schemas/blog.ts @@ -1,5 +1,5 @@ +import { array, boolean, custom, isoTimestamp, object, optional, pipe, string } from 'valibot' import { isAtIdentifierString, type AtIdentifierString } from '@atproto/lex' -import { custom, object, string, optional, array, boolean, pipe, isoDate } from 'valibot' import type { InferOutput } from 'valibot' export const AuthorSchema = object({ @@ -15,7 +15,7 @@ export const AuthorSchema = object({ export const BlogPostSchema = object({ authors: array(AuthorSchema), title: string(), - date: pipe(string(), isoDate()), + date: pipe(string(), isoTimestamp()), description: string(), path: string(), slug: string(),