@@ -3,35 +3,57 @@ import { createHash } from 'node:crypto'
33import { defineNuxtModule , useNuxt , createResolver } from 'nuxt/kit'
44import { safeParse } from 'valibot'
55import * 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'
78import { NPMX_SITE } from '../shared/utils/constants'
89import { read } from 'gray-matter'
910import { TID } from '@atproto/common'
10- import { Client } from '@atproto/lex '
11+ import { $fetch } from 'ofetch '
1112
1213const syncedDocuments = new Map < string , string > ( )
1314const 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+ */
1731export 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}
0 commit comments