What changed, and why

The drop-and-share site is the last static workload left on the cluster. glassbox went first, then 2ls.tech. Most of this PR is the same shape they had: the container path goes (Dockerfile, nginx.conf, .dockerignore, an image-only CI workflow) and Vercel's git integration serves public/ straight from the repo.

One thing gives this round extra weight. Pushing this repo is how every read-thru guide publishes. Making the push itself the deploy doesn't just simplify hosting; it deletes the separate cluster-rollout step from that whole workflow.

Two pieces are genuinely new. cleanUrls is load-bearing, since every shared link (/read-thru/<repo>/pr-<n>-<slug>, no extension) works today only because nginx tried $uri.html. And autoindex has no Vercel equivalent at all, so a small build script now writes the directory listings. Those two carry the review weight below.

vercel.json: config for everything nginx did

Five nginx behaviors, five destinations — two of them into files in this PR:

Serve public/, clean URLs, and run the index generator at build.

vercel.json · 6 lines
vercel.json6 lines · JSON
1{
2 "$schema": "https://openapi.vercel.sh/vercel.json",
3 "buildCommand": "node generate-index.mjs",
4 "outputDirectory": "public",
5 "cleanUrls": true
6}
nginx rulewhere it lives now
try_files $uri $uri.html $uri/ (extension-less URLs)cleanUrls: true — every shared read-thru link depends on this
autoindex on (directory listings, root included)generate-index.mjs, run as the buildCommand (next section)
Cache-Control: no-cache on contentVercel's static default (public, max-age=0, must-revalidate), right for files that mutate in place under stable names
gzip on + typesedge compression, no config
/healthz for the k8s probesdropped — no pod, no probes
Each serving rule, accounted for.

generate-index.mjs: autoindex, rebuilt at build time

Vercel serves files. It will not list a directory, ever, and the root of this site is a directory listing. So the generator walks public/ on every deploy and writes an index.html (entries, sizes, dates) into each directory that doesn't already own one. / keeps working. So does /read-thru/. A freshly dropped file shows up in the listings with nobody maintaining an index by hand, which is the property autoindex actually provided.

The shallow-clone handling and date helper, the ownership rule, and the descend loop; the HTML-emitting middle is folded.

generate-index.mjs · 119 lines
generate-index.mjs119 lines · JavaScript
⋯ 22 lines hidden (lines 1–22)
1// Build-time stand-in for nginx's `autoindex`: Vercel has no directory
2// listings, so this walks public/ and writes an index.html into every
3// directory that doesn't own a real page, listing its entries (dirs first,
4// then files, with sizes and last-commit dates). Runs as the Vercel
5// buildCommand; run locally only to preview (outputs are gitignored — don't
6// commit them). Generated listings carry a MARKER meta tag so re-runs can
7// tell them from committed pages (e.g. review/vibe-themer/index.html, which
8// is left alone, children and all).
9import { execFileSync } from "node:child_process";
10import {
11 existsSync,
12 readFileSync,
13 readdirSync,
14 statSync,
15 writeFileSync,
16} from "node:fs";
17import { join, relative } from "node:path";
18 
19const ROOT = "public";
20const MARKER = '<meta name="generator" content="generate-index.mjs">';
21 
22function git(...args) {
23 return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
25 
26// Vercel clones --depth=10, and in a shallow clone `git log -1 -- <path>`
27// reports the truncation boundary for anything older — a wrong date that
28// drifts forward with every push. Deepen once if the remote allows it;
29// lastTouched() degrades boundary hits to "—" if we're still shallow.
30let shallow = false;
31try {
32 shallow = git("rev-parse", "--is-shallow-repository") === "true";
33 if (shallow) {
34 execFileSync("git", ["fetch", "--quiet", "--unshallow"], { stdio: "ignore" });
35 shallow = false;
36 }
37} catch {
38 // not a git checkout, or the fetch failed — dates degrade below
40 
41// Last-commit date for an entry. Clone mtimes are just checkout time, so git
42// is the only meaningful source; mtime covers untracked files and non-git
43// runs. `%p` (parent hashes) tells a boundary commit apart: parentless in a
44// still-shallow clone means "history truncated here", so admit ignorance.
45function lastTouched(path) {
46 try {
47 const out = git("log", "-1", "--format=%cs %p", "--", path);
48 if (out) {
49 const [date, ...parents] = out.split(" ");
50 if (shallow && parents.length === 0) return "—";
51 return date;
52 }
53 } catch {
54 // fall through to mtime
55 }
56 try {
57 return statSync(path).mtime.toISOString().slice(0, 10);
58 } catch {
59 return "—";
60 }
⋯ 9 lines hidden (lines 62–70)
62 
63function fmtSize(bytes) {
64 if (bytes < 1024) return `${bytes}B`;
65 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}K`;
66 return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
68 
69const esc = (s) =>
70 s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
71 
72// A directory is "owned" when it has an index.html we didn't generate.
73function ownsRealIndex(dir) {
74 const p = join(dir, "index.html");
75 if (!existsSync(p)) return false;
76 try {
77 return !readFileSync(p, "utf8").includes(MARKER);
78 } catch {
79 return true; // unreadable — treat as owned, never overwrite
80 }
⋯ 30 lines hidden (lines 81–110)
82 
83function writeListing(dir) {
84 const entries = readdirSync(dir, { withFileTypes: true })
85 .filter((e) => e.name !== "index.html" && !e.name.startsWith("."))
86 .sort(
87 (a, b) =>
88 Number(b.isDirectory()) - Number(a.isDirectory()) ||
89 a.name.localeCompare(b.name),
90 );
91 
92 const here = relative(ROOT, dir) || "/";
93 const rows = entries.map((e) => {
94 const p = join(dir, e.name);
95 const href = encodeURIComponent(e.name) + (e.isDirectory() ? "/" : "");
96 const label = esc(e.name) + (e.isDirectory() ? "/" : "");
97 const size = e.isDirectory() ? "-" : fmtSize(statSync(p).size);
98 return `<tr><td><a href="${href}">${label}</a></td><td>${size}</td><td>${lastTouched(p)}</td></tr>`;
99 });
100 const up =
101 here === "/" ? "" : `<tr><td><a href="../">../</a></td><td></td><td></td></tr>`;
102 
103 writeFileSync(
104 join(dir, "index.html"),
105 `<!doctype html><meta charset="utf-8">${MARKER}<title>Index of ${esc(here)}</title>
106<style>body{font:14px/1.6 monospace;margin:2rem}td{padding:0 1.5rem 0 0}</style>
107<h1>Index of ${esc(here)}</h1><table>${up}${rows.join("")}</table>\n`,
108 );
109 
110 for (const e of entries) if (e.isDirectory()) descend(join(dir, e.name));
112 
113function descend(dir) {
114 if (ownsRealIndex(dir)) return; // a real page owns this dir — leave it whole
115 writeListing(dir);
117 
118descend(ROOT);
119console.log("directory indexes generated under", ROOT);

What's gone, and the README

The deleted CI workflow was image-only, the same shape 2ls.tech had: one job building ghcr.io/mseeks/static for the cluster pod to pull, nothing to gate on. It goes whole. Dockerfile, nginx.conf (42 lines), and .dockerignore go with it, and the README's local preview drops its docker run for the generator plus a plain Python file server.

The whole README: what the site is, how it serves, local preview.

README.md · 22 lines
README.md22 lines · Markdown
1# static
2 
3A personal drop-and-share static site. Put a file under `public/`, push
4`main`, and it's live at `https://static.mseeks.me/<path>`; the root `/`
5(and every directory without a page of its own) lists what's there.
6 
7## Serving
8 
9Hosted on **Vercel**: a push to `main` deploys production, and pull requests
10get preview URLs. [`vercel.json`](./vercel.json) serves `public/` with clean
11URLs (`/foo` serves `foo.html` — shared links carry no extension), and
12[`generate-index.mjs`](./generate-index.mjs) runs at build time to write the
13directory listings nginx's `autoindex` used to render. A directory with its
14own committed `index.html` is left alone.
15 
16## Local preview
17 
18```sh
19node generate-index.mjs # writes gitignored index.html listings
20python3 -m http.server 8080 -d public
21# open http://localhost:8080/ — extension-less URLs are prod behavior only
22```

How to verify it

The generator ran locally against the real tree, twice. The root listing came out with all four top-level directories and their last-commit dates (froot at 2026-06-08, straight from git). A file added between runs showed up in the regenerated listing, proving the marker-based idempotence, and a probe named tmp probe#1.txt produced a correctly encoded href. vibe-themer's page stayed exactly as committed, and git status showed nothing generated, which is the gitignore doing its job.

Three requests settle it after cutover. The root / should render a listing rather than a 404 — that is the autoindex replacement working. An existing shared link like /read-thru/zo/pr-21-2ls-to-vercel should serve its page with no extension, which proves cleanUrls. And the response should say server: Vercel. One bonus: the guide refreshes that sat in main waiting for a cluster rollout go live the moment Vercel first deploys. Ordering (project first, DNS second, namespace deletion last) lives in mseeks/zo#23.