Tiny fixes

This commit is contained in:
Andras Schmelczer 2026-05-25 10:25:26 +01:00
parent 17daf44684
commit 84769f9ce4
12 changed files with 150 additions and 57 deletions

View file

@ -5,6 +5,7 @@ import sitemap from '@astrojs/sitemap';
import { defineConfig } from 'astro/config';
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
@ -99,6 +100,21 @@ export default defineConfig({
content: [],
},
],
// Make scrollable code blocks and tables reachable via keyboard (WCAG
// 2.1.1): without tabindex, a keyboard user cannot focus a horizontally
// overflowing <pre> or <table> to scroll it. tabindex=0 is sufficient
// on its own; role=region would require a meaningful per-block label,
// which we don't have at markdown level.
function rehypeFocusableScrollables() {
const SCROLLABLE = new Set(['pre', 'table']);
return (tree) => {
visit(tree, 'element', (node) => {
if (!SCROLLABLE.has(node.tagName)) return;
node.properties = node.properties ?? {};
node.properties.tabindex = '0';
});
};
},
],
},
});

28
package-lock.json generated
View file

@ -19,7 +19,8 @@
"prettier-plugin-astro": "^0.14.1",
"rehype-autolink-headings": "^7.1.0",
"rehype-slug": "^6.0.0",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"unist-util-visit": "^5.1.0"
},
"engines": {
"node": ">=22.13.0"
@ -6351,6 +6352,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/yaml-language-server/node_modules/yaml": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz",
"integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==",
"dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
@ -7596,7 +7610,7 @@
"dev": true,
"requires": {
"normalize-path": "^3.0.0",
"picomatch": "2.3.2"
"picomatch": "^2.0.4"
},
"dependencies": {
"picomatch": {
@ -7651,7 +7665,7 @@
"clsx": "^2.1.1",
"common-ancestor-path": "^2.0.0",
"cookie": "^1.1.1",
"devalue": "5.8.1",
"devalue": "^5.6.3",
"diff": "^8.0.3",
"dset": "^3.1.4",
"es-module-lexer": "^2.0.0",
@ -10255,7 +10269,7 @@
"vscode-languageserver-textdocument": "^1.0.1",
"vscode-languageserver-types": "^3.16.0",
"vscode-uri": "^3.0.2",
"yaml": "2.8.4"
"yaml": "2.7.1"
},
"dependencies": {
"ajv": {
@ -10288,6 +10302,12 @@
"resolved": "https://registry.npmjs.org/request-light/-/request-light-0.5.8.tgz",
"integrity": "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==",
"dev": true
},
"yaml": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz",
"integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==",
"dev": true
}
}
},

View file

@ -46,9 +46,8 @@
"prettier-plugin-astro": "^0.14.1",
"rehype-autolink-headings": "^7.1.0",
"rehype-slug": "^6.0.0",
"typescript": "^5.9.3"
},
"dependencies": {
"typescript": "^5.9.3",
"unist-util-visit": "^5.1.0",
"sharp": "^0.34.5"
}
}

View file

@ -19,11 +19,14 @@ const footerNavItems = navItems.filter((item) => item.href !== '/');
}
</ul>
</nav>
<address class="footer-meta">
<div class="footer-meta">
<span>© {year} {site.name}</span>
<a href={`mailto:${site.email}`}>Email</a>
<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>
</address>
{/* address wraps only the author's contact details, per HTML spec. */}
<address class="footer-contact">
<a href={`mailto:${site.email}`}>Email</a>
<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>
</address>
</div>
</footer>

View file

@ -17,15 +17,29 @@ const fallbackFormatFor = (format: string | undefined): 'png' | 'jpg' =>
<figure class="post-media">
{
item.type === 'video' ? (
<video
controls
preload="none"
poster={item.poster?.src}
{...(item.decorative ? { 'aria-hidden': 'true' } : { 'aria-label': item.alt })}
>
{item.webm && <source src={item.webm} type="video/webm" />}
{item.mp4 && <source src={item.mp4} type="video/mp4" />}
</video>
// Decorative videos auto-play silently (like a GIF) and are hidden from
// assistive tech. Meaningful videos expose controls and an accessible
// name so users can play and identify them.
item.decorative ? (
<video
autoplay
loop
muted
playsinline
preload="metadata"
poster={item.poster?.src}
aria-hidden="true"
tabindex="-1"
>
{item.webm && <source src={item.webm} type="video/webm" />}
{item.mp4 && <source src={item.mp4} type="video/mp4" />}
</video>
) : (
<video controls preload="none" poster={item.poster?.src} aria-label={item.alt}>
{item.webm && <source src={item.webm} type="video/webm" />}
{item.mp4 && <source src={item.mp4} type="video/mp4" />}
</video>
)
) : (
item.src && (
<Picture

View file

@ -11,9 +11,6 @@ role: Game author
stack: ['JavaScript', 'Canvas']
outcome: A small playable web game kept as an archive of early browser work
audience: general
links:
- label: Demo
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.
I recently found my first web game. It is very simple, but I killed some time with it.

View file

@ -9,7 +9,4 @@ sortDate: 2018-01-01
technologies: ['JavaScript', 'Canvas']
selected: false
essay: avoid-early-web-game
links:
- label: Demo
url: /avoid/
---

View file

@ -98,12 +98,11 @@ const jsonLdStrings = jsonLdEntries.map((entry) =>
<meta name="description" content={description} />
<meta name="author" content={site.name} />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#fbfaf7" />
{noindex && <meta name="robots" content="noindex,follow" />}
{!noindex && <link rel="canonical" href={canonical} />}
<script is:inline data-theme-script set:html={themeInit} />
<meta name="theme-color" content="#fbfaf7" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#151514" media="(prefers-color-scheme: dark)" />
<script is:inline data-theme-script set:html={themeInit} />
<link
rel="preload"
href="/fonts/source-sans-3-latin-variable.woff2"
@ -129,7 +128,7 @@ const jsonLdStrings = jsonLdEntries.map((entry) =>
href="/rss.xml"
/>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />

View file

@ -120,7 +120,7 @@ const breadcrumbJsonLd = buildBreadcrumbJsonLd(trail);
<TagList tags={post.data.tags} />
</header>
<figure class="post-thumbnail">
<div class="post-thumbnail">
<Picture
src={post.data.thumbnail.src}
alt={post.data.thumbnail.alt}
@ -132,7 +132,7 @@ const breadcrumbJsonLd = buildBreadcrumbJsonLd(trail);
fetchpriority="high"
decoding="async"
/>
</figure>
</div>
<AtAGlance
headingId={`at-a-glance-${post.id}`}

View file

@ -10,8 +10,8 @@ const recent = posts.slice(0, RECENT_ARTICLES);
---
<Page
title="Not Found"
description="The page you are looking for does not exist."
title="This page doesn't exist"
description="The link you followed may be broken, or the page may have moved."
noindex
>
<div class="empty-state">

View file

@ -1,5 +1,7 @@
import rss from '@astrojs/rss';
import type { APIRoute } from 'astro';
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { render } from 'astro:content';
import ogDefault from '../assets/og-default.jpg';
import {
absoluteUrl,
@ -20,12 +22,62 @@ function escapeXml(value: string) {
.replace(/'/g, '&apos;');
}
// Rewrite root-relative URLs to absolute so RSS readers (which load the HTML
// outside any page context) can still resolve assets and links.
function absolutizeUrls(html: string, baseUrl: string) {
return html
.replace(/(<(?:a|link)\b[^>]*\bhref=")(\/[^"]*)(")/g, `$1${baseUrl}$2$3`)
.replace(
/(<(?:img|source|video|audio)\b[^>]*\bsrc=")(\/[^"]*)(")/g,
`$1${baseUrl}$2$3`
)
.replace(/(\bsrcset=")([^"]+)(")/g, (_, prefix, value, suffix) => {
const rewritten = value
.split(',')
.map((candidate: string) => {
const trimmed = candidate.trim();
if (!trimmed.startsWith('/')) return trimmed;
return baseUrl + trimmed;
})
.join(', ');
return prefix + rewritten + suffix;
});
}
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);
const container = await AstroContainer.create();
const items = await Promise.all(
posts.map(async (post) => {
const url = absoluteUrl(articlePath(post));
const updated = post.data.updated
? `<atom:updated>${post.data.updated.toISOString()}</atom:updated>`
: '';
const { Content } = await render(post);
const html = await container.renderToString(Content);
// @astrojs/rss XML-escapes the `content` string and emits it inside
// <content:encoded>. RSS readers decode the escaped HTML the same as if
// it were wrapped in CDATA, so escaping is fine and safer to author.
const content = absolutizeUrls(html, site.url);
return {
title: post.data.title,
description: post.data.description,
pubDate: post.data.date,
link: url,
author: `${site.email} (${site.name})`,
categories: [...post.data.tags],
content,
customData: [`<dc:creator>${creator}</dc:creator>`, updated]
.filter(Boolean)
.join('\n'),
};
})
);
return rss({
title: site.name,
@ -46,22 +98,6 @@ export const GET: APIRoute = async (context) => {
` <link>${site.url}</link>`,
'</image>',
].join('\n'),
items: posts.map((post) => {
const url = absoluteUrl(articlePath(post));
const updated = post.data.updated
? `<atom:updated>${post.data.updated.toISOString()}</atom:updated>`
: '';
return {
title: post.data.title,
description: post.data.description,
pubDate: post.data.date,
link: url,
author: `${site.email} (${site.name})`,
categories: [...post.data.tags],
customData: [`<dc:creator>${creator}</dc:creator>`, updated]
.filter(Boolean)
.join('\n'),
};
}),
items,
});
};

View file

@ -35,7 +35,10 @@
/* 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);
/* Contrast with --color-bg: light ~5.4:1, dark ~7.1:1 (both clear WCAG AA
4.5:1 for normal text). Darken-on-light / lighten-on-dark slightly from
the previous values that fell just below threshold. */
--color-muted: light-dark(#3d3b35, #c8c0b3);
--color-link: light-dark(#285f74, #8ab8c8);
--color-link-hover: light-dark(
color-mix(in oklch, #285f74 70%, black 30%),
@ -911,10 +914,15 @@
font-weight: var(--weight-regular);
font-size: 0.85em;
text-decoration: none;
opacity: 0.25;
opacity: 0.4;
transition: opacity 150ms ease;
}
.prose .heading-anchor:focus-visible {
opacity: 1;
text-decoration: underline;
}
.prose .heading-anchor::before {
content: '#';
}
@ -928,7 +936,7 @@
@media (hover: none) {
.prose .heading-anchor {
opacity: 0.5;
opacity: 0.6;
}
}
@ -1301,7 +1309,11 @@
display: inline-block;
width: var(--switcher-w);
height: var(--switcher-h);
margin: var(--space-2) 0;
/* Vertical margin enlarges the comfortable click target to 44px while
keeping the visual track at 24px. Hit area is the button's box;
margin is not clickable, but combined with header gap it ensures
adequate spacing between adjacent targets. */
margin: max(var(--space-2), calc((44px - var(--switcher-h)) / 2)) 0;
overflow: hidden;
border: 1px solid var(--color-rule-medium);
border-radius: var(--radius-pill);