diff --git a/scripts/markdown_test.mjs b/scripts/markdown_test.mjs new file mode 100644 index 0000000..153cb96 --- /dev/null +++ b/scripts/markdown_test.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +/** + * Unit tests for site/src/lib/markdown.ts formatting logic. + * Run: node scripts/markdown_test.mjs + */ +import { formatWorkshop, formatConferenceYear, formatSingleWorkshopInfo, isAutoTopicsNote } from '../site/src/lib/markdown.ts'; + +let failed = 0; +function check(label, got, expect) { + const ok = typeof expect === 'boolean' ? got === expect : (got.includes ? got.includes(expect) : got === expect); + if (!ok) { + failed++; + console.error(`✗ ${label}\n Got: ${JSON.stringify(got)}\n Expected to match: ${JSON.stringify(expect)}`); + } else { + console.log(`✓ ${label}`); + } +} + +// 1. Test auto topics note filtering +check('isAutoTopicsNote identifies exact auto-suggested note', + isAutoTopicsNote('Topics were auto-suggested and may be imprecise — edits welcome.'), true); +check('isAutoTopicsNote identifies historical auto note', + isAutoTopicsNote('Auto-imported from the OpenReview venue record on 2026-06-20 — please verify and enrich (topics are keyword-guessed).'), true); +check('isAutoTopicsNote identifies SEED DATA note', + isAutoTopicsNote('SEED DATA: unverified entry'), true); +check('isAutoTopicsNote allows legitimate custom description', + isAutoTopicsNote('This workshop focuses on efficient LLM reasoning.'), false); + +// 2. Test formatWorkshop with abstract deadline, bold key-values, and filtered notes +const sampleWs = { + name: 'Efficient Reasoning Workshop', + statusLabel: 'Open call', + abstractDeadlineWallClock: 'Aug 20, 2026 23:59 UTC', + abstractDeadlinePassed: false, + deadlineWallClock: 'Aug 29, 2026 23:59 UTC', + notificationDateLabel: 'Sep 20, 2026', + workshopDateLabel: 'Oct 25, 2026', + topics: ['llms', 'efficiency'], + website: 'https://example.com/ws', + openreview_venue_id: 'NeurIPS.cc/2026/Workshop/ER', + notes: 'Topics were auto-suggested and may be imprecise — edits welcome.', + deadlineChange: { + kind: 'extended', + days: 5, + fromWallClock: 'Aug 24, 2026 23:59 UTC', + }, +}; + +const output = formatWorkshop(sampleWs, 'NeurIPS'); + +check('formatWorkshop includes H2 header', output, '## Efficient Reasoning Workshop'); +check('formatWorkshop formats bold Status label', output, '- **Status:** Open call'); +check('formatWorkshop includes abstract deadline', output, '- **Abstract Deadline:** Aug 20, 2026 23:59 UTC'); +check('formatWorkshop formats bold Submission Deadline', output, '- **Submission Deadline:** Aug 29, 2026 23:59 UTC'); +check('formatWorkshop formats bold Website link', output, '- **Website:** [https://example.com/ws](https://example.com/ws)'); +check('formatWorkshop formats bold OpenReview link', output, '- **OpenReview:** [https://openreview.net/group?id=NeurIPS.cc/2026/Workshop/ER](https://openreview.net/group?id=NeurIPS.cc/2026/Workshop/ER)'); +check('formatWorkshop formats Deadline History', output, '**Deadline History:** Extended by 5 days (previously Aug 24, 2026 23:59 UTC)'); +check('formatWorkshop excludes auto-suggested maintenance notes from Description', output.includes('Description'), false); + +// 3. Test custom notes inclusion +const wsWithCustomNotes = { + ...sampleWs, + notes: 'We welcome papers on efficient attention mechanisms.', +}; +const outputCustom = formatWorkshop(wsWithCustomNotes, 'NeurIPS'); +check('formatWorkshop includes valid custom description', outputCustom, '**Description:**\nWe welcome papers on efficient attention mechanisms.'); + +// 4. Test formatConferenceYear +const confObj = { name: 'NeurIPS', full_name: 'Neural Information Processing Systems' }; +const confOutput = formatConferenceYear(confObj, 2026, [sampleWs]); +check('formatConferenceYear contains conference header', confOutput, '# Neural Information Processing Systems 2026 Workshops'); +check('formatConferenceYear contains Total Workshops count', confOutput, 'Total Workshops: 1'); + +// 5. Test formatSingleWorkshopInfo +const singleOutput = formatSingleWorkshopInfo(sampleWs, confObj); +check('formatSingleWorkshopInfo contains single workshop header', singleOutput, '# Efficient Reasoning Workshop'); +check('formatSingleWorkshopInfo contains Conference field', singleOutput, 'Conference: Neural Information Processing Systems'); + +if (failed > 0) { + console.error(`\nTest suite failed with ${failed} failure(s).`); + process.exit(1); +} else { + console.log('\nAll markdown tests passed successfully!'); +} diff --git a/site/package.json b/site/package.json index 07f6100..aff33ad 100644 --- a/site/package.json +++ b/site/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "astro dev", - "build": "astro build && pagefind --site dist --root-selector '[data-pf-ws]' && pagefind --site dist --output-subdir pagefind-papers --root-selector '[data-pf-papers]'", + "build": "astro build && pagefind --site dist --root-selector \"[data-pf-ws]\" && pagefind --site dist --output-subdir pagefind-papers --root-selector \"[data-pf-papers]\"", "preview": "astro preview" }, "dependencies": { diff --git a/site/src/components/MarkdownExport.astro b/site/src/components/MarkdownExport.astro new file mode 100644 index 0000000..2ca527d --- /dev/null +++ b/site/src/components/MarkdownExport.astro @@ -0,0 +1,139 @@ +--- +export interface Props { + content?: string; + exportUrl?: string; + filename?: string; + variant?: 'button' | 'link'; +} + +const { content, exportUrl, filename, variant = 'button' } = Astro.props; +const encodedContent = content ? encodeURIComponent(content) : undefined; +--- + +{variant === 'link' ? ( + +) : ( +
+ + + {exportUrl && filename ? ( + + ⬇ Download .md + + ) : filename && ( + + )} +
+)} + + + + diff --git a/site/src/lib/markdown.ts b/site/src/lib/markdown.ts new file mode 100644 index 0000000..f136e0b --- /dev/null +++ b/site/src/lib/markdown.ts @@ -0,0 +1,132 @@ +import { topicById } from './data.ts'; + +export const AUTO_TOPICS_NOTE = 'Topics were auto-suggested and may be imprecise — edits welcome.'; + +/** + * True if `notes` is the bot's auto-suggested-topics note or a seed note, + * so we drop it from the exported Description. + */ +export function isAutoTopicsNote(notes?: string | null): boolean { + if (!notes || typeof notes !== 'string') return false; + const trimmed = notes.trim(); + if (trimmed === AUTO_TOPICS_NOTE) return true; + if (trimmed.includes('auto-suggested and may be imprecise')) return true; + if (trimmed.includes('SEED DATA')) return true; + if (/^Auto-imported from the OpenReview venue record on \d{4}-\d{2}-\d{2} — please verify and enrich \(topics are keyword-guessed\)\.?$/.test(trimmed)) return true; + return false; +} + +export function formatWorkshop(w: any, confName?: string): string { + const parts: string[] = []; + + parts.push(`## ${w.name}`); + + const details: string[] = []; + + if (w.statusLabel) { + details.push(`- **Status:** ${w.statusLabel}`); + } + + if (w.abstractDeadlineWallClock) { + const passedTag = w.abstractDeadlinePassed ? ' (closed)' : ''; + details.push(`- **Abstract Deadline:** ${w.abstractDeadlineWallClock}${passedTag}`); + } + + if (w.deadlineWallClock) { + details.push(`- **Submission Deadline:** ${w.deadlineWallClock}`); + } else { + details.push(`- **Submission Deadline:** TBA`); + } + + if (w.notificationDateLabel) { + details.push(`- **Notification:** ${w.notificationDateLabel}`); + } + + if (w.workshopDateLabel) { + details.push(`- **Workshop Date:** ${w.workshopDateLabel}`); + } + + if (w.website) { + details.push(`- **Website:** [${w.website}](${w.website})`); + } + + if (w.openreview_venue_id) { + const url = `https://openreview.net/group?id=${w.openreview_venue_id}`; + details.push(`- **OpenReview:** [${url}](${url})`); + } + + if (details.length > 0) { + parts.push(details.join('\n')); + } + + if (w.topics && w.topics.length > 0) { + const topicLabels = w.topics.map((t: string) => `- ${topicById.get(t)?.label ?? t}`).join('\n'); + parts.push(`**Topics:**\n${topicLabels}`); + } + + if (w.deadlineChange) { + if (w.deadlineChange.kind === 'extended') { + parts.push(`**Deadline History:** Extended by ${w.deadlineChange.days} ${w.deadlineChange.days === 1 ? 'day' : 'days'} (previously ${w.deadlineChange.fromWallClock})`); + } else if (w.deadlineChange.kind === 'earlier') { + parts.push(`**Deadline History:** Moved ${w.deadlineChange.days} ${w.deadlineChange.days === 1 ? 'day' : 'days'} earlier (previously ${w.deadlineChange.fromWallClock})`); + } else if (w.deadlineChange.kind === 'announced') { + parts.push(`**Deadline History:** Deadline just announced`); + } + } else if (Array.isArray(w.deadlineHistoryView) && w.deadlineHistoryView.length > 1) { + const historyLines = w.deadlineHistoryView.map((h: any, i: number) => { + const tag = i === w.deadlineHistoryView.length - 1 ? 'first recorded' : 'changed'; + return `- ${h.recordedLabel}: ${h.wallClock ?? 'no date published'} (${tag})`; + }).join('\n'); + parts.push(`**Deadline History:**\n${historyLines}`); + } + + if (w.notes && !isAutoTopicsNote(w.notes)) { + parts.push(`**Description:**\n${w.notes}`); + } + + return parts.join('\n\n'); +} + +export function formatConferenceYear(conf: any, year: number, wsList: any[]): string { + const confFull = conf.full_name || conf.name; + const genDate = new Date().toISOString().split('T')[0]; + + const header = [ + `# ${confFull} ${year} Workshops`, + `Generated from AI Workshop Tracker`, + ``, + `Conference: ${conf.name}`, + `Edition: ${year}`, + `Generated: ${genDate}`, + `Total Workshops: ${wsList.length}`, + ``, + `---` + ].join('\n'); + + const body = wsList.map(w => formatWorkshop(w, conf.name)).join('\n\n---\n\n'); + + return `${header}\n\n${body}`; +} + +export function formatSingleWorkshopInfo(w: any, conf: any): string { + const confFull = conf.full_name || conf.name; + const genDate = new Date().toISOString().split('T')[0]; + + const header = [ + `# ${w.name}`, + `Generated from AI Workshop Tracker`, + ``, + `Conference: ${confFull}`, + `Edition: ${w.year}`, + `Generated: ${genDate}`, + ``, + `---` + ].join('\n'); + + // Strip the '## ' line from formatWorkshop output since header already has '# w.name' + const bodyParts = formatWorkshop(w, conf.name).split('\n\n'); + bodyParts.shift(); + const body = bodyParts.join('\n\n'); + + return `${header}\n\n${body}`; +} diff --git a/site/src/pages/conference/[conf].astro b/site/src/pages/conference/[conf].astro index 4aecb8c..73e9af0 100644 --- a/site/src/pages/conference/[conf].astro +++ b/site/src/pages/conference/[conf].astro @@ -2,6 +2,8 @@ import Base from '../../components/Base.astro'; import { workshops, conferences } from '../../lib/data'; import { href, REPO_URL } from '../../lib/site'; +import MarkdownExport from '../../components/MarkdownExport.astro'; +import { formatConferenceYear } from '../../lib/markdown'; // One hub page per conference, generated from the same data the rest of the // site uses. Intentionally carries NO data-pf-ws / data-pf-papers attributes, @@ -144,6 +146,7 @@ const statusLines = (w: any): string[] => {yearGroups.map(([year, ws]) => (

{confName} {year}

+