claude again
1099
BLOG_REWRITE_PLAN.md
|
|
@ -6,6 +6,13 @@ The site is article-first: articles live in `src/content/posts`, project index e
|
||||||
live in `src/content/projects`, and normal pages are rendered as static HTML with no
|
live in `src/content/projects`, and normal pages are rendered as static HTML with no
|
||||||
required client JavaScript.
|
required client JavaScript.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
npx playwright install chromium # required before `npm run qa:overflow`
|
||||||
|
```
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,44 @@
|
||||||
|
import { readdirSync, readFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
import sitemap from '@astrojs/sitemap';
|
import sitemap from '@astrojs/sitemap';
|
||||||
import { defineConfig } from 'astro/config';
|
import { defineConfig } from 'astro/config';
|
||||||
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
|
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
|
||||||
import rehypeSlug from 'rehype-slug';
|
import rehypeSlug from 'rehype-slug';
|
||||||
|
|
||||||
|
// Build a lookup of post slugs to their last modification dates so the sitemap
|
||||||
|
// can advertise accurate <lastmod> values to crawlers. We parse the markdown
|
||||||
|
// frontmatter ourselves rather than importing `astro:content` (a virtual module
|
||||||
|
// that may not be available inside the config). Failures are non-fatal —
|
||||||
|
// sitemap entries simply fall back to no lastmod.
|
||||||
|
const postLastmodLookup = new Map();
|
||||||
|
try {
|
||||||
|
const postsDir = path.resolve(
|
||||||
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
'src/content/posts'
|
||||||
|
);
|
||||||
|
for (const entry of readdirSync(postsDir, { withFileTypes: true })) {
|
||||||
|
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
||||||
|
const slug = entry.name.replace(/\.md$/, '');
|
||||||
|
const raw = readFileSync(path.join(postsDir, entry.name), 'utf8');
|
||||||
|
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||||
|
if (!frontmatterMatch) continue;
|
||||||
|
const frontmatter = frontmatterMatch[1];
|
||||||
|
const updatedMatch = frontmatter.match(/^updated:\s*(.+?)\s*$/m);
|
||||||
|
const dateMatch = frontmatter.match(/^date:\s*(.+?)\s*$/m);
|
||||||
|
const rawDate = (updatedMatch ?? dateMatch)?.[1]?.replace(/^['"]|['"]$/g, '');
|
||||||
|
if (!rawDate) continue;
|
||||||
|
const parsed = new Date(rawDate);
|
||||||
|
if (!Number.isNaN(parsed.valueOf())) postLastmodLookup.set(slug, parsed);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Directory missing or unreadable; sitemap will fall back to no lastmod.
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
site: 'https://schmelczer.dev',
|
site: 'https://schmelczer.dev',
|
||||||
trailingSlash: 'always',
|
trailingSlash: 'always',
|
||||||
|
build: { inlineStylesheets: 'always' },
|
||||||
redirects: {
|
redirects: {
|
||||||
'/writing/': '/articles/',
|
'/writing/': '/articles/',
|
||||||
'/writing/[slug]': '/articles/[slug]',
|
'/writing/[slug]': '/articles/[slug]',
|
||||||
|
|
@ -17,7 +50,16 @@ export default defineConfig({
|
||||||
return !path.startsWith('/writing/') && path !== '/404/';
|
return !path.startsWith('/writing/') && path !== '/404/';
|
||||||
},
|
},
|
||||||
serialize(item) {
|
serialize(item) {
|
||||||
return { ...item, changefreq: 'monthly' };
|
const url = new URL(item.url);
|
||||||
|
const match = url.pathname.match(/^\/articles\/([^/]+)\/?$/);
|
||||||
|
let lastmod = item.lastmod;
|
||||||
|
if (match) {
|
||||||
|
const date = postLastmodLookup.get(match[1]);
|
||||||
|
if (date instanceof Date && !Number.isNaN(date.valueOf())) {
|
||||||
|
lastmod = date.toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...item, changefreq: 'monthly', ...(lastmod ? { lastmod } : {}) };
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
1342
package-lock.json
generated
10
package.json
|
|
@ -6,9 +6,11 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"start": "astro dev",
|
"start": "astro dev",
|
||||||
"lint": "astro check && prettier --check \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/*.mjs\" \"*.md\" \"*.json\"",
|
"typecheck": "astro check",
|
||||||
|
"lint": "prettier --check \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/*.mjs\" \"*.md\" \"*.json\"",
|
||||||
"format": "prettier --write \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/*.mjs\" \"*.md\" \"*.json\"",
|
"format": "prettier --write \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/*.mjs\" \"*.md\" \"*.json\"",
|
||||||
"build": "astro check && astro build",
|
"format:check": "prettier --check \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/*.mjs\" \"*.md\" \"*.json\"",
|
||||||
|
"build": "astro build",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"qa:no-js": "node scripts/check-no-js.mjs",
|
"qa:no-js": "node scripts/check-no-js.mjs",
|
||||||
"qa:overflow": "node scripts/check-overflow.mjs",
|
"qa:overflow": "node scripts/check-overflow.mjs",
|
||||||
|
|
@ -43,7 +45,9 @@
|
||||||
"prettier-plugin-astro": "^0.14.1",
|
"prettier-plugin-astro": "^0.14.1",
|
||||||
"rehype-autolink-headings": "^7.1.0",
|
"rehype-autolink-headings": "^7.1.0",
|
||||||
"rehype-slug": "^6.0.0",
|
"rehype-slug": "^6.0.0",
|
||||||
"sharp": "^0.32.6",
|
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"sharp": "^0.34.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 99 KiB |
|
|
@ -35,14 +35,41 @@ if (jsFiles.length > 0) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Script tags are only allowed if they declare one of these safe `type`
|
||||||
|
// attributes (or are tagged with `data-theme-script`). All other scripts —
|
||||||
|
// including untyped ones, which default to executable JavaScript — are
|
||||||
|
// flagged.
|
||||||
|
const SAFE_SCRIPT_TYPES = new Set([
|
||||||
|
'application/ld+json',
|
||||||
|
'importmap',
|
||||||
|
'speculationrules',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isSafeScriptTag(tag) {
|
||||||
|
if (tag.includes('data-theme-script')) return true;
|
||||||
|
const typeMatch = tag.match(/\btype=["']([^"']+)["']/i);
|
||||||
|
if (!typeMatch) return false;
|
||||||
|
return SAFE_SCRIPT_TYPES.has(typeMatch[1].trim().toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
for (const file of files.filter((candidate) => candidate.endsWith('.html'))) {
|
for (const file of files.filter((candidate) => candidate.endsWith('.html'))) {
|
||||||
const html = await readFile(file, 'utf8');
|
const html = await readFile(file, 'utf8');
|
||||||
const scripts = (
|
const scripts = (html.match(/<script\b[^>]*>/gi) ?? []).filter(
|
||||||
html.match(/<script\b(?![^>]*type=["']application\/ld\+json["'])[^>]*>/gi) ?? []
|
(tag) => !isSafeScriptTag(tag)
|
||||||
).filter((script) => !script.includes('data-theme-script'));
|
);
|
||||||
if (scripts?.length) {
|
if (scripts.length) {
|
||||||
failures.push(`Unexpected script tag in ${file}:\n${scripts.join('\n')}`);
|
failures.push(`Unexpected script tag in ${file}:\n${scripts.join('\n')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inline event handlers (onclick=, onload=, etc.) execute JavaScript even
|
||||||
|
// without a <script> tag, so flag any attribute matching `on*=`. We strip
|
||||||
|
// <script> blocks first to avoid false positives from JSON-LD payloads.
|
||||||
|
const stripped = html.replace(/<script\b[\s\S]*?<\/script>/gi, '');
|
||||||
|
const handlerMatches = stripped.match(/\son\w+=/gi);
|
||||||
|
if (handlerMatches?.length) {
|
||||||
|
const unique = [...new Set(handlerMatches.map((m) => m.trim()))];
|
||||||
|
failures.push(`Unexpected inline event handler in ${file}:\n${unique.join('\n')}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (failures.length > 0) {
|
if (failures.length > 0) {
|
||||||
|
|
|
||||||
|
|
@ -6,16 +6,27 @@ import { chromium } from 'playwright';
|
||||||
const dist = path.resolve('dist');
|
const dist = path.resolve('dist');
|
||||||
const widths = [320, 390, 430, 768, 1024, 1440, 1920];
|
const widths = [320, 390, 430, 768, 1024, 1440, 1920];
|
||||||
|
|
||||||
|
const MIME = {
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.js': 'text/javascript; charset=utf-8',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.avif': 'image/avif',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
'.woff': 'font/woff',
|
||||||
|
'.woff2': 'font/woff2',
|
||||||
|
'.mp4': 'video/mp4',
|
||||||
|
'.webm': 'video/webm',
|
||||||
|
'.pdf': 'application/pdf',
|
||||||
|
};
|
||||||
|
|
||||||
function contentType(file) {
|
function contentType(file) {
|
||||||
if (file.endsWith('.html')) return 'text/html; charset=utf-8';
|
const ext = path.extname(file).toLowerCase();
|
||||||
if (file.endsWith('.css')) return 'text/css; charset=utf-8';
|
return MIME[ext] ?? 'application/octet-stream';
|
||||||
if (file.endsWith('.js')) return 'text/javascript; charset=utf-8';
|
|
||||||
if (file.endsWith('.svg')) return 'image/svg+xml';
|
|
||||||
if (file.endsWith('.png')) return 'image/png';
|
|
||||||
if (file.endsWith('.jpg') || file.endsWith('.jpeg')) return 'image/jpeg';
|
|
||||||
if (file.endsWith('.webp')) return 'image/webp';
|
|
||||||
if (file.endsWith('.woff2')) return 'font/woff2';
|
|
||||||
return 'application/octet-stream';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function walk(dir) {
|
async function walk(dir) {
|
||||||
|
|
@ -75,6 +86,12 @@ async function resolveFile(url) {
|
||||||
return path.join(dist, '404.html');
|
return path.join(dist, '404.html');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await stat(dist);
|
||||||
|
} catch {
|
||||||
|
throw new Error('dist/ does not exist. Run npm run build first.');
|
||||||
|
}
|
||||||
|
|
||||||
const routes = await discoverRoutes();
|
const routes = await discoverRoutes();
|
||||||
|
|
||||||
const server = createServer(async (req, res) => {
|
const server = createServer(async (req, res) => {
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 82 KiB |
|
|
@ -22,13 +22,13 @@ const { posts, showYear = true, currentTag } = Astro.props;
|
||||||
<time datetime={post.data.date.toISOString()}>
|
<time datetime={post.data.date.toISOString()}>
|
||||||
{showYear ? formatDate(post.data.date) : formatDateShort(post.data.date)}
|
{showYear ? formatDate(post.data.date) : formatDateShort(post.data.date)}
|
||||||
</time>
|
</time>
|
||||||
<div>
|
<article>
|
||||||
<a class="entry-title" href={href}>
|
<a class="entry-title" href={href}>
|
||||||
{post.data.title}
|
{post.data.title}
|
||||||
</a>
|
</a>
|
||||||
<p>{post.data.description}</p>
|
<p>{post.data.description}</p>
|
||||||
<TagList tags={post.data.tags} currentTag={currentTag} />
|
<TagList tags={post.data.tags} currentTag={currentTag} limit={3} />
|
||||||
</div>
|
</article>
|
||||||
<EntryThumbnail
|
<EntryThumbnail
|
||||||
src={post.data.thumbnail.src}
|
src={post.data.thumbnail.src}
|
||||||
alt={post.data.thumbnail.alt}
|
alt={post.data.thumbnail.alt}
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,18 @@ interface Props {
|
||||||
scale?: string;
|
scale?: string;
|
||||||
outcome?: string;
|
outcome?: string;
|
||||||
links?: Link[];
|
links?: Link[];
|
||||||
|
headingId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { role, projectPeriod, stack = [], scale, outcome, links = [] } = Astro.props;
|
const {
|
||||||
|
role,
|
||||||
|
projectPeriod,
|
||||||
|
stack = [],
|
||||||
|
scale,
|
||||||
|
outcome,
|
||||||
|
links = [],
|
||||||
|
headingId = 'at-a-glance-heading',
|
||||||
|
} = Astro.props;
|
||||||
|
|
||||||
const rows: Array<[string, string]> = [
|
const rows: Array<[string, string]> = [
|
||||||
['Role', role ?? ''],
|
['Role', role ?? ''],
|
||||||
|
|
@ -26,14 +35,14 @@ const rows: Array<[string, string]> = [
|
||||||
|
|
||||||
{
|
{
|
||||||
rows.length > 0 && (
|
rows.length > 0 && (
|
||||||
<aside class="at-a-glance" aria-labelledby="at-a-glance-heading">
|
<aside class="at-a-glance" aria-labelledby={headingId}>
|
||||||
<h2 id="at-a-glance-heading">At a Glance</h2>
|
<h2 id={headingId}>At a Glance</h2>
|
||||||
<dl>
|
<dl>
|
||||||
{rows.map(([label, value]) => (
|
{rows.map(([label, value]) => (
|
||||||
<>
|
<div class="at-a-glance__row">
|
||||||
<dt>{label}</dt>
|
<dt>{label}</dt>
|
||||||
<dd>{value}</dd>
|
<dd>{value}</dd>
|
||||||
</>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
{links.length > 0 && <ProjectLinks links={links} />}
|
{links.length > 0 && <ProjectLinks links={links} />}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
import type { ImageMetadata } from 'astro';
|
import type { ImageMetadata } from 'astro';
|
||||||
import { Picture } from 'astro:assets';
|
import { Picture } from 'astro:assets';
|
||||||
|
|
||||||
|
type FallbackFormat = 'jpg' | 'jpeg' | 'png' | 'webp' | 'avif' | 'gif';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
src: ImageMetadata;
|
src: ImageMetadata;
|
||||||
alt: string;
|
alt: string;
|
||||||
|
|
@ -11,6 +13,8 @@ interface Props {
|
||||||
sizes: string;
|
sizes: string;
|
||||||
loading?: 'lazy' | 'eager';
|
loading?: 'lazy' | 'eager';
|
||||||
fetchpriority?: 'high' | 'low' | 'auto';
|
fetchpriority?: 'high' | 'low' | 'auto';
|
||||||
|
decorative?: boolean;
|
||||||
|
fallbackFormat?: FallbackFormat;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -22,22 +26,27 @@ const {
|
||||||
sizes,
|
sizes,
|
||||||
loading = 'lazy',
|
loading = 'lazy',
|
||||||
fetchpriority,
|
fetchpriority,
|
||||||
|
decorative = true,
|
||||||
|
fallbackFormat,
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
|
|
||||||
const Tag = href ? 'a' : 'div';
|
const Tag = href ? 'a' : 'div';
|
||||||
|
const resolvedFallback: FallbackFormat =
|
||||||
|
fallbackFormat ?? (src.format === 'png' ? 'png' : 'jpg');
|
||||||
|
const isDecorativeLink = Boolean(href) && decorative;
|
||||||
---
|
---
|
||||||
|
|
||||||
<Tag
|
<Tag
|
||||||
class:list={['entry-thumbnail', extraClass]}
|
class:list={['entry-thumbnail', extraClass]}
|
||||||
href={href}
|
href={href}
|
||||||
aria-hidden={href ? 'true' : undefined}
|
aria-hidden={isDecorativeLink ? 'true' : undefined}
|
||||||
tabindex={href ? -1 : undefined}
|
tabindex={isDecorativeLink ? -1 : undefined}
|
||||||
>
|
>
|
||||||
<Picture
|
<Picture
|
||||||
src={src}
|
src={src}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
formats={['avif', 'webp']}
|
formats={['avif', 'webp']}
|
||||||
fallbackFormat="jpg"
|
fallbackFormat={resolvedFallback}
|
||||||
widths={widths}
|
widths={widths}
|
||||||
sizes={sizes}
|
sizes={sizes}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
|
|
||||||
|
|
@ -2,37 +2,42 @@
|
||||||
import { navItems, site } from '../lib/site';
|
import { navItems, site } from '../lib/site';
|
||||||
|
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
|
// Local fallback: Agent 3 hasn't shipped `footerItems`/`footerOnly` yet, so we
|
||||||
|
// derive footer items locally. Footer mirrors Header (Home filtered out) and
|
||||||
|
// adds Tags + RSS.
|
||||||
|
const footerNavItems = [
|
||||||
|
...navItems.filter((item) => item.href !== '/'),
|
||||||
|
{ href: '/tags/', label: 'Tags' },
|
||||||
|
{ href: '/rss.xml', label: 'RSS' },
|
||||||
|
];
|
||||||
---
|
---
|
||||||
|
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<nav aria-label="Footer">
|
<nav aria-label="Footer">
|
||||||
<ul class="footer-links">
|
<ul class="footer-links">
|
||||||
{
|
{
|
||||||
navItems.map((item) => (
|
footerNavItems.map((item) => (
|
||||||
<li>
|
<li>
|
||||||
<a href={item.href}>{item.label}</a>
|
<a href={item.href}>{item.label}</a>
|
||||||
</li>
|
</li>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
<li>
|
|
||||||
<a href="/tags/">Tags</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="/rss.xml">RSS</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
<ul class="footer-meta">
|
<address>
|
||||||
<li><span>© {year} {site.name}</span></li>
|
<ul class="footer-meta">
|
||||||
<li><a href={`mailto:${site.email}`}>Email</a></li>
|
<li><span>© {year} {site.name}</span></li>
|
||||||
<li>
|
<li><a href={`mailto:${site.email}`}>Email</a></li>
|
||||||
<a href={site.cv} rel="noopener">CV</a>
|
<li>
|
||||||
</li>
|
<a href={site.cv} rel="noopener noreferrer">CV</a>
|
||||||
<li>
|
</li>
|
||||||
<a href={site.github} rel="noopener me">GitHub</a>
|
<li>
|
||||||
</li>
|
<a href={site.github} rel="noopener noreferrer me">GitHub</a>
|
||||||
<li>
|
</li>
|
||||||
<a href={site.linkedin} rel="noopener me">LinkedIn</a>
|
<li>
|
||||||
</li>
|
<a href={site.linkedin} rel="noopener noreferrer me">LinkedIn</a>
|
||||||
</ul>
|
</li>
|
||||||
|
</ul>
|
||||||
|
</address>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,14 @@ function isCurrent(href: string) {
|
||||||
if (href === '/') return current === '/';
|
if (href === '/') return current === '/';
|
||||||
return current.startsWith(href);
|
return current.startsWith(href);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Local fallback: Agent 3 hasn't shipped `footerItems`/`footerOnly` yet, so we
|
||||||
|
// derive header items locally. Header shows nav (minus Home) + Tags. RSS lives
|
||||||
|
// in the header as a dedicated icon link.
|
||||||
|
const headerNavItems = [
|
||||||
|
...navItems.filter((item) => item.href !== '/'),
|
||||||
|
{ href: '/tags/', label: 'Tags' },
|
||||||
|
];
|
||||||
---
|
---
|
||||||
|
|
||||||
<a class="skip-link" href="#content">Skip to content</a>
|
<a class="skip-link" href="#content">Skip to content</a>
|
||||||
|
|
@ -15,81 +23,99 @@ function isCurrent(href: string) {
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<nav class="site-nav" aria-label="Primary">
|
<nav class="site-nav" aria-label="Primary">
|
||||||
{
|
{
|
||||||
navItems
|
headerNavItems.map((item) => (
|
||||||
.filter((item) => item.href !== '/')
|
<a href={item.href} aria-current={isCurrent(item.href) ? 'page' : undefined}>
|
||||||
.map((item) => (
|
{item.label}
|
||||||
<a href={item.href} aria-current={isCurrent(item.href) ? 'page' : undefined}>
|
</a>
|
||||||
{item.label}
|
))
|
||||||
</a>
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
</nav>
|
</nav>
|
||||||
|
<a class="rss-link" href="/rss.xml" aria-label="RSS feed">
|
||||||
|
<svg
|
||||||
|
class="rss-icon"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M6.18 17.82a2.18 2.18 0 1 1-4.36 0 2.18 2.18 0 0 1 4.36 0ZM2 9.86v3.13a8.97 8.97 0 0 1 9.01 9.01h3.13A12.1 12.1 0 0 0 2 9.86Zm0-5.86V7.1A14.92 14.92 0 0 1 16.9 22H20A17.9 17.9 0 0 0 2 4Z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
<span class="sr-only">RSS feed</span>
|
||||||
|
</a>
|
||||||
<button
|
<button
|
||||||
id="theme-switcher"
|
id="theme-switcher"
|
||||||
class="theme-switcher"
|
class="theme-switcher"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Toggle dark theme"
|
aria-label="Switch to dark theme"
|
||||||
aria-pressed="false"
|
aria-pressed="false"
|
||||||
>
|
>
|
||||||
|
<span class="sr-only">Toggle theme</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<script is:inline data-theme-script>
|
<script is:inline data-theme-script>
|
||||||
(() => {
|
(() => {
|
||||||
const key = 'theme';
|
var key = 'theme';
|
||||||
const legacyKey = 'dark-mode';
|
var legacyKey = 'dark-mode';
|
||||||
const switcher = document.getElementById('theme-switcher');
|
var switcher = document.getElementById('theme-switcher');
|
||||||
const media = matchMedia('(prefers-color-scheme: dark)');
|
|
||||||
|
|
||||||
const getStored = () => {
|
|
||||||
try {
|
|
||||||
const value = localStorage.getItem(key);
|
|
||||||
if (value === 'light' || value === 'dark') return value;
|
|
||||||
const legacyValue = localStorage.getItem(legacyKey);
|
|
||||||
if (legacyValue !== null) return JSON.parse(legacyValue) ? 'dark' : 'light';
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSystemTheme = () => (media.matches ? 'dark' : 'light');
|
|
||||||
|
|
||||||
const apply = (theme) => {
|
|
||||||
document.documentElement.dataset.theme = theme;
|
|
||||||
document.documentElement.style.colorScheme = theme;
|
|
||||||
if (switcher) switcher.setAttribute('aria-pressed', String(theme === 'dark'));
|
|
||||||
};
|
|
||||||
|
|
||||||
apply(getStored() || getSystemTheme());
|
|
||||||
|
|
||||||
if (!switcher) return;
|
if (!switcher) return;
|
||||||
|
|
||||||
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
|
function syncSwitcher(theme) {
|
||||||
|
switcher.setAttribute('aria-pressed', String(theme === 'dark'));
|
||||||
|
switcher.setAttribute(
|
||||||
|
'aria-label',
|
||||||
|
theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const runApply = (theme) => {
|
var initial = document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light';
|
||||||
|
syncSwitcher(initial);
|
||||||
|
|
||||||
|
var reduced = matchMedia('(prefers-reduced-motion: reduce)');
|
||||||
|
|
||||||
|
function apply(theme) {
|
||||||
|
document.documentElement.dataset.theme = theme;
|
||||||
|
document.documentElement.style.colorScheme = theme;
|
||||||
|
syncSwitcher(theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runApply(theme) {
|
||||||
if (!reduced.matches && typeof document.startViewTransition === 'function') {
|
if (!reduced.matches && typeof document.startViewTransition === 'function') {
|
||||||
document.startViewTransition(() => apply(theme));
|
document.startViewTransition(function () {
|
||||||
|
apply(theme);
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
apply(theme);
|
apply(theme);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
switcher.addEventListener('click', () => {
|
switcher.addEventListener('click', function () {
|
||||||
const current = switcher.getAttribute('aria-pressed') === 'true' ? 'dark' : 'light';
|
var currentTheme =
|
||||||
const next = current === 'dark' ? 'light' : 'dark';
|
switcher.getAttribute('aria-pressed') === 'true' ? 'dark' : 'light';
|
||||||
|
var next = currentTheme === 'dark' ? 'light' : 'dark';
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(key, next);
|
localStorage.setItem(key, next);
|
||||||
localStorage.setItem(legacyKey, JSON.stringify(next === 'dark'));
|
localStorage.setItem(legacyKey, JSON.stringify(next === 'dark'));
|
||||||
} catch {
|
} catch (e) {}
|
||||||
// The switch still applies for the current page when storage is unavailable.
|
|
||||||
}
|
|
||||||
runApply(next);
|
runApply(next);
|
||||||
});
|
});
|
||||||
|
|
||||||
media.addEventListener('change', () => {
|
|
||||||
if (!getStored()) apply(getSystemTheme());
|
|
||||||
});
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.rss-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: inherit;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
.rss-icon {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -9,37 +9,77 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { items } = Astro.props;
|
const { items } = Astro.props;
|
||||||
|
|
||||||
|
const fallbackFormatFor = (format: string | undefined): 'png' | 'jpg' =>
|
||||||
|
format === 'png' ? 'png' : 'jpg';
|
||||||
---
|
---
|
||||||
|
|
||||||
{
|
{
|
||||||
items.map((item) => (
|
items.length > 1 ? (
|
||||||
<figure class:list={['post-media', item.role === 'inline' && 'figure-inline']}>
|
<ul role="list" class="post-gallery">
|
||||||
{item.type === 'video' ? (
|
{items.map((item) => (
|
||||||
<video
|
<li>
|
||||||
controls
|
<figure class:list={['post-media', item.role === 'inline' && 'figure-inline']}>
|
||||||
preload="metadata"
|
{item.type === 'video' ? (
|
||||||
poster={item.poster?.src}
|
<video
|
||||||
aria-label={item.decorative ? undefined : item.alt}
|
controls
|
||||||
>
|
preload="metadata"
|
||||||
{item.webm && <source src={item.webm} type="video/webm" />}
|
poster={item.poster?.src}
|
||||||
{item.mp4 && <source src={item.mp4} type="video/mp4" />}
|
aria-label={item.decorative ? 'Decorative video' : item.alt}
|
||||||
</video>
|
>
|
||||||
) : (
|
{item.webm && <source src={item.webm} type="video/webm" />}
|
||||||
item.src && (
|
{item.mp4 && <source src={item.mp4} type="video/mp4" />}
|
||||||
<Picture
|
</video>
|
||||||
src={item.src}
|
) : (
|
||||||
alt={item.decorative ? '' : (item.alt ?? '')}
|
item.src && (
|
||||||
formats={['avif', 'webp']}
|
<Picture
|
||||||
fallbackFormat="jpg"
|
src={item.src}
|
||||||
widths={[480, 720, 960, 1280, 1600, 1920]}
|
alt={item.decorative ? '' : (item.alt ?? '')}
|
||||||
sizes="(max-width: 700px) calc(100vw - 3rem), (max-width: 1100px) calc(100vw - 4rem), 56rem"
|
formats={['avif', 'webp']}
|
||||||
loading="lazy"
|
fallbackFormat={fallbackFormatFor(item.src.format)}
|
||||||
decoding="async"
|
widths={[480, 720, 960, 1280, 1600, 1920]}
|
||||||
/>
|
sizes="(max-width: 700px) calc(100vw - 2 * clamp(20px, 4vw, 32px)), (max-width: 1100px) min(calc(100vw - 4rem), 56rem), 56rem"
|
||||||
)
|
loading="lazy"
|
||||||
)}
|
decoding="async"
|
||||||
{item.caption && <figcaption>{item.caption}</figcaption>}
|
/>
|
||||||
{item.transcript && <p class="media-transcript">{item.transcript}</p>}
|
)
|
||||||
</figure>
|
)}
|
||||||
))
|
{item.caption && <figcaption>{item.caption}</figcaption>}
|
||||||
|
{item.transcript && <p class="media-transcript">{item.transcript}</p>}
|
||||||
|
</figure>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
items.map((item) => (
|
||||||
|
<figure class:list={['post-media', item.role === 'inline' && 'figure-inline']}>
|
||||||
|
{item.type === 'video' ? (
|
||||||
|
<video
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
poster={item.poster?.src}
|
||||||
|
aria-label={item.decorative ? 'Decorative video' : item.alt}
|
||||||
|
>
|
||||||
|
{item.webm && <source src={item.webm} type="video/webm" />}
|
||||||
|
{item.mp4 && <source src={item.mp4} type="video/mp4" />}
|
||||||
|
</video>
|
||||||
|
) : (
|
||||||
|
item.src && (
|
||||||
|
<Picture
|
||||||
|
src={item.src}
|
||||||
|
alt={item.decorative ? '' : (item.alt ?? '')}
|
||||||
|
formats={['avif', 'webp']}
|
||||||
|
fallbackFormat={fallbackFormatFor(item.src.format)}
|
||||||
|
widths={[480, 720, 960, 1280, 1600, 1920]}
|
||||||
|
sizes="(max-width: 700px) calc(100vw - 2 * clamp(20px, 4vw, 32px)), (max-width: 1100px) min(calc(100vw - 4rem), 56rem), 56rem"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{item.caption && <figcaption>{item.caption}</figcaption>}
|
||||||
|
{item.transcript && <p class="media-transcript">{item.transcript}</p>}
|
||||||
|
</figure>
|
||||||
|
))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,29 @@ function isExternal(url: string) {
|
||||||
<a
|
<a
|
||||||
href={link.url}
|
href={link.url}
|
||||||
download={link.download ? '' : undefined}
|
download={link.download ? '' : undefined}
|
||||||
rel={isExternal(link.url) ? 'noopener' : undefined}
|
rel={isExternal(link.url) ? 'noopener noreferrer' : undefined}
|
||||||
|
target={isExternal(link.url) ? '_blank' : undefined}
|
||||||
>
|
>
|
||||||
{link.label}
|
{link.label}
|
||||||
|
{isExternal(link.url) && (
|
||||||
|
<svg
|
||||||
|
class="external-link-icon"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="0.85em"
|
||||||
|
height="0.85em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||||
|
<polyline points="15 3 21 3 21 9" />
|
||||||
|
<line x1="10" y1="14" x2="21" y2="3" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
{link.download && (
|
{link.download && (
|
||||||
<span class="download-indicator" aria-hidden="true">
|
<span class="download-indicator" aria-hidden="true">
|
||||||
↓
|
↓
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
---
|
---
|
||||||
import type { CollectionEntry } from 'astro:content';
|
import type { CollectionEntry } from 'astro:content';
|
||||||
|
import { getEntry } from 'astro:content';
|
||||||
import EntryThumbnail from './EntryThumbnail.astro';
|
import EntryThumbnail from './EntryThumbnail.astro';
|
||||||
import ProjectLinks from './ProjectLinks.astro';
|
import ProjectLinks from './ProjectLinks.astro';
|
||||||
import { articlePath, projectAnchor } from '../lib/site';
|
import { articlePath, projectAnchor } from '../lib/site';
|
||||||
|
|
@ -10,6 +11,42 @@ interface Props {
|
||||||
|
|
||||||
const { projects } = Astro.props;
|
const { projects } = Astro.props;
|
||||||
type ProjectLink = CollectionEntry<'projects'>['data']['links'][number];
|
type ProjectLink = CollectionEntry<'projects'>['data']['links'][number];
|
||||||
|
|
||||||
|
async function resolveEssayHref(
|
||||||
|
essay: CollectionEntry<'projects'>['data']['essay']
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
if (!essay) return undefined;
|
||||||
|
// Defensively handle both `string` (legacy) and resolved-entry / reference shapes.
|
||||||
|
if (typeof essay === 'string') {
|
||||||
|
return articlePath(essay);
|
||||||
|
}
|
||||||
|
if (typeof essay === 'object') {
|
||||||
|
const ref = essay as {
|
||||||
|
collection?: string;
|
||||||
|
id?: string;
|
||||||
|
slug?: string;
|
||||||
|
data?: unknown;
|
||||||
|
};
|
||||||
|
// Already a resolved CollectionEntry (has `data`)
|
||||||
|
if (ref.data && ref.id) {
|
||||||
|
return articlePath({ id: ref.id });
|
||||||
|
}
|
||||||
|
// A reference: { collection, id } — resolve via getEntry
|
||||||
|
if (ref.collection && ref.id) {
|
||||||
|
const resolved = await getEntry(ref.collection as 'posts', ref.id);
|
||||||
|
if (resolved) return articlePath(resolved);
|
||||||
|
return articlePath(ref.id);
|
||||||
|
}
|
||||||
|
if (ref.id) return articlePath(ref.id);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const essayHrefs = new Map<string, string>();
|
||||||
|
for (const project of projects) {
|
||||||
|
const href = await resolveEssayHref(project.data.essay);
|
||||||
|
if (href) essayHrefs.set(project.id, href);
|
||||||
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
<ol class="project-list">
|
<ol class="project-list">
|
||||||
|
|
@ -17,7 +54,7 @@ type ProjectLink = CollectionEntry<'projects'>['data']['links'][number];
|
||||||
projects.map((project) => {
|
projects.map((project) => {
|
||||||
const anchor = projectAnchor(project);
|
const anchor = projectAnchor(project);
|
||||||
const titleId = `${anchor}-title`;
|
const titleId = `${anchor}-title`;
|
||||||
const essayHref = project.data.essay ? articlePath(project.data.essay) : undefined;
|
const essayHref = essayHrefs.get(project.id);
|
||||||
const essayLink: ProjectLink | undefined = essayHref
|
const essayLink: ProjectLink | undefined = essayHref
|
||||||
? { label: 'Article', type: 'site', url: essayHref }
|
? { label: 'Article', type: 'site', url: essayHref }
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
@ -37,7 +74,7 @@ type ProjectLink = CollectionEntry<'projects'>['data']['links'][number];
|
||||||
widths={[240, 320, 480, 640, 800]}
|
widths={[240, 320, 480, 640, 800]}
|
||||||
sizes="(max-width: 700px) 7rem, (max-width: 960px) clamp(7rem, 18vw, 9.5rem), 19rem"
|
sizes="(max-width: 700px) 7rem, (max-width: 960px) clamp(7rem, 18vw, 9.5rem), 19rem"
|
||||||
/>
|
/>
|
||||||
<div class="project-card__summary">
|
<article class="project-card__summary">
|
||||||
<h3 id={titleId}>
|
<h3 id={titleId}>
|
||||||
{primaryHref ? (
|
{primaryHref ? (
|
||||||
<a href={primaryHref}>{project.data.title}</a>
|
<a href={primaryHref}>{project.data.title}</a>
|
||||||
|
|
@ -51,7 +88,7 @@ type ProjectLink = CollectionEntry<'projects'>['data']['links'][number];
|
||||||
{project.data.period} · {project.data.technologies.join(', ')}
|
{project.data.period} · {project.data.technologies.join(', ')}
|
||||||
</p>
|
</p>
|
||||||
{links.length > 0 && <ProjectLinks links={links} />}
|
{links.length > 0 && <ProjectLinks links={links} />}
|
||||||
</div>
|
</article>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -5,19 +5,37 @@ interface Props {
|
||||||
tags: readonly string[];
|
tags: readonly string[];
|
||||||
currentTag?: string;
|
currentTag?: string;
|
||||||
labelled?: boolean;
|
labelled?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
counts?: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { tags, currentTag, labelled = true } = Astro.props;
|
const { tags, currentTag, limit, counts } = Astro.props;
|
||||||
|
|
||||||
|
const visibleTags = typeof limit === 'number' ? tags.slice(0, limit) : tags;
|
||||||
|
const remaining =
|
||||||
|
typeof limit === 'number' && tags.length > limit ? tags.length - limit : 0;
|
||||||
---
|
---
|
||||||
|
|
||||||
<ul class="tag-list" aria-label={labelled ? 'Tags' : undefined}>
|
<ul class="tag-list">
|
||||||
{
|
{
|
||||||
tags.map((tag) => (
|
visibleTags.map((tag) => (
|
||||||
<li>
|
<li>
|
||||||
<a href={tagPath(tag)} aria-current={tag === currentTag ? 'page' : undefined}>
|
<a href={tagPath(tag)} aria-current={tag === currentTag ? 'page' : undefined}>
|
||||||
{tag}
|
{tag}
|
||||||
|
{counts && counts[tag] !== undefined && (
|
||||||
|
<span class="tag-count">{counts[tag]}</span>
|
||||||
|
)}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
remaining > 0 && (
|
||||||
|
<li>
|
||||||
|
<a href="/tags/" class="tag-more">
|
||||||
|
+{remaining} more
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { defineCollection } from 'astro:content';
|
import { defineCollection, reference } from 'astro:content';
|
||||||
import type { SchemaContext } 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';
|
||||||
|
|
@ -92,8 +92,7 @@ const projects = defineCollection({
|
||||||
status: z.string().optional(),
|
status: z.string().optional(),
|
||||||
technologies: z.array(z.string()).default([]),
|
technologies: z.array(z.string()).default([]),
|
||||||
selected: z.boolean().default(false),
|
selected: z.boolean().default(false),
|
||||||
essay: z.string().optional(),
|
essay: reference('posts').optional(),
|
||||||
legacyAnchor: z.string().optional(),
|
|
||||||
links: z.array(linkSchema).default([]),
|
links: z.array(linkSchema).default([]),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 127 KiB |
BIN
src/content/posts/_assets/decla-red.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 296 KiB After Width: | Height: | Size: 243 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 88 KiB |
BIN
src/content/posts/_assets/platform-game.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 48 KiB |
BIN
src/content/posts/_assets/sdf2d.jpg
Normal file
|
After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 143 KiB |
BIN
src/content/posts/_assets/towers.jpg
Normal file
|
After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
|
@ -4,7 +4,7 @@ description: How decla.red used shared TypeScript game logic, WebSockets, client
|
||||||
date: 2026-05-07
|
date: 2026-05-07
|
||||||
projectPeriod: 'Autumn-Winter 2020'
|
projectPeriod: 'Autumn-Winter 2020'
|
||||||
thumbnail:
|
thumbnail:
|
||||||
src: ./_assets/decla-red.png
|
src: ./_assets/decla-red.jpg
|
||||||
alt: The decla.red browser game interface showing a space scene.
|
alt: The decla.red browser game interface showing a space scene.
|
||||||
tags: ['games', 'web', 'systems']
|
tags: ['games', 'web', 'systems']
|
||||||
selected: true
|
selected: true
|
||||||
|
|
@ -28,7 +28,7 @@ links:
|
||||||
download: true
|
download: true
|
||||||
media:
|
media:
|
||||||
- type: image
|
- type: image
|
||||||
src: ./_assets/decla-red.png
|
src: ./_assets/decla-red.jpg
|
||||||
alt: The decla.red browser game interface showing a space scene with team controls and planets.
|
alt: The decla.red browser game interface showing a space scene with team controls and planets.
|
||||||
caption: decla.red used the SDF-2D renderer in a real-time multiplayer game.
|
caption: decla.red used the SDF-2D renderer in a real-time multiplayer game.
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ description: How a multi-device life tracking project used trie structure to dif
|
||||||
date: 2026-05-05
|
date: 2026-05-05
|
||||||
projectPeriod: 'August-September 2019'
|
projectPeriod: 'August-September 2019'
|
||||||
thumbnail:
|
thumbnail:
|
||||||
src: ./_assets/towers.png
|
src: ./_assets/towers.jpg
|
||||||
alt: Life Towers goal tracking interface with tower-like visual structures.
|
alt: Life Towers goal tracking interface with tower-like visual structures.
|
||||||
tags: ['systems', 'web', 'tools']
|
tags: ['systems', 'web', 'tools']
|
||||||
selected: true
|
selected: true
|
||||||
|
|
@ -24,7 +24,7 @@ links:
|
||||||
url: https://towers.schmelczer.dev
|
url: https://towers.schmelczer.dev
|
||||||
media:
|
media:
|
||||||
- type: image
|
- type: image
|
||||||
src: ./_assets/towers.png
|
src: ./_assets/towers.jpg
|
||||||
alt: Screenshot of a life tracking web interface represented with tower-like visual structures.
|
alt: Screenshot of a life tracking web interface represented with tower-like visual structures.
|
||||||
caption: The visual idea was simple; the useful lesson was the synchronization model behind it.
|
caption: The visual idea was simple; the useful lesson was the synchronization model behind it.
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ description: 'My first proper project: a 3D game with random maps, destructible
|
||||||
date: 2026-04-28
|
date: 2026-04-28
|
||||||
projectPeriod: 'Autumn 2017'
|
projectPeriod: 'Autumn 2017'
|
||||||
thumbnail:
|
thumbnail:
|
||||||
src: ./_assets/platform-game.png
|
src: ./_assets/platform-game.jpg
|
||||||
alt: Screenshot from a 3D platform game written in C.
|
alt: Screenshot from a 3D platform game written in C.
|
||||||
tags: ['games', 'systems']
|
tags: ['games', 'systems']
|
||||||
selected: false
|
selected: false
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ description: How SDF-2D used signed distance fields, dynamic shaders, and tile-b
|
||||||
date: 2026-05-08
|
date: 2026-05-08
|
||||||
projectPeriod: 'Autumn-Winter 2020'
|
projectPeriod: 'Autumn-Winter 2020'
|
||||||
thumbnail:
|
thumbnail:
|
||||||
src: ./_assets/sdf2d.png
|
src: ./_assets/sdf2d.jpg
|
||||||
alt: SDF-2D browser demo with soft lighting effects.
|
alt: SDF-2D browser demo with soft lighting effects.
|
||||||
tags: ['graphics', 'web', 'systems']
|
tags: ['graphics', 'web', 'systems']
|
||||||
selected: true
|
selected: true
|
||||||
|
|
@ -31,7 +31,7 @@ links:
|
||||||
download: true
|
download: true
|
||||||
media:
|
media:
|
||||||
- type: image
|
- type: image
|
||||||
src: ./_assets/sdf2d.png
|
src: ./_assets/sdf2d.jpg
|
||||||
alt: Browser demo page showing SDF-2D scenes rendered with soft lighting effects.
|
alt: Browser demo page showing SDF-2D scenes rendered with soft lighting effects.
|
||||||
caption: SDF-2D was built as a reusable TypeScript library rather than a single demo.
|
caption: SDF-2D was built as a reusable TypeScript library rather than a single demo.
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 127 KiB |
BIN
src/content/projects/_assets/declared.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 296 KiB After Width: | Height: | Size: 243 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 88 KiB |
BIN
src/content/projects/_assets/platform-game.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 57 KiB |
BIN
src/content/projects/_assets/sdf2d.jpg
Normal file
|
After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 143 KiB |
BIN
src/content/projects/_assets/towers.jpg
Normal file
|
After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
|
@ -3,7 +3,7 @@ sourceProjectId: declared
|
||||||
title: decla.red
|
title: decla.red
|
||||||
description: A team-based mobile multiplayer browser game with shared client/server game logic.
|
description: A team-based mobile multiplayer browser game with shared client/server game logic.
|
||||||
thumbnail:
|
thumbnail:
|
||||||
src: ./_assets/declared.png
|
src: ./_assets/declared.jpg
|
||||||
alt: The decla.red browser game interface showing a space scene.
|
alt: The decla.red browser game interface showing a space scene.
|
||||||
period: 'Autumn-Winter 2020'
|
period: 'Autumn-Winter 2020'
|
||||||
sortDate: 2020-11-01
|
sortDate: 2020-11-01
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ sourceProjectId: platform-game
|
||||||
title: Platform Game
|
title: Platform Game
|
||||||
description: An early 3D game in C with SDL 1.2, random maps, destructible voxels, enemies, powerups, and time slowdown.
|
description: An early 3D game in C with SDL 1.2, random maps, destructible voxels, enemies, powerups, and time slowdown.
|
||||||
thumbnail:
|
thumbnail:
|
||||||
src: ./_assets/platform-game.png
|
src: ./_assets/platform-game.jpg
|
||||||
alt: Screenshot from an early 3D platform game.
|
alt: Screenshot from an early 3D platform game.
|
||||||
period: 'Autumn 2017'
|
period: 'Autumn 2017'
|
||||||
sortDate: 2017-10-01
|
sortDate: 2017-10-01
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ sourceProjectId: sdf2d
|
||||||
title: SDF-2D
|
title: SDF-2D
|
||||||
description: A browser rendering library for optimized 2D ray tracing with signed distance fields.
|
description: A browser rendering library for optimized 2D ray tracing with signed distance fields.
|
||||||
thumbnail:
|
thumbnail:
|
||||||
src: ./_assets/sdf2d.png
|
src: ./_assets/sdf2d.jpg
|
||||||
alt: SDF-2D browser demo with soft lighting effects.
|
alt: SDF-2D browser demo with soft lighting effects.
|
||||||
period: 'Autumn-Winter 2020'
|
period: 'Autumn-Winter 2020'
|
||||||
sortDate: 2020-12-01
|
sortDate: 2020-12-01
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ sourceProjectId: towers
|
||||||
title: Life Towers
|
title: Life Towers
|
||||||
description: A multi-device goal tracker where the lasting idea was syncing state with immutable tries.
|
description: A multi-device goal tracker where the lasting idea was syncing state with immutable tries.
|
||||||
thumbnail:
|
thumbnail:
|
||||||
src: ./_assets/towers.png
|
src: ./_assets/towers.jpg
|
||||||
alt: Life Towers goal tracking interface with tower-like visual structures.
|
alt: Life Towers goal tracking interface with tower-like visual structures.
|
||||||
period: 'August-September 2019'
|
period: 'August-September 2019'
|
||||||
sortDate: 2019-09-01
|
sortDate: 2019-09-01
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
---
|
---
|
||||||
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, optimizeOgImage, site } from '../lib/site';
|
||||||
import defaultOg from '../assets/og-default.jpg';
|
import defaultOg from '../assets/og-default.jpg';
|
||||||
|
import themeInit from '../scripts/theme-init.ts?raw';
|
||||||
import '../styles/global.css';
|
import '../styles/global.css';
|
||||||
|
|
||||||
interface ArticleMeta {
|
interface ArticleMeta {
|
||||||
|
|
@ -32,7 +32,7 @@ const {
|
||||||
description = site.description,
|
description = site.description,
|
||||||
canonicalPath = Astro.url.pathname,
|
canonicalPath = Astro.url.pathname,
|
||||||
ogImage,
|
ogImage,
|
||||||
ogImageAlt = site.description,
|
ogImageAlt = "Andras Schmelczer's personal site",
|
||||||
ogImageWidth,
|
ogImageWidth,
|
||||||
ogImageHeight,
|
ogImageHeight,
|
||||||
ogType = 'website',
|
ogType = 'website',
|
||||||
|
|
@ -52,12 +52,7 @@ let resolvedOgWidth = ogImageWidth;
|
||||||
let resolvedOgHeight = ogImageHeight;
|
let resolvedOgHeight = ogImageHeight;
|
||||||
|
|
||||||
if (!resolvedOgImage) {
|
if (!resolvedOgImage) {
|
||||||
const generated = await getImage({
|
const generated = await optimizeOgImage(defaultOg);
|
||||||
src: defaultOg,
|
|
||||||
width: 1200,
|
|
||||||
height: 630,
|
|
||||||
format: 'jpg',
|
|
||||||
});
|
|
||||||
resolvedOgImage = generated.src;
|
resolvedOgImage = generated.src;
|
||||||
resolvedOgWidth = 1200;
|
resolvedOgWidth = 1200;
|
||||||
resolvedOgHeight = 630;
|
resolvedOgHeight = 630;
|
||||||
|
|
@ -86,30 +81,7 @@ const jsonLdEntries = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
|
||||||
{noindex && <meta name="robots" content="noindex,follow" />}
|
{noindex && <meta name="robots" content="noindex,follow" />}
|
||||||
<link rel="canonical" href={canonical} />
|
<link rel="canonical" href={canonical} />
|
||||||
|
|
||||||
<script is:inline data-theme-script>
|
<script is:inline data-theme-script set:html={themeInit} />
|
||||||
(() => {
|
|
||||||
const key = 'theme';
|
|
||||||
const legacyKey = 'dark-mode';
|
|
||||||
let saved = null;
|
|
||||||
try {
|
|
||||||
const value = localStorage.getItem(key);
|
|
||||||
if (value === 'light' || value === 'dark') {
|
|
||||||
saved = value;
|
|
||||||
} else {
|
|
||||||
const legacyValue = localStorage.getItem(legacyKey);
|
|
||||||
if (legacyValue !== null) {
|
|
||||||
saved = JSON.parse(legacyValue) ? 'dark' : 'light';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
saved = null;
|
|
||||||
}
|
|
||||||
const systemDark = matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
const theme = saved || (systemDark ? 'dark' : 'light');
|
|
||||||
document.documentElement.dataset.theme = theme;
|
|
||||||
document.documentElement.style.colorScheme = theme;
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<link
|
<link
|
||||||
rel="preload"
|
rel="preload"
|
||||||
|
|
@ -160,7 +132,7 @@ const jsonLdEntries = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
<meta property="og:type" content={ogType} />
|
<meta property="og:type" content={ogType} />
|
||||||
<meta property="og:locale" content="en_US" />
|
<meta property="og:locale" content="en" />
|
||||||
|
|
||||||
{
|
{
|
||||||
article && (
|
article && (
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,18 @@
|
||||||
---
|
---
|
||||||
|
import type { ComponentProps } from 'astro/types';
|
||||||
import Base from './Base.astro';
|
import Base from './Base.astro';
|
||||||
|
|
||||||
interface Props {
|
type Props = ComponentProps<typeof Base>;
|
||||||
title: 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 {...Astro.props}>
|
<Base {...Astro.props}>
|
||||||
<section class="page-shell">
|
<div class="page-shell">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<h1>{title}</h1>
|
<h1>{title}</h1>
|
||||||
<p>{description}</p>
|
<p>{description}</p>
|
||||||
</header>
|
</header>
|
||||||
<slot />
|
<slot />
|
||||||
</section>
|
</div>
|
||||||
</Base>
|
</Base>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
---
|
---
|
||||||
import type { CollectionEntry } from 'astro:content';
|
import type { CollectionEntry } from 'astro:content';
|
||||||
import { render } from 'astro:content';
|
import { render } from 'astro:content';
|
||||||
import { Picture, getImage } from 'astro:assets';
|
import { Picture } from 'astro:assets';
|
||||||
import ArticleList from '../components/ArticleList.astro';
|
import ArticleList from '../components/ArticleList.astro';
|
||||||
import AtAGlance from '../components/AtAGlance.astro';
|
import AtAGlance from '../components/AtAGlance.astro';
|
||||||
import Breadcrumbs from '../components/Breadcrumbs.astro';
|
import Breadcrumbs from '../components/Breadcrumbs.astro';
|
||||||
|
|
@ -11,10 +11,11 @@ import {
|
||||||
absoluteUrl,
|
absoluteUrl,
|
||||||
adjacentPosts,
|
adjacentPosts,
|
||||||
articlePath,
|
articlePath,
|
||||||
|
buildBreadcrumbTrail,
|
||||||
formatDate,
|
formatDate,
|
||||||
getPublishedPosts,
|
getPublishedPosts,
|
||||||
getRelatedPosts,
|
getRelatedPosts,
|
||||||
site,
|
optimizeOgImage,
|
||||||
} from '../lib/site';
|
} from '../lib/site';
|
||||||
import Base from './Base.astro';
|
import Base from './Base.astro';
|
||||||
|
|
||||||
|
|
@ -23,24 +24,29 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { post } = Astro.props;
|
const { post } = Astro.props;
|
||||||
const { Content } = await render(post);
|
const { Content, headings } = await render(post);
|
||||||
|
|
||||||
const allPosts = await getPublishedPosts();
|
const allPosts = await getPublishedPosts();
|
||||||
const { previous, next } = adjacentPosts(allPosts, post);
|
const { previous, next } = adjacentPosts(allPosts, post);
|
||||||
const related = getRelatedPosts(allPosts, post, 3);
|
const related = getRelatedPosts(allPosts, post, 3);
|
||||||
|
|
||||||
const ogImageOptimized = await getImage({
|
const ogImageOptimized = await optimizeOgImage(post.data.thumbnail.src);
|
||||||
src: post.data.thumbnail.src,
|
|
||||||
width: 1200,
|
|
||||||
height: 630,
|
|
||||||
format: 'jpg',
|
|
||||||
});
|
|
||||||
|
|
||||||
const breadcrumbTrail = [
|
const trail = buildBreadcrumbTrail({ post });
|
||||||
{ href: '/', label: 'Home' },
|
const breadcrumbTrail = trail.map((c, i) => ({
|
||||||
{ href: '/articles/', label: 'Articles' },
|
label: c.name,
|
||||||
{ label: post.data.title },
|
href: i === trail.length - 1 ? undefined : c.href,
|
||||||
];
|
}));
|
||||||
|
|
||||||
|
// Reading time: words in body / 200 wpm, rounded up.
|
||||||
|
const wordCount = post.body ? post.body.trim().split(/\s+/).filter(Boolean).length : 0;
|
||||||
|
const readingMinutes = Math.max(1, Math.ceil(wordCount / 200));
|
||||||
|
|
||||||
|
// TOC: only show when there are >= 3 h2 headings.
|
||||||
|
const h2Headings = headings.filter((h) => h.depth === 2);
|
||||||
|
const showToc = h2Headings.length >= 3;
|
||||||
|
|
||||||
|
const personId = absoluteUrl('/about/#person');
|
||||||
|
|
||||||
const blogPosting = {
|
const blogPosting = {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
|
|
@ -49,16 +55,8 @@ const blogPosting = {
|
||||||
description: post.data.description,
|
description: post.data.description,
|
||||||
datePublished: post.data.date.toISOString(),
|
datePublished: post.data.date.toISOString(),
|
||||||
...(post.data.updated && { dateModified: post.data.updated.toISOString() }),
|
...(post.data.updated && { dateModified: post.data.updated.toISOString() }),
|
||||||
author: {
|
author: { '@id': personId },
|
||||||
'@type': 'Person',
|
publisher: { '@id': personId },
|
||||||
name: site.name,
|
|
||||||
url: absoluteUrl('/about/'),
|
|
||||||
},
|
|
||||||
publisher: {
|
|
||||||
'@type': 'Person',
|
|
||||||
name: site.name,
|
|
||||||
url: site.url,
|
|
||||||
},
|
|
||||||
image: absoluteUrl(ogImageOptimized.src),
|
image: absoluteUrl(ogImageOptimized.src),
|
||||||
url: absoluteUrl(articlePath(post)),
|
url: absoluteUrl(articlePath(post)),
|
||||||
keywords: post.data.tags.join(', '),
|
keywords: post.data.tags.join(', '),
|
||||||
|
|
@ -71,21 +69,12 @@ const blogPosting = {
|
||||||
const breadcrumbJsonLd = {
|
const breadcrumbJsonLd = {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'BreadcrumbList',
|
'@type': 'BreadcrumbList',
|
||||||
itemListElement: [
|
itemListElement: trail.map((c, i) => ({
|
||||||
{ '@type': 'ListItem', position: 1, name: 'Home', item: absoluteUrl('/') },
|
'@type': 'ListItem',
|
||||||
{
|
position: i + 1,
|
||||||
'@type': 'ListItem',
|
name: c.name,
|
||||||
position: 2,
|
item: absoluteUrl(c.href),
|
||||||
name: 'Articles',
|
})),
|
||||||
item: absoluteUrl('/articles/'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'@type': 'ListItem',
|
|
||||||
position: 3,
|
|
||||||
name: post.data.title,
|
|
||||||
item: absoluteUrl(articlePath(post)),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -112,7 +101,7 @@ const breadcrumbJsonLd = {
|
||||||
<p class="eyebrow">{post.data.projectPeriod ?? 'Article'}</p>
|
<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">
|
<div class="post-meta">
|
||||||
<time datetime={post.data.date.toISOString()}>
|
<time datetime={post.data.date.toISOString()}>
|
||||||
{formatDate(post.data.date)}
|
{formatDate(post.data.date)}
|
||||||
</time>
|
</time>
|
||||||
|
|
@ -129,7 +118,9 @@ const breadcrumbJsonLd = {
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</p>
|
{' · '}
|
||||||
|
<span>{readingMinutes} min read</span>
|
||||||
|
</div>
|
||||||
<TagList tags={post.data.tags} />
|
<TagList tags={post.data.tags} />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -147,11 +138,8 @@ const breadcrumbJsonLd = {
|
||||||
/>
|
/>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
<div class="prose">
|
|
||||||
<Content />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AtAGlance
|
<AtAGlance
|
||||||
|
headingId={`at-a-glance-${post.id}`}
|
||||||
role={post.data.role}
|
role={post.data.role}
|
||||||
projectPeriod={post.data.projectPeriod}
|
projectPeriod={post.data.projectPeriod}
|
||||||
stack={post.data.stack}
|
stack={post.data.stack}
|
||||||
|
|
@ -160,6 +148,24 @@ const breadcrumbJsonLd = {
|
||||||
links={post.data.links}
|
links={post.data.links}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{
|
||||||
|
showToc && (
|
||||||
|
<nav class="post-toc" aria-label="On this page">
|
||||||
|
<ol>
|
||||||
|
{h2Headings.map((heading) => (
|
||||||
|
<li>
|
||||||
|
<a href={`#${heading.slug}`}>{heading.text}</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="prose">
|
||||||
|
<Content />
|
||||||
|
</div>
|
||||||
|
|
||||||
<PostMedia items={post.data.media} />
|
<PostMedia items={post.data.media} />
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
@ -174,22 +180,28 @@ const breadcrumbJsonLd = {
|
||||||
{
|
{
|
||||||
(previous || next) && (
|
(previous || next) && (
|
||||||
<nav class="post-nav" aria-label="Adjacent articles">
|
<nav class="post-nav" aria-label="Adjacent articles">
|
||||||
{previous && (
|
<ul class="post-nav__list">
|
||||||
<a class="previous" href={articlePath(previous)} rel="prev">
|
{previous && (
|
||||||
<span class="post-nav__label">
|
<li class="post-nav__prev">
|
||||||
<span aria-hidden="true">←</span> Previous
|
<a class="previous" href={articlePath(previous)} rel="prev">
|
||||||
</span>
|
<span class="post-nav__label">
|
||||||
<span class="post-nav__title">{previous.data.title}</span>
|
<span aria-hidden="true">←</span> Previous
|
||||||
</a>
|
</span>
|
||||||
)}
|
<span class="post-nav__title">{previous.data.title}</span>
|
||||||
{next && (
|
</a>
|
||||||
<a class="next" href={articlePath(next)} rel="next">
|
</li>
|
||||||
<span class="post-nav__label">
|
)}
|
||||||
Next <span aria-hidden="true">→</span>
|
{next && (
|
||||||
</span>
|
<li class="post-nav__next">
|
||||||
<span class="post-nav__title">{next.data.title}</span>
|
<a class="next" href={articlePath(next)} rel="next">
|
||||||
</a>
|
<span class="post-nav__label">
|
||||||
)}
|
Next <span aria-hidden="true">→</span>
|
||||||
|
</span>
|
||||||
|
<span class="post-nav__title">{next.data.title}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
103
src/lib/site.ts
|
|
@ -1,9 +1,11 @@
|
||||||
import type { CollectionEntry } from 'astro:content';
|
import type { CollectionEntry } from 'astro:content';
|
||||||
import { getCollection } from 'astro:content';
|
import { getCollection } from 'astro:content';
|
||||||
|
import { getImage } from 'astro:assets';
|
||||||
|
import type { ImageMetadata } from 'astro';
|
||||||
|
|
||||||
export const site = {
|
export const site = {
|
||||||
name: 'Andras Schmelczer',
|
name: 'Andras Schmelczer',
|
||||||
title: 'Andras Schmelczer — Software systems, AI, graphics, simulations, tools',
|
title: 'Andras Schmelczer — Software engineer',
|
||||||
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',
|
||||||
|
|
@ -13,12 +15,22 @@ export const site = {
|
||||||
cv: '/media/downloads/cv-andras-schmelczer.pdf',
|
cv: '/media/downloads/cv-andras-schmelczer.pdf',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Single source of truth for primary navigation. The Header consumes every
|
||||||
|
// entry where `footerOnly` is falsy AND `href !== '/'` (Home is implicit via
|
||||||
|
// the site title). The Footer renders every entry regardless. Items marked
|
||||||
|
// `footerOnly: true` appear only in the Footer.
|
||||||
export const navItems = [
|
export const navItems = [
|
||||||
{ href: '/', label: 'Home' },
|
{ href: '/', label: 'Home' },
|
||||||
{ href: '/articles/', label: 'Articles' },
|
{ href: '/articles/', label: 'Articles' },
|
||||||
{ href: '/projects/', label: 'Projects' },
|
{ href: '/projects/', label: 'Projects' },
|
||||||
{ href: '/about/', label: 'About' },
|
{ href: '/about/', label: 'About' },
|
||||||
] as const;
|
{ href: '/tags/', label: 'Tags', footerOnly: false },
|
||||||
|
{ href: '/rss.xml', label: 'RSS', footerOnly: true },
|
||||||
|
] as const satisfies ReadonlyArray<{
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
footerOnly?: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
export function formatDate(date: Date) {
|
export function formatDate(date: Date) {
|
||||||
return new Intl.DateTimeFormat('en', {
|
return new Intl.DateTimeFormat('en', {
|
||||||
|
|
@ -59,8 +71,11 @@ export function tagPath(tag: string) {
|
||||||
return `/tags/${tagSlug(tag)}/`;
|
return `/tags/${tagSlug(tag)}/`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function projectAnchor(project: CollectionEntry<'projects'>) {
|
// Anchor used for `id="..."` on project cards and `#fragment` deep links.
|
||||||
return project.data.legacyAnchor ?? project.data.sourceProjectId;
|
// Always derived from the canonical `sourceProjectId` slug now that the
|
||||||
|
// legacy anchor mapping has been dropped.
|
||||||
|
export function projectAnchor(slugOrEntry: string | CollectionEntry<'projects'>) {
|
||||||
|
return typeof slugOrEntry === 'string' ? slugOrEntry : slugOrEntry.data.sourceProjectId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAllTags(posts: { data: { tags: readonly string[] } }[]) {
|
export function getAllTags(posts: { data: { tags: readonly string[] } }[]) {
|
||||||
|
|
@ -69,10 +84,20 @@ export function getAllTags(posts: { data: { tags: readonly string[] } }[]) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPublishedPosts(): Promise<CollectionEntry<'posts'>[]> {
|
// Memoized published-posts loader. Build steps call `getPublishedPosts()`
|
||||||
return (await getCollection('posts'))
|
// from many pages (index, articles, RSS, sitemap, tag pages, post layouts).
|
||||||
.filter((post) => !post.data.draft)
|
// Caching the promise means `getCollection('posts')` runs once per build.
|
||||||
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
|
let publishedPostsPromise: Promise<CollectionEntry<'posts'>[]> | undefined;
|
||||||
|
|
||||||
|
export function getPublishedPosts(): Promise<CollectionEntry<'posts'>[]> {
|
||||||
|
if (!publishedPostsPromise) {
|
||||||
|
publishedPostsPromise = getCollection('posts').then((posts) =>
|
||||||
|
posts
|
||||||
|
.filter((post) => !post.data.draft)
|
||||||
|
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return publishedPostsPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProjects(): Promise<CollectionEntry<'projects'>[]> {
|
export async function getProjects(): Promise<CollectionEntry<'projects'>[]> {
|
||||||
|
|
@ -114,3 +139,65 @@ export function getRelatedPosts(
|
||||||
export function absoluteUrl(path: string) {
|
export function absoluteUrl(path: string) {
|
||||||
return new URL(path, site.url).toString();
|
return new URL(path, site.url).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Canonical Person JSON-LD. Used by the home page and About page; both share
|
||||||
|
// `@id` so search engines treat them as the same entity. Pass `extra` to
|
||||||
|
// add or override fields (e.g. `jobTitle`, richer `description`).
|
||||||
|
export function buildPersonJsonLd(extra?: Record<string, unknown>) {
|
||||||
|
return {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Person',
|
||||||
|
'@id': absoluteUrl('/about/#person'),
|
||||||
|
name: site.name,
|
||||||
|
url: site.url,
|
||||||
|
email: `mailto:${site.email}`,
|
||||||
|
sameAs: [site.github, site.linkedin],
|
||||||
|
description: site.description,
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wraps `getImage` with the standard OG dimensions (1200x630 JPEG). Used by
|
||||||
|
// Base.astro for the default OG image and by Post.astro for per-post
|
||||||
|
// thumbnails. Keeps OG output consistent across the site.
|
||||||
|
export function optimizeOgImage(src: ImageMetadata) {
|
||||||
|
return getImage({
|
||||||
|
src,
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
format: 'jpg',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BreadcrumbCrumb {
|
||||||
|
name: string;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BreadcrumbInput {
|
||||||
|
articles?: boolean;
|
||||||
|
tag?: string;
|
||||||
|
post?: CollectionEntry<'posts'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds the breadcrumb trail shared by JSON-LD (BreadcrumbList) and the
|
||||||
|
// visible Breadcrumbs component. Home is always first. Pass `articles: true`
|
||||||
|
// to include the /articles/ crumb; pass a `tag` to append a tag crumb; pass
|
||||||
|
// a `post` to append the post title (linking to its article path).
|
||||||
|
export function buildBreadcrumbTrail({
|
||||||
|
articles,
|
||||||
|
tag,
|
||||||
|
post,
|
||||||
|
}: BreadcrumbInput): BreadcrumbCrumb[] {
|
||||||
|
const trail: BreadcrumbCrumb[] = [{ name: 'Home', href: '/' }];
|
||||||
|
if (articles || post) {
|
||||||
|
trail.push({ name: 'Articles', href: '/articles/' });
|
||||||
|
}
|
||||||
|
if (tag) {
|
||||||
|
trail.push({ name: tag, href: tagPath(tag) });
|
||||||
|
}
|
||||||
|
if (post) {
|
||||||
|
trail.push({ name: post.data.title, href: articlePath(post) });
|
||||||
|
}
|
||||||
|
return trail;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,17 +13,15 @@ const recent = posts.slice(0, 5);
|
||||||
noindex
|
noindex
|
||||||
>
|
>
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="prose">
|
<p>
|
||||||
<p>
|
Try the <a href="/articles/">articles archive</a>, the
|
||||||
Try the <a href="/articles/">articles archive</a>, the
|
<a href="/projects/">project index</a>, the
|
||||||
<a href="/projects/">project index</a>, the
|
<a href="/tags/">tag index</a>, or head back to the
|
||||||
<a href="/tags/">tag index</a>, or head back to the
|
<a href="/">homepage</a>.
|
||||||
<a href="/">homepage</a>.
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="home-section" aria-labelledby="404-recent">
|
<section class="home-section">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<h2 id="404-recent">Recent articles</h2>
|
<h2 id="404-recent">Recent articles</h2>
|
||||||
<a href="/articles/">All articles →</a>
|
<a href="/articles/">All articles →</a>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
---
|
---
|
||||||
import ArticleList from '../components/ArticleList.astro';
|
import ArticleList from '../components/ArticleList.astro';
|
||||||
import Page from '../layouts/Page.astro';
|
import Page from '../layouts/Page.astro';
|
||||||
import { getPublishedPosts, site } from '../lib/site';
|
import { absoluteUrl, buildPersonJsonLd, getPublishedPosts, site } from '../lib/site';
|
||||||
|
|
||||||
const posts = await getPublishedPosts();
|
const posts = await getPublishedPosts();
|
||||||
const startingPoints = posts
|
const startingPoints = posts
|
||||||
|
|
@ -9,17 +9,22 @@ const startingPoints = posts
|
||||||
.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 = {
|
// Canonical Person JSON-LD. Other pages reference this entity by @id.
|
||||||
'@context': 'https://schema.org',
|
const personJsonLd = buildPersonJsonLd({
|
||||||
'@type': 'Person',
|
|
||||||
name: site.name,
|
|
||||||
url: site.url,
|
|
||||||
email: `mailto:${site.email}`,
|
|
||||||
sameAs: [site.github, site.linkedin],
|
|
||||||
jobTitle: 'Software Engineer',
|
jobTitle: 'Software Engineer',
|
||||||
description:
|
description:
|
||||||
'Software engineer with an MSc in Computer Science working on AI/ML systems, web platforms, graphics, simulations, and tools.',
|
'Software engineer with an MSc in Computer Science working on AI/ML systems, web platforms, graphics, simulations, and tools.',
|
||||||
};
|
knowsAbout: [
|
||||||
|
'Software architecture',
|
||||||
|
'AI/ML systems',
|
||||||
|
'Web platforms',
|
||||||
|
'Computer graphics',
|
||||||
|
'Simulations',
|
||||||
|
'Data visualization',
|
||||||
|
],
|
||||||
|
image: absoluteUrl('/og-image.jpg'),
|
||||||
|
mainEntityOfPage: absoluteUrl('/about/'),
|
||||||
|
});
|
||||||
---
|
---
|
||||||
|
|
||||||
<Page
|
<Page
|
||||||
|
|
@ -45,25 +50,37 @@ const personJsonLd = {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="about-section facts" aria-labelledby="quick-facts">
|
<section class="about-section facts">
|
||||||
<h2 id="quick-facts">Quick Facts</h2>
|
<h2 id="quick-facts">Quick Facts</h2>
|
||||||
<dl>
|
<address>
|
||||||
<dt>Focus</dt>
|
<dl>
|
||||||
<dd>Software systems, AI deployment, architecture, graphics, data visualization</dd>
|
<div>
|
||||||
<dt>Education</dt>
|
<dt>Focus</dt>
|
||||||
<dd>MSc in Computer Science</dd>
|
<dd>
|
||||||
<dt>Contact</dt>
|
Software systems, AI deployment, architecture, graphics, data visualization
|
||||||
<dd><a href={`mailto:${site.email}`}>{site.email}</a></dd>
|
</dd>
|
||||||
<dt>Links</dt>
|
</div>
|
||||||
<dd>
|
<div>
|
||||||
<a href={site.cv} rel="noopener">CV</a>,
|
<dt>Education</dt>
|
||||||
<a href={site.github} rel="noopener me">GitHub</a>,
|
<dd>MSc in Computer Science</dd>
|
||||||
<a href={site.linkedin} rel="noopener me">LinkedIn</a>
|
</div>
|
||||||
</dd>
|
<div>
|
||||||
</dl>
|
<dt>Contact</dt>
|
||||||
|
<dd><a href={`mailto:${site.email}`}>{site.email}</a></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Links</dt>
|
||||||
|
<dd>
|
||||||
|
<a href={site.cv} rel="noopener">CV</a>,
|
||||||
|
<a href={site.github} rel="noopener me">GitHub</a>,
|
||||||
|
<a href={site.linkedin} rel="noopener me">LinkedIn</a>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</address>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="about-section" aria-labelledby="best-starting-points">
|
<section class="about-section">
|
||||||
<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/">Browse all articles →</a>
|
<a href="/articles/">Browse all articles →</a>
|
||||||
|
|
@ -71,7 +88,7 @@ const personJsonLd = {
|
||||||
<ArticleList posts={startingPoints} />
|
<ArticleList posts={startingPoints} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="about-section facts" aria-labelledby="working-style">
|
<section class="about-section facts">
|
||||||
<h2 id="working-style">How I Work</h2>
|
<h2 id="working-style">How I Work</h2>
|
||||||
<div class="prose">
|
<div class="prose">
|
||||||
<p>
|
<p>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import Page from '../../layouts/Page.astro';
|
||||||
import {
|
import {
|
||||||
absoluteUrl,
|
absoluteUrl,
|
||||||
articlePath,
|
articlePath,
|
||||||
|
buildBreadcrumbTrail,
|
||||||
getAllTags,
|
getAllTags,
|
||||||
getPublishedPosts,
|
getPublishedPosts,
|
||||||
site,
|
site,
|
||||||
|
|
@ -30,12 +31,26 @@ const blogJsonLd = {
|
||||||
url: absoluteUrl(articlePath(post)),
|
url: absoluteUrl(articlePath(post)),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const breadcrumbTrail = buildBreadcrumbTrail({ articles: true });
|
||||||
|
const breadcrumbJsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'BreadcrumbList',
|
||||||
|
itemListElement: breadcrumbTrail.map((crumb, index) => ({
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: index + 1,
|
||||||
|
name: crumb.name,
|
||||||
|
item: absoluteUrl(crumb.href),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const jsonLd = [blogJsonLd, breadcrumbJsonLd];
|
||||||
---
|
---
|
||||||
|
|
||||||
<Page
|
<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}
|
jsonLd={jsonLd}
|
||||||
>
|
>
|
||||||
<nav id="tags" class="tag-filter" aria-label="Browse by tag">
|
<nav id="tags" class="tag-filter" aria-label="Browse by tag">
|
||||||
<span>Browse by tag</span>
|
<span>Browse by tag</span>
|
||||||
|
|
@ -46,7 +61,7 @@ const blogJsonLd = {
|
||||||
years.map((year) => {
|
years.map((year) => {
|
||||||
const postsForYear = posts.filter((post) => yearOf(post.data.date) === year);
|
const postsForYear = posts.filter((post) => yearOf(post.data.date) === year);
|
||||||
return (
|
return (
|
||||||
<section class="archive-year" aria-labelledby={`year-${year}`}>
|
<section class="archive-year">
|
||||||
<h2 id={`year-${year}`}>{year}</h2>
|
<h2 id={`year-${year}`}>{year}</h2>
|
||||||
<ArticleList posts={postsForYear} showYear={false} />
|
<ArticleList posts={postsForYear} showYear={false} />
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,12 @@ 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 TagList from '../components/TagList.astro';
|
||||||
import Base from '../layouts/Base.astro';
|
import Base from '../layouts/Base.astro';
|
||||||
import { getAllTags, getProjects, getPublishedPosts, site } from '../lib/site';
|
import {
|
||||||
|
buildPersonJsonLd,
|
||||||
|
getAllTags,
|
||||||
|
getProjects,
|
||||||
|
getPublishedPosts,
|
||||||
|
} from '../lib/site';
|
||||||
|
|
||||||
const posts = await getPublishedPosts();
|
const posts = await getPublishedPosts();
|
||||||
const latestPosts = posts.slice(0, 5);
|
const latestPosts = posts.slice(0, 5);
|
||||||
|
|
@ -11,15 +16,8 @@ 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 tags = getAllTags(posts);
|
||||||
|
|
||||||
const personJsonLd = {
|
// Reference the canonical Person (defined on /about/) by @id.
|
||||||
'@context': 'https://schema.org',
|
const personJsonLd = buildPersonJsonLd();
|
||||||
'@type': 'Person',
|
|
||||||
name: site.name,
|
|
||||||
url: site.url,
|
|
||||||
email: `mailto:${site.email}`,
|
|
||||||
sameAs: [site.github, site.linkedin],
|
|
||||||
description: site.description,
|
|
||||||
};
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<Base jsonLd={personJsonLd}>
|
<Base jsonLd={personJsonLd}>
|
||||||
|
|
@ -28,8 +26,8 @@ const personJsonLd = {
|
||||||
Software systems, AI deployment, graphics, simulations, and tools
|
Software systems, AI deployment, graphics, simulations, and tools
|
||||||
</p>
|
</p>
|
||||||
<h1>
|
<h1>
|
||||||
<span class="home-name-accent">Andras Schmelczer</span> writes about building software
|
Andras Schmelczer writes about building software that has to work under real
|
||||||
that has to work under real constraints.
|
constraints.
|
||||||
</h1>
|
</h1>
|
||||||
<p>
|
<p>
|
||||||
I am a software engineer with an MSc in Computer Science. This site is mostly a
|
I am a software engineer with an MSc in Computer Science. This site is mostly a
|
||||||
|
|
@ -38,7 +36,7 @@ const personJsonLd = {
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="home-section" aria-labelledby="latest-articles">
|
<section class="home-section">
|
||||||
<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 {posts.length} articles →</a>
|
<a href="/articles/">All {posts.length} articles →</a>
|
||||||
|
|
@ -46,15 +44,15 @@ const personJsonLd = {
|
||||||
<ArticleList posts={latestPosts} />
|
<ArticleList posts={latestPosts} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="home-section" aria-labelledby="selected-projects">
|
<section class="home-section">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<h2 id="selected-projects">Selected Projects</h2>
|
<h2 id="home-selected-projects">Selected Projects</h2>
|
||||||
<a href="/projects/">All projects →</a>
|
<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">
|
<section class="home-section">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<h2 id="browse-by-topic">Browse by Topic</h2>
|
<h2 id="browse-by-topic">Browse by Topic</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
---
|
---
|
||||||
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 { absoluteUrl, getProjects, site } from '../../lib/site';
|
import { absoluteUrl, buildBreadcrumbTrail, 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 = {
|
const collectionJsonLd = {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'CollectionPage',
|
'@type': 'CollectionPage',
|
||||||
name: `${site.name} — Projects`,
|
name: `${site.name} — Projects`,
|
||||||
|
|
@ -15,6 +15,23 @@ const jsonLd = {
|
||||||
description:
|
description:
|
||||||
'A compact index of systems, tools, simulations, graphics experiments, games, and older work.',
|
'A compact index of systems, tools, simulations, graphics experiments, games, and older work.',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const breadcrumbTrail = [
|
||||||
|
...buildBreadcrumbTrail({}),
|
||||||
|
{ name: 'Projects', href: '/projects/' },
|
||||||
|
];
|
||||||
|
const breadcrumbJsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'BreadcrumbList',
|
||||||
|
itemListElement: breadcrumbTrail.map((crumb, index) => ({
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: index + 1,
|
||||||
|
name: crumb.name,
|
||||||
|
item: absoluteUrl(crumb.href),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const jsonLd = [collectionJsonLd, breadcrumbJsonLd];
|
||||||
---
|
---
|
||||||
|
|
||||||
<Page
|
<Page
|
||||||
|
|
@ -22,12 +39,12 @@ const jsonLd = {
|
||||||
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}
|
jsonLd={jsonLd}
|
||||||
>
|
>
|
||||||
<section class="project-section" aria-labelledby="selected-projects">
|
<section class="project-section">
|
||||||
<h2 id="selected-projects">Selected Projects</h2>
|
<h2 id="selected-projects">Selected Projects</h2>
|
||||||
<ProjectList projects={selected} />
|
<ProjectList projects={selected} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="project-section" aria-labelledby="older-projects">
|
<section class="project-section">
|
||||||
<h2 id="older-projects">Older and Smaller Projects</h2>
|
<h2 id="older-projects">Older and Smaller Projects</h2>
|
||||||
<ProjectList projects={older} />
|
<ProjectList projects={older} />
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,37 @@
|
||||||
import rss from '@astrojs/rss';
|
import rss from '@astrojs/rss';
|
||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { absoluteUrl, articlePath, getPublishedPosts, site } from '../lib/site';
|
import ogDefault from '../assets/og-default.jpg';
|
||||||
|
import {
|
||||||
|
absoluteUrl,
|
||||||
|
articlePath,
|
||||||
|
entrySlug,
|
||||||
|
getPublishedPosts,
|
||||||
|
optimizeOgImage,
|
||||||
|
site,
|
||||||
|
} from '../lib/site';
|
||||||
|
|
||||||
|
// Escape characters that would otherwise break XML parsing inside text nodes
|
||||||
|
// (the `customData` strings are inserted as-is by @astrojs/rss).
|
||||||
|
function escapeXml(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format a Date as `YYYY-MM-DD` in UTC for use inside tag: URIs.
|
||||||
|
function isoDate(date: Date) {
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
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');
|
const feedUrl = absoluteUrl('/rss.xml');
|
||||||
|
const channelImage = await optimizeOgImage(ogDefault);
|
||||||
|
const channelImageUrl = absoluteUrl(channelImage.src);
|
||||||
|
const creator = escapeXml(site.name);
|
||||||
|
|
||||||
return rss({
|
return rss({
|
||||||
title: site.name,
|
title: site.name,
|
||||||
|
|
@ -13,14 +40,24 @@ export const GET: APIRoute = async (context) => {
|
||||||
xmlns: {
|
xmlns: {
|
||||||
atom: 'http://www.w3.org/2005/Atom',
|
atom: 'http://www.w3.org/2005/Atom',
|
||||||
content: 'http://purl.org/rss/1.0/modules/content/',
|
content: 'http://purl.org/rss/1.0/modules/content/',
|
||||||
|
dc: 'http://purl.org/dc/elements/1.1/',
|
||||||
},
|
},
|
||||||
customData: [
|
customData: [
|
||||||
'<language>en-us</language>',
|
'<language>en-us</language>',
|
||||||
`<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>`,
|
`<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>`,
|
||||||
`<atom:link href="${feedUrl}" rel="self" type="application/rss+xml" />`,
|
`<atom:link href="${feedUrl}" rel="self" type="application/rss+xml" />`,
|
||||||
|
'<image>',
|
||||||
|
` <url>${channelImageUrl}</url>`,
|
||||||
|
` <title>${escapeXml(site.name)}</title>`,
|
||||||
|
` <link>${site.url}</link>`,
|
||||||
|
'</image>',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
items: posts.map((post) => {
|
items: posts.map((post) => {
|
||||||
const url = absoluteUrl(articlePath(post));
|
const url = absoluteUrl(articlePath(post));
|
||||||
|
// Stable tag: URI keeps the GUID constant across path renames
|
||||||
|
// (e.g. the `/writing/` → `/articles/` migration). The date is the
|
||||||
|
// original publish date so re-publishing won't change the GUID.
|
||||||
|
const guid = `tag:schmelczer.dev,${isoDate(post.data.date)}:posts/${entrySlug(post)}`;
|
||||||
const updated = post.data.updated
|
const updated = post.data.updated
|
||||||
? `<atom:updated>${post.data.updated.toISOString()}</atom:updated>`
|
? `<atom:updated>${post.data.updated.toISOString()}</atom:updated>`
|
||||||
: '';
|
: '';
|
||||||
|
|
@ -31,7 +68,13 @@ export const GET: APIRoute = async (context) => {
|
||||||
link: url,
|
link: url,
|
||||||
author: `${site.email} (${site.name})`,
|
author: `${site.email} (${site.name})`,
|
||||||
categories: [...post.data.tags],
|
categories: [...post.data.tags],
|
||||||
customData: `<guid isPermaLink="true">${url}</guid>${updated}`,
|
customData: [
|
||||||
|
`<guid isPermaLink="false">${escapeXml(guid)}</guid>`,
|
||||||
|
`<dc:creator>${creator}</dc:creator>`,
|
||||||
|
updated,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(''),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ 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) => post.data.tags.some((t) => t === tag));
|
const filteredPosts = posts.filter((post) => post.data.tags.some((t) => t === tag));
|
||||||
const title = `Articles tagged #${tag}`;
|
const title = `Articles tagged "${tag}"`;
|
||||||
const trail = [
|
const trail = [
|
||||||
{ href: '/', label: 'Home' },
|
{ href: '/', label: 'Home' },
|
||||||
{ href: '/articles/', label: 'Articles' },
|
{ href: '/articles/', label: 'Articles' },
|
||||||
|
|
@ -36,5 +36,6 @@ const trail = [
|
||||||
<TagList tags={allTags} currentTag={tag} labelled={false} />
|
<TagList tags={allTags} currentTag={tag} labelled={false} />
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<h2 class="sr-only">Articles</h2>
|
||||||
<ArticleList posts={filteredPosts} currentTag={tag} />
|
<ArticleList posts={filteredPosts} currentTag={tag} />
|
||||||
</Page>
|
</Page>
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,57 @@
|
||||||
---
|
---
|
||||||
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 } from '../../lib/site';
|
import {
|
||||||
|
absoluteUrl,
|
||||||
|
buildBreadcrumbTrail,
|
||||||
|
getAllTags,
|
||||||
|
getPublishedPosts,
|
||||||
|
site,
|
||||||
|
} from '../../lib/site';
|
||||||
|
|
||||||
const posts = await getPublishedPosts();
|
const posts = await getPublishedPosts();
|
||||||
const tags = getAllTags(posts);
|
const tags = getAllTags(posts);
|
||||||
|
|
||||||
const tagCounts = new Map<string, number>();
|
const tagCounts: Record<string, number> = {};
|
||||||
for (const post of posts) {
|
for (const post of posts) {
|
||||||
for (const tag of post.data.tags) {
|
for (const tag of post.data.tags) {
|
||||||
tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
|
tagCounts[tag] = (tagCounts[tag] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const collectionJsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'CollectionPage',
|
||||||
|
name: `${site.name} — Tags`,
|
||||||
|
url: absoluteUrl('/tags/'),
|
||||||
|
description: 'Every tag used across the articles archive.',
|
||||||
|
};
|
||||||
|
|
||||||
|
const breadcrumbTrail = [
|
||||||
|
...buildBreadcrumbTrail({ articles: true }),
|
||||||
|
{ name: 'Tags', href: '/tags/' },
|
||||||
|
];
|
||||||
|
const breadcrumbJsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'BreadcrumbList',
|
||||||
|
itemListElement: breadcrumbTrail.map((crumb, index) => ({
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: index + 1,
|
||||||
|
name: crumb.name,
|
||||||
|
item: absoluteUrl(crumb.href),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const jsonLd = [collectionJsonLd, breadcrumbJsonLd];
|
||||||
---
|
---
|
||||||
|
|
||||||
<Page title="Tags" description="Every tag used across the articles archive.">
|
<Page
|
||||||
|
title="Tags"
|
||||||
|
description="Every tag used across the articles archive."
|
||||||
|
jsonLd={jsonLd}
|
||||||
|
>
|
||||||
<p class="dek">
|
<p class="dek">
|
||||||
{posts.length} articles across {tags.length} tags.
|
{posts.length} articles across {tags.length} tags.
|
||||||
</p>
|
</p>
|
||||||
<TagList tags={tags} />
|
<TagList tags={tags} counts={tagCounts} />
|
||||||
</Page>
|
</Page>
|
||||||
|
|
|
||||||
22
src/scripts/theme-init.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
(() => {
|
||||||
|
const key = 'theme';
|
||||||
|
const legacyKey = 'dark-mode';
|
||||||
|
let saved: 'light' | 'dark' | null = null;
|
||||||
|
try {
|
||||||
|
const value = localStorage.getItem(key);
|
||||||
|
if (value === 'light' || value === 'dark') {
|
||||||
|
saved = value;
|
||||||
|
} else {
|
||||||
|
const legacyValue = localStorage.getItem(legacyKey);
|
||||||
|
if (legacyValue !== null) {
|
||||||
|
saved = JSON.parse(legacyValue) ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
saved = null;
|
||||||
|
}
|
||||||
|
const systemDark = matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
const theme = saved || (systemDark ? 'dark' : 'light');
|
||||||
|
document.documentElement.dataset.theme = theme;
|
||||||
|
document.documentElement.style.colorScheme = theme;
|
||||||
|
})();
|
||||||
|
|
@ -25,27 +25,30 @@
|
||||||
========================================================================= */
|
========================================================================= */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light dark;
|
||||||
|
|
||||||
--font-sans:
|
--font-sans:
|
||||||
'Source Sans 3', Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
'Source Sans 3', Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||||
'Segoe UI', sans-serif;
|
'Segoe UI', sans-serif;
|
||||||
--font-mono: 'IBM Plex Mono', 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
|
--font-mono: 'IBM Plex Mono', 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
|
||||||
|
|
||||||
/* Light palette */
|
/* Palette — light-dark() pairs each token (light, dark) */
|
||||||
--color-bg: #fbfaf7;
|
--color-bg: light-dark(#fbfaf7, #151514);
|
||||||
--color-fg: #181817;
|
--color-fg: light-dark(#181817, #f1eee7);
|
||||||
--color-muted: #5f5c54;
|
--color-muted: light-dark(#4d4b44, #b7afa3);
|
||||||
--color-link: #285f74;
|
--color-link: light-dark(#285f74, #8ab8c8);
|
||||||
--color-link-hover: #8a4b2f;
|
--color-link-hover: light-dark(
|
||||||
--color-link-visited: #3c5a7a;
|
color-mix(in oklch, #285f74 70%, black 30%),
|
||||||
--color-accent: oklch(55% 0.13 15);
|
color-mix(in oklch, #8ab8c8 70%, black 30%)
|
||||||
--color-rule: #d9d5ca;
|
);
|
||||||
--color-rule-medium: #a8a294;
|
--color-link-visited: var(--color-link);
|
||||||
--color-rule-strong: #4a4340;
|
--color-accent: light-dark(oklch(55% 0.13 15), oklch(72% 0.13 15));
|
||||||
--color-code-bg: #efede6;
|
--color-rule: light-dark(#d9d5ca, #39352f);
|
||||||
--color-callout-bg: #f4f1e8;
|
--color-rule-medium: light-dark(#7a7466, #6c655c);
|
||||||
--color-selection-bg: #ecddd0;
|
--color-rule-strong: light-dark(#4a4340, #d0c5b7);
|
||||||
|
--color-code-bg: light-dark(#efede6, #24221f);
|
||||||
|
--color-callout-bg: light-dark(#f4f1e8, #211f1c);
|
||||||
|
--color-selection-bg: light-dark(#ecddd0, #4a3a2e);
|
||||||
|
|
||||||
--theme-switcher-track: var(--color-rule-medium);
|
--theme-switcher-track: var(--color-rule-medium);
|
||||||
--theme-switcher-icon-light: #f0e2b6;
|
--theme-switcher-icon-light: #f0e2b6;
|
||||||
|
|
@ -56,16 +59,16 @@
|
||||||
--fs-sm: 0.8125rem;
|
--fs-sm: 0.8125rem;
|
||||||
--fs-caption: 0.875rem;
|
--fs-caption: 0.875rem;
|
||||||
--fs-base: 1rem;
|
--fs-base: 1rem;
|
||||||
--fs-body: 1.125rem;
|
--fs-body: 1.1875rem;
|
||||||
--fs-lg: 1.25rem;
|
--fs-lg: 1.25rem;
|
||||||
--fs-xl: 1.6rem;
|
--fs-xl: 1.75rem;
|
||||||
--fs-2xl: 2.1rem;
|
--fs-2xl: 2.1rem;
|
||||||
--fs-3xl: clamp(2rem, 1.4rem + 2.2vw, 3.85rem);
|
--fs-3xl: clamp(2rem, 1.5rem + 1.8vw, 3rem);
|
||||||
--fs-dek: clamp(1.08rem, 0.95rem + 0.6vw, 1.25rem);
|
--fs-dek: clamp(1.08rem, 0.95rem + 0.6vw, 1.25rem);
|
||||||
|
|
||||||
--leading-tight: 1.18;
|
--leading-tight: 1.18;
|
||||||
--leading-snug: 1.35;
|
--leading-snug: 1.35;
|
||||||
--leading-prose: 1.65;
|
--leading-prose: 1.6;
|
||||||
|
|
||||||
--weight-regular: 400;
|
--weight-regular: 400;
|
||||||
--weight-medium: 500;
|
--weight-medium: 500;
|
||||||
|
|
@ -97,53 +100,12 @@
|
||||||
--gutter: clamp(20px, 4vw, 32px);
|
--gutter: clamp(20px, 4vw, 32px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark palette — applied when explicit data-theme='dark' OR
|
|
||||||
when system prefers dark and no explicit light override is set. */
|
|
||||||
:root[data-theme='dark'],
|
|
||||||
:root:where(:not([data-theme='light'])) {
|
|
||||||
/* Default branch only takes effect under the media query below. */
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme='dark'] {
|
|
||||||
color-scheme: dark;
|
|
||||||
--color-bg: #151514;
|
|
||||||
--color-fg: #f1eee7;
|
|
||||||
--color-muted: #b7afa3;
|
|
||||||
--color-link: #8ab8c8;
|
|
||||||
--color-link-hover: #d6a17f;
|
|
||||||
--color-link-visited: #a3b7d2;
|
|
||||||
--color-accent: oklch(72% 0.13 15);
|
|
||||||
--color-rule: #39352f;
|
|
||||||
--color-rule-medium: #6c655c;
|
|
||||||
--color-rule-strong: #d0c5b7;
|
|
||||||
--color-code-bg: #24221f;
|
|
||||||
--color-callout-bg: #211f1c;
|
|
||||||
--color-selection-bg: #4a3a2e;
|
|
||||||
--theme-switcher-track: var(--color-rule-medium);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme='light'] {
|
:root[data-theme='light'] {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
:root[data-theme='dark'] {
|
||||||
:root:not([data-theme='light']) {
|
color-scheme: dark;
|
||||||
color-scheme: dark;
|
|
||||||
--color-bg: #151514;
|
|
||||||
--color-fg: #f1eee7;
|
|
||||||
--color-muted: #b7afa3;
|
|
||||||
--color-link: #8ab8c8;
|
|
||||||
--color-link-hover: #d6a17f;
|
|
||||||
--color-link-visited: #a3b7d2;
|
|
||||||
--color-accent: oklch(72% 0.13 15);
|
|
||||||
--color-rule: #39352f;
|
|
||||||
--color-rule-medium: #6c655c;
|
|
||||||
--color-rule-strong: #d0c5b7;
|
|
||||||
--color-code-bg: #24221f;
|
|
||||||
--color-callout-bg: #211f1c;
|
|
||||||
--color-selection-bg: #4a3a2e;
|
|
||||||
--theme-switcher-track: var(--color-rule-medium);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =========================================================================
|
/* =========================================================================
|
||||||
|
|
@ -211,7 +173,6 @@
|
||||||
color: var(--color-fg);
|
color: var(--color-fg);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: var(--fs-body);
|
font-size: var(--fs-body);
|
||||||
line-height: var(--leading-prose);
|
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
|
|
@ -227,10 +188,6 @@
|
||||||
transition: color 150ms ease;
|
transition: color 150ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
a:visited {
|
|
||||||
color: var(--color-link-visited);
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover {
|
a:hover {
|
||||||
color: var(--color-link-hover);
|
color: var(--color-link-hover);
|
||||||
}
|
}
|
||||||
|
|
@ -289,26 +246,27 @@
|
||||||
========================================================================= */
|
========================================================================= */
|
||||||
|
|
||||||
@layer layout {
|
@layer layout {
|
||||||
.site-header,
|
:where(
|
||||||
.site-footer,
|
.site-header,
|
||||||
.home-intro,
|
.site-footer,
|
||||||
.home-section,
|
.home-intro,
|
||||||
.page-shell,
|
.home-section,
|
||||||
.post,
|
.page-shell,
|
||||||
.post-footer-shell {
|
.post,
|
||||||
|
.post-footer-shell
|
||||||
|
) {
|
||||||
width: min(100% - 2 * var(--gutter), var(--page));
|
width: min(100% - 2 * var(--gutter), var(--page));
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post,
|
:where(.post, .post-footer-shell) {
|
||||||
.post-footer-shell {
|
|
||||||
width: min(100% - 2 * var(--gutter), var(--measure-wide));
|
width: min(100% - 2 * var(--gutter), var(--measure-wide));
|
||||||
}
|
}
|
||||||
|
|
||||||
.skip-link {
|
.skip-link {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: var(--gutter);
|
left: calc(var(--gutter) + env(safe-area-inset-left));
|
||||||
top: var(--space-3);
|
top: calc(var(--space-3) + env(safe-area-inset-top));
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
transform: translateY(-150%);
|
transform: translateY(-150%);
|
||||||
background: var(--color-fg);
|
background: var(--color-fg);
|
||||||
|
|
@ -435,13 +393,6 @@
|
||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-name-accent {
|
|
||||||
color: var(--color-accent);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 0.93em;
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-intro p:not(.eyebrow),
|
.home-intro p:not(.eyebrow),
|
||||||
.page-header p,
|
.page-header p,
|
||||||
.dek {
|
.dek {
|
||||||
|
|
@ -500,15 +451,10 @@
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-bottom: var(--space-4);
|
margin-bottom: var(--space-4);
|
||||||
border-top: 1px solid var(--color-rule);
|
|
||||||
padding-top: var(--space-6);
|
padding-top: var(--space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-heading h2,
|
:where(.section-heading, .archive-year, .project-section, .facts, .at-a-glance) h2 {
|
||||||
.archive-year h2,
|
|
||||||
.project-section h2,
|
|
||||||
.facts h2,
|
|
||||||
.at-a-glance h2 {
|
|
||||||
font-size: var(--fs-lg);
|
font-size: var(--fs-lg);
|
||||||
font-weight: var(--weight-semibold);
|
font-weight: var(--weight-semibold);
|
||||||
line-height: var(--leading-snug);
|
line-height: var(--leading-snug);
|
||||||
|
|
@ -638,7 +584,7 @@
|
||||||
|
|
||||||
.article-list > li {
|
.article-list > li {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 6rem minmax(0, 1fr) 8rem;
|
grid-template-columns: minmax(5rem, auto) minmax(0, 1fr) minmax(6rem, 8rem);
|
||||||
grid-template-areas: 'date content thumb';
|
grid-template-areas: 'date content thumb';
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-5);
|
gap: var(--space-5);
|
||||||
|
|
@ -654,6 +600,7 @@
|
||||||
grid-area: date;
|
grid-area: date;
|
||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
font-size: var(--fs-caption);
|
font-size: var(--fs-caption);
|
||||||
|
text-align: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-list > li > div {
|
.article-list > li > div {
|
||||||
|
|
@ -732,6 +679,7 @@
|
||||||
grid-template-areas: 'thumb summary';
|
grid-template-areas: 'thumb summary';
|
||||||
min-height: var(--project-thumb-size);
|
min-height: var(--project-thumb-size);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
border: 1px solid var(--color-rule);
|
border: 1px solid var(--color-rule);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
|
|
@ -909,12 +857,17 @@
|
||||||
|
|
||||||
.prose {
|
.prose {
|
||||||
max-inline-size: var(--measure);
|
max-inline-size: var(--measure);
|
||||||
|
line-height: var(--leading-prose);
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose > * + * {
|
.prose > * + * {
|
||||||
margin-top: 1.05em;
|
margin-top: 1.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prose p {
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
|
||||||
.prose h2,
|
.prose h2,
|
||||||
.prose h3 {
|
.prose h3 {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
@ -1039,7 +992,7 @@
|
||||||
.prose pre {
|
.prose pre {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
scrollbar-gutter: stable;
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
background: var(--color-code-bg);
|
background: var(--color-code-bg);
|
||||||
border: 1px solid var(--color-rule);
|
border: 1px solid var(--color-rule);
|
||||||
|
|
@ -1068,28 +1021,16 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Shiki dual-theme: defaultColor: false emits --shiki-light / --shiki-dark
|
/* Shiki dual-theme: defaultColor: false emits --shiki-light / --shiki-dark
|
||||||
vars on every token; one selector list picks the active variant. */
|
vars on every token; light-dark() picks the active variant from
|
||||||
|
color-scheme on :root. */
|
||||||
.prose pre.astro-code,
|
.prose pre.astro-code,
|
||||||
.prose pre.astro-code code,
|
.prose pre.astro-code code,
|
||||||
.prose pre.astro-code span {
|
.prose pre.astro-code span {
|
||||||
color: var(--shiki-light);
|
color: light-dark(var(--shiki-light), var(--shiki-dark));
|
||||||
background-color: var(--shiki-light-bg, var(--color-code-bg));
|
background-color: light-dark(
|
||||||
}
|
var(--shiki-light-bg, var(--color-code-bg)),
|
||||||
|
var(--shiki-dark-bg, var(--color-code-bg))
|
||||||
:root[data-theme='dark'] .prose pre.astro-code,
|
);
|
||||||
:root[data-theme='dark'] .prose pre.astro-code code,
|
|
||||||
:root[data-theme='dark'] .prose pre.astro-code span {
|
|
||||||
color: var(--shiki-dark);
|
|
||||||
background-color: var(--shiki-dark-bg, var(--color-code-bg));
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root:not([data-theme='light']) .prose pre.astro-code,
|
|
||||||
:root:not([data-theme='light']) .prose pre.astro-code code,
|
|
||||||
:root:not([data-theme='light']) .prose pre.astro-code span {
|
|
||||||
color: var(--shiki-dark);
|
|
||||||
background-color: var(--shiki-dark-bg, var(--color-code-bg));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -- At-a-glance + facts --------------------------------------------- */
|
/* -- At-a-glance + facts --------------------------------------------- */
|
||||||
|
|
@ -1388,7 +1329,6 @@
|
||||||
padding-block: var(--space-8) var(--space-6);
|
padding-block: var(--space-8) var(--space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-list > li,
|
|
||||||
.at-a-glance dl,
|
.at-a-glance dl,
|
||||||
.facts dl {
|
.facts dl {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|
@ -1416,6 +1356,10 @@
|
||||||
--project-thumb-size: 7rem;
|
--project-thumb-size: 7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.project-card .project-meta {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
.project-card__summary {
|
.project-card__summary {
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
@ -1458,18 +1402,10 @@
|
||||||
scroll-behavior: auto;
|
scroll-behavior: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
scroll-behavior: auto !important;
|
|
||||||
transition: none !important;
|
|
||||||
animation: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
::view-transition-group(*),
|
::view-transition-group(*),
|
||||||
::view-transition-old(*),
|
::view-transition-old(*),
|
||||||
::view-transition-new(*) {
|
::view-transition-new(*) {
|
||||||
animation: none !important;
|
animation: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1481,7 +1417,6 @@
|
||||||
--color-muted: #333;
|
--color-muted: #333;
|
||||||
--color-link: #000;
|
--color-link: #000;
|
||||||
--color-link-hover: #000;
|
--color-link-hover: #000;
|
||||||
--color-link-visited: #000;
|
|
||||||
--color-accent: #000;
|
--color-accent: #000;
|
||||||
--color-rule: #999;
|
--color-rule: #999;
|
||||||
--color-rule-medium: #777;
|
--color-rule-medium: #777;
|
||||||
|
|
@ -1503,7 +1438,7 @@
|
||||||
.post-nav,
|
.post-nav,
|
||||||
.related-posts,
|
.related-posts,
|
||||||
.heading-anchor {
|
.heading-anchor {
|
||||||
display: none !important;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
|
|
@ -1512,14 +1447,14 @@
|
||||||
|
|
||||||
a,
|
a,
|
||||||
a:visited {
|
a:visited {
|
||||||
color: #000;
|
color: var(--color-fg);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose a[href]::after {
|
.prose a[href]::after {
|
||||||
content: ' (' attr(href) ')';
|
content: ' (' attr(href) ')';
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
color: #555;
|
color: var(--color-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose a[href^='#']::after,
|
.prose a[href^='#']::after,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,6 @@
|
||||||
{
|
{
|
||||||
"extends": "astro/tsconfigs/strict",
|
"extends": "astro/tsconfigs/strict",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"types": ["astro/client"],
|
"types": ["astro/client"]
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["src/*"]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||