Phase 1 of the functional design plan. - Move the old site into legacy/, unchanged, for reference. It still builds from there until cutover. - Add packages/schema: the one definition of all 20 note types, with field titles and descriptions, note and vault checks, and JSON Schema export so later editing tools and the MCP server share it. - Add vault-kit: Obsidian templates generated from the schema, and the Site guide for 24.01_PersonalWebsite. - Add tools/migrate: turns the legacy JSON, collections, store data and page text into 545 Site folder notes and 84 images, and checks the result with the schema. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
14 KiB
Content Guide
How to add and edit content on the site. Four sections use Content Collections
(typed markdown/MDX/YAML); the other interest pages are hand-built .astro files.
Projects (Professional page)
Projects render from src/content/projects/*.md (or .mdx). Each file is one
project card. The schema lives in src/content.config.ts.
Add a project
Create src/content/projects/my-project.md:
---
title: 'My Awesome Project'
role: 'Lead Developer' # optional, default: 'Developer'
tech: ['TypeScript', 'Node.js', 'SAP BTP'] # rendered as pills
summary: 'A 2-3 sentence description of what it does and your contribution.'
repo: 'https://github.com/you/repo' # optional
demo: 'https://demo.example.com' # optional
caseStudy: 'https://blog.example.com' # optional
cover: '/images/my-project.png' # optional, public path
order: 20 # lower = shown first, default 100
placeholder: false # true hides it from "real" styling
---
Optional longer writeup in markdown here. The card only shows `summary`, but you
can link to a case study for depth.
Field reference
| Field | Type | Required | Notes |
|---|---|---|---|
title |
string | yes | Project name |
role |
string | no | e.g. "Lead Developer" |
tech |
string[] | no | Tag pills |
summary |
string | yes | Short card description |
repo / demo / caseStudy |
URL | no | Outbound links |
cover |
string | no | Image path in public/ |
order |
number | no | Sort weight |
placeholder |
boolean | no | Marks it as a TODO card |
To remove the seed placeholder, delete placeholder-project.md. Aim for 3-6 real
entries per the spec.
Experience (Professional timeline)
Experience renders from src/content/experience/*.md. Each file is one role on
the vertical timeline.
Add a role
Create src/content/experience/another-role.md:
---
company: 'Previous Co.'
title: 'Junior Developer'
period: '2016 - 2020' # free-form date label
focus: 'What you primarily did.' # optional
icon: 'briefcase' # mdi icon name for the marker
order: 20 # lower = more recent / first
---
- Bullet point of an accomplishment.
- Another bullet, ideally with a metric.
- One more, showing impact.
The body renders inside an expandable "Show details" panel. Use markdown lists
for bullets. Replace the bracketed placeholders in the seed implico.md with
real outcomes, metrics, and dates.
Icon names
The icon field takes an mdi name without the mdi: prefix. Browse names at
pictogrammers.com or search the
installed set:
node -e "const m=require('./node_modules/@iconify-json/mdi/icons.json'); console.log(Object.keys(m.icons).filter(n=>n.includes('cloud')).join('\n'))"
Games (Gaming page)
The Gaming page (src/pages/interests/gaming.astro) has two content-driven
sections that read from collections: Now playing and the Gaming Hall of
Fame. The platforms grid and favorite-genre cards are still edited inline on
the page.
Hall of Fame — one file per game
Each game is a markdown file in src/content/games/hall-of-fame/. Create a new
file (e.g. hades.md):
---
title: 'Hades'
platform: 'Steam' # free-form; drives the icon + platform pill
year: 2020 # optional, shown as a small detail
art: '/images/games/hades.jpg' # optional, public path (see fetch tool below)
artAlt: 'Hades key art' # optional, defaults to the title
icon: 'sword-cross' # optional, overrides the platform-derived icon
blurb: 'A 2-3 sentence case for why it earned a Hall of Fame slot.'
order: 15 # lower = shown first, default 100
---
| Field | Type | Required | Notes |
|---|---|---|---|
title |
string | yes | Game name |
platform |
string | yes | e.g. "PlayStation 5", "Steam", "Nintendo Switch" |
blurb |
string | yes | Why it's a favorite |
art |
string | no | /images/games/<slug>.jpg (public path) |
artAlt |
string | no | Alt text, defaults to title |
year |
number | no | Release year |
icon |
string | no | mdi icon override (else derived from platform) |
order |
number | no | Sort weight |
platform is matched case-insensitively to pick an icon: playstation →
sony-playstation, switch → nintendo-switch, steam/pc → steam,
xbox → microsoft-xbox, anything else → gamepad-variant. Override with
icon if you want something specific.
Now playing — single list
Edit src/content/games/now-playing.yaml. This is the transient list of games
you're actively playing; each entry is a title + platform only:
games:
- title: "Elden Ring"
platform: "PlayStation 5"
- title: "Balatro"
platform: "Steam"
If the list is empty, the page shows a TODO placeholder instead.
Fetching box art (RAWG)
scripts/fetch-box-art.mjs downloads box/banner art from the
RAWG API into public/images/games/, then prints the
public path to paste into a game file's art: field. RAWG covers Steam,
PlayStation, Xbox, and Nintendo in one place.
- Get a free key at https://rawg.io/login?forward=developer and add it to
.env(copy from.env.example):RAWG_API_KEY=your_key_here - Run (Node 20.6+ can auto-load
.envvia--env-file):node --env-file=.env scripts/fetch-box-art.mjs "Elden Ring" node --env-file=.env scripts/fetch-box-art.mjs "Hollow Knight" --platform pc node --env-file=.env scripts/fetch-box-art.mjs --id 3328 # by RAWG id node --env-file=.env scripts/fetch-box-art.mjs "Celeste" --out celeste.jpg - It prints something like:
Paste that
✔ Saved: public/images/games/elden-ring.jpg art: "/images/games/elden-ring.jpg"art:value into the game file's frontmatter.
RAWG serves landscape banner/screenshot art (not portrait Steam capsules), which matches the Hall of Fame cards (
aspect-[4/3]).
Board games (TTRPGs page)
The "Board games" section on the TTRPGs page reads from a JSON file and renders an interactive shelf: a list of games on the left, and a tall image on the right that cross-fades to a game's BGG cover when you hover its name. Clicking a name opens the game on BoardGameGeek.
The list — src/data/boardgames.json
A flat JSON array. Each entry needs a name and a bggId; note and image
are optional:
[
{ "name": "Wingspan", "bggId": 266192, "note": "Engine builder" },
{ "name": "Brass: Birmingham", "bggId": 224517 }
]
| Field | Type | Required | Notes |
|---|---|---|---|
name |
string | yes | Display label + link text |
bggId |
number | yes | BoardGameGeek numeric id (from its URL) |
note |
string | no | One-line caption shown under the name |
image |
string | no | Cover path/URL; populated by the fetch tool |
Find a game's bggId in its BoardGameGeek URL:
https://boardgamegeek.com/boardgame/<bggId>/<slug>.
The shelf photo at rest is /images/ttrpg-boardgame-shelf.jpg — drop a real
photo of your shelf at that path. The image is a progressive enhancement:
without JS the list still works (links + shelf photo); games without an image
just keep the shelf photo on hover.
Fetching covers (BoardGameGeek)
scripts/fetch-bgg-covers.mjs fills in the image field for any game missing
one, using BGG's public XML2 API (no key needed):
# Default: download covers to public/images/boardgames/<bggId>.jpg (recommended)
node scripts/fetch-bgg-covers.mjs
# Instead hotlink BGG's CDN URLs (no download, depends on their CDN)
node scripts/fetch-bgg-covers.mjs --remote
# Re-fetch even games that already have an image
node scripts/fetch-bgg-covers.mjs --force
# Slow down between BGG calls (they rate-limit; default 1200ms)
node scripts/fetch-bgg-covers.mjs --sleep 2000
It's idempotent — run it again any time you add games to the JSON.
Media viewing history (Anime, TV, Movies, Reading pages)
Four interest pages — Anime (/interests/anime), TV Shows
(/interests/tv), Movies (/interests/movies), and Reading
(/interests/reading) — share one data pattern. Each reads a JSON file, shows
the favorites as poster/cover cards up top, then the full library
grouped by category as pills below.
The data files and their pages:
| Page | Data file |
|---|---|
| Anime | src/data/media/anime.json |
| TV Shows | src/data/media/tv.json |
| Movies | src/data/media/movies.json |
| Reading | src/data/reading.json |
The shared item shape
A flat JSON array. Each entry has a title plus optional fields. The
favorite flag is the single source of truth for what appears in the top
grid — set it on the entry itself, no separate favorites list to keep in sync.
category drives the grouping of the full library below (items without a
category appear only in favorites, if favorited).
[
{
"title": "Breaking Bad",
"favorite": true,
"blurb": "The character study of a decade."
},
{
"title": "Dark",
"category": "Streaming Originals",
"status": "Completed",
"note": "Optional short detail shown on hover."
}
]
| Field | Type | Notes |
|---|---|---|
title |
string | Display label (required) |
favorite |
boolean | True → rich card in the top "Favorites" grid |
category |
string | Grouping label for the full library, e.g. "Prestige Drama" |
status |
string | Pill text, e.g. "Completed", "Watching", "Caught up" |
year |
number | Release year; shown on movie/anime pills |
count |
string | Human span for a franchise, e.g. "All 4 films" |
blurb |
string | Long-form copy on favorite cards |
note |
string | Short detail on a full-list pill (hover/title attr) |
image |
string | Local art path; populated by the fetch tools below |
Adding an entry is just dropping another object in the right JSON file. The
page reads it via the helpers in src/lib/media.ts (getFavorites,
getGrouped). Categories appear in order of first occurrence, so order the
file the way you want the page grouped.
Movies are film-level: each entry is one film, with category naming the
franchise it belongs to (e.g. "The Matrix", "Marvel Cinematic Universe").
Single films with no sequel go under category: "Standalone". A favorite film
carries favorite: true so it appears in the top grid and in its franchise
group.
Fetching poster/cover art
Three idempotent scripts fill in the image field for favorites only (the
full library stays text pills), downloading to public/images/<kind>/:
# Anime — AniList GraphQL API (free, NO key required)
node scripts/fetch-anime-art.mjs
node scripts/fetch-anime-art.mjs --force # re-fetch existing
# Reading — Open Library Covers API (free, NO key required)
node scripts/fetch-book-covers.mjs
# Movies + TV — TMDB API (free key required: TMDB_API_KEY in .env)
node --env-file=.env scripts/fetch-tmdb-art.mjs
node --env-file=.env scripts/fetch-tmdb-art.mjs --kind movies
node --env-file=.env scripts/fetch-tmdb-art.mjs --kind tv
All three skip items that already have an image (use --force to re-fetch),
sleep briefly between requests, and write the local path back into the JSON.
Get a free TMDB key at https://www.themoviedb.org/settings/api.
Cards render the image when present and fall back to a kind-derived icon slab
otherwise, so partial coverage looks intentional.
TCGs (Trading Card Games page)
The TCGs page (src/pages/interests/tcgs.astro) is hand-built, like the other
interest pages. Two parts are data-driven:
- The games I play and Proxy making are inline frontmatter arrays of
{ icon, title, body }— edit them directly on the page. - Magic: The Gathering decks are pulled live from Archidekt on the server (the site is SSR, so no CORS or API key is needed).
Archidekt integration
src/lib/archidekt.ts fetches Greg's public decks from:
GET https://archidekt.com/api/decks/v3/?ownerUsername=<username>
- The username is the
ARCHIDEKT_USERNAMEexport (currentlyPatiun). Change it there to point at a different account. - Results are cached to
node_modules/.cache/archidekt-<username>.jsonforCACHE_TTL_MS(default 1 hour) so repeat visits are instant and we're gentle on Archidekt. Tune the TTL via that export. - Only public, non-unlisted decks are shown. If Archidekt is unreachable, the section falls back to a "Manage on Archidekt" link — it never breaks the page.
Each deck renders as a DeckCard (featured card art, mana-color pips, card
count, format, last-updated) linking to its Archidekt page. To add or remove
decks, just change them on Archidekt — the page picks them up automatically
(within the cache window).
Interest pages
Each interest is a hand-built page in src/pages/interests/. They follow the
spec's template: Header (title + tagline), Overview (narrative),
Highlights (card grid), Currently (active now), Related (cross-links).
To edit content, open the relevant .astro file and change the copy in the
frontmatter arrays or the section bodies. To add a new interest:
- Create
src/pages/interests/my-interest.astro(copy an existing page as a template). - Use
<BaseLayout surface="interest">,<InterestHero>,<InterestCard>, and<RelatedInterests>. - Add a card for it in
src/pages/interests/index.astro(the hub).
Global content (nav, socials, tagline)
Edit src/lib/site.ts:
siteIdentity— name, tagline, domainnavGroups— the two-group top navsocials— footer + contact links (replace the[Greg to fill]handles)
Finding placeholders
Every [Greg to fill] gap from the spec is rendered as a styled Placeholder
slot (dashed border + pencil badge + "TODO · label"). To find them all:
grep -rn "Placeholder label" src/
grep -rn "Greg to fill\|\[Greg" src/
Replace each with real content as you gather it.