diff --git a/src/content.config.ts b/src/content.config.ts
index 1e68fd3..1ddedb6 100644
--- a/src/content.config.ts
+++ b/src/content.config.ts
@@ -3,9 +3,22 @@ import type { SchemaContext } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';
+const safeUrl = z.string().refine(
+ (url) => {
+ if (url.startsWith('/')) return !url.startsWith('//');
+ try {
+ const parsed = new URL(url);
+ return ['http:', 'https:', 'mailto:'].includes(parsed.protocol);
+ } catch {
+ return false;
+ }
+ },
+ { message: 'URL must be an absolute http(s)/mailto URL or a root-relative path.' }
+);
+
const linkSchema = z.object({
label: z.string(),
- url: z.string(),
+ url: safeUrl,
download: z.boolean().optional(),
});
@@ -17,17 +30,30 @@ const thumbnailSchema = ({ image }: SchemaContext) =>
const mediaSchema = ({ image }: SchemaContext) =>
z
- .object({
- type: z.enum(['image', 'video', 'diagram']),
- src: image().optional(),
- poster: image().optional(),
- mp4: z.string().optional(),
- webm: z.string().optional(),
- alt: z.string().optional(),
- decorative: z.boolean().optional(),
- caption: z.string().optional(),
- transcript: z.string().optional(),
- })
+ .discriminatedUnion('type', [
+ z.object({
+ type: z.enum(['image', 'diagram']),
+ src: image(),
+ alt: z.string().optional(),
+ decorative: z.boolean().optional(),
+ caption: z.string().optional(),
+ transcript: z.string().optional(),
+ }),
+ z
+ .object({
+ type: z.literal('video'),
+ poster: image().optional(),
+ mp4: safeUrl.optional(),
+ webm: safeUrl.optional(),
+ alt: z.string().optional(),
+ decorative: z.boolean().optional(),
+ caption: z.string().optional(),
+ transcript: z.string().optional(),
+ })
+ .refine((item) => Boolean(item.mp4) || Boolean(item.webm), {
+ message: 'Video media needs at least one mp4 or webm source.',
+ }),
+ ])
.refine((item) => item.decorative || (Boolean(item.alt) && Boolean(item.caption)), {
message: 'Meaningful media needs both alt text and a caption.',
});
@@ -72,7 +98,6 @@ const projects = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/projects' }),
schema: ({ image }) =>
z.object({
- sourceProjectId: z.string(),
title: z.string(),
description: z.string().max(160),
thumbnail: thumbnailSchema({ image }),
diff --git a/src/content/posts/avoid-early-web-game.md b/src/content/posts/avoid-early-web-game.md
index b411f5f..b69fcef 100644
--- a/src/content/posts/avoid-early-web-game.md
+++ b/src/content/posts/avoid-early-web-game.md
@@ -13,7 +13,7 @@ outcome: A small playable web game kept as an archive of early browser work
audience: general
links:
- label: Demo
- url: https://schmelczer.dev/avoid
+ url: /avoid/
---
I recently found my first web game. It is very simple, but I killed some time with it โ feel free to try it out, and do not judge too harshly.
diff --git a/src/content/posts/fleeting-garden-webgpu-drawing.md b/src/content/posts/fleeting-garden-webgpu-drawing.md
index aa9c858..3685455 100644
--- a/src/content/posts/fleeting-garden-webgpu-drawing.md
+++ b/src/content/posts/fleeting-garden-webgpu-drawing.md
@@ -14,9 +14,9 @@ outcome: A browser drawing toy where user input seeds an agent simulation that r
audience: technical
links:
- label: Demo
- url: https://schmelczer.dev/fleeting/
+ url: /fleeting/
- label: Source
- url: https://github.com/schmelczer/webgpu
+ url: https://home.schmelczer.dev/git/andras/webgpu
media:
- type: image
src: ./_assets/fleeting-garden.jpg
diff --git a/src/content/posts/reconcile-text-3-way-merge.md b/src/content/posts/reconcile-text-3-way-merge.md
index 2652e38..0645ebe 100644
--- a/src/content/posts/reconcile-text-3-way-merge.md
+++ b/src/content/posts/reconcile-text-3-way-merge.md
@@ -15,7 +15,7 @@ outcome: A small, well-tested library that fills a gap between git, CRDTs, and p
audience: recruiter-relevant
links:
- label: Demo
- url: https://schmelczer.dev/reconcile
+ url: /reconcile/
- label: Source
url: https://github.com/schmelczer/reconcile
- label: crates.io
diff --git a/src/content/projects/ad-astra.md b/src/content/projects/ad-astra.md
index d488e22..6b30932 100644
--- a/src/content/projects/ad-astra.md
+++ b/src/content/projects/ad-astra.md
@@ -1,5 +1,4 @@
---
-sourceProjectId: ad-astra
title: Ad Astra
description: A tiny embedded game engine and custom PCB built around an ATtiny85V.
thumbnail:
diff --git a/src/content/projects/avoid.md b/src/content/projects/avoid.md
index 46193de..0963c02 100644
--- a/src/content/projects/avoid.md
+++ b/src/content/projects/avoid.md
@@ -1,5 +1,4 @@
---
-sourceProjectId: avoid
title: Avoid
description: A small early web game, kept as an archive of first experiments on the web.
thumbnail:
@@ -12,5 +11,5 @@ selected: false
essay: avoid-early-web-game
links:
- label: Demo
- url: https://schmelczer.dev/avoid
+ url: /avoid/
---
diff --git a/src/content/projects/city-simulation.md b/src/content/projects/city-simulation.md
index 07fbea3..40b4fb8 100644
--- a/src/content/projects/city-simulation.md
+++ b/src/content/projects/city-simulation.md
@@ -1,5 +1,4 @@
---
-sourceProjectId: city-simulation
title: City Simulation
description: A Unity traffic simulation where REST-controlled traffic lights could produce visible consequences for a cybersecurity challenge.
thumbnail:
diff --git a/src/content/projects/colors.md b/src/content/projects/colors.md
index 3121e3c..2d51055 100644
--- a/src/content/projects/colors.md
+++ b/src/content/projects/colors.md
@@ -1,5 +1,4 @@
---
-sourceProjectId: colors
title: Photo Colour Grader
description: A proof-of-concept colour grading UI based on selecting colours and transforming nearby colour ranges.
thumbnail:
diff --git a/src/content/projects/declared.md b/src/content/projects/declared.md
index 36f7893..afa9297 100644
--- a/src/content/projects/declared.md
+++ b/src/content/projects/declared.md
@@ -1,5 +1,4 @@
---
-sourceProjectId: declared
title: decla.red
description: A team-based mobile multiplayer browser game with shared client/server game logic.
thumbnail:
diff --git a/src/content/projects/fleeting-garden.md b/src/content/projects/fleeting-garden.md
index 9945dcc..8e2385d 100644
--- a/src/content/projects/fleeting-garden.md
+++ b/src/content/projects/fleeting-garden.md
@@ -1,5 +1,4 @@
---
-sourceProjectId: fleeting-garden
title: Fleeting Garden
description: A WebGPU drawing toy where coloured strokes spawn agents that follow them, branch off, and slowly rewrite the patch you laid down.
thumbnail:
@@ -12,7 +11,7 @@ selected: true
essay: fleeting-garden-webgpu-drawing
links:
- label: Demo
- url: https://schmelczer.dev/fleeting/
+ url: /fleeting/
- label: Source
- url: https://github.com/schmelczer/webgpu
+ url: https://home.schmelczer.dev/git/andras/webgpu
---
diff --git a/src/content/projects/forex.md b/src/content/projects/forex.md
index 0631203..672c7d1 100644
--- a/src/content/projects/forex.md
+++ b/src/content/projects/forex.md
@@ -1,5 +1,4 @@
---
-sourceProjectId: forex
title: Foreign Exchange Prediction Experiment
description: A frequency-domain prediction experiment using smoothing, differentiation, STFT, extrapolation, and inverse transforms.
thumbnail:
diff --git a/src/content/projects/great-ai.md b/src/content/projects/great-ai.md
index 0720c71..7d07a1c 100644
--- a/src/content/projects/great-ai.md
+++ b/src/content/projects/great-ai.md
@@ -1,5 +1,4 @@
---
-sourceProjectId: great-ai
title: GreatAI
description: A Python framework and research project for making AI deployment best practices easier to adopt.
thumbnail:
diff --git a/src/content/projects/leds.md b/src/content/projects/leds.md
index 045703e..28349c4 100644
--- a/src/content/projects/leds.md
+++ b/src/content/projects/leds.md
@@ -1,5 +1,4 @@
---
-sourceProjectId: leds
title: Lights Synchronized to Music
description: A Raspberry Pi music player that drove RGB LED strips from audio analysis.
thumbnail:
diff --git a/src/content/projects/my-notes.md b/src/content/projects/my-notes.md
index 95dd1c2..5a08ad3 100644
--- a/src/content/projects/my-notes.md
+++ b/src/content/projects/my-notes.md
@@ -1,5 +1,4 @@
---
-sourceProjectId: my-notes
title: My Notes
description: A minimalist Android markdown note organizer and editor powered by Markwon.
thumbnail:
diff --git a/src/content/projects/nuclear-editor.md b/src/content/projects/nuclear-editor.md
index 0f886bf..6be1da3 100644
--- a/src/content/projects/nuclear-editor.md
+++ b/src/content/projects/nuclear-editor.md
@@ -1,5 +1,4 @@
---
-sourceProjectId: nuclear-editor
title: Graph Editor
description: A JavaFX editor for creating and editing input graphs for the cooling system simulator.
thumbnail:
diff --git a/src/content/projects/nuclear-simulation.md b/src/content/projects/nuclear-simulation.md
index 2bec042..e34a72c 100644
--- a/src/content/projects/nuclear-simulation.md
+++ b/src/content/projects/nuclear-simulation.md
@@ -1,5 +1,4 @@
---
-sourceProjectId: nuclear-simulation
title: Cooling System Simulation
description: A graph-based process simulation with a monitoring client and JavaFX input editor.
thumbnail:
diff --git a/src/content/projects/photos.md b/src/content/projects/photos.md
index da74af9..feb917f 100644
--- a/src/content/projects/photos.md
+++ b/src/content/projects/photos.md
@@ -1,5 +1,4 @@
---
-sourceProjectId: photos
title: Photo Site Generator
description: A static photo site generated from a directory of images, with automatic resizing to multiple quality settings.
thumbnail:
diff --git a/src/content/projects/platform-game.md b/src/content/projects/platform-game.md
index 81dd071..e90790b 100644
--- a/src/content/projects/platform-game.md
+++ b/src/content/projects/platform-game.md
@@ -1,5 +1,4 @@
---
-sourceProjectId: platform-game
title: Platform Game
description: An early 3D game in C with SDL 1.2, random maps, destructible voxels, enemies, powerups, and time slowdown.
thumbnail:
diff --git a/src/content/projects/reconcile.md b/src/content/projects/reconcile.md
index 33760c9..24fabae 100644
--- a/src/content/projects/reconcile.md
+++ b/src/content/projects/reconcile.md
@@ -1,5 +1,4 @@
---
-sourceProjectId: reconcile
title: reconcile-text
description: A Rust library that auto-resolves conflicting text edits without conflict markers, with WebAssembly and Python bindings.
thumbnail:
@@ -12,7 +11,7 @@ selected: true
essay: reconcile-text-3-way-merge
links:
- label: Demo
- url: https://schmelczer.dev/reconcile
+ url: /reconcile/
- label: Source
url: https://github.com/schmelczer/reconcile
- label: crates.io
diff --git a/src/content/projects/sdf-2d.md b/src/content/projects/sdf-2d.md
index b12a188..59890e6 100644
--- a/src/content/projects/sdf-2d.md
+++ b/src/content/projects/sdf-2d.md
@@ -1,5 +1,4 @@
---
-sourceProjectId: sdf-2d
title: SDF-2D
description: A browser rendering library for optimized 2D ray tracing with signed distance fields.
thumbnail:
diff --git a/src/content/projects/towers.md b/src/content/projects/towers.md
index eea4e2b..57038d0 100644
--- a/src/content/projects/towers.md
+++ b/src/content/projects/towers.md
@@ -1,5 +1,4 @@
---
-sourceProjectId: towers
title: Life Towers
description: A multi-device goal tracker where the lasting idea was syncing state with immutable tries.
thumbnail:
diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro
index 93ece4f..aac7dbd 100644
--- a/src/layouts/Base.astro
+++ b/src/layouts/Base.astro
@@ -30,7 +30,7 @@ interface Props {
const {
title = site.title,
description = site.description,
- canonicalPath = Astro.url.pathname,
+ canonicalPath: rawCanonicalPath = Astro.url.pathname,
ogImage,
ogImageAlt = "Andras Schmelczer's personal site",
ogImageWidth,
@@ -45,6 +45,12 @@ const {
const isRoot = title === site.title;
const pageTitle = isRoot ? site.title : `${title} ยท ${site.name}`;
const ogTitle = isRoot ? site.title : title;
+const canonicalPath =
+ rawCanonicalPath === '/' ||
+ rawCanonicalPath.endsWith('/') ||
+ /\.[^/]+$/.test(rawCanonicalPath)
+ ? rawCanonicalPath
+ : `${rawCanonicalPath}/`;
const canonical = absoluteUrl(canonicalPath);
let resolvedOgImage = ogImage;
@@ -75,68 +81,13 @@ const ogImageType =
? 'image/svg+xml'
: 'image/jpeg';
const jsonLdEntries = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
-
-// Head meta tags built as a single HTML string so prettier-plugin-astro
-// doesn't shuffle them outside `` when reformatting (it has trouble
-// with mixed JSX-expression and raw element siblings inside ).
-const attr = (value: string) =>
- value
- .replace(/&/g, '&')
- .replace(/"/g, '"')
- .replace(//g, '>');
-
-const articleMetaParts = article
- ? [
- ``,
- article.modifiedTime
- ? ``
- : '',
- ``,
- ...(article.tags ?? []).map(
- (tag) => ``
- ),
- ]
- : [];
-
-const monoPreloadHtml = preloadMono
- ? ''
- : '';
-
-const headHtml = [
- monoPreloadHtml,
- ``,
- ``,
- ``,
- ``,
- ``,
- ``,
- ``,
- ``,
- ``,
- ``,
- ``,
- ``,
- ``,
- ``,
- ``,
- ``,
- ``,
- ...articleMetaParts,
- ``,
- ``,
- ``,
- ``,
- ``,
- ...jsonLdEntries.map(
- (entry) =>
- ``
- ),
-].join('');
+const jsonLdStrings = jsonLdEntries.map((entry) =>
+ JSON.stringify(entry).replace(/
-
+
-
-
+
{noindex && }
{!noindex && }
-
+
+
-
+ {
+ preloadMono && (
+
+ )
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ article && (
+ <>
+
+ {article.modifiedTime && (
+
+ )}
+
+ {article.tags?.map((tag) => (
+
+ ))}
+ >
+ )
+ }
+
+
+
+
+
+ {
+ jsonLdStrings.map((jsonLdString) => (
+
+ ))
+ }
diff --git a/src/layouts/Page.astro b/src/layouts/Page.astro
index ea79c8b..2cd99de 100644
--- a/src/layouts/Page.astro
+++ b/src/layouts/Page.astro
@@ -2,9 +2,12 @@
import type { ComponentProps } from 'astro/types';
import Base from './Base.astro';
-type Props = ComponentProps;
+type Props = Omit, 'title'> & { title: string };
const { title, description } = Astro.props;
+if (!title) {
+ throw new Error('Page layout requires a `title` prop.');
+}
---
diff --git a/src/lib/site.ts b/src/lib/site.ts
index 90e724a..484db35 100644
--- a/src/lib/site.ts
+++ b/src/lib/site.ts
@@ -73,13 +73,6 @@ export function tagPath(tag: string) {
return `/tags/${tagSlug(tag)}/`;
}
-// Anchor used for `id="..."` on project cards and `#fragment` deep links.
-// Always derived from the canonical `sourceProjectId` slug now that the
-// legacy anchor mapping has been dropped.
-export function projectAnchor(slugOrEntry: string | CollectionEntry<'projects'>) {
- return typeof slugOrEntry === 'string' ? slugOrEntry : slugOrEntry.data.sourceProjectId;
-}
-
export function getAllTags(posts: { data: { tags: readonly string[] } }[]) {
return [...new Set(posts.flatMap((post) => post.data.tags))].sort((a, b) =>
a.localeCompare(b)
diff --git a/src/pages/articles/index.astro b/src/pages/articles/index.astro
index f7c50b3..70f8580 100644
--- a/src/pages/articles/index.astro
+++ b/src/pages/articles/index.astro
@@ -51,7 +51,7 @@ const jsonLd = [blogJsonLd, breadcrumbJsonLd];
---
-
Articles
-
+
diff --git a/src/pages/tags/index.astro b/src/pages/tags/index.astro
index d7b90ec..820e54a 100644
--- a/src/pages/tags/index.astro
+++ b/src/pages/tags/index.astro
@@ -37,7 +37,9 @@ const jsonLd = [collectionJsonLd, breadcrumbJsonLd];
- {posts.length} articles across {tags.length} tags.
+ {posts.length}
+ {posts.length === 1 ? 'article' : 'articles'} across {tags.length}
+ {tags.length === 1 ? 'tag' : 'tags'}.
diff --git a/src/scripts/theme-init.js b/src/scripts/theme-init.js
index 76c27dd..b79a163 100644
--- a/src/scripts/theme-init.js
+++ b/src/scripts/theme-init.js
@@ -1,9 +1,17 @@
// FOUC prevention: runs in before paint. Sets the theme on so
// the page renders with the right colors on first load. The theme switcher
// button is wired up separately, after it is parsed, in Header.astro.
+//
+// Keep THEME_BG values in sync with --color-bg in global.css. They drive the
+// browser-chrome so it follows the user's manual
+// toggle (the static media-keyed metas only tracked OS preference).
(function () {
+ document.documentElement.classList.remove('no-js');
+ document.documentElement.classList.add('js');
+
var STORAGE_KEY = 'theme';
var LEGACY_KEY = 'dark-mode';
+ var THEME_BG = { light: '#fbfaf7', dark: '#151514' };
var saved = null;
try {
var value = localStorage.getItem(STORAGE_KEY);
@@ -17,4 +25,8 @@
var theme = saved || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
+ var themeColorMetas = document.querySelectorAll('meta[name="theme-color"]');
+ for (var i = 0; i < themeColorMetas.length; i += 1) {
+ themeColorMetas[i].setAttribute('content', THEME_BG[theme]);
+ }
})();
diff --git a/src/styles/global.css b/src/styles/global.css
index 1d772de..292d2eb 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -202,7 +202,8 @@
}
main:focus-visible {
- outline: none;
+ outline: 2px solid var(--color-accent);
+ outline-offset: -2px;
}
::selection {
@@ -534,7 +535,6 @@
}
.tag-list a:hover,
- .tag-list a[aria-current='page'],
.tag-list a[aria-current='true'] {
color: var(--color-fg);
}
@@ -647,10 +647,15 @@
border-color: var(--color-rule-strong);
}
- .article-list > li:hover .entry-thumbnail img {
+ .article-list > li:hover .entry-thumbnail img,
+ .article-list > li:focus-within .entry-thumbnail img {
transform: scale(1.02);
}
+ .article-list > li:focus-within .entry-thumbnail {
+ border-color: var(--color-rule-strong);
+ }
+
.article-thumbnail {
grid-area: thumb;
align-self: center;
@@ -673,10 +678,8 @@
transition: border-color 150ms ease;
}
- .project-card:hover {
- border-color: var(--color-rule-strong);
- }
-
+ .project-card:hover,
+ .project-card:focus-within,
.project-card:target {
border-color: var(--color-rule-strong);
}
@@ -695,7 +698,8 @@
transition: transform 300ms ease;
}
- .project-card:hover .project-thumbnail img {
+ .project-card:hover .project-thumbnail img,
+ .project-card:focus-within .project-thumbnail img {
transform: scale(1.02);
}
@@ -798,6 +802,7 @@
.post > .at-a-glance,
.post > .post-thumbnail,
+ .post > .post-gallery,
.post-nav {
max-width: var(--measure-wide);
margin-inline: auto;
@@ -1089,6 +1094,7 @@
.post > .post-header,
.post > .post-thumbnail,
+ .post > .post-gallery,
.post > .post-media,
.post > .post-nav {
grid-column: 1 / -1;
@@ -1242,6 +1248,15 @@
gap: var(--space-6);
}
+ .post > .post-gallery {
+ width: 100%;
+ }
+
+ .post-gallery .post-media {
+ max-inline-size: 100%;
+ margin: 0;
+ }
+
/* -- External link affordance ----------------------------------------- */
.external-link-icon {
@@ -1304,6 +1319,10 @@
border-color: var(--color-rule-strong);
}
+ .no-js .theme-switcher {
+ display: none !important;
+ }
+
.theme-switcher::before,
.theme-switcher::after {
content: '';
@@ -1414,7 +1433,7 @@
padding-block: var(--space-4);
}
- .article-list > li > div {
+ .article-list > li > article {
padding-right: 0;
}
@@ -1447,6 +1466,13 @@
outline-offset: 1px;
}
+ /* Preserve the inset outline on so the post-skip-link focus ring
+ doesn't escape its container. Repeated here because this layer wins
+ over the base rule regardless of selector specificity. */
+ main:focus-visible {
+ outline-offset: -2px;
+ }
+
.post-nav__list {
grid-template-columns: 1fr;
}
Articles
-- {posts.length} articles across {tags.length} tags. + {posts.length} + {posts.length === 1 ? 'article' : 'articles'} across {tags.length} + {tags.length === 1 ? 'tag' : 'tags'}.