Build the Astro site UI
This commit is contained in:
parent
e5a219499e
commit
f27e9ec3fd
84 changed files with 3510 additions and 1949 deletions
33
src/pages/404.astro
Normal file
33
src/pages/404.astro
Normal 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
120
src/pages/about.astro
Normal 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>
|
||||
16
src/pages/articles/[slug].astro
Normal file
16
src/pages/articles/[slug].astro
Normal 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} />
|
||||
74
src/pages/articles/index.astro
Normal file
74
src/pages/articles/index.astro
Normal 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
69
src/pages/index.astro
Normal 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>
|
||||
42
src/pages/projects/index.astro
Normal file
42
src/pages/projects/index.astro
Normal 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
103
src/pages/rss.xml.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
};
|
||||
49
src/pages/tags/[tag].astro
Normal file
49
src/pages/tags/[tag].astro
Normal 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>
|
||||
45
src/pages/tags/index.astro
Normal file
45
src/pages/tags/index.astro
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue