Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 28 additions & 0 deletions .github/workflows/forum-sync.yml
Original file line number Diff line number Diff line change
@@ -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"
59 changes: 59 additions & 0 deletions docs/forum.md
Original file line number Diff line number Diff line change
@@ -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
```
66 changes: 66 additions & 0 deletions functions/api/poll/[number].ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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: [] });
}
}
49 changes: 49 additions & 0 deletions src/components/ForumThreadRow.astro
Original file line number Diff line number Diff line change
@@ -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);
---

<a href={href} class="forum-row">
<div class="forum-row-body">
<div class="forum-row-title-line">
<h3 class="forum-row-title">{thread.title}</h3>
{
showCategory && (
<span class="chip chip--sm">
{thread.category.emoji} {thread.category.name}
</span>
)
}
{
answered && (
<span class="forum-answered" title="Has an accepted answer">
✓ answered
</span>
)
}
</div>
<div class="forum-row-meta">
{thread.author?.login ? <span>@{thread.author.login}</span> : <span>Guest</span>}
<span>·</span>
<span>{formatDate(new Date(thread.createdAt))}</span>
<span>·</span>
<span>{thread.commentCount} {thread.commentCount === 1 ? 'reply' : 'replies'}</span>
{
thread.upvoteCount > 0 && (
<>
<span>·</span>
<span>▲ {thread.upvoteCount}</span>
</>
)
}
</div>
</div>
</a>
76 changes: 76 additions & 0 deletions src/components/GiscusThread.astro
Original file line number Diff line number Diff line change
@@ -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 ? (
<div
class="forum-giscus"
data-giscus-mount
data-repo={repo}
data-repo-id={repoId}
data-term={String(number)}
data-hide={hideSelector ?? ''}
/>
) : (
<p class="forum-more">
Inline sign-in commenting activates once giscus is configured; replies live in{' '}
<a href={`${SITE.repoUrl}/discussions/${number}`} target="_blank" rel="noreferrer">
this discussion
</a>
.
</p>
)
}

<script>
const mount = document.querySelector<HTMLElement>('[data-giscus-mount]');
if (mount && !mount.querySelector('script')) {
const themeName = () =>
document.documentElement.getAttribute('data-theme') === 'dark'
? 'noborder_dark'
: 'noborder_light';

const s = document.createElement('script');
s.src = 'https://giscus.app/client.js';
s.async = true;
s.crossOrigin = 'anonymous';
s.setAttribute('data-repo', mount.dataset.repo ?? '');
s.setAttribute('data-repo-id', mount.dataset.repoId ?? '');
s.setAttribute('data-mapping', 'number');
s.setAttribute('data-term', mount.dataset.term ?? '');
s.setAttribute('data-reactions-enabled', '1');
s.setAttribute('data-input-position', 'top');
s.setAttribute('data-theme', themeName());
s.setAttribute('data-loading', 'lazy');
s.setAttribute('data-lang', 'en');
mount.appendChild(s);

const hide = mount.dataset.hide;
window.addEventListener('message', function onMsg(e: MessageEvent) {
if (e.origin !== 'https://giscus.app') return;
if (hide) {
const stat = document.querySelector<HTMLElement>(hide);
if (stat) stat.style.display = 'none';
}
window.removeEventListener('message', onMsg);
});

new MutationObserver(() => {
const f = document.querySelector<HTMLIFrameElement>('iframe.giscus-frame');
f?.contentWindow?.postMessage(
{ giscus: { setConfig: { theme: themeName() } } },
'https://giscus.app',
);
}).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
}
</script>
1 change: 1 addition & 0 deletions src/components/Nav.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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 + '/');
Expand Down
6 changes: 6 additions & 0 deletions src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading