diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68ba080..458c9ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,32 +36,44 @@ jobs: - name: Install dependencies run: bun install - - name: Check proposal matches PR number + - name: Check RFC number matches PR number run: | BASE_SHA="${{ github.event.pull_request.base.sha }}" HEAD_SHA="${{ github.event.pull_request.head.sha }}" PR_NUMBER="${{ github.event.pull_request.number }}" - CHANGED_PROPOSALS=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" -- 'proposals/' \ - | grep -v '^proposals/0000-template\.md$' || true) + # Grandfathered RFCs that predate the PR number convention + GRANDFATHERED="5 15 23 24 27 29 33" - COUNT=$(echo "$CHANGED_PROPOSALS" | grep -c . || true) + # Only check newly added RFC files, not moved/renamed ones + CHANGED_RFCS=$(git diff --name-only --diff-filter=A "$BASE_SHA" "$HEAD_SHA" -- 'rfcs/' \ + | grep -v '^rfcs/0000-template\.md$' || true) + + COUNT=$(echo "$CHANGED_RFCS" | grep -c . || true) if [ "$COUNT" -gt 1 ]; then - echo "::error::PR touches $COUNT proposals. Only one proposal per PR is allowed." - echo "$CHANGED_PROPOSALS" + echo "::error::PR touches $COUNT RFCs. Only one RFC per PR is allowed." + echo "$CHANGED_RFCS" exit 1 fi if [ "$COUNT" -eq 1 ]; then - FILE=$(echo "$CHANGED_PROPOSALS" | head -1) + FILE=$(echo "$CHANGED_RFCS" | head -1) FILENAME=$(basename "$FILE") FILE_NUMBER=$(echo "$FILENAME" | grep -oE '^[0-9]+' || true) # Strip leading zeros for comparison FILE_NUM=$((10#$FILE_NUMBER)) + # Skip check for grandfathered RFCs + for GF in $GRANDFATHERED; do + if [ "$FILE_NUM" -eq "$GF" ]; then + echo "RFC $FILE_NUM is grandfathered, skipping PR number check." + exit 0 + fi + done + if [ "$FILE_NUM" -ne "$PR_NUMBER" ]; then - echo "::error::Proposal number ($FILE_NUM from $FILENAME) does not match PR number ($PR_NUMBER)." + echo "::error::RFC number ($FILE_NUM from $FILENAME) does not match PR number ($PR_NUMBER)." exit 1 fi fi diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5b31b10..5dc87d1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,6 +4,10 @@ on: push: branches: - develop + pull_request: + types: [opened, closed, reopened] + branches: + - develop permissions: contents: read @@ -23,6 +27,7 @@ jobs: - name: Checkout uses: actions/checkout@v4 with: + ref: develop fetch-depth: 0 - name: Setup Bun diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 0000000..1ace045 --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,41 @@ +name: RFC Preview + +on: + pull_request: + branches: + - develop + +permissions: + contents: read + deployments: write + +jobs: + preview: + env: + GH_TOKEN: ${{ github.token }} + runs-on: ubuntu-latest + environment: + name: rfc-preview + url: ${{ steps.upload.outputs.artifact-url }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install + + - name: Build preview + run: bun run index.ts --preview + + - name: Upload RFC preview + id: upload + uses: actions/upload-artifact@v7 + with: + name: rfc-preview-pr-${{ github.event.pull_request.number }} + path: ./dist/preview.html + archive: false diff --git a/CLAUDE.md b/CLAUDE.md index 5fd294c..c983765 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,14 +7,12 @@ Static site generator for Vortex RFC proposals built with Bun. ``` index.ts - Main build script and dev server styles.css - Site styling (light/dark themes) -proposed/ - RFC markdown files in proposed state -accepted/ - RFC markdown files in accepted state -completed/ - RFC markdown files in completed state +rfcs/ - RFC markdown files (merged to develop = accepted) dist/ - Build output (gitignored) ``` -RFC filenames follow the format `NNNN-slug.md` (e.g., `0001-galp-patches.md`). -Numbering is global across all states - no duplicates allowed. +RFC filenames follow the format `NNNN-slug.md` (e.g., `0027-patches-format.md`). +The RFC number must match the PR number used to propose it. No duplicate numbers allowed. ## Commands @@ -26,29 +24,24 @@ bun run clean # Remove dist/ ## How the Build Works -1. Scans `proposed/`, `accepted/`, `completed/` for RFC files +1. Scans `rfcs/` for RFC markdown files 2. Parses RFC number from filename (e.g., `0002-foo.md` → RFC 0002) -3. Determines state from containing folder -4. Extracts title from first `# ` heading -5. Converts markdown to HTML using `Bun.markdown.html()` -6. Generates `dist/index.html` (table of contents with filter UI) -7. Generates `dist/rfc/{number}.html` for each RFC +3. Extracts title from first `# ` heading +4. Converts markdown to HTML using `Bun.markdown.html()` +5. Generates `dist/index.html` (table of contents) +6. Generates `dist/rfc/{number}.html` for each RFC ## Dev Server - Uses `Bun.serve()` to serve static files from `dist/` -- Watches `proposed/`, `accepted/`, `completed/`, and `styles.css` for changes +- Watches `rfcs/` and `styles.css` for changes - SSE endpoint at `/__reload` for live reload -## RFC States +## RFC Workflow -RFCs progress through three states by moving files between folders: - -- **proposed**: New RFCs under discussion -- **accepted**: Approved RFCs ready for implementation -- **completed**: Fully implemented RFCs - -The index page shows a state pill for each RFC and supports filtering by state. +1. Open a PR with a new file `rfcs/NNNN-slug.md` where NNNN matches the PR number +2. PR builds a preview artifact for reviewers +3. Merging the PR to `develop` accepts the RFC ## Styling diff --git a/accepted/.keep b/accepted/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/completed/.keep b/completed/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/index.ts b/index.ts index d825752..a04293d 100644 --- a/index.ts +++ b/index.ts @@ -3,6 +3,7 @@ import { watch } from "fs"; import { createHighlighter, type Highlighter } from "shiki"; const isDev = process.argv.includes("--dev"); +const isPreview = process.argv.includes("--preview"); const PORT = 3000; interface GitCommit { @@ -22,17 +23,20 @@ interface RFCGitInfo { author: GitHubAuthor | null; } -type RFCState = "proposed" | "accepted" | "completed"; - -const RFC_STATES: RFCState[] = ["proposed", "accepted", "completed"]; - interface RFC { number: string; title: string; filename: string; html: string; git: RFCGitInfo; - state: RFCState; +} + +interface ProposedRFC { + number: string; + title: string; + prNumber: number; + prUrl: string; + author: GitHubAuthor | null; } const THEME_SCRIPT = ` @@ -71,33 +75,6 @@ function updateToggleIcon() { document.addEventListener('DOMContentLoaded', updateToggleIcon); `; -const FILTER_SCRIPT = ` -function filterRFCs(state) { - const items = document.querySelectorAll('.rfc-list li'); - const buttons = document.querySelectorAll('.filter-btn'); - - buttons.forEach(btn => { - btn.classList.toggle('active', btn.dataset.state === state); - }); - - items.forEach(item => { - if (state === 'all' || item.dataset.state === state) { - item.style.display = ''; - } else { - item.style.display = 'none'; - } - }); - - // Save filter preference - localStorage.setItem('rfc-filter', state); -} - -document.addEventListener('DOMContentLoaded', function() { - const saved = localStorage.getItem('rfc-filter') || 'all'; - filterRFCs(saved); -}); -`; - const LIVE_RELOAD_SCRIPT = ` (function() { const evtSource = new EventSource('/__reload'); @@ -160,12 +137,9 @@ function escapeHTML(str: string): string { .replace(/"/g, """); } -function stateLabel(state: RFCState): string { - return state.charAt(0).toUpperCase() + state.slice(1); -} - function indexPage( rfcs: RFC[], + proposed: ProposedRFC[], repoUrl: string | null, liveReload: boolean = false, ): string { @@ -189,10 +163,9 @@ function indexPage( } return ` -
  • +
  • RFC ${rfc.number} - ${stateLabel(rfc.state)} ${escapeHTML(rfc.title)} ${dateStr} ${authorHTML} @@ -200,22 +173,45 @@ function indexPage( }) .join("\n"); - const filterButtons = ` -
    - - - - -
    `; + let proposedSection = ""; + if (proposed.length > 0) { + const proposedList = proposed + .map((rfc) => { + let authorHTML = ""; + if (rfc.author) { + authorHTML = ` + + ${rfc.author.login} + ${rfc.author.login} + `; + } + + return ` +
  • + + RFC ${rfc.number} + ${escapeHTML(rfc.title)} + PR #${rfc.prNumber} + ${authorHTML} +
  • `; + }) + .join("\n"); + + proposedSection = ` +

    Proposed

    + `; + } const content = `

    Request for Comments

    Technical proposals for the Vortex file format.

    -${filterButtons} +${proposedSection} +

    Accepted

    - `; + `; return baseHTML("Vortex RFCs", content, "styles.css", liveReload, repoUrl); } @@ -236,7 +232,7 @@ function rfcPage( let gitHeader = `
    - ${stateLabel(rfc.state)} + Accepted
    `; if (rfc.git.accepted || rfc.git.author) { @@ -390,34 +386,28 @@ interface ValidationError { async function validateProposals(): Promise { const errors: ValidationError[] = []; const glob = new Bun.Glob("*"); - const seenNumbers = new Map(); // number -> "folder/filename" - - for (const state of RFC_STATES) { - const folder = `./${state}`; - - for await (const filename of glob.scan(folder)) { - const fullPath = `${state}/${filename}`; - - // Check filename format: NNNN-slug.md - if (!filename.match(/^\d{4}-[a-zA-Z0-9_-]+\.md$/)) { - errors.push({ - filename: fullPath, - message: `Invalid filename format. Expected: NNNN-name.md (e.g., 0007-my-proposal.md)`, - }); - continue; - } + const seenNumbers = new Map(); + + for await (const filename of glob.scan("./rfcs")) { + // Check filename format: NNNN-slug.md + if (!filename.match(/^\d{4}-[a-zA-Z0-9_-]+\.md$/)) { + errors.push({ + filename: `rfcs/${filename}`, + message: `Invalid filename format. Expected: NNNN-name.md (e.g., 0007-my-proposal.md)`, + }); + continue; + } - // Check for duplicate RFC numbers across all folders - const number = filename.slice(0, 4); - const existing = seenNumbers.get(number); - if (existing) { - errors.push({ - filename: fullPath, - message: `Duplicate RFC number ${number} (also used by ${existing})`, - }); - } else { - seenNumbers.set(number, fullPath); - } + // Check for duplicate RFC numbers + const number = filename.slice(0, 4); + const existing = seenNumbers.get(number); + if (existing) { + errors.push({ + filename: `rfcs/${filename}`, + message: `Duplicate RFC number ${number} (also used by ${existing})`, + }); + } else { + seenNumbers.set(number, `rfcs/${filename}`); } } @@ -493,6 +483,50 @@ async function highlightCodeBlocks(html: string): Promise { return result; } +async function getProposedRFCs( + repoPath: string | null, +): Promise { + if (!repoPath) return []; + try { + const result = + await $`gh pr list --repo ${repoPath} --state open --json number,title,url,files,author`.quiet(); + const prs = JSON.parse(result.stdout.toString()); + const proposed: ProposedRFC[] = []; + + for (const pr of prs) { + // Find RFC files in the PR's changed files (match rfcs/, proposed/, proposals/) + const rfcFile = pr.files?.find( + (f: { path: string }) => + f.path.match( + /^(rfcs|proposed|proposals)\/\d{4}-[a-zA-Z0-9_-]+\.md$/, + ) && !f.path.endsWith("/0000-template.md"), + ); + if (!rfcFile) continue; + + const rfcNumber = rfcFile.path.match(/(\d{4})-/)?.[1]; + if (!rfcNumber) continue; + + proposed.push({ + number: rfcNumber, + title: pr.title, + prNumber: pr.number, + prUrl: pr.url, + author: pr.author + ? { + login: pr.author.login, + avatarUrl: `https://github.com/${pr.author.login}.png?size=48`, + profileUrl: `https://github.com/${pr.author.login}`, + } + : null, + }); + } + + return proposed.sort((a, b) => b.number.localeCompare(a.number)); + } catch { + return []; + } +} + async function build(liveReload: boolean = false): Promise { console.log("Building Vortex RFC site...\n"); @@ -515,29 +549,22 @@ async function build(liveReload: boolean = false): Promise { const glob = new Bun.Glob("*.md"); const rfcs: RFC[] = []; - // Parse all RFC markdown files from each state folder - for (const state of RFC_STATES) { - const folder = `./${state}`; + for await (const filename of glob.scan("./rfcs")) { + console.log(`Processing rfcs/${filename}...`); - for await (const filename of glob.scan(folder)) { - console.log(`Processing ${state}/${filename}...`); + const path = `./rfcs/${filename}`; + const content = await Bun.file(path).text(); + const rawHtml = Bun.markdown.html(content, { autolinks: true }); + const html = await highlightCodeBlocks(rawHtml); + const number = parseRFCNumber(filename); + const title = parseTitle(content, filename); + const git = await getGitHistory(path, repoPath); - const path = `${folder}/${filename}`; - const content = await Bun.file(path).text(); - const rawHtml = Bun.markdown.html(content, { autolinks: true }); - const html = await highlightCodeBlocks(rawHtml); - const number = parseRFCNumber(filename); - const title = parseTitle(content, filename); - const git = await getGitHistory(path, repoPath); - - rfcs.push({ number, title, filename, html, git, state }); - } + rfcs.push({ number, title, filename, html, git }); } if (rfcs.length === 0) { - console.log( - "No RFC files found in ./proposed/, ./accepted/, or ./completed/", - ); + console.log("No RFC files found in ./rfcs/"); return 0; } @@ -564,8 +591,14 @@ async function build(liveReload: boolean = false): Promise { console.log(`Copied static/${filename} -> ${dest}`); } + // Fetch proposed RFCs from open PRs + const proposed = await getProposedRFCs(repoPath); + if (proposed.length > 0) { + console.log(`Found ${proposed.length} proposed RFC(s) from open PRs`); + } + // Generate index page - const indexHTML = indexPage(rfcs, repoUrl, liveReload); + const indexHTML = indexPage(rfcs, proposed, repoUrl, liveReload); await Bun.write("dist/index.html", indexHTML); console.log("Generated dist/index.html"); @@ -598,9 +631,7 @@ async function startDevServer() { // Initial build with live reload enabled await build(true); console.log(`\nStarting dev server at http://localhost:${PORT}`); - console.log( - "Watching for changes in ./proposed/, ./accepted/, ./completed/, and ./styles.css\n", - ); + console.log("Watching for changes in ./rfcs/ and ./styles.css\n"); // Debounce rebuilds let rebuildTimeout: Timer | null = null; @@ -613,14 +644,11 @@ async function startDevServer() { }, 100); }; - // Watch all state directories - for (const state of RFC_STATES) { - watch(`./${state}`, { recursive: true }, (_event, filename) => { - if (filename?.endsWith(".md")) { - scheduleRebuild(); - } - }); - } + watch("./rfcs", { recursive: true }, (_event, filename) => { + if (filename?.endsWith(".md")) { + scheduleRebuild(); + } + }); // Watch styles.css watch("./styles.css", () => { @@ -670,8 +698,99 @@ async function startDevServer() { }); } +async function buildPreview(): Promise { + console.log("Building RFC preview...\n"); + + const repoUrl = await getGitHubRepoUrl(); + const repoPath = repoUrl ? repoUrl.replace("https://github.com/", "") : null; + const css = await Bun.file("styles.css").text(); + + // Find new/changed RFC files compared to the base branch + const glob = new Bun.Glob("*.md"); + const rfcs: RFC[] = []; + + for await (const filename of glob.scan("./rfcs")) { + const path = `./rfcs/${filename}`; + const content = await Bun.file(path).text(); + const rawHtml = Bun.markdown.html(content, { autolinks: true }); + const html = await highlightCodeBlocks(rawHtml); + const number = parseRFCNumber(filename); + const title = parseTitle(content, filename); + const git = await getGitHistory(path, repoPath); + + rfcs.push({ number, title, filename, html, git }); + } + + // Build a single self-contained index page with all RFCs and inlined CSS + const proposed = await getProposedRFCs(repoPath); + const sorted = [...rfcs].sort((a, b) => b.number.localeCompare(a.number)); + + let rfcPages = ""; + for (const rfc of sorted) { + rfcPages += ` +
    +
    + ${rfc.html} +
    `; + } + + const list = sorted + .map( + (rfc) => ` +
  • + + RFC ${rfc.number} + ${escapeHTML(rfc.title)} + +
  • `, + ) + .join("\n"); + + const content = ` +

    Request for Comments

    +

    Technical proposals for the Vortex file format.

    +

    Accepted

    +
      +${list} +
    +${rfcPages}`; + + const html = ` + + + + + Vortex RFCs — Preview + + + + +
    +
    +
    +

    Vortex RFCs — Preview

    +
    +
    + +
    +
    +
    +${content} +
    +
    + + +`; + + await $`mkdir -p dist`.quiet(); + await Bun.write("dist/preview.html", html); + console.log("Generated dist/preview.html"); +} + if (isDev) { startDevServer().catch(console.error); +} else if (isPreview) { + buildPreview().catch(console.error); } else { build() .then((count) => { diff --git a/proposed/.keep b/proposed/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/accepted/0000-template.md b/rfcs/0000-template.md similarity index 100% rename from accepted/0000-template.md rename to rfcs/0000-template.md diff --git a/proposed/0005-extension.md b/rfcs/0005-extension.md similarity index 100% rename from proposed/0005-extension.md rename to rfcs/0005-extension.md diff --git a/accepted/0015-variant-type.md b/rfcs/0015-variant-type.md similarity index 100% rename from accepted/0015-variant-type.md rename to rfcs/0015-variant-type.md diff --git a/proposed/0023-file-compat-testing.md b/rfcs/0023-file-compat-testing.md similarity index 100% rename from proposed/0023-file-compat-testing.md rename to rfcs/0023-file-compat-testing.md diff --git a/accepted/0024-tensor.md b/rfcs/0024-tensor.md similarity index 100% rename from accepted/0024-tensor.md rename to rfcs/0024-tensor.md diff --git a/accepted/0027-patches-format.md b/rfcs/0027-patches-format.md similarity index 100% rename from accepted/0027-patches-format.md rename to rfcs/0027-patches-format.md diff --git a/proposed/0029-types.md b/rfcs/0029-types.md similarity index 100% rename from proposed/0029-types.md rename to rfcs/0029-types.md diff --git a/proposed/0033-block-turboquant.md b/rfcs/0033-block-turboquant.md similarity index 100% rename from proposed/0033-block-turboquant.md rename to rfcs/0033-block-turboquant.md diff --git a/styles.css b/styles.css index 60c5f0b..c754cfd 100644 --- a/styles.css +++ b/styles.css @@ -494,79 +494,33 @@ footer { font-size: 0.875rem; } -/* State filter bar */ -.filter-bar { - display: flex; - gap: 0.5rem; - margin-bottom: 1.5rem; - flex-wrap: wrap; -} - -.filter-btn { - font-family: inherit; - font-size: 0.875rem; - padding: 0.375rem 0.75rem; - border: 1px solid var(--border); - border-radius: 4px; - background: var(--bg); - color: var(--fg-muted); - cursor: pointer; - transition: - background 0.15s ease, - color 0.15s ease, - border-color 0.15s ease; -} - -.filter-btn:hover { - background: var(--bg-alt); - color: var(--fg); -} - -.filter-btn.active { - background: var(--fg); - color: var(--bg); - border-color: var(--fg); -} - -/* State pills */ -.rfc-state-pill { +/* Status pills */ +.rfc-status-pill { display: inline-block; font-size: 0.75rem; font-weight: 500; padding: 0.125rem 0.5rem; border-radius: 9999px; - text-transform: capitalize; flex-shrink: 0; } -.state-proposed { - background: #fef3c7; - color: #92400e; -} - -.state-accepted { +.status-accepted { background: #dbeafe; color: #1e40af; } -.state-completed { - background: #d1fae5; - color: #065f46; -} - -:root[data-theme="dark"] .state-proposed { - background: #78350f; - color: #fef3c7; -} - -:root[data-theme="dark"] .state-accepted { +:root[data-theme="dark"] .status-accepted { background: #1e3a8a; color: #dbeafe; } -:root[data-theme="dark"] .state-completed { - background: #064e3b; - color: #d1fae5; +/* Proposed RFCs (from open PRs) */ +.rfc-list-proposed { + opacity: 0.75; +} + +.rfc-list-proposed li { + border-left: 3px solid var(--border); } /* Shiki syntax highlighting - dual theme support */