Files
portfolio-next/docs/CONTENT-CONTRACT.md
T

10 KiB

Content contract (version 2)

How to write the content repository (portfolio-next-content). The site reads it; nothing you write here needs a code change. Normative source: docs/specs/001-site.md §7.1, §10, §11. A change to this format is a contract change: bump contract and ask the owner first. Validate locally with bun src/cli.ts validate <content-dir>: it lists every problem, not just the first.

Layout

site.yml
interests.yml
projects/<project-id>.md
items/<item-id>.md
issues/<yyyy>-<h1|h2>.md     # optional editor's note for a half-yearly issue
media/<files>                # images, audio, the CV PDF

The file name (without .md) is the ID and appears in URLs: lowercase letters, digits and - only. Renaming a file breaks old links. Any file with draft: true is skipped entirely (not validated, not built): use it for anything not yet confirmed. Dates are YYYY-MM-DD and must be real calendar days (2025-02-30 is rejected).

Media paths (links.cv, a project's image.file and links[].file, an item's media[].file) are relative to media/ and must name a regular file there: no leading / or \, no .., no folders. Subfolders are fine (art/page-1.png). To show a media file inside a Markdown body, link it by its URL, /media/<file>: ![A page from the comic](/media/art/page-1.png) (images in bodies load lazily).

site.yml

contract: 2                   # 1 or 2; declare 2 when any item uses an interest list
name: Example Person
tagline: One line under the name
intro: [First line, Second line]      # the /work/ heading (first line, second line greyed)
personal_intro: One line, off the clock   # optional; the lede on the start page's right half and /off-the-clock/
now: [Reading something, Building something]
links:
  email: person@example.invalid
  github: https://example.invalid/github
  linkedin: https://example.invalid/linkedin
  bluesky: https://example.invalid/bluesky   # optional
  cv: cv.pdf                                 # optional; a file in media/
cv:
  now: Current role
  before: [Earlier role, Even earlier role]
  degree: Degree, University

contract, name, tagline, links.email|github|linkedin, cv.now and cv.degree are required. intro, now and cv.before are lists of text (empty if omitted). personal_intro is optional text; when it is missing there is simply no lede off the clock (the work intro is never reused there). Without intro, /work/ keeps a hidden heading with name for screen readers.

interests.yml

A list; file order is the chip order on the personal page.

- { id: comics, name: Comics }
- { id: made-by-me, name: Made by me }

projects/<id>.md

---
title: Example Compute Portal      # required
date: 2025-06-01                   # required; orders projects within a group
group: work                        # required: work | own | school
where: Example Corp                # required
years: 2024-2025                   # optional; leave it out when unknown (shown only when present)
stack: [TypeScript, Postgres]      # optional
summary: One-line summary.         # required
ai: by-hand                        # ai-made | some-ai | by-hand | unknown (default unknown)
featured: 1                        # optional order (1 first); needs a showcase or an image
showcase: compute-queue            # optional drawn panel: compute-queue | config-editor | peer-network | rowing-stroke
image:                             # optional; stands in for the showcase when there is none
  { file: compute-portal.jpg, alt: Screenshot of the portal }   # file in media/, an image
links:                             # optional; each has a url (http/https) or a file in media/
  - { label: Source, url: 'https://example.invalid/portal' }
  - { label: Report, file: reports/portal.pdf }   # served as /media/reports/portal.pdf
ai_parts:                          # optional breakdown
  - { part: Docs, level: some-ai, note: Drafted with a model }
related: [another-project-id]      # must be existing, non-draft projects
draft: true                        # optional
---
Markdown body: why, how, what is interesting.

showcase must be one of the drawn panels the site has: compute-queue, config-editor, peer-network, rowing-stroke (any other id is an error). A featured project needs a showcase or an image.

image is shown (lazy, with its alt) in the featured panel, list thumbnail, side sheet and project page, but only where the project has no known showcase; without either, a generic drawn frame is used. file must exist in media/ and end in an image extension (png, jpg, jpeg, gif, webp, avif, svg); alt is required.

Never guess ai: leave it out and it shows as "not recorded". Sort order: featured (ascending), then date, newest first.

items/<id>.md

---
title: Fixture Comic               # required
date: 2025-11-02                   # required
kind: comic                        # required: comic | song | game | film | book | art | essay | life | music
interest: comics                   # required: an id from interests.yml, or a list: [film-tv, music]
size: l                            # optional s | m | l (default by kind, below)
line: Your one-liner.              # optional
media:                             # optional
  - { file: page-1.svg, alt: Page one, caption: Optional }   # file in media/
  - { url: 'https://example.invalid/video', alt: Handcam set }   # or an external link
rating: 4                          # optional, 0 to 5
link: https://example.invalid      # optional
credit: Artist Name                # the artist/author of someone else's work; shown as "by …" on art, comic, song
kickstarter: true                  # optional (default false)
ai: by-hand                        # own work only
draft: true                        # optional
---
Markdown body.
  • interest is one id or a list of ids, each from interests.yml, none repeated. The item shows under every chip it names; the first is its primary interest. The others are named in the card's small meta line. Lists need the site from spec 002 on; declare contract: 2 in site.yml (see docs/DEPLOY.md, app first).
  • Each media entry needs alt and exactly one of file (must exist in media/) or url (http/https).
  • Order in an interest list matters: the first id is the primary one (it picks the chip a live card joins, and the meta line names only the others). Put the interest that matches the kind first: [film-tv, music] for a film.
  • Shapes some kinds take from their media:
    • film with music among its interests: the first media entry with a url is its soundtrack, shown as a dark tray across the card's foot (its alt is the tray's title). YouTube, Vimeo, Spotify (album/track/playlist) and Bandcamp EmbeddedPlayer URLs play there after a click; any other URL is a "listen at …" link. A second image in media is the album art (the first stays the poster). No url, no tray.
    • music with an image in media: the first image replaces the striped header (the recording, player or link still works from it). It is framed 4:3, anchored a little above the middle (38% from the top), so a group photo keeps everyone; on the item's own page it is at most 700 px wide.
    • comic with exactly one image: treated as a cover, shown whole in a narrow card (size doesn't widen it). Two or three images show as panels.
    • Images are served as they are (no resizing at build): export them at the size they're shown, e.g. ≤ 1000 px wide.
  • Size defaults: l for comic and essay; m for song, game, film, art, music; s for book and life.
  • AI level: only on things Lucas made: kinds essay, life, music, and art without credit. They default to unknown. On shares of other people's work (comic, song, game, film, book, and art with credit) ai must be absent (an error if present); the site treats it as no level, never hidden.
  • Items sort newest first.

issues/<yyyy>-<h1|h2>.md

Plain Markdown (no front matter needed): the editor's note shown on that half-year's issue, e.g. issues/2025-h2.md. Issues themselves are generated from items by date.

Ghost

Writing lives on Ghost (GHOST_URL); the site reads published posts at build time and decides by tag:

Tag (slug) Effect
none of the below A work post at /work/writing/<slug>/, listed under Writing.
off-the-clock Goes off the clock instead: an essay card on the wall, at /off-the-clock/<slug>/.
off-the-clock + life The same, as a life card. life alone does nothing.
ai-made, some-ai, by-hand The post's AI level; without one it is unknown.
a project id (e.g. example-compute-portal) Lists the post under "Related writing" on that project.

Any other public tag labels the post in lists (the first one wins); internal tags (#…) are ignored. An off-the-clock post whose slug is issues, or equals an item's id, is left out with a build warning (the page belongs to the issues or the item); change its slug in Ghost. Post HTML is cleaned: scripts and embeds become links, and only images hosted on the Ghost site itself are shown inline.

Nothing new to write: a shared link's preview uses the page's existing title and description. A project's image and an item's first image in media become the preview picture (alt included); other pages show none. The feeds (/feed.xml, /work/feed.xml, /off-the-clock/feed.xml) list projects, posts and items with their title, date and a short summary (a project's summary, a post's excerpt, an item's line) and the AI level where there is one. Drafts never appear. All of this needs the image's SITE_URL (docs/DEPLOY.md).

What the validator checks

contract is 1 or 2; IDs match a-z0-9-; dates are real calendar days as YYYY-MM-DD; required fields exist; enums are valid (including showcase); every interest id is in interests.yml (a list is non-empty, without repeats); related ids exist; media paths stay inside media/ and name files; ai rules above. The fixtures in fixtures/content/ (synthetic) show every kind; fixtures/sparse/ is a minimal valid repo.