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
|
||||
required client JavaScript.
|
||||
|
||||
## Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npx playwright install chromium # required before `npm run qa:overflow`
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```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 { defineConfig } from 'astro/config';
|
||||
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
|
||||
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({
|
||||
site: 'https://schmelczer.dev',
|
||||
trailingSlash: 'always',
|
||||
build: { inlineStylesheets: 'always' },
|
||||
redirects: {
|
||||
'/writing/': '/articles/',
|
||||
'/writing/[slug]': '/articles/[slug]',
|
||||
|
|
@ -17,7 +50,16 @@ export default defineConfig({
|
|||
return !path.startsWith('/writing/') && path !== '/404/';
|
||||
},
|
||||
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": {
|
||||
"dev": "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\"",
|
||||
"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",
|
||||
"qa:no-js": "node scripts/check-no-js.mjs",
|
||||
"qa:overflow": "node scripts/check-overflow.mjs",
|
||||
|
|
@ -43,7 +45,9 @@
|
|||
"prettier-plugin-astro": "^0.14.1",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"sharp": "^0.32.6",
|
||||
"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'))) {
|
||||
const html = await readFile(file, 'utf8');
|
||||
const scripts = (
|
||||
html.match(/<script\b(?![^>]*type=["']application\/ld\+json["'])[^>]*>/gi) ?? []
|
||||
).filter((script) => !script.includes('data-theme-script'));
|
||||
if (scripts?.length) {
|
||||
const scripts = (html.match(/<script\b[^>]*>/gi) ?? []).filter(
|
||||
(tag) => !isSafeScriptTag(tag)
|
||||
);
|
||||
if (scripts.length) {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -6,16 +6,27 @@ import { chromium } from 'playwright';
|
|||
const dist = path.resolve('dist');
|
||||
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) {
|
||||
if (file.endsWith('.html')) return 'text/html; charset=utf-8';
|
||||
if (file.endsWith('.css')) return 'text/css; charset=utf-8';
|
||||
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';
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
return MIME[ext] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
async function walk(dir) {
|
||||
|
|
@ -75,6 +86,12 @@ async function resolveFile(url) {
|
|||
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 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()}>
|
||||
{showYear ? formatDate(post.data.date) : formatDateShort(post.data.date)}
|
||||
</time>
|
||||
<div>
|
||||
<article>
|
||||
<a class="entry-title" href={href}>
|
||||
{post.data.title}
|
||||
</a>
|
||||
<p>{post.data.description}</p>
|
||||
<TagList tags={post.data.tags} currentTag={currentTag} />
|
||||
</div>
|
||||
<TagList tags={post.data.tags} currentTag={currentTag} limit={3} />
|
||||
</article>
|
||||
<EntryThumbnail
|
||||
src={post.data.thumbnail.src}
|
||||
alt={post.data.thumbnail.alt}
|
||||
|
|
|
|||
|
|
@ -11,9 +11,18 @@ interface Props {
|
|||
scale?: string;
|
||||
outcome?: string;
|
||||
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]> = [
|
||||
['Role', role ?? ''],
|
||||
|
|
@ -26,14 +35,14 @@ const rows: Array<[string, string]> = [
|
|||
|
||||
{
|
||||
rows.length > 0 && (
|
||||
<aside class="at-a-glance" aria-labelledby="at-a-glance-heading">
|
||||
<h2 id="at-a-glance-heading">At a Glance</h2>
|
||||
<aside class="at-a-glance" aria-labelledby={headingId}>
|
||||
<h2 id={headingId}>At a Glance</h2>
|
||||
<dl>
|
||||
{rows.map(([label, value]) => (
|
||||
<>
|
||||
<div class="at-a-glance__row">
|
||||
<dt>{label}</dt>
|
||||
<dd>{value}</dd>
|
||||
</>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{links.length > 0 && <ProjectLinks links={links} />}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
import type { ImageMetadata } from 'astro';
|
||||
import { Picture } from 'astro:assets';
|
||||
|
||||
type FallbackFormat = 'jpg' | 'jpeg' | 'png' | 'webp' | 'avif' | 'gif';
|
||||
|
||||
interface Props {
|
||||
src: ImageMetadata;
|
||||
alt: string;
|
||||
|
|
@ -11,6 +13,8 @@ interface Props {
|
|||
sizes: string;
|
||||
loading?: 'lazy' | 'eager';
|
||||
fetchpriority?: 'high' | 'low' | 'auto';
|
||||
decorative?: boolean;
|
||||
fallbackFormat?: FallbackFormat;
|
||||
}
|
||||
|
||||
const {
|
||||
|
|
@ -22,22 +26,27 @@ const {
|
|||
sizes,
|
||||
loading = 'lazy',
|
||||
fetchpriority,
|
||||
decorative = true,
|
||||
fallbackFormat,
|
||||
} = Astro.props;
|
||||
|
||||
const Tag = href ? 'a' : 'div';
|
||||
const resolvedFallback: FallbackFormat =
|
||||
fallbackFormat ?? (src.format === 'png' ? 'png' : 'jpg');
|
||||
const isDecorativeLink = Boolean(href) && decorative;
|
||||
---
|
||||
|
||||
<Tag
|
||||
class:list={['entry-thumbnail', extraClass]}
|
||||
href={href}
|
||||
aria-hidden={href ? 'true' : undefined}
|
||||
tabindex={href ? -1 : undefined}
|
||||
aria-hidden={isDecorativeLink ? 'true' : undefined}
|
||||
tabindex={isDecorativeLink ? -1 : undefined}
|
||||
>
|
||||
<Picture
|
||||
src={src}
|
||||
alt={alt}
|
||||
formats={['avif', 'webp']}
|
||||
fallbackFormat="jpg"
|
||||
fallbackFormat={resolvedFallback}
|
||||
widths={widths}
|
||||
sizes={sizes}
|
||||
loading={loading}
|
||||
|
|
|
|||
|
|
@ -2,37 +2,42 @@
|
|||
import { navItems, site } from '../lib/site';
|
||||
|
||||
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">
|
||||
<nav aria-label="Footer">
|
||||
<ul class="footer-links">
|
||||
{
|
||||
navItems.map((item) => (
|
||||
footerNavItems.map((item) => (
|
||||
<li>
|
||||
<a href={item.href}>{item.label}</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
<li>
|
||||
<a href="/tags/">Tags</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/rss.xml">RSS</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<ul class="footer-meta">
|
||||
<li><span>© {year} {site.name}</span></li>
|
||||
<li><a href={`mailto:${site.email}`}>Email</a></li>
|
||||
<li>
|
||||
<a href={site.cv} rel="noopener">CV</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={site.github} rel="noopener me">GitHub</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={site.linkedin} rel="noopener me">LinkedIn</a>
|
||||
</li>
|
||||
</ul>
|
||||
<address>
|
||||
<ul class="footer-meta">
|
||||
<li><span>© {year} {site.name}</span></li>
|
||||
<li><a href={`mailto:${site.email}`}>Email</a></li>
|
||||
<li>
|
||||
<a href={site.cv} rel="noopener noreferrer">CV</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={site.github} rel="noopener noreferrer me">GitHub</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={site.linkedin} rel="noopener noreferrer me">LinkedIn</a>
|
||||
</li>
|
||||
</ul>
|
||||
</address>
|
||||
</footer>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,14 @@ function isCurrent(href: string) {
|
|||
if (href === '/') return current === '/';
|
||||
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>
|
||||
|
|
@ -15,81 +23,99 @@ function isCurrent(href: string) {
|
|||
<div class="header-actions">
|
||||
<nav class="site-nav" aria-label="Primary">
|
||||
{
|
||||
navItems
|
||||
.filter((item) => item.href !== '/')
|
||||
.map((item) => (
|
||||
<a href={item.href} aria-current={isCurrent(item.href) ? 'page' : undefined}>
|
||||
{item.label}
|
||||
</a>
|
||||
))
|
||||
headerNavItems.map((item) => (
|
||||
<a href={item.href} aria-current={isCurrent(item.href) ? 'page' : undefined}>
|
||||
{item.label}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</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
|
||||
id="theme-switcher"
|
||||
class="theme-switcher"
|
||||
type="button"
|
||||
aria-label="Toggle dark theme"
|
||||
aria-label="Switch to dark theme"
|
||||
aria-pressed="false"
|
||||
>
|
||||
<span class="sr-only">Toggle theme</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<script is:inline data-theme-script>
|
||||
(() => {
|
||||
const key = 'theme';
|
||||
const legacyKey = 'dark-mode';
|
||||
const 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());
|
||||
|
||||
var key = 'theme';
|
||||
var legacyKey = 'dark-mode';
|
||||
var switcher = document.getElementById('theme-switcher');
|
||||
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') {
|
||||
document.startViewTransition(() => apply(theme));
|
||||
document.startViewTransition(function () {
|
||||
apply(theme);
|
||||
});
|
||||
} else {
|
||||
apply(theme);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
switcher.addEventListener('click', () => {
|
||||
const current = switcher.getAttribute('aria-pressed') === 'true' ? 'dark' : 'light';
|
||||
const next = current === 'dark' ? 'light' : 'dark';
|
||||
switcher.addEventListener('click', function () {
|
||||
var currentTheme =
|
||||
switcher.getAttribute('aria-pressed') === 'true' ? 'dark' : 'light';
|
||||
var next = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
try {
|
||||
localStorage.setItem(key, next);
|
||||
localStorage.setItem(legacyKey, JSON.stringify(next === 'dark'));
|
||||
} catch {
|
||||
// The switch still applies for the current page when storage is unavailable.
|
||||
}
|
||||
} catch (e) {}
|
||||
runApply(next);
|
||||
});
|
||||
|
||||
media.addEventListener('change', () => {
|
||||
if (!getStored()) apply(getSystemTheme());
|
||||
});
|
||||
})();
|
||||
</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 fallbackFormatFor = (format: string | undefined): 'png' | 'jpg' =>
|
||||
format === 'png' ? 'png' : 'jpg';
|
||||
---
|
||||
|
||||
{
|
||||
items.map((item) => (
|
||||
<figure class:list={['post-media', item.role === 'inline' && 'figure-inline']}>
|
||||
{item.type === 'video' ? (
|
||||
<video
|
||||
controls
|
||||
preload="metadata"
|
||||
poster={item.poster?.src}
|
||||
aria-label={item.decorative ? undefined : item.alt}
|
||||
>
|
||||
{item.webm && <source src={item.webm} type="video/webm" />}
|
||||
{item.mp4 && <source src={item.mp4} type="video/mp4" />}
|
||||
</video>
|
||||
) : (
|
||||
item.src && (
|
||||
<Picture
|
||||
src={item.src}
|
||||
alt={item.decorative ? '' : (item.alt ?? '')}
|
||||
formats={['avif', 'webp']}
|
||||
fallbackFormat="jpg"
|
||||
widths={[480, 720, 960, 1280, 1600, 1920]}
|
||||
sizes="(max-width: 700px) calc(100vw - 3rem), (max-width: 1100px) calc(100vw - 4rem), 56rem"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{item.caption && <figcaption>{item.caption}</figcaption>}
|
||||
{item.transcript && <p class="media-transcript">{item.transcript}</p>}
|
||||
</figure>
|
||||
))
|
||||
items.length > 1 ? (
|
||||
<ul role="list" class="post-gallery">
|
||||
{items.map((item) => (
|
||||
<li>
|
||||
<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>
|
||||
</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
|
||||
href={link.url}
|
||||
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}
|
||||
{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 && (
|
||||
<span class="download-indicator" aria-hidden="true">
|
||||
↓
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import { getEntry } from 'astro:content';
|
||||
import EntryThumbnail from './EntryThumbnail.astro';
|
||||
import ProjectLinks from './ProjectLinks.astro';
|
||||
import { articlePath, projectAnchor } from '../lib/site';
|
||||
|
|
@ -10,6 +11,42 @@ interface Props {
|
|||
|
||||
const { projects } = Astro.props;
|
||||
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">
|
||||
|
|
@ -17,7 +54,7 @@ type ProjectLink = CollectionEntry<'projects'>['data']['links'][number];
|
|||
projects.map((project) => {
|
||||
const anchor = projectAnchor(project);
|
||||
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
|
||||
? { label: 'Article', type: 'site', url: essayHref }
|
||||
: undefined;
|
||||
|
|
@ -37,7 +74,7 @@ type ProjectLink = CollectionEntry<'projects'>['data']['links'][number];
|
|||
widths={[240, 320, 480, 640, 800]}
|
||||
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}>
|
||||
{primaryHref ? (
|
||||
<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(', ')}
|
||||
</p>
|
||||
{links.length > 0 && <ProjectLinks links={links} />}
|
||||
</div>
|
||||
</article>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,19 +5,37 @@ interface Props {
|
|||
tags: readonly string[];
|
||||
currentTag?: string;
|
||||
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>
|
||||
<a href={tagPath(tag)} aria-current={tag === currentTag ? 'page' : undefined}>
|
||||
{tag}
|
||||
{counts && counts[tag] !== undefined && (
|
||||
<span class="tag-count">{counts[tag]}</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
{
|
||||
remaining > 0 && (
|
||||
<li>
|
||||
<a href="/tags/" class="tag-more">
|
||||
+{remaining} more
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { defineCollection } from 'astro:content';
|
||||
import { defineCollection, reference } from 'astro:content';
|
||||
import type { SchemaContext } from 'astro:content';
|
||||
import { glob } from 'astro/loaders';
|
||||
import { z } from 'astro/zod';
|
||||
|
|
@ -92,8 +92,7 @@ const projects = defineCollection({
|
|||
status: z.string().optional(),
|
||||
technologies: z.array(z.string()).default([]),
|
||||
selected: z.boolean().default(false),
|
||||
essay: z.string().optional(),
|
||||
legacyAnchor: z.string().optional(),
|
||||
essay: reference('posts').optional(),
|
||||
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
|
||||
projectPeriod: 'Autumn-Winter 2020'
|
||||
thumbnail:
|
||||
src: ./_assets/decla-red.png
|
||||
src: ./_assets/decla-red.jpg
|
||||
alt: The decla.red browser game interface showing a space scene.
|
||||
tags: ['games', 'web', 'systems']
|
||||
selected: true
|
||||
|
|
@ -28,7 +28,7 @@ links:
|
|||
download: true
|
||||
media:
|
||||
- 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.
|
||||
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
|
||||
projectPeriod: 'August-September 2019'
|
||||
thumbnail:
|
||||
src: ./_assets/towers.png
|
||||
src: ./_assets/towers.jpg
|
||||
alt: Life Towers goal tracking interface with tower-like visual structures.
|
||||
tags: ['systems', 'web', 'tools']
|
||||
selected: true
|
||||
|
|
@ -24,7 +24,7 @@ links:
|
|||
url: https://towers.schmelczer.dev
|
||||
media:
|
||||
- type: image
|
||||
src: ./_assets/towers.png
|
||||
src: ./_assets/towers.jpg
|
||||
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.
|
||||
---
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ description: 'My first proper project: a 3D game with random maps, destructible
|
|||
date: 2026-04-28
|
||||
projectPeriod: 'Autumn 2017'
|
||||
thumbnail:
|
||||
src: ./_assets/platform-game.png
|
||||
src: ./_assets/platform-game.jpg
|
||||
alt: Screenshot from a 3D platform game written in C.
|
||||
tags: ['games', 'systems']
|
||||
selected: false
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ description: How SDF-2D used signed distance fields, dynamic shaders, and tile-b
|
|||
date: 2026-05-08
|
||||
projectPeriod: 'Autumn-Winter 2020'
|
||||
thumbnail:
|
||||
src: ./_assets/sdf2d.png
|
||||
src: ./_assets/sdf2d.jpg
|
||||
alt: SDF-2D browser demo with soft lighting effects.
|
||||
tags: ['graphics', 'web', 'systems']
|
||||
selected: true
|
||||
|
|
@ -31,7 +31,7 @@ links:
|
|||
download: true
|
||||
media:
|
||||
- type: image
|
||||
src: ./_assets/sdf2d.png
|
||||
src: ./_assets/sdf2d.jpg
|
||||
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.
|
||||
---
|
||||
|
|
|
|||
|
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
|
||||
description: A team-based mobile multiplayer browser game with shared client/server game logic.
|
||||
thumbnail:
|
||||
src: ./_assets/declared.png
|
||||
src: ./_assets/declared.jpg
|
||||
alt: The decla.red browser game interface showing a space scene.
|
||||
period: 'Autumn-Winter 2020'
|
||||
sortDate: 2020-11-01
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ sourceProjectId: platform-game
|
|||
title: Platform Game
|
||||
description: An early 3D game in C with SDL 1.2, random maps, destructible voxels, enemies, powerups, and time slowdown.
|
||||
thumbnail:
|
||||
src: ./_assets/platform-game.png
|
||||
src: ./_assets/platform-game.jpg
|
||||
alt: Screenshot from an early 3D platform game.
|
||||
period: 'Autumn 2017'
|
||||
sortDate: 2017-10-01
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ sourceProjectId: sdf2d
|
|||
title: SDF-2D
|
||||
description: A browser rendering library for optimized 2D ray tracing with signed distance fields.
|
||||
thumbnail:
|
||||
src: ./_assets/sdf2d.png
|
||||
src: ./_assets/sdf2d.jpg
|
||||
alt: SDF-2D browser demo with soft lighting effects.
|
||||
period: 'Autumn-Winter 2020'
|
||||
sortDate: 2020-12-01
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ sourceProjectId: towers
|
|||
title: Life Towers
|
||||
description: A multi-device goal tracker where the lasting idea was syncing state with immutable tries.
|
||||
thumbnail:
|
||||
src: ./_assets/towers.png
|
||||
src: ./_assets/towers.jpg
|
||||
alt: Life Towers goal tracking interface with tower-like visual structures.
|
||||
period: 'August-September 2019'
|
||||
sortDate: 2019-09-01
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
---
|
||||
import { getImage } from 'astro:assets';
|
||||
import Footer from '../components/Footer.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 themeInit from '../scripts/theme-init.ts?raw';
|
||||
import '../styles/global.css';
|
||||
|
||||
interface ArticleMeta {
|
||||
|
|
@ -32,7 +32,7 @@ const {
|
|||
description = site.description,
|
||||
canonicalPath = Astro.url.pathname,
|
||||
ogImage,
|
||||
ogImageAlt = site.description,
|
||||
ogImageAlt = "Andras Schmelczer's personal site",
|
||||
ogImageWidth,
|
||||
ogImageHeight,
|
||||
ogType = 'website',
|
||||
|
|
@ -52,12 +52,7 @@ let resolvedOgWidth = ogImageWidth;
|
|||
let resolvedOgHeight = ogImageHeight;
|
||||
|
||||
if (!resolvedOgImage) {
|
||||
const generated = await getImage({
|
||||
src: defaultOg,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
format: 'jpg',
|
||||
});
|
||||
const generated = await optimizeOgImage(defaultOg);
|
||||
resolvedOgImage = generated.src;
|
||||
resolvedOgWidth = 1200;
|
||||
resolvedOgHeight = 630;
|
||||
|
|
@ -86,30 +81,7 @@ const jsonLdEntries = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
|
|||
{noindex && <meta name="robots" content="noindex,follow" />}
|
||||
<link rel="canonical" href={canonical} />
|
||||
|
||||
<script is:inline data-theme-script>
|
||||
(() => {
|
||||
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>
|
||||
<script is:inline data-theme-script set:html={themeInit} />
|
||||
|
||||
<link
|
||||
rel="preload"
|
||||
|
|
@ -160,7 +132,7 @@ const jsonLdEntries = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
|
|||
)
|
||||
}
|
||||
<meta property="og:type" content={ogType} />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta property="og:locale" content="en" />
|
||||
|
||||
{
|
||||
article && (
|
||||
|
|
|
|||
|
|
@ -1,25 +1,18 @@
|
|||
---
|
||||
import type { ComponentProps } from 'astro/types';
|
||||
import Base from './Base.astro';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
canonicalPath?: string;
|
||||
ogImage?: string;
|
||||
ogType?: 'website' | 'article' | 'profile';
|
||||
noindex?: boolean;
|
||||
jsonLd?: Record<string, unknown> | Array<Record<string, unknown>>;
|
||||
}
|
||||
type Props = ComponentProps<typeof Base>;
|
||||
|
||||
const { title, description } = Astro.props;
|
||||
---
|
||||
|
||||
<Base {...Astro.props}>
|
||||
<section class="page-shell">
|
||||
<div class="page-shell">
|
||||
<header class="page-header">
|
||||
<h1>{title}</h1>
|
||||
<p>{description}</p>
|
||||
</header>
|
||||
<slot />
|
||||
</section>
|
||||
</div>
|
||||
</Base>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
import type { CollectionEntry } 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 AtAGlance from '../components/AtAGlance.astro';
|
||||
import Breadcrumbs from '../components/Breadcrumbs.astro';
|
||||
|
|
@ -11,10 +11,11 @@ import {
|
|||
absoluteUrl,
|
||||
adjacentPosts,
|
||||
articlePath,
|
||||
buildBreadcrumbTrail,
|
||||
formatDate,
|
||||
getPublishedPosts,
|
||||
getRelatedPosts,
|
||||
site,
|
||||
optimizeOgImage,
|
||||
} from '../lib/site';
|
||||
import Base from './Base.astro';
|
||||
|
||||
|
|
@ -23,24 +24,29 @@ interface Props {
|
|||
}
|
||||
|
||||
const { post } = Astro.props;
|
||||
const { Content } = await render(post);
|
||||
const { Content, headings } = await render(post);
|
||||
|
||||
const allPosts = await getPublishedPosts();
|
||||
const { previous, next } = adjacentPosts(allPosts, post);
|
||||
const related = getRelatedPosts(allPosts, post, 3);
|
||||
|
||||
const ogImageOptimized = await getImage({
|
||||
src: post.data.thumbnail.src,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
format: 'jpg',
|
||||
});
|
||||
const ogImageOptimized = await optimizeOgImage(post.data.thumbnail.src);
|
||||
|
||||
const breadcrumbTrail = [
|
||||
{ href: '/', label: 'Home' },
|
||||
{ href: '/articles/', label: 'Articles' },
|
||||
{ label: post.data.title },
|
||||
];
|
||||
const trail = buildBreadcrumbTrail({ post });
|
||||
const breadcrumbTrail = trail.map((c, i) => ({
|
||||
label: c.name,
|
||||
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 = {
|
||||
'@context': 'https://schema.org',
|
||||
|
|
@ -49,16 +55,8 @@ const blogPosting = {
|
|||
description: post.data.description,
|
||||
datePublished: post.data.date.toISOString(),
|
||||
...(post.data.updated && { dateModified: post.data.updated.toISOString() }),
|
||||
author: {
|
||||
'@type': 'Person',
|
||||
name: site.name,
|
||||
url: absoluteUrl('/about/'),
|
||||
},
|
||||
publisher: {
|
||||
'@type': 'Person',
|
||||
name: site.name,
|
||||
url: site.url,
|
||||
},
|
||||
author: { '@id': personId },
|
||||
publisher: { '@id': personId },
|
||||
image: absoluteUrl(ogImageOptimized.src),
|
||||
url: absoluteUrl(articlePath(post)),
|
||||
keywords: post.data.tags.join(', '),
|
||||
|
|
@ -71,21 +69,12 @@ const blogPosting = {
|
|||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'Home', item: absoluteUrl('/') },
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: 'Articles',
|
||||
item: absoluteUrl('/articles/'),
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: post.data.title,
|
||||
item: absoluteUrl(articlePath(post)),
|
||||
},
|
||||
],
|
||||
itemListElement: trail.map((c, i) => ({
|
||||
'@type': 'ListItem',
|
||||
position: i + 1,
|
||||
name: c.name,
|
||||
item: absoluteUrl(c.href),
|
||||
})),
|
||||
};
|
||||
---
|
||||
|
||||
|
|
@ -112,7 +101,7 @@ const breadcrumbJsonLd = {
|
|||
<p class="eyebrow">{post.data.projectPeriod ?? 'Article'}</p>
|
||||
<h1>{post.data.title}</h1>
|
||||
<p class="dek">{post.data.description}</p>
|
||||
<p class="post-meta">
|
||||
<div class="post-meta">
|
||||
<time datetime={post.data.date.toISOString()}>
|
||||
{formatDate(post.data.date)}
|
||||
</time>
|
||||
|
|
@ -129,7 +118,9 @@ const breadcrumbJsonLd = {
|
|||
</>
|
||||
)
|
||||
}
|
||||
</p>
|
||||
{' · '}
|
||||
<span>{readingMinutes} min read</span>
|
||||
</div>
|
||||
<TagList tags={post.data.tags} />
|
||||
</header>
|
||||
|
||||
|
|
@ -147,11 +138,8 @@ const breadcrumbJsonLd = {
|
|||
/>
|
||||
</figure>
|
||||
|
||||
<div class="prose">
|
||||
<Content />
|
||||
</div>
|
||||
|
||||
<AtAGlance
|
||||
headingId={`at-a-glance-${post.id}`}
|
||||
role={post.data.role}
|
||||
projectPeriod={post.data.projectPeriod}
|
||||
stack={post.data.stack}
|
||||
|
|
@ -160,6 +148,24 @@ const breadcrumbJsonLd = {
|
|||
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} />
|
||||
|
||||
{
|
||||
|
|
@ -174,22 +180,28 @@ const breadcrumbJsonLd = {
|
|||
{
|
||||
(previous || next) && (
|
||||
<nav class="post-nav" aria-label="Adjacent articles">
|
||||
{previous && (
|
||||
<a class="previous" href={articlePath(previous)} rel="prev">
|
||||
<span class="post-nav__label">
|
||||
<span aria-hidden="true">←</span> Previous
|
||||
</span>
|
||||
<span class="post-nav__title">{previous.data.title}</span>
|
||||
</a>
|
||||
)}
|
||||
{next && (
|
||||
<a class="next" href={articlePath(next)} rel="next">
|
||||
<span class="post-nav__label">
|
||||
Next <span aria-hidden="true">→</span>
|
||||
</span>
|
||||
<span class="post-nav__title">{next.data.title}</span>
|
||||
</a>
|
||||
)}
|
||||
<ul class="post-nav__list">
|
||||
{previous && (
|
||||
<li class="post-nav__prev">
|
||||
<a class="previous" href={articlePath(previous)} rel="prev">
|
||||
<span class="post-nav__label">
|
||||
<span aria-hidden="true">←</span> Previous
|
||||
</span>
|
||||
<span class="post-nav__title">{previous.data.title}</span>
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{next && (
|
||||
<li class="post-nav__next">
|
||||
<a class="next" href={articlePath(next)} rel="next">
|
||||
<span class="post-nav__label">
|
||||
Next <span aria-hidden="true">→</span>
|
||||
</span>
|
||||
<span class="post-nav__title">{next.data.title}</span>
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
103
src/lib/site.ts
|
|
@ -1,9 +1,11 @@
|
|||
import type { CollectionEntry } from 'astro:content';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { getImage } from 'astro:assets';
|
||||
import type { ImageMetadata } from 'astro';
|
||||
|
||||
export const site = {
|
||||
name: 'Andras Schmelczer',
|
||||
title: 'Andras Schmelczer — Software systems, AI, graphics, simulations, tools',
|
||||
title: 'Andras Schmelczer — Software engineer',
|
||||
description:
|
||||
'Andras Schmelczer writes about software systems, AI deployment, graphics, simulations, and tools.',
|
||||
url: 'https://schmelczer.dev',
|
||||
|
|
@ -13,12 +15,22 @@ export const site = {
|
|||
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 = [
|
||||
{ href: '/', label: 'Home' },
|
||||
{ href: '/articles/', label: 'Articles' },
|
||||
{ href: '/projects/', label: 'Projects' },
|
||||
{ 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) {
|
||||
return new Intl.DateTimeFormat('en', {
|
||||
|
|
@ -59,8 +71,11 @@ export function tagPath(tag: string) {
|
|||
return `/tags/${tagSlug(tag)}/`;
|
||||
}
|
||||
|
||||
export function projectAnchor(project: CollectionEntry<'projects'>) {
|
||||
return project.data.legacyAnchor ?? project.data.sourceProjectId;
|
||||
// Anchor used for `id="..."` on project cards and `#fragment` deep links.
|
||||
// Always derived from the canonical `sourceProjectId` slug now that the
|
||||
// legacy anchor mapping has been dropped.
|
||||
export function projectAnchor(slugOrEntry: string | CollectionEntry<'projects'>) {
|
||||
return typeof slugOrEntry === 'string' ? slugOrEntry : slugOrEntry.data.sourceProjectId;
|
||||
}
|
||||
|
||||
export function getAllTags(posts: { data: { tags: readonly string[] } }[]) {
|
||||
|
|
@ -69,10 +84,20 @@ export function getAllTags(posts: { data: { tags: readonly string[] } }[]) {
|
|||
);
|
||||
}
|
||||
|
||||
export async function getPublishedPosts(): Promise<CollectionEntry<'posts'>[]> {
|
||||
return (await getCollection('posts'))
|
||||
.filter((post) => !post.data.draft)
|
||||
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
|
||||
// Memoized published-posts loader. Build steps call `getPublishedPosts()`
|
||||
// from many pages (index, articles, RSS, sitemap, tag pages, post layouts).
|
||||
// Caching the promise means `getCollection('posts')` runs once per build.
|
||||
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'>[]> {
|
||||
|
|
@ -114,3 +139,65 @@ export function getRelatedPosts(
|
|||
export function absoluteUrl(path: string) {
|
||||
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
|
||||
>
|
||||
<div class="empty-state">
|
||||
<div class="prose">
|
||||
<p>
|
||||
Try the <a href="/articles/">articles archive</a>, the
|
||||
<a href="/projects/">project index</a>, the
|
||||
<a href="/tags/">tag index</a>, or head back to the
|
||||
<a href="/">homepage</a>.
|
||||
</p>
|
||||
</div>
|
||||
<p>
|
||||
Try the <a href="/articles/">articles archive</a>, the
|
||||
<a href="/projects/">project index</a>, the
|
||||
<a href="/tags/">tag index</a>, or head back to the
|
||||
<a href="/">homepage</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section class="home-section" aria-labelledby="404-recent">
|
||||
<section class="home-section">
|
||||
<div class="section-heading">
|
||||
<h2 id="404-recent">Recent articles</h2>
|
||||
<a href="/articles/">All articles →</a>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
import ArticleList from '../components/ArticleList.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 startingPoints = posts
|
||||
|
|
@ -9,17 +9,22 @@ const startingPoints = posts
|
|||
.sort((a, b) => (a.data.featuredOrder ?? 99) - (b.data.featuredOrder ?? 99))
|
||||
.slice(0, 4);
|
||||
|
||||
const personJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Person',
|
||||
name: site.name,
|
||||
url: site.url,
|
||||
email: `mailto:${site.email}`,
|
||||
sameAs: [site.github, site.linkedin],
|
||||
// Canonical Person JSON-LD. Other pages reference this entity by @id.
|
||||
const personJsonLd = buildPersonJsonLd({
|
||||
jobTitle: 'Software Engineer',
|
||||
description:
|
||||
'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
|
||||
|
|
@ -45,25 +50,37 @@ const personJsonLd = {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<section class="about-section facts" aria-labelledby="quick-facts">
|
||||
<section class="about-section facts">
|
||||
<h2 id="quick-facts">Quick Facts</h2>
|
||||
<dl>
|
||||
<dt>Focus</dt>
|
||||
<dd>Software systems, AI deployment, architecture, graphics, data visualization</dd>
|
||||
<dt>Education</dt>
|
||||
<dd>MSc in Computer Science</dd>
|
||||
<dt>Contact</dt>
|
||||
<dd><a href={`mailto:${site.email}`}>{site.email}</a></dd>
|
||||
<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>
|
||||
</dl>
|
||||
<address>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Focus</dt>
|
||||
<dd>
|
||||
Software systems, AI deployment, architecture, graphics, data visualization
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Education</dt>
|
||||
<dd>MSc in Computer Science</dd>
|
||||
</div>
|
||||
<div>
|
||||
<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 class="about-section" aria-labelledby="best-starting-points">
|
||||
<section class="about-section">
|
||||
<div class="section-heading">
|
||||
<h2 id="best-starting-points">Best Starting Points</h2>
|
||||
<a href="/articles/">Browse all articles →</a>
|
||||
|
|
@ -71,7 +88,7 @@ const personJsonLd = {
|
|||
<ArticleList posts={startingPoints} />
|
||||
</section>
|
||||
|
||||
<section class="about-section facts" aria-labelledby="working-style">
|
||||
<section class="about-section facts">
|
||||
<h2 id="working-style">How I Work</h2>
|
||||
<div class="prose">
|
||||
<p>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import Page from '../../layouts/Page.astro';
|
|||
import {
|
||||
absoluteUrl,
|
||||
articlePath,
|
||||
buildBreadcrumbTrail,
|
||||
getAllTags,
|
||||
getPublishedPosts,
|
||||
site,
|
||||
|
|
@ -30,12 +31,26 @@ const blogJsonLd = {
|
|||
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
|
||||
title="Articles"
|
||||
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">
|
||||
<span>Browse by tag</span>
|
||||
|
|
@ -46,7 +61,7 @@ const blogJsonLd = {
|
|||
years.map((year) => {
|
||||
const postsForYear = posts.filter((post) => yearOf(post.data.date) === year);
|
||||
return (
|
||||
<section class="archive-year" aria-labelledby={`year-${year}`}>
|
||||
<section class="archive-year">
|
||||
<h2 id={`year-${year}`}>{year}</h2>
|
||||
<ArticleList posts={postsForYear} showYear={false} />
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,12 @@ import ArticleList from '../components/ArticleList.astro';
|
|||
import ProjectList from '../components/ProjectList.astro';
|
||||
import TagList from '../components/TagList.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 latestPosts = posts.slice(0, 5);
|
||||
|
|
@ -11,15 +16,8 @@ const projects = await getProjects();
|
|||
const selectedProjects = projects.filter((project) => project.data.selected);
|
||||
const tags = getAllTags(posts);
|
||||
|
||||
const personJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Person',
|
||||
name: site.name,
|
||||
url: site.url,
|
||||
email: `mailto:${site.email}`,
|
||||
sameAs: [site.github, site.linkedin],
|
||||
description: site.description,
|
||||
};
|
||||
// Reference the canonical Person (defined on /about/) by @id.
|
||||
const personJsonLd = buildPersonJsonLd();
|
||||
---
|
||||
|
||||
<Base jsonLd={personJsonLd}>
|
||||
|
|
@ -28,8 +26,8 @@ const personJsonLd = {
|
|||
Software systems, AI deployment, graphics, simulations, and tools
|
||||
</p>
|
||||
<h1>
|
||||
<span class="home-name-accent">Andras Schmelczer</span> writes about building software
|
||||
that has to work under real constraints.
|
||||
Andras Schmelczer writes about building software that has to work under real
|
||||
constraints.
|
||||
</h1>
|
||||
<p>
|
||||
I am a software engineer with an MSc in Computer Science. This site is mostly a
|
||||
|
|
@ -38,7 +36,7 @@ const personJsonLd = {
|
|||
</p>
|
||||
</section>
|
||||
|
||||
<section class="home-section" aria-labelledby="latest-articles">
|
||||
<section class="home-section">
|
||||
<div class="section-heading">
|
||||
<h2 id="latest-articles">Latest Articles</h2>
|
||||
<a href="/articles/">All {posts.length} articles →</a>
|
||||
|
|
@ -46,15 +44,15 @@ const personJsonLd = {
|
|||
<ArticleList posts={latestPosts} />
|
||||
</section>
|
||||
|
||||
<section class="home-section" aria-labelledby="selected-projects">
|
||||
<section class="home-section">
|
||||
<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>
|
||||
</div>
|
||||
<ProjectList projects={selectedProjects} />
|
||||
</section>
|
||||
|
||||
<section class="home-section" aria-labelledby="browse-by-topic">
|
||||
<section class="home-section">
|
||||
<div class="section-heading">
|
||||
<h2 id="browse-by-topic">Browse by Topic</h2>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
---
|
||||
import ProjectList from '../../components/ProjectList.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 selected = projects.filter((project) => project.data.selected);
|
||||
const older = projects.filter((project) => !project.data.selected);
|
||||
|
||||
const jsonLd = {
|
||||
const collectionJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: `${site.name} — Projects`,
|
||||
|
|
@ -15,6 +15,23 @@ const jsonLd = {
|
|||
description:
|
||||
'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
|
||||
|
|
@ -22,12 +39,12 @@ const jsonLd = {
|
|||
description="A compact index of systems, tools, simulations, graphics experiments, games, and older work."
|
||||
jsonLd={jsonLd}
|
||||
>
|
||||
<section class="project-section" aria-labelledby="selected-projects">
|
||||
<section class="project-section">
|
||||
<h2 id="selected-projects">Selected Projects</h2>
|
||||
<ProjectList projects={selected} />
|
||||
</section>
|
||||
|
||||
<section class="project-section" aria-labelledby="older-projects">
|
||||
<section class="project-section">
|
||||
<h2 id="older-projects">Older and Smaller Projects</h2>
|
||||
<ProjectList projects={older} />
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,37 @@
|
|||
import rss from '@astrojs/rss';
|
||||
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) => {
|
||||
const posts = await getPublishedPosts();
|
||||
const feedUrl = absoluteUrl('/rss.xml');
|
||||
const channelImage = await optimizeOgImage(ogDefault);
|
||||
const channelImageUrl = absoluteUrl(channelImage.src);
|
||||
const creator = escapeXml(site.name);
|
||||
|
||||
return rss({
|
||||
title: site.name,
|
||||
|
|
@ -13,14 +40,24 @@ export const GET: APIRoute = async (context) => {
|
|||
xmlns: {
|
||||
atom: 'http://www.w3.org/2005/Atom',
|
||||
content: 'http://purl.org/rss/1.0/modules/content/',
|
||||
dc: 'http://purl.org/dc/elements/1.1/',
|
||||
},
|
||||
customData: [
|
||||
'<language>en-us</language>',
|
||||
`<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>`,
|
||||
`<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'),
|
||||
items: posts.map((post) => {
|
||||
const url = absoluteUrl(articlePath(post));
|
||||
// Stable tag: URI keeps the GUID constant across path renames
|
||||
// (e.g. the `/writing/` → `/articles/` migration). The date is the
|
||||
// original publish date so re-publishing won't change the GUID.
|
||||
const guid = `tag:schmelczer.dev,${isoDate(post.data.date)}:posts/${entrySlug(post)}`;
|
||||
const updated = post.data.updated
|
||||
? `<atom:updated>${post.data.updated.toISOString()}</atom:updated>`
|
||||
: '';
|
||||
|
|
@ -31,7 +68,13 @@ export const GET: APIRoute = async (context) => {
|
|||
link: url,
|
||||
author: `${site.email} (${site.name})`,
|
||||
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 allTags = getAllTags(posts);
|
||||
const filteredPosts = posts.filter((post) => post.data.tags.some((t) => t === tag));
|
||||
const title = `Articles tagged #${tag}`;
|
||||
const title = `Articles tagged "${tag}"`;
|
||||
const trail = [
|
||||
{ href: '/', label: 'Home' },
|
||||
{ href: '/articles/', label: 'Articles' },
|
||||
|
|
@ -36,5 +36,6 @@ const trail = [
|
|||
<TagList tags={allTags} currentTag={tag} labelled={false} />
|
||||
</nav>
|
||||
|
||||
<h2 class="sr-only">Articles</h2>
|
||||
<ArticleList posts={filteredPosts} currentTag={tag} />
|
||||
</Page>
|
||||
|
|
|
|||
|
|
@ -1,22 +1,57 @@
|
|||
---
|
||||
import TagList from '../../components/TagList.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 tags = getAllTags(posts);
|
||||
|
||||
const tagCounts = new Map<string, number>();
|
||||
const tagCounts: Record<string, number> = {};
|
||||
for (const post of posts) {
|
||||
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">
|
||||
{posts.length} articles across {tags.length} tags.
|
||||
</p>
|
||||
<TagList tags={tags} />
|
||||
<TagList tags={tags} counts={tagCounts} />
|
||||
</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 {
|
||||
color-scheme: light;
|
||||
color-scheme: light dark;
|
||||
|
||||
--font-sans:
|
||||
'Source Sans 3', Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||
'Segoe UI', sans-serif;
|
||||
--font-mono: 'IBM Plex Mono', 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
|
||||
|
||||
/* Light palette */
|
||||
--color-bg: #fbfaf7;
|
||||
--color-fg: #181817;
|
||||
--color-muted: #5f5c54;
|
||||
--color-link: #285f74;
|
||||
--color-link-hover: #8a4b2f;
|
||||
--color-link-visited: #3c5a7a;
|
||||
--color-accent: oklch(55% 0.13 15);
|
||||
--color-rule: #d9d5ca;
|
||||
--color-rule-medium: #a8a294;
|
||||
--color-rule-strong: #4a4340;
|
||||
--color-code-bg: #efede6;
|
||||
--color-callout-bg: #f4f1e8;
|
||||
--color-selection-bg: #ecddd0;
|
||||
/* Palette — light-dark() pairs each token (light, dark) */
|
||||
--color-bg: light-dark(#fbfaf7, #151514);
|
||||
--color-fg: light-dark(#181817, #f1eee7);
|
||||
--color-muted: light-dark(#4d4b44, #b7afa3);
|
||||
--color-link: light-dark(#285f74, #8ab8c8);
|
||||
--color-link-hover: light-dark(
|
||||
color-mix(in oklch, #285f74 70%, black 30%),
|
||||
color-mix(in oklch, #8ab8c8 70%, black 30%)
|
||||
);
|
||||
--color-link-visited: var(--color-link);
|
||||
--color-accent: light-dark(oklch(55% 0.13 15), oklch(72% 0.13 15));
|
||||
--color-rule: light-dark(#d9d5ca, #39352f);
|
||||
--color-rule-medium: light-dark(#7a7466, #6c655c);
|
||||
--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-icon-light: #f0e2b6;
|
||||
|
|
@ -56,16 +59,16 @@
|
|||
--fs-sm: 0.8125rem;
|
||||
--fs-caption: 0.875rem;
|
||||
--fs-base: 1rem;
|
||||
--fs-body: 1.125rem;
|
||||
--fs-body: 1.1875rem;
|
||||
--fs-lg: 1.25rem;
|
||||
--fs-xl: 1.6rem;
|
||||
--fs-xl: 1.75rem;
|
||||
--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);
|
||||
|
||||
--leading-tight: 1.18;
|
||||
--leading-snug: 1.35;
|
||||
--leading-prose: 1.65;
|
||||
--leading-prose: 1.6;
|
||||
|
||||
--weight-regular: 400;
|
||||
--weight-medium: 500;
|
||||
|
|
@ -97,53 +100,12 @@
|
|||
--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'] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme='light']) {
|
||||
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='dark'] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
|
|
@ -211,7 +173,6 @@
|
|||
color: var(--color-fg);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--fs-body);
|
||||
line-height: var(--leading-prose);
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow-wrap: break-word;
|
||||
|
|
@ -227,10 +188,6 @@
|
|||
transition: color 150ms ease;
|
||||
}
|
||||
|
||||
a:visited {
|
||||
color: var(--color-link-visited);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--color-link-hover);
|
||||
}
|
||||
|
|
@ -289,26 +246,27 @@
|
|||
========================================================================= */
|
||||
|
||||
@layer layout {
|
||||
.site-header,
|
||||
.site-footer,
|
||||
.home-intro,
|
||||
.home-section,
|
||||
.page-shell,
|
||||
.post,
|
||||
.post-footer-shell {
|
||||
:where(
|
||||
.site-header,
|
||||
.site-footer,
|
||||
.home-intro,
|
||||
.home-section,
|
||||
.page-shell,
|
||||
.post,
|
||||
.post-footer-shell
|
||||
) {
|
||||
width: min(100% - 2 * var(--gutter), var(--page));
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.post,
|
||||
.post-footer-shell {
|
||||
:where(.post, .post-footer-shell) {
|
||||
width: min(100% - 2 * var(--gutter), var(--measure-wide));
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
left: var(--gutter);
|
||||
top: var(--space-3);
|
||||
left: calc(var(--gutter) + env(safe-area-inset-left));
|
||||
top: calc(var(--space-3) + env(safe-area-inset-top));
|
||||
z-index: 10;
|
||||
transform: translateY(-150%);
|
||||
background: var(--color-fg);
|
||||
|
|
@ -435,13 +393,6 @@
|
|||
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),
|
||||
.page-header p,
|
||||
.dek {
|
||||
|
|
@ -500,15 +451,10 @@
|
|||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--space-4);
|
||||
border-top: 1px solid var(--color-rule);
|
||||
padding-top: var(--space-6);
|
||||
}
|
||||
|
||||
.section-heading h2,
|
||||
.archive-year h2,
|
||||
.project-section h2,
|
||||
.facts h2,
|
||||
.at-a-glance h2 {
|
||||
:where(.section-heading, .archive-year, .project-section, .facts, .at-a-glance) h2 {
|
||||
font-size: var(--fs-lg);
|
||||
font-weight: var(--weight-semibold);
|
||||
line-height: var(--leading-snug);
|
||||
|
|
@ -638,7 +584,7 @@
|
|||
|
||||
.article-list > li {
|
||||
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';
|
||||
align-items: center;
|
||||
gap: var(--space-5);
|
||||
|
|
@ -654,6 +600,7 @@
|
|||
grid-area: date;
|
||||
color: var(--color-muted);
|
||||
font-size: var(--fs-caption);
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.article-list > li > div {
|
||||
|
|
@ -732,6 +679,7 @@
|
|||
grid-template-areas: 'thumb summary';
|
||||
min-height: var(--project-thumb-size);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-rule);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-bg);
|
||||
|
|
@ -909,12 +857,17 @@
|
|||
|
||||
.prose {
|
||||
max-inline-size: var(--measure);
|
||||
line-height: var(--leading-prose);
|
||||
}
|
||||
|
||||
.prose > * + * {
|
||||
margin-top: 1.05em;
|
||||
}
|
||||
|
||||
.prose p {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.prose h2,
|
||||
.prose h3 {
|
||||
position: relative;
|
||||
|
|
@ -1039,7 +992,7 @@
|
|||
.prose pre {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-gutter: stable;
|
||||
padding: var(--space-4);
|
||||
background: var(--color-code-bg);
|
||||
border: 1px solid var(--color-rule);
|
||||
|
|
@ -1068,28 +1021,16 @@
|
|||
}
|
||||
|
||||
/* 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 code,
|
||||
.prose pre.astro-code span {
|
||||
color: var(--shiki-light);
|
||||
background-color: var(--shiki-light-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));
|
||||
}
|
||||
color: light-dark(var(--shiki-light), var(--shiki-dark));
|
||||
background-color: light-dark(
|
||||
var(--shiki-light-bg, var(--color-code-bg)),
|
||||
var(--shiki-dark-bg, var(--color-code-bg))
|
||||
);
|
||||
}
|
||||
|
||||
/* -- At-a-glance + facts --------------------------------------------- */
|
||||
|
|
@ -1388,7 +1329,6 @@
|
|||
padding-block: var(--space-8) var(--space-6);
|
||||
}
|
||||
|
||||
.article-list > li,
|
||||
.at-a-glance dl,
|
||||
.facts dl {
|
||||
grid-template-columns: 1fr;
|
||||
|
|
@ -1416,6 +1356,10 @@
|
|||
--project-thumb-size: 7rem;
|
||||
}
|
||||
|
||||
.project-card .project-meta {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.project-card__summary {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
|
@ -1458,18 +1402,10 @@
|
|||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
::view-transition-group(*),
|
||||
::view-transition-old(*),
|
||||
::view-transition-new(*) {
|
||||
animation: none !important;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1481,7 +1417,6 @@
|
|||
--color-muted: #333;
|
||||
--color-link: #000;
|
||||
--color-link-hover: #000;
|
||||
--color-link-visited: #000;
|
||||
--color-accent: #000;
|
||||
--color-rule: #999;
|
||||
--color-rule-medium: #777;
|
||||
|
|
@ -1503,7 +1438,7 @@
|
|||
.post-nav,
|
||||
.related-posts,
|
||||
.heading-anchor {
|
||||
display: none !important;
|
||||
display: none;
|
||||
}
|
||||
|
||||
main {
|
||||
|
|
@ -1512,14 +1447,14 @@
|
|||
|
||||
a,
|
||||
a:visited {
|
||||
color: #000;
|
||||
color: var(--color-fg);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.prose a[href]::after {
|
||||
content: ' (' attr(href) ')';
|
||||
font-size: 0.85em;
|
||||
color: #555;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.prose a[href^='#']::after,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,6 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"types": ["astro/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
"types": ["astro/client"]
|
||||
}
|
||||
}
|
||||
|
|
|
|||