- /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
160 lines
5.3 KiB
TypeScript
160 lines
5.3 KiB
TypeScript
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
|
||
};
|
||
}
|