From 60bcf3fe382f36de6835991e01498c26a95ba4c8 Mon Sep 17 00:00:00 2001 From: Dinesh <13635627+HumbleBee14@users.noreply.github.com> Date: Fri, 17 Jul 2026 04:14:05 -0700 Subject: [PATCH 1/8] Add read-only forum mirroring GitHub Discussions with SEO-indexed static pages + live refresh --- .github/workflows/forum-sync.yml | 34 +++ functions/api/discussion/[number].ts | 90 +++++++ src/components/ForumThreadRow.astro | 49 ++++ src/components/Nav.astro | 1 + src/env.d.ts | 6 + src/lib/forum.ts | 222 ++++++++++++++++ src/pages/forum/[...page].astro | 77 ++++++ src/pages/forum/[category]/[slug].astro | 222 ++++++++++++++++ src/pages/forum/c/[category]/[...page].astro | 56 ++++ src/styles/global.css | 262 +++++++++++++++++++ 10 files changed, 1019 insertions(+) create mode 100644 .github/workflows/forum-sync.yml create mode 100644 functions/api/discussion/[number].ts create mode 100644 src/components/ForumThreadRow.astro create mode 100644 src/lib/forum.ts create mode 100644 src/pages/forum/[...page].astro create mode 100644 src/pages/forum/[category]/[slug].astro create mode 100644 src/pages/forum/c/[category]/[...page].astro diff --git a/.github/workflows/forum-sync.yml b/.github/workflows/forum-sync.yml new file mode 100644 index 0000000..5236b40 --- /dev/null +++ b/.github/workflows/forum-sync.yml @@ -0,0 +1,34 @@ +# Rebuilds the site when a GitHub Discussion changes, so the forum mirror and its +# SEO snapshot stay current. Between events, a daily cron catches anything missed +# (e.g. edits/deletes that don't always fire). Requires a repo secret +# CLOUDFLARE_DEPLOY_HOOK_URL — the Deploy Hook URL from the Cloudflare Pages project +# (Settings → Builds → Add deploy hook). No-ops quietly if the secret is unset. +name: Forum sync + +on: + discussion: + types: [created, edited, deleted, category_changed, answered, unanswered] + discussion_comment: + types: [created, edited, deleted] + schedule: + - cron: '0 6 * * *' # daily 06:00 UTC safety rebuild + workflow_dispatch: {} + +# One rebuild at a time; newer events supersede a queued run. +concurrency: + group: forum-sync + cancel-in-progress: true + +jobs: + trigger-build: + runs-on: ubuntu-latest + steps: + - name: Trigger Cloudflare Pages deploy + env: + HOOK: ${{ secrets.CLOUDFLARE_DEPLOY_HOOK_URL }} + run: | + if [ -z "$HOOK" ]; then + echo "CLOUDFLARE_DEPLOY_HOOK_URL not set — skipping." + exit 0 + fi + curl -fsS -X POST "$HOOK" -o /dev/null -w "deploy hook status: %{http_code}\n" diff --git a/functions/api/discussion/[number].ts b/functions/api/discussion/[number].ts new file mode 100644 index 0000000..54d5892 --- /dev/null +++ b/functions/api/discussion/[number].ts @@ -0,0 +1,90 @@ +// Read-only proxy: returns a discussion's latest comments so a thread page can +// refresh past its build-time snapshot. Holds the token server-side. Degrades to +// an empty payload (the page keeps its baked-in comments) on any failure. + +type Env = { GITHUB_TOKEN?: string; GH_TOKEN?: string }; + +const OWNER = 'MLSysDev'; +const NAME = 'mlsystems.dev'; +const CAP = 50; + +const QUERY = ` +query($owner:String!,$name:String!,$number:Int!){ + repository(owner:$owner,name:$name){ + discussion(number:$number){ + comments(first:${CAP}){ + totalCount + nodes{ + bodyHTML createdAt isAnswer + author{ login url } + replies(first:20){ nodes{ bodyHTML createdAt author{ login url } } } + } + } + } + } +}`; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { + 'content-type': 'application/json', + // Short edge cache smooths bursts without masking new replies for long. + 'cache-control': 'public, max-age=60', + }, + }); +} + +export async function onRequestGet(context: { + params: { number: string }; + env: Env; +}): Promise { + const number = Number(context.params.number); + if (!Number.isInteger(number) || number <= 0) return json({ comments: [] }, 400); + + const token = context.env.GITHUB_TOKEN || context.env.GH_TOKEN; + if (!token) return json({ comments: [] }); + + try { + const res = await fetch('https://api.github.com/graphql', { + method: 'POST', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + 'user-agent': 'mlsystems-forum', + }, + body: JSON.stringify({ query: QUERY, variables: { owner: OWNER, name: NAME, number } }), + }); + if (!res.ok) return json({ comments: [] }); + const data = (await res.json()) as { + data?: { + repository?: { + discussion?: { + comments: { + totalCount: number; + nodes: Array<{ + bodyHTML: string; + createdAt: string; + isAnswer: boolean; + author: { login: string; url: string } | null; + replies: { nodes: Array> }; + }>; + }; + } | null; + }; + }; + }; + const c = data.data?.repository?.discussion?.comments; + if (!c) return json({ comments: [] }); + const comments = c.nodes.map((n) => ({ + author: n.author, + bodyHTML: n.bodyHTML, + createdAt: n.createdAt, + isAnswer: n.isAnswer, + replies: n.replies.nodes, + })); + return json({ commentCount: c.totalCount, comments }); + } catch { + return json({ comments: [] }); + } +} diff --git a/src/components/ForumThreadRow.astro b/src/components/ForumThreadRow.astro new file mode 100644 index 0000000..8dd45a8 --- /dev/null +++ b/src/components/ForumThreadRow.astro @@ -0,0 +1,49 @@ +--- +import { formatDate } from '@/lib/data'; +import type { ForumThread } from '@/lib/forum'; + +interface Props { + thread: ForumThread; + showCategory?: boolean; +} +const { thread, showCategory = true } = Astro.props; +const href = `/forum/${thread.category.slug}/${thread.slug}`; +const answered = thread.category.isAnswerable && thread.comments.some((c) => c.isAnswer); +--- + + +
+
+

{thread.title}

+ { + showCategory && ( + + {thread.category.emoji} {thread.category.name} + + ) + } + { + answered && ( + + ✓ answered + + ) + } +
+
+ {thread.author?.login ? @{thread.author.login} : Guest} + · + {formatDate(new Date(thread.createdAt))} + · + {thread.commentCount} {thread.commentCount === 1 ? 'reply' : 'replies'} + { + thread.upvoteCount > 0 && ( + <> + · + ▲ {thread.upvoteCount} + + ) + } +
+
+
diff --git a/src/components/Nav.astro b/src/components/Nav.astro index 2831f9b..0071842 100644 --- a/src/components/Nav.astro +++ b/src/components/Nav.astro @@ -10,6 +10,7 @@ const LINKS = [ { id: 'blog', label: 'Blog', path: '/blog' }, { id: 'topics', label: 'Topics', path: '/topics' }, { id: 'playground', label: 'Playground', path: '/playground' }, + { id: 'forum', label: 'Forum', path: '/forum' }, ]; const isActive = (path: string) => currentPath === path || currentPath.startsWith(path + '/'); diff --git a/src/env.d.ts b/src/env.d.ts index d86f284..8683596 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -2,6 +2,12 @@ declare const __OG_VERSION__: string; +interface ImportMetaEnv { + // Build-time token for reading GitHub Discussions into the forum (optional). + readonly GITHUB_TOKEN?: string; + readonly GH_TOKEN?: string; +} + interface Window { dataLayer?: unknown[]; gtag?: (...args: unknown[]) => void; diff --git a/src/lib/forum.ts b/src/lib/forum.ts new file mode 100644 index 0000000..dacdf28 --- /dev/null +++ b/src/lib/forum.ts @@ -0,0 +1,222 @@ +// Server-only: mirrors GitHub Discussions into forum data at build time. +// All writing happens on GitHub; this module is read-only. Never import it into +// client-bundled code — it uses a build-time token. Fetch is fully defensive: +// no token, a failed request, or Discussions being empty all yield an empty +// forum (with a warning) rather than a broken build. + +import { tagSlug } from './data'; + +const OWNER = 'MLSysDev'; +const NAME = 'mlsystems.dev'; + +// giscus stores per-post blog comments in this category — never a forum thread. +const EXCLUDED_CATEGORY_SLUGS = new Set(['comments']); + +// Cap rendered comments per thread; commentCount still reports the true total to +// readers and to search engines, with a "continue on GitHub" link for the rest. +export const COMMENT_RENDER_CAP = 50; + +export type ForumAuthor = { login: string; url: string; avatarUrl: string }; + +export type ForumCategory = { + name: string; + slug: string; + emoji: string; + isAnswerable: boolean; +}; + +export type ForumComment = { + id: string; + author: ForumAuthor | null; + bodyHTML: string; + createdAt: string; + url: string; + isAnswer: boolean; + replies: ForumComment[]; +}; + +export type ForumThread = { + number: number; + title: string; + slug: string; + bodyHTML: string; + createdAt: string; + updatedAt: string; + url: string; + upvoteCount: number; + category: ForumCategory; + author: ForumAuthor | null; + commentCount: number; + comments: ForumComment[]; +}; + +export type Forum = { categories: ForumCategory[]; threads: ForumThread[] }; + +function token(): string | undefined { + // Cloudflare Pages / CI expose the token as an env var at build time. + return ( + import.meta.env.GITHUB_TOKEN || + import.meta.env.GH_TOKEN || + (typeof process !== 'undefined' + ? process.env?.GITHUB_TOKEN || process.env?.GH_TOKEN + : undefined) + ); +} + +// One discussion slug = number + title, so it never collides with a page number +// and stays stable if the title is later edited (the number anchors it). +export function threadSlug(number: number, title: string): string { + const s = tagSlug(title) + .slice(0, 60) + .replace(/^-+|-+$/g, ''); + return s ? `${number}-${s}` : `${number}`; +} + +const DISCUSSIONS_QUERY = ` +query($owner:String!,$name:String!,$after:String){ + repository(owner:$owner,name:$name){ + discussions(first:25, after:$after, orderBy:{field:UPDATED_AT, direction:DESC}){ + pageInfo{ hasNextPage endCursor } + nodes{ + number title bodyHTML createdAt updatedAt url upvoteCount + category{ name slug emoji isAnswerable } + author{ login url avatarUrl } + comments(first:${COMMENT_RENDER_CAP}){ + totalCount + nodes{ + id bodyHTML createdAt url isAnswer + author{ login url avatarUrl } + replies(first:20){ + nodes{ id bodyHTML createdAt url author{ login url avatarUrl } } + } + } + } + } + } + } +}`; + +type RawComment = { + id: string; + bodyHTML: string; + createdAt: string; + url: string; + isAnswer?: boolean; + author: ForumAuthor | null; + replies?: { nodes: RawComment[] }; +}; + +type RawDiscussion = { + number: number; + title: string; + bodyHTML: string; + createdAt: string; + updatedAt: string; + url: string; + upvoteCount: number; + category: ForumCategory | null; + author: ForumAuthor | null; + comments: { totalCount: number; nodes: RawComment[] }; +}; + +function shapeComment(c: RawComment): ForumComment { + return { + id: c.id, + author: c.author, + bodyHTML: c.bodyHTML, + createdAt: c.createdAt, + url: c.url, + isAnswer: Boolean(c.isAnswer), + replies: (c.replies?.nodes ?? []).map(shapeComment), + }; +} + +async function fetchAll(gh: string): Promise { + const out: RawDiscussion[] = []; + let after: string | null = null; + // Bound the loop so a surprise of thousands of threads can't stall a build. + for (let page = 0; page < 40; page++) { + const res = await fetch('https://api.github.com/graphql', { + method: 'POST', + headers: { + authorization: `Bearer ${gh}`, + 'content-type': 'application/json', + 'user-agent': 'mlsystems-forum-build', + }, + body: JSON.stringify({ + query: DISCUSSIONS_QUERY, + variables: { owner: OWNER, name: NAME, after }, + }), + }); + if (!res.ok) throw new Error(`GitHub GraphQL ${res.status}`); + const json = (await res.json()) as { + errors?: { message: string }[]; + data?: { + repository?: { + discussions: { + pageInfo: { hasNextPage: boolean; endCursor: string }; + nodes: RawDiscussion[]; + }; + }; + }; + }; + if (json.errors?.length) throw new Error(json.errors[0].message); + const conn = json.data?.repository?.discussions; + if (!conn) break; + out.push(...conn.nodes); + if (!conn.pageInfo.hasNextPage) break; + after = conn.pageInfo.endCursor; + } + return out; +} + +let cache: Forum | null = null; + +export async function getForum(): Promise { + if (cache) return cache; + const gh = token(); + if (!gh) { + console.warn('[forum] no GITHUB_TOKEN — forum renders empty. Set it in the build env.'); + cache = { categories: [], threads: [] }; + return cache; + } + let raw: RawDiscussion[]; + try { + raw = await fetchAll(gh); + } catch (err) { + console.warn( + `[forum] fetch failed (${err instanceof Error ? err.message : err}) — empty forum.`, + ); + cache = { categories: [], threads: [] }; + return cache; + } + + const threads: ForumThread[] = raw + .filter((d) => d.category && !EXCLUDED_CATEGORY_SLUGS.has(d.category.slug)) + .map((d) => ({ + number: d.number, + title: d.title, + slug: threadSlug(d.number, d.title), + bodyHTML: d.bodyHTML, + createdAt: d.createdAt, + updatedAt: d.updatedAt, + url: d.url, + upvoteCount: d.upvoteCount, + category: d.category as ForumCategory, + author: d.author, + commentCount: d.comments.totalCount, + comments: d.comments.nodes.map(shapeComment), + })); + + // Only surface categories that actually contain forum threads. + const bySlug = new Map(); + for (const t of threads) bySlug.set(t.category.slug, t.category); + const categories = [...bySlug.values()].sort((a, b) => a.name.localeCompare(b.name)); + + cache = { categories, threads }; + return cache; +} + +export function threadsByCategory(threads: ForumThread[], slug: string): ForumThread[] { + return threads.filter((t) => t.category.slug === slug); +} diff --git a/src/pages/forum/[...page].astro b/src/pages/forum/[...page].astro new file mode 100644 index 0000000..c928a4d --- /dev/null +++ b/src/pages/forum/[...page].astro @@ -0,0 +1,77 @@ +--- +import type { GetStaticPaths, Page } from 'astro'; +import BaseLayout from '@/layouts/BaseLayout.astro'; +import Pager from '@/components/Pager.astro'; +import ForumThreadRow from '@/components/ForumThreadRow.astro'; +import { getForum, type ForumCategory, type ForumThread } from '@/lib/forum'; +import { SITE } from '@/lib/site'; + +export const getStaticPaths = (async ({ paginate }) => { + const { threads, categories } = await getForum(); + return paginate(threads, { pageSize: 20, props: { categories } }); +}) satisfies GetStaticPaths; + +interface Props { + page: Page; + categories: ForumCategory[]; +} +const { page, categories } = Astro.props; +--- + + 1 ? `Forum — page ${page.currentPage}` : 'Forum'} + description="Community discussion on machine learning systems — questions, ideas, and show-and-tell, mirrored from GitHub Discussions." +> +
+
+
+
Community
+

Forum.

+

+ Questions, ideas, and things people built — hosted on GitHub Discussions, indexed here. +

+
+ + Start a discussion + +
+ + { + categories.length > 0 && ( + + ) + } + + { + page.data.length > 0 ? ( +
+ {page.data.map((t) => ( + + ))} +
+ ) : ( +
+ No discussions yet.{' '} + + Start the first one on GitHub + + . +
+ ) + } + + +
+
diff --git a/src/pages/forum/[category]/[slug].astro b/src/pages/forum/[category]/[slug].astro new file mode 100644 index 0000000..b3aeeec --- /dev/null +++ b/src/pages/forum/[category]/[slug].astro @@ -0,0 +1,222 @@ +--- +import type { GetStaticPaths } from 'astro'; +import BaseLayout from '@/layouts/BaseLayout.astro'; +import { formatDate } from '@/lib/data'; +import { getForum, type ForumComment, type ForumThread } from '@/lib/forum'; +import { SITE } from '@/lib/site'; + +export const getStaticPaths = (async () => { + const { threads } = await getForum(); + return threads.map((thread) => ({ + params: { category: thread.category.slug, slug: thread.slug }, + props: { thread }, + })); +}) satisfies GetStaticPaths; + +interface Props { + thread: ForumThread; +} +const { thread } = Astro.props; + +const canonical = `${SITE.url}/forum/${thread.category.slug}/${thread.slug}`; +const authorName = thread.author?.login ?? 'Guest'; +const plain = (html: string) => + html + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + +// A Q&A category maps to QAPage; everything else to DiscussionForumPosting. +const commentLd = (c: ForumComment): Record => ({ + '@type': 'Comment', + text: plain(c.bodyHTML).slice(0, 500), + datePublished: c.createdAt, + url: c.url, + ...(c.author ? { author: { '@type': 'Person', name: c.author.login, url: c.author.url } } : {}), + ...(c.replies.length ? { comment: c.replies.map(commentLd) } : {}), +}); + +const postLd = { + '@type': thread.category.isAnswerable ? 'QAPage' : 'DiscussionForumPosting', + headline: thread.title, + text: plain(thread.bodyHTML).slice(0, 1000), + datePublished: thread.createdAt, + dateModified: thread.updatedAt, + url: canonical, + commentCount: thread.commentCount, + ...(thread.author + ? { author: { '@type': 'Person', name: thread.author.login, url: thread.author.url } } + : {}), + comment: thread.comments.map(commentLd), +}; +const jsonLd = { '@context': 'https://schema.org', ...postLd }; + +const rendered = thread.comments.length; +const hiddenCount = thread.commentCount - rendered; +--- + + +
+ + +
+

{thread.title}

+
+ { + thread.author?.url ? ( + + @{authorName} + + ) : ( + {authorName} + ) + } + · + {formatDate(new Date(thread.createdAt))} + {thread.category.isAnswerable && Q&A} +
+
+
+ +
+

+ {thread.commentCount} + {thread.commentCount === 1 ? ' reply' : ' replies'} +

+ +
+ { + thread.comments.map((c) => ( +
+ {c.isAnswer &&
✓ Answer
} +
+ {c.author?.url ? ( + + @{c.author.login} + + ) : ( + Guest + )} + · + {formatDate(new Date(c.createdAt))} +
+
+ {c.replies.length > 0 && ( +
+ {c.replies.map((r) => ( +
+
+ {r.author?.url ? ( + + @{r.author.login} + + ) : ( + Guest + )} + · + {formatDate(new Date(r.createdAt))} +
+
+
+ ))} +
+ )} +
+ )) + } +
+ + { + hiddenCount > 0 && ( +

+ {hiddenCount} more {hiddenCount === 1 ? 'reply' : 'replies'} on GitHub. +

+ ) + } + + + Reply on GitHub + +
+
+
+ + diff --git a/src/pages/forum/c/[category]/[...page].astro b/src/pages/forum/c/[category]/[...page].astro new file mode 100644 index 0000000..b828623 --- /dev/null +++ b/src/pages/forum/c/[category]/[...page].astro @@ -0,0 +1,56 @@ +--- +import type { GetStaticPaths, Page } from 'astro'; +import BaseLayout from '@/layouts/BaseLayout.astro'; +import Pager from '@/components/Pager.astro'; +import ForumThreadRow from '@/components/ForumThreadRow.astro'; +import { getForum, threadsByCategory, type ForumCategory, type ForumThread } from '@/lib/forum'; +import { SITE } from '@/lib/site'; + +export const getStaticPaths = (async ({ paginate }) => { + const { categories, threads } = await getForum(); + return categories.flatMap((category) => + paginate(threadsByCategory(threads, category.slug), { + params: { category: category.slug }, + props: { category }, + pageSize: 20, + }), + ); +}) satisfies GetStaticPaths; + +interface Props { + page: Page; + category: ForumCategory; +} +const { page, category } = Astro.props; +const suffix = page.currentPage > 1 ? ` — page ${page.currentPage}` : ''; +--- + + +
+
+
+
+ Forum / {category.name} +
+

{category.emoji} {category.name}

+
+ + Start a discussion + +
+ +
+ {page.data.map((t) => )} +
+ + +
+
diff --git a/src/styles/global.css b/src/styles/global.css index 046fc64..5e1f2b3 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -3055,3 +3055,265 @@ a.hashtag:hover { background: rgba(225, 228, 232, 0.25); border-radius: var(--radius-pill); } + +/* ── Forum (GitHub Discussions mirror) ─────────────────────────────── */ +.forum-shell { + padding-top: 40px; + padding-bottom: 64px; +} +.forum-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + margin-bottom: 28px; +} +.forum-h1 { + font-family: var(--font-display); + font-size: 40px; + font-weight: 400; + letter-spacing: -0.01em; + margin: 6px 0 0; +} +.forum-lede { + color: var(--ink-2); + font-size: 15px; + margin: 8px 0 0; + max-width: 52ch; +} +.forum-new { + flex-shrink: 0; + white-space: nowrap; +} +.forum-cats { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 28px; + padding-bottom: 20px; + border-bottom: 1px solid var(--line); +} +.forum-cat { + font-family: var(--font-mono); + font-size: 12px; + padding: 5px 12px; + border: 1px solid var(--line-2); + border-radius: var(--radius-pill); + color: var(--ink-2); +} +a.forum-cat:hover { + border-color: var(--accent); + color: var(--accent); +} +.forum-cat.is-active { + background: var(--accent-soft); + border-color: var(--accent-soft); + color: var(--accent); +} +.forum-list { + display: flex; + flex-direction: column; +} +.forum-row { + display: block; + padding: 18px 0; + border-bottom: 1px solid var(--line); +} +.forum-row:hover .forum-row-title { + color: var(--accent); +} +.forum-row-title-line { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} +.forum-row-title { + font-family: var(--font-display); + font-size: 19px; + font-weight: 500; + margin: 0; + transition: color 0.12s ease; +} +.forum-answered { + font-family: var(--font-mono); + font-size: 11px; + color: var(--accent); +} +.forum-row-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 6px; + font-family: var(--font-mono); + font-size: 12px; + color: var(--ink-3); +} +.forum-empty { + padding: 80px 0; + text-align: center; + color: var(--ink-3); +} +.forum-empty a { + color: var(--accent); +} + +/* Thread page */ +.forum-thread { + max-width: var(--text-w, 720px); + padding-top: 32px; + padding-bottom: 64px; +} +.forum-crumb { + display: flex; + gap: 8px; + font-family: var(--font-mono); + font-size: 12px; + color: var(--ink-3); + margin-bottom: 20px; +} +.forum-crumb a { + color: var(--ink-2); +} +.forum-crumb a:hover { + color: var(--accent); +} +.forum-thread-title { + font-family: var(--font-display); + font-size: 34px; + font-weight: 500; + line-height: 1.15; + letter-spacing: -0.01em; + margin: 0 0 12px; +} +.forum-thread-meta, +.forum-comment-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + font-family: var(--font-mono); + font-size: 12px; + color: var(--ink-3); +} +.forum-thread-meta a, +.forum-comment-meta a { + color: var(--ink-2); +} +.forum-op { + padding-bottom: 28px; + border-bottom: 1px solid var(--line); + margin-bottom: 8px; +} +.forum-op .forum-content { + margin-top: 16px; +} +.forum-comments-head { + font-family: var(--font-mono); + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--ink-3); + margin: 28px 0 16px; +} +.forum-comment { + padding: 18px 0; + border-bottom: 1px solid var(--line); +} +.forum-comment.is-answer { + border-left: 2px solid var(--accent); + padding-left: 16px; + background: var(--accent-soft); + border-radius: 0 6px 6px 0; +} +.forum-answer-badge { + font-family: var(--font-mono); + font-size: 11px; + color: var(--accent); + margin-bottom: 6px; +} +.forum-comment .forum-content { + margin-top: 8px; +} +.forum-replies { + margin-top: 14px; + padding-left: 18px; + border-left: 1px solid var(--line-2); +} +.forum-reply { + border-bottom: none; + padding: 12px 0; +} +.forum-more { + font-family: var(--font-mono); + font-size: 12px; + color: var(--ink-3); + margin: 16px 0; +} +.forum-reply-cta { + margin-top: 20px; +} + +/* GitHub-rendered comment/post HTML */ +.forum-content { + font-size: 15px; + line-height: 1.7; + color: var(--ink-body); + overflow-wrap: break-word; +} +.forum-content > *:first-child { + margin-top: 0; +} +.forum-content > *:last-child { + margin-bottom: 0; +} +.forum-content p, +.forum-content ul, +.forum-content ol, +.forum-content blockquote { + margin: 0 0 14px; +} +.forum-content a { + color: var(--accent); + text-decoration: underline; +} +.forum-content img { + max-width: 100%; + height: auto; + border-radius: 6px; +} +.forum-content pre { + background: #24292e; + color: #e1e4e8; + border-radius: 8px; + padding: 14px 16px; + overflow-x: auto; + font-size: 13px; + margin: 0 0 14px; +} +.forum-content code { + font-family: var(--font-mono); + font-size: 0.9em; + background: var(--paper-2); + padding: 0.1em 0.35em; + border-radius: 4px; +} +.forum-content pre code { + background: none; + padding: 0; + color: inherit; +} +.forum-content blockquote { + border-left: 2px solid var(--line-2); + padding-left: 14px; + color: var(--ink-2); +} +@media (max-width: 640px) { + .forum-head { + flex-direction: column; + align-items: flex-start; + } + .forum-thread-title { + font-size: 27px; + } +} From 1f277115300a17a3cc96fc4cfdf7be7ec825d07b Mon Sep 17 00:00:00 2001 From: Dinesh <13635627+HumbleBee14@users.noreply.github.com> Date: Fri, 17 Jul 2026 04:36:06 -0700 Subject: [PATCH 2/8] Forum: inline giscus login+reply per thread (bound by discussion number); drop redundant live-fetch proxy --- functions/api/discussion/[number].ts | 90 ---------------------- src/components/GiscusThread.astro | 86 +++++++++++++++++++++ src/pages/forum/[category]/[slug].astro | 99 +++++-------------------- src/styles/global.css | 4 + 4 files changed, 107 insertions(+), 172 deletions(-) delete mode 100644 functions/api/discussion/[number].ts create mode 100644 src/components/GiscusThread.astro diff --git a/functions/api/discussion/[number].ts b/functions/api/discussion/[number].ts deleted file mode 100644 index 54d5892..0000000 --- a/functions/api/discussion/[number].ts +++ /dev/null @@ -1,90 +0,0 @@ -// Read-only proxy: returns a discussion's latest comments so a thread page can -// refresh past its build-time snapshot. Holds the token server-side. Degrades to -// an empty payload (the page keeps its baked-in comments) on any failure. - -type Env = { GITHUB_TOKEN?: string; GH_TOKEN?: string }; - -const OWNER = 'MLSysDev'; -const NAME = 'mlsystems.dev'; -const CAP = 50; - -const QUERY = ` -query($owner:String!,$name:String!,$number:Int!){ - repository(owner:$owner,name:$name){ - discussion(number:$number){ - comments(first:${CAP}){ - totalCount - nodes{ - bodyHTML createdAt isAnswer - author{ login url } - replies(first:20){ nodes{ bodyHTML createdAt author{ login url } } } - } - } - } - } -}`; - -function json(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { - 'content-type': 'application/json', - // Short edge cache smooths bursts without masking new replies for long. - 'cache-control': 'public, max-age=60', - }, - }); -} - -export async function onRequestGet(context: { - params: { number: string }; - env: Env; -}): Promise { - const number = Number(context.params.number); - if (!Number.isInteger(number) || number <= 0) return json({ comments: [] }, 400); - - const token = context.env.GITHUB_TOKEN || context.env.GH_TOKEN; - if (!token) return json({ comments: [] }); - - try { - const res = await fetch('https://api.github.com/graphql', { - method: 'POST', - headers: { - authorization: `Bearer ${token}`, - 'content-type': 'application/json', - 'user-agent': 'mlsystems-forum', - }, - body: JSON.stringify({ query: QUERY, variables: { owner: OWNER, name: NAME, number } }), - }); - if (!res.ok) return json({ comments: [] }); - const data = (await res.json()) as { - data?: { - repository?: { - discussion?: { - comments: { - totalCount: number; - nodes: Array<{ - bodyHTML: string; - createdAt: string; - isAnswer: boolean; - author: { login: string; url: string } | null; - replies: { nodes: Array> }; - }>; - }; - } | null; - }; - }; - }; - const c = data.data?.repository?.discussion?.comments; - if (!c) return json({ comments: [] }); - const comments = c.nodes.map((n) => ({ - author: n.author, - bodyHTML: n.bodyHTML, - createdAt: n.createdAt, - isAnswer: n.isAnswer, - replies: n.replies.nodes, - })); - return json({ commentCount: c.totalCount, comments }); - } catch { - return json({ comments: [] }); - } -} diff --git a/src/components/GiscusThread.astro b/src/components/GiscusThread.astro new file mode 100644 index 0000000..b91f72c --- /dev/null +++ b/src/components/GiscusThread.astro @@ -0,0 +1,86 @@ +--- +// Inline GitHub-login discussion for a forum thread. Binds giscus to the thread's +// existing discussion by number, so it shows that thread's comments plus a +// sign-in-and-reply box on our own page — no trip to github.com. Reuses the same +// giscus app/repo already configured for blog comments. +// Note: giscus posts REPLIES to an existing discussion; creating a brand-new +// topic still happens on GitHub (needs full OAuth, out of scope here). +import { SITE } from '@/lib/site'; + +interface Props { + number: number; + /** Static comments to hide once giscus (the live view) has loaded. */ + hideSelector?: string; +} +const { number, hideSelector } = Astro.props; +const repo = SITE.repo; +const repoId = import.meta.env.PUBLIC_GISCUS_REPO_ID as string | undefined; +const configured = Boolean(repoId); +--- + +{ + configured ? ( +
+ ) : ( +

+ Inline sign-in commenting activates once giscus is configured; replies live in{' '} + + this discussion + + . +

+ ) +} + + diff --git a/src/pages/forum/[category]/[slug].astro b/src/pages/forum/[category]/[slug].astro index b3aeeec..821c05b 100644 --- a/src/pages/forum/[category]/[slug].astro +++ b/src/pages/forum/[category]/[slug].astro @@ -3,6 +3,7 @@ import type { GetStaticPaths } from 'astro'; import BaseLayout from '@/layouts/BaseLayout.astro'; import { formatDate } from '@/lib/data'; import { getForum, type ForumComment, type ForumThread } from '@/lib/forum'; +import GiscusThread from '@/components/GiscusThread.astro'; import { SITE } from '@/lib/site'; export const getStaticPaths = (async () => { @@ -66,7 +67,7 @@ const hiddenCount = thread.commentCount - rendered; author={authorName} jsonLd={jsonLd} > -
+
- - diff --git a/src/styles/global.css b/src/styles/global.css index 5e1f2b3..fb5d557 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -3317,3 +3317,7 @@ a.forum-cat:hover { font-size: 27px; } } + +.forum-giscus { + margin-top: 8px; +} From 27f2cf4aa5caba66e31190b60c5bdc22b1a1caa7 Mon Sep 17 00:00:00 2001 From: Dinesh <13635627+HumbleBee14@users.noreply.github.com> Date: Fri, 17 Jul 2026 04:36:44 -0700 Subject: [PATCH 3/8] Document GITHUB_TOKEN for forum build in .env.example --- .env.example | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.env.example b/.env.example index 966b013..67068fa 100644 --- a/.env.example +++ b/.env.example @@ -7,9 +7,18 @@ PUBLIC_GA_MEASUREMENT_ID= # Giscus (GitHub Discussions comments). Get IDs from https://giscus.app # Repo: MLSysDev/mlsystems.dev, Category: Comments, Mapping: pathname. # Leaving these blank shows the "comments not configured" fallback. +# The forum reuses PUBLIC_GISCUS_REPO_ID for inline per-thread commenting. PUBLIC_GISCUS_REPO_ID= PUBLIC_GISCUS_CATEGORY_ID= +# Forum: read-only token used AT BUILD to pull GitHub Discussions into the forum. +# Not PUBLIC_ — server/build only, never shipped to the client. A fine-grained or +# classic PAT with read-only access to this repo's Discussions is enough (public +# repo needs no scopes / `public_repo`). Leave blank to render an empty forum. +# Set the same value in the Cloudflare Pages build env (and Functions env). +# Locally you can reuse the gh CLI token: GITHUB_TOKEN=$(gh auth token) npm run build +GITHUB_TOKEN= + # Show the "Designed share card" opt-in under a cover in /write. Only "true" # enables it; anything else (or unset) is treated as off. Opted-in posts render # a per-post OG card at build, so leave off unless you want that cost. From e530b768c703daa863c8af965aa70fe405530497 Mon Sep 17 00:00:00 2001 From: Dinesh <13635627+HumbleBee14@users.noreply.github.com> Date: Fri, 17 Jul 2026 04:49:15 -0700 Subject: [PATCH 4/8] Render category emoji from emojiHTML (real glyph, not :shortcode:) --- src/lib/forum.ts | 47 +++++++++++++++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/src/lib/forum.ts b/src/lib/forum.ts index dacdf28..964f285 100644 --- a/src/lib/forum.ts +++ b/src/lib/forum.ts @@ -63,6 +63,11 @@ function token(): string | undefined { ); } +// GitHub returns the category emoji as HTML (
🙏
); strip it to the char. +function emojiFromHtml(html: string): string { + return html.replace(/<[^>]*>/g, '').trim(); +} + // One discussion slug = number + title, so it never collides with a page number // and stays stable if the title is later edited (the number anchors it). export function threadSlug(number: number, title: string): string { @@ -79,7 +84,7 @@ query($owner:String!,$name:String!,$after:String){ pageInfo{ hasNextPage endCursor } nodes{ number title bodyHTML createdAt updatedAt url upvoteCount - category{ name slug emoji isAnswerable } + category{ name slug emojiHTML isAnswerable } author{ login url avatarUrl } comments(first:${COMMENT_RENDER_CAP}){ totalCount @@ -106,6 +111,8 @@ type RawComment = { replies?: { nodes: RawComment[] }; }; +type RawCategory = { name: string; slug: string; emojiHTML: string; isAnswerable: boolean }; + type RawDiscussion = { number: number; title: string; @@ -114,7 +121,7 @@ type RawDiscussion = { updatedAt: string; url: string; upvoteCount: number; - category: ForumCategory | null; + category: RawCategory | null; author: ForumAuthor | null; comments: { totalCount: number; nodes: RawComment[] }; }; @@ -193,20 +200,28 @@ export async function getForum(): Promise { const threads: ForumThread[] = raw .filter((d) => d.category && !EXCLUDED_CATEGORY_SLUGS.has(d.category.slug)) - .map((d) => ({ - number: d.number, - title: d.title, - slug: threadSlug(d.number, d.title), - bodyHTML: d.bodyHTML, - createdAt: d.createdAt, - updatedAt: d.updatedAt, - url: d.url, - upvoteCount: d.upvoteCount, - category: d.category as ForumCategory, - author: d.author, - commentCount: d.comments.totalCount, - comments: d.comments.nodes.map(shapeComment), - })); + .map((d) => { + const c = d.category as RawCategory; + return { + number: d.number, + title: d.title, + slug: threadSlug(d.number, d.title), + bodyHTML: d.bodyHTML, + createdAt: d.createdAt, + updatedAt: d.updatedAt, + url: d.url, + upvoteCount: d.upvoteCount, + category: { + name: c.name, + slug: c.slug, + emoji: emojiFromHtml(c.emojiHTML), + isAnswerable: c.isAnswerable, + }, + author: d.author, + commentCount: d.comments.totalCount, + comments: d.comments.nodes.map(shapeComment), + }; + }); // Only surface categories that actually contain forum threads. const bySlug = new Map(); From 9ab363b5231b2d59f201785f692d53895fd32b5e Mon Sep 17 00:00:00 2001 From: Dinesh <13635627+HumbleBee14@users.noreply.github.com> Date: Fri, 17 Jul 2026 04:50:38 -0700 Subject: [PATCH 5/8] Exclude Polls category from forum (poll widget not in bodyHTML) --- src/lib/forum.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/lib/forum.ts b/src/lib/forum.ts index 964f285..6435023 100644 --- a/src/lib/forum.ts +++ b/src/lib/forum.ts @@ -9,8 +9,10 @@ import { tagSlug } from './data'; const OWNER = 'MLSysDev'; const NAME = 'mlsystems.dev'; -// giscus stores per-post blog comments in this category — never a forum thread. -const EXCLUDED_CATEGORY_SLUGS = new Set(['comments']); +// 'comments' holds giscus per-post blog comments (not forum threads). 'polls' is +// an interactive GitHub widget whose options/results aren't in bodyHTML, so it +// can't render meaningfully here — voting stays on GitHub. +const EXCLUDED_CATEGORY_SLUGS = new Set(['comments', 'polls']); // Cap rendered comments per thread; commentCount still reports the true total to // readers and to search engines, with a "continue on GitHub" link for the rest. From 48c717cdd2980015106e11af49689558fcb27675 Mon Sep 17 00:00:00 2001 From: Dinesh <13635627+HumbleBee14@users.noreply.github.com> Date: Fri, 17 Jul 2026 04:52:44 -0700 Subject: [PATCH 6/8] Remove code comments from forum modules --- .github/workflows/forum-sync.yml | 6 +----- src/components/GiscusThread.astro | 10 ---------- src/lib/forum.ts | 17 ----------------- src/pages/forum/[category]/[slug].astro | 1 - 4 files changed, 1 insertion(+), 33 deletions(-) diff --git a/.github/workflows/forum-sync.yml b/.github/workflows/forum-sync.yml index 5236b40..d9ddcbc 100644 --- a/.github/workflows/forum-sync.yml +++ b/.github/workflows/forum-sync.yml @@ -1,8 +1,4 @@ -# Rebuilds the site when a GitHub Discussion changes, so the forum mirror and its -# SEO snapshot stay current. Between events, a daily cron catches anything missed -# (e.g. edits/deletes that don't always fire). Requires a repo secret -# CLOUDFLARE_DEPLOY_HOOK_URL — the Deploy Hook URL from the Cloudflare Pages project -# (Settings → Builds → Add deploy hook). No-ops quietly if the secret is unset. +# Requires repo secret CLOUDFLARE_DEPLOY_HOOK_URL (Cloudflare Pages deploy hook). name: Forum sync on: diff --git a/src/components/GiscusThread.astro b/src/components/GiscusThread.astro index b91f72c..b43a4a1 100644 --- a/src/components/GiscusThread.astro +++ b/src/components/GiscusThread.astro @@ -1,15 +1,8 @@ --- -// Inline GitHub-login discussion for a forum thread. Binds giscus to the thread's -// existing discussion by number, so it shows that thread's comments plus a -// sign-in-and-reply box on our own page — no trip to github.com. Reuses the same -// giscus app/repo already configured for blog comments. -// Note: giscus posts REPLIES to an existing discussion; creating a brand-new -// topic still happens on GitHub (needs full OAuth, out of scope here). import { SITE } from '@/lib/site'; interface Props { number: number; - /** Static comments to hide once giscus (the live view) has loaded. */ hideSelector?: string; } const { number, hideSelector } = Astro.props; @@ -62,8 +55,6 @@ const configured = Boolean(repoId); s.setAttribute('data-lang', 'en'); mount.appendChild(s); - // Swap the static (SEO) comments for the live giscus view only once giscus is - // actually up, so there's no empty gap while it loads. const hide = mount.dataset.hide; window.addEventListener('message', function onMsg(e: MessageEvent) { if (e.origin !== 'https://giscus.app') return; @@ -74,7 +65,6 @@ const configured = Boolean(repoId); window.removeEventListener('message', onMsg); }); - // Keep giscus in sync with the site theme toggle. new MutationObserver(() => { const f = document.querySelector('iframe.giscus-frame'); f?.contentWindow?.postMessage( diff --git a/src/lib/forum.ts b/src/lib/forum.ts index 6435023..f4efef3 100644 --- a/src/lib/forum.ts +++ b/src/lib/forum.ts @@ -1,21 +1,10 @@ -// Server-only: mirrors GitHub Discussions into forum data at build time. -// All writing happens on GitHub; this module is read-only. Never import it into -// client-bundled code — it uses a build-time token. Fetch is fully defensive: -// no token, a failed request, or Discussions being empty all yield an empty -// forum (with a warning) rather than a broken build. - import { tagSlug } from './data'; const OWNER = 'MLSysDev'; const NAME = 'mlsystems.dev'; -// 'comments' holds giscus per-post blog comments (not forum threads). 'polls' is -// an interactive GitHub widget whose options/results aren't in bodyHTML, so it -// can't render meaningfully here — voting stays on GitHub. const EXCLUDED_CATEGORY_SLUGS = new Set(['comments', 'polls']); -// Cap rendered comments per thread; commentCount still reports the true total to -// readers and to search engines, with a "continue on GitHub" link for the rest. export const COMMENT_RENDER_CAP = 50; export type ForumAuthor = { login: string; url: string; avatarUrl: string }; @@ -55,7 +44,6 @@ export type ForumThread = { export type Forum = { categories: ForumCategory[]; threads: ForumThread[] }; function token(): string | undefined { - // Cloudflare Pages / CI expose the token as an env var at build time. return ( import.meta.env.GITHUB_TOKEN || import.meta.env.GH_TOKEN || @@ -65,13 +53,10 @@ function token(): string | undefined { ); } -// GitHub returns the category emoji as HTML (
🙏
); strip it to the char. function emojiFromHtml(html: string): string { return html.replace(/<[^>]*>/g, '').trim(); } -// One discussion slug = number + title, so it never collides with a page number -// and stays stable if the title is later edited (the number anchors it). export function threadSlug(number: number, title: string): string { const s = tagSlug(title) .slice(0, 60) @@ -143,7 +128,6 @@ function shapeComment(c: RawComment): ForumComment { async function fetchAll(gh: string): Promise { const out: RawDiscussion[] = []; let after: string | null = null; - // Bound the loop so a surprise of thousands of threads can't stall a build. for (let page = 0; page < 40; page++) { const res = await fetch('https://api.github.com/graphql', { method: 'POST', @@ -225,7 +209,6 @@ export async function getForum(): Promise { }; }); - // Only surface categories that actually contain forum threads. const bySlug = new Map(); for (const t of threads) bySlug.set(t.category.slug, t.category); const categories = [...bySlug.values()].sort((a, b) => a.name.localeCompare(b.name)); diff --git a/src/pages/forum/[category]/[slug].astro b/src/pages/forum/[category]/[slug].astro index 821c05b..42105db 100644 --- a/src/pages/forum/[category]/[slug].astro +++ b/src/pages/forum/[category]/[slug].astro @@ -27,7 +27,6 @@ const plain = (html: string) => .replace(/\s+/g, ' ') .trim(); -// A Q&A category maps to QAPage; everything else to DiscussionForumPosting. const commentLd = (c: ForumComment): Record => ({ '@type': 'Comment', text: plain(c.bodyHTML).slice(0, 500), From 69418019c44965341583a2d62d0ee08031df7c76 Mon Sep 17 00:00:00 2001 From: Dinesh <13635627+HumbleBee14@users.noreply.github.com> Date: Fri, 17 Jul 2026 04:56:33 -0700 Subject: [PATCH 7/8] Render GitHub Discussion polls read-only; add forum docs --- docs/forum.md | 57 +++++++++++++++++++++++++ src/lib/forum.ts | 27 +++++++++++- src/pages/forum/[category]/[slug].astro | 36 ++++++++++++++++ src/styles/global.css | 43 +++++++++++++++++++ 4 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 docs/forum.md diff --git a/docs/forum.md b/docs/forum.md new file mode 100644 index 0000000..5582ee4 --- /dev/null +++ b/docs/forum.md @@ -0,0 +1,57 @@ +# Forum (GitHub Discussions mirror) + +The `/forum` section is a read-only, SEO-indexed mirror of this repo's +**GitHub Discussions**. GitHub is the source of truth for everything — +identity, posting, moderation, notifications, `@mentions`. The website fetches +discussions at build time, renders them as static pages Google can index, and +lets people **read live and comment inline** without leaving the site. + +## How people use it + +**Start a new topic** — on GitHub: repo → **Discussions → New discussion**, +pick a category. (The "Start a discussion" button on `/forum` links here.) +It appears on the site after the next build. + +**Comment / reply** — inline on the thread page, using the giscus +"Sign in with GitHub" box. Replies post to the GitHub discussion and show up +immediately in the widget. + +**Vote in a poll** — poll results render on the site; the "Vote on GitHub" link +opens the poll to cast a vote. (giscus can't vote inline.) + +## What shows up + +Every discussion category **except**: + +- **Comments** — used by giscus for blog-post comments, not forum threads. +- Any other slug listed in `EXCLUDED_CATEGORY_SLUGS` in `src/lib/forum.ts`. + +Categories are pulled from GitHub automatically — add or rename one in +**Settings → Discussions** and it flows through on the next build. Nothing is +hardcoded. + +## Build & environment setup + +The forum needs a read-only token at **build time** to read Discussions, and a +deploy hook so it rebuilds when discussions change. + +| Where | Name | Purpose | +| ----------------------------------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| Cloudflare Pages → Settings → Variables (build) | `GITHUB_TOKEN` | Lets the build read Discussions. Without it the forum renders empty. Fine-grained PAT, read-only Discussions on this repo. | +| GitHub → Settings → Secrets → Actions | `CLOUDFLARE_DEPLOY_HOOK_URL` | The Cloudflare Pages deploy-hook URL. The `forum-sync` workflow POSTs to it. | + +The deploy hook itself is created in **Cloudflare Pages → Settings → Builds → +Deploy hooks** (branch `main`); copy its URL into the GitHub secret above. + +`.github/workflows/forum-sync.yml` triggers a rebuild on discussion and comment +events, plus a daily cron. If the secret is unset it no-ops. + +Inline commenting reuses the same `PUBLIC_GISCUS_REPO_ID` already configured for +blog comments — no extra setup. + +### Test the build locally + +```bash +GITHUB_TOKEN=$(gh auth token) npm run build && npm run preview +# open http://localhost:4321/forum +``` diff --git a/src/lib/forum.ts b/src/lib/forum.ts index f4efef3..dc28cbe 100644 --- a/src/lib/forum.ts +++ b/src/lib/forum.ts @@ -3,7 +3,7 @@ import { tagSlug } from './data'; const OWNER = 'MLSysDev'; const NAME = 'mlsystems.dev'; -const EXCLUDED_CATEGORY_SLUGS = new Set(['comments', 'polls']); +const EXCLUDED_CATEGORY_SLUGS = new Set(['comments']); export const COMMENT_RENDER_CAP = 50; @@ -26,6 +26,12 @@ export type ForumComment = { replies: ForumComment[]; }; +export type ForumPoll = { + question: string; + totalVoteCount: number; + options: { option: string; votes: number }[]; +}; + export type ForumThread = { number: number; title: string; @@ -39,6 +45,7 @@ export type ForumThread = { author: ForumAuthor | null; commentCount: number; comments: ForumComment[]; + poll: ForumPoll | null; }; export type Forum = { categories: ForumCategory[]; threads: ForumThread[] }; @@ -73,6 +80,7 @@ query($owner:String!,$name:String!,$after:String){ number title bodyHTML createdAt updatedAt url upvoteCount category{ name slug emojiHTML isAnswerable } author{ login url avatarUrl } + poll{ question totalVoteCount options(first:10){ nodes{ option totalVoteCount } } } comments(first:${COMMENT_RENDER_CAP}){ totalCount nodes{ @@ -100,6 +108,12 @@ type RawComment = { type RawCategory = { name: string; slug: string; emojiHTML: string; isAnswerable: boolean }; +type RawPoll = { + question: string; + totalVoteCount: number; + options: { nodes: { option: string; totalVoteCount: number }[] }; +} | null; + type RawDiscussion = { number: number; title: string; @@ -110,6 +124,7 @@ type RawDiscussion = { upvoteCount: number; category: RawCategory | null; author: ForumAuthor | null; + poll: RawPoll; comments: { totalCount: number; nodes: RawComment[] }; }; @@ -206,6 +221,16 @@ export async function getForum(): Promise { author: d.author, commentCount: d.comments.totalCount, comments: d.comments.nodes.map(shapeComment), + poll: d.poll + ? { + question: d.poll.question, + totalVoteCount: d.poll.totalVoteCount, + options: d.poll.options.nodes.map((o) => ({ + option: o.option, + votes: o.totalVoteCount, + })), + } + : null, }; }); diff --git a/src/pages/forum/[category]/[slug].astro b/src/pages/forum/[category]/[slug].astro index 42105db..b1230bc 100644 --- a/src/pages/forum/[category]/[slug].astro +++ b/src/pages/forum/[category]/[slug].astro @@ -53,6 +53,15 @@ const jsonLd = { '@context': 'https://schema.org', ...postLd }; const rendered = thread.comments.length; const hiddenCount = thread.commentCount - rendered; + +const poll = thread.poll; +const pollOptions = poll + ? poll.options.map((o) => ({ + label: o.option, + votes: o.votes, + pct: poll.totalVoteCount ? Math.round((o.votes / poll.totalVoteCount) * 100) : 0, + })) + : []; --- Q&A}
+ + { + poll && ( +
+
{poll.question}
+ {pollOptions.map((o) => ( +
+
+ {o.label} + + {o.pct}% · {o.votes} + +
+
+
+
+
+ ))} +
+ {poll.totalVoteCount} {poll.totalVoteCount === 1 ? 'vote' : 'votes'} ·{' '} + + Vote on GitHub ↗ + +
+
+ ) + }
diff --git a/src/styles/global.css b/src/styles/global.css index fb5d557..8e19ec2 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -3321,3 +3321,46 @@ a.forum-cat:hover { .forum-giscus { margin-top: 8px; } + +.forum-poll { + margin: 20px 0; + padding: 18px 20px; + border: 1px solid var(--line-2); + border-radius: 10px; + background: var(--paper-2); +} +.forum-poll-q { + font-family: var(--font-display); + font-size: 17px; + margin-bottom: 14px; +} +.forum-poll-opt { + margin-bottom: 12px; +} +.forum-poll-opt-top { + display: flex; + justify-content: space-between; + font-size: 13px; + color: var(--ink-2); + margin-bottom: 4px; +} +.forum-poll-bar { + height: 8px; + border-radius: var(--radius-pill); + background: var(--paper-3); + overflow: hidden; +} +.forum-poll-fill { + height: 100%; + background: var(--accent); + border-radius: var(--radius-pill); +} +.forum-poll-meta { + margin-top: 14px; + font-family: var(--font-mono); + font-size: 12px; + color: var(--ink-3); +} +.forum-poll-meta a { + color: var(--accent); +} From f1a05cf9f0548204ff263c4a84f8257ea4fc7334 Mon Sep 17 00:00:00 2001 From: Dinesh <13635627+HumbleBee14@users.noreply.github.com> Date: Fri, 17 Jul 2026 05:27:29 -0700 Subject: [PATCH 8/8] Forum: live poll vote counts, clickable poll bars, on-site search of comments, rebuild only on discussion changes, copy cleanup --- .github/workflows/forum-sync.yml | 4 +- docs/forum.md | 14 +++-- functions/api/poll/[number].ts | 66 ++++++++++++++++++++ src/pages/forum/[...page].astro | 4 +- src/pages/forum/[category]/[slug].astro | 47 +++++++++++--- src/pages/forum/c/[category]/[...page].astro | 2 +- src/styles/global.css | 12 +++- 7 files changed, 127 insertions(+), 22 deletions(-) create mode 100644 functions/api/poll/[number].ts diff --git a/.github/workflows/forum-sync.yml b/.github/workflows/forum-sync.yml index d9ddcbc..de667fa 100644 --- a/.github/workflows/forum-sync.yml +++ b/.github/workflows/forum-sync.yml @@ -3,9 +3,7 @@ name: Forum sync on: discussion: - types: [created, edited, deleted, category_changed, answered, unanswered] - discussion_comment: - types: [created, edited, deleted] + types: [created, edited, deleted, category_changed] schedule: - cron: '0 6 * * *' # daily 06:00 UTC safety rebuild workflow_dispatch: {} diff --git a/docs/forum.md b/docs/forum.md index 5582ee4..c5acd2b 100644 --- a/docs/forum.md +++ b/docs/forum.md @@ -35,16 +35,18 @@ hardcoded. The forum needs a read-only token at **build time** to read Discussions, and a deploy hook so it rebuilds when discussions change. -| Where | Name | Purpose | -| ----------------------------------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| Cloudflare Pages → Settings → Variables (build) | `GITHUB_TOKEN` | Lets the build read Discussions. Without it the forum renders empty. Fine-grained PAT, read-only Discussions on this repo. | -| GitHub → Settings → Secrets → Actions | `CLOUDFLARE_DEPLOY_HOOK_URL` | The Cloudflare Pages deploy-hook URL. The `forum-sync` workflow POSTs to it. | +| Where | Name | Purpose | +| ----------------------------------------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Cloudflare Pages → Settings → Variables (build + Functions) | `GITHUB_TOKEN` | Read Discussions at build (forum pages) and at runtime (the `/api/poll` live vote counts). Without it the forum renders empty. Fine-grained PAT, read-only Discussions on this repo. | +| GitHub → Settings → Secrets → Actions | `CLOUDFLARE_DEPLOY_HOOK_URL` | The Cloudflare Pages deploy-hook URL. The `forum-sync` workflow POSTs to it. | The deploy hook itself is created in **Cloudflare Pages → Settings → Builds → Deploy hooks** (branch `main`); copy its URL into the GitHub secret above. -`.github/workflows/forum-sync.yml` triggers a rebuild on discussion and comment -events, plus a daily cron. If the secret is unset it no-ops. +`.github/workflows/forum-sync.yml` triggers a rebuild when a discussion is +created, edited, deleted, or moved category — plus a daily cron. Comments and +poll votes are live (giscus / `/api/poll`), so they never trigger a rebuild. +If the secret is unset it no-ops. Inline commenting reuses the same `PUBLIC_GISCUS_REPO_ID` already configured for blog comments — no extra setup. diff --git a/functions/api/poll/[number].ts b/functions/api/poll/[number].ts new file mode 100644 index 0000000..427c293 --- /dev/null +++ b/functions/api/poll/[number].ts @@ -0,0 +1,66 @@ +type Env = { GITHUB_TOKEN?: string; GH_TOKEN?: string }; + +const OWNER = 'MLSysDev'; +const NAME = 'mlsystems.dev'; + +const QUERY = ` +query($owner:String!,$name:String!,$number:Int!){ + repository(owner:$owner,name:$name){ + discussion(number:$number){ + poll{ question totalVoteCount options(first:10){ nodes{ option totalVoteCount } } } + } + } +}`; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json', 'cache-control': 'no-store' }, + }); +} + +export async function onRequestGet(context: { + params: { number: string }; + env: Env; +}): Promise { + const number = Number(context.params.number); + if (!Number.isInteger(number) || number <= 0) return json({ options: [] }, 400); + + const token = context.env.GITHUB_TOKEN || context.env.GH_TOKEN; + if (!token) return json({ options: [] }); + + try { + const res = await fetch('https://api.github.com/graphql', { + method: 'POST', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + 'user-agent': 'mlsystems-forum', + }, + body: JSON.stringify({ query: QUERY, variables: { owner: OWNER, name: NAME, number } }), + }); + if (!res.ok) return json({ options: [] }); + const data = (await res.json()) as { + data?: { + repository?: { + discussion?: { + poll?: { + question: string; + totalVoteCount: number; + options: { nodes: { option: string; totalVoteCount: number }[] }; + } | null; + } | null; + }; + }; + }; + const poll = data.data?.repository?.discussion?.poll; + if (!poll) return json({ options: [] }); + return json({ + question: poll.question, + totalVoteCount: poll.totalVoteCount, + options: poll.options.nodes.map((o) => ({ option: o.option, votes: o.totalVoteCount })), + }); + } catch { + return json({ options: [] }); + } +} diff --git a/src/pages/forum/[...page].astro b/src/pages/forum/[...page].astro index c928a4d..97cd37a 100644 --- a/src/pages/forum/[...page].astro +++ b/src/pages/forum/[...page].astro @@ -20,7 +20,7 @@ const { page, categories } = Astro.props; 1 ? `Forum — page ${page.currentPage}` : 'Forum'} - description="Community discussion on machine learning systems — questions, ideas, and show-and-tell, mirrored from GitHub Discussions." + description="Community discussion on machine learning systems — questions, ideas, and show-and-tell from people building in the space." >
@@ -28,7 +28,7 @@ const { page, categories } = Astro.props;
Community

Forum.

- Questions, ideas, and things people built — hosted on GitHub Discussions, indexed here. + Questions, ideas, and things people built — ask, share, and discuss with the community.

+
{poll.question}
{pollOptions.map((o) => ( -
+
{o.label} - + {o.pct}% · {o.votes}
-
-
-
+
+ ))}
- {poll.totalVoteCount} {poll.totalVoteCount === 1 ? 'vote' : 'votes'} ·{' '} + {poll.totalVoteCount} votes ·{' '} Vote on GitHub ↗ @@ -130,7 +136,7 @@ const pollOptions = poll
-
+

{thread.commentCount} {thread.commentCount === 1 ? ' reply' : ' replies'} @@ -190,3 +196,28 @@ const pollOptions = poll

+ + diff --git a/src/pages/forum/c/[category]/[...page].astro b/src/pages/forum/c/[category]/[...page].astro index b828623..030e295 100644 --- a/src/pages/forum/c/[category]/[...page].astro +++ b/src/pages/forum/c/[category]/[...page].astro @@ -27,7 +27,7 @@ const suffix = page.currentPage > 1 ? ` — page ${page.currentPage}` : '';
diff --git a/src/styles/global.css b/src/styles/global.css index 8e19ec2..caad1ef 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -3063,7 +3063,7 @@ a.hashtag:hover { } .forum-head { display: flex; - align-items: flex-end; + align-items: center; justify-content: space-between; gap: 24px; margin-bottom: 28px; @@ -3079,7 +3079,6 @@ a.hashtag:hover { color: var(--ink-2); font-size: 15px; margin: 8px 0 0; - max-width: 52ch; } .forum-new { flex-shrink: 0; @@ -3345,10 +3344,19 @@ a.forum-cat:hover { margin-bottom: 4px; } .forum-poll-bar { + display: block; height: 8px; border-radius: var(--radius-pill); background: var(--paper-3); overflow: hidden; + cursor: pointer; +} +a.forum-poll-bar:hover { + outline: 2px solid var(--accent-soft); + outline-offset: 2px; +} +a.forum-poll-bar:hover .forum-poll-fill { + background: var(--accent-2, var(--accent)); } .forum-poll-fill { height: 100%;