Skip to content
itsvedantkumarPublic

About

My widdle cworner of ze intwernet

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

363 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

vedant.to

My widdle cworner of ze intwernet

CI Next.js 16 Node 22 License: MIT live

Live at vedant.to. To run your own copy, start at Deploy your own.

The content rules here are enforced by something that can fail rather than by remembering them. draft: true is applied once, in lib/posts.ts and lib/daily.ts; the pages, both feeds and the sitemap all read that one filtered list, so the four outputs cannot disagree about what is published. CI runs the content normaliser and then fails on its own diff, so a .mdoc that would break rendering cannot land quietly. What each gate catches is in What CI enforces.

Stack

Next.js 16 (App Router) with Keystatic as the CMS, Tailwind CSS 4 for styling, TypeScript throughout, deployed on Vercel, with Upstash Redis for rate limiting and Cloudflare R2 for asset and whisper storage. Node 22.

There is no ESLint here. npm run typecheck and Prettier are the static gates, so do not go looking for npm run lint.

Content

None of it starts here. I write in Notion, edit it there until it is done, and only then publish to the site. That library is also personal data collection on myself, half for whatever self-actualization it eventually adds up to and half so there is a backup if this site ever gets fucked.

Three Keystatic collections, all stored as flat files in the repo:

Collection Path Format
Posts content/posts/ .mdoc
Daily content/daily/ .mdoc
Quotes content/quotes/ .yaml

Posts and daily entries both carry a draft boolean. draft: true hides an entry everywhere: site listings, RSS, JSON Feed, and the sitemap. It is enforced once, at the source, in lib/posts.ts and lib/daily.ts; lib/feed-utils.ts and app/sitemap.xml/route.ts consume the already-filtered getPublishedPosts() / getPublishedDailyEntries() rather than re-implementing the check. One filter, no drift.

This is also why backups must never land in a public bucket: an unpublished draft is invisible on the site but sits in plain text in content/. See Backup.

Quotes have no draft field. Everything in content/quotes/ is public.

Images referenced from content live on the CDN, not in the repo. Keystatic writes an upload to public/images/, scripts/sync-images-to-r2.mjs converts it to WebP and pushes it to R2 on the next push to main, and the reference in the .mdoc points at assets.vedant.to. Because the sync converts the file, the reference has to say .webp too. npm run fix-images rewrites it, and npm run check fails if one was missed.

Structure

path what it is
app/(site)/ the public pages
app/api/ 13 route handlers: the auth endpoints, OG images, upload, whisper, redact
app/keystatic/ the CMS UI
app/auth/keystatic/ the login and device-management screens
lib/ content readers (posts.ts, daily.ts, reader.ts) and the feed/SEO helpers
lib/auth/, lib/webauthn/ the /keystatic gate
components/ the three client components that have to be client components
content/ the three collections above: 9 posts, 17 daily entries, 51 quotes
public/ static assets, including a hand-written robots.txt
scripts/ the content normalisers, the auditor, the R2 image sync, restore.sh
tests/ 24 node:test files, 212 cases. tests/manual/ needs a live Redis; CI skips it
legacy/ the archived Framer site that came before this one. Its own Worker, no app code
docs/ the long-form design notes. One so far: docs/auth.md
.github/workflows/ CI and the seven scheduled or triggered jobs (see Deployment)
proxy.ts the /keystatic auth gate. Node.js runtime, and in Next 16 that is not configurable
.claude/, .conductor/ my agent tooling. It does nothing in a fork. Delete it

A table and not an ASCII tree: the tree that used to be here had drifted wrong in four places. Regenerate the counts with ls content/posts/*.mdoc | wc -l, the same for daily and quotes, and npm test for the case count.

Running locally

git clone https://github.com/itsvedantkumar/vedant.to
cd vedant.to
npm install
cp .env.example .env.local
npm run dev

The site is at http://localhost:3000, the CMS at http://localhost:3000/keystatic. Keystatic runs in local mode when the GitHub credentials are absent, so it writes straight to disk and needs no OAuth for dev.

Env vars are documented in .env.example, not here. It explains the semantics of the trickier ones (KEYSTATIC_ENROLL_TOKEN, KEYSTATIC_SESSION_SECRET) better than a table could, and a second copy would only rot.

Before pushing:

npm run check   # tsc --noEmit, then scripts/audit-content.mjs, then npm test

Four more scripts help while writing:

Command Does
npm run audit:content:watch Runs the content auditor continuously
npm run fix-content Rewrites frontmatter and Markdoc that Keystatic would reject
npm run fix-images Slugifies image filenames and points CDN references at .webp
npm run format Prettier over the repo

Keystatic auth

/keystatic is gated by WebAuthn passkeys, with a break-glass password as the recovery path. Full design, env vars, and known limitations: docs/auth.md.

The one thing worth knowing up front: KEYSTATIC_AUTH_MODE defaults to basic. Anything other than the literal string passkey keeps the old HTTP Basic Auth prompt, so shipping the passkey code is deliberately a no-op until you opt in.

If the passkey gate is the only part you want, it lives standalone as a runnable Next.js app at itsvedantkumar/keystatic-passkeys, same code, none of this site around it.

Redacted lines

/sidequests has bullets that render as a sealed strip until the reader types a password. The plaintext is not in the repo, the bundle, or the page: it is AES-256-GCM ciphertext in the REDACTED_LINES env var, and POST /api/redact decrypts it with a key derived by scrypt (128 MiB per guess) from the password the reader sends. Nothing public can be brute forced offline, and every guess, right or wrong, costs one of 5 per IP per hour and 60 site-wide; without Upstash the route answers 503 rather than run unthrottled. One password covers every line, so it is typed once; the lines the reader did not click stay strips and open on a click. Adding or rotating a line is three commands, in docs/ops.md.

Feeds and SEO

Three hand-rolled route handlers, all export const dynamic = 'force-static', all sharing lib/feed-utils.ts so the three outputs can't disagree about what's published:

  • app/rss.xml/, RSS 2.0
  • app/feed.json/, JSON Feed 1.1
  • app/sitemap.xml/, sitemap

They are route handlers rather than Next's sitemap.ts / robots.ts conventions because the shared-source-of-truth wiring is easier to see this way. robots.txt is a static file in public/. OG images are generated per-page by next/og at /api/og. scripts/indexnow.mjs runs as npm's postbuild script and pings IndexNow after every production build, so new posts get crawled in minutes instead of days.

Deployment

Production deploys come from Vercel's Git integration on pushes to main, not from GitHub Actions. That keeps releases independent of Actions billing and quota failures. A red CI run never blocks a deploy.

When GitHub Actions is unavailable

Without Actions, two gates still run. npm install's prepare script sets core.hooksPath to .githooks/, so .githooks/pre-push runs npm run check (typecheck, content audit, identity guard, tests), prettier --check, eslint, and, when installed locally, gitleaks over the pushed commits, osv-scanner on package-lock.json, and zizmor offline. Skip once with git push --no-verify. vercel.json sets buildCommand to npm run check && npm run build, so Vercel refuses to deploy a commit that fails the check, the actual deploy gate. To push secrets without Actions: npx --yes vercel@59.1.4 link --yes --project vedant-blog in an empty directory, then printf '%s' "$VALUE" | npx vercel env add NAME production (repeat for preview), then npx --yes vercel@59.1.4 redeploy <latest production url>. The dashboard works too.

Ops Worker

The scheduled jobs Actions used to run, backup and health checks, now run on a Cloudflare Worker, ops/worker/index.mjs, since Actions is a permanent billing lock rather than a temporary outage. Full detail, schedules and log-reading are in docs/ops.md. Deploy or redeploy it with:

R2_BUCKET_NAME=<assets bucket> R2_BACKUP_BUCKET_NAME=<backup bucket> npm run ops:deploy

That generates ops/worker/wrangler.generated.jsonc (gitignored, so no identity is checked in) from site.config.mjs and deploys it. One secret has to be set by hand, once:

npx wrangler secret put RESEND_API_KEY --config ops/worker/wrangler.generated.jsonc

Run either cron by hand without waiting for the schedule:

npx wrangler dev --config ops/worker/wrangler.generated.jsonc --test-scheduled
curl 'http://localhost:8787/__scheduled?cron=17+3+*+*+*'

Legacy archive

The site that came before this one was built in Framer. It is preserved verbatim on a second Worker, legacy/worker/index.mjs, at its own subdomain, with Framer removed from the serving path entirely: the rewritten HTML is in legacy/site/ and the runtime it needs (JS chunks, fonts, images, CMS blobs) is mirrored into R2 under fr-mirr/.

npm run legacy:mirror                                  # re-fetch and rewrite
R2_BUCKET_NAME=<assets bucket> npm run legacy:upload   # push the runtime to R2
R2_BUCKET_NAME=<assets bucket> npm run legacy:deploy   # deploy the Worker
npm run legacy:verify -- --runtime                     # acceptance test, against the live origin

Three things make this harder than a wget mirror, all covered in docs/ops.md. Framer's CMS reader uses its own ?range= query parameter and rejects anything but a 200 of exactly the right length. The mirrored asset prefix has to be the same byte length as the Framer one it replaces, or every byte offset inside those binary blobs shifts. And two JS chunks fetched from Framer at runtime through URLs that no string search of the mirror could find, which is why legacy:verify --runtime drives a real browser and bans more hosts on the wire than it does in the markup. None of this touches the Next.js app. If you fork the repo, delete legacy/, the four legacy:* scripts and legacyUrl in site.config.mjs.

GitHub Actions handles validation and support jobs only:

Workflow Name Trigger Does
ci.yml CI push to main, PR build, normalize-content, audit-content, format:check, typecheck, npm test, R2 image sync, Lighthouse CI
security.yml security PR, push to main, weekly Mon 06:00 UTC gitleaks, semgrep, osv-scanner, zizmor, npm audit, but dormant: Actions is billing-locked, and .githooks/pre-push already runs gitleaks, osv-scanner and zizmor locally
setup-env.yml Setup Vercel Env Vars manual only syncs secrets to Vercel, forces a redeploy

What CI enforces

Every step below can fail the run, in this order. The cheap static gates come first on purpose, so a formatting typo fails in seconds rather than after a full build.

Step Fails when Catches
normalize-content.mjs && git diff --exit-code the normaliser rewrote a tracked file a .mdoc Keystatic would reject, committed unfixed. The run fails and names the file instead of silently fixing it inside the runner
audit-content.mjs a content rule is broken CDN references that are not .webp, and the other checks in scripts/audit-content.mjs
format:check Prettier would rewrite a file formatting drift
typecheck tsc --noEmit reports an error type regressions. There is no ESLint here, so this and Prettier are the whole static gate
npm test any of the 212 cases fails the draft: true invariant (tests/draft-invariant.test.ts), the auth gate (guard, session, enrollment, counter, timing), the whisper route and its quiz, and metadata generation
next build the build errors a route that only breaks at build time. Runs with placeholder Keystatic secrets; Vercel rebuilds with the real ones
Lighthouse CI performance, accessibility or SEO scores below 0.90 on /, /blog, /daily, /quotes, over 3 runs a regression in any of the three. Best-practices is a warning, not an error. Thresholds live in lighthouserc.json

A red run does not block the deploy. Vercel deploys from main independently, so CI is a report rather than a gate. That is the trade for keeping releases off Actions quota.

Deploy your own

Deploy with Vercel

This is a personal site rather than a template. The button gets you a running copy of my site; the steps below turn it into yours. Nothing here is hidden: one config file carries my identity, and Make it yours covers the rest.

1. What you need first

Three accounts, and that is the whole list: GitHub (the fork, and Keystatic's storage in production), Vercel (hosting), and an agent you are already signed in to, such as Claude Code or Cursor. Every command-line tool this repo touches is installed by step 4, so you do not have to set up a toolchain by hand.

You do not need vercel login. Nothing here drives the Vercel CLI from your local session. The one place it runs is .github/workflows/setup-env.yml:98, as a pinned npx vercel@59.11.0 deploy --prod --token "$VERCEL_TOKEN". What Vercel needs from you is three GitHub Actions secrets, not a login:

gh secret set VERCEL_TOKEN        # vercel.com/account/tokens
gh secret set VERCEL_ORG_ID       # Vercel → Settings → General
gh secret set VERCEL_PROJECT_ID   # Vercel → your project → Settings → General

Prefer clicking? Set the same values in the Vercel dashboard and skip setup-env.yml. It exists so secrets live in one place and reach Vercel in one run.

2. Get it running

  1. Fork the repo, then clone your fork.
  2. Run nvm use, npm install, cp .env.example .env.local, then npm run dev. The site comes up at http://localhost:3000 with the CMS at /keystatic, writing to disk. No credentials are needed yet, and .env.local can stay empty for now.
  3. Delete my writing: rm -rf content/posts/* content/daily/* content/quotes/*. Keep the directories and keep keystatic.config.ts, which defines their shape.
  4. Work through Make it yours before deploying, so the first build already carries your domain.

3. Make it yours

Edit site.config.mjs. It holds the name, the domain, the asset host, the GitHub repo Keystatic writes to, the social handles and the three email addresses, and every file that needs one of those values derives it from there: lib/constants.ts for TypeScript, next.config.mjs and scripts/*.mjs by direct import, and the workflows through node scripts/site.mjs <key>.

Then run npm run check. Its check-identity step greps the tracked tree for my values and fails on any it finds outside site.config.mjs, prose and content excepted, so a copy you missed cannot fail open the way the old audit-content regex did.

What is still yours to change by hand, because it is not a string:

Where What
app/icon.png, app/apple-icon.png, public/icon-192.png favicon and app icons. Still mine until you replace
public/<key>.txt IndexNow key. Regenerate, do not reuse mine
package.json, LICENSE, SECURITY.md, this README project name, author, copyright, reporting address
content/ my writing, see step 2
.env.example to .env.local fresh secrets, see the agent prompt below

Rip out what is not yours. /whisper is an anonymous-message endpoint gated by a personal-trivia quiz. The questions live in the WHISPER_QUIZ env var, deliberately not in the repo, so it fails closed with a 503 until you write your own. /sidequests is my timeline, and its password-gated lines read REDACTED_LINES, which fails the same way when unset. components/ holds two easter eggs: easter-egg.tsx prints a line to the devtools console site-wide, and post-console-art.tsx prints ASCII art on every post and daily entry, hand-drawn for four slugs and generated from the slug for the rest. CLAUDE.md, .claude/ and .conductor/ are my agent configuration and mean nothing in your fork.

4. Or let an agent do it

Before pasting this, finish step 1: the agent needs to be signed in and to have push access to your fork. It cannot create your DNS records, Vercel project, R2 buckets, Upstash database, or GitHub OAuth app, and the prompt ends by telling it to say so rather than pretend.

This repo is a fork of itsvedantkumar/vedant.to, a Next.js 16 + Keystatic blog. Rebrand it
for me and leave the architecture alone.

My details:
- Name: <YOUR NAME>          - Short site name: <SHORT NAME>
- Domain: <example.com>      - Asset host: <assets.example.com>
- GitHub: <owner>/<repo>     - X: <@handle>   - LinkedIn: <url>
- Contact email: <you@example.com>

Do not run `vercel login`; this repo never uses a local Vercel session.

Do this:
1. Set up the toolchain before anything else, installing whatever is missing rather than
   asking me to. This repo pins Node 22 in .nvmrc and package.json "engines", so run
   `nvm install 22 && nvm use` (install nvm first if absent). Then `npm install`. Install
   the GitHub CLI if it is missing and run `gh auth login` if `gh auth status` fails; it is
   needed to set Actions secrets. Install the `aws` CLI only if I say I want the R2 backup
   and restore scripts. The ops Worker deploy runs through `npx wrangler`, so nothing extra
   to install there. Report the versions you ended up with.
2. Put my details into site.config.mjs. That is the only code file that carries the
   previous owner's identity; everything else derives from it. Then update package.json
   ("name", "author"), LICENSE, SECURITY.md and docs/auth.md, which are prose and are not
   derived. Run `node scripts/check-identity.mjs` and fix anything it lists.
3. Empty content/posts/, content/daily/, and content/quotes/. Do not change
   keystatic.config.ts's collection schemas.
4. Delete the /whisper feature: app/(site)/whisper/, app/api/whisper/, lib/whisper-quiz.ts,
   its lines in public/robots.txt, and its env vars in .env.example (WHISPER_TOKEN_SECRET,
   WHISPER_QUIZ, WHISPER_BUCKET_NAME, WHISPER_TO_EMAIL) and in .github/workflows/setup-env.yml.
   Delete /sidequests and its password gate: app/(site)/sidequests/, app/api/redact/,
   components/redacted.tsx, lib/redact.ts, lib/redacted-store.ts, scripts/redact.mjs,
   tests/redact*.test.ts, the
   `.redacted-*` block in app/globals.css, the link on app/(site)/page.tsx, the
   `/sidequests` entry in scripts/indexnow.mjs, and REDACTED_LINES in .env.example.
5. Delete the easter eggs: components/easter-egg.tsx and components/post-console-art.tsx,
   plus their imports in app/layout.tsx, app/(site)/blog/[slug]/page.tsx AND
   app/(site)/daily/[slug]/page.tsx. Both page files import PostConsoleArt.
6. Delete CLAUDE.md, .claude/ and .conductor/. They are the previous owner's agent tooling.
7. Replace the IndexNow key: generate a new 32-character hex string, and rename
   public/<old-key>.txt to <new-key>.txt with the key as its only content.
   scripts/indexnow.mjs discovers the key file by name, so nothing else to change.
8. Generate fresh values for every secret in .env.example and write them to .env.local,
   which you should create by copying .env.example. Each of KEYSTATIC_SECRET,
   KEYSTATIC_SESSION_SECRET, KEYSTATIC_ENROLL_TOKEN and UPLOAD_SECRET must be a new random
   string of at least 32 characters. Never reuse a value from the upstream repo.
9. Rewrite the intro paragraphs of README.md and the homepage copy in app/(site)/page.tsx
   in my voice. Update the Vercel deploy button URL at the top of the Deploy your own
   section to point at my repo.
10. Run `npm run check` and `npm run build` and fix what breaks. `check` includes
    check-identity, so a leftover value from the previous owner fails here rather than
    shipping.
11. Then list for me, and do not attempt yourself: the DNS records, the Vercel project and
    its three Actions secrets (VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID), the
    Cloudflare R2 buckets, the Upstash Redis database, the GitHub OAuth app, replacing
    app/icon.png and app/apple-icon.png, and enrolling a passkey at /auth/keystatic/enroll.

5. Set up the services

The site builds and serves content with none of these. Add each one when you want what it does.

Service Needed for Without it
GitHub OAuth app /keystatic in production The build fails; CI uses placeholders
Vercel Hosting No hosting
Upstash Redis Rate limiting, passkey storage Limits are skipped; /keystatic still fails closed
Cloudflare R2 (public) Image hosting Uploads return 503
Cloudflare R2 (private) Daily backups, whisper messages the ops Worker's backup run and /api/whisper fail
Resend Security alerts and whisper email Alerts are dropped
proxycheck.io VPN detection on /whisper The call still runs, unkeyed on the free tier
PostHog Analytics, replays, errors Nothing is sent; the client never initialises

Three env vars get a production build to pass: KEYSTATIC_GITHUB_CLIENT_ID, KEYSTATIC_GITHUB_CLIENT_SECRET, and KEYSTATIC_SECRET. Add KEYSTATIC_AUTH_PASSWORD and you can also get into /keystatic; without either that password or an Upstash Redis, the gate fails closed and answers 503. .env.example documents all 27 and says which are required. Set them in the Vercel dashboard, or put them in GitHub Actions secrets and run setup-env.yml, which pushes them to Vercel and redeploys. Without Actions, add them directly with npx vercel env add; see When GitHub Actions is unavailable.

Once /keystatic is reachable, enroll a passkey at /auth/keystatic/enroll and set KEYSTATIC_AUTH_MODE=passkey. Enroll a second one before you rely on it: the break-glass password needs Upstash reachable, so a single passkey plus a Redis outage locks you out. docs/auth.md has the runbook.

One thing I would change if starting over. The feeds and sitemap are hand-rolled route handlers rather than Next's sitemap.ts / robots.ts conventions. That was a deliberate call for a visible shared source of truth, but the conventions are less code.

Limits

Worth knowing before you fork it.

  • CI cannot stop a bad deploy. Vercel ships on push to main whatever CI says. If that trade is wrong for you, add a branch protection rule; nothing in the repo does it for you.
  • The image pipeline hardcodes assets.vedant.to in three places and fails open on any other host: scripts/audit-content.mjs:125,140 and scripts/normalize-images.mjs:54,72. Point your content at a different CDN and the checker stops recognising your references, so npm run check passes, while npm run fix-images quietly rewrites nothing. Meanwhile scripts/sync-images-to-r2.mjs still uploads the file as .webp, so the reference and the object disagree and the image 404s in production. Change the host in all three files together.
  • The passkey gate is off by default. KEYSTATIC_AUTH_MODE has to be the literal string passkey. Anything else keeps HTTP Basic Auth, so shipping the WebAuthn code changes nothing until you opt in.
  • Upstash and R2 are optional, but not equally. The site builds and serves without either. Without Upstash there is no rate limiting; without R2 the whisper route and image uploads have nowhere to write.
  • macOS and Linux only. Nothing here has been run on Windows, and scripts/restore.sh is bash.
  • No ESLint. Deliberate, and it means a lint rule you rely on elsewhere is not running here.

Backup

A Cloudflare Worker, ops/worker/index.mjs, runs a backup every day at 03:17 UTC. Full schedule, limits and how to run it or read its logs by hand are in docs/ops.md. Each run:

  • Fetches the GitHub tarball of main and writes it to the private backup bucket as backups/repo-YYYY-MM-DD.tar.gz, pruning copies older than 30 days.
  • Mirrors the live asset bucket into s3://$R2_BACKUP_BUCKET_NAME/r2-assets/, capturing objects that exist only in R2 (API uploads, whisper messages) and have no copy in the repo. The free Workers plan caps subrequests at 50 a run, so at most 18 objects mirror per day; a large batch converges over the following days instead of failing.

Backups go to a private bucket (R2_BACKUP_BUCKET_NAME), never to R2_BUCKET_NAME. The latter is the live asset bucket, fronted by the public domain assets.vedant.to. Anything written there is world-readable, and backups can contain unpublished drafts. Keep these separate.

Restore

Hand the tarball straight to scripts/restore.sh. It verifies the archive, refuses absolute or parent-relative entries, lifts content/ and public/images/ out of the nested <owner>-<repo>-<sha>/ directory GitHub tarballs use, and moves any existing copies aside to *.bak-<timestamp> before restoring:

scripts/restore.sh repo-YYYY-MM-DD.tar.gz

A zip holding content/ and public/images/ at its root (the old Actions format) still works the same way.

R2-only objects restore in the opposite direction, with aws s3 sync s3://$R2_BACKUP_BUCKET_NAME/r2-assets/ s3://$R2_BUCKET_NAME/.

Docs

  • docs/auth.md — the /keystatic gate: WebAuthn design, the break-glass password path, every env var it reads, and how to verify the gate is live in production.
  • docs/ops.md, the ops Worker: what runs where, schedules, limits, how to deploy, run a job by hand, and read the logs.
  • .env.example — every environment variable, with the semantics of the awkward ones written next to them. Deliberately the only copy; a table in this file would rot against it.

Credits

Next.js, Keystatic, Tailwind CSS, and Vercel do the heavy lifting. The passkey gate is SimpleWebAuthn. Syntax highlighting is sugar-high.

MIT. The code is yours to take; the writing in content/ is not.

About

My widdle cworner of ze intwernet

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages