claude
|
|
@ -7,14 +7,15 @@ import rehypeAutolinkHeadings from 'rehype-autolink-headings';
|
|||
import rehypeSlug from 'rehype-slug';
|
||||
import { visit } from 'unist-util-visit';
|
||||
|
||||
// Build a lookup of post slugs to their last modification dates so the sitemap
|
||||
// can advertise accurate <lastmod> values to crawlers. astro:content isn't
|
||||
// available inside the config, so we read post frontmatter directly. Our posts
|
||||
// always use single-line scalar `date:` / `updated:` keys, so a small regex
|
||||
// extraction is sufficient and intentional.
|
||||
// Build a lookup of article slugs to their last modification dates so the
|
||||
// sitemap can advertise accurate <lastmod> values to crawlers. astro:content
|
||||
// isn't available inside the config, so we read entry frontmatter directly.
|
||||
// Each entry uses a single-line top-level scalar `date:` key, so a small regex
|
||||
// extraction is sufficient and intentional. (`updated:` now lives nested under
|
||||
// `article:`; no entry sets it, so falling back to `date` is correct.)
|
||||
const postsDir = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'src/content/posts'
|
||||
'src/content/work'
|
||||
);
|
||||
|
||||
function extractScalar(frontmatter, key) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import TagList from './TagList.astro';
|
|||
import { ARTICLE_THUMBNAIL, articlePath, formatDate, formatDateShort } from '../lib/site';
|
||||
|
||||
interface Props {
|
||||
posts: CollectionEntry<'posts'>[];
|
||||
posts: CollectionEntry<'work'>[];
|
||||
showYear?: boolean;
|
||||
tagLimit?: number;
|
||||
timeline?: boolean;
|
||||
|
|
@ -39,7 +39,7 @@ const {
|
|||
</a>
|
||||
</h3>
|
||||
<p>{post.data.description}</p>
|
||||
<TagList tags={post.data.tags} limit={tagLimit} />
|
||||
<TagList tags={post.data.article?.tags ?? []} limit={tagLimit} />
|
||||
</article>
|
||||
<time datetime={post.data.date.toISOString()}>
|
||||
{showYear ? formatDate(post.data.date) : formatDateShort(post.data.date)}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import type { CollectionEntry } from 'astro:content';
|
||||
import ProjectLinks from './ProjectLinks.astro';
|
||||
|
||||
type Link = CollectionEntry<'projects'>['data']['links'][number];
|
||||
type Link = CollectionEntry<'work'>['data']['links'][number];
|
||||
|
||||
interface Props {
|
||||
role?: string;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import PostMediaFigure from './PostMediaFigure.astro';
|
||||
import type { WorkMedia } from '../lib/site';
|
||||
|
||||
type MediaItem = CollectionEntry<'posts'>['data']['media'][number];
|
||||
type MediaItem = WorkMedia;
|
||||
|
||||
interface Props {
|
||||
items: MediaItem[];
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import { Picture } from 'astro:assets';
|
||||
import type { WorkMedia } from '../lib/site';
|
||||
|
||||
type MediaItem = CollectionEntry<'posts'>['data']['media'][number];
|
||||
type MediaItem = WorkMedia;
|
||||
|
||||
interface Props {
|
||||
item: MediaItem;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import VideoThumbnail from './VideoThumbnail.astro';
|
|||
import { absoluteUrl, getHeaderVideo } from '../lib/site';
|
||||
|
||||
interface Props {
|
||||
post: CollectionEntry<'posts'>;
|
||||
post: CollectionEntry<'work'>;
|
||||
}
|
||||
|
||||
const { post } = Astro.props;
|
||||
|
|
@ -13,7 +13,7 @@ const headerVideo = getHeaderVideo(post);
|
|||
const demoLink = post.data.links.find(
|
||||
(link) => !link.download && link.label.trim().toLowerCase() === 'demo'
|
||||
);
|
||||
const iframeUrl = post.data.iframeThumbnail ? demoLink?.url : undefined;
|
||||
const iframeUrl = post.data.article?.iframeThumbnail ? demoLink?.url : undefined;
|
||||
const iframeSrc = iframeUrl?.startsWith('/') ? absoluteUrl(iframeUrl) : iframeUrl;
|
||||
const iframeTitle = demoLink
|
||||
? `${demoLink.label}: ${post.data.title}`
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
|
||||
type Link = CollectionEntry<'projects'>['data']['links'][number];
|
||||
type Link = CollectionEntry<'work'>['data']['links'][number];
|
||||
|
||||
interface Props {
|
||||
links: Link[];
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import { getEntry } from 'astro:content';
|
||||
import EntryThumbnail from './EntryThumbnail.astro';
|
||||
import VideoThumbnail from './VideoThumbnail.astro';
|
||||
import type { HeaderVideo } from '../lib/site';
|
||||
import { PROJECT_THUMBNAIL, articlePath, entrySlug, getHeaderVideo } from '../lib/site';
|
||||
import {
|
||||
PROJECT_THUMBNAIL,
|
||||
articlePath,
|
||||
entrySlug,
|
||||
getHeaderVideo,
|
||||
projectCard,
|
||||
} from '../lib/site';
|
||||
|
||||
interface Props {
|
||||
projects: CollectionEntry<'projects'>[];
|
||||
projects: CollectionEntry<'work'>[];
|
||||
// Opt-in: eagerly load thumbnails that are reliably above the fold. Lists
|
||||
// below substantial content should leave this at zero.
|
||||
eagerFirstThumbnail?: boolean;
|
||||
|
|
@ -24,23 +28,15 @@ function isExternal(url: string) {
|
|||
return /^https?:\/\//.test(url);
|
||||
}
|
||||
|
||||
// The `essay` field is a `reference('posts')`, so when present it's always a
|
||||
// `{ collection, id }` shape that `getEntry` resolves to a CollectionEntry.
|
||||
// Drafts are skipped because their article page is not built. A project may
|
||||
// have no essay (no article) just as an article may have no project; the
|
||||
// relationship is optional in both directions.
|
||||
const essayHrefs = new Map<string, string>();
|
||||
// When the linked article has a header video, the card thumbnail becomes a
|
||||
// click-to-play poster (the card body still opens the project site).
|
||||
const essayVideos = new Map<string, HeaderVideo>();
|
||||
for (const project of projects) {
|
||||
const essay = project.data.essay;
|
||||
if (!essay) continue;
|
||||
const resolved = await getEntry(essay);
|
||||
if (!resolved || resolved.data.draft) continue;
|
||||
essayHrefs.set(project.id, articlePath(resolved));
|
||||
const headerVideo = getHeaderVideo(resolved);
|
||||
if (headerVideo) essayVideos.set(project.id, headerVideo);
|
||||
// Project and article are the same entry now. A card links to its article when
|
||||
// the entry has a published (non-draft) `article` facet; an entry without one
|
||||
// is a project card with no article page. When that article has a header video,
|
||||
// the card thumbnail becomes a click-to-play poster (the card body still opens
|
||||
// the project site).
|
||||
function publishedArticleHref(project: CollectionEntry<'work'>) {
|
||||
const article = project.data.article;
|
||||
if (!article || article.draft) return undefined;
|
||||
return articlePath(project);
|
||||
}
|
||||
|
||||
// The whole card opens the project's website: the first link that isn't a
|
||||
|
|
@ -48,7 +44,7 @@ for (const project of projects) {
|
|||
// The Open button is that link, and its overlay makes the entire card
|
||||
// clickable. Projects without such a link have no Open button and are not
|
||||
// clickable; their article, if any, is reachable through the Article link.
|
||||
function websiteUrl(project: CollectionEntry<'projects'>) {
|
||||
function websiteUrl(project: CollectionEntry<'work'>) {
|
||||
return project.data.links.find((link) => !link.download)?.url;
|
||||
}
|
||||
---
|
||||
|
|
@ -58,8 +54,9 @@ function websiteUrl(project: CollectionEntry<'projects'>) {
|
|||
projects.map((project, index) => {
|
||||
const anchor = entrySlug(project);
|
||||
const titleId = `${anchor}-title`;
|
||||
const essayHref = essayHrefs.get(project.id);
|
||||
const headerVideo = essayVideos.get(project.id);
|
||||
const card = projectCard(project);
|
||||
const essayHref = publishedArticleHref(project);
|
||||
const headerVideo = essayHref ? getHeaderVideo(project) : undefined;
|
||||
const website = websiteUrl(project);
|
||||
const websiteExternal = website ? isExternal(website) : false;
|
||||
const eager = index < eagerThumbnailCount;
|
||||
|
|
@ -70,8 +67,8 @@ function websiteUrl(project: CollectionEntry<'projects'>) {
|
|||
<VideoThumbnail
|
||||
class="entry-thumbnail project-thumbnail"
|
||||
variant="card"
|
||||
poster={project.data.thumbnail.src}
|
||||
alt={project.data.thumbnail.alt}
|
||||
poster={card.thumbnail.src}
|
||||
alt={card.thumbnail.alt}
|
||||
video={headerVideo}
|
||||
widths={PROJECT_THUMBNAIL.widths}
|
||||
sizes={PROJECT_THUMBNAIL.sizes}
|
||||
|
|
@ -80,8 +77,8 @@ function websiteUrl(project: CollectionEntry<'projects'>) {
|
|||
/>
|
||||
) : (
|
||||
<EntryThumbnail
|
||||
src={project.data.thumbnail.src}
|
||||
alt={project.data.thumbnail.alt}
|
||||
src={card.thumbnail.src}
|
||||
alt={card.thumbnail.alt}
|
||||
class="project-thumbnail"
|
||||
widths={PROJECT_THUMBNAIL.widths}
|
||||
sizes={PROJECT_THUMBNAIL.sizes}
|
||||
|
|
@ -91,8 +88,8 @@ function websiteUrl(project: CollectionEntry<'projects'>) {
|
|||
)}
|
||||
<article class="project-card__summary">
|
||||
<div class="project-card__head">
|
||||
<h3 id={titleId}>{project.data.title}</h3>
|
||||
<p class="project-description">{project.data.description}</p>
|
||||
<h3 id={titleId}>{card.title}</h3>
|
||||
<p class="project-description">{card.description}</p>
|
||||
</div>
|
||||
{(essayHref || website) && (
|
||||
<div class="project-card__actions">
|
||||
|
|
@ -100,7 +97,7 @@ function websiteUrl(project: CollectionEntry<'projects'>) {
|
|||
<a
|
||||
class="project-article-link"
|
||||
href={essayHref}
|
||||
aria-label={`Read the article about ${project.data.title}`}
|
||||
aria-label={`Read the article about ${card.title}`}
|
||||
>
|
||||
Article
|
||||
<span aria-hidden="true">→</span>
|
||||
|
|
@ -114,8 +111,8 @@ function websiteUrl(project: CollectionEntry<'projects'>) {
|
|||
target={websiteExternal ? '_blank' : undefined}
|
||||
aria-label={
|
||||
websiteExternal
|
||||
? `Open the ${project.data.title} site in a new tab`
|
||||
: `Open ${project.data.title}`
|
||||
? `Open the ${card.title} site in a new tab`
|
||||
: `Open ${card.title}`
|
||||
}
|
||||
>
|
||||
Open
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { defineCollection, reference } from 'astro:content';
|
||||
import { defineCollection } from 'astro:content';
|
||||
import type { SchemaContext } from 'astro:content';
|
||||
import { glob } from 'astro/loaders';
|
||||
import { z } from 'astro/zod';
|
||||
|
|
@ -41,6 +41,17 @@ function isIframeUrl(url: string) {
|
|||
}
|
||||
}
|
||||
|
||||
export const TAGS = [
|
||||
'ai',
|
||||
'systems',
|
||||
'graphics',
|
||||
'simulation',
|
||||
'embedded',
|
||||
'web',
|
||||
'tools',
|
||||
'games',
|
||||
] as const;
|
||||
|
||||
const linkSchema = z.object({
|
||||
label: z.string(),
|
||||
url: linkUrl,
|
||||
|
|
@ -53,6 +64,15 @@ const thumbnailSchema = ({ image }: SchemaContext) =>
|
|||
alt: z.string().min(1, 'Thumbnail alt text must not be empty.'),
|
||||
});
|
||||
|
||||
// Partial thumbnail used by the project card to override just the image, just
|
||||
// the alt text, or both. Anything omitted falls back to the entry's top-level
|
||||
// thumbnail, so a card that only rewords its alt doesn't repeat the `src`.
|
||||
const thumbnailOverrideSchema = ({ image }: SchemaContext) =>
|
||||
z.object({
|
||||
src: image().optional(),
|
||||
alt: z.string().min(1, 'Thumbnail alt text must not be empty.').optional(),
|
||||
});
|
||||
|
||||
const mediaSchema = ({ image }: SchemaContext) =>
|
||||
z
|
||||
.discriminatedUnion('type', [
|
||||
|
|
@ -97,73 +117,72 @@ const mediaSchema = ({ image }: SchemaContext) =>
|
|||
}
|
||||
);
|
||||
|
||||
const posts = defineCollection({
|
||||
loader: glob({ pattern: '**/*.md', base: './src/content/posts' }),
|
||||
schema: ({ image }) =>
|
||||
z
|
||||
.object({
|
||||
title: z.string(),
|
||||
description: z.string().max(160),
|
||||
date: z.coerce.date(),
|
||||
// A single collection where each entry can carry an `article` facet (a written
|
||||
// page under /articles/<slug>), a `project` facet (a card in the /projects/
|
||||
// index), or both. Fields shared by both facets live at the top level and are
|
||||
// written once; the few where the project card deliberately differs from the
|
||||
// article are overridden inside `project`. The markdown body is the article's
|
||||
// content.
|
||||
const articleFacet = ({ image }: SchemaContext) =>
|
||||
z.object({
|
||||
updated: z.coerce.date().optional(),
|
||||
draft: z.boolean().default(false),
|
||||
thumbnail: thumbnailSchema({ image }),
|
||||
iframeThumbnail: z.boolean().default(false),
|
||||
tags: z.array(
|
||||
z.enum([
|
||||
'ai',
|
||||
'systems',
|
||||
'graphics',
|
||||
'simulation',
|
||||
'embedded',
|
||||
'web',
|
||||
'tools',
|
||||
'games',
|
||||
])
|
||||
),
|
||||
featuredOrder: z.number().optional(),
|
||||
projectPeriod: z.string().optional(),
|
||||
tags: z.array(z.enum(TAGS)).default([]),
|
||||
role: z.string().optional(),
|
||||
stack: z.array(z.string()).optional(),
|
||||
scale: z.string().optional(),
|
||||
outcome: z.string().optional(),
|
||||
audience: z
|
||||
.enum(['general', 'technical', 'recruiter-relevant'])
|
||||
.default('technical'),
|
||||
links: z.array(linkSchema).default([]),
|
||||
audience: z.enum(['general', 'technical', 'recruiter-relevant']).default('technical'),
|
||||
media: z.array(mediaSchema({ image })).default([]),
|
||||
});
|
||||
|
||||
const projectFacet = ({ image }: SchemaContext) =>
|
||||
z.object({
|
||||
selected: z.boolean().default(false),
|
||||
// Card chips. When omitted, the card falls back to the article's `stack`.
|
||||
technologies: z.array(z.string()).optional(),
|
||||
// Card overrides. When omitted, the card uses the top-level value.
|
||||
title: z.string().optional(),
|
||||
description: z.string().max(160).optional(),
|
||||
thumbnail: thumbnailOverrideSchema({ image }).optional(),
|
||||
});
|
||||
|
||||
const work = defineCollection({
|
||||
loader: glob({ pattern: '**/*.md', base: './src/content/work' }),
|
||||
schema: ({ image }) =>
|
||||
z
|
||||
.object({
|
||||
// Shared identity (single source of truth).
|
||||
title: z.string(),
|
||||
description: z.string().max(160),
|
||||
thumbnail: thumbnailSchema({ image }),
|
||||
period: z.string().optional(),
|
||||
date: z.coerce.date(),
|
||||
links: z.array(linkSchema).default([]),
|
||||
|
||||
article: articleFacet({ image }).optional(),
|
||||
project: projectFacet({ image }).optional(),
|
||||
})
|
||||
.refine((entry) => Boolean(entry.article) || Boolean(entry.project), {
|
||||
message: 'An entry needs at least an `article` or a `project` facet.',
|
||||
})
|
||||
.refine(
|
||||
(post) =>
|
||||
!post.iframeThumbnail ||
|
||||
post.links.some(
|
||||
(entry) =>
|
||||
!entry.article?.iframeThumbnail ||
|
||||
entry.links.some(
|
||||
(link) =>
|
||||
!link.download &&
|
||||
link.label.trim().toLowerCase() === 'demo' &&
|
||||
isIframeUrl(link.url)
|
||||
),
|
||||
{
|
||||
path: ['iframeThumbnail'],
|
||||
path: ['article', 'iframeThumbnail'],
|
||||
message:
|
||||
'iframeThumbnail requires a non-download Demo link with an https or root-relative URL.',
|
||||
}
|
||||
),
|
||||
});
|
||||
|
||||
const projects = defineCollection({
|
||||
loader: glob({ pattern: '**/*.md', base: './src/content/projects' }),
|
||||
schema: ({ image }) =>
|
||||
z.object({
|
||||
title: z.string(),
|
||||
description: z.string().max(160),
|
||||
thumbnail: thumbnailSchema({ image }),
|
||||
period: z.string(),
|
||||
sortDate: z.coerce.date(),
|
||||
technologies: z.array(z.string()).default([]),
|
||||
selected: z.boolean().default(false),
|
||||
essay: reference('posts').optional(),
|
||||
links: z.array(linkSchema).default([]),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = { posts, projects };
|
||||
export const collections = { work };
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 301 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 3.4 MiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 243 KiB |
|
Before Width: | Height: | Size: 377 KiB |
|
Before Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
|
@ -1,47 +0,0 @@
|
|||
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#4A90E2;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#357ABD;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Background circle -->
|
||||
<circle cx="100" cy="100" r="90" fill="url(#grad1)" opacity="0.15"/>
|
||||
|
||||
<!-- Main vault icon -->
|
||||
<g transform="translate(100, 100)">
|
||||
<!-- Vault body -->
|
||||
<rect x="-45" y="-50" width="90" height="80" rx="8" fill="none" stroke="url(#grad1)" stroke-width="6"/>
|
||||
|
||||
<!-- Vault door circle -->
|
||||
<circle cx="0" cy="-10" r="22" fill="none" stroke="url(#grad1)" stroke-width="5"/>
|
||||
<circle cx="0" cy="-10" r="14" fill="none" stroke="url(#grad1)" stroke-width="3"/>
|
||||
<circle cx="0" cy="-10" r="6" fill="url(#grad1)"/>
|
||||
|
||||
<!-- Vault handle -->
|
||||
<line x1="0" y1="-4" x2="18" y2="-4" stroke="url(#grad1)" stroke-width="3" stroke-linecap="round"/>
|
||||
<circle cx="18" cy="-4" r="4" fill="url(#grad1)"/>
|
||||
|
||||
<!-- Link chain -->
|
||||
<g opacity="0.9">
|
||||
<!-- Left link -->
|
||||
<ellipse cx="-30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
|
||||
<!-- Right link -->
|
||||
<ellipse cx="30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
|
||||
<!-- Center link connecting them -->
|
||||
<ellipse cx="0" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
|
||||
</g>
|
||||
|
||||
<!-- Sync arrows (subtle) -->
|
||||
<g opacity="0.5">
|
||||
<!-- Clockwise arrow top-right -->
|
||||
<path d="M 35 -35 Q 50 -35 50 -20 L 50 -15" fill="none" stroke="url(#grad1)" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<polygon points="50,-15 47,-22 53,-22" fill="url(#grad1)"/>
|
||||
|
||||
<!-- Counter-clockwise arrow bottom-left -->
|
||||
<path d="M -35 25 Q -50 25 -50 10 L -50 5" fill="none" stroke="url(#grad1)" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<polygon points="-50,5 -47,12 -53,12" fill="url(#grad1)"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2 KiB |
|
|
@ -1,15 +0,0 @@
|
|||
---
|
||||
title: Ad Astra
|
||||
description: 'A handheld game built from a custom PCB up: ATtiny85V, OLED, IR, EEPROM. 8-bit ALU at 8 MHz, 50 FPS floor.'
|
||||
thumbnail:
|
||||
src: ./_assets/ad-astra.jpg
|
||||
alt: The Ad Astra handheld game running on its OLED display.
|
||||
period: 'Spring 2020'
|
||||
sortDate: 2020-04-01
|
||||
technologies: ['C', 'ATtiny85V', 'OLED', 'EEPROM', 'PCB design']
|
||||
selected: true
|
||||
essay: ad-astra-attiny85-game-engine
|
||||
links:
|
||||
- label: Source
|
||||
url: https://github.com/schmelczer/ad_astra
|
||||
---
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
---
|
||||
title: Avoid
|
||||
description: My first browser game, kept around so the timeline is honest.
|
||||
thumbnail:
|
||||
src: ./_assets/avoid.jpg
|
||||
alt: Screenshot of the Avoid canvas game.
|
||||
period: 'January 2018'
|
||||
sortDate: 2018-01-01
|
||||
technologies: ['JavaScript', 'Canvas']
|
||||
selected: false
|
||||
essay: avoid-early-web-game
|
||||
---
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
---
|
||||
title: Backup Container
|
||||
description: A Bash container around BorgBackup. BTRFS snapshot for atomic consistency, numeric env vars for multi-target 3-2-1, sleep-loop instead of cron.
|
||||
thumbnail:
|
||||
src: ./_assets/backup.png
|
||||
alt: Placeholder thumbnail for the backup container project.
|
||||
period: '2024-2026'
|
||||
sortDate: 2024-06-01
|
||||
technologies: ['Bash', 'BorgBackup', 'BTRFS', 'Alpine', 'Docker', 'SSH', 'zstd']
|
||||
selected: false
|
||||
essay: backup-container-btrfs-borg
|
||||
links:
|
||||
- label: Source
|
||||
url: https://github.com/schmelczer/backup-container
|
||||
- label: Container image
|
||||
url: https://github.com/schmelczer/backup-container/pkgs/container/backup-container
|
||||
---
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
---
|
||||
title: City Simulation
|
||||
description: A Unity city where REST-controlled traffic lights made bad PLC code visible as car crashes.
|
||||
thumbnail:
|
||||
src: ./_assets/city-simulation.jpg
|
||||
alt: Screenshot of a Unity city traffic simulation.
|
||||
period: 'July-August 2018'
|
||||
sortDate: 2018-08-01
|
||||
technologies: ['Unity', 'C#', 'REST API', 'Blender']
|
||||
selected: false
|
||||
essay: city-simulation-unity-traffic
|
||||
links: []
|
||||
---
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
---
|
||||
title: Photo Colour Grader
|
||||
description: Pick a colour, edit every nearby colour as a function of distance. A grader built around one interaction idea.
|
||||
thumbnail:
|
||||
src: ./_assets/photo-colour-grader.jpg
|
||||
alt: Screenshot of a colour grading interface applied to a photograph.
|
||||
period: 'June 2018'
|
||||
sortDate: 2018-06-01
|
||||
technologies: ['JavaScript', 'Canvas', 'Image processing']
|
||||
selected: false
|
||||
essay: photo-colour-grader
|
||||
links: []
|
||||
---
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
---
|
||||
title: decla.red
|
||||
description: Browser multiplayer where the client and server linked the same TypeScript rules module. Concurrency bugs you can't have are bugs you don't have.
|
||||
thumbnail:
|
||||
src: ./_assets/declared.jpg
|
||||
alt: The decla.red browser game interface showing a space scene.
|
||||
period: 'Autumn-Winter 2020'
|
||||
sortDate: 2020-11-01
|
||||
technologies: ['TypeScript', 'Node.js', 'WebSockets', 'Firebase', 'WebGL']
|
||||
selected: true
|
||||
essay: declared-shared-simulation-code
|
||||
links:
|
||||
- label: Source
|
||||
url: https://github.com/schmelczer/decla.red
|
||||
- label: BSc thesis
|
||||
url: /media/downloads/sdf2d-andras-schmelczer.pdf
|
||||
download: true
|
||||
---
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
---
|
||||
title: Fizika
|
||||
description: 'I needed it for my own physics érettségi: 659 past-paper questions, jQuery, localStorage, no accounts. Eight years on, students still find it.'
|
||||
thumbnail:
|
||||
src: ./_assets/fizika.jpg
|
||||
alt: Screenshot of the Fizika practice app showing topic-selection buttons.
|
||||
period: '2017-2018'
|
||||
sortDate: 2018-05-01
|
||||
technologies: ['jQuery', 'HTML/CSS', 'Node/Express', 'JSON', 'localStorage']
|
||||
selected: false
|
||||
essay: fizika-erettsegi-practice-app
|
||||
links:
|
||||
- label: Live
|
||||
url: https://fizika.schmelczer.dev
|
||||
- label: Source
|
||||
url: https://home.schmelczer.dev/git/andras/fizika
|
||||
---
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
---
|
||||
title: Fleeting Garden
|
||||
description: A single-file WebGPU drawing toy. Your strokes seed a swarm; nine numbers per vibe give each preset its personality.
|
||||
thumbnail:
|
||||
src: ./_assets/fleeting-garden.jpg
|
||||
alt: A kaleidoscopic Fleeting Garden snapshot of cyan, violet, and yellow agent trails radiating from a central knot.
|
||||
period: '2026'
|
||||
sortDate: 2026-05-01
|
||||
technologies: ['TypeScript', 'WebGPU', 'WGSL', 'Compute shaders', 'Vite', 'Tweakpane']
|
||||
selected: true
|
||||
essay: fleeting-garden-webgpu-drawing
|
||||
links:
|
||||
- label: Demo
|
||||
url: /fleeting/
|
||||
- label: Source
|
||||
url: https://home.schmelczer.dev/git/andras/webgpu
|
||||
---
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
---
|
||||
title: Foreign Exchange Prediction Experiment
|
||||
description: A Hanning-windowed STFT experiment on EUR/USD. Passable backtest, sober conclusions, no real money risked.
|
||||
thumbnail:
|
||||
src: ./_assets/forex.jpg
|
||||
alt: Chart from a foreign exchange prediction experiment.
|
||||
period: 'Autumn 2019'
|
||||
sortDate: 2019-10-01
|
||||
technologies: ['Python', 'NumPy', 'SciPy', 'Flask', 'MQL4']
|
||||
selected: false
|
||||
essay: foreign-exchange-prediction-experiment
|
||||
links: []
|
||||
---
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
---
|
||||
title: Frame
|
||||
description: A LAN-only e-ink photo frame. Pulls from self-hosted Immich, gated on Home Assistant presence, Atkinson-dithered to 6 colours, no cloud.
|
||||
thumbnail:
|
||||
src: ./_assets/frame.jpg
|
||||
alt: The e-ink frame on the wall showing a dithered landscape scene with the capture age and EXIF location painted into the bottom corners.
|
||||
period: '2026'
|
||||
sortDate: 2026-05-01
|
||||
technologies:
|
||||
[
|
||||
'Python',
|
||||
'Raspberry Pi Zero 2W',
|
||||
'Waveshare PhotoPainter',
|
||||
'Immich',
|
||||
'Home Assistant',
|
||||
'numba',
|
||||
'Atkinson dither',
|
||||
]
|
||||
selected: true
|
||||
essay: frame-eink-photo-display
|
||||
links:
|
||||
- label: Source
|
||||
url: https://home.schmelczer.dev/git/andras/frame
|
||||
---
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
---
|
||||
title: GreatAI
|
||||
description: One decorator on a Python function turned it into a deployed ML service. MSc thesis with a survey to back the API choices.
|
||||
thumbnail:
|
||||
src: ./_assets/great-ai.png
|
||||
alt: Example Python code using the GreatAI API.
|
||||
period: '2022'
|
||||
sortDate: 2022-01-01
|
||||
technologies: ['Python', 'ML deployment', 'API design']
|
||||
selected: true
|
||||
essay: greatai-ai-deployment-api
|
||||
links:
|
||||
- label: PyPI
|
||||
url: https://pypi.org/project/great-ai/
|
||||
- label: Project site
|
||||
url: https://great-ai.scoutinscience.com
|
||||
- label: MSc thesis
|
||||
url: /media/downloads/great-ai-andras-schmelczer.pdf
|
||||
download: true
|
||||
---
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
---
|
||||
title: Lights Synchronized to Music
|
||||
description: Raspberry Pi music player, NumPy FFT, MOSFETs, RGB strips. The first thing I built that I actually finished.
|
||||
thumbnail:
|
||||
src: ./_assets/leds.jpg
|
||||
alt: RGB LED strips glowing from a music synchronization project.
|
||||
period: 'Spring 2016'
|
||||
sortDate: 2016-04-01
|
||||
technologies: ['Python', 'NumPy', 'FFT', 'Raspberry Pi', 'MOSFETs', 'vanilla web']
|
||||
selected: false
|
||||
essay: lights-synchronized-to-music
|
||||
links: []
|
||||
---
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
---
|
||||
title: My Notes
|
||||
description: A small Android Markdown note app. The point was a few weeks outside the web stack.
|
||||
thumbnail:
|
||||
src: ./_assets/my-notes.png
|
||||
alt: Screenshot of the My Notes Android markdown app.
|
||||
period: 'November 2019'
|
||||
sortDate: 2019-11-01
|
||||
technologies: ['Android', 'Markdown', 'Markwon']
|
||||
selected: false
|
||||
essay: my-notes-android-markdown-app
|
||||
links:
|
||||
- label: Source
|
||||
url: https://github.com/schmelczer/my-notes
|
||||
---
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
---
|
||||
title: Graph Editor
|
||||
description: A drag-and-drop JavaFX editor that let event organisers reconfigure the cooling sim without me sitting next to them.
|
||||
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
|
||||
technologies: ['JavaFX', 'JSON', 'REST API']
|
||||
selected: false
|
||||
essay: graph-editor-javafx-simulation-input
|
||||
links: []
|
||||
---
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
---
|
||||
title: Cooling System Simulation
|
||||
description: 'A live cooling-plant simulator for a PLC cybersecurity event. Flow as graph traversal and heat as a matrix solve: two passes instead of one PDE.'
|
||||
thumbnail:
|
||||
src: ./_assets/nuclear-simulation.jpg
|
||||
alt: Cooling system simulator interface with pipes, pumps, and temperature values.
|
||||
period: 'October-November 2018'
|
||||
sortDate: 2018-11-01
|
||||
technologies: ['Python', 'Flask', 'NumPy', 'HTML canvas', 'JavaFX']
|
||||
selected: true
|
||||
essay: nuclear-cooling-simulation
|
||||
links: []
|
||||
---
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
---
|
||||
title: Perfect Postcode
|
||||
description: A UK property-intelligence map. ~25M historical transactions, ~150 features per row, all u16-quantised in RAM, served from a single Rust binary.
|
||||
thumbnail:
|
||||
src: ./_assets/perfect-postcode.jpg
|
||||
alt: The Perfect Postcode dashboard with active filters on property type, price, transit time, and crime, showing a Manchester map with matching properties as a heatmap.
|
||||
period: '2026'
|
||||
sortDate: 2026-05-01
|
||||
technologies:
|
||||
[
|
||||
'Rust',
|
||||
'Axum',
|
||||
'Polars',
|
||||
'h3o',
|
||||
'rayon',
|
||||
'PocketBase',
|
||||
'PMTiles',
|
||||
'MapLibre',
|
||||
'deck.gl',
|
||||
'Conveyal R5',
|
||||
'Gemini',
|
||||
]
|
||||
selected: true
|
||||
essay: perfect-postcode-rust-property-server
|
||||
links:
|
||||
- label: Site
|
||||
url: https://perfect-postcode.co.uk
|
||||
---
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
---
|
||||
title: Photo Site Generator
|
||||
description: Point a Webpack script at a folder of photos, get a static site with responsive image variants. An excuse to walk with a camera.
|
||||
thumbnail:
|
||||
src: ./_assets/photos.jpg
|
||||
alt: Screenshot of a generated photography site.
|
||||
period: 'Summer 2016'
|
||||
sortDate: 2016-07-01
|
||||
technologies: ['Webpack', 'Image processing', 'Static site generation']
|
||||
selected: false
|
||||
essay: photo-site-generator
|
||||
links: []
|
||||
---
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
---
|
||||
title: Platform Game
|
||||
description: My Basics of Programming project. 3D voxel game in C and SDL 1.2. Pointers, learned painfully.
|
||||
thumbnail:
|
||||
src: ./_assets/platform-game.jpg
|
||||
alt: Screenshot from an early 3D platform game.
|
||||
period: 'Autumn 2017'
|
||||
sortDate: 2017-10-01
|
||||
technologies: ['C', 'SDL 1.2', 'Voxel terrain']
|
||||
selected: false
|
||||
essay: platform-game-c-sdl
|
||||
links: []
|
||||
---
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
---
|
||||
title: reconcile-text
|
||||
description: One Rust core, three packages. Merges Markdown notes from three editors I don't control, with no operation history. Never emits markers.
|
||||
thumbnail:
|
||||
src: ./_assets/reconcile.png
|
||||
alt: The reconcile-text logo and tagline "Conflict-free 3-way text merging".
|
||||
period: '2025'
|
||||
sortDate: 2025-05-01
|
||||
technologies: ['Rust', 'WebAssembly', 'Python', 'pyo3', 'wasm-bindgen', 'Myers diff']
|
||||
selected: true
|
||||
essay: reconcile-text-3-way-merge
|
||||
links:
|
||||
- label: Demo
|
||||
url: /reconcile/
|
||||
- label: Source
|
||||
url: https://github.com/schmelczer/reconcile
|
||||
- label: crates.io
|
||||
url: https://crates.io/crates/reconcile-text
|
||||
- label: npm
|
||||
url: https://www.npmjs.com/package/reconcile-text
|
||||
- label: PyPI
|
||||
url: https://pypi.org/project/reconcile-text/
|
||||
---
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
---
|
||||
title: SDF-2D
|
||||
description: A browser 2D ray-tracer tuned for the phone in your pocket. Tile-based passes, deferred shading, shaders generated per scene and device.
|
||||
thumbnail:
|
||||
src: ./_assets/sdf2d.jpg
|
||||
alt: SDF-2D browser demo with soft lighting effects.
|
||||
period: 'Autumn-Winter 2020'
|
||||
sortDate: 2020-12-01
|
||||
technologies: ['TypeScript', 'WebGL', 'WebGL2', 'Signed distance fields']
|
||||
selected: true
|
||||
essay: sdf-2d-ray-tracing
|
||||
links:
|
||||
- label: NPM package
|
||||
url: https://www.npmjs.com/package/sdf-2d
|
||||
- label: Video
|
||||
url: https://www.youtube.com/watch?v=K3cEtnZUNR0
|
||||
- label: BSc thesis
|
||||
url: /media/downloads/sdf2d-andras-schmelczer.pdf
|
||||
download: true
|
||||
---
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
---
|
||||
title: Life Towers
|
||||
description: A multi-device goal tracker. The trie underneath made the sync diff free; the towers were just the UI.
|
||||
thumbnail:
|
||||
src: ./_assets/towers.jpg
|
||||
alt: Life Towers goal tracking interface with tower-like visual structures.
|
||||
period: 'August-September 2019'
|
||||
sortDate: 2019-09-01
|
||||
technologies: ['Python', 'Angular', 'TypeScript', 'Immutable trie']
|
||||
selected: true
|
||||
essay: life-towers-immutable-tries
|
||||
links:
|
||||
- label: Source
|
||||
url: https://github.com/schmelczer/life-towers/
|
||||
---
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
---
|
||||
title: VaultLink
|
||||
description: 'I refuse to give up the editor: Obsidian, Vim, VS Code, sed. Self-hosted sync that survives all four, built on reconcile-text underneath.'
|
||||
thumbnail:
|
||||
src: ./_assets/vault-link.svg
|
||||
alt: 'The VaultLink logo: a chain-link mark in a soft gradient.'
|
||||
period: '2025-2026'
|
||||
sortDate: 2025-12-01
|
||||
technologies:
|
||||
[
|
||||
'Rust',
|
||||
'axum',
|
||||
'sqlx',
|
||||
'SQLite',
|
||||
'WebSockets',
|
||||
'TypeScript',
|
||||
'Obsidian plugin',
|
||||
'ts-rs',
|
||||
'wasm-bindgen',
|
||||
'reconcile-text',
|
||||
]
|
||||
selected: true
|
||||
essay: vault-link-obsidian-sync
|
||||
links:
|
||||
- label: Source
|
||||
url: https://github.com/schmelczer/vault-link
|
||||
- label: Docs
|
||||
url: https://vault-link.schmelczer.dev
|
||||
---
|
||||
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 127 KiB After Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 301 KiB After Width: | Height: | Size: 301 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 3.4 MiB After Width: | Height: | Size: 3.4 MiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 243 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 377 KiB After Width: | Height: | Size: 377 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 2 KiB After Width: | Height: | Size: 2 KiB |
|
|
@ -2,20 +2,21 @@
|
|||
title: A 50 FPS Game Engine on an 8-Bit Microcontroller
|
||||
description: 'A handheld game built from the PCB up: ATtiny85V, OLED, IR receiver, EEPROM, 8 MHz 8-bit ALU. 50 FPS floor.'
|
||||
date: 2026-05-06
|
||||
projectPeriod: 'Spring 2020'
|
||||
period: 'Spring 2020'
|
||||
thumbnail:
|
||||
src: ./_assets/ad-astra.jpg
|
||||
alt: The Ad Astra game running on a small OLED display.
|
||||
tags: ['embedded', 'games', 'systems']
|
||||
role: Hardware and firmware author
|
||||
stack: ['C', 'ATtiny85V', 'SPI OLED', 'IR receiver', 'EEPROM', 'KiCad']
|
||||
scale: 8 MHz, 8-bit ALU, ~31 mW at full brightness, ~1.5 mA standby, 15–20 ms frame budget
|
||||
outcome: A handheld built from schematic to firmware, with a 50 FPS game on it
|
||||
audience: technical
|
||||
links:
|
||||
- label: Source
|
||||
url: https://github.com/schmelczer/ad_astra
|
||||
media:
|
||||
article:
|
||||
tags: ['embedded', 'games', 'systems']
|
||||
role: Hardware and firmware author
|
||||
stack: ['C', 'ATtiny85V', 'SPI OLED', 'IR receiver', 'EEPROM', 'KiCad']
|
||||
scale: 8 MHz, 8-bit ALU, ~31 mW at full brightness, ~1.5 mA standby, 15–20 ms frame budget
|
||||
outcome: A handheld built from schematic to firmware, with a 50 FPS game on it
|
||||
audience: technical
|
||||
media:
|
||||
- type: video
|
||||
poster: ./_assets/ad-astra.jpg
|
||||
webm: /media/video/ad_astra.webm
|
||||
|
|
@ -24,6 +25,13 @@ media:
|
|||
alt: Video demonstration of the embedded game running on a small OLED display.
|
||||
caption: The whole thing, from board and firmware to sprites and game loop, runs on a single ATtiny85V at 8 MHz.
|
||||
transcript: No spoken dialogue. The handheld board runs its OLED game; the player moves through the small display while the IR input controls gameplay.
|
||||
project:
|
||||
title: Ad Astra
|
||||
description: 'A handheld game built from a custom PCB up: ATtiny85V, OLED, IR, EEPROM. 8-bit ALU at 8 MHz, 50 FPS floor.'
|
||||
selected: true
|
||||
technologies: ['C', 'ATtiny85V', 'OLED', 'EEPROM', 'PCB design']
|
||||
thumbnail:
|
||||
alt: The Ad Astra handheld game running on its OLED display.
|
||||
---
|
||||
|
||||
I'd done microcontroller work on dev boards before and it always felt like I was renting the hardware. As soon as I had a real board with my own soldering on it, bugs stopped feeling like software inconveniences and started feeling like consequences of choices I'd made in KiCad. That shift was most of the value of doing it this way. Four years on from [my first hardware project](/articles/lights-synchronized-to-music/), the lesson was that owning the whole stack down to the copper changes how you debug.
|
||||
|
|
@ -2,15 +2,20 @@
|
|||
title: Avoid
|
||||
description: My first browser game. Tiny, archived for honesty.
|
||||
date: 2026-04-29
|
||||
projectPeriod: 'January 2018'
|
||||
period: 'January 2018'
|
||||
thumbnail:
|
||||
src: ./_assets/avoid.jpg
|
||||
alt: Screenshot of the Avoid web game.
|
||||
tags: ['games', 'web']
|
||||
role: Game author
|
||||
stack: ['JavaScript', 'Canvas']
|
||||
outcome: My first browser game; kept for the timeline
|
||||
audience: general
|
||||
article:
|
||||
tags: ['games', 'web']
|
||||
role: Game author
|
||||
stack: ['JavaScript', 'Canvas']
|
||||
outcome: My first browser game; kept for the timeline
|
||||
audience: general
|
||||
project:
|
||||
description: My first browser game, kept around so the timeline is honest.
|
||||
thumbnail:
|
||||
alt: Screenshot of the Avoid canvas game.
|
||||
---
|
||||
|
||||
Keeping it here because pretending the older work didn't happen would be dishonest. The first browser game I wrote, January 2018. It isn't good, but it was the moment a `<canvas>` element stopped being mysterious.
|
||||
|
|
@ -2,21 +2,27 @@
|
|||
title: Backing Up Running Databases Without Stopping Them
|
||||
description: A Bash container around BorgBackup. BTRFS snapshots give atomic consistency, numeric env vars give multi-target 3-2-1, the loop is sleep not cron.
|
||||
date: 2026-05-29
|
||||
projectPeriod: '2024-2026'
|
||||
period: '2024-2026'
|
||||
thumbnail:
|
||||
src: ./_assets/backup.png
|
||||
alt: Placeholder thumbnail for the backup container post.
|
||||
tags: ['systems', 'tools']
|
||||
role: Container and script author
|
||||
stack: ['Bash', 'BorgBackup', 'BTRFS', 'Alpine', 'Docker', 'SSH', 'zstd']
|
||||
scale: One container, multiple targets per host, two years of restored incidents
|
||||
outcome: A self-hosted backup that has survived every actual incident I've thrown at it
|
||||
audience: technical
|
||||
links:
|
||||
- label: Source
|
||||
url: https://github.com/schmelczer/backup-container
|
||||
- label: Container image
|
||||
url: https://github.com/schmelczer/backup-container/pkgs/container/backup-container
|
||||
article:
|
||||
tags: ['systems', 'tools']
|
||||
role: Container and script author
|
||||
stack: ['Bash', 'BorgBackup', 'BTRFS', 'Alpine', 'Docker', 'SSH', 'zstd']
|
||||
scale: One container, multiple targets per host, two years of restored incidents
|
||||
outcome: A self-hosted backup that has survived every actual incident I've thrown at it
|
||||
audience: technical
|
||||
project:
|
||||
title: Backup Container
|
||||
description: A Bash container around BorgBackup. BTRFS snapshot for atomic consistency, numeric env vars for multi-target 3-2-1, sleep-loop instead of cron.
|
||||
thumbnail:
|
||||
alt: Placeholder thumbnail for the backup container project.
|
||||
---
|
||||
|
||||
Once you self-host a few services with live databases, the backup question stops being theoretical. A Postgres or SQLite file half-written when `tar` reads it goes into the archive in a state nothing on Earth will replay; you just don't find out until the restore. Two years in, with multiple incidents I had to actually recover from (including the photos behind the [e-ink frame](/articles/frame-eink-photo-display/)), I trust this stack precisely because the correctness argument is short: BTRFS gives me an atomic snapshot, and everything above it can be a shell script. One Alpine container, ~75 lines of Bash, pushes that snapshot to one or more [Borg](https://borgbackup.readthedocs.io/) repositories on a fixed interval. Multi-target is numeric env vars (`BORG_REPO_0`, `BORG_REPO_1`, ...). No config format, no DSL; the env file is the configuration.
|
||||
|
|
@ -2,16 +2,21 @@
|
|||
title: A Unity City Where Bad PLC Code Made Cars Crash
|
||||
description: A REST-controlled traffic-light sim for a cybersecurity event. Bad PLC code showed up as car crashes, the most honest feedback loop I've shipped.
|
||||
date: 2026-05-01
|
||||
projectPeriod: 'July-August 2018'
|
||||
period: 'July-August 2018'
|
||||
thumbnail:
|
||||
src: ./_assets/city-simulation.jpg
|
||||
alt: Screenshot of a Unity traffic simulation.
|
||||
tags: ['simulation', 'systems']
|
||||
role: Simulation author
|
||||
stack: ['Unity', 'C#', 'REST API', 'Blender']
|
||||
outcome: Visible consequences for an otherwise abstract PLC challenge
|
||||
audience: technical
|
||||
links: []
|
||||
article:
|
||||
tags: ['simulation', 'systems']
|
||||
role: Simulation author
|
||||
stack: ['Unity', 'C#', 'REST API', 'Blender']
|
||||
outcome: Visible consequences for an otherwise abstract PLC challenge
|
||||
audience: technical
|
||||
project:
|
||||
title: City Simulation
|
||||
description: A Unity city where REST-controlled traffic lights made bad PLC code visible as car crashes.
|
||||
thumbnail:
|
||||
alt: Screenshot of a Unity city traffic simulation.
|
||||
---
|
||||
|
||||
Most security challenges punish wrong answers with a red "incorrect." This one punished them with car wrecks, and people learned faster. A PLC cybersecurity event in the summer of 2018 needed something visceral; I built a small Unity city where the traffic lights were driven by a REST API and contestants wrote the control logic.
|
||||
|
|
@ -2,27 +2,35 @@
|
|||
title: One Game Library, Imported by Both the Client and the Server
|
||||
description: A mobile multiplayer browser game where client and server linked the same TypeScript module. One source of truth, one fewer class of bug.
|
||||
date: 2026-05-07
|
||||
projectPeriod: 'Autumn-Winter 2020'
|
||||
period: 'Autumn-Winter 2020'
|
||||
thumbnail:
|
||||
src: ./_assets/decla-red.jpg
|
||||
alt: The decla.red browser game interface showing a space scene.
|
||||
tags: ['games', 'web', 'systems']
|
||||
role: Game and backend systems author
|
||||
stack: ['TypeScript', 'Node.js', 'WebSockets', 'Firebase', 'WebGL', 'SDF-2D']
|
||||
scale: Multiple game servers, each talking to 16–32 clients, browser and mobile
|
||||
outcome: A multiplayer browser game that proved SDF-2D survived a real game loop
|
||||
audience: technical
|
||||
links:
|
||||
- label: Source
|
||||
url: https://github.com/schmelczer/decla.red
|
||||
- label: BSc thesis
|
||||
url: /media/downloads/sdf2d-andras-schmelczer.pdf
|
||||
download: true
|
||||
media:
|
||||
article:
|
||||
tags: ['games', 'web', 'systems']
|
||||
role: Game and backend systems author
|
||||
stack: ['TypeScript', 'Node.js', 'WebSockets', 'Firebase', 'WebGL', 'SDF-2D']
|
||||
scale: Multiple game servers, each talking to 16–32 clients, browser and mobile
|
||||
outcome: A multiplayer browser game that proved SDF-2D survived a real game loop
|
||||
audience: technical
|
||||
media:
|
||||
- type: image
|
||||
src: ./_assets/decla-red.jpg
|
||||
alt: The decla.red browser game interface showing a space scene with team controls and planets.
|
||||
caption: A real game loop is a worse audience than a tech demo. That's the point.
|
||||
project:
|
||||
title: decla.red
|
||||
description: Browser multiplayer where the client and server linked the same TypeScript rules module. Concurrency bugs you can't have are bugs you don't have.
|
||||
selected: true
|
||||
technologies: ['TypeScript', 'Node.js', 'WebSockets', 'Firebase', 'WebGL']
|
||||
thumbnail:
|
||||
src: ./_assets/declared.jpg
|
||||
---
|
||||
|
||||
My thesis was a renderer; proving it in a real multiplayer loop was the point. A real game loop is a worse audience than a tech demo. That's the point. So through autumn 2020 I built decla.red on top of [SDF-2D](/articles/sdf-2d-ray-tracing/): a conquest-style space shooter, two teams, small planets, ray-traced 2D rendering, browser and mobile. The architecture decision worth remembering came out of needing the server and the client to stop lying to each other: one TypeScript module containing the game rules, linked by both sides of the wire.
|
||||
|
|
@ -2,20 +2,27 @@
|
|||
title: A Physics Practice App for the Hungarian Érettségi
|
||||
description: A static jQuery site I built in high school to drill past exam questions. 659 questions, a decade of past papers, still online and still used.
|
||||
date: 2026-05-28
|
||||
projectPeriod: '2017-2018'
|
||||
period: '2017-2018'
|
||||
thumbnail:
|
||||
src: ./_assets/fizika.jpg
|
||||
alt: Screenshot of the Fizika practice app showing topic-selection buttons over a light textured background.
|
||||
tags: ['web', 'tools']
|
||||
role: Question database, frontend, backend
|
||||
stack: ['jQuery', 'vanilla HTML/CSS', 'Node/Express', 'JSON', 'localStorage']
|
||||
outcome: A free practice app real students still find when they search for past érettségi physics papers
|
||||
audience: general
|
||||
links:
|
||||
- label: Live
|
||||
url: https://fizika.schmelczer.dev
|
||||
- label: Source
|
||||
url: https://home.schmelczer.dev/git/andras/fizika
|
||||
article:
|
||||
tags: ['web', 'tools']
|
||||
role: Question database, frontend, backend
|
||||
stack: ['jQuery', 'vanilla HTML/CSS', 'Node/Express', 'JSON', 'localStorage']
|
||||
outcome: A free practice app real students still find when they search for past érettségi physics papers
|
||||
audience: general
|
||||
project:
|
||||
title: Fizika
|
||||
description: 'I needed it for my own physics érettségi: 659 past-paper questions, jQuery, localStorage, no accounts. Eight years on, students still find it.'
|
||||
technologies: ['jQuery', 'HTML/CSS', 'Node/Express', 'JSON', 'localStorage']
|
||||
thumbnail:
|
||||
alt: Screenshot of the Fizika practice app showing topic-selection buttons.
|
||||
---
|
||||
|
||||
I needed it. In my last year of high school I was about to sit the _emelt szintű_ (advanced-level) physics érettségi, and the practice material I could find online was either paywalled or scattered across PDFs that wouldn't tell you whether your answer was right. So one evening I started typing past exam questions into a JSON file. A few weeks later I had something resembling a study tool, and a few weeks after that I had 659 questions covering more than a decade of past papers.
|
||||
|
|
@ -2,27 +2,32 @@
|
|||
title: A WebGPU Drawing Garden Where Agents Rewrite Your Strokes
|
||||
description: A single-file WebGPU drawing toy. You stroke a colour, agents follow it, and a 3×3 matrix per vibe gives each preset its personality.
|
||||
date: 2026-05-22
|
||||
projectPeriod: '2026'
|
||||
period: '2026'
|
||||
thumbnail:
|
||||
src: ./_assets/fleeting-garden.jpg
|
||||
alt: A kaleidoscopic Fleeting Garden snapshot of cyan, violet, and yellow agent trails radiating from a central knot.
|
||||
iframeThumbnail: true
|
||||
tags: ['graphics', 'simulation', 'web']
|
||||
role: Graphics and shader author
|
||||
stack: ['TypeScript', 'WebGPU', 'WGSL', 'Compute shaders', 'Vite', 'Tweakpane']
|
||||
scale: One HTML file, ~10 WGSL shaders, 6 vibe presets, 60 FPS target on consumer hardware
|
||||
outcome: A browser drawing toy where user strokes seed an agent simulation that overwrites them
|
||||
audience: technical
|
||||
links:
|
||||
- label: Demo
|
||||
url: /fleeting/
|
||||
- label: Source
|
||||
url: https://home.schmelczer.dev/git/andras/webgpu
|
||||
media:
|
||||
article:
|
||||
iframeThumbnail: true
|
||||
tags: ['graphics', 'simulation', 'web']
|
||||
role: Graphics and shader author
|
||||
stack: ['TypeScript', 'WebGPU', 'WGSL', 'Compute shaders', 'Vite', 'Tweakpane']
|
||||
scale: One HTML file, ~10 WGSL shaders, 6 vibe presets, 60 FPS target on consumer hardware
|
||||
outcome: A browser drawing toy where user strokes seed an agent simulation that overwrites them
|
||||
audience: technical
|
||||
media:
|
||||
- type: image
|
||||
src: ./_assets/fleeting-garden.jpg
|
||||
alt: Close-up of intertwining cyan, violet, and yellow agent trails radiating into a kaleidoscopic central knot.
|
||||
caption: A snapshot from one session. What you see is the trail texture; the agents that drew it are already gone.
|
||||
project:
|
||||
title: Fleeting Garden
|
||||
description: A single-file WebGPU drawing toy. Your strokes seed a swarm; nine numbers per vibe give each preset its personality.
|
||||
selected: true
|
||||
---
|
||||
|
||||
Nine numbers in `{-1, 0, 1}` arranged in a 3×3 matrix decide an entire vibe's personality. That constraint is what kept me up: proving simplicity can be expressive, that you don't need a behaviour function per preset. A WebGPU drawing toy where you stroke a colour, agents spawn along it, and the garden slowly overwrites the patch you laid down. One static HTML file, six compute stages, none of them skippable.
|
||||
|
|
@ -2,16 +2,21 @@
|
|||
title: Predicting EUR/USD With Hanning Windows
|
||||
description: A weekend frequency-domain experiment that did a passable job on EUR/USD. I would not have trusted it with my money, and I didn't.
|
||||
date: 2026-05-03
|
||||
projectPeriod: 'Autumn 2019'
|
||||
period: 'Autumn 2019'
|
||||
thumbnail:
|
||||
src: ./_assets/forex.jpg
|
||||
alt: Chart comparing predicted and actual EUR/USD exchange rates.
|
||||
tags: ['systems', 'tools']
|
||||
role: Experiment author
|
||||
stack: ['Python', 'NumPy', 'SciPy', 'Flask', 'MQL4']
|
||||
outcome: A prediction server, an MQL4 trading client, and a clearer view of how far my edge wasn't
|
||||
audience: technical
|
||||
links: []
|
||||
article:
|
||||
tags: ['systems', 'tools']
|
||||
role: Experiment author
|
||||
stack: ['Python', 'NumPy', 'SciPy', 'Flask', 'MQL4']
|
||||
outcome: A prediction server, an MQL4 trading client, and a clearer view of how far my edge wasn't
|
||||
audience: technical
|
||||
project:
|
||||
title: Foreign Exchange Prediction Experiment
|
||||
description: A Hanning-windowed STFT experiment on EUR/USD. Passable backtest, sober conclusions, no real money risked.
|
||||
thumbnail:
|
||||
alt: Chart from a foreign exchange prediction experiment.
|
||||
---
|
||||
|
||||
In the autumn of 2019 I was an undergrad with a few weekends free and the quiet conviction that I could find a small edge on EUR/USD. The screenshots were flattering: the prediction (blue) hugged the actual rate (green) in a way that looked like skill. A linear regression in the frequency domain, dressed up. I did not trade real money with it, and that restraint is the only thing about the project that aged well.
|
||||
|
|
@ -2,13 +2,17 @@
|
|||
title: An E-Ink Photo Frame That Sleeps When the House Is Empty
|
||||
description: A Pi, a 6-colour e-ink panel, and a self-hosted Immich library. Photos picked by date and favourites, gated on Home Assistant presence, Atkinson-dithered.
|
||||
date: 2026-05-27
|
||||
projectPeriod: '2026'
|
||||
period: '2026'
|
||||
thumbnail:
|
||||
src: ./_assets/frame.jpg
|
||||
alt: The e-ink frame on the wall showing a dithered landscape scene with the capture age and EXIF location painted into the bottom corners.
|
||||
tags: ['embedded', 'systems', 'tools']
|
||||
role: Frame builder and pipeline author
|
||||
stack:
|
||||
links:
|
||||
- label: Source
|
||||
url: https://home.schmelczer.dev/git/andras/frame
|
||||
article:
|
||||
tags: ['embedded', 'systems', 'tools']
|
||||
role: Frame builder and pipeline author
|
||||
stack:
|
||||
[
|
||||
'Python',
|
||||
'Raspberry Pi Zero 2W',
|
||||
|
|
@ -18,17 +22,28 @@ stack:
|
|||
'numba',
|
||||
'Atkinson dither',
|
||||
]
|
||||
scale: One panel, one household, ~64 refreshes a day at peak
|
||||
outcome: A wall-mounted photo frame that pulls from self-hosted Immich, gated on home presence, with no cloud dependencies
|
||||
audience: general
|
||||
links:
|
||||
- label: Source
|
||||
url: https://home.schmelczer.dev/git/andras/frame
|
||||
media:
|
||||
scale: One panel, one household, ~64 refreshes a day at peak
|
||||
outcome: A wall-mounted photo frame that pulls from self-hosted Immich, gated on home presence, with no cloud dependencies
|
||||
audience: general
|
||||
media:
|
||||
- type: image
|
||||
src: ./_assets/frame.jpg
|
||||
alt: The frame on the wall showing a 6-colour Atkinson-dithered landscape scene, with "2 years ago" and a location label painted into the bottom corners.
|
||||
caption: The bottom corners carry the photo's age and EXIF location. Painted as text on top, so the dither can't smear them.
|
||||
project:
|
||||
title: Frame
|
||||
description: A LAN-only e-ink photo frame. Pulls from self-hosted Immich, gated on Home Assistant presence, Atkinson-dithered to 6 colours, no cloud.
|
||||
selected: true
|
||||
technologies:
|
||||
[
|
||||
'Python',
|
||||
'Raspberry Pi Zero 2W',
|
||||
'Waveshare PhotoPainter',
|
||||
'Immich',
|
||||
'Home Assistant',
|
||||
'numba',
|
||||
'Atkinson dither',
|
||||
]
|
||||
---
|
||||
|
||||
In 2024, researchers found family-blog photos of Brazilian children inside the LAION training set. Self-hosting your photos used to be a preference; it's a safeguarding decision now. Nixplay's cloud-tied frames have bricked. Funimation deleted libraries people had paid for. I wanted a photo frame on the hallway wall, and I wasn't going to hand the family album to a vendor who could close the doors on it.
|
||||
|
|
@ -2,16 +2,21 @@
|
|||
title: A JavaFX Editor for the Cooling Simulator
|
||||
description: Companion editor for the cooling-system sim. Drag-and-drop graph layout, JSON export, upload-to-backend. Small tool, mattered more than I expected.
|
||||
date: 2026-04-25
|
||||
projectPeriod: 'October-November 2018'
|
||||
period: 'October-November 2018'
|
||||
thumbnail:
|
||||
src: ./_assets/process-simulator-input.jpg
|
||||
alt: JavaFX graph editor for the cooling system simulator.
|
||||
tags: ['simulation', 'tools']
|
||||
role: Editor author
|
||||
stack: ['JavaFX', 'JSON', 'REST API']
|
||||
outcome: A drag-and-drop graph editor that let non-developers feed the simulator
|
||||
audience: technical
|
||||
links: []
|
||||
article:
|
||||
tags: ['simulation', 'tools']
|
||||
role: Editor author
|
||||
stack: ['JavaFX', 'JSON', 'REST API']
|
||||
outcome: A drag-and-drop graph editor that let non-developers feed the simulator
|
||||
audience: technical
|
||||
project:
|
||||
title: Graph Editor
|
||||
description: A drag-and-drop JavaFX editor that let event organisers reconfigure the cooling sim without me sitting next to them.
|
||||
thumbnail:
|
||||
alt: JavaFX editor interface for the cooling system simulator input graph.
|
||||
---
|
||||
|
||||
Non-technical event organisers needed to rewire a cooling plant in real time without me hovering. That was the brief, and it ruled out every interface I'd have enjoyed writing. The [cooling system sim](/articles/nuclear-cooling-simulation/) was only as useful as the tool that fed it, so in late 2018 I built a JavaFX desktop editor: lay out the plant as a graph, edit each element's parameters in a side panel, export JSON, or upload straight to the backend.
|
||||
|
|
@ -2,17 +2,10 @@
|
|||
title: A Python Framework Where Doing the Right Thing Is the Default
|
||||
description: My MSc thesis. 33 catalogued ML deployment habits, a decorator-shaped Python API, and a survey of working engineers on which actually got adopted.
|
||||
date: 2026-05-09
|
||||
projectPeriod: '2022'
|
||||
period: '2022'
|
||||
thumbnail:
|
||||
src: ./_assets/great-ai.png
|
||||
alt: Example Python code using the GreatAI API.
|
||||
tags: ['ai', 'systems', 'tools']
|
||||
featuredOrder: 1
|
||||
role: Researcher and framework author
|
||||
stack: ['Python', 'decorators', 'FastAPI', 'survey design']
|
||||
scale: 33 deployment habits surveyed, 6 proposed additions, framework evaluated by working data scientists and engineers
|
||||
outcome: A pip-installable framework, an MSc thesis, and one strong opinion about API surface area
|
||||
audience: recruiter-relevant
|
||||
links:
|
||||
- label: PyPI
|
||||
url: https://pypi.org/project/great-ai/
|
||||
|
|
@ -21,11 +14,24 @@ links:
|
|||
- label: MSc thesis
|
||||
url: /media/downloads/great-ai-andras-schmelczer.pdf
|
||||
download: true
|
||||
media:
|
||||
article:
|
||||
featuredOrder: 1
|
||||
tags: ['ai', 'systems', 'tools']
|
||||
role: Researcher and framework author
|
||||
stack: ['Python', 'decorators', 'FastAPI', 'survey design']
|
||||
scale: 33 deployment habits surveyed, 6 proposed additions, framework evaluated by working data scientists and engineers
|
||||
outcome: A pip-installable framework, an MSc thesis, and one strong opinion about API surface area
|
||||
audience: recruiter-relevant
|
||||
media:
|
||||
- type: image
|
||||
src: ./_assets/great-ai.png
|
||||
alt: Example Python code using GreatAI decorators and prediction helpers.
|
||||
caption: A working GreatAI service is about ten lines on top of a plain prediction function.
|
||||
project:
|
||||
title: GreatAI
|
||||
description: One decorator on a Python function turned it into a deployed ML service. MSc thesis with a survey to back the API choices.
|
||||
selected: true
|
||||
technologies: ['Python', 'ML deployment', 'API design']
|
||||
---
|
||||
|
||||
By the end of 2021 I had stopped believing the people skipping ML deployment best practices were the problem. They knew the list. They agreed with the list. They had a deadline, and every item on the list cost five lines of glue. My MSc thesis turned that into the actual research question: not "what should engineers do" but "what API shape makes doing the right thing cheaper than not." The framework that fell out, `great-ai`, is a decorator on a plain Python function. The thesis behind it is the part worth reading.
|
||||
|
|
@ -2,25 +2,31 @@
|
|||
title: Syncing State with an Immutable Trie
|
||||
description: 'A visual goal tracker whose lasting idea was the sync model: an immutable trie so structural diffs are trivial and only deltas cross the wire.'
|
||||
date: 2026-05-05
|
||||
projectPeriod: 'August-September 2019'
|
||||
period: 'August-September 2019'
|
||||
thumbnail:
|
||||
src: ./_assets/towers.jpg
|
||||
alt: Life Towers goal tracking interface with tower-like visual structures.
|
||||
tags: ['systems', 'web', 'tools']
|
||||
featuredOrder: 4
|
||||
role: Full-stack author
|
||||
stack: ['Python', 'Angular', 'TypeScript', 'Custom sync protocol']
|
||||
scale: Multi-device goal and task state shared between clients and a server
|
||||
outcome: A working sync protocol where structural sharing made the delta tiny
|
||||
audience: recruiter-relevant
|
||||
links:
|
||||
- label: Source
|
||||
url: https://github.com/schmelczer/life-towers/
|
||||
media:
|
||||
article:
|
||||
featuredOrder: 4
|
||||
tags: ['systems', 'web', 'tools']
|
||||
role: Full-stack author
|
||||
stack: ['Python', 'Angular', 'TypeScript', 'Custom sync protocol']
|
||||
scale: Multi-device goal and task state shared between clients and a server
|
||||
outcome: A working sync protocol where structural sharing made the delta tiny
|
||||
audience: recruiter-relevant
|
||||
media:
|
||||
- type: image
|
||||
src: ./_assets/towers.jpg
|
||||
alt: Screenshot of a life tracking web interface represented with tower-like visual structures.
|
||||
caption: The interface was a 2019 weekend experiment. The trie underneath aged better.
|
||||
project:
|
||||
title: Life Towers
|
||||
description: A multi-device goal tracker. The trie underneath made the sync diff free; the towers were just the UI.
|
||||
selected: true
|
||||
technologies: ['Python', 'Angular', 'TypeScript', 'Immutable trie']
|
||||
---
|
||||
|
||||
In August 2019 I wanted a goal tracker I'd actually open, on whichever device was nearest, without watching it disagree with itself. Nothing off the shelf fit, so I built one over a couple of weekends. The tower metaphor was the part friends saw; the part that aged well was the sync model that fell out of needing the same state in three places at once.
|
||||
|
|
@ -2,16 +2,21 @@
|
|||
title: 'My First Real Project: LEDs Driven by an FFT'
|
||||
description: A Raspberry Pi music player that drove RGB strips through MOSFETs. The first thing I started and actually finished.
|
||||
date: 2026-04-26
|
||||
projectPeriod: 'Spring 2016'
|
||||
period: 'Spring 2016'
|
||||
thumbnail:
|
||||
src: ./_assets/leds.jpg
|
||||
alt: RGB LED strips lit by a music synchronisation project.
|
||||
tags: ['systems', 'tools']
|
||||
role: Hardware and software author
|
||||
stack: ['Python', 'NumPy', 'FFT', 'Raspberry Pi', 'MOSFETs', 'vanilla web']
|
||||
outcome: The first non-trivial project I started and finished
|
||||
audience: technical
|
||||
links: []
|
||||
article:
|
||||
tags: ['systems', 'tools']
|
||||
role: Hardware and software author
|
||||
stack: ['Python', 'NumPy', 'FFT', 'Raspberry Pi', 'MOSFETs', 'vanilla web']
|
||||
outcome: The first non-trivial project I started and finished
|
||||
audience: technical
|
||||
project:
|
||||
title: Lights Synchronized to Music
|
||||
description: Raspberry Pi music player, NumPy FFT, MOSFETs, RGB strips. The first thing I built that I actually finished.
|
||||
thumbnail:
|
||||
alt: RGB LED strips glowing from a music synchronization project.
|
||||
---
|
||||
|
||||
Spring 2016. I had a Raspberry Pi, a couple of 12V RGB LED strips someone had given me, a handful of MOSFETs from an electronics kit, and zero idea what I was doing. I wired one of the MOSFETs backwards and it got hot enough to leave a small mark on the breadboard. I learned to read a datasheet, slowly, by needing one. This was the first thing I started and actually finished.
|
||||
|
|
@ -2,18 +2,24 @@
|
|||
title: 'My Notes: A Markdown App for Android'
|
||||
description: A small Android note app built on Markwon. The idea wasn't new; the point was learning a platform that wasn't the web.
|
||||
date: 2026-05-02
|
||||
projectPeriod: 'November 2019'
|
||||
period: 'November 2019'
|
||||
thumbnail:
|
||||
src: ./_assets/my-notes.png
|
||||
alt: Screenshots of the My Notes Android app.
|
||||
tags: ['tools']
|
||||
role: Android app author
|
||||
stack: ['Android', 'Markdown', 'Markwon']
|
||||
outcome: A working notes app and my first time outside the web stack
|
||||
audience: technical
|
||||
links:
|
||||
- label: Source
|
||||
url: https://github.com/schmelczer/my-notes
|
||||
article:
|
||||
tags: ['tools']
|
||||
role: Android app author
|
||||
stack: ['Android', 'Markdown', 'Markwon']
|
||||
outcome: A working notes app and my first time outside the web stack
|
||||
audience: technical
|
||||
project:
|
||||
title: My Notes
|
||||
description: A small Android Markdown note app. The point was a few weeks outside the web stack.
|
||||
thumbnail:
|
||||
alt: Screenshot of the My Notes Android markdown app.
|
||||
---
|
||||
|
||||
In November 2019 I wrote my own notes app for Android, used it daily for a while, and then it lost a long battle with Obsidian. The loss was the lesson: I learned what I actually wanted from a notes app by watching mine fail to be it. Years later that same itch is why I wrote [reconcile-text](/articles/reconcile-text-3-way-merge/); by then I was editing the same notes in Vim, VS Code, and Obsidian, and nothing existed to merge three independently-edited copies back into one.
|
||||
|
|
@ -2,19 +2,19 @@
|
|||
title: 'Two Graphs Are Simpler Than One: A Cooling System Simulator'
|
||||
description: Live cooling-system sim for a PLC cybersecurity event. Splitting flow and heat into two graph passes kept it cheap and the behaviour believable.
|
||||
date: 2026-05-04
|
||||
projectPeriod: 'October-November 2018'
|
||||
period: 'October-November 2018'
|
||||
thumbnail:
|
||||
src: ./_assets/process-simulator.jpg
|
||||
alt: Cooling system simulator interface with pipes, pumps, and temperature values.
|
||||
tags: ['simulation', 'systems', 'tools']
|
||||
featuredOrder: 5
|
||||
role: Simulation and UI author
|
||||
stack: ['Python', 'Flask', 'NumPy', 'HTML canvas', 'JavaFX']
|
||||
scale: One remote sim server, many monitoring clients, separate JavaFX graph editor
|
||||
outcome: A believable PLC simulation usable by non-specialists during a live cybersecurity challenge
|
||||
audience: recruiter-relevant
|
||||
links: []
|
||||
media:
|
||||
article:
|
||||
featuredOrder: 5
|
||||
tags: ['simulation', 'systems', 'tools']
|
||||
role: Simulation and UI author
|
||||
stack: ['Python', 'Flask', 'NumPy', 'HTML canvas', 'JavaFX']
|
||||
scale: One remote sim server, many monitoring clients, separate JavaFX graph editor
|
||||
outcome: A believable PLC simulation usable by non-specialists during a live cybersecurity challenge
|
||||
audience: recruiter-relevant
|
||||
media:
|
||||
- type: image
|
||||
src: ./_assets/process-simulator.jpg
|
||||
alt: Screenshot of the cooling system simulator with pipes, pumps, coolers, and temperature values.
|
||||
|
|
@ -23,6 +23,12 @@ media:
|
|||
src: ./_assets/process-simulator-input.jpg
|
||||
alt: Screenshot of the JavaFX graph editor used to define simulator input.
|
||||
caption: The JavaFX editor produced JSON that the simulator ate as input.
|
||||
project:
|
||||
title: Cooling System Simulation
|
||||
description: 'A live cooling-plant simulator for a PLC cybersecurity event. Flow as graph traversal and heat as a matrix solve: two passes instead of one PDE.'
|
||||
selected: true
|
||||
thumbnail:
|
||||
src: ./_assets/nuclear-simulation.jpg
|
||||
---
|
||||
|
||||
Trying to solve flow and heat as a coupled system would have been a real CFD problem and I had two weeks. A cybersecurity event in late 2018 needed a cooling-system simulator that contestants could poke at through PLCs over a weekend, and the deadline shaped every decision after it: cheap to compute, plausible to a non-specialist, runs all weekend on one server. The useful design move was modelling flow and heat as **two separate graph passes**, not one combined PDE.
|
||||
|
|
@ -2,13 +2,17 @@
|
|||
title: 25 Million UK Property Rows in a Single Rust Process
|
||||
description: Notes on perfect-postcode.co.uk. Every numeric feature is u16-quantised in a row-major array, so filter eval is two integer compares per row.
|
||||
date: 2026-05-28
|
||||
projectPeriod: '2026'
|
||||
period: '2026'
|
||||
thumbnail:
|
||||
src: ./_assets/perfect-postcode.jpg
|
||||
alt: The Perfect Postcode dashboard with active filters on property type, price, transit time, and crime, showing a Manchester map with matching properties highlighted as a heatmap.
|
||||
tags: ['systems', 'web', 'tools']
|
||||
role: Server architect and operator
|
||||
stack:
|
||||
links:
|
||||
- label: Site
|
||||
url: https://perfect-postcode.co.uk
|
||||
article:
|
||||
tags: ['systems', 'web', 'tools']
|
||||
role: Server architect and operator
|
||||
stack:
|
||||
[
|
||||
'Rust',
|
||||
'Axum',
|
||||
|
|
@ -22,17 +26,20 @@ stack:
|
|||
'Conveyal R5',
|
||||
'Gemini',
|
||||
]
|
||||
scale: ~25M historical properties, ~2.5M postcodes, ~150 numeric features per row, all in RAM on a single VM
|
||||
outcome: A single-binary UK property-intelligence service with sub-100ms hexagon aggregations under filter
|
||||
audience: technical
|
||||
links:
|
||||
- label: Site
|
||||
url: https://perfect-postcode.co.uk
|
||||
media:
|
||||
scale: ~25M historical properties, ~2.5M postcodes, ~150 numeric features per row, all in RAM on a single VM
|
||||
outcome: A single-binary UK property-intelligence service with sub-100ms hexagon aggregations under filter
|
||||
audience: technical
|
||||
media:
|
||||
- type: image
|
||||
src: ./_assets/perfect-postcode.jpg
|
||||
alt: A Perfect Postcode dashboard view of Manchester with five active filters (property type, price, public-transport time to Manchester city centre, crime, noise) and a hex heatmap of 1,247 matching properties.
|
||||
caption: A normal user pan triggers a hexagon aggregation under filter. The hot path holds itself to two u16 compares per row.
|
||||
project:
|
||||
title: Perfect Postcode
|
||||
description: A UK property-intelligence map. ~25M historical transactions, ~150 features per row, all u16-quantised in RAM, served from a single Rust binary.
|
||||
selected: true
|
||||
thumbnail:
|
||||
alt: The Perfect Postcode dashboard with active filters on property type, price, transit time, and crime, showing a Manchester map with matching properties as a heatmap.
|
||||
---
|
||||
|
||||
A user told me the map felt sluggish when they dragged it across Manchester with four filters on. They were right. The previous version round-tripped to a database, decoded floats, and lost the budget for a single pan inside the first filter. The rewrite is one Rust binary that holds the entire UK property history in RAM and treats every filter as three integer compares. Everything else in this post is the consequence of refusing to break that latency again.
|
||||
|
|
@ -2,16 +2,21 @@
|
|||
title: A Colour Grader Where Distance Was the Whole Idea
|
||||
description: Pick a colour, transform every nearby colour as a function of distance. A proof-of-concept grader I built to try one interaction idea.
|
||||
date: 2026-04-30
|
||||
projectPeriod: 'June 2018'
|
||||
period: 'June 2018'
|
||||
thumbnail:
|
||||
src: ./_assets/photo-colour-grader.jpg
|
||||
alt: Colour grading interface with tonal controls and an edited preview.
|
||||
tags: ['graphics', 'web', 'tools']
|
||||
role: Interface and image processing author
|
||||
stack: ['JavaScript', 'Canvas', 'Image processing']
|
||||
outcome: A working proof-of-concept grader and an interaction model I'd still defend
|
||||
audience: technical
|
||||
links: []
|
||||
article:
|
||||
tags: ['graphics', 'web', 'tools']
|
||||
role: Interface and image processing author
|
||||
stack: ['JavaScript', 'Canvas', 'Image processing']
|
||||
outcome: A working proof-of-concept grader and an interaction model I'd still defend
|
||||
audience: technical
|
||||
project:
|
||||
title: Photo Colour Grader
|
||||
description: Pick a colour, edit every nearby colour as a function of distance. A grader built around one interaction idea.
|
||||
thumbnail:
|
||||
alt: Screenshot of a colour grading interface applied to a photograph.
|
||||
---
|
||||
|
||||
In June 2018 I got tired of every grader I tried making me think in masks. I wanted to point at "this orange" in a photo from one of my [walks](/articles/photo-site-generator/), nudge it, and have the neighbouring reds and yellows come along by however much made sense. Distance in colour space, not a brush. So I built the proof.
|
||||
|
|
@ -2,16 +2,19 @@
|
|||
title: A Photo Site That Generated Itself From a Folder
|
||||
description: A Webpack script that turns a folder of photos into a static site with responsive image variants. Mostly here as an excuse to talk about walks.
|
||||
date: 2026-04-27
|
||||
projectPeriod: 'Summer 2016'
|
||||
period: 'Summer 2016'
|
||||
thumbnail:
|
||||
src: ./_assets/photos.jpg
|
||||
alt: Screenshot of a generated photography site.
|
||||
tags: ['web', 'tools']
|
||||
role: Site generator author
|
||||
stack: ['Webpack', 'Image processing', 'Static site generation']
|
||||
outcome: A photography site that updated itself when I dropped new images into a folder
|
||||
audience: general
|
||||
links: []
|
||||
article:
|
||||
tags: ['web', 'tools']
|
||||
role: Site generator author
|
||||
stack: ['Webpack', 'Image processing', 'Static site generation']
|
||||
outcome: A photography site that updated itself when I dropped new images into a folder
|
||||
audience: general
|
||||
project:
|
||||
title: Photo Site Generator
|
||||
description: Point a Webpack script at a folder of photos, get a static site with responsive image variants. An excuse to walk with a camera.
|
||||
---
|
||||
|
||||
I take walks with a camera. Most of what I shoot isn't good, but the act of walking slowly with a frame to think about is the most reliable way I know to come back with an idea for whatever I'm working on. In the summer of 2016 I wanted somewhere to put the few frames that survived, and I wasn't going to maintain a CMS for it.
|
||||
|
|
@ -2,15 +2,21 @@
|
|||
title: A 3D Voxel Game in C, Built While Learning Pointers
|
||||
description: My Basics of Programming project. 3D platformer in C with SDL 1.2, destructible terrain, time-slowdown powerups, and a great many segmentation faults.
|
||||
date: 2026-04-28
|
||||
projectPeriod: 'Autumn 2017'
|
||||
period: 'Autumn 2017'
|
||||
thumbnail:
|
||||
src: ./_assets/platform-game.jpg
|
||||
alt: Screenshot from a 3D platform game written in C.
|
||||
tags: ['games', 'systems']
|
||||
role: Game author
|
||||
stack: ['C', 'SDL 1.2', 'Voxel terrain']
|
||||
outcome: A playable course project, and the moment programming clicked
|
||||
audience: technical
|
||||
article:
|
||||
tags: ['games', 'systems']
|
||||
role: Game author
|
||||
stack: ['C', 'SDL 1.2', 'Voxel terrain']
|
||||
outcome: A playable course project, and the moment programming clicked
|
||||
audience: technical
|
||||
project:
|
||||
title: Platform Game
|
||||
description: My Basics of Programming project. 3D voxel game in C and SDL 1.2. Pointers, learned painfully.
|
||||
thumbnail:
|
||||
alt: Screenshot from an early 3D platform game.
|
||||
---
|
||||
|
||||
Autumn 2017, Basics of Programming, a deadline that forced me to learn C the hard way. I'd write almost none of it the same way today, and I'd defend every choice in it anyway. A 3D voxel platformer in pure C with SDL 1.2. No engine, no scripting layer.
|
||||
|
|
@ -2,17 +2,10 @@
|
|||
title: A 3-Way Text Merger That Never Shows Conflict Markers
|
||||
description: reconcile-text merges Markdown notes from three editors I don't control, with no history. Why git, CRDTs, and diff-match-patch each failed me.
|
||||
date: 2026-05-21
|
||||
projectPeriod: '2025'
|
||||
period: '2025'
|
||||
thumbnail:
|
||||
src: ./_assets/reconcile.png
|
||||
alt: The reconcile-text logo and tagline "Conflict-free 3-way text merging".
|
||||
tags: ['systems', 'tools', 'web']
|
||||
featuredOrder: 2
|
||||
role: Library author
|
||||
stack: ['Rust', 'WebAssembly', 'Python', 'pyo3', 'wasm-bindgen']
|
||||
scale: One Rust core, three published packages (crates.io, npm, PyPI), driving an Obsidian sync plugin
|
||||
outcome: A small Rust library that auto-resolves prose conflicts, with WASM and Python bindings
|
||||
audience: recruiter-relevant
|
||||
links:
|
||||
- label: Demo
|
||||
url: /reconcile/
|
||||
|
|
@ -24,11 +17,24 @@ links:
|
|||
url: https://www.npmjs.com/package/reconcile-text
|
||||
- label: PyPI
|
||||
url: https://pypi.org/project/reconcile-text/
|
||||
media:
|
||||
article:
|
||||
featuredOrder: 2
|
||||
tags: ['systems', 'tools', 'web']
|
||||
role: Library author
|
||||
stack: ['Rust', 'WebAssembly', 'Python', 'pyo3', 'wasm-bindgen']
|
||||
scale: One Rust core, three published packages (crates.io, npm, PyPI), driving an Obsidian sync plugin
|
||||
outcome: A small Rust library that auto-resolves prose conflicts, with WASM and Python bindings
|
||||
audience: recruiter-relevant
|
||||
media:
|
||||
- type: image
|
||||
src: ./_assets/reconcile.png
|
||||
alt: The reconcile-text logo, a stylised merge arrow, with the tagline "Conflict-free 3-way text merging".
|
||||
caption: reconcile-text weaves conflicting edits together instead of asking a human to choose.
|
||||
project:
|
||||
title: reconcile-text
|
||||
description: One Rust core, three packages. Merges Markdown notes from three editors I don't control, with no operation history. Never emits markers.
|
||||
selected: true
|
||||
technologies: ['Rust', 'WebAssembly', 'Python', 'pyo3', 'wasm-bindgen', 'Myers diff']
|
||||
---
|
||||
|
||||
## Why I wrote it
|
||||
|
|
@ -2,18 +2,10 @@
|
|||
title: A 2D Ray Tracer for the Browser, Tuned for the Phone in Your Pocket
|
||||
description: 'My BSc thesis library. The mobile GPU shaped the architecture: tile-based passes, deferred shading, shaders generated per scene and device.'
|
||||
date: 2026-05-08
|
||||
projectPeriod: 'Autumn-Winter 2020'
|
||||
period: 'Autumn-Winter 2020'
|
||||
thumbnail:
|
||||
src: ./_assets/sdf2d.jpg
|
||||
alt: SDF-2D browser demo with soft lighting effects.
|
||||
tags: ['graphics', 'web', 'systems']
|
||||
featuredOrder: 3
|
||||
role: Library author
|
||||
stack:
|
||||
['TypeScript', 'WebGL', 'WebGL2', 'Signed distance fields', 'Dynamic shader generation']
|
||||
scale: Browser library, mobile-targeted, real-time on consumer GPUs, both WebGL1 and WebGL2 paths
|
||||
outcome: An NPM package and BSc thesis; the renderer behind the decla.red multiplayer game
|
||||
audience: recruiter-relevant
|
||||
links:
|
||||
- label: NPM package
|
||||
url: https://www.npmjs.com/package/sdf-2d
|
||||
|
|
@ -22,11 +14,31 @@ links:
|
|||
- label: BSc thesis
|
||||
url: /media/downloads/sdf2d-andras-schmelczer.pdf
|
||||
download: true
|
||||
media:
|
||||
article:
|
||||
featuredOrder: 3
|
||||
tags: ['graphics', 'web', 'systems']
|
||||
role: Library author
|
||||
stack:
|
||||
[
|
||||
'TypeScript',
|
||||
'WebGL',
|
||||
'WebGL2',
|
||||
'Signed distance fields',
|
||||
'Dynamic shader generation',
|
||||
]
|
||||
scale: Browser library, mobile-targeted, real-time on consumer GPUs, both WebGL1 and WebGL2 paths
|
||||
outcome: An NPM package and BSc thesis; the renderer behind the decla.red multiplayer game
|
||||
audience: recruiter-relevant
|
||||
media:
|
||||
- type: image
|
||||
src: ./_assets/sdf2d.jpg
|
||||
alt: Browser demo page showing SDF-2D scenes rendered with soft lighting effects.
|
||||
caption: SDF-2D shipped as a TypeScript library, not a one-shot demo. That distinction shaped most of the design.
|
||||
project:
|
||||
title: SDF-2D
|
||||
description: A browser 2D ray-tracer tuned for the phone in your pocket. Tile-based passes, deferred shading, shaders generated per scene and device.
|
||||
selected: true
|
||||
technologies: ['TypeScript', 'WebGL', 'WebGL2', 'Signed distance fields']
|
||||
---
|
||||
|
||||
Winter 2020, BSc thesis deadline closing in, and the thing had to run acceptably on my advisor's laptop the day he graded it. That single shipping pressure exposed every lazy assumption in the architecture and picked the design: tile-based passes, deferred shading, shaders generated per scene and per device. A 2D ray tracer in the browser via signed distance fields: soft shadows, smooth reflections, no triangle mesh. The other half of the thesis was [decla.red](/articles/declared-shared-simulation-code/), the multiplayer game that proved the renderer survived a real game loop.
|
||||
|
|
@ -2,13 +2,19 @@
|
|||
title: An Obsidian Sync Built Around the Merger I Already Had
|
||||
description: 'VaultLink: self-hosted Obsidian sync. Edit in any editor, online or off, then come back to a converged vault. The app that justified reconcile-text.'
|
||||
date: 2026-05-30
|
||||
projectPeriod: '2025-2026'
|
||||
period: '2025-2026'
|
||||
thumbnail:
|
||||
src: ./_assets/vault-link.svg
|
||||
alt: 'The VaultLink logo: a chain-link mark in a soft gradient.'
|
||||
tags: ['systems', 'web', 'tools']
|
||||
role: Sync engine and server author
|
||||
stack:
|
||||
links:
|
||||
- label: Source
|
||||
url: https://github.com/schmelczer/vault-link
|
||||
- label: Docs
|
||||
url: https://vault-link.schmelczer.dev
|
||||
article:
|
||||
tags: ['systems', 'web', 'tools']
|
||||
role: Sync engine and server author
|
||||
stack:
|
||||
[
|
||||
'Rust',
|
||||
'axum',
|
||||
|
|
@ -21,14 +27,13 @@ stack:
|
|||
'wasm-bindgen',
|
||||
'reconcile-text',
|
||||
]
|
||||
scale: One Rust server, one TypeScript sync engine, three published consumers (Obsidian plugin, CLI, fuzz/deterministic test harnesses)
|
||||
outcome: A self-hosted Obsidian sync I trust enough to use as my primary vault transport
|
||||
audience: technical
|
||||
links:
|
||||
- label: Source
|
||||
url: https://github.com/schmelczer/vault-link
|
||||
- label: Docs
|
||||
url: https://vault-link.schmelczer.dev
|
||||
scale: One Rust server, one TypeScript sync engine, three published consumers (Obsidian plugin, CLI, fuzz/deterministic test harnesses)
|
||||
outcome: A self-hosted Obsidian sync I trust enough to use as my primary vault transport
|
||||
audience: technical
|
||||
project:
|
||||
title: VaultLink
|
||||
description: 'I refuse to give up the editor: Obsidian, Vim, VS Code, sed. Self-hosted sync that survives all four, built on reconcile-text underneath.'
|
||||
selected: true
|
||||
---
|
||||
|
||||
I refuse to give up the editor. Obsidian on the phone, Vim on the laptop, VS Code at work, the occasional headless `sed` across the whole vault. None of them know about each other, none of them are going to learn to, and I'm not switching to whichever sync product picks a favourite. VaultLink is the architecture that falls out of that refusal: one Rust server, one TypeScript sync engine, an Obsidian plugin, a CLI, and two test harnesses. The merge primitive underneath it all is [reconcile-text](/articles/reconcile-text-3-way-merge/), which I wrote first. VaultLink is the question that made it worth writing, finally asked in earnest.
|
||||
|
|
@ -9,29 +9,29 @@ import PostThumbnail from '../components/PostThumbnail.astro';
|
|||
import TagList from '../components/TagList.astro';
|
||||
import {
|
||||
absoluteUrl,
|
||||
adjacentPosts,
|
||||
adjacentArticles,
|
||||
articlePath,
|
||||
buildBreadcrumbJsonLd,
|
||||
buildPersonJsonLd,
|
||||
buildBreadcrumbTrail,
|
||||
formatDate,
|
||||
getArticles,
|
||||
getHeaderVideo,
|
||||
getPublishedPosts,
|
||||
getRelatedPosts,
|
||||
getRelatedArticles,
|
||||
optimizeOgImage,
|
||||
} from '../lib/site';
|
||||
import Base from './Base.astro';
|
||||
|
||||
interface Props {
|
||||
post: CollectionEntry<'posts'>;
|
||||
post: CollectionEntry<'work'>;
|
||||
}
|
||||
|
||||
const { post } = Astro.props;
|
||||
const { Content, headings } = await render(post);
|
||||
|
||||
const allPosts = await getPublishedPosts();
|
||||
const { previous, next } = adjacentPosts(allPosts, post);
|
||||
const related = getRelatedPosts(allPosts, post, 3);
|
||||
const allPosts = await getArticles();
|
||||
const { previous, next } = adjacentArticles(allPosts, post);
|
||||
const related = getRelatedArticles(allPosts, post, 3);
|
||||
|
||||
const ogImageOptimized = await optimizeOgImage(post.data.thumbnail.src);
|
||||
|
||||
|
|
@ -57,7 +57,7 @@ const showToc = h2Headings.length >= 3;
|
|||
// banner image and, when present, plays the header video inline.
|
||||
const thumbnailSrc = post.data.thumbnail.src.src;
|
||||
const headerVideo = getHeaderVideo(post);
|
||||
const trailingMedia = post.data.media.filter((item) => {
|
||||
const trailingMedia = (post.data.article?.media ?? []).filter((item) => {
|
||||
if (item === headerVideo) return false;
|
||||
if (item.type === 'video') return true;
|
||||
return item.src.src !== thumbnailSrc;
|
||||
|
|
@ -71,12 +71,14 @@ const blogPosting = {
|
|||
headline: post.data.title,
|
||||
description: post.data.description,
|
||||
datePublished: post.data.date.toISOString(),
|
||||
...(post.data.updated && { dateModified: post.data.updated.toISOString() }),
|
||||
...(post.data.article?.updated && {
|
||||
dateModified: post.data.article.updated.toISOString(),
|
||||
}),
|
||||
author: { '@id': personId },
|
||||
publisher: { '@id': personId },
|
||||
image: absoluteUrl(ogImageOptimized.src),
|
||||
url: absoluteUrl(articlePath(post)),
|
||||
keywords: post.data.tags.join(', '),
|
||||
keywords: (post.data.article?.tags ?? []).join(', '),
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
'@id': absoluteUrl(articlePath(post)),
|
||||
|
|
@ -99,15 +101,15 @@ const personJsonLd = buildPersonJsonLd();
|
|||
preloadMono={hasCode}
|
||||
article={{
|
||||
publishedTime: post.data.date.toISOString(),
|
||||
modifiedTime: post.data.updated?.toISOString(),
|
||||
tags: post.data.tags,
|
||||
modifiedTime: post.data.article?.updated?.toISOString(),
|
||||
tags: post.data.article?.tags ?? [],
|
||||
}}
|
||||
jsonLd={[blogPosting, breadcrumbJsonLd, personJsonLd]}
|
||||
>
|
||||
<article class="post">
|
||||
<header class="post-header">
|
||||
<Breadcrumbs items={breadcrumbTrail} />
|
||||
<p class="eyebrow">{post.data.projectPeriod ?? 'Article'}</p>
|
||||
<p class="eyebrow">{post.data.period ?? 'Article'}</p>
|
||||
<h1>{post.data.title}</h1>
|
||||
<p class="dek">{post.data.description}</p>
|
||||
<div class="post-meta">
|
||||
|
|
@ -115,13 +117,13 @@ const personJsonLd = buildPersonJsonLd();
|
|||
{formatDate(post.data.date)}
|
||||
</time>
|
||||
{
|
||||
post.data.updated && (
|
||||
post.data.article?.updated && (
|
||||
<>
|
||||
{' · '}
|
||||
<span>
|
||||
Updated{' '}
|
||||
<time datetime={post.data.updated.toISOString()}>
|
||||
{formatDate(post.data.updated)}
|
||||
<time datetime={post.data.article.updated.toISOString()}>
|
||||
{formatDate(post.data.article.updated)}
|
||||
</time>
|
||||
</span>
|
||||
</>
|
||||
|
|
@ -130,18 +132,18 @@ const personJsonLd = buildPersonJsonLd();
|
|||
{' · '}
|
||||
<span>{readingMinutes} min read</span>
|
||||
</div>
|
||||
<TagList tags={post.data.tags} />
|
||||
<TagList tags={post.data.article?.tags ?? []} />
|
||||
</header>
|
||||
|
||||
<PostThumbnail post={post} />
|
||||
|
||||
<AtAGlance
|
||||
headingId={`at-a-glance-${post.id}`}
|
||||
role={post.data.role}
|
||||
projectPeriod={post.data.projectPeriod}
|
||||
stack={post.data.stack}
|
||||
scale={post.data.scale}
|
||||
outcome={post.data.outcome}
|
||||
role={post.data.article?.role}
|
||||
projectPeriod={post.data.period}
|
||||
stack={post.data.article?.stack}
|
||||
scale={post.data.article?.scale}
|
||||
outcome={post.data.article?.outcome}
|
||||
links={post.data.links}
|
||||
/>
|
||||
|
||||
|
|
|
|||
120
src/lib/site.ts
|
|
@ -74,77 +74,109 @@ export function tagPath(tag: string) {
|
|||
return `/tags/${tagSlug(tag)}/`;
|
||||
}
|
||||
|
||||
export function getAllTags(posts: { data: { tags: readonly string[] } }[]) {
|
||||
return [...new Set(posts.flatMap((post) => post.data.tags))].sort((a, b) =>
|
||||
a.localeCompare(b)
|
||||
);
|
||||
export function getAllTags(articles: CollectionEntry<'work'>[]) {
|
||||
return [
|
||||
...new Set(articles.flatMap((article) => article.data.article?.tags ?? [])),
|
||||
].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
// Memoized published-posts loader. Build steps call `getPublishedPosts()`
|
||||
// from many pages (index, articles, RSS, sitemap, tag pages, post layouts).
|
||||
// Caching the promise means `getCollection('posts')` runs once per build.
|
||||
let publishedPostsPromise: Promise<CollectionEntry<'posts'>[]> | undefined;
|
||||
// Memoized article loader. Build steps call `getArticles()` from many pages
|
||||
// (index, articles, RSS, sitemap, tag pages, article layout). Caching the
|
||||
// promise means `getCollection('work')` runs once per build. An entry is an
|
||||
// article when it carries an `article` facet and isn't a draft.
|
||||
let articlesPromise: Promise<CollectionEntry<'work'>[]> | undefined;
|
||||
|
||||
export function getPublishedPosts(): Promise<CollectionEntry<'posts'>[]> {
|
||||
if (!publishedPostsPromise) {
|
||||
publishedPostsPromise = getCollection('posts').then((posts) =>
|
||||
posts
|
||||
.filter((post) => !post.data.draft)
|
||||
export function getArticles(): Promise<CollectionEntry<'work'>[]> {
|
||||
if (!articlesPromise) {
|
||||
articlesPromise = getCollection('work').then((entries) =>
|
||||
entries
|
||||
.filter((entry) => entry.data.article && !entry.data.article.draft)
|
||||
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
|
||||
);
|
||||
}
|
||||
return publishedPostsPromise;
|
||||
return articlesPromise;
|
||||
}
|
||||
|
||||
export async function getProjects(): Promise<CollectionEntry<'projects'>[]> {
|
||||
return (await getCollection('projects')).sort(
|
||||
(a, b) => b.data.sortDate.valueOf() - a.data.sortDate.valueOf()
|
||||
);
|
||||
// Entries shown in the projects index: those carrying a `project` facet,
|
||||
// newest first.
|
||||
export async function getProjects(): Promise<CollectionEntry<'work'>[]> {
|
||||
return (await getCollection('work'))
|
||||
.filter((entry) => entry.data.project)
|
||||
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
|
||||
}
|
||||
|
||||
type PostMediaItem = CollectionEntry<'posts'>['data']['media'][number];
|
||||
export type HeaderVideo = Extract<PostMediaItem, { type: 'video' }>;
|
||||
export interface ProjectCard {
|
||||
title: string;
|
||||
description: string;
|
||||
thumbnail: { src: ImageMetadata; alt: string };
|
||||
technologies: string[];
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
// A post has a "header video" when one of its media items is a video whose
|
||||
// poster is the same image as the post thumbnail. That video can stand in for
|
||||
// the static banner: the header shows the poster, then plays inline on click.
|
||||
// Posts without such a video keep the plain image header.
|
||||
export function getHeaderVideo(post: CollectionEntry<'posts'>): HeaderVideo | undefined {
|
||||
const thumbnailSrc = post.data.thumbnail.src.src;
|
||||
return post.data.media.find(
|
||||
// Resolves a project card's presentation. Shared identity lives at the top
|
||||
// level; the `project` facet overrides only the fields where the card
|
||||
// deliberately differs from the article. Technologies fall back to the
|
||||
// article's stack.
|
||||
export function projectCard(entry: CollectionEntry<'work'>): ProjectCard {
|
||||
const { data } = entry;
|
||||
const project = data.project;
|
||||
return {
|
||||
title: project?.title ?? data.title,
|
||||
description: project?.description ?? data.description,
|
||||
thumbnail: {
|
||||
src: project?.thumbnail?.src ?? data.thumbnail.src,
|
||||
alt: project?.thumbnail?.alt ?? data.thumbnail.alt,
|
||||
},
|
||||
technologies: project?.technologies ?? data.article?.stack ?? [],
|
||||
selected: project?.selected ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
type ArticleData = NonNullable<CollectionEntry<'work'>['data']['article']>;
|
||||
export type WorkMedia = ArticleData['media'][number];
|
||||
export type HeaderVideo = Extract<WorkMedia, { type: 'video' }>;
|
||||
|
||||
// An entry has a "header video" when one of its article media items is a video
|
||||
// whose poster is the same image as the top-level thumbnail. That video can
|
||||
// stand in for the static banner: the header shows the poster, then plays
|
||||
// inline on click. Entries without such a video keep the plain image header.
|
||||
export function getHeaderVideo(entry: CollectionEntry<'work'>): HeaderVideo | undefined {
|
||||
const thumbnailSrc = entry.data.thumbnail.src.src;
|
||||
return (entry.data.article?.media ?? []).find(
|
||||
(item): item is HeaderVideo =>
|
||||
item.type === 'video' && item.poster?.src === thumbnailSrc
|
||||
);
|
||||
}
|
||||
|
||||
export function adjacentPosts(
|
||||
posts: CollectionEntry<'posts'>[],
|
||||
current: CollectionEntry<'posts'>
|
||||
export function adjacentArticles(
|
||||
articles: CollectionEntry<'work'>[],
|
||||
current: CollectionEntry<'work'>
|
||||
) {
|
||||
const index = posts.findIndex((post) => post.id === current.id);
|
||||
const index = articles.findIndex((article) => article.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,
|
||||
previous: index < articles.length - 1 ? articles[index + 1] : undefined,
|
||||
next: index > 0 ? articles[index - 1] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function getRelatedPosts(
|
||||
posts: CollectionEntry<'posts'>[],
|
||||
current: CollectionEntry<'posts'>,
|
||||
export function getRelatedArticles(
|
||||
articles: CollectionEntry<'work'>[],
|
||||
current: CollectionEntry<'work'>,
|
||||
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,
|
||||
const currentTags = new Set(current.data.article?.tags ?? []);
|
||||
return articles
|
||||
.filter((article) => article.id !== current.id)
|
||||
.map((article) => ({
|
||||
article,
|
||||
overlap: (article.data.article?.tags ?? []).filter((tag) => currentTags.has(tag))
|
||||
.length,
|
||||
}))
|
||||
.filter(({ overlap }) => overlap > 0)
|
||||
.sort((a, b) => b.overlap - a.overlap)
|
||||
.slice(0, limit)
|
||||
.map(({ post }) => post);
|
||||
.map(({ article }) => article);
|
||||
}
|
||||
|
||||
export function absoluteUrl(path: string) {
|
||||
|
|
@ -203,7 +235,7 @@ interface BreadcrumbInput {
|
|||
projects?: boolean;
|
||||
tagsIndex?: boolean;
|
||||
tag?: string;
|
||||
post?: CollectionEntry<'posts'>;
|
||||
post?: CollectionEntry<'work'>;
|
||||
}
|
||||
|
||||
// Builds the breadcrumb trail shared by JSON-LD (BreadcrumbList) and the
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
---
|
||||
import ArticleList from '../components/ArticleList.astro';
|
||||
import Page from '../layouts/Page.astro';
|
||||
import { getPublishedPosts } from '../lib/site';
|
||||
import { getArticles } from '../lib/site';
|
||||
|
||||
const RECENT_ARTICLES = 5;
|
||||
|
||||
const posts = await getPublishedPosts();
|
||||
const posts = await getArticles();
|
||||
const recent = posts.slice(0, RECENT_ARTICLES);
|
||||
---
|
||||
|
||||
|
|
|
|||