diff --git a/app/components/EmbeddableBlueskyPost.client.vue b/app/components/EmbeddableBlueskyPost.client.vue
new file mode 100644
index 000000000..b23146c5e
--- /dev/null
+++ b/app/components/EmbeddableBlueskyPost.client.vue
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('blog.atproto.view_on_bluesky') }}
+
+
+
+
+
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..f1cf1ff18
--- /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.client.ts b/app/plugins/bluesky-embed.client.ts
new file mode 100644
index 000000000..5972fc7e3
--- /dev/null
+++ b/app/plugins/bluesky-embed.client.ts
@@ -0,0 +1,10 @@
+import EmbeddableBlueskyPost from '~/components/EmbeddableBlueskyPost.client.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(),