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>
12 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
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>:
 (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.
interestis one id or a list of ids, each frominterests.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; declarecontract: 2insite.yml(seedocs/DEPLOY.md, app first).- Each
mediaentry needsaltand exactly one offile(must exist inmedia/) orurl(http/https). - Order in an
interestlist 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:
filmwithmusicamong its interests: the firstmediaentry with aurlis its soundtrack, shown as a dark tray across the card's foot (itsaltis the tray's title). YouTube, Vimeo, Spotify (album/track/playlist) and BandcampEmbeddedPlayerURLs play there after a click; any other URL is a "listen at …" link. A second image inmediais the album art (the first stays the poster). Nourl, no tray.musicwith an image inmedia: 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.comicwith exactly one image: treated as a cover, shown whole in a narrow card (sizedoesn'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:
lforcomicandessay;mforsong,game,film,art,music;sforbookandlife. - AI level: only on things Lucas made: kinds
essay,life,music, andartwithoutcredit. They default tounknown. On shares of other people's work (comic,song,game,film,book, andartwithcredit)aimust 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.
Link previews and feeds
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.