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; idsWEBSITE_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,taxonomyandnewsDetailcandidate 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 bycopyPublicAssets(); allows all and named AI crawlers.cloudflare/_worker.ts— sendsx-robots-tagfor 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
noindexfrom the family'sindexing. - Extra structured data on a React page: add
schema: ctx => ({ itemList, faqs, dateModified })to its SINGLE_PAGES entry (seebestAngelsSchema). - Never hand-write
<head>in a page component; never point canonical anywhere butcanonicalFor.
Rebuild from scratch
headSpec+renderHeadMeta+renderHeadinserver/publicChrome.ts.canonicalFor,fitPageTitle,route()helper increateSeoRouteManifest.buildPageGraphand entity builders inserver/publicSchema.ts.buildCanonicalSitemap, llms renderers, robots.txt copy inscripts/generate-static.ts.deploy-gate(includesscripts/validate-public-seo-build.mjsin 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.