Claude improvements

This commit is contained in:
Andras Schmelczer 2026-05-11 07:48:33 +01:00
parent a86940da30
commit df2267a968
79 changed files with 2695 additions and 1162 deletions

View file

@ -1,18 +1,52 @@
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 rehypeSlug from 'rehype-slug';
export default defineConfig({ export default defineConfig({
site: 'https://schmelczer.dev', site: 'https://schmelczer.dev',
trailingSlash: 'always', trailingSlash: 'always',
redirects: {
'/writing/': '/articles/',
'/writing/[slug]': '/articles/[slug]',
},
integrations: [ integrations: [
sitemap({ sitemap({
filter: (page) => !new URL(page).pathname.startsWith('/writing/'), filter: (page) => {
const path = new URL(page).pathname;
return !path.startsWith('/writing/') && path !== '/404/';
},
serialize(item) {
return { ...item, changefreq: 'monthly' };
},
}), }),
], ],
image: {
service: { entrypoint: 'astro/assets/services/sharp' },
},
markdown: { markdown: {
shikiConfig: { shikiConfig: {
theme: 'github-light', themes: {
light: 'github-light',
dark: 'github-dark',
},
defaultColor: false,
wrap: false, wrap: false,
}, },
rehypePlugins: [
rehypeSlug,
[
rehypeAutolinkHeadings,
{
behavior: 'append',
properties: {
className: ['heading-anchor'],
'aria-hidden': 'true',
tabIndex: -1,
},
content: { type: 'text', value: '#' },
},
],
],
}, },
}); });

112
package-lock.json generated
View file

@ -14,6 +14,8 @@
"playwright": "^1.59.1", "playwright": "^1.59.1",
"prettier": "^3.8.3", "prettier": "^3.8.3",
"prettier-plugin-astro": "^0.14.1", "prettier-plugin-astro": "^0.14.1",
"rehype-autolink-headings": "^7.1.0",
"rehype-slug": "^6.0.0",
"sharp": "^0.32.6", "sharp": "^0.32.6",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
@ -3586,6 +3588,20 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/hast-util-heading-rank": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-heading-rank/-/hast-util-heading-rank-3.0.0.tgz",
"integrity": "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-is-element": { "node_modules/hast-util-is-element": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz",
@ -3684,6 +3700,20 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/hast-util-to-string": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz",
"integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-text": { "node_modules/hast-util-to-text": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz",
@ -5399,6 +5429,25 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/rehype-autolink-headings": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/rehype-autolink-headings/-/rehype-autolink-headings-7.1.0.tgz",
"integrity": "sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@ungap/structured-clone": "^1.0.0",
"hast-util-heading-rank": "^3.0.0",
"hast-util-is-element": "^3.0.0",
"unified": "^11.0.0",
"unist-util-visit": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/rehype-parse": { "node_modules/rehype-parse": {
"version": "9.0.1", "version": "9.0.1",
"resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz", "resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz",
@ -5431,6 +5480,24 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/rehype-slug": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-6.0.0.tgz",
"integrity": "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"github-slugger": "^2.0.0",
"hast-util-heading-rank": "^3.0.0",
"hast-util-to-string": "^3.0.0",
"unist-util-visit": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/rehype-stringify": { "node_modules/rehype-stringify": {
"version": "10.0.1", "version": "10.0.1",
"resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz",
@ -9189,6 +9256,15 @@
"web-namespaces": "^2.0.0" "web-namespaces": "^2.0.0"
} }
}, },
"hast-util-heading-rank": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-heading-rank/-/hast-util-heading-rank-3.0.0.tgz",
"integrity": "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==",
"dev": true,
"requires": {
"@types/hast": "^3.0.0"
}
},
"hast-util-is-element": { "hast-util-is-element": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz",
@ -9262,6 +9338,15 @@
"zwitch": "^2.0.0" "zwitch": "^2.0.0"
} }
}, },
"hast-util-to-string": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz",
"integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==",
"dev": true,
"requires": {
"@types/hast": "^3.0.0"
}
},
"hast-util-to-text": { "hast-util-to-text": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz",
@ -10374,6 +10459,20 @@
"unified": "^11.0.0" "unified": "^11.0.0"
} }
}, },
"rehype-autolink-headings": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/rehype-autolink-headings/-/rehype-autolink-headings-7.1.0.tgz",
"integrity": "sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==",
"dev": true,
"requires": {
"@types/hast": "^3.0.0",
"@ungap/structured-clone": "^1.0.0",
"hast-util-heading-rank": "^3.0.0",
"hast-util-is-element": "^3.0.0",
"unified": "^11.0.0",
"unist-util-visit": "^5.0.0"
}
},
"rehype-parse": { "rehype-parse": {
"version": "9.0.1", "version": "9.0.1",
"resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz", "resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz",
@ -10396,6 +10495,19 @@
"vfile": "^6.0.0" "vfile": "^6.0.0"
} }
}, },
"rehype-slug": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-6.0.0.tgz",
"integrity": "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==",
"dev": true,
"requires": {
"@types/hast": "^3.0.0",
"github-slugger": "^2.0.0",
"hast-util-heading-rank": "^3.0.0",
"hast-util-to-string": "^3.0.0",
"unist-util-visit": "^5.0.0"
}
},
"rehype-stringify": { "rehype-stringify": {
"version": "10.0.1", "version": "10.0.1",
"resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz",

View file

@ -41,6 +41,8 @@
"playwright": "^1.59.1", "playwright": "^1.59.1",
"prettier": "^3.8.3", "prettier": "^3.8.3",
"prettier-plugin-astro": "^0.14.1", "prettier-plugin-astro": "^0.14.1",
"rehype-autolink-headings": "^7.1.0",
"rehype-slug": "^6.0.0",
"sharp": "^0.32.6", "sharp": "^0.32.6",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Before After
Before After

View file

@ -1,2 +1,4 @@
User-agent: * User-agent: *
Allow: / Allow: /
Sitemap: https://schmelczer.dev/sitemap-index.xml

View file

@ -1,11 +1,23 @@
{ {
"name": "Portfolio - Andras Schmelczer", "name": "Andras Schmelczer",
"short_name": "Portfolio", "short_name": "Schmelczer",
"description": "Andras Schmelczer writes about software systems, AI deployment, graphics, simulations, and tools.",
"lang": "en",
"id": "/",
"categories": ["education", "personal", "technology"],
"icons": [ "icons": [
{ "src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" } { "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" },
], {
"theme_color": "#B7455E", "src": "/android-chrome-512x512.png",
"background_color": "#242638", "sizes": "512x512",
"display": "standalone" "type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#fbfaf7",
"background_color": "#fbfaf7",
"display": "standalone",
"start_url": "/",
"scope": "/"
} }

View file

@ -1,19 +1,10 @@
import { createServer } from 'node:http'; import { createServer } from 'node:http';
import { readFile, stat } from 'node:fs/promises'; import { readdir, readFile, stat } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { chromium } from 'playwright'; import { chromium } from 'playwright';
const dist = path.resolve('dist'); const dist = path.resolve('dist');
const routes = [ const widths = [320, 390, 430, 768, 1024, 1440, 1920];
'/',
'/articles/',
'/articles/greatai-ai-deployment-api/',
'/writing/',
'/writing/greatai-ai-deployment-api/',
'/projects/',
'/about/',
];
const widths = [320, 390, 430];
function contentType(file) { function contentType(file) {
if (file.endsWith('.html')) return 'text/html; charset=utf-8'; if (file.endsWith('.html')) return 'text/html; charset=utf-8';
@ -27,6 +18,38 @@ function contentType(file) {
return 'application/octet-stream'; return 'application/octet-stream';
} }
async function walk(dir) {
const entries = await readdir(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...(await walk(fullPath)));
} else {
files.push(fullPath);
}
}
return files;
}
async function discoverRoutes() {
const files = await walk(dist);
const routes = new Set();
for (const file of files) {
if (!file.endsWith('.html')) continue;
const rel = path.relative(dist, file).replaceAll(path.sep, '/');
if (rel === '404.html') continue;
if (rel.endsWith('/index.html')) {
routes.add('/' + rel.slice(0, -'index.html'.length));
} else if (rel === 'index.html') {
routes.add('/');
} else {
routes.add('/' + rel.replace(/\.html$/, '/'));
}
}
return [...routes].sort();
}
async function resolveFile(url) { async function resolveFile(url) {
const parsed = new URL(url, 'http://localhost'); const parsed = new URL(url, 'http://localhost');
const safePath = path const safePath = path
@ -52,6 +75,8 @@ async function resolveFile(url) {
return path.join(dist, '404.html'); return path.join(dist, '404.html');
} }
const routes = await discoverRoutes();
const server = createServer(async (req, res) => { const server = createServer(async (req, res) => {
try { try {
const file = await resolveFile(req.url ?? '/'); const file = await resolveFile(req.url ?? '/');
@ -122,4 +147,6 @@ if (failures.length > 0) {
process.exit(1); process.exit(1);
} }
console.log('No horizontal overflow detected at 320px, 390px, or 430px.'); console.log(
`No horizontal overflow detected at ${widths.join(', ')}px across ${routes.length} routes.`
);

BIN
src/assets/og-default.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

View file

@ -1,8 +1,8 @@
--- ---
import type { CollectionEntry } from 'astro:content'; import type { CollectionEntry } from 'astro:content';
import { Image } from 'astro:assets'; import EntryThumbnail from './EntryThumbnail.astro';
import { articlePath, formatDate } from '../lib/site';
import TagList from './TagList.astro'; import TagList from './TagList.astro';
import { articlePath, formatDate, formatDateShort } from '../lib/site';
interface Props { interface Props {
posts: CollectionEntry<'posts'>[]; posts: CollectionEntry<'posts'>[];
@ -10,7 +10,7 @@ interface Props {
currentTag?: string; currentTag?: string;
} }
const { posts, showYear = false, currentTag } = Astro.props; const { posts, showYear = true, currentTag } = Astro.props;
--- ---
<ol class="article-list"> <ol class="article-list">
@ -20,7 +20,7 @@ const { posts, showYear = false, currentTag } = Astro.props;
return ( return (
<li> <li>
<time datetime={post.data.date.toISOString()}> <time datetime={post.data.date.toISOString()}>
{showYear ? formatDate(post.data.date) : formatDate(post.data.date)} {showYear ? formatDate(post.data.date) : formatDateShort(post.data.date)}
</time> </time>
<div> <div>
<a class="entry-title" href={href}> <a class="entry-title" href={href}>
@ -29,19 +29,14 @@ const { posts, showYear = false, currentTag } = Astro.props;
<p>{post.data.description}</p> <p>{post.data.description}</p>
<TagList tags={post.data.tags} currentTag={currentTag} /> <TagList tags={post.data.tags} currentTag={currentTag} />
</div> </div>
<a <EntryThumbnail
class="entry-thumbnail article-thumbnail"
href={href}
aria-label={post.data.title}
>
<Image
src={post.data.thumbnail.src} src={post.data.thumbnail.src}
alt={post.data.thumbnail.alt} alt={post.data.thumbnail.alt}
widths={[160, 240, 320, 480]} href={href}
sizes="(max-width: 700px) 5rem, 10rem" class="article-thumbnail"
loading="lazy" widths={[120, 180, 240, 320, 480]}
sizes="(max-width: 700px) clamp(64px, 22vw, 80px), (max-width: 960px) 7rem, 8rem"
/> />
</a>
</li> </li>
); );
}) })

View file

@ -1,30 +1,33 @@
--- ---
import type { CollectionEntry } from 'astro:content';
import ProjectLinks from './ProjectLinks.astro'; import ProjectLinks from './ProjectLinks.astro';
type Link = CollectionEntry<'projects'>['data']['links'][number];
interface Props { interface Props {
role?: string; role?: string;
projectPeriod?: string; projectPeriod?: string;
stack?: string[]; stack?: string[];
scale?: string; scale?: string;
outcome?: string; outcome?: string;
links?: Array<{ label: string; type: string; url: string; download?: boolean }>; links?: Link[];
} }
const { role, projectPeriod, stack = [], scale, outcome, links = [] } = Astro.props; const { role, projectPeriod, stack = [], scale, outcome, links = [] } = Astro.props;
const rows = [ const rows: Array<[string, string]> = [
['Role', role], ['Role', role ?? ''],
['Period', projectPeriod], ['Period', projectPeriod ?? ''],
['Stack', stack.join(', ')], ['Stack', stack.join(', ')],
['Scale', scale], ['Scale', scale ?? ''],
['Outcome', outcome], ['Outcome', outcome ?? ''],
].filter(([, value]) => Boolean(value)); ].filter((row): row is [string, string] => Boolean(row[1]));
--- ---
{ {
rows.length > 0 && ( rows.length > 0 && (
<aside class="at-a-glance" aria-label="At a glance"> <aside class="at-a-glance" aria-labelledby="at-a-glance-heading">
<h2>At a Glance</h2> <h2 id="at-a-glance-heading">At a Glance</h2>
<dl> <dl>
{rows.map(([label, value]) => ( {rows.map(([label, value]) => (
<> <>
@ -33,7 +36,7 @@ const rows = [
</> </>
))} ))}
</dl> </dl>
<ProjectLinks links={links} /> {links.length > 0 && <ProjectLinks links={links} />}
</aside> </aside>
) )
} }

View file

@ -0,0 +1,29 @@
---
interface Crumb {
href?: string;
label: string;
}
interface Props {
items: Crumb[];
}
const { items } = Astro.props;
const last = items.length - 1;
---
<nav aria-label="Breadcrumb">
<ol class="breadcrumbs">
{
items.map((item, index) => (
<li>
{item.href && index !== last ? (
<a href={item.href}>{item.label}</a>
) : (
<span aria-current={index === last ? 'page' : undefined}>{item.label}</span>
)}
</li>
))
}
</ol>
</nav>

View file

@ -0,0 +1,47 @@
---
import type { ImageMetadata } from 'astro';
import { Picture } from 'astro:assets';
interface Props {
src: ImageMetadata;
alt: string;
href?: string;
class?: string;
widths: number[];
sizes: string;
loading?: 'lazy' | 'eager';
fetchpriority?: 'high' | 'low' | 'auto';
}
const {
src,
alt,
href,
class: extraClass,
widths,
sizes,
loading = 'lazy',
fetchpriority,
} = Astro.props;
const Tag = href ? 'a' : 'div';
---
<Tag
class:list={['entry-thumbnail', extraClass]}
href={href}
aria-hidden={href ? 'true' : undefined}
tabindex={href ? -1 : undefined}
>
<Picture
src={src}
alt={alt}
formats={['avif', 'webp']}
fallbackFormat="jpg"
widths={widths}
sizes={sizes}
loading={loading}
decoding="async"
fetchpriority={fetchpriority}
/>
</Tag>

View file

@ -1,50 +0,0 @@
---
import { Image } from 'astro:assets';
interface MediaItem {
type: 'image' | 'video' | 'diagram';
src?: ImageMetadata;
poster?: ImageMetadata;
mp4?: string;
webm?: string;
alt?: string;
caption?: string;
transcript?: string;
role?: 'evidence' | 'og' | 'inline';
}
interface Props {
items: MediaItem[];
}
const { items } = Astro.props;
---
{
items.map((item) => (
<figure class:list={['evidence-media', item.role === 'inline' && 'figure-inline']}>
{item.type === 'video' ? (
<video
controls
preload="metadata"
poster={item.poster?.src}
aria-label={item.alt}
>
{item.webm && <source src={item.webm} type="video/webm" />}
{item.mp4 && <source src={item.mp4} type="video/mp4" />}
</video>
) : (
item.src && (
<Image
src={item.src}
alt={item.alt ?? ''}
widths={[480, 720, 960, 1280]}
sizes="(max-width: 760px) calc(100vw - 2rem), 56rem"
/>
)
)}
{item.caption && <figcaption>{item.caption}</figcaption>}
{item.transcript && <p class="media-transcript">{item.transcript}</p>}
</figure>
))
}

View file

@ -1,14 +1,38 @@
--- ---
import { site } from '../lib/site'; import { navItems, site } from '../lib/site';
const year = new Date().getFullYear();
--- ---
<footer class="site-footer"> <footer class="site-footer">
<p> <nav aria-label="Footer">
<span>{site.name}</span> <ul class="footer-links">
<a href={`mailto:${site.email}`}>Email</a> {
<a href={site.cv}>CV</a> navItems.map((item) => (
<a href={site.github}>GitHub</a> <li>
<a href={site.linkedin}>LinkedIn</a> <a href={item.href}>{item.label}</a>
</li>
))
}
<li>
<a href="/tags/">Tags</a>
</li>
<li>
<a href="/rss.xml">RSS</a> <a href="/rss.xml">RSS</a>
</p> </li>
</ul>
</nav>
<ul class="footer-meta">
<li><span>© {year} {site.name}</span></li>
<li><a href={`mailto:${site.email}`}>Email</a></li>
<li>
<a href={site.cv} rel="noopener">CV</a>
</li>
<li>
<a href={site.github} rel="noopener me">GitHub</a>
</li>
<li>
<a href={site.linkedin} rel="noopener me">LinkedIn</a>
</li>
</ul>
</footer> </footer>

View file

@ -1,37 +1,37 @@
--- ---
import { site } from '../lib/site'; import { navItems, site } from '../lib/site';
const current = Astro.url.pathname; const current = Astro.url.pathname;
const navItems = [
{ href: '/articles/', label: 'Articles' },
{ href: '/projects/', label: 'Projects' },
{ href: '/about/', label: 'About' },
{ href: '/rss.xml', label: 'RSS' },
];
function isCurrent(href: string) { function isCurrent(href: string) {
return href !== '/rss.xml' && current.startsWith(href); if (href === '/') return current === '/';
return current.startsWith(href);
} }
--- ---
<a class="skip-link" href="#content">Skip to content</a> <a class="skip-link" href="#content">Skip to content</a>
<header class="site-header" aria-label="Site header"> <header class="site-header">
<a class="site-title" href="/">{site.name}</a> <a class="site-title" href="/">{site.name}</a>
<div class="header-actions"> <div class="header-actions">
<nav class="site-nav" aria-label="Primary navigation"> <nav class="site-nav" aria-label="Primary">
{ {
navItems.map((item) => ( navItems
.filter((item) => item.href !== '/')
.map((item) => (
<a href={item.href} aria-current={isCurrent(item.href) ? 'page' : undefined}> <a href={item.href} aria-current={isCurrent(item.href) ? 'page' : undefined}>
{item.label} {item.label}
</a> </a>
)) ))
} }
</nav> </nav>
<label class="theme-control" for="theme-switcher"> <button
<span class="sr-only">Use dark theme</span> id="theme-switcher"
<input id="theme-switcher" class="theme-switcher" type="checkbox" name="theme" /> class="theme-switcher"
</label> type="button"
aria-label="Toggle dark theme"
aria-pressed="false"
>
</button>
</div> </div>
</header> </header>
@ -46,15 +46,11 @@ function isCurrent(href: string) {
try { try {
const value = localStorage.getItem(key); const value = localStorage.getItem(key);
if (value === 'light' || value === 'dark') return value; if (value === 'light' || value === 'dark') return value;
const legacyValue = localStorage.getItem(legacyKey); const legacyValue = localStorage.getItem(legacyKey);
if (legacyValue !== null) { if (legacyValue !== null) return JSON.parse(legacyValue) ? 'dark' : 'light';
return JSON.parse(legacyValue) ? 'dark' : 'light';
}
} catch { } catch {
return null; return null;
} }
return null; return null;
}; };
@ -63,22 +59,33 @@ function isCurrent(href: string) {
const apply = (theme) => { const apply = (theme) => {
document.documentElement.dataset.theme = theme; document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme; document.documentElement.style.colorScheme = theme;
switcher.checked = theme === 'dark'; if (switcher) switcher.setAttribute('aria-pressed', String(theme === 'dark'));
}; };
if (!switcher) return;
apply(getStored() || getSystemTheme()); apply(getStored() || getSystemTheme());
switcher.addEventListener('change', () => { if (!switcher) return;
const theme = switcher.checked ? 'dark' : 'light';
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
const runApply = (theme) => {
if (!reduced.matches && typeof document.startViewTransition === 'function') {
document.startViewTransition(() => apply(theme));
} else {
apply(theme);
}
};
switcher.addEventListener('click', () => {
const current = switcher.getAttribute('aria-pressed') === 'true' ? 'dark' : 'light';
const next = current === 'dark' ? 'light' : 'dark';
try { try {
localStorage.setItem(key, theme); localStorage.setItem(key, next);
localStorage.setItem(legacyKey, JSON.stringify(theme === 'dark')); localStorage.setItem(legacyKey, JSON.stringify(next === 'dark'));
} catch { } catch {
// The switch still applies for the current page when storage is unavailable. // The switch still applies for the current page when storage is unavailable.
} }
apply(theme); runApply(next);
}); });
media.addEventListener('change', () => { media.addEventListener('change', () => {

View file

@ -0,0 +1,45 @@
---
import type { CollectionEntry } from 'astro:content';
import { Picture } from 'astro:assets';
type MediaItem = CollectionEntry<'posts'>['data']['media'][number];
interface Props {
items: MediaItem[];
}
const { items } = Astro.props;
---
{
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 ? undefined : 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="jpg"
widths={[480, 720, 960, 1280, 1600, 1920]}
sizes="(max-width: 700px) calc(100vw - 3rem), (max-width: 1100px) calc(100vw - 4rem), 56rem"
loading="lazy"
decoding="async"
/>
)
)}
{item.caption && <figcaption>{item.caption}</figcaption>}
{item.transcript && <p class="media-transcript">{item.transcript}</p>}
</figure>
))
}

View file

@ -1,16 +1,17 @@
--- ---
interface Link { import type { CollectionEntry } from 'astro:content';
label: string;
type: string; type Link = CollectionEntry<'projects'>['data']['links'][number];
url: string;
download?: boolean;
}
interface Props { interface Props {
links: Link[]; links: Link[];
} }
const { links } = Astro.props; const { links } = Astro.props;
function isExternal(url: string) {
return /^https?:\/\//.test(url);
}
--- ---
{ {
@ -18,8 +19,18 @@ const { links } = Astro.props;
<ul class="project-links" aria-label="Project links"> <ul class="project-links" aria-label="Project links">
{links.map((link) => ( {links.map((link) => (
<li> <li>
<a href={link.url} download={link.download ? '' : undefined}> <a
href={link.url}
download={link.download ? '' : undefined}
rel={isExternal(link.url) ? 'noopener' : undefined}
>
{link.label} {link.label}
{link.download && (
<span class="download-indicator" aria-hidden="true">
</span>
)}
{link.download && <span class="sr-only">(download)</span>}
</a> </a>
</li> </li>
))} ))}

View file

@ -1,8 +1,8 @@
--- ---
import type { CollectionEntry } from 'astro:content'; import type { CollectionEntry } from 'astro:content';
import { Image } from 'astro:assets'; import EntryThumbnail from './EntryThumbnail.astro';
import { articlePath } from '../lib/site';
import ProjectLinks from './ProjectLinks.astro'; import ProjectLinks from './ProjectLinks.astro';
import { articlePath, projectAnchor } from '../lib/site';
interface Props { interface Props {
projects: CollectionEntry<'projects'>[]; projects: CollectionEntry<'projects'>[];
@ -15,7 +15,7 @@ type ProjectLink = CollectionEntry<'projects'>['data']['links'][number];
<ol class="project-list"> <ol class="project-list">
{ {
projects.map((project) => { projects.map((project) => {
const anchor = project.data.legacyAnchor ?? project.data.sourceProjectId; const anchor = projectAnchor(project);
const titleId = `${anchor}-title`; const titleId = `${anchor}-title`;
const essayHref = project.data.essay ? articlePath(project.data.essay) : undefined; const essayHref = project.data.essay ? articlePath(project.data.essay) : undefined;
const essayLink: ProjectLink | undefined = essayHref const essayLink: ProjectLink | undefined = essayHref
@ -27,37 +27,16 @@ type ProjectLink = CollectionEntry<'projects'>['data']['links'][number];
...project.data.links, ...project.data.links,
]; ];
if (links.length === 0) {
links.push({ label: 'Permalink', type: 'site', url: `#${anchor}` });
}
return ( return (
<li class="project-card" id={anchor} aria-labelledby={titleId}> <li class="project-card" id={anchor}>
{primaryHref ? ( <EntryThumbnail
<a src={project.data.thumbnail.src}
class="entry-thumbnail project-thumbnail" alt={project.data.thumbnail.alt}
href={primaryHref} href={primaryHref}
aria-labelledby={titleId} class="project-thumbnail"
>
<Image
src={project.data.thumbnail.src}
alt={project.data.thumbnail.alt}
widths={[240, 320, 480, 640, 800]} widths={[240, 320, 480, 640, 800]}
sizes="(max-width: 700px) calc(100vw - 2rem), 19rem" sizes="(max-width: 700px) 7rem, (max-width: 960px) clamp(7rem, 18vw, 9.5rem), 19rem"
loading="lazy"
/> />
</a>
) : (
<div class="entry-thumbnail project-thumbnail">
<Image
src={project.data.thumbnail.src}
alt={project.data.thumbnail.alt}
widths={[240, 320, 480, 640, 800]}
sizes="(max-width: 700px) calc(100vw - 2rem), 19rem"
loading="lazy"
/>
</div>
)}
<div class="project-card__summary"> <div class="project-card__summary">
<h3 id={titleId}> <h3 id={titleId}>
{primaryHref ? ( {primaryHref ? (
@ -65,12 +44,13 @@ type ProjectLink = CollectionEntry<'projects'>['data']['links'][number];
) : ( ) : (
project.data.title project.data.title
)} )}
{essayHref && <span class="project-essay-badge">Article</span>}
</h3> </h3>
<p class="project-description">{project.data.description}</p> <p class="project-description">{project.data.description}</p>
<p class="project-meta"> <p class="project-meta">
{project.data.period} · {project.data.technologies.join(', ')} {project.data.period} · {project.data.technologies.join(', ')}
</p> </p>
<ProjectLinks links={links} /> {links.length > 0 && <ProjectLinks links={links} />}
</div> </div>
</li> </li>
); );

View file

@ -1,20 +1,21 @@
--- ---
import { formatTag, tagPath } from '../lib/site'; import { tagPath } from '../lib/site';
interface Props { interface Props {
tags: string[]; tags: readonly string[];
currentTag?: string; currentTag?: string;
labelled?: boolean;
} }
const { tags, currentTag } = Astro.props; const { tags, currentTag, labelled = true } = Astro.props;
--- ---
<ul class="tag-list" aria-label="Tags"> <ul class="tag-list" aria-label={labelled ? 'Tags' : undefined}>
{ {
tags.map((tag) => ( tags.map((tag) => (
<li> <li>
<a href={tagPath(tag)} aria-current={tag === currentTag ? 'page' : undefined}> <a href={tagPath(tag)} aria-current={tag === currentTag ? 'page' : undefined}>
{formatTag(tag)} {tag}
</a> </a>
</li> </li>
)) ))

View file

@ -1,4 +1,5 @@
import { defineCollection } from 'astro:content'; import { defineCollection } 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';
@ -18,13 +19,13 @@ const linkSchema = z.object({
download: z.boolean().optional(), download: z.boolean().optional(),
}); });
const thumbnailSchema = ({ image }: { image: any }) => const thumbnailSchema = ({ image }: SchemaContext) =>
z.object({ z.object({
src: image(), src: image(),
alt: z.string(), alt: z.string(),
}); });
const mediaSchema = ({ image }: { image: any }) => const mediaSchema = ({ image }: SchemaContext) =>
z z
.object({ .object({
type: z.enum(['image', 'video', 'diagram']), type: z.enum(['image', 'video', 'diagram']),
@ -38,13 +39,8 @@ const mediaSchema = ({ image }: { image: any }) =>
transcript: z.string().optional(), transcript: z.string().optional(),
role: z.enum(['evidence', 'og', 'inline']).default('evidence'), role: z.enum(['evidence', 'og', 'inline']).default('evidence'),
}) })
.refine((item) => item.decorative || Boolean(item.alt), { .refine((item) => item.decorative || (Boolean(item.alt) && Boolean(item.caption)), {
message: 'Meaningful media needs alt text.', message: 'Meaningful media needs both alt text and a caption.',
path: ['alt'],
})
.refine((item) => item.decorative || Boolean(item.caption), {
message: 'Meaningful media needs a caption.',
path: ['caption'],
}); });
const posts = defineCollection({ const posts = defineCollection({
@ -52,7 +48,7 @@ const posts = defineCollection({
schema: ({ image }) => schema: ({ image }) =>
z.object({ z.object({
title: z.string(), title: z.string(),
description: z.string(), description: z.string().max(160),
date: z.coerce.date(), date: z.coerce.date(),
updated: z.coerce.date().optional(), updated: z.coerce.date().optional(),
draft: z.boolean().default(false), draft: z.boolean().default(false),
@ -69,9 +65,7 @@ const posts = defineCollection({
'games', 'games',
]) ])
), ),
selected: z.boolean().default(false),
featuredOrder: z.number().optional(), featuredOrder: z.number().optional(),
project: z.string().optional(),
projectPeriod: z.string().optional(), projectPeriod: z.string().optional(),
role: z.string().optional(), role: z.string().optional(),
stack: z.array(z.string()).optional(), stack: z.array(z.string()).optional(),
@ -91,7 +85,7 @@ const projects = defineCollection({
z.object({ z.object({
sourceProjectId: z.string(), sourceProjectId: z.string(),
title: z.string(), title: z.string(),
description: z.string(), description: z.string().max(160),
thumbnail: thumbnailSchema({ image }), thumbnail: thumbnailSchema({ image }),
period: z.string(), period: z.string(),
sortDate: z.coerce.date(), sortDate: z.coerce.date(),
@ -101,7 +95,6 @@ const projects = defineCollection({
essay: z.string().optional(), essay: z.string().optional(),
legacyAnchor: z.string().optional(), legacyAnchor: z.string().optional(),
links: z.array(linkSchema).default([]), links: z.array(linkSchema).default([]),
downloads: z.array(z.object({ label: z.string(), url: z.string() })).default([]),
}), }),
}); });

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 928 KiB

After

Width:  |  Height:  |  Size: 205 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 615 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 408 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Before After
Before After

View file

@ -0,0 +1,22 @@
---
title: Avoid, an Early Web Game
description: A tiny archived web game from my first experiments with browser-based interaction.
date: 2026-04-29
projectPeriod: 'January 2018'
thumbnail:
src: ./_assets/avoid.jpg
alt: Screenshot of the Avoid web game.
tags: ['games', 'web']
selected: false
project: avoid
role: Game author
stack: ['JavaScript', 'Canvas']
outcome: A small playable web game kept as an archive of early browser work
audience: general
links:
- label: Demo
type: demo
url: https://schmelczer.dev/avoid
---
I recently found my first-ever web game. It is incredibly simple, but I killed some time with it, so feel free to try it out and do not judge too harshly.

View file

@ -0,0 +1,27 @@
---
title: A Unity City Simulation for a Cybersecurity Challenge
description: A client-server Unity simulation where REST-controlled traffic lights made mistakes immediately visible through crashes.
date: 2026-05-01
projectPeriod: 'July-August 2018'
thumbnail:
src: ./_assets/city-simulation.jpg
alt: Screenshot of a Unity traffic simulation.
tags: ['simulation', 'systems']
selected: false
project: city-simulation
role: Simulation author
stack: ['Unity', 'C#', 'REST API', 'Blender']
outcome: A visual context for a PLC-focused cybersecurity challenge
audience: technical
links: []
---
I simulated a city where car crashes were more frequent than usual.
The state of the traffic lights could be changed through a REST API. Drivers followed the instructions of those lights, so if a mistake was made, collisions appeared in the simulation. There was also support for displaying tweets on a HUD.
The project was created as the context for a cybersecurity challenge about PLCs. With the help of this program, contestants could instantly see the effect of their work.
An exciting aspect of the project was building it with a server-client architecture. Every decision of the agents was calculated server-side. The real challenge was broadcasting these decisions in a fault-tolerant way using minimal bandwidth.
It was made with Unity using C# as the scripting language. I also made the models and animations in Blender.

View file

@ -0,0 +1,25 @@
---
title: A Frequency-Domain Foreign Exchange Prediction Experiment
description: An older EUR/USD prediction experiment built from smoothing, short-time Fourier transforms, extrapolation, and a Python prediction server.
date: 2026-05-03
projectPeriod: 'Autumn 2019'
thumbnail:
src: ./_assets/forex.jpg
alt: Chart comparing predicted and actual EUR/USD exchange rates.
tags: ['systems', 'tools']
selected: false
project: forex
role: Experiment author
stack: ['Python', 'NumPy', 'SciPy', 'Flask', 'MQL4']
outcome: A working prediction server connected to an MQL4 client for trading experiments
audience: technical
links: []
---
This was an experiment in predicting EUR/USD rates. The animation from the old portfolio showed the implementation doing a somewhat good job predicting the rates: the prediction was the blue graph and the actual values were the green chart. Of course, I would not trust it with my money.
The algorithm was a fancy linear regression in the frequency domain. The steps were: smoothing the input values, differentiating, applying a short-time Fourier transformation with overlapped and Hanning-windowed windows, extrapolating, and then applying the inverse of these transformations to the resulting values.
The prediction server was written in Python using NumPy, SciPy, and Flask. It communicated with an MQL4 client that was responsible for handling financial transactions based on the generated data.
There was still plenty of room for improvement, but even with this simple algorithm, a sometimes profitable trading strategy was viable. More importantly, the project gave me a useful look into trading algorithms, their complexity, and the fierce competition around them.

View file

@ -0,0 +1,23 @@
---
title: A JavaFX Graph Editor for Simulation Input
description: A small JavaFX editor for creating and uploading graph input for the cooling system simulator.
date: 2026-04-25
projectPeriod: 'October-November 2018'
thumbnail:
src: ./_assets/process-simulator-input.jpg
alt: JavaFX graph editor for the cooling system simulator.
tags: ['simulation', 'tools']
selected: false
project: nuclear-editor
role: Editor author
stack: ['JavaFX', 'JSON', 'REST API']
outcome: An editor for building input graphs and sending them to the simulation backend
audience: technical
links: []
---
This was an intuitive editor to create and edit input for the nuclear facility simulator.
Nodes could be moved with drag and drop gestures. Editing the parameters of elements was done on the right panel.
The UI was built with JavaFX. The output could be exported as JSON or directly uploaded to the simulation backend.

View file

@ -0,0 +1,23 @@
---
title: Lights Synchronized to Music
description: A Raspberry Pi music player that analyzed audio output and drove RGB LED strips.
date: 2026-04-26
projectPeriod: 'Spring 2016'
thumbnail:
src: ./_assets/leds.jpg
alt: RGB LED strips lit by a music synchronization project.
tags: ['systems', 'tools']
selected: false
project: leds
role: Hardware and software author
stack: ['Python', 'NumPy', 'FFT', 'Raspberry Pi', 'Vanilla web']
outcome: My first finished non-trivial project, combining a web UI, audio processing, and hardware output
audience: technical
links: []
---
This was a full-stack application with a built-in music player, the output of which controlled the colour of a couple of RGB LED strips through a Raspberry Pi and some MOSFETs.
It was my first non-trivial project that got finished. Obviously, it was rather far from perfect, but I am still proud that I was able to build it on my own.
The backend logic was written in Python, and the FFT implementation was provided by NumPy. I also built a simple frontend for accessing the music player and changing the settings using vanilla web development technologies.

View file

@ -0,0 +1,26 @@
---
title: My Notes, an Android Markdown App
description: A small Android notes app for creating, editing, and filtering markdown notes with hashtags.
date: 2026-05-02
projectPeriod: 'November 2019'
thumbnail:
src: ./_assets/my-notes.png
alt: Screenshots of the My Notes Android app.
tags: ['tools']
selected: false
project: my-notes
role: Android app author
stack: ['Android', 'Markdown', 'Markwon']
outcome: A functional markdown note organizer and a first exposure to Android development
audience: technical
links:
- label: Source
type: source
url: https://github.com/schmelczer/my-notes
---
My Notes was a minimalist Android note organizer and editor powered by Markwon.
It was a basic app for creating and filtering markdown notes based on hashtags. It was also my first exposure to Android development.
All in all, it was not a unique idea, but it was functional. It also exposed me to a wildly different paradigm than I was used to from full-stack web development, which made the project worthwhile.

View file

@ -0,0 +1,25 @@
---
title: A Proof-of-Concept Photo Colour Grader
description: A web UI experiment for selecting colours and transforming nearby ranges based on colour distance.
date: 2026-04-30
projectPeriod: 'June 2018'
thumbnail:
src: ./_assets/photo-colour-grader.jpg
alt: Screenshot of a photo colour grading interface.
tags: ['graphics', 'web', 'tools']
selected: false
project: colors
role: Interface and image processing author
stack: ['JavaScript', 'Canvas', 'Image processing']
outcome: A proof-of-concept colour grading interaction model
audience: technical
links: []
---
This was an innovative, or at least I thought so at the time, colour grader web application.
The most noteworthy feature of the application was the colour selector UI. The program was only intended as a proof-of-concept. I wanted to experiment with a few interaction ideas, and this was the outcome.
The core idea was that you could select some colours and then apply transformations to other colours as a function of their distance to the selected colour.
By clicking on a coloured circle, you could change its settings. New circles could be created by clicking inside the large circle, and they could also be moved with drag and drop.

View file

@ -0,0 +1,26 @@
---
title: A Static Photo Site Generator
description: A simple photography site generated from a directory of images with automatic resizing to multiple quality settings.
date: 2026-04-27
projectPeriod: 'Summer 2016'
thumbnail:
src: ./_assets/photos.jpg
alt: Screenshot of a generated photography site.
tags: ['web', 'tools']
selected: false
project: photos
role: Site generator author
stack: ['Webpack', 'Image processing', 'Static site generation']
outcome: A generated static photo site for publishing photography with responsive image output
audience: general
links:
- label: Site
type: site
url: https://photo.schmelczer.dev
---
Photos was a simple webpage where you could view my photos.
Taking time to appreciate the world around us fills me with joy. That is why I like to go on walks with a camera. I might not end up with great photos. Nonetheless, I usually end up with some inspiration regarding my current or next project.
As for the webpage, a Webpack script generated the site from the photos in a directory. Automatic resizing to multiple quality settings was also part of the pipeline.

View file

@ -0,0 +1,25 @@
---
title: 'A 3D Platform Game in C and SDL 1.2'
description: 'My first proper project: a 3D game with random maps, destructible voxels, enemies, powerups, and time slowdown.'
date: 2026-04-28
projectPeriod: 'Autumn 2017'
thumbnail:
src: ./_assets/platform-game.png
alt: Screenshot from a 3D platform game written in C.
tags: ['games', 'systems']
selected: false
project: platform-game
role: Game author
stack: ['C', 'SDL 1.2', 'Voxel terrain']
outcome: A playable 3D course project that made programming feel like the right long-term direction
audience: technical
links: []
---
This was my first proper project. I created an actually fun 3D game written in pure C with the help of SDL 1.2.
The maps were randomly generated and fully destroyable voxel by voxel. That also allowed the player to create structures for hiding from flying enemies, which chased the player and could destroy the terrain after merging together and growing larger.
After collecting enough powerups, the player could shoot and even slow down time in exchange for losing some points.
I did this as my final project for my Basics of Programming course. Through making it, I learned a lot about pointers after an adequate number of segmentation faults. It also made me realize my passion for programming.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 454 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 928 KiB

After

Width:  |  Height:  |  Size: 205 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 MiB

After

Width:  |  Height:  |  Size: 296 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 570 KiB

After

Width:  |  Height:  |  Size: 145 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 366 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 615 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 408 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Before After
Before After

View file

@ -10,6 +10,7 @@ sortDate: 2018-01-01
status: Early web game status: Early web game
technologies: ['JavaScript', 'Canvas'] technologies: ['JavaScript', 'Canvas']
selected: false selected: false
essay: avoid-early-web-game
legacyAnchor: avoid legacyAnchor: avoid
links: links:
- label: Demo - label: Demo

View file

@ -10,6 +10,7 @@ sortDate: 2018-08-01
status: Simulation status: Simulation
technologies: ['Unity', 'C#', 'REST API', 'Blender'] technologies: ['Unity', 'C#', 'REST API', 'Blender']
selected: false selected: false
essay: city-simulation-unity-traffic
legacyAnchor: city-simulation-unity legacyAnchor: city-simulation-unity
links: [] links: []
--- ---

View file

@ -10,6 +10,7 @@ sortDate: 2018-06-01
status: UI experiment status: UI experiment
technologies: ['JavaScript', 'Canvas', 'Image processing'] technologies: ['JavaScript', 'Canvas', 'Image processing']
selected: false selected: false
essay: photo-colour-grader
legacyAnchor: photo-colour-grader legacyAnchor: photo-colour-grader
links: [] links: []
--- ---

View file

@ -10,6 +10,7 @@ sortDate: 2019-10-01
status: Experiment status: Experiment
technologies: ['Python', 'NumPy', 'SciPy', 'Flask', 'MQL4'] technologies: ['Python', 'NumPy', 'SciPy', 'Flask', 'MQL4']
selected: false selected: false
essay: foreign-exchange-prediction-experiment
legacyAnchor: predicting-foreign-exchange-rates legacyAnchor: predicting-foreign-exchange-rates
links: [] links: []
--- ---

View file

@ -10,6 +10,7 @@ sortDate: 2016-04-01
status: Early hardware/software project status: Early hardware/software project
technologies: ['Python', 'NumPy', 'FFT', 'Raspberry Pi', 'Vanilla web'] technologies: ['Python', 'NumPy', 'FFT', 'Raspberry Pi', 'Vanilla web']
selected: false selected: false
essay: lights-synchronized-to-music
legacyAnchor: lights-synchronised-to-music legacyAnchor: lights-synchronised-to-music
links: [] links: []
--- ---

View file

@ -10,6 +10,7 @@ sortDate: 2019-11-01
status: Android app status: Android app
technologies: ['Android', 'Kotlin/Java', 'Markdown', 'Markwon'] technologies: ['Android', 'Kotlin/Java', 'Markdown', 'Markwon']
selected: false selected: false
essay: my-notes-android-markdown-app
legacyAnchor: my-notes-android-app legacyAnchor: my-notes-android-app
links: links:
- label: Source - label: Source

View file

@ -0,0 +1,16 @@
---
sourceProjectId: nuclear-editor
title: Graph Editor
description: A JavaFX editor for creating and editing input graphs for the cooling system simulator.
thumbnail:
src: ./_assets/process-simulator-input.jpg
alt: JavaFX editor interface for the cooling system simulator input graph.
period: 'October-November 2018'
sortDate: 2018-10-15
status: Input editor
technologies: ['JavaFX', 'JSON', 'REST API']
selected: false
essay: graph-editor-javafx-simulation-input
legacyAnchor: graph-editor-javafx
links: []
---

View file

@ -10,6 +10,7 @@ sortDate: 2016-07-01
status: Static site generator status: Static site generator
technologies: ['Webpack', 'Image processing', 'Static site generation'] technologies: ['Webpack', 'Image processing', 'Static site generation']
selected: false selected: false
essay: photo-site-generator
legacyAnchor: photos legacyAnchor: photos
links: links:
- label: Site - label: Site

View file

@ -10,6 +10,7 @@ sortDate: 2017-10-01
status: Early game project status: Early game project
technologies: ['C', 'SDL 1.2', 'Voxel terrain'] technologies: ['C', 'SDL 1.2', 'Voxel terrain']
selected: false selected: false
essay: platform-game-c-sdl
legacyAnchor: platform-game legacyAnchor: platform-game
links: [] links: []
--- ---

View file

@ -1,29 +1,91 @@
--- ---
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, site } from '../lib/site';
import defaultOg from '../assets/og-default.jpg';
import '../styles/global.css'; import '../styles/global.css';
interface ArticleMeta {
publishedTime: string;
modifiedTime?: string;
tags?: readonly string[];
}
interface Props { interface Props {
title?: string; title?: string;
description?: string; description?: string;
canonicalPath?: string; canonicalPath?: string;
ogImage?: string;
ogImageAlt?: string;
ogImageWidth?: number;
ogImageHeight?: number;
ogType?: 'website' | 'article' | 'profile';
article?: ArticleMeta;
jsonLd?: Record<string, unknown> | Array<Record<string, unknown>>;
noindex?: boolean;
preloadMono?: boolean;
} }
const { const {
title = site.title, title = site.title,
description = site.description, description = site.description,
canonicalPath = Astro.url.pathname, canonicalPath = Astro.url.pathname,
ogImage,
ogImageAlt = site.description,
ogImageWidth,
ogImageHeight,
ogType = 'website',
article,
jsonLd,
noindex = false,
preloadMono = false,
} = Astro.props; } = Astro.props;
const pageTitle = title === site.title ? site.title : `${title} | ${site.name}`; const isRoot = title === site.title;
const pageTitle = isRoot ? site.title : `${title} · ${site.name}`;
const ogTitle = isRoot ? site.title : title;
const canonical = absoluteUrl(canonicalPath); const canonical = absoluteUrl(canonicalPath);
let resolvedOgImage = ogImage;
let resolvedOgWidth = ogImageWidth;
let resolvedOgHeight = ogImageHeight;
if (!resolvedOgImage) {
const generated = await getImage({
src: defaultOg,
width: 1200,
height: 630,
format: 'jpg',
});
resolvedOgImage = generated.src;
resolvedOgWidth = 1200;
resolvedOgHeight = 630;
}
const ogImageUrl = resolvedOgImage.startsWith('http')
? resolvedOgImage
: absoluteUrl(resolvedOgImage);
const jsonLdEntries = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
--- ---
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width,initial-scale=1,viewport-fit=cover"
/>
<title>{pageTitle}</title>
<meta name="description" content={description} />
<meta name="author" content={site.name} />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#fbfaf7" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#151514" media="(prefers-color-scheme: dark)" />
{noindex && <meta name="robots" content="noindex,follow" />}
<link rel="canonical" href={canonical} />
<script is:inline data-theme-script> <script is:inline data-theme-script>
(() => { (() => {
const key = 'theme'; const key = 'theme';
@ -48,13 +110,26 @@ const canonical = absoluteUrl(canonicalPath);
document.documentElement.style.colorScheme = theme; document.documentElement.style.colorScheme = theme;
})(); })();
</script> </script>
<meta
name="viewport" <link
content="width=device-width,initial-scale=1,viewport-fit=cover" rel="preload"
href="/fonts/source-sans-3-latin-variable.woff2"
as="font"
type="font/woff2"
crossorigin
/> />
<meta name="description" content={description} /> {
<meta name="theme-color" content="#fbfaf7" /> preloadMono && (
<link rel="canonical" href={canonical} /> <link
rel="preload"
href="/fonts/ibm-plex-mono-latin-400.woff2"
as="font"
type="font/woff2"
crossorigin
/>
)
}
<link <link
rel="alternate" rel="alternate"
type="application/rss+xml" type="application/rss+xml"
@ -66,11 +141,53 @@ const canonical = absoluteUrl(canonicalPath);
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" /> <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" /> <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" /> <link rel="manifest" href="/site.webmanifest" />
<meta property="og:title" content={pageTitle} />
<meta property="og:site_name" content={site.name} />
<meta property="og:title" content={ogTitle} />
<meta property="og:description" content={description} /> <meta property="og:description" content={description} />
<meta property="og:url" content={canonical} /> <meta property="og:url" content={canonical} />
<meta property="og:image" content={absoluteUrl('/og-image.jpg')} /> <meta property="og:image" content={ogImageUrl} />
<title>{pageTitle}</title> <meta property="og:image:type" content="image/jpeg" />
<meta property="og:image:alt" content={ogImageAlt} />
{
resolvedOgWidth && (
<meta property="og:image:width" content={String(resolvedOgWidth)} />
)
}
{
resolvedOgHeight && (
<meta property="og:image:height" content={String(resolvedOgHeight)} />
)
}
<meta property="og:type" content={ogType} />
<meta property="og:locale" content="en_US" />
{
article && (
<>
<meta property="article:published_time" content={article.publishedTime} />
{article.modifiedTime && (
<meta property="article:modified_time" content={article.modifiedTime} />
)}
<meta property="article:author" content={absoluteUrl('/about/')} />
{article.tags?.map((tag) => (
<meta property="article:tag" content={tag} />
))}
</>
)
}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={ogTitle} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={ogImageUrl} />
<meta name="twitter:image:alt" content={ogImageAlt} />
{
jsonLdEntries.map((entry) => (
<script is:inline type="application/ld+json" set:html={JSON.stringify(entry)} />
))
}
</head> </head>
<body> <body>
<Header /> <Header />

View file

@ -4,12 +4,17 @@ import Base from './Base.astro';
interface Props { interface Props {
title: string; title: string;
description: 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 title={title} description={description}> <Base {...Astro.props}>
<section class="page-shell"> <section class="page-shell">
<header class="page-header"> <header class="page-header">
<h1>{title}</h1> <h1>{title}</h1>

View file

@ -1,11 +1,21 @@
--- ---
import type { CollectionEntry } from 'astro:content'; import type { CollectionEntry } from 'astro:content';
import { render } from 'astro:content'; import { render } from 'astro:content';
import { Image } from 'astro:assets'; import { Picture, getImage } from 'astro:assets';
import ArticleList from '../components/ArticleList.astro';
import AtAGlance from '../components/AtAGlance.astro'; import AtAGlance from '../components/AtAGlance.astro';
import EvidenceMedia from '../components/EvidenceMedia.astro'; import Breadcrumbs from '../components/Breadcrumbs.astro';
import PostMedia from '../components/PostMedia.astro';
import TagList from '../components/TagList.astro'; import TagList from '../components/TagList.astro';
import { articlePath, formatDate } from '../lib/site'; import {
absoluteUrl,
adjacentPosts,
articlePath,
formatDate,
getPublishedPosts,
getRelatedPosts,
site,
} from '../lib/site';
import Base from './Base.astro'; import Base from './Base.astro';
interface Props { interface Props {
@ -14,33 +24,126 @@ interface Props {
const { post } = Astro.props; const { post } = Astro.props;
const { Content } = await render(post); const { Content } = await render(post);
const dates = [formatDate(post.data.date)];
if (post.data.updated) dates.push(`Updated ${formatDate(post.data.updated)}`); const allPosts = await getPublishedPosts();
const { previous, next } = adjacentPosts(allPosts, post);
const related = getRelatedPosts(allPosts, post, 3);
const ogImageOptimized = await getImage({
src: post.data.thumbnail.src,
width: 1200,
height: 630,
format: 'jpg',
});
const breadcrumbTrail = [
{ href: '/', label: 'Home' },
{ href: '/articles/', label: 'Articles' },
{ label: post.data.title },
];
const blogPosting = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.data.title,
description: post.data.description,
datePublished: post.data.date.toISOString(),
...(post.data.updated && { dateModified: post.data.updated.toISOString() }),
author: {
'@type': 'Person',
name: site.name,
url: absoluteUrl('/about/'),
},
publisher: {
'@type': 'Person',
name: site.name,
url: site.url,
},
image: absoluteUrl(ogImageOptimized.src),
url: absoluteUrl(articlePath(post)),
keywords: post.data.tags.join(', '),
mainEntityOfPage: {
'@type': 'WebPage',
'@id': absoluteUrl(articlePath(post)),
},
};
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: absoluteUrl('/') },
{
'@type': 'ListItem',
position: 2,
name: 'Articles',
item: absoluteUrl('/articles/'),
},
{
'@type': 'ListItem',
position: 3,
name: post.data.title,
item: absoluteUrl(articlePath(post)),
},
],
};
--- ---
<Base <Base
title={post.data.title} title={post.data.title}
description={post.data.description} description={post.data.description}
canonicalPath={articlePath(post)} canonicalPath={articlePath(post)}
ogImage={ogImageOptimized.src}
ogImageAlt={post.data.thumbnail.alt}
ogImageWidth={1200}
ogImageHeight={630}
ogType="article"
preloadMono={true}
article={{
publishedTime: post.data.date.toISOString(),
modifiedTime: post.data.updated?.toISOString(),
tags: post.data.tags,
}}
jsonLd={[blogPosting, breadcrumbJsonLd]}
> >
<article class="post"> <article class="post">
<header class="post-header"> <header class="post-header">
<p class="eyebrow">{post.data.projectPeriod ?? 'Articles'}</p> <Breadcrumbs items={breadcrumbTrail} />
<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"> <p class="post-meta">
<time datetime={post.data.date.toISOString()}>{dates.join(' · ')}</time> <time datetime={post.data.date.toISOString()}>
{formatDate(post.data.date)}
</time>
{
post.data.updated && (
<>
{' · '}
<span>
Updated{' '}
<time datetime={post.data.updated.toISOString()}>
{formatDate(post.data.updated)}
</time>
</span>
</>
)
}
</p> </p>
<TagList tags={post.data.tags} /> <TagList tags={post.data.tags} />
</header> </header>
<figure class="post-thumbnail"> <figure class="post-thumbnail">
<Image <Picture
src={post.data.thumbnail.src} src={post.data.thumbnail.src}
alt={post.data.thumbnail.alt} alt={post.data.thumbnail.alt}
widths={[640, 960, 1280]} formats={['avif', 'webp']}
sizes="(max-width: 760px) calc(100vw - 2rem), 62rem" fallbackFormat="jpg"
widths={[640, 960, 1280, 1600]}
sizes="(max-width: 700px) calc(100vw - 3rem), (max-width: 1100px) calc(100vw - 4rem), 56rem"
loading="eager" loading="eager"
fetchpriority="high"
decoding="async"
/> />
</figure> </figure>
@ -57,6 +160,38 @@ if (post.data.updated) dates.push(`Updated ${formatDate(post.data.updated)}`);
links={post.data.links} links={post.data.links}
/> />
<EvidenceMedia items={post.data.media} /> <PostMedia items={post.data.media} />
{
related.length > 0 && (
<section class="related-posts" aria-labelledby="related-heading">
<h2 id="related-heading">Related articles</h2>
<ArticleList posts={related} />
</section>
)
}
{
(previous || next) && (
<nav class="post-nav" aria-label="Adjacent articles">
{previous && (
<a class="previous" href={articlePath(previous)} rel="prev">
<span class="post-nav__label">
<span aria-hidden="true">←</span> Previous
</span>
<span class="post-nav__title">{previous.data.title}</span>
</a>
)}
{next && (
<a class="next" href={articlePath(next)} rel="next">
<span class="post-nav__label">
Next <span aria-hidden="true">→</span>
</span>
<span class="post-nav__title">{next.data.title}</span>
</a>
)}
</nav>
)
}
</article> </article>
</Base> </Base>

View file

@ -1,8 +1,9 @@
import type { CollectionEntry } from 'astro:content';
import { getCollection } from 'astro:content'; import { getCollection } from 'astro:content';
export const site = { export const site = {
name: 'Andras Schmelczer', name: 'Andras Schmelczer',
title: 'Andras Schmelczer', title: 'Andras Schmelczer — Software systems, AI, graphics, simulations, tools',
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',
@ -12,6 +13,13 @@ export const site = {
cv: '/media/downloads/cv-andras-schmelczer.pdf', cv: '/media/downloads/cv-andras-schmelczer.pdf',
}; };
export const navItems = [
{ href: '/', label: 'Home' },
{ href: '/articles/', label: 'Articles' },
{ href: '/projects/', label: 'Projects' },
{ href: '/about/', label: 'About' },
] as const;
export function formatDate(date: Date) { export function formatDate(date: Date) {
return new Intl.DateTimeFormat('en', { return new Intl.DateTimeFormat('en', {
year: 'numeric', year: 'numeric',
@ -20,6 +28,13 @@ export function formatDate(date: Date) {
}).format(date); }).format(date);
} }
export function formatDateShort(date: Date) {
return new Intl.DateTimeFormat('en', {
month: 'short',
day: 'numeric',
}).format(date);
}
export function yearOf(date: Date) { export function yearOf(date: Date) {
return new Intl.DateTimeFormat('en', { year: 'numeric' }).format(date); return new Intl.DateTimeFormat('en', { year: 'numeric' }).format(date);
} }
@ -44,28 +59,58 @@ export function tagPath(tag: string) {
return `/tags/${tagSlug(tag)}/`; return `/tags/${tagSlug(tag)}/`;
} }
export function formatTag(tag: string) { export function projectAnchor(project: CollectionEntry<'projects'>) {
return tag; return project.data.legacyAnchor ?? project.data.sourceProjectId;
} }
export function getAllTags(posts: { data: { tags: string[] } }[]) { export function getAllTags(posts: { data: { tags: readonly string[] } }[]) {
return [...new Set(posts.flatMap((post) => post.data.tags))].sort((a, b) => return [...new Set(posts.flatMap((post) => post.data.tags))].sort((a, b) =>
a.localeCompare(b) a.localeCompare(b)
); );
} }
export async function getPublishedPosts() { export async function getPublishedPosts(): Promise<CollectionEntry<'posts'>[]> {
return (await getCollection('posts')) return (await getCollection('posts'))
.filter((post) => !post.data.draft) .filter((post) => !post.data.draft)
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf()); .sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
} }
export async function getProjects() { export async function getProjects(): Promise<CollectionEntry<'projects'>[]> {
return (await getCollection('projects')).sort( return (await getCollection('projects')).sort(
(a, b) => b.data.sortDate.valueOf() - a.data.sortDate.valueOf() (a, b) => b.data.sortDate.valueOf() - a.data.sortDate.valueOf()
); );
} }
export function adjacentPosts(
posts: CollectionEntry<'posts'>[],
current: CollectionEntry<'posts'>
) {
const index = posts.findIndex((post) => post.id === current.id);
if (index === -1) return { previous: undefined, next: undefined };
return {
previous: index < posts.length - 1 ? posts[index + 1] : undefined,
next: index > 0 ? posts[index - 1] : undefined,
};
}
export function getRelatedPosts(
posts: CollectionEntry<'posts'>[],
current: CollectionEntry<'posts'>,
limit = 3
) {
const currentTags = new Set(current.data.tags);
return posts
.filter((post) => post.id !== current.id)
.map((post) => ({
post,
overlap: post.data.tags.filter((tag) => currentTags.has(tag)).length,
}))
.filter(({ overlap }) => overlap > 0)
.sort((a, b) => b.overlap - a.overlap)
.slice(0, limit)
.map(({ post }) => post);
}
export function absoluteUrl(path: string) { export function absoluteUrl(path: string) {
return new URL(path, site.url).toString(); return new URL(path, site.url).toString();
} }

View file

@ -1,12 +1,33 @@
--- ---
import ArticleList from '../components/ArticleList.astro';
import Page from '../layouts/Page.astro'; import Page from '../layouts/Page.astro';
import { getPublishedPosts } from '../lib/site';
const posts = await getPublishedPosts();
const recent = posts.slice(0, 5);
--- ---
<Page title="Not Found" description="The page you are looking for does not exist."> <Page
title="Not Found"
description="The page you are looking for does not exist."
noindex
>
<div class="empty-state">
<div class="prose"> <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>, or the <a href="/">homepage</a>. <a href="/projects/">project index</a>, the
<a href="/tags/">tag index</a>, or head back to the
<a href="/">homepage</a>.
</p> </p>
</div> </div>
</div>
<section class="home-section" aria-labelledby="404-recent">
<div class="section-heading">
<h2 id="404-recent">Recent articles</h2>
<a href="/articles/">All articles →</a>
</div>
<ArticleList posts={recent} />
</section>
</Page> </Page>

View file

@ -8,17 +8,40 @@ const startingPoints = posts
.filter((post) => post.data.audience === 'recruiter-relevant') .filter((post) => post.data.audience === 'recruiter-relevant')
.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 = {
'@context': 'https://schema.org',
'@type': 'Person',
name: site.name,
url: site.url,
email: `mailto:${site.email}`,
sameAs: [site.github, site.linkedin],
jobTitle: 'Software Engineer',
description:
'Software engineer with an MSc in Computer Science working on AI/ML systems, web platforms, graphics, simulations, and tools.',
};
--- ---
<Page <Page
title="About" title="About"
description="A direct summary of my background, technical interests, and best starting points." description="A direct summary of my background, technical interests, and best starting points."
jsonLd={personJsonLd}
ogType="profile"
> >
<div class="prose"> <div class="prose">
<p> <p>
I am Andras Schmelczer, a software engineer with an MSc in Computer Science and a I am Andras Schmelczer, a software engineer with an MSc in Computer Science and more
background across AI/ML systems, web platforms, graphics, simulations, and tools. I than six years of professional engineering experience. My work spans AI/ML systems,
like work where architecture, constraints, and product usefulness all matter. 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> </p>
</div> </div>
@ -33,8 +56,9 @@ const startingPoints = posts
<dd><a href={`mailto:${site.email}`}>{site.email}</a></dd> <dd><a href={`mailto:${site.email}`}>{site.email}</a></dd>
<dt>Links</dt> <dt>Links</dt>
<dd> <dd>
<a href={site.cv}>CV</a>, <a href={site.github}>GitHub</a>, <a href={site.cv} rel="noopener">CV</a>,
<a href={site.linkedin}>LinkedIn</a> <a href={site.github} rel="noopener me">GitHub</a>,
<a href={site.linkedin} rel="noopener me">LinkedIn</a>
</dd> </dd>
</dl> </dl>
</section> </section>
@ -42,7 +66,7 @@ const startingPoints = posts
<section class="about-section" aria-labelledby="best-starting-points"> <section class="about-section" aria-labelledby="best-starting-points">
<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/">All articles</a> <a href="/articles/">Browse all articles →</a>
</div> </div>
<ArticleList posts={startingPoints} /> <ArticleList posts={startingPoints} />
</section> </section>

View file

@ -2,20 +2,44 @@
import ArticleList from '../../components/ArticleList.astro'; import ArticleList from '../../components/ArticleList.astro';
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, yearOf } from '../../lib/site'; import {
absoluteUrl,
articlePath,
getAllTags,
getPublishedPosts,
site,
yearOf,
} from '../../lib/site';
const posts = await getPublishedPosts(); const posts = await getPublishedPosts();
const years = [...new Set(posts.map((post) => yearOf(post.data.date)))]; const years = [...new Set(posts.map((post) => yearOf(post.data.date)))];
const tags = getAllTags(posts); const tags = getAllTags(posts);
const blogJsonLd = {
'@context': 'https://schema.org',
'@type': 'Blog',
name: `${site.name} — Articles`,
url: absoluteUrl('/articles/'),
description:
'Technical articles and notes about projects, systems, AI deployment, graphics, simulations, and tools.',
blogPost: posts.map((post) => ({
'@type': 'BlogPosting',
headline: post.data.title,
description: post.data.description,
datePublished: post.data.date.toISOString(),
url: absoluteUrl(articlePath(post)),
})),
};
--- ---
<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}
> >
<nav class="tag-filter" aria-label="Filter articles by tag"> <nav id="tags" class="tag-filter" aria-label="Browse by tag">
<span>Filter by tag</span> <span>Browse by tag</span>
<TagList tags={tags} /> <TagList tags={tags} labelled={false} />
</nav> </nav>
{ {
@ -24,7 +48,7 @@ const tags = getAllTags(posts);
return ( return (
<section class="archive-year" aria-labelledby={`year-${year}`}> <section class="archive-year" aria-labelledby={`year-${year}`}>
<h2 id={`year-${year}`}>{year}</h2> <h2 id={`year-${year}`}>{year}</h2>
<ArticleList posts={postsForYear} showYear /> <ArticleList posts={postsForYear} showYear={false} />
</section> </section>
); );
}) })

View file

@ -1,16 +1,28 @@
--- ---
import ArticleList from '../components/ArticleList.astro'; 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 Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
import { getProjects, getPublishedPosts } from '../lib/site'; import { getAllTags, getProjects, getPublishedPosts, site } from '../lib/site';
const posts = await getPublishedPosts(); const posts = await getPublishedPosts();
const latestPosts = posts.slice(0, 5); const latestPosts = posts.slice(0, 5);
const projects = await getProjects(); 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 personJsonLd = {
'@context': 'https://schema.org',
'@type': 'Person',
name: site.name,
url: site.url,
email: `mailto:${site.email}`,
sameAs: [site.github, site.linkedin],
description: site.description,
};
--- ---
<Base> <Base jsonLd={personJsonLd}>
<section class="home-intro"> <section class="home-intro">
<p class="eyebrow"> <p class="eyebrow">
Software systems, AI deployment, graphics, simulations, and tools Software systems, AI deployment, graphics, simulations, and tools
@ -29,7 +41,7 @@ const selectedProjects = projects.filter((project) => project.data.selected);
<section class="home-section" aria-labelledby="latest-articles"> <section class="home-section" aria-labelledby="latest-articles">
<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 articles</a> <a href="/articles/">All {posts.length} articles</a>
</div> </div>
<ArticleList posts={latestPosts} /> <ArticleList posts={latestPosts} />
</section> </section>
@ -37,8 +49,17 @@ const selectedProjects = projects.filter((project) => project.data.selected);
<section class="home-section" aria-labelledby="selected-projects"> <section class="home-section" aria-labelledby="selected-projects">
<div class="section-heading"> <div class="section-heading">
<h2 id="selected-projects">Selected Projects</h2> <h2 id="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">
<div class="section-heading">
<h2 id="browse-by-topic">Browse by Topic</h2>
</div>
<div class="tag-cloud">
<TagList tags={tags} />
</div>
</section>
</Base> </Base>

View file

@ -1,16 +1,26 @@
--- ---
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 { getProjects } from '../../lib/site'; import { absoluteUrl, 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 = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: `${site.name} — Projects`,
url: absoluteUrl('/projects/'),
description:
'A compact index of systems, tools, simulations, graphics experiments, games, and older work.',
};
--- ---
<Page <Page
title="Projects" title="Projects"
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}
> >
<section class="project-section" aria-labelledby="selected-projects"> <section class="project-section" aria-labelledby="selected-projects">
<h2 id="selected-projects">Selected Projects</h2> <h2 id="selected-projects">Selected Projects</h2>

View file

@ -1,23 +1,38 @@
import rss from '@astrojs/rss'; import rss from '@astrojs/rss';
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
import { articlePath, getPublishedPosts, site } from '../lib/site'; import { absoluteUrl, articlePath, getPublishedPosts, site } from '../lib/site';
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');
return rss({ return rss({
title: site.name, title: site.name,
description: site.description, description: site.description,
site: context.site ?? site.url, site: context.site ?? site.url,
items: posts.map((post) => ({ xmlns: {
atom: 'http://www.w3.org/2005/Atom',
content: 'http://purl.org/rss/1.0/modules/content/',
},
customData: [
'<language>en-us</language>',
`<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>`,
`<atom:link href="${feedUrl}" rel="self" type="application/rss+xml" />`,
].join('\n'),
items: posts.map((post) => {
const url = absoluteUrl(articlePath(post));
const updated = post.data.updated
? `<atom:updated>${post.data.updated.toISOString()}</atom:updated>`
: '';
return {
title: post.data.title, title: post.data.title,
description: post.data.description, description: post.data.description,
pubDate: post.data.date, pubDate: post.data.date,
link: articlePath(post), link: url,
categories: post.data.tags, author: `${site.email} (${site.name})`,
customData: post.data.updated categories: [...post.data.tags],
? `<updated>${post.data.updated.toISOString()}</updated>` customData: `<guid isPermaLink="true">${url}</guid>${updated}`,
: undefined, };
})), }),
}); });
}; };

View file

@ -1,8 +1,9 @@
--- ---
import ArticleList from '../../components/ArticleList.astro'; import ArticleList from '../../components/ArticleList.astro';
import Breadcrumbs from '../../components/Breadcrumbs.astro';
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 { formatTag, getAllTags, getPublishedPosts, tagSlug } from '../../lib/site'; import { getAllTags, getPublishedPosts, tagSlug } from '../../lib/site';
export async function getStaticPaths() { export async function getStaticPaths() {
const posts = await getPublishedPosts(); const posts = await getPublishedPosts();
@ -15,19 +16,24 @@ export async function getStaticPaths() {
const { tag } = Astro.props; 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) => const filteredPosts = posts.filter((post) => post.data.tags.some((t) => t === tag));
(post.data.tags as readonly string[]).includes(tag) const title = `Articles tagged #${tag}`;
); const trail = [
const title = `Articles tagged #${formatTag(tag)}`; { href: '/', label: 'Home' },
{ href: '/articles/', label: 'Articles' },
{ href: '/tags/', label: 'Tags' },
{ label: `#${tag}` },
];
--- ---
<Page <Page
title={title} title={title}
description={`Project articles and technical notes filed under #${formatTag(tag)}.`} description={`Project articles and technical notes filed under #${tag}.`}
> >
<nav class="tag-filter" aria-label="Filter articles by tag"> <Breadcrumbs items={trail} />
<span>Filter by tag</span> <nav class="tag-filter" aria-label="Browse other tags">
<TagList tags={allTags} currentTag={tag} /> <span>Browse other tags</span>
<TagList tags={allTags} currentTag={tag} labelled={false} />
</nav> </nav>
<ArticleList posts={filteredPosts} currentTag={tag} /> <ArticleList posts={filteredPosts} currentTag={tag} />

View file

@ -0,0 +1,22 @@
---
import TagList from '../../components/TagList.astro';
import Page from '../../layouts/Page.astro';
import { getAllTags, getPublishedPosts } from '../../lib/site';
const posts = await getPublishedPosts();
const tags = getAllTags(posts);
const tagCounts = new Map<string, number>();
for (const post of posts) {
for (const tag of post.data.tags) {
tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
}
}
---
<Page title="Tags" description="Every tag used across the articles archive.">
<p class="dek">
{posts.length} articles across {tags.length} tags.
</p>
<TagList tags={tags} />
</Page>

View file

@ -1,37 +0,0 @@
---
import { getCollection } from 'astro:content';
import { absoluteUrl, articlePath, entrySlug, site } from '../../lib/site';
interface Props {
slug: string;
}
export async function getStaticPaths() {
const posts = (await getCollection('posts')).filter((post) => !post.data.draft);
return posts.map((post) => ({
params: { slug: entrySlug(post) },
props: { slug: entrySlug(post) },
}));
}
const { slug } = Astro.props;
const target = articlePath(slug);
const targetUrl = absoluteUrl(target);
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="robots" content="noindex" />
<meta http-equiv="refresh" content={`0; url=${target}`} />
<link rel="canonical" href={targetUrl} />
<title>Article moved | {site.name}</title>
</head>
<body>
<main>
<p>This article has moved to <a href={target}>the new article URL</a>.</p>
</main>
</body>
</html>

View file

@ -1,23 +0,0 @@
---
import { absoluteUrl, site } from '../../lib/site';
const target = '/articles/';
const targetUrl = absoluteUrl(target);
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="robots" content="noindex" />
<meta http-equiv="refresh" content={`0; url=${target}`} />
<link rel="canonical" href={targetUrl} />
<title>Articles moved | {site.name}</title>
</head>
<body>
<main>
<p>Articles have moved to <a href={target}>/articles/</a>.</p>
</main>
</body>
</html>

File diff suppressed because it is too large Load diff

View file

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