Files
website/src/lib/seo.ts
Stan Runge 7e69d1e888 ui: separate trackers from projects; keep header dropdown on top
- /trackers index, Trackers section on the homepage and in the header nav;
  /projects lists only projects (tracker URLs unchanged)
- header is its own z-50 layer and <main> is isolated, so maps, charts and
  sticky tables no longer paint over the nav dropdowns
2026-10-23 22:03:59 +00:00

160 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { formatTitle, getTitle, type SiteEnvironment } from './title';
export const SITE_ORIGIN = 'https://stanrunge.dev';
type Article = {
title: string;
content: string;
updatedAt: Date | string;
};
type List = { name: string; isProgress: boolean; items: { name: string }[] };
type Race = { short: string; distance: number; discipline: string };
/** Pages that describe themselves, such as language learner lessons. */
type Described = { title: string; description: string; index?: boolean };
type PageContent = { blogArticle?: Article; list?: List; race?: Race; seo?: Described };
type Seo = {
title: string;
description: string;
url: string;
indexable: boolean;
type: 'website' | 'article';
image: string;
imageAlt: string;
modifiedAt?: string;
};
const publicPages: Record<string, { title: string; description: string }> = {
'/': {
title: 'Projects, lists and writing',
description: 'Stan Runge’s projects, lists, writing and experience.'
},
'/projects': {
title: 'Projects',
description:
'Software projects by Stan Runge, including osu! map finder, Derby Lens and Language Learner.'
},
'/blog': { title: 'Blog', description: 'Articles and notes by Stan Runge.' },
'/trackers': {
title: 'Trackers',
description:
'Stan Runge’s trackers: games, money, houses, Counter-Strike prices, Animaldex and lists.'
},
'/lists': { title: 'Lists', description: 'Lists collected and maintained by Stan Runge.' },
'/projects/game-tracker': {
title: 'Game Tracker',
description:
'Pokémon adventures and collections, Call of Duty ranks and unlocks, and osu! scores and training.'
},
'/projects/game-tracker/pokemon': {
title: 'Pokémon journal',
description:
'Explore Pokémon encounters and regional Pokédexes, record catches and teams, and track badges across your playthroughs.'
},
'/projects/game-tracker/osu': {
title: 'osu! training',
description:
'Keep memorable scores, beatmap goals and a training journal for speed, stamina and consistency.'
},
'/projects/game-tracker/pokemon/locations': {
title: 'Pokémon locations',
description: 'Every Pokémon location by region, with the Pokémon you can find there.'
},
'/projects/animaldex': {
title: 'Animaldex',
description: 'Find and add neighborhood animals on a map.'
},
'/projects/cs-prices': {
title: 'CS Prices',
description:
'Compare historical Counter-Strike market prices by listing, weapon, skin, wear, and other item dimensions.'
},
'/projects/derby': {
title: 'Derby Lens',
description: 'Estimate horse racing odds and compare them with market prices.'
},
'/projects/spotify-beatmap-finder': {
title: 'osu! map finder',
description: 'Find osu! beatmaps for Spotify songs, albums and playlists.'
},
'/projects/language-learner': {
title: 'Language Learner',
description:
'Learn Dutch, Korean and English from beginner to advanced, with lessons and endless daily challenges.'
},
'/projects/world-opinions': {
title: 'World Opinions',
description:
'How political words, parties, values and national histories differ across the world.'
}
};
function excerpt(markdown: string): string {
return markdown
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/<[^>]*>/g, ' ')
.replace(/[`*_>#~|]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 160);
}
function date(value: Date | string): string | undefined {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
}
export function getSeo(
pathname: string,
content: PageContent = {},
environment: SiteEnvironment = null
): Seo {
const path = pathname.replace(/\/$/, '') || '/';
const url = new URL(path, SITE_ORIGIN).href;
let page = publicPages[path];
let type: Seo['type'] = 'website';
let modifiedAt: string | undefined;
if (/^\/blog\/[^/]+$/.test(path) && content.blogArticle) {
const article = content.blogArticle;
page = {
title: article.title,
description: excerpt(article.content) || `Read ${article.title} by Stan Runge.`
};
type = 'article';
modifiedAt = date(article.updatedAt);
} else if (/^\/blog\/[^/]+\/edit$/.test(path) && content.blogArticle) {
page = { title: `Editing ${content.blogArticle.title}`, description: 'Edit a blog article.' };
} else if (/^\/lists\/[^/]+$/.test(path) && content.list) {
const list = content.list;
const names = list.items.slice(0, 3).map((item) => item.name);
page = {
title: list.name,
description: names.length
? `${list.name}: ${names.join(', ')}${list.items.length > names.length ? ', and more' : ''}.`
: `${list.name}, a ${list.isProgress ? 'progress ' : ''}list by Stan Runge.`
};
} else if (content.seo) {
page = content.seo;
} else if (/^\/projects\/derby\/[^/]+$/.test(path) && content.race) {
const race = content.race;
page = {
title: `${race.short} · Derby Lens`,
description: `Estimate odds for the ${race.short}, a ${race.distance} m ${race.discipline} race.`
};
}
return {
title: page ? formatTitle(page.title, environment) : getTitle(path, environment),
description: page?.description ?? 'Stan Runge’s personal website.',
url,
indexable:
!!page && !environment && !/^\/blog\/[^/]+\/edit$/.test(path) && content.seo?.index !== false,
type,
image: `${SITE_ORIGIN}/moon.jpg`,
imageAlt: 'Moon icon for Stan Runge’s website',
modifiedAt
};
}