diff --git a/README.md b/README.md index 605ba73..8e61540 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,95 @@ The application supports the following environment variables: | `NODE_ENV` | Node.js environment | `development` | | `PORT` | Port to run the server on | `3000` | +### Blog Authoring (MDX) + +You can write posts in MDX and have them show up on `/blog`. + +- Local posts: add `.mdx` files to `app/blog/posts/` (these are always included). +- Optional GitHub-backed posts: if you configure the `BLOG_*` environment variables, MDX files from your GitHub repo are fetched and merged with local posts. GitHub posts override local ones on duplicate slugs. + +Setup: +1) Copy `.env.local.example` to `.env.local` and fill in values. +2) For GitHub-backed content, set: + - `BLOG_REPO=owner/repo` + - `BLOG_PATH=path/to/mdx/folder` (relative to repo root) + - `BLOG_BRANCH=main` (or your branch) + - `GITHUB_TOKEN=` (only required for private repos or higher rate limits) + +Frontmatter template (put at the top of each `.mdx` file): +```md +--- +title: "Post Title" +publishedAt: "2025-01-15" # YYYY-MM-DD +summary: "One-liner summary" +tags: ["tag1", "tag2"] +image: "https://your.cdn/path-or-absolute-url.jpg" +--- +``` + +Images: +- Prefer absolute URLs (CDN or repo raw URLs) to avoid build-time asset issues. +- The MDX renderer handles links and images for you (see `components/mdx.tsx`). + +How updates appear on the site: +- Incremental Static Regeneration (ISR): GitHub responses are cached with `next: { revalidate: BLOG_REVALIDATE_SECONDS }`. New/edited posts appear automatically after the configured window (default 300s). +- On-demand revalidation: trigger an immediate refresh via the `/api/revalidate` endpoint (see below) or configure a GitHub webhook to call it on each push. + +### Blog Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `BLOG_REPO` | GitHub `owner/repo` containing your MDX files. Leave empty to use only local posts. | — | +| `BLOG_PATH` | Path in the repo where `.mdx` files live (relative to repo root). | `app/blog/posts` | +| `BLOG_BRANCH` | Branch name to read from. | `main` | +| `GITHUB_TOKEN` | GitHub token. Required for private repos or higher rate limits. | — | +| `BLOG_REVALIDATE_SECONDS` | ISR interval (seconds) for GitHub fetch cache. | `300` | +| `BLOG_CACHE_TAG` | Cache tag used for on-demand invalidation (`revalidateTag`). | `blog-content` | +| `REVALIDATE_SECRET` | Shared secret for the revalidate API and GitHub webhook signature. | — | + +### On-demand Revalidation + +An API route at `/api/revalidate` invalidates the blog listing and post pages, and also busts the cache tag used for GitHub content. + +- GET (simple manual trigger): + - Revalidate listing: + ```bash + curl -sS "https://your-domain/api/revalidate?secret=REVALIDATE_SECRET" + ``` + - Revalidate a specific post (by slug): + ```bash + curl -sS "https://your-domain/api/revalidate?secret=REVALIDATE_SECRET&slug=my-post-slug" + ``` + - Optionally revalidate additional paths: + ```bash + curl -sS "https://your-domain/api/revalidate?secret=REVALIDATE_SECRET&path=/blog/my-post-slug" + ``` + +- POST (advanced, supports multiple slugs/paths): + ```bash + curl -sS -X POST "https://your-domain/api/revalidate" \ + -H "x-revalidate-secret: REVALIDATE_SECRET" \ + -H "content-type: application/json" \ + -d '{ + "slugs": ["my-post-slug"], + "paths": ["/blog", "/blog/my-post-slug"] + }' + ``` + +- GitHub Webhook (recommended): + 1) In your repo settings, add a Webhook: + - Payload URL: `https://your-domain/api/revalidate` + - Content type: `application/json` + - Secret: set to the same value as `REVALIDATE_SECRET` + - Event: “Just the push event” + 2) On push, the webhook sends changed file paths. The API will: + - Revalidate the `BLOG_CACHE_TAG` to refresh GitHub fetches. + - Revalidate `/blog` and any changed post slugs under `BLOG_PATH`. + +Security notes: +- Do not expose `REVALIDATE_SECRET` publicly. For manual GET usage, keep the URL private. +- For CI/CD, use the POST form with the `x-revalidate-secret` header. + ## License This project is open source, take it. I don't give a fuck. I am not your dad. diff --git a/app/api/revalidate/route.ts b/app/api/revalidate/route.ts new file mode 100644 index 0000000..ab6020d --- /dev/null +++ b/app/api/revalidate/route.ts @@ -0,0 +1,160 @@ +import { NextRequest, NextResponse } from 'next/server' +import { revalidatePath, revalidateTag } from 'next/cache' +import crypto from 'crypto' + +export const runtime = 'nodejs' + +const SECRET = process.env.REVALIDATE_SECRET || '' +const BLOG_CACHE_TAG = process.env.BLOG_CACHE_TAG || 'blog-content' +const BLOG_PATH = (process.env.BLOG_PATH || '').replace(/^\/+|\/+$/g, '') + +/** + * Compare strings in a timing-safe way + */ +function timingSafeEqual(a: string, b: string) { + const aBuf = Buffer.from(a) + const bBuf = Buffer.from(b) + if (aBuf.length !== bBuf.length) return false + return crypto.timingSafeEqual(aBuf, bBuf) +} + +/** + * Validate GitHub's HMAC signature header (X-Hub-Signature-256) + */ +function verifyGitHubSignature(rawBody: string, signatureHeader: string | null) { + if (!SECRET || !signatureHeader) return false + const expected = `sha256=${crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex')}` + return timingSafeEqual(expected, signatureHeader) +} + +/** + * Authorize request via: + * - query param ?secret= + * - header x-revalidate-secret + * - GitHub webhook signature X-Hub-Signature-256 + */ +function isAuthorized(req: NextRequest, rawBody?: string) { + if (!SECRET) return false + + const urlSecret = req.nextUrl.searchParams.get('secret') + if (urlSecret && timingSafeEqual(urlSecret, SECRET)) return true + + const headerSecret = req.headers.get('x-revalidate-secret') + if (headerSecret && timingSafeEqual(headerSecret, SECRET)) return true + + const ghSig = req.headers.get('x-hub-signature-256') + if (ghSig && rawBody && verifyGitHubSignature(rawBody, ghSig)) return true + + return false +} + +function deriveSlugsFromChangedFiles(files: string[], blogPath: string) { + const normalized = blogPath ? blogPath.replace(/^\/+|\/+$/g, '') : '' + return files + .filter((f) => f.endsWith('.mdx')) + .filter((f) => { + if (!normalized) return true + // match if file is in configured BLOG_PATH + const fNorm = f.replace(/^\/+|\/+$/g, '') + return fNorm === normalized || fNorm.startsWith(`${normalized}/`) + }) + .map((f) => { + const name = f.split('/').pop() || f + return name.replace(/\.mdx$/i, '') + }) +} + +export async function GET(req: NextRequest) { + if (!isAuthorized(req)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const paths = new Set(['/blog']) + const tag = req.nextUrl.searchParams.get('tag') || BLOG_CACHE_TAG + + const slug = req.nextUrl.searchParams.get('slug') + if (slug) paths.add(`/blog/${slug}`) + + const pathParam = req.nextUrl.searchParams.get('path') + if (pathParam) paths.add(pathParam) + + // Revalidate cache tag (refreshes GitHub fetches using next: { tags }) + revalidateTag(tag) + // Revalidate listing and any provided paths + for (const p of paths) revalidatePath(p) + + return NextResponse.json({ + revalidated: true, + tag, + paths: Array.from(paths), + mode: 'GET', + }) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function getChangedFilesFromGitHubPush(body: unknown): string[] { + if (!isRecord(body) || !Array.isArray((body as Record).commits)) return [] + const files: string[] = [] + const commits = (body as Record).commits as unknown[] + for (const c of commits) { + if (!isRecord(c)) continue + for (const key of ['added', 'modified', 'removed'] as const) { + const arr = (c as Record)[key] + if (Array.isArray(arr)) { + for (const f of arr) { + if (typeof f === 'string') files.push(f) + } + } + } + } + return files +} + +export async function POST(req: NextRequest) { + const raw = await req.text() + + if (!isAuthorized(req, raw)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + let body: unknown = {} + try { + body = raw ? JSON.parse(raw) : {} + } catch { + // ignore parse errors for signature validation; continue with empty body + } + + const paths = new Set(['/blog']) + const tag = BLOG_CACHE_TAG + + // Direct payload usage + if (isRecord(body) && typeof body.path === 'string') paths.add(body.path) + if (isRecord(body) && typeof body.slug === 'string') paths.add(`/blog/${body.slug}`) + if (isRecord(body) && Array.isArray(body.paths)) { + for (const p of body.paths as unknown[]) if (typeof p === 'string') paths.add(p) + } + if (isRecord(body) && Array.isArray(body.slugs)) { + for (const s of body.slugs as unknown[]) if (typeof s === 'string') paths.add(`/blog/${s}`) + } + + // GitHub webhook (push) payload + const ghEvent = req.headers.get('x-github-event') + if (ghEvent === 'push') { + const changed = getChangedFilesFromGitHubPush(body) + const slugs = deriveSlugsFromChangedFiles(changed, BLOG_PATH) + for (const s of slugs) paths.add(`/blog/${s}`) + } + + revalidateTag(tag) + for (const p of paths) revalidatePath(p) + + return NextResponse.json({ + revalidated: true, + tag, + paths: Array.from(paths), + mode: 'POST', + }) +} diff --git a/app/blog/page.tsx b/app/blog/page.tsx index fab3187..6206145 100644 --- a/app/blog/page.tsx +++ b/app/blog/page.tsx @@ -9,6 +9,13 @@ export const metadata = { export default async function Page() { const posts = await getAllPosts() + const hasRepo = Boolean(process.env.BLOG_REPO) + const repo = process.env.BLOG_REPO + const repoPath = process.env.BLOG_PATH || '' + const repoBranch = process.env.BLOG_BRANCH || 'main' + const sourceLabel = hasRepo + ? `Local MDX + GitHub (${repo}${repoPath ? `/${repoPath}` : ''}@${repoBranch})` + : 'Local MDX' return (
@@ -22,6 +29,9 @@ export default async function Page() {

My Blog

+
+ Content source: {sourceLabel} +
{posts.map((post) => ( diff --git a/app/blog/utils.ts b/app/blog/utils.ts index 481da8b..e3a31b6 100644 --- a/app/blog/utils.ts +++ b/app/blog/utils.ts @@ -102,6 +102,8 @@ const BLOG_REPO = process.env.BLOG_REPO // 'owner/repo' const BLOG_PATH = process.env.BLOG_PATH || '' const BLOG_BRANCH = process.env.BLOG_BRANCH || 'main' const GITHUB_TOKEN = process.env.GITHUB_TOKEN +const BLOG_REVALIDATE_SECONDS = Number(process.env.BLOG_REVALIDATE_SECONDS || 300) +const BLOG_CACHE_TAG = process.env.BLOG_CACHE_TAG || 'blog-content' type GithubContentItem = { name: string @@ -119,7 +121,10 @@ async function githubApi(url: string): Promise { } if (GITHUB_TOKEN) headers.Authorization = `Bearer ${GITHUB_TOKEN}` - const res = await fetch(url, { headers, cache: 'no-store' }) + const res = await fetch(url, { + headers, + next: { revalidate: BLOG_REVALIDATE_SECONDS, tags: [BLOG_CACHE_TAG] }, + }) if (!res.ok) { throw new Error(`GitHub API error ${res.status} on ${url}`) } diff --git a/implementation_plan.md b/implementation_plan.md index 10bc84f..b00d587 100644 --- a/implementation_plan.md +++ b/implementation_plan.md @@ -1,43 +1,168 @@ # Implementation Plan -## Overview -Integrate the navigation and blog functionality from the EXAMPLE/blog into the main site, ensuring all dependencies are properly installed and configured. +[Overview] +Add a Doom emulator that launches in a modal window (“floating over the page”) when the user clicks a new “running on a potato” entry in the existing sidebar menu. The emulator will be loaded on-demand, client-only, and isolated from SSR to protect page performance and stability. -## Types -No new type definitions are required for this implementation. +Multiple paragraphs outlining the scope, context, and high-level approach. Explain why this implementation is needed and how it fits into the existing system. +- This Next.js 15 app uses the App Router, React 19, Tailwind, and several client-side providers (Lenis, Motion). The request is to add a lightweight, non-intrusive Doom experience without altering the primary site navigation. The user wants a single menu item in the sidebar labeled “running on a potato” that, when clicked, opens an overlay modal containing the emulator. +- The overlay will be implemented as a client-only modal and mounted at the root layout so it renders above all content, and remains accessible across pages. We will add a React Context Provider to manage the “open/close” state of the modal globally. The sidebar menu will receive an action hook to open the modal. +- For the emulator engine, we will use js-dos (DOSBox compiled to WebAssembly) via the npm package “js-dos”. It can boot a packaged .jsdos archive containing DOS Doom and run entirely in the browser. This approach avoids adding complex custom build steps and does not require special COOP/COEP headers. The emulator will be lazy-loaded via dynamic import to keep initial page loads fast. +- To provide an immediate out-of-the-box experience, the emulator will load a remotely hosted demo .jsdos archive from the official js-dos CDN (subject to its availability). If remote loading fails (network/CORS), the overlay will show a prompt allowing the user to drag-and-drop or select a local .jsdos archive to run. This keeps the feature functional while avoiding bundling large binary assets into the repository. +- Accessibility and usability: the modal will trap focus, provide keyboard controls (ESC to close), restore focus on close, and disable background scroll while open. -## Files -- Create app/robots.ts for SEO optimization -- Update app/layout.tsx to include Footer component -- Update app/components/footer.tsx to remove Vercel references +[Types] +Single sentence describing the type system changes. +We will add a small set of TypeScript types for the modal provider context and emulator configuration. -## Functions -No new functions are required, but existing functions need to be properly connected. +Detailed type definitions, interfaces, enums, or data structures with complete specifications. Include field names, types, validation rules, and relationships. +- app/providers/DoomOverlayProvider.tsx + - export type DoomOverlayContextValue = { + isOpen: boolean; + open: () => void; + close: () => void; + } + - Constraints: + - isOpen: true when modal is visible. + - open/close: stable callbacks; must be usable from any client component. +- app/components/doom/JsDosPlayer.tsx + - export type JsDosPlayerProps = { + zipUrl?: string; // optional remote .jsdos archive URL + className?: string; + } + - export type JsDosHandle = { + stop: () => void; // shutdown emulator and free resources + } +- Emulator engine choice type: + - export enum DoomEngine { JsDos = "js-dos" } + - export type DoomConfig = { engine: DoomEngine; jsdos?: { zipUrl?: string } } -## Classes -No new classes are required for this implementation. +[Files] +Single sentence describing file modifications. +We will add a provider, modal overlay, emulator player component, and update the sidebar menu and root layout to integrate the overlay trigger and mount point. -## Dependencies -Install missing dependencies: -- next-mdx-remote -- sugar-high +Detailed breakdown: +- New files to be created (with full paths and purpose) + 1) app/providers/DoomOverlayProvider.tsx + - Purpose: Client-side React context that exposes open/close state and actions for the Doom modal. Wraps children so any component can open the overlay. + 2) app/components/overlay/Modal.tsx + - Purpose: Accessible, reusable modal component using a portal, focus trap, ESC-to-close, backdrop click-to-close, and scroll lock. + 3) app/components/doom/DoomOverlay.tsx + - Purpose: The top-level container rendered inside the modal. It displays loading/error states and hosts the emulator player. Includes a close button and optional instructions if remote loading fails. + 4) app/components/doom/JsDosPlayer.tsx + - Purpose: Client-only component that lazy-loads “js-dos”, renders the emulator surface, runs a provided .jsdos archive via a URL, and tears down cleanly on unmount. +- Existing files to be modified (with specific changes) + 1) app/layout.tsx + - Wrap current body content with . Mount near the end of (still inside providers) so it renders above app content, but shares styles/providers. + 2) app/components/sidebar-menu.tsx + - Add a new “running on a potato” menu action above or below current items (About/Writing/Contact). + - This should be a button (not a Link) that calls open() from DoomOverlayProvider to display the modal. Maintain current styling conventions via cn() and Tailwind. +- Files to be deleted or moved + - None. +- Configuration file updates + - package.json (Dependencies): add "js-dos". + - No changes to next.config.ts or tsconfig.json are required for this plan. -Remove unnecessary dependencies: -- @vercel/analytics -- @vercel/speed-insights +[Functions] +Single sentence describing function modifications. +We will add modal state management functions, emulator lifecycle controls, and integrate a new onclick handler in the sidebar menu. -## Testing -Verify that: -- Blog posts are properly displayed -- Navigation works correctly -- All dependencies are properly installed -- RSS feed is working -- OG images are generated correctly +Detailed breakdown: +- New functions (name, signature, file path, purpose) + 1) open ((): void) — app/providers/DoomOverlayProvider.tsx + - Purpose: Set modal open state to true. + 2) close ((): void) — app/providers/DoomOverlayProvider.tsx + - Purpose: Set modal open state to false. + 3) DoomOverlay ((): JSX.Element) — app/components/doom/DoomOverlay.tsx + - Purpose: Renders Modal and inside it lazy-loads the emulator (JsDosPlayer). Handles errors, shows loading fallback, exposes close control. + 4) JsDosPlayer ((props: JsDosPlayerProps) => JSX.Element) — app/components/doom/JsDosPlayer.tsx + - Purpose: Initializes js-dos in a container div/canvas and runs a .jsdos ZIP archive via props.zipUrl. Disposes emulator instance on unmount. + 5) Modal ((props: { open: boolean; onClose: () => void; children: React.ReactNode; title?: string; className?: string }) => JSX.Element) — app/components/overlay/Modal.tsx + - Purpose: Accessible modal with portal, focus trap, backdrop, escape close, body scroll lock. +- Modified functions (exact name, current file path, required changes) + 1) default export SidebarMenu — app/components/sidebar-menu.tsx + - Add one extra item rendered as a button: + - Label: "running on a potato" + - onClick: calls open() via DoomOverlayProvider context + - Style: consistent with other menu items (Tailwind classes), ensure focus-visible styles for accessibility. +- Removed functions (name, file path, reason, migration strategy) + - None. -## Implementation Order -1. Install missing dependencies -2. Create robots.ts file -3. Update layout to include Footer -4. Update footer to remove Vercel references -5. Test blog functionality -6. Verify all links and navigation work correctly +[Classes] +Single sentence describing class modifications. +No class-based components are used; all additions are functional React components. + +Detailed breakdown: +- New classes + - None. +- Modified classes + - None. +- Removed classes + - None. + +[Dependencies] +Single sentence describing dependency modifications. +We will add js-dos to support running Doom in-browser via a WASM-powered DOSBox. + +Details of new packages, version changes, and integration requirements. +- Add: "js-dos": "^7.5.0" (or latest v7) + - Import usage in client-only component: + - import "js-dos/css/js-dos.css" + - const { Dos } = await import("js-dos") + - Runtime: + - Provide a container element; initialize: const dos = Dos(container) + - Run: dos.run(zipUrl) + - No special COOP/COEP headers required for basic usage. + - Asset loading: + - Default: remote demo archive (subject to availability), e.g. https://v8.js-dos.com/v7/build/doom.jsdos + - Fallback: file input to select a local .jsdos package. We will show instructions in the overlay if remote load fails. +- No changes to Next.js, React, Tailwind, or other existing dependencies. + +[Testing] +Single sentence describing testing approach. +We will verify overlay behavior, emulator load success and teardown, and ensure no SSR/runtime errors across pages. + +Test file requirements, existing test modifications, and validation strategies. +- Manual QA (dev server): + - Open site, click “running on a potato” in sidebar: + - Modal opens, background scroll locked, focus lands on modal. + - Emulator loads and becomes interactive; performance acceptable. + - Close via ESC, backdrop click (if enabled), or close button — focus returns to triggering button. + - Network-offline test: + - Remote .jsdos load should fail gracefully; overlay shows a message and a file picker to choose a local .jsdos archive. After selecting, emulator runs. + - Navigation test: + - Navigate between sections while modal closed; ensure global provider doesn’t interfere with Lenis/scroll. +- Accessibility: + - Confirm appropriate aria attributes, focus management, and keyboard support. +- Performance: + - Confirm dynamic import defers js-dos until modal opens. + - Confirm emulator stops and frees resources when modal closes. + +[Implementation Order] +Single sentence describing the implementation sequence. +Implement provider and modal primitives first, then the overlay and emulator, finally wire up the sidebar, and add the dependency. + +Numbered steps showing the logical order of changes to minimize conflicts and ensure successful integration. +1) Add dependency: js-dos to package.json and install. +2) Create app/providers/DoomOverlayProvider.tsx with context, open/close, and a useDoomOverlay() hook. +3) Create app/components/overlay/Modal.tsx with portal, focus trap, ESC handling, backdrop, and scroll lock. +4) Create app/components/doom/JsDosPlayer.tsx that: + - Dynamically imports "js-dos" on mount (client-only). + - Imports "js-dos/css/js-dos.css". + - Initializes Dos(container) and calls .run(zipUrl). + - Cleans up on unmount or when parent closes. +5) Create app/components/doom/DoomOverlay.tsx that: + - Reads overlay state from context. + - Uses Modal to render content. + - Attempts to load remote .jsdos: https://v8.js-dos.com/v7/build/doom.jsdos + - If loading fails, renders a file input and drag-and-drop area to run a local .jsdos file. + - Provides close control UI. +6) Modify app/layout.tsx to wrap existing providers with and mount near the end of . +7) Modify app/components/sidebar-menu.tsx to add a new button-styled item labeled “running on a potato” that calls open() from the provider. +8) Run dev server and test: + - Modal open/close behavior. + - Emulator loads, runs, and shuts down cleanly. + - Accessibility and performance checks. +9) Optional polish: + - Add loading spinner. + - Persist last used source (remote vs local) in session storage. + - Add instructions link about providing your own .jsdos archive if desired.