Build the Astro site UI
This commit is contained in:
parent
e5a219499e
commit
f27e9ec3fd
84 changed files with 3510 additions and 1949 deletions
235
src/lib/site.ts
Normal file
235
src/lib/site.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
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 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' },
|
||||
{ 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 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(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<CollectionEntry<'posts'>[]> | undefined;
|
||||
|
||||
export function getPublishedPosts(): Promise<CollectionEntry<'posts'>[]> {
|
||||
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<CollectionEntry<'projects'>[]> {
|
||||
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<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.github, 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: [120, 180, 240, 320, 480],
|
||||
sizes: '(max-width: 700px) clamp(64px, 22vw, 80px), (max-width: 960px) 7rem, 8rem',
|
||||
};
|
||||
|
||||
export const PROJECT_THUMBNAIL = {
|
||||
widths: [240, 320, 480, 640, 800],
|
||||
sizes: '(max-width: 700px) 7rem, (max-width: 960px) clamp(7rem, 18vw, 9.5rem), 19rem',
|
||||
};
|
||||
|
||||
// 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<'posts'>;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue