Build the Astro site UI

This commit is contained in:
Andras Schmelczer 2026-05-25 13:12:10 +01:00
parent e5a219499e
commit f27e9ec3fd
84 changed files with 3510 additions and 1949 deletions

33
src/pages/404.astro Normal file
View file

@ -0,0 +1,33 @@
---
import ArticleList from '../components/ArticleList.astro';
import Page from '../layouts/Page.astro';
import { getPublishedPosts } from '../lib/site';
const RECENT_ARTICLES = 5;
const posts = await getPublishedPosts();
const recent = posts.slice(0, RECENT_ARTICLES);
---
<Page
title="This page doesn't exist"
description="The link you followed may be broken, or the page may have moved."
noindex
>
<div class="empty-state">
<p>
Try the <a href="/articles/">articles archive</a>, the
<a href="/projects/">project index</a>, the
<a href="/tags/">tag index</a>, or head back to the
<a href="/">homepage</a>.
</p>
</div>
<section class="home-section">
<div class="section-heading">
<h2 id="recent-articles-404">Recent articles</h2>
<a href="/articles/">All articles <span aria-hidden="true">→</span></a>
</div>
<ArticleList posts={recent} />
</section>
</Page>

120
src/pages/about.astro Normal file
View file

@ -0,0 +1,120 @@
---
import ArticleList from '../components/ArticleList.astro';
import Page from '../layouts/Page.astro';
import {
absoluteUrl,
buildPersonJsonLd,
getPublishedPosts,
optimizeOgImage,
site,
} from '../lib/site';
import defaultOg from '../assets/og-default.jpg';
const STARTING_POINTS = 4;
const posts = await getPublishedPosts();
const startingPoints = posts
.filter((post) => post.data.audience === 'recruiter-relevant')
.sort((a, b) => (a.data.featuredOrder ?? 99) - (b.data.featuredOrder ?? 99))
.slice(0, STARTING_POINTS);
const personImage = await optimizeOgImage(defaultOg);
// Canonical Person JSON-LD. Other pages reference this entity by @id.
const personJsonLd = buildPersonJsonLd({
jobTitle: 'Software Engineer',
description:
'Software engineer with an MSc in Computer Science working on AI/ML systems, web platforms, graphics, simulations, and tools.',
knowsAbout: [
'Software architecture',
'AI/ML systems',
'Web platforms',
'Computer graphics',
'Simulations',
'Data visualization',
],
image: absoluteUrl(personImage.src),
mainEntityOfPage: absoluteUrl('/about/'),
});
---
<Page
title="About"
description="A direct summary of my background, technical interests, and best starting points."
jsonLd={personJsonLd}
ogType="profile"
>
<div class="prose">
<p>
I am Andras Schmelczer, a software engineer with an MSc in Computer Science and more
than six years of professional engineering experience. My work spans AI/ML systems,
web platforms, graphics, simulations, and tools, and I like projects where
architecture, constraints, and product usefulness all matter.
</p>
<p>
I am especially interested in architecting and building large-scale systems,
particularly around AI/ML. In my own time I also return to shaders, data
visualization, simulations, and occasionally microcontrollers. The
<a href="/articles/">articles</a> and <a href="/projects/">projects</a> indexes are the
best way to understand that range; the CV and contact links are here when a direct summary
is more useful.
</p>
</div>
<section class="about-section facts">
<h2 id="quick-facts">Quick Facts</h2>
<dl>
<div>
<dt>Focus</dt>
<dd>
Software systems, AI deployment, architecture, graphics, data visualization
</dd>
</div>
<div>
<dt>Education</dt>
<dd>MSc in Computer Science</dd>
</div>
<div>
<dt>Contact</dt>
<dd>
<address>
<a href={`mailto:${site.email}`}>{site.email}</a>
</address>
</dd>
</div>
<div>
<dt>Links</dt>
<dd class="about-links">
<a href={site.cv} rel="noopener">CV</a>
<a href={site.github} rel="noopener me">GitHub</a>
<a href={site.linkedin} rel="noopener me">LinkedIn</a>
</dd>
</div>
</dl>
</section>
<section class="about-section">
<div class="section-heading">
<h2 id="best-starting-points">Best Starting Points</h2>
<a href="/articles/">Browse all articles <span aria-hidden="true">→</span></a>
</div>
<ArticleList posts={startingPoints} />
</section>
<section class="about-section facts">
<h2 id="working-style">How I Work</h2>
<div class="prose">
<p>
I am strongest when I can reason through a system end to end: the data model, the
API shape, the performance constraints, the operational risks, and the human path
through the tool. The projects on this site are older and newer examples of that
habit.
</p>
<p>
I care about simple interfaces over accidental complexity, and I prefer technical
depth that can be explained clearly. That is why this site is structured around
articles rather than screenshots and slogans.
</p>
</div>
</section>
</Page>

View file

@ -0,0 +1,16 @@
---
import Post from '../../layouts/Post.astro';
import { entrySlug, getPublishedPosts } from '../../lib/site';
export async function getStaticPaths() {
const posts = await getPublishedPosts();
return posts.map((post) => ({
params: { slug: entrySlug(post) },
props: { post },
}));
}
const { post } = Astro.props;
---
<Post post={post} />

View file

@ -0,0 +1,74 @@
---
import ArticleList from '../../components/ArticleList.astro';
import TagList from '../../components/TagList.astro';
import Page from '../../layouts/Page.astro';
import {
absoluteUrl,
articlePath,
buildBreadcrumbJsonLd,
buildBreadcrumbTrail,
getAllTags,
getPublishedPosts,
optimizeOgImage,
site,
yearOf,
} from '../../lib/site';
const description =
'Technical articles and notes about projects, systems, AI deployment, graphics, simulations, and tools.';
const posts = await getPublishedPosts();
const years = [...new Set(posts.map((post) => yearOf(post.data.date)))];
const tags = getAllTags(posts);
const postOgImages = await Promise.all(
posts.map((post) => optimizeOgImage(post.data.thumbnail.src))
);
const personId = absoluteUrl('/about/#person');
const blogJsonLd = {
'@context': 'https://schema.org',
'@type': 'Blog',
name: `${site.name} — Articles`,
url: absoluteUrl('/articles/'),
description,
publisher: { '@id': personId },
blogPost: posts.map((post, index) => ({
'@type': 'BlogPosting',
headline: post.data.title,
description: post.data.description,
datePublished: post.data.date.toISOString(),
url: absoluteUrl(articlePath(post)),
author: { '@id': personId },
image: absoluteUrl(postOgImages[index].src),
keywords: post.data.tags.join(', '),
})),
};
const breadcrumbJsonLd = buildBreadcrumbJsonLd(buildBreadcrumbTrail({ articles: true }));
const jsonLd = [blogJsonLd, breadcrumbJsonLd];
---
<Page title="Articles" description={description} jsonLd={jsonLd}>
<nav id="tag-filter" class="tag-filter" aria-label="Browse by tag">
<span>Browse by tag</span>
<TagList tags={tags} />
</nav>
{
years.map((year) => {
const postsForYear = posts.filter((post) => yearOf(post.data.date) === year);
return (
<section class="archive-year">
<h2 id={`year-${year}`}>{year}</h2>
<ArticleList
posts={postsForYear}
showYear={false}
eagerFirstThumbnail={year === years[0]}
/>
</section>
);
})
}
</Page>

69
src/pages/index.astro Normal file
View file

@ -0,0 +1,69 @@
---
import ArticleList from '../components/ArticleList.astro';
import ProjectList from '../components/ProjectList.astro';
import TagList from '../components/TagList.astro';
import Base from '../layouts/Base.astro';
import {
buildPersonJsonLd,
getAllTags,
getProjects,
getPublishedPosts,
} from '../lib/site';
const LATEST_ARTICLES = 5;
const posts = await getPublishedPosts();
const latestPosts = posts.slice(0, LATEST_ARTICLES);
const projects = await getProjects();
const selectedProjects = projects.filter((project) => project.data.selected);
const tags = getAllTags(posts);
// Reference the canonical Person (defined on /about/) by @id.
const personJsonLd = buildPersonJsonLd();
---
<Base jsonLd={personJsonLd}>
<section class="home-intro">
<p class="eyebrow">
Software systems, AI deployment, graphics, simulations, and tools
</p>
<h1>
Andras Schmelczer writes about building software that has to work under real
constraints.
</h1>
<p>
I am a software engineer with an MSc in Computer Science. This site is mostly a
notebook of technical articles and project writeups; the hiring details live on the
<a href="/about/">About</a> page.
</p>
</section>
<section class="home-section">
<div class="section-heading">
<h2 id="latest-articles">Latest Articles</h2>
<a href="/articles/"
>All {posts.length}
{posts.length === 1 ? 'article' : 'articles'}
<span aria-hidden="true">→</span></a
>
</div>
<ArticleList posts={latestPosts} />
</section>
<section class="home-section">
<div class="section-heading">
<h2 id="home-selected-projects">Selected Projects</h2>
<a href="/projects/">All projects <span aria-hidden="true">→</span></a>
</div>
<ProjectList projects={selectedProjects} />
</section>
<section class="home-section">
<div class="section-heading">
<h2 id="browse-by-topic">Browse by Topic</h2>
</div>
<div class="tag-cloud">
<TagList tags={tags} />
</div>
</section>
</Base>

View file

@ -0,0 +1,42 @@
---
import ProjectList from '../../components/ProjectList.astro';
import Page from '../../layouts/Page.astro';
import {
absoluteUrl,
buildBreadcrumbJsonLd,
buildBreadcrumbTrail,
getProjects,
site,
} from '../../lib/site';
const description =
'A compact index of systems, tools, simulations, graphics experiments, games, and older work.';
const projects = await getProjects();
const selected = projects.filter((project) => project.data.selected);
const older = projects.filter((project) => !project.data.selected);
const collectionJsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: `${site.name} — Projects`,
url: absoluteUrl('/projects/'),
description,
};
const breadcrumbJsonLd = buildBreadcrumbJsonLd(buildBreadcrumbTrail({ projects: true }));
const jsonLd = [collectionJsonLd, breadcrumbJsonLd];
---
<Page title="Projects" description={description} jsonLd={jsonLd}>
<section class="project-section">
<h2 id="selected-projects">Selected Projects</h2>
<ProjectList projects={selected} eagerFirstThumbnail />
</section>
<section class="project-section">
<h2 id="older-projects">Older and Smaller Projects</h2>
<ProjectList projects={older} />
</section>
</Page>

103
src/pages/rss.xml.ts Normal file
View file

@ -0,0 +1,103 @@
import rss from '@astrojs/rss';
import type { APIRoute } from 'astro';
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { render } from 'astro:content';
import ogDefault from '../assets/og-default.jpg';
import {
absoluteUrl,
articlePath,
getPublishedPosts,
optimizeOgImage,
site,
} from '../lib/site';
// Escape characters that would otherwise break XML parsing inside text nodes
// (the `customData` strings are inserted as-is by @astrojs/rss).
function escapeXml(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
// Rewrite root-relative URLs to absolute so RSS readers (which load the HTML
// outside any page context) can still resolve assets and links.
function absolutizeUrls(html: string, baseUrl: string) {
return html
.replace(/(<(?:a|link)\b[^>]*\bhref=")(\/[^"]*)(")/g, `$1${baseUrl}$2$3`)
.replace(
/(<(?:img|source|video|audio)\b[^>]*\bsrc=")(\/[^"]*)(")/g,
`$1${baseUrl}$2$3`
)
.replace(/(\bsrcset=")([^"]+)(")/g, (_, prefix, value, suffix) => {
const rewritten = value
.split(',')
.map((candidate: string) => {
const trimmed = candidate.trim();
if (!trimmed.startsWith('/')) return trimmed;
return baseUrl + trimmed;
})
.join(', ');
return prefix + rewritten + suffix;
});
}
export const GET: APIRoute = async () => {
const posts = await getPublishedPosts();
const feedUrl = absoluteUrl('/rss.xml');
const channelImage = await optimizeOgImage(ogDefault);
const channelImageUrl = absoluteUrl(channelImage.src);
const creator = escapeXml(site.name);
const container = await AstroContainer.create();
const items = await Promise.all(
posts.map(async (post) => {
const url = absoluteUrl(articlePath(post));
const updated = post.data.updated
? `<atom:updated>${post.data.updated.toISOString()}</atom:updated>`
: '';
const { Content } = await render(post);
const html = await container.renderToString(Content);
// @astrojs/rss XML-escapes the `content` string and emits it inside
// <content:encoded>. RSS readers decode the escaped HTML the same as if
// it were wrapped in CDATA, so escaping is fine and safer to author.
const content = absolutizeUrls(html, site.url);
return {
title: post.data.title,
description: post.data.description,
pubDate: post.data.date,
link: url,
author: `${site.email} (${site.name})`,
categories: [...post.data.tags],
content,
customData: [`<dc:creator>${creator}</dc:creator>`, updated]
.filter(Boolean)
.join('\n'),
};
})
);
return rss({
title: site.name,
description: site.description,
site: site.url,
xmlns: {
atom: 'http://www.w3.org/2005/Atom',
content: 'http://purl.org/rss/1.0/modules/content/',
dc: 'http://purl.org/dc/elements/1.1/',
},
customData: [
'<language>en-us</language>',
`<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>`,
`<atom:link href="${feedUrl}" rel="self" type="application/rss+xml" />`,
'<image>',
` <url>${channelImageUrl}</url>`,
` <title>${escapeXml(site.name)}</title>`,
` <link>${site.url}</link>`,
'</image>',
].join('\n'),
items,
});
};

View file

@ -0,0 +1,49 @@
---
import ArticleList from '../../components/ArticleList.astro';
import Breadcrumbs from '../../components/Breadcrumbs.astro';
import TagList from '../../components/TagList.astro';
import Page from '../../layouts/Page.astro';
import {
buildBreadcrumbJsonLd,
buildBreadcrumbTrail,
getAllTags,
getPublishedPosts,
tagSlug,
} from '../../lib/site';
export async function getStaticPaths() {
const posts = await getPublishedPosts();
return getAllTags(posts).map((tag) => ({
params: { tag: tagSlug(tag) },
props: { tag },
}));
}
const { tag } = Astro.props;
const posts = await getPublishedPosts();
const allTags = getAllTags(posts);
const filteredPosts = posts.filter((post) => post.data.tags.some((t) => t === tag));
const title = `Articles tagged "${tag}"`;
const trail = buildBreadcrumbTrail({ tag });
const visibleTrail = trail.map((c, i) => ({
label: c.name,
href: i === trail.length - 1 ? undefined : c.href,
}));
const breadcrumbJsonLd = buildBreadcrumbJsonLd(trail);
---
<Page
title={title}
description={`Project articles and technical notes filed under #${tag}.`}
jsonLd={breadcrumbJsonLd}
noindex
>
<Breadcrumbs slot="breadcrumbs" items={visibleTrail} />
<nav class="tag-filter" aria-label="Browse other tags">
<span>Browse other tags</span>
<TagList tags={allTags} currentTag={tag} />
</nav>
<h2 class="sr-only">Articles</h2>
<ArticleList posts={filteredPosts} eagerFirstThumbnail />
</Page>

View file

@ -0,0 +1,45 @@
---
import TagList from '../../components/TagList.astro';
import Page from '../../layouts/Page.astro';
import {
absoluteUrl,
buildBreadcrumbJsonLd,
buildBreadcrumbTrail,
getAllTags,
getPublishedPosts,
site,
} from '../../lib/site';
const description = 'Every tag used across the articles archive.';
const posts = await getPublishedPosts();
const tags = getAllTags(posts);
const tagCounts: Record<string, number> = {};
for (const post of posts) {
for (const tag of post.data.tags) {
tagCounts[tag] = (tagCounts[tag] ?? 0) + 1;
}
}
const collectionJsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: `${site.name} — Tags`,
url: absoluteUrl('/tags/'),
description,
};
const breadcrumbJsonLd = buildBreadcrumbJsonLd(buildBreadcrumbTrail({ tagsIndex: true }));
const jsonLd = [collectionJsonLd, breadcrumbJsonLd];
---
<Page title="Tags" description={description} jsonLd={jsonLd}>
<p class="dek">
{posts.length}
{posts.length === 1 ? 'article' : 'articles'} across {tags.length}
{tags.length === 1 ? 'tag' : 'tags'}.
</p>
<TagList tags={tags} counts={tagCounts} />
</Page>