claude again

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

1342
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Before After
Before After

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 127 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

After

Width:  |  Height:  |  Size: 243 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 127 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

After

Width:  |  Height:  |  Size: 243 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

View file

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

View file

@ -3,7 +3,7 @@ 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:
src: ./_assets/platform-game.png
src: ./_assets/platform-game.jpg
alt: Screenshot from an early 3D platform game.
period: 'Autumn 2017'
sortDate: 2017-10-01

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,9 +1,11 @@
import type { CollectionEntry } from 'astro:content';
import { getCollection } from 'astro:content';
import { getImage } from 'astro:assets';
import type { ImageMetadata } from 'astro';
export const site = {
name: 'Andras Schmelczer',
title: 'Andras Schmelczer — Software systems, AI, graphics, simulations, tools',
title: 'Andras Schmelczer — Software engineer',
description:
'Andras Schmelczer writes about software systems, AI deployment, graphics, simulations, and tools.',
url: 'https://schmelczer.dev',
@ -13,12 +15,22 @@ export const site = {
cv: '/media/downloads/cv-andras-schmelczer.pdf',
};
// Single source of truth for primary navigation. The Header consumes every
// entry where `footerOnly` is falsy AND `href !== '/'` (Home is implicit via
// the site title). The Footer renders every entry regardless. Items marked
// `footerOnly: true` appear only in the Footer.
export const navItems = [
{ href: '/', label: 'Home' },
{ href: '/articles/', label: 'Articles' },
{ href: '/projects/', label: 'Projects' },
{ href: '/about/', label: 'About' },
] as const;
{ href: '/tags/', label: 'Tags', footerOnly: false },
{ href: '/rss.xml', label: 'RSS', footerOnly: true },
] as const satisfies ReadonlyArray<{
href: string;
label: string;
footerOnly?: boolean;
}>;
export function formatDate(date: Date) {
return new Intl.DateTimeFormat('en', {
@ -59,8 +71,11 @@ export function tagPath(tag: string) {
return `/tags/${tagSlug(tag)}/`;
}
export function projectAnchor(project: CollectionEntry<'projects'>) {
return project.data.legacyAnchor ?? project.data.sourceProjectId;
// 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[] } }[]) {
@ -69,10 +84,20 @@ export function getAllTags(posts: { data: { tags: readonly string[] } }[]) {
);
}
export async function getPublishedPosts(): Promise<CollectionEntry<'posts'>[]> {
return (await getCollection('posts'))
.filter((post) => !post.data.draft)
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
// 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;
export function getPublishedPosts(): Promise<CollectionEntry<'posts'>[]> {
if (!publishedPostsPromise) {
publishedPostsPromise = getCollection('posts').then((posts) =>
posts
.filter((post) => !post.data.draft)
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
);
}
return publishedPostsPromise;
}
export async function getProjects(): Promise<CollectionEntry<'projects'>[]> {
@ -114,3 +139,65 @@ export function getRelatedPosts(
export function absoluteUrl(path: string) {
return new URL(path, site.url).toString();
}
// Canonical Person JSON-LD. Used by the home page and About page; both share
// `@id` so search engines treat them as the same entity. Pass `extra` to
// add or override fields (e.g. `jobTitle`, richer `description`).
export function buildPersonJsonLd(extra?: Record<string, unknown>) {
return {
'@context': 'https://schema.org',
'@type': 'Person',
'@id': absoluteUrl('/about/#person'),
name: site.name,
url: site.url,
email: `mailto:${site.email}`,
sameAs: [site.github, site.linkedin],
description: site.description,
...extra,
};
}
// Wraps `getImage` with the standard OG dimensions (1200x630 JPEG). Used by
// Base.astro for the default OG image and by Post.astro for per-post
// thumbnails. Keeps OG output consistent across the site.
export function optimizeOgImage(src: ImageMetadata) {
return getImage({
src,
width: 1200,
height: 630,
format: 'jpg',
});
}
interface BreadcrumbCrumb {
name: string;
href: string;
}
interface BreadcrumbInput {
articles?: boolean;
tag?: string;
post?: CollectionEntry<'posts'>;
}
// Builds the breadcrumb trail shared by JSON-LD (BreadcrumbList) and the
// visible Breadcrumbs component. Home is always first. Pass `articles: true`
// to include the /articles/ crumb; pass a `tag` to append a tag crumb; pass
// a `post` to append the post title (linking to its article path).
export function buildBreadcrumbTrail({
articles,
tag,
post,
}: BreadcrumbInput): BreadcrumbCrumb[] {
const trail: BreadcrumbCrumb[] = [{ name: 'Home', href: '/' }];
if (articles || post) {
trail.push({ name: 'Articles', href: '/articles/' });
}
if (tag) {
trail.push({ name: tag, href: tagPath(tag) });
}
if (post) {
trail.push({ name: post.data.title, href: articlePath(post) });
}
return trail;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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