From 1cc49eaf93a058ca11f48fd2ee5bc14f2fbe510a Mon Sep 17 00:00:00 2001 From: Andrew Duffy Date: Mon, 23 Feb 2026 13:20:24 -0500 Subject: [PATCH 1/3] static site build Signed-off-by: Andrew Duffy gh pages deploy workflow Signed-off-by: Andrew Duffy move validation to index.ts Signed-off-by: Andrew Duffy fix Signed-off-by: Andrew Duffy --- .github/workflows/ci.yml | 22 ++ .github/workflows/deploy.yml | 47 ++++ .github/workflows/validate-proposals.yml | 17 -- .gitignore | 34 +++ CLAUDE.md | 157 +++++++++++ README.md | 38 +++ bun.lock | 26 ++ index.ts | 337 +++++++++++++++++++++++ package.json | 17 ++ scripts/validate-proposals.sh | 50 ---- static/vortex_logo.svg | 17 ++ styles.css | 298 ++++++++++++++++++++ tsconfig.json | 29 ++ 13 files changed, 1022 insertions(+), 67 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/deploy.yml delete mode 100644 .github/workflows/validate-proposals.yml create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 bun.lock create mode 100644 index.ts create mode 100644 package.json delete mode 100755 scripts/validate-proposals.sh create mode 100644 static/vortex_logo.svg create mode 100644 styles.css create mode 100644 tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..21a404a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: + pull_request: + branches: + - develop + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install + + - name: Build + run: bun run build diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..311635a --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,47 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: + - develop + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install + + - name: Build + run: bun run build + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: ./dist + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/validate-proposals.yml b/.github/workflows/validate-proposals.yml deleted file mode 100644 index 32b186a..0000000 --- a/.github/workflows/validate-proposals.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: Validate Proposals - -on: - pull_request: - branches: - - develop - -jobs: - validate-naming: - name: Validate proposal naming convention - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Validate proposal file names - run: ./scripts/validate-proposals.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b0bd496 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,157 @@ +# Vortex RFC Site + +Static site generator for Vortex RFC proposals. + +## Project Structure + +``` +index.ts - Main build script and dev server +styles.css - Site styling (light/dark themes, monospace aesthetic) +proposals/ - RFC markdown files (format: NNNN-slug.md) +dist/ - Build output (gitignored) +``` + +## Commands + +```sh +bun run build # Build static site to ./dist/ +bun run dev # Dev server with live reload on localhost:3000 +bun run clean # Remove dist/ +``` + +## How the Build Works + +1. Scans `proposals/*.md` for RFC files +2. Parses RFC number from filename (e.g., `0002-foo.md` → RFC 0002) +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 `proposals/` and `styles.css` for changes +- SSE endpoint at `/__reload` for live reload +- Live reload script only injected in dev mode + +## Styling + +- CSS custom properties for theming (`--bg`, `--fg`, `--link`, etc.) +- System preference detection via `prefers-color-scheme` +- Three-state toggle: auto → dark → light → auto +- Theme persisted to localStorage + +## Adding Features + +- Keep dependencies minimal (only `@types/bun` currently) +- Use Bun built-ins: `Bun.file`, `Bun.Glob`, `Bun.markdown`, `Bun.serve` +- Maintain the retro monospace aesthetic + +--- + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +With the following `frontend.tsx`: + +```tsx#frontend.tsx +import React from "react"; +import { createRoot } from "react-dom/client"; + +// import .css files directly and it works +import './index.css'; + +const root = createRoot(document.body); + +export default function Frontend() { + return

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`. diff --git a/README.md b/README.md index f42857f..0d0a4b3 100644 --- a/README.md +++ b/README.md @@ -17,3 +17,41 @@ There's no set template but it should at the very least include the following de For changes that affect serialization, please take special care at explaining how compatibility is maintained and tested. Once an agreement is achieved and concerns are addressed, the proposal will be merged into the repo. + +## Building the Site + +The RFCs are published as a static website. You'll need [Bun](https://bun.sh) installed. + +### Install dependencies + +```sh +bun install +``` + +### Development + +Run the dev server with live reload: + +```sh +bun run dev +``` + +Open http://localhost:3000 to view the site. Changes to files in `proposals/` or `styles.css` will automatically rebuild and refresh the browser. + +### Production build + +Generate the static site: + +```sh +bun run build +``` + +The output is written to `dist/`. This folder can be deployed to any static hosting service (Cloudflare Pages, GitHub Pages, S3, Netlify, etc.). + +### Clean + +Remove the build output: + +```sh +bun run clean +``` diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..fb39207 --- /dev/null +++ b/bun.lock @@ -0,0 +1,26 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "vortexrfc", + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + + "@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + + "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + } +} diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..7b08f6a --- /dev/null +++ b/index.ts @@ -0,0 +1,337 @@ +import { $, type Server } from "bun"; +import { watch } from "fs"; + +const isDev = process.argv.includes("--dev"); +const PORT = 3000; + +interface RFC { + number: string; + title: string; + filename: string; + html: string; +} + +const THEME_SCRIPT = ` +(function() { + const saved = localStorage.getItem('theme') || 'light'; + document.documentElement.setAttribute('data-theme', saved); +})(); +`; + +const ICON_SUN = ``; + +const ICON_MOON = ``; + +const TOGGLE_SCRIPT = ` +function toggleTheme() { + const root = document.documentElement; + const current = root.getAttribute('data-theme'); + const next = current === 'dark' ? 'light' : 'dark'; + root.setAttribute('data-theme', next); + localStorage.setItem('theme', next); + updateToggleIcon(); +} + +function updateToggleIcon() { + const btn = document.querySelector('.theme-toggle'); + if (!btn) return; + const current = document.documentElement.getAttribute('data-theme'); + btn.innerHTML = current === 'dark' ? '${ICON_SUN}' : '${ICON_MOON}'; + btn.setAttribute('aria-label', current === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'); +} + +document.addEventListener('DOMContentLoaded', updateToggleIcon); +`; + +const LIVE_RELOAD_SCRIPT = ` +(function() { + const evtSource = new EventSource('/__reload'); + evtSource.onmessage = function() { + location.reload(); + }; +})(); +`; + +function baseHTML(title: string, content: string, cssPath: string = "styles.css", liveReload: boolean = false): string { + const basePath = cssPath === "styles.css" ? "./" : "../"; + return ` + + + + + ${escapeHTML(title)} + + + + +
+
+ + +

Vortex RFCs

+
+ +
+
+${content} +
+
+ Vortex RFC Archive +
+
+ ${liveReload ? `\n ` : ""} + +`; +} + +function escapeHTML(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function indexPage(rfcs: RFC[], liveReload: boolean = false): string { + const sorted = [...rfcs].sort((a, b) => a.number.localeCompare(b.number)); + + 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.

    +
      +${list} +
    `; + + return baseHTML("Vortex RFCs", content, "styles.css", liveReload); +} + +function rfcPage(rfc: RFC, liveReload: boolean = false): string { + const content = ` + ← Back to index +
    + ${rfc.html} +
    `; + + return baseHTML(`RFC ${rfc.number} - ${rfc.title}`, content, "../styles.css", liveReload); +} + +function parseRFCNumber(filename: string): string { + // Extract number from filename like "0002-patches-galp.md" + const match = filename.match(/^(\d+)/); + return match?.[1] ?? "0000"; +} + +interface ValidationError { + filename: string; + message: string; +} + +async function validateProposals(): Promise { + const errors: ValidationError[] = []; + const glob = new Bun.Glob("*"); + const seenNumbers = new Map(); + + for await (const filename of glob.scan("./proposals")) { + // Check filename format: NNNN-slug.md + if (!filename.match(/^\d{4}-[a-zA-Z0-9_-]+\.md$/)) { + errors.push({ + filename, + message: `Invalid filename format. Expected: NNNN-name.md (e.g., 0007-my-proposal.md)`, + }); + continue; + } + + // Check for duplicate RFC numbers + const number = filename.slice(0, 4); + const existing = seenNumbers.get(number); + if (existing) { + errors.push({ + filename, + message: `Duplicate RFC number ${number} (also used by ${existing})`, + }); + } else { + seenNumbers.set(number, filename); + } + } + + return errors; +} + +function parseTitle(markdown: string, filename: string): string { + // Try to extract title from first # heading + const match = markdown.match(/^#\s+(.+)$/m); + if (match?.[1]) { + // Clean up "RFC XXX - " prefix if present + return match[1].replace(/^RFC\s+\d+\s*[-:]\s*/i, "").trim(); + } + // Fallback to filename + return filename.replace(/^\d+-/, "").replace(/\.md$/, "").replace(/-/g, " "); +} + +async function build(liveReload: boolean = false): Promise { + console.log("Building Vortex RFC site...\n"); + + // Validate proposals first + const validationErrors = await validateProposals(); + if (validationErrors.length > 0) { + console.error("Validation errors found:\n"); + for (const error of validationErrors) { + console.error(` ${error.filename}: ${error.message}`); + } + console.error(""); + process.exit(1); + } + + const glob = new Bun.Glob("*.md"); + const rfcs: RFC[] = []; + + // Parse all RFC markdown files + for await (const filename of glob.scan("./proposals")) { + console.log(`Processing ${filename}...`); + + const path = `./proposals/${filename}`; + const content = await Bun.file(path).text(); + const html = Bun.markdown.html(content); + const number = parseRFCNumber(filename); + const title = parseTitle(content, filename); + + rfcs.push({ number, title, filename, html }); + } + + if (rfcs.length === 0) { + console.log("No RFC files found in ./proposals/"); + return 0; + } + + // Clean and create dist directory + await $`rm -rf dist`.quiet(); + await $`mkdir -p dist/rfc`.quiet(); + + // Copy CSS + await Bun.write("dist/styles.css", await Bun.file("styles.css").text()); + + // Copy static assets + const logo = Bun.file("static/vortex_logo.svg"); + if (await logo.exists()) { + await Bun.write("dist/vortex_logo.svg", await logo.text()); + } + + // Generate index page + const indexHTML = indexPage(rfcs, liveReload); + await Bun.write("dist/index.html", indexHTML); + console.log("Generated dist/index.html"); + + // Generate individual RFC pages + for (const rfc of rfcs) { + const html = rfcPage(rfc, liveReload); + const outPath = `dist/rfc/${rfc.number}.html`; + await Bun.write(outPath, html); + console.log(`Generated ${outPath}`); + } + + console.log(`\nBuild complete! ${rfcs.length} RFC(s) processed.`); + return rfcs.length; +} + +// Track SSE clients for live reload +const reloadClients = new Set(); + +function notifyReload() { + for (const controller of reloadClients) { + try { + controller.enqueue("data: reload\n\n"); + } catch { + reloadClients.delete(controller); + } + } +} + +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 ./proposals/ and ./styles.css\n"); + + // Debounce rebuilds + let rebuildTimeout: Timer | null = null; + const scheduleRebuild = () => { + if (rebuildTimeout) clearTimeout(rebuildTimeout); + rebuildTimeout = setTimeout(async () => { + console.log("\nFile change detected, rebuilding..."); + await build(true); + notifyReload(); + }, 100); + }; + + // Watch proposals directory + watch("./proposals", { recursive: true }, (_event, filename) => { + if (filename?.endsWith(".md")) { + scheduleRebuild(); + } + }); + + // Watch styles.css + watch("./styles.css", () => { + scheduleRebuild(); + }); + + // Start server + Bun.serve({ + port: PORT, + async fetch(req) { + const url = new URL(req.url); + let pathname = url.pathname; + + // SSE endpoint for live reload + if (pathname === "/__reload") { + const stream = new ReadableStream({ + start(controller) { + reloadClients.add(controller); + }, + cancel(controller) { + reloadClients.delete(controller); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + }); + } + + // Serve static files from dist/ + if (pathname === "/") pathname = "/index.html"; + if (pathname.endsWith("/")) pathname += "index.html"; + + const filePath = `./dist${pathname}`; + const file = Bun.file(filePath); + + if (await file.exists()) { + return new Response(file); + } + + return new Response("Not Found", { status: 404 }); + }, + }); +} + +if (isDev) { + startDevServer().catch(console.error); +} else { + build().then(count => { + if (count > 0) { + console.log("Output directory: ./dist/"); + } + }).catch(console.error); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..fd90889 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "vortexrfc", + "module": "index.ts", + "type": "module", + "private": true, + "scripts": { + "build": "bun run index.ts", + "dev": "bun run index.ts --dev", + "clean": "rm -rf dist" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + } +} diff --git a/scripts/validate-proposals.sh b/scripts/validate-proposals.sh deleted file mode 100755 index 4325d9f..0000000 --- a/scripts/validate-proposals.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -validate_file() { - local filename="$1" - - # Check if file matches the expected pattern: NNNN-*.md - if ! echo "$filename" | grep -qE '^[0-9]{4}-[a-zA-Z0-9_-]+\.md$'; then - echo "ERROR: Invalid filename: $filename" - echo " Expected format: NNNN-name.md (e.g., 0007-my-proposal.md)" - return 1 - fi - - return 0 -} - -exit_code=0 -valid_ids="" - -# Find all files in proposals/ directory (if it exists) -if [ -d "proposals" ]; then - for file in $(ls proposals/ | sort); do - file="proposals/$file" - # Skip if no files match (glob returns literal pattern) - [ -e "$file" ] || continue - - filename=$(basename "$file") - - if validate_file "$filename"; then - echo "OK: $filename" - # Collect ID and filename for duplicate checking - id="${filename:0:4}" - valid_ids="${valid_ids}${id} ${filename}"$'\n' - else - exit_code=1 - fi - done - - # Check for duplicate IDs - duplicate_ids=$(echo "$valid_ids" | awk '{print $1}' | sort | uniq -d) - if [ -n "$duplicate_ids" ]; then - for dup_id in $duplicate_ids; do - files=$(echo "$valid_ids" | grep "^$dup_id " | awk '{print $2}' | tr '\n' ' ') - echo "ERROR: Duplicate ID $dup_id found in: $files" - done - exit_code=1 - fi -fi - -exit $exit_code diff --git a/static/vortex_logo.svg b/static/vortex_logo.svg new file mode 100644 index 0000000..ba6d176 --- /dev/null +++ b/static/vortex_logo.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..71fc8ff --- /dev/null +++ b/styles.css @@ -0,0 +1,298 @@ +:root { + --bg: #fafafa; + --bg-alt: #f0f0f0; + --fg: #1a1a1a; + --fg-muted: #666; + --border: #ddd; + --link: #0066cc; + --link-hover: #004499; + --code-bg: #f5f5f5; + --accent: #333; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --bg: #1a1a1a; + --bg-alt: #252525; + --fg: #e0e0e0; + --fg-muted: #999; + --border: #333; + --link: #6db3f2; + --link-hover: #9acbf7; + --code-bg: #252525; + --accent: #ccc; + } +} + +:root[data-theme="dark"] { + --bg: #1a1a1a; + --bg-alt: #252525; + --fg: #e0e0e0; + --fg-muted: #999; + --border: #333; + --link: #6db3f2; + --link-hover: #9acbf7; + --code-bg: #252525; + --accent: #ccc; +} + +:root[data-theme="light"] { + --bg: #fafafa; + --bg-alt: #f0f0f0; + --fg: #1a1a1a; + --fg-muted: #666; + --border: #ddd; + --link: #0066cc; + --link-hover: #004499; + --code-bg: #f5f5f5; + --accent: #333; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 16px; +} + +body { + font-family: "IBM Plex Mono", "SF Mono", "Menlo", "Monaco", "Consolas", monospace; + background: var(--bg); + color: var(--fg); + line-height: 1.6; + min-height: 100vh; +} + +.container { + max-width: 52rem; + margin: 0 auto; + padding: 2rem 1.5rem; +} + +header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 1.5rem; + border-bottom: 1px solid var(--border); + margin-bottom: 2rem; +} + +.header-brand { + display: flex; + align-items: center; + gap: 0.75rem; + text-decoration: none; + color: var(--fg); +} + +.header-brand:hover { + color: var(--link); + text-decoration: none; +} + +.header-logo { + height: 32px; + width: 32px; + flex-shrink: 0; +} + +header h1 { + font-size: 1.25rem; + font-weight: 600; + letter-spacing: -0.02em; + margin: 0; +} + +header h1 a { + color: var(--fg); + text-decoration: none; +} + +header h1 a:hover { + color: var(--link); +} + +.theme-toggle { + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-alt); + border: 1px solid var(--border); + color: var(--fg); + width: 36px; + height: 36px; + padding: 0; + cursor: pointer; + border-radius: 4px; + transition: background 0.15s ease; +} + +.theme-toggle:hover { + background: var(--border); +} + +.theme-toggle svg { + display: block; +} + +main { + min-height: calc(100vh - 200px); +} + +h1, h2, h3, h4, h5, h6 { + font-weight: 600; + margin-top: 2rem; + margin-bottom: 1rem; + color: var(--accent); +} + +h1 { font-size: 1.75rem; margin-top: 0; } +h2 { font-size: 1.375rem; } +h3 { font-size: 1.125rem; } +h4, h5, h6 { font-size: 1rem; } + +p { + margin-bottom: 1rem; +} + +a { + color: var(--link); + text-decoration: none; +} + +a:hover { + color: var(--link-hover); + text-decoration: underline; +} + +ul, ol { + margin-bottom: 1rem; + padding-left: 1.5rem; +} + +li { + margin-bottom: 0.5rem; +} + +code { + font-family: inherit; + background: var(--code-bg); + padding: 0.125rem 0.375rem; + border-radius: 3px; + font-size: 0.9em; +} + +pre { + background: var(--code-bg); + border: 1px solid var(--border); + border-radius: 4px; + padding: 1rem; + overflow-x: auto; + margin-bottom: 1rem; +} + +pre code { + background: none; + padding: 0; + border-radius: 0; +} + +blockquote { + border-left: 3px solid var(--border); + padding-left: 1rem; + margin: 1rem 0; + color: var(--fg-muted); +} + +hr { + border: none; + border-top: 1px solid var(--border); + margin: 2rem 0; +} + +table { + width: 100%; + border-collapse: collapse; + margin-bottom: 1rem; +} + +th, td { + border: 1px solid var(--border); + padding: 0.5rem 0.75rem; + text-align: left; +} + +th { + background: var(--bg-alt); + font-weight: 600; +} + +/* Index page specific */ +.rfc-list { + list-style: none; + padding: 0; +} + +.rfc-list li { + border-bottom: 1px solid var(--border); + margin-bottom: 0; +} + +.rfc-list li:last-child { + border-bottom: none; +} + +.rfc-item { + display: block; + padding: 1rem 0; + text-decoration: none; + color: var(--fg); + transition: background 0.1s ease; +} + +.rfc-item:hover { + color: var(--link); + text-decoration: none; +} + +.rfc-item .rfc-number { + color: var(--fg-muted); + font-size: 0.875rem; +} + +.rfc-item .rfc-title { + display: block; + font-weight: 500; + margin-top: 0.25rem; +} + +/* RFC page specific */ +.rfc-header { + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border); +} + +.rfc-meta { + color: var(--fg-muted); + font-size: 0.875rem; + margin-top: 0.5rem; +} + +.back-link { + display: inline-block; + margin-bottom: 1.5rem; + font-size: 0.875rem; +} + +footer { + margin-top: 3rem; + padding-top: 1.5rem; + border-top: 1px solid var(--border); + color: var(--fg-muted); + font-size: 0.875rem; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} From 6c436d4879a2da883c36f74eda071fbf5fd2a931 Mon Sep 17 00:00:00 2001 From: Andrew Duffy Date: Mon, 23 Feb 2026 13:55:03 -0500 Subject: [PATCH 2/3] add accepted/updated metadata Signed-off-by: Andrew Duffy --- index.ts | 107 +++++++++++++++++++++++++++++++++++-- proposals/0000-template.md | 43 ++++++++++++--- styles.css | 30 +++++++++++ 3 files changed, 170 insertions(+), 10 deletions(-) diff --git a/index.ts b/index.ts index 7b08f6a..03f3071 100644 --- a/index.ts +++ b/index.ts @@ -4,11 +4,22 @@ import { watch } from "fs"; const isDev = process.argv.includes("--dev"); const PORT = 3000; +interface GitCommit { + hash: string; + date: Date; +} + +interface RFCGitInfo { + accepted: GitCommit | null; + lastUpdated: GitCommit | null; // null if same as accepted or no git history +} + interface RFC { number: string; title: string; filename: string; html: string; + git: RFCGitInfo; } const THEME_SCRIPT = ` @@ -113,9 +124,47 @@ ${list} return baseHTML("Vortex RFCs", content, "styles.css", liveReload); } -function rfcPage(rfc: RFC, liveReload: boolean = false): string { +function formatDate(date: Date): string { + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); +} + +function rfcPage(rfc: RFC, repoUrl: string | null, liveReload: boolean = false): string { + let gitHeader = ""; + + if (rfc.git.accepted) { + const acceptedLink = repoUrl + ? `${formatDate(rfc.git.accepted.date)}` + : formatDate(rfc.git.accepted.date); + + gitHeader = ` +
    +
    + Accepted: + ${acceptedLink} +
    `; + + if (rfc.git.lastUpdated) { + const updatedLink = repoUrl + ? `${formatDate(rfc.git.lastUpdated.date)}` + : formatDate(rfc.git.lastUpdated.date); + + gitHeader += ` +
    + Last updated: + ${updatedLink} +
    `; + } + + gitHeader += ` +
    `; + } + const content = ` - ← Back to index + ← Back to index${gitHeader}
    ${rfc.html}
    `; @@ -129,6 +178,52 @@ function parseRFCNumber(filename: string): string { return match?.[1] ?? "0000"; } +async function getGitHubRepoUrl(): Promise { + try { + const result = await $`git remote get-url origin`.quiet(); + const url = result.stdout.toString().trim(); + // Convert git@github.com:user/repo.git to https://github.com/user/repo + if (url.startsWith("git@github.com:")) { + return "https://github.com/" + url.slice(15).replace(/\.git$/, ""); + } + // Convert https://github.com/user/repo.git to https://github.com/user/repo + if (url.startsWith("https://github.com/")) { + return url.replace(/\.git$/, ""); + } + return null; + } catch { + return null; + } +} + +async function getGitHistory(filepath: string): Promise { + try { + const result = await $`git log --follow --format=%H\ %aI -- ${filepath}`.quiet(); + const lines = result.stdout.toString().trim().split("\n").filter(Boolean); + + if (lines.length === 0) { + return { accepted: null, lastUpdated: null }; + } + + const parseCommit = (line: string): GitCommit => { + const [hash, dateStr] = line.split(" "); + return { hash, date: new Date(dateStr) }; + }; + + const mostRecent = parseCommit(lines[0]); + const oldest = parseCommit(lines[lines.length - 1]); + + // If only one commit, or same commit, don't show lastUpdated + if (lines.length === 1 || mostRecent.hash === oldest.hash) { + return { accepted: oldest, lastUpdated: null }; + } + + return { accepted: oldest, lastUpdated: mostRecent }; + } catch { + return { accepted: null, lastUpdated: null }; + } +} + interface ValidationError { filename: string; message: string; @@ -190,6 +285,9 @@ async function build(liveReload: boolean = false): Promise { process.exit(1); } + // Get GitHub repo URL for commit links + const repoUrl = await getGitHubRepoUrl(); + const glob = new Bun.Glob("*.md"); const rfcs: RFC[] = []; @@ -202,8 +300,9 @@ async function build(liveReload: boolean = false): Promise { const html = Bun.markdown.html(content); const number = parseRFCNumber(filename); const title = parseTitle(content, filename); + const git = await getGitHistory(path); - rfcs.push({ number, title, filename, html }); + rfcs.push({ number, title, filename, html, git }); } if (rfcs.length === 0) { @@ -231,7 +330,7 @@ async function build(liveReload: boolean = false): Promise { // Generate individual RFC pages for (const rfc of rfcs) { - const html = rfcPage(rfc, liveReload); + const html = rfcPage(rfc, repoUrl, liveReload); const outPath = `dist/rfc/${rfc.number}.html`; await Bun.write(outPath, html); console.log(`Generated ${outPath}`); diff --git a/proposals/0000-template.md b/proposals/0000-template.md index 158cbc1..d9ba2a8 100644 --- a/proposals/0000-template.md +++ b/proposals/0000-template.md @@ -1,7 +1,38 @@ ---- -author: Adam Gutglick -description: This is a template for future proposals, using frontmatter for metadata. -date: 2025-02-23 ---- +# RFC 0000 - Template Mode -# Example Title +## Goals + +1. Create RFCs +1. ?? +1. Profit + +## Diagrams + +Here is an ASCII diagram: + +``` + ╔ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ + ║ + ║ BitPackedArray + ║ + ╚│═ ═ ═ ═ ═ ═ ═ ╤ ═ ═ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ patch + │ │ indices ╔ ═ ═ ═ ═ ═ +┌─────▼─────┐ ├─────────────▶ ArrayRef ║ +│░░░░░░░░░░░│ │ ╚ ═ ═ ═ ═ ═ +│░░Buffer░░░│ │ +│░░░░░░░░░░░│ │ patch +└───────────┘ │ values ╔ ═ ═ ═ ═ ═ + encoded └─────────────▶ ArrayRef ║ + ╚ ═ ═ ═ ═ ═ +``` + +We can have links, like https://github.com/vortex-data + +BUT, we can also have [**LINKS**](https://vortex.dev) or [__links__](https://docs.vortex.dev) diff --git a/styles.css b/styles.css index 71fc8ff..8a92e05 100644 --- a/styles.css +++ b/styles.css @@ -271,6 +271,36 @@ th { } /* RFC page specific */ +.rfc-meta-header { + display: flex; + flex-wrap: wrap; + gap: 1.5rem; + padding: 1rem; + background: var(--bg-alt); + border: 1px solid var(--border); + border-radius: 4px; + margin-bottom: 1.5rem; + font-size: 0.875rem; +} + +.rfc-meta-item { + display: flex; + gap: 0.5rem; +} + +.rfc-meta-label { + color: var(--fg-muted); +} + +.commit-link { + color: var(--link); +} + +.commit-link:hover { + color: var(--link-hover); + text-decoration: underline; +} + .rfc-header { margin-bottom: 2rem; padding-bottom: 1rem; From d2247f67846b8e93da86a6967bdb3ffc652bdadd Mon Sep 17 00:00:00 2001 From: Andrew Duffy Date: Mon, 23 Feb 2026 13:59:43 -0500 Subject: [PATCH 3/3] add author info Signed-off-by: Andrew Duffy --- index.ts | 80 ++++++++++++++++++++++++++++++++++++++++++++---------- styles.css | 32 ++++++++++++++++++++++ 2 files changed, 97 insertions(+), 15 deletions(-) diff --git a/index.ts b/index.ts index 03f3071..43d614d 100644 --- a/index.ts +++ b/index.ts @@ -9,9 +9,16 @@ interface GitCommit { date: Date; } +interface GitHubAuthor { + login: string; + avatarUrl: string; + profileUrl: string; +} + interface RFCGitInfo { accepted: GitCommit | null; lastUpdated: GitCommit | null; // null if same as accepted or no git history + author: GitHubAuthor | null; } interface RFC { @@ -135,18 +142,35 @@ function formatDate(date: Date): string { function rfcPage(rfc: RFC, repoUrl: string | null, liveReload: boolean = false): string { let gitHeader = ""; - if (rfc.git.accepted) { - const acceptedLink = repoUrl - ? `${formatDate(rfc.git.accepted.date)}` - : formatDate(rfc.git.accepted.date); - + if (rfc.git.accepted || rfc.git.author) { gitHeader = ` -
    +
    `; + + // Author section + if (rfc.git.author) { + gitHeader += ` + `; + } + + // Accepted date + if (rfc.git.accepted) { + const acceptedLink = repoUrl + ? `${formatDate(rfc.git.accepted.date)}` + : formatDate(rfc.git.accepted.date); + + gitHeader += `
    Accepted: ${acceptedLink}
    `; + } + // Last updated date if (rfc.git.lastUpdated) { const updatedLink = repoUrl ? `${formatDate(rfc.git.lastUpdated.date)}` @@ -196,31 +220,55 @@ async function getGitHubRepoUrl(): Promise { } } -async function getGitHistory(filepath: string): Promise { +async function getGitHubAuthor(repoPath: string, commitHash: string): Promise { + try { + // Use gh CLI to fetch commit info from GitHub API + const result = await $`gh api repos/${repoPath}/commits/${commitHash} --jq '.author.login, .author.avatar_url, .author.html_url'`.quiet(); + const lines = result.stdout.toString().trim().split("\n"); + + if (lines.length >= 3 && lines[0] && lines[1] && lines[2]) { + return { + login: lines[0], + avatarUrl: lines[1], + profileUrl: lines[2], + }; + } + return null; + } catch { + return null; + } +} + +async function getGitHistory(filepath: string, repoPath: string | null): Promise { try { const result = await $`git log --follow --format=%H\ %aI -- ${filepath}`.quiet(); const lines = result.stdout.toString().trim().split("\n").filter(Boolean); if (lines.length === 0) { - return { accepted: null, lastUpdated: null }; + return { accepted: null, lastUpdated: null, author: null }; } const parseCommit = (line: string): GitCommit => { - const [hash, dateStr] = line.split(" "); + const parts = line.split(" "); + const hash = parts[0] ?? ""; + const dateStr = parts[1] ?? ""; return { hash, date: new Date(dateStr) }; }; - const mostRecent = parseCommit(lines[0]); - const oldest = parseCommit(lines[lines.length - 1]); + const mostRecent = parseCommit(lines[0]!); + const oldest = parseCommit(lines[lines.length - 1]!); + + // Fetch author info from the first commit + const author = repoPath ? await getGitHubAuthor(repoPath, oldest.hash) : null; // If only one commit, or same commit, don't show lastUpdated if (lines.length === 1 || mostRecent.hash === oldest.hash) { - return { accepted: oldest, lastUpdated: null }; + return { accepted: oldest, lastUpdated: null, author }; } - return { accepted: oldest, lastUpdated: mostRecent }; + return { accepted: oldest, lastUpdated: mostRecent, author }; } catch { - return { accepted: null, lastUpdated: null }; + return { accepted: null, lastUpdated: null, author: null }; } } @@ -287,6 +335,8 @@ async function build(liveReload: boolean = false): Promise { // Get GitHub repo URL for commit links const repoUrl = await getGitHubRepoUrl(); + // Extract repo path (e.g., "vortex-data/rfcs") for API calls + const repoPath = repoUrl ? repoUrl.replace("https://github.com/", "") : null; const glob = new Bun.Glob("*.md"); const rfcs: RFC[] = []; @@ -300,7 +350,7 @@ async function build(liveReload: boolean = false): Promise { const html = Bun.markdown.html(content); const number = parseRFCNumber(filename); const title = parseTitle(content, filename); - const git = await getGitHistory(path); + const git = await getGitHistory(path, repoPath); rfcs.push({ number, title, filename, html, git }); } diff --git a/styles.css b/styles.css index 8a92e05..f421875 100644 --- a/styles.css +++ b/styles.css @@ -301,6 +301,38 @@ th { text-decoration: underline; } +.rfc-author { + display: flex; + align-items: center; +} + +.author-link { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--fg); + text-decoration: none; +} + +.author-link:hover { + color: var(--link); +} + +.author-link:hover .author-name { + text-decoration: underline; +} + +.author-avatar { + width: 24px; + height: 24px; + border-radius: 50%; + flex-shrink: 0; +} + +.author-name { + font-weight: 500; +} + .rfc-header { margin-bottom: 2rem; padding-bottom: 1rem;