More AI
This commit is contained in:
parent
f3fc893675
commit
bb5b4c4cf3
43 changed files with 585 additions and 524 deletions
|
|
@ -35,8 +35,13 @@ jobs:
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Build
|
- name: Typecheck
|
||||||
run: npm run build
|
run: npm run typecheck
|
||||||
|
|
||||||
|
- name: Build & QA
|
||||||
|
run: |
|
||||||
|
npx playwright install chromium
|
||||||
|
npm run qa
|
||||||
|
|
||||||
- name: Copy build to host pages mount
|
- name: Copy build to host pages mount
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
|
|
||||||
|
|
@ -83,10 +83,11 @@ export default defineConfig({
|
||||||
behavior: 'append',
|
behavior: 'append',
|
||||||
properties: {
|
properties: {
|
||||||
className: ['heading-anchor'],
|
className: ['heading-anchor'],
|
||||||
'aria-hidden': 'true',
|
ariaLabel: 'Permalink',
|
||||||
tabIndex: -1,
|
|
||||||
},
|
},
|
||||||
content: { type: 'text', value: '#' },
|
// Glyph rendered via CSS ::before so it doesn't leak into the TOC
|
||||||
|
// when astro:content extracts heading.text from the rendered HTML.
|
||||||
|
content: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,11 @@
|
||||||
"typecheck": "astro check",
|
"typecheck": "astro check",
|
||||||
"lint": "prettier --check \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/*.mjs\" \"*.md\" \"*.json\"",
|
"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\"",
|
||||||
"format:check": "prettier --check \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/*.mjs\" \"*.md\" \"*.json\"",
|
|
||||||
"build": "astro build",
|
"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",
|
||||||
"qa": "npm run build && npm run qa:no-js && npm run qa:overflow"
|
"qa": "npm run typecheck && npm run lint && npm run build && npm run qa:no-js && npm run qa:overflow"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,9 @@ const { posts, showYear = true, currentTag } = Astro.props;
|
||||||
|
|
||||||
<ol class="article-list">
|
<ol class="article-list">
|
||||||
{
|
{
|
||||||
posts.map((post) => {
|
posts.map((post, index) => {
|
||||||
const href = articlePath(post);
|
const href = articlePath(post);
|
||||||
|
const isFirst = index === 0;
|
||||||
return (
|
return (
|
||||||
<li>
|
<li>
|
||||||
<time datetime={post.data.date.toISOString()}>
|
<time datetime={post.data.date.toISOString()}>
|
||||||
|
|
@ -36,6 +37,8 @@ const { posts, showYear = true, currentTag } = Astro.props;
|
||||||
class="article-thumbnail"
|
class="article-thumbnail"
|
||||||
widths={[120, 180, 240, 320, 480]}
|
widths={[120, 180, 240, 320, 480]}
|
||||||
sizes="(max-width: 700px) clamp(64px, 22vw, 80px), (max-width: 960px) 7rem, 8rem"
|
sizes="(max-width: 700px) clamp(64px, 22vw, 80px), (max-width: 960px) 7rem, 8rem"
|
||||||
|
loading={isFirst ? 'eager' : 'lazy'}
|
||||||
|
fetchpriority={isFirst ? 'high' : undefined}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,9 @@ const {
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
|
|
||||||
const Tag = href ? 'a' : 'div';
|
const Tag = href ? 'a' : 'div';
|
||||||
const resolvedFallback: FallbackFormat =
|
// Listing thumbnails are screenshots with no required transparency; force JPG
|
||||||
fallbackFormat ?? (src.format === 'png' ? 'png' : 'jpg');
|
// fallback to avoid shipping multi-hundred-KB PNG derivatives.
|
||||||
|
const resolvedFallback: FallbackFormat = fallbackFormat ?? 'jpg';
|
||||||
const isDecorativeLink = Boolean(href) && decorative;
|
const isDecorativeLink = Boolean(href) && decorative;
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,8 @@ 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
|
// Footer shows all nav items except Home (which is implicit via the site title).
|
||||||
// derive footer items locally. Footer mirrors Header (Home filtered out) and
|
const footerNavItems = navItems.filter((item) => item.href !== '/');
|
||||||
// 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">
|
||||||
|
|
@ -25,19 +19,21 @@ const footerNavItems = [
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
<address>
|
<ul class="footer-meta">
|
||||||
<ul class="footer-meta">
|
<li><span>© {year} {site.name}</span></li>
|
||||||
<li><span>© {year} {site.name}</span></li>
|
<li>
|
||||||
<li><a href={`mailto:${site.email}`}>Email</a></li>
|
<address>
|
||||||
<li>
|
<a href={`mailto:${site.email}`}>Email</a>
|
||||||
<a href={site.cv} rel="noopener noreferrer">CV</a>
|
</address>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href={site.github} rel="noopener noreferrer me">GitHub</a>
|
<a href={site.cv} rel="noopener">CV</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href={site.linkedin} rel="noopener noreferrer me">LinkedIn</a>
|
<a href={site.github} rel="noopener me">GitHub</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
<li>
|
||||||
</address>
|
<a href={site.linkedin} rel="noopener me">LinkedIn</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
|
||||||
|
|
@ -3,28 +3,28 @@ import { navItems, site } from '../lib/site';
|
||||||
|
|
||||||
const current = Astro.url.pathname;
|
const current = Astro.url.pathname;
|
||||||
|
|
||||||
function isCurrent(href: string) {
|
// Exact match for the current page; section match (descendant URLs) for
|
||||||
if (href === '/') return current === '/';
|
// ancestor links. `aria-current="page"` is reserved for the exact page,
|
||||||
return current.startsWith(href);
|
// `"true"` indicates an ancestor section.
|
||||||
|
function currentState(href: string): 'page' | 'true' | undefined {
|
||||||
|
if (current === href) return 'page';
|
||||||
|
if (href !== '/' && current.startsWith(href)) return 'true';
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Local fallback: Agent 3 hasn't shipped `footerItems`/`footerOnly` yet, so we
|
// Header shows nav items except Home and footer-only entries. RSS lives as a
|
||||||
// derive header items locally. Header shows nav (minus Home) + Tags. RSS lives
|
// dedicated icon link to the right of the nav.
|
||||||
// in the header as a dedicated icon link.
|
const headerNavItems = navItems.filter((item) => item.href !== '/' && !item.footerOnly);
|
||||||
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>
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<a class="site-title" href="/">{site.name}</a>
|
<a class="site-title" href="/" aria-current={currentState('/')}>{site.name}</a>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<nav class="site-nav" aria-label="Primary">
|
<nav class="site-nav" aria-label="Primary">
|
||||||
{
|
{
|
||||||
headerNavItems.map((item) => (
|
headerNavItems.map((item) => (
|
||||||
<a href={item.href} aria-current={isCurrent(item.href) ? 'page' : undefined}>
|
<a href={item.href} aria-current={currentState(item.href)}>
|
||||||
{item.label}
|
{item.label}
|
||||||
</a>
|
</a>
|
||||||
))
|
))
|
||||||
|
|
@ -112,8 +112,15 @@ const headerNavItems = [
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
min-block-size: 44px;
|
||||||
|
min-inline-size: 44px;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
|
transition: color 150ms ease;
|
||||||
|
}
|
||||||
|
.rss-link:hover,
|
||||||
|
.rss-link:focus-visible {
|
||||||
|
color: var(--color-link-hover);
|
||||||
}
|
}
|
||||||
.rss-icon {
|
.rss-icon {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
import type { CollectionEntry } from 'astro:content';
|
import type { CollectionEntry } from 'astro:content';
|
||||||
import { Picture } from 'astro:assets';
|
import PostMediaFigure from './PostMediaFigure.astro';
|
||||||
|
|
||||||
type MediaItem = CollectionEntry<'posts'>['data']['media'][number];
|
type MediaItem = CollectionEntry<'posts'>['data']['media'][number];
|
||||||
|
|
||||||
|
|
@ -10,76 +10,21 @@ interface Props {
|
||||||
|
|
||||||
const { items } = Astro.props;
|
const { items } = Astro.props;
|
||||||
|
|
||||||
const fallbackFormatFor = (format: string | undefined): 'png' | 'jpg' =>
|
// Wrap in a gallery `<ul>` when there's more than one item; otherwise the
|
||||||
format === 'png' ? 'png' : 'jpg';
|
// figures sit directly in the post flow.
|
||||||
|
const isGallery = items.length > 1;
|
||||||
---
|
---
|
||||||
|
|
||||||
{
|
{
|
||||||
items.length > 1 ? (
|
isGallery ? (
|
||||||
<ul role="list" class="post-gallery">
|
<ul role="list" class="post-gallery">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<li>
|
<li>
|
||||||
<figure class:list={['post-media', item.role === 'inline' && 'figure-inline']}>
|
<PostMediaFigure item={item} />
|
||||||
{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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
items.map((item) => (
|
items.map((item) => <PostMediaFigure item={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>
|
|
||||||
))
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
46
src/components/PostMediaFigure.astro
Normal file
46
src/components/PostMediaFigure.astro
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
---
|
||||||
|
import type { CollectionEntry } from 'astro:content';
|
||||||
|
import { Picture } from 'astro:assets';
|
||||||
|
|
||||||
|
type MediaItem = CollectionEntry<'posts'>['data']['media'][number];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: MediaItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { item } = Astro.props;
|
||||||
|
|
||||||
|
const fallbackFormatFor = (format: string | undefined): 'png' | 'jpg' =>
|
||||||
|
format === 'png' ? 'png' : 'jpg';
|
||||||
|
---
|
||||||
|
|
||||||
|
<figure class="post-media">
|
||||||
|
{
|
||||||
|
item.type === 'video' ? (
|
||||||
|
<video
|
||||||
|
controls
|
||||||
|
preload="none"
|
||||||
|
poster={item.poster?.src}
|
||||||
|
{...(item.decorative ? { 'aria-hidden': 'true' } : { 'aria-label': item.alt })}
|
||||||
|
>
|
||||||
|
{item.webm && <source src={item.webm} type="video/webm" />}
|
||||||
|
{item.mp4 && <source src={item.mp4} type="video/mp4" />}
|
||||||
|
</video>
|
||||||
|
) : (
|
||||||
|
item.src && (
|
||||||
|
<Picture
|
||||||
|
src={item.src}
|
||||||
|
alt={item.decorative ? '' : (item.alt ?? '')}
|
||||||
|
formats={['avif', 'webp']}
|
||||||
|
fallbackFormat={fallbackFormatFor(item.src.format)}
|
||||||
|
widths={[480, 720, 960, 1280, 1600, 1920, 2400]}
|
||||||
|
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 && !item.decorative && <figcaption>{item.caption}</figcaption>}
|
||||||
|
{item.transcript && <p class="media-transcript">{item.transcript}</p>}
|
||||||
|
</figure>
|
||||||
|
|
@ -16,7 +16,7 @@ function isExternal(url: string) {
|
||||||
|
|
||||||
{
|
{
|
||||||
links.length > 0 && (
|
links.length > 0 && (
|
||||||
<ul class="project-links" aria-label="Project links">
|
<ul class="project-links">
|
||||||
{links.map((link) => (
|
{links.map((link) => (
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
|
|
@ -27,30 +27,35 @@ function isExternal(url: string) {
|
||||||
>
|
>
|
||||||
{link.label}
|
{link.label}
|
||||||
{isExternal(link.url) && (
|
{isExternal(link.url) && (
|
||||||
<svg
|
<>
|
||||||
class="external-link-icon"
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
class="external-link-icon"
|
||||||
width="0.85em"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
height="0.85em"
|
width="0.85em"
|
||||||
viewBox="0 0 24 24"
|
height="0.85em"
|
||||||
fill="none"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
fill="none"
|
||||||
stroke-width="2"
|
stroke="currentColor"
|
||||||
stroke-linecap="round"
|
stroke-width="2"
|
||||||
stroke-linejoin="round"
|
stroke-linecap="round"
|
||||||
aria-hidden="true"
|
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" />
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||||
<line x1="10" y1="14" x2="21" y2="3" />
|
<polyline points="15 3 21 3 21 9" />
|
||||||
</svg>
|
<line x1="10" y1="14" x2="21" y2="3" />
|
||||||
|
</svg>
|
||||||
|
<span class="sr-only">(opens in new tab)</span>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{link.download && (
|
{link.download && (
|
||||||
<span class="download-indicator" aria-hidden="true">
|
<>
|
||||||
↓
|
<span class="download-indicator" aria-hidden="true">
|
||||||
</span>
|
↓
|
||||||
|
</span>
|
||||||
|
<span class="sr-only">(download)</span>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{link.download && <span class="sr-only">(download)</span>}
|
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -12,57 +12,26 @@ 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(
|
// The `essay` field is a `reference('posts')`, so when present it's always a
|
||||||
essay: CollectionEntry<'projects'>['data']['essay']
|
// `{ collection, id }` shape that `getEntry` resolves to a CollectionEntry.
|
||||||
): 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>();
|
const essayHrefs = new Map<string, string>();
|
||||||
for (const project of projects) {
|
for (const project of projects) {
|
||||||
const href = await resolveEssayHref(project.data.essay);
|
const essay = project.data.essay;
|
||||||
if (href) essayHrefs.set(project.id, href);
|
if (!essay) continue;
|
||||||
|
const resolved = await getEntry(essay);
|
||||||
|
if (resolved) essayHrefs.set(project.id, articlePath(resolved));
|
||||||
}
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
<ol class="project-list">
|
<ol class="project-list">
|
||||||
{
|
{
|
||||||
projects.map((project) => {
|
projects.map((project, index) => {
|
||||||
const anchor = projectAnchor(project);
|
const anchor = projectAnchor(project);
|
||||||
const titleId = `${anchor}-title`;
|
const titleId = `${anchor}-title`;
|
||||||
const essayHref = essayHrefs.get(project.id);
|
const essayHref = essayHrefs.get(project.id);
|
||||||
const essayLink: ProjectLink | undefined = essayHref
|
|
||||||
? { label: 'Article', type: 'site', url: essayHref }
|
|
||||||
: undefined;
|
|
||||||
const primaryHref = essayHref ?? project.data.links[0]?.url;
|
const primaryHref = essayHref ?? project.data.links[0]?.url;
|
||||||
const links: ProjectLink[] = [
|
const links: ProjectLink[] = project.data.links;
|
||||||
...(essayLink ? [essayLink] : []),
|
const isFirst = index === 0;
|
||||||
...project.data.links,
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li class="project-card" id={anchor}>
|
<li class="project-card" id={anchor}>
|
||||||
|
|
@ -73,6 +42,8 @@ for (const project of projects) {
|
||||||
class="project-thumbnail"
|
class="project-thumbnail"
|
||||||
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"
|
||||||
|
loading={isFirst ? 'eager' : 'lazy'}
|
||||||
|
fetchpriority={isFirst ? 'high' : undefined}
|
||||||
/>
|
/>
|
||||||
<article class="project-card__summary">
|
<article class="project-card__summary">
|
||||||
<h3 id={titleId}>
|
<h3 id={titleId}>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { tagPath } from '../lib/site';
|
||||||
interface Props {
|
interface Props {
|
||||||
tags: readonly string[];
|
tags: readonly string[];
|
||||||
currentTag?: string;
|
currentTag?: string;
|
||||||
labelled?: boolean;
|
|
||||||
limit?: number;
|
limit?: number;
|
||||||
counts?: Record<string, number>;
|
counts?: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
@ -20,7 +19,7 @@ const remaining =
|
||||||
{
|
{
|
||||||
visibleTags.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 ? 'true' : undefined}>
|
||||||
{tag}
|
{tag}
|
||||||
{counts && counts[tag] !== undefined && (
|
{counts && counts[tag] !== undefined && (
|
||||||
<span class="tag-count">{counts[tag]}</span>
|
<span class="tag-count">{counts[tag]}</span>
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,6 @@ import { z } from 'astro/zod';
|
||||||
|
|
||||||
const linkSchema = z.object({
|
const linkSchema = z.object({
|
||||||
label: z.string(),
|
label: z.string(),
|
||||||
type: z.enum([
|
|
||||||
'source',
|
|
||||||
'demo',
|
|
||||||
'package',
|
|
||||||
'paper',
|
|
||||||
'thesis',
|
|
||||||
'video',
|
|
||||||
'site',
|
|
||||||
'contact',
|
|
||||||
]),
|
|
||||||
url: z.string(),
|
url: z.string(),
|
||||||
download: z.boolean().optional(),
|
download: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
@ -37,7 +27,6 @@ const mediaSchema = ({ image }: SchemaContext) =>
|
||||||
decorative: z.boolean().optional(),
|
decorative: z.boolean().optional(),
|
||||||
caption: z.string().optional(),
|
caption: z.string().optional(),
|
||||||
transcript: z.string().optional(),
|
transcript: z.string().optional(),
|
||||||
role: z.enum(['evidence', 'og', 'inline']).default('evidence'),
|
|
||||||
})
|
})
|
||||||
.refine((item) => item.decorative || (Boolean(item.alt) && Boolean(item.caption)), {
|
.refine((item) => item.decorative || (Boolean(item.alt) && Boolean(item.caption)), {
|
||||||
message: 'Meaningful media needs both alt text and a caption.',
|
message: 'Meaningful media needs both alt text and a caption.',
|
||||||
|
|
@ -89,7 +78,6 @@ const projects = defineCollection({
|
||||||
thumbnail: thumbnailSchema({ image }),
|
thumbnail: thumbnailSchema({ image }),
|
||||||
period: z.string(),
|
period: z.string(),
|
||||||
sortDate: z.coerce.date(),
|
sortDate: z.coerce.date(),
|
||||||
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: reference('posts').optional(),
|
essay: reference('posts').optional(),
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,10 @@ thumbnail:
|
||||||
alt: The Ad Astra handheld game running on its OLED display.
|
alt: The Ad Astra handheld game running on its OLED display.
|
||||||
period: 'Spring 2020'
|
period: 'Spring 2020'
|
||||||
sortDate: 2020-04-01
|
sortDate: 2020-04-01
|
||||||
status: Embedded game engine
|
|
||||||
technologies: ['C', 'ATtiny85V', 'OLED', 'EEPROM', 'PCB design']
|
technologies: ['C', 'ATtiny85V', 'OLED', 'EEPROM', 'PCB design']
|
||||||
selected: true
|
selected: true
|
||||||
essay: ad-astra-attiny85-game-engine
|
essay: ad-astra-attiny85-game-engine
|
||||||
legacyAnchor: embedded-game-engine
|
|
||||||
links:
|
links:
|
||||||
- label: Source
|
- label: Source
|
||||||
type: source
|
|
||||||
url: https://github.com/schmelczer/ad_astra
|
url: https://github.com/schmelczer/ad_astra
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,10 @@ thumbnail:
|
||||||
alt: Screenshot of the Avoid canvas game.
|
alt: Screenshot of the Avoid canvas game.
|
||||||
period: 'January 2018'
|
period: 'January 2018'
|
||||||
sortDate: 2018-01-01
|
sortDate: 2018-01-01
|
||||||
status: Early web game
|
|
||||||
technologies: ['JavaScript', 'Canvas']
|
technologies: ['JavaScript', 'Canvas']
|
||||||
selected: false
|
selected: false
|
||||||
essay: avoid-early-web-game
|
essay: avoid-early-web-game
|
||||||
legacyAnchor: avoid
|
|
||||||
links:
|
links:
|
||||||
- label: Demo
|
- label: Demo
|
||||||
type: demo
|
|
||||||
url: https://schmelczer.dev/avoid
|
url: https://schmelczer.dev/avoid
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,8 @@ thumbnail:
|
||||||
alt: Screenshot of a Unity city traffic simulation.
|
alt: Screenshot of a Unity city traffic simulation.
|
||||||
period: 'July-August 2018'
|
period: 'July-August 2018'
|
||||||
sortDate: 2018-08-01
|
sortDate: 2018-08-01
|
||||||
status: Simulation
|
|
||||||
technologies: ['Unity', 'C#', 'REST API', 'Blender']
|
technologies: ['Unity', 'C#', 'REST API', 'Blender']
|
||||||
selected: false
|
selected: false
|
||||||
essay: city-simulation-unity-traffic
|
essay: city-simulation-unity-traffic
|
||||||
legacyAnchor: city-simulation-unity
|
|
||||||
links: []
|
links: []
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,8 @@ thumbnail:
|
||||||
alt: Screenshot of a colour grading interface applied to a photograph.
|
alt: Screenshot of a colour grading interface applied to a photograph.
|
||||||
period: 'June 2018'
|
period: 'June 2018'
|
||||||
sortDate: 2018-06-01
|
sortDate: 2018-06-01
|
||||||
status: UI experiment
|
|
||||||
technologies: ['JavaScript', 'Canvas', 'Image processing']
|
technologies: ['JavaScript', 'Canvas', 'Image processing']
|
||||||
selected: false
|
selected: false
|
||||||
essay: photo-colour-grader
|
essay: photo-colour-grader
|
||||||
legacyAnchor: photo-colour-grader
|
|
||||||
links: []
|
links: []
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -7,20 +7,15 @@ thumbnail:
|
||||||
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
|
||||||
status: Thesis project and browser game
|
|
||||||
technologies: ['TypeScript', 'Node.js', 'WebSockets', 'Firebase', 'WebGL']
|
technologies: ['TypeScript', 'Node.js', 'WebSockets', 'Firebase', 'WebGL']
|
||||||
selected: true
|
selected: true
|
||||||
essay: declared-shared-simulation-code
|
essay: declared-shared-simulation-code
|
||||||
legacyAnchor: multiplayer-mobile-game
|
|
||||||
links:
|
links:
|
||||||
- label: Source
|
- label: Source
|
||||||
type: source
|
|
||||||
url: https://github.com/schmelczer/decla.red
|
url: https://github.com/schmelczer/decla.red
|
||||||
- label: Demo
|
- label: Demo
|
||||||
type: demo
|
|
||||||
url: https://decla.red
|
url: https://decla.red
|
||||||
- label: BSc thesis
|
- label: BSc thesis
|
||||||
type: thesis
|
|
||||||
url: /media/downloads/sdf2d-andras-schmelczer.pdf
|
url: /media/downloads/sdf2d-andras-schmelczer.pdf
|
||||||
download: true
|
download: true
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,8 @@ thumbnail:
|
||||||
alt: Chart from a foreign exchange prediction experiment.
|
alt: Chart from a foreign exchange prediction experiment.
|
||||||
period: 'Autumn 2019'
|
period: 'Autumn 2019'
|
||||||
sortDate: 2019-10-01
|
sortDate: 2019-10-01
|
||||||
status: Experiment
|
|
||||||
technologies: ['Python', 'NumPy', 'SciPy', 'Flask', 'MQL4']
|
technologies: ['Python', 'NumPy', 'SciPy', 'Flask', 'MQL4']
|
||||||
selected: false
|
selected: false
|
||||||
essay: foreign-exchange-prediction-experiment
|
essay: foreign-exchange-prediction-experiment
|
||||||
legacyAnchor: predicting-foreign-exchange-rates
|
|
||||||
links: []
|
links: []
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -7,20 +7,15 @@ thumbnail:
|
||||||
alt: Example Python code using the GreatAI API.
|
alt: Example Python code using the GreatAI API.
|
||||||
period: '2022'
|
period: '2022'
|
||||||
sortDate: 2022-01-01
|
sortDate: 2022-01-01
|
||||||
status: Research project and framework
|
|
||||||
technologies: ['Python', 'ML deployment', 'API design']
|
technologies: ['Python', 'ML deployment', 'API design']
|
||||||
selected: true
|
selected: true
|
||||||
essay: greatai-ai-deployment-api
|
essay: greatai-ai-deployment-api
|
||||||
legacyAnchor: great-ai-ai-deployment-framework
|
|
||||||
links:
|
links:
|
||||||
- label: PyPI
|
- label: PyPI
|
||||||
type: package
|
|
||||||
url: https://pypi.org/project/great-ai/
|
url: https://pypi.org/project/great-ai/
|
||||||
- label: Project site
|
- label: Project site
|
||||||
type: site
|
|
||||||
url: https://great-ai.scoutinscience.com
|
url: https://great-ai.scoutinscience.com
|
||||||
- label: MSc thesis
|
- label: MSc thesis
|
||||||
type: thesis
|
|
||||||
url: /media/downloads/great-ai-andras-schmelczer.pdf
|
url: /media/downloads/great-ai-andras-schmelczer.pdf
|
||||||
download: true
|
download: true
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,8 @@ thumbnail:
|
||||||
alt: RGB LED strips glowing from a music synchronization project.
|
alt: RGB LED strips glowing from a music synchronization project.
|
||||||
period: 'Spring 2016'
|
period: 'Spring 2016'
|
||||||
sortDate: 2016-04-01
|
sortDate: 2016-04-01
|
||||||
status: Early hardware/software project
|
|
||||||
technologies: ['Python', 'NumPy', 'FFT', 'Raspberry Pi', 'Vanilla web']
|
technologies: ['Python', 'NumPy', 'FFT', 'Raspberry Pi', 'Vanilla web']
|
||||||
selected: false
|
selected: false
|
||||||
essay: lights-synchronized-to-music
|
essay: lights-synchronized-to-music
|
||||||
legacyAnchor: lights-synchronised-to-music
|
|
||||||
links: []
|
links: []
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,10 @@ thumbnail:
|
||||||
alt: Screenshot of the My Notes Android markdown app.
|
alt: Screenshot of the My Notes Android markdown app.
|
||||||
period: 'November 2019'
|
period: 'November 2019'
|
||||||
sortDate: 2019-11-01
|
sortDate: 2019-11-01
|
||||||
status: Android app
|
|
||||||
technologies: ['Android', 'Kotlin/Java', 'Markdown', 'Markwon']
|
technologies: ['Android', 'Kotlin/Java', 'Markdown', 'Markwon']
|
||||||
selected: false
|
selected: false
|
||||||
essay: my-notes-android-markdown-app
|
essay: my-notes-android-markdown-app
|
||||||
legacyAnchor: my-notes-android-app
|
|
||||||
links:
|
links:
|
||||||
- label: Source
|
- label: Source
|
||||||
type: source
|
|
||||||
url: https://github.com/schmelczer/my-notes
|
url: https://github.com/schmelczer/my-notes
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,8 @@ thumbnail:
|
||||||
alt: JavaFX editor interface for the cooling system simulator input graph.
|
alt: JavaFX editor interface for the cooling system simulator input graph.
|
||||||
period: 'October-November 2018'
|
period: 'October-November 2018'
|
||||||
sortDate: 2018-10-15
|
sortDate: 2018-10-15
|
||||||
status: Input editor
|
|
||||||
technologies: ['JavaFX', 'JSON', 'REST API']
|
technologies: ['JavaFX', 'JSON', 'REST API']
|
||||||
selected: false
|
selected: false
|
||||||
essay: graph-editor-javafx-simulation-input
|
essay: graph-editor-javafx-simulation-input
|
||||||
legacyAnchor: graph-editor-javafx
|
|
||||||
links: []
|
links: []
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,8 @@ thumbnail:
|
||||||
alt: Cooling system simulator interface with pipes, pumps, and temperature values.
|
alt: Cooling system simulator interface with pipes, pumps, and temperature values.
|
||||||
period: 'October-November 2018'
|
period: 'October-November 2018'
|
||||||
sortDate: 2018-11-01
|
sortDate: 2018-11-01
|
||||||
status: Simulation and editor
|
|
||||||
technologies: ['Python', 'Flask', 'NumPy', 'HTML canvas', 'JavaFX']
|
technologies: ['Python', 'Flask', 'NumPy', 'HTML canvas', 'JavaFX']
|
||||||
selected: true
|
selected: true
|
||||||
essay: nuclear-cooling-simulation
|
essay: nuclear-cooling-simulation
|
||||||
legacyAnchor: simulating-the-cooling-system-of-a-nuclear-facility
|
|
||||||
links: []
|
links: []
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,10 @@ thumbnail:
|
||||||
alt: Screenshot of a generated photography site.
|
alt: Screenshot of a generated photography site.
|
||||||
period: 'Summer 2016'
|
period: 'Summer 2016'
|
||||||
sortDate: 2016-07-01
|
sortDate: 2016-07-01
|
||||||
status: Static site generator
|
|
||||||
technologies: ['Webpack', 'Image processing', 'Static site generation']
|
technologies: ['Webpack', 'Image processing', 'Static site generation']
|
||||||
selected: false
|
selected: false
|
||||||
essay: photo-site-generator
|
essay: photo-site-generator
|
||||||
legacyAnchor: photos
|
|
||||||
links:
|
links:
|
||||||
- label: Site
|
- label: Site
|
||||||
type: site
|
|
||||||
url: https://photo.schmelczer.dev
|
url: https://photo.schmelczer.dev
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,8 @@ thumbnail:
|
||||||
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
|
||||||
status: Early game project
|
|
||||||
technologies: ['C', 'SDL 1.2', 'Voxel terrain']
|
technologies: ['C', 'SDL 1.2', 'Voxel terrain']
|
||||||
selected: false
|
selected: false
|
||||||
essay: platform-game-c-sdl
|
essay: platform-game-c-sdl
|
||||||
legacyAnchor: platform-game
|
|
||||||
links: []
|
links: []
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -7,23 +7,17 @@ thumbnail:
|
||||||
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
|
||||||
status: Thesis project and NPM package
|
|
||||||
technologies: ['TypeScript', 'WebGL', 'WebGL2', 'SDF rendering']
|
technologies: ['TypeScript', 'WebGL', 'WebGL2', 'SDF rendering']
|
||||||
selected: true
|
selected: true
|
||||||
essay: sdf-2d-ray-tracing
|
essay: sdf-2d-ray-tracing
|
||||||
legacyAnchor: optimising-2d-ray-tracing
|
|
||||||
links:
|
links:
|
||||||
- label: NPM package
|
- label: NPM package
|
||||||
type: package
|
|
||||||
url: https://www.npmjs.com/package/sdf-2d
|
url: https://www.npmjs.com/package/sdf-2d
|
||||||
- label: Demo
|
- label: Demo
|
||||||
type: demo
|
|
||||||
url: https://sdf2d.schmelczer.dev
|
url: https://sdf2d.schmelczer.dev
|
||||||
- label: Video
|
- label: Video
|
||||||
type: video
|
|
||||||
url: https://www.youtube.com/watch?v=K3cEtnZUNR0
|
url: https://www.youtube.com/watch?v=K3cEtnZUNR0
|
||||||
- label: BSc thesis
|
- label: BSc thesis
|
||||||
type: thesis
|
|
||||||
url: /media/downloads/sdf2d-andras-schmelczer.pdf
|
url: /media/downloads/sdf2d-andras-schmelczer.pdf
|
||||||
download: true
|
download: true
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -7,16 +7,12 @@ thumbnail:
|
||||||
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
|
||||||
status: Full-stack web app
|
|
||||||
technologies: ['Python', 'Angular', 'State synchronization']
|
technologies: ['Python', 'Angular', 'State synchronization']
|
||||||
selected: true
|
selected: true
|
||||||
essay: life-towers-immutable-tries
|
essay: life-towers-immutable-tries
|
||||||
legacyAnchor: multi-device-life-tracking
|
|
||||||
links:
|
links:
|
||||||
- label: Source
|
- label: Source
|
||||||
type: source
|
|
||||||
url: https://github.com/schmelczer/life-towers/
|
url: https://github.com/schmelczer/life-towers/
|
||||||
- label: Demo
|
- label: Demo
|
||||||
type: demo
|
|
||||||
url: https://towers.schmelczer.dev
|
url: https://towers.schmelczer.dev
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import Footer from '../components/Footer.astro';
|
||||||
import Header from '../components/Header.astro';
|
import Header from '../components/Header.astro';
|
||||||
import { absoluteUrl, optimizeOgImage, 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 themeInit from '../scripts/theme-init.js?raw';
|
||||||
import '../styles/global.css';
|
import '../styles/global.css';
|
||||||
|
|
||||||
interface ArticleMeta {
|
interface ArticleMeta {
|
||||||
|
|
@ -48,8 +48,8 @@ const ogTitle = isRoot ? site.title : title;
|
||||||
const canonical = absoluteUrl(canonicalPath);
|
const canonical = absoluteUrl(canonicalPath);
|
||||||
|
|
||||||
let resolvedOgImage = ogImage;
|
let resolvedOgImage = ogImage;
|
||||||
let resolvedOgWidth = ogImageWidth;
|
let resolvedOgWidth = ogImageWidth ?? 1200;
|
||||||
let resolvedOgHeight = ogImageHeight;
|
let resolvedOgHeight = ogImageHeight ?? 630;
|
||||||
|
|
||||||
if (!resolvedOgImage) {
|
if (!resolvedOgImage) {
|
||||||
const generated = await optimizeOgImage(defaultOg);
|
const generated = await optimizeOgImage(defaultOg);
|
||||||
|
|
@ -62,6 +62,64 @@ const ogImageUrl = resolvedOgImage.startsWith('http')
|
||||||
? resolvedOgImage
|
? resolvedOgImage
|
||||||
: absoluteUrl(resolvedOgImage);
|
: absoluteUrl(resolvedOgImage);
|
||||||
const jsonLdEntries = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
|
const jsonLdEntries = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
|
||||||
|
|
||||||
|
// Head meta tags built as a single HTML string so prettier-plugin-astro
|
||||||
|
// doesn't shuffle them outside `<head>` when reformatting (it has trouble
|
||||||
|
// with mixed JSX-expression and raw element siblings inside <head>).
|
||||||
|
const attr = (value: string) =>
|
||||||
|
value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
|
||||||
|
const articleMetaParts = article
|
||||||
|
? [
|
||||||
|
`<meta property="article:published_time" content="${attr(article.publishedTime)}">`,
|
||||||
|
article.modifiedTime
|
||||||
|
? `<meta property="article:modified_time" content="${attr(article.modifiedTime)}">`
|
||||||
|
: '',
|
||||||
|
`<meta property="article:author" content="${attr(absoluteUrl('/about/'))}">`,
|
||||||
|
...(article.tags ?? []).map(
|
||||||
|
(tag) => `<meta property="article:tag" content="${attr(tag)}">`
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const monoPreloadHtml = preloadMono
|
||||||
|
? '<link rel="preload" href="/fonts/ibm-plex-mono-latin-400.woff2" as="font" type="font/woff2" crossorigin>'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const headHtml = [
|
||||||
|
monoPreloadHtml,
|
||||||
|
`<link rel="alternate" type="application/rss+xml" title="${attr(`${site.name} RSS`)}" href="/rss.xml">`,
|
||||||
|
`<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">`,
|
||||||
|
`<link rel="icon" href="/favicon.ico" type="image/x-icon">`,
|
||||||
|
`<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">`,
|
||||||
|
`<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">`,
|
||||||
|
`<link rel="manifest" href="/site.webmanifest">`,
|
||||||
|
`<meta property="og:site_name" content="${attr(site.name)}">`,
|
||||||
|
`<meta property="og:title" content="${attr(ogTitle)}">`,
|
||||||
|
`<meta property="og:description" content="${attr(description)}">`,
|
||||||
|
`<meta property="og:url" content="${attr(canonical)}">`,
|
||||||
|
`<meta property="og:image" content="${attr(ogImageUrl)}">`,
|
||||||
|
`<meta property="og:image:type" content="image/jpeg">`,
|
||||||
|
`<meta property="og:image:alt" content="${attr(ogImageAlt)}">`,
|
||||||
|
`<meta property="og:image:width" content="${resolvedOgWidth}">`,
|
||||||
|
`<meta property="og:image:height" content="${resolvedOgHeight}">`,
|
||||||
|
`<meta property="og:type" content="${attr(ogType)}">`,
|
||||||
|
`<meta property="og:locale" content="en">`,
|
||||||
|
...articleMetaParts,
|
||||||
|
`<meta name="twitter:card" content="summary_large_image">`,
|
||||||
|
`<meta name="twitter:title" content="${attr(ogTitle)}">`,
|
||||||
|
`<meta name="twitter:description" content="${attr(description)}">`,
|
||||||
|
`<meta name="twitter:image" content="${attr(ogImageUrl)}">`,
|
||||||
|
`<meta name="twitter:image:alt" content="${attr(ogImageAlt)}">`,
|
||||||
|
...jsonLdEntries.map(
|
||||||
|
(entry) =>
|
||||||
|
`<script type="application/ld+json">${JSON.stringify(entry).replace(/<\/script/gi, '<\\/script')}</script>`
|
||||||
|
),
|
||||||
|
].join('');
|
||||||
---
|
---
|
||||||
|
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
|
|
@ -79,10 +137,15 @@ const jsonLdEntries = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
|
||||||
<meta name="theme-color" content="#fbfaf7" media="(prefers-color-scheme: light)" />
|
<meta name="theme-color" content="#fbfaf7" media="(prefers-color-scheme: light)" />
|
||||||
<meta name="theme-color" content="#151514" media="(prefers-color-scheme: dark)" />
|
<meta name="theme-color" content="#151514" media="(prefers-color-scheme: dark)" />
|
||||||
{noindex && <meta name="robots" content="noindex,follow" />}
|
{noindex && <meta name="robots" content="noindex,follow" />}
|
||||||
<link rel="canonical" href={canonical} />
|
{!noindex && <link rel="canonical" href={canonical} />}
|
||||||
|
|
||||||
<script is:inline data-theme-script set:html={themeInit} />
|
<script is:inline data-theme-script set:html={themeInit} />
|
||||||
|
<noscript
|
||||||
|
><style>
|
||||||
|
.theme-switcher {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
</style></noscript
|
||||||
|
>
|
||||||
<link
|
<link
|
||||||
rel="preload"
|
rel="preload"
|
||||||
href="/fonts/source-sans-3-latin-variable.woff2"
|
href="/fonts/source-sans-3-latin-variable.woff2"
|
||||||
|
|
@ -90,76 +153,7 @@ const jsonLdEntries = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
|
||||||
type="font/woff2"
|
type="font/woff2"
|
||||||
crossorigin
|
crossorigin
|
||||||
/>
|
/>
|
||||||
{
|
<Fragment set:html={headHtml} />
|
||||||
preloadMono && (
|
|
||||||
<link
|
|
||||||
rel="preload"
|
|
||||||
href="/fonts/ibm-plex-mono-latin-400.woff2"
|
|
||||||
as="font"
|
|
||||||
type="font/woff2"
|
|
||||||
crossorigin
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
<link
|
|
||||||
rel="alternate"
|
|
||||||
type="application/rss+xml"
|
|
||||||
title={`${site.name} RSS`}
|
|
||||||
href="/rss.xml"
|
|
||||||
/>
|
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
|
||||||
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
|
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
|
||||||
|
|
||||||
<meta property="og:site_name" content={site.name} />
|
|
||||||
<meta property="og:title" content={ogTitle} />
|
|
||||||
<meta property="og:description" content={description} />
|
|
||||||
<meta property="og:url" content={canonical} />
|
|
||||||
<meta property="og:image" content={ogImageUrl} />
|
|
||||||
<meta property="og:image:type" content="image/jpeg" />
|
|
||||||
<meta property="og:image:alt" content={ogImageAlt} />
|
|
||||||
{
|
|
||||||
resolvedOgWidth && (
|
|
||||||
<meta property="og:image:width" content={String(resolvedOgWidth)} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
{
|
|
||||||
resolvedOgHeight && (
|
|
||||||
<meta property="og:image:height" content={String(resolvedOgHeight)} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
<meta property="og:type" content={ogType} />
|
|
||||||
<meta property="og:locale" content="en" />
|
|
||||||
|
|
||||||
{
|
|
||||||
article && (
|
|
||||||
<>
|
|
||||||
<meta property="article:published_time" content={article.publishedTime} />
|
|
||||||
{article.modifiedTime && (
|
|
||||||
<meta property="article:modified_time" content={article.modifiedTime} />
|
|
||||||
)}
|
|
||||||
<meta property="article:author" content={absoluteUrl('/about/')} />
|
|
||||||
{article.tags?.map((tag) => (
|
|
||||||
<meta property="article:tag" content={tag} />
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
|
||||||
<meta name="twitter:title" content={ogTitle} />
|
|
||||||
<meta name="twitter:description" content={description} />
|
|
||||||
<meta name="twitter:image" content={ogImageUrl} />
|
|
||||||
<meta name="twitter:image:alt" content={ogImageAlt} />
|
|
||||||
|
|
||||||
{
|
|
||||||
jsonLdEntries.map((entry) => (
|
|
||||||
<script is:inline type="application/ld+json" set:html={JSON.stringify(entry)} />
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<Header />
|
<Header />
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ const { title, description } = Astro.props;
|
||||||
<Base {...Astro.props}>
|
<Base {...Astro.props}>
|
||||||
<div class="page-shell">
|
<div class="page-shell">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
|
<slot name="breadcrumbs" />
|
||||||
<h1>{title}</h1>
|
<h1>{title}</h1>
|
||||||
<p>{description}</p>
|
<p>{description}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
absoluteUrl,
|
absoluteUrl,
|
||||||
adjacentPosts,
|
adjacentPosts,
|
||||||
articlePath,
|
articlePath,
|
||||||
|
buildBreadcrumbJsonLd,
|
||||||
buildBreadcrumbTrail,
|
buildBreadcrumbTrail,
|
||||||
formatDate,
|
formatDate,
|
||||||
getPublishedPosts,
|
getPublishedPosts,
|
||||||
|
|
@ -42,6 +43,10 @@ const breadcrumbTrail = trail.map((c, i) => ({
|
||||||
const wordCount = post.body ? post.body.trim().split(/\s+/).filter(Boolean).length : 0;
|
const wordCount = post.body ? post.body.trim().split(/\s+/).filter(Boolean).length : 0;
|
||||||
const readingMinutes = Math.max(1, Math.ceil(wordCount / 200));
|
const readingMinutes = Math.max(1, Math.ceil(wordCount / 200));
|
||||||
|
|
||||||
|
// Only preload the monospace font if the post body actually contains code
|
||||||
|
// (inline `…` or fenced ``` blocks). Saves ~15 KB on every code-free article.
|
||||||
|
const hasCode = !!post.body && /(^|[^`])`[^`\n]+`|```/m.test(post.body);
|
||||||
|
|
||||||
// TOC: only show when there are >= 3 h2 headings.
|
// TOC: only show when there are >= 3 h2 headings.
|
||||||
const h2Headings = headings.filter((h) => h.depth === 2);
|
const h2Headings = headings.filter((h) => h.depth === 2);
|
||||||
const showToc = h2Headings.length >= 3;
|
const showToc = h2Headings.length >= 3;
|
||||||
|
|
@ -66,16 +71,7 @@ const blogPosting = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const breadcrumbJsonLd = {
|
const breadcrumbJsonLd = buildBreadcrumbJsonLd(trail);
|
||||||
'@context': 'https://schema.org',
|
|
||||||
'@type': 'BreadcrumbList',
|
|
||||||
itemListElement: trail.map((c, i) => ({
|
|
||||||
'@type': 'ListItem',
|
|
||||||
position: i + 1,
|
|
||||||
name: c.name,
|
|
||||||
item: absoluteUrl(c.href),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<Base
|
<Base
|
||||||
|
|
@ -87,7 +83,7 @@ const breadcrumbJsonLd = {
|
||||||
ogImageWidth={1200}
|
ogImageWidth={1200}
|
||||||
ogImageHeight={630}
|
ogImageHeight={630}
|
||||||
ogType="article"
|
ogType="article"
|
||||||
preloadMono={true}
|
preloadMono={hasCode}
|
||||||
article={{
|
article={{
|
||||||
publishedTime: post.data.date.toISOString(),
|
publishedTime: post.data.date.toISOString(),
|
||||||
modifiedTime: post.data.updated?.toISOString(),
|
modifiedTime: post.data.updated?.toISOString(),
|
||||||
|
|
@ -130,7 +126,7 @@ const breadcrumbJsonLd = {
|
||||||
alt={post.data.thumbnail.alt}
|
alt={post.data.thumbnail.alt}
|
||||||
formats={['avif', 'webp']}
|
formats={['avif', 'webp']}
|
||||||
fallbackFormat="jpg"
|
fallbackFormat="jpg"
|
||||||
widths={[640, 960, 1280, 1600]}
|
widths={[640, 960, 1280, 1600, 1920, 2400]}
|
||||||
sizes="(max-width: 700px) calc(100vw - 3rem), (max-width: 1100px) calc(100vw - 4rem), 56rem"
|
sizes="(max-width: 700px) calc(100vw - 3rem), (max-width: 1100px) calc(100vw - 4rem), 56rem"
|
||||||
loading="eager"
|
loading="eager"
|
||||||
fetchpriority="high"
|
fetchpriority="high"
|
||||||
|
|
@ -170,8 +166,8 @@ const breadcrumbJsonLd = {
|
||||||
|
|
||||||
{
|
{
|
||||||
related.length > 0 && (
|
related.length > 0 && (
|
||||||
<section class="related-posts" aria-labelledby="related-heading">
|
<section class="related-posts">
|
||||||
<h2 id="related-heading">Related articles</h2>
|
<h2>Related articles</h2>
|
||||||
<ArticleList posts={related} />
|
<ArticleList posts={related} />
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -19,18 +19,20 @@ export const site = {
|
||||||
// entry where `footerOnly` is falsy AND `href !== '/'` (Home is implicit via
|
// entry where `footerOnly` is falsy AND `href !== '/'` (Home is implicit via
|
||||||
// the site title). The Footer renders every entry regardless. Items marked
|
// the site title). The Footer renders every entry regardless. Items marked
|
||||||
// `footerOnly: true` appear only in the Footer.
|
// `footerOnly: true` appear only in the Footer.
|
||||||
export const navItems = [
|
export interface NavItem {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
footerOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const navItems: readonly NavItem[] = [
|
||||||
{ 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' },
|
||||||
{ href: '/tags/', label: 'Tags', footerOnly: false },
|
{ href: '/tags/', label: 'Tags' },
|
||||||
{ href: '/rss.xml', label: 'RSS', footerOnly: true },
|
{ 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', {
|
||||||
|
|
@ -176,28 +178,53 @@ interface BreadcrumbCrumb {
|
||||||
|
|
||||||
interface BreadcrumbInput {
|
interface BreadcrumbInput {
|
||||||
articles?: boolean;
|
articles?: boolean;
|
||||||
|
projects?: boolean;
|
||||||
|
tagsIndex?: boolean;
|
||||||
tag?: string;
|
tag?: string;
|
||||||
post?: CollectionEntry<'posts'>;
|
post?: CollectionEntry<'posts'>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Builds the breadcrumb trail shared by JSON-LD (BreadcrumbList) and the
|
// Builds the breadcrumb trail shared by JSON-LD (BreadcrumbList) and the
|
||||||
// visible Breadcrumbs component. Home is always first. Pass `articles: true`
|
// visible Breadcrumbs component. Home is always first. Flags append crumbs
|
||||||
// to include the /articles/ crumb; pass a `tag` to append a tag crumb; pass
|
// in a fixed order: Articles → Tags → tag → Post (or Projects). A `tag`
|
||||||
// a `post` to append the post title (linking to its article path).
|
// implies both Articles and Tags so callers don't have to set every flag.
|
||||||
export function buildBreadcrumbTrail({
|
export function buildBreadcrumbTrail({
|
||||||
articles,
|
articles,
|
||||||
|
projects,
|
||||||
|
tagsIndex,
|
||||||
tag,
|
tag,
|
||||||
post,
|
post,
|
||||||
}: BreadcrumbInput): BreadcrumbCrumb[] {
|
}: BreadcrumbInput): BreadcrumbCrumb[] {
|
||||||
const trail: BreadcrumbCrumb[] = [{ name: 'Home', href: '/' }];
|
const trail: BreadcrumbCrumb[] = [{ name: 'Home', href: '/' }];
|
||||||
if (articles || post) {
|
if (articles || post || tagsIndex || tag) {
|
||||||
trail.push({ name: 'Articles', href: '/articles/' });
|
trail.push({ name: 'Articles', href: '/articles/' });
|
||||||
}
|
}
|
||||||
|
if (tagsIndex || tag) {
|
||||||
|
trail.push({ name: 'Tags', href: '/tags/' });
|
||||||
|
}
|
||||||
if (tag) {
|
if (tag) {
|
||||||
trail.push({ name: tag, href: tagPath(tag) });
|
trail.push({ name: `#${tag}`, href: tagPath(tag) });
|
||||||
}
|
}
|
||||||
if (post) {
|
if (post) {
|
||||||
trail.push({ name: post.data.title, href: articlePath(post) });
|
trail.push({ name: post.data.title, href: articlePath(post) });
|
||||||
}
|
}
|
||||||
|
if (projects) {
|
||||||
|
trail.push({ name: 'Projects', href: '/projects/' });
|
||||||
|
}
|
||||||
return trail;
|
return trail;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Builds the schema.org BreadcrumbList JSON-LD object for a given trail.
|
||||||
|
// Shared by every page that emits breadcrumb structured data.
|
||||||
|
export function buildBreadcrumbJsonLd(trail: BreadcrumbCrumb[]) {
|
||||||
|
return {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'BreadcrumbList',
|
||||||
|
itemListElement: trail.map((crumb, index) => ({
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: index + 1,
|
||||||
|
name: crumb.name,
|
||||||
|
item: absoluteUrl(crumb.href),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,8 @@ const recent = posts.slice(0, 5);
|
||||||
|
|
||||||
<section class="home-section">
|
<section class="home-section">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<h2 id="404-recent">Recent articles</h2>
|
<h2 id="recent-articles-404">Recent articles</h2>
|
||||||
<a href="/articles/">All articles →</a>
|
<a href="/articles/">All articles <span aria-hidden="true">→</span></a>
|
||||||
</div>
|
</div>
|
||||||
<ArticleList posts={recent} />
|
<ArticleList posts={recent} />
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
---
|
---
|
||||||
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 { absoluteUrl, buildPersonJsonLd, getPublishedPosts, site } from '../lib/site';
|
import {
|
||||||
|
absoluteUrl,
|
||||||
|
buildPersonJsonLd,
|
||||||
|
getPublishedPosts,
|
||||||
|
optimizeOgImage,
|
||||||
|
site,
|
||||||
|
} from '../lib/site';
|
||||||
|
import defaultOg from '../assets/og-default.jpg';
|
||||||
|
|
||||||
const posts = await getPublishedPosts();
|
const posts = await getPublishedPosts();
|
||||||
const startingPoints = posts
|
const startingPoints = posts
|
||||||
|
|
@ -9,6 +16,8 @@ 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 personImage = await optimizeOgImage(defaultOg);
|
||||||
|
|
||||||
// Canonical Person JSON-LD. Other pages reference this entity by @id.
|
// Canonical Person JSON-LD. Other pages reference this entity by @id.
|
||||||
const personJsonLd = buildPersonJsonLd({
|
const personJsonLd = buildPersonJsonLd({
|
||||||
jobTitle: 'Software Engineer',
|
jobTitle: 'Software Engineer',
|
||||||
|
|
@ -22,7 +31,7 @@ const personJsonLd = buildPersonJsonLd({
|
||||||
'Simulations',
|
'Simulations',
|
||||||
'Data visualization',
|
'Data visualization',
|
||||||
],
|
],
|
||||||
image: absoluteUrl('/og-image.jpg'),
|
image: absoluteUrl(personImage.src),
|
||||||
mainEntityOfPage: absoluteUrl('/about/'),
|
mainEntityOfPage: absoluteUrl('/about/'),
|
||||||
});
|
});
|
||||||
---
|
---
|
||||||
|
|
@ -52,38 +61,40 @@ const personJsonLd = buildPersonJsonLd({
|
||||||
|
|
||||||
<section class="about-section facts">
|
<section class="about-section facts">
|
||||||
<h2 id="quick-facts">Quick Facts</h2>
|
<h2 id="quick-facts">Quick Facts</h2>
|
||||||
<address>
|
<dl>
|
||||||
<dl>
|
<div>
|
||||||
<div>
|
<dt>Focus</dt>
|
||||||
<dt>Focus</dt>
|
<dd>
|
||||||
<dd>
|
Software systems, AI deployment, architecture, graphics, data visualization
|
||||||
Software systems, AI deployment, architecture, graphics, data visualization
|
</dd>
|
||||||
</dd>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div>
|
<dt>Education</dt>
|
||||||
<dt>Education</dt>
|
<dd>MSc in Computer Science</dd>
|
||||||
<dd>MSc in Computer Science</dd>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div>
|
<dt>Contact</dt>
|
||||||
<dt>Contact</dt>
|
<dd>
|
||||||
<dd><a href={`mailto:${site.email}`}>{site.email}</a></dd>
|
<address>
|
||||||
</div>
|
<a href={`mailto:${site.email}`}>{site.email}</a>
|
||||||
<div>
|
</address>
|
||||||
<dt>Links</dt>
|
</dd>
|
||||||
<dd>
|
</div>
|
||||||
<a href={site.cv} rel="noopener">CV</a>,
|
<div>
|
||||||
<a href={site.github} rel="noopener me">GitHub</a>,
|
<dt>Links</dt>
|
||||||
<a href={site.linkedin} rel="noopener me">LinkedIn</a>
|
<dd class="about-links">
|
||||||
</dd>
|
<a href={site.cv} rel="noopener">CV</a>
|
||||||
</div>
|
<a href={site.github} rel="noopener me">GitHub</a>
|
||||||
</dl>
|
<a href={site.linkedin} rel="noopener me">LinkedIn</a>
|
||||||
</address>
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="about-section">
|
<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 <span aria-hidden="true">→</span></a>
|
||||||
</div>
|
</div>
|
||||||
<ArticleList posts={startingPoints} />
|
<ArticleList posts={startingPoints} />
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
---
|
---
|
||||||
import { getCollection } from 'astro:content';
|
|
||||||
import Post from '../../layouts/Post.astro';
|
import Post from '../../layouts/Post.astro';
|
||||||
import { entrySlug } from '../../lib/site';
|
import { entrySlug, getPublishedPosts } from '../../lib/site';
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
const posts = (await getCollection('posts')).filter((post) => !post.data.draft);
|
const posts = await getPublishedPosts();
|
||||||
return posts.map((post) => ({
|
return posts.map((post) => ({
|
||||||
params: { slug: entrySlug(post) },
|
params: { slug: entrySlug(post) },
|
||||||
props: { post },
|
props: { post },
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,11 @@ import Page from '../../layouts/Page.astro';
|
||||||
import {
|
import {
|
||||||
absoluteUrl,
|
absoluteUrl,
|
||||||
articlePath,
|
articlePath,
|
||||||
|
buildBreadcrumbJsonLd,
|
||||||
buildBreadcrumbTrail,
|
buildBreadcrumbTrail,
|
||||||
getAllTags,
|
getAllTags,
|
||||||
getPublishedPosts,
|
getPublishedPosts,
|
||||||
|
optimizeOgImage,
|
||||||
site,
|
site,
|
||||||
yearOf,
|
yearOf,
|
||||||
} from '../../lib/site';
|
} from '../../lib/site';
|
||||||
|
|
@ -16,6 +18,11 @@ const posts = await getPublishedPosts();
|
||||||
const years = [...new Set(posts.map((post) => yearOf(post.data.date)))];
|
const years = [...new Set(posts.map((post) => yearOf(post.data.date)))];
|
||||||
const tags = getAllTags(posts);
|
const tags = getAllTags(posts);
|
||||||
|
|
||||||
|
const postOgImages = await Promise.all(
|
||||||
|
posts.map((post) => optimizeOgImage(post.data.thumbnail.src))
|
||||||
|
);
|
||||||
|
|
||||||
|
const personId = absoluteUrl('/about/#person');
|
||||||
const blogJsonLd = {
|
const blogJsonLd = {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'Blog',
|
'@type': 'Blog',
|
||||||
|
|
@ -23,26 +30,20 @@ const blogJsonLd = {
|
||||||
url: absoluteUrl('/articles/'),
|
url: absoluteUrl('/articles/'),
|
||||||
description:
|
description:
|
||||||
'Technical articles and notes about projects, systems, AI deployment, graphics, simulations, and tools.',
|
'Technical articles and notes about projects, systems, AI deployment, graphics, simulations, and tools.',
|
||||||
blogPost: posts.map((post) => ({
|
publisher: { '@id': personId },
|
||||||
|
blogPost: posts.map((post, index) => ({
|
||||||
'@type': 'BlogPosting',
|
'@type': 'BlogPosting',
|
||||||
headline: post.data.title,
|
headline: post.data.title,
|
||||||
description: post.data.description,
|
description: post.data.description,
|
||||||
datePublished: post.data.date.toISOString(),
|
datePublished: post.data.date.toISOString(),
|
||||||
url: absoluteUrl(articlePath(post)),
|
url: absoluteUrl(articlePath(post)),
|
||||||
|
author: { '@id': personId },
|
||||||
|
image: absoluteUrl(postOgImages[index].src),
|
||||||
|
keywords: post.data.tags.join(', '),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
const breadcrumbTrail = buildBreadcrumbTrail({ articles: true });
|
const breadcrumbJsonLd = buildBreadcrumbJsonLd(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];
|
const jsonLd = [blogJsonLd, breadcrumbJsonLd];
|
||||||
---
|
---
|
||||||
|
|
@ -54,7 +55,7 @@ const jsonLd = [blogJsonLd, breadcrumbJsonLd];
|
||||||
>
|
>
|
||||||
<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>
|
||||||
<TagList tags={tags} labelled={false} />
|
<TagList tags={tags} />
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,11 @@ const personJsonLd = buildPersonJsonLd();
|
||||||
<section class="home-section">
|
<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}
|
||||||
|
{posts.length === 1 ? 'article' : 'articles'}
|
||||||
|
<span aria-hidden="true">→</span></a
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<ArticleList posts={latestPosts} />
|
<ArticleList posts={latestPosts} />
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -47,7 +51,7 @@ const personJsonLd = buildPersonJsonLd();
|
||||||
<section class="home-section">
|
<section class="home-section">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<h2 id="home-selected-projects">Selected Projects</h2>
|
<h2 id="home-selected-projects">Selected Projects</h2>
|
||||||
<a href="/projects/">All projects →</a>
|
<a href="/projects/">All projects <span aria-hidden="true">→</span></a>
|
||||||
</div>
|
</div>
|
||||||
<ProjectList projects={selectedProjects} />
|
<ProjectList projects={selectedProjects} />
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +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, buildBreadcrumbTrail, getProjects, site } from '../../lib/site';
|
import {
|
||||||
|
absoluteUrl,
|
||||||
|
buildBreadcrumbJsonLd,
|
||||||
|
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);
|
||||||
|
|
@ -16,20 +22,7 @@ const collectionJsonLd = {
|
||||||
'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 = [
|
const breadcrumbJsonLd = buildBreadcrumbJsonLd(buildBreadcrumbTrail({ projects: true }));
|
||||||
...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];
|
const jsonLd = [collectionJsonLd, breadcrumbJsonLd];
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,13 @@ import ArticleList from '../../components/ArticleList.astro';
|
||||||
import Breadcrumbs from '../../components/Breadcrumbs.astro';
|
import Breadcrumbs from '../../components/Breadcrumbs.astro';
|
||||||
import TagList from '../../components/TagList.astro';
|
import TagList from '../../components/TagList.astro';
|
||||||
import Page from '../../layouts/Page.astro';
|
import Page from '../../layouts/Page.astro';
|
||||||
import { getAllTags, getPublishedPosts, tagSlug } from '../../lib/site';
|
import {
|
||||||
|
buildBreadcrumbJsonLd,
|
||||||
|
buildBreadcrumbTrail,
|
||||||
|
getAllTags,
|
||||||
|
getPublishedPosts,
|
||||||
|
tagSlug,
|
||||||
|
} from '../../lib/site';
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
const posts = await getPublishedPosts();
|
const posts = await getPublishedPosts();
|
||||||
|
|
@ -18,22 +24,23 @@ 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 = buildBreadcrumbTrail({ tag });
|
||||||
{ href: '/', label: 'Home' },
|
const visibleTrail = trail.map((c, i) => ({
|
||||||
{ href: '/articles/', label: 'Articles' },
|
label: c.name,
|
||||||
{ href: '/tags/', label: 'Tags' },
|
href: i === trail.length - 1 ? undefined : c.href,
|
||||||
{ label: `#${tag}` },
|
}));
|
||||||
];
|
const breadcrumbJsonLd = buildBreadcrumbJsonLd(trail);
|
||||||
---
|
---
|
||||||
|
|
||||||
<Page
|
<Page
|
||||||
title={title}
|
title={title}
|
||||||
description={`Project articles and technical notes filed under #${tag}.`}
|
description={`Project articles and technical notes filed under #${tag}.`}
|
||||||
|
jsonLd={breadcrumbJsonLd}
|
||||||
>
|
>
|
||||||
<Breadcrumbs items={trail} />
|
<Breadcrumbs slot="breadcrumbs" items={visibleTrail} />
|
||||||
<nav class="tag-filter" aria-label="Browse other tags">
|
<nav class="tag-filter" aria-label="Browse other tags">
|
||||||
<span>Browse other tags</span>
|
<span>Browse other tags</span>
|
||||||
<TagList tags={allTags} currentTag={tag} labelled={false} />
|
<TagList tags={allTags} currentTag={tag} />
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<h2 class="sr-only">Articles</h2>
|
<h2 class="sr-only">Articles</h2>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import TagList from '../../components/TagList.astro';
|
||||||
import Page from '../../layouts/Page.astro';
|
import Page from '../../layouts/Page.astro';
|
||||||
import {
|
import {
|
||||||
absoluteUrl,
|
absoluteUrl,
|
||||||
|
buildBreadcrumbJsonLd,
|
||||||
buildBreadcrumbTrail,
|
buildBreadcrumbTrail,
|
||||||
getAllTags,
|
getAllTags,
|
||||||
getPublishedPosts,
|
getPublishedPosts,
|
||||||
|
|
@ -27,20 +28,7 @@ const collectionJsonLd = {
|
||||||
description: 'Every tag used across the articles archive.',
|
description: 'Every tag used across the articles archive.',
|
||||||
};
|
};
|
||||||
|
|
||||||
const breadcrumbTrail = [
|
const breadcrumbJsonLd = buildBreadcrumbJsonLd(buildBreadcrumbTrail({ tagsIndex: true }));
|
||||||
...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];
|
const jsonLd = [collectionJsonLd, breadcrumbJsonLd];
|
||||||
---
|
---
|
||||||
|
|
|
||||||
22
src/scripts/theme-init.js
Normal file
22
src/scripts/theme-init.js
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
(function () {
|
||||||
|
var key = 'theme';
|
||||||
|
var legacyKey = 'dark-mode';
|
||||||
|
var saved = null;
|
||||||
|
try {
|
||||||
|
var value = localStorage.getItem(key);
|
||||||
|
if (value === 'light' || value === 'dark') {
|
||||||
|
saved = value;
|
||||||
|
} else {
|
||||||
|
var legacyValue = localStorage.getItem(legacyKey);
|
||||||
|
if (legacyValue !== null) {
|
||||||
|
saved = JSON.parse(legacyValue) ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
saved = null;
|
||||||
|
}
|
||||||
|
var systemDark = matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
var theme = saved || (systemDark ? 'dark' : 'light');
|
||||||
|
document.documentElement.dataset.theme = theme;
|
||||||
|
document.documentElement.style.colorScheme = theme;
|
||||||
|
})();
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
(() => {
|
|
||||||
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;
|
|
||||||
})();
|
|
||||||
|
|
@ -39,14 +39,14 @@
|
||||||
--color-link: light-dark(#285f74, #8ab8c8);
|
--color-link: light-dark(#285f74, #8ab8c8);
|
||||||
--color-link-hover: light-dark(
|
--color-link-hover: light-dark(
|
||||||
color-mix(in oklch, #285f74 70%, black 30%),
|
color-mix(in oklch, #285f74 70%, black 30%),
|
||||||
color-mix(in oklch, #8ab8c8 70%, black 30%)
|
color-mix(in oklch, #8ab8c8 70%, white 30%)
|
||||||
);
|
);
|
||||||
--color-link-visited: var(--color-link);
|
--color-link-visited: var(--color-link);
|
||||||
--color-accent: light-dark(oklch(55% 0.13 15), oklch(72% 0.13 15));
|
--color-accent: light-dark(oklch(55% 0.13 15), oklch(72% 0.13 15));
|
||||||
--color-rule: light-dark(#d9d5ca, #39352f);
|
--color-rule: light-dark(#d9d5ca, #39352f);
|
||||||
--color-rule-medium: light-dark(#7a7466, #6c655c);
|
--color-rule-medium: light-dark(#7a7466, #8a8478);
|
||||||
--color-rule-strong: light-dark(#4a4340, #d0c5b7);
|
--color-rule-strong: light-dark(#4a4340, #d0c5b7);
|
||||||
--color-code-bg: light-dark(#efede6, #24221f);
|
--color-code-bg: light-dark(#efede6, #2f2c27);
|
||||||
--color-callout-bg: light-dark(#f4f1e8, #211f1c);
|
--color-callout-bg: light-dark(#f4f1e8, #211f1c);
|
||||||
--color-selection-bg: light-dark(#ecddd0, #4a3a2e);
|
--color-selection-bg: light-dark(#ecddd0, #4a3a2e);
|
||||||
|
|
||||||
|
|
@ -173,6 +173,7 @@
|
||||||
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-snug);
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
|
|
@ -181,6 +182,10 @@
|
||||||
color 200ms ease;
|
color 200ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
address {
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: var(--color-link);
|
color: var(--color-link);
|
||||||
text-decoration-thickness: 0.08em;
|
text-decoration-thickness: 0.08em;
|
||||||
|
|
@ -198,7 +203,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: 2px solid var(--color-rule-strong);
|
outline: 2px solid var(--color-accent);
|
||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -228,17 +233,6 @@
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shell {
|
|
||||||
width: min(100% - 2 * var(--gutter), var(--page));
|
|
||||||
margin-inline: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tap-target {
|
|
||||||
min-height: 44px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =========================================================================
|
/* =========================================================================
|
||||||
|
|
@ -246,20 +240,12 @@
|
||||||
========================================================================= */
|
========================================================================= */
|
||||||
|
|
||||||
@layer layout {
|
@layer layout {
|
||||||
:where(
|
:where(.site-header, .site-footer, .home-intro, .home-section, .page-shell, .post) {
|
||||||
.site-header,
|
|
||||||
.site-footer,
|
|
||||||
.home-intro,
|
|
||||||
.home-section,
|
|
||||||
.page-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;
|
||||||
}
|
}
|
||||||
|
|
||||||
:where(.post, .post-footer-shell) {
|
.post {
|
||||||
width: min(100% - 2 * var(--gutter), var(--measure-wide));
|
width: min(100% - 2 * var(--gutter), var(--measure-wide));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -271,7 +257,10 @@
|
||||||
transform: translateY(-150%);
|
transform: translateY(-150%);
|
||||||
background: var(--color-fg);
|
background: var(--color-fg);
|
||||||
color: var(--color-bg);
|
color: var(--color-bg);
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-3) var(--space-4);
|
||||||
|
min-block-size: 44px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: transform 150ms ease;
|
transition: transform 150ms ease;
|
||||||
}
|
}
|
||||||
|
|
@ -294,7 +283,9 @@
|
||||||
|
|
||||||
.site-title {
|
.site-title {
|
||||||
color: var(--color-fg);
|
color: var(--color-fg);
|
||||||
font-weight: var(--weight-semibold);
|
font-size: var(--fs-lg);
|
||||||
|
font-weight: var(--weight-bold);
|
||||||
|
letter-spacing: -0.005em;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -302,6 +293,10 @@
|
||||||
color: var(--color-fg);
|
color: var(--color-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.site-title[aria-current='page'] {
|
||||||
|
color: var(--color-fg);
|
||||||
|
}
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -337,17 +332,11 @@
|
||||||
text-underline-offset: 0.25em;
|
text-underline-offset: 0.25em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-nav a[aria-current='page'] {
|
.site-nav a[aria-current='page'],
|
||||||
|
.site-nav a[aria-current='true'] {
|
||||||
color: var(--color-fg);
|
color: var(--color-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-control {
|
|
||||||
cursor: pointer;
|
|
||||||
min-height: 44px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-footer {
|
.site-footer {
|
||||||
border-top: 1px solid var(--color-rule);
|
border-top: 1px solid var(--color-rule);
|
||||||
margin-top: var(--space-16);
|
margin-top: var(--space-16);
|
||||||
|
|
@ -370,16 +359,24 @@
|
||||||
font-size: var(--fs-caption);
|
font-size: var(--fs-caption);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer-links a,
|
||||||
|
.footer-meta a,
|
||||||
.footer-meta span {
|
.footer-meta span {
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer-links a,
|
||||||
|
.footer-meta a {
|
||||||
|
padding-inline: var(--space-1);
|
||||||
|
margin-inline: calc(-1 * var(--space-1));
|
||||||
|
}
|
||||||
|
|
||||||
/* Page header (shared by .home-intro, .page-header, .post-header) */
|
/* Page header (shared by .home-intro, .page-header, .post-header) */
|
||||||
.home-intro {
|
.home-intro {
|
||||||
max-width: var(--measure-wide);
|
max-width: var(--measure-wide);
|
||||||
padding-block: clamp(2rem, 5vw, 4rem) var(--space-10);
|
padding-block: clamp(2rem, 5vw, 4rem) var(--space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-intro h1,
|
.home-intro h1,
|
||||||
|
|
@ -545,8 +542,23 @@
|
||||||
color: var(--color-rule-medium);
|
color: var(--color-rule-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tag-list .tag-more::before {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-list .tag-count {
|
||||||
|
margin-inline-start: 0.35em;
|
||||||
|
padding: 0 0.4em;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-code-bg);
|
||||||
|
color: var(--color-fg);
|
||||||
|
font-size: var(--fs-caption);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
.tag-list a:hover,
|
.tag-list a:hover,
|
||||||
.tag-list a[aria-current='page'] {
|
.tag-list a[aria-current='page'],
|
||||||
|
.tag-list a[aria-current='true'] {
|
||||||
color: var(--color-fg);
|
color: var(--color-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -584,10 +596,10 @@
|
||||||
|
|
||||||
.article-list > li {
|
.article-list > li {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(5rem, auto) minmax(0, 1fr) minmax(6rem, 8rem);
|
grid-template-columns: 4.5rem 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-4);
|
||||||
padding-block: var(--space-6);
|
padding-block: var(--space-6);
|
||||||
border-top: 1px solid var(--color-rule);
|
border-top: 1px solid var(--color-rule);
|
||||||
}
|
}
|
||||||
|
|
@ -611,6 +623,9 @@
|
||||||
|
|
||||||
.article-list .entry-title,
|
.article-list .entry-title,
|
||||||
.project-list h3 a {
|
.project-list h3 a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 28px;
|
||||||
color: var(--color-fg);
|
color: var(--color-fg);
|
||||||
font-weight: var(--weight-semibold);
|
font-weight: var(--weight-semibold);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|
@ -734,9 +749,10 @@
|
||||||
.project-card .project-meta {
|
.project-card .project-meta {
|
||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
font-size: var(--fs-sm);
|
font-size: var(--fs-sm);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-essay-badge {
|
.project-essay-badge {
|
||||||
|
|
@ -778,6 +794,11 @@
|
||||||
color: var(--color-link);
|
color: var(--color-link);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.project-links a:hover,
|
||||||
|
.project-links a:focus-visible {
|
||||||
|
color: var(--color-link-hover);
|
||||||
|
}
|
||||||
|
|
||||||
.project-links a .download-indicator {
|
.project-links a .download-indicator {
|
||||||
margin-left: 0.25em;
|
margin-left: 0.25em;
|
||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
|
|
@ -838,6 +859,12 @@
|
||||||
margin-top: var(--space-4);
|
margin-top: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.about-links {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-1) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
.post > .prose {
|
.post > .prose {
|
||||||
margin-top: var(--space-8);
|
margin-top: var(--space-8);
|
||||||
}
|
}
|
||||||
|
|
@ -864,6 +891,11 @@
|
||||||
margin-top: 1.05em;
|
margin-top: 1.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prose > h2:first-child,
|
||||||
|
.prose > h3:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.prose p {
|
.prose p {
|
||||||
text-wrap: pretty;
|
text-wrap: pretty;
|
||||||
}
|
}
|
||||||
|
|
@ -902,19 +934,24 @@
|
||||||
font-weight: var(--weight-regular);
|
font-weight: var(--weight-regular);
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
opacity: 0;
|
opacity: 0.25;
|
||||||
transition: opacity 150ms ease;
|
transition: opacity 150ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prose .heading-anchor::before {
|
||||||
|
content: '#';
|
||||||
|
}
|
||||||
|
|
||||||
.prose h2:hover .heading-anchor,
|
.prose h2:hover .heading-anchor,
|
||||||
.prose h3:hover .heading-anchor,
|
.prose h3:hover .heading-anchor,
|
||||||
|
.prose .heading-anchor:hover,
|
||||||
.prose .heading-anchor:focus-visible {
|
.prose .heading-anchor:focus-visible {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (hover: none) {
|
@media (hover: none) {
|
||||||
.prose .heading-anchor {
|
.prose .heading-anchor {
|
||||||
opacity: 0.4;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1043,10 +1080,17 @@
|
||||||
|
|
||||||
.at-a-glance dl,
|
.at-a-glance dl,
|
||||||
.facts dl {
|
.facts dl {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin: var(--space-4) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.at-a-glance__row,
|
||||||
|
.facts dl > div {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(6rem, max-content) minmax(0, 1fr);
|
grid-template-columns: minmax(6rem, max-content) minmax(0, 1fr);
|
||||||
gap: var(--space-2) var(--space-4);
|
gap: var(--space-4);
|
||||||
margin: var(--space-4) 0 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.at-a-glance dt,
|
.at-a-glance dt,
|
||||||
|
|
@ -1089,24 +1133,23 @@
|
||||||
|
|
||||||
.post > .at-a-glance {
|
.post > .at-a-glance {
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
|
grid-row: span 5;
|
||||||
margin-top: var(--space-8);
|
margin-top: var(--space-8);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: var(--space-6);
|
top: var(--space-6);
|
||||||
|
align-self: start;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -- Post media (formerly EvidenceMedia) ----------------------------- */
|
/* -- Post media ------------------------------------------------------- */
|
||||||
|
|
||||||
.post-media,
|
.post-media {
|
||||||
.evidence-media {
|
|
||||||
max-inline-size: min(100%, var(--measure-wide));
|
max-inline-size: min(100%, var(--measure-wide));
|
||||||
margin: var(--space-8) 0 0;
|
margin: var(--space-8) 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-media img,
|
.post-media img,
|
||||||
.post-media video,
|
.post-media video {
|
||||||
.evidence-media img,
|
|
||||||
.evidence-media video {
|
|
||||||
border: 1px solid var(--color-rule);
|
border: 1px solid var(--color-rule);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
background: var(--color-code-bg);
|
background: var(--color-code-bg);
|
||||||
|
|
@ -1114,7 +1157,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-media figcaption,
|
.post-media figcaption,
|
||||||
.evidence-media figcaption,
|
|
||||||
.media-transcript {
|
.media-transcript {
|
||||||
max-width: var(--measure);
|
max-width: var(--measure);
|
||||||
margin-top: var(--space-2);
|
margin-top: var(--space-2);
|
||||||
|
|
@ -1126,14 +1168,24 @@
|
||||||
/* -- Post nav --------------------------------------------------------- */
|
/* -- Post nav --------------------------------------------------------- */
|
||||||
|
|
||||||
.post-nav {
|
.post-nav {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
|
|
||||||
gap: var(--space-4);
|
|
||||||
margin-top: var(--space-12);
|
margin-top: var(--space-12);
|
||||||
padding-top: var(--space-6);
|
padding-top: var(--space-6);
|
||||||
border-top: 1px solid var(--color-rule);
|
border-top: 1px solid var(--color-rule);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post-nav__list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-nav__next {
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
.post-nav a {
|
.post-nav a {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -1170,6 +1222,67 @@
|
||||||
font-weight: var(--weight-semibold);
|
font-weight: var(--weight-semibold);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -- Post TOC --------------------------------------------------------- */
|
||||||
|
|
||||||
|
.post-toc {
|
||||||
|
margin-top: var(--space-6);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-inline-start: 2px solid var(--color-rule);
|
||||||
|
font-size: var(--fs-caption);
|
||||||
|
color: var(--color-muted);
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-toc .post-nav__title,
|
||||||
|
.post-header h1,
|
||||||
|
.post-nav .post-nav__title,
|
||||||
|
.project-card h3 {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-toc ol {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-toc a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-block-size: 24px;
|
||||||
|
padding-block: 2px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-toc a:hover,
|
||||||
|
.post-toc a:focus-visible {
|
||||||
|
color: var(--color-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Post media gallery ----------------------------------------------- */
|
||||||
|
|
||||||
|
.post-gallery {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: var(--space-8) 0 0;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- External link affordance ----------------------------------------- */
|
||||||
|
|
||||||
|
.external-link-icon {
|
||||||
|
display: inline-block;
|
||||||
|
margin-inline-start: 0.25em;
|
||||||
|
vertical-align: -0.125em;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
/* -- Related ---------------------------------------------------------- */
|
/* -- Related ---------------------------------------------------------- */
|
||||||
|
|
||||||
.related-posts {
|
.related-posts {
|
||||||
|
|
@ -1187,36 +1300,27 @@
|
||||||
/* -- Empty state (e.g. 404) ----------------------------------------- */
|
/* -- Empty state (e.g. 404) ----------------------------------------- */
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
min-height: 50vh;
|
|
||||||
display: grid;
|
|
||||||
place-content: center;
|
|
||||||
text-align: center;
|
|
||||||
max-width: var(--measure);
|
max-width: var(--measure);
|
||||||
margin-inline: auto;
|
padding-block: var(--space-6);
|
||||||
padding-block: var(--space-10);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state .prose {
|
|
||||||
margin-inline: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -- Theme switcher --------------------------------------------------- */
|
/* -- Theme switcher --------------------------------------------------- */
|
||||||
|
|
||||||
.theme-switcher {
|
.theme-switcher {
|
||||||
--switcher-w: 2.5rem;
|
--switcher-w: 2.75rem;
|
||||||
--switcher-h: 1.25rem;
|
--switcher-h: 1.5rem;
|
||||||
--switcher-icon: 0.85rem;
|
--switcher-icon: 1.05rem;
|
||||||
--switcher-mask: 0.68rem;
|
--switcher-mask: 0.78rem;
|
||||||
--switcher-gap: 0.2rem;
|
--switcher-gap: 0.22rem;
|
||||||
--switcher-mask-offset: 0.28rem;
|
--switcher-mask-offset: 0.32rem;
|
||||||
|
|
||||||
position: relative;
|
position: relative;
|
||||||
display: block;
|
display: inline-block;
|
||||||
width: var(--switcher-w);
|
width: var(--switcher-w);
|
||||||
height: var(--switcher-h);
|
height: var(--switcher-h);
|
||||||
margin: 0;
|
margin: var(--space-2) 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--color-rule);
|
border: 1px solid var(--color-rule-medium);
|
||||||
border-radius: var(--radius-pill);
|
border-radius: var(--radius-pill);
|
||||||
appearance: none;
|
appearance: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -1225,9 +1329,7 @@
|
||||||
transition:
|
transition:
|
||||||
background-color 200ms ease,
|
background-color 200ms ease,
|
||||||
border-color 150ms ease;
|
border-color 150ms ease;
|
||||||
box-shadow:
|
box-shadow: inset 0 1px 2px rgb(0 0 0 / 18%);
|
||||||
inset 0 0 10px 2px rgb(0 0 0 / 17.5%),
|
|
||||||
inset 0 0 1px rgb(0 0 0 / 40%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-switcher:hover {
|
.theme-switcher:hover {
|
||||||
|
|
@ -1329,8 +1431,8 @@
|
||||||
padding-block: var(--space-8) var(--space-6);
|
padding-block: var(--space-8) var(--space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.at-a-glance dl,
|
.at-a-glance__row,
|
||||||
.facts dl {
|
.facts dl > div {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
@ -1357,7 +1459,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-card .project-meta {
|
.project-card .project-meta {
|
||||||
white-space: normal;
|
-webkit-line-clamp: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-card__summary {
|
.project-card__summary {
|
||||||
|
|
@ -1377,10 +1479,14 @@
|
||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-nav {
|
.post-nav__list {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post-nav__next {
|
||||||
|
justify-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
.post-nav a.next {
|
.post-nav a.next {
|
||||||
text-align: start;
|
text-align: start;
|
||||||
}
|
}
|
||||||
|
|
@ -1402,6 +1508,14 @@
|
||||||
scroll-behavior: auto;
|
scroll-behavior: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
|
||||||
::view-transition-group(*),
|
::view-transition-group(*),
|
||||||
::view-transition-old(*),
|
::view-transition-old(*),
|
||||||
::view-transition-new(*) {
|
::view-transition-new(*) {
|
||||||
|
|
@ -1430,10 +1544,17 @@
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
print-color-adjust: economy;
|
||||||
|
-webkit-print-color-adjust: economy;
|
||||||
|
}
|
||||||
|
|
||||||
.site-header,
|
.site-header,
|
||||||
.site-footer,
|
.site-footer,
|
||||||
.skip-link,
|
.skip-link,
|
||||||
.theme-control,
|
.theme-switcher,
|
||||||
.tag-filter,
|
.tag-filter,
|
||||||
.post-nav,
|
.post-nav,
|
||||||
.related-posts,
|
.related-posts,
|
||||||
|
|
@ -1465,8 +1586,7 @@
|
||||||
.prose pre,
|
.prose pre,
|
||||||
.prose code,
|
.prose code,
|
||||||
.post-thumbnail img,
|
.post-thumbnail img,
|
||||||
.post-media img,
|
.post-media img {
|
||||||
.evidence-media img {
|
|
||||||
page-break-inside: avoid;
|
page-break-inside: avoid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue