sahin.io

All skills and agents

seo-head-and-schema

How every page on sahin.io/angel-investments gets its <head> — title (fitPageTitle, 60-char budget, site-titles.yaml), meta description, absolute canonical, robots/noindex, Open Graph and Twitter tags, the single JSON-LD @graph from server/publicSchema.ts — plus sitemap.xml, llms.txt / llms-full.txt and robots.txt. Use when building or changing any page's title, description, canonical, indexing, structured data, sitemap entry or llms output.

Source file
.claude/skills/seo-head-and-schema/SKILL.md

SEO head and structured data

Files

  • server/publicChrome.ts — HeadSpec, headSpec(facts, { title, description, canonicalUrl, jsonLd, indexing?, ogType? }), renderHeadMeta() (all tags), renderHead() (adds charset, viewport, <style>${PUBLIC_PAGE_CSS}).
  • server/seoRouteManifest.ts — canonicalFor(path) (private; / → HUB_URL, else ${SITE_ORIGIN}${BASE_PATH}${path}), PAGE_TITLE_MAX = 60, fitPageTitle(), firstFittingTitle(), taxonomyPageTitle(), investorsIndexTitle(), renderDocument(), renderReactShellPage(), isIndexableRoute(), buildCanonicalSitemap(), renderRouteHtml().
  • server/publicSchema.ts — buildPageGraph(spec: PageGraphSpec), pageEntityType(kind), personEntity, websiteEntity, breadcrumbListEntity, organizationEntity, itemListEntity, faqPageEntity, serializeJsonLd, extractJsonLd, graphEntities; ids WEBSITE_ID (${HUB_URL}#website), PERSON_ID (${SITE_ORIGIN}/#person).
  • server/publicRouting.ts — RouteIndexing, NOINDEX_ROBOTS = 'noindex, follow'.
  • server/llmsText.ts — deriveLlmsFacts, renderLlmsTxt, renderLlmsFullTxt.
  • content/pages/site-titles.yaml — home, explore, guidesIndex, investorsIndex, brandSuffixes, taxonomy and newsDetail candidate lists (longest first).
  • content/pages/structured-data.yaml — person/website descriptions for JSON-LD.
  • data/entity-facts.json — site name, og image (+ size), X handle, inLanguage, person.
  • client/public/robots.txt — copied verbatim by copyPublicAssets(); allows all and named AI crawlers.
  • cloudflare/_worker.ts — sends x-robots-tag for noindex routes (routeRobotsTag).

How it works

Title. Each manifest route's title goes through fitPageTitle(): over 60 characters it drops the first matching brandSuffixes entry; the wording before the suffix must fit by itself. Sources: menu pages → navigation.yaml title; React SINGLE_PAGES/FAMILIES → their *PageMeta (from the page's YAML); companies → server/companyPageCopy.ts; editorial → Markdown front matter; others → site-titles.yaml templates.

Head tags (renderHeadMeta): <title>, description, author (chrome.yaml head.author), robots (index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1 or noindex, follow), canonical (must be absolute https, else throws), og:type/site_name/locale/ title/description/url/image(+width/height/type/alt), twitter card/site/creator/title/ description/image, one <script type="application/ld+json">. og:image must be absolute and client/public/images/og/default.png must exist (the build checks).

JSON-LD. One @graph per page from buildPageGraph: the page entity (pageEntityType: ProfilePage for company/coInvestor, WebPage for companyInvestors, Article for editorial kinds, CollectionPage otherwise) with isPartOf website, author/publisher person, breadcrumb, dateModified; optional article fields, FAQPage (faqs), Organization (mainEntity on profile pages, else about), ItemList (itemList; hasPart when a mainEntity exists), hub Dataset; then BreadcrumbList, WebSite, Person. React pages add itemList/faqs/dateModified through ReactRouteSpec.schema (type ReactPageSchema in server/reactPageRoutes.ts). React pages keep the static <head> untouched (server/publicReactDocument.ts).

Indexing. route.indexing is index or noindex. Noindex routes stay live but carry the noindex robots meta (renderRouteHtml throws if missing), the worker header, and are excluded from sitemap.xml, llms*.txt (indexableManifest) and IndexNow. FAMILIES set it with an indexing function (e.g. founderDetailIndexing, newsItemIndexing).

Sitemap. buildCanonicalSitemap(manifest) lists indexable routes with <loc> canonical and <lastmod> (route.lastModified: the portfolio date, or a page's own lastModified). The sahin.io proxy edge-caches it (see production-deployment).

llms. deriveLlmsFacts counts only indexable routes; statuses via summarizePortfolioStatus (portfolio-counts-single-source). Both renderers assert the public content policy (private-data-boundary).

How to change

  • New page title: put it in that page's YAML; keep the words before the brand suffix ≤ 60 chars.
  • Thin/new family member that should not rank: return noindex from the family's indexing.
  • Extra structured data on a React page: add schema: ctx => ({ itemList, faqs, dateModified }) to its SINGLE_PAGES entry (see bestAngelsSchema).
  • Never hand-write <head> in a page component; never point canonical anywhere but canonicalFor.

Rebuild from scratch

  1. headSpec + renderHeadMeta + renderHead in server/publicChrome.ts.
  2. canonicalFor, fitPageTitle, route() helper in createSeoRouteManifest.
  3. buildPageGraph and entity builders in server/publicSchema.ts.
  4. buildCanonicalSitemap, llms renderers, robots.txt copy in scripts/generate-static.ts.
  5. deploy-gate (includes scripts/validate-public-seo-build.mjs in CI) → ui-before-after → ship-to-production.

Tests

server/public-schema.test.ts, server/keyword-[redacted].test.ts (title budget on every indexable route), server/route-indexing.test.ts, server/seo-features.test.ts, server/seo-route-manifest.test.ts, server/llms-text.test.ts, server/public-site.test.ts, server/cloudflare-worker.test.ts (robots header), server/ai-visibility.test.ts.