sahin.io

All skills and agents

page-content-yaml

Every visible word on sahin.io/angel-investments lives in YAML or Markdown under content/, never in page code (owner, 2026-10-11 "no html code, yaml + markdown only"). Use before writing or changing any visible copy (headings, labels, intros, FAQs, buttons, theme text), when adding a page, and when moving an existing page's copy out of code.

Source file
.claude/skills/page-content-yaml/SKILL.md

Page content lives in YAML + Markdown

Code computes numbers and builds layout. Words come from files the owner can edit without touching code:

Kind Where
A page's copy (headings, labels, intros, tables, FAQs, link text) content/pages/<page>.yaml
Long-form writing (guides, category essays) Markdown in content/guides/, content/taxonomy/
Server-rendered pages (company, Category, investor directories, news stories, editorial wrappers, Explore, home index, titles) content/pages/<family>.yaml (company-page, taxonomy-page, investor-directories, news-detail, editorial-pages, explore, portfolio-index, menu-pages, site-titles, lists, co-investor, company-list-controls, structured-data, page-crumbs)
A theme view's copy (client/src/components/views/*) content/themes/<theme>.yaml
The interactive hub (client/src/pages/Home.tsx) content/chrome/hub.yaml
Shared terms content/shared/site-language.yaml
Navigation: menu sections, page labels, page titles and meta descriptions content/shared/navigation.yaml
Site chrome: header, footer, sidebar, mobile tabs, breadcrumb, search palette content/shared/chrome.yaml
Theme gallery copy and every theme's name and description content/shared/themes.yaml
Owner-edited data (short names, …) YAML in data/

JSON stays only for generated output (the Airtable sync's files, browser bundles, public data downloads).

The pattern (see the Angel Investor Guide)

content/pages/angel-investor-guide.yaml + server/publicPages/angelGuide.ts:

  1. YAML: schemaVersion: 1, then the page's copy. Numbers are placeholders (Based on {total} public portfolio companies), plurals are { one: "{count} company", other: "{count} companies" }, links name a site path (path: /faq), and **double asterisks** make text bold. Document the placeholders in a comment at the top.
  2. Shape: the page builder declares the YAML's shape once with text, optional, list, record from server/pageContent.ts, then loadPageContent('<page>', SHAPE). A missing, empty or misspelled key fails the build naming the YAML path, and every string passes the public content policy. The shape also types what the builder reads.
  3. Fill: compute numbers from the shared sources (shared/portfolioStatus.ts for counts, server/publicCompanyValue.ts for values) and pass them through fillText / pluralText. An unknown placeholder throws.
  4. Component: the React page renders props only. Any word it used to write (e.g. "Written by", "company/companies") becomes a prop from the YAML.
  5. Prove no visible change across the whole site: before editing, build and node scripts/diff-static-build.mjs snapshot <scratch>/before; after, build again and node scripts/diff-static-build.mjs compare <scratch>/before. It compares all ~1,770 built pages and must report 0 text and 0 markup changes (only intended copy edits may differ). Then run ui-before-after.
  6. Ratchet: npx tsx scripts/update-inline-copy-baseline.ts lowers the page's count in content/migration/inline-copy-baseline.json. Add the migrated files to the "migrated pages" list in server/content-in-yaml.test.ts, which also fails when any page file gains inline copy.

Server-rendered pages (HTML strings)

server/seoRouteManifest.ts, server/listPages.ts and server/coInvestorPages.ts build HTML in template strings. Their words come from YAML the same way:

  1. Shape in server/staticPageCopy.ts (one getter per YAML file, e.g. companyCopy()), or beside the builder for a single page.
  2. Fill an HTML text position with fillHtml(template, values) or htmlText(text) from server/pageContentHtml.ts. They escape only &, < and >, so apostrophes and quotes stay byte-identical. Values go in as given: pass data through escapeHtml and links as built HTML ({ link: <a class="link" href="…">${htmlText(copy.link)}</a> }). Attributes, <title>, meta descriptions and JSON-LD use fillText (then escapeHtml where it lands in HTML).
  3. Key code by route path, never by a label. A label in YAML can change; "/faq" cannot.
  4. The ratchet reads these strings as their text between tags plus aria-label, alt, title, placeholder and <meta content>; markup, CSS, inline scripts, file names and locale codes do not count. Single words (Market, Live) are not counted, so move them too.

Browser components (theme views, hub, nav, footer, search)

These render in the browser with no server builder, so they read a generated module instead of the YAML (see SocialFeedView.tsx + content/themes/social-feed.yaml):

  1. YAML in content/themes/<theme>.yaml or content/chrome/<part>.yaml, same conventions as a page (placeholders documented at the top).
  2. node scripts/sync-site-copy-module.mjs writes client/src/generated/copy/<group>/<name>.ts (one module per file, so a lazy view ships only its own copy). The Vite configs run it before every build; server/site-copy-module.test.ts fails when a module is stale or a string breaks the public content policy. Commit the generated module.
  3. Component: import copy from '@/generated/copy/themes/<name>' and read copy.feed.heading; fill numbers with fillText/pluralText from @/lib/siteCopy. Company-name filters and status labels move too (hiddenCompanies, status.active).
  4. Zero inline copy: npx tsx -e "import {findInlineCopy} from './server/inlineCopy'; console.log(findInlineCopy('<file>'))" must print [], then add the file to MIGRATED_FILES in server/content-in-yaml.test.ts and run the ratchet script.
  5. Theme views are not in the static build, so prove no visible change by opening the theme in the preview (/?theme=<id>) before and after.

Rules

  • Never write visible copy in .ts/.tsx page code, including new pages.
  • Never write "(sourced)" or "(Publisher, Month)" notes (standing rule).
  • Keep the page's words identical when migrating; copy edits are their own change the owner approves.
  • Copy that also runs in the browser (navigation, chrome, theme gallery) cannot read YAML at runtime. Its YAML lives in content/shared/, and the one generator, node scripts/sync-site-copy-module.mjs, turns it into client/src/generated/copy/shared/<name>.ts like every other group (committed, never edited by hand); browser code imports @/generated/copy/shared/chrome, server and shared code the same file by relative path. Its shape and cross-reference checks live in server/sharedContent.ts and run in server/shared-content.test.ts; server/site-copy-module.test.ts fails while a module is stale. Fill placeholders with fillText, or fillParts in JSX (it keeps React's text nodes, so server-rendered markup does not change), from shared/contentText.ts.

Migration stages (proposed 2026-10-11, approved stages 1 and 2)

  1. Foundation + the Angel Investor Guide (done).
  2. Navigation/chrome (content/shared/) and the static page builders (server/seoRouteManifest.ts, list and co-investor pages, and their helpers: company/investor titles and descriptions, the sort and filter toolbar, structured data, breadcrumbs) (done 2026-10-11).
  3. The React public pages (client/src/publicPages/), in batches.
  4. The theme views (client/src/components/views/).
  5. Owner-edited JSON data to YAML.

content/migration/inline-copy-baseline.json shows what is left.