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:
- 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. - Shape: the page builder declares the YAML's shape once with
text,optional,list,recordfromserver/pageContent.ts, thenloadPageContent('<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. - Fill: compute numbers from the shared sources (
shared/portfolioStatus.tsfor counts,server/publicCompanyValue.tsfor values) and pass them throughfillText/pluralText. An unknown placeholder throws. - 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.
- Prove no visible change across the whole site: before editing, build and
node scripts/diff-static-build.mjs snapshot <scratch>/before; after, build again andnode 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 runui-before-after. - Ratchet:
npx tsx scripts/update-inline-copy-baseline.tslowers the page's count incontent/migration/inline-copy-baseline.json. Add the migrated files to the "migrated pages" list inserver/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:
- Shape in
server/staticPageCopy.ts(one getter per YAML file, e.g.companyCopy()), or beside the builder for a single page. - Fill an HTML text position with
fillHtml(template, values)orhtmlText(text)fromserver/pageContentHtml.ts. They escape only&,<and>, so apostrophes and quotes stay byte-identical. Values go in as given: pass data throughescapeHtmland links as built HTML ({ link:<a class="link" href="…">${htmlText(copy.link)}</a>}). Attributes,<title>, meta descriptions and JSON-LD usefillText(thenescapeHtmlwhere it lands in HTML). - Key code by route path, never by a label. A label in YAML can change;
"/faq"cannot. - The ratchet reads these strings as their text between tags plus
aria-label,alt,title,placeholderand<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):
- YAML in
content/themes/<theme>.yamlorcontent/chrome/<part>.yaml, same conventions as a page (placeholders documented at the top). node scripts/sync-site-copy-module.mjswritesclient/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.tsfails when a module is stale or a string breaks the public content policy. Commit the generated module.- Component:
import copy from '@/generated/copy/themes/<name>'and readcopy.feed.heading; fill numbers withfillText/pluralTextfrom@/lib/siteCopy. Company-name filters and status labels move too (hiddenCompanies,status.active). - Zero inline copy:
npx tsx -e "import {findInlineCopy} from './server/inlineCopy'; console.log(findInlineCopy('<file>'))"must print[], then add the file toMIGRATED_FILESinserver/content-in-yaml.test.tsand run the ratchet script. - 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/.tsxpage 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 intoclient/src/generated/copy/shared/<name>.tslike 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 inserver/sharedContent.tsand run inserver/shared-content.test.ts;server/site-copy-module.test.tsfails while a module is stale. Fill placeholders withfillText, orfillPartsin JSX (it keeps React's text nodes, so server-rendered markup does not change), fromshared/contentText.ts.
Migration stages (proposed 2026-10-11, approved stages 1 and 2)
- Foundation + the Angel Investor Guide (done).
- 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). - The React public pages (
client/src/publicPages/), in batches. - The theme views (
client/src/components/views/). - Owner-edited JSON data to YAML.
content/migration/inline-copy-baseline.json shows what is left.