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 // 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[]> | undefined; export function getArticles(): Promise[]> { 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[]> { 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['data']['article']>; export type WorkMedia = ArticleData['media'][number]; export type HeaderVideo = Extract; // 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) { 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), })), }; }