import type { CollectionEntry } from 'astro:content'; import { getCollection } from 'astro:content'; import { getImage } from 'astro:assets'; import type { ImageMetadata } from 'astro'; export const site = { name: 'Andras Schmelczer', title: 'Andras Schmelczer — Software engineer', description: 'Andras Schmelczer writes about software systems, AI deployment, graphics, simulations, and tools.', url: 'https://schmelczer.dev', email: 'andras@schmelczer.dev', github: 'https://github.com/schmelczer', 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 const navItems = [ { href: '/', label: 'Home' }, { href: '/articles/', label: 'Articles' }, { href: '/projects/', label: 'Projects' }, { href: '/about/', label: 'About' }, { href: '/tags/', label: 'Tags', footerOnly: false }, { href: '/rss.xml', label: 'RSS', footerOnly: true }, ] as const satisfies ReadonlyArray<{ href: string; label: string; footerOnly?: boolean; }>; 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 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)}/`; } // Anchor used for `id="..."` on project cards and `#fragment` deep links. // Always derived from the canonical `sourceProjectId` slug now that the // legacy anchor mapping has been dropped. export function projectAnchor(slugOrEntry: string | CollectionEntry<'projects'>) { return typeof slugOrEntry === 'string' ? slugOrEntry : slugOrEntry.data.sourceProjectId; } export function getAllTags(posts: { data: { tags: readonly string[] } }[]) { return [...new Set(posts.flatMap((post) => post.data.tags))].sort((a, b) => a.localeCompare(b) ); } // Memoized published-posts loader. Build steps call `getPublishedPosts()` // from many pages (index, articles, RSS, sitemap, tag pages, post layouts). // Caching the promise means `getCollection('posts')` runs once per build. let publishedPostsPromise: Promise[]> | undefined; export function getPublishedPosts(): Promise[]> { if (!publishedPostsPromise) { publishedPostsPromise = getCollection('posts').then((posts) => posts .filter((post) => !post.data.draft) .sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf()) ); } return publishedPostsPromise; } export async function getProjects(): Promise[]> { return (await getCollection('projects')).sort( (a, b) => b.data.sortDate.valueOf() - a.data.sortDate.valueOf() ); } export function adjacentPosts( posts: CollectionEntry<'posts'>[], current: CollectionEntry<'posts'> ) { const index = posts.findIndex((post) => post.id === current.id); if (index === -1) return { previous: undefined, next: undefined }; return { previous: index < posts.length - 1 ? posts[index + 1] : undefined, next: index > 0 ? posts[index - 1] : undefined, }; } export function getRelatedPosts( posts: CollectionEntry<'posts'>[], current: CollectionEntry<'posts'>, limit = 3 ) { const currentTags = new Set(current.data.tags); return posts .filter((post) => post.id !== current.id) .map((post) => ({ post, overlap: post.data.tags.filter((tag) => currentTags.has(tag)).length, })) .filter(({ overlap }) => overlap > 0) .sort((a, b) => b.overlap - a.overlap) .slice(0, limit) .map(({ post }) => post); } 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.github, site.linkedin], description: site.description, ...extra, }; } // 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; tag?: string; post?: CollectionEntry<'posts'>; } // Builds the breadcrumb trail shared by JSON-LD (BreadcrumbList) and the // visible Breadcrumbs component. Home is always first. Pass `articles: true` // to include the /articles/ crumb; pass a `tag` to append a tag crumb; pass // a `post` to append the post title (linking to its article path). export function buildBreadcrumbTrail({ articles, tag, post, }: BreadcrumbInput): BreadcrumbCrumb[] { const trail: BreadcrumbCrumb[] = [{ name: 'Home', href: '/' }]; if (articles || post) { trail.push({ name: 'Articles', href: '/articles/' }); } if (tag) { trail.push({ name: tag, href: tagPath(tag) }); } if (post) { trail.push({ name: post.data.title, href: articlePath(post) }); } return trail; }