diff --git a/modules/markdown.ts b/modules/markdown.ts index 677e8b5d..342d140a 100644 --- a/modules/markdown.ts +++ b/modules/markdown.ts @@ -76,7 +76,12 @@ export default defineNuxtModule({ blogPosts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) - nuxt.hook('modules:done', () => nuxt.callHook('markdown:blog-entries', blogPosts)) + nuxt.hook('modules:done', async () => { + await Promise.all([ + nuxt.callHook('markdown:blog-entries', blogPosts), + nuxt.callHook('markdown:sync-articles', syncArticles), + ]) + }) addTemplate({ filename: 'markdown/blog-entries.mjs', @@ -190,9 +195,6 @@ export async function getBody () { } })) - nuxt.options.nitro.virtual['#sync-articles.json'] = () => - `export const syncArticles = ${JSON.stringify(syncArticles)}` - const rawBlogData = blogPosts.map(post => ({ slug: post.slug, title: post.title, @@ -215,7 +217,7 @@ export async function getBody () { nuxt.options.nitro.externals ||= {} nuxt.options.nitro.externals.inline ||= [] - nuxt.options.nitro.externals.inline.push('#metadata.json', '#sync-articles.json', '#md-raw-blog.json', '#md-raw-pages.json') + nuxt.options.nitro.externals.inline.push('#metadata.json', '#md-raw-blog.json', '#md-raw-pages.json') addTypeTemplate({ filename: 'types/markdown.d.ts', @@ -279,5 +281,15 @@ declare module '#md-pages.json' { declare module '@nuxt/schema' { interface NuxtHooks { 'markdown:blog-entries': (entries: Array<{ path: string, slug: string, date: string, bluesky?: string }>) => void + 'markdown:sync-articles': (articles: Array<{ + type: 'blog' + title: string + date: string + description: string + body_markdown: string + text_content: string + canonical_url: string + tags?: string[] + }>) => void } } diff --git a/modules/sync/index.ts b/modules/sync/index.ts index 149222d1..9caf98f4 100644 --- a/modules/sync/index.ts +++ b/modules/sync/index.ts @@ -1,45 +1,82 @@ -import { defu } from 'defu' -import { defineNuxtModule, createResolver, addServerHandler } from 'nuxt/kit' +import { createClient } from '@sanity/client' +import { defineNuxtModule, useNuxt } from 'nuxt/kit' + +import { syncAll } from './providers' +import type { SyncItem } from './providers' + +const TALK_TYPE_MAP: Record = { + podcast: 'video', + talk: 'talk', + meetup: 'talk', + conference: 'talk', + stream: 'video', + workshop: 'talk', +} export default defineNuxtModule({ meta: { name: 'sync', - configKey: 'sync', }, - setup (_options, nuxt) { - if (nuxt.options.test) return - const resolver = createResolver(import.meta.url) + setup () { + const nuxt = useNuxt() + if (nuxt.options.test || nuxt.options._prepare) return - addServerHandler({ - route: '/_tasks/sync', - handler: resolver.resolve('./runtime/server/routes/_tasks/sync'), - }) + const dryRun = process.argv.includes('--dry-run') + const isProductionDeploy = process.env.VERCEL_ENV === 'production' + + if (!dryRun && !isProductionDeploy) return + + nuxt.hook('markdown:sync-articles', async articles => { + const talks = await fetchTalks() + const items: SyncItem[] = [...articles, ...talks] - if (nuxt.options.dev) { - // register task for local use - nuxt.options.nitro.tasks = defu(nuxt.options.nitro.tasks, { - sync: { - handler: resolver.resolve('./runtime/server/tasks/sync'), - }, - }) - return - } - - nuxt.options.nitro.prerender = nuxt.options.nitro.prerender || {} - nuxt.options.nitro.prerender.routes = nuxt.options.nitro.prerender.routes || [] - if (!nuxt.options.nitro.prerender.routes.includes('/_tasks/sync')) { - nuxt.options.nitro.prerender.routes.push('/_tasks/sync') - } - - // Add to prerender routes - nuxt.hook('nitro:init', nitro => { - // prerender configuration only - const nitroConfig = nitro.options._config - nitroConfig.tasks = defu(nitroConfig.tasks, { - sync: { - handler: resolver.resolve('./runtime/server/tasks/sync'), - }, - }) + console.info(`[sync] ${dryRun ? 'Dry run' : 'Syncing'}: ${items.length} items (${articles.length} articles, ${talks.length} talks)`) + await syncAll(items, { dryRun }) }) }, }) + +async function fetchTalks (): Promise { + const sanityToken = process.env.NUXT_SANITY_TOKEN + if (!sanityToken) { + console.warn('[sync] No NUXT_SANITY_TOKEN — skipping talks') + return [] + } + + const client = createClient({ + projectId: '9bj3w2vo', + dataset: 'production', + apiVersion: '2025-01-01', + useCdn: false, + token: sanityToken, + }) + + try { + const talks = await client.fetch>(`*[_type == "talk" && date < now() && defined(title) && title != ""] { + title, description, date, type, tags, link, video + } | order(date desc)`) + + return talks + .filter(t => t.link || t.video) + .map(t => ({ + type: TALK_TYPE_MAP[t.type] || 'talk' as SyncItem['type'], + title: t.title, + date: t.date, + body_markdown: t.description || '', + canonical_url: (t.link || t.video)!, + tags: Array.isArray(t.tags) ? t.tags : [], + })) + } + catch (error) { + console.warn('[sync] Failed to fetch talks:', error instanceof Error ? error.message : error) + return [] + } +} diff --git a/modules/sync/runtime/server/providers/dev-to.ts b/modules/sync/providers/dev-to.ts similarity index 58% rename from modules/sync/runtime/server/providers/dev-to.ts rename to modules/sync/providers/dev-to.ts index e59ca630..69768d1d 100644 --- a/modules/sync/runtime/server/providers/dev-to.ts +++ b/modules/sync/providers/dev-to.ts @@ -1,10 +1,23 @@ -import type { SyncItem, SyncProvider } from './index' +import { $fetch } from 'ofetch' + +import type { SyncItem, SyncOptions, SyncProvider } from './index' export class DevToProvider implements SyncProvider { name = 'dev-to' - async sync (items: SyncItem[]): Promise<{ status: string, count: number, total: number }> { - const token = useRuntimeConfig().devToToken + async sync (items: SyncItem[], { dryRun }: SyncOptions): Promise { + const blogItems = items.filter(i => i.type === 'blog') + if (!blogItems.length) return + + if (dryRun) { + console.info(`[sync:dev-to] Would sync ${blogItems.length} blog posts`) + for (const item of blogItems) { + console.info(`[sync:dev-to] ${item.title}`) + } + return + } + + const token = process.env.NUXT_DEV_TO_TOKEN if (!token) throw new Error('No NUXT_DEV_TO_TOKEN provided.') const $devto = $fetch.create({ @@ -12,14 +25,16 @@ export class DevToProvider implements SyncProvider { headers: { 'api-key': token }, }) - // Fetch published articles - const publishedArticles = await $devto>('articles/me') + const publishedArticles = await $devto>('articles/me') let updated = 0 - for (const item of items) { - if (item.type !== 'blog') { - continue - } + for (const item of blogItems) { const tags = (item.tags || []) .map(t => t.replace(/[^a-z0-9-]/g, '')) .filter(Boolean) @@ -35,8 +50,8 @@ export class DevToProvider implements SyncProvider { ) { continue } - console.info(`Updating article: ${item.title}`) - await new Promise(resolve => setTimeout(resolve, 1000)) + console.info(`[sync:dev-to] Updating: ${item.title}`) + await delay(1000) await $devto(`articles/${article.id}`, { method: 'PUT', body: { @@ -53,8 +68,8 @@ export class DevToProvider implements SyncProvider { continue } - console.info(`Publishing new article: ${item.title}`) - await new Promise(resolve => setTimeout(resolve, 1000)) + console.info(`[sync:dev-to] Publishing: ${item.title}`) + await delay(1000) await $devto('articles', { method: 'POST', body: { @@ -69,6 +84,10 @@ export class DevToProvider implements SyncProvider { }) updated++ } - return { status: 'done', count: updated, total: items.length } + console.info(`[sync:dev-to] Done: ${updated} updated`) } } + +function delay (ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} diff --git a/modules/sync/runtime/server/providers/gde-advocu.ts b/modules/sync/providers/gde-advocu.ts similarity index 62% rename from modules/sync/runtime/server/providers/gde-advocu.ts rename to modules/sync/providers/gde-advocu.ts index e0e8731d..190ce2f6 100644 --- a/modules/sync/runtime/server/providers/gde-advocu.ts +++ b/modules/sync/providers/gde-advocu.ts @@ -1,18 +1,51 @@ -import type { SyncItem, SyncProvider } from './index' +import { createClient } from '@sanity/client' +import { $fetch } from 'ofetch' + +import type { SyncItem, SyncOptions, SyncProvider } from './index' // Advocu allowed tags enum (from API docs) const ADVOCU_TAGS = [ 'AI', 'AI - AI Studio', 'AI - Agent Development Kit (ADK)', 'AI - Agents', 'AI - Colab', 'AI - Gemini', 'AI - Gemma', 'AI - Generative AI', 'AI - JAX', 'AI - Kaggle', 'AI - Keras', 'AI - LLM', 'AI - LiteRT', 'AI - ML Engineering (MLOps)', 'AI - MediaPipe', 'AI - On-Device AI', 'AI - Responsible AI', 'AI - TPU', 'AI - TensorFlow', 'AI - Vertex AI', 'AI Math Clubs', 'AI Paper Reading Clubs', 'AR/VR', 'Android', 'Android - ARCore', 'Android - Android Auto', 'Android - Android Dev Tools', 'Android - Android Studio', 'Android - Android TV', 'Android - App Architecture & Arch Components', 'Android - App Indexing', 'Android - App Performance', 'Android - App Quality', 'Android - Camera', 'Android - Composer', 'Android - Form Factors', 'Android - Gemini Nano', 'Android - Jetpack', 'Android - Jetpack Compose', 'Android - Kotlin', 'Android - Material Design', 'Android - Media', 'Android - Modern Android Development', 'Android - Modern UI', 'Android - NDK', 'Android - Passkeys', 'Android - Performance', 'Android - Vulkan', 'Android - Wear OS', 'Android - Widgets', 'Android - XR', 'Angular', 'Build with AI', 'Cloud - AI Tools', 'Cloud - API Gateways', 'Cloud - App Development', 'Cloud - Compute, Networking, Storage', 'Cloud - Data', 'Cloud - Operations & Management', 'Cloud - Security', 'Cloud - Serverless & Containers', 'Cloud Study Jam', 'Dart', 'DevFest', 'Diversity & Inclusion', 'Earth Engine', 'Firebase', 'Firebase - A/B Testing', 'Firebase - AI Monitoring', 'Firebase - Analytics', 'Firebase - App Hosting', 'Firebase - Authentication', 'Firebase - Cloud Messaging', 'Firebase - Data Connect', 'Firebase - Firebase Studio', 'Firebase - Firestore', 'Firebase - Genkit', 'Firebase - Performance', 'Firebase - Realtime Database', 'Firebase - Remote Config', 'Firebase - Test Lab', 'Firebase - Vertex AI in Firebase', 'Flutter', 'Golang', 'Google Cloud', 'Google I/O Extended', 'Google Maps Platform', 'Google Workspace', 'Identity', 'Identity - Google OAuth', 'Identity - Sign in with Google', 'International Women\'s Day', 'ML Study Jams', 'Open Source', 'Payments', 'Payments - Google Pay', 'Payments - Google Wallet', 'Road to Google Developers Certification', 'UX / UI Design', 'Web', 'Web - AI for Web Developers', 'Web - Browser Extensions', 'Web - CSS & UI', 'Web - DevTools & Browser Automation', 'Web - Fugu/PWA APIs', 'Web - Identity', 'Web - Performance', 'Workspace - Add Ons', 'Workspace - AppSheet', 'Workspace - Google Apps Script', 'Workspace - Google Workspace (REST) APIs', ] +const ELIGIBLE_TYPES = new Set(['blog', 'article', 'talk']) + export class GdeAdvocuProvider implements SyncProvider { name = 'gde-advocu' - async sync (items: SyncItem[]): Promise<{ status: string, count: number, total: number }> { - const token = useRuntimeConfig().advocuToken || useRuntimeConfig().advocu_token || useRuntimeConfig().ADVOCU_TOKEN + async sync (items: SyncItem[], { dryRun }: SyncOptions): Promise { + const sixMonthsAgo = new Date() + sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6) + + const eligible = items.filter(i => + ELIGIBLE_TYPES.has(i.type) + && (!i.date || new Date(i.date) >= sixMonthsAgo), + ) + if (!eligible.length) return + + if (dryRun) { + console.info(`[sync:gde-advocu] Would sync up to ${eligible.length} items (last 6 months, pending dedupe check)`) + for (const item of eligible) { + const safeTags = validateTags(item.tags) + const tagNote = item.tags?.length && !safeTags.length ? ' (no valid tags — would skip)' : '' + console.info(`[sync:gde-advocu] ${item.type}: ${item.title}${tagNote}`) + } + return + } + + const token = process.env.NUXT_ADVOCU_TOKEN if (!token) throw new Error('No NUXT_ADVOCU_TOKEN provided.') - const { client: sanityClient } = useSanity() + const sanityToken = process.env.NUXT_SANITY_TOKEN + if (!sanityToken) throw new Error('No NUXT_SANITY_TOKEN provided for Advocu dedupe tracking.') + + const sanityClient = createClient({ + projectId: '9bj3w2vo', + dataset: 'production', + apiVersion: '2025-01-01', + useCdn: false, + token: sanityToken, + }) const $advocu = $fetch.create({ baseURL: 'https://api.advocu.com/personal-api/v1/gde', @@ -22,78 +55,45 @@ export class GdeAdvocuProvider implements SyncProvider { }, }) - const now = new Date() - const sixMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 6, now.getDate()) let synced = 0 - - for (const item of items) { - if (item.type !== 'blog' && item.type !== 'article' && item.type !== 'talk') { - continue - } - - if (item.date) { - const itemDate = new Date(item.date) - if (itemDate < sixMonthsAgo) { - continue - } - } - - // Check if already synced in Sanity + for (const item of eligible) { const sanityId = `advocu-sync-${item.type}-${base64url(item.canonical_url)}` const existing = await sanityClient.fetch('*[_id == $id][0]', { id: sanityId }) if (existing) continue const safeTags = validateTags(item.tags) if (item.tags && safeTags.length === 0) { - console.warn(`No valid Advocu tags for item: ${item.title} (${item.canonical_url}). Provided: ${JSON.stringify(item.tags)}`) + console.warn(`[sync:gde-advocu] No valid tags for: ${item.title}. Provided: ${JSON.stringify(item.tags)}`) continue } + const sharedBody = { + title: item.title.slice(0, 200), + description: item.description?.slice(0, 2000) || '', + tags: safeTags, + activityDate: item.date?.slice(0, 10), + activityUrl: item.canonical_url.slice(0, 500), + additionalInfo: '', + private: false, + } + try { if (item.type === 'blog' || item.type === 'article') { await $advocu('/activity-drafts/content-creation', { method: 'POST', - body: { - contentType: 'Articles', - title: item.title.slice(0, 200), - description: item.description?.slice(0, 2000) || '', - tags: safeTags, - activityDate: item.date?.slice(0, 10), - activityUrl: item.canonical_url.slice(0, 500), - additionalInfo: '', - private: false, - }, + body: { contentType: 'Articles', ...sharedBody }, }) } else if (item.type === 'talk') { await $advocu('/activity-drafts/public-speaking', { method: 'POST', - body: { - title: item.title.slice(0, 200), - description: item.description?.slice(0, 2000) || '', - tags: safeTags, - activityDate: item.date?.slice(0, 10), - activityUrl: item.canonical_url.slice(0, 500), - additionalInfo: '', - private: false, - }, + body: sharedBody, }) } } - catch (error: any) { - // Log payload and error for debugging - console.error('Advocu sync error:', { - type: item.type, - url: item.canonical_url, - payload: { - title: item.title.slice(0, 200), - description: item.description?.slice(0, 2000) || '', - tags: safeTags, - activityDate: item.date?.slice(0, 10), - activityUrl: item.canonical_url.slice(0, 500), - }, - error: error?.response || error?.data || error?.message || error, - }) + catch (error: unknown) { + const message = error instanceof Error ? error.message : error + console.error(`[sync:gde-advocu] Failed for ${item.canonical_url}:`, message) continue } @@ -104,20 +104,19 @@ export class GdeAdvocuProvider implements SyncProvider { canonical_url: item.canonical_url, syncedAt: new Date().toISOString(), }) - // Extend for other types as needed } - return { status: 'done', count: synced, total: items.length } + + console.info(`[sync:gde-advocu] Done: ${synced} synced`) } } -function base64url (str: string) { +function base64url (str: string): string { return Buffer.from(str).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } function validateTags (tags: string[] = []): string[] { - // Only allow tags that match the enum exactly (case-insensitive match) - return (tags || []) + return tags .map(t => ADVOCU_TAGS.find(e => t.toLowerCase() === e.toLowerCase())) - .filter(Boolean) - .slice(0, 4) as string[] + .filter((t): t is string => !!t) + .slice(0, 4) } diff --git a/modules/sync/providers/github-stars.ts b/modules/sync/providers/github-stars.ts new file mode 100644 index 00000000..3dc0bd24 --- /dev/null +++ b/modules/sync/providers/github-stars.ts @@ -0,0 +1,113 @@ +import { $fetch } from 'ofetch' + +import type { SyncItem, SyncOptions, SyncProvider } from './index' + +const CONTRIBUTION_TYPE_MAP: Record = { + blog: 'BLOGPOST', + talk: 'SPEAKING', + article: 'ARTICLE_PUBLICATION', + event: 'EVENT_ORGANIZATION', + hackathon: 'HACKATHON', + oss: 'OPEN_SOURCE_PROJECT', + video: 'VIDEO_PODCAST', + forum: 'FORUM', + other: 'OTHER', +} + +export class GithubStarsProvider implements SyncProvider { + name = 'github-stars' + + async sync (items: SyncItem[], { dryRun }: SyncOptions): Promise { + const eligible = items.filter(i => i.canonical_url && i.date) + if (!eligible.length) return + + if (dryRun) { + console.info(`[sync:github-stars] Would sync ${eligible.length} items`) + for (const item of eligible) { + console.info(`[sync:github-stars] ${CONTRIBUTION_TYPE_MAP[item.type] || 'OTHER'}: ${item.title}`) + } + return + } + + const token = process.env.NUXT_GITHUB_STARS_TOKEN + if (!token) throw new Error('No NUXT_GITHUB_STARS_TOKEN provided.') + + const $stars = $fetch.create({ + baseURL: 'https://api-stars.github.com/', + headers: { 'content-type': 'application/json', 'Authorization': `Bearer ${token}` }, + }) + + const existing = await $stars<{ + data: { + contributions: Array<{ + id: string + url: string + title: string + type: string + date: string + description: string + }> + } + }>('', { + method: 'POST', + body: { query: `query { contributions { id url title type date description } }` }, + }) + + const existingByUrl = new Map( + (existing.data?.contributions || []).map(c => [c.url, c]), + ) + + const toCreate: Array<{ type: string, title: string, description: string, url: string, date: string }> = [] + const toUpdate: Array<{ id: string, data: { type: string, title: string, description: string, url: string, date: string } }> = [] + + for (const item of eligible) { + const input = { + type: CONTRIBUTION_TYPE_MAP[item.type] || 'OTHER', + title: item.title, + description: item.description || '', + url: item.canonical_url, + date: item.date!, + } + + const match = existingByUrl.get(item.canonical_url) + if (!match) { + toCreate.push(input) + } + else if ( + match.title !== input.title + || match.type !== input.type + || match.date !== input.date + || (match.description || '') !== input.description + ) { + toUpdate.push({ id: match.id, data: input }) + } + } + + let created = 0 + let updated = 0 + + if (toCreate.length) { + const response = await $stars<{ data: { createContributions: Array } }>('', { + method: 'POST', + body: { + query: `mutation createContributions($data: [ContributionInput!]!) { createContributions(data: $data) { type title url description date } }`, + variables: { data: toCreate }, + }, + }) + created = response.data?.createContributions?.length || 0 + } + + for (const { id, data } of toUpdate) { + await $stars('', { + method: 'POST', + body: { + query: `mutation updateContribution($id: String!, $data: ContributionInput!) { updateContribution(id: $id, data: $data) { id } }`, + variables: { id, data }, + }, + }) + updated++ + } + + console.info(`[sync:github-stars] Done: ${created} created, ${updated} updated`) + } +} diff --git a/modules/sync/providers/index.ts b/modules/sync/providers/index.ts new file mode 100644 index 00000000..87a1bb6e --- /dev/null +++ b/modules/sync/providers/index.ts @@ -0,0 +1,44 @@ +import { GithubStarsProvider } from './github-stars' +import { DevToProvider } from './dev-to' +import { GdeAdvocuProvider } from './gde-advocu' +import { StandardSiteProvider } from './standard-site' + +export interface SyncItem { + title: string + description?: string + body_markdown?: string + text_content?: string + canonical_url: string + type: 'blog' | 'talk' | 'article' | 'event' | 'hackathon' | 'oss' | 'video' | 'forum' | 'other' + date?: string + tags?: string[] +} + +export interface SyncOptions { + dryRun: boolean +} + +export interface SyncProvider { + name: string + sync(items: SyncItem[], options: SyncOptions): Promise +} + +function createProviders (): SyncProvider[] { + return [ + new GithubStarsProvider(), + new DevToProvider(), + new GdeAdvocuProvider(), + new StandardSiteProvider(), + ] +} + +export async function syncAll (items: SyncItem[], options: SyncOptions): Promise { + await Promise.all(createProviders().map(async provider => { + try { + await provider.sync(items, options) + } + catch (error) { + console.warn(`[sync:${provider.name}] Failed:`, error instanceof Error ? error.message : error) + } + })) +} diff --git a/modules/sync/runtime/server/providers/standard-site.ts b/modules/sync/providers/standard-site.ts similarity index 56% rename from modules/sync/runtime/server/providers/standard-site.ts rename to modules/sync/providers/standard-site.ts index 61ae7fa5..3bfe066d 100644 --- a/modules/sync/runtime/server/providers/standard-site.ts +++ b/modules/sync/providers/standard-site.ts @@ -1,15 +1,26 @@ import { AtpAgent } from '@atproto/api' -import type { SyncItem, SyncProvider } from './index' +import type { SyncItem, SyncOptions, SyncProvider } from './index' export class StandardSiteProvider implements SyncProvider { name = 'standard-site' - async sync (items: SyncItem[]): Promise<{ status: string, count: number, total: number }> { - const config = useRuntimeConfig() - const pdsUrl = config.standardSite?.pdsUrl - const identifier = config.standardSite?.identifier - const password = config.standardSite?.password + async sync (items: SyncItem[], { dryRun }: SyncOptions): Promise { + const blogItems = items.filter(i => i.type === 'blog') + if (!blogItems.length) return + + if (dryRun) { + console.info(`[sync:standard-site] Would sync ${blogItems.length} blog posts as AT Protocol documents`) + for (const item of blogItems) { + const slug = item.canonical_url.replace('https://roe.dev/blog/', '').replace(/\/$/, '') + console.info(`[sync:standard-site] /blog/${slug}: ${item.title}`) + } + return + } + + const pdsUrl = process.env.NUXT_STANDARD_SITE_PDS_URL + const identifier = process.env.NUXT_STANDARD_SITE_IDENTIFIER + const password = process.env.NUXT_STANDARD_SITE_PASSWORD if (!pdsUrl || !identifier || !password) { throw new Error('Missing NUXT_STANDARD_SITE_PDS_URL, NUXT_STANDARD_SITE_IDENTIFIER, or NUXT_STANDARD_SITE_PASSWORD') } @@ -19,7 +30,6 @@ export class StandardSiteProvider implements SyncProvider { const did = agent.session!.did - // Upsert the publication record await agent.com.atproto.repo.putRecord({ repo: did, collection: 'site.standard.publication', @@ -29,19 +39,12 @@ export class StandardSiteProvider implements SyncProvider { url: 'https://roe.dev', name: 'Daniel Roe', description: 'The personal website of Daniel Roe', - preferences: { - showInDiscover: true, - }, + preferences: { showInDiscover: true }, }, }) let updated = 0 - for (const item of items) { - if (item.type !== 'blog') { - continue - } - - // Extract slug from canonical_url: https://roe.dev/blog// + for (const item of blogItems) { const slug = item.canonical_url.replace('https://roe.dev/blog/', '').replace(/\/$/, '') if (!slug) continue @@ -53,17 +56,9 @@ export class StandardSiteProvider implements SyncProvider { publishedAt: item.date ? new Date(item.date).toISOString() : new Date().toISOString(), } - if (item.description) { - record.description = item.description - } - - if (item.tags?.length) { - record.tags = item.tags - } - - if (item.text_content) { - record.textContent = item.text_content - } + if (item.description) record.description = item.description + if (item.tags?.length) record.tags = item.tags + if (item.text_content) record.textContent = item.text_content await agent.com.atproto.repo.putRecord({ repo: did, @@ -75,6 +70,6 @@ export class StandardSiteProvider implements SyncProvider { updated++ } - return { status: 'done', count: updated, total: items.length } + console.info(`[sync:standard-site] Done: ${updated} updated`) } } diff --git a/modules/sync/runtime/server/providers/github-stars.ts b/modules/sync/runtime/server/providers/github-stars.ts deleted file mode 100644 index 5f3f68fd..00000000 --- a/modules/sync/runtime/server/providers/github-stars.ts +++ /dev/null @@ -1,122 +0,0 @@ -import type { SyncItem, SyncProvider } from './index' - -export class GithubStarsProvider implements SyncProvider { - name = 'github-stars' - - async sync (items: SyncItem[]): Promise<{ status: string, count: number, total: number }> { - const token = useRuntimeConfig().github.starsToken - if (!token) throw new Error('No NUXT_GITHUB_STARS_TOKEN provided.') - - const $stars = $fetch.create({ - baseURL: 'https://api-stars.github.com/', - headers: { 'content-type': 'application/json', 'Authorization': `Bearer ${token}` }, - }) - - // Fetch existing contributions - const existing = await $stars<{ data: { contributions: Array<{ id: string, url: string, title: string, type: string, date: string, description: string }> } }>( - '', - { - method: 'POST', - body: { - query: `query { contributions { id url title type date description } }`, - }, - }, - ) - const existingByUrl = new Map( - (existing.data?.contributions || []).map(c => [c.url, c]), - ) - - // Prepare new and updated items - const toCreate = [] - const toUpdate = [] - for (const item of items) { - if (!item.canonical_url || !item.date) { - continue - } - const mappedType = mapSyncItemTypeToContributionType(item.type) - const existing = existingByUrl.get(item.canonical_url) - const input = { - type: mappedType, - title: item.title, - description: item.description || '', - url: item.canonical_url, - date: item.date, - } - if (!existing) { - toCreate.push(input) - } - else { - // Check if any field has changed - if ( - existing.title !== input.title - || existing.type !== input.type - || existing.date !== input.date - || (existing.description || '') !== input.description - ) { - toUpdate.push({ id: existing.id, data: input }) - } - } - } - - let created = 0 - let updated = 0 - if (toCreate.length) { - const response = await $stars<{ data: { createContributions: Array } }>( - '', - { - method: 'POST', - body: { - query: `mutation createContributions($data: [ContributionInput!]!) { createContributions(data: $data) { type title url description date } }`, - variables: { data: toCreate }, - }, - }, - ) - created = response.data?.createContributions?.length || 0 - } - if (toUpdate.length) { - for (const { id, data } of toUpdate) { - const res = await $stars<{ data: { updateContribution: any } }>( - '', - { - method: 'POST', - body: { - query: `mutation updateContribution($id: String!, $data: ContributionInput!) { updateContribution(id: $id, data: $data) { id } }`, - variables: { id, data }, - }, - }, - ) - console.log(res, 'updated', data.title) - updated++ - } - } - return { - status: created || updated ? 'ok' : 'noop', - count: created + updated, - total: items.length, - } - } -} - -function mapSyncItemTypeToContributionType (type: SyncItem['type']): string { - switch (type) { - case 'blog': - return 'BLOGPOST' - case 'talk': - return 'SPEAKING' - case 'article': - return 'ARTICLE_PUBLICATION' - case 'event': - return 'EVENT_ORGANIZATION' - case 'hackathon': - return 'HACKATHON' - case 'oss': - return 'OPEN_SOURCE_PROJECT' - case 'video': - return 'VIDEO_PODCAST' - case 'forum': - return 'FORUM' - case 'other': - default: - return 'OTHER' - } -} diff --git a/modules/sync/runtime/server/providers/index.ts b/modules/sync/runtime/server/providers/index.ts deleted file mode 100644 index d0966b82..00000000 --- a/modules/sync/runtime/server/providers/index.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { GithubStarsProvider } from './github-stars' -import { DevToProvider } from './dev-to' -import { GdeAdvocuProvider } from './gde-advocu' -import { StandardSiteProvider } from './standard-site' - -const providers = { - 'github-stars': new GithubStarsProvider(), - 'dev-to': new DevToProvider(), - 'gde-advocu': new GdeAdvocuProvider(), - 'standard-site': new StandardSiteProvider(), -} satisfies Record - -type ProviderName = keyof typeof providers - -export const providerNames = Object.keys(providers) as ProviderName[] - -function getProvider (name: ProviderName): SyncProvider { - const provider = providers[name] - if (!provider) throw new Error(`Unknown sync provider: ${name}`) - return provider -} - -export async function syncWithProvider (providerName: ProviderName, items: SyncItem[]) { - const provider = getProvider(providerName) - return provider.sync(items) -} - -export interface SyncItem { - title: string - description?: string - body_markdown?: string - text_content?: string - canonical_url: string - type: 'blog' | 'talk' | 'article' | 'event' | 'hackathon' | 'oss' | 'video' | 'forum' | 'other' - date?: string - tags?: string[] -} - -export interface SyncProvider { - name: string - sync(items: SyncItem[]): Promise<{ status: string, count: number, total: number }> -} diff --git a/modules/sync/runtime/server/routes/_tasks/sync.ts b/modules/sync/runtime/server/routes/_tasks/sync.ts deleted file mode 100644 index b224995d..00000000 --- a/modules/sync/runtime/server/routes/_tasks/sync.ts +++ /dev/null @@ -1,4 +0,0 @@ -export default defineEventHandler(async () => { - const { result } = await runTask('sync') - return result -}) diff --git a/modules/sync/runtime/server/tasks/sync.ts b/modules/sync/runtime/server/tasks/sync.ts deleted file mode 100644 index ff80c756..00000000 --- a/modules/sync/runtime/server/tasks/sync.ts +++ /dev/null @@ -1,19 +0,0 @@ -// @ts-expect-error virtual file -import { syncArticles } from '#sync-articles.json' -import { providerNames, syncWithProvider } from '../providers/index' -import { getTalks } from '../utils/items' - -export default defineTask({ - meta: { - name: 'sync', - description: 'Sync blogs and talks to GitHub Stars API and dev.to', - }, - async run () { - const items = [ - ...syncArticles, - ...await getTalks(), - ] - const result = await Promise.all(providerNames.map(p => syncWithProvider(p, items))) - return { result } - }, -}) diff --git a/modules/sync/runtime/server/utils/items.ts b/modules/sync/runtime/server/utils/items.ts deleted file mode 100644 index cf1cfb94..00000000 --- a/modules/sync/runtime/server/utils/items.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { SyncItem } from '../providers' - -const talkMap: Record = { - podcast: 'video', - talk: 'talk', - meetup: 'talk', - conference: 'talk', - stream: 'video', - workshop: 'talk', -} - -export async function getTalks () { - const items: SyncItem[] = [] - - try { - const talks = await $fetch('/api/talks') - - for (const talk of talks) { - const link = talk.link || talk.video - // Only sync talks that have a link/video - if (!link) { - continue - } - - items.push({ - type: talkMap[talk.type] || 'talk', - title: talk.title, - date: talk.date, - body_markdown: talk.description || '', - canonical_url: link, - tags: Array.isArray(talk.tags) ? talk.tags : [], - }) - } - } - catch (error) { - console.warn('Failed to fetch talks from CMS for sync, skipping:', error) - } - - return items -} diff --git a/nuxt.config.ts b/nuxt.config.ts index b8f94c2d..b9e41f3c 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -133,14 +133,6 @@ export default defineNuxtConfig({ // oauth flow clientSecret: '', inviteToken: '', - starsToken: '', - }, - devToToken: '', - advocuToken: '', - standardSite: { - pdsUrl: '', - identifier: '', - password: '', }, public: { githubClientId: '', @@ -170,9 +162,6 @@ export default defineNuxtConfig({ compatibilityDate: '2025-06-09', nitro: { - experimental: { - tasks: true, - }, replace: { 'import.meta.test': isTest, },