Tiny fixes
This commit is contained in:
parent
17daf44684
commit
84769f9ce4
12 changed files with 150 additions and 57 deletions
|
|
@ -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
28
package-lock.json
generated
|
|
@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -9,7 +9,4 @@ sortDate: 2018-01-01
|
|||
technologies: ['JavaScript', 'Canvas']
|
||||
selected: false
|
||||
essay: avoid-early-web-game
|
||||
links:
|
||||
- label: Demo
|
||||
url: /avoid/
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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, ''');
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue