A webring for personal sites, in loving memory of <blink>.
Live at: deadhtml.pickles.dev Maintainers: @technicalpickles · @noizwaves
An old-school webring. Members link to a "next" and "previous" neighbor
plus the hub, using four plain <a> tags — no JavaScript, no tracking, no
runtime dependency on this repo. Joining is a pull request: fork, add a
file under members/, open a PR.
See JOINING.md to join, DESIGN.md for
the full design doc, and DECISIONS.md for the
decision log and rationale history.
ring.json holds the ring's own name/URL; each member is one file under
members/{slug}.json — one member per PR, so joining never conflicts with
anyone else's join PR. A GitHub Actions build reads all of it and
generates a fully static site:
ring.json + members/*.json ──(build)──► dist/
├── index.html home + directory + join snippet
├── 404.html
├── badges/*.png|gif|svg
├── random/index.html random-site hop (the only JS in the project)
└── {slug}/
├── next/index.html
└── prev/index.html
Ring order (next/prev) is derived deterministically from each member's
joined date (slug breaks ties on the same day) rather than stored
anywhere, since there's no longer a single array to hold a position.
Membership changes regenerate every redirect page, so the ring re-wires itself on every merge — members never touch their own site again after adding the snippet once.
Requires Node 22+.
npm install
npm run build # ring.json + members/*.json + templates → dist/
npm run dev # build + serve dist/ locally
npm test # vitest
npm run validate # schema/charset/tag/badge/reachability checks (what CI runs on PRs)webring/
├── ring.json # ring name + url (maintainer-owned)
├── ring.schema.json # JSON Schema for the assembled ring
├── dead-tags.json # curated list of adoptable patron tags
├── members/ # one {slug}.json per member — join = add a file here
├── badges/ # member-supplied 88×31 PNG/GIF badges
├── src/
│ ├── build.ts # ring.json + members/*.json + templates → dist/
│ ├── members.ts # loads/validates one member per file, derives ring order
│ ├── validate.ts # PR validation (schema, charset, tags, badges, reachability)
│ ├── check-links.ts # weekly dead-link checker
│ ├── allowlist.ts # changed-file allowlist for non-maintainer PRs
│ ├── badges.ts # default badge + hub badge generation
│ ├── escape.ts # the one HTML-escape helper
│ └── templates/ # index, redirect, random, 404, layout, snippet
├── dist/ # gitignored, built in CI
├── .github/
│ ├── CODEOWNERS
│ └── workflows/ # deploy.yml, validate.yml, linkcheck.yml
├── JOINING.md
├── DESIGN.md
├── DECISIONS.md
└── CNAME
These need to be set once in GitHub's Settings UI and aren't tracked by this repo:
- Branch protection on
main: require PR review, require thevalidatestatus check, require review from Code Owners. - Actions → General: require approval for first-time contributors' workflow runs; set workflow default permissions to read-only.
- Pages: source = GitHub Actions; custom domain
deadhtml.pickles.devwith HTTPS enforced (theCNAMEfile indist/sets the domain, but DNS + the HTTPS toggle are configured here). - 2FA/passkeys required for both maintainer accounts.
- Resolve the Action version tags in the workflows under
.github/workflows/to full commit SHAs — see D15 inDECISIONS.mdfor why they currently aren't.
Untrusted contributions (member PRs) can only ever be data —
members/ and badges/ — never code. See DECISIONS.md
for the full threat model and the CI controls that enforce it
(changed-file allowlist, pull_request-only trigger with base-branch
code run against PR data, CODEOWNERS, least-privilege workflow
permissions).
MIT — see LICENSE.