Files
portfolio-next/docs/CONTENT-CONTRACT.md
Lucas WintherandClaude Opus 5.5 43a22adc7a feat(library): recognition.yml and courses.yml
Two optional content files for spec 009: competitions and volunteering,
and completed courses and labs. Every malformed field is reported by
file and field; unknown fields are errors. Synthetic fixtures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 10:05:59 +01:00

12 KiB
Raw Permalink Blame History

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
recognition.yml              # optional: competitions and volunteering
courses.yml                  # optional: completed courses and hands-on labs
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]      # not shown on /work/ since spec 009 (the CV header leads); kept for compatibility
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). intro is no longer shown on /work/: the page opens with the CV header (name, tagline, the cv rows, and now as a short list).

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.

recognition.yml (optional)

Competitions and volunteering. A list; file order is display order. A missing file means no entries, and the "Highlights" block on /work/ and the "Recognition & volunteering" card are left out.

- title: Example Hackathon 2019    # required
  kind: competition                # required: competition | volunteering
  result: winner                   # required; for volunteering the role, e.g. "IT & website"
  highlight: true                  # optional, default false; the first 3 show in the /work/ header
  detail: four years               # optional short text after the title
  year: "2019"                     # optional text ("2019", "2016–2019"); shown only when present
  url: https://example.invalid     # optional http(s)
  status: ongoing                  # optional, volunteering only: ongoing | retired

courses.yml (optional)

Completed courses and hands-on labs. A list in any order (the site sorts). A missing file means no courses: the "Keeping up" blocks, the course search under "Also made" and /work/learning/ are left out.

- title: Example Fundamentals      # required
  provider: Example Academy        # required
  kind: course                     # required: course | lab
  topic: Ansible                   # required, a short name; the same text groups courses (chips, table, filters)
  completed: 2025-04-18            # required, a real calendar date
  minutes: 224                     # required, a positive whole number (course time only)
  certificate: true                # optional, default false
  url: https://example.invalid     # optional http(s)
  keywords: [ansible, automation]  # optional list of text, used by search only

Keep topic short: it is a chip label, a table row and a filter button. Hours shown are course time only. In both files an unknown field is an error (it catches typos such as certifcate). Neither file needs a contract bump: older sites ignore them.

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), unless it is an SVG: link previews need a raster image, and png or jpg is the safest. 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; recognition.yml and courses.yml entries as above (no unknown fields); 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.