claude again

This commit is contained in:
Andras Schmelczer 2026-05-11 08:12:35 +01:00
parent df2267a968
commit f3fc893675
81 changed files with 945 additions and 2813 deletions

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,13 @@ The site is article-first: articles live in `src/content/posts`, project index e
live in `src/content/projects`, and normal pages are rendered as static HTML with no live in `src/content/projects`, and normal pages are rendered as static HTML with no
required client JavaScript. required client JavaScript.
## Setup
```sh
npm install
npx playwright install chromium # required before `npm run qa:overflow`
```
## Commands ## Commands
```sh ```sh

View file

@ -1,11 +1,44 @@
import { readdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import sitemap from '@astrojs/sitemap'; import sitemap from '@astrojs/sitemap';
import { defineConfig } from 'astro/config'; import { defineConfig } from 'astro/config';
import rehypeAutolinkHeadings from 'rehype-autolink-headings'; import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import rehypeSlug from 'rehype-slug'; import rehypeSlug from 'rehype-slug';
// Build a lookup of post slugs to their last modification dates so the sitemap
// can advertise accurate <lastmod> values to crawlers. We parse the markdown
// frontmatter ourselves rather than importing `astro:content` (a virtual module
// that may not be available inside the config). Failures are non-fatal —
// sitemap entries simply fall back to no lastmod.
const postLastmodLookup = new Map();
try {
const postsDir = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'src/content/posts'
);
for (const entry of readdirSync(postsDir, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
const slug = entry.name.replace(/\.md$/, '');
const raw = readFileSync(path.join(postsDir, entry.name), 'utf8');
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!frontmatterMatch) continue;
const frontmatter = frontmatterMatch[1];
const updatedMatch = frontmatter.match(/^updated:\s*(.+?)\s*$/m);
const dateMatch = frontmatter.match(/^date:\s*(.+?)\s*$/m);
const rawDate = (updatedMatch ?? dateMatch)?.[1]?.replace(/^['"]|['"]$/g, '');
if (!rawDate) continue;
const parsed = new Date(rawDate);
if (!Number.isNaN(parsed.valueOf())) postLastmodLookup.set(slug, parsed);
}
} catch {
// Directory missing or unreadable; sitemap will fall back to no lastmod.
}
export default defineConfig({ export default defineConfig({
site: 'https://schmelczer.dev', site: 'https://schmelczer.dev',
trailingSlash: 'always', trailingSlash: 'always',
build: { inlineStylesheets: 'always' },
redirects: { redirects: {
'/writing/': '/articles/', '/writing/': '/articles/',
'/writing/[slug]': '/articles/[slug]', '/writing/[slug]': '/articles/[slug]',
@ -17,7 +50,16 @@ export default defineConfig({
return !path.startsWith('/writing/') && path !== '/404/'; return !path.startsWith('/writing/') && path !== '/404/';
}, },
serialize(item) { serialize(item) {
return { ...item, changefreq: 'monthly' }; const url = new URL(item.url);
const match = url.pathname.match(/^\/articles\/([^/]+)\/?$/);
let lastmod = item.lastmod;
if (match) {
const date = postLastmodLookup.get(match[1]);
if (date instanceof Date && !Number.isNaN(date.valueOf())) {
lastmod = date.toISOString();
}
}
return { ...item, changefreq: 'monthly', ...(lastmod ? { lastmod } : {}) };
}, },
}), }),
], ],

1342
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,9 +6,11 @@
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"start": "astro dev", "start": "astro dev",
"lint": "astro check && prettier --check \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/*.mjs\" \"*.md\" \"*.json\"", "typecheck": "astro check",
"lint": "prettier --check \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/*.mjs\" \"*.md\" \"*.json\"",
"format": "prettier --write \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/*.mjs\" \"*.md\" \"*.json\"", "format": "prettier --write \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/*.mjs\" \"*.md\" \"*.json\"",
"build": "astro check && astro build", "format:check": "prettier --check \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/*.mjs\" \"*.md\" \"*.json\"",
"build": "astro build",
"preview": "astro preview", "preview": "astro preview",
"qa:no-js": "node scripts/check-no-js.mjs", "qa:no-js": "node scripts/check-no-js.mjs",
"qa:overflow": "node scripts/check-overflow.mjs", "qa:overflow": "node scripts/check-overflow.mjs",
@ -43,7 +45,9 @@
"prettier-plugin-astro": "^0.14.1", "prettier-plugin-astro": "^0.14.1",
"rehype-autolink-headings": "^7.1.0", "rehype-autolink-headings": "^7.1.0",
"rehype-slug": "^6.0.0", "rehype-slug": "^6.0.0",
"sharp": "^0.32.6",
"typescript": "^5.9.3" "typescript": "^5.9.3"
},
"dependencies": {
"sharp": "^0.34.5"
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

View file

@ -35,14 +35,41 @@ if (jsFiles.length > 0) {
); );
} }
// Script tags are only allowed if they declare one of these safe `type`
// attributes (or are tagged with `data-theme-script`). All other scripts —
// including untyped ones, which default to executable JavaScript — are
// flagged.
const SAFE_SCRIPT_TYPES = new Set([
'application/ld+json',
'importmap',
'speculationrules',
]);
function isSafeScriptTag(tag) {
if (tag.includes('data-theme-script')) return true;
const typeMatch = tag.match(/\btype=["']([^"']+)["']/i);
if (!typeMatch) return false;
return SAFE_SCRIPT_TYPES.has(typeMatch[1].trim().toLowerCase());
}
for (const file of files.filter((candidate) => candidate.endsWith('.html'))) { for (const file of files.filter((candidate) => candidate.endsWith('.html'))) {
const html = await readFile(file, 'utf8'); const html = await readFile(file, 'utf8');
const scripts = ( const scripts = (html.match(/<script\b[^>]*>/gi) ?? []).filter(
html.match(/<script\b(?![^>]*type=["']application\/ld\+json["'])[^>]*>/gi) ?? [] (tag) => !isSafeScriptTag(tag)
).filter((script) => !script.includes('data-theme-script')); );
if (scripts?.length) { if (scripts.length) {
failures.push(`Unexpected script tag in ${file}:\n${scripts.join('\n')}`); failures.push(`Unexpected script tag in ${file}:\n${scripts.join('\n')}`);
} }
// Inline event handlers (onclick=, onload=, etc.) execute JavaScript even
// without a <script> tag, so flag any attribute matching `on*=`. We strip
// <script> blocks first to avoid false positives from JSON-LD payloads.
const stripped = html.replace(/<script\b[\s\S]*?<\/script>/gi, '');
const handlerMatches = stripped.match(/\son\w+=/gi);
if (handlerMatches?.length) {
const unique = [...new Set(handlerMatches.map((m) => m.trim()))];
failures.push(`Unexpected inline event handler in ${file}:\n${unique.join('\n')}`);
}
} }
if (failures.length > 0) { if (failures.length > 0) {

View file

@ -6,16 +6,27 @@ import { chromium } from 'playwright';
const dist = path.resolve('dist'); const dist = path.resolve('dist');
const widths = [320, 390, 430, 768, 1024, 1440, 1920]; const widths = [320, 390, 430, 768, 1024, 1440, 1920];
const MIME = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.avif': 'image/avif',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.pdf': 'application/pdf',
};
function contentType(file) { function contentType(file) {
if (file.endsWith('.html')) return 'text/html; charset=utf-8'; const ext = path.extname(file).toLowerCase();
if (file.endsWith('.css')) return 'text/css; charset=utf-8'; return MIME[ext] ?? 'application/octet-stream';
if (file.endsWith('.js')) return 'text/javascript; charset=utf-8';
if (file.endsWith('.svg')) return 'image/svg+xml';
if (file.endsWith('.png')) return 'image/png';
if (file.endsWith('.jpg') || file.endsWith('.jpeg')) return 'image/jpeg';
if (file.endsWith('.webp')) return 'image/webp';
if (file.endsWith('.woff2')) return 'font/woff2';
return 'application/octet-stream';
} }
async function walk(dir) { async function walk(dir) {
@ -75,6 +86,12 @@ async function resolveFile(url) {
return path.join(dist, '404.html'); return path.join(dist, '404.html');
} }
try {
await stat(dist);
} catch {
throw new Error('dist/ does not exist. Run npm run build first.');
}
const routes = await discoverRoutes(); const routes = await discoverRoutes();
const server = createServer(async (req, res) => { const server = createServer(async (req, res) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Before After
Before After

View file

@ -22,13 +22,13 @@ const { posts, showYear = true, currentTag } = Astro.props;
<time datetime={post.data.date.toISOString()}> <time datetime={post.data.date.toISOString()}>
{showYear ? formatDate(post.data.date) : formatDateShort(post.data.date)} {showYear ? formatDate(post.data.date) : formatDateShort(post.data.date)}
</time> </time>
<div> <article>
<a class="entry-title" href={href}> <a class="entry-title" href={href}>
{post.data.title} {post.data.title}
</a> </a>
<p>{post.data.description}</p> <p>{post.data.description}</p>
<TagList tags={post.data.tags} currentTag={currentTag} /> <TagList tags={post.data.tags} currentTag={currentTag} limit={3} />
</div> </article>
<EntryThumbnail <EntryThumbnail
src={post.data.thumbnail.src} src={post.data.thumbnail.src}
alt={post.data.thumbnail.alt} alt={post.data.thumbnail.alt}

View file

@ -11,9 +11,18 @@ interface Props {
scale?: string; scale?: string;
outcome?: string; outcome?: string;
links?: Link[]; links?: Link[];
headingId?: string;
} }
const { role, projectPeriod, stack = [], scale, outcome, links = [] } = Astro.props; const {
role,
projectPeriod,
stack = [],
scale,
outcome,
links = [],
headingId = 'at-a-glance-heading',
} = Astro.props;
const rows: Array<[string, string]> = [ const rows: Array<[string, string]> = [
['Role', role ?? ''], ['Role', role ?? ''],
@ -26,14 +35,14 @@ const rows: Array<[string, string]> = [
{ {
rows.length > 0 && ( rows.length > 0 && (
<aside class="at-a-glance" aria-labelledby="at-a-glance-heading"> <aside class="at-a-glance" aria-labelledby={headingId}>
<h2 id="at-a-glance-heading">At a Glance</h2> <h2 id={headingId}>At a Glance</h2>
<dl> <dl>
{rows.map(([label, value]) => ( {rows.map(([label, value]) => (
<> <div class="at-a-glance__row">
<dt>{label}</dt> <dt>{label}</dt>
<dd>{value}</dd> <dd>{value}</dd>
</> </div>
))} ))}
</dl> </dl>
{links.length > 0 && <ProjectLinks links={links} />} {links.length > 0 && <ProjectLinks links={links} />}

View file

@ -2,6 +2,8 @@
import type { ImageMetadata } from 'astro'; import type { ImageMetadata } from 'astro';
import { Picture } from 'astro:assets'; import { Picture } from 'astro:assets';
type FallbackFormat = 'jpg' | 'jpeg' | 'png' | 'webp' | 'avif' | 'gif';
interface Props { interface Props {
src: ImageMetadata; src: ImageMetadata;
alt: string; alt: string;
@ -11,6 +13,8 @@ interface Props {
sizes: string; sizes: string;
loading?: 'lazy' | 'eager'; loading?: 'lazy' | 'eager';
fetchpriority?: 'high' | 'low' | 'auto'; fetchpriority?: 'high' | 'low' | 'auto';
decorative?: boolean;
fallbackFormat?: FallbackFormat;
} }
const { const {
@ -22,22 +26,27 @@ const {
sizes, sizes,
loading = 'lazy', loading = 'lazy',
fetchpriority, fetchpriority,
decorative = true,
fallbackFormat,
} = Astro.props; } = Astro.props;
const Tag = href ? 'a' : 'div'; const Tag = href ? 'a' : 'div';
const resolvedFallback: FallbackFormat =
fallbackFormat ?? (src.format === 'png' ? 'png' : 'jpg');
const isDecorativeLink = Boolean(href) && decorative;
--- ---
<Tag <Tag
class:list={['entry-thumbnail', extraClass]} class:list={['entry-thumbnail', extraClass]}
href={href} href={href}
aria-hidden={href ? 'true' : undefined} aria-hidden={isDecorativeLink ? 'true' : undefined}
tabindex={href ? -1 : undefined} tabindex={isDecorativeLink ? -1 : undefined}
> >
<Picture <Picture
src={src} src={src}
alt={alt} alt={alt}
formats={['avif', 'webp']} formats={['avif', 'webp']}
fallbackFormat="jpg" fallbackFormat={resolvedFallback}
widths={widths} widths={widths}
sizes={sizes} sizes={sizes}
loading={loading} loading={loading}

View file

@ -2,37 +2,42 @@
import { navItems, site } from '../lib/site'; import { navItems, site } from '../lib/site';
const year = new Date().getFullYear(); const year = new Date().getFullYear();
// Local fallback: Agent 3 hasn't shipped `footerItems`/`footerOnly` yet, so we
// derive footer items locally. Footer mirrors Header (Home filtered out) and
// adds Tags + RSS.
const footerNavItems = [
...navItems.filter((item) => item.href !== '/'),
{ href: '/tags/', label: 'Tags' },
{ href: '/rss.xml', label: 'RSS' },
];
--- ---
<footer class="site-footer"> <footer class="site-footer">
<nav aria-label="Footer"> <nav aria-label="Footer">
<ul class="footer-links"> <ul class="footer-links">
{ {
navItems.map((item) => ( footerNavItems.map((item) => (
<li> <li>
<a href={item.href}>{item.label}</a> <a href={item.href}>{item.label}</a>
</li> </li>
)) ))
} }
<li>
<a href="/tags/">Tags</a>
</li>
<li>
<a href="/rss.xml">RSS</a>
</li>
</ul> </ul>
</nav> </nav>
<ul class="footer-meta"> <address>
<li><span>© {year} {site.name}</span></li> <ul class="footer-meta">
<li><a href={`mailto:${site.email}`}>Email</a></li> <li><span>© {year} {site.name}</span></li>
<li> <li><a href={`mailto:${site.email}`}>Email</a></li>
<a href={site.cv} rel="noopener">CV</a> <li>
</li> <a href={site.cv} rel="noopener noreferrer">CV</a>
<li> </li>
<a href={site.github} rel="noopener me">GitHub</a> <li>
</li> <a href={site.github} rel="noopener noreferrer me">GitHub</a>
<li> </li>
<a href={site.linkedin} rel="noopener me">LinkedIn</a> <li>
</li> <a href={site.linkedin} rel="noopener noreferrer me">LinkedIn</a>
</ul> </li>
</ul>
</address>
</footer> </footer>

View file

@ -7,6 +7,14 @@ function isCurrent(href: string) {
if (href === '/') return current === '/'; if (href === '/') return current === '/';
return current.startsWith(href); return current.startsWith(href);
} }
// Local fallback: Agent 3 hasn't shipped `footerItems`/`footerOnly` yet, so we
// derive header items locally. Header shows nav (minus Home) + Tags. RSS lives
// in the header as a dedicated icon link.
const headerNavItems = [
...navItems.filter((item) => item.href !== '/'),
{ href: '/tags/', label: 'Tags' },
];
--- ---
<a class="skip-link" href="#content">Skip to content</a> <a class="skip-link" href="#content">Skip to content</a>
@ -15,81 +23,99 @@ function isCurrent(href: string) {
<div class="header-actions"> <div class="header-actions">
<nav class="site-nav" aria-label="Primary"> <nav class="site-nav" aria-label="Primary">
{ {
navItems headerNavItems.map((item) => (
.filter((item) => item.href !== '/') <a href={item.href} aria-current={isCurrent(item.href) ? 'page' : undefined}>
.map((item) => ( {item.label}
<a href={item.href} aria-current={isCurrent(item.href) ? 'page' : undefined}> </a>
{item.label} ))
</a>
))
} }
</nav> </nav>
<a class="rss-link" href="/rss.xml" aria-label="RSS feed">
<svg
class="rss-icon"
viewBox="0 0 24 24"
width="18"
height="18"
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M6.18 17.82a2.18 2.18 0 1 1-4.36 0 2.18 2.18 0 0 1 4.36 0ZM2 9.86v3.13a8.97 8.97 0 0 1 9.01 9.01h3.13A12.1 12.1 0 0 0 2 9.86Zm0-5.86V7.1A14.92 14.92 0 0 1 16.9 22H20A17.9 17.9 0 0 0 2 4Z"
></path>
</svg>
<span class="sr-only">RSS feed</span>
</a>
<button <button
id="theme-switcher" id="theme-switcher"
class="theme-switcher" class="theme-switcher"
type="button" type="button"
aria-label="Toggle dark theme" aria-label="Switch to dark theme"
aria-pressed="false" aria-pressed="false"
> >
<span class="sr-only">Toggle theme</span>
</button> </button>
</div> </div>
</header> </header>
<script is:inline data-theme-script> <script is:inline data-theme-script>
(() => { (() => {
const key = 'theme'; var key = 'theme';
const legacyKey = 'dark-mode'; var legacyKey = 'dark-mode';
const switcher = document.getElementById('theme-switcher'); var switcher = document.getElementById('theme-switcher');
const media = matchMedia('(prefers-color-scheme: dark)');
const getStored = () => {
try {
const value = localStorage.getItem(key);
if (value === 'light' || value === 'dark') return value;
const legacyValue = localStorage.getItem(legacyKey);
if (legacyValue !== null) return JSON.parse(legacyValue) ? 'dark' : 'light';
} catch {
return null;
}
return null;
};
const getSystemTheme = () => (media.matches ? 'dark' : 'light');
const apply = (theme) => {
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
if (switcher) switcher.setAttribute('aria-pressed', String(theme === 'dark'));
};
apply(getStored() || getSystemTheme());
if (!switcher) return; if (!switcher) return;
const reduced = matchMedia('(prefers-reduced-motion: reduce)'); function syncSwitcher(theme) {
switcher.setAttribute('aria-pressed', String(theme === 'dark'));
switcher.setAttribute(
'aria-label',
theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'
);
}
const runApply = (theme) => { var initial = document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light';
syncSwitcher(initial);
var reduced = matchMedia('(prefers-reduced-motion: reduce)');
function apply(theme) {
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
syncSwitcher(theme);
}
function runApply(theme) {
if (!reduced.matches && typeof document.startViewTransition === 'function') { if (!reduced.matches && typeof document.startViewTransition === 'function') {
document.startViewTransition(() => apply(theme)); document.startViewTransition(function () {
apply(theme);
});
} else { } else {
apply(theme); apply(theme);
} }
}; }
switcher.addEventListener('click', () => { switcher.addEventListener('click', function () {
const current = switcher.getAttribute('aria-pressed') === 'true' ? 'dark' : 'light'; var currentTheme =
const next = current === 'dark' ? 'light' : 'dark'; switcher.getAttribute('aria-pressed') === 'true' ? 'dark' : 'light';
var next = currentTheme === 'dark' ? 'light' : 'dark';
try { try {
localStorage.setItem(key, next); localStorage.setItem(key, next);
localStorage.setItem(legacyKey, JSON.stringify(next === 'dark')); localStorage.setItem(legacyKey, JSON.stringify(next === 'dark'));
} catch { } catch (e) {}
// The switch still applies for the current page when storage is unavailable.
}
runApply(next); runApply(next);
}); });
media.addEventListener('change', () => {
if (!getStored()) apply(getSystemTheme());
});
})(); })();
</script> </script>
<style>
.rss-link {
display: inline-flex;
align-items: center;
justify-content: center;
color: inherit;
line-height: 0;
}
.rss-icon {
display: block;
}
</style>

View file

@ -9,37 +9,77 @@ interface Props {
} }
const { items } = Astro.props; const { items } = Astro.props;
const fallbackFormatFor = (format: string | undefined): 'png' | 'jpg' =>
format === 'png' ? 'png' : 'jpg';
--- ---
{ {
items.map((item) => ( items.length > 1 ? (
<figure class:list={['post-media', item.role === 'inline' && 'figure-inline']}> <ul role="list" class="post-gallery">
{item.type === 'video' ? ( {items.map((item) => (
<video <li>
controls <figure class:list={['post-media', item.role === 'inline' && 'figure-inline']}>
preload="metadata" {item.type === 'video' ? (
poster={item.poster?.src} <video
aria-label={item.decorative ? undefined : item.alt} controls
> preload="metadata"
{item.webm && <source src={item.webm} type="video/webm" />} poster={item.poster?.src}
{item.mp4 && <source src={item.mp4} type="video/mp4" />} aria-label={item.decorative ? 'Decorative video' : item.alt}
</video> >
) : ( {item.webm && <source src={item.webm} type="video/webm" />}
item.src && ( {item.mp4 && <source src={item.mp4} type="video/mp4" />}
<Picture </video>
src={item.src} ) : (
alt={item.decorative ? '' : (item.alt ?? '')} item.src && (
formats={['avif', 'webp']} <Picture
fallbackFormat="jpg" src={item.src}
widths={[480, 720, 960, 1280, 1600, 1920]} alt={item.decorative ? '' : (item.alt ?? '')}
sizes="(max-width: 700px) calc(100vw - 3rem), (max-width: 1100px) calc(100vw - 4rem), 56rem" formats={['avif', 'webp']}
loading="lazy" fallbackFormat={fallbackFormatFor(item.src.format)}
decoding="async" widths={[480, 720, 960, 1280, 1600, 1920]}
/> sizes="(max-width: 700px) calc(100vw - 2 * clamp(20px, 4vw, 32px)), (max-width: 1100px) min(calc(100vw - 4rem), 56rem), 56rem"
) loading="lazy"
)} decoding="async"
{item.caption && <figcaption>{item.caption}</figcaption>} />
{item.transcript && <p class="media-transcript">{item.transcript}</p>} )
</figure> )}
)) {item.caption && <figcaption>{item.caption}</figcaption>}
{item.transcript && <p class="media-transcript">{item.transcript}</p>}
</figure>
</li>
))}
</ul>
) : (
items.map((item) => (
<figure class:list={['post-media', item.role === 'inline' && 'figure-inline']}>
{item.type === 'video' ? (
<video
controls
preload="metadata"
poster={item.poster?.src}
aria-label={item.decorative ? 'Decorative video' : item.alt}
>
{item.webm && <source src={item.webm} type="video/webm" />}
{item.mp4 && <source src={item.mp4} type="video/mp4" />}
</video>
) : (
item.src && (
<Picture
src={item.src}
alt={item.decorative ? '' : (item.alt ?? '')}
formats={['avif', 'webp']}
fallbackFormat={fallbackFormatFor(item.src.format)}
widths={[480, 720, 960, 1280, 1600, 1920]}
sizes="(max-width: 700px) calc(100vw - 2 * clamp(20px, 4vw, 32px)), (max-width: 1100px) min(calc(100vw - 4rem), 56rem), 56rem"
loading="lazy"
decoding="async"
/>
)
)}
{item.caption && <figcaption>{item.caption}</figcaption>}
{item.transcript && <p class="media-transcript">{item.transcript}</p>}
</figure>
))
)
} }

View file

@ -22,9 +22,29 @@ function isExternal(url: string) {
<a <a
href={link.url} href={link.url}
download={link.download ? '' : undefined} download={link.download ? '' : undefined}
rel={isExternal(link.url) ? 'noopener' : undefined} rel={isExternal(link.url) ? 'noopener noreferrer' : undefined}
target={isExternal(link.url) ? '_blank' : undefined}
> >
{link.label} {link.label}
{isExternal(link.url) && (
<svg
class="external-link-icon"
xmlns="http://www.w3.org/2000/svg"
width="0.85em"
height="0.85em"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
)}
{link.download && ( {link.download && (
<span class="download-indicator" aria-hidden="true"> <span class="download-indicator" aria-hidden="true">

View file

@ -1,5 +1,6 @@
--- ---
import type { CollectionEntry } from 'astro:content'; import type { CollectionEntry } from 'astro:content';
import { getEntry } from 'astro:content';
import EntryThumbnail from './EntryThumbnail.astro'; import EntryThumbnail from './EntryThumbnail.astro';
import ProjectLinks from './ProjectLinks.astro'; import ProjectLinks from './ProjectLinks.astro';
import { articlePath, projectAnchor } from '../lib/site'; import { articlePath, projectAnchor } from '../lib/site';
@ -10,6 +11,42 @@ interface Props {
const { projects } = Astro.props; const { projects } = Astro.props;
type ProjectLink = CollectionEntry<'projects'>['data']['links'][number]; type ProjectLink = CollectionEntry<'projects'>['data']['links'][number];
async function resolveEssayHref(
essay: CollectionEntry<'projects'>['data']['essay']
): Promise<string | undefined> {
if (!essay) return undefined;
// Defensively handle both `string` (legacy) and resolved-entry / reference shapes.
if (typeof essay === 'string') {
return articlePath(essay);
}
if (typeof essay === 'object') {
const ref = essay as {
collection?: string;
id?: string;
slug?: string;
data?: unknown;
};
// Already a resolved CollectionEntry (has `data`)
if (ref.data && ref.id) {
return articlePath({ id: ref.id });
}
// A reference: { collection, id } — resolve via getEntry
if (ref.collection && ref.id) {
const resolved = await getEntry(ref.collection as 'posts', ref.id);
if (resolved) return articlePath(resolved);
return articlePath(ref.id);
}
if (ref.id) return articlePath(ref.id);
}
return undefined;
}
const essayHrefs = new Map<string, string>();
for (const project of projects) {
const href = await resolveEssayHref(project.data.essay);
if (href) essayHrefs.set(project.id, href);
}
--- ---
<ol class="project-list"> <ol class="project-list">
@ -17,7 +54,7 @@ type ProjectLink = CollectionEntry<'projects'>['data']['links'][number];
projects.map((project) => { projects.map((project) => {
const anchor = projectAnchor(project); const anchor = projectAnchor(project);
const titleId = `${anchor}-title`; const titleId = `${anchor}-title`;
const essayHref = project.data.essay ? articlePath(project.data.essay) : undefined; const essayHref = essayHrefs.get(project.id);
const essayLink: ProjectLink | undefined = essayHref const essayLink: ProjectLink | undefined = essayHref
? { label: 'Article', type: 'site', url: essayHref } ? { label: 'Article', type: 'site', url: essayHref }
: undefined; : undefined;
@ -37,7 +74,7 @@ type ProjectLink = CollectionEntry<'projects'>['data']['links'][number];
widths={[240, 320, 480, 640, 800]} widths={[240, 320, 480, 640, 800]}
sizes="(max-width: 700px) 7rem, (max-width: 960px) clamp(7rem, 18vw, 9.5rem), 19rem" sizes="(max-width: 700px) 7rem, (max-width: 960px) clamp(7rem, 18vw, 9.5rem), 19rem"
/> />
<div class="project-card__summary"> <article class="project-card__summary">
<h3 id={titleId}> <h3 id={titleId}>
{primaryHref ? ( {primaryHref ? (
<a href={primaryHref}>{project.data.title}</a> <a href={primaryHref}>{project.data.title}</a>
@ -51,7 +88,7 @@ type ProjectLink = CollectionEntry<'projects'>['data']['links'][number];
{project.data.period} · {project.data.technologies.join(', ')} {project.data.period} · {project.data.technologies.join(', ')}
</p> </p>
{links.length > 0 && <ProjectLinks links={links} />} {links.length > 0 && <ProjectLinks links={links} />}
</div> </article>
</li> </li>
); );
}) })

View file

@ -5,19 +5,37 @@ interface Props {
tags: readonly string[]; tags: readonly string[];
currentTag?: string; currentTag?: string;
labelled?: boolean; labelled?: boolean;
limit?: number;
counts?: Record<string, number>;
} }
const { tags, currentTag, labelled = true } = Astro.props; const { tags, currentTag, limit, counts } = Astro.props;
const visibleTags = typeof limit === 'number' ? tags.slice(0, limit) : tags;
const remaining =
typeof limit === 'number' && tags.length > limit ? tags.length - limit : 0;
--- ---
<ul class="tag-list" aria-label={labelled ? 'Tags' : undefined}> <ul class="tag-list">
{ {
tags.map((tag) => ( visibleTags.map((tag) => (
<li> <li>
<a href={tagPath(tag)} aria-current={tag === currentTag ? 'page' : undefined}> <a href={tagPath(tag)} aria-current={tag === currentTag ? 'page' : undefined}>
{tag} {tag}
{counts && counts[tag] !== undefined && (
<span class="tag-count">{counts[tag]}</span>
)}
</a> </a>
</li> </li>
)) ))
} }
{
remaining > 0 && (
<li>
<a href="/tags/" class="tag-more">
+{remaining} more
</a>
</li>
)
}
</ul> </ul>

View file

@ -1,4 +1,4 @@
import { defineCollection } from 'astro:content'; import { defineCollection, reference } from 'astro:content';
import type { SchemaContext } from 'astro:content'; import type { SchemaContext } from 'astro:content';
import { glob } from 'astro/loaders'; import { glob } from 'astro/loaders';
import { z } from 'astro/zod'; import { z } from 'astro/zod';
@ -92,8 +92,7 @@ const projects = defineCollection({
status: z.string().optional(), status: z.string().optional(),
technologies: z.array(z.string()).default([]), technologies: z.array(z.string()).default([]),
selected: z.boolean().default(false), selected: z.boolean().default(false),
essay: z.string().optional(), essay: reference('posts').optional(),
legacyAnchor: z.string().optional(),
links: z.array(linkSchema).default([]), links: z.array(linkSchema).default([]),
}), }),
}); });

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 127 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

After

Width:  |  Height:  |  Size: 243 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

View file

@ -4,7 +4,7 @@ description: How decla.red used shared TypeScript game logic, WebSockets, client
date: 2026-05-07 date: 2026-05-07
projectPeriod: 'Autumn-Winter 2020' projectPeriod: 'Autumn-Winter 2020'
thumbnail: thumbnail:
src: ./_assets/decla-red.png src: ./_assets/decla-red.jpg
alt: The decla.red browser game interface showing a space scene. alt: The decla.red browser game interface showing a space scene.
tags: ['games', 'web', 'systems'] tags: ['games', 'web', 'systems']
selected: true selected: true
@ -28,7 +28,7 @@ links:
download: true download: true
media: media:
- type: image - type: image
src: ./_assets/decla-red.png src: ./_assets/decla-red.jpg
alt: The decla.red browser game interface showing a space scene with team controls and planets. alt: The decla.red browser game interface showing a space scene with team controls and planets.
caption: decla.red used the SDF-2D renderer in a real-time multiplayer game. caption: decla.red used the SDF-2D renderer in a real-time multiplayer game.
--- ---

View file

@ -4,7 +4,7 @@ description: How a multi-device life tracking project used trie structure to dif
date: 2026-05-05 date: 2026-05-05
projectPeriod: 'August-September 2019' projectPeriod: 'August-September 2019'
thumbnail: thumbnail:
src: ./_assets/towers.png src: ./_assets/towers.jpg
alt: Life Towers goal tracking interface with tower-like visual structures. alt: Life Towers goal tracking interface with tower-like visual structures.
tags: ['systems', 'web', 'tools'] tags: ['systems', 'web', 'tools']
selected: true selected: true
@ -24,7 +24,7 @@ links:
url: https://towers.schmelczer.dev url: https://towers.schmelczer.dev
media: media:
- type: image - type: image
src: ./_assets/towers.png src: ./_assets/towers.jpg
alt: Screenshot of a life tracking web interface represented with tower-like visual structures. alt: Screenshot of a life tracking web interface represented with tower-like visual structures.
caption: The visual idea was simple; the useful lesson was the synchronization model behind it. caption: The visual idea was simple; the useful lesson was the synchronization model behind it.
--- ---

View file

@ -4,7 +4,7 @@ description: 'My first proper project: a 3D game with random maps, destructible
date: 2026-04-28 date: 2026-04-28
projectPeriod: 'Autumn 2017' projectPeriod: 'Autumn 2017'
thumbnail: thumbnail:
src: ./_assets/platform-game.png src: ./_assets/platform-game.jpg
alt: Screenshot from a 3D platform game written in C. alt: Screenshot from a 3D platform game written in C.
tags: ['games', 'systems'] tags: ['games', 'systems']
selected: false selected: false

View file

@ -4,7 +4,7 @@ description: How SDF-2D used signed distance fields, dynamic shaders, and tile-b
date: 2026-05-08 date: 2026-05-08
projectPeriod: 'Autumn-Winter 2020' projectPeriod: 'Autumn-Winter 2020'
thumbnail: thumbnail:
src: ./_assets/sdf2d.png src: ./_assets/sdf2d.jpg
alt: SDF-2D browser demo with soft lighting effects. alt: SDF-2D browser demo with soft lighting effects.
tags: ['graphics', 'web', 'systems'] tags: ['graphics', 'web', 'systems']
selected: true selected: true
@ -31,7 +31,7 @@ links:
download: true download: true
media: media:
- type: image - type: image
src: ./_assets/sdf2d.png src: ./_assets/sdf2d.jpg
alt: Browser demo page showing SDF-2D scenes rendered with soft lighting effects. alt: Browser demo page showing SDF-2D scenes rendered with soft lighting effects.
caption: SDF-2D was built as a reusable TypeScript library rather than a single demo. caption: SDF-2D was built as a reusable TypeScript library rather than a single demo.
--- ---

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 127 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

After

Width:  |  Height:  |  Size: 243 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

View file

@ -3,7 +3,7 @@ sourceProjectId: declared
title: decla.red title: decla.red
description: A team-based mobile multiplayer browser game with shared client/server game logic. description: A team-based mobile multiplayer browser game with shared client/server game logic.
thumbnail: thumbnail:
src: ./_assets/declared.png src: ./_assets/declared.jpg
alt: The decla.red browser game interface showing a space scene. alt: The decla.red browser game interface showing a space scene.
period: 'Autumn-Winter 2020' period: 'Autumn-Winter 2020'
sortDate: 2020-11-01 sortDate: 2020-11-01

View file

@ -3,7 +3,7 @@ sourceProjectId: platform-game
title: Platform Game title: Platform Game
description: An early 3D game in C with SDL 1.2, random maps, destructible voxels, enemies, powerups, and time slowdown. description: An early 3D game in C with SDL 1.2, random maps, destructible voxels, enemies, powerups, and time slowdown.
thumbnail: thumbnail:
src: ./_assets/platform-game.png src: ./_assets/platform-game.jpg
alt: Screenshot from an early 3D platform game. alt: Screenshot from an early 3D platform game.
period: 'Autumn 2017' period: 'Autumn 2017'
sortDate: 2017-10-01 sortDate: 2017-10-01

View file

@ -3,7 +3,7 @@ sourceProjectId: sdf2d
title: SDF-2D title: SDF-2D
description: A browser rendering library for optimized 2D ray tracing with signed distance fields. description: A browser rendering library for optimized 2D ray tracing with signed distance fields.
thumbnail: thumbnail:
src: ./_assets/sdf2d.png src: ./_assets/sdf2d.jpg
alt: SDF-2D browser demo with soft lighting effects. alt: SDF-2D browser demo with soft lighting effects.
period: 'Autumn-Winter 2020' period: 'Autumn-Winter 2020'
sortDate: 2020-12-01 sortDate: 2020-12-01

View file

@ -3,7 +3,7 @@ sourceProjectId: towers
title: Life Towers title: Life Towers
description: A multi-device goal tracker where the lasting idea was syncing state with immutable tries. description: A multi-device goal tracker where the lasting idea was syncing state with immutable tries.
thumbnail: thumbnail:
src: ./_assets/towers.png src: ./_assets/towers.jpg
alt: Life Towers goal tracking interface with tower-like visual structures. alt: Life Towers goal tracking interface with tower-like visual structures.
period: 'August-September 2019' period: 'August-September 2019'
sortDate: 2019-09-01 sortDate: 2019-09-01

View file

@ -1,9 +1,9 @@
--- ---
import { getImage } from 'astro:assets';
import Footer from '../components/Footer.astro'; import Footer from '../components/Footer.astro';
import Header from '../components/Header.astro'; import Header from '../components/Header.astro';
import { absoluteUrl, site } from '../lib/site'; import { absoluteUrl, optimizeOgImage, site } from '../lib/site';
import defaultOg from '../assets/og-default.jpg'; import defaultOg from '../assets/og-default.jpg';
import themeInit from '../scripts/theme-init.ts?raw';
import '../styles/global.css'; import '../styles/global.css';
interface ArticleMeta { interface ArticleMeta {
@ -32,7 +32,7 @@ const {
description = site.description, description = site.description,
canonicalPath = Astro.url.pathname, canonicalPath = Astro.url.pathname,
ogImage, ogImage,
ogImageAlt = site.description, ogImageAlt = "Andras Schmelczer's personal site",
ogImageWidth, ogImageWidth,
ogImageHeight, ogImageHeight,
ogType = 'website', ogType = 'website',
@ -52,12 +52,7 @@ let resolvedOgWidth = ogImageWidth;
let resolvedOgHeight = ogImageHeight; let resolvedOgHeight = ogImageHeight;
if (!resolvedOgImage) { if (!resolvedOgImage) {
const generated = await getImage({ const generated = await optimizeOgImage(defaultOg);
src: defaultOg,
width: 1200,
height: 630,
format: 'jpg',
});
resolvedOgImage = generated.src; resolvedOgImage = generated.src;
resolvedOgWidth = 1200; resolvedOgWidth = 1200;
resolvedOgHeight = 630; resolvedOgHeight = 630;
@ -86,30 +81,7 @@ const jsonLdEntries = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
{noindex && <meta name="robots" content="noindex,follow" />} {noindex && <meta name="robots" content="noindex,follow" />}
<link rel="canonical" href={canonical} /> <link rel="canonical" href={canonical} />
<script is:inline data-theme-script> <script is:inline data-theme-script set:html={themeInit} />
(() => {
const key = 'theme';
const legacyKey = 'dark-mode';
let saved = null;
try {
const value = localStorage.getItem(key);
if (value === 'light' || value === 'dark') {
saved = value;
} else {
const legacyValue = localStorage.getItem(legacyKey);
if (legacyValue !== null) {
saved = JSON.parse(legacyValue) ? 'dark' : 'light';
}
}
} catch {
saved = null;
}
const systemDark = matchMedia('(prefers-color-scheme: dark)').matches;
const theme = saved || (systemDark ? 'dark' : 'light');
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
})();
</script>
<link <link
rel="preload" rel="preload"
@ -160,7 +132,7 @@ const jsonLdEntries = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
) )
} }
<meta property="og:type" content={ogType} /> <meta property="og:type" content={ogType} />
<meta property="og:locale" content="en_US" /> <meta property="og:locale" content="en" />
{ {
article && ( article && (

View file

@ -1,25 +1,18 @@
--- ---
import type { ComponentProps } from 'astro/types';
import Base from './Base.astro'; import Base from './Base.astro';
interface Props { type Props = ComponentProps<typeof Base>;
title: string;
description: string;
canonicalPath?: string;
ogImage?: string;
ogType?: 'website' | 'article' | 'profile';
noindex?: boolean;
jsonLd?: Record<string, unknown> | Array<Record<string, unknown>>;
}
const { title, description } = Astro.props; const { title, description } = Astro.props;
--- ---
<Base {...Astro.props}> <Base {...Astro.props}>
<section class="page-shell"> <div class="page-shell">
<header class="page-header"> <header class="page-header">
<h1>{title}</h1> <h1>{title}</h1>
<p>{description}</p> <p>{description}</p>
</header> </header>
<slot /> <slot />
</section> </div>
</Base> </Base>

View file

@ -1,7 +1,7 @@
--- ---
import type { CollectionEntry } from 'astro:content'; import type { CollectionEntry } from 'astro:content';
import { render } from 'astro:content'; import { render } from 'astro:content';
import { Picture, getImage } from 'astro:assets'; import { Picture } from 'astro:assets';
import ArticleList from '../components/ArticleList.astro'; import ArticleList from '../components/ArticleList.astro';
import AtAGlance from '../components/AtAGlance.astro'; import AtAGlance from '../components/AtAGlance.astro';
import Breadcrumbs from '../components/Breadcrumbs.astro'; import Breadcrumbs from '../components/Breadcrumbs.astro';
@ -11,10 +11,11 @@ import {
absoluteUrl, absoluteUrl,
adjacentPosts, adjacentPosts,
articlePath, articlePath,
buildBreadcrumbTrail,
formatDate, formatDate,
getPublishedPosts, getPublishedPosts,
getRelatedPosts, getRelatedPosts,
site, optimizeOgImage,
} from '../lib/site'; } from '../lib/site';
import Base from './Base.astro'; import Base from './Base.astro';
@ -23,24 +24,29 @@ interface Props {
} }
const { post } = Astro.props; const { post } = Astro.props;
const { Content } = await render(post); const { Content, headings } = await render(post);
const allPosts = await getPublishedPosts(); const allPosts = await getPublishedPosts();
const { previous, next } = adjacentPosts(allPosts, post); const { previous, next } = adjacentPosts(allPosts, post);
const related = getRelatedPosts(allPosts, post, 3); const related = getRelatedPosts(allPosts, post, 3);
const ogImageOptimized = await getImage({ const ogImageOptimized = await optimizeOgImage(post.data.thumbnail.src);
src: post.data.thumbnail.src,
width: 1200,
height: 630,
format: 'jpg',
});
const breadcrumbTrail = [ const trail = buildBreadcrumbTrail({ post });
{ href: '/', label: 'Home' }, const breadcrumbTrail = trail.map((c, i) => ({
{ href: '/articles/', label: 'Articles' }, label: c.name,
{ label: post.data.title }, href: i === trail.length - 1 ? undefined : c.href,
]; }));
// Reading time: words in body / 200 wpm, rounded up.
const wordCount = post.body ? post.body.trim().split(/\s+/).filter(Boolean).length : 0;
const readingMinutes = Math.max(1, Math.ceil(wordCount / 200));
// TOC: only show when there are >= 3 h2 headings.
const h2Headings = headings.filter((h) => h.depth === 2);
const showToc = h2Headings.length >= 3;
const personId = absoluteUrl('/about/#person');
const blogPosting = { const blogPosting = {
'@context': 'https://schema.org', '@context': 'https://schema.org',
@ -49,16 +55,8 @@ const blogPosting = {
description: post.data.description, description: post.data.description,
datePublished: post.data.date.toISOString(), datePublished: post.data.date.toISOString(),
...(post.data.updated && { dateModified: post.data.updated.toISOString() }), ...(post.data.updated && { dateModified: post.data.updated.toISOString() }),
author: { author: { '@id': personId },
'@type': 'Person', publisher: { '@id': personId },
name: site.name,
url: absoluteUrl('/about/'),
},
publisher: {
'@type': 'Person',
name: site.name,
url: site.url,
},
image: absoluteUrl(ogImageOptimized.src), image: absoluteUrl(ogImageOptimized.src),
url: absoluteUrl(articlePath(post)), url: absoluteUrl(articlePath(post)),
keywords: post.data.tags.join(', '), keywords: post.data.tags.join(', '),
@ -71,21 +69,12 @@ const blogPosting = {
const breadcrumbJsonLd = { const breadcrumbJsonLd = {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'BreadcrumbList', '@type': 'BreadcrumbList',
itemListElement: [ itemListElement: trail.map((c, i) => ({
{ '@type': 'ListItem', position: 1, name: 'Home', item: absoluteUrl('/') }, '@type': 'ListItem',
{ position: i + 1,
'@type': 'ListItem', name: c.name,
position: 2, item: absoluteUrl(c.href),
name: 'Articles', })),
item: absoluteUrl('/articles/'),
},
{
'@type': 'ListItem',
position: 3,
name: post.data.title,
item: absoluteUrl(articlePath(post)),
},
],
}; };
--- ---
@ -112,7 +101,7 @@ const breadcrumbJsonLd = {
<p class="eyebrow">{post.data.projectPeriod ?? 'Article'}</p> <p class="eyebrow">{post.data.projectPeriod ?? 'Article'}</p>
<h1>{post.data.title}</h1> <h1>{post.data.title}</h1>
<p class="dek">{post.data.description}</p> <p class="dek">{post.data.description}</p>
<p class="post-meta"> <div class="post-meta">
<time datetime={post.data.date.toISOString()}> <time datetime={post.data.date.toISOString()}>
{formatDate(post.data.date)} {formatDate(post.data.date)}
</time> </time>
@ -129,7 +118,9 @@ const breadcrumbJsonLd = {
</> </>
) )
} }
</p> {' · '}
<span>{readingMinutes} min read</span>
</div>
<TagList tags={post.data.tags} /> <TagList tags={post.data.tags} />
</header> </header>
@ -147,11 +138,8 @@ const breadcrumbJsonLd = {
/> />
</figure> </figure>
<div class="prose">
<Content />
</div>
<AtAGlance <AtAGlance
headingId={`at-a-glance-${post.id}`}
role={post.data.role} role={post.data.role}
projectPeriod={post.data.projectPeriod} projectPeriod={post.data.projectPeriod}
stack={post.data.stack} stack={post.data.stack}
@ -160,6 +148,24 @@ const breadcrumbJsonLd = {
links={post.data.links} links={post.data.links}
/> />
{
showToc && (
<nav class="post-toc" aria-label="On this page">
<ol>
{h2Headings.map((heading) => (
<li>
<a href={`#${heading.slug}`}>{heading.text}</a>
</li>
))}
</ol>
</nav>
)
}
<div class="prose">
<Content />
</div>
<PostMedia items={post.data.media} /> <PostMedia items={post.data.media} />
{ {
@ -174,22 +180,28 @@ const breadcrumbJsonLd = {
{ {
(previous || next) && ( (previous || next) && (
<nav class="post-nav" aria-label="Adjacent articles"> <nav class="post-nav" aria-label="Adjacent articles">
{previous && ( <ul class="post-nav__list">
<a class="previous" href={articlePath(previous)} rel="prev"> {previous && (
<span class="post-nav__label"> <li class="post-nav__prev">
<span aria-hidden="true">←</span> Previous <a class="previous" href={articlePath(previous)} rel="prev">
</span> <span class="post-nav__label">
<span class="post-nav__title">{previous.data.title}</span> <span aria-hidden="true">←</span> Previous
</a> </span>
)} <span class="post-nav__title">{previous.data.title}</span>
{next && ( </a>
<a class="next" href={articlePath(next)} rel="next"> </li>
<span class="post-nav__label"> )}
Next <span aria-hidden="true">→</span> {next && (
</span> <li class="post-nav__next">
<span class="post-nav__title">{next.data.title}</span> <a class="next" href={articlePath(next)} rel="next">
</a> <span class="post-nav__label">
)} Next <span aria-hidden="true">→</span>
</span>
<span class="post-nav__title">{next.data.title}</span>
</a>
</li>
)}
</ul>
</nav> </nav>
) )
} }

View file

@ -1,9 +1,11 @@
import type { CollectionEntry } from 'astro:content'; import type { CollectionEntry } from 'astro:content';
import { getCollection } from 'astro:content'; import { getCollection } from 'astro:content';
import { getImage } from 'astro:assets';
import type { ImageMetadata } from 'astro';
export const site = { export const site = {
name: 'Andras Schmelczer', name: 'Andras Schmelczer',
title: 'Andras Schmelczer — Software systems, AI, graphics, simulations, tools', title: 'Andras Schmelczer — Software engineer',
description: description:
'Andras Schmelczer writes about software systems, AI deployment, graphics, simulations, and tools.', 'Andras Schmelczer writes about software systems, AI deployment, graphics, simulations, and tools.',
url: 'https://schmelczer.dev', url: 'https://schmelczer.dev',
@ -13,12 +15,22 @@ export const site = {
cv: '/media/downloads/cv-andras-schmelczer.pdf', 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 = [ export const navItems = [
{ href: '/', label: 'Home' }, { href: '/', label: 'Home' },
{ href: '/articles/', label: 'Articles' }, { href: '/articles/', label: 'Articles' },
{ href: '/projects/', label: 'Projects' }, { href: '/projects/', label: 'Projects' },
{ href: '/about/', label: 'About' }, { href: '/about/', label: 'About' },
] as const; { 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) { export function formatDate(date: Date) {
return new Intl.DateTimeFormat('en', { return new Intl.DateTimeFormat('en', {
@ -59,8 +71,11 @@ export function tagPath(tag: string) {
return `/tags/${tagSlug(tag)}/`; return `/tags/${tagSlug(tag)}/`;
} }
export function projectAnchor(project: CollectionEntry<'projects'>) { // Anchor used for `id="..."` on project cards and `#fragment` deep links.
return project.data.legacyAnchor ?? project.data.sourceProjectId; // 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[] } }[]) { export function getAllTags(posts: { data: { tags: readonly string[] } }[]) {
@ -69,10 +84,20 @@ export function getAllTags(posts: { data: { tags: readonly string[] } }[]) {
); );
} }
export async function getPublishedPosts(): Promise<CollectionEntry<'posts'>[]> { // Memoized published-posts loader. Build steps call `getPublishedPosts()`
return (await getCollection('posts')) // from many pages (index, articles, RSS, sitemap, tag pages, post layouts).
.filter((post) => !post.data.draft) // Caching the promise means `getCollection('posts')` runs once per build.
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf()); 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'>[]> { export async function getProjects(): Promise<CollectionEntry<'projects'>[]> {
@ -114,3 +139,65 @@ export function getRelatedPosts(
export function absoluteUrl(path: string) { export function absoluteUrl(path: string) {
return new URL(path, site.url).toString(); 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,
};
}
// 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;
}

View file

@ -13,17 +13,15 @@ const recent = posts.slice(0, 5);
noindex noindex
> >
<div class="empty-state"> <div class="empty-state">
<div class="prose"> <p>
<p> Try the <a href="/articles/">articles archive</a>, the
Try the <a href="/articles/">articles archive</a>, the <a href="/projects/">project index</a>, the
<a href="/projects/">project index</a>, the <a href="/tags/">tag index</a>, or head back to the
<a href="/tags/">tag index</a>, or head back to the <a href="/">homepage</a>.
<a href="/">homepage</a>. </p>
</p>
</div>
</div> </div>
<section class="home-section" aria-labelledby="404-recent"> <section class="home-section">
<div class="section-heading"> <div class="section-heading">
<h2 id="404-recent">Recent articles</h2> <h2 id="404-recent">Recent articles</h2>
<a href="/articles/">All articles →</a> <a href="/articles/">All articles →</a>

View file

@ -1,7 +1,7 @@
--- ---
import ArticleList from '../components/ArticleList.astro'; import ArticleList from '../components/ArticleList.astro';
import Page from '../layouts/Page.astro'; import Page from '../layouts/Page.astro';
import { getPublishedPosts, site } from '../lib/site'; import { absoluteUrl, buildPersonJsonLd, getPublishedPosts, site } from '../lib/site';
const posts = await getPublishedPosts(); const posts = await getPublishedPosts();
const startingPoints = posts const startingPoints = posts
@ -9,17 +9,22 @@ const startingPoints = posts
.sort((a, b) => (a.data.featuredOrder ?? 99) - (b.data.featuredOrder ?? 99)) .sort((a, b) => (a.data.featuredOrder ?? 99) - (b.data.featuredOrder ?? 99))
.slice(0, 4); .slice(0, 4);
const personJsonLd = { // Canonical Person JSON-LD. Other pages reference this entity by @id.
'@context': 'https://schema.org', const personJsonLd = buildPersonJsonLd({
'@type': 'Person',
name: site.name,
url: site.url,
email: `mailto:${site.email}`,
sameAs: [site.github, site.linkedin],
jobTitle: 'Software Engineer', jobTitle: 'Software Engineer',
description: description:
'Software engineer with an MSc in Computer Science working on AI/ML systems, web platforms, graphics, simulations, and tools.', '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('/og-image.jpg'),
mainEntityOfPage: absoluteUrl('/about/'),
});
--- ---
<Page <Page
@ -45,25 +50,37 @@ const personJsonLd = {
</p> </p>
</div> </div>
<section class="about-section facts" aria-labelledby="quick-facts"> <section class="about-section facts">
<h2 id="quick-facts">Quick Facts</h2> <h2 id="quick-facts">Quick Facts</h2>
<dl> <address>
<dt>Focus</dt> <dl>
<dd>Software systems, AI deployment, architecture, graphics, data visualization</dd> <div>
<dt>Education</dt> <dt>Focus</dt>
<dd>MSc in Computer Science</dd> <dd>
<dt>Contact</dt> Software systems, AI deployment, architecture, graphics, data visualization
<dd><a href={`mailto:${site.email}`}>{site.email}</a></dd> </dd>
<dt>Links</dt> </div>
<dd> <div>
<a href={site.cv} rel="noopener">CV</a>, <dt>Education</dt>
<a href={site.github} rel="noopener me">GitHub</a>, <dd>MSc in Computer Science</dd>
<a href={site.linkedin} rel="noopener me">LinkedIn</a> </div>
</dd> <div>
</dl> <dt>Contact</dt>
<dd><a href={`mailto:${site.email}`}>{site.email}</a></dd>
</div>
<div>
<dt>Links</dt>
<dd>
<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>
</address>
</section> </section>
<section class="about-section" aria-labelledby="best-starting-points"> <section class="about-section">
<div class="section-heading"> <div class="section-heading">
<h2 id="best-starting-points">Best Starting Points</h2> <h2 id="best-starting-points">Best Starting Points</h2>
<a href="/articles/">Browse all articles →</a> <a href="/articles/">Browse all articles →</a>
@ -71,7 +88,7 @@ const personJsonLd = {
<ArticleList posts={startingPoints} /> <ArticleList posts={startingPoints} />
</section> </section>
<section class="about-section facts" aria-labelledby="working-style"> <section class="about-section facts">
<h2 id="working-style">How I Work</h2> <h2 id="working-style">How I Work</h2>
<div class="prose"> <div class="prose">
<p> <p>

View file

@ -5,6 +5,7 @@ import Page from '../../layouts/Page.astro';
import { import {
absoluteUrl, absoluteUrl,
articlePath, articlePath,
buildBreadcrumbTrail,
getAllTags, getAllTags,
getPublishedPosts, getPublishedPosts,
site, site,
@ -30,12 +31,26 @@ const blogJsonLd = {
url: absoluteUrl(articlePath(post)), url: absoluteUrl(articlePath(post)),
})), })),
}; };
const breadcrumbTrail = buildBreadcrumbTrail({ articles: true });
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: breadcrumbTrail.map((crumb, index) => ({
'@type': 'ListItem',
position: index + 1,
name: crumb.name,
item: absoluteUrl(crumb.href),
})),
};
const jsonLd = [blogJsonLd, breadcrumbJsonLd];
--- ---
<Page <Page
title="Articles" title="Articles"
description="Technical articles and notes about projects, systems, AI deployment, graphics, simulations, and tools." description="Technical articles and notes about projects, systems, AI deployment, graphics, simulations, and tools."
jsonLd={blogJsonLd} jsonLd={jsonLd}
> >
<nav id="tags" class="tag-filter" aria-label="Browse by tag"> <nav id="tags" class="tag-filter" aria-label="Browse by tag">
<span>Browse by tag</span> <span>Browse by tag</span>
@ -46,7 +61,7 @@ const blogJsonLd = {
years.map((year) => { years.map((year) => {
const postsForYear = posts.filter((post) => yearOf(post.data.date) === year); const postsForYear = posts.filter((post) => yearOf(post.data.date) === year);
return ( return (
<section class="archive-year" aria-labelledby={`year-${year}`}> <section class="archive-year">
<h2 id={`year-${year}`}>{year}</h2> <h2 id={`year-${year}`}>{year}</h2>
<ArticleList posts={postsForYear} showYear={false} /> <ArticleList posts={postsForYear} showYear={false} />
</section> </section>

View file

@ -3,7 +3,12 @@ import ArticleList from '../components/ArticleList.astro';
import ProjectList from '../components/ProjectList.astro'; import ProjectList from '../components/ProjectList.astro';
import TagList from '../components/TagList.astro'; import TagList from '../components/TagList.astro';
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
import { getAllTags, getProjects, getPublishedPosts, site } from '../lib/site'; import {
buildPersonJsonLd,
getAllTags,
getProjects,
getPublishedPosts,
} from '../lib/site';
const posts = await getPublishedPosts(); const posts = await getPublishedPosts();
const latestPosts = posts.slice(0, 5); const latestPosts = posts.slice(0, 5);
@ -11,15 +16,8 @@ const projects = await getProjects();
const selectedProjects = projects.filter((project) => project.data.selected); const selectedProjects = projects.filter((project) => project.data.selected);
const tags = getAllTags(posts); const tags = getAllTags(posts);
const personJsonLd = { // Reference the canonical Person (defined on /about/) by @id.
'@context': 'https://schema.org', const personJsonLd = buildPersonJsonLd();
'@type': 'Person',
name: site.name,
url: site.url,
email: `mailto:${site.email}`,
sameAs: [site.github, site.linkedin],
description: site.description,
};
--- ---
<Base jsonLd={personJsonLd}> <Base jsonLd={personJsonLd}>
@ -28,8 +26,8 @@ const personJsonLd = {
Software systems, AI deployment, graphics, simulations, and tools Software systems, AI deployment, graphics, simulations, and tools
</p> </p>
<h1> <h1>
<span class="home-name-accent">Andras Schmelczer</span> writes about building software Andras Schmelczer writes about building software that has to work under real
that has to work under real constraints. constraints.
</h1> </h1>
<p> <p>
I am a software engineer with an MSc in Computer Science. This site is mostly a I am a software engineer with an MSc in Computer Science. This site is mostly a
@ -38,7 +36,7 @@ const personJsonLd = {
</p> </p>
</section> </section>
<section class="home-section" aria-labelledby="latest-articles"> <section class="home-section">
<div class="section-heading"> <div class="section-heading">
<h2 id="latest-articles">Latest Articles</h2> <h2 id="latest-articles">Latest Articles</h2>
<a href="/articles/">All {posts.length} articles →</a> <a href="/articles/">All {posts.length} articles →</a>
@ -46,15 +44,15 @@ const personJsonLd = {
<ArticleList posts={latestPosts} /> <ArticleList posts={latestPosts} />
</section> </section>
<section class="home-section" aria-labelledby="selected-projects"> <section class="home-section">
<div class="section-heading"> <div class="section-heading">
<h2 id="selected-projects">Selected Projects</h2> <h2 id="home-selected-projects">Selected Projects</h2>
<a href="/projects/">All projects →</a> <a href="/projects/">All projects →</a>
</div> </div>
<ProjectList projects={selectedProjects} /> <ProjectList projects={selectedProjects} />
</section> </section>
<section class="home-section" aria-labelledby="browse-by-topic"> <section class="home-section">
<div class="section-heading"> <div class="section-heading">
<h2 id="browse-by-topic">Browse by Topic</h2> <h2 id="browse-by-topic">Browse by Topic</h2>
</div> </div>

View file

@ -1,13 +1,13 @@
--- ---
import ProjectList from '../../components/ProjectList.astro'; import ProjectList from '../../components/ProjectList.astro';
import Page from '../../layouts/Page.astro'; import Page from '../../layouts/Page.astro';
import { absoluteUrl, getProjects, site } from '../../lib/site'; import { absoluteUrl, buildBreadcrumbTrail, getProjects, site } from '../../lib/site';
const projects = await getProjects(); const projects = await getProjects();
const selected = projects.filter((project) => project.data.selected); const selected = projects.filter((project) => project.data.selected);
const older = projects.filter((project) => !project.data.selected); const older = projects.filter((project) => !project.data.selected);
const jsonLd = { const collectionJsonLd = {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'CollectionPage', '@type': 'CollectionPage',
name: `${site.name} — Projects`, name: `${site.name} — Projects`,
@ -15,6 +15,23 @@ const jsonLd = {
description: description:
'A compact index of systems, tools, simulations, graphics experiments, games, and older work.', 'A compact index of systems, tools, simulations, graphics experiments, games, and older work.',
}; };
const breadcrumbTrail = [
...buildBreadcrumbTrail({}),
{ name: 'Projects', href: '/projects/' },
];
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: breadcrumbTrail.map((crumb, index) => ({
'@type': 'ListItem',
position: index + 1,
name: crumb.name,
item: absoluteUrl(crumb.href),
})),
};
const jsonLd = [collectionJsonLd, breadcrumbJsonLd];
--- ---
<Page <Page
@ -22,12 +39,12 @@ const jsonLd = {
description="A compact index of systems, tools, simulations, graphics experiments, games, and older work." description="A compact index of systems, tools, simulations, graphics experiments, games, and older work."
jsonLd={jsonLd} jsonLd={jsonLd}
> >
<section class="project-section" aria-labelledby="selected-projects"> <section class="project-section">
<h2 id="selected-projects">Selected Projects</h2> <h2 id="selected-projects">Selected Projects</h2>
<ProjectList projects={selected} /> <ProjectList projects={selected} />
</section> </section>
<section class="project-section" aria-labelledby="older-projects"> <section class="project-section">
<h2 id="older-projects">Older and Smaller Projects</h2> <h2 id="older-projects">Older and Smaller Projects</h2>
<ProjectList projects={older} /> <ProjectList projects={older} />
</section> </section>

View file

@ -1,10 +1,37 @@
import rss from '@astrojs/rss'; import rss from '@astrojs/rss';
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
import { absoluteUrl, articlePath, getPublishedPosts, site } from '../lib/site'; import ogDefault from '../assets/og-default.jpg';
import {
absoluteUrl,
articlePath,
entrySlug,
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;');
}
// Format a Date as `YYYY-MM-DD` in UTC for use inside tag: URIs.
function isoDate(date: Date) {
return date.toISOString().slice(0, 10);
}
export const GET: APIRoute = async (context) => { export const GET: APIRoute = async (context) => {
const posts = await getPublishedPosts(); const posts = await getPublishedPosts();
const feedUrl = absoluteUrl('/rss.xml'); const feedUrl = absoluteUrl('/rss.xml');
const channelImage = await optimizeOgImage(ogDefault);
const channelImageUrl = absoluteUrl(channelImage.src);
const creator = escapeXml(site.name);
return rss({ return rss({
title: site.name, title: site.name,
@ -13,14 +40,24 @@ export const GET: APIRoute = async (context) => {
xmlns: { xmlns: {
atom: 'http://www.w3.org/2005/Atom', atom: 'http://www.w3.org/2005/Atom',
content: 'http://purl.org/rss/1.0/modules/content/', content: 'http://purl.org/rss/1.0/modules/content/',
dc: 'http://purl.org/dc/elements/1.1/',
}, },
customData: [ customData: [
'<language>en-us</language>', '<language>en-us</language>',
`<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>`, `<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>`,
`<atom:link href="${feedUrl}" rel="self" type="application/rss+xml" />`, `<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'), ].join('\n'),
items: posts.map((post) => { items: posts.map((post) => {
const url = absoluteUrl(articlePath(post)); const url = absoluteUrl(articlePath(post));
// Stable tag: URI keeps the GUID constant across path renames
// (e.g. the `/writing/` → `/articles/` migration). The date is the
// original publish date so re-publishing won't change the GUID.
const guid = `tag:schmelczer.dev,${isoDate(post.data.date)}:posts/${entrySlug(post)}`;
const updated = post.data.updated const updated = post.data.updated
? `<atom:updated>${post.data.updated.toISOString()}</atom:updated>` ? `<atom:updated>${post.data.updated.toISOString()}</atom:updated>`
: ''; : '';
@ -31,7 +68,13 @@ export const GET: APIRoute = async (context) => {
link: url, link: url,
author: `${site.email} (${site.name})`, author: `${site.email} (${site.name})`,
categories: [...post.data.tags], categories: [...post.data.tags],
customData: `<guid isPermaLink="true">${url}</guid>${updated}`, customData: [
`<guid isPermaLink="false">${escapeXml(guid)}</guid>`,
`<dc:creator>${creator}</dc:creator>`,
updated,
]
.filter(Boolean)
.join(''),
}; };
}), }),
}); });

View file

@ -17,7 +17,7 @@ const { tag } = Astro.props;
const posts = await getPublishedPosts(); const posts = await getPublishedPosts();
const allTags = getAllTags(posts); const allTags = getAllTags(posts);
const filteredPosts = posts.filter((post) => post.data.tags.some((t) => t === tag)); const filteredPosts = posts.filter((post) => post.data.tags.some((t) => t === tag));
const title = `Articles tagged #${tag}`; const title = `Articles tagged "${tag}"`;
const trail = [ const trail = [
{ href: '/', label: 'Home' }, { href: '/', label: 'Home' },
{ href: '/articles/', label: 'Articles' }, { href: '/articles/', label: 'Articles' },
@ -36,5 +36,6 @@ const trail = [
<TagList tags={allTags} currentTag={tag} labelled={false} /> <TagList tags={allTags} currentTag={tag} labelled={false} />
</nav> </nav>
<h2 class="sr-only">Articles</h2>
<ArticleList posts={filteredPosts} currentTag={tag} /> <ArticleList posts={filteredPosts} currentTag={tag} />
</Page> </Page>

View file

@ -1,22 +1,57 @@
--- ---
import TagList from '../../components/TagList.astro'; import TagList from '../../components/TagList.astro';
import Page from '../../layouts/Page.astro'; import Page from '../../layouts/Page.astro';
import { getAllTags, getPublishedPosts } from '../../lib/site'; import {
absoluteUrl,
buildBreadcrumbTrail,
getAllTags,
getPublishedPosts,
site,
} from '../../lib/site';
const posts = await getPublishedPosts(); const posts = await getPublishedPosts();
const tags = getAllTags(posts); const tags = getAllTags(posts);
const tagCounts = new Map<string, number>(); const tagCounts: Record<string, number> = {};
for (const post of posts) { for (const post of posts) {
for (const tag of post.data.tags) { for (const tag of post.data.tags) {
tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1); tagCounts[tag] = (tagCounts[tag] ?? 0) + 1;
} }
} }
const collectionJsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: `${site.name} — Tags`,
url: absoluteUrl('/tags/'),
description: 'Every tag used across the articles archive.',
};
const breadcrumbTrail = [
...buildBreadcrumbTrail({ articles: true }),
{ name: 'Tags', href: '/tags/' },
];
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: breadcrumbTrail.map((crumb, index) => ({
'@type': 'ListItem',
position: index + 1,
name: crumb.name,
item: absoluteUrl(crumb.href),
})),
};
const jsonLd = [collectionJsonLd, breadcrumbJsonLd];
--- ---
<Page title="Tags" description="Every tag used across the articles archive."> <Page
title="Tags"
description="Every tag used across the articles archive."
jsonLd={jsonLd}
>
<p class="dek"> <p class="dek">
{posts.length} articles across {tags.length} tags. {posts.length} articles across {tags.length} tags.
</p> </p>
<TagList tags={tags} /> <TagList tags={tags} counts={tagCounts} />
</Page> </Page>

22
src/scripts/theme-init.ts Normal file
View file

@ -0,0 +1,22 @@
(() => {
const key = 'theme';
const legacyKey = 'dark-mode';
let saved: 'light' | 'dark' | null = null;
try {
const value = localStorage.getItem(key);
if (value === 'light' || value === 'dark') {
saved = value;
} else {
const legacyValue = localStorage.getItem(legacyKey);
if (legacyValue !== null) {
saved = JSON.parse(legacyValue) ? 'dark' : 'light';
}
}
} catch {
saved = null;
}
const systemDark = matchMedia('(prefers-color-scheme: dark)').matches;
const theme = saved || (systemDark ? 'dark' : 'light');
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
})();

View file

@ -25,27 +25,30 @@
========================================================================= */ ========================================================================= */
:root { :root {
color-scheme: light; color-scheme: light dark;
--font-sans: --font-sans:
'Source Sans 3', Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Source Sans 3', Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', sans-serif; 'Segoe UI', sans-serif;
--font-mono: 'IBM Plex Mono', 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace; --font-mono: 'IBM Plex Mono', 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
/* Light palette */ /* Palette — light-dark() pairs each token (light, dark) */
--color-bg: #fbfaf7; --color-bg: light-dark(#fbfaf7, #151514);
--color-fg: #181817; --color-fg: light-dark(#181817, #f1eee7);
--color-muted: #5f5c54; --color-muted: light-dark(#4d4b44, #b7afa3);
--color-link: #285f74; --color-link: light-dark(#285f74, #8ab8c8);
--color-link-hover: #8a4b2f; --color-link-hover: light-dark(
--color-link-visited: #3c5a7a; color-mix(in oklch, #285f74 70%, black 30%),
--color-accent: oklch(55% 0.13 15); color-mix(in oklch, #8ab8c8 70%, black 30%)
--color-rule: #d9d5ca; );
--color-rule-medium: #a8a294; --color-link-visited: var(--color-link);
--color-rule-strong: #4a4340; --color-accent: light-dark(oklch(55% 0.13 15), oklch(72% 0.13 15));
--color-code-bg: #efede6; --color-rule: light-dark(#d9d5ca, #39352f);
--color-callout-bg: #f4f1e8; --color-rule-medium: light-dark(#7a7466, #6c655c);
--color-selection-bg: #ecddd0; --color-rule-strong: light-dark(#4a4340, #d0c5b7);
--color-code-bg: light-dark(#efede6, #24221f);
--color-callout-bg: light-dark(#f4f1e8, #211f1c);
--color-selection-bg: light-dark(#ecddd0, #4a3a2e);
--theme-switcher-track: var(--color-rule-medium); --theme-switcher-track: var(--color-rule-medium);
--theme-switcher-icon-light: #f0e2b6; --theme-switcher-icon-light: #f0e2b6;
@ -56,16 +59,16 @@
--fs-sm: 0.8125rem; --fs-sm: 0.8125rem;
--fs-caption: 0.875rem; --fs-caption: 0.875rem;
--fs-base: 1rem; --fs-base: 1rem;
--fs-body: 1.125rem; --fs-body: 1.1875rem;
--fs-lg: 1.25rem; --fs-lg: 1.25rem;
--fs-xl: 1.6rem; --fs-xl: 1.75rem;
--fs-2xl: 2.1rem; --fs-2xl: 2.1rem;
--fs-3xl: clamp(2rem, 1.4rem + 2.2vw, 3.85rem); --fs-3xl: clamp(2rem, 1.5rem + 1.8vw, 3rem);
--fs-dek: clamp(1.08rem, 0.95rem + 0.6vw, 1.25rem); --fs-dek: clamp(1.08rem, 0.95rem + 0.6vw, 1.25rem);
--leading-tight: 1.18; --leading-tight: 1.18;
--leading-snug: 1.35; --leading-snug: 1.35;
--leading-prose: 1.65; --leading-prose: 1.6;
--weight-regular: 400; --weight-regular: 400;
--weight-medium: 500; --weight-medium: 500;
@ -97,53 +100,12 @@
--gutter: clamp(20px, 4vw, 32px); --gutter: clamp(20px, 4vw, 32px);
} }
/* Dark palette applied when explicit data-theme='dark' OR
when system prefers dark and no explicit light override is set. */
:root[data-theme='dark'],
:root:where(:not([data-theme='light'])) {
/* Default branch only takes effect under the media query below. */
}
:root[data-theme='dark'] {
color-scheme: dark;
--color-bg: #151514;
--color-fg: #f1eee7;
--color-muted: #b7afa3;
--color-link: #8ab8c8;
--color-link-hover: #d6a17f;
--color-link-visited: #a3b7d2;
--color-accent: oklch(72% 0.13 15);
--color-rule: #39352f;
--color-rule-medium: #6c655c;
--color-rule-strong: #d0c5b7;
--color-code-bg: #24221f;
--color-callout-bg: #211f1c;
--color-selection-bg: #4a3a2e;
--theme-switcher-track: var(--color-rule-medium);
}
:root[data-theme='light'] { :root[data-theme='light'] {
color-scheme: light; color-scheme: light;
} }
@media (prefers-color-scheme: dark) { :root[data-theme='dark'] {
:root:not([data-theme='light']) { color-scheme: dark;
color-scheme: dark;
--color-bg: #151514;
--color-fg: #f1eee7;
--color-muted: #b7afa3;
--color-link: #8ab8c8;
--color-link-hover: #d6a17f;
--color-link-visited: #a3b7d2;
--color-accent: oklch(72% 0.13 15);
--color-rule: #39352f;
--color-rule-medium: #6c655c;
--color-rule-strong: #d0c5b7;
--color-code-bg: #24221f;
--color-callout-bg: #211f1c;
--color-selection-bg: #4a3a2e;
--theme-switcher-track: var(--color-rule-medium);
}
} }
/* ========================================================================= /* =========================================================================
@ -211,7 +173,6 @@
color: var(--color-fg); color: var(--color-fg);
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: var(--fs-body); font-size: var(--fs-body);
line-height: var(--leading-prose);
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
overflow-wrap: break-word; overflow-wrap: break-word;
@ -227,10 +188,6 @@
transition: color 150ms ease; transition: color 150ms ease;
} }
a:visited {
color: var(--color-link-visited);
}
a:hover { a:hover {
color: var(--color-link-hover); color: var(--color-link-hover);
} }
@ -289,26 +246,27 @@
========================================================================= */ ========================================================================= */
@layer layout { @layer layout {
.site-header, :where(
.site-footer, .site-header,
.home-intro, .site-footer,
.home-section, .home-intro,
.page-shell, .home-section,
.post, .page-shell,
.post-footer-shell { .post,
.post-footer-shell
) {
width: min(100% - 2 * var(--gutter), var(--page)); width: min(100% - 2 * var(--gutter), var(--page));
margin-inline: auto; margin-inline: auto;
} }
.post, :where(.post, .post-footer-shell) {
.post-footer-shell {
width: min(100% - 2 * var(--gutter), var(--measure-wide)); width: min(100% - 2 * var(--gutter), var(--measure-wide));
} }
.skip-link { .skip-link {
position: absolute; position: absolute;
left: var(--gutter); left: calc(var(--gutter) + env(safe-area-inset-left));
top: var(--space-3); top: calc(var(--space-3) + env(safe-area-inset-top));
z-index: 10; z-index: 10;
transform: translateY(-150%); transform: translateY(-150%);
background: var(--color-fg); background: var(--color-fg);
@ -435,13 +393,6 @@
text-wrap: balance; text-wrap: balance;
} }
.home-name-accent {
color: var(--color-accent);
font-family: var(--font-mono);
font-size: 0.93em;
font-weight: var(--weight-medium);
}
.home-intro p:not(.eyebrow), .home-intro p:not(.eyebrow),
.page-header p, .page-header p,
.dek { .dek {
@ -500,15 +451,10 @@
gap: var(--space-4); gap: var(--space-4);
flex-wrap: wrap; flex-wrap: wrap;
margin-bottom: var(--space-4); margin-bottom: var(--space-4);
border-top: 1px solid var(--color-rule);
padding-top: var(--space-6); padding-top: var(--space-6);
} }
.section-heading h2, :where(.section-heading, .archive-year, .project-section, .facts, .at-a-glance) h2 {
.archive-year h2,
.project-section h2,
.facts h2,
.at-a-glance h2 {
font-size: var(--fs-lg); font-size: var(--fs-lg);
font-weight: var(--weight-semibold); font-weight: var(--weight-semibold);
line-height: var(--leading-snug); line-height: var(--leading-snug);
@ -638,7 +584,7 @@
.article-list > li { .article-list > li {
display: grid; display: grid;
grid-template-columns: 6rem minmax(0, 1fr) 8rem; grid-template-columns: minmax(5rem, auto) minmax(0, 1fr) minmax(6rem, 8rem);
grid-template-areas: 'date content thumb'; grid-template-areas: 'date content thumb';
align-items: center; align-items: center;
gap: var(--space-5); gap: var(--space-5);
@ -654,6 +600,7 @@
grid-area: date; grid-area: date;
color: var(--color-muted); color: var(--color-muted);
font-size: var(--fs-caption); font-size: var(--fs-caption);
text-align: end;
} }
.article-list > li > div { .article-list > li > div {
@ -732,6 +679,7 @@
grid-template-areas: 'thumb summary'; grid-template-areas: 'thumb summary';
min-height: var(--project-thumb-size); min-height: var(--project-thumb-size);
min-width: 0; min-width: 0;
overflow: hidden;
border: 1px solid var(--color-rule); border: 1px solid var(--color-rule);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
background: var(--color-bg); background: var(--color-bg);
@ -909,12 +857,17 @@
.prose { .prose {
max-inline-size: var(--measure); max-inline-size: var(--measure);
line-height: var(--leading-prose);
} }
.prose > * + * { .prose > * + * {
margin-top: 1.05em; margin-top: 1.05em;
} }
.prose p {
text-wrap: pretty;
}
.prose h2, .prose h2,
.prose h3 { .prose h3 {
position: relative; position: relative;
@ -1039,7 +992,7 @@
.prose pre { .prose pre {
max-width: 100%; max-width: 100%;
overflow-x: auto; overflow-x: auto;
-webkit-overflow-scrolling: touch; scrollbar-gutter: stable;
padding: var(--space-4); padding: var(--space-4);
background: var(--color-code-bg); background: var(--color-code-bg);
border: 1px solid var(--color-rule); border: 1px solid var(--color-rule);
@ -1068,28 +1021,16 @@
} }
/* Shiki dual-theme: defaultColor: false emits --shiki-light / --shiki-dark /* Shiki dual-theme: defaultColor: false emits --shiki-light / --shiki-dark
vars on every token; one selector list picks the active variant. */ vars on every token; light-dark() picks the active variant from
color-scheme on :root. */
.prose pre.astro-code, .prose pre.astro-code,
.prose pre.astro-code code, .prose pre.astro-code code,
.prose pre.astro-code span { .prose pre.astro-code span {
color: var(--shiki-light); color: light-dark(var(--shiki-light), var(--shiki-dark));
background-color: var(--shiki-light-bg, var(--color-code-bg)); background-color: light-dark(
} var(--shiki-light-bg, var(--color-code-bg)),
var(--shiki-dark-bg, var(--color-code-bg))
:root[data-theme='dark'] .prose pre.astro-code, );
:root[data-theme='dark'] .prose pre.astro-code code,
:root[data-theme='dark'] .prose pre.astro-code span {
color: var(--shiki-dark);
background-color: var(--shiki-dark-bg, var(--color-code-bg));
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) .prose pre.astro-code,
:root:not([data-theme='light']) .prose pre.astro-code code,
:root:not([data-theme='light']) .prose pre.astro-code span {
color: var(--shiki-dark);
background-color: var(--shiki-dark-bg, var(--color-code-bg));
}
} }
/* -- At-a-glance + facts --------------------------------------------- */ /* -- At-a-glance + facts --------------------------------------------- */
@ -1388,7 +1329,6 @@
padding-block: var(--space-8) var(--space-6); padding-block: var(--space-8) var(--space-6);
} }
.article-list > li,
.at-a-glance dl, .at-a-glance dl,
.facts dl { .facts dl {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@ -1416,6 +1356,10 @@
--project-thumb-size: 7rem; --project-thumb-size: 7rem;
} }
.project-card .project-meta {
white-space: normal;
}
.project-card__summary { .project-card__summary {
padding: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-3);
} }
@ -1458,18 +1402,10 @@
scroll-behavior: auto; scroll-behavior: auto;
} }
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition: none !important;
animation: none !important;
}
::view-transition-group(*), ::view-transition-group(*),
::view-transition-old(*), ::view-transition-old(*),
::view-transition-new(*) { ::view-transition-new(*) {
animation: none !important; animation: none;
} }
} }
@ -1481,7 +1417,6 @@
--color-muted: #333; --color-muted: #333;
--color-link: #000; --color-link: #000;
--color-link-hover: #000; --color-link-hover: #000;
--color-link-visited: #000;
--color-accent: #000; --color-accent: #000;
--color-rule: #999; --color-rule: #999;
--color-rule-medium: #777; --color-rule-medium: #777;
@ -1503,7 +1438,7 @@
.post-nav, .post-nav,
.related-posts, .related-posts,
.heading-anchor { .heading-anchor {
display: none !important; display: none;
} }
main { main {
@ -1512,14 +1447,14 @@
a, a,
a:visited { a:visited {
color: #000; color: var(--color-fg);
text-decoration: underline; text-decoration: underline;
} }
.prose a[href]::after { .prose a[href]::after {
content: ' (' attr(href) ')'; content: ' (' attr(href) ')';
font-size: 0.85em; font-size: 0.85em;
color: #555; color: var(--color-muted);
} }
.prose a[href^='#']::after, .prose a[href^='#']::after,

View file

@ -1,10 +1,6 @@
{ {
"extends": "astro/tsconfigs/strict", "extends": "astro/tsconfigs/strict",
"compilerOptions": { "compilerOptions": {
"types": ["astro/client"], "types": ["astro/client"]
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
} }
} }