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. diff --git a/.github/workflows/forum-sync.yml b/.github/workflows/forum-sync.yml new file mode 100644 index 0000000..de667fa --- /dev/null +++ b/.github/workflows/forum-sync.yml @@ -0,0 +1,28 @@ +# Requires repo secret CLOUDFLARE_DEPLOY_HOOK_URL (Cloudflare Pages deploy hook). +name: Forum sync + +on: + discussion: + types: [created, edited, deleted, category_changed] + 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/docs/forum.md b/docs/forum.md new file mode 100644 index 0000000..c5acd2b --- /dev/null +++ b/docs/forum.md @@ -0,0 +1,59 @@ +# 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 + 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 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. + +### Test the build locally + +```bash +GITHUB_TOKEN=$(gh auth token) npm run build && npm run preview +# open http://localhost:4321/forum +``` 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/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/GiscusThread.astro b/src/components/GiscusThread.astro new file mode 100644 index 0000000..b43a4a1 --- /dev/null +++ b/src/components/GiscusThread.astro @@ -0,0 +1,76 @@ +--- +import { SITE } from '@/lib/site'; + +interface Props { + number: number; + 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/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..dc28cbe --- /dev/null +++ b/src/lib/forum.ts @@ -0,0 +1,247 @@ +import { tagSlug } from './data'; + +const OWNER = 'MLSysDev'; +const NAME = 'mlsystems.dev'; + +const EXCLUDED_CATEGORY_SLUGS = new Set(['comments']); + +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 ForumPoll = { + question: string; + totalVoteCount: number; + options: { option: string; votes: number }[]; +}; + +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[]; + poll: ForumPoll | null; +}; + +export type Forum = { categories: ForumCategory[]; threads: ForumThread[] }; + +function token(): string | undefined { + return ( + import.meta.env.GITHUB_TOKEN || + import.meta.env.GH_TOKEN || + (typeof process !== 'undefined' + ? process.env?.GITHUB_TOKEN || process.env?.GH_TOKEN + : undefined) + ); +} + +function emojiFromHtml(html: string): string { + return html.replace(/<[^>]*>/g, '').trim(); +} + +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 emojiHTML isAnswerable } + author{ login url avatarUrl } + poll{ question totalVoteCount options(first:10){ nodes{ option totalVoteCount } } } + 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 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; + bodyHTML: string; + createdAt: string; + updatedAt: string; + url: string; + upvoteCount: number; + category: RawCategory | null; + author: ForumAuthor | null; + poll: RawPoll; + 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; + 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) => { + 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), + 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, + }; + }); + + 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..97cd37a --- /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 from people building in the space." +> +
+
+
+
Community
+

Forum.

+

+ Questions, ideas, and things people built — ask, share, and discuss with the community. +

+
+ + 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..a7d250b --- /dev/null +++ b/src/pages/forum/[category]/[slug].astro @@ -0,0 +1,223 @@ +--- +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 () => { + 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(); + +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; + +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, + })) + : []; +--- + + +
+ + +
+

{thread.title}

+
+ { + thread.author?.url ? ( + + @{authorName} + + ) : ( + {authorName} + ) + } + · + {formatDate(new Date(thread.createdAt))} + {thread.category.isAnswerable && Q&A} +
+
+ + { + poll && ( +
+
{poll.question}
+ {pollOptions.map((o) => ( +
+
+ {o.label} + + {o.pct}% · {o.votes} + +
+ +
+ +
+ ))} +
+ {poll.totalVoteCount} votes ·{' '} + + Vote on GitHub ↗ + +
+
+ ) + } +
+ +
+
+

+ {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'} — sign in below to see + all. +

+ ) + } +
+ + +
+
+
+ + diff --git a/src/pages/forum/c/[category]/[...page].astro b/src/pages/forum/c/[category]/[...page].astro new file mode 100644 index 0000000..030e295 --- /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..caad1ef 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -3055,3 +3055,320 @@ 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: center; + 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; +} +.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; + } +} + +.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 { + 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%; + 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); +}