export type MoreSection = {
title?: string;
text: string;
}
export type SocialLink = {
label: string;
url: string;
icon: "gh" | "li" | "x" | "dev";
};
export type ContactMethod = {
masked: boolean;
label: string;
icon: string;
}
/** A gallery image: either a bare url, or a url with metadata. */
export type GalleryImage = string | {
src: string;
/** Used as the image's alt text and tooltip. */
description?: string;
};
export function imageSrc(image: GalleryImage): string {
return typeof image === "string" ? image : image.src;
}
export function imageDescription(image: GalleryImage): string | undefined {
return typeof image === "string" ? undefined : image.description;
}
export type Project = {
id: number;
slug: string;
title: string;
description: string;
details?: string;
tags: string[];
images?: GalleryImage[];
videos?: string[];
link: string;
year: string;
};
export type BlogPost = {
id: number;
slug: string; // kebab-case, URL-safe
title: string;
description: string; // 1-2 sentence excerpt shown on cards
date: string; // display date, e.g. "2026-09-14"
category: string; // slug, e.g. "meta" — one category per post
tags: string[];
images?: GalleryImage[]; // optional, rendered via SimpleGallery
details: string; // markdown body
};
export type Mod = {
id: number;
title: string;
description: string;
link: string;
linkLabel?: string;
version?: string;
tags?: string[];
};
export type ModdedGame = {
id: number;
slug: string;
title: string;
platform?: string;
description?: string;
mods: Mod[];
};
export const PROFILE = {
name: "Hunter",
title: "Software Engineer",
email: "contact@hwilliams.dev",
portrait: "img/portrait.jpg",
bio: `Hi there. I consider myself a problem solver type and the adventurous sort. Lets take it apart, see how it works — then we have some options:
build something completely different out of it, take it to it's genuine limits, or just change a few things about it.
Looking for work. See below.`,
moreSections: [
{
title:"A little more about me",
text: `
I'm an associate level (~1yr) software engineer with full-stack development experience across a variety of frameworks and languages including Vue,
React, Next.JS, Nuxt, Node.JS, Express, Flask, FastAPI, Gunicorn, and more. Graduated with a B.S. in May of 2025. Last seen at USAN building AWS deployed apps. `
},
{
title:"What do I do right now?",
text: "I'm building Flowdesk! Check it out on my projects page. I also build mods for video games such as Palworld, Space Engineers and Rimworld, and run webservers for various modding tools — like the U4ESS Object Explorer. I don't post my projects on github as much anymore to avoid being free training for AI, but my old stuff is still there and you can find any new open source projects of mine at git.hwilliams.dev/hwilliams."
},
{
title:"My goals",
text: `Land the perfect user experience every time. \n \n Nothing makes me happier than seeing someone enjoy the experience they have using the things I make, and the way that happens is by practicing
development techniques that make a good user experience. I love to hear things like 'Your app makes it easy!', 'Your app feels good to use!', and 'Wow, it used to be difficult to do this!'.
`
},
{
title:"Footnote",
text: `If you've found this page trying to answer a question about my mods, please feel free to reach out.
`
}
] satisfies MoreSection[],
moreSectionsStart: 1,
links: [
{ label: "GitHub", url: "https://github.com/FerrenF", icon: "gh" },
{ label: "LinkedIn", url: "https://www.linkedin.com/in/hwilliamsf/", icon: "li" }
] satisfies SocialLink[],
contactMethods: [{
masked: true,
label: "contact@hwilliams.dev",
icon: "email"
}
] satisfies ContactMethod[]
};
export const SITE = {
title: "Hunter W. - Software Engineer",
description: "SWE Looking for work",
source: {
label: "Git",
link: "https://git.hwilliams.dev/hwilliams/hwilliams-dev"
}
};
export const PROJECTS: Project[] = [{
id: 1,
slug: "workflows-app",
title: "Flowdesk: Hit the ground running",
description: "Flowdesk is a microservices platform for breaking down complex business processes into guided visual workflows. Includes an SSO system, administration panel, database and API, and front-end.",
details: `# Flowdesk: A Flow-based support platform designed for event-based organizations.
It's not just "make the UI simpler" but "make a day-one hire perform like a veteran on this task, every time."![]()
A blog is just another collection of cards. The difference is what happens when you click one.## The shape of the problem Every tab on this site follows the same recipe: - A typed data array in a single content file, which acts as the whole CMS - A shared layout that provides the nav chrome and the footer - A thin client page that renders one list component - A detail page that reuses the same layout, minus the nav, plus a context provider A post that fits that shape is just another entry in an array. A post that doesn't fit it is a content management system in disguise, and that's a whole other project. ## Why the markdown was already there The project detail pages render markdown through a small remark pipeline that has been sitting in the codebase for a while, used by the project pages. The blog needed exactly the same thing, which meant the data layer for a blog post is mostly just a type and a lookup function. The pipeline is shared, so a post and a project detail are rendered by the same code, with the same quirks, and the same fixes. That is the whole trick. The feature felt like it would need infrastructure, but the infrastructure already existed for a different feature. When a new feature turns out to be a clone of an old one, the new feature is cheap, and the codebase gets a little more regular as a side effect. ## What actually changed A new type, a new data array, a new lookup function, a new tab in the nav, two layouts, a list page, a card, and a detail page. No new dependencies, no new build steps, no new configuration. The tab order now reads profile, project, blog, mods, and the blog sits in the middle of the two portfolio tabs. Editing a post from here on out is editing the array, which is the same as editing a project. That's the part I wanted. `, }, { id: 2, slug: "hello-world", title: "Hello, world. This is the new blog.", description: "A short note about why this site grew a fourth tab, how the posts are written, and what the rest of this page demonstrates.", date: "2026-09-14", category: "meta", tags: ["meta", "blog"], details: `# Hello, world. This is the new blog. This is a blog now. There's a fourth tab up top, and it works the same way the rest of the site works: content lives in the repository as plain data, the page renders it, and the markdown is processed by the same pipeline that renders the project detail pages.
If a site is only ever going to have three tabs, it's a portfolio. The fourth tab is where it starts being a site.## Why it exists The portfolio is for finished things, and the mods page is for the things I ship to other people's games. This is for the middle: write-ups, notes, and the occasional \"here's how I did that\" that doesn't belong anywhere else. It's also the first place on this site where I write in the first person at length, which is a change. ## How posts are written Each post is a typed object in the content file, with a slug, a date, a short description for the card, and a markdown body. The body goes through the site's existing remark pipeline, so any markdown that works on the project pages works here, including raw HTML blocks when I want a specific layout. - Headings render in the mono face, like the rest of the site - Blockquotes get the raised, bordered treatment you can see above - Images are kept out of this post on purpose. The pipeline supports them, and a future post will use one ## What this page is doing while you read it This page is a dynamic route. The slug comes from the URL, the layout resolves it to rendered HTML on the server, and the page on the client side reads that and paints it in. That's the same architecture the project detail pages use, which is the point of reusing it: a new page type that can't do anything the old page types can't already do. `, } ]; /* ---- blog directory helpers ---- * Pagination and category filtering for the /blog list. Everything derives * from BLOG_POSTS above, which remains the single source of truth. */ /** Small enough that pagination is visible as soon as a few posts exist. */ export const POSTS_PER_PAGE = 3; /** Deduplicated, alphabetically sorted categories currently in use. */ export function blogCategories(): string[] { return [...new Set(BLOG_POSTS.map((p) => p.category))].sort(); } /** Slug -> display string: "development" -> "Development", "game-mods" -> "Game Mods". */ export function categoryLabel(slug: string): string { return slug .split(/[-_]+/) .filter(Boolean) .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(" "); } /** All posts when omitted, otherwise the posts in the given category. */ export function postsForCategory(slug?: string): BlogPost[] { if (!slug) return BLOG_POSTS; return BLOG_POSTS.filter((p) => p.category === slug); } /** * Pure pagination over an already-sorted array. Clamps `page` into * [1, totalPages], so out-of-range values (?page=99) land on the last page * instead of producing an empty or error state. */ export function paginate