Claude improvements
|
|
@ -1,18 +1,52 @@
|
||||||
import sitemap from '@astrojs/sitemap';
|
import sitemap from '@astrojs/sitemap';
|
||||||
import { defineConfig } from 'astro/config';
|
import { defineConfig } from 'astro/config';
|
||||||
|
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
|
||||||
|
import rehypeSlug from 'rehype-slug';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
site: 'https://schmelczer.dev',
|
site: 'https://schmelczer.dev',
|
||||||
trailingSlash: 'always',
|
trailingSlash: 'always',
|
||||||
|
redirects: {
|
||||||
|
'/writing/': '/articles/',
|
||||||
|
'/writing/[slug]': '/articles/[slug]',
|
||||||
|
},
|
||||||
integrations: [
|
integrations: [
|
||||||
sitemap({
|
sitemap({
|
||||||
filter: (page) => !new URL(page).pathname.startsWith('/writing/'),
|
filter: (page) => {
|
||||||
|
const path = new URL(page).pathname;
|
||||||
|
return !path.startsWith('/writing/') && path !== '/404/';
|
||||||
|
},
|
||||||
|
serialize(item) {
|
||||||
|
return { ...item, changefreq: 'monthly' };
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
image: {
|
||||||
|
service: { entrypoint: 'astro/assets/services/sharp' },
|
||||||
|
},
|
||||||
markdown: {
|
markdown: {
|
||||||
shikiConfig: {
|
shikiConfig: {
|
||||||
theme: 'github-light',
|
themes: {
|
||||||
|
light: 'github-light',
|
||||||
|
dark: 'github-dark',
|
||||||
|
},
|
||||||
|
defaultColor: false,
|
||||||
wrap: false,
|
wrap: false,
|
||||||
},
|
},
|
||||||
|
rehypePlugins: [
|
||||||
|
rehypeSlug,
|
||||||
|
[
|
||||||
|
rehypeAutolinkHeadings,
|
||||||
|
{
|
||||||
|
behavior: 'append',
|
||||||
|
properties: {
|
||||||
|
className: ['heading-anchor'],
|
||||||
|
'aria-hidden': 'true',
|
||||||
|
tabIndex: -1,
|
||||||
|
},
|
||||||
|
content: { type: 'text', value: '#' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
112
package-lock.json
generated
|
|
@ -14,6 +14,8 @@
|
||||||
"playwright": "^1.59.1",
|
"playwright": "^1.59.1",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
"prettier-plugin-astro": "^0.14.1",
|
"prettier-plugin-astro": "^0.14.1",
|
||||||
|
"rehype-autolink-headings": "^7.1.0",
|
||||||
|
"rehype-slug": "^6.0.0",
|
||||||
"sharp": "^0.32.6",
|
"sharp": "^0.32.6",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
|
|
@ -3586,6 +3588,20 @@
|
||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hast-util-heading-rank": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/hast-util-heading-rank/-/hast-util-heading-rank-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/hast": "^3.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hast-util-is-element": {
|
"node_modules/hast-util-is-element": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz",
|
||||||
|
|
@ -3684,6 +3700,20 @@
|
||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hast-util-to-string": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/hast": "^3.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hast-util-to-text": {
|
"node_modules/hast-util-to-text": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz",
|
||||||
|
|
@ -5399,6 +5429,25 @@
|
||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rehype-autolink-headings": {
|
||||||
|
"version": "7.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/rehype-autolink-headings/-/rehype-autolink-headings-7.1.0.tgz",
|
||||||
|
"integrity": "sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/hast": "^3.0.0",
|
||||||
|
"@ungap/structured-clone": "^1.0.0",
|
||||||
|
"hast-util-heading-rank": "^3.0.0",
|
||||||
|
"hast-util-is-element": "^3.0.0",
|
||||||
|
"unified": "^11.0.0",
|
||||||
|
"unist-util-visit": "^5.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/rehype-parse": {
|
"node_modules/rehype-parse": {
|
||||||
"version": "9.0.1",
|
"version": "9.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz",
|
||||||
|
|
@ -5431,6 +5480,24 @@
|
||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rehype-slug": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/hast": "^3.0.0",
|
||||||
|
"github-slugger": "^2.0.0",
|
||||||
|
"hast-util-heading-rank": "^3.0.0",
|
||||||
|
"hast-util-to-string": "^3.0.0",
|
||||||
|
"unist-util-visit": "^5.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/rehype-stringify": {
|
"node_modules/rehype-stringify": {
|
||||||
"version": "10.0.1",
|
"version": "10.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz",
|
||||||
|
|
@ -9189,6 +9256,15 @@
|
||||||
"web-namespaces": "^2.0.0"
|
"web-namespaces": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"hast-util-heading-rank": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/hast-util-heading-rank/-/hast-util-heading-rank-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"@types/hast": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"hast-util-is-element": {
|
"hast-util-is-element": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz",
|
||||||
|
|
@ -9262,6 +9338,15 @@
|
||||||
"zwitch": "^2.0.0"
|
"zwitch": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"hast-util-to-string": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"@types/hast": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"hast-util-to-text": {
|
"hast-util-to-text": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz",
|
||||||
|
|
@ -10374,6 +10459,20 @@
|
||||||
"unified": "^11.0.0"
|
"unified": "^11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"rehype-autolink-headings": {
|
||||||
|
"version": "7.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/rehype-autolink-headings/-/rehype-autolink-headings-7.1.0.tgz",
|
||||||
|
"integrity": "sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"@types/hast": "^3.0.0",
|
||||||
|
"@ungap/structured-clone": "^1.0.0",
|
||||||
|
"hast-util-heading-rank": "^3.0.0",
|
||||||
|
"hast-util-is-element": "^3.0.0",
|
||||||
|
"unified": "^11.0.0",
|
||||||
|
"unist-util-visit": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"rehype-parse": {
|
"rehype-parse": {
|
||||||
"version": "9.0.1",
|
"version": "9.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz",
|
||||||
|
|
@ -10396,6 +10495,19 @@
|
||||||
"vfile": "^6.0.0"
|
"vfile": "^6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"rehype-slug": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"@types/hast": "^3.0.0",
|
||||||
|
"github-slugger": "^2.0.0",
|
||||||
|
"hast-util-heading-rank": "^3.0.0",
|
||||||
|
"hast-util-to-string": "^3.0.0",
|
||||||
|
"unist-util-visit": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"rehype-stringify": {
|
"rehype-stringify": {
|
||||||
"version": "10.0.1",
|
"version": "10.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@
|
||||||
"playwright": "^1.59.1",
|
"playwright": "^1.59.1",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
"prettier-plugin-astro": "^0.14.1",
|
"prettier-plugin-astro": "^0.14.1",
|
||||||
|
"rehype-autolink-headings": "^7.1.0",
|
||||||
|
"rehype-slug": "^6.0.0",
|
||||||
"sharp": "^0.32.6",
|
"sharp": "^0.32.6",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 99 KiB |
|
|
@ -1,2 +1,4 @@
|
||||||
User-agent: *
|
User-agent: *
|
||||||
Allow: /
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://schmelczer.dev/sitemap-index.xml
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,23 @@
|
||||||
{
|
{
|
||||||
"name": "Portfolio - Andras Schmelczer",
|
"name": "Andras Schmelczer",
|
||||||
"short_name": "Portfolio",
|
"short_name": "Schmelczer",
|
||||||
|
"description": "Andras Schmelczer writes about software systems, AI deployment, graphics, simulations, and tools.",
|
||||||
|
"lang": "en",
|
||||||
|
"id": "/",
|
||||||
|
"categories": ["education", "personal", "technology"],
|
||||||
"icons": [
|
"icons": [
|
||||||
{ "src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" },
|
{ "src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" },
|
||||||
{ "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" }
|
{ "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" },
|
||||||
|
{
|
||||||
|
"src": "/android-chrome-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"theme_color": "#B7455E",
|
"theme_color": "#fbfaf7",
|
||||||
"background_color": "#242638",
|
"background_color": "#fbfaf7",
|
||||||
"display": "standalone"
|
"display": "standalone",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,10 @@
|
||||||
import { createServer } from 'node:http';
|
import { createServer } from 'node:http';
|
||||||
import { readFile, stat } from 'node:fs/promises';
|
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { chromium } from 'playwright';
|
import { chromium } from 'playwright';
|
||||||
|
|
||||||
const dist = path.resolve('dist');
|
const dist = path.resolve('dist');
|
||||||
const routes = [
|
const widths = [320, 390, 430, 768, 1024, 1440, 1920];
|
||||||
'/',
|
|
||||||
'/articles/',
|
|
||||||
'/articles/greatai-ai-deployment-api/',
|
|
||||||
'/writing/',
|
|
||||||
'/writing/greatai-ai-deployment-api/',
|
|
||||||
'/projects/',
|
|
||||||
'/about/',
|
|
||||||
];
|
|
||||||
const widths = [320, 390, 430];
|
|
||||||
|
|
||||||
function contentType(file) {
|
function contentType(file) {
|
||||||
if (file.endsWith('.html')) return 'text/html; charset=utf-8';
|
if (file.endsWith('.html')) return 'text/html; charset=utf-8';
|
||||||
|
|
@ -27,6 +18,38 @@ function contentType(file) {
|
||||||
return 'application/octet-stream';
|
return 'application/octet-stream';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 discoverRoutes() {
|
||||||
|
const files = await walk(dist);
|
||||||
|
const routes = new Set();
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.endsWith('.html')) continue;
|
||||||
|
const rel = path.relative(dist, file).replaceAll(path.sep, '/');
|
||||||
|
if (rel === '404.html') continue;
|
||||||
|
if (rel.endsWith('/index.html')) {
|
||||||
|
routes.add('/' + rel.slice(0, -'index.html'.length));
|
||||||
|
} else if (rel === 'index.html') {
|
||||||
|
routes.add('/');
|
||||||
|
} else {
|
||||||
|
routes.add('/' + rel.replace(/\.html$/, '/'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...routes].sort();
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveFile(url) {
|
async function resolveFile(url) {
|
||||||
const parsed = new URL(url, 'http://localhost');
|
const parsed = new URL(url, 'http://localhost');
|
||||||
const safePath = path
|
const safePath = path
|
||||||
|
|
@ -52,6 +75,8 @@ async function resolveFile(url) {
|
||||||
return path.join(dist, '404.html');
|
return path.join(dist, '404.html');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const routes = await discoverRoutes();
|
||||||
|
|
||||||
const server = createServer(async (req, res) => {
|
const server = createServer(async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const file = await resolveFile(req.url ?? '/');
|
const file = await resolveFile(req.url ?? '/');
|
||||||
|
|
@ -122,4 +147,6 @@ if (failures.length > 0) {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('No horizontal overflow detected at 320px, 390px, or 430px.');
|
console.log(
|
||||||
|
`No horizontal overflow detected at ${widths.join(', ')}px across ${routes.length} routes.`
|
||||||
|
);
|
||||||
|
|
|
||||||
BIN
src/assets/og-default.jpg
Normal file
|
After Width: | Height: | Size: 99 KiB |
|
|
@ -1,8 +1,8 @@
|
||||||
---
|
---
|
||||||
import type { CollectionEntry } from 'astro:content';
|
import type { CollectionEntry } from 'astro:content';
|
||||||
import { Image } from 'astro:assets';
|
import EntryThumbnail from './EntryThumbnail.astro';
|
||||||
import { articlePath, formatDate } from '../lib/site';
|
|
||||||
import TagList from './TagList.astro';
|
import TagList from './TagList.astro';
|
||||||
|
import { articlePath, formatDate, formatDateShort } from '../lib/site';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
posts: CollectionEntry<'posts'>[];
|
posts: CollectionEntry<'posts'>[];
|
||||||
|
|
@ -10,7 +10,7 @@ interface Props {
|
||||||
currentTag?: string;
|
currentTag?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { posts, showYear = false, currentTag } = Astro.props;
|
const { posts, showYear = true, currentTag } = Astro.props;
|
||||||
---
|
---
|
||||||
|
|
||||||
<ol class="article-list">
|
<ol class="article-list">
|
||||||
|
|
@ -20,7 +20,7 @@ const { posts, showYear = false, currentTag } = Astro.props;
|
||||||
return (
|
return (
|
||||||
<li>
|
<li>
|
||||||
<time datetime={post.data.date.toISOString()}>
|
<time datetime={post.data.date.toISOString()}>
|
||||||
{showYear ? formatDate(post.data.date) : formatDate(post.data.date)}
|
{showYear ? formatDate(post.data.date) : formatDateShort(post.data.date)}
|
||||||
</time>
|
</time>
|
||||||
<div>
|
<div>
|
||||||
<a class="entry-title" href={href}>
|
<a class="entry-title" href={href}>
|
||||||
|
|
@ -29,19 +29,14 @@ const { posts, showYear = false, currentTag } = Astro.props;
|
||||||
<p>{post.data.description}</p>
|
<p>{post.data.description}</p>
|
||||||
<TagList tags={post.data.tags} currentTag={currentTag} />
|
<TagList tags={post.data.tags} currentTag={currentTag} />
|
||||||
</div>
|
</div>
|
||||||
<a
|
<EntryThumbnail
|
||||||
class="entry-thumbnail article-thumbnail"
|
src={post.data.thumbnail.src}
|
||||||
|
alt={post.data.thumbnail.alt}
|
||||||
href={href}
|
href={href}
|
||||||
aria-label={post.data.title}
|
class="article-thumbnail"
|
||||||
>
|
widths={[120, 180, 240, 320, 480]}
|
||||||
<Image
|
sizes="(max-width: 700px) clamp(64px, 22vw, 80px), (max-width: 960px) 7rem, 8rem"
|
||||||
src={post.data.thumbnail.src}
|
/>
|
||||||
alt={post.data.thumbnail.alt}
|
|
||||||
widths={[160, 240, 320, 480]}
|
|
||||||
sizes="(max-width: 700px) 5rem, 10rem"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,33 @@
|
||||||
---
|
---
|
||||||
|
import type { CollectionEntry } from 'astro:content';
|
||||||
import ProjectLinks from './ProjectLinks.astro';
|
import ProjectLinks from './ProjectLinks.astro';
|
||||||
|
|
||||||
|
type Link = CollectionEntry<'projects'>['data']['links'][number];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
role?: string;
|
role?: string;
|
||||||
projectPeriod?: string;
|
projectPeriod?: string;
|
||||||
stack?: string[];
|
stack?: string[];
|
||||||
scale?: string;
|
scale?: string;
|
||||||
outcome?: string;
|
outcome?: string;
|
||||||
links?: Array<{ label: string; type: string; url: string; download?: boolean }>;
|
links?: Link[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const { role, projectPeriod, stack = [], scale, outcome, links = [] } = Astro.props;
|
const { role, projectPeriod, stack = [], scale, outcome, links = [] } = Astro.props;
|
||||||
|
|
||||||
const rows = [
|
const rows: Array<[string, string]> = [
|
||||||
['Role', role],
|
['Role', role ?? ''],
|
||||||
['Period', projectPeriod],
|
['Period', projectPeriod ?? ''],
|
||||||
['Stack', stack.join(', ')],
|
['Stack', stack.join(', ')],
|
||||||
['Scale', scale],
|
['Scale', scale ?? ''],
|
||||||
['Outcome', outcome],
|
['Outcome', outcome ?? ''],
|
||||||
].filter(([, value]) => Boolean(value));
|
].filter((row): row is [string, string] => Boolean(row[1]));
|
||||||
---
|
---
|
||||||
|
|
||||||
{
|
{
|
||||||
rows.length > 0 && (
|
rows.length > 0 && (
|
||||||
<aside class="at-a-glance" aria-label="At a glance">
|
<aside class="at-a-glance" aria-labelledby="at-a-glance-heading">
|
||||||
<h2>At a Glance</h2>
|
<h2 id="at-a-glance-heading">At a Glance</h2>
|
||||||
<dl>
|
<dl>
|
||||||
{rows.map(([label, value]) => (
|
{rows.map(([label, value]) => (
|
||||||
<>
|
<>
|
||||||
|
|
@ -33,7 +36,7 @@ const rows = [
|
||||||
</>
|
</>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
<ProjectLinks links={links} />
|
{links.length > 0 && <ProjectLinks links={links} />}
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
29
src/components/Breadcrumbs.astro
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
---
|
||||||
|
interface Crumb {
|
||||||
|
href?: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: Crumb[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { items } = Astro.props;
|
||||||
|
const last = items.length - 1;
|
||||||
|
---
|
||||||
|
|
||||||
|
<nav aria-label="Breadcrumb">
|
||||||
|
<ol class="breadcrumbs">
|
||||||
|
{
|
||||||
|
items.map((item, index) => (
|
||||||
|
<li>
|
||||||
|
{item.href && index !== last ? (
|
||||||
|
<a href={item.href}>{item.label}</a>
|
||||||
|
) : (
|
||||||
|
<span aria-current={index === last ? 'page' : undefined}>{item.label}</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
47
src/components/EntryThumbnail.astro
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
---
|
||||||
|
import type { ImageMetadata } from 'astro';
|
||||||
|
import { Picture } from 'astro:assets';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
src: ImageMetadata;
|
||||||
|
alt: string;
|
||||||
|
href?: string;
|
||||||
|
class?: string;
|
||||||
|
widths: number[];
|
||||||
|
sizes: string;
|
||||||
|
loading?: 'lazy' | 'eager';
|
||||||
|
fetchpriority?: 'high' | 'low' | 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
href,
|
||||||
|
class: extraClass,
|
||||||
|
widths,
|
||||||
|
sizes,
|
||||||
|
loading = 'lazy',
|
||||||
|
fetchpriority,
|
||||||
|
} = Astro.props;
|
||||||
|
|
||||||
|
const Tag = href ? 'a' : 'div';
|
||||||
|
---
|
||||||
|
|
||||||
|
<Tag
|
||||||
|
class:list={['entry-thumbnail', extraClass]}
|
||||||
|
href={href}
|
||||||
|
aria-hidden={href ? 'true' : undefined}
|
||||||
|
tabindex={href ? -1 : undefined}
|
||||||
|
>
|
||||||
|
<Picture
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
formats={['avif', 'webp']}
|
||||||
|
fallbackFormat="jpg"
|
||||||
|
widths={widths}
|
||||||
|
sizes={sizes}
|
||||||
|
loading={loading}
|
||||||
|
decoding="async"
|
||||||
|
fetchpriority={fetchpriority}
|
||||||
|
/>
|
||||||
|
</Tag>
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
---
|
|
||||||
import { Image } from 'astro:assets';
|
|
||||||
|
|
||||||
interface MediaItem {
|
|
||||||
type: 'image' | 'video' | 'diagram';
|
|
||||||
src?: ImageMetadata;
|
|
||||||
poster?: ImageMetadata;
|
|
||||||
mp4?: string;
|
|
||||||
webm?: string;
|
|
||||||
alt?: string;
|
|
||||||
caption?: string;
|
|
||||||
transcript?: string;
|
|
||||||
role?: 'evidence' | 'og' | 'inline';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
items: MediaItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const { items } = Astro.props;
|
|
||||||
---
|
|
||||||
|
|
||||||
{
|
|
||||||
items.map((item) => (
|
|
||||||
<figure class:list={['evidence-media', item.role === 'inline' && 'figure-inline']}>
|
|
||||||
{item.type === 'video' ? (
|
|
||||||
<video
|
|
||||||
controls
|
|
||||||
preload="metadata"
|
|
||||||
poster={item.poster?.src}
|
|
||||||
aria-label={item.alt}
|
|
||||||
>
|
|
||||||
{item.webm && <source src={item.webm} type="video/webm" />}
|
|
||||||
{item.mp4 && <source src={item.mp4} type="video/mp4" />}
|
|
||||||
</video>
|
|
||||||
) : (
|
|
||||||
item.src && (
|
|
||||||
<Image
|
|
||||||
src={item.src}
|
|
||||||
alt={item.alt ?? ''}
|
|
||||||
widths={[480, 720, 960, 1280]}
|
|
||||||
sizes="(max-width: 760px) calc(100vw - 2rem), 56rem"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
{item.caption && <figcaption>{item.caption}</figcaption>}
|
|
||||||
{item.transcript && <p class="media-transcript">{item.transcript}</p>}
|
|
||||||
</figure>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +1,38 @@
|
||||||
---
|
---
|
||||||
import { site } from '../lib/site';
|
import { navItems, site } from '../lib/site';
|
||||||
|
|
||||||
|
const year = new Date().getFullYear();
|
||||||
---
|
---
|
||||||
|
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<p>
|
<nav aria-label="Footer">
|
||||||
<span>{site.name}</span>
|
<ul class="footer-links">
|
||||||
<a href={`mailto:${site.email}`}>Email</a>
|
{
|
||||||
<a href={site.cv}>CV</a>
|
navItems.map((item) => (
|
||||||
<a href={site.github}>GitHub</a>
|
<li>
|
||||||
<a href={site.linkedin}>LinkedIn</a>
|
<a href={item.href}>{item.label}</a>
|
||||||
<a href="/rss.xml">RSS</a>
|
</li>
|
||||||
</p>
|
))
|
||||||
|
}
|
||||||
|
<li>
|
||||||
|
<a href="/tags/">Tags</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/rss.xml">RSS</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<ul class="footer-meta">
|
||||||
|
<li><span>© {year} {site.name}</span></li>
|
||||||
|
<li><a href={`mailto:${site.email}`}>Email</a></li>
|
||||||
|
<li>
|
||||||
|
<a href={site.cv} rel="noopener">CV</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href={site.github} rel="noopener me">GitHub</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href={site.linkedin} rel="noopener me">LinkedIn</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,37 @@
|
||||||
---
|
---
|
||||||
import { site } from '../lib/site';
|
import { navItems, site } from '../lib/site';
|
||||||
|
|
||||||
const current = Astro.url.pathname;
|
const current = Astro.url.pathname;
|
||||||
|
|
||||||
const navItems = [
|
|
||||||
{ href: '/articles/', label: 'Articles' },
|
|
||||||
{ href: '/projects/', label: 'Projects' },
|
|
||||||
{ href: '/about/', label: 'About' },
|
|
||||||
{ href: '/rss.xml', label: 'RSS' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function isCurrent(href: string) {
|
function isCurrent(href: string) {
|
||||||
return href !== '/rss.xml' && current.startsWith(href);
|
if (href === '/') return current === '/';
|
||||||
|
return current.startsWith(href);
|
||||||
}
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
<a class="skip-link" href="#content">Skip to content</a>
|
<a class="skip-link" href="#content">Skip to content</a>
|
||||||
<header class="site-header" aria-label="Site header">
|
<header class="site-header">
|
||||||
<a class="site-title" href="/">{site.name}</a>
|
<a class="site-title" href="/">{site.name}</a>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<nav class="site-nav" aria-label="Primary navigation">
|
<nav class="site-nav" aria-label="Primary">
|
||||||
{
|
{
|
||||||
navItems.map((item) => (
|
navItems
|
||||||
<a href={item.href} aria-current={isCurrent(item.href) ? 'page' : undefined}>
|
.filter((item) => item.href !== '/')
|
||||||
{item.label}
|
.map((item) => (
|
||||||
</a>
|
<a href={item.href} aria-current={isCurrent(item.href) ? 'page' : undefined}>
|
||||||
))
|
{item.label}
|
||||||
|
</a>
|
||||||
|
))
|
||||||
}
|
}
|
||||||
</nav>
|
</nav>
|
||||||
<label class="theme-control" for="theme-switcher">
|
<button
|
||||||
<span class="sr-only">Use dark theme</span>
|
id="theme-switcher"
|
||||||
<input id="theme-switcher" class="theme-switcher" type="checkbox" name="theme" />
|
class="theme-switcher"
|
||||||
</label>
|
type="button"
|
||||||
|
aria-label="Toggle dark theme"
|
||||||
|
aria-pressed="false"
|
||||||
|
>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -46,15 +46,11 @@ function isCurrent(href: string) {
|
||||||
try {
|
try {
|
||||||
const value = localStorage.getItem(key);
|
const value = localStorage.getItem(key);
|
||||||
if (value === 'light' || value === 'dark') return value;
|
if (value === 'light' || value === 'dark') return value;
|
||||||
|
|
||||||
const legacyValue = localStorage.getItem(legacyKey);
|
const legacyValue = localStorage.getItem(legacyKey);
|
||||||
if (legacyValue !== null) {
|
if (legacyValue !== null) return JSON.parse(legacyValue) ? 'dark' : 'light';
|
||||||
return JSON.parse(legacyValue) ? 'dark' : 'light';
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -63,22 +59,33 @@ function isCurrent(href: string) {
|
||||||
const apply = (theme) => {
|
const apply = (theme) => {
|
||||||
document.documentElement.dataset.theme = theme;
|
document.documentElement.dataset.theme = theme;
|
||||||
document.documentElement.style.colorScheme = theme;
|
document.documentElement.style.colorScheme = theme;
|
||||||
switcher.checked = theme === 'dark';
|
if (switcher) switcher.setAttribute('aria-pressed', String(theme === 'dark'));
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!switcher) return;
|
|
||||||
|
|
||||||
apply(getStored() || getSystemTheme());
|
apply(getStored() || getSystemTheme());
|
||||||
|
|
||||||
switcher.addEventListener('change', () => {
|
if (!switcher) return;
|
||||||
const theme = switcher.checked ? 'dark' : 'light';
|
|
||||||
|
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
|
||||||
|
|
||||||
|
const runApply = (theme) => {
|
||||||
|
if (!reduced.matches && typeof document.startViewTransition === 'function') {
|
||||||
|
document.startViewTransition(() => apply(theme));
|
||||||
|
} else {
|
||||||
|
apply(theme);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
switcher.addEventListener('click', () => {
|
||||||
|
const current = switcher.getAttribute('aria-pressed') === 'true' ? 'dark' : 'light';
|
||||||
|
const next = current === 'dark' ? 'light' : 'dark';
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(key, theme);
|
localStorage.setItem(key, next);
|
||||||
localStorage.setItem(legacyKey, JSON.stringify(theme === 'dark'));
|
localStorage.setItem(legacyKey, JSON.stringify(next === 'dark'));
|
||||||
} catch {
|
} catch {
|
||||||
// The switch still applies for the current page when storage is unavailable.
|
// The switch still applies for the current page when storage is unavailable.
|
||||||
}
|
}
|
||||||
apply(theme);
|
runApply(next);
|
||||||
});
|
});
|
||||||
|
|
||||||
media.addEventListener('change', () => {
|
media.addEventListener('change', () => {
|
||||||
|
|
|
||||||
45
src/components/PostMedia.astro
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
---
|
||||||
|
import type { CollectionEntry } from 'astro:content';
|
||||||
|
import { Picture } from 'astro:assets';
|
||||||
|
|
||||||
|
type MediaItem = CollectionEntry<'posts'>['data']['media'][number];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: MediaItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { items } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
{
|
||||||
|
items.map((item) => (
|
||||||
|
<figure class:list={['post-media', item.role === 'inline' && 'figure-inline']}>
|
||||||
|
{item.type === 'video' ? (
|
||||||
|
<video
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
poster={item.poster?.src}
|
||||||
|
aria-label={item.decorative ? undefined : item.alt}
|
||||||
|
>
|
||||||
|
{item.webm && <source src={item.webm} type="video/webm" />}
|
||||||
|
{item.mp4 && <source src={item.mp4} type="video/mp4" />}
|
||||||
|
</video>
|
||||||
|
) : (
|
||||||
|
item.src && (
|
||||||
|
<Picture
|
||||||
|
src={item.src}
|
||||||
|
alt={item.decorative ? '' : (item.alt ?? '')}
|
||||||
|
formats={['avif', 'webp']}
|
||||||
|
fallbackFormat="jpg"
|
||||||
|
widths={[480, 720, 960, 1280, 1600, 1920]}
|
||||||
|
sizes="(max-width: 700px) calc(100vw - 3rem), (max-width: 1100px) calc(100vw - 4rem), 56rem"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{item.caption && <figcaption>{item.caption}</figcaption>}
|
||||||
|
{item.transcript && <p class="media-transcript">{item.transcript}</p>}
|
||||||
|
</figure>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
---
|
---
|
||||||
interface Link {
|
import type { CollectionEntry } from 'astro:content';
|
||||||
label: string;
|
|
||||||
type: string;
|
type Link = CollectionEntry<'projects'>['data']['links'][number];
|
||||||
url: string;
|
|
||||||
download?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
links: Link[];
|
links: Link[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const { links } = Astro.props;
|
const { links } = Astro.props;
|
||||||
|
|
||||||
|
function isExternal(url: string) {
|
||||||
|
return /^https?:\/\//.test(url);
|
||||||
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
@ -18,8 +19,18 @@ const { links } = Astro.props;
|
||||||
<ul class="project-links" aria-label="Project links">
|
<ul class="project-links" aria-label="Project links">
|
||||||
{links.map((link) => (
|
{links.map((link) => (
|
||||||
<li>
|
<li>
|
||||||
<a href={link.url} download={link.download ? '' : undefined}>
|
<a
|
||||||
|
href={link.url}
|
||||||
|
download={link.download ? '' : undefined}
|
||||||
|
rel={isExternal(link.url) ? 'noopener' : undefined}
|
||||||
|
>
|
||||||
{link.label}
|
{link.label}
|
||||||
|
{link.download && (
|
||||||
|
<span class="download-indicator" aria-hidden="true">
|
||||||
|
↓
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{link.download && <span class="sr-only">(download)</span>}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
---
|
---
|
||||||
import type { CollectionEntry } from 'astro:content';
|
import type { CollectionEntry } from 'astro:content';
|
||||||
import { Image } from 'astro:assets';
|
import EntryThumbnail from './EntryThumbnail.astro';
|
||||||
import { articlePath } from '../lib/site';
|
|
||||||
import ProjectLinks from './ProjectLinks.astro';
|
import ProjectLinks from './ProjectLinks.astro';
|
||||||
|
import { articlePath, projectAnchor } from '../lib/site';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
projects: CollectionEntry<'projects'>[];
|
projects: CollectionEntry<'projects'>[];
|
||||||
|
|
@ -15,7 +15,7 @@ type ProjectLink = CollectionEntry<'projects'>['data']['links'][number];
|
||||||
<ol class="project-list">
|
<ol class="project-list">
|
||||||
{
|
{
|
||||||
projects.map((project) => {
|
projects.map((project) => {
|
||||||
const anchor = project.data.legacyAnchor ?? project.data.sourceProjectId;
|
const anchor = projectAnchor(project);
|
||||||
const titleId = `${anchor}-title`;
|
const titleId = `${anchor}-title`;
|
||||||
const essayHref = project.data.essay ? articlePath(project.data.essay) : undefined;
|
const essayHref = project.data.essay ? articlePath(project.data.essay) : undefined;
|
||||||
const essayLink: ProjectLink | undefined = essayHref
|
const essayLink: ProjectLink | undefined = essayHref
|
||||||
|
|
@ -27,37 +27,16 @@ type ProjectLink = CollectionEntry<'projects'>['data']['links'][number];
|
||||||
...project.data.links,
|
...project.data.links,
|
||||||
];
|
];
|
||||||
|
|
||||||
if (links.length === 0) {
|
|
||||||
links.push({ label: 'Permalink', type: 'site', url: `#${anchor}` });
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li class="project-card" id={anchor} aria-labelledby={titleId}>
|
<li class="project-card" id={anchor}>
|
||||||
{primaryHref ? (
|
<EntryThumbnail
|
||||||
<a
|
src={project.data.thumbnail.src}
|
||||||
class="entry-thumbnail project-thumbnail"
|
alt={project.data.thumbnail.alt}
|
||||||
href={primaryHref}
|
href={primaryHref}
|
||||||
aria-labelledby={titleId}
|
class="project-thumbnail"
|
||||||
>
|
widths={[240, 320, 480, 640, 800]}
|
||||||
<Image
|
sizes="(max-width: 700px) 7rem, (max-width: 960px) clamp(7rem, 18vw, 9.5rem), 19rem"
|
||||||
src={project.data.thumbnail.src}
|
/>
|
||||||
alt={project.data.thumbnail.alt}
|
|
||||||
widths={[240, 320, 480, 640, 800]}
|
|
||||||
sizes="(max-width: 700px) calc(100vw - 2rem), 19rem"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<div class="entry-thumbnail project-thumbnail">
|
|
||||||
<Image
|
|
||||||
src={project.data.thumbnail.src}
|
|
||||||
alt={project.data.thumbnail.alt}
|
|
||||||
widths={[240, 320, 480, 640, 800]}
|
|
||||||
sizes="(max-width: 700px) calc(100vw - 2rem), 19rem"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div class="project-card__summary">
|
<div class="project-card__summary">
|
||||||
<h3 id={titleId}>
|
<h3 id={titleId}>
|
||||||
{primaryHref ? (
|
{primaryHref ? (
|
||||||
|
|
@ -65,12 +44,13 @@ type ProjectLink = CollectionEntry<'projects'>['data']['links'][number];
|
||||||
) : (
|
) : (
|
||||||
project.data.title
|
project.data.title
|
||||||
)}
|
)}
|
||||||
|
{essayHref && <span class="project-essay-badge">Article</span>}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="project-description">{project.data.description}</p>
|
<p class="project-description">{project.data.description}</p>
|
||||||
<p class="project-meta">
|
<p class="project-meta">
|
||||||
{project.data.period} · {project.data.technologies.join(', ')}
|
{project.data.period} · {project.data.technologies.join(', ')}
|
||||||
</p>
|
</p>
|
||||||
<ProjectLinks links={links} />
|
{links.length > 0 && <ProjectLinks links={links} />}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,21 @@
|
||||||
---
|
---
|
||||||
import { formatTag, tagPath } from '../lib/site';
|
import { tagPath } from '../lib/site';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
tags: string[];
|
tags: readonly string[];
|
||||||
currentTag?: string;
|
currentTag?: string;
|
||||||
|
labelled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { tags, currentTag } = Astro.props;
|
const { tags, currentTag, labelled = true } = Astro.props;
|
||||||
---
|
---
|
||||||
|
|
||||||
<ul class="tag-list" aria-label="Tags">
|
<ul class="tag-list" aria-label={labelled ? 'Tags' : undefined}>
|
||||||
{
|
{
|
||||||
tags.map((tag) => (
|
tags.map((tag) => (
|
||||||
<li>
|
<li>
|
||||||
<a href={tagPath(tag)} aria-current={tag === currentTag ? 'page' : undefined}>
|
<a href={tagPath(tag)} aria-current={tag === currentTag ? 'page' : undefined}>
|
||||||
{formatTag(tag)}
|
{tag}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
))
|
))
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { defineCollection } from 'astro:content';
|
import { defineCollection } from 'astro:content';
|
||||||
|
import type { SchemaContext } from 'astro:content';
|
||||||
import { glob } from 'astro/loaders';
|
import { glob } from 'astro/loaders';
|
||||||
import { z } from 'astro/zod';
|
import { z } from 'astro/zod';
|
||||||
|
|
||||||
|
|
@ -18,13 +19,13 @@ const linkSchema = z.object({
|
||||||
download: z.boolean().optional(),
|
download: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const thumbnailSchema = ({ image }: { image: any }) =>
|
const thumbnailSchema = ({ image }: SchemaContext) =>
|
||||||
z.object({
|
z.object({
|
||||||
src: image(),
|
src: image(),
|
||||||
alt: z.string(),
|
alt: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const mediaSchema = ({ image }: { image: any }) =>
|
const mediaSchema = ({ image }: SchemaContext) =>
|
||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
type: z.enum(['image', 'video', 'diagram']),
|
type: z.enum(['image', 'video', 'diagram']),
|
||||||
|
|
@ -38,13 +39,8 @@ const mediaSchema = ({ image }: { image: any }) =>
|
||||||
transcript: z.string().optional(),
|
transcript: z.string().optional(),
|
||||||
role: z.enum(['evidence', 'og', 'inline']).default('evidence'),
|
role: z.enum(['evidence', 'og', 'inline']).default('evidence'),
|
||||||
})
|
})
|
||||||
.refine((item) => item.decorative || Boolean(item.alt), {
|
.refine((item) => item.decorative || (Boolean(item.alt) && Boolean(item.caption)), {
|
||||||
message: 'Meaningful media needs alt text.',
|
message: 'Meaningful media needs both alt text and a caption.',
|
||||||
path: ['alt'],
|
|
||||||
})
|
|
||||||
.refine((item) => item.decorative || Boolean(item.caption), {
|
|
||||||
message: 'Meaningful media needs a caption.',
|
|
||||||
path: ['caption'],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const posts = defineCollection({
|
const posts = defineCollection({
|
||||||
|
|
@ -52,7 +48,7 @@ const posts = defineCollection({
|
||||||
schema: ({ image }) =>
|
schema: ({ image }) =>
|
||||||
z.object({
|
z.object({
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
description: z.string(),
|
description: z.string().max(160),
|
||||||
date: z.coerce.date(),
|
date: z.coerce.date(),
|
||||||
updated: z.coerce.date().optional(),
|
updated: z.coerce.date().optional(),
|
||||||
draft: z.boolean().default(false),
|
draft: z.boolean().default(false),
|
||||||
|
|
@ -69,9 +65,7 @@ const posts = defineCollection({
|
||||||
'games',
|
'games',
|
||||||
])
|
])
|
||||||
),
|
),
|
||||||
selected: z.boolean().default(false),
|
|
||||||
featuredOrder: z.number().optional(),
|
featuredOrder: z.number().optional(),
|
||||||
project: z.string().optional(),
|
|
||||||
projectPeriod: z.string().optional(),
|
projectPeriod: z.string().optional(),
|
||||||
role: z.string().optional(),
|
role: z.string().optional(),
|
||||||
stack: z.array(z.string()).optional(),
|
stack: z.array(z.string()).optional(),
|
||||||
|
|
@ -91,7 +85,7 @@ const projects = defineCollection({
|
||||||
z.object({
|
z.object({
|
||||||
sourceProjectId: z.string(),
|
sourceProjectId: z.string(),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
description: z.string(),
|
description: z.string().max(160),
|
||||||
thumbnail: thumbnailSchema({ image }),
|
thumbnail: thumbnailSchema({ image }),
|
||||||
period: z.string(),
|
period: z.string(),
|
||||||
sortDate: z.coerce.date(),
|
sortDate: z.coerce.date(),
|
||||||
|
|
@ -101,7 +95,6 @@ const projects = defineCollection({
|
||||||
essay: z.string().optional(),
|
essay: z.string().optional(),
|
||||||
legacyAnchor: z.string().optional(),
|
legacyAnchor: z.string().optional(),
|
||||||
links: z.array(linkSchema).default([]),
|
links: z.array(linkSchema).default([]),
|
||||||
downloads: z.array(z.object({ label: z.string(), url: z.string() })).default([]),
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 46 KiB |
BIN
src/content/posts/_assets/avoid.jpg
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
src/content/posts/_assets/city-simulation.jpg
Normal file
|
After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 928 KiB After Width: | Height: | Size: 205 KiB |
BIN
src/content/posts/_assets/forex.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
src/content/posts/_assets/leds.jpg
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
src/content/posts/_assets/my-notes.png
Normal file
|
After Width: | Height: | Size: 296 KiB |
BIN
src/content/posts/_assets/photo-colour-grader.jpg
Normal file
|
After Width: | Height: | Size: 145 KiB |
BIN
src/content/posts/_assets/photos.jpg
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
src/content/posts/_assets/platform-game.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 615 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 408 KiB After Width: | Height: | Size: 52 KiB |
22
src/content/posts/avoid-early-web-game.md
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
---
|
||||||
|
title: Avoid, an Early Web Game
|
||||||
|
description: A tiny archived web game from my first experiments with browser-based interaction.
|
||||||
|
date: 2026-04-29
|
||||||
|
projectPeriod: 'January 2018'
|
||||||
|
thumbnail:
|
||||||
|
src: ./_assets/avoid.jpg
|
||||||
|
alt: Screenshot of the Avoid web game.
|
||||||
|
tags: ['games', 'web']
|
||||||
|
selected: false
|
||||||
|
project: avoid
|
||||||
|
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
|
||||||
|
type: demo
|
||||||
|
url: https://schmelczer.dev/avoid
|
||||||
|
---
|
||||||
|
|
||||||
|
I recently found my first-ever web game. It is incredibly simple, but I killed some time with it, so feel free to try it out and do not judge too harshly.
|
||||||
27
src/content/posts/city-simulation-unity-traffic.md
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
---
|
||||||
|
title: A Unity City Simulation for a Cybersecurity Challenge
|
||||||
|
description: A client-server Unity simulation where REST-controlled traffic lights made mistakes immediately visible through crashes.
|
||||||
|
date: 2026-05-01
|
||||||
|
projectPeriod: 'July-August 2018'
|
||||||
|
thumbnail:
|
||||||
|
src: ./_assets/city-simulation.jpg
|
||||||
|
alt: Screenshot of a Unity traffic simulation.
|
||||||
|
tags: ['simulation', 'systems']
|
||||||
|
selected: false
|
||||||
|
project: city-simulation
|
||||||
|
role: Simulation author
|
||||||
|
stack: ['Unity', 'C#', 'REST API', 'Blender']
|
||||||
|
outcome: A visual context for a PLC-focused cybersecurity challenge
|
||||||
|
audience: technical
|
||||||
|
links: []
|
||||||
|
---
|
||||||
|
|
||||||
|
I simulated a city where car crashes were more frequent than usual.
|
||||||
|
|
||||||
|
The state of the traffic lights could be changed through a REST API. Drivers followed the instructions of those lights, so if a mistake was made, collisions appeared in the simulation. There was also support for displaying tweets on a HUD.
|
||||||
|
|
||||||
|
The project was created as the context for a cybersecurity challenge about PLCs. With the help of this program, contestants could instantly see the effect of their work.
|
||||||
|
|
||||||
|
An exciting aspect of the project was building it with a server-client architecture. Every decision of the agents was calculated server-side. The real challenge was broadcasting these decisions in a fault-tolerant way using minimal bandwidth.
|
||||||
|
|
||||||
|
It was made with Unity using C# as the scripting language. I also made the models and animations in Blender.
|
||||||
25
src/content/posts/foreign-exchange-prediction-experiment.md
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
---
|
||||||
|
title: A Frequency-Domain Foreign Exchange Prediction Experiment
|
||||||
|
description: An older EUR/USD prediction experiment built from smoothing, short-time Fourier transforms, extrapolation, and a Python prediction server.
|
||||||
|
date: 2026-05-03
|
||||||
|
projectPeriod: 'Autumn 2019'
|
||||||
|
thumbnail:
|
||||||
|
src: ./_assets/forex.jpg
|
||||||
|
alt: Chart comparing predicted and actual EUR/USD exchange rates.
|
||||||
|
tags: ['systems', 'tools']
|
||||||
|
selected: false
|
||||||
|
project: forex
|
||||||
|
role: Experiment author
|
||||||
|
stack: ['Python', 'NumPy', 'SciPy', 'Flask', 'MQL4']
|
||||||
|
outcome: A working prediction server connected to an MQL4 client for trading experiments
|
||||||
|
audience: technical
|
||||||
|
links: []
|
||||||
|
---
|
||||||
|
|
||||||
|
This was an experiment in predicting EUR/USD rates. The animation from the old portfolio showed the implementation doing a somewhat good job predicting the rates: the prediction was the blue graph and the actual values were the green chart. Of course, I would not trust it with my money.
|
||||||
|
|
||||||
|
The algorithm was a fancy linear regression in the frequency domain. The steps were: smoothing the input values, differentiating, applying a short-time Fourier transformation with overlapped and Hanning-windowed windows, extrapolating, and then applying the inverse of these transformations to the resulting values.
|
||||||
|
|
||||||
|
The prediction server was written in Python using NumPy, SciPy, and Flask. It communicated with an MQL4 client that was responsible for handling financial transactions based on the generated data.
|
||||||
|
|
||||||
|
There was still plenty of room for improvement, but even with this simple algorithm, a sometimes profitable trading strategy was viable. More importantly, the project gave me a useful look into trading algorithms, their complexity, and the fierce competition around them.
|
||||||
23
src/content/posts/graph-editor-javafx-simulation-input.md
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
---
|
||||||
|
title: A JavaFX Graph Editor for Simulation Input
|
||||||
|
description: A small JavaFX editor for creating and uploading graph input for the cooling system simulator.
|
||||||
|
date: 2026-04-25
|
||||||
|
projectPeriod: 'October-November 2018'
|
||||||
|
thumbnail:
|
||||||
|
src: ./_assets/process-simulator-input.jpg
|
||||||
|
alt: JavaFX graph editor for the cooling system simulator.
|
||||||
|
tags: ['simulation', 'tools']
|
||||||
|
selected: false
|
||||||
|
project: nuclear-editor
|
||||||
|
role: Editor author
|
||||||
|
stack: ['JavaFX', 'JSON', 'REST API']
|
||||||
|
outcome: An editor for building input graphs and sending them to the simulation backend
|
||||||
|
audience: technical
|
||||||
|
links: []
|
||||||
|
---
|
||||||
|
|
||||||
|
This was an intuitive editor to create and edit input for the nuclear facility simulator.
|
||||||
|
|
||||||
|
Nodes could be moved with drag and drop gestures. Editing the parameters of elements was done on the right panel.
|
||||||
|
|
||||||
|
The UI was built with JavaFX. The output could be exported as JSON or directly uploaded to the simulation backend.
|
||||||
23
src/content/posts/lights-synchronized-to-music.md
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
---
|
||||||
|
title: Lights Synchronized to Music
|
||||||
|
description: A Raspberry Pi music player that analyzed audio output and drove RGB LED strips.
|
||||||
|
date: 2026-04-26
|
||||||
|
projectPeriod: 'Spring 2016'
|
||||||
|
thumbnail:
|
||||||
|
src: ./_assets/leds.jpg
|
||||||
|
alt: RGB LED strips lit by a music synchronization project.
|
||||||
|
tags: ['systems', 'tools']
|
||||||
|
selected: false
|
||||||
|
project: leds
|
||||||
|
role: Hardware and software author
|
||||||
|
stack: ['Python', 'NumPy', 'FFT', 'Raspberry Pi', 'Vanilla web']
|
||||||
|
outcome: My first finished non-trivial project, combining a web UI, audio processing, and hardware output
|
||||||
|
audience: technical
|
||||||
|
links: []
|
||||||
|
---
|
||||||
|
|
||||||
|
This was a full-stack application with a built-in music player, the output of which controlled the colour of a couple of RGB LED strips through a Raspberry Pi and some MOSFETs.
|
||||||
|
|
||||||
|
It was my first non-trivial project that got finished. Obviously, it was rather far from perfect, but I am still proud that I was able to build it on my own.
|
||||||
|
|
||||||
|
The backend logic was written in Python, and the FFT implementation was provided by NumPy. I also built a simple frontend for accessing the music player and changing the settings using vanilla web development technologies.
|
||||||
26
src/content/posts/my-notes-android-markdown-app.md
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
title: My Notes, an Android Markdown App
|
||||||
|
description: A small Android notes app for creating, editing, and filtering markdown notes with hashtags.
|
||||||
|
date: 2026-05-02
|
||||||
|
projectPeriod: 'November 2019'
|
||||||
|
thumbnail:
|
||||||
|
src: ./_assets/my-notes.png
|
||||||
|
alt: Screenshots of the My Notes Android app.
|
||||||
|
tags: ['tools']
|
||||||
|
selected: false
|
||||||
|
project: my-notes
|
||||||
|
role: Android app author
|
||||||
|
stack: ['Android', 'Markdown', 'Markwon']
|
||||||
|
outcome: A functional markdown note organizer and a first exposure to Android development
|
||||||
|
audience: technical
|
||||||
|
links:
|
||||||
|
- label: Source
|
||||||
|
type: source
|
||||||
|
url: https://github.com/schmelczer/my-notes
|
||||||
|
---
|
||||||
|
|
||||||
|
My Notes was a minimalist Android note organizer and editor powered by Markwon.
|
||||||
|
|
||||||
|
It was a basic app for creating and filtering markdown notes based on hashtags. It was also my first exposure to Android development.
|
||||||
|
|
||||||
|
All in all, it was not a unique idea, but it was functional. It also exposed me to a wildly different paradigm than I was used to from full-stack web development, which made the project worthwhile.
|
||||||
25
src/content/posts/photo-colour-grader.md
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
---
|
||||||
|
title: A Proof-of-Concept Photo Colour Grader
|
||||||
|
description: A web UI experiment for selecting colours and transforming nearby ranges based on colour distance.
|
||||||
|
date: 2026-04-30
|
||||||
|
projectPeriod: 'June 2018'
|
||||||
|
thumbnail:
|
||||||
|
src: ./_assets/photo-colour-grader.jpg
|
||||||
|
alt: Screenshot of a photo colour grading interface.
|
||||||
|
tags: ['graphics', 'web', 'tools']
|
||||||
|
selected: false
|
||||||
|
project: colors
|
||||||
|
role: Interface and image processing author
|
||||||
|
stack: ['JavaScript', 'Canvas', 'Image processing']
|
||||||
|
outcome: A proof-of-concept colour grading interaction model
|
||||||
|
audience: technical
|
||||||
|
links: []
|
||||||
|
---
|
||||||
|
|
||||||
|
This was an innovative, or at least I thought so at the time, colour grader web application.
|
||||||
|
|
||||||
|
The most noteworthy feature of the application was the colour selector UI. The program was only intended as a proof-of-concept. I wanted to experiment with a few interaction ideas, and this was the outcome.
|
||||||
|
|
||||||
|
The core idea was that you could select some colours and then apply transformations to other colours as a function of their distance to the selected colour.
|
||||||
|
|
||||||
|
By clicking on a coloured circle, you could change its settings. New circles could be created by clicking inside the large circle, and they could also be moved with drag and drop.
|
||||||
26
src/content/posts/photo-site-generator.md
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
title: A Static Photo Site Generator
|
||||||
|
description: A simple photography site generated from a directory of images with automatic resizing to multiple quality settings.
|
||||||
|
date: 2026-04-27
|
||||||
|
projectPeriod: 'Summer 2016'
|
||||||
|
thumbnail:
|
||||||
|
src: ./_assets/photos.jpg
|
||||||
|
alt: Screenshot of a generated photography site.
|
||||||
|
tags: ['web', 'tools']
|
||||||
|
selected: false
|
||||||
|
project: photos
|
||||||
|
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
|
||||||
|
type: site
|
||||||
|
url: https://photo.schmelczer.dev
|
||||||
|
---
|
||||||
|
|
||||||
|
Photos was a simple webpage where you could view my photos.
|
||||||
|
|
||||||
|
Taking time to appreciate the world around us fills me with joy. That is why I like to go on walks with a camera. I might not end up with great photos. Nonetheless, I usually end up with some inspiration regarding my current or next project.
|
||||||
|
|
||||||
|
As for the webpage, a Webpack script generated the site from the photos in a directory. Automatic resizing to multiple quality settings was also part of the pipeline.
|
||||||
25
src/content/posts/platform-game-c-sdl.md
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
---
|
||||||
|
title: 'A 3D Platform Game in C and SDL 1.2'
|
||||||
|
description: 'My first proper project: a 3D game with random maps, destructible voxels, enemies, powerups, and time slowdown.'
|
||||||
|
date: 2026-04-28
|
||||||
|
projectPeriod: 'Autumn 2017'
|
||||||
|
thumbnail:
|
||||||
|
src: ./_assets/platform-game.png
|
||||||
|
alt: Screenshot from a 3D platform game written in C.
|
||||||
|
tags: ['games', 'systems']
|
||||||
|
selected: false
|
||||||
|
project: platform-game
|
||||||
|
role: Game author
|
||||||
|
stack: ['C', 'SDL 1.2', 'Voxel terrain']
|
||||||
|
outcome: A playable 3D course project that made programming feel like the right long-term direction
|
||||||
|
audience: technical
|
||||||
|
links: []
|
||||||
|
---
|
||||||
|
|
||||||
|
This was my first proper project. I created an actually fun 3D game written in pure C with the help of SDL 1.2.
|
||||||
|
|
||||||
|
The maps were randomly generated and fully destroyable voxel by voxel. That also allowed the player to create structures for hiding from flying enemies, which chased the player and could destroy the terrain after merging together and growing larger.
|
||||||
|
|
||||||
|
After collecting enough powerups, the player could shoot and even slow down time in exchange for losing some points.
|
||||||
|
|
||||||
|
I did this as my final project for my Basics of Programming course. Through making it, I learned a lot about pointers after an adequate number of segmentation faults. It also made me realize my passion for programming.
|
||||||
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 454 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 928 KiB After Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 2 MiB After Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 570 KiB After Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 366 KiB After Width: | Height: | Size: 89 KiB |
BIN
src/content/projects/_assets/process-simulator-input.jpg
Normal file
|
After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 615 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 408 KiB After Width: | Height: | Size: 52 KiB |
|
|
@ -10,6 +10,7 @@ sortDate: 2018-01-01
|
||||||
status: Early web game
|
status: Early web game
|
||||||
technologies: ['JavaScript', 'Canvas']
|
technologies: ['JavaScript', 'Canvas']
|
||||||
selected: false
|
selected: false
|
||||||
|
essay: avoid-early-web-game
|
||||||
legacyAnchor: avoid
|
legacyAnchor: avoid
|
||||||
links:
|
links:
|
||||||
- label: Demo
|
- label: Demo
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ sortDate: 2018-08-01
|
||||||
status: Simulation
|
status: Simulation
|
||||||
technologies: ['Unity', 'C#', 'REST API', 'Blender']
|
technologies: ['Unity', 'C#', 'REST API', 'Blender']
|
||||||
selected: false
|
selected: false
|
||||||
|
essay: city-simulation-unity-traffic
|
||||||
legacyAnchor: city-simulation-unity
|
legacyAnchor: city-simulation-unity
|
||||||
links: []
|
links: []
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ sortDate: 2018-06-01
|
||||||
status: UI experiment
|
status: UI experiment
|
||||||
technologies: ['JavaScript', 'Canvas', 'Image processing']
|
technologies: ['JavaScript', 'Canvas', 'Image processing']
|
||||||
selected: false
|
selected: false
|
||||||
|
essay: photo-colour-grader
|
||||||
legacyAnchor: photo-colour-grader
|
legacyAnchor: photo-colour-grader
|
||||||
links: []
|
links: []
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ sortDate: 2019-10-01
|
||||||
status: Experiment
|
status: Experiment
|
||||||
technologies: ['Python', 'NumPy', 'SciPy', 'Flask', 'MQL4']
|
technologies: ['Python', 'NumPy', 'SciPy', 'Flask', 'MQL4']
|
||||||
selected: false
|
selected: false
|
||||||
|
essay: foreign-exchange-prediction-experiment
|
||||||
legacyAnchor: predicting-foreign-exchange-rates
|
legacyAnchor: predicting-foreign-exchange-rates
|
||||||
links: []
|
links: []
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ sortDate: 2016-04-01
|
||||||
status: Early hardware/software project
|
status: Early hardware/software project
|
||||||
technologies: ['Python', 'NumPy', 'FFT', 'Raspberry Pi', 'Vanilla web']
|
technologies: ['Python', 'NumPy', 'FFT', 'Raspberry Pi', 'Vanilla web']
|
||||||
selected: false
|
selected: false
|
||||||
|
essay: lights-synchronized-to-music
|
||||||
legacyAnchor: lights-synchronised-to-music
|
legacyAnchor: lights-synchronised-to-music
|
||||||
links: []
|
links: []
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ sortDate: 2019-11-01
|
||||||
status: Android app
|
status: Android app
|
||||||
technologies: ['Android', 'Kotlin/Java', 'Markdown', 'Markwon']
|
technologies: ['Android', 'Kotlin/Java', 'Markdown', 'Markwon']
|
||||||
selected: false
|
selected: false
|
||||||
|
essay: my-notes-android-markdown-app
|
||||||
legacyAnchor: my-notes-android-app
|
legacyAnchor: my-notes-android-app
|
||||||
links:
|
links:
|
||||||
- label: Source
|
- label: Source
|
||||||
|
|
|
||||||
16
src/content/projects/nuclear-editor.md
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
---
|
||||||
|
sourceProjectId: nuclear-editor
|
||||||
|
title: Graph Editor
|
||||||
|
description: A JavaFX editor for creating and editing input graphs for the cooling system simulator.
|
||||||
|
thumbnail:
|
||||||
|
src: ./_assets/process-simulator-input.jpg
|
||||||
|
alt: JavaFX editor interface for the cooling system simulator input graph.
|
||||||
|
period: 'October-November 2018'
|
||||||
|
sortDate: 2018-10-15
|
||||||
|
status: Input editor
|
||||||
|
technologies: ['JavaFX', 'JSON', 'REST API']
|
||||||
|
selected: false
|
||||||
|
essay: graph-editor-javafx-simulation-input
|
||||||
|
legacyAnchor: graph-editor-javafx
|
||||||
|
links: []
|
||||||
|
---
|
||||||
|
|
@ -10,6 +10,7 @@ sortDate: 2016-07-01
|
||||||
status: Static site generator
|
status: Static site generator
|
||||||
technologies: ['Webpack', 'Image processing', 'Static site generation']
|
technologies: ['Webpack', 'Image processing', 'Static site generation']
|
||||||
selected: false
|
selected: false
|
||||||
|
essay: photo-site-generator
|
||||||
legacyAnchor: photos
|
legacyAnchor: photos
|
||||||
links:
|
links:
|
||||||
- label: Site
|
- label: Site
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ sortDate: 2017-10-01
|
||||||
status: Early game project
|
status: Early game project
|
||||||
technologies: ['C', 'SDL 1.2', 'Voxel terrain']
|
technologies: ['C', 'SDL 1.2', 'Voxel terrain']
|
||||||
selected: false
|
selected: false
|
||||||
|
essay: platform-game-c-sdl
|
||||||
legacyAnchor: platform-game
|
legacyAnchor: platform-game
|
||||||
links: []
|
links: []
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,91 @@
|
||||||
---
|
---
|
||||||
|
import { getImage } from 'astro:assets';
|
||||||
import Footer from '../components/Footer.astro';
|
import Footer from '../components/Footer.astro';
|
||||||
import Header from '../components/Header.astro';
|
import Header from '../components/Header.astro';
|
||||||
import { absoluteUrl, site } from '../lib/site';
|
import { absoluteUrl, site } from '../lib/site';
|
||||||
|
import defaultOg from '../assets/og-default.jpg';
|
||||||
import '../styles/global.css';
|
import '../styles/global.css';
|
||||||
|
|
||||||
|
interface ArticleMeta {
|
||||||
|
publishedTime: string;
|
||||||
|
modifiedTime?: string;
|
||||||
|
tags?: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
canonicalPath?: string;
|
canonicalPath?: string;
|
||||||
|
ogImage?: string;
|
||||||
|
ogImageAlt?: string;
|
||||||
|
ogImageWidth?: number;
|
||||||
|
ogImageHeight?: number;
|
||||||
|
ogType?: 'website' | 'article' | 'profile';
|
||||||
|
article?: ArticleMeta;
|
||||||
|
jsonLd?: Record<string, unknown> | Array<Record<string, unknown>>;
|
||||||
|
noindex?: boolean;
|
||||||
|
preloadMono?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
title = site.title,
|
title = site.title,
|
||||||
description = site.description,
|
description = site.description,
|
||||||
canonicalPath = Astro.url.pathname,
|
canonicalPath = Astro.url.pathname,
|
||||||
|
ogImage,
|
||||||
|
ogImageAlt = site.description,
|
||||||
|
ogImageWidth,
|
||||||
|
ogImageHeight,
|
||||||
|
ogType = 'website',
|
||||||
|
article,
|
||||||
|
jsonLd,
|
||||||
|
noindex = false,
|
||||||
|
preloadMono = false,
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
|
|
||||||
const pageTitle = title === site.title ? site.title : `${title} | ${site.name}`;
|
const isRoot = title === site.title;
|
||||||
|
const pageTitle = isRoot ? site.title : `${title} · ${site.name}`;
|
||||||
|
const ogTitle = isRoot ? site.title : title;
|
||||||
const canonical = absoluteUrl(canonicalPath);
|
const canonical = absoluteUrl(canonicalPath);
|
||||||
|
|
||||||
|
let resolvedOgImage = ogImage;
|
||||||
|
let resolvedOgWidth = ogImageWidth;
|
||||||
|
let resolvedOgHeight = ogImageHeight;
|
||||||
|
|
||||||
|
if (!resolvedOgImage) {
|
||||||
|
const generated = await getImage({
|
||||||
|
src: defaultOg,
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
format: 'jpg',
|
||||||
|
});
|
||||||
|
resolvedOgImage = generated.src;
|
||||||
|
resolvedOgWidth = 1200;
|
||||||
|
resolvedOgHeight = 630;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ogImageUrl = resolvedOgImage.startsWith('http')
|
||||||
|
? resolvedOgImage
|
||||||
|
: absoluteUrl(resolvedOgImage);
|
||||||
|
const jsonLdEntries = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
|
||||||
---
|
---
|
||||||
|
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width,initial-scale=1,viewport-fit=cover"
|
||||||
|
/>
|
||||||
|
<title>{pageTitle}</title>
|
||||||
|
<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" />}
|
||||||
|
<link rel="canonical" href={canonical} />
|
||||||
|
|
||||||
<script is:inline data-theme-script>
|
<script is:inline data-theme-script>
|
||||||
(() => {
|
(() => {
|
||||||
const key = 'theme';
|
const key = 'theme';
|
||||||
|
|
@ -48,13 +110,26 @@ const canonical = absoluteUrl(canonicalPath);
|
||||||
document.documentElement.style.colorScheme = theme;
|
document.documentElement.style.colorScheme = theme;
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<meta
|
|
||||||
name="viewport"
|
<link
|
||||||
content="width=device-width,initial-scale=1,viewport-fit=cover"
|
rel="preload"
|
||||||
|
href="/fonts/source-sans-3-latin-variable.woff2"
|
||||||
|
as="font"
|
||||||
|
type="font/woff2"
|
||||||
|
crossorigin
|
||||||
/>
|
/>
|
||||||
<meta name="description" content={description} />
|
{
|
||||||
<meta name="theme-color" content="#fbfaf7" />
|
preloadMono && (
|
||||||
<link rel="canonical" href={canonical} />
|
<link
|
||||||
|
rel="preload"
|
||||||
|
href="/fonts/ibm-plex-mono-latin-400.woff2"
|
||||||
|
as="font"
|
||||||
|
type="font/woff2"
|
||||||
|
crossorigin
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
<link
|
<link
|
||||||
rel="alternate"
|
rel="alternate"
|
||||||
type="application/rss+xml"
|
type="application/rss+xml"
|
||||||
|
|
@ -66,11 +141,53 @@ const canonical = absoluteUrl(canonicalPath);
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
<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="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
<meta property="og:title" content={pageTitle} />
|
|
||||||
|
<meta property="og:site_name" content={site.name} />
|
||||||
|
<meta property="og:title" content={ogTitle} />
|
||||||
<meta property="og:description" content={description} />
|
<meta property="og:description" content={description} />
|
||||||
<meta property="og:url" content={canonical} />
|
<meta property="og:url" content={canonical} />
|
||||||
<meta property="og:image" content={absoluteUrl('/og-image.jpg')} />
|
<meta property="og:image" content={ogImageUrl} />
|
||||||
<title>{pageTitle}</title>
|
<meta property="og:image:type" content="image/jpeg" />
|
||||||
|
<meta property="og:image:alt" content={ogImageAlt} />
|
||||||
|
{
|
||||||
|
resolvedOgWidth && (
|
||||||
|
<meta property="og:image:width" content={String(resolvedOgWidth)} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
resolvedOgHeight && (
|
||||||
|
<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} />
|
||||||
|
|
||||||
|
{
|
||||||
|
jsonLdEntries.map((entry) => (
|
||||||
|
<script is:inline type="application/ld+json" set:html={JSON.stringify(entry)} />
|
||||||
|
))
|
||||||
|
}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<Header />
|
<Header />
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,17 @@ import Base from './Base.astro';
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
canonicalPath?: string;
|
||||||
|
ogImage?: string;
|
||||||
|
ogType?: 'website' | 'article' | 'profile';
|
||||||
|
noindex?: boolean;
|
||||||
|
jsonLd?: Record<string, unknown> | Array<Record<string, unknown>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { title, description } = Astro.props;
|
const { title, description } = Astro.props;
|
||||||
---
|
---
|
||||||
|
|
||||||
<Base title={title} description={description}>
|
<Base {...Astro.props}>
|
||||||
<section class="page-shell">
|
<section class="page-shell">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<h1>{title}</h1>
|
<h1>{title}</h1>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,21 @@
|
||||||
---
|
---
|
||||||
import type { CollectionEntry } from 'astro:content';
|
import type { CollectionEntry } from 'astro:content';
|
||||||
import { render } from 'astro:content';
|
import { render } from 'astro:content';
|
||||||
import { Image } from 'astro:assets';
|
import { Picture, getImage } from 'astro:assets';
|
||||||
|
import ArticleList from '../components/ArticleList.astro';
|
||||||
import AtAGlance from '../components/AtAGlance.astro';
|
import AtAGlance from '../components/AtAGlance.astro';
|
||||||
import EvidenceMedia from '../components/EvidenceMedia.astro';
|
import Breadcrumbs from '../components/Breadcrumbs.astro';
|
||||||
|
import PostMedia from '../components/PostMedia.astro';
|
||||||
import TagList from '../components/TagList.astro';
|
import TagList from '../components/TagList.astro';
|
||||||
import { articlePath, formatDate } from '../lib/site';
|
import {
|
||||||
|
absoluteUrl,
|
||||||
|
adjacentPosts,
|
||||||
|
articlePath,
|
||||||
|
formatDate,
|
||||||
|
getPublishedPosts,
|
||||||
|
getRelatedPosts,
|
||||||
|
site,
|
||||||
|
} from '../lib/site';
|
||||||
import Base from './Base.astro';
|
import Base from './Base.astro';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -14,33 +24,126 @@ interface Props {
|
||||||
|
|
||||||
const { post } = Astro.props;
|
const { post } = Astro.props;
|
||||||
const { Content } = await render(post);
|
const { Content } = await render(post);
|
||||||
const dates = [formatDate(post.data.date)];
|
|
||||||
if (post.data.updated) dates.push(`Updated ${formatDate(post.data.updated)}`);
|
const allPosts = await getPublishedPosts();
|
||||||
|
const { previous, next } = adjacentPosts(allPosts, post);
|
||||||
|
const related = getRelatedPosts(allPosts, post, 3);
|
||||||
|
|
||||||
|
const ogImageOptimized = await getImage({
|
||||||
|
src: post.data.thumbnail.src,
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
format: 'jpg',
|
||||||
|
});
|
||||||
|
|
||||||
|
const breadcrumbTrail = [
|
||||||
|
{ href: '/', label: 'Home' },
|
||||||
|
{ href: '/articles/', label: 'Articles' },
|
||||||
|
{ label: post.data.title },
|
||||||
|
];
|
||||||
|
|
||||||
|
const blogPosting = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'BlogPosting',
|
||||||
|
headline: post.data.title,
|
||||||
|
description: post.data.description,
|
||||||
|
datePublished: post.data.date.toISOString(),
|
||||||
|
...(post.data.updated && { dateModified: post.data.updated.toISOString() }),
|
||||||
|
author: {
|
||||||
|
'@type': 'Person',
|
||||||
|
name: site.name,
|
||||||
|
url: absoluteUrl('/about/'),
|
||||||
|
},
|
||||||
|
publisher: {
|
||||||
|
'@type': 'Person',
|
||||||
|
name: site.name,
|
||||||
|
url: site.url,
|
||||||
|
},
|
||||||
|
image: absoluteUrl(ogImageOptimized.src),
|
||||||
|
url: absoluteUrl(articlePath(post)),
|
||||||
|
keywords: post.data.tags.join(', '),
|
||||||
|
mainEntityOfPage: {
|
||||||
|
'@type': 'WebPage',
|
||||||
|
'@id': absoluteUrl(articlePath(post)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const breadcrumbJsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'BreadcrumbList',
|
||||||
|
itemListElement: [
|
||||||
|
{ '@type': 'ListItem', position: 1, name: 'Home', item: absoluteUrl('/') },
|
||||||
|
{
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: 2,
|
||||||
|
name: 'Articles',
|
||||||
|
item: absoluteUrl('/articles/'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: 3,
|
||||||
|
name: post.data.title,
|
||||||
|
item: absoluteUrl(articlePath(post)),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
---
|
---
|
||||||
|
|
||||||
<Base
|
<Base
|
||||||
title={post.data.title}
|
title={post.data.title}
|
||||||
description={post.data.description}
|
description={post.data.description}
|
||||||
canonicalPath={articlePath(post)}
|
canonicalPath={articlePath(post)}
|
||||||
|
ogImage={ogImageOptimized.src}
|
||||||
|
ogImageAlt={post.data.thumbnail.alt}
|
||||||
|
ogImageWidth={1200}
|
||||||
|
ogImageHeight={630}
|
||||||
|
ogType="article"
|
||||||
|
preloadMono={true}
|
||||||
|
article={{
|
||||||
|
publishedTime: post.data.date.toISOString(),
|
||||||
|
modifiedTime: post.data.updated?.toISOString(),
|
||||||
|
tags: post.data.tags,
|
||||||
|
}}
|
||||||
|
jsonLd={[blogPosting, breadcrumbJsonLd]}
|
||||||
>
|
>
|
||||||
<article class="post">
|
<article class="post">
|
||||||
<header class="post-header">
|
<header class="post-header">
|
||||||
<p class="eyebrow">{post.data.projectPeriod ?? 'Articles'}</p>
|
<Breadcrumbs items={breadcrumbTrail} />
|
||||||
|
<p class="eyebrow">{post.data.projectPeriod ?? 'Article'}</p>
|
||||||
<h1>{post.data.title}</h1>
|
<h1>{post.data.title}</h1>
|
||||||
<p class="dek">{post.data.description}</p>
|
<p class="dek">{post.data.description}</p>
|
||||||
<p class="post-meta">
|
<p class="post-meta">
|
||||||
<time datetime={post.data.date.toISOString()}>{dates.join(' · ')}</time>
|
<time datetime={post.data.date.toISOString()}>
|
||||||
|
{formatDate(post.data.date)}
|
||||||
|
</time>
|
||||||
|
{
|
||||||
|
post.data.updated && (
|
||||||
|
<>
|
||||||
|
{' · '}
|
||||||
|
<span>
|
||||||
|
Updated{' '}
|
||||||
|
<time datetime={post.data.updated.toISOString()}>
|
||||||
|
{formatDate(post.data.updated)}
|
||||||
|
</time>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
<TagList tags={post.data.tags} />
|
<TagList tags={post.data.tags} />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<figure class="post-thumbnail">
|
<figure class="post-thumbnail">
|
||||||
<Image
|
<Picture
|
||||||
src={post.data.thumbnail.src}
|
src={post.data.thumbnail.src}
|
||||||
alt={post.data.thumbnail.alt}
|
alt={post.data.thumbnail.alt}
|
||||||
widths={[640, 960, 1280]}
|
formats={['avif', 'webp']}
|
||||||
sizes="(max-width: 760px) calc(100vw - 2rem), 62rem"
|
fallbackFormat="jpg"
|
||||||
|
widths={[640, 960, 1280, 1600]}
|
||||||
|
sizes="(max-width: 700px) calc(100vw - 3rem), (max-width: 1100px) calc(100vw - 4rem), 56rem"
|
||||||
loading="eager"
|
loading="eager"
|
||||||
|
fetchpriority="high"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
|
|
@ -57,6 +160,38 @@ if (post.data.updated) dates.push(`Updated ${formatDate(post.data.updated)}`);
|
||||||
links={post.data.links}
|
links={post.data.links}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<EvidenceMedia items={post.data.media} />
|
<PostMedia items={post.data.media} />
|
||||||
|
|
||||||
|
{
|
||||||
|
related.length > 0 && (
|
||||||
|
<section class="related-posts" aria-labelledby="related-heading">
|
||||||
|
<h2 id="related-heading">Related articles</h2>
|
||||||
|
<ArticleList posts={related} />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
(previous || next) && (
|
||||||
|
<nav class="post-nav" aria-label="Adjacent articles">
|
||||||
|
{previous && (
|
||||||
|
<a class="previous" href={articlePath(previous)} rel="prev">
|
||||||
|
<span class="post-nav__label">
|
||||||
|
<span aria-hidden="true">←</span> Previous
|
||||||
|
</span>
|
||||||
|
<span class="post-nav__title">{previous.data.title}</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{next && (
|
||||||
|
<a class="next" href={articlePath(next)} rel="next">
|
||||||
|
<span class="post-nav__label">
|
||||||
|
Next <span aria-hidden="true">→</span>
|
||||||
|
</span>
|
||||||
|
<span class="post-nav__title">{next.data.title}</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
</article>
|
</article>
|
||||||
</Base>
|
</Base>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
|
import type { CollectionEntry } from 'astro:content';
|
||||||
import { getCollection } from 'astro:content';
|
import { getCollection } from 'astro:content';
|
||||||
|
|
||||||
export const site = {
|
export const site = {
|
||||||
name: 'Andras Schmelczer',
|
name: 'Andras Schmelczer',
|
||||||
title: 'Andras Schmelczer',
|
title: 'Andras Schmelczer — Software systems, AI, graphics, simulations, tools',
|
||||||
description:
|
description:
|
||||||
'Andras Schmelczer writes about software systems, AI deployment, graphics, simulations, and tools.',
|
'Andras Schmelczer writes about software systems, AI deployment, graphics, simulations, and tools.',
|
||||||
url: 'https://schmelczer.dev',
|
url: 'https://schmelczer.dev',
|
||||||
|
|
@ -12,6 +13,13 @@ export const site = {
|
||||||
cv: '/media/downloads/cv-andras-schmelczer.pdf',
|
cv: '/media/downloads/cv-andras-schmelczer.pdf',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const navItems = [
|
||||||
|
{ href: '/', label: 'Home' },
|
||||||
|
{ href: '/articles/', label: 'Articles' },
|
||||||
|
{ href: '/projects/', label: 'Projects' },
|
||||||
|
{ href: '/about/', label: 'About' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
export function formatDate(date: Date) {
|
export function formatDate(date: Date) {
|
||||||
return new Intl.DateTimeFormat('en', {
|
return new Intl.DateTimeFormat('en', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
|
|
@ -20,6 +28,13 @@ export function formatDate(date: Date) {
|
||||||
}).format(date);
|
}).format(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatDateShort(date: Date) {
|
||||||
|
return new Intl.DateTimeFormat('en', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
export function yearOf(date: Date) {
|
export function yearOf(date: Date) {
|
||||||
return new Intl.DateTimeFormat('en', { year: 'numeric' }).format(date);
|
return new Intl.DateTimeFormat('en', { year: 'numeric' }).format(date);
|
||||||
}
|
}
|
||||||
|
|
@ -44,28 +59,58 @@ export function tagPath(tag: string) {
|
||||||
return `/tags/${tagSlug(tag)}/`;
|
return `/tags/${tagSlug(tag)}/`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatTag(tag: string) {
|
export function projectAnchor(project: CollectionEntry<'projects'>) {
|
||||||
return tag;
|
return project.data.legacyAnchor ?? project.data.sourceProjectId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAllTags(posts: { data: { tags: string[] } }[]) {
|
export function getAllTags(posts: { data: { tags: readonly string[] } }[]) {
|
||||||
return [...new Set(posts.flatMap((post) => post.data.tags))].sort((a, b) =>
|
return [...new Set(posts.flatMap((post) => post.data.tags))].sort((a, b) =>
|
||||||
a.localeCompare(b)
|
a.localeCompare(b)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPublishedPosts() {
|
export async function getPublishedPosts(): Promise<CollectionEntry<'posts'>[]> {
|
||||||
return (await getCollection('posts'))
|
return (await getCollection('posts'))
|
||||||
.filter((post) => !post.data.draft)
|
.filter((post) => !post.data.draft)
|
||||||
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
|
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProjects() {
|
export async function getProjects(): Promise<CollectionEntry<'projects'>[]> {
|
||||||
return (await getCollection('projects')).sort(
|
return (await getCollection('projects')).sort(
|
||||||
(a, b) => b.data.sortDate.valueOf() - a.data.sortDate.valueOf()
|
(a, b) => b.data.sortDate.valueOf() - a.data.sortDate.valueOf()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function adjacentPosts(
|
||||||
|
posts: CollectionEntry<'posts'>[],
|
||||||
|
current: CollectionEntry<'posts'>
|
||||||
|
) {
|
||||||
|
const index = posts.findIndex((post) => post.id === current.id);
|
||||||
|
if (index === -1) return { previous: undefined, next: undefined };
|
||||||
|
return {
|
||||||
|
previous: index < posts.length - 1 ? posts[index + 1] : undefined,
|
||||||
|
next: index > 0 ? posts[index - 1] : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRelatedPosts(
|
||||||
|
posts: CollectionEntry<'posts'>[],
|
||||||
|
current: CollectionEntry<'posts'>,
|
||||||
|
limit = 3
|
||||||
|
) {
|
||||||
|
const currentTags = new Set(current.data.tags);
|
||||||
|
return posts
|
||||||
|
.filter((post) => post.id !== current.id)
|
||||||
|
.map((post) => ({
|
||||||
|
post,
|
||||||
|
overlap: post.data.tags.filter((tag) => currentTags.has(tag)).length,
|
||||||
|
}))
|
||||||
|
.filter(({ overlap }) => overlap > 0)
|
||||||
|
.sort((a, b) => b.overlap - a.overlap)
|
||||||
|
.slice(0, limit)
|
||||||
|
.map(({ post }) => post);
|
||||||
|
}
|
||||||
|
|
||||||
export function absoluteUrl(path: string) {
|
export function absoluteUrl(path: string) {
|
||||||
return new URL(path, site.url).toString();
|
return new URL(path, site.url).toString();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,33 @@
|
||||||
---
|
---
|
||||||
|
import ArticleList from '../components/ArticleList.astro';
|
||||||
import Page from '../layouts/Page.astro';
|
import Page from '../layouts/Page.astro';
|
||||||
|
import { getPublishedPosts } from '../lib/site';
|
||||||
|
|
||||||
|
const posts = await getPublishedPosts();
|
||||||
|
const recent = posts.slice(0, 5);
|
||||||
---
|
---
|
||||||
|
|
||||||
<Page title="Not Found" description="The page you are looking for does not exist.">
|
<Page
|
||||||
<div class="prose">
|
title="Not Found"
|
||||||
<p>
|
description="The page you are looking for does not exist."
|
||||||
Try the <a href="/articles/">articles archive</a>, the
|
noindex
|
||||||
<a href="/projects/">project index</a>, or the <a href="/">homepage</a>.
|
>
|
||||||
</p>
|
<div class="empty-state">
|
||||||
|
<div class="prose">
|
||||||
|
<p>
|
||||||
|
Try the <a href="/articles/">articles archive</a>, the
|
||||||
|
<a href="/projects/">project index</a>, the
|
||||||
|
<a href="/tags/">tag index</a>, or head back to the
|
||||||
|
<a href="/">homepage</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section class="home-section" aria-labelledby="404-recent">
|
||||||
|
<div class="section-heading">
|
||||||
|
<h2 id="404-recent">Recent articles</h2>
|
||||||
|
<a href="/articles/">All articles →</a>
|
||||||
|
</div>
|
||||||
|
<ArticleList posts={recent} />
|
||||||
|
</section>
|
||||||
</Page>
|
</Page>
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,40 @@ const startingPoints = posts
|
||||||
.filter((post) => post.data.audience === 'recruiter-relevant')
|
.filter((post) => post.data.audience === 'recruiter-relevant')
|
||||||
.sort((a, b) => (a.data.featuredOrder ?? 99) - (b.data.featuredOrder ?? 99))
|
.sort((a, b) => (a.data.featuredOrder ?? 99) - (b.data.featuredOrder ?? 99))
|
||||||
.slice(0, 4);
|
.slice(0, 4);
|
||||||
|
|
||||||
|
const personJsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Person',
|
||||||
|
name: site.name,
|
||||||
|
url: site.url,
|
||||||
|
email: `mailto:${site.email}`,
|
||||||
|
sameAs: [site.github, site.linkedin],
|
||||||
|
jobTitle: 'Software Engineer',
|
||||||
|
description:
|
||||||
|
'Software engineer with an MSc in Computer Science working on AI/ML systems, web platforms, graphics, simulations, and tools.',
|
||||||
|
};
|
||||||
---
|
---
|
||||||
|
|
||||||
<Page
|
<Page
|
||||||
title="About"
|
title="About"
|
||||||
description="A direct summary of my background, technical interests, and best starting points."
|
description="A direct summary of my background, technical interests, and best starting points."
|
||||||
|
jsonLd={personJsonLd}
|
||||||
|
ogType="profile"
|
||||||
>
|
>
|
||||||
<div class="prose">
|
<div class="prose">
|
||||||
<p>
|
<p>
|
||||||
I am Andras Schmelczer, a software engineer with an MSc in Computer Science and a
|
I am Andras Schmelczer, a software engineer with an MSc in Computer Science and more
|
||||||
background across AI/ML systems, web platforms, graphics, simulations, and tools. I
|
than six years of professional engineering experience. My work spans AI/ML systems,
|
||||||
like work where architecture, constraints, and product usefulness all matter.
|
web platforms, graphics, simulations, and tools, and I like projects where
|
||||||
|
architecture, constraints, and product usefulness all matter.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
I am especially interested in architecting and building large-scale systems,
|
||||||
|
particularly around AI/ML. In my own time I also return to shaders, data
|
||||||
|
visualization, simulations, and occasionally microcontrollers. The
|
||||||
|
<a href="/articles/">articles</a> and <a href="/projects/">projects</a> indexes are the
|
||||||
|
best way to understand that range; the CV and contact links are here when a direct summary
|
||||||
|
is more useful.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -33,8 +56,9 @@ const startingPoints = posts
|
||||||
<dd><a href={`mailto:${site.email}`}>{site.email}</a></dd>
|
<dd><a href={`mailto:${site.email}`}>{site.email}</a></dd>
|
||||||
<dt>Links</dt>
|
<dt>Links</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<a href={site.cv}>CV</a>, <a href={site.github}>GitHub</a>,
|
<a href={site.cv} rel="noopener">CV</a>,
|
||||||
<a href={site.linkedin}>LinkedIn</a>
|
<a href={site.github} rel="noopener me">GitHub</a>,
|
||||||
|
<a href={site.linkedin} rel="noopener me">LinkedIn</a>
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -42,7 +66,7 @@ const startingPoints = posts
|
||||||
<section class="about-section" aria-labelledby="best-starting-points">
|
<section class="about-section" aria-labelledby="best-starting-points">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<h2 id="best-starting-points">Best Starting Points</h2>
|
<h2 id="best-starting-points">Best Starting Points</h2>
|
||||||
<a href="/articles/">All articles</a>
|
<a href="/articles/">Browse all articles →</a>
|
||||||
</div>
|
</div>
|
||||||
<ArticleList posts={startingPoints} />
|
<ArticleList posts={startingPoints} />
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,44 @@
|
||||||
import ArticleList from '../../components/ArticleList.astro';
|
import ArticleList from '../../components/ArticleList.astro';
|
||||||
import TagList from '../../components/TagList.astro';
|
import TagList from '../../components/TagList.astro';
|
||||||
import Page from '../../layouts/Page.astro';
|
import Page from '../../layouts/Page.astro';
|
||||||
import { getAllTags, getPublishedPosts, yearOf } from '../../lib/site';
|
import {
|
||||||
|
absoluteUrl,
|
||||||
|
articlePath,
|
||||||
|
getAllTags,
|
||||||
|
getPublishedPosts,
|
||||||
|
site,
|
||||||
|
yearOf,
|
||||||
|
} from '../../lib/site';
|
||||||
|
|
||||||
const posts = await getPublishedPosts();
|
const posts = await getPublishedPosts();
|
||||||
const years = [...new Set(posts.map((post) => yearOf(post.data.date)))];
|
const years = [...new Set(posts.map((post) => yearOf(post.data.date)))];
|
||||||
const tags = getAllTags(posts);
|
const tags = getAllTags(posts);
|
||||||
|
|
||||||
|
const blogJsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Blog',
|
||||||
|
name: `${site.name} — Articles`,
|
||||||
|
url: absoluteUrl('/articles/'),
|
||||||
|
description:
|
||||||
|
'Technical articles and notes about projects, systems, AI deployment, graphics, simulations, and tools.',
|
||||||
|
blogPost: posts.map((post) => ({
|
||||||
|
'@type': 'BlogPosting',
|
||||||
|
headline: post.data.title,
|
||||||
|
description: post.data.description,
|
||||||
|
datePublished: post.data.date.toISOString(),
|
||||||
|
url: absoluteUrl(articlePath(post)),
|
||||||
|
})),
|
||||||
|
};
|
||||||
---
|
---
|
||||||
|
|
||||||
<Page
|
<Page
|
||||||
title="Articles"
|
title="Articles"
|
||||||
description="Technical articles and notes about projects, systems, AI deployment, graphics, simulations, and tools."
|
description="Technical articles and notes about projects, systems, AI deployment, graphics, simulations, and tools."
|
||||||
|
jsonLd={blogJsonLd}
|
||||||
>
|
>
|
||||||
<nav class="tag-filter" aria-label="Filter articles by tag">
|
<nav id="tags" class="tag-filter" aria-label="Browse by tag">
|
||||||
<span>Filter by tag</span>
|
<span>Browse by tag</span>
|
||||||
<TagList tags={tags} />
|
<TagList tags={tags} labelled={false} />
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
@ -24,7 +48,7 @@ const tags = getAllTags(posts);
|
||||||
return (
|
return (
|
||||||
<section class="archive-year" aria-labelledby={`year-${year}`}>
|
<section class="archive-year" aria-labelledby={`year-${year}`}>
|
||||||
<h2 id={`year-${year}`}>{year}</h2>
|
<h2 id={`year-${year}`}>{year}</h2>
|
||||||
<ArticleList posts={postsForYear} showYear />
|
<ArticleList posts={postsForYear} showYear={false} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,28 @@
|
||||||
---
|
---
|
||||||
import ArticleList from '../components/ArticleList.astro';
|
import ArticleList from '../components/ArticleList.astro';
|
||||||
import ProjectList from '../components/ProjectList.astro';
|
import ProjectList from '../components/ProjectList.astro';
|
||||||
|
import TagList from '../components/TagList.astro';
|
||||||
import Base from '../layouts/Base.astro';
|
import Base from '../layouts/Base.astro';
|
||||||
import { getProjects, getPublishedPosts } from '../lib/site';
|
import { getAllTags, getProjects, getPublishedPosts, site } from '../lib/site';
|
||||||
|
|
||||||
const posts = await getPublishedPosts();
|
const posts = await getPublishedPosts();
|
||||||
const latestPosts = posts.slice(0, 5);
|
const latestPosts = posts.slice(0, 5);
|
||||||
const projects = await getProjects();
|
const projects = await getProjects();
|
||||||
const selectedProjects = projects.filter((project) => project.data.selected);
|
const selectedProjects = projects.filter((project) => project.data.selected);
|
||||||
|
const tags = getAllTags(posts);
|
||||||
|
|
||||||
|
const personJsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Person',
|
||||||
|
name: site.name,
|
||||||
|
url: site.url,
|
||||||
|
email: `mailto:${site.email}`,
|
||||||
|
sameAs: [site.github, site.linkedin],
|
||||||
|
description: site.description,
|
||||||
|
};
|
||||||
---
|
---
|
||||||
|
|
||||||
<Base>
|
<Base jsonLd={personJsonLd}>
|
||||||
<section class="home-intro">
|
<section class="home-intro">
|
||||||
<p class="eyebrow">
|
<p class="eyebrow">
|
||||||
Software systems, AI deployment, graphics, simulations, and tools
|
Software systems, AI deployment, graphics, simulations, and tools
|
||||||
|
|
@ -29,7 +41,7 @@ const selectedProjects = projects.filter((project) => project.data.selected);
|
||||||
<section class="home-section" aria-labelledby="latest-articles">
|
<section class="home-section" aria-labelledby="latest-articles">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<h2 id="latest-articles">Latest Articles</h2>
|
<h2 id="latest-articles">Latest Articles</h2>
|
||||||
<a href="/articles/">All articles</a>
|
<a href="/articles/">All {posts.length} articles →</a>
|
||||||
</div>
|
</div>
|
||||||
<ArticleList posts={latestPosts} />
|
<ArticleList posts={latestPosts} />
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -37,8 +49,17 @@ const selectedProjects = projects.filter((project) => project.data.selected);
|
||||||
<section class="home-section" aria-labelledby="selected-projects">
|
<section class="home-section" aria-labelledby="selected-projects">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<h2 id="selected-projects">Selected Projects</h2>
|
<h2 id="selected-projects">Selected Projects</h2>
|
||||||
<a href="/projects/">All projects</a>
|
<a href="/projects/">All projects →</a>
|
||||||
</div>
|
</div>
|
||||||
<ProjectList projects={selectedProjects} />
|
<ProjectList projects={selectedProjects} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="home-section" aria-labelledby="browse-by-topic">
|
||||||
|
<div class="section-heading">
|
||||||
|
<h2 id="browse-by-topic">Browse by Topic</h2>
|
||||||
|
</div>
|
||||||
|
<div class="tag-cloud">
|
||||||
|
<TagList tags={tags} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</Base>
|
</Base>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,26 @@
|
||||||
---
|
---
|
||||||
import ProjectList from '../../components/ProjectList.astro';
|
import ProjectList from '../../components/ProjectList.astro';
|
||||||
import Page from '../../layouts/Page.astro';
|
import Page from '../../layouts/Page.astro';
|
||||||
import { getProjects } from '../../lib/site';
|
import { absoluteUrl, getProjects, site } from '../../lib/site';
|
||||||
|
|
||||||
const projects = await getProjects();
|
const projects = await getProjects();
|
||||||
const selected = projects.filter((project) => project.data.selected);
|
const selected = projects.filter((project) => project.data.selected);
|
||||||
const older = projects.filter((project) => !project.data.selected);
|
const older = projects.filter((project) => !project.data.selected);
|
||||||
|
|
||||||
|
const jsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'CollectionPage',
|
||||||
|
name: `${site.name} — Projects`,
|
||||||
|
url: absoluteUrl('/projects/'),
|
||||||
|
description:
|
||||||
|
'A compact index of systems, tools, simulations, graphics experiments, games, and older work.',
|
||||||
|
};
|
||||||
---
|
---
|
||||||
|
|
||||||
<Page
|
<Page
|
||||||
title="Projects"
|
title="Projects"
|
||||||
description="A compact index of systems, tools, simulations, graphics experiments, games, and older work."
|
description="A compact index of systems, tools, simulations, graphics experiments, games, and older work."
|
||||||
|
jsonLd={jsonLd}
|
||||||
>
|
>
|
||||||
<section class="project-section" aria-labelledby="selected-projects">
|
<section class="project-section" aria-labelledby="selected-projects">
|
||||||
<h2 id="selected-projects">Selected Projects</h2>
|
<h2 id="selected-projects">Selected Projects</h2>
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,38 @@
|
||||||
import rss from '@astrojs/rss';
|
import rss from '@astrojs/rss';
|
||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { articlePath, getPublishedPosts, site } from '../lib/site';
|
import { absoluteUrl, articlePath, getPublishedPosts, site } from '../lib/site';
|
||||||
|
|
||||||
export const GET: APIRoute = async (context) => {
|
export const GET: APIRoute = async (context) => {
|
||||||
const posts = await getPublishedPosts();
|
const posts = await getPublishedPosts();
|
||||||
|
const feedUrl = absoluteUrl('/rss.xml');
|
||||||
|
|
||||||
return rss({
|
return rss({
|
||||||
title: site.name,
|
title: site.name,
|
||||||
description: site.description,
|
description: site.description,
|
||||||
site: context.site ?? site.url,
|
site: context.site ?? site.url,
|
||||||
items: posts.map((post) => ({
|
xmlns: {
|
||||||
title: post.data.title,
|
atom: 'http://www.w3.org/2005/Atom',
|
||||||
description: post.data.description,
|
content: 'http://purl.org/rss/1.0/modules/content/',
|
||||||
pubDate: post.data.date,
|
},
|
||||||
link: articlePath(post),
|
customData: [
|
||||||
categories: post.data.tags,
|
'<language>en-us</language>',
|
||||||
customData: post.data.updated
|
`<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>`,
|
||||||
? `<updated>${post.data.updated.toISOString()}</updated>`
|
`<atom:link href="${feedUrl}" rel="self" type="application/rss+xml" />`,
|
||||||
: undefined,
|
].join('\n'),
|
||||||
})),
|
items: posts.map((post) => {
|
||||||
|
const url = absoluteUrl(articlePath(post));
|
||||||
|
const updated = post.data.updated
|
||||||
|
? `<atom:updated>${post.data.updated.toISOString()}</atom:updated>`
|
||||||
|
: '';
|
||||||
|
return {
|
||||||
|
title: post.data.title,
|
||||||
|
description: post.data.description,
|
||||||
|
pubDate: post.data.date,
|
||||||
|
link: url,
|
||||||
|
author: `${site.email} (${site.name})`,
|
||||||
|
categories: [...post.data.tags],
|
||||||
|
customData: `<guid isPermaLink="true">${url}</guid>${updated}`,
|
||||||
|
};
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
---
|
---
|
||||||
import ArticleList from '../../components/ArticleList.astro';
|
import ArticleList from '../../components/ArticleList.astro';
|
||||||
|
import Breadcrumbs from '../../components/Breadcrumbs.astro';
|
||||||
import TagList from '../../components/TagList.astro';
|
import TagList from '../../components/TagList.astro';
|
||||||
import Page from '../../layouts/Page.astro';
|
import Page from '../../layouts/Page.astro';
|
||||||
import { formatTag, getAllTags, getPublishedPosts, tagSlug } from '../../lib/site';
|
import { getAllTags, getPublishedPosts, tagSlug } from '../../lib/site';
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
const posts = await getPublishedPosts();
|
const posts = await getPublishedPosts();
|
||||||
|
|
@ -15,19 +16,24 @@ export async function getStaticPaths() {
|
||||||
const { tag } = Astro.props;
|
const { tag } = Astro.props;
|
||||||
const posts = await getPublishedPosts();
|
const posts = await getPublishedPosts();
|
||||||
const allTags = getAllTags(posts);
|
const allTags = getAllTags(posts);
|
||||||
const filteredPosts = posts.filter((post) =>
|
const filteredPosts = posts.filter((post) => post.data.tags.some((t) => t === tag));
|
||||||
(post.data.tags as readonly string[]).includes(tag)
|
const title = `Articles tagged #${tag}`;
|
||||||
);
|
const trail = [
|
||||||
const title = `Articles tagged #${formatTag(tag)}`;
|
{ href: '/', label: 'Home' },
|
||||||
|
{ href: '/articles/', label: 'Articles' },
|
||||||
|
{ href: '/tags/', label: 'Tags' },
|
||||||
|
{ label: `#${tag}` },
|
||||||
|
];
|
||||||
---
|
---
|
||||||
|
|
||||||
<Page
|
<Page
|
||||||
title={title}
|
title={title}
|
||||||
description={`Project articles and technical notes filed under #${formatTag(tag)}.`}
|
description={`Project articles and technical notes filed under #${tag}.`}
|
||||||
>
|
>
|
||||||
<nav class="tag-filter" aria-label="Filter articles by tag">
|
<Breadcrumbs items={trail} />
|
||||||
<span>Filter by tag</span>
|
<nav class="tag-filter" aria-label="Browse other tags">
|
||||||
<TagList tags={allTags} currentTag={tag} />
|
<span>Browse other tags</span>
|
||||||
|
<TagList tags={allTags} currentTag={tag} labelled={false} />
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<ArticleList posts={filteredPosts} currentTag={tag} />
|
<ArticleList posts={filteredPosts} currentTag={tag} />
|
||||||
|
|
|
||||||
22
src/pages/tags/index.astro
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
---
|
||||||
|
import TagList from '../../components/TagList.astro';
|
||||||
|
import Page from '../../layouts/Page.astro';
|
||||||
|
import { getAllTags, getPublishedPosts } from '../../lib/site';
|
||||||
|
|
||||||
|
const posts = await getPublishedPosts();
|
||||||
|
const tags = getAllTags(posts);
|
||||||
|
|
||||||
|
const tagCounts = new Map<string, number>();
|
||||||
|
for (const post of posts) {
|
||||||
|
for (const tag of post.data.tags) {
|
||||||
|
tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<Page title="Tags" description="Every tag used across the articles archive.">
|
||||||
|
<p class="dek">
|
||||||
|
{posts.length} articles across {tags.length} tags.
|
||||||
|
</p>
|
||||||
|
<TagList tags={tags} />
|
||||||
|
</Page>
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
---
|
|
||||||
import { getCollection } from 'astro:content';
|
|
||||||
import { absoluteUrl, articlePath, entrySlug, site } from '../../lib/site';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
slug: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
|
||||||
const posts = (await getCollection('posts')).filter((post) => !post.data.draft);
|
|
||||||
return posts.map((post) => ({
|
|
||||||
params: { slug: entrySlug(post) },
|
|
||||||
props: { slug: entrySlug(post) },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
const { slug } = Astro.props;
|
|
||||||
const target = articlePath(slug);
|
|
||||||
const targetUrl = absoluteUrl(target);
|
|
||||||
---
|
|
||||||
|
|
||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
||||||
<meta name="robots" content="noindex" />
|
|
||||||
<meta http-equiv="refresh" content={`0; url=${target}`} />
|
|
||||||
<link rel="canonical" href={targetUrl} />
|
|
||||||
<title>Article moved | {site.name}</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main>
|
|
||||||
<p>This article has moved to <a href={target}>the new article URL</a>.</p>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
---
|
|
||||||
import { absoluteUrl, site } from '../../lib/site';
|
|
||||||
|
|
||||||
const target = '/articles/';
|
|
||||||
const targetUrl = absoluteUrl(target);
|
|
||||||
---
|
|
||||||
|
|
||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
||||||
<meta name="robots" content="noindex" />
|
|
||||||
<meta http-equiv="refresh" content={`0; url=${target}`} />
|
|
||||||
<link rel="canonical" href={targetUrl} />
|
|
||||||
<title>Articles moved | {site.name}</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main>
|
|
||||||
<p>Articles have moved to <a href={target}>/articles/</a>.</p>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
{
|
{
|
||||||
"extends": "astro/tsconfigs/strict",
|
"extends": "astro/tsconfigs/strict",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"types": ["astro/client"]
|
"types": ["astro/client"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||