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}
+
{ws.map((w) => (
-
diff --git a/site/src/pages/workshop/[slug].astro b/site/src/pages/workshop/[slug].astro
index ed79c38..5d3fbac 100644
--- a/site/src/pages/workshop/[slug].astro
+++ b/site/src/pages/workshop/[slug].astro
@@ -2,6 +2,8 @@
import Base from '../../components/Base.astro';
import { workshops, conferenceById, topicById, loadPaperCache } from '../../lib/data';
import { href, REPO_URL, CALENDAR_ENABLED } from '../../lib/site';
+import MarkdownExport from '../../components/MarkdownExport.astro';
+import { formatSingleWorkshopInfo } from '../../lib/markdown';
export function getStaticPaths() {
return workshops.map((w) => ({ params: { slug: w.slug }, props: { w } }));
@@ -152,6 +154,7 @@ const breadcrumbLd = {
{CALENDAR_ENABLED && w.deadlineUtcMs && Add deadline to calendar (.ics)}
See all {conf.name} workshops →
✎ Edit this entry
+
{isSeed && (