sahin.io

All skills and agents

public-url-conventions

The URL and slug rules for every public page on sahin.io/angel-investments. Use before adding, renaming, moving, or linking any public route or slug (new page families, companies, Categories, Sub-Categories, investor directories, lists, news, guides), whenever a portfolio/Airtable sync introduces a new Category or Sub-Category, and whenever someone asks to change, prettify, shorten, or redirect a URL.

Source file
.claude/skills/public-url-conventions/SKILL.md

Public URL conventions

Every public URL is built by one module, shared/publicUrls.ts, and follows one grammar. The static manifest (server/seoRouteManifest.ts), the redirect builder (server/legacyRedirects.ts), llms.txt, the /data/* endpoints, the GSC report, and the React hub all import from it. server/public-urls.test.ts fails the build if anything drifts.

The grammar

The section root is https://sahin.io/angel-investments/. Nothing lives at the top of sahin.io (sahin.io/investors is not ours). The builders return site-relative paths such as /anthropic/angel-investors; the public URL is the root plus that path. When you write URLs for the owner, always write them in full, because a bare /investors reads as sahin.io/investors.

Page Full URL Builder
Hub sahin.io/angel-investments/ —
Company profile sahin.io/angel-investments/{company} companyPath
Company investors sahin.io/angel-investments/{company}/angel-investors companyInvestorsPath
Category sahin.io/angel-investments/{category} categoryPath
Category investors sahin.io/angel-investments/{category}/angel-investors categoryInvestorsPath
Sub-Category sahin.io/angel-investments/{parent-category}/{subcategory} subcategoryPath
Sub-Category investors sahin.io/angel-investments/{parent-category}/{subcategory}/angel-investors subcategoryInvestorsPath
Investor section index sahin.io/angel-investments/angel-investors INVESTORS_INDEX_PATH
Co-investor firm sahin.io/angel-investments/angel-investors/{firm} coInvestorPath
Lists sahin.io/angel-investments/lists, …/lists/{list} listPath
News sahin.io/angel-investments/news/{slug} newsPath
Guides sahin.io/angel-investments/guides, …/guides/{slug} guidePath
Feature pages sahin.io/angel-investments/{page} (thesis, stages, faq, …) content/shared/navigation.yaml via shared/publicNavigation.ts

Examples: sahin.io/angel-investments/fintech, sahin.io/angel-investments/fintech/banking, sahin.io/angel-investments/anthropic/angel-investors, sahin.io/angel-investments/fintech/banking/angel-investors, sahin.io/angel-investments/lists/best-angel-investors.

An investor directory is always the page it describes followed by /angel-investors (INVESTORS_SEGMENT): sahin.io/angel-investments/anthropic → sahin.io/angel-investments/anthropic/angel-investors, sahin.io/angel-investments/fintech/banking → sahin.io/angel-investments/fintech/banking/angel-investors. The investor index is sahin.io/angel-investments/angel-investors (INVESTORS_INDEX_PATH) and the co-investor firm pages sit under it. This is the owner's explicit, final choice (2026-10-08, after trying …/investors and …/angel-investors/{page}/angel-investors). Paths are at most three segments deep.

The slug rule

  • Every path segment matches ^[a-z0-9]+(-[a-z0-9]+)*$: lowercase ASCII, single hyphens, no underscores, no trailing slash (the hub / is the only exception). At most three segments.
  • Category and Sub-Category slugs are always taxonomySlug(label) (lowercase; every run of other characters becomes one hyphen; trim hyphens): AI Agents → ai-agents, E-Commerce → e-commerce, HR → hr. Never hand-edit or shorten a taxonomy slug (no /ai/agents); the label decides it.
  • Company slugs are stable IDs stored in client/public/portfolio-data.json. Never re-derive them from the name, and never change one after it is published without a redirect (see below).
  • Lists, news, and guides choose a descriptive slug once and keep it.

Never hand-build a path

import { publicHref, subcategoryPath, companyInvestorsPath } from '../shared/publicUrls'   // server
import { publicHref, taxonomyPath } from '@shared/publicUrls'                               // client
publicHref(subcategoryPath('banking'))   // "/angel-investments/fintech/banking"

Do not write `/angel-investors/${slug}/angel-investors` or /angel-investments/... strings in renderers, scripts, or components. To read a slug back from a route, use routeEntitySlug(route.kind, route.path). The test no hand-built public paths outside shared/publicUrls.ts rejects the retired spellings anywhere outside the redirect modules.

In content/**/*.md, link with full site paths (/angel-investments/fintech/banking). A link to a path that is not a manifest route is silently rewritten to the hub, so after any URL change grep content/ for the old spelling.

Sub-Category parents

A Sub-Category belongs to one pinned parent Category, its first URL segment. The pins live in data/subcategory-parents.json ("banking": "fintech"). Sub-Categories are many-to-many in Airtable (Banking companies carry Fintech and, once, Software), so the pin is a URL decision, not a data fact. The Sub-Category page still lists every company tagged with it.

How a parent is chosen: the Category most of the Sub-Category's companies carry; ai-* Sub-Categories sit under ai; ties are decided by meaning (rockets → space, solar → energy, therapeutics → biotech). pnpm urls:parents prints the majority for each Sub-Category and flags unpinned ones and disagreements; --all lists everything.

A new Sub-Category from a sync fails the build with Sub-Category "x" has no parent Category. Run pnpm urls:parents, add the pin, and run pnpm test. Tests also require the pinned parent to be a Category that at least one company in that Sub-Category carries.

Changing a pin moves a public URL. It is allowed, and the old /{old-parent}/{sub} keeps working automatically if some company in the Sub-Category still carries the old parent (the redirect builder 301s every co-occurring Category to the pinned one). If not, add "/{old-parent}/{sub}": "/{new-parent}/{sub}" and the /investors variant to data/legacy-redirects.json.

Namespace rules

Companies, Categories, feature pages, and section roots all share the first segment. createSeoRouteManifest() throws with the names of both owners when two claim the same segment, before anything is written.

  • Reserved first segments (RESERVED_TOP_LEVEL_SEGMENTS): angel-investors, investors, lists, news, guides, sources, data, assets, images, explore, plus every feature page path.
  • A company slug may not equal a Category slug. If a new company would collide (a company called "Space"), give the company a distinct slug (space-inc) and ask the owner.
  • A Sub-Category may not be named angel-investors or investors.

Adding a new page family

  1. Add a builder to shared/publicUrls.ts. If the family introduces a new first segment, add it to RESERVED_TOP_LEVEL_SEGMENTS.
  2. Create the routes in createSeoRouteManifest() with that builder; render them in renderRouteHtml() reading the slug with routeEntitySlug.
  3. Link to them only through the builder (server and client).
  4. Add the pattern to the URL patterns block in server/llmsText.ts.
  5. Extend server/public-urls.test.ts and update the grammar table above.

Changing or retiring a URL

URLs are permanent public commitments. Any rename or move needs, in the same change:

  1. A 301 for every old URL. Pattern families get a resolver in resolveLegacyPath() and an enumeration in enumerateLegacySources() (server/legacyRedirects.ts); one-offs go in data/legacy-redirects.json. The build materializes an exact map: every target must be a manifest route, there are no chains, and a real page always wins over a redirect. Point existing redirects straight at the new URL. Never chain them.
  2. Updated links in content/, data/legacy-redirects.json targets, and tests.
  3. A GSC note when the old URL had traffic (pnpm gsc:report lists legacy URLs with traffic and their suggested targets via server/gscReport.ts).

Verify

pnpm check && pnpm test
NODE_ENV=production npx vite build --config vite.config.cloudflare.ts
NODE_ENV=development npx tsx scripts/generate-static.ts
node scripts/validate-public-seo-build.mjs        # needs Pillow (python3 -m venv + pip install pillow)
npx esbuild cloudflare/_worker.ts --bundle --platform=neutral --target=es2022 --format=esm --outfile=dist/cloudflare/_worker.js
npx wrangler pages dev dist/cloudflare --port 8788
curl -sI http://localhost:8788/angel-investments/subcategory/banking   # 301 → /fintech/banking

Check a new URL returns 200 with a matching <link rel="canonical">, and every old one returns a single 301 to it.

Retired families (2026-10-08 unification)

All paths below are under sahin.io/angel-investments.

Before Now
/category/{c} /{c}
/category/{c}-angels-investors /{c}/angel-investors
/subcategory/{s} /{parent}/{s}
/subcategory/{s}-angels-investors /{parent}/{s}/angel-investors
/{company}-angels-investors /{company}/angel-investors
/investors, /investors/{firm} (the section root until 2026-10-08) /angel-investors, /angel-investors/{firm}
/{page}/investors, /angel-investors/{page}/angel-investors (shapes live for a few hours on 2026-10-08) /{page}/angel-investors
/best-angel-investors /angel-investor-rankings (the old page was the all-time top 15; owner decision 2026-10-11)
/most-active-angel-investors, /lists/most-active-angel-investors (merged into the top list 2026-10-11; its activity tiers are the #most-active section) /lists/best-angel-investors

Older families (/investors/{company}, /{company}-angel-investors, /{company}-funding, /compare/…, /market/…, …) also resolve to the new URLs in one hop.

Shortcuts and aliases (also 301s)

All paths below are under sahin.io/angel-investments.

Typed URL Lands on
/{subcategory} /{parent}/{subcategory} (/banking → /fintech/banking)
/angel-investors/{company or category or subcategory}, /angel-investors/{category}/{subcategory} (no trailing segment) that page's investor directory
/{page}/angel-investors when that page has no investor directory yet the page itself; dropped automatically once the directory exists
/category, /categories, /subcategory, /subcategories, /sub-category /explore
A company's name as typed (/together-ai, /replit, /cart-com, /lamb-labs-yc-s26) its canonical slug (deriveCompanyAliases; skipped when two companies share the spelling or it is taken)

All of these come from enumerateLegacySources(); a real page always wins, so adding a company or Category whose slug equals a shortcut simply replaces the redirect.