|
1 | | -import { defineNuxtModule, useNuxt } from 'nuxt/kit' |
| 1 | +import { readFileSync } from 'node:fs' |
| 2 | +import { createHash } from 'node:crypto' |
| 3 | +import { defineNuxtModule, useNuxt, createResolver } from 'nuxt/kit' |
| 4 | +import { safeParse } from 'valibot' |
2 | 5 | import * as site from '../shared/types/lexicons/site' |
| 6 | +import { BlogPostSchema } from '../shared/schemas/blog' |
| 7 | +import { NPMX_SITE } from '../shared/utils/constants' |
3 | 8 |
|
4 | | -const PUBLICATION_SITE = 'https://npmx.dev' |
| 9 | +const syncedDocuments = new Map<string, string>() |
5 | 10 |
|
6 | 11 | export default defineNuxtModule({ |
7 | | - meta: { |
8 | | - name: 'standard-site-sync', |
9 | | - }, |
10 | | - setup() { |
| 12 | + meta: { name: 'standard-site-sync' }, |
| 13 | + async setup() { |
11 | 14 | const nuxt = useNuxt() |
12 | | - if (nuxt.options._prepare) { |
13 | | - return |
14 | | - } |
15 | | - nuxt.hook('content:file:afterParse', ctx => { |
16 | | - const { content } = ctx |
17 | | - |
18 | | - const document = site.standard.document.$build({ |
19 | | - site: PUBLICATION_SITE, |
20 | | - path: content.path as string, |
21 | | - title: content.title as string, |
22 | | - description: (content.excerpt || content.description) as string | undefined, |
23 | | - tags: content.tags as string[] | undefined, |
24 | | - publishedAt: new Date(content.date as string).toISOString(), |
25 | | - }) |
26 | | - |
27 | | - // TODO: Mock PDS push |
28 | | - console.log('[standard-site-sync] Would push:', JSON.stringify(document, null, 2)) |
| 15 | + const { resolve } = createResolver(import.meta.url) |
| 16 | + const contentDir = resolve('../app/pages/blog') |
| 17 | + |
| 18 | + if (nuxt.options._prepare) return |
| 19 | + |
| 20 | + 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) |
| 26 | + } |
| 27 | + }) |
| 28 | + |
| 29 | + nuxt.hook('builder:watch', async (_event, path) => { |
| 30 | + if (path.endsWith('.md')) { |
| 31 | + await syncFile(resolve(nuxt.options.rootDir, path), NPMX_SITE) |
| 32 | + } |
29 | 33 | }) |
30 | 34 | }, |
31 | 35 | }) |
| 36 | + |
| 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 | + } |
| 64 | + |
| 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 | + } |
| 75 | + } |
| 76 | + return obj |
| 77 | +} |
| 78 | + |
| 79 | +const syncFile = async (filePath: string, siteUrl: string) => { |
| 80 | + try { |
| 81 | + const fileContent = readFileSync(filePath, 'utf-8') |
| 82 | + const frontmatter = parseBasicFrontmatter(fileContent) |
| 83 | + |
| 84 | + // Schema expects 'path' & frontmatter provides 'slug' |
| 85 | + if (frontmatter.slug) { |
| 86 | + frontmatter.path = `/blog/${frontmatter.slug}` |
| 87 | + } |
| 88 | + |
| 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 | + } |
| 94 | + |
| 95 | + const data = result.output |
| 96 | + |
| 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 | + } |
| 104 | + |
| 105 | + const hash = createHash('sha1').update(JSON.stringify(data)).digest('hex') |
| 106 | + |
| 107 | + if (syncedDocuments.get(data.path) === hash) { |
| 108 | + return |
| 109 | + } |
| 110 | + |
| 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 | + }) |
| 120 | + |
| 121 | + console.log('[standard-site-sync] Pushing:', JSON.stringify(document, null, 2)) |
| 122 | + // TODO: Real PDS push |
| 123 | + |
| 124 | + syncedDocuments.set(data.path, hash) |
| 125 | + } catch (error) { |
| 126 | + console.error(`[standard-site-sync] Error in ${filePath}:`, error) |
| 127 | + } |
| 128 | +} |
0 commit comments