Start the rebuild: note schema, vault kit and data migration

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
This commit is contained in:
Claude
2026-10-11 18:40:55 +00:00
parent 4ded4b277b
commit a2f723ca2d
324 changed files with 11538 additions and 7556 deletions
+402
View File
@@ -0,0 +1,402 @@
/**
* Turns the legacy site's data into Site folder notes.
*
* Pure apart from reading legacy/: it returns the notes and the attachments
* to copy, and the CLI writes them. Every note goes through the schema's
* writeNote, so nothing invalid can be produced.
*/
import { readFileSync, readdirSync } from "node:fs";
import { basename, extname, join } from "node:path";
import ts from "typescript";
import { notePath, safeFileName, slugify, splitNote, toWikilink, writeNote } from "@gk/schema";
import * as seed from "./seed.ts";
export interface BuiltNote {
path: string;
text: string;
data: Record<string, unknown>;
body: string;
}
export interface Attachment {
/** Absolute path of the legacy file. */
from: string;
/** Path under the Site folder. */
to: string;
}
export interface BuildResult {
notes: BuiltNote[];
attachments: Attachment[];
/** Plain-language notes about choices the migration made, for the report. */
log: string[];
}
const today = seed.MIGRATED_ON;
export function build(legacyDir: string): BuildResult {
const notes: BuiltNote[] = [];
const attachments = new Map<string, Attachment>();
const usedNames = new Set<string>();
const log: string[] = [];
const json = (p: string) => JSON.parse(readFileSync(join(legacyDir, p), "utf8"));
/** Copy a legacy public image into _attachments/<area>/ and return its wikilink. */
const attach = (publicPath: string, area: string): string => {
const from = join(legacyDir, "public", publicPath);
const existing = attachments.get(from);
if (existing) return toWikilink(basename(existing.to));
const ext = extname(publicPath).toLowerCase();
let name = `${slugify(basename(publicPath, extname(publicPath)))}${ext}`;
for (let i = 2; usedNames.has(name); i++) name = `${slugify(basename(publicPath, extname(publicPath)))}-${i}${ext}`;
usedNames.add(name);
attachments.set(from, { from, to: `_attachments/${area}/${name}` });
return toWikilink(name);
};
const noteNames = new Set<string>();
const add = (data: Record<string, unknown>, body = "") => {
let path = notePath(data);
let name = basename(path, ".md").toLowerCase();
if (noteNames.has(name)) {
// Same title as an earlier note: keep both linkable by naming the type.
const qualifier = String(data.medium ?? data.type);
const renamed = notePath({ ...data, title: `${String(data.title)} (${qualifier})` });
const fixedPath = renamed.replace(/[^/]+\.md$/, `${safeFileName(`${String(data.title)} (${qualifier})`)}.md`);
log.push(`Two notes are called "${String(data.title)}", so one file is named "${basename(fixedPath, ".md")}".`);
path = fixedPath;
name = basename(path, ".md").toLowerCase();
data = { ...data, slug: slugify(`${String(data.title)} ${qualifier}`) };
}
noteNames.add(name);
notes.push({ path, text: writeNote(data, body), data, body });
return basename(path, ".md");
};
const link = (title: string) => toWikilink(safeFileName(title));
// ------------------------------------------------------------ Site, pages, kinds
add({ type: "site-settings", publish: true, ...seed.site });
add({ type: "status-kinds", title: "Status kinds", publish: true, kinds: seed.statusKinds });
for (const p of seed.pages) {
const { body, source: _source, ...rest } = p;
add({ type: "page", created: today, ...rest }, body);
}
// ------------------------------------------------------------ Library: reading
const readingMedium: Record<string, string> = {
Books: "book",
"Light Novels": "light novel",
Webnovels: "webnovel",
Webtoons: "manhwa",
};
// The legacy list grouped manga and manhwa together; these two are Japanese manga.
const mangaTitles = new Set(["Tokyo Revengers", "Wistoria: Wand and Sword"]);
const readingState: Record<string, { state: string; note?: string; kind?: string }> = {
Complete: { state: "finished" },
Reading: { state: "now", kind: "Reading" },
Listening: { state: "now", note: "Listening to the audiobooks.", kind: "Listening" },
Rereading: { state: "now", note: "Rereading.", kind: "Reading" },
"Reread-pending": { state: "up next", note: "Planning a reread." },
Next: { state: "up next" },
New: { state: "up next" },
};
const nowItems: { title: string; kind: string; note?: string }[] = [];
for (const r of json("src/data/reading.json") as any[]) {
let title: string = r.title;
let creator: string | undefined;
const dash = /^(.+?) — (.+)$/.exec(title);
const paren = /^(.+?) \(([^)]+)\)$/.exec(title);
if (dash) [creator, title] = [dash[1]!, dash[2]!];
else if (paren) [title, creator] = [paren[1]!, paren[2]!];
const medium = mangaTitles.has(title) ? "manga" : r.category === "Manga & Manhwa" ? "manhwa" : readingMedium[r.category];
if (!medium) throw new Error(`Unknown reading category ${r.category}`);
const s = readingState[r.status];
if (!s) throw new Error(`Unknown reading status ${r.status}`);
const note = [s.note, r.note].filter(Boolean).join(" ") || undefined;
const name = add({
type: "library-item",
title,
publish: true,
favorite: r.favorite || undefined,
medium,
state: s.state,
creator,
verdict: r.blurb,
note,
cover: r.image ? attach(r.image, "Library") : undefined,
created: today,
});
if (s.kind) nowItems.push({ title: name, kind: s.kind });
}
log.push("Reading: \"Author — Series\" titles were split into a title and a creator.");
// ------------------------------------------------------------ Library: screen
const merged = (file: string) => {
// The legacy files list a favorite twice: once with its blurb and once in its group.
const byTitle = new Map<string, any>();
for (const item of json(file) as any[]) {
const prev = byTitle.get(item.title);
if (prev) log.push(`${item.title}: two legacy entries were merged into one note.`);
byTitle.set(item.title, { ...prev, ...Object.fromEntries(Object.entries(item).filter(([, v]) => v != null)) });
}
return [...byTitle.values()];
};
const screenState: Record<string, string> = { Completed: "finished", "Caught up": "caught up", Watching: "now" };
for (const [file, medium] of [
["src/data/media/anime.json", "anime"],
["src/data/media/tv.json", "TV"],
["src/data/media/movies.json", "movie"],
] as const) {
for (const m of merged(file)) {
const state = m.status ? screenState[m.status] : "finished";
if (!state) throw new Error(`Unknown status ${m.status}`);
const name = add({
type: "library-item",
title: m.title,
publish: true,
favorite: m.favorite || undefined,
medium,
state,
group: m.category,
year: m.year,
verdict: m.blurb,
note: m.note,
cover: m.image ? attach(m.image, "Library") : undefined,
created: today,
});
if (state === "now") nowItems.push({ title: name, kind: "Watching", note: m.note });
}
}
log.push("Movies and shows with no status in the legacy lists were marked finished, since the lists were what Greg had watched.");
// ------------------------------------------------------------ Library: video games
const hallDir = join(legacyDir, "src/content/games/hall-of-fame");
for (const file of readdirSync(hallDir).sort()) {
const { frontmatter: g } = splitNote(readFileSync(join(hallDir, file), "utf8")) as { frontmatter: any };
add({
type: "library-item",
title: g.title,
publish: true,
favorite: true,
medium: "video game",
state: "played",
platform: g.platform,
year: g.year,
verdict: String(g.blurb).trim(),
cover: g.art ? attach(g.art, "Library") : undefined,
created: today,
});
}
const nowPlaying = splitNote(`---\n${readFileSync(join(legacyDir, "src/content/games/now-playing.yaml"), "utf8")}\n---\n`)
.frontmatter as { games: { title: string; platform: string }[] };
for (const g of nowPlaying.games) {
const name = add({ type: "library-item", title: g.title, publish: true, medium: "video game", state: "now", platform: g.platform, created: today });
nowItems.push({ title: name, kind: "Playing" });
}
log.push("The Gaming Hall of Fame became favorite video games with the state played.");
// ------------------------------------------------------------ The Shelf
for (const b of json("src/data/boardgames.json") as any[]) {
if (/^hhttps:/.test(b.image)) {
b.image = b.image.slice(1);
log.push(`${b.name}: fixed a typo in its cover link (hhttps).`);
}
add({
type: "board-game",
title: b.name,
publish: true,
favorite: seed.favoriteBoardGames.includes(b.name) || undefined,
bggId: b.bggId,
blurb: b.note,
owned: true,
cover: b.image,
created: today,
});
}
// ------------------------------------------------------------ Workshop
for (const s of seed.stations) add({ type: "station", publish: true, created: today, ...s });
for (const t of seed.tools) add({ type: "tool", publish: true, created: today, ...t, station: link(t.station) });
seed.crafts.forEach((c, i) =>
add({
type: "craft",
publish: true,
created: today,
title: c.title,
status: c.status,
blurb: "blurb" in c ? c.blurb : undefined,
station: "station" in c ? link(c.station) : undefined,
tools: "tools" in c ? c.tools.map(link) : undefined,
order: i + 1,
}),
);
for (const m of seed.makes) {
add({
type: "make",
title: m.title,
publish: m.publish ?? true,
created: today,
craft: link(m.craft),
photos: m.photo ? [attach(m.photo, "Workshop")] : undefined,
cover: m.photo ? attach(m.photo, "Workshop") : undefined,
blurb: m.blurb,
tags: m.tags,
links: m.links?.map(link),
});
}
// ------------------------------------------------------------ Tabletop
seed.systems.forEach((s, i) => add({ type: "tabletop-system", publish: true, created: today, ...s, order: i + 1 }));
for (const c of seed.campaigns) {
const { body, system, ...rest } = c as typeof c & { body?: string; system?: string };
add({ type: "campaign", created: today, ...rest, system: system ? link(system) : undefined }, body);
}
seed.cardGames.forEach((g, i) => add({ type: "card-game", publish: true, created: today, ...g, order: i + 1 }));
for (const d of seed.decks) add({ type: "deck", publish: true, created: today, ...d, game: link(d.game) });
// ------------------------------------------------------------ Resume
const expDir = join(legacyDir, "src/content/experience");
const months: Record<string, string> = { Jan: "01", Feb: "02", Mar: "03", Apr: "04", May: "05", Jun: "06", Jul: "07", Aug: "08", Sep: "09", Oct: "10", Nov: "11", Dec: "12" };
const month = (s: string) => {
const m = /^([A-Z][a-z]{2}) (\d{4})$/.exec(s.trim());
return m ? `${m[2]}-${months[m[1]!]}` : undefined;
};
for (const file of readdirSync(expDir).sort()) {
const { frontmatter: e, body } = splitNote(readFileSync(join(expDir, file), "utf8")) as { frontmatter: any; body: string };
const [start, end] = String(e.period).split(" - ");
const bullets = body.split("\n").filter((l) => l.startsWith("- ")).map((l) => l.slice(2).trim());
const extra = body.split("\n").filter((l) => l.trim() && !l.startsWith("- ") && !/^Key contributions:?$/.test(l.trim())).join("\n");
add(
{
type: "experience",
title: `${e.title} at ${e.company}`,
publish: true,
company: e.company,
role: e.title,
start: month(start!),
end: end && end.trim() !== "Present" ? month(end) : undefined,
focus: e.focus,
bullets,
created: today,
},
extra,
);
}
add({ type: "skills", title: "Skills", publish: true, ...seed.skills });
add({ type: "education", title: "Education", publish: true, ...seed.education });
// Projects become case study posts. Their bodies were never written, so they
// stay unpublished until the professional pass writes them.
const projDir = join(legacyDir, "src/content/projects");
for (const file of readdirSync(projDir).sort()) {
const { frontmatter: p } = splitNote(readFileSync(join(projDir, file), "utf8")) as { frontmatter: any };
const key = basename(file, ".md");
const extra = seed.caseStudies[key] ?? {};
const summary = String(p.summary).trim();
const firstSentence = /^.+?[.!?](\s|$)/.exec(summary)?.[0].trim() ?? summary;
add(
{
type: "post",
title: p.title,
publish: false,
date: today,
topic: "Work and tech",
postType: "case study",
summary: firstSentence,
role: p.role,
tech: p.tech,
employer: extra.employer,
facts: extra.facts,
created: today,
},
`${summary}\n\n<!-- The full case study gets written in the professional pass. -->`,
);
}
log.push("The four legacy projects became case study posts, unpublished until each one is written up.");
// ------------------------------------------------------------ Store
const store = json("src/data/store-availability.json");
const products = readProducts(legacyDir);
const tabletopTerms = products.requirements.map((r: any) => ({
kind: r.icon === "information" ? "info" : r.icon === "alert" ? "warning" : "rule",
title: r.title,
body: r.body,
}));
const general = new Set(["All sales are final", "Limited capacity"]);
products.items.forEach((p: any, i: number) => {
add(
{
type: "product-line",
title: p.title,
publish: true,
category: "Tabletop",
mode: "made to order",
channel: "here",
summary: p.summary,
icon: p.icon,
order: i + 1,
slots: store.slots[p.slotKey],
images: p.images.map((im: any) => ({ file: attach(im.src, "Store"), alt: im.alt, label: im.label })),
specs: p.specs,
priceTiers: p.pricing,
options: p.colors?.map((c: any) => ({ group: "Filament", name: c.name, hex: c.hex, inStock: c.inStock })),
// The legacy terms were written for the proxy cards.
terms: p.slotKey === "cards" ? tabletopTerms.filter((t: any) => !general.has(t.title)) : undefined,
created: today,
},
);
});
seed.futureLines.forEach((l, i) =>
add({
type: "product-line",
title: l.title,
publish: false,
category: l.category,
mode: "made to order",
channel: "coming soon",
summary: "Coming soon.",
order: products.items.length + i + 1,
priceTiers: [],
created: today,
}),
);
add({
type: "store-settings",
title: "Store settings",
publish: true,
categories: [
{ name: "Tabletop", ordersOpen: store.ordersOpen, turnaround: store.turnaround },
{ name: "Paper", ordersOpen: false },
{ name: "Leather", ordersOpen: false },
],
capacityNote: store.maxNote,
terms: tabletopTerms.filter((t: any) => general.has(t.title)),
});
// ------------------------------------------------------------ Status log
for (const item of nowItems) {
add({ type: "status", title: item.title, publish: true, kind: item.kind, date: today, subject: toWikilink(item.title), note: item.note });
}
for (const system of ["Dungeons & Dragons", "Daggerheart"]) {
add({ type: "status", title: system, publish: true, kind: "Running", date: today, subject: link(system) });
}
log.push(`Every status is dated ${today}, the day of the migration, because the legacy site did not record when each one started.`);
return { notes, attachments: [...attachments.values()], log };
}
/** The legacy store data is a TypeScript module; loadProducts reads it. */
function readProducts(legacyDir: string): { items: any[]; requirements: any[] } {
return productsCache.get(legacyDir) ?? (() => {
throw new Error("Call loadProducts(legacyDir) before build()");
})();
}
const productsCache = new Map<string, { items: any[]; requirements: any[] }>();
export async function loadProducts(legacyDir: string) {
// Transpile on its own, so nothing from the legacy project's config or
// dependencies is loaded. The module only declares data.
const source = readFileSync(join(legacyDir, "src/data/store-products.ts"), "utf8");
const js = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 } }).outputText;
const mod = await import(`data:text/javascript;base64,${Buffer.from(js).toString("base64")}`);
productsCache.set(legacyDir, { items: mod.products, requirements: mod.requirements });
}
+73
View File
@@ -0,0 +1,73 @@
/**
* npm run migrate -- [outDir]
*
* Writes the migrated Site folder to out/24.01_PersonalWebsite/Site (or the
* given folder), with the vault kit's templates, then checks every note and
* the vault as a whole and writes MIGRATION-REPORT.md beside it.
*/
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { basename, dirname, join, resolve } from "node:path";
import { checkNote, checkVault } from "@gk/schema";
import { build, loadProducts } from "./build.ts";
const root = resolve(import.meta.dirname, "../../..");
const legacy = join(root, "legacy");
const outRoot = resolve(process.argv[2] ?? join(root, "out"));
const area = join(outRoot, "24.01_PersonalWebsite");
const site = join(area, "Site");
await loadProducts(legacy);
const result = build(legacy);
rmSync(area, { recursive: true, force: true });
cpSync(join(root, "vault-kit/24.01_PersonalWebsite"), area, { recursive: true });
for (const n of result.notes) {
mkdirSync(dirname(join(site, n.path)), { recursive: true });
writeFileSync(join(site, n.path), n.text);
}
for (const a of result.attachments) {
if (!existsSync(a.from)) throw new Error(`Missing legacy image ${a.from}`);
mkdirSync(dirname(join(site, a.to)), { recursive: true });
cpSync(a.from, join(site, a.to));
}
// Check what was written, the way the content service will read it.
const problems: string[] = [];
const vaultNotes = result.notes.map((n) => {
const checked = checkNote(n.text);
if (!checked.ok) problems.push(`${n.path}: ${checked.problems.map((p) => `${p.field} ${p.message}`).join("; ")}`);
return { path: n.path, type: String(n.data.type), data: checked.ok ? checked.data : n.data, body: n.body };
});
for (const p of checkVault(vaultNotes, result.attachments.map((a) => basename(a.to)))) problems.push(`${p.path}: ${p.message}`);
const counts = new Map<string, { all: number; published: number }>();
for (const n of result.notes) {
const c = counts.get(String(n.data.type)) ?? { all: 0, published: 0 };
c.all++;
if (n.data.publish) c.published++;
counts.set(String(n.data.type), c);
}
const unpublished = result.notes.filter((n) => !n.data.publish).map((n) => `- ${n.path}`);
const report = `# Migration report
${result.notes.length} notes and ${result.attachments.length} images, checked against the schema: ${problems.length ? `**${problems.length} problems**` : "no problems"}.
| Type | Notes | Published |
| --- | --- | --- |
${[...counts].map(([t, c]) => `| ${t} | ${c.all} | ${c.published} |`).join("\n")}
## Problems
${problems.length ? problems.map((p) => `- ${p}`).join("\n") : "None."}
## Choices the migration made
${result.log.map((l) => `- ${l}`).join("\n")}
## Unpublished, waiting on you
${unpublished.join("\n")}
`;
writeFileSync(join(outRoot, "MIGRATION-REPORT.md"), report);
console.log(report.split("\n").slice(0, 3).join("\n"));
if (problems.length) process.exit(1);
+278
View File
@@ -0,0 +1,278 @@
/**
* Content that lived inside the legacy site's page code rather than in data
* files, extracted by hand once. Every entry names where it came from:
* a legacy file, or Greg's own answers during the 2026-10-11 redesign. Nothing
* here is invented; anything not known yet is left out or kept unpublished.
*/
export const MIGRATED_ON = "2026-10-11";
// ---------------------------------------------------------------- Site
/** legacy/src/lib/site.ts, legacy/src/pages/about.astro, legacy/src/lib/archidekt.ts */
export const site = {
title: "Site settings",
name: "Greg Kilmer",
tagline: "Developer. Maker. Storyteller.",
lockup: "Web Development · a Patiun LLC company",
location: "Philadelphia, PA",
socials: [
{ label: "GitHub", handle: "Patiun", url: "https://github.com/patiun" },
{ label: "LinkedIn", handle: "Gregory Kilmer", url: "https://www.linkedin.com/in/gregory-kilmer-36646bb5/" },
{ label: "Archidekt", handle: "Patiun", url: "https://archidekt.com/search/decks?ownerUsername=Patiun" },
],
footer: "Built with intention. Powered by caffeine.",
};
/** The status log's kinds. Icons are mdi names until the brand icon set lands. */
export const statusKinds = [
{ label: "Reading", icon: "book-open-variant", page: "/library" },
{ label: "Listening", icon: "headphones", page: "/library" },
{ label: "Watching", icon: "television-classic", page: "/library" },
{ label: "Playing", icon: "gamepad-variant", page: "/library" },
{ label: "Running", icon: "dice-multiple", page: "/tabletop" },
{ label: "Making", icon: "hammer-wrench", page: "/workshop" },
{ label: "Learning", icon: "school", page: "/workshop" },
];
// ---------------------------------------------------------------- Pages
/** Written introductions, verbatim from the legacy pages. */
export const pages = [
{
title: "Landing",
page: "/",
publish: true,
body: "I build things with code during the day and with resin, plastic, and imagination at night. I lead cloud and mobile development at Implico Inc., run D&D campaigns that span years, and fill my shelves with Brandon Sanderson novels, custom dice, and board games. This is the place where all of those worlds meet.",
source: "legacy/src/pages/index.astro",
},
{
title: "About me",
page: "/about",
publish: true,
quickFacts: [
{ icon: "map-marker", text: "Philadelphia, PA" },
{ icon: "briefcase", text: "BTP, Cloud, and Mobile App Lead at Implico Inc." },
{ icon: "dog", text: "Dog dad to Kashi the mini dachshund" },
{ icon: "dice-multiple", text: "Running D&D & Daggerheart campaigns" },
{ icon: "book-open-variant", text: "Currently tandem reading: Heir of Fire and Tower of Dawn by Sarah J. Maas" },
{ icon: "tea", text: "Fueled by loose-leaf Chinese tea and Mountain Dew" },
],
body: `I am Greg: a software developer and technical team lead based in Philadelphia who spends his free time running tabletop RPGs, making resin dice and custom cards, 3D printing anything I can, and reading his way through fantasy series at an alarming rate. I live with my partner Krystina, our mini dachshund Kashi, and more board games than any reasonable apartment should hold.
## The longer arc
I grew up loving books and games, and not much has changed. Technology pulled me into a career building cloud and mobile applications, but I never stopped collecting dice, designing campaigns, or losing weekends to Sanderson reads. I am currently expanding into SAP's Business Technology Platform, exploring AI and machine learning, and trying to convince my group that yes, we can fit one more board game into game night.
I believe the best work happens when curiosity isn't compartmentalized. The same system-thinking that makes a good developer makes a good gamemaster. The patience that produces a clean resin cast is the same patience that debugs a stubborn deployment. This site is where I don't have to pretend those things are separate.`,
source: "legacy/src/pages/about.astro",
},
{
title: "Workshop",
page: "/workshop",
publish: true,
lede: "A magnet for super glue and nice cardboard.",
body: `Making is part of me. Ever since I can remember, I had been dreaming of making inventions and things from cardboard and clay. I took things apart to see how they worked, and rarely got them back together. But now I have the tools and the skills to take ideas from my imagination and physically produce them. I cast resin dice using pressure pots and custom molds, print miniatures and props on my Elegoo Mars 4 (resin) and Bambu Labs P1S (FDM), and generally fill my workspace with projects-in-progress. I make beautiful cards as gifts and even make proxy mockups of trading cards to enjoy games casually.
There's something deeply satisfying about taking raw material through a process and holding the finished piece, whether that's a perfect polyhedral set or a hook that fits just right in that weird spot in the bathroom. The ability to blend my technical skills and my creative impulses into tangible objects is a fantasy come true my child self only dreamed of.`,
source: "legacy/src/pages/interests/making.astro",
},
{
title: "Tabletop",
page: "/tabletop",
publish: true,
lede: "Long-running D&D and Daggerheart campaigns on Foundry VTT, the MCDM Crows playtest, and a growing board game collection.",
body: `I love playing tabletop games from trading card games to role-playing games. I run Dungeons & Dragons and Daggerheart campaigns that span years of real-time play, maintaining worlds, NPCs, and narrative threads that my players remember years later. I host everything on Foundry VTT, which lets me build the kind of dynamic, visually rich experiences that keep players invested session after session and lets me flex my technical skills.
I also love board games, from strategy to social deduction. My collection is growing, and I enjoy hosting game nights with friends. I have a particular fondness for games that challenge players to think critically and creatively, and I appreciate the social aspect of gaming as much as the gameplay itself. Although, I do love the ability to play some of the engine building and deck-building games solo. Spirit Island and Wyrmspan have been my favorites lately, and I am always on the lookout for new games to add to my collection.
Trading card games are where my collecting, deckbuilding, and making instincts all collide. I primarily play Magic: The Gathering, mostly Commander (Elder Dragon Highlander), where a hundred cards becomes a personal expression and the brewing never really ends. But I keep a hand in the Pokémon TCG, and I'm watching Riftbound and the new Gundam card game closely. If it has nice cardboard, pretty art, and a decision tree, I'm interested.
The hobby overlaps the rest of the fun wing: the same workshop that produces resin dice and 3D prints also turns out proxies, tokens, and deckboxes and more. And the deckbuilding itch is a close relation of the RPG character-builder in me. Optimizing a list tells a story about how I want to play and the theme I build the deck around.`,
source: "legacy/src/pages/interests/ttrpgs.astro, legacy/src/pages/interests/tcgs.astro",
},
{
title: "Video games",
page: "/library",
publish: false,
tags: ["legacy text"],
body: `Gaming has always been a passion in my life. I span console generations from the Sega Genesis to the modern era. I play mainly with a PlayStation 5, Nintendo Switch 2, and a Steam library that has gotten politely out of hand. I'm particularly drawn to RPGs, strategy games, and anything with a creature to tame or a deeply layered world to explore. But a good game loop will get me sucked in harder than the sunk cost fallacy of a League of Legends streak.
I love to play with friends, but have found myself leaning more towards single-player experiences. Someone should have told younger me that scheduling would be the final boss!
## Platforms
- **PlayStation 5.** Mainly for single player and exclusives.
- **Nintendo Switch / Switch 2.** Handheld and docked. Pokemon, Mario, and many other Nintendo and indie gems.
- **Steam.** The primary game library. Strategy, RPGs, Adventure, oh my! And an ever-growing backlog.
## Favorite genres
- **RPGs (Western & JRPG).** Character progression and stories I can live in for 100+ hours and then turn around and do it again.
- **Creature Collecting.** If I can catch, train, or befriend a roster, I'm sold.
- **Strategy & Tactics.** Turn-based puzzles and tactical challenges where the right plan is oh so rewarding.
- **Building & Crafting.** Give me a grid, resources, and a goal, and watch the evening vanish.
- **Automation.** Optimizing a production line until its perfect is my zen.
- **Open-world Exploration.** The map IS the point. I will try to climb every mountain and I will check every waterfall for treasure.
- **Indie Darlings.** The small, weird, passionate projects that punch far above their size.`,
source: "legacy/src/pages/interests/gaming.astro",
},
{
title: "Pokémon",
page: "/library",
publish: false,
tags: ["legacy text"],
body: `Pokémon has been a lifelong obsession. From Red and Blue on the original Game Boy to modern competitive formats, I've followed the franchise through every generation. My favorite? Metagross, the iron leg Pokémon. Steel/Psychic, beautiful design, absolutely devastating in battle. No further questions.`,
source: "legacy/src/pages/interests/pokemon.astro",
},
];
// ---------------------------------------------------------------- Workshop
/** Greg's workspace description, 2026-10-11 redesign interview. */
export const stations = [
{ title: "Print station", where: "Work counter and filament wall", goodFor: "Deck boxes, terrain, props", order: 1 },
{ title: "Print and cut desk", where: "The smaller desk", goodFor: "Proxy cards, stickers, decals", order: 2 },
{ title: "Main desk", where: "Monitors plus a large cutting mat", goodFor: "Card finishing, leatherwork soon", order: 3 },
{ title: "Fume hood", where: "Custom built for resin", goodFor: "Minis, dice, rings", order: 4 },
];
/** Blurbs from legacy/src/pages/interests/making.astro; placement from the interview. */
export const tools = [
{ title: "Bambu Labs P1S", kind: "printer", station: "Print station", blurb: "FDM 3D printer. Functional prints, terrain pieces, and larger prototypes." },
{ title: "Filament wall", kind: "material", station: "Print station" },
{ title: "Epson 8500", kind: "printer", station: "Print and cut desk" },
{ title: "Silhouette Cameo 5a", kind: "cutter", station: "Print and cut desk", blurb: "Multi material cutting machine. Silly stickers, custom vinyl decals, and custom/proxy TCG cards." },
{ title: "Cutting mat", kind: "finishing", station: "Main desk" },
{ title: "Elegoo Mars 4", kind: "printer", station: "Fume hood", blurb: "Resin 3D printer. Miniatures, terrain, and high-detail props." },
{ title: "Pressure pot", kind: "casting", station: "Fume hood" },
{ title: "Dice molds", kind: "casting", station: "Fume hood" },
] as const;
/** Crafts and statuses from the interview; the resin blurb from making.astro. */
export const crafts = [
{ title: "Resin", status: "Practicing", station: "Fume hood", tools: ["Pressure pot", "Dice molds"], blurb: "Custom molds, pressure pot curing, pigment experimentation. Producing dice sets for personal use and gifts." },
{ title: "3D printing", status: "Practicing", station: "Print station", tools: ["Bambu Labs P1S", "Elegoo Mars 4", "Filament wall"] },
{ title: "Cards and decals", status: "Practicing", station: "Print and cut desk", tools: ["Silhouette Cameo 5a", "Epson 8500"] },
{ title: "Paper and bookbinding", status: "Learning" },
{ title: "Leather", status: "Getting into", station: "Main desk", tools: ["Cutting mat"] },
{ title: "Props and gifts", status: "Practicing" },
] as const;
/** The legacy gallery (making.astro) with its captions, plus the sword rack from the interview. */
export const makes = [
{ title: "Teal and gold leaf dice", craft: "Resin", photo: "/images/making-resin.jpg", blurb: "Resin dice set, teal with gold leaf inclusions." },
{ title: "Golden moss dice", craft: "Resin", photo: "/images/dice_golden_moss.jpg", blurb: "Resin dice set, a moss-like pattern with gold lettering." },
{ title: "Scarlet dice", craft: "Resin", photo: "/images/dice_scarlet.jpg", blurb: "Resin dice set, scarlet with gold lettering." },
{ title: "Galaxy dice", craft: "Resin", photo: "/images/dice_galaxy.jpg", blurb: "Resin dice set, a dark galaxy pattern with gold powder swirls." },
{ title: "Seastone dice", craft: "Resin", photo: "/images/dice_seastone.jpg", publish: false },
{ title: "Resin and painted rings", craft: "Resin", photo: "/images/resin_rings.jpg" },
{ title: "Miniature", craft: "3D printing", photo: "/images/minis.jpg", blurb: "3D printed miniature." },
{ title: "Deck boxes", craft: "3D printing", photo: "/images/deckboxes.jpg", blurb: "Design by @Iceenchanted on MakerWorld." },
{ title: "Kynaios and Tiro proxies", craft: "Cards and decals", photo: "/images/proxies.jpg", blurb: "Proxy cards for my Kynaios and Tiro deck.", links: ["Kynaios and Tiro"] },
{ title: "Foil proxies", craft: "Cards and decals", photo: "/images/proxies2.jpg", blurb: "Successful foil proxies for Magic." },
{ title: "Christmas gifts for family", craft: "Props and gifts", photo: "/images/xmasgifts.jpg", blurb: "CS:GO cases, Nightblood, a Runescape helmet and ACOTAR drawers." },
// The sword rack, 3D printed (interview, round 1)
{ title: "Nightblood", craft: "3D printing", blurb: "From the Cosmere.", tags: ["sword rack"] },
{ title: "Ronin lightsaber", craft: "3D printing", blurb: "From Star Wars Visions.", tags: ["sword rack"] },
{ title: "Wado Ichimonji", craft: "3D printing", blurb: "Zoro's blade, One Piece.", tags: ["sword rack"] },
{ title: "Enma", craft: "3D printing", blurb: "Zoro's blade, One Piece.", tags: ["sword rack"] },
{ title: "Sandai Kitetsu", craft: "3D printing", blurb: "Zoro's blade, One Piece.", tags: ["sword rack"] },
{ title: "Shusui", craft: "3D printing", blurb: "Zoro's blade, One Piece.", tags: ["sword rack"] },
{ title: "Tensa Zangetsu", craft: "3D printing", blurb: "Ichigo's blade, Bleach.", tags: ["sword rack"] },
{ title: "Tanjiro's Nichirin blade", craft: "3D printing", blurb: "Tanjiro's final blade, Demon Slayer.", tags: ["sword rack"] },
];
// ---------------------------------------------------------------- Tabletop
/** Blurbs from legacy/src/pages/interests/ttrpgs.astro; states from the interview. */
export const systems = [
{ title: "Dungeons & Dragons", state: "running", blurb: "Lately 5th Edition. Long-running campaigns on and off Foundry VTT. Focus on rich worldbuilding, meaningful NPC relationships, and player-driven narrative." },
{ title: "Daggerheart", state: "running", blurb: "Darrington Press's dice-building RPG. Currently running a campaign and exploring how its mechanics create different story rhythms than D&D." },
{ title: "Cypher System", state: "played", blurb: "Monte Cook's narrative-focused system. Played a few one-shots and short form campaigns." },
{ title: "Draw Steel", state: "want to run", blurb: "MCDM's fantasy tactical cinematic combat game. Interested in running it when I get a chance." },
{ title: "Blades in the Dark", state: "want to play", blurb: "A gritty, cinematic system for heists and criminal enterprises. Interested in playing short campaigns in it one day." },
{ title: "Avatar Legends", state: "want to try", blurb: "A narrative-focused system set in the world of Avatar. Interested in exploring bending mechanics and character-driven stories." },
] as const;
/**
* The campaigns are real, but their names and schedules are not known yet,
* so the D&D and Daggerheart ones stay unpublished until Greg fills them in.
*/
export const campaigns = [
{ title: "D&D campaign", system: "Dungeons & Dragons", status: "active", platform: "Foundry VTT", publish: false, body: "Name, schedule and a one-line status to fill in before publishing." },
{ title: "Daggerheart campaign", system: "Daggerheart", status: "active", platform: "Foundry VTT", publish: false, body: "Name, schedule and a one-line status to fill in before publishing." },
{ title: "MCDM Crows playtest", status: "playtest", publish: true },
] as const;
/** Blurbs from legacy/src/pages/interests/tcgs.astro; stances and Flesh and Blood from the interview. */
export const cardGames = [
{ title: "Magic: The Gathering", stance: "Main event", blurb: "Mostly Commander (EDH), with a deckbuilding obsession that Archidekt enables far too well." },
{ title: "Pokémon TCG", stance: "Collecting", blurb: "Collector energy and casual play. Beautiful art pulls, deck drafts, and a soft spot for Steel/Psychic. Mostly on Pocket TCG." },
{ title: "Flesh and Blood", stance: "Getting into" },
{ title: "Riftbound", stance: "Exploring", blurb: "Riot's League of Legends TCG. A new game with a lot of potential. Interested in exploring deckbuilding and the evolving meta." },
{ title: "Gundam Card Game", stance: "Watching", blurb: "Bandai's Gundam TCG. A new game with a lot of potential. Interested in how some of the figures will come into play with the other table top pieces for this entry." },
] as const;
/** The deck behind the legacy proxy photo. Its Archidekt id gets filled in by the sync. */
export const decks = [{ title: "Kynaios and Tiro", game: "Magic: The Gathering", format: "Commander", favorite: true }];
/** "Spirit Island and Wyrmspan have been my favorites lately" (ttrpgs.astro). */
export const favoriteBoardGames = ["Spirit Island", "Wyrmspan"];
// ---------------------------------------------------------------- Resume
/** legacy/src/components/professional/SkillGrid.astro */
export const skills = {
core: [
"Cloud Architecture & Development",
"Mobile Application Development",
"SAP Business Technology Platform (BTP)",
"Team Leadership & Technical Mentorship",
"Cross-functional Communication",
],
learning: [
"SAP BTP deep-dive (active expansion)",
"Cutting edge AI-powered development tooling",
"Advanced automation and workflow engineering",
],
stack: [
{ label: "Languages", items: ["JavaScript", "TypeScript", "Java", "Python", "C#", "C", "SQL", "HTML", "CSS"] },
{ label: "Frameworks", items: ["React", "Vue", "Astro", "Flask"] },
{ label: "Cloud Platforms", items: ["SAP BTP", "Google Cloud Platform", "AWS"] },
{ label: "Tools", items: ["PostgreSQL", "VSCode", "Unity", "XCode", "Android Studio", "Git", "Gitea", "CI/CD pipelines"] },
{ label: "AI/ML", items: ["Machine learning frameworks", "LLM integration", "Claude Code", "Hermes", "OpenClaw"] },
],
};
/** legacy/src/components/professional/EducationHistory.astro and Certifications.astro */
export const education = {
degrees: [
{
credential: "B.S., Computer Science",
institution: "Drexel University",
period: "2014 – 2018",
detail: "Foundational coursework in software engineering, algorithms, and systems. Focuses in AI and human-computer interaction.",
},
],
certifications: [{ name: "Planet 9 Development", issuer: "Neptune", year: "2019", status: "Active" as const }],
};
/** Employer and facts for the legacy projects; facts from the Implico and Cosmos entries. */
export const caseStudies: Record<string, { employer?: string; facts?: { label: string; value: string }[] }> = {
"carrier-connect": { employer: "Implico Inc.", facts: [{ label: "Active users", value: "1,000+" }, { label: "Companies", value: "100" }] },
"driver-app": { employer: "Implico Inc." },
"cosmos-homelab": { facts: [{ label: "AI agents", value: "17" }] },
"placeholder-project": {},
};
// ---------------------------------------------------------------- Store
/** Lines Greg named on 2026-10-11 that are not made yet; kept unpublished. */
export const futureLines = [
{ title: "Book-bound journals", category: "Paper" },
{ title: "Field notes journals", category: "Paper" },
{ title: "Leather bracelets", category: "Leather" },
{ title: "Journal folios", category: "Leather" },
{ title: "Card holders", category: "Leather" },
] as const;