diff --git a/cms/schemaTypes/advocuSync.ts b/cms/schemaTypes/advocuSync.ts new file mode 100644 index 00000000..1d6c59e8 --- /dev/null +++ b/cms/schemaTypes/advocuSync.ts @@ -0,0 +1,17 @@ +export default { + name: 'advocuSync', + type: 'document', + title: 'Advocu Sync', + fields: [ + { + name: 'canonical_url', + type: 'url', + title: 'Canonical URL', + }, + { + name: 'syncedAt', + type: 'datetime', + title: 'Synced At', + }, + ], +} diff --git a/cms/schemaTypes/index.ts b/cms/schemaTypes/index.ts index a33da679..1e3b4f29 100644 --- a/cms/schemaTypes/index.ts +++ b/cms/schemaTypes/index.ts @@ -4,5 +4,6 @@ import event from './event' import invite from './invite' import location from './location' import uses from './uses' +import advocuSync from './advocuSync' -export const schemaTypes = [ama, entity, event, location, invite, uses] +export const schemaTypes = [ama, entity, event, location, invite, uses, advocuSync] diff --git a/modules/sync/runtime/providers/dev-to.ts b/modules/sync/runtime/providers/dev-to.ts index e7541984..e59ca630 100644 --- a/modules/sync/runtime/providers/dev-to.ts +++ b/modules/sync/runtime/providers/dev-to.ts @@ -5,7 +5,7 @@ export class DevToProvider implements SyncProvider { async sync (items: SyncItem[]): Promise<{ status: string, count: number, total: number }> { const token = useRuntimeConfig().devToToken - if (!token) throw new Error('No DEVTO_TOKEN provided.') + if (!token) throw new Error('No NUXT_DEV_TO_TOKEN provided.') const $devto = $fetch.create({ baseURL: 'https://dev.to/api', @@ -13,23 +13,30 @@ export class DevToProvider implements SyncProvider { }) // 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 } + const tags = (item.tags || []) + .map(t => t.replace(/[^a-z0-9-]/g, '')) + .filter(Boolean) + .slice(0, 4) + const article = publishedArticles.find(a => a.canonical_url === item.canonical_url) if (article) { if ( item.body_markdown === article.body_markdown && item.title === article.title && item.canonical_url === article.canonical_url + && JSON.stringify(tags.sort()) === JSON.stringify((article.tag_list || []).sort()) ) { continue } console.info(`Updating article: ${item.title}`) + await new Promise(resolve => setTimeout(resolve, 1000)) await $devto(`articles/${article.id}`, { method: 'PUT', body: { @@ -38,13 +45,16 @@ export class DevToProvider implements SyncProvider { title: item.title, body_markdown: item.body_markdown, canonical_url: item.canonical_url, + tags, }, }, }).catch(console.error) updated++ continue } + console.info(`Publishing new article: ${item.title}`) + await new Promise(resolve => setTimeout(resolve, 1000)) await $devto('articles', { method: 'POST', body: { @@ -53,6 +63,7 @@ export class DevToProvider implements SyncProvider { title: item.title, canonical_url: item.canonical_url, body_markdown: item.body_markdown, + tags, }, }, }) diff --git a/modules/sync/runtime/providers/gde-advocu.ts b/modules/sync/runtime/providers/gde-advocu.ts new file mode 100644 index 00000000..2236e02f --- /dev/null +++ b/modules/sync/runtime/providers/gde-advocu.ts @@ -0,0 +1,122 @@ +import type { SyncItem, 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', +] + +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 + if (!token) throw new Error('No NUXT_ADVOCU_TOKEN provided.') + + const { client: sanityClient } = useSanity() + + const $advocu = $fetch.create({ + baseURL: 'https://api.advocu.com/personal-api/v1/gde', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + }) + + 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 + 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)}`) + } + + 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, + }, + }) + } + 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, + }, + }) + } + } + 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, + }) + continue + } + + synced++ + await sanityClient.createOrReplace({ + _id: sanityId, + _type: 'advocuSync', + canonical_url: item.canonical_url, + syncedAt: new Date().toISOString(), + }) + // Extend for other types as needed + } + return { status: 'done', count: synced, total: items.length } + } +} + +function base64url (str: 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 || []) + .map(t => ADVOCU_TAGS.find(e => t.toLowerCase() === e.toLowerCase())) + .filter(Boolean) + .slice(0, 4) as string[] +} diff --git a/modules/sync/runtime/providers/index.ts b/modules/sync/runtime/providers/index.ts index 3ff3e9cf..20d0a314 100644 --- a/modules/sync/runtime/providers/index.ts +++ b/modules/sync/runtime/providers/index.ts @@ -1,18 +1,24 @@ import { GithubStarsProvider } from './github-stars' import { DevToProvider } from './dev-to' +import { GdeAdvocuProvider } from './gde-advocu' -const providers: Record = { +const providers = { 'github-stars': new GithubStarsProvider(), 'dev-to': new DevToProvider(), -} + 'gde-advocu': new GdeAdvocuProvider(), +} satisfies Record + +type ProviderName = keyof typeof providers + +export const providerNames = Object.keys(providers) as ProviderName[] -function getProvider (name: string): SyncProvider { +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: string, items: SyncItem[]) { +export async function syncWithProvider (providerName: ProviderName, items: SyncItem[]) { const provider = getProvider(providerName) return provider.sync(items) } @@ -24,6 +30,7 @@ export interface SyncItem { canonical_url: string type: 'blog' | 'talk' | 'article' | 'event' | 'hackathon' | 'oss' | 'video' | 'forum' | 'other' date?: string + tags?: string[] } export interface SyncProvider { diff --git a/modules/sync/runtime/tasks/sync.ts b/modules/sync/runtime/tasks/sync.ts index 6ad37f8b..b78c5f31 100644 --- a/modules/sync/runtime/tasks/sync.ts +++ b/modules/sync/runtime/tasks/sync.ts @@ -1,4 +1,4 @@ -import { syncWithProvider } from '../providers/index' +import { providerNames, syncWithProvider } from '../providers/index' import { getMarkdownArticles, getTalks } from '../utils/items' export default defineTask({ @@ -11,10 +11,7 @@ export default defineTask({ ...await getMarkdownArticles(), ...await getTalks(), ] - const result = await Promise.all([ - syncWithProvider('github-stars', items), - syncWithProvider('dev-to', items), - ]) + const result = await Promise.all(providerNames.map(p => syncWithProvider(p, items))) return { result } }, }) diff --git a/modules/sync/runtime/utils/items.ts b/modules/sync/runtime/utils/items.ts index 5f4a81e2..4ba67834 100644 --- a/modules/sync/runtime/utils/items.ts +++ b/modules/sync/runtime/utils/items.ts @@ -1,5 +1,6 @@ import fsp from 'node:fs/promises' import { globby } from 'globby' +import matter from 'gray-matter' import { serializers } from '../../../shared/serialisers' import talks from '../../../../app/data/talks.json' import type { SyncItem } from '../providers' @@ -14,20 +15,29 @@ export async function getMarkdownArticles (): Promise { for (const file of files) { let contents = await fsp.readFile(file, 'utf-8') if (contents.includes('skip_dev')) continue - const date = contents.match(/date: ['"]?(?.*[^"'\n])/)?.groups?.date?.trim() - const title = contents.match(/title: ['"]?(?.*[^"'\n])/)?.groups?.title?.trim() || 'Untitled' + const { data, content } = matter(contents) + const date = typeof data.date === 'string' ? data.date.trim() : (data.date instanceof Date ? data.date.toISOString().slice(0, 10) : undefined) + const title = data.title?.toString().trim() || 'Untitled' for (const item of serializers) { contents = contents.replace(item[0], item[1]) } const slug = file.match(/\/([^/]*)$/)?.[1]?.replace(/\.md$/, '') || '' - const firstParagraph = contents.split(/\n\s*\n/).find(p => p.trim())?.trim() || '' + const firstParagraph = content.split(/\n\s*\n/).find(p => p.trim())?.trim() || '' + let tags: string[] = [] + if (Array.isArray(data.tags)) { + tags = data.tags.map((t: any) => t.toString().trim()) + } + else if (typeof data.tags === 'string') { + tags = data.tags.split(',').map((t: string) => t.trim()) + } articles.push({ type: 'blog' as const, title, date, description: firstParagraph || '', - body_markdown: contents, + body_markdown: content, canonical_url: `https://roe.dev/blog/${slug}/`, + tags: tags.length ? tags : undefined, }) if (!date) { console.warn(`Article "${title}" in ${file} has no date set. Please add a date to the frontmatter.`) @@ -57,6 +67,7 @@ export async function getTalks () { date: talk.date, body_markdown: talk.description || '', canonical_url: link, + tags: Array.isArray(talk.tags) ? talk.tags : [], }) } return items diff --git a/nuxt.config.ts b/nuxt.config.ts index 4c0981e1..f93e0c54 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -163,6 +163,7 @@ export default defineNuxtConfig({ starsToken: '', }, devToToken: '', + advocuToken: '', public: { githubClientId: '', }, diff --git a/test/unit/bundle.spec.ts b/test/unit/bundle.spec.ts index 98a77a98..9110a30e 100644 --- a/test/unit/bundle.spec.ts +++ b/test/unit/bundle.spec.ts @@ -79,7 +79,7 @@ describe('project sizes', () => { stats.server = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir) expect .soft(roundToKilobytes(stats.server.totalBytes)) - .toMatchInlineSnapshot(`"844k"`) + .toMatchInlineSnapshot(`"845k"`) const modules = await analyzeSizes('node_modules/**/*', serverDir) expect diff --git a/test/unit/sync-items.spec.ts b/test/unit/sync-items.spec.ts new file mode 100644 index 00000000..78f1d39b --- /dev/null +++ b/test/unit/sync-items.spec.ts @@ -0,0 +1,58 @@ +import fsp from 'node:fs/promises' +import { join } from 'node:path' +import { describe, it, expect } from 'vitest' +import { getMarkdownArticles, getTalks } from '../../modules/sync/runtime/utils/items' + +describe('getMarkdownArticles', () => { + it('parses markdown frontmatter and content correctly', async () => { + // Create a temporary markdown file with frontmatter + const filePath = join(process.cwd(), 'content/blog/test-article.md') + const markdown = `---\ntitle: Test Article\ndate: 2025-06-26\ntags: ["foo", "bar"]\n---\n\nThis is the first paragraph.\n\nSecond paragraph.` + await fsp.mkdir(join(process.cwd(), 'content/blog'), { recursive: true }) + await fsp.writeFile(filePath, markdown) + const articles = await getMarkdownArticles() + const testArticle = articles.find(a => a.title === 'Test Article') + expect(testArticle).toBeDefined() + expect(testArticle?.tags).toEqual(['foo', 'bar']) + expect(testArticle?.description).toContain('This is the first paragraph') + expect(testArticle?.date).toBe('2025-06-26') + // Cleanup + await fsp.unlink(filePath) + }) + + it('parses titles and dates with quotes', async () => { + const filePath = join(process.cwd(), 'content/blog/test-article-quotes.md') + const markdown = `---\ntitle: "A 'Quoted' Title"\ndate: '2025-07-01'\ntags: ["foo"]\n---\n\nContent with quotes.` + await fsp.mkdir(join(process.cwd(), 'content/blog'), { recursive: true }) + await fsp.writeFile(filePath, markdown) + const articles = await getMarkdownArticles() + const testArticle = articles.find(a => a.title === 'A \'Quoted\' Title') + expect(testArticle).toBeDefined() + expect(testArticle?.date).toBe('2025-07-01') + expect(testArticle?.tags).toEqual(['foo']) + // Cleanup + await fsp.unlink(filePath) + }) + + it('parses tags as YAML array', async () => { + const filePath = join(process.cwd(), 'content/blog/test-article-yaml-array.md') + const markdown = `---\ntitle: YAML Array Tags\ndate: 2025-08-01\ntags:\n - personal\n - ux\n - brand\n---\n\nYAML array tags test.` + await fsp.mkdir(join(process.cwd(), 'content/blog'), { recursive: true }) + await fsp.writeFile(filePath, markdown) + const articles = await getMarkdownArticles() + const testArticle = articles.find(a => a.title === 'YAML Array Tags') + expect(testArticle).toBeDefined() + expect(testArticle?.tags).toEqual(['personal', 'ux', 'brand']) + // Cleanup + await fsp.unlink(filePath) + }) +}) + +describe('getTalks', () => { + it('returns talks with correct fields and tags', async () => { + const items = await getTalks() + const found = items.find(i => i.title === 'How concision might save your life') + expect(found).toBeDefined() + expect(found?.tags || []).toEqual(['marketing', 'communication']) + }) +})