Skip to content

Commit a52e4b1

Browse files
committed
feat: added atomicity to the standard site sync
1 parent ada797b commit a52e4b1

File tree

5 files changed

+157
-93
lines changed

5 files changed

+157
-93
lines changed

app/components/BlogPost.server.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const props = defineProps<{ title: string; htmlContent: string; date: string }>(
1515
class="text-xs text-fg-subtle"
1616
/>
1717

18+
<!-- INFO: v-html can only be used in ssr on html elements, not components -->
1819
<div class="" v-html="htmlContent" />
1920
</main>
2021
</template>

modules/standard-site-sync.ts

Lines changed: 95 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -5,124 +5,133 @@ import { safeParse } from 'valibot'
55
import * as site from '../shared/types/lexicons/site'
66
import { BlogPostSchema } from '../shared/schemas/blog'
77
import { NPMX_SITE } from '../shared/utils/constants'
8+
import { parseBasicFrontmatter } from '../shared/utils/parse-basic-frontmatter'
9+
import { TID } from '@atproto/common'
10+
import { Client } from '@atproto/lex'
811

912
const syncedDocuments = new Map<string, string>()
13+
const CLOCK_ID_THREE = 3
14+
const DATE_TO_MICROSECONDS = 1000
1015

16+
// TODO: Currently logging quite a lot, can remove some later if we want
1117
export default defineNuxtModule({
1218
meta: { name: 'standard-site-sync' },
1319
async setup() {
1420
const nuxt = useNuxt()
1521
const { resolve } = createResolver(import.meta.url)
1622
const contentDir = resolve('../app/pages/blog')
1723

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)
32+
1833
if (nuxt.options._prepare) return
1934

2035
nuxt.hook('build:before', async () => {
21-
const glob = await import('fast-glob').then(m => m.default)
22-
const files = await glob(`${contentDir}/**/*.md`)
23-
24-
for (const file of files) {
25-
await syncFile(file, NPMX_SITE)
36+
const { globby } = await import('globby')
37+
const files: string[] = await globby(`${contentDir}/**/*.md`)
38+
39+
// INFO: Arbitrarily chosen concurrency limit, can be changed if needed
40+
const concurrencyLimit = 5
41+
for (let i = 0; i < files.length; i += concurrencyLimit) {
42+
const batch = files.slice(i, i + concurrencyLimit)
43+
// Process files in parallel
44+
await Promise.all(
45+
batch.map(file =>
46+
syncFile(file, NPMX_SITE, client).catch(error =>
47+
console.error(`[standard-site-sync] Error in ${file}:` + error),
48+
),
49+
),
50+
)
2651
}
2752
})
2853

29-
nuxt.hook('builder:watch', async (_event, path) => {
30-
if (path.endsWith('.md')) {
31-
await syncFile(resolve(nuxt.options.rootDir, path), NPMX_SITE)
54+
nuxt.hook('builder:watch', async (event, path) => {
55+
if (!path.endsWith('.md')) return
56+
57+
// Ignore deleted files
58+
if (event === 'unlink') {
59+
console.log(`[standard-site-sync] File deleted: ${path}`)
60+
return
3261
}
62+
63+
// 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+
)
3367
})
3468
},
3569
})
3670

37-
// TODO: Placeholder algo, can likely be simplified
38-
function parseBasicFrontmatter(fileContent: string): Record<string, any> {
39-
const match = fileContent.match(/^---\r?\n([\s\S]+?)\r?\n---/)
40-
if (!match) return {}
41-
42-
const obj: Record<string, any> = {}
43-
const lines = match[1]?.split('\n')
44-
45-
if (!lines) return {}
46-
47-
for (const line of lines) {
48-
const [key, ...valParts] = line.split(':')
49-
if (key && valParts.length) {
50-
let value = valParts.join(':').trim()
51-
52-
// Remove surrounding quotes
53-
value = value.replace(/^["']|["']$/g, '')
54-
55-
// Handle Booleans
56-
if (value === 'true') {
57-
obj[key.trim()] = true
58-
continue
59-
}
60-
if (value === 'false') {
61-
obj[key.trim()] = false
62-
continue
63-
}
71+
/*
72+
* INFO: Loads record to atproto and ensures uniqueness by checking the date the article is published
73+
* publishedAt is an id that does not change
74+
* Atomicity is enforced with upsert using publishedAt so we always update existing records instead of creating new ones
75+
* Clock id(3) provides a deterministic ID
76+
* WARN: DOES NOT CATCH ERRORS, THIS MUST BE HANDLED
77+
*/
78+
const syncFile = async (filePath: string, siteUrl: string, client: Client) => {
79+
const fileContent = readFileSync(filePath, 'utf-8')
80+
const frontmatter = parseBasicFrontmatter(fileContent)
81+
82+
// Schema expects 'path' & frontmatter provides 'slug'
83+
const normalizedFrontmatter = {
84+
...frontmatter,
85+
path: typeof frontmatter.slug === 'string' ? `/blog/${frontmatter.slug}` : frontmatter.path,
86+
}
6487

65-
// Handle basic array [tag1, tag2]
66-
if (value.startsWith('[') && value.endsWith(']')) {
67-
obj[key.trim()] = value
68-
.slice(1, -1)
69-
.split(',')
70-
.map(s => s.trim().replace(/^["']|["']$/g, ''))
71-
} else {
72-
obj[key.trim()] = value
73-
}
74-
}
88+
const result = safeParse(BlogPostSchema, normalizedFrontmatter)
89+
if (!result.success) {
90+
console.warn(`[standard-site-sync] Validation failed for ${filePath}`, result.issues)
91+
return
7592
}
76-
return obj
77-
}
7893

79-
const syncFile = async (filePath: string, siteUrl: string) => {
80-
try {
81-
const fileContent = readFileSync(filePath, 'utf-8')
82-
const frontmatter = parseBasicFrontmatter(fileContent)
94+
const data = result.output
8395

84-
// Schema expects 'path' & frontmatter provides 'slug'
85-
if (frontmatter.slug) {
86-
frontmatter.path = `/blog/${frontmatter.slug}`
96+
// filter drafts
97+
if (data.draft) {
98+
if (process.env.DEBUG === 'true') {
99+
console.debug(`[standard-site-sync] Skipping draft: ${data.path}`)
87100
}
101+
return
102+
}
88103

89-
const result = safeParse(BlogPostSchema, frontmatter)
90-
if (!result.success) {
91-
console.warn(`[standard-site-sync] Validation failed for ${filePath}`, result.issues)
92-
return
93-
}
104+
// Keys are sorted to provide a more stable hash
105+
const hash = createHash('sha256')
106+
.update(JSON.stringify(data, Object.keys(data).sort()))
107+
.digest('hex')
94108

95-
const data = result.output
109+
if (syncedDocuments.get(data.path) === hash) {
110+
return
111+
}
96112

97-
// filter drafts
98-
if (data.draft) {
99-
if (process.env.DEBUG === 'true') {
100-
console.debug(`[standard-site-sync] Skipping draft: ${data.path}`)
101-
}
102-
return
103-
}
113+
const document = site.standard.document.$build({
114+
site: siteUrl as `${string}:${string}`,
115+
path: data.path,
116+
title: data.title,
117+
description: data.description ?? data.excerpt,
118+
tags: data.tags,
119+
// This can be extended to update the site.standard.document .updatedAt if it is changed and use the posts date here
120+
publishedAt: new Date(data.date).toISOString(),
121+
})
104122

105-
const hash = createHash('sha1').update(JSON.stringify(data)).digest('hex')
123+
const dateInMicroSeconds = new Date(result.output.date).getTime() * DATE_TO_MICROSECONDS
106124

107-
if (syncedDocuments.get(data.path) === hash) {
108-
return
109-
}
125+
// Clock id(3) needs to be the same everytime to get the same TID from a timestamp
126+
const tid = TID.fromTime(dateInMicroSeconds, CLOCK_ID_THREE)
110127

111-
// TODO: Review later
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-
publishedAt: new Date(data.date).toISOString(),
119-
})
128+
// client.put is async and needs to be awaited
129+
await client.put(site.standard.document, document, {
130+
rkey: tid.str,
131+
})
120132

121-
console.log('[standard-site-sync] Pushing:', JSON.stringify(document, null, 2))
122-
// TODO: Real PDS push
133+
// TODO: Replace with real PDS push
134+
console.log('[standard-site-sync] Pushing:', JSON.stringify(document, null, 2))
123135

124-
syncedDocuments.set(data.path, hash)
125-
} catch (error) {
126-
console.error(`[standard-site-sync] Error in ${filePath}:`, error)
127-
}
136+
syncedDocuments.set(data.path, hash)
128137
}

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,9 @@
3636
"start:playwright:webserver": "NODE_ENV=test pnpm build && pnpm preview --port 5678"
3737
},
3838
"dependencies": {
39-
"@atproto/api": "^0.18.17",
4039
"@atcute/bluesky-richtext-segmenter": "3.0.0",
40+
"@atproto/api": "^0.18.17",
41+
"@atproto/common": "0.5.10",
4142
"@atproto/lex": "0.0.13",
4243
"@atproto/oauth-client-node": "^0.3.15",
4344
"@deno/doc": "jsr:^0.189.1",
@@ -71,6 +72,7 @@
7172
"@vueuse/nuxt": "14.2.0",
7273
"@vueuse/router": "^14.2.0",
7374
"focus-trap": "^7.8.0",
75+
"globby": "16.1.0",
7476
"marked": "17.0.1",
7577
"module-replacements": "2.11.0",
7678
"nuxt": "4.3.0",
@@ -83,6 +85,7 @@
8385
"simple-git": "3.30.0",
8486
"spdx-license-list": "6.11.0",
8587
"std-env": "3.10.0",
88+
"tinyglobby": "0.2.15",
8689
"ufo": "1.6.3",
8790
"unocss": "66.6.0",
8891
"unplugin-vue-router": "0.19.2",

pnpm-lock.yaml

Lines changed: 23 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
export function parseBasicFrontmatter(fileContent: string): Record<string, unknown> {
2+
const match = fileContent.match(/^---\s*\n([\s\S]*?)\n---\s*(?:\n|$)/)
3+
if (!match?.[1]) return {}
4+
5+
return match[1].split('\n').reduce(
6+
(acc, line) => {
7+
const idx = line.indexOf(':')
8+
if (idx === -1) return acc
9+
10+
const key = line.slice(0, idx).trim()
11+
12+
// Remove surrounding quotes
13+
let value = line
14+
.slice(idx + 1)
15+
.trim()
16+
.replace(/^["']|["']$/g, '')
17+
18+
// Type coercion (handles 123, 45.6, boolean, arrays)
19+
if (value === 'true') acc[key] = true
20+
else if (value === 'false') acc[key] = false
21+
else if (/^-?\d+$/.test(value)) acc[key] = parseInt(value, 10)
22+
else if (/^-?\d+\.\d+$/.test(value)) acc[key] = parseFloat(value)
23+
else if (value.startsWith('[') && value.endsWith(']')) {
24+
acc[key] = value
25+
.slice(1, -1)
26+
.split(',')
27+
.map(s => s.trim().replace(/^["']|["']$/g, ''))
28+
} else acc[key] = value
29+
30+
return acc
31+
},
32+
{} as Record<string, unknown>,
33+
)
34+
}

0 commit comments

Comments
 (0)