schmelczer-dev/src/lib/site.ts
2026-06-26 17:56:05 +02:00

310 lines
10 KiB
TypeScript

import type { CollectionEntry } from 'astro:content';
import { getCollection } from 'astro:content';
import { getImage } from 'astro:assets';
import type { ImageMetadata } from 'astro';
// Theme background colors, the single source of truth for the
// <meta name="theme-color"> tags and the FOUC-prevention scripts (injected in
// Base.astro and Header.astro). Keep --color-bg in styles/tokens.css in sync;
// CSS cannot import these values.
export const THEME_BG = { light: '#fbfaf7', dark: '#201f1d' };
export const site = {
brand: 'schmelczer.dev',
name: 'Andras Schmelczer',
title: 'Andras Schmelczer',
description:
'Notebook of someone who keeps reaching for the same two moves: let the hard constraint pick the data structure, then keep the API small enough to defend.',
url: 'https://schmelczer.dev',
email: 'andras@schmelczer.dev',
git: 'https://git.schmelczer.dev/andras',
linkedin: 'https://www.linkedin.com/in/andras-schmelczer',
cv: '/media/downloads/cv-andras-schmelczer.pdf',
};
// Single source of truth for primary navigation. The Header consumes every
// entry where `footerOnly` is falsy AND `href !== '/'` (Home is implicit via
// the site title). The Footer renders every entry regardless. Items marked
// `footerOnly: true` appear only in the Footer.
export interface NavItem {
href: string;
label: string;
footerOnly?: boolean;
}
export const navItems: readonly NavItem[] = [
{ href: '/', label: 'Home' },
{ href: '/articles/', label: 'Articles' },
{ href: '/projects/', label: 'Projects' },
{ href: '/about/', label: 'About' },
{ href: '/tags/', label: 'Tags', footerOnly: true },
{ href: '/rss.xml', label: 'RSS', footerOnly: true },
];
export function formatDate(date: Date) {
return new Intl.DateTimeFormat('en', {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(date);
}
export function formatDateShort(date: Date) {
return new Intl.DateTimeFormat('en', {
month: 'short',
day: 'numeric',
}).format(date);
}
export function yearOf(date: Date) {
return new Intl.DateTimeFormat('en', { year: 'numeric' }).format(date);
}
export function isExternal(url: string) {
return /^https?:\/\//.test(url);
}
// Directory-style paths get a trailing slash; the root and file-style paths
// (e.g. /rss.xml) pass through unchanged. Shared by canonical URL generation
// and the header's current-page matching so the two can never disagree.
export function normalizeTrailingSlash(path: string) {
return path === '/' || path.endsWith('/') || /\.[^/]+$/.test(path) ? path : `${path}/`;
}
export function entrySlug(entry: { id: string }) {
return entry.id.replace(/\.mdx?$/, '').replace(/\/index$/, '');
}
export function articlePath(entry: { id: string } | string) {
const slug = typeof entry === 'string' ? entry : entrySlug(entry);
return `/articles/${slug}/`;
}
export function tagSlug(tag: string) {
return tag
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
export function tagPath(tag: string) {
return `/tags/${tagSlug(tag)}/`;
}
export function getAllTags(articles: CollectionEntry<'work'>[]) {
return [
...new Set(articles.flatMap((article) => article.data.article?.tags ?? [])),
].sort((a, b) => a.localeCompare(b));
}
// Memoized article loader. Build steps call `getArticles()` from many pages
// (index, articles, RSS, sitemap, tag pages, article layout). Caching the
// promise means `getCollection('work')` runs once per build. An entry is an
// article when it carries an `article` facet and isn't a draft.
let articlesPromise: Promise<CollectionEntry<'work'>[]> | undefined;
export function getArticles(): Promise<CollectionEntry<'work'>[]> {
if (!articlesPromise) {
articlesPromise = getCollection('work').then((entries) =>
entries
.filter((entry) => entry.data.article && !entry.data.article.draft)
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
);
}
return articlesPromise;
}
// Entries shown in the projects index: those carrying a `project` facet,
// newest first.
export async function getProjects(): Promise<CollectionEntry<'work'>[]> {
return (await getCollection('work'))
.filter((entry) => entry.data.project)
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
}
export interface ProjectCard {
title: string;
description: string;
thumbnail: { src: ImageMetadata; alt: string };
technologies: string[];
selected: boolean;
}
// Resolves a project card's presentation. Shared identity lives at the top
// level; the `project` facet overrides only the fields where the card
// deliberately differs from the article. Technologies fall back to the
// article's stack.
export function projectCard(entry: CollectionEntry<'work'>): ProjectCard {
const { data } = entry;
const project = data.project;
return {
title: project?.title ?? data.title,
description: data.description,
thumbnail: {
src: project?.thumbnail?.src ?? data.thumbnail.src,
alt: data.thumbnail.alt,
},
technologies: project?.technologies ?? data.article?.stack ?? [],
selected: project?.selected ?? false,
};
}
type ArticleData = NonNullable<CollectionEntry<'work'>['data']['article']>;
export type WorkMedia = ArticleData['media'][number];
export type HeaderVideo = Extract<WorkMedia, { type: 'video' }>;
// An entry has a "header video" when one of its article media items is a video
// whose poster is the same image as the top-level thumbnail. That video can
// stand in for the static banner: the header shows the poster, then plays
// inline on click. Entries without such a video keep the plain image header.
export function getHeaderVideo(entry: CollectionEntry<'work'>): HeaderVideo | undefined {
const thumbnailSrc = entry.data.thumbnail.src.src;
return (entry.data.article?.media ?? []).find(
(item): item is HeaderVideo =>
item.type === 'video' && item.poster?.src === thumbnailSrc
);
}
export function adjacentArticles(
articles: CollectionEntry<'work'>[],
current: CollectionEntry<'work'>
) {
const index = articles.findIndex((article) => article.id === current.id);
if (index === -1) return { previous: undefined, next: undefined };
return {
previous: index < articles.length - 1 ? articles[index + 1] : undefined,
next: index > 0 ? articles[index - 1] : undefined,
};
}
export function getRelatedArticles(
articles: CollectionEntry<'work'>[],
current: CollectionEntry<'work'>,
limit = 3
) {
const currentTags = new Set(current.data.article?.tags ?? []);
return articles
.filter((article) => article.id !== current.id)
.map((article) => ({
article,
overlap: (article.data.article?.tags ?? []).filter((tag) => currentTags.has(tag))
.length,
}))
.filter(({ overlap }) => overlap > 0)
.sort((a, b) => b.overlap - a.overlap)
.slice(0, limit)
.map(({ article }) => article);
}
export function absoluteUrl(path: string) {
return new URL(path, site.url).toString();
}
// Canonical Person JSON-LD. Used by the home page and About page; both share
// `@id` so search engines treat them as the same entity. Pass `extra` to
// add or override fields (e.g. `jobTitle`, richer `description`).
export function buildPersonJsonLd(extra?: Record<string, unknown>) {
return {
'@context': 'https://schema.org',
'@type': 'Person',
'@id': absoluteUrl('/about/#person'),
name: site.name,
url: site.url,
email: `mailto:${site.email}`,
sameAs: [site.git, site.linkedin],
description: site.description,
...extra,
};
}
// Responsive image config shared by entry listings. Centralized here so a
// change to one breakpoint set is a single edit, not two component changes.
export const ARTICLE_THUMBNAIL = {
widths: [160, 240, 320, 480, 640],
sizes: '(max-width: 700px) clamp(64px, 22vw, 80px), (max-width: 960px) 7rem, 8rem',
};
export const PROJECT_THUMBNAIL = {
widths: [320, 480, 640, 800, 960, 1200, 1280],
sizes:
'(max-width: 700px) calc((100vw - 40px - 0.75rem) / 2), (max-width: 960px) calc((100vw - 64px - 1rem) / 2), calc((min(100vw - 64px, 72rem) - 2rem) / 3)',
};
// Wraps `getImage` with the standard OG dimensions (1200x630 JPEG). Used by
// Base.astro for the default OG image and by Post.astro for per-post
// thumbnails. Keeps OG output consistent across the site.
export function optimizeOgImage(src: ImageMetadata) {
return getImage({
src,
width: 1200,
height: 630,
format: 'jpg',
});
}
interface BreadcrumbCrumb {
name: string;
href: string;
}
interface BreadcrumbInput {
articles?: boolean;
projects?: boolean;
tagsIndex?: boolean;
tag?: string;
post?: CollectionEntry<'work'>;
}
// Builds the breadcrumb trail shared by JSON-LD (BreadcrumbList) and the
// visible Breadcrumbs component. Home is always first. Flags append crumbs
// in a fixed order: Articles → Tags → tag → Post (or Projects). A `tag`
// implies both Articles and Tags so callers don't have to set every flag.
export function buildBreadcrumbTrail({
articles,
projects,
tagsIndex,
tag,
post,
}: BreadcrumbInput): BreadcrumbCrumb[] {
const trail: BreadcrumbCrumb[] = [{ name: 'Home', href: '/' }];
if (articles || post || tagsIndex || tag) {
trail.push({ name: 'Articles', href: '/articles/' });
}
if (tagsIndex || tag) {
trail.push({ name: 'Tags', href: '/tags/' });
}
if (tag) {
trail.push({ name: `#${tag}`, href: tagPath(tag) });
}
if (post) {
trail.push({ name: post.data.title, href: articlePath(post) });
}
if (projects) {
trail.push({ name: 'Projects', href: '/projects/' });
}
return trail;
}
// Adapts a trail for the visible Breadcrumbs component, which renders the
// current page (the last crumb) as plain text rather than a link.
export function visibleBreadcrumbs(trail: BreadcrumbCrumb[]) {
return trail.map((crumb, index) => ({
label: crumb.name,
href: index === trail.length - 1 ? undefined : crumb.href,
}));
}
// Builds the schema.org BreadcrumbList JSON-LD object for a given trail.
// Shared by every page that emits breadcrumb structured data.
export function buildBreadcrumbJsonLd(trail: BreadcrumbCrumb[]) {
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: trail.map((crumb, index) => ({
'@type': 'ListItem',
position: index + 1,
name: crumb.name,
item: absoluteUrl(crumb.href),
})),
};
}