Skip to content

Commit 6cf9eae

Browse files
committed
feat: Finalize actual atproto upload
1 parent 84d1830 commit 6cf9eae

File tree

10 files changed

+190
-58
lines changed

10 files changed

+190
-58
lines changed

app/pages/blog/atproto.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ authors:
99
title: 'ATProto'
1010
tags: ['OpenSource', 'Nuxt']
1111
excerpt: 'ATProto is very cool'
12-
date: '2026-01-28'
12+
date: '2026-01-28T14:30:00Z'
1313
slug: 'atproto'
1414
description: 'ATProto Adjacency Agenda'
1515
draft: false

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: false

app/pages/blog/nuxt.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ authors:
55
title: 'Nuxted'
66
tags: ['OpenSource', 'Nuxt']
77
excerpt: 'Nuxting'
8-
date: '2026-01-28'
8+
date: '2026-01-28T13:30:00Z'
99
slug: 'nuxt'
1010
description: 'Nuxter'
1111
draft: false

app/pages/blog/open-source.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ authors:
55
title: 'OSS'
66
tags: ['OpenSource', 'Nuxt']
77
excerpt: 'OSS Things'
8-
date: '2026-01-28'
8+
date: '2026-01-28T16:30:00Z'
99
slug: 'open-source'
1010
description: 'Talking about Open Source Software'
1111
draft: false

app/pages/blog/package-registries.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ authors:
55
title: 'Package Registries'
66
tags: ['OpenSource', 'Nuxt']
77
excerpt: 'Package Registries need fixing'
8-
date: '2026-01-28'
8+
date: '2026-01-28T12:30:00Z'
99
slug: 'package-registries'
1010
description: 'Package Registries Reimagined'
1111
draft: false

app/pages/blog/server-components.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ authors:
33
- name: Daniel Roe
44
blueskyHandle: danielroe.dev
55
title: 'Server Components'
6-
date: '2026-01-28'
6+
date: '2026-01-28T11:30:00Z'
77
slug: 'server-components'
88
description: 'My first post on the blog'
99
excerpt: 'Zero JS'

app/pages/blog/test-fail.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ authors:
55
title: 'TEST FAIL'
66
tags: ['OpenSource', 'Nuxt']
77
excerpt: 'My first post'
8-
date: '2026-01-28'
8+
date: '2026-01-28T10:30:00Z'
99
slug: 'first-post'
1010
description: 'I was made to test this nuxt module'
1111
draft: false

modules/standard-site-sync.ts

Lines changed: 160 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -3,35 +3,57 @@ import { createHash } from 'node:crypto'
33
import { defineNuxtModule, useNuxt, createResolver } from 'nuxt/kit'
44
import { safeParse } from 'valibot'
55
import * as site from '../shared/types/lexicons/site'
6-
import { BlogPostSchema } from '../shared/schemas/blog'
6+
import { PDSSessionSchema, type PDSSessionResponse } from '../shared/schemas/atproto'
7+
import { BlogPostSchema, type BlogPostFrontmatter } from '../shared/schemas/blog'
78
import { NPMX_SITE } from '../shared/utils/constants'
89
import { read } from 'gray-matter'
910
import { TID } from '@atproto/common'
10-
import { Client } from '@atproto/lex'
11+
import { $fetch } from 'ofetch'
1112

1213
const syncedDocuments = new Map<string, string>()
1314
const CLOCK_ID_THREE = 3
14-
const DATE_TO_MICROSECONDS = 1000
15+
const MS_TO_MICROSECONDS = 1000
16+
17+
type PDSSession = Pick<PDSSessionResponse, 'did' | 'handle'> & {
18+
accessToken: string
19+
}
20+
21+
type BlogPostDocument = Pick<
22+
BlogPostFrontmatter,
23+
'title' | 'date' | 'path' | 'tags' | 'draft' | 'description' | 'excerpt'
24+
>
1525

1626
// TODO: Currently logging quite a lot, can remove some later if we want
27+
/**
28+
* INFO: Performs all necessary steps to synchronize with atproto for blog uploads
29+
* All module setup logic is encapsulated in this file so as to make it available during nuxt build-time.
30+
*/
1731
export default defineNuxtModule({
1832
meta: { name: 'standard-site-sync' },
1933
async setup() {
2034
const nuxt = useNuxt()
2135
const { resolve } = createResolver(import.meta.url)
2236
const contentDir = resolve('../app/pages/blog')
2337

24-
// Authentication with PDS using an app password
25-
const pdsUrl = process.env.NPMX_PDS_URL
26-
if (!pdsUrl) {
27-
console.warn('[standard-site-sync] NPMX_PDS_URL not set, skipping sync')
28-
return
29-
}
30-
// Instantiate a single new client instance that is reused for every file
31-
const client = new Client(pdsUrl)
38+
const config = getPDSConfig()
39+
if (!config) return
40+
41+
const { pdsUrl, handle, password } = config
3242

43+
// Skip auth during prepare phase (nuxt prepare, nuxt generate --prepare, etc)
3344
if (nuxt.options._prepare) return
3445

46+
let session: PDSSession
47+
48+
// Login to get session
49+
try {
50+
session = await authenticatePDS(pdsUrl, handle, password)
51+
console.log(`[standard-site-sync] Logged in as ${session.handle} (${session.did})`)
52+
} catch (error) {
53+
console.error('[standard-site-sync] Authentication failed:', error)
54+
return
55+
}
56+
3557
nuxt.hook('build:before', async () => {
3658
const { glob } = await import('tinyglobby')
3759
const files: string[] = await glob(`${contentDir}/**/*.md`)
@@ -43,7 +65,7 @@ export default defineNuxtModule({
4365
// Process files in parallel
4466
await Promise.all(
4567
batch.map(file =>
46-
syncFile(file, NPMX_SITE, client).catch(error =>
68+
syncFile(file, NPMX_SITE, pdsUrl, session.accessToken, session.did).catch(error =>
4769
console.error(`[standard-site-sync] Error in ${file}:` + error),
4870
),
4971
),
@@ -61,28 +83,126 @@ export default defineNuxtModule({
6183
}
6284

6385
// Process add/change events only
64-
await syncFile(resolve(nuxt.options.rootDir, path), NPMX_SITE, client).catch(err =>
65-
console.error(`[standard-site-sync] Failed ${path}:`, err),
66-
)
86+
await syncFile(
87+
resolve(nuxt.options.rootDir, path),
88+
NPMX_SITE,
89+
pdsUrl,
90+
session.accessToken,
91+
session.did,
92+
).catch(err => console.error(`[standard-site-sync] Failed ${path}:`, err))
6793
})
6894
},
6995
})
7096

97+
// Get config from env vars
98+
function getPDSConfig(): { pdsUrl: string; handle: string; password: string } | undefined {
99+
const pdsUrl = process.env.NPMX_PDS_URL
100+
if (!pdsUrl) {
101+
console.warn('[standard-site-sync] NPMX_PDS_URL not set, skipping sync')
102+
return
103+
}
104+
105+
// TODO: Update to better env var names for production
106+
const handle = process.env.NPMX_TEST_HANDLE
107+
const password = process.env.NPMX_TEST_PASSWORD
108+
109+
if (!handle || !password) {
110+
console.warn(
111+
'[standard-site-sync] NPMX_TEST_HANDLE or NPMX_TEST_PASSWORD not set, skipping sync',
112+
)
113+
return
114+
}
115+
116+
return {
117+
pdsUrl,
118+
handle,
119+
password,
120+
}
121+
}
122+
123+
// Authenticate PDS with creds
124+
async function authenticatePDS(
125+
pdsUrl: string,
126+
handle: string,
127+
password: string,
128+
): Promise<PDSSession> {
129+
const sessionResponse = await $fetch(`${pdsUrl}/xrpc/com.atproto.server.createSession`, {
130+
method: 'POST',
131+
body: { identifier: handle, password },
132+
})
133+
134+
const result = safeParse(PDSSessionSchema, sessionResponse)
135+
if (!result.success) {
136+
throw new Error(`PDS response validation failed: ${result.issues[0].message}`)
137+
}
138+
139+
return {
140+
accessToken: result.output.accessJwt,
141+
did: result.output.did,
142+
handle: result.output.handle,
143+
}
144+
}
145+
146+
// Parse date from frontmatter, add file-path entropy for same-date collision resolution
147+
function generateTID(dateString: string, filePath: string): string {
148+
let timestamp = new Date(dateString).getTime()
149+
150+
// If date has no time component (exact midnight), add file-based entropy
151+
// This ensures unique TIDs when multiple posts share the same date
152+
if (timestamp % 86400000 === 0) {
153+
// Hash the file path to generate deterministic microseconds offset
154+
const pathHash = createHash('md5').update(filePath).digest('hex')
155+
const offset = parseInt(pathHash.slice(0, 8), 16) % 1000000 // 0-999999 microseconds
156+
timestamp += offset
157+
}
158+
159+
// Clock id(3) needs to be the same everytime to get the same TID from a timestamp
160+
return TID.fromTime(timestamp * MS_TO_MICROSECONDS, CLOCK_ID_THREE).str
161+
}
162+
163+
// Schema expects 'path' & frontmatter provides 'slug'
164+
function normalizeBlogFrontmatter(frontmatter: Record<string, unknown>): Record<string, unknown> {
165+
return {
166+
...frontmatter,
167+
path: typeof frontmatter.slug === 'string' ? `/blog/${frontmatter.slug}` : frontmatter.path,
168+
}
169+
}
170+
171+
// Keys are sorted to provide a more stable hash
172+
function createContentHash(data: unknown): string {
173+
return createHash('sha256')
174+
.update(JSON.stringify(data, Object.keys(data as object).sort()))
175+
.digest('hex')
176+
}
177+
178+
function buildATProtoDocument(siteUrl: string, data: BlogPostDocument) {
179+
return site.standard.document.$build({
180+
site: siteUrl as `${string}:${string}`,
181+
path: data.path,
182+
title: data.title,
183+
description: data.description ?? data.excerpt,
184+
tags: data.tags,
185+
publishedAt: new Date(data.date).toISOString(),
186+
})
187+
}
188+
71189
/*
72-
* INFO: Loads record to atproto and ensures uniqueness by checking the date the article is published
190+
* Loads a record to atproto and ensures uniqueness by checking the date the article is published
73191
* publishedAt is an id that does not change
74192
* Atomicity is enforced with upsert using publishedAt so we always update existing records instead of creating new ones
75193
* Clock id(3) provides a deterministic ID
76194
* WARN: DOES NOT CATCH ERRORS, THIS MUST BE HANDLED
77195
*/
78-
const syncFile = async (filePath: string, siteUrl: string, client: Client) => {
196+
const syncFile = async (
197+
filePath: string,
198+
siteUrl: string,
199+
pdsUrl: string,
200+
accessToken: string,
201+
did: string,
202+
) => {
79203
const { data: frontmatter } = read(filePath)
80204

81-
// Schema expects 'path' & frontmatter provides 'slug'
82-
const normalizedFrontmatter = {
83-
...frontmatter,
84-
path: typeof frontmatter.slug === 'string' ? `/blog/${frontmatter.slug}` : frontmatter.path,
85-
}
205+
const normalizedFrontmatter = normalizeBlogFrontmatter(frontmatter)
86206

87207
const result = safeParse(BlogPostSchema, normalizedFrontmatter)
88208
if (!result.success) {
@@ -100,34 +220,30 @@ const syncFile = async (filePath: string, siteUrl: string, client: Client) => {
100220
return
101221
}
102222

103-
// Keys are sorted to provide a more stable hash
104-
const hash = createHash('sha256')
105-
.update(JSON.stringify(data, Object.keys(data).sort()))
106-
.digest('hex')
223+
const hash = createContentHash(data)
107224

108225
if (syncedDocuments.get(data.path) === hash) {
109226
return
110227
}
111228

112-
const document = site.standard.document.$build({
113-
site: siteUrl as `${string}:${string}`,
114-
path: data.path,
115-
title: data.title,
116-
description: data.description ?? data.excerpt,
117-
tags: data.tags,
118-
// This can be extended to update the site.standard.document .updatedAt if it is changed and use the posts date here
119-
publishedAt: new Date(data.date).toISOString(),
120-
})
121-
122-
const dateInMicroSeconds = new Date(result.output.date).getTime() * DATE_TO_MICROSECONDS
123-
124-
// Clock id(3) needs to be the same everytime to get the same TID from a timestamp
125-
const tid = TID.fromTime(dateInMicroSeconds, CLOCK_ID_THREE)
126-
127-
// client.put is async and needs to be awaited
128-
await client.put(site.standard.document, document, {
129-
rkey: tid.str,
229+
const document = buildATProtoDocument(siteUrl, data)
230+
231+
const tid = generateTID(data.date, filePath)
232+
233+
await $fetch(`${pdsUrl}/xrpc/com.atproto.repo.putRecord`, {
234+
method: 'POST',
235+
headers: {
236+
Authorization: `Bearer ${accessToken}`,
237+
},
238+
body: {
239+
// Pass object directly, not JSON.stringify
240+
repo: did,
241+
collection: 'site.standard.document',
242+
rkey: tid,
243+
record: document,
244+
},
130245
})
131246

132247
syncedDocuments.set(data.path, hash)
248+
console.log(`[standard-site-sync] Synced ${data.path} (rkey: ${tid})`)
133249
}

shared/schemas/atproto.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,33 @@
11
import {
2-
object,
3-
string,
4-
startsWith,
2+
boolean,
53
minLength,
6-
regex,
7-
pipe,
84
nonEmpty,
5+
object,
96
optional,
107
picklist,
8+
pipe,
9+
regex,
10+
startsWith,
11+
string,
1112
} from 'valibot'
1213
import type { InferOutput } from 'valibot'
1314
import { AT_URI_REGEX, BLUESKY_URL_REGEX, ERROR_BLUESKY_URL_FAILED } from '#shared/utils/constants'
1415

16+
/**
17+
* INFO: Validates AT Protocol createSession response
18+
* Used for authenticating PDS sessions.
19+
*/
20+
export const PDSSessionSchema = object({
21+
did: string(),
22+
handle: string(),
23+
accessJwt: string(),
24+
refreshJwt: string(),
25+
email: string(),
26+
emailConfirmed: boolean(),
27+
})
28+
29+
export type PDSSessionResponse = InferOutput<typeof PDSSessionSchema>
30+
1531
/**
1632
* INFO: Validates AT Protocol URI format (at://did:plc:.../app.bsky.feed.post/...)
1733
* Used for referencing Bluesky posts in our database and API routes.

shared/schemas/blog.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { object, string, optional, array, boolean, pipe, isoDate } from 'valibot'
1+
import { object, string, optional, array, boolean, pipe, isoTimestamp } from 'valibot'
22
import type { InferOutput } from 'valibot'
33

44
export const AuthorSchema = object({
@@ -9,7 +9,7 @@ export const AuthorSchema = object({
99
export const BlogPostSchema = object({
1010
authors: array(AuthorSchema),
1111
title: string(),
12-
date: pipe(string(), isoDate()),
12+
date: pipe(string(), isoTimestamp()),
1313
description: string(),
1414
path: string(),
1515
slug: string(),

0 commit comments

Comments
 (0)