Compare commits
4 commits
2165ed0c33
...
fd4bb61b5f
| Author | SHA1 | Date | |
|---|---|---|---|
| fd4bb61b5f | |||
| 84769f9ce4 | |||
| 17daf44684 | |||
| db8d4597df |
55 changed files with 938 additions and 455 deletions
11
.github/dependabot.yml
vendored
11
.github/dependabot.yml
vendored
|
|
@ -1,11 +0,0 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
|
@ -9,7 +9,7 @@ required client JavaScript.
|
|||
## Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm ci
|
||||
npx playwright install --with-deps chromium # required before `npm run qa:overflow`
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -38,17 +39,12 @@ const postLastmodLookup = new Map(
|
|||
|
||||
export default defineConfig({
|
||||
site: 'https://schmelczer.dev',
|
||||
trailingSlash: 'always',
|
||||
build: { inlineStylesheets: 'always' },
|
||||
redirects: {
|
||||
'/writing/': '/articles/',
|
||||
'/writing/[slug]': '/articles/[slug]',
|
||||
},
|
||||
trailingSlash: 'ignore',
|
||||
integrations: [
|
||||
sitemap({
|
||||
filter: (page) => {
|
||||
const path = new URL(page).pathname;
|
||||
return !path.startsWith('/writing/') && path !== '/404/';
|
||||
return !/^\/tags\/[^/]+\/?$/.test(path) && path !== '/404/';
|
||||
},
|
||||
serialize(item) {
|
||||
const url = new URL(item.url);
|
||||
|
|
@ -92,13 +88,49 @@ export default defineConfig({
|
|||
behavior: 'append',
|
||||
properties: {
|
||||
className: ['heading-anchor'],
|
||||
ariaLabel: 'Permalink',
|
||||
},
|
||||
// Glyph rendered via CSS ::before so it doesn't leak into the TOC
|
||||
// when astro:content extracts heading.text from the rendered HTML.
|
||||
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.tabindex = '0';
|
||||
});
|
||||
};
|
||||
},
|
||||
function rehypeLabelHeadingPermalinks() {
|
||||
function textOf(node) {
|
||||
if (!node) return '';
|
||||
if (node.type === 'text') return node.value ?? '';
|
||||
return (node.children ?? []).map(textOf).join('');
|
||||
}
|
||||
|
||||
return (tree) => {
|
||||
visit(tree, 'element', (node) => {
|
||||
if (!/^h[2-6]$/.test(node.tagName)) return;
|
||||
const headingText = textOf(node).trim();
|
||||
if (!headingText) return;
|
||||
|
||||
for (const child of node.children ?? []) {
|
||||
const className = child.properties?.className;
|
||||
const classes = Array.isArray(className) ? className : [className];
|
||||
if (child.tagName === 'a' && classes.includes('heading-anchor')) {
|
||||
child.properties.ariaLabel = `Permalink to ${headingText}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
|
|
|||
249
package-lock.json
generated
249
package-lock.json
generated
|
|
@ -6,9 +6,6 @@
|
|||
"": {
|
||||
"name": "schmelczer-dev",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.9",
|
||||
"@astrojs/rss": "^4.0.18",
|
||||
|
|
@ -19,7 +16,12 @@
|
|||
"prettier-plugin-astro": "^0.14.1",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"typescript": "^5.9.3"
|
||||
"sharp": "^0.34.5",
|
||||
"typescript": "^5.9.3",
|
||||
"unist-util-visit": "^5.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@astrojs/check": {
|
||||
|
|
@ -474,6 +476,7 @@
|
|||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
|
|
@ -926,6 +929,7 @@
|
|||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
|
@ -938,6 +942,7 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -960,6 +965,7 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -982,6 +988,7 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -998,6 +1005,7 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1014,9 +1022,7 @@
|
|||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1033,9 +1039,7 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1052,9 +1056,7 @@
|
|||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1071,9 +1073,7 @@
|
|||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1090,9 +1090,7 @@
|
|||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1109,9 +1107,7 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1128,9 +1124,7 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1147,9 +1141,7 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1166,9 +1158,7 @@
|
|||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1191,9 +1181,7 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1216,9 +1204,7 @@
|
|||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1241,9 +1227,7 @@
|
|||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1266,9 +1250,7 @@
|
|||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1291,9 +1273,7 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1316,9 +1296,7 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1341,9 +1319,7 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1366,6 +1342,7 @@
|
|||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
|
|
@ -1385,6 +1362,7 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1404,6 +1382,7 @@
|
|||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1423,6 +1402,7 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1590,9 +1570,6 @@
|
|||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1607,9 +1584,6 @@
|
|||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1624,9 +1598,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1641,9 +1612,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1658,9 +1626,6 @@
|
|||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1675,9 +1640,6 @@
|
|||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1692,9 +1654,6 @@
|
|||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1709,9 +1668,6 @@
|
|||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1726,9 +1682,6 @@
|
|||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1743,9 +1696,6 @@
|
|||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1760,9 +1710,6 @@
|
|||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1777,9 +1724,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1794,9 +1738,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2217,6 +2158,19 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/anymatch/node_modules/picomatch": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/arg": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||
|
|
@ -2962,15 +2916,16 @@
|
|||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/devalue": {
|
||||
"version": "5.8.0",
|
||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.0.tgz",
|
||||
"integrity": "sha512-2zA9pFEsnp7vWBZbXF5JAgAq0fsUIt/1XPbRiAmRV3lp/2C3upzH+sADiyy66aFCihoLEsrQHxNM5w1gIDfsBg==",
|
||||
"version": "5.8.1",
|
||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
|
||||
"integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
|
|
@ -4798,18 +4753,6 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||
|
|
@ -5322,6 +5265,7 @@
|
|||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
|
|
@ -5334,6 +5278,7 @@
|
|||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
|
|
@ -5606,6 +5551,7 @@
|
|||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
|
||||
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/typesafe-path": {
|
||||
|
|
@ -6350,9 +6296,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.4",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz",
|
||||
"integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==",
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
|
|
@ -6434,19 +6380,6 @@
|
|||
"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",
|
||||
|
|
@ -6712,7 +6645,7 @@
|
|||
"integrity": "sha512-PJzRmgQzUxI2uwpdX2lXSHtP4G8ocp24/t+bZyf5Fy0SZLSF9f9KXZoMlFM/XCGue+B0nH/2IZ7FpBYQATBsCg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"yaml": "^2.8.2"
|
||||
"yaml": "^2.9.0"
|
||||
}
|
||||
},
|
||||
"@babel/helper-string-parser": {
|
||||
|
|
@ -6838,6 +6771,7 @@
|
|||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"tslib": "^2.4.0"
|
||||
|
|
@ -7028,12 +6962,14 @@
|
|||
"@img/colour": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||
"dev": true
|
||||
},
|
||||
"@img/sharp-darwin-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.2.4"
|
||||
|
|
@ -7043,6 +6979,7 @@
|
|||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.2.4"
|
||||
|
|
@ -7052,66 +6989,77 @@
|
|||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
|
||||
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
|
||||
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
|
||||
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
|
||||
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@img/sharp-linux-arm": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
|
||||
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"@img/sharp-libvips-linux-arm": "1.2.4"
|
||||
|
|
@ -7121,6 +7069,7 @@
|
|||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.2.4"
|
||||
|
|
@ -7130,6 +7079,7 @@
|
|||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
|
||||
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.2.4"
|
||||
|
|
@ -7139,6 +7089,7 @@
|
|||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
|
||||
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.2.4"
|
||||
|
|
@ -7148,6 +7099,7 @@
|
|||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
|
||||
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.2.4"
|
||||
|
|
@ -7157,6 +7109,7 @@
|
|||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"@img/sharp-libvips-linux-x64": "1.2.4"
|
||||
|
|
@ -7166,6 +7119,7 @@
|
|||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
|
||||
|
|
@ -7175,6 +7129,7 @@
|
|||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
|
||||
|
|
@ -7184,6 +7139,7 @@
|
|||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
|
||||
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"@emnapi/runtime": "^1.7.0"
|
||||
|
|
@ -7193,18 +7149,21 @@
|
|||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@img/sharp-win32-ia32": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
|
||||
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@img/sharp-win32-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@jridgewell/sourcemap-codec": {
|
||||
|
|
@ -7693,6 +7652,14 @@
|
|||
"requires": {
|
||||
"normalize-path": "^3.0.0",
|
||||
"picomatch": "^2.0.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"picomatch": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"arg": {
|
||||
|
|
@ -8140,12 +8107,13 @@
|
|||
"detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"dev": true
|
||||
},
|
||||
"devalue": {
|
||||
"version": "5.8.0",
|
||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.0.tgz",
|
||||
"integrity": "sha512-2zA9pFEsnp7vWBZbXF5JAgAq0fsUIt/1XPbRiAmRV3lp/2C3upzH+sADiyy66aFCihoLEsrQHxNM5w1gIDfsBg==",
|
||||
"version": "5.8.1",
|
||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
|
||||
"integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
|
||||
"dev": true
|
||||
},
|
||||
"devlop": {
|
||||
|
|
@ -9340,12 +9308,6 @@
|
|||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true
|
||||
},
|
||||
"picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true
|
||||
},
|
||||
"playwright": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||
|
|
@ -9694,12 +9656,14 @@
|
|||
"semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"dev": true
|
||||
},
|
||||
"sharp": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@img/colour": "^1.0.0",
|
||||
"@img/sharp-darwin-arm64": "0.34.5",
|
||||
|
|
@ -9880,6 +9844,7 @@
|
|||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
|
||||
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"typesafe-path": {
|
||||
|
|
@ -10328,9 +10293,9 @@
|
|||
"dev": true
|
||||
},
|
||||
"yaml": {
|
||||
"version": "2.8.4",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz",
|
||||
"integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==",
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||
"dev": true
|
||||
},
|
||||
"yaml-language-server": {
|
||||
|
|
@ -10349,7 +10314,7 @@
|
|||
"vscode-languageserver-textdocument": "^1.0.1",
|
||||
"vscode-languageserver-types": "^3.16.0",
|
||||
"vscode-uri": "^3.0.2",
|
||||
"yaml": "2.7.1"
|
||||
"yaml": "^2.9.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": {
|
||||
|
|
@ -10382,12 +10347,6 @@
|
|||
"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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
15
package.json
15
package.json
|
|
@ -3,6 +3,10 @@
|
|||
"description": "A static personal blog for Andras Schmelczer.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "npm@10.9.2",
|
||||
"engines": {
|
||||
"node": ">=22.13.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
|
|
@ -11,9 +15,10 @@
|
|||
"format": "prettier --write \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/*.mjs\" \"*.md\" \"*.json\"",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"qa:links": "node scripts/check-links.mjs",
|
||||
"qa:no-js": "node scripts/check-no-js.mjs",
|
||||
"qa:overflow": "node scripts/install-playwright-deps.mjs && node scripts/check-overflow.mjs",
|
||||
"qa": "npm run typecheck && npm run lint && npm run build && npm run qa:no-js && npm run qa:overflow"
|
||||
"qa": "npm run typecheck && npm run lint && npm run build && npm run qa:links && npm run qa:no-js && npm run qa:overflow"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
@ -41,9 +46,11 @@
|
|||
"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"
|
||||
},
|
||||
"overrides": {
|
||||
"yaml": "^2.9.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
13
public/media/video/ad_astra.vtt
Normal file
13
public/media/video/ad_astra.vtt
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
WEBVTT
|
||||
|
||||
00:00.000 --> 00:04.000
|
||||
No spoken dialogue. Game audio only.
|
||||
|
||||
00:04.000 --> 00:35.000
|
||||
The Ad Astra handheld board runs the game on a small OLED display.
|
||||
|
||||
00:35.000 --> 01:05.000
|
||||
The player controls the game through the IR input while the engine updates the display in real time.
|
||||
|
||||
01:05.000 --> 01:34.600
|
||||
The clip continues showing gameplay on the custom ATtiny85-based board.
|
||||
Binary file not shown.
136
scripts/check-links.mjs
Normal file
136
scripts/check-links.mjs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const dist = path.resolve('dist');
|
||||
const allowedPreservedRoutes = new Set(['/fleeting/', '/reconcile/']);
|
||||
const failures = [];
|
||||
|
||||
async function walk(dir) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const files = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await walk(fullPath)));
|
||||
} else {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
async function exists(file) {
|
||||
try {
|
||||
return (await stat(file)).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function targetExists(pathname) {
|
||||
if (allowedPreservedRoutes.has(pathname)) return true;
|
||||
|
||||
const safePath = path
|
||||
.normalize(decodeURIComponent(pathname))
|
||||
.replace(/^\/+/, '')
|
||||
.replace(/^(\.\.(\/|\\|$))+/, '');
|
||||
const candidate = path.join(dist, safePath);
|
||||
const candidates = [
|
||||
candidate,
|
||||
path.join(candidate, 'index.html'),
|
||||
path.join(dist, `${safePath}.html`),
|
||||
];
|
||||
|
||||
for (const file of candidates) {
|
||||
if (await exists(file)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await stat(dist);
|
||||
} catch {
|
||||
throw new Error('dist/ does not exist. Run npm run build first.');
|
||||
}
|
||||
|
||||
const files = await walk(dist);
|
||||
const checkedFiles = files.filter((file) => /\.(html|xml|css|webmanifest)$/.test(file));
|
||||
|
||||
function pagePathname(file) {
|
||||
const rel = path.relative(dist, file).replaceAll(path.sep, '/');
|
||||
if (rel === 'index.html') return '/';
|
||||
if (rel.endsWith('/index.html')) return `/${rel.slice(0, -'index.html'.length)}`;
|
||||
return `/${rel}`;
|
||||
}
|
||||
|
||||
function collectUrlReferences(body, rel) {
|
||||
const urls = [];
|
||||
|
||||
for (const match of body.matchAll(/\b(?:href|src|poster)=["']([^"']+)["']/g)) {
|
||||
urls.push(match[1]);
|
||||
}
|
||||
|
||||
for (const match of body.matchAll(/\bsrcset=["']([^"']+)["']/g)) {
|
||||
for (const candidate of match[1].split(',')) {
|
||||
const url = candidate.trim().split(/\s+/)[0];
|
||||
if (url) urls.push(url);
|
||||
}
|
||||
}
|
||||
|
||||
if (rel.endsWith('.css')) {
|
||||
for (const match of body.matchAll(/url\(\s*['"]?([^'")]+)['"]?\s*\)/g)) {
|
||||
urls.push(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
if (rel.endsWith('.webmanifest')) {
|
||||
try {
|
||||
const manifest = JSON.parse(body);
|
||||
for (const key of ['start_url', 'scope']) {
|
||||
if (typeof manifest[key] === 'string') urls.push(manifest[key]);
|
||||
}
|
||||
for (const icon of manifest.icons ?? []) {
|
||||
if (typeof icon?.src === 'string') urls.push(icon.src);
|
||||
}
|
||||
for (const screenshot of manifest.screenshots ?? []) {
|
||||
if (typeof screenshot?.src === 'string') urls.push(screenshot.src);
|
||||
}
|
||||
} catch {
|
||||
failures.push(`${rel}: invalid web manifest JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
for (const file of checkedFiles) {
|
||||
const body = await readFile(file, 'utf8');
|
||||
const rel = path.relative(dist, file);
|
||||
const baseUrl = new URL(pagePathname(file), 'https://schmelczer.dev');
|
||||
|
||||
for (const raw of collectUrlReferences(body, rel)) {
|
||||
if (/^(mailto:|tel:|data:)/i.test(raw)) continue;
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(raw, baseUrl);
|
||||
} catch {
|
||||
failures.push(`${rel}: invalid URL ${raw}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsed.origin !== 'https://schmelczer.dev') continue;
|
||||
if (!(await targetExists(parsed.pathname))) {
|
||||
failures.push(`${rel}: missing local target ${parsed.pathname}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(failures.join('\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('No missing local href/src targets found in dist/.');
|
||||
|
|
@ -1,14 +1,20 @@
|
|||
import { createServer } from 'node:http';
|
||||
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||
import { mkdir, readdir, readFile, rm, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const dist = path.resolve('dist');
|
||||
const browserTmp = path.resolve('.astro', 'playwright-overflow-tmp');
|
||||
const INDEX_FILE = 'index.html';
|
||||
const MAX_NAV_RETRIES = 3;
|
||||
const MAX_NAV_RETRIES = 4;
|
||||
// Common device widths: iPhone SE / Galaxy S / iPhone 14 / iPad portrait /
|
||||
// iPad landscape / common laptop / full HD desktop.
|
||||
const VIEWPORT_WIDTHS = [320, 390, 430, 768, 1024, 1440, 1920];
|
||||
const CLOSE_TIMEOUT_MS = 3000;
|
||||
const LAUNCH_TIMEOUT_MS = 10000;
|
||||
const CONTEXT_TIMEOUT_MS = 8000;
|
||||
const PAGE_TIMEOUT_MS = 15000;
|
||||
const MEASURE_TIMEOUT_MS = 25000;
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
|
|
@ -25,6 +31,7 @@ const MIME = {
|
|||
'.woff2': 'font/woff2',
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.vtt': 'text/vtt; charset=utf-8',
|
||||
'.pdf': 'application/pdf',
|
||||
};
|
||||
|
||||
|
|
@ -54,9 +61,6 @@ async function discoverRoutes() {
|
|||
if (!file.endsWith('.html')) continue;
|
||||
const rel = path.relative(dist, file).replaceAll(path.sep, '/');
|
||||
if (rel === '404.html') continue;
|
||||
// /writing/* are meta-refresh redirect stubs to /articles/*, not real
|
||||
// pages — measuring them would just remeasure /articles/.
|
||||
if (rel.startsWith('writing/')) continue;
|
||||
if (rel === INDEX_FILE) {
|
||||
routes.add('/');
|
||||
} else if (rel.endsWith(`/${INDEX_FILE}`)) {
|
||||
|
|
@ -99,6 +103,16 @@ try {
|
|||
throw new Error('dist/ does not exist. Run npm run build first.');
|
||||
}
|
||||
|
||||
// Some CI/dev containers mount /tmp as a very small tmpfs. Chromium uses the
|
||||
// process temp directory for profiles and internal files; putting it under the
|
||||
// already-ignored .astro/ directory keeps the overflow check reproducible even
|
||||
// when the system temp mount is full.
|
||||
await rm(browserTmp, { recursive: true, force: true });
|
||||
await mkdir(browserTmp, { recursive: true });
|
||||
process.env.TMPDIR = browserTmp;
|
||||
process.env.TMP = browserTmp;
|
||||
process.env.TEMP = browserTmp;
|
||||
|
||||
const routes = await discoverRoutes();
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
|
|
@ -115,51 +129,174 @@ const server = createServer(async (req, res) => {
|
|||
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const { port } = server.address();
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const failures = [];
|
||||
|
||||
async function measureViewport(page) {
|
||||
for (let attempt = 0; attempt < MAX_NAV_RETRIES; attempt += 1) {
|
||||
try {
|
||||
await page.waitForLoadState('load');
|
||||
return await page.evaluate(() => ({
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
}));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const isLast = attempt === MAX_NAV_RETRIES - 1;
|
||||
if (isLast || !/Execution context was destroyed|navigation/i.test(message)) {
|
||||
throw error;
|
||||
}
|
||||
await page.waitForLoadState('load').catch(() => {});
|
||||
function launchBrowser() {
|
||||
return chromium.launch({
|
||||
headless: true,
|
||||
env: {
|
||||
...process.env,
|
||||
TMPDIR: browserTmp,
|
||||
TMP: browserTmp,
|
||||
TEMP: browserTmp,
|
||||
},
|
||||
args: ['--disable-dev-shm-usage', '--disable-gpu', '--no-sandbox'],
|
||||
});
|
||||
}
|
||||
|
||||
async function withTimeout(promise, timeoutMs, label) {
|
||||
let timeout;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
timeout = setTimeout(() => reject(new Error(label)), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function safeClosePage(page) {
|
||||
await withTimeout(
|
||||
page.close(),
|
||||
CLOSE_TIMEOUT_MS,
|
||||
'Timed out while closing Playwright page'
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
async function safeCloseContext(context) {
|
||||
await withTimeout(
|
||||
context.close(),
|
||||
CLOSE_TIMEOUT_MS,
|
||||
'Timed out while closing Playwright context'
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
async function safeCloseBrowser(browser) {
|
||||
const childProcess = browser.process?.();
|
||||
try {
|
||||
await withTimeout(
|
||||
browser.close(),
|
||||
CLOSE_TIMEOUT_MS,
|
||||
'Timed out while closing Chromium'
|
||||
);
|
||||
} catch {
|
||||
childProcess?.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
|
||||
async function openBrowser() {
|
||||
return withTimeout(
|
||||
launchBrowser(),
|
||||
LAUNCH_TIMEOUT_MS,
|
||||
'Timed out while launching Chromium'
|
||||
);
|
||||
}
|
||||
|
||||
async function newMeasurementContext(browser, width) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height: 900 },
|
||||
javaScriptEnabled: true,
|
||||
});
|
||||
await context.route('**/*', (route) => {
|
||||
const type = route.request().resourceType();
|
||||
if (type === 'media') {
|
||||
route.abort('blockedbyclient');
|
||||
} else {
|
||||
route.continue();
|
||||
}
|
||||
});
|
||||
return context;
|
||||
}
|
||||
|
||||
async function openMeasurementContext(browser, width) {
|
||||
return withTimeout(
|
||||
newMeasurementContext(browser, width),
|
||||
CONTEXT_TIMEOUT_MS,
|
||||
`Timed out while creating ${width}px Playwright context`
|
||||
);
|
||||
}
|
||||
|
||||
async function measureViewport(page) {
|
||||
await page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
|
||||
return page.evaluate(() => ({
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
}));
|
||||
}
|
||||
|
||||
function shouldRetryNavigation(error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return /ERR_INSUFFICIENT_RESOURCES|Execution context was destroyed|Target.*closed|has been closed|Timed out while|navigation/i.test(
|
||||
message
|
||||
);
|
||||
}
|
||||
|
||||
async function measureRoute(context, route) {
|
||||
let page;
|
||||
try {
|
||||
page = await withTimeout(
|
||||
context.newPage(),
|
||||
PAGE_TIMEOUT_MS,
|
||||
`Timed out while creating page for ${route}`
|
||||
);
|
||||
return await withTimeout(
|
||||
(async () => {
|
||||
await page.goto(`http://127.0.0.1:${port}${route}`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 15000,
|
||||
});
|
||||
return measureViewport(page);
|
||||
})(),
|
||||
MEASURE_TIMEOUT_MS,
|
||||
`Timed out while measuring ${route}`
|
||||
);
|
||||
} finally {
|
||||
if (page) await safeClosePage(page);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
for (const width of VIEWPORT_WIDTHS) {
|
||||
const page = await browser.newPage({
|
||||
viewport: { width, height: 900 },
|
||||
javaScriptEnabled: false,
|
||||
});
|
||||
let browser;
|
||||
let context;
|
||||
try {
|
||||
browser = await openBrowser();
|
||||
context = await openMeasurementContext(browser, width);
|
||||
for (const route of routes) {
|
||||
let result;
|
||||
|
||||
for (const route of routes) {
|
||||
await page.goto(`http://127.0.0.1:${port}${route}`, { waitUntil: 'load' });
|
||||
const result = await measureViewport(page);
|
||||
for (let attempt = 0; attempt < MAX_NAV_RETRIES; attempt += 1) {
|
||||
try {
|
||||
result = await measureRoute(context, route);
|
||||
break;
|
||||
} catch (error) {
|
||||
if (!shouldRetryNavigation(error) || attempt === MAX_NAV_RETRIES - 1) {
|
||||
throw error;
|
||||
}
|
||||
await safeCloseContext(context);
|
||||
await safeCloseBrowser(browser);
|
||||
browser = await openBrowser();
|
||||
context = await openMeasurementContext(browser, width);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.scrollWidth > result.clientWidth + 1) {
|
||||
failures.push(
|
||||
`${route} overflows at ${width}px: ${result.scrollWidth}px > ${result.clientWidth}px`
|
||||
);
|
||||
if (result.scrollWidth > result.clientWidth + 1) {
|
||||
failures.push(
|
||||
`${route} overflows at ${width}px: ${result.scrollWidth}px > ${result.clientWidth}px`
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (context) await safeCloseContext(context);
|
||||
if (browser) await safeCloseBrowser(browser);
|
||||
}
|
||||
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
server.close();
|
||||
await rm(browserTmp, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
|
|
|
|||
|
|
@ -7,11 +7,14 @@ import { ARTICLE_THUMBNAIL, articlePath, formatDate, formatDateShort } from '../
|
|||
interface Props {
|
||||
posts: CollectionEntry<'posts'>[];
|
||||
showYear?: boolean;
|
||||
currentTag?: string;
|
||||
tagLimit?: number;
|
||||
// Opt-in: eagerly load the first thumbnail. Only set when the list is
|
||||
// reliably above the fold (home, tag pages). Lists below substantial
|
||||
// content (related, archives by year, 404) should leave this off.
|
||||
eagerFirstThumbnail?: boolean;
|
||||
}
|
||||
|
||||
const { posts, showYear = true, currentTag, tagLimit = 3 } = Astro.props;
|
||||
const { posts, showYear = true, tagLimit = 3, eagerFirstThumbnail = false } = Astro.props;
|
||||
---
|
||||
|
||||
<ol class="article-list">
|
||||
|
|
@ -21,11 +24,13 @@ const { posts, showYear = true, currentTag, tagLimit = 3 } = Astro.props;
|
|||
return (
|
||||
<li>
|
||||
<article>
|
||||
<a class="entry-title" href={href}>
|
||||
{post.data.title}
|
||||
</a>
|
||||
<h3>
|
||||
<a class="entry-title" href={href}>
|
||||
{post.data.title}
|
||||
</a>
|
||||
</h3>
|
||||
<p>{post.data.description}</p>
|
||||
<TagList tags={post.data.tags} currentTag={currentTag} limit={tagLimit} />
|
||||
<TagList tags={post.data.tags} limit={tagLimit} />
|
||||
</article>
|
||||
<time datetime={post.data.date.toISOString()}>
|
||||
{showYear ? formatDate(post.data.date) : formatDateShort(post.data.date)}
|
||||
|
|
@ -37,8 +42,9 @@ const { posts, showYear = true, currentTag, tagLimit = 3 } = Astro.props;
|
|||
class="article-thumbnail"
|
||||
widths={ARTICLE_THUMBNAIL.widths}
|
||||
sizes={ARTICLE_THUMBNAIL.sizes}
|
||||
loading={index === 0 ? 'eager' : 'lazy'}
|
||||
fetchpriority={index === 0 ? 'high' : undefined}
|
||||
ariaLabel={`Open article: ${post.data.title}`}
|
||||
loading={eagerFirstThumbnail && index === 0 ? 'eager' : 'lazy'}
|
||||
fetchpriority={eagerFirstThumbnail && index === 0 ? 'high' : undefined}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,10 +11,11 @@ interface Props {
|
|||
sizes: string;
|
||||
loading?: 'lazy' | 'eager';
|
||||
fetchpriority?: 'high' | 'low' | 'auto';
|
||||
ariaLabel?: string;
|
||||
// When the listing already has a focusable, screen-reader-visible title
|
||||
// link, the thumbnail link is visually duplicative. We keep it clickable
|
||||
// for pointer users but drop it from the tab order and announce no alt
|
||||
// text, so assistive tech doesn't read the same target twice.
|
||||
// for pointer users but drop it from the tab order. The link still needs
|
||||
// a name because some assistive tech exposes non-tabbable links.
|
||||
decorative?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -27,6 +28,7 @@ const {
|
|||
sizes,
|
||||
loading = 'lazy',
|
||||
fetchpriority,
|
||||
ariaLabel,
|
||||
decorative = true,
|
||||
} = Astro.props;
|
||||
|
||||
|
|
@ -38,6 +40,7 @@ const isDecorativeLink = Boolean(href) && decorative;
|
|||
class:list={['entry-thumbnail', extraClass]}
|
||||
href={href}
|
||||
tabindex={isDecorativeLink ? -1 : undefined}
|
||||
aria-label={isDecorativeLink ? (ariaLabel ?? alt) : undefined}
|
||||
>
|
||||
<Picture
|
||||
src={src}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
---
|
||||
import { navItems, site } from '../lib/site';
|
||||
|
||||
const current = Astro.url.pathname;
|
||||
const currentPath = Astro.url.pathname;
|
||||
const current =
|
||||
currentPath === '/' || currentPath.endsWith('/') || /\.[^/]+$/.test(currentPath)
|
||||
? currentPath
|
||||
: `${currentPath}/`;
|
||||
|
||||
// Exact match for the current page; section match (descendant URLs) for
|
||||
// ancestor links. `aria-current="page"` is reserved for the exact page,
|
||||
|
|
@ -46,7 +50,13 @@ const headerNavItems = navItems.filter((item) => item.href !== '/' && !item.foot
|
|||
</svg>
|
||||
<span class="sr-only">RSS feed</span>
|
||||
</a>
|
||||
<button id="theme-switcher" class="theme-switcher" type="button">
|
||||
<button
|
||||
id="theme-switcher"
|
||||
class="theme-switcher"
|
||||
type="button"
|
||||
aria-label="Dark theme"
|
||||
aria-pressed="false"
|
||||
>
|
||||
<span class="sr-only">Toggle theme</span>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -61,12 +71,19 @@ const headerNavItems = navItems.filter((item) => item.href !== '/' && !item.foot
|
|||
var switcher = document.getElementById('theme-switcher');
|
||||
if (!switcher) return;
|
||||
|
||||
// Keep in sync with --color-bg in global.css and theme-init.js.
|
||||
var THEME_BG = { light: '#fbfaf7', dark: '#151514' };
|
||||
var themeColorMetas = document.querySelectorAll('meta[name="theme-color"]');
|
||||
|
||||
function sync(theme) {
|
||||
switcher.setAttribute('aria-pressed', String(theme === 'dark'));
|
||||
switcher.setAttribute(
|
||||
'aria-label',
|
||||
'title',
|
||||
theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'
|
||||
);
|
||||
for (var i = 0; i < themeColorMetas.length; i += 1) {
|
||||
themeColorMetas[i].setAttribute('content', THEME_BG[theme]);
|
||||
}
|
||||
}
|
||||
sync(root.dataset.theme === 'dark' ? 'dark' : 'light');
|
||||
|
||||
|
|
|
|||
|
|
@ -10,30 +10,58 @@ interface Props {
|
|||
|
||||
const { item } = Astro.props;
|
||||
|
||||
const fallbackFormatFor = (format: string | undefined): 'png' | 'jpg' =>
|
||||
format === 'png' ? 'png' : 'jpg';
|
||||
const videoWidth = item.type === 'video' ? (item.poster?.width ?? 1280) : undefined;
|
||||
const videoHeight = item.type === 'video' ? (item.poster?.height ?? 720) : undefined;
|
||||
---
|
||||
|
||||
<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 stay inert and hidden from assistive tech. Meaningful
|
||||
// videos expose controls, captions, and an accessible name.
|
||||
item.decorative ? (
|
||||
<video
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
poster={item.poster?.src}
|
||||
width={videoWidth}
|
||||
height={videoHeight}
|
||||
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}
|
||||
width={videoWidth}
|
||||
height={videoHeight}
|
||||
aria-label={item.alt}
|
||||
>
|
||||
{item.webm && <source src={item.webm} type="video/webm" />}
|
||||
{item.mp4 && <source src={item.mp4} type="video/mp4" />}
|
||||
{item.captions && (
|
||||
<track
|
||||
kind="captions"
|
||||
src={item.captions}
|
||||
srclang="en"
|
||||
label={item.captionsLabel}
|
||||
default
|
||||
/>
|
||||
)}
|
||||
</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, 2400]}
|
||||
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"
|
||||
|
|
@ -42,5 +70,11 @@ const fallbackFormatFor = (format: string | undefined): 'png' | 'jpg' =>
|
|||
)
|
||||
}
|
||||
{item.caption && !item.decorative && <figcaption>{item.caption}</figcaption>}
|
||||
{item.transcript && <p class="media-transcript">{item.transcript}</p>}
|
||||
{
|
||||
item.transcript && (
|
||||
<p class="media-transcript">
|
||||
<strong>Transcript:</strong> {item.transcript}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
</figure>
|
||||
|
|
|
|||
|
|
@ -3,13 +3,17 @@ import type { CollectionEntry } from 'astro:content';
|
|||
import { getEntry } from 'astro:content';
|
||||
import EntryThumbnail from './EntryThumbnail.astro';
|
||||
import ProjectLinks from './ProjectLinks.astro';
|
||||
import { PROJECT_THUMBNAIL, articlePath, projectAnchor } from '../lib/site';
|
||||
import { PROJECT_THUMBNAIL, articlePath, entrySlug } from '../lib/site';
|
||||
|
||||
interface Props {
|
||||
projects: CollectionEntry<'projects'>[];
|
||||
// Opt-in: eagerly load the first thumbnail. Only set when the list is
|
||||
// reliably above the fold. The home and projects-index lists sit below
|
||||
// other sections, so leave this off there.
|
||||
eagerFirstThumbnail?: boolean;
|
||||
}
|
||||
|
||||
const { projects } = Astro.props;
|
||||
const { projects, eagerFirstThumbnail = false } = Astro.props;
|
||||
|
||||
// The `essay` field is a `reference('posts')`, so when present it's always a
|
||||
// `{ collection, id }` shape that `getEntry` resolves to a CollectionEntry.
|
||||
|
|
@ -25,7 +29,7 @@ for (const project of projects) {
|
|||
<ol class="project-list">
|
||||
{
|
||||
projects.map((project, index) => {
|
||||
const anchor = projectAnchor(project);
|
||||
const anchor = entrySlug(project);
|
||||
const titleId = `${anchor}-title`;
|
||||
const essayHref = essayHrefs.get(project.id);
|
||||
const primaryHref = essayHref ?? project.data.links[0]?.url;
|
||||
|
|
@ -39,8 +43,9 @@ for (const project of projects) {
|
|||
class="project-thumbnail"
|
||||
widths={PROJECT_THUMBNAIL.widths}
|
||||
sizes={PROJECT_THUMBNAIL.sizes}
|
||||
loading={index === 0 ? 'eager' : 'lazy'}
|
||||
fetchpriority={index === 0 ? 'high' : undefined}
|
||||
ariaLabel={`Open project: ${project.data.title}`}
|
||||
loading={eagerFirstThumbnail && index === 0 ? 'eager' : 'lazy'}
|
||||
fetchpriority={eagerFirstThumbnail && index === 0 ? 'high' : undefined}
|
||||
/>
|
||||
<article class="project-card__summary">
|
||||
<h3 id={titleId}>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ const remaining =
|
|||
{
|
||||
visibleTags.map((tag) => (
|
||||
<li>
|
||||
<a href={tagPath(tag)} aria-current={tag === currentTag ? 'true' : undefined}>
|
||||
<a href={tagPath(tag)} aria-current={tag === currentTag ? 'page' : undefined}>
|
||||
{tag}
|
||||
{counts && counts[tag] !== undefined && (
|
||||
<span class="tag-count">{counts[tag]}</span>
|
||||
|
|
|
|||
|
|
@ -3,9 +3,38 @@ import type { SchemaContext } from 'astro:content';
|
|||
import { glob } from 'astro/loaders';
|
||||
import { z } from 'astro/zod';
|
||||
|
||||
function isRootRelativeUrl(url: string) {
|
||||
return url.startsWith('/') && !url.startsWith('//');
|
||||
}
|
||||
|
||||
const linkUrl = z.string().refine(
|
||||
(url) => {
|
||||
if (isRootRelativeUrl(url)) return true;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return ['https:', 'mailto:'].includes(parsed.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ message: 'URL must be an absolute https/mailto URL or a root-relative path.' }
|
||||
);
|
||||
|
||||
const mediaUrl = z.string().refine(
|
||||
(url) => {
|
||||
if (isRootRelativeUrl(url)) return true;
|
||||
try {
|
||||
return new URL(url).protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ message: 'Media URL must be an absolute https URL or a root-relative path.' }
|
||||
);
|
||||
|
||||
const linkSchema = z.object({
|
||||
label: z.string(),
|
||||
url: z.string(),
|
||||
url: linkUrl,
|
||||
download: z.boolean().optional(),
|
||||
});
|
||||
|
||||
|
|
@ -17,20 +46,47 @@ const thumbnailSchema = ({ image }: SchemaContext) =>
|
|||
|
||||
const mediaSchema = ({ image }: SchemaContext) =>
|
||||
z
|
||||
.object({
|
||||
type: z.enum(['image', 'video', 'diagram']),
|
||||
src: image().optional(),
|
||||
poster: image().optional(),
|
||||
mp4: z.string().optional(),
|
||||
webm: z.string().optional(),
|
||||
alt: z.string().optional(),
|
||||
decorative: z.boolean().optional(),
|
||||
caption: z.string().optional(),
|
||||
transcript: z.string().optional(),
|
||||
})
|
||||
.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.enum(['image', 'diagram']),
|
||||
src: image(),
|
||||
alt: z.string().optional(),
|
||||
decorative: z.boolean().optional(),
|
||||
caption: z.string().optional(),
|
||||
transcript: z.string().optional(),
|
||||
}),
|
||||
z
|
||||
.object({
|
||||
type: z.literal('video'),
|
||||
poster: image().optional(),
|
||||
mp4: mediaUrl.optional(),
|
||||
webm: mediaUrl.optional(),
|
||||
captions: mediaUrl.optional(),
|
||||
captionsLabel: z.string().default('English captions'),
|
||||
alt: z.string().optional(),
|
||||
decorative: z.boolean().optional(),
|
||||
caption: z.string().optional(),
|
||||
transcript: z.string().optional(),
|
||||
})
|
||||
.refine((item) => Boolean(item.mp4) || Boolean(item.webm), {
|
||||
message: 'Video media needs at least one mp4 or webm source.',
|
||||
}),
|
||||
])
|
||||
.refine((item) => item.decorative || (Boolean(item.alt) && Boolean(item.caption)), {
|
||||
message: 'Meaningful media needs both alt text and a caption.',
|
||||
});
|
||||
})
|
||||
.refine(
|
||||
(item) => item.type !== 'video' || item.decorative || Boolean(item.captions),
|
||||
{
|
||||
message: 'Meaningful video needs captions.',
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(item) => item.type !== 'video' || item.decorative || Boolean(item.transcript),
|
||||
{
|
||||
message: 'Meaningful video needs a transcript.',
|
||||
}
|
||||
);
|
||||
|
||||
const posts = defineCollection({
|
||||
loader: glob({ pattern: '**/*.md', base: './src/content/posts' }),
|
||||
|
|
@ -72,7 +128,6 @@ const projects = defineCollection({
|
|||
loader: glob({ pattern: '**/*.md', base: './src/content/projects' }),
|
||||
schema: ({ image }) =>
|
||||
z.object({
|
||||
sourceProjectId: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().max(160),
|
||||
thumbnail: thumbnailSchema({ image }),
|
||||
|
|
|
|||
|
|
@ -20,8 +20,10 @@ media:
|
|||
poster: ./_assets/ad-astra.jpg
|
||||
webm: /media/video/ad_astra.webm
|
||||
mp4: /media/video/ad_astra.mp4
|
||||
captions: /media/video/ad_astra.vtt
|
||||
alt: Video demonstration of the embedded game running on a small OLED display.
|
||||
caption: The game engine ran on an ATtiny85V with an OLED display and IR input.
|
||||
transcript: No spoken dialogue. The demonstration shows the Ad Astra handheld board running its OLED game, with the player moving through the small display while the IR input controls gameplay.
|
||||
---
|
||||
|
||||
Ad Astra came from wanting to combine graphics and microcontrollers without hiding behind a large development board. The result was a small embedded game engine and game built around an ATtiny85V, an OLED display, IR input, EEPROM persistence, and a custom PCB.
|
||||
|
|
|
|||
|
|
@ -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: https://schmelczer.dev/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.
|
||||
|
|
|
|||
|
|
@ -15,8 +15,6 @@ audience: technical
|
|||
links:
|
||||
- label: Source
|
||||
url: https://github.com/schmelczer/decla.red
|
||||
- label: Demo
|
||||
url: https://decla.red
|
||||
- label: BSc thesis
|
||||
url: /media/downloads/sdf2d-andras-schmelczer.pdf
|
||||
download: true
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@ outcome: A browser drawing toy where user input seeds an agent simulation that r
|
|||
audience: technical
|
||||
links:
|
||||
- label: Demo
|
||||
url: https://schmelczer.dev/fleeting/
|
||||
url: /fleeting/
|
||||
- label: Source
|
||||
url: https://github.com/schmelczer/webgpu
|
||||
url: https://home.schmelczer.dev/git/andras/webgpu
|
||||
media:
|
||||
- type: image
|
||||
src: ./_assets/fleeting-garden.jpg
|
||||
|
|
|
|||
|
|
@ -16,8 +16,6 @@ audience: recruiter-relevant
|
|||
links:
|
||||
- label: Source
|
||||
url: https://github.com/schmelczer/life-towers/
|
||||
- label: Demo
|
||||
url: https://towers.schmelczer.dev
|
||||
media:
|
||||
- type: image
|
||||
src: ./_assets/towers.jpg
|
||||
|
|
|
|||
|
|
@ -11,9 +11,7 @@ role: Site generator author
|
|||
stack: ['Webpack', 'Image processing', 'Static site generation']
|
||||
outcome: A generated static photo site for publishing photography with responsive image output
|
||||
audience: general
|
||||
links:
|
||||
- label: Site
|
||||
url: https://photo.schmelczer.dev
|
||||
links: []
|
||||
---
|
||||
|
||||
Photos was a small webpage where you could view my photos.
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ outcome: A small, well-tested library that fills a gap between git, CRDTs, and p
|
|||
audience: recruiter-relevant
|
||||
links:
|
||||
- label: Demo
|
||||
url: https://schmelczer.dev/reconcile
|
||||
url: /reconcile/
|
||||
- label: Source
|
||||
url: https://github.com/schmelczer/reconcile
|
||||
- label: crates.io
|
||||
|
|
|
|||
|
|
@ -16,8 +16,6 @@ audience: recruiter-relevant
|
|||
links:
|
||||
- label: NPM package
|
||||
url: https://www.npmjs.com/package/sdf-2d
|
||||
- label: Demo
|
||||
url: https://sdf2d.schmelczer.dev
|
||||
- label: Video
|
||||
url: https://www.youtube.com/watch?v=K3cEtnZUNR0
|
||||
- label: BSc thesis
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
---
|
||||
sourceProjectId: ad-astra
|
||||
title: Ad Astra
|
||||
description: A tiny embedded game engine and custom PCB built around an ATtiny85V.
|
||||
thumbnail:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
---
|
||||
sourceProjectId: avoid
|
||||
title: Avoid
|
||||
description: A small early web game, kept as an archive of first experiments on the web.
|
||||
thumbnail:
|
||||
|
|
@ -10,7 +9,4 @@ sortDate: 2018-01-01
|
|||
technologies: ['JavaScript', 'Canvas']
|
||||
selected: false
|
||||
essay: avoid-early-web-game
|
||||
links:
|
||||
- label: Demo
|
||||
url: https://schmelczer.dev/avoid
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
---
|
||||
sourceProjectId: city-simulation
|
||||
title: City Simulation
|
||||
description: A Unity traffic simulation where REST-controlled traffic lights could produce visible consequences for a cybersecurity challenge.
|
||||
thumbnail:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
---
|
||||
sourceProjectId: colors
|
||||
title: Photo Colour Grader
|
||||
description: A proof-of-concept colour grading UI based on selecting colours and transforming nearby colour ranges.
|
||||
thumbnail:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
---
|
||||
sourceProjectId: declared
|
||||
title: decla.red
|
||||
description: A team-based mobile multiplayer browser game with shared client/server game logic.
|
||||
thumbnail:
|
||||
|
|
@ -13,8 +12,6 @@ essay: declared-shared-simulation-code
|
|||
links:
|
||||
- label: Source
|
||||
url: https://github.com/schmelczer/decla.red
|
||||
- label: Demo
|
||||
url: https://decla.red
|
||||
- label: BSc thesis
|
||||
url: /media/downloads/sdf2d-andras-schmelczer.pdf
|
||||
download: true
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
---
|
||||
sourceProjectId: fleeting-garden
|
||||
title: Fleeting Garden
|
||||
description: A WebGPU drawing toy where coloured strokes spawn agents that follow them, branch off, and slowly rewrite the patch you laid down.
|
||||
thumbnail:
|
||||
|
|
@ -12,7 +11,7 @@ selected: true
|
|||
essay: fleeting-garden-webgpu-drawing
|
||||
links:
|
||||
- label: Demo
|
||||
url: https://schmelczer.dev/fleeting/
|
||||
url: /fleeting/
|
||||
- label: Source
|
||||
url: https://github.com/schmelczer/webgpu
|
||||
url: https://home.schmelczer.dev/git/andras/webgpu
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
---
|
||||
sourceProjectId: forex
|
||||
title: Foreign Exchange Prediction Experiment
|
||||
description: A frequency-domain prediction experiment using smoothing, differentiation, STFT, extrapolation, and inverse transforms.
|
||||
thumbnail:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
---
|
||||
sourceProjectId: great-ai
|
||||
title: GreatAI
|
||||
description: A Python framework and research project for making AI deployment best practices easier to adopt.
|
||||
thumbnail:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
---
|
||||
sourceProjectId: leds
|
||||
title: Lights Synchronized to Music
|
||||
description: A Raspberry Pi music player that drove RGB LED strips from audio analysis.
|
||||
thumbnail:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
---
|
||||
sourceProjectId: my-notes
|
||||
title: My Notes
|
||||
description: A minimalist Android markdown note organizer and editor powered by Markwon.
|
||||
thumbnail:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
---
|
||||
sourceProjectId: nuclear-editor
|
||||
title: Graph Editor
|
||||
description: A JavaFX editor for creating and editing input graphs for the cooling system simulator.
|
||||
thumbnail:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
---
|
||||
sourceProjectId: nuclear-simulation
|
||||
title: Cooling System Simulation
|
||||
description: A graph-based process simulation with a monitoring client and JavaFX input editor.
|
||||
thumbnail:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
---
|
||||
sourceProjectId: photos
|
||||
title: Photo Site Generator
|
||||
description: A static photo site generated from a directory of images, with automatic resizing to multiple quality settings.
|
||||
thumbnail:
|
||||
|
|
@ -10,7 +9,5 @@ sortDate: 2016-07-01
|
|||
technologies: ['Webpack', 'Image processing', 'Static site generation']
|
||||
selected: false
|
||||
essay: photo-site-generator
|
||||
links:
|
||||
- label: Site
|
||||
url: https://photo.schmelczer.dev
|
||||
links: []
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
---
|
||||
sourceProjectId: platform-game
|
||||
title: Platform Game
|
||||
description: An early 3D game in C with SDL 1.2, random maps, destructible voxels, enemies, powerups, and time slowdown.
|
||||
thumbnail:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
---
|
||||
sourceProjectId: reconcile
|
||||
title: reconcile-text
|
||||
description: A Rust library that auto-resolves conflicting text edits without conflict markers, with WebAssembly and Python bindings.
|
||||
thumbnail:
|
||||
|
|
@ -12,7 +11,7 @@ selected: true
|
|||
essay: reconcile-text-3-way-merge
|
||||
links:
|
||||
- label: Demo
|
||||
url: https://schmelczer.dev/reconcile
|
||||
url: /reconcile/
|
||||
- label: Source
|
||||
url: https://github.com/schmelczer/reconcile
|
||||
- label: crates.io
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
---
|
||||
sourceProjectId: sdf-2d
|
||||
title: SDF-2D
|
||||
description: A browser rendering library for optimized 2D ray tracing with signed distance fields.
|
||||
thumbnail:
|
||||
|
|
@ -13,8 +12,6 @@ essay: sdf-2d-ray-tracing
|
|||
links:
|
||||
- label: NPM package
|
||||
url: https://www.npmjs.com/package/sdf-2d
|
||||
- label: Demo
|
||||
url: https://sdf2d.schmelczer.dev
|
||||
- label: Video
|
||||
url: https://www.youtube.com/watch?v=K3cEtnZUNR0
|
||||
- label: BSc thesis
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
---
|
||||
sourceProjectId: towers
|
||||
title: Life Towers
|
||||
description: A multi-device goal tracker where the lasting idea was syncing state with immutable tries.
|
||||
thumbnail:
|
||||
|
|
@ -13,6 +12,4 @@ essay: life-towers-immutable-tries
|
|||
links:
|
||||
- label: Source
|
||||
url: https://github.com/schmelczer/life-towers/
|
||||
- label: Demo
|
||||
url: https://towers.schmelczer.dev
|
||||
---
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ interface Props {
|
|||
const {
|
||||
title = site.title,
|
||||
description = site.description,
|
||||
canonicalPath = Astro.url.pathname,
|
||||
canonicalPath: rawCanonicalPath = Astro.url.pathname,
|
||||
ogImage,
|
||||
ogImageAlt = "Andras Schmelczer's personal site",
|
||||
ogImageWidth,
|
||||
|
|
@ -45,6 +45,12 @@ const {
|
|||
const isRoot = title === site.title;
|
||||
const pageTitle = isRoot ? site.title : `${title} · ${site.name}`;
|
||||
const ogTitle = isRoot ? site.title : title;
|
||||
const canonicalPath =
|
||||
rawCanonicalPath === '/' ||
|
||||
rawCanonicalPath.endsWith('/') ||
|
||||
/\.[^/]+$/.test(rawCanonicalPath)
|
||||
? rawCanonicalPath
|
||||
: `${rawCanonicalPath}/`;
|
||||
const canonical = absoluteUrl(canonicalPath);
|
||||
|
||||
let resolvedOgImage = ogImage;
|
||||
|
|
@ -75,68 +81,13 @@ const ogImageType =
|
|||
? 'image/svg+xml'
|
||||
: 'image/jpeg';
|
||||
const jsonLdEntries = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
|
||||
|
||||
// Head meta tags built as a single HTML string so prettier-plugin-astro
|
||||
// doesn't shuffle them outside `<head>` when reformatting (it has trouble
|
||||
// with mixed JSX-expression and raw element siblings inside <head>).
|
||||
const attr = (value: string) =>
|
||||
value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
const articleMetaParts = article
|
||||
? [
|
||||
`<meta property="article:published_time" content="${attr(article.publishedTime)}">`,
|
||||
article.modifiedTime
|
||||
? `<meta property="article:modified_time" content="${attr(article.modifiedTime)}">`
|
||||
: '',
|
||||
`<meta property="article:author" content="${attr(absoluteUrl('/about/'))}">`,
|
||||
...(article.tags ?? []).map(
|
||||
(tag) => `<meta property="article:tag" content="${attr(tag)}">`
|
||||
),
|
||||
]
|
||||
: [];
|
||||
|
||||
const monoPreloadHtml = preloadMono
|
||||
? '<link rel="preload" href="/fonts/ibm-plex-mono-latin-400.woff2" as="font" type="font/woff2" crossorigin>'
|
||||
: '';
|
||||
|
||||
const headHtml = [
|
||||
monoPreloadHtml,
|
||||
`<link rel="alternate" type="application/rss+xml" title="${attr(`${site.name} RSS`)}" 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" 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">`,
|
||||
`<meta property="og:site_name" content="${attr(site.name)}">`,
|
||||
`<meta property="og:title" content="${attr(ogTitle)}">`,
|
||||
`<meta property="og:description" content="${attr(description)}">`,
|
||||
`<meta property="og:url" content="${attr(canonical)}">`,
|
||||
`<meta property="og:image" content="${attr(ogImageUrl)}">`,
|
||||
`<meta property="og:image:type" content="${ogImageType}">`,
|
||||
`<meta property="og:image:alt" content="${attr(ogImageAlt)}">`,
|
||||
`<meta property="og:image:width" content="${resolvedOgWidth}">`,
|
||||
`<meta property="og:image:height" content="${resolvedOgHeight}">`,
|
||||
`<meta property="og:type" content="${attr(ogType)}">`,
|
||||
`<meta property="og:locale" content="en">`,
|
||||
...articleMetaParts,
|
||||
`<meta name="twitter:card" content="summary_large_image">`,
|
||||
`<meta name="twitter:title" content="${attr(ogTitle)}">`,
|
||||
`<meta name="twitter:description" content="${attr(description)}">`,
|
||||
`<meta name="twitter:image" content="${attr(ogImageUrl)}">`,
|
||||
`<meta name="twitter:image:alt" content="${attr(ogImageAlt)}">`,
|
||||
...jsonLdEntries.map(
|
||||
(entry) =>
|
||||
`<script type="application/ld+json">${JSON.stringify(entry).replace(/<\/script/gi, '<\\/script')}</script>`
|
||||
),
|
||||
].join('');
|
||||
const jsonLdStrings = jsonLdEntries.map((entry) =>
|
||||
JSON.stringify(entry).replace(/</g, '\\u003c')
|
||||
);
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en" class="no-js">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
|
|
@ -147,18 +98,11 @@ const headHtml = [
|
|||
<meta name="description" content={description} />
|
||||
<meta name="author" content={site.name} />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="theme-color" content="#fbfaf7" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#151514" media="(prefers-color-scheme: dark)" />
|
||||
{noindex && <meta name="robots" content="noindex,follow" />}
|
||||
{!noindex && <link rel="canonical" href={canonical} />}
|
||||
<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} />
|
||||
<noscript
|
||||
><style>
|
||||
.theme-switcher {
|
||||
display: none !important;
|
||||
}
|
||||
</style></noscript
|
||||
>
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/source-sans-3-latin-variable.woff2"
|
||||
|
|
@ -166,7 +110,63 @@ const headHtml = [
|
|||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
<Fragment set:html={headHtml} />
|
||||
{
|
||||
preloadMono && (
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/ibm-plex-mono-latin-400.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
)
|
||||
}
|
||||
<link
|
||||
rel="alternate"
|
||||
type="application/rss+xml"
|
||||
title={`${site.name} RSS`}
|
||||
href="/rss.xml"
|
||||
/>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<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" />
|
||||
<meta property="og:site_name" content={site.name} />
|
||||
<meta property="og:title" content={ogTitle} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:url" content={canonical} />
|
||||
<meta property="og:image" content={ogImageUrl} />
|
||||
<meta property="og:image:type" content={ogImageType} />
|
||||
<meta property="og:image:alt" content={ogImageAlt} />
|
||||
<meta property="og:image:width" content={String(resolvedOgWidth)} />
|
||||
<meta property="og:image:height" content={String(resolvedOgHeight)} />
|
||||
<meta property="og:type" content={ogType} />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
{
|
||||
article && (
|
||||
<>
|
||||
<meta property="article:published_time" content={article.publishedTime} />
|
||||
{article.modifiedTime && (
|
||||
<meta property="article:modified_time" content={article.modifiedTime} />
|
||||
)}
|
||||
<meta property="article:author" content={absoluteUrl('/about/')} />
|
||||
{article.tags?.map((tag) => (
|
||||
<meta property="article:tag" content={tag} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={ogTitle} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={ogImageUrl} />
|
||||
<meta name="twitter:image:alt" content={ogImageAlt} />
|
||||
{
|
||||
jsonLdStrings.map((jsonLdString) => (
|
||||
<script is:inline type="application/ld+json" set:html={jsonLdString} />
|
||||
))
|
||||
}
|
||||
</head>
|
||||
<body>
|
||||
<Header />
|
||||
|
|
|
|||
|
|
@ -2,9 +2,12 @@
|
|||
import type { ComponentProps } from 'astro/types';
|
||||
import Base from './Base.astro';
|
||||
|
||||
type Props = ComponentProps<typeof Base>;
|
||||
type Props = Omit<ComponentProps<typeof Base>, 'title'> & { title: string };
|
||||
|
||||
const { title, description } = Astro.props;
|
||||
if (!title) {
|
||||
throw new Error('Page layout requires a `title` prop.');
|
||||
}
|
||||
---
|
||||
|
||||
<Base {...Astro.props}>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
adjacentPosts,
|
||||
articlePath,
|
||||
buildBreadcrumbJsonLd,
|
||||
buildPersonJsonLd,
|
||||
buildBreadcrumbTrail,
|
||||
formatDate,
|
||||
getPublishedPosts,
|
||||
|
|
@ -72,6 +73,7 @@ const blogPosting = {
|
|||
};
|
||||
|
||||
const breadcrumbJsonLd = buildBreadcrumbJsonLd(trail);
|
||||
const personJsonLd = buildPersonJsonLd();
|
||||
---
|
||||
|
||||
<Base
|
||||
|
|
@ -89,7 +91,7 @@ const breadcrumbJsonLd = buildBreadcrumbJsonLd(trail);
|
|||
modifiedTime: post.data.updated?.toISOString(),
|
||||
tags: post.data.tags,
|
||||
}}
|
||||
jsonLd={[blogPosting, breadcrumbJsonLd]}
|
||||
jsonLd={[blogPosting, breadcrumbJsonLd, personJsonLd]}
|
||||
>
|
||||
<article class="post">
|
||||
<header class="post-header">
|
||||
|
|
@ -120,19 +122,19 @@ 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}
|
||||
formats={['avif', 'webp']}
|
||||
fallbackFormat="jpg"
|
||||
widths={[640, 960, 1280, 1600, 1920, 2400]}
|
||||
widths={[640, 960, 1280, 1600, 1920]}
|
||||
sizes="(max-width: 700px) calc(100vw - 3rem), (max-width: 1100px) calc(100vw - 4rem), 56rem"
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
/>
|
||||
</figure>
|
||||
</div>
|
||||
|
||||
<AtAGlance
|
||||
headingId={`at-a-glance-${post.id}`}
|
||||
|
|
|
|||
|
|
@ -73,13 +73,6 @@ export function tagPath(tag: string) {
|
|||
return `/tags/${tagSlug(tag)}/`;
|
||||
}
|
||||
|
||||
// Anchor used for `id="..."` on project cards and `#fragment` deep links.
|
||||
// Always derived from the canonical `sourceProjectId` slug now that the
|
||||
// legacy anchor mapping has been dropped.
|
||||
export function projectAnchor(slugOrEntry: string | CollectionEntry<'projects'>) {
|
||||
return typeof slugOrEntry === 'string' ? slugOrEntry : slugOrEntry.data.sourceProjectId;
|
||||
}
|
||||
|
||||
export function getAllTags(posts: { data: { tags: readonly string[] } }[]) {
|
||||
return [...new Set(posts.flatMap((post) => post.data.tags))].sort((a, b) =>
|
||||
a.localeCompare(b)
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ const jsonLd = [blogJsonLd, breadcrumbJsonLd];
|
|||
---
|
||||
|
||||
<Page title="Articles" description={description} jsonLd={jsonLd}>
|
||||
<nav id="tags" class="tag-filter" aria-label="Browse by tag">
|
||||
<nav id="tag-filter" class="tag-filter" aria-label="Browse by tag">
|
||||
<span>Browse by tag</span>
|
||||
<TagList tags={tags} />
|
||||
</nav>
|
||||
|
|
@ -62,7 +62,11 @@ const jsonLd = [blogJsonLd, breadcrumbJsonLd];
|
|||
return (
|
||||
<section class="archive-year">
|
||||
<h2 id={`year-${year}`}>{year}</h2>
|
||||
<ArticleList posts={postsForYear} showYear={false} />
|
||||
<ArticleList
|
||||
posts={postsForYear}
|
||||
showYear={false}
|
||||
eagerFirstThumbnail={year === years[0]}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ const jsonLd = [collectionJsonLd, breadcrumbJsonLd];
|
|||
<Page title="Projects" description={description} jsonLd={jsonLd}>
|
||||
<section class="project-section">
|
||||
<h2 id="selected-projects">Selected Projects</h2>
|
||||
<ProjectList projects={selected} />
|
||||
<ProjectList projects={selected} eagerFirstThumbnail />
|
||||
</section>
|
||||
|
||||
<section class="project-section">
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
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,
|
||||
articlePath,
|
||||
entrySlug,
|
||||
getPublishedPosts,
|
||||
optimizeOgImage,
|
||||
site,
|
||||
|
|
@ -21,22 +22,67 @@ function escapeXml(value: string) {
|
|||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Format a Date as `YYYY-MM-DD` in UTC for use inside tag: URIs.
|
||||
function isoDate(date: Date) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
// 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) => {
|
||||
export const GET: APIRoute = async () => {
|
||||
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,
|
||||
description: site.description,
|
||||
site: context.site ?? site.url,
|
||||
site: site.url,
|
||||
xmlns: {
|
||||
atom: 'http://www.w3.org/2005/Atom',
|
||||
content: 'http://purl.org/rss/1.0/modules/content/',
|
||||
|
|
@ -52,30 +98,6 @@ export const GET: APIRoute = async (context) => {
|
|||
` <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>`
|
||||
: '';
|
||||
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: [
|
||||
`<guid isPermaLink="false">${escapeXml(guid)}</guid>`,
|
||||
`<dc:creator>${creator}</dc:creator>`,
|
||||
updated,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
};
|
||||
}),
|
||||
items,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ const breadcrumbJsonLd = buildBreadcrumbJsonLd(trail);
|
|||
title={title}
|
||||
description={`Project articles and technical notes filed under #${tag}.`}
|
||||
jsonLd={breadcrumbJsonLd}
|
||||
noindex
|
||||
>
|
||||
<Breadcrumbs slot="breadcrumbs" items={visibleTrail} />
|
||||
<nav class="tag-filter" aria-label="Browse other tags">
|
||||
|
|
@ -44,5 +45,5 @@ const breadcrumbJsonLd = buildBreadcrumbJsonLd(trail);
|
|||
</nav>
|
||||
|
||||
<h2 class="sr-only">Articles</h2>
|
||||
<ArticleList posts={filteredPosts} currentTag={tag} />
|
||||
<ArticleList posts={filteredPosts} eagerFirstThumbnail />
|
||||
</Page>
|
||||
|
|
|
|||
|
|
@ -37,7 +37,9 @@ const jsonLd = [collectionJsonLd, breadcrumbJsonLd];
|
|||
|
||||
<Page title="Tags" description={description} jsonLd={jsonLd}>
|
||||
<p class="dek">
|
||||
{posts.length} articles across {tags.length} tags.
|
||||
{posts.length}
|
||||
{posts.length === 1 ? 'article' : 'articles'} across {tags.length}
|
||||
{tags.length === 1 ? 'tag' : 'tags'}.
|
||||
</p>
|
||||
<TagList tags={tags} counts={tagCounts} />
|
||||
</Page>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,26 @@
|
|||
// FOUC prevention: runs in <head> before paint. Sets the theme on <html> so
|
||||
// the page renders with the right colors on first load. The theme switcher
|
||||
// button is wired up separately, after it is parsed, in Header.astro.
|
||||
//
|
||||
// Keep THEME_BG values in sync with --color-bg in global.css. They drive the
|
||||
// browser-chrome <meta name="theme-color"> so it follows the user's manual
|
||||
// toggle (the static media-keyed metas only tracked OS preference).
|
||||
(function () {
|
||||
document.documentElement.classList.remove('no-js');
|
||||
document.documentElement.classList.add('js');
|
||||
|
||||
var STORAGE_KEY = 'theme';
|
||||
var LEGACY_KEY = 'dark-mode';
|
||||
var THEME_BG = { light: '#fbfaf7', dark: '#151514' };
|
||||
var saved = null;
|
||||
try {
|
||||
var value = localStorage.getItem(STORAGE_KEY);
|
||||
if (value === 'light' || value === 'dark') {
|
||||
saved = value;
|
||||
} else {
|
||||
var legacy = localStorage.getItem(LEGACY_KEY);
|
||||
if (legacy !== null) saved = JSON.parse(legacy) ? 'dark' : 'light';
|
||||
}
|
||||
if (value === 'light' || value === 'dark') saved = value;
|
||||
} catch (e) {}
|
||||
var theme = saved || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
var themeColorMetas = document.querySelectorAll('meta[name="theme-color"]');
|
||||
for (var i = 0; i < themeColorMetas.length; i += 1) {
|
||||
themeColorMetas[i].setAttribute('content', THEME_BG[theme]);
|
||||
}
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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%),
|
||||
|
|
@ -202,7 +205,8 @@
|
|||
}
|
||||
|
||||
main:focus-visible {
|
||||
outline: none;
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
::selection {
|
||||
|
|
@ -354,6 +358,13 @@
|
|||
margin-inline: calc(-1 * var(--space-1));
|
||||
}
|
||||
|
||||
.footer-contact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2) var(--space-5);
|
||||
}
|
||||
|
||||
/* Page header (shared by .home-intro, .page-header, .post-header) */
|
||||
.home-intro {
|
||||
max-width: var(--measure-wide);
|
||||
|
|
@ -466,10 +477,17 @@
|
|||
font-size: var(--fs-caption);
|
||||
}
|
||||
|
||||
.breadcrumbs li + li::before {
|
||||
.breadcrumbs li {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.breadcrumbs li:not(:last-child)::after {
|
||||
content: '›';
|
||||
margin-right: var(--space-2);
|
||||
margin-left: var(--space-2);
|
||||
color: var(--color-rule-medium);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.breadcrumbs a {
|
||||
|
|
@ -534,8 +552,7 @@
|
|||
}
|
||||
|
||||
.tag-list a:hover,
|
||||
.tag-list a[aria-current='page'],
|
||||
.tag-list a[aria-current='true'] {
|
||||
.tag-list a[aria-current='page'] {
|
||||
color: var(--color-fg);
|
||||
}
|
||||
|
||||
|
|
@ -598,6 +615,12 @@
|
|||
padding-right: var(--space-3);
|
||||
}
|
||||
|
||||
.article-list h3,
|
||||
.project-list h3 {
|
||||
font-size: var(--fs-base);
|
||||
line-height: var(--leading-snug);
|
||||
}
|
||||
|
||||
.article-list .entry-title,
|
||||
.project-list h3 a {
|
||||
display: inline-flex;
|
||||
|
|
@ -647,10 +670,15 @@
|
|||
border-color: var(--color-rule-strong);
|
||||
}
|
||||
|
||||
.article-list > li:hover .entry-thumbnail img {
|
||||
.article-list > li:hover .entry-thumbnail img,
|
||||
.article-list > li:focus-within .entry-thumbnail img {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.article-list > li:focus-within .entry-thumbnail {
|
||||
border-color: var(--color-rule-strong);
|
||||
}
|
||||
|
||||
.article-thumbnail {
|
||||
grid-area: thumb;
|
||||
align-self: center;
|
||||
|
|
@ -673,10 +701,8 @@
|
|||
transition: border-color 150ms ease;
|
||||
}
|
||||
|
||||
.project-card:hover {
|
||||
border-color: var(--color-rule-strong);
|
||||
}
|
||||
|
||||
.project-card:hover,
|
||||
.project-card:focus-within,
|
||||
.project-card:target {
|
||||
border-color: var(--color-rule-strong);
|
||||
}
|
||||
|
|
@ -695,7 +721,8 @@
|
|||
transition: transform 300ms ease;
|
||||
}
|
||||
|
||||
.project-card:hover .project-thumbnail img {
|
||||
.project-card:hover .project-thumbnail img,
|
||||
.project-card:focus-within .project-thumbnail img {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
|
|
@ -743,11 +770,6 @@
|
|||
vertical-align: 0.15em;
|
||||
}
|
||||
|
||||
.project-list h3 {
|
||||
font-size: var(--fs-base);
|
||||
line-height: var(--leading-snug);
|
||||
}
|
||||
|
||||
/* -- Project links ---------------------------------------------------- */
|
||||
|
||||
.project-links {
|
||||
|
|
@ -761,6 +783,7 @@
|
|||
|
||||
.project-links a {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--color-link);
|
||||
|
|
@ -784,7 +807,7 @@
|
|||
}
|
||||
|
||||
.project-card .project-links a {
|
||||
min-height: 2.25rem;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* -- Post layout ------------------------------------------------------ */
|
||||
|
|
@ -798,6 +821,7 @@
|
|||
|
||||
.post > .at-a-glance,
|
||||
.post > .post-thumbnail,
|
||||
.post > .post-gallery,
|
||||
.post-nav {
|
||||
max-width: var(--measure-wide);
|
||||
margin-inline: auto;
|
||||
|
|
@ -906,10 +930,15 @@
|
|||
font-weight: var(--weight-regular);
|
||||
font-size: 0.85em;
|
||||
text-decoration: none;
|
||||
opacity: 0.25;
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.prose .heading-anchor:focus-visible {
|
||||
opacity: 1;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.prose .heading-anchor::before {
|
||||
content: '#';
|
||||
}
|
||||
|
|
@ -923,7 +952,7 @@
|
|||
|
||||
@media (hover: none) {
|
||||
.prose .heading-anchor {
|
||||
opacity: 0.5;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1089,6 +1118,7 @@
|
|||
|
||||
.post > .post-header,
|
||||
.post > .post-thumbnail,
|
||||
.post > .post-gallery,
|
||||
.post > .post-media,
|
||||
.post > .post-nav {
|
||||
grid-column: 1 / -1;
|
||||
|
|
@ -1137,6 +1167,11 @@
|
|||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.media-transcript strong {
|
||||
color: var(--color-fg);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
|
||||
/* -- Post nav --------------------------------------------------------- */
|
||||
|
||||
.post-nav {
|
||||
|
|
@ -1209,6 +1244,11 @@
|
|||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.project-card h3 a {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
.post-toc ol {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
|
|
@ -1242,6 +1282,15 @@
|
|||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.post > .post-gallery {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.post-gallery .post-media {
|
||||
max-inline-size: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* -- External link affordance ----------------------------------------- */
|
||||
|
||||
.external-link-icon {
|
||||
|
|
@ -1286,7 +1335,9 @@
|
|||
display: inline-block;
|
||||
width: var(--switcher-w);
|
||||
height: var(--switcher-h);
|
||||
margin: var(--space-2) 0;
|
||||
/* Adjacent header targets remain at least 44px apart while the visual
|
||||
track stays compact. */
|
||||
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);
|
||||
|
|
@ -1304,6 +1355,10 @@
|
|||
border-color: var(--color-rule-strong);
|
||||
}
|
||||
|
||||
.no-js .theme-switcher {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.theme-switcher::before,
|
||||
.theme-switcher::after {
|
||||
content: '';
|
||||
|
|
@ -1353,24 +1408,36 @@
|
|||
.theme-switcher {
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-block-size: 44px;
|
||||
min-inline-size: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
overflow: visible;
|
||||
background: ButtonFace;
|
||||
color: ButtonText;
|
||||
border: 1px solid ButtonBorder;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.theme-switcher::before,
|
||||
.theme-switcher::after {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.theme-switcher::before {
|
||||
content: 'Light';
|
||||
position: static;
|
||||
width: auto;
|
||||
height: auto;
|
||||
transform: none;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.theme-switcher[aria-pressed='true']::before {
|
||||
content: 'Dark';
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1414,16 +1481,33 @@
|
|||
padding-block: var(--space-4);
|
||||
}
|
||||
|
||||
.article-list > li > div {
|
||||
.article-list > li > article {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.article-list time {
|
||||
text-align: start;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.article-list .entry-thumbnail {
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.project-card {
|
||||
--project-thumb-size: 7rem;
|
||||
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
'thumb'
|
||||
'summary';
|
||||
}
|
||||
|
||||
.project-card .project-thumbnail {
|
||||
height: auto;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--color-rule);
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.project-card .project-meta {
|
||||
|
|
@ -1447,6 +1531,13 @@
|
|||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Preserve the inset outline on <main> so the post-skip-link focus ring
|
||||
doesn't escape its container. Repeated here because this layer wins
|
||||
over the base rule regardless of selector specificity. */
|
||||
main:focus-visible {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.post-nav__list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue