-
-
Notifications
You must be signed in to change notification settings - Fork 424
Expand file tree
/
Copy pathstandard-site-sync.ts
More file actions
133 lines (112 loc) · 4.43 KB
/
standard-site-sync.ts
File metadata and controls
133 lines (112 loc) · 4.43 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
import process from 'node:process'
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 { NPMX_SITE } from '../shared/utils/constants'
import { read } from 'gray-matter'
import { TID } from '@atproto/common'
import { Client } from '@atproto/lex'
const syncedDocuments = new Map<string, string>()
const CLOCK_ID_THREE = 3
const DATE_TO_MICROSECONDS = 1000
// TODO: Currently logging quite a lot, can remove some later if we want
export default defineNuxtModule({
meta: { name: 'standard-site-sync' },
async setup() {
const nuxt = useNuxt()
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)
if (nuxt.options._prepare) return
nuxt.hook('build:before', async () => {
const { glob } = await import('tinyglobby')
const files: string[] = await glob(`${contentDir}/**/*.md`)
// INFO: Arbitrarily chosen concurrency limit, can be changed if needed
const concurrencyLimit = 5
for (let i = 0; i < files.length; i += concurrencyLimit) {
const batch = files.slice(i, i + concurrencyLimit)
// Process files in parallel
await Promise.all(
batch.map(file =>
syncFile(file, NPMX_SITE, client).catch(error =>
console.error(`[standard-site-sync] Error in ${file}:` + error),
),
),
)
}
})
nuxt.hook('builder:watch', async (event, path) => {
if (!path.endsWith('.md')) return
// Ignore deleted files
if (event === 'unlink') {
console.log(`[standard-site-sync] File deleted: ${path}`)
return
}
// 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),
)
})
},
})
/*
* INFO: Loads 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 { data: frontmatter } = read(filePath)
// Schema expects 'path' & frontmatter provides 'slug'
const normalizedFrontmatter = {
...frontmatter,
path: typeof frontmatter.slug === 'string' ? `/blog/${frontmatter.slug}` : frontmatter.path,
}
const result = safeParse(BlogPostSchema, normalizedFrontmatter)
if (!result.success) {
console.warn(`[standard-site-sync] Validation failed for ${filePath}`, result.issues)
return
}
const data = result.output
// filter drafts
if (data.draft) {
if (process.env.DEBUG === 'true') {
console.debug(`[standard-site-sync] Skipping draft: ${data.path}`)
}
return
}
// Keys are sorted to provide a more stable hash
const hash = createHash('sha256')
.update(JSON.stringify(data, Object.keys(data).sort()))
.digest('hex')
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,
})
syncedDocuments.set(data.path, hash)
}