@@ -61,6 +65,11 @@ export default function StatModule() {
diff --git a/src/data/projects.ts b/src/data/projects.ts
new file mode 100644
index 0000000..86237aa
--- /dev/null
+++ b/src/data/projects.ts
@@ -0,0 +1,70 @@
+export type Project = {
+ slug: string;
+ title: string;
+ /** one-line summary, used on cards and as the meta description */
+ description: string;
+ /** where the source lives */
+ link: string;
+ pills: string[];
+ /** paragraphs of prose for the detail page */
+ body: string[];
+};
+
+export const PROJECTS: Project[] = [
+ {
+ slug: "led-matrix-scoreboard",
+ title: "LED Matrix Scoreboard",
+ description:
+ "A physical scoreboard driven by an LED matrix, pulls live sports data and renders pixel by pixel.",
+ link: "https://git.alexav.gg/alex/scoreboard",
+ pills: ["Python", "Raspberry Pi", "Hardware"],
+ body: [
+ "I wanted live scores on a wall instead of on a phone, so I built a physical scoreboard: a Raspberry Pi driving an RGB LED matrix, polling live sports data and drawing the result pixel by pixel.",
+ "The interesting constraint is the display itself. An LED matrix has no text rendering, no layout engine, and very few pixels to spend, so every score, team abbreviation, and clock has to be composed by hand and kept legible from across a room. The Python service handles fetching and caching the game state, then hands a frame to the matrix driver.",
+ "Written in Python and running on a Raspberry Pi. The source is on my Gitea instance if you want to build one.",
+ ],
+ },
+ {
+ slug: "subber",
+ title: "Subber",
+ description:
+ "Keeps track of your subscriptions and generates clear metrics for you to manage.",
+ link: "https://github.com/azpha/subber",
+ pills: ["TypeScript", "Tailwind", "Prisma"],
+ body: [
+ "Subber tracks recurring subscriptions and turns them into metrics you can actually act on — what you are paying monthly versus annually, and where the money is going.",
+ "The problem with most subscription trackers is that they are a list. A list tells you what you signed up for; it does not tell you what it costs you over a year, or which renewals are about to land in the same week. Subber is built around the aggregate view first and the list second.",
+ "TypeScript throughout, Prisma for the data layer, and Tailwind for the interface.",
+ ],
+ },
+ {
+ slug: "snapchat-memory-extractor",
+ title: "Snapchat Memory Extractor",
+ description:
+ "Extracts your Snapchat memories from your data export in a cleaner format than their horrible index.html.",
+ link: "https://github.com/azpha/snapchat-memory-extractor",
+ pills: ["Python", "Automation"],
+ body: [
+ "When you request your data from Snapchat, your memories arrive as an enormous index.html full of links rather than as files you can actually keep. This tool takes that export and pulls the memories out into a sane folder of real media files.",
+ "It is the kind of small, unglamorous automation that saves an afternoon of clicking. Point it at the export, let it walk the index, and it resolves and downloads each memory for you.",
+ "Written in Python with no heavy dependencies, so it runs anywhere you can get a Python interpreter.",
+ ],
+ },
+ {
+ slug: "homelab",
+ title: "Homelab",
+ description:
+ "My tinkering sandbox. Full CI/CD pipeline for people to learn & take inspiration from.",
+ link: "https://git.alexav.gg/alex/homelab",
+ pills: ["Docker", "Ansible", "Python", "Automation"],
+ body: [
+ "My homelab is where everything else on this site actually runs — this website, my Gitea instance, my analytics, my personal API. The whole thing is defined as code and deployed through its own CI/CD pipeline.",
+ "I keep the repository public because homelab writeups tend to stop at the hardware photos, and the useful part is the boring middle: how services get configured, how deploys happen, how it recovers when something breaks. Docker for the services, Ansible for the configuration, Python for the glue.",
+ "Take whatever is useful. It is a sandbox, so expect it to change.",
+ ],
+ },
+];
+
+export function findProject(slug: string | undefined) {
+ return PROJECTS.find((p) => p.slug === slug);
+}
diff --git a/src/entry-client.tsx b/src/entry-client.tsx
index 03a9727..3e30316 100644
--- a/src/entry-client.tsx
+++ b/src/entry-client.tsx
@@ -2,10 +2,19 @@ import { hydrateRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import "./assets/main.css";
import App from "./pages/App.tsx";
+import InitialDataProvider from "./components/InitialDataProvider";
+import { INITIAL_DATA_GLOBAL, type InitialData } from "./utils/initialData";
+
+const initialData: InitialData =
+ (window as unknown as Record
)[
+ INITIAL_DATA_GLOBAL
+ ] ?? {};
hydrateRoot(
document.getElementById("root")!,
-
+
+
+
,
);
diff --git a/src/entry-server.tsx b/src/entry-server.tsx
index 183e114..80eaa28 100644
--- a/src/entry-server.tsx
+++ b/src/entry-server.tsx
@@ -1,17 +1,53 @@
import { renderToString } from "react-dom/server";
-import * as RouterExports from "react-router-dom";
import App from "./pages/App";
+import { StaticRouter } from "react-router-dom";
+import { resolveMeta, normalizePath } from "./utils/resolveMeta";
+import { renderHead } from "./utils/seo";
+import { fetchPosts } from "./utils/blogFeed";
+import { sitemapPaths } from "./routes";
+import { absoluteUrl } from "./utils/site";
+import InitialDataProvider from "./components/InitialDataProvider";
+import { serializeInitialData, type InitialData } from "./utils/initialData";
-// Vite's SSR runner resolves react-router-dom to its CommonJS build, so the
-// named exports land on `.default`; fall back to the namespace when not.
-const { StaticRouter } =
- (RouterExports as { default?: typeof RouterExports }).default ?? RouterExports;
+export async function render(url: string) {
+ const location = normalizePath(url);
+ const { meta, status } = resolveMeta(location);
+
+ // fetched here rather than in a useEffect so the content is in the served
+ // html. memoized in blogFeed, and it never throws.
+ const posts = await fetchPosts();
+ const data: InitialData = { posts: posts.slice(0, 3) };
-export function render(url: string) {
const html = renderToString(
-
-
+
+
+
+
,
);
- return { html };
+
+ return {
+ html,
+ // the data script goes in the head so it runs before the deferred client
+ // module reads it, and so it cannot cause a hydration mismatch inside #root
+ head: `${renderHead(meta)}\n ${serializeInitialData(data)}`,
+ statusCode: status,
+ };
+}
+
+/**
+ * sitemap.xml, generated from the same route table the router uses, so it can
+ * never list a url that does not exist or miss one that does. No lastmod:
+ * fabricated dates are worse than none, and Google ignores unreliable ones.
+ */
+export function renderSitemap() {
+ const urls = sitemapPaths()
+ .map((path) => ` ${absoluteUrl(path)}`)
+ .join("\n");
+
+ return `
+
+${urls}
+
+`;
}
diff --git a/src/pages/App.tsx b/src/pages/App.tsx
index af675b3..abbf3d8 100644
--- a/src/pages/App.tsx
+++ b/src/pages/App.tsx
@@ -1,122 +1,40 @@
+import { useEffect } from "react";
+import { Routes, Route, useLocation } from "react-router-dom";
import Layout from "../components/Layout";
-import ProjectCard from "../components/ProjectCard";
-import FantasyStatus from "../components/FantasyStatus";
-import PictureCarousel from "../components/modules/PictureCarousel";
-import Footer from "../components/Footer";
-import BlogRow from "../components/BlogRow";
-import GameComponent from "../components/info/GameComponent";
-import MusicComponent from "../components/info/MusicComponent";
+import { routes } from "../routes";
+import { resolveMeta } from "../utils/resolveMeta";
+
+/**
+ * SSR sets the title in the document head; this keeps it correct after a
+ * client-side navigation, which never re-runs that head injection.
+ */
+function TitleSync() {
+ const { pathname } = useLocation();
+
+ useEffect(() => {
+ const { meta } = resolveMeta(pathname);
+ document.title = meta.title;
+
+ const canonical = document.querySelector(
+ 'link[rel="canonical"]',
+ );
+ if (canonical) {
+ canonical.href = new URL(meta.path, window.location.origin).href;
+ }
+ }, [pathname]);
+
+ return null;
+}
export default function App() {
return (
-
-
-
HI, I'M ALEX
-
- I write software sometimes, then I spend twice as long breaking it
-
-
-
-
- My day job is Senior QA at{" "}
-
- Medal.tv
- {" "}
- (
-
- we're hiring!
-
- ), then by night I play with my homelab rack or write some mediocre
- JavaScript.
-
-
- Based in Western New York (go bills, go sabres). Working on learning
- Java, C#. Pointless software for funzies is my passion.
-
-
-
-
-
-
-
- LATEST BLOG POST
-
-
-
-
-
-
- THINGS I'VE BUILT
-
-
-
-
- MY ACTIVITIES
-
-
-
-
-
-
-
-
-
Fantasy
-
-
-
-
- My Life, Pictured
-
-
-
-
-
-
-
-
-
+
+
+ {routes.map((route) => (
+
+ ))}
+
);
}
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
new file mode 100644
index 0000000..7e28948
--- /dev/null
+++ b/src/pages/Home.tsx
@@ -0,0 +1,116 @@
+import { Link } from "react-router-dom";
+import ProjectCard from "../components/ProjectCard";
+import FantasyStatus from "../components/FantasyStatus";
+import PictureCarousel from "../components/modules/PictureCarousel";
+import Footer from "../components/Footer";
+import BlogRow from "../components/BlogRow";
+import GameComponent from "../components/info/GameComponent";
+import MusicComponent from "../components/info/MusicComponent";
+import EmailLink from "../components/EmailLink";
+import { PROJECTS } from "../data/projects";
+import { PROFILES } from "../utils/site";
+
+export default function Home() {
+ return (
+
+
+
HI, I'M ALEX
+
+ I write software sometimes, then I spend twice as long breaking it
+
+
+
+
+ My day job is Senior QA at{" "}
+
+ Medal.tv
+ {" "}
+ (
+
+ we're hiring!
+
+ ), then by night I play with my{" "}
+
+ homelab rack
+ {" "}
+ or write some mediocre JavaScript.
+
+
+ Based in Western New York (go bills, go sabres). Working on learning
+ Java, C#. Pointless software for funzies is my passion. You can see{" "}
+
+ what my teams are doing
+ {" "}
+ or reach me at .
+
+
+
+
+
+
+
+
+
+
+
+ THINGS I'VE BUILT
+
+
+ {PROJECTS.map((project) => (
+
+ ))}
+
+
+
+
+
+
+
+ MY ACTIVITIES
+
+
+
+
+
+
+
+
+
+
Fantasy
+
+
+
+
+ My Life, Pictured
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/NotFound.tsx b/src/pages/NotFound.tsx
new file mode 100644
index 0000000..02dd785
--- /dev/null
+++ b/src/pages/NotFound.tsx
@@ -0,0 +1,41 @@
+import { Link } from "react-router-dom";
+import Footer from "../components/Footer";
+
+export default function NotFound() {
+ return (
+
+
+
404
+
+ There's nothing here
+
+
+ That page doesn't exist — it may have moved, or it may never have
+ existed in the first place.
+
+
+
+
+ -
+
+ Home
+
+
+ -
+
+ Projects
+
+
+ -
+
+ Sports
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/Pictures.tsx b/src/pages/Pictures.tsx
index 9c086b1..f1e5355 100644
--- a/src/pages/Pictures.tsx
+++ b/src/pages/Pictures.tsx
@@ -1,10 +1,34 @@
+import { Link } from "react-router-dom";
import PictureCarousel from "../components/modules/PictureCarousel";
-export default function PicturesPage() {
+import Footer from "../components/Footer";
+
+export default function Pictures() {
return (
-
-
-
+
+
+
MY LIFE, PICTURED
+
Pictures
+
+ A rolling selection of photos from my camera roll — Western New York,
+ the homelab, and whatever else I pointed a camera at.
+
-
+
+
+
+
+
+
+
+
+
+ < Back home
+
+
+
+
+
+
+
);
}
diff --git a/src/pages/ProjectDetail.tsx b/src/pages/ProjectDetail.tsx
new file mode 100644
index 0000000..5ad97b4
--- /dev/null
+++ b/src/pages/ProjectDetail.tsx
@@ -0,0 +1,68 @@
+import { Link, useParams } from "react-router-dom";
+import Footer from "../components/Footer";
+import NotFound from "./NotFound";
+import { findProject } from "../data/projects";
+
+export default function ProjectDetail() {
+ const { slug } = useParams();
+ const project = findProject(slug);
+
+ if (!project) return
;
+
+ return (
+
+
+
+
+
+ {project.title}
+
+
+ {project.pills.map((pill) => (
+
+ {pill}
+
+ ))}
+
+
+
+
+ {project.body.map((paragraph, i) => (
+
{paragraph}
+ ))}
+
+
+
+
+
+
+
+
+ < All projects
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/Projects.tsx b/src/pages/Projects.tsx
new file mode 100644
index 0000000..3550202
--- /dev/null
+++ b/src/pages/Projects.tsx
@@ -0,0 +1,45 @@
+import { Link } from "react-router-dom";
+import ProjectCard from "../components/ProjectCard";
+import Footer from "../components/Footer";
+import { PROJECTS } from "../data/projects";
+
+export default function Projects() {
+ return (
+
+
+
THINGS I'VE BUILT
+
Projects
+
+ Mostly pointless software, built for the fun of building it — a
+ physical scoreboard, a subscription tracker, a data-export scraper,
+ and the homelab that runs all of this.
+
+
+
+
+
+
+
+ ALL PROJECTS
+
+
+ {PROJECTS.map((project) => (
+
+ ))}
+
+
+
+
+
+
+
+ < Back home
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/Sports.tsx b/src/pages/Sports.tsx
index 8d4e6ab..2761d39 100644
--- a/src/pages/Sports.tsx
+++ b/src/pages/Sports.tsx
@@ -1,19 +1,49 @@
+import { Link } from "react-router-dom";
import FantasyStatus from "../components/FantasyStatus";
import ScoreModule from "../components/modules/ScoreModule";
+import Footer from "../components/Footer";
export default function Sports() {
return (
-
-
+
+
+
GO BILLS, GO SABRES
+
Sports
+
+ Live scores for the Buffalo Bills, Buffalo Sabres and New York
+ Yankees, plus however badly my fantasy team is doing this week.
+
+
+
+
+
+
+
-
+
-
-
fantasy
+
-
+
+
+
+
+
+
+ < Back home
+
+
+
+
+
+
+
);
}
diff --git a/src/routes.tsx b/src/routes.tsx
new file mode 100644
index 0000000..2ae1b60
--- /dev/null
+++ b/src/routes.tsx
@@ -0,0 +1,115 @@
+import type { ReactElement } from "react";
+import Home from "./pages/Home";
+import Projects from "./pages/Projects";
+import ProjectDetail from "./pages/ProjectDetail";
+import Sports from "./pages/Sports";
+import Pictures from "./pages/Pictures";
+import NotFound from "./pages/NotFound";
+import { PROJECTS, findProject } from "./data/projects";
+import type { PageMeta } from "./utils/seo";
+import {
+ profilePageGraph,
+ projectListGraph,
+ projectGraph,
+} from "./utils/structuredData";
+
+type Params = Record
;
+
+export type AppRoute = {
+ path: string;
+ element: ReactElement;
+ /**
+ * Head metadata for the route. Returns null when the path matched but the
+ * resource does not exist (an unknown project slug), which the server turns
+ * into a real 404 rather than a soft one.
+ */
+ meta: (params: Params) => PageMeta | null;
+ /** concrete urls to list in the sitemap; omit to exclude the route */
+ sitemapPaths?: () => string[];
+};
+
+export const NOT_FOUND_META: PageMeta = {
+ title: "Page not found — Alex Frantz",
+ description: "That page doesn't exist.",
+ path: "/404",
+ noindex: true,
+};
+
+export const routes: AppRoute[] = [
+ {
+ path: "/",
+ element: ,
+ sitemapPaths: () => ["/"],
+ meta: () => ({
+ title: "Alex Frantz — Senior QA Engineer & Developer",
+ description:
+ "Senior QA engineer at Medal.tv, based in Western New York. I write software, then spend twice as long breaking it — homelab, JavaScript and Python projects.",
+ path: "/",
+ type: "profile",
+ jsonLd: profilePageGraph(),
+ }),
+ },
+ {
+ path: "/projects",
+ element: ,
+ sitemapPaths: () => ["/projects"],
+ meta: () => ({
+ title: "Projects — Alex Frantz",
+ description:
+ "The things I've built: an LED matrix scoreboard, a subscription tracker, a Snapchat export extractor, and the homelab that runs all of it.",
+ path: "/projects",
+ jsonLd: projectListGraph(PROJECTS),
+ }),
+ },
+ {
+ path: "/projects/:slug",
+ element: ,
+ sitemapPaths: () => PROJECTS.map((p) => `/projects/${p.slug}`),
+ meta: (params) => {
+ const project = findProject(params.slug);
+ if (!project) return null;
+
+ return {
+ title: `${project.title} — Alex Frantz`,
+ description: project.description,
+ path: `/projects/${project.slug}`,
+ type: "article",
+ jsonLd: projectGraph(project),
+ };
+ },
+ },
+ {
+ path: "/sports",
+ element: ,
+ sitemapPaths: () => ["/sports"],
+ meta: () => ({
+ title: "Bills, Sabres & Yankees Scores — Alex Frantz",
+ description:
+ "Live Buffalo Bills, Buffalo Sabres and New York Yankees scores, plus how my fantasy football matchup is going this week.",
+ path: "/sports",
+ }),
+ },
+ {
+ path: "/pictures",
+ element: ,
+ sitemapPaths: () => ["/pictures"],
+ meta: () => ({
+ title: "Pictures — Alex Frantz",
+ description:
+ "Photos from my camera roll: Western New York, the homelab rack, and whatever else I pointed a camera at.",
+ path: "/pictures",
+ }),
+ },
+ {
+ // the in-app catch-all. the server independently answers 404 for anything
+ // that lands here, so this never becomes a soft 404
+ path: "*",
+ element: ,
+ meta: () => NOT_FOUND_META,
+ },
+];
+
+/** every indexable url, for sitemap.xml */
+export function sitemapPaths(): string[] {
+ return routes.flatMap((route) => route.sitemapPaths?.() ?? []);
+}
diff --git a/src/utils/blogFeed.ts b/src/utils/blogFeed.ts
new file mode 100644
index 0000000..b22df78
--- /dev/null
+++ b/src/utils/blogFeed.ts
@@ -0,0 +1,42 @@
+import type { BlogPost } from "./types";
+
+const FEED_URL = "https://blog.alexav.gg/api/posts";
+const TTL_MS = 5 * 60 * 1000;
+
+let cache: { at: number; posts: BlogPost[] } | null = null;
+
+/**
+ * Server-side fetch of the blog index, memoized for a few minutes so a crawl
+ * does not hammer blog.alexav.gg. Never throws — a dead feed should degrade to
+ * an empty section, not a 500.
+ */
+export async function fetchPosts(): Promise {
+ if (cache && Date.now() - cache.at < TTL_MS) return cache.posts;
+
+ try {
+ const res = await fetch(FEED_URL, {
+ signal: AbortSignal.timeout(3000),
+ });
+ if (!res.ok) throw new Error(`feed responded ${res.status}`);
+
+ const data = (await res.json()) as BlogPost[];
+ // project down to the fields the ui uses. the api also returns the full
+ // post body and internal ids, and all of that would otherwise be inlined
+ // into every html response
+ const posts = (Array.isArray(data) ? data : []).map(
+ ({ id, title, description, slug, createdAt }) => ({
+ id,
+ title,
+ description,
+ slug,
+ createdAt,
+ }),
+ );
+ cache = { at: Date.now(), posts };
+ return posts;
+ } catch (e) {
+ console.error("Failed to fetch blog posts during SSR:", e);
+ // serve the stale copy rather than nothing if we ever had one
+ return cache?.posts ?? [];
+ }
+}
diff --git a/src/utils/initialData.ts b/src/utils/initialData.ts
new file mode 100644
index 0000000..5159238
--- /dev/null
+++ b/src/utils/initialData.ts
@@ -0,0 +1,28 @@
+import { createContext, useContext } from "react";
+import type { BlogPost } from "./types";
+
+export type InitialData = {
+ posts?: BlogPost[];
+};
+
+/**
+ * Data fetched during SSR and handed to the client, so content that used to
+ * appear only after a `useEffect` is present in the served HTML. Passed
+ * through context rather than a module global, because a module global on the
+ * server would be shared between concurrent requests.
+ */
+export const InitialDataContext = createContext({});
+
+export function useInitialData() {
+ return useContext(InitialDataContext);
+}
+
+export const INITIAL_DATA_GLOBAL = "__INITIAL_DATA__";
+
+/** the inside a json string would close the tag early; escaping the
+ // slash keeps the json valid while making the sequence un-parseable
+ const json = JSON.stringify(data).replace(/window.${INITIAL_DATA_GLOBAL}=${json}`;
+}
diff --git a/src/utils/resolveMeta.ts b/src/utils/resolveMeta.ts
new file mode 100644
index 0000000..9f526fe
--- /dev/null
+++ b/src/utils/resolveMeta.ts
@@ -0,0 +1,38 @@
+import { matchRoutes } from "react-router-dom";
+import { routes, NOT_FOUND_META, type AppRoute } from "../routes";
+import type { PageMeta } from "./seo";
+
+export type Resolved = { meta: PageMeta; status: 200 | 404 };
+
+/**
+ * Map a request url to its head metadata and http status. Shared by the server
+ * (which needs the status) and the client (which needs the title on
+ * navigation), so the two can never disagree about what a url is.
+ */
+export function resolveMeta(url: string): Resolved {
+ const pathname = normalizePath(url);
+
+ const matches = matchRoutes(
+ routes.map((route) => ({ path: route.path, meta: route.meta })),
+ pathname,
+ );
+ const match = matches?.[matches.length - 1];
+ if (!match) return { meta: NOT_FOUND_META, status: 404 };
+
+ const route = match.route as unknown as AppRoute;
+ // the in-app catch-all resolves, but it is still a miss
+ if (route.path === "*") return { meta: NOT_FOUND_META, status: 404 };
+
+ // meta() returns null when the path shape matched but the resource does not
+ // exist, e.g. /projects/not-a-real-slug
+ const meta = route.meta(match.params);
+ if (!meta) return { meta: NOT_FOUND_META, status: 404 };
+
+ return { meta, status: 200 };
+}
+
+export function normalizePath(url: string) {
+ const pathname = url.split("?")[0].split("#")[0];
+ if (!pathname) return "/";
+ return pathname.startsWith("/") ? pathname : `/${pathname}`;
+}
diff --git a/src/utils/seo.ts b/src/utils/seo.ts
new file mode 100644
index 0000000..8d04011
--- /dev/null
+++ b/src/utils/seo.ts
@@ -0,0 +1,105 @@
+import {
+ SITE_NAME,
+ SITE_LOCALE,
+ DEFAULT_OG_IMAGE,
+ DEFAULT_OG_IMAGE_ALT,
+ absoluteUrl,
+} from "./site";
+
+export type PageMeta = {
+ /** full text, already including any " — Alex Frantz" suffix */
+ title: string;
+ description: string;
+ /** site-relative path, used for the canonical and og:url */
+ path: string;
+ image?: string;
+ imageAlt?: string;
+ imageWidth?: number;
+ imageHeight?: number;
+ /** og:type, defaults to "website" */
+ type?: string;
+ noindex?: boolean;
+ /** one or more JSON-LD objects to emit as ld+json */
+ jsonLd?: object[];
+};
+
+/** escape a value for interpolation into a double-quoted html attribute */
+function attr(value: string) {
+ return value
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+/** escape for text content, e.g. inside */
+function text(value: string) {
+ return value
+ .replace(/&/g, "&")
+ .replace(//g, ">");
+}
+
+// inside a json string would close the tag early. escaping the
+// slash keeps the json valid while making the sequence un-parseable as a tag
+function jsonLdPayload(data: object) {
+ return JSON.stringify(data).replace(/ fragment injected at the ``
+ * placeholder in index.html. Plain string building rather than a helmet-style
+ * library, because SSR already owns the document head.
+ */
+export function renderHead(meta: PageMeta): string {
+ const canonical = absoluteUrl(meta.path);
+ const image = meta.image ?? DEFAULT_OG_IMAGE;
+ const imageAlt = meta.imageAlt ?? DEFAULT_OG_IMAGE_ALT;
+ const type = meta.type ?? "website";
+
+ // a noindex page should not claim a canonical url of its own
+ const canonicalTags = meta.noindex
+ ? []
+ : [
+ ``,
+ ``,
+ ];
+
+ const tags: string[] = [
+ `${text(meta.title)}`,
+ ``,
+ ...canonicalTags,
+ meta.noindex
+ ? ``
+ : ``,
+
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ ];
+
+ if (meta.imageWidth && meta.imageHeight) {
+ tags.push(
+ ``,
+ ``,
+ );
+ }
+
+ for (const block of meta.jsonLd ?? []) {
+ tags.push(
+ ``,
+ );
+ }
+
+ return tags.join("\n ");
+}
diff --git a/src/utils/site.ts b/src/utils/site.ts
new file mode 100644
index 0000000..8503b63
--- /dev/null
+++ b/src/utils/site.ts
@@ -0,0 +1,27 @@
+// the public origin, in one place. previously hardcoded in four separate
+// places in index.html, which is why nothing could build a canonical url
+export const SITE_ORIGIN = "https://alexav.gg";
+
+export const SITE_NAME = "Alex Frantz";
+export const SITE_LOCALE = "en_US";
+export const AUTHOR_EMAIL = "alex@alexav.gg";
+
+export const DEFAULT_OG_IMAGE =
+ "https://storage.alexav.gg/content/picturev2.jpg";
+export const DEFAULT_OG_IMAGE_ALT = "Alex Frantz";
+
+// external properties, kept together so the JSON-LD `sameAs` graph and the
+// rendered links cannot drift apart
+export const PROFILES = {
+ github: "https://github.com/azpha",
+ blog: "https://blog.alexav.gg",
+ medal: "https://medal.tv/u/alexav",
+ gitea: "https://git.alexav.gg/alex",
+} as const;
+
+/** absolute url for a site-relative path, with no trailing slash */
+export function absoluteUrl(path: string) {
+ if (path === "/" || path === "") return SITE_ORIGIN;
+ const withSlash = path.startsWith("/") ? path : `/${path}`;
+ return SITE_ORIGIN + withSlash.replace(/\/+$/, "");
+}
diff --git a/src/utils/structuredData.ts b/src/utils/structuredData.ts
new file mode 100644
index 0000000..e0773fd
--- /dev/null
+++ b/src/utils/structuredData.ts
@@ -0,0 +1,121 @@
+import {
+ SITE_ORIGIN,
+ SITE_NAME,
+ AUTHOR_EMAIL,
+ PROFILES,
+ absoluteUrl,
+} from "./site";
+import type { Project } from "../data/projects";
+
+const PERSON_ID = `${SITE_ORIGIN}/#alex`;
+
+/** the Person + WebSite graph, the main signal for a name-query result */
+export function personGraph(): object[] {
+ return [
+ {
+ "@context": "https://schema.org",
+ "@type": "Person",
+ "@id": PERSON_ID,
+ name: "Alex Frantz",
+ alternateName: ["alexav", "Alexavier Frantz"],
+ url: SITE_ORIGIN,
+ email: `mailto:${AUTHOR_EMAIL}`,
+ jobTitle: "Senior QA Engineer",
+ worksFor: {
+ "@type": "Organization",
+ name: "Medal.tv",
+ url: "https://medal.tv",
+ },
+ address: {
+ "@type": "PostalAddress",
+ addressRegion: "NY",
+ addressCountry: "US",
+ addressLocality: "Western New York",
+ },
+ knowsAbout: [
+ "Quality Assurance",
+ "Test Automation",
+ "JavaScript",
+ "TypeScript",
+ "React",
+ "Python",
+ "Java",
+ "C#",
+ "Docker",
+ "Homelab infrastructure",
+ ],
+ sameAs: [PROFILES.github, PROFILES.blog, PROFILES.medal, PROFILES.gitea],
+ },
+ {
+ "@context": "https://schema.org",
+ "@type": "WebSite",
+ "@id": `${SITE_ORIGIN}/#website`,
+ url: SITE_ORIGIN,
+ name: SITE_NAME,
+ publisher: { "@id": PERSON_ID },
+ inLanguage: "en-US",
+ },
+ ];
+}
+
+/** ProfilePage wrapper for the homepage, which is primarily about a person */
+export function profilePageGraph(): object[] {
+ return [
+ ...personGraph(),
+ {
+ "@context": "https://schema.org",
+ "@type": "ProfilePage",
+ url: SITE_ORIGIN,
+ mainEntity: { "@id": PERSON_ID },
+ },
+ ];
+}
+
+export function projectListGraph(projects: Project[]): object[] {
+ return [
+ {
+ "@context": "https://schema.org",
+ "@type": "CollectionPage",
+ url: absoluteUrl("/projects"),
+ name: "Projects",
+ about: { "@id": PERSON_ID },
+ mainEntity: {
+ "@type": "ItemList",
+ itemListElement: projects.map((project, index) => ({
+ "@type": "ListItem",
+ position: index + 1,
+ url: absoluteUrl(`/projects/${project.slug}`),
+ name: project.title,
+ })),
+ },
+ },
+ ];
+}
+
+export function projectGraph(project: Project): object[] {
+ return [
+ {
+ "@context": "https://schema.org",
+ "@type": "SoftwareSourceCode",
+ name: project.title,
+ description: project.description,
+ url: absoluteUrl(`/projects/${project.slug}`),
+ codeRepository: project.link,
+ programmingLanguage: project.pills,
+ author: { "@id": PERSON_ID },
+ },
+ {
+ "@context": "https://schema.org",
+ "@type": "BreadcrumbList",
+ itemListElement: [
+ {
+ "@type": "ListItem",
+ position: 1,
+ name: "Projects",
+ item: absoluteUrl("/projects"),
+ },
+ { "@type": "ListItem", position: 2, name: project.title },
+ ],
+ },
+ ];
+}
diff --git a/vite.config.ts b/vite.config.ts
index 4ff4f8f..0b8fd3b 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -5,4 +5,19 @@ import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
+ ssr: {
+ // react-router-dom's "node" export condition points at its CommonJS build,
+ // so SSR would otherwise get a module whose named exports all hang off
+ // `.default` — every `import { Routes } from "react-router-dom"` throws at
+ // request time in dev. "module-sync" selects the ESM entry instead.
+ resolve: {
+ conditions: [
+ "module-sync",
+ "module",
+ "browser",
+ "development|production",
+ ],
+ externalConditions: ["module-sync", "node", "import"],
+ },
+ },
});