310 lines
10 KiB
TypeScript
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),
|
|
})),
|
|
};
|
|
}
|